mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-11 21:57:14 +03:00
Compare commits
64
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b130f911ef | ||
|
|
61f37ccfb8 | ||
|
|
55b297d7fc | ||
|
|
97dbaad4a4 | ||
|
|
c654acb005 | ||
|
|
c7b67b3832 | ||
|
|
1b194d9751 | ||
|
|
e2ef633b95 | ||
|
|
6cbb7ce8d1 | ||
|
|
529184bbe4 | ||
|
|
fc620b9620 | ||
|
|
79656d1ce8 | ||
|
|
f148496f67 | ||
|
|
331c2f2696 | ||
|
|
60c4fa25db | ||
|
|
917d3afcf2 | ||
|
|
4e381305e1 | ||
|
|
c6ac34b929 | ||
|
|
8259c28209 | ||
|
|
f2049c2b76 | ||
|
|
630fc2682a | ||
|
|
da99645a58 | ||
|
|
4c5b3aa45a | ||
|
|
51e5af258e | ||
|
|
0d1c34e9d0 | ||
|
|
fb6d3e9791 | ||
|
|
e34e53f140 | ||
|
|
4e715d0f35 | ||
|
|
f40e2f8f0a | ||
|
|
c11cc7b58c | ||
|
|
3e38e595c7 | ||
|
|
149396d91f | ||
|
|
a5310c9cda | ||
|
|
0417d2014e | ||
|
|
18dec816de | ||
|
|
1a46353b29 | ||
|
|
e813a16d22 | ||
|
|
49ae0384b9 | ||
|
|
1d8f051454 | ||
|
|
ca121aa35f | ||
|
|
a99bb976b7 | ||
|
|
cec76bb681 | ||
|
|
0e6e888932 | ||
|
|
c775e03c1d | ||
|
|
0092df907b | ||
|
|
7b2c2a1f23 | ||
|
|
3cfa1dfaa2 | ||
|
|
9f49cb85cc | ||
|
|
60d32e1e58 | ||
|
|
e199cd92f3 | ||
|
|
7190295f3d | ||
|
|
410552b00e | ||
|
|
95b67ffa83 | ||
|
|
9927634d4f | ||
|
|
88f52ac4e6 | ||
|
|
751ec31dd6 | ||
|
|
582f23eae3 | ||
|
|
a20bbfc752 | ||
|
|
7501e67b55 | ||
|
|
9798bb7235 | ||
|
|
67e73f47a6 | ||
|
|
1fe9c41759 | ||
|
|
44c27a72af | ||
|
|
a82f02d1a1 |
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: impeccable
|
||||
description: Use when the user wants to design, redesign, shape, critique, audit, polish, clarify, distill, harden, optimize, adapt, animate, colorize, extract, or otherwise improve a frontend interface. Covers websites, landing pages, dashboards, product UI, app shells, components, forms, settings, onboarding, and empty states. Handles UX review, visual hierarchy, information architecture, cognitive load, accessibility, performance, responsive behavior, theming, anti-patterns, typography, fonts, spacing, layout, alignment, color, motion, micro-interactions, UX copy, error states, edge cases, i18n, and reusable design systems or tokens. Also use for bland designs that need to become bolder or more delightful, loud designs that should become quieter, live browser iteration on UI elements, or ambitious visual effects that should feel technically extraordinary. Not for backend-only or non-UI tasks.
|
||||
version: 3.9.0
|
||||
version: 3.9.1
|
||||
---
|
||||
|
||||
Designs and iterates production-grade frontend interfaces. Real working code, committed design choices, exceptional craft.
|
||||
@@ -10,11 +10,12 @@ Designs and iterates production-grade frontend interfaces. Real working code, co
|
||||
|
||||
You MUST do these steps before proceeding:
|
||||
|
||||
1. Run `node .agents/skills/impeccable/scripts/context.mjs` once per session. If the request names or implies a file, route, or app inside a monorepo, infer the concrete path and run `node .agents/skills/impeccable/scripts/context.mjs --target <path>` instead. If you've already seen its output in this conversation, do not re-run it. The script either prints the project's PRODUCT.md (and DESIGN.md when present) as a markdown block, or tells you it's missing. Follow whatever it prints. **If it reports `NO_PRODUCT_MD`, stop and follow `reference/init.md` before doing anything else.** If the output ends with an `UPDATE_AVAILABLE` directive, follow it (ask the user once about updating, then continue). It never blocks the current task.
|
||||
2. If the user invoked a sub-command (`craft`, `shape`, `audit`, `polish`, ...), you MUST read `reference/<command>.md` next. Non-optional. The reference defines the command's flow; without it you will skip steps the user expects.
|
||||
1. Run `node .agents/skills/impeccable/scripts/context.mjs` once per session; if the runtime shows this skill's loaded base directory, run `node <skill-base-dir>/scripts/context.mjs` instead. Keep cwd/workdir at the user's project, not the skill directory. If the request names or implies a file, route, or app inside a monorepo, infer the concrete path and append `--target <path>` to the same command. If you've already seen its output in this conversation, do not re-run it. The script either prints the project's PRODUCT.md (and DESIGN.md when present) as a markdown block, or tells you it's missing. Follow whatever it prints. **If it reports `NO_PRODUCT_MD`:** divert into `reference/init.md` first when the user invoked `init`, `teach`, `craft`, or `shape`, or when their wording clearly maps to one of those from-scratch build flows (for example: "build/create/make a landing page", "design a new app", or "shape a feature"). Captured product context is the point of those flows. For any other command, a scoped evaluate / refine / enhance / fix / iterate request against existing code, do **not** divert into init. The existing code is the context: proceed with the requested command, infer the register from the surface in focus (step 4), and offer `$impeccable init` once as a suggestion the user can take later. A missing PRODUCT.md must never block a scoped request. If the output ends with an `UPDATE_AVAILABLE` directive, follow it (ask the user once about updating, then continue). It never blocks the current task.
|
||||
2. If the user invoked a sub-command (`craft`, `shape`, `audit`, `polish`, ...), you MUST read the command's reference next: **`reference/<command>.md`, or the native variant from the Commands table** (e.g. `reference/audit.native.md`) **when the project platform is native** (`ios` / `android` / `adaptive`, per the `context.mjs` directive). One file, not both. Non-optional. The reference defines the command's flow; without it you will skip steps the user expects.
|
||||
3. Familiarize yourself with any existing design system, conventions, and components in the code. Read at least one project file (CSS / tokens / theme / a representative component or page). **Required even when you've loaded a sub-command reference in step 2.** Don't reinvent the wheel; use what's there when it works, branch out when the UX wins.
|
||||
4. Read the matching register reference. **This is non-optional; skipping it produces generic output.** If the project is marketing, a landing page, a campaign, long-form content, or a portfolio (design IS the product), read `reference/brand.md`. If it is app UI, admin, a dashboard, or a tool (design SERVES the product), read `reference/product.md`. Pick by first match: (1) task cue ("landing page" vs "dashboard"); (2) surface in focus (the page, file, or route being worked on); (3) `register` field in PRODUCT.md.
|
||||
5. **If the project is brand-new (no existing CSS tokens / theme / committed brand colors found in step 3)**, run `node .agents/skills/impeccable/scripts/palette.mjs` to receive a brand seed color and composition guidance. This is the anchor for your primary brand color. Compose the rest of the palette (bg, surface, ink, accent, muted) around it per the script's instructions. Use OKLCH throughout. **Skip this step only if step 3 found committed brand colors in existing tokens; in that case identity-preservation wins.**
|
||||
5. **If PRODUCT.md's `## Platform` is `ios` or `android`**, also read `reference/<platform>.md` (HIG / Material 3 conventions). `adaptive` (cross-platform, ships both) reads both files. `web`, absent, or unrecognized: nothing extra to read. `context.mjs` prints the directive when one applies.
|
||||
6. **If the project is brand-new (no existing CSS tokens / theme / committed brand colors found in step 3)**, run `node .agents/skills/impeccable/scripts/palette.mjs` to receive a brand seed color and composition guidance. This is the anchor for your primary brand color. Compose the rest of the palette (bg, surface, ink, accent, muted) around it per the script's instructions. Use OKLCH throughout. **Skip this step only if step 3 found committed brand colors in existing tokens; in that case identity-preservation wins.**
|
||||
|
||||
## Design guidance
|
||||
|
||||
@@ -115,7 +116,7 @@ If someone could look at this interface and say "AI made that" without doubt, it
|
||||
| `document` | Build | Generate DESIGN.md from existing project code | [reference/document.md](reference/document.md) |
|
||||
| `extract [target]` | Build | Pull reusable tokens and components into design system | [reference/extract.md](reference/extract.md) |
|
||||
| `critique [target]` | Evaluate | UX design review with heuristic scoring | [reference/critique.md](reference/critique.md) |
|
||||
| `audit [target]` | Evaluate | Technical quality checks (a11y, perf, responsive) | [reference/audit.md](reference/audit.md) |
|
||||
| `audit [target]` | Evaluate | Technical quality checks (a11y, perf, responsive) | [reference/audit.md](reference/audit.md) · native: [reference/audit.native.md](reference/audit.native.md) |
|
||||
| `polish [target]` | Refine | Final quality pass before shipping | [reference/polish.md](reference/polish.md) |
|
||||
| `bolder [target]` | Refine | Amplify safe or bland designs | [reference/bolder.md](reference/bolder.md) |
|
||||
| `quieter [target]` | Refine | Tone down aggressive or overstimulating designs | [reference/quieter.md](reference/quieter.md) |
|
||||
@@ -129,7 +130,7 @@ If someone could look at this interface and say "AI made that" without doubt, it
|
||||
| `delight [target]` | Enhance | Add personality and memorable touches | [reference/delight.md](reference/delight.md) |
|
||||
| `overdrive [target]` | Enhance | Push past conventional limits | [reference/overdrive.md](reference/overdrive.md) |
|
||||
| `clarify [target]` | Fix | Improve UX copy, labels, and error messages | [reference/clarify.md](reference/clarify.md) |
|
||||
| `adapt [target]` | Fix | Adapt for different devices and screen sizes | [reference/adapt.md](reference/adapt.md) |
|
||||
| `adapt [target]` | Fix | Adapt for different devices and screen sizes | [reference/adapt.md](reference/adapt.md) · native: [reference/adapt.native.md](reference/adapt.native.md) |
|
||||
| `optimize [target]` | Fix | Diagnose and fix UI performance | [reference/optimize.md](reference/optimize.md) |
|
||||
| `live` | Iterate | Visual variant mode: pick elements in the browser, generate alternatives | [reference/live.md](reference/live.md) |
|
||||
|
||||
@@ -137,26 +138,26 @@ Plus three management commands: `pin <command>`, `unpin <command>`, and `hooks <
|
||||
|
||||
### Routing rules
|
||||
|
||||
1. **No argument**: the user is asking "what should I do?" Make the menu context-aware instead of static. Setup has already run `context.mjs`; if that reported `NO_PRODUCT_MD` you are already in init (setup), so finish that and skip this. Otherwise run `node .agents/skills/impeccable/scripts/context-signals.mjs` once and read its JSON, then lead with the **2-3 highest-value next commands**, each with a one-line reason pulled from the signals, followed by the full menu (the table above, grouped by category). **Never auto-run a command; the recommendation is a suggestion the user confirms.**
|
||||
1. **No argument**: the user is asking "what should I do?" Make the menu context-aware instead of static. Setup has already run `context.mjs`; if that reported `NO_PRODUCT_MD` the project has no captured context yet, so lead the menu with `$impeccable init` as the top recommendation (one line on why) and still show the rest below; don't silently jump into init. Otherwise run `node .agents/skills/impeccable/scripts/context-signals.mjs` once and read its JSON, then lead with the **2-3 highest-value next commands**, each with a one-line reason pulled from the signals, followed by the full menu (the table above, grouped by category). **Never auto-run a command; the recommendation is a suggestion the user confirms.**
|
||||
|
||||
Reason over the signals; there is no score to obey:
|
||||
- `setup.hasDesign` false while `setup.hasCode` true → `document` (capture the visual system).
|
||||
- `critique.latest` is `null` → the project has never been critiqued; for a set-up project with a real surface, offering `$impeccable critique <surface>` is a strong default.
|
||||
- `critique.latest` with a low `score` or non-zero `p0` / `p1` → `polish` (it reads that snapshot as its backlog), or re-run `critique` if the snapshot looks stale.
|
||||
- `git.changedFiles` pointing at one surface → scope `audit` or `polish` to those files specifically, naming them.
|
||||
- `devServer.running` true → `live` is available for in-browser iteration; if false, don't lead with `live`.
|
||||
- `devServer.running` true → `live` is available for in-browser iteration; if false, don't lead with `live`. **`live` and the bundled `detect.mjs` are web-only.** If `setup.platform` is `ios`, `android`, or `adaptive`, don't lead with either; the browser overlay and the HTML rule engine don't apply to native app code.
|
||||
- Otherwise group by intent exactly as init's "Recommend starting points" step does (build new / improve what's there / iterate visually), tailored to `setup.register`.
|
||||
|
||||
**If `scan.targets` is non-empty, run `node .agents/skills/impeccable/scripts/detect.mjs --json <scan.targets joined by spaces>` once** (the bundled detector over local files: no network, no npx). `scan.via` tells you what they are: `git-changes` (the markup/style files in your dirty tree, the most relevant set), `source-dir` (e.g. `src`, `app`), `html`, or `root`. Fold the hits into your picks: many quality / contrast hits → `audit` or `polish`; a specific slop family → the matching command (gradient text or eyebrows → `quieter` / `typeset`, flat or gray palette → `colorize`, and so on). It's a real, current signal that beats guessing. If detect errors or the tree is large and slow, skip it and recommend the user run `audit` themselves; never block the suggestion on it.
|
||||
**If `scan.targets` is non-empty and `setup.platform` is not `ios`/`android`/`adaptive`, run `node .agents/skills/impeccable/scripts/detect.mjs --json <scan.targets joined by spaces>` once** (the bundled detector over local files: no network, no npx; it reads HTML/CSS, so skip it for native projects). `scan.via` tells you what they are: `git-changes` (the markup/style files in your dirty tree, the most relevant set), `source-dir` (e.g. `src`, `app`), `html`, or `root`. Fold the hits into your picks: many quality / contrast hits → `audit` or `polish`; a specific slop family → the matching command (gradient text or eyebrows → `quieter` / `typeset`, flat or gray palette → `colorize`, and so on). It's a real, current signal that beats guessing. If detect errors or the tree is large and slow, skip it and recommend the user run `audit` themselves; never block the suggestion on it.
|
||||
|
||||
Keep it to 2-3 pointed picks with the exact command to type. The menu stays the fallback; the recommendation is the lede.
|
||||
2. **First word matches a command** (table above OR `pin` / `unpin` / `hooks`): load its reference file and follow its instructions. Everything after the command name is the target.
|
||||
3. **First word doesn't match, but the intent clearly maps to one command** (e.g. "fix the spacing" → `layout`, "rewrite this error message" → `clarify`, "the colors feel flat" → `colorize`): load that command's reference and proceed as if invoked. If two commands could fit, ask once which.
|
||||
2. **First word matches a command** (table above OR `pin` / `unpin` / `hooks`): load its reference file (on native platforms, the table's native variant; Setup step 2's one-file rule) and follow its instructions. Everything after the command name is the target.
|
||||
3. **First word doesn't match, but the intent clearly maps to one command** (e.g. "fix the spacing" → `layout`, "rewrite this error message" → `clarify`, "the colors feel flat" → `colorize`): load that command's reference (same native-variant rule) and proceed as if invoked. If two commands could fit, ask once which.
|
||||
4. **No clear command match**: general design invocation. Apply the setup steps, the General rules, and the loaded register reference, using the full argument as context.
|
||||
|
||||
Setup (context gathering, register) is already loaded by then; sub-commands don't re-invoke `$impeccable`.
|
||||
|
||||
If the first word is `craft`, setup still runs first, but [reference/craft.md](reference/craft.md) owns the rest of the flow. If setup invokes `init` as a blocker, finish init, refresh context, then resume the original command and target.
|
||||
If the first word is `craft` or `shape`, or routing rule 3 clearly maps the user's intent to either command, setup still runs first, but the matching reference ([reference/craft.md](reference/craft.md) or [reference/shape.md](reference/shape.md)) owns the rest of the flow. Both are from-scratch build flows: if setup invokes `init` as a blocker, finish init, refresh context, then resume the original command and target.
|
||||
|
||||
`teach` is a deprecated alias for `init`: if the user types it, load [reference/init.md](reference/init.md) and proceed as if they ran `init`.
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
Adapt an existing design to a different context: another screen size, device, platform, or use case. The trap is treating adaptation as scaling. The job is rethinking the experience for the new context.
|
||||
|
||||
**Web only** (mobile web included). Native platforms (`ios` / `android` / `adaptive`) route to [adapt.native.md](adapt.native.md) instead; if the project is native, switch to it now.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
> **Additional context needed**: target platforms/devices and usage contexts.
|
||||
|
||||
Adapt an existing **native** design (`ios` / `android` / `adaptive`) to a different context: another device class, orientation, platform, or origin. The trap is treating adaptation as scaling. The job is rethinking the experience for the new context, inside the platform conventions of [ios.md](ios.md) / [android.md](android.md); read the target platform's reference before planning if Setup hasn't already.
|
||||
|
||||
## Assess Adaptation Challenge
|
||||
|
||||
1. **Source context**: what was it designed for, and what assumptions did it make? (Phone-only? Portrait-only? One platform's idioms? A website?)
|
||||
2. **Target context**: which device class (phone, tablet, foldable), orientation, platform, and usage posture (one-handed on the go vs two-handed at rest)?
|
||||
3. **What breaks**: navigation that doesn't fit the target, layouts that stretch instead of restructure, gestures or controls that don't exist there?
|
||||
|
||||
## Adaptation Strategies
|
||||
|
||||
### Phone → Tablet (iPad / large screens)
|
||||
|
||||
- **Restructure, don't stretch.** A scaled-up phone UI on a tablet is the failure mode. Use size classes (iOS) / window size classes (Android) to switch structure.
|
||||
- **Navigation changes shape**: tab bar stays or becomes a sidebar on iPad; Android navigation bar becomes a rail or drawer on expanded width.
|
||||
- **Use the width**: split view / master-detail (list + detail side by side), multi-column grids, popovers where phones used sheets.
|
||||
- **Multitasking is a size, not an edge case**: iPad Split View and Android multi-window can hand you a phone-width window on a tablet; size-class-driven layout handles both for free.
|
||||
|
||||
### Orientation & foldables
|
||||
|
||||
- Landscape restructures (side-by-side panes, repositioned controls); never clip or letterbox. Lock orientation only when the task truly demands it.
|
||||
- Foldables (Android): react to posture and hinge via window size classes; test folded, unfolded, and tabletop.
|
||||
|
||||
### Platform → platform (iOS ↔ Android)
|
||||
|
||||
Translate idioms; never transplant them:
|
||||
|
||||
| iOS | Android |
|
||||
|---|---|
|
||||
| Tab bar | Navigation bar / rail / drawer |
|
||||
| Edge-swipe back, back chevron | Predictive Back gesture / button |
|
||||
| Switch, segmented control, system pickers | Material switch, chips, Material pickers |
|
||||
| Action sheet | Bottom sheet / Material dialog |
|
||||
| SF Symbols, SF Pro, Dynamic Type | Material Symbols, Roboto, sp scaling |
|
||||
| Semantic system colors, materials | Material color roles, tonal elevation |
|
||||
| System push/sheet transitions | Container transform, shared-axis, fade-through |
|
||||
|
||||
Rebuild navigation and controls in the target's vocabulary; carry over the brand's expressive layer (palette intent, type accent, motion personality) through the target's theming system.
|
||||
|
||||
### Web → native (porting a website or web app)
|
||||
|
||||
Reconform, don't reflow. Replace web navigation with the platform's model, HTML-shaped controls with platform controls, hover affordances with touch-first ones, and px-based type with Dynamic Type / sp. Then treat the result to the full platform reference; the slop test there is the acceptance bar.
|
||||
|
||||
## Implement & Verify
|
||||
|
||||
- Drive structure from **size classes / window size classes**, never from device-model checks.
|
||||
- Respect safe areas and window insets in every new configuration (notch, hinge, status bar, keyboard).
|
||||
- Test on simulators for breadth, then real hardware for truth: at least one phone and one tablet per shipped platform, both orientations, split-screen where supported.
|
||||
|
||||
When the adaptation feels native to each context, hand off to `$impeccable polish` for the final pass.
|
||||
|
||||
**NEVER**:
|
||||
- Ship a stretched phone layout on a tablet
|
||||
- Port one platform's controls or navigation onto the other
|
||||
- Hide core functionality on smaller devices (if it matters, make it work)
|
||||
- Lock orientation to dodge a layout bug
|
||||
- Trust simulators alone (posture, gestures, and performance need hardware)
|
||||
@@ -0,0 +1,40 @@
|
||||
# Android platform
|
||||
|
||||
For native Android apps: Jetpack Compose, Android Views, React Native, Expo, Flutter shipping to Android hardware.
|
||||
|
||||
On native, register narrows. Material Design 3 governs structure, navigation, and interaction whatever the register; brand expresses through Material's theming (color roles, type scale, shape, motion). A Material-everywhere cross-platform app that also ships to iPhone still owes iOS its OS guarantees on that hardware: safe-area insets, Reduce Motion, edge-swipe back.
|
||||
|
||||
## The Android slop test
|
||||
|
||||
Would a fluent Android user trust this app, or trip on off-spec components? The most common tell is an iOS app wearing Android's skin: a bottom-only navigation copied from iPhone, a back arrow that ignores the system Back gesture, Cupertino-shaped switches and dialogs. Material 3 is the rulebook; follow its components and theme the brand through it.
|
||||
|
||||
## Layout & structure
|
||||
|
||||
- **Material navigation, matched to size.** Navigation bar (bottom, 3–5 destinations) on compact width; navigation rail or drawer on expanded width. Never ship a phone bottom-bar untouched on a tablet.
|
||||
- **System Back always works.** Honor the predictive Back gesture and Back button; never trap the user or hijack the gesture.
|
||||
- **Edge-to-edge with window insets.** Apply the status bar, navigation bar, display cutout, and IME insets so content never hides behind system bars or the keyboard.
|
||||
- **Top app bar for screen context**; pair with a FAB when the screen has a single primary action.
|
||||
|
||||
## Touch targets
|
||||
|
||||
- **48×48 dp minimum** for every touch target, with at least 8 dp between them.
|
||||
|
||||
## Typography
|
||||
|
||||
- **Material type scale.** Display, Headline, Title, Body, Label roles (large/medium/small each). Map text to roles; never hand-pick sizes per screen.
|
||||
- **Roboto is the system face**; theme a brand face in through the type scale, keeping body, labels, and controls legible and consistent.
|
||||
- **sp units, never fixed px**, so type follows the system font-size setting.
|
||||
|
||||
## Color & theming
|
||||
|
||||
- **Material color roles** (primary, on-primary, surface, surface-variant, secondary-container, outline, error). Role tokens resolve light/dark and contrast variants automatically; raw hex breaks there.
|
||||
- **Dynamic Color (Material You)** where it fits: derive the scheme from the user's wallpaper on Android 12+, with a static fallback.
|
||||
- **Dark theme is a first-class scheme.** Design and test it; never a quick invert.
|
||||
- **Tonal elevation.** Convey elevation through the standard surface tonal levels (plus shadow where appropriate); no arbitrary drop shadows.
|
||||
|
||||
## Components & motion
|
||||
|
||||
- **Material components.** Buttons (filled / tonal / outlined / text), FAB, switches, chips, snackbars, bottom sheets, Material dialogs, navigation bar/rail/drawer. Never port iOS controls or invent equivalents.
|
||||
- **One FAB, one primary action.** Never stack FABs or spend one on a secondary task.
|
||||
- **Snackbars for transient feedback** (actionable when useful, never a toast for that); dialogs only for decisions that must interrupt.
|
||||
- **Material motion patterns.** Container transform, shared-axis, fade-through, with standard easing and durations; honor the system Remove animations setting with a crossfade or instant cut.
|
||||
@@ -10,6 +10,8 @@ Brand: motion is part of the voice; one well-rehearsed entrance beats scattered
|
||||
|
||||
Product: 150–250 ms on most transitions. Motion conveys state: feedback, reveal, loading, transitions between views. No page-load choreography; users are in a task and won't wait for it.
|
||||
|
||||
Native (`ios` / `android` / `adaptive`): implementation follows the Motion section of [ios.md](ios.md) / [android.md](android.md) (read it first if Setup hasn't already): system transitions and OS Reduce Motion, never the web tooling below.
|
||||
|
||||
---
|
||||
|
||||
## Assess Animation Opportunities
|
||||
|
||||
@@ -2,6 +2,8 @@ Run systematic **technical** quality checks and generate a comprehensive report.
|
||||
|
||||
This is a code-level audit, not a design critique. Check what's measurable and verifiable in the implementation.
|
||||
|
||||
**Web only.** Native platforms (`ios` / `android` / `adaptive`) route to [audit.native.md](audit.native.md) instead; if the project is native, switch to it now.
|
||||
|
||||
## Diagnostic Scan
|
||||
|
||||
Run comprehensive checks across 5 dimensions. Score each dimension 0-4 using the criteria below.
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
Run systematic **technical** quality checks on a native app (`ios` / `android` / `adaptive`) and generate a comprehensive report. Don't fix issues; document them for other commands to address.
|
||||
|
||||
This is a code-level audit, not a design critique. Audit from source (SwiftUI / UIKit / Compose / React Native / Flutter); no browser tooling or `detect.mjs` applies. Score against the platform reference(s): [ios.md](ios.md) / [android.md](android.md), both for `adaptive`. Read them before scoring if Setup hasn't already. The report skeleton mirrors [audit.md](audit.md); keep the two in sync when changing it.
|
||||
|
||||
## Diagnostic Scan
|
||||
|
||||
Run comprehensive checks across 5 dimensions. Score each dimension 0-4 using the criteria below.
|
||||
|
||||
### 1. Accessibility (VoiceOver / TalkBack)
|
||||
|
||||
**Check for**:
|
||||
- **Missing labels**: interactive elements without accessibility labels, traits/roles, or state announcements
|
||||
- **Reading and focus order**: illogical traversal, unreachable controls, focus lost on navigation
|
||||
- **Text scaling**: fixed point sizes defeating Dynamic Type (iOS) or px instead of sp (Android); layouts that clip or overlap at large sizes
|
||||
- **Touch targets**: below 44 pt (iOS) / 48 dp (Android), or crammed without spacing
|
||||
- **Reduce Motion ignored**: parallax and large slides with no crossfade alternative
|
||||
- **Contrast**: text failing contrast in either appearance, light or dark
|
||||
|
||||
**Score 0-4**: 0=Screen reader unusable, 1=Major gaps (unlabeled controls, no scaling), 2=Partial (labels exist, order or scaling breaks), 3=Good (minor gaps), 4=Excellent (labeled, ordered, scales cleanly, Reduce Motion honored)
|
||||
|
||||
### 2. Performance
|
||||
|
||||
**Check for**:
|
||||
- **Slow startup**: heavy work on launch before first frame
|
||||
- **Unvirtualized lists**: long content without FlatList / LazyColumn / List recycling
|
||||
- **Main-thread jank**: synchronous work in scroll or gesture paths, dropped frames on 60/120 Hz
|
||||
- **Wasted rendering**: unnecessary re-renders (React Native) or recompositions (Compose); missing memoization/keys
|
||||
- **Image handling**: full-size images decoded for thumbnails, no caching
|
||||
- **App weight**: bloated JS bundle or binary, unused dependencies
|
||||
|
||||
**Score 0-4**: 0=Janky everywhere, 1=Major problems (unvirtualized lists, slow launch), 2=Partial, 3=Good (minor improvements possible), 4=Excellent (fast launch, smooth scroll, lean)
|
||||
|
||||
### 3. Appearance & Theming
|
||||
|
||||
**Check for**:
|
||||
- **Hard-coded colors**: raw hex instead of semantic system colors (iOS) / Material color roles (Android) / design tokens
|
||||
- **Broken dark appearance**: missing dark variants, poor contrast in dark, quick inverts
|
||||
- **Dynamic Color** (Android 12+): no static fallback scheme, or ignored where it fits
|
||||
- **Off-platform materials**: hand-rolled blur/glassmorphism instead of system materials or tonal elevation
|
||||
|
||||
**Score 0-4**: 0=Hard-coded everything, 1=Minimal tokens, 2=Partial (tokens exist, inconsistently used), 3=Good (minor hard-coded values), 4=Excellent (semantic throughout, both appearances first-class)
|
||||
|
||||
### 4. Platform Conformance (CRITICAL)
|
||||
|
||||
Score against the loaded platform reference(s), including their slop tests. **Check for**:
|
||||
- **Broken system gestures**: edge-swipe back disabled (iOS), predictive Back hijacked (Android)
|
||||
- **Inset violations**: content under the notch, Dynamic Island, home indicator, status bar, or keyboard
|
||||
- **Off-platform navigation**: custom global nav, overloaded tab bars, iOS patterns on Android or vice versa
|
||||
- **Web-shaped controls**: HTML-style buttons, custom toggles, hover-dependent affordances
|
||||
- **Icon drift**: mixed icon sets instead of SF Symbols / Material Symbols
|
||||
- **AI tells**: the shared absolute bans still apply (AI palette, gradient text, hero metrics)
|
||||
|
||||
**Score 0-4**: 0=Web port (nothing native), 1=Heavy violations (3-4 kinds), 2=Some (1-2 noticeable), 3=Mostly conformant (subtle issues), 4=Fully native (a fluent user trusts every screen)
|
||||
|
||||
### 5. Adaptivity
|
||||
|
||||
**Check for**:
|
||||
- **Stretched phone layouts**: tablet/iPad rendering a scaled-up phone UI instead of using size classes / window size classes
|
||||
- **Orientation breakage**: landscape clipping, ignored, or locked without reason
|
||||
- **Keyboard/IME handling**: inputs hidden behind the keyboard, no inset adjustment
|
||||
- **Multitasking**: iPad Split View / Android multi-window breaking layout
|
||||
- **Foldables**: hinge-unaware layouts on posture change (Android)
|
||||
|
||||
**Score 0-4**: 0=One screen size only, 1=Major breakage (landscape or tablet broken), 2=Partial, 3=Good (minor edge cases), 4=Excellent (adapts across sizes, orientations, and windowing)
|
||||
|
||||
## Generate Report
|
||||
|
||||
### Audit Health Score
|
||||
|
||||
| # | Dimension | Score | Key Finding |
|
||||
|---|-----------|-------|-------------|
|
||||
| 1 | Accessibility | ? | [most critical issue or "--"] |
|
||||
| 2 | Performance | ? | |
|
||||
| 3 | Appearance & Theming | ? | |
|
||||
| 4 | Platform Conformance | ? | |
|
||||
| 5 | Adaptivity | ? | |
|
||||
| **Total** | | **??/20** | **[Rating band]** |
|
||||
|
||||
**Rating bands**: 18-20 Excellent (minor polish), 14-17 Good (address weak dimensions), 10-13 Acceptable (significant work needed), 6-9 Poor (major overhaul), 0-5 Critical (fundamental issues)
|
||||
|
||||
### Platform Conformance Verdict
|
||||
**Start here.** Pass/fail: does this read as a native app or a ported website? List specific violations. Be brutally honest.
|
||||
|
||||
### Executive Summary
|
||||
- Audit Health Score: **??/20** ([rating band])
|
||||
- Total issues found (count by severity: P0/P1/P2/P3)
|
||||
- Top 3-5 critical issues
|
||||
- Recommended next steps
|
||||
|
||||
### Detailed Findings by Severity
|
||||
|
||||
Tag every issue with **P0-P3 severity**:
|
||||
- **P0 Blocking**: Prevents task completion. Fix immediately
|
||||
- **P1 Major**: Significant difficulty or platform-guideline violation. Fix before release
|
||||
- **P2 Minor**: Annoyance, workaround exists. Fix in next pass
|
||||
- **P3 Polish**: Nice-to-fix, no real user impact. Fix if time permits
|
||||
|
||||
For each issue, document:
|
||||
- **[P?] Issue name**
|
||||
- **Location**: Screen, file, line
|
||||
- **Category**: Accessibility / Performance / Theming / Conformance / Adaptivity
|
||||
- **Impact**: How it affects users
|
||||
- **Guideline**: The HIG / Material rule it violates (if applicable)
|
||||
- **Recommendation**: How to fix it
|
||||
- **Suggested command**: Which command to use (prefer: $impeccable adapt, $impeccable animate, $impeccable audit, $impeccable bolder, $impeccable clarify, $impeccable colorize, $impeccable critique, $impeccable delight, $impeccable distill, $impeccable document, $impeccable harden, $impeccable layout, $impeccable onboard, $impeccable optimize, $impeccable overdrive, $impeccable polish, $impeccable quieter, $impeccable shape, $impeccable typeset)
|
||||
|
||||
### Patterns & Systemic Issues
|
||||
|
||||
Identify recurring problems that indicate systemic gaps rather than one-off mistakes:
|
||||
- "Hard-coded colors appear in 15+ screens, should use semantic colors"
|
||||
- "Touch targets consistently below 44 pt throughout the tab bar and list rows"
|
||||
|
||||
### Positive Findings
|
||||
|
||||
Note what's working well: good practices to maintain and replicate.
|
||||
|
||||
## Recommended Actions
|
||||
|
||||
List recommended commands in priority order (P0 first, then P1, then P2):
|
||||
|
||||
1. **[P?] `$command-name`**: Brief description (specific context from audit findings)
|
||||
2. **[P?] `$command-name`**: Brief description (specific context)
|
||||
|
||||
**Rules**: Only recommend commands from: $impeccable adapt, $impeccable animate, $impeccable audit, $impeccable bolder, $impeccable clarify, $impeccable colorize, $impeccable critique, $impeccable delight, $impeccable distill, $impeccable document, $impeccable harden, $impeccable layout, $impeccable onboard, $impeccable optimize, $impeccable overdrive, $impeccable polish, $impeccable quieter, $impeccable shape, $impeccable typeset. Map findings to the most appropriate command. End with `$impeccable polish` as the final step if any fixes were recommended.
|
||||
|
||||
After presenting the summary, tell the user:
|
||||
|
||||
> You can ask me to run these one at a time, all at once, or in any order you prefer.
|
||||
>
|
||||
> Re-run `$impeccable audit` after fixes to see your score improve.
|
||||
|
||||
**IMPORTANT**: Be thorough but actionable. Too many P3 issues creates noise. Focus on what actually matters.
|
||||
|
||||
**NEVER**:
|
||||
- Report issues without explaining impact (why does this matter?)
|
||||
- Provide generic recommendations (be specific and actionable)
|
||||
- Skip positive findings (celebrate what works)
|
||||
- Forget to prioritize (everything can't be P0)
|
||||
- Report false positives without verification
|
||||
@@ -6,9 +6,11 @@ The hook runs the impeccable design detector on direct file edits to design-rele
|
||||
|
||||
This command toggles the hook **per project** by editing `.impeccable/config.json` (the unified Impeccable config; hook runtime settings live under its `hook` key, and shared detector ignores live under `detector`). Per-developer overrides, including the install consent decision (`hook.consent`) the CLI records, live in the gitignored `.impeccable/config.local.json`. Set `hook.enabled: false` to turn the hook off, `hook.quiet: true` to silence the clean/pending acks, or `hook.auditLog` to a file path for an NDJSON log. The legacy `IMPECCABLE_HOOK_DISABLED`, `IMPECCABLE_HOOK_QUIET`, and `IMPECCABLE_HOOK_LOG` env vars are still honored and override these config values when set.
|
||||
|
||||
Declare server-side template extensions under **`detector.extensions`** when the project uses Blade, Twig, ERB, or Handlebars files; the hook skips them otherwise because they sit outside the built-in extension list. One entry per extension, `{ "ext": ".blade.php", "engine": "html" }`. `engine` picks the analyzer (`html` for markup templates, `text` for JS/TS/CSS-like files) and defaults to `html`. Match against the end of the filename, so double extensions like `.blade.php` and `.html.erb` work. Config only adds extensions; the built-in list always applies.
|
||||
|
||||
Manual `npx impeccable detect` scans use the same project filter config by default: `detector.ignoreRules`, `detector.ignoreFiles`, `detector.ignoreValues`, and `detector.designSystem.enabled`. `hook.enabled` only controls automatic hook execution, not manual CLI scans. Use `npx impeccable detect --no-config ...` for a raw detector run that ignores project config/context. Use `npx impeccable ignores ...` for direct CLI CRUD on the same detector ignores.
|
||||
|
||||
Supported harnesses: Claude Code (`.claude/settings.local.json` in the project, which is gitignored so the hook stays machine-local; a hook you move into the shared `settings.json` is honored in place too), Codex (`.codex/hooks.json` in the project), Cursor (`.cursor/hooks.json` in the project), and GitHub Copilot (`.github/hooks$impeccable.json` in the project, a team-shared committed file that both the Copilot CLI and the cloud agent read). For the Copilot CLI, repo-level hooks fire once `.github/hooks$impeccable.json` is committed to the repository's default branch.
|
||||
Supported harnesses: Claude Code (`.claude/settings.local.json` in the project, which is gitignored so the hook stays machine-local; a hook you move into the shared `settings.json` is honored in place too), Codex (`.codex/hooks.json` in the project), Cursor (`.cursor/hooks.json` in the project), and GitHub Copilot (`.github/hooks/impeccable.json` in the project, a team-shared committed file that both the Copilot CLI and the cloud agent read). For the Copilot CLI, repo-level hooks fire once `.github/hooks/impeccable.json` is committed to the repository's default branch.
|
||||
|
||||
On **Cursor**, `preToolUse` checks proposed Write/Edit/Shell write content and denies only when the real detector finds an issue. The denial message is visible to the agent as the tool error, so the agent can reconsider before the bad write lands.
|
||||
|
||||
@@ -79,10 +81,10 @@ node .agents/skills/impeccable/scripts/hook-admin.mjs ignore-file "src/legacy/Ca
|
||||
|
||||
## Constraints
|
||||
|
||||
- Never modify `.impeccable/config.json` or `.impeccable/config.local.json` by hand from this command. Always go through `hook-admin.mjs` so writes stay validated and the file shape stays consistent.
|
||||
- Never modify `.impeccable/config.json` or `.impeccable/config.local.json` by hand from this command. Always go through `hook-admin.mjs` so writes stay validated and the file shape stays consistent. One exception: `detector.extensions` has no admin action, so when the user asks to cover a template stack, edit that one field in `.impeccable/config.json` directly and leave the rest of the file untouched.
|
||||
- Do not edit the hook scripts themselves (`hook.mjs`, `hook-lib.mjs`, `hook-before-edit.mjs`) from this flow. Those are skill plumbing.
|
||||
- Cursor can block a proposed write when the detector finds a real issue. Claude Code, Codex, and GitHub Copilot do not block the edit; they emit a post-edit reminder instead. Disabling stops both blocking and reminders.
|
||||
- The hook is bundled with the Impeccable skill and installed through project-local manifests: `.claude/settings.local.json`, `.codex/hooks.json`, `.cursor/hooks.json`, and `.github/hooks$impeccable.json`. On Codex, the user must approve the hook via `/hooks` the first time. On Cursor, confirm hooks are enabled under Settings -> Hooks. On GitHub Copilot, the CLI loads `.github/hooks$impeccable.json` once it is committed to the repository's default branch, and the cloud agent reads it from the repo directly.
|
||||
- The hook is bundled with the Impeccable skill and installed through project-local manifests: `.claude/settings.local.json`, `.codex/hooks.json`, `.cursor/hooks.json`, and `.github/hooks/impeccable.json`. On Codex, the user must approve the hook via `/hooks` the first time. On Cursor, confirm hooks are enabled under Settings -> Hooks. On GitHub Copilot, the CLI loads `.github/hooks/impeccable.json` once it is committed to the repository's default branch, and the cloud agent reads it from the repo directly.
|
||||
|
||||
## Failure modes
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ Decision tree:
|
||||
- **Neither file exists (empty project or no context yet)**: do Steps 2-4 (write PRODUCT.md), then decide on DESIGN.md based on whether there's code to analyze.
|
||||
- **PRODUCT.md exists, DESIGN.md missing**: skip to Step 5 and offer to run `$impeccable document` for DESIGN.md.
|
||||
- **PRODUCT.md exists but has no `## Register` section (legacy)**: add it. Infer a hypothesis from the codebase (see Step 2), confirm with the user, write the field.
|
||||
- **PRODUCT.md exists but has no `## Platform` section (legacy)**: add it the same way, but only when the project is native (`ios` / `android` / `adaptive`) or the user wants it explicit; a missing field already means `web`.
|
||||
- **Both exist**: STOP and use Codex's structured user-input/question tool when available; if unavailable, ask directly in chat to clarify what you cannot infer. Ask which file to refresh. Skip the one the user doesn't want changed.
|
||||
- **Just DESIGN.md exists (unusual)**: do Steps 2-4 to produce PRODUCT.md.
|
||||
|
||||
@@ -41,26 +42,34 @@ Also form a **register hypothesis** from what you find:
|
||||
|
||||
Register is a hypothesis at this point, not a decision; Step 3 confirms it.
|
||||
|
||||
Also form a **platform hypothesis**:
|
||||
|
||||
- Native signals: React Native / Expo (`react-native`, `expo`), Flutter (`pubspec.yaml`, `flutter`), SwiftUI / UIKit (`.swift`, `.xcodeproj`, an `ios/` app target), Jetpack Compose / Android (`build.gradle`, an `android/` app module, `AndroidManifest.xml`). An `ios/` and/or `android/` directory that is a real app target, not just a Capacitor/Cordova wrapper around a website.
|
||||
- Web signals (the default): a web framework (Vite, Next, Nuxt, SvelteKit, Astro), an HTML entry, a CSS/Tailwind setup, no native app target.
|
||||
|
||||
Values: `web` / `ios` / `android` / `adaptive` (one codebase, ships both, adapts per OS). Mobile web is still `web`. Like register, this is a hypothesis; Step 3 confirms it.
|
||||
|
||||
Note what you've learned and what remains unclear. Also note any rough edges worth a follow-up command (thin hierarchy, flat or gray palette, missing error/empty states, dull copy); Step 7 turns these into concrete recommendations without re-analyzing.
|
||||
|
||||
## Step 3: Ask strategic questions (for PRODUCT.md)
|
||||
|
||||
STOP and use Codex's structured user-input/question tool when available; if unavailable, ask directly in chat to clarify what you cannot infer. Ask only about what you couldn't infer from the codebase.
|
||||
STOP and use Codex's structured user-input/question tool when available; if unavailable, ask directly in chat to clarify what you cannot infer. Ask about anything the codebase doesn't answer with strong, explicit evidence.
|
||||
|
||||
### Interview mode, not confirmation mode
|
||||
|
||||
If the repo is empty or the user's brief is sparse, run a short interview before proposing PRODUCT.md. Do **not** turn a one-sentence request into a complete inferred PRODUCT.md and ask for blanket confirmation.
|
||||
|
||||
- Use the harness's structured question tool when one exists. Otherwise, ask directly in chat and stop.
|
||||
- Ask **2-3 questions per round**, then wait for answers.
|
||||
- Use the harness's structured question tool when one exists. Otherwise, ask directly in chat and stop: one question at a time, with lettered options where the crawl suggests likely answers, waiting for each answer before the next.
|
||||
- Keep skill vocabulary (register, belief ladder, anti-references) out of question text; ask for the thing in words the user would use. For the brand register, ask like a magazine editor profiling the brand: curious and narrative, drawing out the story, the feel, and what a visitor should come to believe.
|
||||
- Ask in focused rounds and wait for answers between them. Keep **one topic per question**; add rounds rather than fold several topics into one either-or choice. Options obey the same rule: an option answers only the question asked; never write a compound option that bundles a feeling with a business outcome or names an additional audience.
|
||||
- Use inferred answers as hypotheses or options, not as finished facts.
|
||||
- Complete at least one real user-answer round before drafting PRODUCT.md, unless every required answer is directly discoverable from repo docs.
|
||||
- Round 1 should establish register, users/purpose, and desired outcome.
|
||||
- Round 2 should establish brand personality or references, anti-references, and accessibility needs.
|
||||
- Round 1 should establish register, platform, users, purpose, positioning, and desired outcome.
|
||||
- Round 2 should establish brand personality or references, anti-references, and accessibility needs, plus conversion & proof for the brand register.
|
||||
|
||||
### Minimum viable interview
|
||||
|
||||
Ask enough to complete PRODUCT.md. At minimum, cover register confirmation, users and purpose, brand personality, anti-references, and accessibility needs unless each answer is directly discoverable from repo context. After at least one interview round, you may propose inferred answers, but the user must confirm them before you write PRODUCT.md. Never synthesize PRODUCT.md from the original task prompt alone.
|
||||
Ask enough to complete PRODUCT.md. At minimum, cover register confirmation, **platform confirmation** (`web` / `ios` / `android` / `adaptive`), users, purpose, positioning, brand personality, anti-references, and accessibility needs (plus conversion & proof for the brand register) unless each answer is directly discoverable from repo context. Never let the template's default `web` stand unconfirmed for a native or cross-platform repo. After at least one interview round, you may propose inferred answers, but the user must confirm them before you write PRODUCT.md. Never synthesize PRODUCT.md from the original task prompt alone.
|
||||
|
||||
### Register (ask first; it shapes everything below)
|
||||
|
||||
@@ -68,20 +77,42 @@ Every design task is either **brand** (marketing, landing, campaign, long-form c
|
||||
|
||||
If Step 2 produced a clear hypothesis, lead with it: *"From the codebase, this looks like a [brand / product] surface. Does that match your intent, or should we treat it differently?"*
|
||||
|
||||
If the signal is genuinely split (e.g. a product with a big marketing landing), STOP and use Codex's structured user-input/question tool when available; if unavailable, ask directly in chat to clarify what you cannot infer. Ask which register describes the **primary** surface. The register can be overridden per task later, but PRODUCT.md carries one default.
|
||||
If the signal is genuinely split (e.g. a product with a big marketing landing), STOP and use Codex's structured user-input/question tool when available; if unavailable, ask directly in chat to clarify what you cannot infer. Ask which register describes the **primary** surface. The register can be overridden per task later, but PRODUCT.md carries one default. Settle the default before drafting any register-dependent questions; never batch brand-only questions (Conversion & proof) into the same round as the question that decides the register.
|
||||
|
||||
### Platform (ask right after register)
|
||||
|
||||
Every project targets **web** (includes responsive mobile web), **ios**, **android**, or **adaptive** (one codebase, ships both, adapts per OS: Flutter, React Native, KMP). Platform picks the native rulebook: HIG for `ios`, Material 3 for `android`, both for `adaptive`, none for `web`.
|
||||
|
||||
If Step 2 produced a clear hypothesis, lead with it: *"From the codebase, this looks like a [web / ios / android / adaptive] project. Does that match?"* For cross-platform apps, decide by the **design language the app renders**, not the toolchain: one look on both platforms (Flutter's Material-everywhere default) takes that platform's value; genuine per-OS adaptation (Cupertino on iOS, Material on Android) is `adaptive`. When in doubt, `web`.
|
||||
|
||||
A monorepo shipping both a website and a native app gets a PRODUCT.md per app, each with its own `## Platform`; the root PRODUCT.md carries the primary surface's platform.
|
||||
|
||||
### Users & Purpose
|
||||
- Who uses this? What's their context when using it?
|
||||
- What job are they trying to get done?
|
||||
- For brand: what emotions should the interface evoke? (confidence, delight, calm, urgency)
|
||||
- What is this for? A purpose stated in README or docs is a hypothesis, not strong evidence; confirm it, don't transcribe it.
|
||||
- What does success look like?
|
||||
- If more than one kind of user is plausible, confirm a primary and secondary audience; don't manufacture a split that isn't there. An audience implied by another answer (a success metric, a CTA) is still unconfirmed; ask before writing it as secondary.
|
||||
- If the surface speaks to a different audience than the people who use the product, ask the user to name both.
|
||||
- For brand: what emotions should the interface evoke? (confidence, delight, calm, urgency) Ask this standalone; don't fold emotions into the success question.
|
||||
- For product: what workflow are they in? What's the primary task on any given screen?
|
||||
|
||||
### Positioning
|
||||
- In one line, what does this do that nothing else does? The single strategic claim every screen reinforces.
|
||||
|
||||
### Brand & Personality
|
||||
- How would you describe the brand personality in 3 words?
|
||||
- Reference sites or apps that capture the right feel? What specifically about them?
|
||||
- Push for specific named references with the *specific* thing about them that fits this brand, not generic "modern" adjectives or category-bucket lanes.
|
||||
- What should this explicitly NOT look like? Any anti-references?
|
||||
|
||||
### Conversion & proof (brand register only)
|
||||
- What's the primary CTA?
|
||||
- What's the secondary fallback, for visitors not ready for the primary?
|
||||
- The one line a visitor should remember after 10 seconds.
|
||||
- What must the visitor believe, in order, before taking the primary CTA? (The template's belief ladder.)
|
||||
- What proof is on hand? Ask the user to hand over any testimonials, case studies, press, or client/partner logos they already have. If you can receive files directly, collect them; otherwise create `.impeccable/assets/proof/` and ask the user to add files there. Reference supplied files by path; record text proof inline.
|
||||
|
||||
### Accessibility & Inclusion
|
||||
- Specific accessibility requirements? (WCAG level, known user needs)
|
||||
- Considerations for reduced motion, color blindness, or other accommodations?
|
||||
@@ -90,7 +121,7 @@ Skip questions where the answer is already clear. **Do NOT ask about colors, fon
|
||||
|
||||
## Step 4: Write PRODUCT.md
|
||||
|
||||
Write PRODUCT.md only after the user has confirmed the strategic answers from Step 3. If an inferred answer is uncertain or unconfirmed, ask before writing.
|
||||
Write PRODUCT.md only after the user has confirmed the strategic answers from Step 3. If an inferred answer is uncertain or unconfirmed, ask before writing. Confirmed means what the user actually said yes to; do not pad a confirmed answer with extras they never picked (additional anti-references, audiences, roadmap claims, a WCAG level), whether drawn from the crawl, another answer, or your own option text. If an extra belongs in the doc, ask about it first.
|
||||
|
||||
Synthesize into a strategic document:
|
||||
|
||||
@@ -101,12 +132,26 @@ Synthesize into a strategic document:
|
||||
|
||||
product
|
||||
|
||||
## Platform
|
||||
|
||||
web
|
||||
|
||||
## Users
|
||||
[Who they are, their context, the job to be done]
|
||||
[Who they are, their context, the job to be done. Primary audience; a secondary audience or a surface-vs-user split only when they apply.]
|
||||
|
||||
## Product Purpose
|
||||
[What this product does, why it exists, what success looks like]
|
||||
|
||||
## Positioning
|
||||
[The single strategic claim every screen reinforces. Not a visual rule, not an anti-reference.]
|
||||
|
||||
## Conversion & proof
|
||||
[Brand register only. Product register: omit this section entirely, heading included.]
|
||||
- Primary and secondary CTA: [...]
|
||||
- The line a visitor remembers after 10 seconds: [...]
|
||||
- Belief ladder: [...]
|
||||
- Proof on hand: [testimonials, case studies, press, or logos, referenced by path]
|
||||
|
||||
## Brand Personality
|
||||
[Voice, tone, 3-word personality, emotional goals]
|
||||
|
||||
@@ -120,7 +165,9 @@ product
|
||||
[WCAG level, known user needs, considerations]
|
||||
```
|
||||
|
||||
Register is either `brand` or `product` as a bare value. No prose, no commentary.
|
||||
Register is either `brand` or `product` as a bare value. No prose, no commentary. Platform is `web`, `ios`, `android`, or `adaptive`, also a bare value; omit the section only on legacy files you're leaving untouched, otherwise write `web` explicitly.
|
||||
|
||||
Write fields as prose, and use bold sparingly: only where a word carries a decision, never as a label lead-in on every line.
|
||||
|
||||
Write to `PROJECT_ROOT/PRODUCT.md`. If `.impeccable.md` existed, the loader already renamed it; merge into that content rather than starting from scratch.
|
||||
|
||||
@@ -137,6 +184,8 @@ If the user prefers to skip, mention they can run `$impeccable document` any tim
|
||||
|
||||
## Step 6: Configure live mode (when code exists)
|
||||
|
||||
**Skip this step when the platform is native** (`ios` / `android` / `adaptive`): live mode drives a browser overlay. A hybrid wrapper or Expo web target serving HTML doesn't change that.
|
||||
|
||||
If the project has code with HTML entries and a dev server (the same "code exists" condition that puts `$impeccable document` in scan mode), pre-configure live mode now. You already identified the framework and the served HTML entry in Step 2, so this is nearly free, and it spares the user the first-time setup detour when they later run `$impeccable live`.
|
||||
|
||||
**Skip this step for empty / pre-implementation projects** (nothing to inject into yet). Tell the user live mode will configure itself the first time they run it once there's code.
|
||||
@@ -154,16 +203,16 @@ Writing the config file is harmless and needs no consent; only the CSP **source-
|
||||
## Step 7: Recommend starting points, then wrap up
|
||||
|
||||
Summarize tersely:
|
||||
- Register captured (brand / product)
|
||||
- Register captured (brand / product) and platform captured (web / ios / android / adaptive)
|
||||
- What was written (PRODUCT.md, DESIGN.md, live config, or a subset)
|
||||
- The 3-5 strategic principles from PRODUCT.md that will guide future work
|
||||
- If DESIGN.md or live config is pending, one line on how to set it up later
|
||||
|
||||
Then recommend the **best commands to run next**, drawn from what your Step 2 crawl already surfaced. Do not run a fresh analysis here; surface observations you already have. Tailor to register and to what you saw, offer the 2-4 most relevant (not a menu dump), and give the exact command to type. Group by intent:
|
||||
Then recommend the **best commands to run next**, drawn from what your Step 2 crawl already surfaced. Do not run a fresh analysis here; surface observations you already have. Tailor to register **and platform**, offer the 2-4 most relevant (not a menu dump), and give the exact command to type. Group by intent:
|
||||
|
||||
- **Build something new**: `$impeccable craft <feature>` (shape, then build end-to-end) or `$impeccable shape <feature>` (plan first). Lead with this for empty or early-stage projects.
|
||||
- **Improve what's there**: name the specific surface. `$impeccable critique <page>` for a scored UX review; `$impeccable audit <area>` for a11y / perf / responsive checks; `$impeccable polish <component>` for a pre-ship pass. When the crawl flagged a specific weakness, point the matching command at it: thin hierarchy or spacing → `layout`, flat or gray palette → `colorize`, missing error / empty states → `harden` or `onboard`, dull or unclear copy → `clarify`.
|
||||
- **Iterate visually**: `$impeccable live` (configured in Step 6) to pick elements in the browser and generate variants in place.
|
||||
- **Iterate visually** (web only): `$impeccable live` (configured in Step 6) to pick elements in the browser and generate variants in place. **Skip this group for native platforms.**
|
||||
|
||||
The full command menu is one bare `$impeccable` away; keep this list short and pointed.
|
||||
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
# iOS platform
|
||||
|
||||
For native iOS / iPadOS apps: SwiftUI, UIKit, React Native, Expo, Flutter shipping to Apple hardware.
|
||||
|
||||
On native, register narrows. HIG conformance governs structure, navigation, and interaction whatever the register; brand expresses through the expressive layer the platform provides (tint, type, motion, content). Calm, Duolingo, and Spotify carry strong identity entirely inside HIG conventions.
|
||||
|
||||
## The iOS slop test
|
||||
|
||||
Would a fluent iPhone user trust this app, or pause at off-spec controls? The tell is "ported from a website": reinvented navigation bars, custom back gestures, web-shaped buttons, hover-dependent affordances. Default to the platform's components; depart only for a reason the user would thank you for.
|
||||
|
||||
## Layout & structure
|
||||
|
||||
- **Safe area.** Lay out inside the safe-area insets. No controls under the notch, Dynamic Island, home indicator, or rounded corners.
|
||||
- **System navigation.** Tab bar for 2–5 top-level sections (sections, never actions), navigation stack for hierarchy, sheet for self-contained tasks. No custom global nav, no mixed metaphors.
|
||||
- **Edge-swipe back stays alive.** The left-edge back gesture is muscle memory; never disable or overlay it.
|
||||
- **Large titles** on top-level screens, collapsing to inline on scroll. Deep detail screens stay inline.
|
||||
|
||||
## Touch targets
|
||||
|
||||
- **44×44 pt minimum** for every tappable control, with breathing room between adjacent targets.
|
||||
|
||||
## Typography
|
||||
|
||||
- **Dynamic Type.** Use the system text styles (Large Title through Caption) so text follows the user's reading size. No hard-coded point sizes.
|
||||
- **San Francisco carries the UI.** Body, labels, and controls stay on SF Pro / SF Compact; a brand face may appear in display moments.
|
||||
- **11 pt floor**; Body is 17 pt.
|
||||
|
||||
## Color & materials
|
||||
|
||||
- **Semantic system colors** (label, secondaryLabel, systemBackground, separator, tint). They adapt to Dark Mode and increased contrast automatically; raw hex breaks there.
|
||||
- **Dark Mode is a first-class appearance.** Design and test both.
|
||||
- **One tint color** drives interactive elements; decoration is not its job.
|
||||
- **System materials** for blur and translucency behind bars and sheets; no hand-rolled glassmorphism.
|
||||
|
||||
## Components & controls
|
||||
|
||||
- **Platform controls.** Switch, segmented control, stepper, system pickers, action sheets, alerts, context menus, swipe actions. Reinventing these for flavor is the most common native slop.
|
||||
- **SF Symbols** for iconography: baseline-aligned, Dynamic Type-aware, weight and scale variants. Don't mix in a web icon set.
|
||||
- **Deliberate modality.** Sheet for a focused dismissible sub-task, full-screen cover for immersion. Clear Cancel/Done; honor swipe-to-dismiss unless data loss requires a guard.
|
||||
- **Grouped/inset lists** for settings-shaped content; no bespoke card stacks.
|
||||
|
||||
## Motion
|
||||
|
||||
- **System transitions.** Push slides, sheets rise, dismiss reverses the entrance. Custom transitions that fight the navigation model disorient.
|
||||
- **Honor Reduce Motion.** Crossfade instead of parallax and large slides.
|
||||
@@ -8,11 +8,33 @@ Brand: asymmetric compositions, fluid spacing with `clamp()`, intentional grid-b
|
||||
|
||||
Product: predictable grids, consistent densities, familiar navigation patterns. Responsive behavior is structural (collapse sidebar, responsive table), not fluid typography. Consistency IS an affordance.
|
||||
|
||||
Native (`ios` / `android` / `adaptive`): structure follows the Layout section of [ios.md](ios.md) / [android.md](android.md) (read it first if Setup hasn't already): platform navigation, insets, and touch targets, never the CSS tooling below.
|
||||
|
||||
---
|
||||
|
||||
## Two isolated assessments (required)
|
||||
|
||||
Spawn two parallel sub-agents whenever a sub-agent/Task tool is exposed: one for the layout assessment, one for the mechanical pre-scan. If the harness needs explicit user permission for sub-agents, stop and ask before proceeding. Isolation is the point: detector output anchors visual judgment toward what the scan can see, so neither sub-agent gets the other's output. Each assessment runs in its own sub-agent; running either one in this context when a sub-agent tool exists is not permitted, even when it is faster; the fallback below is only for sessions with no sub-agent tool. Give each a self-contained prompt (target files, register, documented spacing scale when present, and its instructions below); do not assume it can read this file.
|
||||
|
||||
**Sub-agent A (layout assessment)**: give it the full [Assess Current Layout](#assess-current-layout) checklist below, verbatim, in its prompt. It works through every item and returns per-item findings citing file, selector, or value.
|
||||
|
||||
**Sub-agent B (mechanical pre-scan)**: run the bundled detector scoped to layout:
|
||||
|
||||
```bash
|
||||
node .agents/skills/impeccable/scripts/detect.mjs --json --scope layout [target files or dirs]
|
||||
```
|
||||
|
||||
A missing `node` on PATH is not permission to skip: hunt for a runtime (`command -v node`, nvm or Homebrew paths, the harness's own bundled node) and run it by full path. If none exists, halt the scan and report that Node must be installed (the parent relays this to the user); do **not** substitute grep for the detector or proceed unscanned. The detector abstains on arbitrary Tailwind spacing (`gap-[13px]`, `p-[7px]`) and ad-hoc `z-index` stacks, so when the project documents a spacing scale, also grep `gap-\[`, `p[trblxy]?-\[`, `m[trblxy]?-\[`, `z-\[` and judge those hits against it. Return the findings JSON plus the grep verdicts.
|
||||
|
||||
**If no sub-agent tool is exposed (or the user declined)**: run both yourself, assessment first, pre-scan second, so the deterministic findings can't anchor the visual judgment. Keep that order even when the scan feels quicker to start with.
|
||||
|
||||
**Synthesize** once both are done: merge into a single findings list, noting where they agree and what each caught alone. Fix every finding, or list it as a deliberate exception for the user to accept. A clean scan is a floor, not a verdict: a monotone grid with uniform spacing passes every detector rule, which is exactly what the assessment exists to catch. State in your final summary which path ran (parallel sub-agents or single-context fallback).
|
||||
|
||||
---
|
||||
|
||||
## Assess Current Layout
|
||||
|
||||
Analyze what's weak about the current spatial design:
|
||||
This checklist is sub-agent A's brief (on the fallback path, work through it yourself before the pre-scan). Analyze what's weak about the current spatial design:
|
||||
|
||||
1. **Spacing**:
|
||||
- Is spacing consistent or arbitrary? (Random padding/margin values)
|
||||
@@ -138,6 +160,8 @@ Create a systematic plan:
|
||||
- **Consistency**: Is the spacing system applied uniformly?
|
||||
- **Responsiveness**: Does the layout adapt gracefully across screen sizes?
|
||||
|
||||
Answer each item above by citing the file, selector, or value that satisfies it; never a bare yes. Then re-run the pre-scan and fix until the count of unresolved items and unaccepted findings is zero.
|
||||
|
||||
When the rhythm and hierarchy land, hand off to `$impeccable polish` for the final pass.
|
||||
|
||||
## Live-mode signature params
|
||||
|
||||
@@ -10,9 +10,29 @@ Product: system fonts and familiar sans stacks are legitimate here. One well-tun
|
||||
|
||||
---
|
||||
|
||||
## Two isolated assessments (required)
|
||||
|
||||
Spawn two parallel sub-agents whenever a sub-agent/Task tool is exposed: one for the typography assessment, one for the mechanical pre-scan. If the harness needs explicit user permission for sub-agents, stop and ask before proceeding. Isolation is the point: detector output anchors visual judgment toward what the scan can see, so neither sub-agent gets the other's output. Each assessment runs in its own sub-agent; running either one in this context when a sub-agent tool exists is not permitted, even when it is faster; the fallback below is only for sessions with no sub-agent tool. Give each a self-contained prompt (target files, register, **DESIGN.md** content when present, and its instructions below); do not assume it can read this file.
|
||||
|
||||
**Sub-agent A (typography assessment)**: give it the full [Assess Current Typography](#assess-current-typography) checklist below, verbatim, in its prompt. It works through every item and returns per-item findings citing file, selector, or value.
|
||||
|
||||
**Sub-agent B (mechanical pre-scan)**: run the bundled detector scoped to type:
|
||||
|
||||
```bash
|
||||
node .agents/skills/impeccable/scripts/detect.mjs --json --scope type [target files or dirs]
|
||||
```
|
||||
|
||||
A missing `node` on PATH is not permission to skip: hunt for a runtime (`command -v node`, nvm or Homebrew paths, the harness's own bundled node) and run it by full path. If none exists, halt the scan and report that Node must be installed (the parent relays this to the user); do **not** substitute grep for the detector or proceed unscanned. The scan checks literal font sizes against the **DESIGN.md** ramp but abstains on `em`, `%`, `clamp()`, and line-heights, so also grep `font-size\s*:`, `fontSize`, `text-\[`, `leading-\[` and judge those hits against the spec. Return the findings JSON plus the grep verdicts.
|
||||
|
||||
**If no sub-agent tool is exposed (or the user declined)**: run both yourself, assessment first, pre-scan second, so the deterministic findings can't anchor the visual judgment. Keep that order even when the scan feels quicker to start with.
|
||||
|
||||
**Synthesize** once both are done: merge into a single findings list, noting where they agree and what each caught alone. Fix every finding, or list it as a deliberate exception for the user to accept. A clean scan is a floor, not a verdict: a generic font stack at a flat scale passes every detector rule, which is exactly what the assessment exists to catch. State in your final summary which path ran (parallel sub-agents or single-context fallback).
|
||||
|
||||
---
|
||||
|
||||
## Assess Current Typography
|
||||
|
||||
Analyze what's weak or generic about the current type:
|
||||
This checklist is sub-agent A's brief (on the fallback path, work through it yourself before the pre-scan). Analyze what's weak or generic about the current type:
|
||||
|
||||
1. **Font choices**:
|
||||
- Are we using invisible defaults? (Inter, Roboto, Arial, Open Sans, system defaults)
|
||||
@@ -109,6 +129,8 @@ Build a clear type scale:
|
||||
- **Performance**: Are web fonts loading efficiently without layout shift?
|
||||
- **Accessibility**: Does text meet WCAG contrast ratios? Is it zoomable to 200%?
|
||||
|
||||
Answer each item above by citing the file, selector, or value that satisfies it; never a bare yes. Then re-run the pre-scan and fix until the count of unresolved items and unaccepted findings is zero.
|
||||
|
||||
When the type carries the hierarchy on its own, hand off to `$impeccable polish` for the final pass.
|
||||
|
||||
## Live-mode signature params
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Context-signals gatherer for the bare `{{command_prefix}}impeccable`
|
||||
* Context-signals gatherer for the bare Impeccable invocation
|
||||
* (no-argument) path. Collects cheap, deterministic signals about the current
|
||||
* project and emits them as JSON.
|
||||
*
|
||||
@@ -21,7 +21,7 @@ import net from 'node:net';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { loadContext, extractRegister } from './context.mjs';
|
||||
import { loadContext, extractRegister, extractPlatform } from './context.mjs';
|
||||
import { getCritiqueDir } from './lib/impeccable-paths.mjs';
|
||||
|
||||
/** Is there code here at all, or just context files / an empty repo? */
|
||||
@@ -197,6 +197,7 @@ export async function gatherSignals(cwd = process.cwd()) {
|
||||
designPath: ctx.designPath,
|
||||
hasCode: hasCode(cwd),
|
||||
register: extractRegister(ctx.product),
|
||||
platform: extractPlatform(ctx.product),
|
||||
},
|
||||
critique: { latest: latestCritique(cwd) },
|
||||
git,
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
/**
|
||||
* 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.
|
||||
* markdown block on stdout, or prints a `NO_PRODUCT_MD:` message when no
|
||||
* PRODUCT.md is found anywhere. The skill keys off that message to branch:
|
||||
* from-scratch build commands (init / teach / craft / shape) and clear
|
||||
* build/shape intent divert into the init flow, while scoped commands proceed
|
||||
* using the existing code as context.
|
||||
*
|
||||
* Path resolution (first match wins):
|
||||
* 1. Active project root, if PRODUCT.md or DESIGN.md is there
|
||||
@@ -21,6 +23,7 @@ import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { parseTargetOptions } from './lib/target-args.mjs';
|
||||
import { IMPECCABLE_COMMAND } from './lib/provider.mjs';
|
||||
|
||||
const PRODUCT_NAMES = ['PRODUCT.md', 'Product.md', 'product.md'];
|
||||
const DESIGN_NAMES = ['DESIGN.md', 'Design.md', 'design.md'];
|
||||
@@ -691,24 +694,60 @@ function escapeRegExp(value) {
|
||||
return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the first non-empty line under a bare `## <heading>` section of
|
||||
* PRODUCT.md (e.g. `## Register`, `## Platform`). Returns null when the
|
||||
* section is absent. The heading match is exact (`\s*$`) so near-miss
|
||||
* headings like `## Register guidelines` don't shadow the real field.
|
||||
*/
|
||||
export function extractSectionValue(product, heading) {
|
||||
if (!product) return null;
|
||||
const headingRe = new RegExp(`^##\\s+${escapeRegExp(heading)}\\s*$`, 'i');
|
||||
const lines = product.split('\n');
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
if (headingRe.test(lines[i].trim())) {
|
||||
for (let j = i + 1; j < lines.length; j++) {
|
||||
const next = lines[j].trim();
|
||||
// A new heading before any value means the section is empty.
|
||||
if (/^#{1,6}\s/.test(next)) return null;
|
||||
if (next) return next;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
const word = (extractSectionValue(product, 'Register') || '').toLowerCase();
|
||||
return word === 'brand' || word === 'product' ? word : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull the platform (`web`, `ios`, `android`, or `adaptive`) out of PRODUCT.md
|
||||
* by looking for a `## Platform` section and reading the first non-empty line
|
||||
* that follows it. `adaptive` is for cross-platform apps (Flutter, React
|
||||
* Native) that ship both iOS and Android from one codebase; a line that names
|
||||
* both targets (e.g. `ios, android`) is also read as `adaptive`. Returns null
|
||||
* when the file is legacy / platform-less, which the skill treats as `web`
|
||||
* (the default the general rules already assume).
|
||||
*/
|
||||
export function extractPlatform(product) {
|
||||
const value = (extractSectionValue(product, 'Platform') || '').toLowerCase();
|
||||
if (!value) return null;
|
||||
if (value === 'web' || value === 'ios' || value === 'android' || value === 'adaptive') return value;
|
||||
// A short list naming both native targets (`ios, android`, `ios and
|
||||
// android`) = adaptive. Only list separators and the two platform words may
|
||||
// appear; anything else (prose, negations) is unrecognized and falls
|
||||
// through to the CLI's WARNING path.
|
||||
const tokens = value.split(/[\s,+&/]+/).filter(t => t && t !== 'and');
|
||||
if (tokens.length >= 2 && tokens.every(t => t === 'ios' || t === 'android')
|
||||
&& tokens.includes('ios') && tokens.includes('android')) {
|
||||
return 'adaptive';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -860,8 +899,11 @@ async function cli() {
|
||||
// — 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.',
|
||||
'Follow SKILL.md Setup step 1: for `init`, `teach`, `craft`, `shape`, ' +
|
||||
'or wording that clearly maps to a from-scratch build/shape flow, load ' +
|
||||
'reference/init.md and write PRODUCT.md first; for any other (scoped) ' +
|
||||
'command against existing code, proceed using the code as context and ' +
|
||||
`offer \`${IMPECCABLE_COMMAND} init\` as a suggestion (do not block).`,
|
||||
];
|
||||
parts.push(buildResolvedContextDirective(ctx, cliOptions, { targetExists }));
|
||||
if (shouldWarnMissingTarget(ctx, targetProvided, targetExists)) {
|
||||
@@ -884,6 +926,26 @@ async function cli() {
|
||||
? `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);
|
||||
const platform = extractPlatform(ctx.product);
|
||||
const nativeRefs =
|
||||
platform === 'adaptive' ? ['ios', 'android'] : platform === 'ios' || platform === 'android' ? [platform] : [];
|
||||
if (nativeRefs.length) {
|
||||
const refList = nativeRefs.map(p => `\`reference/${p}.md\``).join(' and ');
|
||||
const label = platform === 'adaptive' ? '`adaptive` (both iOS and Android)' : `\`${platform}\``;
|
||||
parts.push(
|
||||
`NEXT STEP: This project targets ${label}. Also read ${refList} for native conventions, in addition to the register reference.`,
|
||||
);
|
||||
} else if (!platform) {
|
||||
// A `## Platform` section that names something we don't recognize (a
|
||||
// toolchain like `flutter`, a typo) would otherwise silently fall back to
|
||||
// web — the wrong default exactly when the user tried to say "native".
|
||||
const rawPlatform = extractSectionValue(ctx.product, 'Platform');
|
||||
if (rawPlatform) {
|
||||
parts.push(
|
||||
`WARNING: PRODUCT.md's \`## Platform\` value \`${rawPlatform}\` is not recognized; treating the project as \`web\`. Valid values are \`web\`, \`ios\`, \`android\`, or \`adaptive\` (cross-platform, ships both). If this project is native, fix the field (name the design language the app renders, not the toolchain) and surface it to the user.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (updateDirective) parts.push(updateDirective);
|
||||
process.stdout.write(parts.join('\n\n---\n\n') + '\n');
|
||||
}
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
/**
|
||||
* Critique persistence helper.
|
||||
*
|
||||
* Each run of /impeccable critique writes a per-target snapshot to
|
||||
* Each critique run writes a per-target snapshot to
|
||||
* .impeccable/critique/<timestamp>__<slug>.md
|
||||
* with a small YAML frontmatter carrying the score + P0/P1 counts.
|
||||
*
|
||||
* /impeccable polish reads the latest matching snapshot at start as its
|
||||
* The polish workflow reads the latest matching snapshot at start as its
|
||||
* fix backlog. No other skill auto-reads critique output.
|
||||
*
|
||||
* The slug is derived mechanically from the *resolved* primary artifact
|
||||
|
||||
@@ -2,6 +2,7 @@ import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import { loadDesignSystemForCwd } from '../design-system.mjs';
|
||||
import { RULE_SCOPES, filterByScopes } from '../registry/antipatterns.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';
|
||||
@@ -93,6 +94,8 @@ Options:
|
||||
--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)
|
||||
--scope <name> Only report rules in the given design domain
|
||||
(type, layout). Comma-separated.
|
||||
--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
|
||||
@@ -151,6 +154,33 @@ async function detectCli() {
|
||||
const providers = [];
|
||||
if (args.includes('--gpt')) providers.push('gpt');
|
||||
if (args.includes('--gemini')) providers.push('gemini');
|
||||
const scopes = [];
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
if (args[i] !== '--scope' && !args[i].startsWith('--scope=')) continue;
|
||||
const inline = args[i].startsWith('--scope=');
|
||||
const value = inline ? args[i].slice('--scope='.length) : args[i + 1];
|
||||
const parsed = (value && !value.startsWith('--'))
|
||||
? value.split(',').map(s => s.trim()).filter(Boolean)
|
||||
: [];
|
||||
// A bare `--scope` would otherwise fall out of `targets` and scan unscoped;
|
||||
// fail loudly so a mistyped pre-scan never runs the wrong rule set.
|
||||
if (parsed.length === 0) {
|
||||
process.stderr.write(
|
||||
`Error: --scope requires a value. Valid scopes: ${[...RULE_SCOPES].join(', ')}\n`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
scopes.push(...parsed);
|
||||
args.splice(i, inline ? 1 : 2);
|
||||
i -= 1;
|
||||
}
|
||||
const unknownScopes = scopes.filter(s => !RULE_SCOPES.has(s));
|
||||
if (unknownScopes.length > 0) {
|
||||
process.stderr.write(
|
||||
`Error: unknown --scope value(s): ${unknownScopes.join(', ')}. Valid scopes: ${[...RULE_SCOPES].join(', ')}\n`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
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
|
||||
@@ -276,6 +306,7 @@ async function detectCli() {
|
||||
}
|
||||
|
||||
allFindings = filterDetectionFindings(allFindings, detectionConfig);
|
||||
allFindings = filterByScopes(allFindings, scopes);
|
||||
|
||||
if (allFindings.length > 0) {
|
||||
if (jsonMode) process.stdout.write(formatFindings(allFindings, true) + '\n');
|
||||
|
||||
@@ -9,6 +9,8 @@ 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 FONT_SIZE_TOLERANCE_PX = 0.5;
|
||||
const FONT_SIZE_LITERAL_RE = /^-?[\d.]+(?:px|rem)$/;
|
||||
|
||||
const CSS_COLOR_RE = /#[0-9a-f]{3,8}\b|rgba?\([^)]+\)|oklch\([^)]+\)|hsla?\([^)]+\)/gi;
|
||||
const FONT_DECL_RE = /font-family\s*:\s*([^;}\n]+)/gi;
|
||||
@@ -16,6 +18,9 @@ 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 FONT_SIZE_DECL_RE = /font-size\s*:\s*([^;}\n]+)/gi;
|
||||
const FONT_SIZE_JS_RE = /fontSize\s*[:=]\s*["'`]([^"'`]+)["'`]/g;
|
||||
const TAILWIND_FONT_SIZE_RE = /\btext-\[(-?[\d.]+(?:px|rem))\]/g;
|
||||
const STATIC_DESIGN_SKIP_TAGS = new Set(['head', 'title', 'meta', 'link', 'style', 'script', 'noscript', 'template', 'source']);
|
||||
|
||||
function firstExisting(dir, names) {
|
||||
@@ -283,6 +288,18 @@ function addTypographyFonts(out, typography) {
|
||||
}
|
||||
}
|
||||
|
||||
function addTypographySizes(out, typography) {
|
||||
if (!typography || typeof typography !== 'object') return;
|
||||
for (const role of Object.values(typography)) {
|
||||
if (!role || typeof role !== 'object') continue;
|
||||
const raw = String(role.fontSize ?? '').trim().toLowerCase();
|
||||
if (!FONT_SIZE_LITERAL_RE.test(raw)) continue;
|
||||
const px = resolveLengthPx(raw, 16);
|
||||
if (px == null || !Number.isFinite(px) || px <= 0) continue;
|
||||
out.allowedFontSizes.push({ value: raw, px });
|
||||
}
|
||||
}
|
||||
|
||||
function addRoundedScale(out, rounded) {
|
||||
if (!rounded || typeof rounded !== 'object') return;
|
||||
for (const [rawName, value] of Object.entries(rounded)) {
|
||||
@@ -340,10 +357,12 @@ function normalizeDesignSystem(input = {}) {
|
||||
allowedFonts: new Set(),
|
||||
allowedColorKeys: new Map(),
|
||||
allowedRadii: [],
|
||||
allowedFontSizes: [],
|
||||
hasPillRadius: false,
|
||||
};
|
||||
|
||||
addTypographyFonts(out, frontmatter.typography);
|
||||
addTypographySizes(out, frontmatter.typography);
|
||||
addColorObject(out, frontmatter.colors);
|
||||
addSidecarColors(out, sidecar);
|
||||
addRoundedScale(out, frontmatter.rounded);
|
||||
@@ -352,6 +371,7 @@ function normalizeDesignSystem(input = {}) {
|
||||
out.hasFonts = out.allowedFonts.size > 0;
|
||||
out.hasColors = out.allowedColorKeys.size > 0;
|
||||
out.hasRadii = out.allowedRadii.length > 0;
|
||||
out.hasFontSizes = out.allowedFontSizes.length > 0;
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -418,6 +438,17 @@ function isAllowedRadiusRaw(raw, designSystem) {
|
||||
return designSystem.allowedRadii.some(entry => Math.abs(entry.px - px) <= RADIUS_TOLERANCE_PX);
|
||||
}
|
||||
|
||||
function isAllowedFontSizeRaw(raw, designSystem) {
|
||||
if (!designSystem?.hasFontSizes) return true;
|
||||
const text = String(raw || '').trim().toLowerCase().replace(/\s*!important\s*$/, '');
|
||||
if (!FONT_SIZE_LITERAL_RE.test(text)) return true;
|
||||
const px = resolveLengthPx(text, 16);
|
||||
if (px == null || !Number.isFinite(px) || px <= 0) return true;
|
||||
return designSystem.allowedFontSizes.some(
|
||||
entry => Math.abs(entry.px - px) <= FONT_SIZE_TOLERANCE_PX,
|
||||
);
|
||||
}
|
||||
|
||||
function lineLooksCommented(line) {
|
||||
const trimmed = String(line || '').trim();
|
||||
return trimmed.startsWith('//') || trimmed.startsWith('/*') || trimmed.startsWith('*') || trimmed.startsWith('<!--');
|
||||
@@ -509,6 +540,18 @@ function checkRadiusValue(value, filePath, line, designSystem, context) {
|
||||
return findings;
|
||||
}
|
||||
|
||||
function checkFontSizeValue(value, filePath, line, designSystem, context) {
|
||||
const token = String(value || '').trim();
|
||||
if (isAllowedFontSizeRaw(token, designSystem)) return [];
|
||||
return [makeDesignFinding(
|
||||
'design-system-font-size',
|
||||
filePath,
|
||||
`${context}: ${token} is off the DESIGN.md type ramp`,
|
||||
line,
|
||||
{ ignoreValue: token },
|
||||
)];
|
||||
}
|
||||
|
||||
function checkSourceDesignSystem(content, filePath, options = {}) {
|
||||
const designSystem = options.designSystem;
|
||||
if (!designSystem?.present) return [];
|
||||
@@ -567,6 +610,18 @@ function checkSourceDesignSystem(content, filePath, options = {}) {
|
||||
findings.push(...checkRadiusValue(match[1], filePath, lineNum, designSystem, 'borderRadius'));
|
||||
}
|
||||
}
|
||||
|
||||
if (designSystem.hasFontSizes) {
|
||||
for (const match of line.matchAll(FONT_SIZE_DECL_RE)) {
|
||||
findings.push(...checkFontSizeValue(match[1], filePath, lineNum, designSystem, 'font-size'));
|
||||
}
|
||||
for (const match of line.matchAll(FONT_SIZE_JS_RE)) {
|
||||
findings.push(...checkFontSizeValue(match[1], filePath, lineNum, designSystem, 'fontSize'));
|
||||
}
|
||||
for (const match of line.matchAll(TAILWIND_FONT_SIZE_RE)) {
|
||||
findings.push(...checkFontSizeValue(match[1], filePath, lineNum, designSystem, 'text-[…] class'));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return dedupeDesignFindings(findings);
|
||||
@@ -581,6 +636,8 @@ function sampleText(el) {
|
||||
return text ? ` "${text.slice(0, 40)}"` : '';
|
||||
}
|
||||
|
||||
// Font-size design-system checks are source-scan-only (see checkSourceDesignSystem).
|
||||
// Computed font-size cascades and clamp() ramps resolve to off-ramp px in the browser.
|
||||
function collectStaticDesignSystemFindings(document, window, filePath, designSystem) {
|
||||
if (!designSystem?.present) return [];
|
||||
const findings = [];
|
||||
@@ -698,6 +755,12 @@ function canonicalDesignFindingKey(item) {
|
||||
const label = String(value || '').trim().toLowerCase();
|
||||
return label ? `${item.antipattern}:radius:${label}` : null;
|
||||
}
|
||||
if (item.antipattern === 'design-system-font-size') {
|
||||
const px = resolveLengthPx(String(value || '').trim(), 16);
|
||||
if (px != null && Number.isFinite(px)) return `${item.antipattern}:font-size:${Math.round(px * 100) / 100}`;
|
||||
const label = String(value || '').trim().toLowerCase();
|
||||
return label ? `${item.antipattern}:font-size:${label}` : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -744,6 +807,7 @@ export {
|
||||
isAllowedFont,
|
||||
isAllowedColorRaw,
|
||||
isAllowedRadiusRaw,
|
||||
isAllowedFontSizeRaw,
|
||||
checkSourceDesignSystem,
|
||||
collectStaticDesignSystemFindings,
|
||||
mergeDesignSystemFindings,
|
||||
|
||||
@@ -123,6 +123,7 @@ const ANTIPATTERNS = [
|
||||
{
|
||||
id: 'overused-font',
|
||||
category: 'slop',
|
||||
scopes: ['type'],
|
||||
name: 'Overused font',
|
||||
description:
|
||||
'Inter, Roboto, Fraunces, Geist, Plus Jakarta Sans, and Space Grotesk are used on so many sites they no longer feel distinctive. Each new wave of AI-generated UIs converges on the same handful of faces. Choose a face that gives your interface personality.',
|
||||
@@ -132,6 +133,7 @@ const ANTIPATTERNS = [
|
||||
{
|
||||
id: 'single-font',
|
||||
category: 'slop',
|
||||
scopes: ['type'],
|
||||
name: 'Single font for everything',
|
||||
description:
|
||||
'Only one font family is used for the entire page. Pair a distinctive display font with a refined body font to create typographic hierarchy.',
|
||||
@@ -141,6 +143,7 @@ const ANTIPATTERNS = [
|
||||
{
|
||||
id: 'flat-type-hierarchy',
|
||||
category: 'slop',
|
||||
scopes: ['type'],
|
||||
name: 'Flat type hierarchy',
|
||||
description:
|
||||
'Font sizes are too close together — no clear visual hierarchy. Use fewer sizes with more contrast (aim for at least a 1.25 ratio between steps).',
|
||||
@@ -177,6 +180,7 @@ const ANTIPATTERNS = [
|
||||
{
|
||||
id: 'nested-cards',
|
||||
category: 'slop',
|
||||
scopes: ['layout'],
|
||||
name: 'Nested cards',
|
||||
description:
|
||||
'Cards inside cards create visual noise and excessive depth. Flatten the hierarchy — use spacing, typography, and dividers instead of nesting containers.',
|
||||
@@ -186,6 +190,7 @@ const ANTIPATTERNS = [
|
||||
{
|
||||
id: 'monotonous-spacing',
|
||||
category: 'slop',
|
||||
scopes: ['layout'],
|
||||
name: 'Monotonous spacing',
|
||||
description:
|
||||
'The same spacing value used everywhere — no rhythm, no variation. Use tight groupings for related items and generous separations between sections.',
|
||||
@@ -213,6 +218,7 @@ const ANTIPATTERNS = [
|
||||
{
|
||||
id: 'icon-tile-stack',
|
||||
category: 'slop',
|
||||
scopes: ['layout'],
|
||||
name: 'Icon tile stacked above heading',
|
||||
description:
|
||||
'A small rounded-square icon container above a heading is the universal AI feature-card template — every generator outputs this exact shape. Try a side-by-side icon and heading, or let the icon sit in flow without its own container.',
|
||||
@@ -222,6 +228,7 @@ const ANTIPATTERNS = [
|
||||
{
|
||||
id: 'italic-serif-display',
|
||||
category: 'slop',
|
||||
scopes: ['type'],
|
||||
name: 'Italic serif display headline',
|
||||
description:
|
||||
'Oversized italic serif (Fraunces, Recoleta, Playfair, Newsreader-italic) as the primary hero headline reads as taste in isolation but has become the universal AI-startup landing page hero. Set roman, or move to a non-serif display face. Editorial / magazine register may legitimately want this — judge by context.',
|
||||
@@ -231,6 +238,7 @@ const ANTIPATTERNS = [
|
||||
{
|
||||
id: 'hero-eyebrow-chip',
|
||||
category: 'slop',
|
||||
scopes: ['type'],
|
||||
name: 'Hero eyebrow / pill chip',
|
||||
description:
|
||||
'A tiny uppercase letter-spaced label sitting immediately above an oversized hero headline — or the same shape rendered as a pill chip — is now the default AI SaaS hero. Drop the eyebrow, integrate the kicker into the headline, or run it as a navigation breadcrumb instead.',
|
||||
@@ -240,6 +248,7 @@ const ANTIPATTERNS = [
|
||||
{
|
||||
id: 'repeated-section-kickers',
|
||||
category: 'slop',
|
||||
scopes: ['type'],
|
||||
severity: 'advisory',
|
||||
name: 'Repeated section kicker labels',
|
||||
description:
|
||||
@@ -250,6 +259,7 @@ const ANTIPATTERNS = [
|
||||
{
|
||||
id: 'numbered-section-markers',
|
||||
category: 'slop',
|
||||
scopes: ['layout'],
|
||||
severity: 'advisory',
|
||||
name: 'Numbered section markers (01 / 02 / 03)',
|
||||
description:
|
||||
@@ -287,6 +297,7 @@ const ANTIPATTERNS = [
|
||||
{
|
||||
id: 'oversized-h1',
|
||||
category: 'slop',
|
||||
scopes: ['type'],
|
||||
name: 'Oversized hero headline',
|
||||
description:
|
||||
'A full-sentence headline set at display size ends up dominating the viewport, leaving no room for anything else above the fold. A punchy one- or two-word headline at that size is fine — the problem is a long headline blown up too large. Set long headlines smaller, or tighten the copy.',
|
||||
@@ -296,6 +307,7 @@ const ANTIPATTERNS = [
|
||||
{
|
||||
id: 'extreme-negative-tracking',
|
||||
category: 'slop',
|
||||
scopes: ['type'],
|
||||
name: 'Crushed letter spacing',
|
||||
description:
|
||||
'Letter-spacing pulled tighter than the point where characters keep their own shapes costs legibility. Tighten display type optically, not destructively.',
|
||||
@@ -341,6 +353,7 @@ const ANTIPATTERNS = [
|
||||
{
|
||||
id: 'line-length',
|
||||
category: 'quality',
|
||||
scopes: ['type', 'layout'],
|
||||
name: 'Line length too long',
|
||||
description:
|
||||
'Text lines wider than ~80 characters are hard to read. The eye loses its place tracking back to the start of the next line. Add a max-width (65ch to 75ch) to text containers.',
|
||||
@@ -350,6 +363,7 @@ const ANTIPATTERNS = [
|
||||
{
|
||||
id: 'cramped-padding',
|
||||
category: 'quality',
|
||||
scopes: ['layout'],
|
||||
name: 'Cramped padding',
|
||||
description:
|
||||
'Text is too close to the edge of its container. Two shapes: (1) an element with its own text where the padding is too low for the font size, and (2) a wrapper with text-bearing children and near-zero padding against a visible boundary (border, outline, or non-transparent background) — children land flush against the boundary line. Add at least 8px (ideally 12–16px) of padding inside bordered, outlined, or colored containers.',
|
||||
@@ -359,6 +373,7 @@ const ANTIPATTERNS = [
|
||||
{
|
||||
id: 'body-text-viewport-edge',
|
||||
category: 'quality',
|
||||
scopes: ['layout'],
|
||||
name: 'Body text touching viewport edge',
|
||||
description:
|
||||
'Body paragraphs render flush against the left or right viewport edge with no container providing horizontal padding. Wrap content in a container with at least 16px (ideally 24-32px) of horizontal padding, or apply max-width with mx-auto.',
|
||||
@@ -366,6 +381,7 @@ const ANTIPATTERNS = [
|
||||
{
|
||||
id: 'tight-leading',
|
||||
category: 'quality',
|
||||
scopes: ['type'],
|
||||
name: 'Tight line height',
|
||||
description:
|
||||
'Line height below 1.3x the font size makes multi-line text hard to read. Use 1.5 to 1.7 for body text so lines have room to breathe.',
|
||||
@@ -373,6 +389,7 @@ const ANTIPATTERNS = [
|
||||
{
|
||||
id: 'skipped-heading',
|
||||
category: 'quality',
|
||||
scopes: ['type'],
|
||||
name: 'Skipped heading level',
|
||||
description:
|
||||
'Heading levels should not skip (e.g. h1 then h3 with no h2). Screen readers use heading hierarchy for navigation. Skipping levels breaks the document outline.',
|
||||
@@ -380,6 +397,7 @@ const ANTIPATTERNS = [
|
||||
{
|
||||
id: 'justified-text',
|
||||
category: 'quality',
|
||||
scopes: ['type'],
|
||||
name: 'Justified text',
|
||||
description:
|
||||
'Justified text without hyphenation creates uneven word spacing ("rivers of white"). Use text-align: left for body text, or enable hyphens: auto if you must justify.',
|
||||
@@ -387,6 +405,7 @@ const ANTIPATTERNS = [
|
||||
{
|
||||
id: 'tiny-text',
|
||||
category: 'quality',
|
||||
scopes: ['type'],
|
||||
name: 'Tiny body text',
|
||||
description:
|
||||
'Body text below 12px is hard to read, especially on high-DPI screens. Use at least 14px for body content, 16px is ideal.',
|
||||
@@ -394,6 +413,7 @@ const ANTIPATTERNS = [
|
||||
{
|
||||
id: 'all-caps-body',
|
||||
category: 'quality',
|
||||
scopes: ['type'],
|
||||
name: 'All-caps body text',
|
||||
description:
|
||||
'Long passages in uppercase are hard to read. We recognize words by shape (ascenders and descenders), which all-caps removes. Reserve uppercase for short labels and headings.',
|
||||
@@ -403,6 +423,7 @@ const ANTIPATTERNS = [
|
||||
{
|
||||
id: 'wide-tracking',
|
||||
category: 'quality',
|
||||
scopes: ['type'],
|
||||
name: 'Wide letter spacing on body text',
|
||||
description:
|
||||
'Letter spacing above 0.05em on body text disrupts natural character groupings and slows reading. Reserve wide tracking for short uppercase labels only.',
|
||||
@@ -410,6 +431,7 @@ const ANTIPATTERNS = [
|
||||
{
|
||||
id: 'text-overflow',
|
||||
category: 'quality',
|
||||
scopes: ['layout'],
|
||||
name: 'Content overflowing its container',
|
||||
description:
|
||||
'Content renders wider than its container, spilling out or forcing a horizontal scrollbar. Let text wrap, constrain widths, or give the region a deliberate scroll affordance.',
|
||||
@@ -419,6 +441,7 @@ const ANTIPATTERNS = [
|
||||
{
|
||||
id: 'clipped-overflow-container',
|
||||
category: 'quality',
|
||||
scopes: ['layout'],
|
||||
name: 'Positioned child clipped by overflow container',
|
||||
description:
|
||||
'A clipping container (overflow hidden or clip) wrapping an absolutely-positioned child cuts off tooltips, menus, and popovers that need to escape. Let the overflow be visible, or move the positioned layer out of the clip.',
|
||||
@@ -428,6 +451,7 @@ const ANTIPATTERNS = [
|
||||
{
|
||||
id: 'design-system-font',
|
||||
category: 'quality',
|
||||
scopes: ['type'],
|
||||
name: 'Font outside DESIGN.md',
|
||||
description:
|
||||
'A font is used that is not declared in DESIGN.md typography. Use the documented type system or update DESIGN.md if this is an intentional brand addition.',
|
||||
@@ -454,6 +478,17 @@ const ANTIPATTERNS = [
|
||||
skillSection: 'Visual Details',
|
||||
skillGuideline: 'border radius outside the project design system',
|
||||
},
|
||||
{
|
||||
id: 'design-system-font-size',
|
||||
category: 'quality',
|
||||
severity: 'advisory',
|
||||
scopes: ['type'],
|
||||
name: 'Font size outside DESIGN.md',
|
||||
description:
|
||||
'A literal font-size is off the type ramp documented in DESIGN.md typography. Use a documented size step or update the design system if the new step is intentional.',
|
||||
skillSection: 'Typography',
|
||||
skillGuideline: 'font size outside the project design system',
|
||||
},
|
||||
|
||||
// ── Provider tells: opt-in via --gpt / --gemini (gated off by default) ──
|
||||
{
|
||||
@@ -628,6 +663,36 @@ function colorToHex(c) {
|
||||
return '#' + [c.r, c.g, c.b].map(v => v.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
|
||||
// --- cli/engine/shared/fonts.mjs ---
|
||||
const GOOGLE_FONTS_URL_RE = /fonts\.googleapis\.com\/css2?\?[^"'\s)<>]*/gi;
|
||||
|
||||
function normalizeGoogleFontFamilyParam(value) {
|
||||
return String(value || '')
|
||||
.split('|')
|
||||
.map(part => part.split(':')[0].trim().toLowerCase())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function extractGoogleFontFamilies(text) {
|
||||
const families = [];
|
||||
if (!text) return families;
|
||||
|
||||
GOOGLE_FONTS_URL_RE.lastIndex = 0;
|
||||
let urlMatch;
|
||||
while ((urlMatch = GOOGLE_FONTS_URL_RE.exec(text)) !== null) {
|
||||
const url = urlMatch[0];
|
||||
const queryStart = url.indexOf('?');
|
||||
if (queryStart === -1) continue;
|
||||
|
||||
const params = new URLSearchParams(url.slice(queryStart + 1).replace(/&/g, '&'));
|
||||
for (const value of params.getAll('family')) {
|
||||
families.push(...normalizeGoogleFontFamilyParam(value));
|
||||
}
|
||||
}
|
||||
|
||||
return families;
|
||||
}
|
||||
|
||||
// --- cli/engine/rules/checks.mjs ---
|
||||
const DETECTOR_IS_BROWSER = typeof window !== 'undefined';
|
||||
|
||||
@@ -2682,14 +2747,9 @@ function checkPageTypography(doc, win) {
|
||||
|
||||
// Check Google Fonts links in HTML
|
||||
const html = doc.documentElement?.outerHTML || '';
|
||||
const gfRe = /fonts\.googleapis\.com\/css2?\?family=([^&"'\s]+)/gi;
|
||||
let m;
|
||||
while ((m = gfRe.exec(html)) !== null) {
|
||||
const families = m[1].split('|').map(f => f.split(':')[0].replace(/\+/g, ' ').toLowerCase());
|
||||
for (const f of families) {
|
||||
fonts.add(f);
|
||||
if (OVERUSED_FONTS.has(f)) overusedFound.add(f);
|
||||
}
|
||||
for (const f of extractGoogleFontFamilies(html)) {
|
||||
fonts.add(f);
|
||||
if (OVERUSED_FONTS.has(f)) overusedFound.add(f);
|
||||
}
|
||||
|
||||
// Also parse raw HTML/style content for font-family (jsdom may not expose all via CSSOM)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { GENERIC_FONTS } from '../../shared/constants.mjs';
|
||||
import { GENERIC_FONTS, OVERUSED_FONTS } from '../../shared/constants.mjs';
|
||||
import { isNeutralColor } from '../../shared/color.mjs';
|
||||
import { extractGoogleFontFamilies } from '../../shared/fonts.mjs';
|
||||
import { checkSourceDesignSystem } from '../../design-system.mjs';
|
||||
import { isFullPage } from '../../shared/page.mjs';
|
||||
import { applyInlineIgnores } from '../../shared/inline-ignores.mjs';
|
||||
@@ -38,6 +39,10 @@ function shouldRunPageAnalyzers(content, filePath) {
|
||||
return !ext || PAGE_ANALYZER_EXTS.has(ext);
|
||||
}
|
||||
|
||||
function firstOverusedGoogleFont(text) {
|
||||
return extractGoogleFontFamilies(text).find(f => OVERUSED_FONTS.has(f)) || '';
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -88,9 +93,12 @@ const REGEX_MATCHERS = [
|
||||
{ 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, ' ')}` },
|
||||
{ id: 'overused-font', regex: /fonts\.googleapis\.com\/css2?\?[^"'\s)<>]*/gi,
|
||||
test: (m) => {
|
||||
m.overusedGoogleFont = firstOverusedGoogleFont(m[0]);
|
||||
return Boolean(m.overusedGoogleFont);
|
||||
},
|
||||
fmt: (m) => `Google Fonts: ${m.overusedGoogleFont || firstOverusedGoogleFont(m[0])}` },
|
||||
// --- 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),
|
||||
@@ -170,10 +178,7 @@ const REGEX_ANALYZERS = [
|
||||
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);
|
||||
}
|
||||
for (const f of extractGoogleFontFamilies(content)) fonts.add(f);
|
||||
if (fonts.size !== 1 || content.split('\n').length < 20) return [];
|
||||
const name = [...fonts][0];
|
||||
const lines = content.split('\n');
|
||||
|
||||
@@ -21,6 +21,7 @@ const ANTIPATTERNS = [
|
||||
{
|
||||
id: 'overused-font',
|
||||
category: 'slop',
|
||||
scopes: ['type'],
|
||||
name: 'Overused font',
|
||||
description:
|
||||
'Inter, Roboto, Fraunces, Geist, Plus Jakarta Sans, and Space Grotesk are used on so many sites they no longer feel distinctive. Each new wave of AI-generated UIs converges on the same handful of faces. Choose a face that gives your interface personality.',
|
||||
@@ -30,6 +31,7 @@ const ANTIPATTERNS = [
|
||||
{
|
||||
id: 'single-font',
|
||||
category: 'slop',
|
||||
scopes: ['type'],
|
||||
name: 'Single font for everything',
|
||||
description:
|
||||
'Only one font family is used for the entire page. Pair a distinctive display font with a refined body font to create typographic hierarchy.',
|
||||
@@ -39,6 +41,7 @@ const ANTIPATTERNS = [
|
||||
{
|
||||
id: 'flat-type-hierarchy',
|
||||
category: 'slop',
|
||||
scopes: ['type'],
|
||||
name: 'Flat type hierarchy',
|
||||
description:
|
||||
'Font sizes are too close together — no clear visual hierarchy. Use fewer sizes with more contrast (aim for at least a 1.25 ratio between steps).',
|
||||
@@ -75,6 +78,7 @@ const ANTIPATTERNS = [
|
||||
{
|
||||
id: 'nested-cards',
|
||||
category: 'slop',
|
||||
scopes: ['layout'],
|
||||
name: 'Nested cards',
|
||||
description:
|
||||
'Cards inside cards create visual noise and excessive depth. Flatten the hierarchy — use spacing, typography, and dividers instead of nesting containers.',
|
||||
@@ -84,6 +88,7 @@ const ANTIPATTERNS = [
|
||||
{
|
||||
id: 'monotonous-spacing',
|
||||
category: 'slop',
|
||||
scopes: ['layout'],
|
||||
name: 'Monotonous spacing',
|
||||
description:
|
||||
'The same spacing value used everywhere — no rhythm, no variation. Use tight groupings for related items and generous separations between sections.',
|
||||
@@ -111,6 +116,7 @@ const ANTIPATTERNS = [
|
||||
{
|
||||
id: 'icon-tile-stack',
|
||||
category: 'slop',
|
||||
scopes: ['layout'],
|
||||
name: 'Icon tile stacked above heading',
|
||||
description:
|
||||
'A small rounded-square icon container above a heading is the universal AI feature-card template — every generator outputs this exact shape. Try a side-by-side icon and heading, or let the icon sit in flow without its own container.',
|
||||
@@ -120,6 +126,7 @@ const ANTIPATTERNS = [
|
||||
{
|
||||
id: 'italic-serif-display',
|
||||
category: 'slop',
|
||||
scopes: ['type'],
|
||||
name: 'Italic serif display headline',
|
||||
description:
|
||||
'Oversized italic serif (Fraunces, Recoleta, Playfair, Newsreader-italic) as the primary hero headline reads as taste in isolation but has become the universal AI-startup landing page hero. Set roman, or move to a non-serif display face. Editorial / magazine register may legitimately want this — judge by context.',
|
||||
@@ -129,6 +136,7 @@ const ANTIPATTERNS = [
|
||||
{
|
||||
id: 'hero-eyebrow-chip',
|
||||
category: 'slop',
|
||||
scopes: ['type'],
|
||||
name: 'Hero eyebrow / pill chip',
|
||||
description:
|
||||
'A tiny uppercase letter-spaced label sitting immediately above an oversized hero headline — or the same shape rendered as a pill chip — is now the default AI SaaS hero. Drop the eyebrow, integrate the kicker into the headline, or run it as a navigation breadcrumb instead.',
|
||||
@@ -138,6 +146,7 @@ const ANTIPATTERNS = [
|
||||
{
|
||||
id: 'repeated-section-kickers',
|
||||
category: 'slop',
|
||||
scopes: ['type'],
|
||||
severity: 'advisory',
|
||||
name: 'Repeated section kicker labels',
|
||||
description:
|
||||
@@ -148,6 +157,7 @@ const ANTIPATTERNS = [
|
||||
{
|
||||
id: 'numbered-section-markers',
|
||||
category: 'slop',
|
||||
scopes: ['layout'],
|
||||
severity: 'advisory',
|
||||
name: 'Numbered section markers (01 / 02 / 03)',
|
||||
description:
|
||||
@@ -185,6 +195,7 @@ const ANTIPATTERNS = [
|
||||
{
|
||||
id: 'oversized-h1',
|
||||
category: 'slop',
|
||||
scopes: ['type'],
|
||||
name: 'Oversized hero headline',
|
||||
description:
|
||||
'A full-sentence headline set at display size ends up dominating the viewport, leaving no room for anything else above the fold. A punchy one- or two-word headline at that size is fine — the problem is a long headline blown up too large. Set long headlines smaller, or tighten the copy.',
|
||||
@@ -194,6 +205,7 @@ const ANTIPATTERNS = [
|
||||
{
|
||||
id: 'extreme-negative-tracking',
|
||||
category: 'slop',
|
||||
scopes: ['type'],
|
||||
name: 'Crushed letter spacing',
|
||||
description:
|
||||
'Letter-spacing pulled tighter than the point where characters keep their own shapes costs legibility. Tighten display type optically, not destructively.',
|
||||
@@ -239,6 +251,7 @@ const ANTIPATTERNS = [
|
||||
{
|
||||
id: 'line-length',
|
||||
category: 'quality',
|
||||
scopes: ['type', 'layout'],
|
||||
name: 'Line length too long',
|
||||
description:
|
||||
'Text lines wider than ~80 characters are hard to read. The eye loses its place tracking back to the start of the next line. Add a max-width (65ch to 75ch) to text containers.',
|
||||
@@ -248,6 +261,7 @@ const ANTIPATTERNS = [
|
||||
{
|
||||
id: 'cramped-padding',
|
||||
category: 'quality',
|
||||
scopes: ['layout'],
|
||||
name: 'Cramped padding',
|
||||
description:
|
||||
'Text is too close to the edge of its container. Two shapes: (1) an element with its own text where the padding is too low for the font size, and (2) a wrapper with text-bearing children and near-zero padding against a visible boundary (border, outline, or non-transparent background) — children land flush against the boundary line. Add at least 8px (ideally 12–16px) of padding inside bordered, outlined, or colored containers.',
|
||||
@@ -257,6 +271,7 @@ const ANTIPATTERNS = [
|
||||
{
|
||||
id: 'body-text-viewport-edge',
|
||||
category: 'quality',
|
||||
scopes: ['layout'],
|
||||
name: 'Body text touching viewport edge',
|
||||
description:
|
||||
'Body paragraphs render flush against the left or right viewport edge with no container providing horizontal padding. Wrap content in a container with at least 16px (ideally 24-32px) of horizontal padding, or apply max-width with mx-auto.',
|
||||
@@ -264,6 +279,7 @@ const ANTIPATTERNS = [
|
||||
{
|
||||
id: 'tight-leading',
|
||||
category: 'quality',
|
||||
scopes: ['type'],
|
||||
name: 'Tight line height',
|
||||
description:
|
||||
'Line height below 1.3x the font size makes multi-line text hard to read. Use 1.5 to 1.7 for body text so lines have room to breathe.',
|
||||
@@ -271,6 +287,7 @@ const ANTIPATTERNS = [
|
||||
{
|
||||
id: 'skipped-heading',
|
||||
category: 'quality',
|
||||
scopes: ['type'],
|
||||
name: 'Skipped heading level',
|
||||
description:
|
||||
'Heading levels should not skip (e.g. h1 then h3 with no h2). Screen readers use heading hierarchy for navigation. Skipping levels breaks the document outline.',
|
||||
@@ -278,6 +295,7 @@ const ANTIPATTERNS = [
|
||||
{
|
||||
id: 'justified-text',
|
||||
category: 'quality',
|
||||
scopes: ['type'],
|
||||
name: 'Justified text',
|
||||
description:
|
||||
'Justified text without hyphenation creates uneven word spacing ("rivers of white"). Use text-align: left for body text, or enable hyphens: auto if you must justify.',
|
||||
@@ -285,6 +303,7 @@ const ANTIPATTERNS = [
|
||||
{
|
||||
id: 'tiny-text',
|
||||
category: 'quality',
|
||||
scopes: ['type'],
|
||||
name: 'Tiny body text',
|
||||
description:
|
||||
'Body text below 12px is hard to read, especially on high-DPI screens. Use at least 14px for body content, 16px is ideal.',
|
||||
@@ -292,6 +311,7 @@ const ANTIPATTERNS = [
|
||||
{
|
||||
id: 'all-caps-body',
|
||||
category: 'quality',
|
||||
scopes: ['type'],
|
||||
name: 'All-caps body text',
|
||||
description:
|
||||
'Long passages in uppercase are hard to read. We recognize words by shape (ascenders and descenders), which all-caps removes. Reserve uppercase for short labels and headings.',
|
||||
@@ -301,6 +321,7 @@ const ANTIPATTERNS = [
|
||||
{
|
||||
id: 'wide-tracking',
|
||||
category: 'quality',
|
||||
scopes: ['type'],
|
||||
name: 'Wide letter spacing on body text',
|
||||
description:
|
||||
'Letter spacing above 0.05em on body text disrupts natural character groupings and slows reading. Reserve wide tracking for short uppercase labels only.',
|
||||
@@ -308,6 +329,7 @@ const ANTIPATTERNS = [
|
||||
{
|
||||
id: 'text-overflow',
|
||||
category: 'quality',
|
||||
scopes: ['layout'],
|
||||
name: 'Content overflowing its container',
|
||||
description:
|
||||
'Content renders wider than its container, spilling out or forcing a horizontal scrollbar. Let text wrap, constrain widths, or give the region a deliberate scroll affordance.',
|
||||
@@ -317,6 +339,7 @@ const ANTIPATTERNS = [
|
||||
{
|
||||
id: 'clipped-overflow-container',
|
||||
category: 'quality',
|
||||
scopes: ['layout'],
|
||||
name: 'Positioned child clipped by overflow container',
|
||||
description:
|
||||
'A clipping container (overflow hidden or clip) wrapping an absolutely-positioned child cuts off tooltips, menus, and popovers that need to escape. Let the overflow be visible, or move the positioned layer out of the clip.',
|
||||
@@ -326,6 +349,7 @@ const ANTIPATTERNS = [
|
||||
{
|
||||
id: 'design-system-font',
|
||||
category: 'quality',
|
||||
scopes: ['type'],
|
||||
name: 'Font outside DESIGN.md',
|
||||
description:
|
||||
'A font is used that is not declared in DESIGN.md typography. Use the documented type system or update DESIGN.md if this is an intentional brand addition.',
|
||||
@@ -352,6 +376,17 @@ const ANTIPATTERNS = [
|
||||
skillSection: 'Visual Details',
|
||||
skillGuideline: 'border radius outside the project design system',
|
||||
},
|
||||
{
|
||||
id: 'design-system-font-size',
|
||||
category: 'quality',
|
||||
severity: 'advisory',
|
||||
scopes: ['type'],
|
||||
name: 'Font size outside DESIGN.md',
|
||||
description:
|
||||
'A literal font-size is off the type ramp documented in DESIGN.md typography. Use a documented size step or update the design system if the new step is intentional.',
|
||||
skillSection: 'Typography',
|
||||
skillGuideline: 'font size outside the project design system',
|
||||
},
|
||||
|
||||
// ── Provider tells: opt-in via --gpt / --gemini (gated off by default) ──
|
||||
{
|
||||
@@ -448,12 +483,32 @@ function filterByProviders(findings, providers = []) {
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// Set of scope tags rules can declare (e.g. 'type', 'layout'). Used by the
|
||||
// CLI --scope flag to narrow output to one design domain.
|
||||
const RULE_SCOPES = new Set(
|
||||
ANTIPATTERNS.flatMap(rule => rule.scopes || []),
|
||||
);
|
||||
|
||||
// Keep only findings whose rule declares at least one of the requested
|
||||
// scopes. An empty scope list means no filtering (default CLI behavior).
|
||||
function filterByScopes(findings, scopes = []) {
|
||||
if (!scopes || scopes.length === 0) return findings;
|
||||
const enabled = new Set(scopes);
|
||||
return findings.filter(f => {
|
||||
const rule = getAntipattern(f.antipattern);
|
||||
return (rule?.scopes || []).some(scope => enabled.has(scope));
|
||||
});
|
||||
}
|
||||
|
||||
export {
|
||||
ANTIPATTERNS,
|
||||
RULE_SCOPES,
|
||||
RULE_ENGINE_SUPPORT,
|
||||
GATED_PROVIDERS,
|
||||
getAntipattern,
|
||||
getRulesForCategory,
|
||||
getRuleEngineSupport,
|
||||
filterByProviders,
|
||||
filterByScopes,
|
||||
};
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
parseRgb,
|
||||
relativeLuminance,
|
||||
} from '../shared/color.mjs';
|
||||
import { extractGoogleFontFamilies } from '../shared/fonts.mjs';
|
||||
|
||||
const DETECTOR_IS_BROWSER = typeof window !== 'undefined';
|
||||
|
||||
@@ -2072,14 +2073,9 @@ function checkPageTypography(doc, win) {
|
||||
|
||||
// Check Google Fonts links in HTML
|
||||
const html = doc.documentElement?.outerHTML || '';
|
||||
const gfRe = /fonts\.googleapis\.com\/css2?\?family=([^&"'\s]+)/gi;
|
||||
let m;
|
||||
while ((m = gfRe.exec(html)) !== null) {
|
||||
const families = m[1].split('|').map(f => f.split(':')[0].replace(/\+/g, ' ').toLowerCase());
|
||||
for (const f of families) {
|
||||
fonts.add(f);
|
||||
if (OVERUSED_FONTS.has(f)) overusedFound.add(f);
|
||||
}
|
||||
for (const f of extractGoogleFontFamilies(html)) {
|
||||
fonts.add(f);
|
||||
if (OVERUSED_FONTS.has(f)) overusedFound.add(f);
|
||||
}
|
||||
|
||||
// Also parse raw HTML/style content for font-family (jsdom may not expose all via CSSOM)
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
const GOOGLE_FONTS_URL_RE = /fonts\.googleapis\.com\/css2?\?[^"'\s)<>]*/gi;
|
||||
|
||||
function normalizeGoogleFontFamilyParam(value) {
|
||||
return String(value || '')
|
||||
.split('|')
|
||||
.map(part => part.split(':')[0].trim().toLowerCase())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function extractGoogleFontFamilies(text) {
|
||||
const families = [];
|
||||
if (!text) return families;
|
||||
|
||||
GOOGLE_FONTS_URL_RE.lastIndex = 0;
|
||||
let urlMatch;
|
||||
while ((urlMatch = GOOGLE_FONTS_URL_RE.exec(text)) !== null) {
|
||||
const url = urlMatch[0];
|
||||
const queryStart = url.indexOf('?');
|
||||
if (queryStart === -1) continue;
|
||||
|
||||
const params = new URLSearchParams(url.slice(queryStart + 1).replace(/&/g, '&'));
|
||||
for (const value of params.getAll('family')) {
|
||||
families.push(...normalizeGoogleFontFamilyParam(value));
|
||||
}
|
||||
}
|
||||
|
||||
return families;
|
||||
}
|
||||
|
||||
export { extractGoogleFontFamilies };
|
||||
@@ -1,6 +1,6 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* `/impeccable hooks <on|off|status|reset>` — manage the design hook runtime
|
||||
* The Impeccable hooks command manages the design hook runtime
|
||||
* via the `hook` key and shared detector ignores via the `detector` key in
|
||||
* .impeccable/config.json / .impeccable/config.local.json.
|
||||
*
|
||||
@@ -21,6 +21,7 @@
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { IMPECCABLE_COMMAND } from './lib/provider.mjs';
|
||||
|
||||
import {
|
||||
getConfigPath,
|
||||
@@ -184,7 +185,7 @@ function writeHookConfig(cwd, hookConfig, opts = {}) {
|
||||
const existing = existingRaw && typeof existingRaw === 'object' && !Array.isArray(existingRaw) ? existingRaw : {};
|
||||
const existingHook = stripDetectorKeys(hookSection(existing));
|
||||
// Merge over the existing hook object so fields the merge helpers don't manage
|
||||
// (consent, quiet, auditLog) survive a `/impeccable hooks` edit.
|
||||
// (consent, quiet, auditLog) survive an Impeccable hooks edit.
|
||||
const next = { ...existing, hook: { ...existingHook, ...hookConfig } };
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
fs.writeFileSync(filePath, JSON.stringify(next, null, 2) + '\n');
|
||||
@@ -513,9 +514,9 @@ function parseIgnoreRuleArgs(args) {
|
||||
function addIgnoreRule(cwd, args) {
|
||||
const parsed = parseIgnoreRuleArgs(args);
|
||||
const rule = parsed.rule;
|
||||
if (!rule) throw new Error('Pass a rule id, e.g. /impeccable hooks ignore-rule side-tab');
|
||||
if (!rule) throw new Error(`Pass a rule id, e.g. ${IMPECCABLE_COMMAND} hooks ignore-rule side-tab`);
|
||||
if (rule === 'overused-font' && !parsed.allValues) {
|
||||
throw new Error('overused-font is value-specific by default. Use /impeccable hooks ignore-value overused-font <font> for a confirmed font, or /impeccable hooks ignore-rule overused-font --all-values only when the user asked to ignore overused fonts generally.');
|
||||
throw new Error(`overused-font is value-specific by default. Use ${IMPECCABLE_COMMAND} hooks ignore-value overused-font <font> for a confirmed font, or ${IMPECCABLE_COMMAND} hooks ignore-rule overused-font --all-values only when the user asked to ignore overused fonts generally.`);
|
||||
}
|
||||
const config = mergeDetectorConfig(readRawDetectorConfig(cwd));
|
||||
if (!config.ignoreRules.includes(rule)) config.ignoreRules.push(rule);
|
||||
@@ -524,7 +525,7 @@ function addIgnoreRule(cwd, args) {
|
||||
}
|
||||
|
||||
function addIgnoreFile(cwd, glob) {
|
||||
if (!glob) throw new Error('Pass a glob, e.g. /impeccable hooks ignore-file "src/legacy/**"');
|
||||
if (!glob) throw new Error(`Pass a glob, e.g. ${IMPECCABLE_COMMAND} hooks ignore-file "src/legacy/**"`);
|
||||
const config = mergeDetectorConfig(readRawDetectorConfig(cwd));
|
||||
if (!config.ignoreFiles.includes(glob)) config.ignoreFiles.push(glob);
|
||||
writeDetectorConfig(cwd, config);
|
||||
@@ -569,7 +570,7 @@ function parseIgnoreValueArgs(args) {
|
||||
function addIgnoreValue(cwd, args) {
|
||||
const parsed = parseIgnoreValueArgs(args);
|
||||
if (!parsed.rule || !parsed.value) {
|
||||
throw new Error('Pass a rule id and value, e.g. /impeccable hooks ignore-value overused-font Inter');
|
||||
throw new Error(`Pass a rule id and value, e.g. ${IMPECCABLE_COMMAND} hooks ignore-value overused-font Inter`);
|
||||
}
|
||||
|
||||
if (parsed.shared && parsed.local) {
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import {
|
||||
@@ -21,13 +22,17 @@ import {
|
||||
appendDesignSystemNote,
|
||||
designSystemOptions,
|
||||
filterFindings,
|
||||
isNativePlatform,
|
||||
loadDetector,
|
||||
matchConfiguredExtension,
|
||||
matchesAnyGlob,
|
||||
persistCache,
|
||||
readCache,
|
||||
readConfig,
|
||||
renderTemplate,
|
||||
resolveCacheCwd,
|
||||
resolveProjectCwd,
|
||||
resolveProjectPlatform,
|
||||
truthy,
|
||||
writeAuditLog,
|
||||
} from './hook-lib.mjs';
|
||||
@@ -332,6 +337,22 @@ function isInsideProject(filePath, cwd) {
|
||||
}
|
||||
}
|
||||
|
||||
// The static HTML engine reads its input from disk, but preToolUse only has
|
||||
// the proposed content. Stage it in a temp file so html-engine targets get the
|
||||
// same DOM-structural rules pre-write that runHook applies post-edit.
|
||||
async function detectProposedHtml(detector, content, filePath, scanOptions) {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'impeccable-pre-'));
|
||||
const tmpFile = path.join(dir, path.basename(filePath));
|
||||
try {
|
||||
fs.writeFileSync(tmpFile, content);
|
||||
const findings = await detector.detectHtml(tmpFile, scanOptions);
|
||||
// Findings carry the temp path; remap so file-scoped ignores still match.
|
||||
return (findings || []).map((f) => (f && typeof f === 'object' ? { ...f, file: filePath } : f));
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
function cursorBlockMessage(findings, filePath, config, cwd) {
|
||||
const rendered = renderTemplate(findings, filePath, config, { cwd });
|
||||
const blocked = rendered.replace(
|
||||
@@ -379,9 +400,12 @@ async function main() {
|
||||
return allow({ skipped: 'stdin-empty' });
|
||||
}
|
||||
|
||||
const cwd = resolveProjectCwd(event);
|
||||
const sessionCwd = resolveProjectCwd(event);
|
||||
const started = Date.now();
|
||||
const filePath = proposedFilePath(event, cwd);
|
||||
const filePath = proposedFilePath(event, sessionCwd);
|
||||
// Re-key config/cache to the edited file's project root when the session
|
||||
// was launched from a non-project umbrella directory (issue #305).
|
||||
const cwd = resolveCacheCwd(filePath, sessionCwd);
|
||||
const audit = {
|
||||
harness: 'cursor',
|
||||
cwd,
|
||||
@@ -394,9 +418,13 @@ async function main() {
|
||||
if (SENSITIVE_PATH.test(filePath)) return allow({ ...audit, skipped: 'sensitive', durationMs: Date.now() - started });
|
||||
if (GENERATED_PATH.test(filePath)) return allow({ ...audit, skipped: 'generated', durationMs: Date.now() - started });
|
||||
|
||||
// Config is read before the extension gate so `detector.extensions` entries
|
||||
// (e.g. `.blade.php` template files, issue #316) can widen it.
|
||||
const config = readConfig(cwd);
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
audit.ext = ext;
|
||||
if (!ALLOWED_EXTS.has(ext)) return allow({ ...audit, skipped: 'extension', durationMs: Date.now() - started });
|
||||
const configuredExt = matchConfiguredExtension(filePath, config.extensions);
|
||||
audit.ext = configuredExt ? configuredExt.ext : ext;
|
||||
if (!ALLOWED_EXTS.has(ext) && !configuredExt) return allow({ ...audit, skipped: 'extension', durationMs: Date.now() - started });
|
||||
|
||||
const contentResult = proposedContent(event, cwd, filePath);
|
||||
if (contentResult && typeof contentResult === 'object' && contentResult.skipped) {
|
||||
@@ -405,9 +433,14 @@ async function main() {
|
||||
const content = typeof contentResult === 'string' ? contentResult : '';
|
||||
if (!content) return allow({ ...audit, skipped: 'no-proposed-content', durationMs: Date.now() - started });
|
||||
|
||||
const config = readConfig(cwd);
|
||||
if (config.enabled === false) return allow({ ...audit, skipped: 'config-disabled', durationMs: Date.now() - started });
|
||||
|
||||
// Web rule engine, native project: stand aside (see resolveProjectPlatform).
|
||||
const platform = resolveProjectPlatform(cwd);
|
||||
if (isNativePlatform(platform)) {
|
||||
return allow({ ...audit, skipped: 'native-platform', platform, durationMs: Date.now() - started });
|
||||
}
|
||||
|
||||
const rel = relativePath(filePath, cwd);
|
||||
if (matchesAnyGlob(rel, config.ignoreFiles) || matchesAnyGlob(filePath, config.ignoreFiles)) {
|
||||
return allow({ ...audit, skipped: 'config-ignore-file', durationMs: Date.now() - started });
|
||||
@@ -419,9 +452,16 @@ async function main() {
|
||||
}
|
||||
const scanOptions = designSystemOptions(config, detector, cwd);
|
||||
|
||||
// Mirror runHook's engine routing so template issues the HTML engine catches
|
||||
// post-edit cannot slip past the pre-write gate.
|
||||
const useHtmlEngine = configuredExt
|
||||
? configuredExt.engine === 'html'
|
||||
: (ext === '.html' || ext === '.htm');
|
||||
let findings = [];
|
||||
try {
|
||||
findings = await detector.detectText(content, filePath, scanOptions);
|
||||
findings = useHtmlEngine && typeof detector.detectHtml === 'function'
|
||||
? await detectProposedHtml(detector, content, filePath, scanOptions)
|
||||
: await detector.detectText(content, filePath, scanOptions);
|
||||
} catch {
|
||||
return allow({ ...audit, error: 'detector-threw', durationMs: Date.now() - started });
|
||||
}
|
||||
|
||||
@@ -9,15 +9,17 @@
|
||||
* ENVELOPE_PREFIX, ALLOWED_EXTS, ACK_EXTS, SENSITIVE_PATH, GENERATED_PATH, TRUTHY
|
||||
* truthy(value)
|
||||
* readConfig(cwd) / DEFAULT_CONFIG / getConfigPath(cwd) / getLocalConfigPath(cwd)
|
||||
* resolveProjectPlatform(cwd) / isNativePlatform(platform)
|
||||
* normalizeIgnoreValue(value)
|
||||
* readCache(cwd) / persistCache(cwd, cache)
|
||||
* readCache(cwd) / persistCache(cwd, cache) / resolveCacheCwd(primaryFile, sessionCwd)
|
||||
* bumpEditCount(cache, sessionId, filePath) -> number
|
||||
* suppressionNotice(filePath)
|
||||
* filterFindings(findings, content, ext, config)
|
||||
* matchConfiguredExtension(filePath, extensions)
|
||||
* dedupeAgainstCache(findings, cache, sessionId, filePath)
|
||||
* renderTemplate(findings, filePath, config, opts)
|
||||
* renderCleanAck(filePath, opts) / renderPendingAck(filePath, known, opts)
|
||||
* shouldEmitAckForFile(filePath)
|
||||
* shouldEmitAckForFile(filePath, config?)
|
||||
* writeAuditLog(env, entry)
|
||||
* loadDetector() -> Promise<{ detectText, detectHtml }>
|
||||
* matchesAnyGlob(filePath, globs)
|
||||
@@ -35,8 +37,11 @@
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { pathToFileURL, fileURLToPath } from 'node:url';
|
||||
import { extractPlatform, loadContext } from './context.mjs';
|
||||
import { IMPECCABLE_COMMAND } from './lib/provider.mjs';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
@@ -77,6 +82,7 @@ export const DEFAULT_CONFIG = Object.freeze({
|
||||
ignoreRules: [],
|
||||
ignoreFiles: [],
|
||||
ignoreValues: [],
|
||||
extensions: [],
|
||||
limits: { maxFindings: 5, maxChars: 8000 },
|
||||
});
|
||||
|
||||
@@ -134,6 +140,59 @@ export function resolveProjectCwd(event, fallback = process.cwd()) {
|
||||
|| fallback;
|
||||
}
|
||||
|
||||
function looksLikeProjectRoot(dir) {
|
||||
return ['.git', 'package.json', '.impeccable'].some((marker) => {
|
||||
try { return fs.existsSync(path.join(dir, marker)); } catch { return false; }
|
||||
});
|
||||
}
|
||||
|
||||
// Where `.impeccable/` (cache + config) lives for this event. Normally the
|
||||
// session cwd, untouched. But when the agent was launched from an umbrella
|
||||
// directory that is not itself a project (no .git, package.json, or
|
||||
// .impeccable), key to the edited file's nearest project root instead, so a
|
||||
// multi-project launch dir doesn't accumulate a shared cross-project cache
|
||||
// (issue #305). Climbing stops at the home dir, falling back to the session
|
||||
// cwd when no marker is found.
|
||||
export function resolveCacheCwd(primaryFile, sessionCwd) {
|
||||
const base = path.resolve(sessionCwd || process.cwd());
|
||||
if (!primaryFile || typeof primaryFile !== 'string' || hasPathTraversal(primaryFile)) return base;
|
||||
if (looksLikeProjectRoot(base)) return base;
|
||||
let dir;
|
||||
try {
|
||||
dir = path.dirname(path.resolve(primaryFile));
|
||||
} catch {
|
||||
return base;
|
||||
}
|
||||
const home = path.resolve(os.homedir());
|
||||
while (true) {
|
||||
if (dir === home) return base;
|
||||
if (looksLikeProjectRoot(dir)) return dir;
|
||||
const parent = path.dirname(dir);
|
||||
if (parent === dir) return base;
|
||||
dir = parent;
|
||||
}
|
||||
}
|
||||
|
||||
// The detector's rules are web rules (HTML/CSS shapes), but a React Native or
|
||||
// Flutter project is made of the exact extensions the hook watches (.tsx, .ts,
|
||||
// .js), so without this gate every native screen edit would draw web-shaped
|
||||
// findings that contradict the native platform references. PRODUCT.md's
|
||||
// `## Platform` field decides: `ios` / `android` / `adaptive` projects skip
|
||||
// the scan entirely. Resolution goes through loadContext so the hook reads the
|
||||
// same PRODUCT.md the skill does (alternate context dirs, monorepo fallback).
|
||||
export function resolveProjectPlatform(cwd) {
|
||||
try {
|
||||
const ctx = loadContext(cwd);
|
||||
return extractPlatform(ctx && ctx.product);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function isNativePlatform(platform) {
|
||||
return platform === 'ios' || platform === 'android' || platform === 'adaptive';
|
||||
}
|
||||
|
||||
export function readConfig(cwd) {
|
||||
const config = cloneDefaultConfig();
|
||||
// Hook runtime settings live under `hook`; detector filters live under
|
||||
@@ -168,6 +227,7 @@ function cloneDefaultConfig() {
|
||||
ignoreRules: [],
|
||||
ignoreFiles: [],
|
||||
ignoreValues: [],
|
||||
extensions: [],
|
||||
designSystem: { ...DEFAULT_CONFIG.designSystem },
|
||||
limits: { ...DEFAULT_CONFIG.limits },
|
||||
};
|
||||
@@ -190,9 +250,55 @@ function applyDetectorConfigSource(config, raw) {
|
||||
if (Array.isArray(raw.ignoreValues)) {
|
||||
config.ignoreValues = mergeIgnoreValues(config.ignoreValues, raw.ignoreValues);
|
||||
}
|
||||
if (Array.isArray(raw.extensions)) {
|
||||
config.extensions = mergeExtensions(config.extensions, raw.extensions);
|
||||
}
|
||||
return config;
|
||||
}
|
||||
|
||||
// Extra scanned extensions from `detector.extensions` config. Entries are
|
||||
// `{ ext, engine }` (engine 'html' | 'text', default 'html' — the common case
|
||||
// for server-side templates) or bare strings as shorthand. Extensions are
|
||||
// matched against the end of the filename, not path.extname, so double
|
||||
// extensions like `.blade.php` and `.html.erb` work (issue #316).
|
||||
function normalizeExtensionEntries(entries) {
|
||||
if (!Array.isArray(entries)) return [];
|
||||
const out = [];
|
||||
for (const entry of entries) {
|
||||
const raw = typeof entry === 'string' ? entry : entry?.ext;
|
||||
if (typeof raw !== 'string') continue;
|
||||
let ext = raw.trim().toLowerCase();
|
||||
if (!ext) continue;
|
||||
if (!ext.startsWith('.')) ext = `.${ext}`;
|
||||
const engine = (!(typeof entry === 'string') && entry?.engine === 'text') ? 'text' : 'html';
|
||||
out.push({ ext, engine });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function mergeExtensions(existing, incoming) {
|
||||
const map = new Map();
|
||||
for (const entry of normalizeExtensionEntries(existing)) map.set(entry.ext, entry);
|
||||
for (const entry of normalizeExtensionEntries(incoming)) map.set(entry.ext, entry);
|
||||
return Array.from(map.values());
|
||||
}
|
||||
|
||||
export function matchConfiguredExtension(filePath, extensions) {
|
||||
if (!Array.isArray(extensions) || extensions.length === 0) return null;
|
||||
const name = path.basename(String(filePath || '')).toLowerCase();
|
||||
if (!name) return null;
|
||||
// The longest matching suffix wins, so `.blade.php` beats a broader `.php`
|
||||
// entry regardless of config order.
|
||||
let best = null;
|
||||
for (const entry of normalizeExtensionEntries(extensions)) {
|
||||
if (name.length > entry.ext.length && name.endsWith(entry.ext)
|
||||
&& (!best || entry.ext.length > best.ext.length)) {
|
||||
best = entry;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
function applyConfigSource(config, raw) {
|
||||
if (!raw || typeof raw !== 'object') return config;
|
||||
if (Object.prototype.hasOwnProperty.call(raw, 'enabled')) {
|
||||
@@ -556,7 +662,7 @@ export function bumpEditCount(cache, sessionId, filePath) {
|
||||
}
|
||||
|
||||
export function suppressionNotice(filePath) {
|
||||
return `${ENVELOPE_PREFIX} Suppressing further design hints on ${filePath}. More than ${EDIT_COUNT_THRESHOLD} edits in this session reached. Run /impeccable audit to revisit.`;
|
||||
return `${ENVELOPE_PREFIX} Suppressing further design hints on ${filePath}. More than ${EDIT_COUNT_THRESHOLD} edits in this session reached. Run ${IMPECCABLE_COMMAND} audit to revisit.`;
|
||||
}
|
||||
|
||||
// Glob → RegExp. Supports `**`, `*`, `?`, and `{a,b}` alternation.
|
||||
@@ -628,11 +734,13 @@ export function filterFindings(findings, _content, _ext, config) {
|
||||
function isIgnoredFindingValue(finding, ignoreValues) {
|
||||
if (!Array.isArray(ignoreValues) || ignoreValues.length === 0) return false;
|
||||
const rule = normalizeIgnoreRule(finding.antipattern);
|
||||
if (!rule) return false;
|
||||
// File-scoped wildcards suppress rules with no extractable value, such as side-tab.
|
||||
const value = extractFindingIgnoreValue(finding);
|
||||
if (!rule || !value) return false;
|
||||
return ignoreValues.some((entry) => {
|
||||
if (entry.rule !== rule) return false;
|
||||
const wildcardValue = entry.value === '*';
|
||||
if (entry.rule !== rule || (!wildcardValue && !ignoreValueMatches(rule, entry.value, value))) return false;
|
||||
if (!wildcardValue && (!value || !ignoreValueMatches(rule, entry.value, value))) return false;
|
||||
if (!Array.isArray(entry.files) || entry.files.length === 0) return !wildcardValue;
|
||||
return findingMatchesScopedIgnoreFile(finding, entry.files);
|
||||
});
|
||||
@@ -770,7 +878,7 @@ export function renderTemplate(findings, filePath, config, opts = {}) {
|
||||
const header = `${ENVELOPE_PREFIX} Design hook findings requiring review in ${display} (${total} issue(s)):`;
|
||||
const lines = shown.map((f) => formatFindingLine(f));
|
||||
const more = remaining > 0
|
||||
? `... and ${remaining} more (see /impeccable audit).`
|
||||
? `... and ${remaining} more (see ${IMPECCABLE_COMMAND} audit).`
|
||||
: null;
|
||||
const footer = directiveFooter(display);
|
||||
|
||||
@@ -814,7 +922,7 @@ function renderGroupedTemplate(groups, config, opts = {}) {
|
||||
shownCount += shown.length;
|
||||
const hidden = group.findings.length - shown.length;
|
||||
if (hidden > 0) {
|
||||
lines.push(`- ... ${hidden} more in ${display} (see /impeccable audit).`);
|
||||
lines.push(`- ... ${hidden} more in ${display} (see ${IMPECCABLE_COMMAND} audit).`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -830,7 +938,7 @@ function clampGroupedToBudget(header, lines, footer, maxChars) {
|
||||
const assemble = (linesArr, omitted) => [
|
||||
header,
|
||||
...linesArr,
|
||||
...(omitted ? ['... and more (see /impeccable audit).'] : []),
|
||||
...(omitted ? [`... and more (see ${IMPECCABLE_COMMAND} audit).`] : []),
|
||||
'',
|
||||
footer,
|
||||
].join('\n');
|
||||
@@ -863,7 +971,7 @@ function clampToBudget(header, lines, more, footer, maxChars) {
|
||||
let assembled = assemble(working, moreText);
|
||||
while (assembled.length > maxChars && working.length > 1) {
|
||||
working.pop();
|
||||
moreText = '... and more (see /impeccable audit).';
|
||||
moreText = `... and more (see ${IMPECCABLE_COMMAND} audit).`;
|
||||
assembled = assemble(working, moreText);
|
||||
}
|
||||
if (assembled.length > maxChars) {
|
||||
@@ -895,7 +1003,7 @@ function formatFindingIgnoreCommand(finding) {
|
||||
const value = extractFindingIgnoreValueRaw(finding);
|
||||
const valueArg = quoteCommandArg(value);
|
||||
const reason = quoteCommandArg(`User confirmed ${value} is intentional`);
|
||||
return `/impeccable hooks ignore-value ${rule} ${valueArg} --shared --reason ${reason}`;
|
||||
return `${IMPECCABLE_COMMAND} hooks ignore-value ${rule} ${valueArg} --shared --reason ${reason}`;
|
||||
}
|
||||
|
||||
function quoteCommandArg(value) {
|
||||
@@ -1319,8 +1427,12 @@ export function renderPendingAck(filePath, knownFindings, opts = {}) {
|
||||
return `${ENVELOPE_PREFIX} Design hook scanned ${display}. Still has ${count} finding(s) flagged earlier this session (${sample}${more}). Handle them before finalizing — the previous reminder still applies.`;
|
||||
}
|
||||
|
||||
export function shouldEmitAckForFile(filePath) {
|
||||
return ACK_EXTS.has(path.extname(String(filePath || '')).toLowerCase());
|
||||
export function shouldEmitAckForFile(filePath, config = null) {
|
||||
if (ACK_EXTS.has(path.extname(String(filePath || '')).toLowerCase())) return true;
|
||||
// Configured html-engine extensions are declared UI markup, so they get the
|
||||
// clean/pending acks; text-engine ones stay quiet like plain .ts/.js.
|
||||
const configured = matchConfiguredExtension(filePath, config?.extensions);
|
||||
return Boolean(configured && configured.engine === 'html');
|
||||
}
|
||||
|
||||
export function designSystemOptions(config, detector, projectCwd) {
|
||||
@@ -1336,7 +1448,7 @@ export function designSystemOptions(config, detector, projectCwd) {
|
||||
|
||||
export function appendDesignSystemNote(text, scanOptions) {
|
||||
if (!text || !scanOptions?.designSystem?.mdNewerThanJson) return text;
|
||||
return `${text}\n\n${ENVELOPE_PREFIX} DESIGN.md is newer than .impeccable/design.json. Run /impeccable document to refresh the design-system sidecar.`;
|
||||
return `${text}\n\n${ENVELOPE_PREFIX} DESIGN.md is newer than .impeccable/design.json. Run ${IMPECCABLE_COMMAND} document to refresh the design-system sidecar.`;
|
||||
}
|
||||
|
||||
// The directive footer is the part of the hook output that steers model
|
||||
@@ -1353,16 +1465,16 @@ export function appendDesignSystemNote(text, scanOptions) {
|
||||
// raw envelope. Asking the model to surface the resolution in its
|
||||
// reply is the cheapest way to make the feedback loop visible.
|
||||
function directiveFooter(display, opts = {}) {
|
||||
const ignoreFileCommand = `/impeccable hooks ignore-file ${quoteCommandArg(display)}`;
|
||||
const ignoreFileCommand = `${IMPECCABLE_COMMAND} hooks ignore-file ${quoteCommandArg(display)}`;
|
||||
const fileIgnoreGuidance = opts.grouped
|
||||
? 'run `/impeccable hooks ignore-file <path>` for the specific file'
|
||||
? `run \`${IMPECCABLE_COMMAND} hooks ignore-file <path>\` for the specific file`
|
||||
: `run \`${ignoreFileCommand}\``;
|
||||
return [
|
||||
'Handle these before finalizing: fix findings that are real design problems, or explicitly classify contextually intentional findings as false positives. Acknowledge what you changed or why you are leaving a finding unchanged.',
|
||||
'',
|
||||
'Use context judgment before editing. A finding is not automatically a defect; literal or domain-appropriate motion, intentional demos or fixtures, documentation of bad design, and user-confirmed choices can be valid as-is.',
|
||||
'',
|
||||
`Do not change intentional design just to satisfy the hook, and do not silence a real finding with an inline ignore comment to skip fixing it. Suppress a finding only after the user explicitly confirms it is intentional. Prefer a config ignore (one reviewable place, the commands below); reach for an inline \`impeccable-disable <rule>\` comment only when the waiver must travel with a file that leaves the repo, such as an exported or standalone document. Prefer the narrowest persisted exception: run the exact \`/impeccable hooks ignore-value ... --shared\` command shown next to a value-specific finding. For \`overused-font\`, use \`ignore-value\` for a specific font and use \`/impeccable hooks ignore-rule overused-font --all-values\` only when the user asks to ignore overused fonts generally. For file-specific findings without an ignore-value command, ${fileIgnoreGuidance}; use \`/impeccable hooks ignore-rule <id>\` only when the user asks to suppress the whole non-value-specific rule. Run /impeccable audit for the full pass.`,
|
||||
`Do not change intentional design just to satisfy the hook, and do not silence a real finding with an inline ignore comment to skip fixing it. Suppress a finding only after the user explicitly confirms it is intentional. Prefer a config ignore (one reviewable place, the commands below); reach for an inline \`impeccable-disable <rule>\` comment only when the waiver must travel with a file that leaves the repo, such as an exported or standalone document. Prefer the narrowest persisted exception: run the exact \`${IMPECCABLE_COMMAND} hooks ignore-value ... --shared\` command shown next to a value-specific finding. For \`overused-font\`, use \`ignore-value\` for a specific font and use \`${IMPECCABLE_COMMAND} hooks ignore-rule overused-font --all-values\` only when the user asks to ignore overused fonts generally. For file-specific findings without an ignore-value command, ${fileIgnoreGuidance}; use \`${IMPECCABLE_COMMAND} hooks ignore-rule <id>\` only when the user asks to suppress the whole non-value-specific rule. Run ${IMPECCABLE_COMMAND} audit for the full pass.`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
@@ -1402,9 +1514,10 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now =
|
||||
event = normalizeHookEvent(event, cwd, harness);
|
||||
audit.harness = harness;
|
||||
|
||||
const projectCwd = event.cwd || cwd;
|
||||
const sessionCwd = event.cwd || cwd;
|
||||
const primaryFiles = normalizeScanTargets(resolveTargetFiles(event, sessionCwd), sessionCwd);
|
||||
const projectCwd = resolveCacheCwd(primaryFiles[0], sessionCwd);
|
||||
audit.cwd = projectCwd;
|
||||
const primaryFiles = normalizeScanTargets(resolveTargetFiles(event, projectCwd), projectCwd);
|
||||
const primaryFileSet = new Set(primaryFiles);
|
||||
const targetFiles = expandScanTargets(primaryFiles, projectCwd);
|
||||
audit.session = event.session_id || null;
|
||||
@@ -1419,11 +1532,16 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now =
|
||||
return result({ skipped: 'config-disabled', durationMs: Date.now() - started });
|
||||
}
|
||||
|
||||
const platform = resolveProjectPlatform(projectCwd);
|
||||
if (isNativePlatform(platform)) {
|
||||
return result({ skipped: 'native-platform', platform, durationMs: Date.now() - started });
|
||||
}
|
||||
|
||||
const cache = readCache(projectCwd);
|
||||
const sessionId = event.session_id || 'unknown';
|
||||
const det = detector || await loadDetector();
|
||||
if (!det || typeof det.detectText !== 'function') {
|
||||
persistCache(projectCwd, cache);
|
||||
// Cache is not mutated yet at this point; nothing to persist.
|
||||
return result({ skipped: 'detector-missing', durationMs: Date.now() - started });
|
||||
}
|
||||
const scanOptions = designSystemOptions(config, det, projectCwd);
|
||||
@@ -1435,6 +1553,7 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now =
|
||||
let detectorThrewAny = false;
|
||||
let lastSkip = 'no-scannable-file';
|
||||
let suppressedHit = false;
|
||||
let cacheDirty = false;
|
||||
|
||||
for (const filePath of targetFiles) {
|
||||
audit.file = filePath;
|
||||
@@ -1449,8 +1568,9 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now =
|
||||
}
|
||||
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
audit.ext = ext;
|
||||
if (!ALLOWED_EXTS.has(ext)) {
|
||||
const configuredExt = matchConfiguredExtension(filePath, config.extensions);
|
||||
audit.ext = configuredExt ? configuredExt.ext : ext;
|
||||
if (!ALLOWED_EXTS.has(ext) && !configuredExt) {
|
||||
lastSkip = 'extension';
|
||||
continue;
|
||||
}
|
||||
@@ -1467,6 +1587,7 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now =
|
||||
|
||||
if (primaryFileSet.has(filePath)) {
|
||||
const editCount = bumpEditCount(cache, sessionId, filePath);
|
||||
cacheDirty = true;
|
||||
audit.editCount = editCount;
|
||||
|
||||
if (editCount > EDIT_COUNT_THRESHOLD) {
|
||||
@@ -1483,7 +1604,10 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now =
|
||||
const content = fs.readFileSync(filePath, 'utf-8');
|
||||
let findings;
|
||||
let detectorThrew = false;
|
||||
if ((ext === '.html' || ext === '.htm') && typeof det.detectHtml === 'function') {
|
||||
const useHtmlEngine = configuredExt
|
||||
? configuredExt.engine === 'html'
|
||||
: (ext === '.html' || ext === '.htm');
|
||||
if (useHtmlEngine && typeof det.detectHtml === 'function') {
|
||||
try { findings = await det.detectHtml(filePath, scanOptions); } catch { findings = []; detectorThrew = true; }
|
||||
} else {
|
||||
try { findings = await det.detectText(content, filePath, scanOptions); } catch { findings = []; detectorThrew = true; }
|
||||
@@ -1496,6 +1620,7 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now =
|
||||
|
||||
if (fresh.length > 0) {
|
||||
rememberFindings(cache, sessionId, filePath, fresh);
|
||||
cacheDirty = true;
|
||||
freshGroups.push({ filePath, findings: fresh });
|
||||
continue;
|
||||
}
|
||||
@@ -1513,7 +1638,15 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now =
|
||||
}
|
||||
}
|
||||
|
||||
persistCache(projectCwd, cache);
|
||||
// Persist only when the write is earned: fresh findings justify creating
|
||||
// `.impeccable/` (dedup and suppression need it), and an already-present
|
||||
// `.impeccable/` dir marks a project that opted in. A non-UI edit, or a
|
||||
// clean UI edit in a project with no Impeccable footprint, must be a
|
||||
// no-op on disk (issues #344, #305).
|
||||
if (freshGroups.length > 0
|
||||
|| (cacheDirty && fs.existsSync(path.join(projectCwd, '.impeccable')))) {
|
||||
persistCache(projectCwd, cache);
|
||||
}
|
||||
|
||||
if (freshGroups.length > 0) {
|
||||
const firstGroup = freshGroups[0];
|
||||
@@ -1548,7 +1681,7 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now =
|
||||
return result({ emitted: false, quiet: true, durationMs: Date.now() - started });
|
||||
}
|
||||
|
||||
if (pendingWinner && shouldEmitAckForFile(pendingWinner.filePath)) {
|
||||
if (pendingWinner && shouldEmitAckForFile(pendingWinner.filePath, config)) {
|
||||
const text = appendDesignSystemNote(renderPendingAck(pendingWinner.filePath, pendingWinner.known, { cwd: projectCwd }), scanOptions);
|
||||
return {
|
||||
exitCode: 0,
|
||||
@@ -1582,7 +1715,7 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now =
|
||||
};
|
||||
}
|
||||
|
||||
if (cleanWinner && shouldEmitAckForFile(cleanWinner.filePath)) {
|
||||
if (cleanWinner && shouldEmitAckForFile(cleanWinner.filePath, config)) {
|
||||
const text = appendDesignSystemNote(renderCleanAck(cleanWinner.filePath, { cwd: projectCwd }), scanOptions);
|
||||
return {
|
||||
exitCode: 0,
|
||||
|
||||
@@ -459,11 +459,13 @@ export function filterDetectionFindings(findings, config) {
|
||||
function isIgnoredFindingValue(finding, ignoreValues) {
|
||||
if (!Array.isArray(ignoreValues) || ignoreValues.length === 0) return false;
|
||||
const rule = normalizeIgnoreRule(finding.antipattern);
|
||||
if (!rule) return false;
|
||||
// File-scoped wildcards suppress rules with no extractable value, such as side-tab.
|
||||
const value = extractFindingIgnoreValue(finding);
|
||||
if (!rule || !value) return false;
|
||||
return ignoreValues.some((entry) => {
|
||||
if (entry.rule !== rule) return false;
|
||||
const wildcardValue = entry.value === '*';
|
||||
if (entry.rule !== rule || (!wildcardValue && !ignoreValueMatches(rule, entry.value, value))) return false;
|
||||
if (!wildcardValue && (!value || !ignoreValueMatches(rule, entry.value, value))) return false;
|
||||
if (!Array.isArray(entry.files) || entry.files.length === 0) return !wildcardValue;
|
||||
return findingMatchesScopedIgnoreFile(finding, entry.files);
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { resolveProjectRoot } from '../context.mjs';
|
||||
export { IMPECCABLE_COMMAND_PREFIX } from './provider.mjs';
|
||||
|
||||
export const IMPECCABLE_DIR = '.impeccable';
|
||||
export const LIVE_DIR = 'live';
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
// Source scripts default to slash commands. The provider build replaces only
|
||||
// this exact declaration, avoiding heuristic rewrites across executable code.
|
||||
export const IMPECCABLE_COMMAND_PREFIX = "$";
|
||||
export const IMPECCABLE_COMMAND = `${IMPECCABLE_COMMAND_PREFIX}impeccable`;
|
||||
@@ -57,6 +57,7 @@
|
||||
const Z = { highlight: 100001, bar: 100005, picker: 100007, toast: 100010 };
|
||||
const EASE = 'cubic-bezier(0.22, 1, 0.36, 1)'; // ease-out-quint
|
||||
const PREFIX = 'impeccable-live';
|
||||
const IMPECCABLE_COMMAND = (window.__IMPECCABLE_COMMAND_PREFIX__ || '/') + 'impeccable';
|
||||
const PICK_CURSOR_STYLE_ID = PREFIX + '-pick-cursor-style';
|
||||
const MANUAL_APPLY_STATE_TTL_MS = 15 * 60 * 1000;
|
||||
const sessionState = window.__IMPECCABLE_LIVE_SESSION__?.createLiveBrowserSessionState({
|
||||
@@ -153,6 +154,7 @@
|
||||
let scrollLockRaf = null;
|
||||
let scrollLockAbort = null;
|
||||
const SCROLL_ANCHOR_LOCK_ID = 'impeccable-scroll-anchor-lock';
|
||||
const VARIANT_STATE_STYLE_ID = 'impeccable-variant-state';
|
||||
|
||||
// Dedicated key for scroll position - SEPARATE from LS_KEY so that
|
||||
// saveSession's state updates don't clobber a carefully-captured scrollY.
|
||||
@@ -3035,16 +3037,26 @@
|
||||
function applyParamValue(variantEl, param, value) {
|
||||
if (!variantEl) return;
|
||||
const attr = 'data-p-' + param.id;
|
||||
if (param.kind === 'range') {
|
||||
variantEl.style.setProperty('--p-' + param.id, String(value));
|
||||
} else if (param.kind === 'toggle') {
|
||||
if (param.kind === 'toggle') {
|
||||
const on = !!value;
|
||||
variantEl.style.setProperty('--p-' + param.id, on ? '1' : '0');
|
||||
if (on) variantEl.setAttribute(attr, 'on');
|
||||
else variantEl.removeAttribute(attr);
|
||||
} else if (param.kind === 'steps') {
|
||||
variantEl.setAttribute(attr, String(value));
|
||||
}
|
||||
// Svelte component variants are client-mounted into
|
||||
// [data-impeccable-component-mount] with no [data-impeccable-variant="N"]
|
||||
// wrapper for the state stylesheet to target, and the element is not SSR'd,
|
||||
// so there is no React hydration to mismatch. Drive range/toggle --p-* inline
|
||||
// on the mounted element so scoped preview CSS resolves them.
|
||||
if (svelteComponentSession?.sessionId === currentSessionId) {
|
||||
if (param.kind === 'range') variantEl.style.setProperty('--p-' + param.id, String(value));
|
||||
else if (param.kind === 'toggle') variantEl.style.setProperty('--p-' + param.id, value ? '1' : '0');
|
||||
return;
|
||||
}
|
||||
// range/toggle --p-* custom properties are driven through the injected
|
||||
// variant-state stylesheet so we never mutate inline style on SSR'd divs.
|
||||
updateVariantStateStylesheet(currentSessionId, visibleVariant);
|
||||
}
|
||||
|
||||
function applyParamDefaults(variantEl, params) {
|
||||
@@ -4714,6 +4726,7 @@
|
||||
paramsCurrentValues = {};
|
||||
tuneOpen = false;
|
||||
hideParamsPanel();
|
||||
if (currentSessionId && visibleVariant) updateVariantStateStylesheet(currentSessionId, visibleVariant);
|
||||
return;
|
||||
}
|
||||
applyParamDefaults(variantEl, params);
|
||||
@@ -4771,20 +4784,7 @@
|
||||
|
||||
function isVariantShown(el) {
|
||||
if (!el) return false;
|
||||
if (el.hidden) return false;
|
||||
if (el.style?.display === 'none') return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function setVariantShown(el, shown) {
|
||||
if (!el) return;
|
||||
if (shown) {
|
||||
el.removeAttribute('hidden');
|
||||
el.style.display = '';
|
||||
} else {
|
||||
el.setAttribute('hidden', '');
|
||||
el.style.display = 'none';
|
||||
}
|
||||
return getComputedStyle(el).display !== 'none';
|
||||
}
|
||||
|
||||
function scheduleCyclingBarSync(sessionId, variantNum) {
|
||||
@@ -4823,11 +4823,7 @@
|
||||
}
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
if (!wrapper) return false;
|
||||
for (const child of wrapper.children) {
|
||||
const v = child.dataset ? child.dataset.impeccableVariant : null;
|
||||
if (!v) continue;
|
||||
setVariantShown(child, v === String(num));
|
||||
}
|
||||
updateVariantStateStylesheet(sessionId, num);
|
||||
// Unconditional refresh - covers first-reveal (no-op if state isn't
|
||||
// CYCLING yet, the subsequent CYCLING transition triggers its own
|
||||
// refresh) and every cycle step.
|
||||
@@ -5492,6 +5488,7 @@
|
||||
if (pendingSvelteComponentRetryObserver) { pendingSvelteComponentRetryObserver.disconnect(); pendingSvelteComponentRetryObserver = null; }
|
||||
if (pendingVariantAnchorRetryObserver) { pendingVariantAnchorRetryObserver.disconnect(); pendingVariantAnchorRetryObserver = null; }
|
||||
stopScrollLock();
|
||||
removeVariantStateStylesheet();
|
||||
clearSession();
|
||||
clearHandled();
|
||||
resetSessionFileMeta();
|
||||
@@ -5806,6 +5803,68 @@
|
||||
return variantDiv;
|
||||
}
|
||||
|
||||
// Variant visibility and range/toggle params are expressed through ONE
|
||||
// injected stylesheet, never inline attributes on the variant divs. Those
|
||||
// divs are scaffolded into page source, so SSR frameworks (Next.js App
|
||||
// Router) server-render them; toggling their `hidden` / inline `style` /
|
||||
// `--p-*` client-side trips a React 19 hydration mismatch on the next
|
||||
// Fast-Refresh re-render — the same failure mode the scroll-anchor (#276)
|
||||
// and pick-cursor (#286) fixes address. A stylesheet rule has the same
|
||||
// computed effect without mutating any hydrated element's attributes.
|
||||
// (steps params keep driving `data-p-*` attributes, matching scoped CSS.)
|
||||
const VARIANT_HIDE_DECL = 'display: none !important;';
|
||||
const VARIANT_SHOW_DECL = 'display: block !important;';
|
||||
|
||||
// Build a direct-child variant selector for a session. With `num`, targets a
|
||||
// single variant (`… > [data-impeccable-variant="N"]`); without it, targets
|
||||
// every variant via the bare `[data-impeccable-variant]` attribute.
|
||||
function variantStateSelector(sessionId, num) {
|
||||
const wrapper = '[data-impeccable-variants="' + sessionId + '"]';
|
||||
const variant = num == null
|
||||
? '[data-impeccable-variant]'
|
||||
: '[data-impeccable-variant="' + num + '"]';
|
||||
return wrapper + ' > ' + variant;
|
||||
}
|
||||
|
||||
// Serialize the visible variant's knob values into `--p-<id>` custom-property
|
||||
// declarations. Only range (number) and toggle (boolean) values become a
|
||||
// custom property; steps params drive `data-p-*` attributes instead.
|
||||
function variantParamDecls(values) {
|
||||
return Object.entries(values || {})
|
||||
.map(([id, val]) => {
|
||||
if (typeof val === 'number') return ' --p-' + id + ': ' + val + ';';
|
||||
if (typeof val === 'boolean') return ' --p-' + id + ': ' + (val ? '1' : '0') + ';';
|
||||
return '';
|
||||
})
|
||||
.join('');
|
||||
}
|
||||
|
||||
function updateVariantStateStylesheet(sessionId, num) {
|
||||
if (!sessionId || num == null || num < 1) return;
|
||||
|
||||
let styleEl = document.getElementById(VARIANT_STATE_STYLE_ID);
|
||||
if (!styleEl) {
|
||||
styleEl = document.createElement('style');
|
||||
styleEl.id = VARIANT_STATE_STYLE_ID;
|
||||
(document.head || document.documentElement).appendChild(styleEl);
|
||||
}
|
||||
|
||||
// Hide every variant except the visible one (incl. the SSR'd "original").
|
||||
const hideOthers = variantStateSelector(sessionId)
|
||||
+ ':not([data-impeccable-variant="' + num + '"]) { ' + VARIANT_HIDE_DECL + ' }';
|
||||
|
||||
// Force-show the visible variant (beats the source inline display:none on
|
||||
// v2/v3) and apply its knob values as custom properties.
|
||||
const showVisible = variantStateSelector(sessionId, num)
|
||||
+ ' { ' + VARIANT_SHOW_DECL + variantParamDecls(paramsCurrentValues) + ' }';
|
||||
|
||||
styleEl.textContent = hideOthers + '\n' + showVisible + '\n';
|
||||
}
|
||||
|
||||
function removeVariantStateStylesheet() {
|
||||
document.getElementById(VARIANT_STATE_STYLE_ID)?.remove();
|
||||
}
|
||||
|
||||
// Hold window.scrollY at a fixed value across DOM mutations inside the
|
||||
// session's wrapper (HMR patches, variant inserts, cycle swaps).
|
||||
function startScrollLock(sessionId, initialTargetY) {
|
||||
@@ -6087,7 +6146,7 @@
|
||||
switch (msg.type) {
|
||||
case 'connected':
|
||||
hasProjectContext = !!msg.hasProjectContext;
|
||||
if (!hasProjectContext) showToast('No PRODUCT.md found. Variants will be brand-agnostic. Run /impeccable init to generate one.', 7000);
|
||||
if (!hasProjectContext) showToast(`No PRODUCT.md found. Variants will be brand-agnostic. Run ${IMPECCABLE_COMMAND} init to generate one.`, 7000);
|
||||
console.log('[impeccable] Live mode connected.');
|
||||
syncAgentPollingUi(!!msg.agentPolling);
|
||||
startAgentStatusPoll();
|
||||
@@ -7636,6 +7695,7 @@ void main() {
|
||||
stopScrollTracking();
|
||||
if (variantObserver) { variantObserver.disconnect(); variantObserver = null; }
|
||||
stopScrollLock();
|
||||
removeVariantStateStylesheet();
|
||||
clearScrollY();
|
||||
clearSession();
|
||||
resetSessionFileMeta();
|
||||
@@ -7897,6 +7957,7 @@ void main() {
|
||||
if (variantObserver) { variantObserver.disconnect(); variantObserver = null; }
|
||||
if (pendingVariantAnchorRetryObserver) { pendingVariantAnchorRetryObserver.disconnect(); pendingVariantAnchorRetryObserver = null; }
|
||||
stopScrollLock();
|
||||
removeVariantStateStylesheet();
|
||||
clearScrollY();
|
||||
finalizeInsertSession();
|
||||
clearSession();
|
||||
@@ -7928,7 +7989,7 @@ void main() {
|
||||
const barTopFromBottom = barRect && barRect.height > 0
|
||||
? Math.max(16, window.innerHeight - barRect.top + 12)
|
||||
: 16;
|
||||
toastEl = el('div', {
|
||||
const currentToast = el('div', {
|
||||
position: 'fixed', bottom: barTopFromBottom + 'px', left: '50%',
|
||||
transform: 'translateX(-50%) translateY(8px)',
|
||||
background: C.ink, color: C.white,
|
||||
@@ -7938,19 +7999,24 @@ void main() {
|
||||
transition: 'opacity 0.25s ' + EASE + ', transform 0.25s ' + EASE,
|
||||
pointerEvents: 'none', maxWidth: '420px', textAlign: 'center',
|
||||
});
|
||||
toastEl.id = PREFIX + '-toast';
|
||||
toastEl.textContent = message;
|
||||
uiAppend(toastEl);
|
||||
toastEl = currentToast;
|
||||
currentToast.id = PREFIX + '-toast';
|
||||
currentToast.textContent = message;
|
||||
uiAppend(currentToast);
|
||||
requestAnimationFrame(() => {
|
||||
toastEl.style.opacity = '1';
|
||||
toastEl.style.transform = 'translateX(-50%) translateY(0)';
|
||||
if (toastEl !== currentToast) return;
|
||||
currentToast.style.opacity = '1';
|
||||
currentToast.style.transform = 'translateX(-50%) translateY(0)';
|
||||
});
|
||||
setTimeout(() => {
|
||||
if (toastEl) {
|
||||
toastEl.style.opacity = '0';
|
||||
toastEl.style.transform = 'translateX(-50%) translateY(8px)';
|
||||
setTimeout(() => { if (toastEl) { toastEl.remove(); toastEl = null; } }, 250);
|
||||
}
|
||||
if (toastEl !== currentToast) return;
|
||||
currentToast.style.opacity = '0';
|
||||
currentToast.style.transform = 'translateX(-50%) translateY(8px)';
|
||||
setTimeout(() => {
|
||||
if (toastEl !== currentToast) return;
|
||||
currentToast.remove();
|
||||
toastEl = null;
|
||||
}, 250);
|
||||
}, duration);
|
||||
}
|
||||
|
||||
@@ -9984,6 +10050,7 @@ void main() {
|
||||
window.postMessage({ source: 'impeccable-command', action: 'remove' }, '*');
|
||||
setLiveState('IDLE');
|
||||
document.getElementById(PICK_CURSOR_STYLE_ID)?.remove();
|
||||
removeVariantStateStylesheet();
|
||||
window.__IMPECCABLE_LIVE_INIT__ = false;
|
||||
console.log('[impeccable] Live mode exited.');
|
||||
}
|
||||
@@ -10472,7 +10539,7 @@ void main() {
|
||||
if (designState.present === false) {
|
||||
const empty = document.createElement('div');
|
||||
empty.className = 'empty';
|
||||
empty.innerHTML = `<strong>No DESIGN.md yet</strong>Create one by running <code>/impeccable document</code> in your terminal, then re-open this panel.`;
|
||||
empty.innerHTML = `<strong>No DESIGN.md yet</strong>Create one by running <code>${IMPECCABLE_COMMAND} document</code> in your terminal, then re-open this panel.`;
|
||||
body.appendChild(empty);
|
||||
return;
|
||||
}
|
||||
@@ -10502,7 +10569,7 @@ void main() {
|
||||
box.className = 'stale';
|
||||
box.innerHTML = `
|
||||
<span class="stale-dot"></span>
|
||||
<span class="stale-text"><strong>DESIGN.md is newer than .impeccable/design.json.</strong> Run <code>/impeccable document</code> to refresh the sidecar.</span>
|
||||
<span class="stale-text"><strong>DESIGN.md is newer than .impeccable/design.json.</strong> Run <code>${IMPECCABLE_COMMAND} document</code> to refresh the sidecar.</span>
|
||||
`;
|
||||
return box;
|
||||
}
|
||||
@@ -10510,7 +10577,7 @@ void main() {
|
||||
function renderParsedMdCta() {
|
||||
const box = document.createElement('div');
|
||||
box.className = 'parsed-md-cta';
|
||||
box.innerHTML = `<strong>Basic view</strong>This panel reads the tokens in your <code>DESIGN.md</code> frontmatter. Running <code>/impeccable document</code> also generates a <code>.impeccable/design.json</code> sidecar with your project's actual component snippets (button, input, nav) and tonal ramps, rendered live below the tokens.`;
|
||||
box.innerHTML = `<strong>Basic view</strong>This panel reads the tokens in your <code>DESIGN.md</code> frontmatter. Running <code>${IMPECCABLE_COMMAND} document</code> also generates a <code>.impeccable/design.json</code> sidecar with your project's actual component snippets (button, input, nav) and tonal ramps, rendered live below the tokens.`;
|
||||
return box;
|
||||
}
|
||||
|
||||
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
getDesignSidecarPath,
|
||||
getLiveDir,
|
||||
getLiveAnnotationsDir,
|
||||
IMPECCABLE_COMMAND_PREFIX,
|
||||
readLiveServerInfo,
|
||||
removeLiveServerInfo,
|
||||
resolveDesignSidecarPath,
|
||||
@@ -413,6 +414,7 @@ function createRequestHandler({ detectScript, liveScriptParts }) {
|
||||
token: state.token,
|
||||
port: state.port,
|
||||
vocabulary: LIVE_COMMANDS,
|
||||
commandPrefix: IMPECCABLE_COMMAND_PREFIX,
|
||||
parts,
|
||||
});
|
||||
res.writeHead(200, {
|
||||
|
||||
@@ -32,10 +32,11 @@ export function readLiveBrowserScriptParts(parts, readFile = (filePath) => fs.re
|
||||
}));
|
||||
}
|
||||
|
||||
export function assembleLiveBrowserScript({ token, port, vocabulary, parts }) {
|
||||
export function assembleLiveBrowserScript({ token, port, vocabulary, commandPrefix = '/', parts }) {
|
||||
const prelude =
|
||||
`window.__IMPECCABLE_TOKEN__ = '${token}';\n` +
|
||||
`window.__IMPECCABLE_PORT__ = ${port};\n` +
|
||||
`window.__IMPECCABLE_COMMAND_PREFIX__ = ${JSON.stringify(commandPrefix)};\n` +
|
||||
// Canonical command vocabulary (values + labels + icons). live-browser.js
|
||||
// builds its action picker from this instead of an inline copy.
|
||||
`window.__IMPECCABLE_VOCAB__ = ${JSON.stringify(vocabulary)};\n`;
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
* node <scripts_path>/pin.mjs pin <command>
|
||||
* node <scripts_path>/pin.mjs unpin <command>
|
||||
*
|
||||
* `pin audit` creates a lightweight /audit skill that redirects to /impeccable audit.
|
||||
* `pin audit` creates a lightweight audit skill that redirects to Impeccable's audit workflow.
|
||||
* `unpin audit` removes that shortcut.
|
||||
*
|
||||
* The script discovers harness directories (.claude/skills, .cursor/skills, etc.)
|
||||
@@ -14,7 +14,7 @@
|
||||
*/
|
||||
|
||||
import { existsSync, readFileSync, writeFileSync, mkdirSync, rmSync, readdirSync } from 'node:fs';
|
||||
import { join, resolve, dirname } from 'node:path';
|
||||
import { basename, join, resolve, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
@@ -25,6 +25,8 @@ const HARNESS_DIRS = [
|
||||
'.trae', '.trae-cn', '.pi', '.opencode', '.kiro', '.rovodev',
|
||||
];
|
||||
|
||||
const CODEX_HARNESSES = new Set(['.codex', '.agents']);
|
||||
|
||||
// Valid sub-command names
|
||||
const VALID_COMMANDS = [
|
||||
'craft', 'init', 'extract', 'document', 'shape',
|
||||
@@ -87,8 +89,12 @@ function loadCommandMetadata() {
|
||||
/**
|
||||
* Generate a pinned skill's SKILL.md content.
|
||||
*/
|
||||
function generatePinnedSkill(command, metadata) {
|
||||
const desc = metadata[command]?.description || `Shortcut for /impeccable ${command}.`;
|
||||
function commandPrefixForSkillsDir(skillsDir) {
|
||||
return CODEX_HARNESSES.has(basename(dirname(skillsDir))) ? '$' : '/';
|
||||
}
|
||||
|
||||
function generatePinnedSkill(command, metadata, commandPrefix) {
|
||||
const desc = metadata[command]?.description || `Shortcut for ${commandPrefix}impeccable ${command}.`;
|
||||
const hint = metadata[command]?.argumentHint || '[target]';
|
||||
|
||||
return `---
|
||||
@@ -100,9 +106,9 @@ user-invocable: true
|
||||
|
||||
${PIN_MARKER}
|
||||
|
||||
This is a pinned shortcut for \`{{command_prefix}}impeccable ${command}\`.
|
||||
This is a pinned shortcut for \`${commandPrefix}impeccable ${command}\`.
|
||||
|
||||
Invoke {{command_prefix}}impeccable ${command}, passing along any arguments provided here, and follow its instructions.
|
||||
Invoke ${commandPrefix}impeccable ${command}, passing along any arguments provided here, and follow its instructions.
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -118,10 +124,11 @@ function pin(command, projectRoot) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const content = generatePinnedSkill(command, metadata);
|
||||
let created = 0;
|
||||
|
||||
for (const skillsDir of harnessDirs) {
|
||||
const commandPrefix = commandPrefixForSkillsDir(skillsDir);
|
||||
const content = generatePinnedSkill(command, metadata, commandPrefix);
|
||||
// Check if skill already exists (and isn't a pin)
|
||||
const skillDir = join(skillsDir, command);
|
||||
if (existsSync(skillDir)) {
|
||||
@@ -143,7 +150,7 @@ function pin(command, projectRoot) {
|
||||
|
||||
if (created > 0) {
|
||||
console.log(`\nPinned '${command}' as a standalone shortcut in ${created} location(s).`);
|
||||
console.log(`You can now use /${command} directly.`);
|
||||
console.log('Use the pinned command directly in each harness.');
|
||||
}
|
||||
|
||||
return created > 0;
|
||||
@@ -177,7 +184,7 @@ function unpin(command, projectRoot) {
|
||||
|
||||
if (removed > 0) {
|
||||
console.log(`\nUnpinned '${command}' from ${removed} location(s).`);
|
||||
console.log(`Use /impeccable ${command} to access it.`);
|
||||
console.log(`Use Impeccable's '${command}' workflow directly to access it.`);
|
||||
} else {
|
||||
console.log(`No pinned '${command}' shortcut found.`);
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
{
|
||||
"name": "impeccable",
|
||||
"description": "Design fluency for frontend development. 1 skill with 23 commands (/impeccable polish, /impeccable audit, /impeccable critique, etc.) and curated anti-pattern detection.",
|
||||
"version": "3.9.0",
|
||||
"version": "3.9.1",
|
||||
"author": {
|
||||
"name": "Paul Bakaus",
|
||||
"email": "paul@paulbakaus.com"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "impeccable",
|
||||
"description": "Design fluency for frontend development. 1 skill with 23 commands (/impeccable polish, /impeccable audit, /impeccable critique, etc.) and curated anti-pattern detection.",
|
||||
"version": "3.9.0",
|
||||
"version": "3.9.1",
|
||||
"author": {
|
||||
"name": "Paul Bakaus",
|
||||
"email": "paul@paulbakaus.com"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: impeccable
|
||||
description: Use when the user wants to design, redesign, shape, critique, audit, polish, clarify, distill, harden, optimize, adapt, animate, colorize, extract, or otherwise improve a frontend interface. Covers websites, landing pages, dashboards, product UI, app shells, components, forms, settings, onboarding, and empty states. Handles UX review, visual hierarchy, information architecture, cognitive load, accessibility, performance, responsive behavior, theming, anti-patterns, typography, fonts, spacing, layout, alignment, color, motion, micro-interactions, UX copy, error states, edge cases, i18n, and reusable design systems or tokens. Also use for bland designs that need to become bolder or more delightful, loud designs that should become quieter, live browser iteration on UI elements, or ambitious visual effects that should feel technically extraordinary. Not for backend-only or non-UI tasks.
|
||||
version: 3.9.0
|
||||
version: 3.9.1
|
||||
user-invocable: true
|
||||
argument-hint: "[craft|shape · audit|critique · animate|bolder|colorize|delight|layout|overdrive|quieter|typeset · adapt|clarify|distill · harden|onboard|optimize|polish · init|document|extract|live] [target]"
|
||||
license: Apache 2.0
|
||||
@@ -16,11 +16,12 @@ Designs and iterates production-grade frontend interfaces. Real working code, co
|
||||
|
||||
You MUST do these steps before proceeding:
|
||||
|
||||
1. Run `node .claude/skills/impeccable/scripts/context.mjs` once per session. If the request names or implies a file, route, or app inside a monorepo, infer the concrete path and run `node .claude/skills/impeccable/scripts/context.mjs --target <path>` instead. If you've already seen its output in this conversation, do not re-run it. The script either prints the project's PRODUCT.md (and DESIGN.md when present) as a markdown block, or tells you it's missing. Follow whatever it prints. **If it reports `NO_PRODUCT_MD`, stop and follow `reference/init.md` before doing anything else.** If the output ends with an `UPDATE_AVAILABLE` directive, follow it (ask the user once about updating, then continue). It never blocks the current task.
|
||||
2. If the user invoked a sub-command (`craft`, `shape`, `audit`, `polish`, ...), you MUST read `reference/<command>.md` next. Non-optional. The reference defines the command's flow; without it you will skip steps the user expects.
|
||||
1. Run `node .claude/skills/impeccable/scripts/context.mjs` once per session; if the runtime shows this skill's loaded base directory, run `node <skill-base-dir>/scripts/context.mjs` instead. Keep cwd/workdir at the user's project, not the skill directory. If the request names or implies a file, route, or app inside a monorepo, infer the concrete path and append `--target <path>` to the same command. If you've already seen its output in this conversation, do not re-run it. The script either prints the project's PRODUCT.md (and DESIGN.md when present) as a markdown block, or tells you it's missing. Follow whatever it prints. **If it reports `NO_PRODUCT_MD`:** divert into `reference/init.md` first when the user invoked `init`, `teach`, `craft`, or `shape`, or when their wording clearly maps to one of those from-scratch build flows (for example: "build/create/make a landing page", "design a new app", or "shape a feature"). Captured product context is the point of those flows. For any other command, a scoped evaluate / refine / enhance / fix / iterate request against existing code, do **not** divert into init. The existing code is the context: proceed with the requested command, infer the register from the surface in focus (step 4), and offer `/impeccable init` once as a suggestion the user can take later. A missing PRODUCT.md must never block a scoped request. If the output ends with an `UPDATE_AVAILABLE` directive, follow it (ask the user once about updating, then continue). It never blocks the current task.
|
||||
2. If the user invoked a sub-command (`craft`, `shape`, `audit`, `polish`, ...), you MUST read the command's reference next: **`reference/<command>.md`, or the native variant from the Commands table** (e.g. `reference/audit.native.md`) **when the project platform is native** (`ios` / `android` / `adaptive`, per the `context.mjs` directive). One file, not both. Non-optional. The reference defines the command's flow; without it you will skip steps the user expects.
|
||||
3. Familiarize yourself with any existing design system, conventions, and components in the code. Read at least one project file (CSS / tokens / theme / a representative component or page). **Required even when you've loaded a sub-command reference in step 2.** Don't reinvent the wheel; use what's there when it works, branch out when the UX wins.
|
||||
4. Read the matching register reference. **This is non-optional; skipping it produces generic output.** If the project is marketing, a landing page, a campaign, long-form content, or a portfolio (design IS the product), read `reference/brand.md`. If it is app UI, admin, a dashboard, or a tool (design SERVES the product), read `reference/product.md`. Pick by first match: (1) task cue ("landing page" vs "dashboard"); (2) surface in focus (the page, file, or route being worked on); (3) `register` field in PRODUCT.md.
|
||||
5. **If the project is brand-new (no existing CSS tokens / theme / committed brand colors found in step 3)**, run `node .claude/skills/impeccable/scripts/palette.mjs` to receive a brand seed color and composition guidance. This is the anchor for your primary brand color. Compose the rest of the palette (bg, surface, ink, accent, muted) around it per the script's instructions. Use OKLCH throughout. **Skip this step only if step 3 found committed brand colors in existing tokens; in that case identity-preservation wins.**
|
||||
5. **If PRODUCT.md's `## Platform` is `ios` or `android`**, also read `reference/<platform>.md` (HIG / Material 3 conventions). `adaptive` (cross-platform, ships both) reads both files. `web`, absent, or unrecognized: nothing extra to read. `context.mjs` prints the directive when one applies.
|
||||
6. **If the project is brand-new (no existing CSS tokens / theme / committed brand colors found in step 3)**, run `node .claude/skills/impeccable/scripts/palette.mjs` to receive a brand seed color and composition guidance. This is the anchor for your primary brand color. Compose the rest of the palette (bg, surface, ink, accent, muted) around it per the script's instructions. Use OKLCH throughout. **Skip this step only if step 3 found committed brand colors in existing tokens; in that case identity-preservation wins.**
|
||||
|
||||
## Design guidance
|
||||
|
||||
@@ -109,7 +110,7 @@ If someone could look at this interface and say "AI made that" without doubt, it
|
||||
| `document` | Build | Generate DESIGN.md from existing project code | [reference/document.md](reference/document.md) |
|
||||
| `extract [target]` | Build | Pull reusable tokens and components into design system | [reference/extract.md](reference/extract.md) |
|
||||
| `critique [target]` | Evaluate | UX design review with heuristic scoring | [reference/critique.md](reference/critique.md) |
|
||||
| `audit [target]` | Evaluate | Technical quality checks (a11y, perf, responsive) | [reference/audit.md](reference/audit.md) |
|
||||
| `audit [target]` | Evaluate | Technical quality checks (a11y, perf, responsive) | [reference/audit.md](reference/audit.md) · native: [reference/audit.native.md](reference/audit.native.md) |
|
||||
| `polish [target]` | Refine | Final quality pass before shipping | [reference/polish.md](reference/polish.md) |
|
||||
| `bolder [target]` | Refine | Amplify safe or bland designs | [reference/bolder.md](reference/bolder.md) |
|
||||
| `quieter [target]` | Refine | Tone down aggressive or overstimulating designs | [reference/quieter.md](reference/quieter.md) |
|
||||
@@ -123,7 +124,7 @@ If someone could look at this interface and say "AI made that" without doubt, it
|
||||
| `delight [target]` | Enhance | Add personality and memorable touches | [reference/delight.md](reference/delight.md) |
|
||||
| `overdrive [target]` | Enhance | Push past conventional limits | [reference/overdrive.md](reference/overdrive.md) |
|
||||
| `clarify [target]` | Fix | Improve UX copy, labels, and error messages | [reference/clarify.md](reference/clarify.md) |
|
||||
| `adapt [target]` | Fix | Adapt for different devices and screen sizes | [reference/adapt.md](reference/adapt.md) |
|
||||
| `adapt [target]` | Fix | Adapt for different devices and screen sizes | [reference/adapt.md](reference/adapt.md) · native: [reference/adapt.native.md](reference/adapt.native.md) |
|
||||
| `optimize [target]` | Fix | Diagnose and fix UI performance | [reference/optimize.md](reference/optimize.md) |
|
||||
| `live` | Iterate | Visual variant mode: pick elements in the browser, generate alternatives | [reference/live.md](reference/live.md) |
|
||||
|
||||
@@ -131,26 +132,26 @@ Plus three management commands: `pin <command>`, `unpin <command>`, and `hooks <
|
||||
|
||||
### Routing rules
|
||||
|
||||
1. **No argument**: the user is asking "what should I do?" Make the menu context-aware instead of static. Setup has already run `context.mjs`; if that reported `NO_PRODUCT_MD` you are already in init (setup), so finish that and skip this. Otherwise run `node .claude/skills/impeccable/scripts/context-signals.mjs` once and read its JSON, then lead with the **2-3 highest-value next commands**, each with a one-line reason pulled from the signals, followed by the full menu (the table above, grouped by category). **Never auto-run a command; the recommendation is a suggestion the user confirms.**
|
||||
1. **No argument**: the user is asking "what should I do?" Make the menu context-aware instead of static. Setup has already run `context.mjs`; if that reported `NO_PRODUCT_MD` the project has no captured context yet, so lead the menu with `/impeccable init` as the top recommendation (one line on why) and still show the rest below; don't silently jump into init. Otherwise run `node .claude/skills/impeccable/scripts/context-signals.mjs` once and read its JSON, then lead with the **2-3 highest-value next commands**, each with a one-line reason pulled from the signals, followed by the full menu (the table above, grouped by category). **Never auto-run a command; the recommendation is a suggestion the user confirms.**
|
||||
|
||||
Reason over the signals; there is no score to obey:
|
||||
- `setup.hasDesign` false while `setup.hasCode` true → `document` (capture the visual system).
|
||||
- `critique.latest` is `null` → the project has never been critiqued; for a set-up project with a real surface, offering `/impeccable critique <surface>` is a strong default.
|
||||
- `critique.latest` with a low `score` or non-zero `p0` / `p1` → `polish` (it reads that snapshot as its backlog), or re-run `critique` if the snapshot looks stale.
|
||||
- `git.changedFiles` pointing at one surface → scope `audit` or `polish` to those files specifically, naming them.
|
||||
- `devServer.running` true → `live` is available for in-browser iteration; if false, don't lead with `live`.
|
||||
- `devServer.running` true → `live` is available for in-browser iteration; if false, don't lead with `live`. **`live` and the bundled `detect.mjs` are web-only.** If `setup.platform` is `ios`, `android`, or `adaptive`, don't lead with either; the browser overlay and the HTML rule engine don't apply to native app code.
|
||||
- Otherwise group by intent exactly as init's "Recommend starting points" step does (build new / improve what's there / iterate visually), tailored to `setup.register`.
|
||||
|
||||
**If `scan.targets` is non-empty, run `node .claude/skills/impeccable/scripts/detect.mjs --json <scan.targets joined by spaces>` once** (the bundled detector over local files: no network, no npx). `scan.via` tells you what they are: `git-changes` (the markup/style files in your dirty tree, the most relevant set), `source-dir` (e.g. `src`, `app`), `html`, or `root`. Fold the hits into your picks: many quality / contrast hits → `audit` or `polish`; a specific slop family → the matching command (gradient text or eyebrows → `quieter` / `typeset`, flat or gray palette → `colorize`, and so on). It's a real, current signal that beats guessing. If detect errors or the tree is large and slow, skip it and recommend the user run `audit` themselves; never block the suggestion on it.
|
||||
**If `scan.targets` is non-empty and `setup.platform` is not `ios`/`android`/`adaptive`, run `node .claude/skills/impeccable/scripts/detect.mjs --json <scan.targets joined by spaces>` once** (the bundled detector over local files: no network, no npx; it reads HTML/CSS, so skip it for native projects). `scan.via` tells you what they are: `git-changes` (the markup/style files in your dirty tree, the most relevant set), `source-dir` (e.g. `src`, `app`), `html`, or `root`. Fold the hits into your picks: many quality / contrast hits → `audit` or `polish`; a specific slop family → the matching command (gradient text or eyebrows → `quieter` / `typeset`, flat or gray palette → `colorize`, and so on). It's a real, current signal that beats guessing. If detect errors or the tree is large and slow, skip it and recommend the user run `audit` themselves; never block the suggestion on it.
|
||||
|
||||
Keep it to 2-3 pointed picks with the exact command to type. The menu stays the fallback; the recommendation is the lede.
|
||||
2. **First word matches a command** (table above OR `pin` / `unpin` / `hooks`): load its reference file and follow its instructions. Everything after the command name is the target.
|
||||
3. **First word doesn't match, but the intent clearly maps to one command** (e.g. "fix the spacing" → `layout`, "rewrite this error message" → `clarify`, "the colors feel flat" → `colorize`): load that command's reference and proceed as if invoked. If two commands could fit, ask once which.
|
||||
2. **First word matches a command** (table above OR `pin` / `unpin` / `hooks`): load its reference file (on native platforms, the table's native variant; Setup step 2's one-file rule) and follow its instructions. Everything after the command name is the target.
|
||||
3. **First word doesn't match, but the intent clearly maps to one command** (e.g. "fix the spacing" → `layout`, "rewrite this error message" → `clarify`, "the colors feel flat" → `colorize`): load that command's reference (same native-variant rule) and proceed as if invoked. If two commands could fit, ask once which.
|
||||
4. **No clear command match**: general design invocation. Apply the setup steps, the General rules, and the loaded register reference, using the full argument as context.
|
||||
|
||||
Setup (context gathering, register) is already loaded by then; sub-commands don't re-invoke `/impeccable`.
|
||||
|
||||
If the first word is `craft`, setup still runs first, but [reference/craft.md](reference/craft.md) owns the rest of the flow. If setup invokes `init` as a blocker, finish init, refresh context, then resume the original command and target.
|
||||
If the first word is `craft` or `shape`, or routing rule 3 clearly maps the user's intent to either command, setup still runs first, but the matching reference ([reference/craft.md](reference/craft.md) or [reference/shape.md](reference/shape.md)) owns the rest of the flow. Both are from-scratch build flows: if setup invokes `init` as a blocker, finish init, refresh context, then resume the original command and target.
|
||||
|
||||
`teach` is a deprecated alias for `init`: if the user types it, load [reference/init.md](reference/init.md) and proceed as if they ran `init`.
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
Adapt an existing design to a different context: another screen size, device, platform, or use case. The trap is treating adaptation as scaling. The job is rethinking the experience for the new context.
|
||||
|
||||
**Web only** (mobile web included). Native platforms (`ios` / `android` / `adaptive`) route to [adapt.native.md](adapt.native.md) instead; if the project is native, switch to it now.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
> **Additional context needed**: target platforms/devices and usage contexts.
|
||||
|
||||
Adapt an existing **native** design (`ios` / `android` / `adaptive`) to a different context: another device class, orientation, platform, or origin. The trap is treating adaptation as scaling. The job is rethinking the experience for the new context, inside the platform conventions of [ios.md](ios.md) / [android.md](android.md); read the target platform's reference before planning if Setup hasn't already.
|
||||
|
||||
## Assess Adaptation Challenge
|
||||
|
||||
1. **Source context**: what was it designed for, and what assumptions did it make? (Phone-only? Portrait-only? One platform's idioms? A website?)
|
||||
2. **Target context**: which device class (phone, tablet, foldable), orientation, platform, and usage posture (one-handed on the go vs two-handed at rest)?
|
||||
3. **What breaks**: navigation that doesn't fit the target, layouts that stretch instead of restructure, gestures or controls that don't exist there?
|
||||
|
||||
## Adaptation Strategies
|
||||
|
||||
### Phone → Tablet (iPad / large screens)
|
||||
|
||||
- **Restructure, don't stretch.** A scaled-up phone UI on a tablet is the failure mode. Use size classes (iOS) / window size classes (Android) to switch structure.
|
||||
- **Navigation changes shape**: tab bar stays or becomes a sidebar on iPad; Android navigation bar becomes a rail or drawer on expanded width.
|
||||
- **Use the width**: split view / master-detail (list + detail side by side), multi-column grids, popovers where phones used sheets.
|
||||
- **Multitasking is a size, not an edge case**: iPad Split View and Android multi-window can hand you a phone-width window on a tablet; size-class-driven layout handles both for free.
|
||||
|
||||
### Orientation & foldables
|
||||
|
||||
- Landscape restructures (side-by-side panes, repositioned controls); never clip or letterbox. Lock orientation only when the task truly demands it.
|
||||
- Foldables (Android): react to posture and hinge via window size classes; test folded, unfolded, and tabletop.
|
||||
|
||||
### Platform → platform (iOS ↔ Android)
|
||||
|
||||
Translate idioms; never transplant them:
|
||||
|
||||
| iOS | Android |
|
||||
|---|---|
|
||||
| Tab bar | Navigation bar / rail / drawer |
|
||||
| Edge-swipe back, back chevron | Predictive Back gesture / button |
|
||||
| Switch, segmented control, system pickers | Material switch, chips, Material pickers |
|
||||
| Action sheet | Bottom sheet / Material dialog |
|
||||
| SF Symbols, SF Pro, Dynamic Type | Material Symbols, Roboto, sp scaling |
|
||||
| Semantic system colors, materials | Material color roles, tonal elevation |
|
||||
| System push/sheet transitions | Container transform, shared-axis, fade-through |
|
||||
|
||||
Rebuild navigation and controls in the target's vocabulary; carry over the brand's expressive layer (palette intent, type accent, motion personality) through the target's theming system.
|
||||
|
||||
### Web → native (porting a website or web app)
|
||||
|
||||
Reconform, don't reflow. Replace web navigation with the platform's model, HTML-shaped controls with platform controls, hover affordances with touch-first ones, and px-based type with Dynamic Type / sp. Then treat the result to the full platform reference; the slop test there is the acceptance bar.
|
||||
|
||||
## Implement & Verify
|
||||
|
||||
- Drive structure from **size classes / window size classes**, never from device-model checks.
|
||||
- Respect safe areas and window insets in every new configuration (notch, hinge, status bar, keyboard).
|
||||
- Test on simulators for breadth, then real hardware for truth: at least one phone and one tablet per shipped platform, both orientations, split-screen where supported.
|
||||
|
||||
When the adaptation feels native to each context, hand off to `/impeccable polish` for the final pass.
|
||||
|
||||
**NEVER**:
|
||||
- Ship a stretched phone layout on a tablet
|
||||
- Port one platform's controls or navigation onto the other
|
||||
- Hide core functionality on smaller devices (if it matters, make it work)
|
||||
- Lock orientation to dodge a layout bug
|
||||
- Trust simulators alone (posture, gestures, and performance need hardware)
|
||||
@@ -0,0 +1,40 @@
|
||||
# Android platform
|
||||
|
||||
For native Android apps: Jetpack Compose, Android Views, React Native, Expo, Flutter shipping to Android hardware.
|
||||
|
||||
On native, register narrows. Material Design 3 governs structure, navigation, and interaction whatever the register; brand expresses through Material's theming (color roles, type scale, shape, motion). A Material-everywhere cross-platform app that also ships to iPhone still owes iOS its OS guarantees on that hardware: safe-area insets, Reduce Motion, edge-swipe back.
|
||||
|
||||
## The Android slop test
|
||||
|
||||
Would a fluent Android user trust this app, or trip on off-spec components? The most common tell is an iOS app wearing Android's skin: a bottom-only navigation copied from iPhone, a back arrow that ignores the system Back gesture, Cupertino-shaped switches and dialogs. Material 3 is the rulebook; follow its components and theme the brand through it.
|
||||
|
||||
## Layout & structure
|
||||
|
||||
- **Material navigation, matched to size.** Navigation bar (bottom, 3–5 destinations) on compact width; navigation rail or drawer on expanded width. Never ship a phone bottom-bar untouched on a tablet.
|
||||
- **System Back always works.** Honor the predictive Back gesture and Back button; never trap the user or hijack the gesture.
|
||||
- **Edge-to-edge with window insets.** Apply the status bar, navigation bar, display cutout, and IME insets so content never hides behind system bars or the keyboard.
|
||||
- **Top app bar for screen context**; pair with a FAB when the screen has a single primary action.
|
||||
|
||||
## Touch targets
|
||||
|
||||
- **48×48 dp minimum** for every touch target, with at least 8 dp between them.
|
||||
|
||||
## Typography
|
||||
|
||||
- **Material type scale.** Display, Headline, Title, Body, Label roles (large/medium/small each). Map text to roles; never hand-pick sizes per screen.
|
||||
- **Roboto is the system face**; theme a brand face in through the type scale, keeping body, labels, and controls legible and consistent.
|
||||
- **sp units, never fixed px**, so type follows the system font-size setting.
|
||||
|
||||
## Color & theming
|
||||
|
||||
- **Material color roles** (primary, on-primary, surface, surface-variant, secondary-container, outline, error). Role tokens resolve light/dark and contrast variants automatically; raw hex breaks there.
|
||||
- **Dynamic Color (Material You)** where it fits: derive the scheme from the user's wallpaper on Android 12+, with a static fallback.
|
||||
- **Dark theme is a first-class scheme.** Design and test it; never a quick invert.
|
||||
- **Tonal elevation.** Convey elevation through the standard surface tonal levels (plus shadow where appropriate); no arbitrary drop shadows.
|
||||
|
||||
## Components & motion
|
||||
|
||||
- **Material components.** Buttons (filled / tonal / outlined / text), FAB, switches, chips, snackbars, bottom sheets, Material dialogs, navigation bar/rail/drawer. Never port iOS controls or invent equivalents.
|
||||
- **One FAB, one primary action.** Never stack FABs or spend one on a secondary task.
|
||||
- **Snackbars for transient feedback** (actionable when useful, never a toast for that); dialogs only for decisions that must interrupt.
|
||||
- **Material motion patterns.** Container transform, shared-axis, fade-through, with standard easing and durations; honor the system Remove animations setting with a crossfade or instant cut.
|
||||
@@ -10,6 +10,8 @@ Brand: motion is part of the voice; one well-rehearsed entrance beats scattered
|
||||
|
||||
Product: 150–250 ms on most transitions. Motion conveys state: feedback, reveal, loading, transitions between views. No page-load choreography; users are in a task and won't wait for it.
|
||||
|
||||
Native (`ios` / `android` / `adaptive`): implementation follows the Motion section of [ios.md](ios.md) / [android.md](android.md) (read it first if Setup hasn't already): system transitions and OS Reduce Motion, never the web tooling below.
|
||||
|
||||
---
|
||||
|
||||
## Assess Animation Opportunities
|
||||
|
||||
@@ -2,6 +2,8 @@ Run systematic **technical** quality checks and generate a comprehensive report.
|
||||
|
||||
This is a code-level audit, not a design critique. Check what's measurable and verifiable in the implementation.
|
||||
|
||||
**Web only.** Native platforms (`ios` / `android` / `adaptive`) route to [audit.native.md](audit.native.md) instead; if the project is native, switch to it now.
|
||||
|
||||
## Diagnostic Scan
|
||||
|
||||
Run comprehensive checks across 5 dimensions. Score each dimension 0-4 using the criteria below.
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
Run systematic **technical** quality checks on a native app (`ios` / `android` / `adaptive`) and generate a comprehensive report. Don't fix issues; document them for other commands to address.
|
||||
|
||||
This is a code-level audit, not a design critique. Audit from source (SwiftUI / UIKit / Compose / React Native / Flutter); no browser tooling or `detect.mjs` applies. Score against the platform reference(s): [ios.md](ios.md) / [android.md](android.md), both for `adaptive`. Read them before scoring if Setup hasn't already. The report skeleton mirrors [audit.md](audit.md); keep the two in sync when changing it.
|
||||
|
||||
## Diagnostic Scan
|
||||
|
||||
Run comprehensive checks across 5 dimensions. Score each dimension 0-4 using the criteria below.
|
||||
|
||||
### 1. Accessibility (VoiceOver / TalkBack)
|
||||
|
||||
**Check for**:
|
||||
- **Missing labels**: interactive elements without accessibility labels, traits/roles, or state announcements
|
||||
- **Reading and focus order**: illogical traversal, unreachable controls, focus lost on navigation
|
||||
- **Text scaling**: fixed point sizes defeating Dynamic Type (iOS) or px instead of sp (Android); layouts that clip or overlap at large sizes
|
||||
- **Touch targets**: below 44 pt (iOS) / 48 dp (Android), or crammed without spacing
|
||||
- **Reduce Motion ignored**: parallax and large slides with no crossfade alternative
|
||||
- **Contrast**: text failing contrast in either appearance, light or dark
|
||||
|
||||
**Score 0-4**: 0=Screen reader unusable, 1=Major gaps (unlabeled controls, no scaling), 2=Partial (labels exist, order or scaling breaks), 3=Good (minor gaps), 4=Excellent (labeled, ordered, scales cleanly, Reduce Motion honored)
|
||||
|
||||
### 2. Performance
|
||||
|
||||
**Check for**:
|
||||
- **Slow startup**: heavy work on launch before first frame
|
||||
- **Unvirtualized lists**: long content without FlatList / LazyColumn / List recycling
|
||||
- **Main-thread jank**: synchronous work in scroll or gesture paths, dropped frames on 60/120 Hz
|
||||
- **Wasted rendering**: unnecessary re-renders (React Native) or recompositions (Compose); missing memoization/keys
|
||||
- **Image handling**: full-size images decoded for thumbnails, no caching
|
||||
- **App weight**: bloated JS bundle or binary, unused dependencies
|
||||
|
||||
**Score 0-4**: 0=Janky everywhere, 1=Major problems (unvirtualized lists, slow launch), 2=Partial, 3=Good (minor improvements possible), 4=Excellent (fast launch, smooth scroll, lean)
|
||||
|
||||
### 3. Appearance & Theming
|
||||
|
||||
**Check for**:
|
||||
- **Hard-coded colors**: raw hex instead of semantic system colors (iOS) / Material color roles (Android) / design tokens
|
||||
- **Broken dark appearance**: missing dark variants, poor contrast in dark, quick inverts
|
||||
- **Dynamic Color** (Android 12+): no static fallback scheme, or ignored where it fits
|
||||
- **Off-platform materials**: hand-rolled blur/glassmorphism instead of system materials or tonal elevation
|
||||
|
||||
**Score 0-4**: 0=Hard-coded everything, 1=Minimal tokens, 2=Partial (tokens exist, inconsistently used), 3=Good (minor hard-coded values), 4=Excellent (semantic throughout, both appearances first-class)
|
||||
|
||||
### 4. Platform Conformance (CRITICAL)
|
||||
|
||||
Score against the loaded platform reference(s), including their slop tests. **Check for**:
|
||||
- **Broken system gestures**: edge-swipe back disabled (iOS), predictive Back hijacked (Android)
|
||||
- **Inset violations**: content under the notch, Dynamic Island, home indicator, status bar, or keyboard
|
||||
- **Off-platform navigation**: custom global nav, overloaded tab bars, iOS patterns on Android or vice versa
|
||||
- **Web-shaped controls**: HTML-style buttons, custom toggles, hover-dependent affordances
|
||||
- **Icon drift**: mixed icon sets instead of SF Symbols / Material Symbols
|
||||
- **AI tells**: the shared absolute bans still apply (AI palette, gradient text, hero metrics)
|
||||
|
||||
**Score 0-4**: 0=Web port (nothing native), 1=Heavy violations (3-4 kinds), 2=Some (1-2 noticeable), 3=Mostly conformant (subtle issues), 4=Fully native (a fluent user trusts every screen)
|
||||
|
||||
### 5. Adaptivity
|
||||
|
||||
**Check for**:
|
||||
- **Stretched phone layouts**: tablet/iPad rendering a scaled-up phone UI instead of using size classes / window size classes
|
||||
- **Orientation breakage**: landscape clipping, ignored, or locked without reason
|
||||
- **Keyboard/IME handling**: inputs hidden behind the keyboard, no inset adjustment
|
||||
- **Multitasking**: iPad Split View / Android multi-window breaking layout
|
||||
- **Foldables**: hinge-unaware layouts on posture change (Android)
|
||||
|
||||
**Score 0-4**: 0=One screen size only, 1=Major breakage (landscape or tablet broken), 2=Partial, 3=Good (minor edge cases), 4=Excellent (adapts across sizes, orientations, and windowing)
|
||||
|
||||
## Generate Report
|
||||
|
||||
### Audit Health Score
|
||||
|
||||
| # | Dimension | Score | Key Finding |
|
||||
|---|-----------|-------|-------------|
|
||||
| 1 | Accessibility | ? | [most critical issue or "--"] |
|
||||
| 2 | Performance | ? | |
|
||||
| 3 | Appearance & Theming | ? | |
|
||||
| 4 | Platform Conformance | ? | |
|
||||
| 5 | Adaptivity | ? | |
|
||||
| **Total** | | **??/20** | **[Rating band]** |
|
||||
|
||||
**Rating bands**: 18-20 Excellent (minor polish), 14-17 Good (address weak dimensions), 10-13 Acceptable (significant work needed), 6-9 Poor (major overhaul), 0-5 Critical (fundamental issues)
|
||||
|
||||
### Platform Conformance Verdict
|
||||
**Start here.** Pass/fail: does this read as a native app or a ported website? List specific violations. Be brutally honest.
|
||||
|
||||
### Executive Summary
|
||||
- Audit Health Score: **??/20** ([rating band])
|
||||
- Total issues found (count by severity: P0/P1/P2/P3)
|
||||
- Top 3-5 critical issues
|
||||
- Recommended next steps
|
||||
|
||||
### Detailed Findings by Severity
|
||||
|
||||
Tag every issue with **P0-P3 severity**:
|
||||
- **P0 Blocking**: Prevents task completion. Fix immediately
|
||||
- **P1 Major**: Significant difficulty or platform-guideline violation. Fix before release
|
||||
- **P2 Minor**: Annoyance, workaround exists. Fix in next pass
|
||||
- **P3 Polish**: Nice-to-fix, no real user impact. Fix if time permits
|
||||
|
||||
For each issue, document:
|
||||
- **[P?] Issue name**
|
||||
- **Location**: Screen, file, line
|
||||
- **Category**: Accessibility / Performance / Theming / Conformance / Adaptivity
|
||||
- **Impact**: How it affects users
|
||||
- **Guideline**: The HIG / Material rule it violates (if applicable)
|
||||
- **Recommendation**: How to fix it
|
||||
- **Suggested command**: Which command to use (prefer: /impeccable adapt, /impeccable animate, /impeccable audit, /impeccable bolder, /impeccable clarify, /impeccable colorize, /impeccable critique, /impeccable delight, /impeccable distill, /impeccable document, /impeccable harden, /impeccable layout, /impeccable onboard, /impeccable optimize, /impeccable overdrive, /impeccable polish, /impeccable quieter, /impeccable shape, /impeccable typeset)
|
||||
|
||||
### Patterns & Systemic Issues
|
||||
|
||||
Identify recurring problems that indicate systemic gaps rather than one-off mistakes:
|
||||
- "Hard-coded colors appear in 15+ screens, should use semantic colors"
|
||||
- "Touch targets consistently below 44 pt throughout the tab bar and list rows"
|
||||
|
||||
### Positive Findings
|
||||
|
||||
Note what's working well: good practices to maintain and replicate.
|
||||
|
||||
## Recommended Actions
|
||||
|
||||
List recommended commands in priority order (P0 first, then P1, then P2):
|
||||
|
||||
1. **[P?] `/command-name`**: Brief description (specific context from audit findings)
|
||||
2. **[P?] `/command-name`**: Brief description (specific context)
|
||||
|
||||
**Rules**: Only recommend commands from: /impeccable adapt, /impeccable animate, /impeccable audit, /impeccable bolder, /impeccable clarify, /impeccable colorize, /impeccable critique, /impeccable delight, /impeccable distill, /impeccable document, /impeccable harden, /impeccable layout, /impeccable onboard, /impeccable optimize, /impeccable overdrive, /impeccable polish, /impeccable quieter, /impeccable shape, /impeccable typeset. Map findings to the most appropriate command. End with `/impeccable polish` as the final step if any fixes were recommended.
|
||||
|
||||
After presenting the summary, tell the user:
|
||||
|
||||
> You can ask me to run these one at a time, all at once, or in any order you prefer.
|
||||
>
|
||||
> Re-run `/impeccable audit` after fixes to see your score improve.
|
||||
|
||||
**IMPORTANT**: Be thorough but actionable. Too many P3 issues creates noise. Focus on what actually matters.
|
||||
|
||||
**NEVER**:
|
||||
- Report issues without explaining impact (why does this matter?)
|
||||
- Provide generic recommendations (be specific and actionable)
|
||||
- Skip positive findings (celebrate what works)
|
||||
- Forget to prioritize (everything can't be P0)
|
||||
- Report false positives without verification
|
||||
@@ -6,6 +6,8 @@ The hook runs the impeccable design detector on direct file edits to design-rele
|
||||
|
||||
This command toggles the hook **per project** by editing `.impeccable/config.json` (the unified Impeccable config; hook runtime settings live under its `hook` key, and shared detector ignores live under `detector`). Per-developer overrides, including the install consent decision (`hook.consent`) the CLI records, live in the gitignored `.impeccable/config.local.json`. Set `hook.enabled: false` to turn the hook off, `hook.quiet: true` to silence the clean/pending acks, or `hook.auditLog` to a file path for an NDJSON log. The legacy `IMPECCABLE_HOOK_DISABLED`, `IMPECCABLE_HOOK_QUIET`, and `IMPECCABLE_HOOK_LOG` env vars are still honored and override these config values when set.
|
||||
|
||||
Declare server-side template extensions under **`detector.extensions`** when the project uses Blade, Twig, ERB, or Handlebars files; the hook skips them otherwise because they sit outside the built-in extension list. One entry per extension, `{ "ext": ".blade.php", "engine": "html" }`. `engine` picks the analyzer (`html` for markup templates, `text` for JS/TS/CSS-like files) and defaults to `html`. Match against the end of the filename, so double extensions like `.blade.php` and `.html.erb` work. Config only adds extensions; the built-in list always applies.
|
||||
|
||||
Manual `npx impeccable detect` scans use the same project filter config by default: `detector.ignoreRules`, `detector.ignoreFiles`, `detector.ignoreValues`, and `detector.designSystem.enabled`. `hook.enabled` only controls automatic hook execution, not manual CLI scans. Use `npx impeccable detect --no-config ...` for a raw detector run that ignores project config/context. Use `npx impeccable ignores ...` for direct CLI CRUD on the same detector ignores.
|
||||
|
||||
Supported harnesses: Claude Code (`.claude/settings.local.json` in the project, which is gitignored so the hook stays machine-local; a hook you move into the shared `settings.json` is honored in place too), Codex (`.codex/hooks.json` in the project), Cursor (`.cursor/hooks.json` in the project), and GitHub Copilot (`.github/hooks/impeccable.json` in the project, a team-shared committed file that both the Copilot CLI and the cloud agent read). For the Copilot CLI, repo-level hooks fire once `.github/hooks/impeccable.json` is committed to the repository's default branch.
|
||||
@@ -79,7 +81,7 @@ node .claude/skills/impeccable/scripts/hook-admin.mjs ignore-file "src/legacy/Ca
|
||||
|
||||
## Constraints
|
||||
|
||||
- Never modify `.impeccable/config.json` or `.impeccable/config.local.json` by hand from this command. Always go through `hook-admin.mjs` so writes stay validated and the file shape stays consistent.
|
||||
- Never modify `.impeccable/config.json` or `.impeccable/config.local.json` by hand from this command. Always go through `hook-admin.mjs` so writes stay validated and the file shape stays consistent. One exception: `detector.extensions` has no admin action, so when the user asks to cover a template stack, edit that one field in `.impeccable/config.json` directly and leave the rest of the file untouched.
|
||||
- Do not edit the hook scripts themselves (`hook.mjs`, `hook-lib.mjs`, `hook-before-edit.mjs`) from this flow. Those are skill plumbing.
|
||||
- Cursor can block a proposed write when the detector finds a real issue. Claude Code, Codex, and GitHub Copilot do not block the edit; they emit a post-edit reminder instead. Disabling stops both blocking and reminders.
|
||||
- The hook is bundled with the Impeccable skill and installed through project-local manifests: `.claude/settings.local.json`, `.codex/hooks.json`, `.cursor/hooks.json`, and `.github/hooks/impeccable.json`. On Codex, the user must approve the hook via `/hooks` the first time. On Cursor, confirm hooks are enabled under Settings -> Hooks. On GitHub Copilot, the CLI loads `.github/hooks/impeccable.json` once it is committed to the repository's default branch, and the cloud agent reads it from the repo directly.
|
||||
|
||||
@@ -16,6 +16,7 @@ Decision tree:
|
||||
- **Neither file exists (empty project or no context yet)**: do Steps 2-4 (write PRODUCT.md), then decide on DESIGN.md based on whether there's code to analyze.
|
||||
- **PRODUCT.md exists, DESIGN.md missing**: skip to Step 5 and offer to run `/impeccable document` for DESIGN.md.
|
||||
- **PRODUCT.md exists but has no `## Register` section (legacy)**: add it. Infer a hypothesis from the codebase (see Step 2), confirm with the user, write the field.
|
||||
- **PRODUCT.md exists but has no `## Platform` section (legacy)**: add it the same way, but only when the project is native (`ios` / `android` / `adaptive`) or the user wants it explicit; a missing field already means `web`.
|
||||
- **Both exist**: STOP and call the AskUserQuestion tool to clarify. Ask which file to refresh. Skip the one the user doesn't want changed.
|
||||
- **Just DESIGN.md exists (unusual)**: do Steps 2-4 to produce PRODUCT.md.
|
||||
|
||||
@@ -41,26 +42,34 @@ Also form a **register hypothesis** from what you find:
|
||||
|
||||
Register is a hypothesis at this point, not a decision; Step 3 confirms it.
|
||||
|
||||
Also form a **platform hypothesis**:
|
||||
|
||||
- Native signals: React Native / Expo (`react-native`, `expo`), Flutter (`pubspec.yaml`, `flutter`), SwiftUI / UIKit (`.swift`, `.xcodeproj`, an `ios/` app target), Jetpack Compose / Android (`build.gradle`, an `android/` app module, `AndroidManifest.xml`). An `ios/` and/or `android/` directory that is a real app target, not just a Capacitor/Cordova wrapper around a website.
|
||||
- Web signals (the default): a web framework (Vite, Next, Nuxt, SvelteKit, Astro), an HTML entry, a CSS/Tailwind setup, no native app target.
|
||||
|
||||
Values: `web` / `ios` / `android` / `adaptive` (one codebase, ships both, adapts per OS). Mobile web is still `web`. Like register, this is a hypothesis; Step 3 confirms it.
|
||||
|
||||
Note what you've learned and what remains unclear. Also note any rough edges worth a follow-up command (thin hierarchy, flat or gray palette, missing error/empty states, dull copy); Step 7 turns these into concrete recommendations without re-analyzing.
|
||||
|
||||
## Step 3: Ask strategic questions (for PRODUCT.md)
|
||||
|
||||
STOP and call the AskUserQuestion tool to clarify. Ask only about what you couldn't infer from the codebase.
|
||||
STOP and call the AskUserQuestion tool to clarify. Ask about anything the codebase doesn't answer with strong, explicit evidence.
|
||||
|
||||
### Interview mode, not confirmation mode
|
||||
|
||||
If the repo is empty or the user's brief is sparse, run a short interview before proposing PRODUCT.md. Do **not** turn a one-sentence request into a complete inferred PRODUCT.md and ask for blanket confirmation.
|
||||
|
||||
- Use the harness's structured question tool when one exists. Otherwise, ask directly in chat and stop.
|
||||
- Ask **2-3 questions per round**, then wait for answers.
|
||||
- Use the harness's structured question tool when one exists. Otherwise, ask directly in chat and stop: one question at a time, with lettered options where the crawl suggests likely answers, waiting for each answer before the next.
|
||||
- Keep skill vocabulary (register, belief ladder, anti-references) out of question text; ask for the thing in words the user would use. For the brand register, ask like a magazine editor profiling the brand: curious and narrative, drawing out the story, the feel, and what a visitor should come to believe.
|
||||
- Ask in focused rounds and wait for answers between them. Keep **one topic per question**; add rounds rather than fold several topics into one either-or choice. Options obey the same rule: an option answers only the question asked; never write a compound option that bundles a feeling with a business outcome or names an additional audience.
|
||||
- Use inferred answers as hypotheses or options, not as finished facts.
|
||||
- Complete at least one real user-answer round before drafting PRODUCT.md, unless every required answer is directly discoverable from repo docs.
|
||||
- Round 1 should establish register, users/purpose, and desired outcome.
|
||||
- Round 2 should establish brand personality or references, anti-references, and accessibility needs.
|
||||
- Round 1 should establish register, platform, users, purpose, positioning, and desired outcome.
|
||||
- Round 2 should establish brand personality or references, anti-references, and accessibility needs, plus conversion & proof for the brand register.
|
||||
|
||||
### Minimum viable interview
|
||||
|
||||
Ask enough to complete PRODUCT.md. At minimum, cover register confirmation, users and purpose, brand personality, anti-references, and accessibility needs unless each answer is directly discoverable from repo context. After at least one interview round, you may propose inferred answers, but the user must confirm them before you write PRODUCT.md. Never synthesize PRODUCT.md from the original task prompt alone.
|
||||
Ask enough to complete PRODUCT.md. At minimum, cover register confirmation, **platform confirmation** (`web` / `ios` / `android` / `adaptive`), users, purpose, positioning, brand personality, anti-references, and accessibility needs (plus conversion & proof for the brand register) unless each answer is directly discoverable from repo context. Never let the template's default `web` stand unconfirmed for a native or cross-platform repo. After at least one interview round, you may propose inferred answers, but the user must confirm them before you write PRODUCT.md. Never synthesize PRODUCT.md from the original task prompt alone.
|
||||
|
||||
### Register (ask first; it shapes everything below)
|
||||
|
||||
@@ -68,20 +77,42 @@ Every design task is either **brand** (marketing, landing, campaign, long-form c
|
||||
|
||||
If Step 2 produced a clear hypothesis, lead with it: *"From the codebase, this looks like a [brand / product] surface. Does that match your intent, or should we treat it differently?"*
|
||||
|
||||
If the signal is genuinely split (e.g. a product with a big marketing landing), STOP and call the AskUserQuestion tool to clarify. Ask which register describes the **primary** surface. The register can be overridden per task later, but PRODUCT.md carries one default.
|
||||
If the signal is genuinely split (e.g. a product with a big marketing landing), STOP and call the AskUserQuestion tool to clarify. Ask which register describes the **primary** surface. The register can be overridden per task later, but PRODUCT.md carries one default. Settle the default before drafting any register-dependent questions; never batch brand-only questions (Conversion & proof) into the same round as the question that decides the register.
|
||||
|
||||
### Platform (ask right after register)
|
||||
|
||||
Every project targets **web** (includes responsive mobile web), **ios**, **android**, or **adaptive** (one codebase, ships both, adapts per OS: Flutter, React Native, KMP). Platform picks the native rulebook: HIG for `ios`, Material 3 for `android`, both for `adaptive`, none for `web`.
|
||||
|
||||
If Step 2 produced a clear hypothesis, lead with it: *"From the codebase, this looks like a [web / ios / android / adaptive] project. Does that match?"* For cross-platform apps, decide by the **design language the app renders**, not the toolchain: one look on both platforms (Flutter's Material-everywhere default) takes that platform's value; genuine per-OS adaptation (Cupertino on iOS, Material on Android) is `adaptive`. When in doubt, `web`.
|
||||
|
||||
A monorepo shipping both a website and a native app gets a PRODUCT.md per app, each with its own `## Platform`; the root PRODUCT.md carries the primary surface's platform.
|
||||
|
||||
### Users & Purpose
|
||||
- Who uses this? What's their context when using it?
|
||||
- What job are they trying to get done?
|
||||
- For brand: what emotions should the interface evoke? (confidence, delight, calm, urgency)
|
||||
- What is this for? A purpose stated in README or docs is a hypothesis, not strong evidence; confirm it, don't transcribe it.
|
||||
- What does success look like?
|
||||
- If more than one kind of user is plausible, confirm a primary and secondary audience; don't manufacture a split that isn't there. An audience implied by another answer (a success metric, a CTA) is still unconfirmed; ask before writing it as secondary.
|
||||
- If the surface speaks to a different audience than the people who use the product, ask the user to name both.
|
||||
- For brand: what emotions should the interface evoke? (confidence, delight, calm, urgency) Ask this standalone; don't fold emotions into the success question.
|
||||
- For product: what workflow are they in? What's the primary task on any given screen?
|
||||
|
||||
### Positioning
|
||||
- In one line, what does this do that nothing else does? The single strategic claim every screen reinforces.
|
||||
|
||||
### Brand & Personality
|
||||
- How would you describe the brand personality in 3 words?
|
||||
- Reference sites or apps that capture the right feel? What specifically about them?
|
||||
- Push for specific named references with the *specific* thing about them that fits this brand, not generic "modern" adjectives or category-bucket lanes.
|
||||
- What should this explicitly NOT look like? Any anti-references?
|
||||
|
||||
### Conversion & proof (brand register only)
|
||||
- What's the primary CTA?
|
||||
- What's the secondary fallback, for visitors not ready for the primary?
|
||||
- The one line a visitor should remember after 10 seconds.
|
||||
- What must the visitor believe, in order, before taking the primary CTA? (The template's belief ladder.)
|
||||
- What proof is on hand? Ask the user to hand over any testimonials, case studies, press, or client/partner logos they already have. If you can receive files directly, collect them; otherwise create `.impeccable/assets/proof/` and ask the user to add files there. Reference supplied files by path; record text proof inline.
|
||||
|
||||
### Accessibility & Inclusion
|
||||
- Specific accessibility requirements? (WCAG level, known user needs)
|
||||
- Considerations for reduced motion, color blindness, or other accommodations?
|
||||
@@ -90,7 +121,7 @@ Skip questions where the answer is already clear. **Do NOT ask about colors, fon
|
||||
|
||||
## Step 4: Write PRODUCT.md
|
||||
|
||||
Write PRODUCT.md only after the user has confirmed the strategic answers from Step 3. If an inferred answer is uncertain or unconfirmed, ask before writing.
|
||||
Write PRODUCT.md only after the user has confirmed the strategic answers from Step 3. If an inferred answer is uncertain or unconfirmed, ask before writing. Confirmed means what the user actually said yes to; do not pad a confirmed answer with extras they never picked (additional anti-references, audiences, roadmap claims, a WCAG level), whether drawn from the crawl, another answer, or your own option text. If an extra belongs in the doc, ask about it first.
|
||||
|
||||
Synthesize into a strategic document:
|
||||
|
||||
@@ -101,12 +132,26 @@ Synthesize into a strategic document:
|
||||
|
||||
product
|
||||
|
||||
## Platform
|
||||
|
||||
web
|
||||
|
||||
## Users
|
||||
[Who they are, their context, the job to be done]
|
||||
[Who they are, their context, the job to be done. Primary audience; a secondary audience or a surface-vs-user split only when they apply.]
|
||||
|
||||
## Product Purpose
|
||||
[What this product does, why it exists, what success looks like]
|
||||
|
||||
## Positioning
|
||||
[The single strategic claim every screen reinforces. Not a visual rule, not an anti-reference.]
|
||||
|
||||
## Conversion & proof
|
||||
[Brand register only. Product register: omit this section entirely, heading included.]
|
||||
- Primary and secondary CTA: [...]
|
||||
- The line a visitor remembers after 10 seconds: [...]
|
||||
- Belief ladder: [...]
|
||||
- Proof on hand: [testimonials, case studies, press, or logos, referenced by path]
|
||||
|
||||
## Brand Personality
|
||||
[Voice, tone, 3-word personality, emotional goals]
|
||||
|
||||
@@ -120,7 +165,9 @@ product
|
||||
[WCAG level, known user needs, considerations]
|
||||
```
|
||||
|
||||
Register is either `brand` or `product` as a bare value. No prose, no commentary.
|
||||
Register is either `brand` or `product` as a bare value. No prose, no commentary. Platform is `web`, `ios`, `android`, or `adaptive`, also a bare value; omit the section only on legacy files you're leaving untouched, otherwise write `web` explicitly.
|
||||
|
||||
Write fields as prose, and use bold sparingly: only where a word carries a decision, never as a label lead-in on every line.
|
||||
|
||||
Write to `PROJECT_ROOT/PRODUCT.md`. If `.impeccable.md` existed, the loader already renamed it; merge into that content rather than starting from scratch.
|
||||
|
||||
@@ -137,6 +184,8 @@ If the user prefers to skip, mention they can run `/impeccable document` any tim
|
||||
|
||||
## Step 6: Configure live mode (when code exists)
|
||||
|
||||
**Skip this step when the platform is native** (`ios` / `android` / `adaptive`): live mode drives a browser overlay. A hybrid wrapper or Expo web target serving HTML doesn't change that.
|
||||
|
||||
If the project has code with HTML entries and a dev server (the same "code exists" condition that puts `/impeccable document` in scan mode), pre-configure live mode now. You already identified the framework and the served HTML entry in Step 2, so this is nearly free, and it spares the user the first-time setup detour when they later run `/impeccable live`.
|
||||
|
||||
**Skip this step for empty / pre-implementation projects** (nothing to inject into yet). Tell the user live mode will configure itself the first time they run it once there's code.
|
||||
@@ -154,16 +203,16 @@ Writing the config file is harmless and needs no consent; only the CSP **source-
|
||||
## Step 7: Recommend starting points, then wrap up
|
||||
|
||||
Summarize tersely:
|
||||
- Register captured (brand / product)
|
||||
- Register captured (brand / product) and platform captured (web / ios / android / adaptive)
|
||||
- What was written (PRODUCT.md, DESIGN.md, live config, or a subset)
|
||||
- The 3-5 strategic principles from PRODUCT.md that will guide future work
|
||||
- If DESIGN.md or live config is pending, one line on how to set it up later
|
||||
|
||||
Then recommend the **best commands to run next**, drawn from what your Step 2 crawl already surfaced. Do not run a fresh analysis here; surface observations you already have. Tailor to register and to what you saw, offer the 2-4 most relevant (not a menu dump), and give the exact command to type. Group by intent:
|
||||
Then recommend the **best commands to run next**, drawn from what your Step 2 crawl already surfaced. Do not run a fresh analysis here; surface observations you already have. Tailor to register **and platform**, offer the 2-4 most relevant (not a menu dump), and give the exact command to type. Group by intent:
|
||||
|
||||
- **Build something new**: `/impeccable craft <feature>` (shape, then build end-to-end) or `/impeccable shape <feature>` (plan first). Lead with this for empty or early-stage projects.
|
||||
- **Improve what's there**: name the specific surface. `/impeccable critique <page>` for a scored UX review; `/impeccable audit <area>` for a11y / perf / responsive checks; `/impeccable polish <component>` for a pre-ship pass. When the crawl flagged a specific weakness, point the matching command at it: thin hierarchy or spacing → `layout`, flat or gray palette → `colorize`, missing error / empty states → `harden` or `onboard`, dull or unclear copy → `clarify`.
|
||||
- **Iterate visually**: `/impeccable live` (configured in Step 6) to pick elements in the browser and generate variants in place.
|
||||
- **Iterate visually** (web only): `/impeccable live` (configured in Step 6) to pick elements in the browser and generate variants in place. **Skip this group for native platforms.**
|
||||
|
||||
The full command menu is one bare `/impeccable` away; keep this list short and pointed.
|
||||
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
# iOS platform
|
||||
|
||||
For native iOS / iPadOS apps: SwiftUI, UIKit, React Native, Expo, Flutter shipping to Apple hardware.
|
||||
|
||||
On native, register narrows. HIG conformance governs structure, navigation, and interaction whatever the register; brand expresses through the expressive layer the platform provides (tint, type, motion, content). Calm, Duolingo, and Spotify carry strong identity entirely inside HIG conventions.
|
||||
|
||||
## The iOS slop test
|
||||
|
||||
Would a fluent iPhone user trust this app, or pause at off-spec controls? The tell is "ported from a website": reinvented navigation bars, custom back gestures, web-shaped buttons, hover-dependent affordances. Default to the platform's components; depart only for a reason the user would thank you for.
|
||||
|
||||
## Layout & structure
|
||||
|
||||
- **Safe area.** Lay out inside the safe-area insets. No controls under the notch, Dynamic Island, home indicator, or rounded corners.
|
||||
- **System navigation.** Tab bar for 2–5 top-level sections (sections, never actions), navigation stack for hierarchy, sheet for self-contained tasks. No custom global nav, no mixed metaphors.
|
||||
- **Edge-swipe back stays alive.** The left-edge back gesture is muscle memory; never disable or overlay it.
|
||||
- **Large titles** on top-level screens, collapsing to inline on scroll. Deep detail screens stay inline.
|
||||
|
||||
## Touch targets
|
||||
|
||||
- **44×44 pt minimum** for every tappable control, with breathing room between adjacent targets.
|
||||
|
||||
## Typography
|
||||
|
||||
- **Dynamic Type.** Use the system text styles (Large Title through Caption) so text follows the user's reading size. No hard-coded point sizes.
|
||||
- **San Francisco carries the UI.** Body, labels, and controls stay on SF Pro / SF Compact; a brand face may appear in display moments.
|
||||
- **11 pt floor**; Body is 17 pt.
|
||||
|
||||
## Color & materials
|
||||
|
||||
- **Semantic system colors** (label, secondaryLabel, systemBackground, separator, tint). They adapt to Dark Mode and increased contrast automatically; raw hex breaks there.
|
||||
- **Dark Mode is a first-class appearance.** Design and test both.
|
||||
- **One tint color** drives interactive elements; decoration is not its job.
|
||||
- **System materials** for blur and translucency behind bars and sheets; no hand-rolled glassmorphism.
|
||||
|
||||
## Components & controls
|
||||
|
||||
- **Platform controls.** Switch, segmented control, stepper, system pickers, action sheets, alerts, context menus, swipe actions. Reinventing these for flavor is the most common native slop.
|
||||
- **SF Symbols** for iconography: baseline-aligned, Dynamic Type-aware, weight and scale variants. Don't mix in a web icon set.
|
||||
- **Deliberate modality.** Sheet for a focused dismissible sub-task, full-screen cover for immersion. Clear Cancel/Done; honor swipe-to-dismiss unless data loss requires a guard.
|
||||
- **Grouped/inset lists** for settings-shaped content; no bespoke card stacks.
|
||||
|
||||
## Motion
|
||||
|
||||
- **System transitions.** Push slides, sheets rise, dismiss reverses the entrance. Custom transitions that fight the navigation model disorient.
|
||||
- **Honor Reduce Motion.** Crossfade instead of parallax and large slides.
|
||||
@@ -8,11 +8,33 @@ Brand: asymmetric compositions, fluid spacing with `clamp()`, intentional grid-b
|
||||
|
||||
Product: predictable grids, consistent densities, familiar navigation patterns. Responsive behavior is structural (collapse sidebar, responsive table), not fluid typography. Consistency IS an affordance.
|
||||
|
||||
Native (`ios` / `android` / `adaptive`): structure follows the Layout section of [ios.md](ios.md) / [android.md](android.md) (read it first if Setup hasn't already): platform navigation, insets, and touch targets, never the CSS tooling below.
|
||||
|
||||
---
|
||||
|
||||
## Two isolated assessments (required)
|
||||
|
||||
Spawn two parallel sub-agents whenever a sub-agent/Task tool is exposed: one for the layout assessment, one for the mechanical pre-scan. If the harness needs explicit user permission for sub-agents, stop and ask before proceeding. Isolation is the point: detector output anchors visual judgment toward what the scan can see, so neither sub-agent gets the other's output. Each assessment runs in its own sub-agent; running either one in this context when a sub-agent tool exists is not permitted, even when it is faster; the fallback below is only for sessions with no sub-agent tool. Give each a self-contained prompt (target files, register, documented spacing scale when present, and its instructions below); do not assume it can read this file.
|
||||
|
||||
**Sub-agent A (layout assessment)**: give it the full [Assess Current Layout](#assess-current-layout) checklist below, verbatim, in its prompt. It works through every item and returns per-item findings citing file, selector, or value.
|
||||
|
||||
**Sub-agent B (mechanical pre-scan)**: run the bundled detector scoped to layout:
|
||||
|
||||
```bash
|
||||
node .claude/skills/impeccable/scripts/detect.mjs --json --scope layout [target files or dirs]
|
||||
```
|
||||
|
||||
A missing `node` on PATH is not permission to skip: hunt for a runtime (`command -v node`, nvm or Homebrew paths, the harness's own bundled node) and run it by full path. If none exists, halt the scan and report that Node must be installed (the parent relays this to the user); do **not** substitute grep for the detector or proceed unscanned. The detector abstains on arbitrary Tailwind spacing (`gap-[13px]`, `p-[7px]`) and ad-hoc `z-index` stacks, so when the project documents a spacing scale, also grep `gap-\[`, `p[trblxy]?-\[`, `m[trblxy]?-\[`, `z-\[` and judge those hits against it. Return the findings JSON plus the grep verdicts.
|
||||
|
||||
**If no sub-agent tool is exposed (or the user declined)**: run both yourself, assessment first, pre-scan second, so the deterministic findings can't anchor the visual judgment. Keep that order even when the scan feels quicker to start with.
|
||||
|
||||
**Synthesize** once both are done: merge into a single findings list, noting where they agree and what each caught alone. Fix every finding, or list it as a deliberate exception for the user to accept. A clean scan is a floor, not a verdict: a monotone grid with uniform spacing passes every detector rule, which is exactly what the assessment exists to catch. State in your final summary which path ran (parallel sub-agents or single-context fallback).
|
||||
|
||||
---
|
||||
|
||||
## Assess Current Layout
|
||||
|
||||
Analyze what's weak about the current spatial design:
|
||||
This checklist is sub-agent A's brief (on the fallback path, work through it yourself before the pre-scan). Analyze what's weak about the current spatial design:
|
||||
|
||||
1. **Spacing**:
|
||||
- Is spacing consistent or arbitrary? (Random padding/margin values)
|
||||
@@ -138,6 +160,8 @@ Create a systematic plan:
|
||||
- **Consistency**: Is the spacing system applied uniformly?
|
||||
- **Responsiveness**: Does the layout adapt gracefully across screen sizes?
|
||||
|
||||
Answer each item above by citing the file, selector, or value that satisfies it; never a bare yes. Then re-run the pre-scan and fix until the count of unresolved items and unaccepted findings is zero.
|
||||
|
||||
When the rhythm and hierarchy land, hand off to `/impeccable polish` for the final pass.
|
||||
|
||||
## Live-mode signature params
|
||||
|
||||
@@ -10,9 +10,29 @@ Product: system fonts and familiar sans stacks are legitimate here. One well-tun
|
||||
|
||||
---
|
||||
|
||||
## Two isolated assessments (required)
|
||||
|
||||
Spawn two parallel sub-agents whenever a sub-agent/Task tool is exposed: one for the typography assessment, one for the mechanical pre-scan. If the harness needs explicit user permission for sub-agents, stop and ask before proceeding. Isolation is the point: detector output anchors visual judgment toward what the scan can see, so neither sub-agent gets the other's output. Each assessment runs in its own sub-agent; running either one in this context when a sub-agent tool exists is not permitted, even when it is faster; the fallback below is only for sessions with no sub-agent tool. Give each a self-contained prompt (target files, register, **DESIGN.md** content when present, and its instructions below); do not assume it can read this file.
|
||||
|
||||
**Sub-agent A (typography assessment)**: give it the full [Assess Current Typography](#assess-current-typography) checklist below, verbatim, in its prompt. It works through every item and returns per-item findings citing file, selector, or value.
|
||||
|
||||
**Sub-agent B (mechanical pre-scan)**: run the bundled detector scoped to type:
|
||||
|
||||
```bash
|
||||
node .claude/skills/impeccable/scripts/detect.mjs --json --scope type [target files or dirs]
|
||||
```
|
||||
|
||||
A missing `node` on PATH is not permission to skip: hunt for a runtime (`command -v node`, nvm or Homebrew paths, the harness's own bundled node) and run it by full path. If none exists, halt the scan and report that Node must be installed (the parent relays this to the user); do **not** substitute grep for the detector or proceed unscanned. The scan checks literal font sizes against the **DESIGN.md** ramp but abstains on `em`, `%`, `clamp()`, and line-heights, so also grep `font-size\s*:`, `fontSize`, `text-\[`, `leading-\[` and judge those hits against the spec. Return the findings JSON plus the grep verdicts.
|
||||
|
||||
**If no sub-agent tool is exposed (or the user declined)**: run both yourself, assessment first, pre-scan second, so the deterministic findings can't anchor the visual judgment. Keep that order even when the scan feels quicker to start with.
|
||||
|
||||
**Synthesize** once both are done: merge into a single findings list, noting where they agree and what each caught alone. Fix every finding, or list it as a deliberate exception for the user to accept. A clean scan is a floor, not a verdict: a generic font stack at a flat scale passes every detector rule, which is exactly what the assessment exists to catch. State in your final summary which path ran (parallel sub-agents or single-context fallback).
|
||||
|
||||
---
|
||||
|
||||
## Assess Current Typography
|
||||
|
||||
Analyze what's weak or generic about the current type:
|
||||
This checklist is sub-agent A's brief (on the fallback path, work through it yourself before the pre-scan). Analyze what's weak or generic about the current type:
|
||||
|
||||
1. **Font choices**:
|
||||
- Are we using invisible defaults? (Inter, Roboto, Arial, Open Sans, system defaults)
|
||||
@@ -109,6 +129,8 @@ Build a clear type scale:
|
||||
- **Performance**: Are web fonts loading efficiently without layout shift?
|
||||
- **Accessibility**: Does text meet WCAG contrast ratios? Is it zoomable to 200%?
|
||||
|
||||
Answer each item above by citing the file, selector, or value that satisfies it; never a bare yes. Then re-run the pre-scan and fix until the count of unresolved items and unaccepted findings is zero.
|
||||
|
||||
When the type carries the hierarchy on its own, hand off to `/impeccable polish` for the final pass.
|
||||
|
||||
## Live-mode signature params
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Context-signals gatherer for the bare `{{command_prefix}}impeccable`
|
||||
* Context-signals gatherer for the bare Impeccable invocation
|
||||
* (no-argument) path. Collects cheap, deterministic signals about the current
|
||||
* project and emits them as JSON.
|
||||
*
|
||||
@@ -21,7 +21,7 @@ import net from 'node:net';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { loadContext, extractRegister } from './context.mjs';
|
||||
import { loadContext, extractRegister, extractPlatform } from './context.mjs';
|
||||
import { getCritiqueDir } from './lib/impeccable-paths.mjs';
|
||||
|
||||
/** Is there code here at all, or just context files / an empty repo? */
|
||||
@@ -197,6 +197,7 @@ export async function gatherSignals(cwd = process.cwd()) {
|
||||
designPath: ctx.designPath,
|
||||
hasCode: hasCode(cwd),
|
||||
register: extractRegister(ctx.product),
|
||||
platform: extractPlatform(ctx.product),
|
||||
},
|
||||
critique: { latest: latestCritique(cwd) },
|
||||
git,
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
/**
|
||||
* 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.
|
||||
* markdown block on stdout, or prints a `NO_PRODUCT_MD:` message when no
|
||||
* PRODUCT.md is found anywhere. The skill keys off that message to branch:
|
||||
* from-scratch build commands (init / teach / craft / shape) and clear
|
||||
* build/shape intent divert into the init flow, while scoped commands proceed
|
||||
* using the existing code as context.
|
||||
*
|
||||
* Path resolution (first match wins):
|
||||
* 1. Active project root, if PRODUCT.md or DESIGN.md is there
|
||||
@@ -21,6 +23,7 @@ import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { parseTargetOptions } from './lib/target-args.mjs';
|
||||
import { IMPECCABLE_COMMAND } from './lib/provider.mjs';
|
||||
|
||||
const PRODUCT_NAMES = ['PRODUCT.md', 'Product.md', 'product.md'];
|
||||
const DESIGN_NAMES = ['DESIGN.md', 'Design.md', 'design.md'];
|
||||
@@ -691,24 +694,60 @@ function escapeRegExp(value) {
|
||||
return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the first non-empty line under a bare `## <heading>` section of
|
||||
* PRODUCT.md (e.g. `## Register`, `## Platform`). Returns null when the
|
||||
* section is absent. The heading match is exact (`\s*$`) so near-miss
|
||||
* headings like `## Register guidelines` don't shadow the real field.
|
||||
*/
|
||||
export function extractSectionValue(product, heading) {
|
||||
if (!product) return null;
|
||||
const headingRe = new RegExp(`^##\\s+${escapeRegExp(heading)}\\s*$`, 'i');
|
||||
const lines = product.split('\n');
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
if (headingRe.test(lines[i].trim())) {
|
||||
for (let j = i + 1; j < lines.length; j++) {
|
||||
const next = lines[j].trim();
|
||||
// A new heading before any value means the section is empty.
|
||||
if (/^#{1,6}\s/.test(next)) return null;
|
||||
if (next) return next;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
const word = (extractSectionValue(product, 'Register') || '').toLowerCase();
|
||||
return word === 'brand' || word === 'product' ? word : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull the platform (`web`, `ios`, `android`, or `adaptive`) out of PRODUCT.md
|
||||
* by looking for a `## Platform` section and reading the first non-empty line
|
||||
* that follows it. `adaptive` is for cross-platform apps (Flutter, React
|
||||
* Native) that ship both iOS and Android from one codebase; a line that names
|
||||
* both targets (e.g. `ios, android`) is also read as `adaptive`. Returns null
|
||||
* when the file is legacy / platform-less, which the skill treats as `web`
|
||||
* (the default the general rules already assume).
|
||||
*/
|
||||
export function extractPlatform(product) {
|
||||
const value = (extractSectionValue(product, 'Platform') || '').toLowerCase();
|
||||
if (!value) return null;
|
||||
if (value === 'web' || value === 'ios' || value === 'android' || value === 'adaptive') return value;
|
||||
// A short list naming both native targets (`ios, android`, `ios and
|
||||
// android`) = adaptive. Only list separators and the two platform words may
|
||||
// appear; anything else (prose, negations) is unrecognized and falls
|
||||
// through to the CLI's WARNING path.
|
||||
const tokens = value.split(/[\s,+&/]+/).filter(t => t && t !== 'and');
|
||||
if (tokens.length >= 2 && tokens.every(t => t === 'ios' || t === 'android')
|
||||
&& tokens.includes('ios') && tokens.includes('android')) {
|
||||
return 'adaptive';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -860,8 +899,11 @@ async function cli() {
|
||||
// — 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.',
|
||||
'Follow SKILL.md Setup step 1: for `init`, `teach`, `craft`, `shape`, ' +
|
||||
'or wording that clearly maps to a from-scratch build/shape flow, load ' +
|
||||
'reference/init.md and write PRODUCT.md first; for any other (scoped) ' +
|
||||
'command against existing code, proceed using the code as context and ' +
|
||||
`offer \`${IMPECCABLE_COMMAND} init\` as a suggestion (do not block).`,
|
||||
];
|
||||
parts.push(buildResolvedContextDirective(ctx, cliOptions, { targetExists }));
|
||||
if (shouldWarnMissingTarget(ctx, targetProvided, targetExists)) {
|
||||
@@ -884,6 +926,26 @@ async function cli() {
|
||||
? `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);
|
||||
const platform = extractPlatform(ctx.product);
|
||||
const nativeRefs =
|
||||
platform === 'adaptive' ? ['ios', 'android'] : platform === 'ios' || platform === 'android' ? [platform] : [];
|
||||
if (nativeRefs.length) {
|
||||
const refList = nativeRefs.map(p => `\`reference/${p}.md\``).join(' and ');
|
||||
const label = platform === 'adaptive' ? '`adaptive` (both iOS and Android)' : `\`${platform}\``;
|
||||
parts.push(
|
||||
`NEXT STEP: This project targets ${label}. Also read ${refList} for native conventions, in addition to the register reference.`,
|
||||
);
|
||||
} else if (!platform) {
|
||||
// A `## Platform` section that names something we don't recognize (a
|
||||
// toolchain like `flutter`, a typo) would otherwise silently fall back to
|
||||
// web — the wrong default exactly when the user tried to say "native".
|
||||
const rawPlatform = extractSectionValue(ctx.product, 'Platform');
|
||||
if (rawPlatform) {
|
||||
parts.push(
|
||||
`WARNING: PRODUCT.md's \`## Platform\` value \`${rawPlatform}\` is not recognized; treating the project as \`web\`. Valid values are \`web\`, \`ios\`, \`android\`, or \`adaptive\` (cross-platform, ships both). If this project is native, fix the field (name the design language the app renders, not the toolchain) and surface it to the user.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (updateDirective) parts.push(updateDirective);
|
||||
process.stdout.write(parts.join('\n\n---\n\n') + '\n');
|
||||
}
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
/**
|
||||
* Critique persistence helper.
|
||||
*
|
||||
* Each run of /impeccable critique writes a per-target snapshot to
|
||||
* Each critique run writes a per-target snapshot to
|
||||
* .impeccable/critique/<timestamp>__<slug>.md
|
||||
* with a small YAML frontmatter carrying the score + P0/P1 counts.
|
||||
*
|
||||
* /impeccable polish reads the latest matching snapshot at start as its
|
||||
* The polish workflow reads the latest matching snapshot at start as its
|
||||
* fix backlog. No other skill auto-reads critique output.
|
||||
*
|
||||
* The slug is derived mechanically from the *resolved* primary artifact
|
||||
|
||||
@@ -2,6 +2,7 @@ import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import { loadDesignSystemForCwd } from '../design-system.mjs';
|
||||
import { RULE_SCOPES, filterByScopes } from '../registry/antipatterns.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';
|
||||
@@ -93,6 +94,8 @@ Options:
|
||||
--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)
|
||||
--scope <name> Only report rules in the given design domain
|
||||
(type, layout). Comma-separated.
|
||||
--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
|
||||
@@ -151,6 +154,33 @@ async function detectCli() {
|
||||
const providers = [];
|
||||
if (args.includes('--gpt')) providers.push('gpt');
|
||||
if (args.includes('--gemini')) providers.push('gemini');
|
||||
const scopes = [];
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
if (args[i] !== '--scope' && !args[i].startsWith('--scope=')) continue;
|
||||
const inline = args[i].startsWith('--scope=');
|
||||
const value = inline ? args[i].slice('--scope='.length) : args[i + 1];
|
||||
const parsed = (value && !value.startsWith('--'))
|
||||
? value.split(',').map(s => s.trim()).filter(Boolean)
|
||||
: [];
|
||||
// A bare `--scope` would otherwise fall out of `targets` and scan unscoped;
|
||||
// fail loudly so a mistyped pre-scan never runs the wrong rule set.
|
||||
if (parsed.length === 0) {
|
||||
process.stderr.write(
|
||||
`Error: --scope requires a value. Valid scopes: ${[...RULE_SCOPES].join(', ')}\n`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
scopes.push(...parsed);
|
||||
args.splice(i, inline ? 1 : 2);
|
||||
i -= 1;
|
||||
}
|
||||
const unknownScopes = scopes.filter(s => !RULE_SCOPES.has(s));
|
||||
if (unknownScopes.length > 0) {
|
||||
process.stderr.write(
|
||||
`Error: unknown --scope value(s): ${unknownScopes.join(', ')}. Valid scopes: ${[...RULE_SCOPES].join(', ')}\n`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
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
|
||||
@@ -276,6 +306,7 @@ async function detectCli() {
|
||||
}
|
||||
|
||||
allFindings = filterDetectionFindings(allFindings, detectionConfig);
|
||||
allFindings = filterByScopes(allFindings, scopes);
|
||||
|
||||
if (allFindings.length > 0) {
|
||||
if (jsonMode) process.stdout.write(formatFindings(allFindings, true) + '\n');
|
||||
|
||||
@@ -9,6 +9,8 @@ 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 FONT_SIZE_TOLERANCE_PX = 0.5;
|
||||
const FONT_SIZE_LITERAL_RE = /^-?[\d.]+(?:px|rem)$/;
|
||||
|
||||
const CSS_COLOR_RE = /#[0-9a-f]{3,8}\b|rgba?\([^)]+\)|oklch\([^)]+\)|hsla?\([^)]+\)/gi;
|
||||
const FONT_DECL_RE = /font-family\s*:\s*([^;}\n]+)/gi;
|
||||
@@ -16,6 +18,9 @@ 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 FONT_SIZE_DECL_RE = /font-size\s*:\s*([^;}\n]+)/gi;
|
||||
const FONT_SIZE_JS_RE = /fontSize\s*[:=]\s*["'`]([^"'`]+)["'`]/g;
|
||||
const TAILWIND_FONT_SIZE_RE = /\btext-\[(-?[\d.]+(?:px|rem))\]/g;
|
||||
const STATIC_DESIGN_SKIP_TAGS = new Set(['head', 'title', 'meta', 'link', 'style', 'script', 'noscript', 'template', 'source']);
|
||||
|
||||
function firstExisting(dir, names) {
|
||||
@@ -283,6 +288,18 @@ function addTypographyFonts(out, typography) {
|
||||
}
|
||||
}
|
||||
|
||||
function addTypographySizes(out, typography) {
|
||||
if (!typography || typeof typography !== 'object') return;
|
||||
for (const role of Object.values(typography)) {
|
||||
if (!role || typeof role !== 'object') continue;
|
||||
const raw = String(role.fontSize ?? '').trim().toLowerCase();
|
||||
if (!FONT_SIZE_LITERAL_RE.test(raw)) continue;
|
||||
const px = resolveLengthPx(raw, 16);
|
||||
if (px == null || !Number.isFinite(px) || px <= 0) continue;
|
||||
out.allowedFontSizes.push({ value: raw, px });
|
||||
}
|
||||
}
|
||||
|
||||
function addRoundedScale(out, rounded) {
|
||||
if (!rounded || typeof rounded !== 'object') return;
|
||||
for (const [rawName, value] of Object.entries(rounded)) {
|
||||
@@ -340,10 +357,12 @@ function normalizeDesignSystem(input = {}) {
|
||||
allowedFonts: new Set(),
|
||||
allowedColorKeys: new Map(),
|
||||
allowedRadii: [],
|
||||
allowedFontSizes: [],
|
||||
hasPillRadius: false,
|
||||
};
|
||||
|
||||
addTypographyFonts(out, frontmatter.typography);
|
||||
addTypographySizes(out, frontmatter.typography);
|
||||
addColorObject(out, frontmatter.colors);
|
||||
addSidecarColors(out, sidecar);
|
||||
addRoundedScale(out, frontmatter.rounded);
|
||||
@@ -352,6 +371,7 @@ function normalizeDesignSystem(input = {}) {
|
||||
out.hasFonts = out.allowedFonts.size > 0;
|
||||
out.hasColors = out.allowedColorKeys.size > 0;
|
||||
out.hasRadii = out.allowedRadii.length > 0;
|
||||
out.hasFontSizes = out.allowedFontSizes.length > 0;
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -418,6 +438,17 @@ function isAllowedRadiusRaw(raw, designSystem) {
|
||||
return designSystem.allowedRadii.some(entry => Math.abs(entry.px - px) <= RADIUS_TOLERANCE_PX);
|
||||
}
|
||||
|
||||
function isAllowedFontSizeRaw(raw, designSystem) {
|
||||
if (!designSystem?.hasFontSizes) return true;
|
||||
const text = String(raw || '').trim().toLowerCase().replace(/\s*!important\s*$/, '');
|
||||
if (!FONT_SIZE_LITERAL_RE.test(text)) return true;
|
||||
const px = resolveLengthPx(text, 16);
|
||||
if (px == null || !Number.isFinite(px) || px <= 0) return true;
|
||||
return designSystem.allowedFontSizes.some(
|
||||
entry => Math.abs(entry.px - px) <= FONT_SIZE_TOLERANCE_PX,
|
||||
);
|
||||
}
|
||||
|
||||
function lineLooksCommented(line) {
|
||||
const trimmed = String(line || '').trim();
|
||||
return trimmed.startsWith('//') || trimmed.startsWith('/*') || trimmed.startsWith('*') || trimmed.startsWith('<!--');
|
||||
@@ -509,6 +540,18 @@ function checkRadiusValue(value, filePath, line, designSystem, context) {
|
||||
return findings;
|
||||
}
|
||||
|
||||
function checkFontSizeValue(value, filePath, line, designSystem, context) {
|
||||
const token = String(value || '').trim();
|
||||
if (isAllowedFontSizeRaw(token, designSystem)) return [];
|
||||
return [makeDesignFinding(
|
||||
'design-system-font-size',
|
||||
filePath,
|
||||
`${context}: ${token} is off the DESIGN.md type ramp`,
|
||||
line,
|
||||
{ ignoreValue: token },
|
||||
)];
|
||||
}
|
||||
|
||||
function checkSourceDesignSystem(content, filePath, options = {}) {
|
||||
const designSystem = options.designSystem;
|
||||
if (!designSystem?.present) return [];
|
||||
@@ -567,6 +610,18 @@ function checkSourceDesignSystem(content, filePath, options = {}) {
|
||||
findings.push(...checkRadiusValue(match[1], filePath, lineNum, designSystem, 'borderRadius'));
|
||||
}
|
||||
}
|
||||
|
||||
if (designSystem.hasFontSizes) {
|
||||
for (const match of line.matchAll(FONT_SIZE_DECL_RE)) {
|
||||
findings.push(...checkFontSizeValue(match[1], filePath, lineNum, designSystem, 'font-size'));
|
||||
}
|
||||
for (const match of line.matchAll(FONT_SIZE_JS_RE)) {
|
||||
findings.push(...checkFontSizeValue(match[1], filePath, lineNum, designSystem, 'fontSize'));
|
||||
}
|
||||
for (const match of line.matchAll(TAILWIND_FONT_SIZE_RE)) {
|
||||
findings.push(...checkFontSizeValue(match[1], filePath, lineNum, designSystem, 'text-[…] class'));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return dedupeDesignFindings(findings);
|
||||
@@ -581,6 +636,8 @@ function sampleText(el) {
|
||||
return text ? ` "${text.slice(0, 40)}"` : '';
|
||||
}
|
||||
|
||||
// Font-size design-system checks are source-scan-only (see checkSourceDesignSystem).
|
||||
// Computed font-size cascades and clamp() ramps resolve to off-ramp px in the browser.
|
||||
function collectStaticDesignSystemFindings(document, window, filePath, designSystem) {
|
||||
if (!designSystem?.present) return [];
|
||||
const findings = [];
|
||||
@@ -698,6 +755,12 @@ function canonicalDesignFindingKey(item) {
|
||||
const label = String(value || '').trim().toLowerCase();
|
||||
return label ? `${item.antipattern}:radius:${label}` : null;
|
||||
}
|
||||
if (item.antipattern === 'design-system-font-size') {
|
||||
const px = resolveLengthPx(String(value || '').trim(), 16);
|
||||
if (px != null && Number.isFinite(px)) return `${item.antipattern}:font-size:${Math.round(px * 100) / 100}`;
|
||||
const label = String(value || '').trim().toLowerCase();
|
||||
return label ? `${item.antipattern}:font-size:${label}` : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -744,6 +807,7 @@ export {
|
||||
isAllowedFont,
|
||||
isAllowedColorRaw,
|
||||
isAllowedRadiusRaw,
|
||||
isAllowedFontSizeRaw,
|
||||
checkSourceDesignSystem,
|
||||
collectStaticDesignSystemFindings,
|
||||
mergeDesignSystemFindings,
|
||||
|
||||
@@ -123,6 +123,7 @@ const ANTIPATTERNS = [
|
||||
{
|
||||
id: 'overused-font',
|
||||
category: 'slop',
|
||||
scopes: ['type'],
|
||||
name: 'Overused font',
|
||||
description:
|
||||
'Inter, Roboto, Fraunces, Geist, Plus Jakarta Sans, and Space Grotesk are used on so many sites they no longer feel distinctive. Each new wave of AI-generated UIs converges on the same handful of faces. Choose a face that gives your interface personality.',
|
||||
@@ -132,6 +133,7 @@ const ANTIPATTERNS = [
|
||||
{
|
||||
id: 'single-font',
|
||||
category: 'slop',
|
||||
scopes: ['type'],
|
||||
name: 'Single font for everything',
|
||||
description:
|
||||
'Only one font family is used for the entire page. Pair a distinctive display font with a refined body font to create typographic hierarchy.',
|
||||
@@ -141,6 +143,7 @@ const ANTIPATTERNS = [
|
||||
{
|
||||
id: 'flat-type-hierarchy',
|
||||
category: 'slop',
|
||||
scopes: ['type'],
|
||||
name: 'Flat type hierarchy',
|
||||
description:
|
||||
'Font sizes are too close together — no clear visual hierarchy. Use fewer sizes with more contrast (aim for at least a 1.25 ratio between steps).',
|
||||
@@ -177,6 +180,7 @@ const ANTIPATTERNS = [
|
||||
{
|
||||
id: 'nested-cards',
|
||||
category: 'slop',
|
||||
scopes: ['layout'],
|
||||
name: 'Nested cards',
|
||||
description:
|
||||
'Cards inside cards create visual noise and excessive depth. Flatten the hierarchy — use spacing, typography, and dividers instead of nesting containers.',
|
||||
@@ -186,6 +190,7 @@ const ANTIPATTERNS = [
|
||||
{
|
||||
id: 'monotonous-spacing',
|
||||
category: 'slop',
|
||||
scopes: ['layout'],
|
||||
name: 'Monotonous spacing',
|
||||
description:
|
||||
'The same spacing value used everywhere — no rhythm, no variation. Use tight groupings for related items and generous separations between sections.',
|
||||
@@ -213,6 +218,7 @@ const ANTIPATTERNS = [
|
||||
{
|
||||
id: 'icon-tile-stack',
|
||||
category: 'slop',
|
||||
scopes: ['layout'],
|
||||
name: 'Icon tile stacked above heading',
|
||||
description:
|
||||
'A small rounded-square icon container above a heading is the universal AI feature-card template — every generator outputs this exact shape. Try a side-by-side icon and heading, or let the icon sit in flow without its own container.',
|
||||
@@ -222,6 +228,7 @@ const ANTIPATTERNS = [
|
||||
{
|
||||
id: 'italic-serif-display',
|
||||
category: 'slop',
|
||||
scopes: ['type'],
|
||||
name: 'Italic serif display headline',
|
||||
description:
|
||||
'Oversized italic serif (Fraunces, Recoleta, Playfair, Newsreader-italic) as the primary hero headline reads as taste in isolation but has become the universal AI-startup landing page hero. Set roman, or move to a non-serif display face. Editorial / magazine register may legitimately want this — judge by context.',
|
||||
@@ -231,6 +238,7 @@ const ANTIPATTERNS = [
|
||||
{
|
||||
id: 'hero-eyebrow-chip',
|
||||
category: 'slop',
|
||||
scopes: ['type'],
|
||||
name: 'Hero eyebrow / pill chip',
|
||||
description:
|
||||
'A tiny uppercase letter-spaced label sitting immediately above an oversized hero headline — or the same shape rendered as a pill chip — is now the default AI SaaS hero. Drop the eyebrow, integrate the kicker into the headline, or run it as a navigation breadcrumb instead.',
|
||||
@@ -240,6 +248,7 @@ const ANTIPATTERNS = [
|
||||
{
|
||||
id: 'repeated-section-kickers',
|
||||
category: 'slop',
|
||||
scopes: ['type'],
|
||||
severity: 'advisory',
|
||||
name: 'Repeated section kicker labels',
|
||||
description:
|
||||
@@ -250,6 +259,7 @@ const ANTIPATTERNS = [
|
||||
{
|
||||
id: 'numbered-section-markers',
|
||||
category: 'slop',
|
||||
scopes: ['layout'],
|
||||
severity: 'advisory',
|
||||
name: 'Numbered section markers (01 / 02 / 03)',
|
||||
description:
|
||||
@@ -287,6 +297,7 @@ const ANTIPATTERNS = [
|
||||
{
|
||||
id: 'oversized-h1',
|
||||
category: 'slop',
|
||||
scopes: ['type'],
|
||||
name: 'Oversized hero headline',
|
||||
description:
|
||||
'A full-sentence headline set at display size ends up dominating the viewport, leaving no room for anything else above the fold. A punchy one- or two-word headline at that size is fine — the problem is a long headline blown up too large. Set long headlines smaller, or tighten the copy.',
|
||||
@@ -296,6 +307,7 @@ const ANTIPATTERNS = [
|
||||
{
|
||||
id: 'extreme-negative-tracking',
|
||||
category: 'slop',
|
||||
scopes: ['type'],
|
||||
name: 'Crushed letter spacing',
|
||||
description:
|
||||
'Letter-spacing pulled tighter than the point where characters keep their own shapes costs legibility. Tighten display type optically, not destructively.',
|
||||
@@ -341,6 +353,7 @@ const ANTIPATTERNS = [
|
||||
{
|
||||
id: 'line-length',
|
||||
category: 'quality',
|
||||
scopes: ['type', 'layout'],
|
||||
name: 'Line length too long',
|
||||
description:
|
||||
'Text lines wider than ~80 characters are hard to read. The eye loses its place tracking back to the start of the next line. Add a max-width (65ch to 75ch) to text containers.',
|
||||
@@ -350,6 +363,7 @@ const ANTIPATTERNS = [
|
||||
{
|
||||
id: 'cramped-padding',
|
||||
category: 'quality',
|
||||
scopes: ['layout'],
|
||||
name: 'Cramped padding',
|
||||
description:
|
||||
'Text is too close to the edge of its container. Two shapes: (1) an element with its own text where the padding is too low for the font size, and (2) a wrapper with text-bearing children and near-zero padding against a visible boundary (border, outline, or non-transparent background) — children land flush against the boundary line. Add at least 8px (ideally 12–16px) of padding inside bordered, outlined, or colored containers.',
|
||||
@@ -359,6 +373,7 @@ const ANTIPATTERNS = [
|
||||
{
|
||||
id: 'body-text-viewport-edge',
|
||||
category: 'quality',
|
||||
scopes: ['layout'],
|
||||
name: 'Body text touching viewport edge',
|
||||
description:
|
||||
'Body paragraphs render flush against the left or right viewport edge with no container providing horizontal padding. Wrap content in a container with at least 16px (ideally 24-32px) of horizontal padding, or apply max-width with mx-auto.',
|
||||
@@ -366,6 +381,7 @@ const ANTIPATTERNS = [
|
||||
{
|
||||
id: 'tight-leading',
|
||||
category: 'quality',
|
||||
scopes: ['type'],
|
||||
name: 'Tight line height',
|
||||
description:
|
||||
'Line height below 1.3x the font size makes multi-line text hard to read. Use 1.5 to 1.7 for body text so lines have room to breathe.',
|
||||
@@ -373,6 +389,7 @@ const ANTIPATTERNS = [
|
||||
{
|
||||
id: 'skipped-heading',
|
||||
category: 'quality',
|
||||
scopes: ['type'],
|
||||
name: 'Skipped heading level',
|
||||
description:
|
||||
'Heading levels should not skip (e.g. h1 then h3 with no h2). Screen readers use heading hierarchy for navigation. Skipping levels breaks the document outline.',
|
||||
@@ -380,6 +397,7 @@ const ANTIPATTERNS = [
|
||||
{
|
||||
id: 'justified-text',
|
||||
category: 'quality',
|
||||
scopes: ['type'],
|
||||
name: 'Justified text',
|
||||
description:
|
||||
'Justified text without hyphenation creates uneven word spacing ("rivers of white"). Use text-align: left for body text, or enable hyphens: auto if you must justify.',
|
||||
@@ -387,6 +405,7 @@ const ANTIPATTERNS = [
|
||||
{
|
||||
id: 'tiny-text',
|
||||
category: 'quality',
|
||||
scopes: ['type'],
|
||||
name: 'Tiny body text',
|
||||
description:
|
||||
'Body text below 12px is hard to read, especially on high-DPI screens. Use at least 14px for body content, 16px is ideal.',
|
||||
@@ -394,6 +413,7 @@ const ANTIPATTERNS = [
|
||||
{
|
||||
id: 'all-caps-body',
|
||||
category: 'quality',
|
||||
scopes: ['type'],
|
||||
name: 'All-caps body text',
|
||||
description:
|
||||
'Long passages in uppercase are hard to read. We recognize words by shape (ascenders and descenders), which all-caps removes. Reserve uppercase for short labels and headings.',
|
||||
@@ -403,6 +423,7 @@ const ANTIPATTERNS = [
|
||||
{
|
||||
id: 'wide-tracking',
|
||||
category: 'quality',
|
||||
scopes: ['type'],
|
||||
name: 'Wide letter spacing on body text',
|
||||
description:
|
||||
'Letter spacing above 0.05em on body text disrupts natural character groupings and slows reading. Reserve wide tracking for short uppercase labels only.',
|
||||
@@ -410,6 +431,7 @@ const ANTIPATTERNS = [
|
||||
{
|
||||
id: 'text-overflow',
|
||||
category: 'quality',
|
||||
scopes: ['layout'],
|
||||
name: 'Content overflowing its container',
|
||||
description:
|
||||
'Content renders wider than its container, spilling out or forcing a horizontal scrollbar. Let text wrap, constrain widths, or give the region a deliberate scroll affordance.',
|
||||
@@ -419,6 +441,7 @@ const ANTIPATTERNS = [
|
||||
{
|
||||
id: 'clipped-overflow-container',
|
||||
category: 'quality',
|
||||
scopes: ['layout'],
|
||||
name: 'Positioned child clipped by overflow container',
|
||||
description:
|
||||
'A clipping container (overflow hidden or clip) wrapping an absolutely-positioned child cuts off tooltips, menus, and popovers that need to escape. Let the overflow be visible, or move the positioned layer out of the clip.',
|
||||
@@ -428,6 +451,7 @@ const ANTIPATTERNS = [
|
||||
{
|
||||
id: 'design-system-font',
|
||||
category: 'quality',
|
||||
scopes: ['type'],
|
||||
name: 'Font outside DESIGN.md',
|
||||
description:
|
||||
'A font is used that is not declared in DESIGN.md typography. Use the documented type system or update DESIGN.md if this is an intentional brand addition.',
|
||||
@@ -454,6 +478,17 @@ const ANTIPATTERNS = [
|
||||
skillSection: 'Visual Details',
|
||||
skillGuideline: 'border radius outside the project design system',
|
||||
},
|
||||
{
|
||||
id: 'design-system-font-size',
|
||||
category: 'quality',
|
||||
severity: 'advisory',
|
||||
scopes: ['type'],
|
||||
name: 'Font size outside DESIGN.md',
|
||||
description:
|
||||
'A literal font-size is off the type ramp documented in DESIGN.md typography. Use a documented size step or update the design system if the new step is intentional.',
|
||||
skillSection: 'Typography',
|
||||
skillGuideline: 'font size outside the project design system',
|
||||
},
|
||||
|
||||
// ── Provider tells: opt-in via --gpt / --gemini (gated off by default) ──
|
||||
{
|
||||
@@ -628,6 +663,36 @@ function colorToHex(c) {
|
||||
return '#' + [c.r, c.g, c.b].map(v => v.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
|
||||
// --- cli/engine/shared/fonts.mjs ---
|
||||
const GOOGLE_FONTS_URL_RE = /fonts\.googleapis\.com\/css2?\?[^"'\s)<>]*/gi;
|
||||
|
||||
function normalizeGoogleFontFamilyParam(value) {
|
||||
return String(value || '')
|
||||
.split('|')
|
||||
.map(part => part.split(':')[0].trim().toLowerCase())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function extractGoogleFontFamilies(text) {
|
||||
const families = [];
|
||||
if (!text) return families;
|
||||
|
||||
GOOGLE_FONTS_URL_RE.lastIndex = 0;
|
||||
let urlMatch;
|
||||
while ((urlMatch = GOOGLE_FONTS_URL_RE.exec(text)) !== null) {
|
||||
const url = urlMatch[0];
|
||||
const queryStart = url.indexOf('?');
|
||||
if (queryStart === -1) continue;
|
||||
|
||||
const params = new URLSearchParams(url.slice(queryStart + 1).replace(/&/g, '&'));
|
||||
for (const value of params.getAll('family')) {
|
||||
families.push(...normalizeGoogleFontFamilyParam(value));
|
||||
}
|
||||
}
|
||||
|
||||
return families;
|
||||
}
|
||||
|
||||
// --- cli/engine/rules/checks.mjs ---
|
||||
const DETECTOR_IS_BROWSER = typeof window !== 'undefined';
|
||||
|
||||
@@ -2682,14 +2747,9 @@ function checkPageTypography(doc, win) {
|
||||
|
||||
// Check Google Fonts links in HTML
|
||||
const html = doc.documentElement?.outerHTML || '';
|
||||
const gfRe = /fonts\.googleapis\.com\/css2?\?family=([^&"'\s]+)/gi;
|
||||
let m;
|
||||
while ((m = gfRe.exec(html)) !== null) {
|
||||
const families = m[1].split('|').map(f => f.split(':')[0].replace(/\+/g, ' ').toLowerCase());
|
||||
for (const f of families) {
|
||||
fonts.add(f);
|
||||
if (OVERUSED_FONTS.has(f)) overusedFound.add(f);
|
||||
}
|
||||
for (const f of extractGoogleFontFamilies(html)) {
|
||||
fonts.add(f);
|
||||
if (OVERUSED_FONTS.has(f)) overusedFound.add(f);
|
||||
}
|
||||
|
||||
// Also parse raw HTML/style content for font-family (jsdom may not expose all via CSSOM)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { GENERIC_FONTS } from '../../shared/constants.mjs';
|
||||
import { GENERIC_FONTS, OVERUSED_FONTS } from '../../shared/constants.mjs';
|
||||
import { isNeutralColor } from '../../shared/color.mjs';
|
||||
import { extractGoogleFontFamilies } from '../../shared/fonts.mjs';
|
||||
import { checkSourceDesignSystem } from '../../design-system.mjs';
|
||||
import { isFullPage } from '../../shared/page.mjs';
|
||||
import { applyInlineIgnores } from '../../shared/inline-ignores.mjs';
|
||||
@@ -38,6 +39,10 @@ function shouldRunPageAnalyzers(content, filePath) {
|
||||
return !ext || PAGE_ANALYZER_EXTS.has(ext);
|
||||
}
|
||||
|
||||
function firstOverusedGoogleFont(text) {
|
||||
return extractGoogleFontFamilies(text).find(f => OVERUSED_FONTS.has(f)) || '';
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -88,9 +93,12 @@ const REGEX_MATCHERS = [
|
||||
{ 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, ' ')}` },
|
||||
{ id: 'overused-font', regex: /fonts\.googleapis\.com\/css2?\?[^"'\s)<>]*/gi,
|
||||
test: (m) => {
|
||||
m.overusedGoogleFont = firstOverusedGoogleFont(m[0]);
|
||||
return Boolean(m.overusedGoogleFont);
|
||||
},
|
||||
fmt: (m) => `Google Fonts: ${m.overusedGoogleFont || firstOverusedGoogleFont(m[0])}` },
|
||||
// --- 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),
|
||||
@@ -170,10 +178,7 @@ const REGEX_ANALYZERS = [
|
||||
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);
|
||||
}
|
||||
for (const f of extractGoogleFontFamilies(content)) fonts.add(f);
|
||||
if (fonts.size !== 1 || content.split('\n').length < 20) return [];
|
||||
const name = [...fonts][0];
|
||||
const lines = content.split('\n');
|
||||
|
||||
@@ -21,6 +21,7 @@ const ANTIPATTERNS = [
|
||||
{
|
||||
id: 'overused-font',
|
||||
category: 'slop',
|
||||
scopes: ['type'],
|
||||
name: 'Overused font',
|
||||
description:
|
||||
'Inter, Roboto, Fraunces, Geist, Plus Jakarta Sans, and Space Grotesk are used on so many sites they no longer feel distinctive. Each new wave of AI-generated UIs converges on the same handful of faces. Choose a face that gives your interface personality.',
|
||||
@@ -30,6 +31,7 @@ const ANTIPATTERNS = [
|
||||
{
|
||||
id: 'single-font',
|
||||
category: 'slop',
|
||||
scopes: ['type'],
|
||||
name: 'Single font for everything',
|
||||
description:
|
||||
'Only one font family is used for the entire page. Pair a distinctive display font with a refined body font to create typographic hierarchy.',
|
||||
@@ -39,6 +41,7 @@ const ANTIPATTERNS = [
|
||||
{
|
||||
id: 'flat-type-hierarchy',
|
||||
category: 'slop',
|
||||
scopes: ['type'],
|
||||
name: 'Flat type hierarchy',
|
||||
description:
|
||||
'Font sizes are too close together — no clear visual hierarchy. Use fewer sizes with more contrast (aim for at least a 1.25 ratio between steps).',
|
||||
@@ -75,6 +78,7 @@ const ANTIPATTERNS = [
|
||||
{
|
||||
id: 'nested-cards',
|
||||
category: 'slop',
|
||||
scopes: ['layout'],
|
||||
name: 'Nested cards',
|
||||
description:
|
||||
'Cards inside cards create visual noise and excessive depth. Flatten the hierarchy — use spacing, typography, and dividers instead of nesting containers.',
|
||||
@@ -84,6 +88,7 @@ const ANTIPATTERNS = [
|
||||
{
|
||||
id: 'monotonous-spacing',
|
||||
category: 'slop',
|
||||
scopes: ['layout'],
|
||||
name: 'Monotonous spacing',
|
||||
description:
|
||||
'The same spacing value used everywhere — no rhythm, no variation. Use tight groupings for related items and generous separations between sections.',
|
||||
@@ -111,6 +116,7 @@ const ANTIPATTERNS = [
|
||||
{
|
||||
id: 'icon-tile-stack',
|
||||
category: 'slop',
|
||||
scopes: ['layout'],
|
||||
name: 'Icon tile stacked above heading',
|
||||
description:
|
||||
'A small rounded-square icon container above a heading is the universal AI feature-card template — every generator outputs this exact shape. Try a side-by-side icon and heading, or let the icon sit in flow without its own container.',
|
||||
@@ -120,6 +126,7 @@ const ANTIPATTERNS = [
|
||||
{
|
||||
id: 'italic-serif-display',
|
||||
category: 'slop',
|
||||
scopes: ['type'],
|
||||
name: 'Italic serif display headline',
|
||||
description:
|
||||
'Oversized italic serif (Fraunces, Recoleta, Playfair, Newsreader-italic) as the primary hero headline reads as taste in isolation but has become the universal AI-startup landing page hero. Set roman, or move to a non-serif display face. Editorial / magazine register may legitimately want this — judge by context.',
|
||||
@@ -129,6 +136,7 @@ const ANTIPATTERNS = [
|
||||
{
|
||||
id: 'hero-eyebrow-chip',
|
||||
category: 'slop',
|
||||
scopes: ['type'],
|
||||
name: 'Hero eyebrow / pill chip',
|
||||
description:
|
||||
'A tiny uppercase letter-spaced label sitting immediately above an oversized hero headline — or the same shape rendered as a pill chip — is now the default AI SaaS hero. Drop the eyebrow, integrate the kicker into the headline, or run it as a navigation breadcrumb instead.',
|
||||
@@ -138,6 +146,7 @@ const ANTIPATTERNS = [
|
||||
{
|
||||
id: 'repeated-section-kickers',
|
||||
category: 'slop',
|
||||
scopes: ['type'],
|
||||
severity: 'advisory',
|
||||
name: 'Repeated section kicker labels',
|
||||
description:
|
||||
@@ -148,6 +157,7 @@ const ANTIPATTERNS = [
|
||||
{
|
||||
id: 'numbered-section-markers',
|
||||
category: 'slop',
|
||||
scopes: ['layout'],
|
||||
severity: 'advisory',
|
||||
name: 'Numbered section markers (01 / 02 / 03)',
|
||||
description:
|
||||
@@ -185,6 +195,7 @@ const ANTIPATTERNS = [
|
||||
{
|
||||
id: 'oversized-h1',
|
||||
category: 'slop',
|
||||
scopes: ['type'],
|
||||
name: 'Oversized hero headline',
|
||||
description:
|
||||
'A full-sentence headline set at display size ends up dominating the viewport, leaving no room for anything else above the fold. A punchy one- or two-word headline at that size is fine — the problem is a long headline blown up too large. Set long headlines smaller, or tighten the copy.',
|
||||
@@ -194,6 +205,7 @@ const ANTIPATTERNS = [
|
||||
{
|
||||
id: 'extreme-negative-tracking',
|
||||
category: 'slop',
|
||||
scopes: ['type'],
|
||||
name: 'Crushed letter spacing',
|
||||
description:
|
||||
'Letter-spacing pulled tighter than the point where characters keep their own shapes costs legibility. Tighten display type optically, not destructively.',
|
||||
@@ -239,6 +251,7 @@ const ANTIPATTERNS = [
|
||||
{
|
||||
id: 'line-length',
|
||||
category: 'quality',
|
||||
scopes: ['type', 'layout'],
|
||||
name: 'Line length too long',
|
||||
description:
|
||||
'Text lines wider than ~80 characters are hard to read. The eye loses its place tracking back to the start of the next line. Add a max-width (65ch to 75ch) to text containers.',
|
||||
@@ -248,6 +261,7 @@ const ANTIPATTERNS = [
|
||||
{
|
||||
id: 'cramped-padding',
|
||||
category: 'quality',
|
||||
scopes: ['layout'],
|
||||
name: 'Cramped padding',
|
||||
description:
|
||||
'Text is too close to the edge of its container. Two shapes: (1) an element with its own text where the padding is too low for the font size, and (2) a wrapper with text-bearing children and near-zero padding against a visible boundary (border, outline, or non-transparent background) — children land flush against the boundary line. Add at least 8px (ideally 12–16px) of padding inside bordered, outlined, or colored containers.',
|
||||
@@ -257,6 +271,7 @@ const ANTIPATTERNS = [
|
||||
{
|
||||
id: 'body-text-viewport-edge',
|
||||
category: 'quality',
|
||||
scopes: ['layout'],
|
||||
name: 'Body text touching viewport edge',
|
||||
description:
|
||||
'Body paragraphs render flush against the left or right viewport edge with no container providing horizontal padding. Wrap content in a container with at least 16px (ideally 24-32px) of horizontal padding, or apply max-width with mx-auto.',
|
||||
@@ -264,6 +279,7 @@ const ANTIPATTERNS = [
|
||||
{
|
||||
id: 'tight-leading',
|
||||
category: 'quality',
|
||||
scopes: ['type'],
|
||||
name: 'Tight line height',
|
||||
description:
|
||||
'Line height below 1.3x the font size makes multi-line text hard to read. Use 1.5 to 1.7 for body text so lines have room to breathe.',
|
||||
@@ -271,6 +287,7 @@ const ANTIPATTERNS = [
|
||||
{
|
||||
id: 'skipped-heading',
|
||||
category: 'quality',
|
||||
scopes: ['type'],
|
||||
name: 'Skipped heading level',
|
||||
description:
|
||||
'Heading levels should not skip (e.g. h1 then h3 with no h2). Screen readers use heading hierarchy for navigation. Skipping levels breaks the document outline.',
|
||||
@@ -278,6 +295,7 @@ const ANTIPATTERNS = [
|
||||
{
|
||||
id: 'justified-text',
|
||||
category: 'quality',
|
||||
scopes: ['type'],
|
||||
name: 'Justified text',
|
||||
description:
|
||||
'Justified text without hyphenation creates uneven word spacing ("rivers of white"). Use text-align: left for body text, or enable hyphens: auto if you must justify.',
|
||||
@@ -285,6 +303,7 @@ const ANTIPATTERNS = [
|
||||
{
|
||||
id: 'tiny-text',
|
||||
category: 'quality',
|
||||
scopes: ['type'],
|
||||
name: 'Tiny body text',
|
||||
description:
|
||||
'Body text below 12px is hard to read, especially on high-DPI screens. Use at least 14px for body content, 16px is ideal.',
|
||||
@@ -292,6 +311,7 @@ const ANTIPATTERNS = [
|
||||
{
|
||||
id: 'all-caps-body',
|
||||
category: 'quality',
|
||||
scopes: ['type'],
|
||||
name: 'All-caps body text',
|
||||
description:
|
||||
'Long passages in uppercase are hard to read. We recognize words by shape (ascenders and descenders), which all-caps removes. Reserve uppercase for short labels and headings.',
|
||||
@@ -301,6 +321,7 @@ const ANTIPATTERNS = [
|
||||
{
|
||||
id: 'wide-tracking',
|
||||
category: 'quality',
|
||||
scopes: ['type'],
|
||||
name: 'Wide letter spacing on body text',
|
||||
description:
|
||||
'Letter spacing above 0.05em on body text disrupts natural character groupings and slows reading. Reserve wide tracking for short uppercase labels only.',
|
||||
@@ -308,6 +329,7 @@ const ANTIPATTERNS = [
|
||||
{
|
||||
id: 'text-overflow',
|
||||
category: 'quality',
|
||||
scopes: ['layout'],
|
||||
name: 'Content overflowing its container',
|
||||
description:
|
||||
'Content renders wider than its container, spilling out or forcing a horizontal scrollbar. Let text wrap, constrain widths, or give the region a deliberate scroll affordance.',
|
||||
@@ -317,6 +339,7 @@ const ANTIPATTERNS = [
|
||||
{
|
||||
id: 'clipped-overflow-container',
|
||||
category: 'quality',
|
||||
scopes: ['layout'],
|
||||
name: 'Positioned child clipped by overflow container',
|
||||
description:
|
||||
'A clipping container (overflow hidden or clip) wrapping an absolutely-positioned child cuts off tooltips, menus, and popovers that need to escape. Let the overflow be visible, or move the positioned layer out of the clip.',
|
||||
@@ -326,6 +349,7 @@ const ANTIPATTERNS = [
|
||||
{
|
||||
id: 'design-system-font',
|
||||
category: 'quality',
|
||||
scopes: ['type'],
|
||||
name: 'Font outside DESIGN.md',
|
||||
description:
|
||||
'A font is used that is not declared in DESIGN.md typography. Use the documented type system or update DESIGN.md if this is an intentional brand addition.',
|
||||
@@ -352,6 +376,17 @@ const ANTIPATTERNS = [
|
||||
skillSection: 'Visual Details',
|
||||
skillGuideline: 'border radius outside the project design system',
|
||||
},
|
||||
{
|
||||
id: 'design-system-font-size',
|
||||
category: 'quality',
|
||||
severity: 'advisory',
|
||||
scopes: ['type'],
|
||||
name: 'Font size outside DESIGN.md',
|
||||
description:
|
||||
'A literal font-size is off the type ramp documented in DESIGN.md typography. Use a documented size step or update the design system if the new step is intentional.',
|
||||
skillSection: 'Typography',
|
||||
skillGuideline: 'font size outside the project design system',
|
||||
},
|
||||
|
||||
// ── Provider tells: opt-in via --gpt / --gemini (gated off by default) ──
|
||||
{
|
||||
@@ -448,12 +483,32 @@ function filterByProviders(findings, providers = []) {
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// Set of scope tags rules can declare (e.g. 'type', 'layout'). Used by the
|
||||
// CLI --scope flag to narrow output to one design domain.
|
||||
const RULE_SCOPES = new Set(
|
||||
ANTIPATTERNS.flatMap(rule => rule.scopes || []),
|
||||
);
|
||||
|
||||
// Keep only findings whose rule declares at least one of the requested
|
||||
// scopes. An empty scope list means no filtering (default CLI behavior).
|
||||
function filterByScopes(findings, scopes = []) {
|
||||
if (!scopes || scopes.length === 0) return findings;
|
||||
const enabled = new Set(scopes);
|
||||
return findings.filter(f => {
|
||||
const rule = getAntipattern(f.antipattern);
|
||||
return (rule?.scopes || []).some(scope => enabled.has(scope));
|
||||
});
|
||||
}
|
||||
|
||||
export {
|
||||
ANTIPATTERNS,
|
||||
RULE_SCOPES,
|
||||
RULE_ENGINE_SUPPORT,
|
||||
GATED_PROVIDERS,
|
||||
getAntipattern,
|
||||
getRulesForCategory,
|
||||
getRuleEngineSupport,
|
||||
filterByProviders,
|
||||
filterByScopes,
|
||||
};
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
parseRgb,
|
||||
relativeLuminance,
|
||||
} from '../shared/color.mjs';
|
||||
import { extractGoogleFontFamilies } from '../shared/fonts.mjs';
|
||||
|
||||
const DETECTOR_IS_BROWSER = typeof window !== 'undefined';
|
||||
|
||||
@@ -2072,14 +2073,9 @@ function checkPageTypography(doc, win) {
|
||||
|
||||
// Check Google Fonts links in HTML
|
||||
const html = doc.documentElement?.outerHTML || '';
|
||||
const gfRe = /fonts\.googleapis\.com\/css2?\?family=([^&"'\s]+)/gi;
|
||||
let m;
|
||||
while ((m = gfRe.exec(html)) !== null) {
|
||||
const families = m[1].split('|').map(f => f.split(':')[0].replace(/\+/g, ' ').toLowerCase());
|
||||
for (const f of families) {
|
||||
fonts.add(f);
|
||||
if (OVERUSED_FONTS.has(f)) overusedFound.add(f);
|
||||
}
|
||||
for (const f of extractGoogleFontFamilies(html)) {
|
||||
fonts.add(f);
|
||||
if (OVERUSED_FONTS.has(f)) overusedFound.add(f);
|
||||
}
|
||||
|
||||
// Also parse raw HTML/style content for font-family (jsdom may not expose all via CSSOM)
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
const GOOGLE_FONTS_URL_RE = /fonts\.googleapis\.com\/css2?\?[^"'\s)<>]*/gi;
|
||||
|
||||
function normalizeGoogleFontFamilyParam(value) {
|
||||
return String(value || '')
|
||||
.split('|')
|
||||
.map(part => part.split(':')[0].trim().toLowerCase())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function extractGoogleFontFamilies(text) {
|
||||
const families = [];
|
||||
if (!text) return families;
|
||||
|
||||
GOOGLE_FONTS_URL_RE.lastIndex = 0;
|
||||
let urlMatch;
|
||||
while ((urlMatch = GOOGLE_FONTS_URL_RE.exec(text)) !== null) {
|
||||
const url = urlMatch[0];
|
||||
const queryStart = url.indexOf('?');
|
||||
if (queryStart === -1) continue;
|
||||
|
||||
const params = new URLSearchParams(url.slice(queryStart + 1).replace(/&/g, '&'));
|
||||
for (const value of params.getAll('family')) {
|
||||
families.push(...normalizeGoogleFontFamilyParam(value));
|
||||
}
|
||||
}
|
||||
|
||||
return families;
|
||||
}
|
||||
|
||||
export { extractGoogleFontFamilies };
|
||||
@@ -1,6 +1,6 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* `/impeccable hooks <on|off|status|reset>` — manage the design hook runtime
|
||||
* The Impeccable hooks command manages the design hook runtime
|
||||
* via the `hook` key and shared detector ignores via the `detector` key in
|
||||
* .impeccable/config.json / .impeccable/config.local.json.
|
||||
*
|
||||
@@ -21,6 +21,7 @@
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { IMPECCABLE_COMMAND } from './lib/provider.mjs';
|
||||
|
||||
import {
|
||||
getConfigPath,
|
||||
@@ -184,7 +185,7 @@ function writeHookConfig(cwd, hookConfig, opts = {}) {
|
||||
const existing = existingRaw && typeof existingRaw === 'object' && !Array.isArray(existingRaw) ? existingRaw : {};
|
||||
const existingHook = stripDetectorKeys(hookSection(existing));
|
||||
// Merge over the existing hook object so fields the merge helpers don't manage
|
||||
// (consent, quiet, auditLog) survive a `/impeccable hooks` edit.
|
||||
// (consent, quiet, auditLog) survive an Impeccable hooks edit.
|
||||
const next = { ...existing, hook: { ...existingHook, ...hookConfig } };
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
fs.writeFileSync(filePath, JSON.stringify(next, null, 2) + '\n');
|
||||
@@ -513,9 +514,9 @@ function parseIgnoreRuleArgs(args) {
|
||||
function addIgnoreRule(cwd, args) {
|
||||
const parsed = parseIgnoreRuleArgs(args);
|
||||
const rule = parsed.rule;
|
||||
if (!rule) throw new Error('Pass a rule id, e.g. /impeccable hooks ignore-rule side-tab');
|
||||
if (!rule) throw new Error(`Pass a rule id, e.g. ${IMPECCABLE_COMMAND} hooks ignore-rule side-tab`);
|
||||
if (rule === 'overused-font' && !parsed.allValues) {
|
||||
throw new Error('overused-font is value-specific by default. Use /impeccable hooks ignore-value overused-font <font> for a confirmed font, or /impeccable hooks ignore-rule overused-font --all-values only when the user asked to ignore overused fonts generally.');
|
||||
throw new Error(`overused-font is value-specific by default. Use ${IMPECCABLE_COMMAND} hooks ignore-value overused-font <font> for a confirmed font, or ${IMPECCABLE_COMMAND} hooks ignore-rule overused-font --all-values only when the user asked to ignore overused fonts generally.`);
|
||||
}
|
||||
const config = mergeDetectorConfig(readRawDetectorConfig(cwd));
|
||||
if (!config.ignoreRules.includes(rule)) config.ignoreRules.push(rule);
|
||||
@@ -524,7 +525,7 @@ function addIgnoreRule(cwd, args) {
|
||||
}
|
||||
|
||||
function addIgnoreFile(cwd, glob) {
|
||||
if (!glob) throw new Error('Pass a glob, e.g. /impeccable hooks ignore-file "src/legacy/**"');
|
||||
if (!glob) throw new Error(`Pass a glob, e.g. ${IMPECCABLE_COMMAND} hooks ignore-file "src/legacy/**"`);
|
||||
const config = mergeDetectorConfig(readRawDetectorConfig(cwd));
|
||||
if (!config.ignoreFiles.includes(glob)) config.ignoreFiles.push(glob);
|
||||
writeDetectorConfig(cwd, config);
|
||||
@@ -569,7 +570,7 @@ function parseIgnoreValueArgs(args) {
|
||||
function addIgnoreValue(cwd, args) {
|
||||
const parsed = parseIgnoreValueArgs(args);
|
||||
if (!parsed.rule || !parsed.value) {
|
||||
throw new Error('Pass a rule id and value, e.g. /impeccable hooks ignore-value overused-font Inter');
|
||||
throw new Error(`Pass a rule id and value, e.g. ${IMPECCABLE_COMMAND} hooks ignore-value overused-font Inter`);
|
||||
}
|
||||
|
||||
if (parsed.shared && parsed.local) {
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import {
|
||||
@@ -21,13 +22,17 @@ import {
|
||||
appendDesignSystemNote,
|
||||
designSystemOptions,
|
||||
filterFindings,
|
||||
isNativePlatform,
|
||||
loadDetector,
|
||||
matchConfiguredExtension,
|
||||
matchesAnyGlob,
|
||||
persistCache,
|
||||
readCache,
|
||||
readConfig,
|
||||
renderTemplate,
|
||||
resolveCacheCwd,
|
||||
resolveProjectCwd,
|
||||
resolveProjectPlatform,
|
||||
truthy,
|
||||
writeAuditLog,
|
||||
} from './hook-lib.mjs';
|
||||
@@ -332,6 +337,22 @@ function isInsideProject(filePath, cwd) {
|
||||
}
|
||||
}
|
||||
|
||||
// The static HTML engine reads its input from disk, but preToolUse only has
|
||||
// the proposed content. Stage it in a temp file so html-engine targets get the
|
||||
// same DOM-structural rules pre-write that runHook applies post-edit.
|
||||
async function detectProposedHtml(detector, content, filePath, scanOptions) {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'impeccable-pre-'));
|
||||
const tmpFile = path.join(dir, path.basename(filePath));
|
||||
try {
|
||||
fs.writeFileSync(tmpFile, content);
|
||||
const findings = await detector.detectHtml(tmpFile, scanOptions);
|
||||
// Findings carry the temp path; remap so file-scoped ignores still match.
|
||||
return (findings || []).map((f) => (f && typeof f === 'object' ? { ...f, file: filePath } : f));
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
function cursorBlockMessage(findings, filePath, config, cwd) {
|
||||
const rendered = renderTemplate(findings, filePath, config, { cwd });
|
||||
const blocked = rendered.replace(
|
||||
@@ -379,9 +400,12 @@ async function main() {
|
||||
return allow({ skipped: 'stdin-empty' });
|
||||
}
|
||||
|
||||
const cwd = resolveProjectCwd(event);
|
||||
const sessionCwd = resolveProjectCwd(event);
|
||||
const started = Date.now();
|
||||
const filePath = proposedFilePath(event, cwd);
|
||||
const filePath = proposedFilePath(event, sessionCwd);
|
||||
// Re-key config/cache to the edited file's project root when the session
|
||||
// was launched from a non-project umbrella directory (issue #305).
|
||||
const cwd = resolveCacheCwd(filePath, sessionCwd);
|
||||
const audit = {
|
||||
harness: 'cursor',
|
||||
cwd,
|
||||
@@ -394,9 +418,13 @@ async function main() {
|
||||
if (SENSITIVE_PATH.test(filePath)) return allow({ ...audit, skipped: 'sensitive', durationMs: Date.now() - started });
|
||||
if (GENERATED_PATH.test(filePath)) return allow({ ...audit, skipped: 'generated', durationMs: Date.now() - started });
|
||||
|
||||
// Config is read before the extension gate so `detector.extensions` entries
|
||||
// (e.g. `.blade.php` template files, issue #316) can widen it.
|
||||
const config = readConfig(cwd);
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
audit.ext = ext;
|
||||
if (!ALLOWED_EXTS.has(ext)) return allow({ ...audit, skipped: 'extension', durationMs: Date.now() - started });
|
||||
const configuredExt = matchConfiguredExtension(filePath, config.extensions);
|
||||
audit.ext = configuredExt ? configuredExt.ext : ext;
|
||||
if (!ALLOWED_EXTS.has(ext) && !configuredExt) return allow({ ...audit, skipped: 'extension', durationMs: Date.now() - started });
|
||||
|
||||
const contentResult = proposedContent(event, cwd, filePath);
|
||||
if (contentResult && typeof contentResult === 'object' && contentResult.skipped) {
|
||||
@@ -405,9 +433,14 @@ async function main() {
|
||||
const content = typeof contentResult === 'string' ? contentResult : '';
|
||||
if (!content) return allow({ ...audit, skipped: 'no-proposed-content', durationMs: Date.now() - started });
|
||||
|
||||
const config = readConfig(cwd);
|
||||
if (config.enabled === false) return allow({ ...audit, skipped: 'config-disabled', durationMs: Date.now() - started });
|
||||
|
||||
// Web rule engine, native project: stand aside (see resolveProjectPlatform).
|
||||
const platform = resolveProjectPlatform(cwd);
|
||||
if (isNativePlatform(platform)) {
|
||||
return allow({ ...audit, skipped: 'native-platform', platform, durationMs: Date.now() - started });
|
||||
}
|
||||
|
||||
const rel = relativePath(filePath, cwd);
|
||||
if (matchesAnyGlob(rel, config.ignoreFiles) || matchesAnyGlob(filePath, config.ignoreFiles)) {
|
||||
return allow({ ...audit, skipped: 'config-ignore-file', durationMs: Date.now() - started });
|
||||
@@ -419,9 +452,16 @@ async function main() {
|
||||
}
|
||||
const scanOptions = designSystemOptions(config, detector, cwd);
|
||||
|
||||
// Mirror runHook's engine routing so template issues the HTML engine catches
|
||||
// post-edit cannot slip past the pre-write gate.
|
||||
const useHtmlEngine = configuredExt
|
||||
? configuredExt.engine === 'html'
|
||||
: (ext === '.html' || ext === '.htm');
|
||||
let findings = [];
|
||||
try {
|
||||
findings = await detector.detectText(content, filePath, scanOptions);
|
||||
findings = useHtmlEngine && typeof detector.detectHtml === 'function'
|
||||
? await detectProposedHtml(detector, content, filePath, scanOptions)
|
||||
: await detector.detectText(content, filePath, scanOptions);
|
||||
} catch {
|
||||
return allow({ ...audit, error: 'detector-threw', durationMs: Date.now() - started });
|
||||
}
|
||||
|
||||
@@ -9,15 +9,17 @@
|
||||
* ENVELOPE_PREFIX, ALLOWED_EXTS, ACK_EXTS, SENSITIVE_PATH, GENERATED_PATH, TRUTHY
|
||||
* truthy(value)
|
||||
* readConfig(cwd) / DEFAULT_CONFIG / getConfigPath(cwd) / getLocalConfigPath(cwd)
|
||||
* resolveProjectPlatform(cwd) / isNativePlatform(platform)
|
||||
* normalizeIgnoreValue(value)
|
||||
* readCache(cwd) / persistCache(cwd, cache)
|
||||
* readCache(cwd) / persistCache(cwd, cache) / resolveCacheCwd(primaryFile, sessionCwd)
|
||||
* bumpEditCount(cache, sessionId, filePath) -> number
|
||||
* suppressionNotice(filePath)
|
||||
* filterFindings(findings, content, ext, config)
|
||||
* matchConfiguredExtension(filePath, extensions)
|
||||
* dedupeAgainstCache(findings, cache, sessionId, filePath)
|
||||
* renderTemplate(findings, filePath, config, opts)
|
||||
* renderCleanAck(filePath, opts) / renderPendingAck(filePath, known, opts)
|
||||
* shouldEmitAckForFile(filePath)
|
||||
* shouldEmitAckForFile(filePath, config?)
|
||||
* writeAuditLog(env, entry)
|
||||
* loadDetector() -> Promise<{ detectText, detectHtml }>
|
||||
* matchesAnyGlob(filePath, globs)
|
||||
@@ -35,8 +37,11 @@
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { pathToFileURL, fileURLToPath } from 'node:url';
|
||||
import { extractPlatform, loadContext } from './context.mjs';
|
||||
import { IMPECCABLE_COMMAND } from './lib/provider.mjs';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
@@ -77,6 +82,7 @@ export const DEFAULT_CONFIG = Object.freeze({
|
||||
ignoreRules: [],
|
||||
ignoreFiles: [],
|
||||
ignoreValues: [],
|
||||
extensions: [],
|
||||
limits: { maxFindings: 5, maxChars: 8000 },
|
||||
});
|
||||
|
||||
@@ -134,6 +140,59 @@ export function resolveProjectCwd(event, fallback = process.cwd()) {
|
||||
|| fallback;
|
||||
}
|
||||
|
||||
function looksLikeProjectRoot(dir) {
|
||||
return ['.git', 'package.json', '.impeccable'].some((marker) => {
|
||||
try { return fs.existsSync(path.join(dir, marker)); } catch { return false; }
|
||||
});
|
||||
}
|
||||
|
||||
// Where `.impeccable/` (cache + config) lives for this event. Normally the
|
||||
// session cwd, untouched. But when the agent was launched from an umbrella
|
||||
// directory that is not itself a project (no .git, package.json, or
|
||||
// .impeccable), key to the edited file's nearest project root instead, so a
|
||||
// multi-project launch dir doesn't accumulate a shared cross-project cache
|
||||
// (issue #305). Climbing stops at the home dir, falling back to the session
|
||||
// cwd when no marker is found.
|
||||
export function resolveCacheCwd(primaryFile, sessionCwd) {
|
||||
const base = path.resolve(sessionCwd || process.cwd());
|
||||
if (!primaryFile || typeof primaryFile !== 'string' || hasPathTraversal(primaryFile)) return base;
|
||||
if (looksLikeProjectRoot(base)) return base;
|
||||
let dir;
|
||||
try {
|
||||
dir = path.dirname(path.resolve(primaryFile));
|
||||
} catch {
|
||||
return base;
|
||||
}
|
||||
const home = path.resolve(os.homedir());
|
||||
while (true) {
|
||||
if (dir === home) return base;
|
||||
if (looksLikeProjectRoot(dir)) return dir;
|
||||
const parent = path.dirname(dir);
|
||||
if (parent === dir) return base;
|
||||
dir = parent;
|
||||
}
|
||||
}
|
||||
|
||||
// The detector's rules are web rules (HTML/CSS shapes), but a React Native or
|
||||
// Flutter project is made of the exact extensions the hook watches (.tsx, .ts,
|
||||
// .js), so without this gate every native screen edit would draw web-shaped
|
||||
// findings that contradict the native platform references. PRODUCT.md's
|
||||
// `## Platform` field decides: `ios` / `android` / `adaptive` projects skip
|
||||
// the scan entirely. Resolution goes through loadContext so the hook reads the
|
||||
// same PRODUCT.md the skill does (alternate context dirs, monorepo fallback).
|
||||
export function resolveProjectPlatform(cwd) {
|
||||
try {
|
||||
const ctx = loadContext(cwd);
|
||||
return extractPlatform(ctx && ctx.product);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function isNativePlatform(platform) {
|
||||
return platform === 'ios' || platform === 'android' || platform === 'adaptive';
|
||||
}
|
||||
|
||||
export function readConfig(cwd) {
|
||||
const config = cloneDefaultConfig();
|
||||
// Hook runtime settings live under `hook`; detector filters live under
|
||||
@@ -168,6 +227,7 @@ function cloneDefaultConfig() {
|
||||
ignoreRules: [],
|
||||
ignoreFiles: [],
|
||||
ignoreValues: [],
|
||||
extensions: [],
|
||||
designSystem: { ...DEFAULT_CONFIG.designSystem },
|
||||
limits: { ...DEFAULT_CONFIG.limits },
|
||||
};
|
||||
@@ -190,9 +250,55 @@ function applyDetectorConfigSource(config, raw) {
|
||||
if (Array.isArray(raw.ignoreValues)) {
|
||||
config.ignoreValues = mergeIgnoreValues(config.ignoreValues, raw.ignoreValues);
|
||||
}
|
||||
if (Array.isArray(raw.extensions)) {
|
||||
config.extensions = mergeExtensions(config.extensions, raw.extensions);
|
||||
}
|
||||
return config;
|
||||
}
|
||||
|
||||
// Extra scanned extensions from `detector.extensions` config. Entries are
|
||||
// `{ ext, engine }` (engine 'html' | 'text', default 'html' — the common case
|
||||
// for server-side templates) or bare strings as shorthand. Extensions are
|
||||
// matched against the end of the filename, not path.extname, so double
|
||||
// extensions like `.blade.php` and `.html.erb` work (issue #316).
|
||||
function normalizeExtensionEntries(entries) {
|
||||
if (!Array.isArray(entries)) return [];
|
||||
const out = [];
|
||||
for (const entry of entries) {
|
||||
const raw = typeof entry === 'string' ? entry : entry?.ext;
|
||||
if (typeof raw !== 'string') continue;
|
||||
let ext = raw.trim().toLowerCase();
|
||||
if (!ext) continue;
|
||||
if (!ext.startsWith('.')) ext = `.${ext}`;
|
||||
const engine = (!(typeof entry === 'string') && entry?.engine === 'text') ? 'text' : 'html';
|
||||
out.push({ ext, engine });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function mergeExtensions(existing, incoming) {
|
||||
const map = new Map();
|
||||
for (const entry of normalizeExtensionEntries(existing)) map.set(entry.ext, entry);
|
||||
for (const entry of normalizeExtensionEntries(incoming)) map.set(entry.ext, entry);
|
||||
return Array.from(map.values());
|
||||
}
|
||||
|
||||
export function matchConfiguredExtension(filePath, extensions) {
|
||||
if (!Array.isArray(extensions) || extensions.length === 0) return null;
|
||||
const name = path.basename(String(filePath || '')).toLowerCase();
|
||||
if (!name) return null;
|
||||
// The longest matching suffix wins, so `.blade.php` beats a broader `.php`
|
||||
// entry regardless of config order.
|
||||
let best = null;
|
||||
for (const entry of normalizeExtensionEntries(extensions)) {
|
||||
if (name.length > entry.ext.length && name.endsWith(entry.ext)
|
||||
&& (!best || entry.ext.length > best.ext.length)) {
|
||||
best = entry;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
function applyConfigSource(config, raw) {
|
||||
if (!raw || typeof raw !== 'object') return config;
|
||||
if (Object.prototype.hasOwnProperty.call(raw, 'enabled')) {
|
||||
@@ -556,7 +662,7 @@ export function bumpEditCount(cache, sessionId, filePath) {
|
||||
}
|
||||
|
||||
export function suppressionNotice(filePath) {
|
||||
return `${ENVELOPE_PREFIX} Suppressing further design hints on ${filePath}. More than ${EDIT_COUNT_THRESHOLD} edits in this session reached. Run /impeccable audit to revisit.`;
|
||||
return `${ENVELOPE_PREFIX} Suppressing further design hints on ${filePath}. More than ${EDIT_COUNT_THRESHOLD} edits in this session reached. Run ${IMPECCABLE_COMMAND} audit to revisit.`;
|
||||
}
|
||||
|
||||
// Glob → RegExp. Supports `**`, `*`, `?`, and `{a,b}` alternation.
|
||||
@@ -628,11 +734,13 @@ export function filterFindings(findings, _content, _ext, config) {
|
||||
function isIgnoredFindingValue(finding, ignoreValues) {
|
||||
if (!Array.isArray(ignoreValues) || ignoreValues.length === 0) return false;
|
||||
const rule = normalizeIgnoreRule(finding.antipattern);
|
||||
if (!rule) return false;
|
||||
// File-scoped wildcards suppress rules with no extractable value, such as side-tab.
|
||||
const value = extractFindingIgnoreValue(finding);
|
||||
if (!rule || !value) return false;
|
||||
return ignoreValues.some((entry) => {
|
||||
if (entry.rule !== rule) return false;
|
||||
const wildcardValue = entry.value === '*';
|
||||
if (entry.rule !== rule || (!wildcardValue && !ignoreValueMatches(rule, entry.value, value))) return false;
|
||||
if (!wildcardValue && (!value || !ignoreValueMatches(rule, entry.value, value))) return false;
|
||||
if (!Array.isArray(entry.files) || entry.files.length === 0) return !wildcardValue;
|
||||
return findingMatchesScopedIgnoreFile(finding, entry.files);
|
||||
});
|
||||
@@ -770,7 +878,7 @@ export function renderTemplate(findings, filePath, config, opts = {}) {
|
||||
const header = `${ENVELOPE_PREFIX} Design hook findings requiring review in ${display} (${total} issue(s)):`;
|
||||
const lines = shown.map((f) => formatFindingLine(f));
|
||||
const more = remaining > 0
|
||||
? `... and ${remaining} more (see /impeccable audit).`
|
||||
? `... and ${remaining} more (see ${IMPECCABLE_COMMAND} audit).`
|
||||
: null;
|
||||
const footer = directiveFooter(display);
|
||||
|
||||
@@ -814,7 +922,7 @@ function renderGroupedTemplate(groups, config, opts = {}) {
|
||||
shownCount += shown.length;
|
||||
const hidden = group.findings.length - shown.length;
|
||||
if (hidden > 0) {
|
||||
lines.push(`- ... ${hidden} more in ${display} (see /impeccable audit).`);
|
||||
lines.push(`- ... ${hidden} more in ${display} (see ${IMPECCABLE_COMMAND} audit).`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -830,7 +938,7 @@ function clampGroupedToBudget(header, lines, footer, maxChars) {
|
||||
const assemble = (linesArr, omitted) => [
|
||||
header,
|
||||
...linesArr,
|
||||
...(omitted ? ['... and more (see /impeccable audit).'] : []),
|
||||
...(omitted ? [`... and more (see ${IMPECCABLE_COMMAND} audit).`] : []),
|
||||
'',
|
||||
footer,
|
||||
].join('\n');
|
||||
@@ -863,7 +971,7 @@ function clampToBudget(header, lines, more, footer, maxChars) {
|
||||
let assembled = assemble(working, moreText);
|
||||
while (assembled.length > maxChars && working.length > 1) {
|
||||
working.pop();
|
||||
moreText = '... and more (see /impeccable audit).';
|
||||
moreText = `... and more (see ${IMPECCABLE_COMMAND} audit).`;
|
||||
assembled = assemble(working, moreText);
|
||||
}
|
||||
if (assembled.length > maxChars) {
|
||||
@@ -895,7 +1003,7 @@ function formatFindingIgnoreCommand(finding) {
|
||||
const value = extractFindingIgnoreValueRaw(finding);
|
||||
const valueArg = quoteCommandArg(value);
|
||||
const reason = quoteCommandArg(`User confirmed ${value} is intentional`);
|
||||
return `/impeccable hooks ignore-value ${rule} ${valueArg} --shared --reason ${reason}`;
|
||||
return `${IMPECCABLE_COMMAND} hooks ignore-value ${rule} ${valueArg} --shared --reason ${reason}`;
|
||||
}
|
||||
|
||||
function quoteCommandArg(value) {
|
||||
@@ -1319,8 +1427,12 @@ export function renderPendingAck(filePath, knownFindings, opts = {}) {
|
||||
return `${ENVELOPE_PREFIX} Design hook scanned ${display}. Still has ${count} finding(s) flagged earlier this session (${sample}${more}). Handle them before finalizing — the previous reminder still applies.`;
|
||||
}
|
||||
|
||||
export function shouldEmitAckForFile(filePath) {
|
||||
return ACK_EXTS.has(path.extname(String(filePath || '')).toLowerCase());
|
||||
export function shouldEmitAckForFile(filePath, config = null) {
|
||||
if (ACK_EXTS.has(path.extname(String(filePath || '')).toLowerCase())) return true;
|
||||
// Configured html-engine extensions are declared UI markup, so they get the
|
||||
// clean/pending acks; text-engine ones stay quiet like plain .ts/.js.
|
||||
const configured = matchConfiguredExtension(filePath, config?.extensions);
|
||||
return Boolean(configured && configured.engine === 'html');
|
||||
}
|
||||
|
||||
export function designSystemOptions(config, detector, projectCwd) {
|
||||
@@ -1336,7 +1448,7 @@ export function designSystemOptions(config, detector, projectCwd) {
|
||||
|
||||
export function appendDesignSystemNote(text, scanOptions) {
|
||||
if (!text || !scanOptions?.designSystem?.mdNewerThanJson) return text;
|
||||
return `${text}\n\n${ENVELOPE_PREFIX} DESIGN.md is newer than .impeccable/design.json. Run /impeccable document to refresh the design-system sidecar.`;
|
||||
return `${text}\n\n${ENVELOPE_PREFIX} DESIGN.md is newer than .impeccable/design.json. Run ${IMPECCABLE_COMMAND} document to refresh the design-system sidecar.`;
|
||||
}
|
||||
|
||||
// The directive footer is the part of the hook output that steers model
|
||||
@@ -1353,16 +1465,16 @@ export function appendDesignSystemNote(text, scanOptions) {
|
||||
// raw envelope. Asking the model to surface the resolution in its
|
||||
// reply is the cheapest way to make the feedback loop visible.
|
||||
function directiveFooter(display, opts = {}) {
|
||||
const ignoreFileCommand = `/impeccable hooks ignore-file ${quoteCommandArg(display)}`;
|
||||
const ignoreFileCommand = `${IMPECCABLE_COMMAND} hooks ignore-file ${quoteCommandArg(display)}`;
|
||||
const fileIgnoreGuidance = opts.grouped
|
||||
? 'run `/impeccable hooks ignore-file <path>` for the specific file'
|
||||
? `run \`${IMPECCABLE_COMMAND} hooks ignore-file <path>\` for the specific file`
|
||||
: `run \`${ignoreFileCommand}\``;
|
||||
return [
|
||||
'Handle these before finalizing: fix findings that are real design problems, or explicitly classify contextually intentional findings as false positives. Acknowledge what you changed or why you are leaving a finding unchanged.',
|
||||
'',
|
||||
'Use context judgment before editing. A finding is not automatically a defect; literal or domain-appropriate motion, intentional demos or fixtures, documentation of bad design, and user-confirmed choices can be valid as-is.',
|
||||
'',
|
||||
`Do not change intentional design just to satisfy the hook, and do not silence a real finding with an inline ignore comment to skip fixing it. Suppress a finding only after the user explicitly confirms it is intentional. Prefer a config ignore (one reviewable place, the commands below); reach for an inline \`impeccable-disable <rule>\` comment only when the waiver must travel with a file that leaves the repo, such as an exported or standalone document. Prefer the narrowest persisted exception: run the exact \`/impeccable hooks ignore-value ... --shared\` command shown next to a value-specific finding. For \`overused-font\`, use \`ignore-value\` for a specific font and use \`/impeccable hooks ignore-rule overused-font --all-values\` only when the user asks to ignore overused fonts generally. For file-specific findings without an ignore-value command, ${fileIgnoreGuidance}; use \`/impeccable hooks ignore-rule <id>\` only when the user asks to suppress the whole non-value-specific rule. Run /impeccable audit for the full pass.`,
|
||||
`Do not change intentional design just to satisfy the hook, and do not silence a real finding with an inline ignore comment to skip fixing it. Suppress a finding only after the user explicitly confirms it is intentional. Prefer a config ignore (one reviewable place, the commands below); reach for an inline \`impeccable-disable <rule>\` comment only when the waiver must travel with a file that leaves the repo, such as an exported or standalone document. Prefer the narrowest persisted exception: run the exact \`${IMPECCABLE_COMMAND} hooks ignore-value ... --shared\` command shown next to a value-specific finding. For \`overused-font\`, use \`ignore-value\` for a specific font and use \`${IMPECCABLE_COMMAND} hooks ignore-rule overused-font --all-values\` only when the user asks to ignore overused fonts generally. For file-specific findings without an ignore-value command, ${fileIgnoreGuidance}; use \`${IMPECCABLE_COMMAND} hooks ignore-rule <id>\` only when the user asks to suppress the whole non-value-specific rule. Run ${IMPECCABLE_COMMAND} audit for the full pass.`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
@@ -1402,9 +1514,10 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now =
|
||||
event = normalizeHookEvent(event, cwd, harness);
|
||||
audit.harness = harness;
|
||||
|
||||
const projectCwd = event.cwd || cwd;
|
||||
const sessionCwd = event.cwd || cwd;
|
||||
const primaryFiles = normalizeScanTargets(resolveTargetFiles(event, sessionCwd), sessionCwd);
|
||||
const projectCwd = resolveCacheCwd(primaryFiles[0], sessionCwd);
|
||||
audit.cwd = projectCwd;
|
||||
const primaryFiles = normalizeScanTargets(resolveTargetFiles(event, projectCwd), projectCwd);
|
||||
const primaryFileSet = new Set(primaryFiles);
|
||||
const targetFiles = expandScanTargets(primaryFiles, projectCwd);
|
||||
audit.session = event.session_id || null;
|
||||
@@ -1419,11 +1532,16 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now =
|
||||
return result({ skipped: 'config-disabled', durationMs: Date.now() - started });
|
||||
}
|
||||
|
||||
const platform = resolveProjectPlatform(projectCwd);
|
||||
if (isNativePlatform(platform)) {
|
||||
return result({ skipped: 'native-platform', platform, durationMs: Date.now() - started });
|
||||
}
|
||||
|
||||
const cache = readCache(projectCwd);
|
||||
const sessionId = event.session_id || 'unknown';
|
||||
const det = detector || await loadDetector();
|
||||
if (!det || typeof det.detectText !== 'function') {
|
||||
persistCache(projectCwd, cache);
|
||||
// Cache is not mutated yet at this point; nothing to persist.
|
||||
return result({ skipped: 'detector-missing', durationMs: Date.now() - started });
|
||||
}
|
||||
const scanOptions = designSystemOptions(config, det, projectCwd);
|
||||
@@ -1435,6 +1553,7 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now =
|
||||
let detectorThrewAny = false;
|
||||
let lastSkip = 'no-scannable-file';
|
||||
let suppressedHit = false;
|
||||
let cacheDirty = false;
|
||||
|
||||
for (const filePath of targetFiles) {
|
||||
audit.file = filePath;
|
||||
@@ -1449,8 +1568,9 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now =
|
||||
}
|
||||
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
audit.ext = ext;
|
||||
if (!ALLOWED_EXTS.has(ext)) {
|
||||
const configuredExt = matchConfiguredExtension(filePath, config.extensions);
|
||||
audit.ext = configuredExt ? configuredExt.ext : ext;
|
||||
if (!ALLOWED_EXTS.has(ext) && !configuredExt) {
|
||||
lastSkip = 'extension';
|
||||
continue;
|
||||
}
|
||||
@@ -1467,6 +1587,7 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now =
|
||||
|
||||
if (primaryFileSet.has(filePath)) {
|
||||
const editCount = bumpEditCount(cache, sessionId, filePath);
|
||||
cacheDirty = true;
|
||||
audit.editCount = editCount;
|
||||
|
||||
if (editCount > EDIT_COUNT_THRESHOLD) {
|
||||
@@ -1483,7 +1604,10 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now =
|
||||
const content = fs.readFileSync(filePath, 'utf-8');
|
||||
let findings;
|
||||
let detectorThrew = false;
|
||||
if ((ext === '.html' || ext === '.htm') && typeof det.detectHtml === 'function') {
|
||||
const useHtmlEngine = configuredExt
|
||||
? configuredExt.engine === 'html'
|
||||
: (ext === '.html' || ext === '.htm');
|
||||
if (useHtmlEngine && typeof det.detectHtml === 'function') {
|
||||
try { findings = await det.detectHtml(filePath, scanOptions); } catch { findings = []; detectorThrew = true; }
|
||||
} else {
|
||||
try { findings = await det.detectText(content, filePath, scanOptions); } catch { findings = []; detectorThrew = true; }
|
||||
@@ -1496,6 +1620,7 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now =
|
||||
|
||||
if (fresh.length > 0) {
|
||||
rememberFindings(cache, sessionId, filePath, fresh);
|
||||
cacheDirty = true;
|
||||
freshGroups.push({ filePath, findings: fresh });
|
||||
continue;
|
||||
}
|
||||
@@ -1513,7 +1638,15 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now =
|
||||
}
|
||||
}
|
||||
|
||||
persistCache(projectCwd, cache);
|
||||
// Persist only when the write is earned: fresh findings justify creating
|
||||
// `.impeccable/` (dedup and suppression need it), and an already-present
|
||||
// `.impeccable/` dir marks a project that opted in. A non-UI edit, or a
|
||||
// clean UI edit in a project with no Impeccable footprint, must be a
|
||||
// no-op on disk (issues #344, #305).
|
||||
if (freshGroups.length > 0
|
||||
|| (cacheDirty && fs.existsSync(path.join(projectCwd, '.impeccable')))) {
|
||||
persistCache(projectCwd, cache);
|
||||
}
|
||||
|
||||
if (freshGroups.length > 0) {
|
||||
const firstGroup = freshGroups[0];
|
||||
@@ -1548,7 +1681,7 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now =
|
||||
return result({ emitted: false, quiet: true, durationMs: Date.now() - started });
|
||||
}
|
||||
|
||||
if (pendingWinner && shouldEmitAckForFile(pendingWinner.filePath)) {
|
||||
if (pendingWinner && shouldEmitAckForFile(pendingWinner.filePath, config)) {
|
||||
const text = appendDesignSystemNote(renderPendingAck(pendingWinner.filePath, pendingWinner.known, { cwd: projectCwd }), scanOptions);
|
||||
return {
|
||||
exitCode: 0,
|
||||
@@ -1582,7 +1715,7 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now =
|
||||
};
|
||||
}
|
||||
|
||||
if (cleanWinner && shouldEmitAckForFile(cleanWinner.filePath)) {
|
||||
if (cleanWinner && shouldEmitAckForFile(cleanWinner.filePath, config)) {
|
||||
const text = appendDesignSystemNote(renderCleanAck(cleanWinner.filePath, { cwd: projectCwd }), scanOptions);
|
||||
return {
|
||||
exitCode: 0,
|
||||
|
||||
@@ -459,11 +459,13 @@ export function filterDetectionFindings(findings, config) {
|
||||
function isIgnoredFindingValue(finding, ignoreValues) {
|
||||
if (!Array.isArray(ignoreValues) || ignoreValues.length === 0) return false;
|
||||
const rule = normalizeIgnoreRule(finding.antipattern);
|
||||
if (!rule) return false;
|
||||
// File-scoped wildcards suppress rules with no extractable value, such as side-tab.
|
||||
const value = extractFindingIgnoreValue(finding);
|
||||
if (!rule || !value) return false;
|
||||
return ignoreValues.some((entry) => {
|
||||
if (entry.rule !== rule) return false;
|
||||
const wildcardValue = entry.value === '*';
|
||||
if (entry.rule !== rule || (!wildcardValue && !ignoreValueMatches(rule, entry.value, value))) return false;
|
||||
if (!wildcardValue && (!value || !ignoreValueMatches(rule, entry.value, value))) return false;
|
||||
if (!Array.isArray(entry.files) || entry.files.length === 0) return !wildcardValue;
|
||||
return findingMatchesScopedIgnoreFile(finding, entry.files);
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { resolveProjectRoot } from '../context.mjs';
|
||||
export { IMPECCABLE_COMMAND_PREFIX } from './provider.mjs';
|
||||
|
||||
export const IMPECCABLE_DIR = '.impeccable';
|
||||
export const LIVE_DIR = 'live';
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
// Source scripts default to slash commands. The provider build replaces only
|
||||
// this exact declaration, avoiding heuristic rewrites across executable code.
|
||||
export const IMPECCABLE_COMMAND_PREFIX = "/";
|
||||
export const IMPECCABLE_COMMAND = `${IMPECCABLE_COMMAND_PREFIX}impeccable`;
|
||||
@@ -57,6 +57,7 @@
|
||||
const Z = { highlight: 100001, bar: 100005, picker: 100007, toast: 100010 };
|
||||
const EASE = 'cubic-bezier(0.22, 1, 0.36, 1)'; // ease-out-quint
|
||||
const PREFIX = 'impeccable-live';
|
||||
const IMPECCABLE_COMMAND = (window.__IMPECCABLE_COMMAND_PREFIX__ || '/') + 'impeccable';
|
||||
const PICK_CURSOR_STYLE_ID = PREFIX + '-pick-cursor-style';
|
||||
const MANUAL_APPLY_STATE_TTL_MS = 15 * 60 * 1000;
|
||||
const sessionState = window.__IMPECCABLE_LIVE_SESSION__?.createLiveBrowserSessionState({
|
||||
@@ -153,6 +154,7 @@
|
||||
let scrollLockRaf = null;
|
||||
let scrollLockAbort = null;
|
||||
const SCROLL_ANCHOR_LOCK_ID = 'impeccable-scroll-anchor-lock';
|
||||
const VARIANT_STATE_STYLE_ID = 'impeccable-variant-state';
|
||||
|
||||
// Dedicated key for scroll position - SEPARATE from LS_KEY so that
|
||||
// saveSession's state updates don't clobber a carefully-captured scrollY.
|
||||
@@ -3035,16 +3037,26 @@
|
||||
function applyParamValue(variantEl, param, value) {
|
||||
if (!variantEl) return;
|
||||
const attr = 'data-p-' + param.id;
|
||||
if (param.kind === 'range') {
|
||||
variantEl.style.setProperty('--p-' + param.id, String(value));
|
||||
} else if (param.kind === 'toggle') {
|
||||
if (param.kind === 'toggle') {
|
||||
const on = !!value;
|
||||
variantEl.style.setProperty('--p-' + param.id, on ? '1' : '0');
|
||||
if (on) variantEl.setAttribute(attr, 'on');
|
||||
else variantEl.removeAttribute(attr);
|
||||
} else if (param.kind === 'steps') {
|
||||
variantEl.setAttribute(attr, String(value));
|
||||
}
|
||||
// Svelte component variants are client-mounted into
|
||||
// [data-impeccable-component-mount] with no [data-impeccable-variant="N"]
|
||||
// wrapper for the state stylesheet to target, and the element is not SSR'd,
|
||||
// so there is no React hydration to mismatch. Drive range/toggle --p-* inline
|
||||
// on the mounted element so scoped preview CSS resolves them.
|
||||
if (svelteComponentSession?.sessionId === currentSessionId) {
|
||||
if (param.kind === 'range') variantEl.style.setProperty('--p-' + param.id, String(value));
|
||||
else if (param.kind === 'toggle') variantEl.style.setProperty('--p-' + param.id, value ? '1' : '0');
|
||||
return;
|
||||
}
|
||||
// range/toggle --p-* custom properties are driven through the injected
|
||||
// variant-state stylesheet so we never mutate inline style on SSR'd divs.
|
||||
updateVariantStateStylesheet(currentSessionId, visibleVariant);
|
||||
}
|
||||
|
||||
function applyParamDefaults(variantEl, params) {
|
||||
@@ -4714,6 +4726,7 @@
|
||||
paramsCurrentValues = {};
|
||||
tuneOpen = false;
|
||||
hideParamsPanel();
|
||||
if (currentSessionId && visibleVariant) updateVariantStateStylesheet(currentSessionId, visibleVariant);
|
||||
return;
|
||||
}
|
||||
applyParamDefaults(variantEl, params);
|
||||
@@ -4771,20 +4784,7 @@
|
||||
|
||||
function isVariantShown(el) {
|
||||
if (!el) return false;
|
||||
if (el.hidden) return false;
|
||||
if (el.style?.display === 'none') return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function setVariantShown(el, shown) {
|
||||
if (!el) return;
|
||||
if (shown) {
|
||||
el.removeAttribute('hidden');
|
||||
el.style.display = '';
|
||||
} else {
|
||||
el.setAttribute('hidden', '');
|
||||
el.style.display = 'none';
|
||||
}
|
||||
return getComputedStyle(el).display !== 'none';
|
||||
}
|
||||
|
||||
function scheduleCyclingBarSync(sessionId, variantNum) {
|
||||
@@ -4823,11 +4823,7 @@
|
||||
}
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
if (!wrapper) return false;
|
||||
for (const child of wrapper.children) {
|
||||
const v = child.dataset ? child.dataset.impeccableVariant : null;
|
||||
if (!v) continue;
|
||||
setVariantShown(child, v === String(num));
|
||||
}
|
||||
updateVariantStateStylesheet(sessionId, num);
|
||||
// Unconditional refresh - covers first-reveal (no-op if state isn't
|
||||
// CYCLING yet, the subsequent CYCLING transition triggers its own
|
||||
// refresh) and every cycle step.
|
||||
@@ -5492,6 +5488,7 @@
|
||||
if (pendingSvelteComponentRetryObserver) { pendingSvelteComponentRetryObserver.disconnect(); pendingSvelteComponentRetryObserver = null; }
|
||||
if (pendingVariantAnchorRetryObserver) { pendingVariantAnchorRetryObserver.disconnect(); pendingVariantAnchorRetryObserver = null; }
|
||||
stopScrollLock();
|
||||
removeVariantStateStylesheet();
|
||||
clearSession();
|
||||
clearHandled();
|
||||
resetSessionFileMeta();
|
||||
@@ -5806,6 +5803,68 @@
|
||||
return variantDiv;
|
||||
}
|
||||
|
||||
// Variant visibility and range/toggle params are expressed through ONE
|
||||
// injected stylesheet, never inline attributes on the variant divs. Those
|
||||
// divs are scaffolded into page source, so SSR frameworks (Next.js App
|
||||
// Router) server-render them; toggling their `hidden` / inline `style` /
|
||||
// `--p-*` client-side trips a React 19 hydration mismatch on the next
|
||||
// Fast-Refresh re-render — the same failure mode the scroll-anchor (#276)
|
||||
// and pick-cursor (#286) fixes address. A stylesheet rule has the same
|
||||
// computed effect without mutating any hydrated element's attributes.
|
||||
// (steps params keep driving `data-p-*` attributes, matching scoped CSS.)
|
||||
const VARIANT_HIDE_DECL = 'display: none !important;';
|
||||
const VARIANT_SHOW_DECL = 'display: block !important;';
|
||||
|
||||
// Build a direct-child variant selector for a session. With `num`, targets a
|
||||
// single variant (`… > [data-impeccable-variant="N"]`); without it, targets
|
||||
// every variant via the bare `[data-impeccable-variant]` attribute.
|
||||
function variantStateSelector(sessionId, num) {
|
||||
const wrapper = '[data-impeccable-variants="' + sessionId + '"]';
|
||||
const variant = num == null
|
||||
? '[data-impeccable-variant]'
|
||||
: '[data-impeccable-variant="' + num + '"]';
|
||||
return wrapper + ' > ' + variant;
|
||||
}
|
||||
|
||||
// Serialize the visible variant's knob values into `--p-<id>` custom-property
|
||||
// declarations. Only range (number) and toggle (boolean) values become a
|
||||
// custom property; steps params drive `data-p-*` attributes instead.
|
||||
function variantParamDecls(values) {
|
||||
return Object.entries(values || {})
|
||||
.map(([id, val]) => {
|
||||
if (typeof val === 'number') return ' --p-' + id + ': ' + val + ';';
|
||||
if (typeof val === 'boolean') return ' --p-' + id + ': ' + (val ? '1' : '0') + ';';
|
||||
return '';
|
||||
})
|
||||
.join('');
|
||||
}
|
||||
|
||||
function updateVariantStateStylesheet(sessionId, num) {
|
||||
if (!sessionId || num == null || num < 1) return;
|
||||
|
||||
let styleEl = document.getElementById(VARIANT_STATE_STYLE_ID);
|
||||
if (!styleEl) {
|
||||
styleEl = document.createElement('style');
|
||||
styleEl.id = VARIANT_STATE_STYLE_ID;
|
||||
(document.head || document.documentElement).appendChild(styleEl);
|
||||
}
|
||||
|
||||
// Hide every variant except the visible one (incl. the SSR'd "original").
|
||||
const hideOthers = variantStateSelector(sessionId)
|
||||
+ ':not([data-impeccable-variant="' + num + '"]) { ' + VARIANT_HIDE_DECL + ' }';
|
||||
|
||||
// Force-show the visible variant (beats the source inline display:none on
|
||||
// v2/v3) and apply its knob values as custom properties.
|
||||
const showVisible = variantStateSelector(sessionId, num)
|
||||
+ ' { ' + VARIANT_SHOW_DECL + variantParamDecls(paramsCurrentValues) + ' }';
|
||||
|
||||
styleEl.textContent = hideOthers + '\n' + showVisible + '\n';
|
||||
}
|
||||
|
||||
function removeVariantStateStylesheet() {
|
||||
document.getElementById(VARIANT_STATE_STYLE_ID)?.remove();
|
||||
}
|
||||
|
||||
// Hold window.scrollY at a fixed value across DOM mutations inside the
|
||||
// session's wrapper (HMR patches, variant inserts, cycle swaps).
|
||||
function startScrollLock(sessionId, initialTargetY) {
|
||||
@@ -6087,7 +6146,7 @@
|
||||
switch (msg.type) {
|
||||
case 'connected':
|
||||
hasProjectContext = !!msg.hasProjectContext;
|
||||
if (!hasProjectContext) showToast('No PRODUCT.md found. Variants will be brand-agnostic. Run /impeccable init to generate one.', 7000);
|
||||
if (!hasProjectContext) showToast(`No PRODUCT.md found. Variants will be brand-agnostic. Run ${IMPECCABLE_COMMAND} init to generate one.`, 7000);
|
||||
console.log('[impeccable] Live mode connected.');
|
||||
syncAgentPollingUi(!!msg.agentPolling);
|
||||
startAgentStatusPoll();
|
||||
@@ -7636,6 +7695,7 @@ void main() {
|
||||
stopScrollTracking();
|
||||
if (variantObserver) { variantObserver.disconnect(); variantObserver = null; }
|
||||
stopScrollLock();
|
||||
removeVariantStateStylesheet();
|
||||
clearScrollY();
|
||||
clearSession();
|
||||
resetSessionFileMeta();
|
||||
@@ -7897,6 +7957,7 @@ void main() {
|
||||
if (variantObserver) { variantObserver.disconnect(); variantObserver = null; }
|
||||
if (pendingVariantAnchorRetryObserver) { pendingVariantAnchorRetryObserver.disconnect(); pendingVariantAnchorRetryObserver = null; }
|
||||
stopScrollLock();
|
||||
removeVariantStateStylesheet();
|
||||
clearScrollY();
|
||||
finalizeInsertSession();
|
||||
clearSession();
|
||||
@@ -7928,7 +7989,7 @@ void main() {
|
||||
const barTopFromBottom = barRect && barRect.height > 0
|
||||
? Math.max(16, window.innerHeight - barRect.top + 12)
|
||||
: 16;
|
||||
toastEl = el('div', {
|
||||
const currentToast = el('div', {
|
||||
position: 'fixed', bottom: barTopFromBottom + 'px', left: '50%',
|
||||
transform: 'translateX(-50%) translateY(8px)',
|
||||
background: C.ink, color: C.white,
|
||||
@@ -7938,19 +7999,24 @@ void main() {
|
||||
transition: 'opacity 0.25s ' + EASE + ', transform 0.25s ' + EASE,
|
||||
pointerEvents: 'none', maxWidth: '420px', textAlign: 'center',
|
||||
});
|
||||
toastEl.id = PREFIX + '-toast';
|
||||
toastEl.textContent = message;
|
||||
uiAppend(toastEl);
|
||||
toastEl = currentToast;
|
||||
currentToast.id = PREFIX + '-toast';
|
||||
currentToast.textContent = message;
|
||||
uiAppend(currentToast);
|
||||
requestAnimationFrame(() => {
|
||||
toastEl.style.opacity = '1';
|
||||
toastEl.style.transform = 'translateX(-50%) translateY(0)';
|
||||
if (toastEl !== currentToast) return;
|
||||
currentToast.style.opacity = '1';
|
||||
currentToast.style.transform = 'translateX(-50%) translateY(0)';
|
||||
});
|
||||
setTimeout(() => {
|
||||
if (toastEl) {
|
||||
toastEl.style.opacity = '0';
|
||||
toastEl.style.transform = 'translateX(-50%) translateY(8px)';
|
||||
setTimeout(() => { if (toastEl) { toastEl.remove(); toastEl = null; } }, 250);
|
||||
}
|
||||
if (toastEl !== currentToast) return;
|
||||
currentToast.style.opacity = '0';
|
||||
currentToast.style.transform = 'translateX(-50%) translateY(8px)';
|
||||
setTimeout(() => {
|
||||
if (toastEl !== currentToast) return;
|
||||
currentToast.remove();
|
||||
toastEl = null;
|
||||
}, 250);
|
||||
}, duration);
|
||||
}
|
||||
|
||||
@@ -9984,6 +10050,7 @@ void main() {
|
||||
window.postMessage({ source: 'impeccable-command', action: 'remove' }, '*');
|
||||
setLiveState('IDLE');
|
||||
document.getElementById(PICK_CURSOR_STYLE_ID)?.remove();
|
||||
removeVariantStateStylesheet();
|
||||
window.__IMPECCABLE_LIVE_INIT__ = false;
|
||||
console.log('[impeccable] Live mode exited.');
|
||||
}
|
||||
@@ -10472,7 +10539,7 @@ void main() {
|
||||
if (designState.present === false) {
|
||||
const empty = document.createElement('div');
|
||||
empty.className = 'empty';
|
||||
empty.innerHTML = `<strong>No DESIGN.md yet</strong>Create one by running <code>/impeccable document</code> in your terminal, then re-open this panel.`;
|
||||
empty.innerHTML = `<strong>No DESIGN.md yet</strong>Create one by running <code>${IMPECCABLE_COMMAND} document</code> in your terminal, then re-open this panel.`;
|
||||
body.appendChild(empty);
|
||||
return;
|
||||
}
|
||||
@@ -10502,7 +10569,7 @@ void main() {
|
||||
box.className = 'stale';
|
||||
box.innerHTML = `
|
||||
<span class="stale-dot"></span>
|
||||
<span class="stale-text"><strong>DESIGN.md is newer than .impeccable/design.json.</strong> Run <code>/impeccable document</code> to refresh the sidecar.</span>
|
||||
<span class="stale-text"><strong>DESIGN.md is newer than .impeccable/design.json.</strong> Run <code>${IMPECCABLE_COMMAND} document</code> to refresh the sidecar.</span>
|
||||
`;
|
||||
return box;
|
||||
}
|
||||
@@ -10510,7 +10577,7 @@ void main() {
|
||||
function renderParsedMdCta() {
|
||||
const box = document.createElement('div');
|
||||
box.className = 'parsed-md-cta';
|
||||
box.innerHTML = `<strong>Basic view</strong>This panel reads the tokens in your <code>DESIGN.md</code> frontmatter. Running <code>/impeccable document</code> also generates a <code>.impeccable/design.json</code> sidecar with your project's actual component snippets (button, input, nav) and tonal ramps, rendered live below the tokens.`;
|
||||
box.innerHTML = `<strong>Basic view</strong>This panel reads the tokens in your <code>DESIGN.md</code> frontmatter. Running <code>${IMPECCABLE_COMMAND} document</code> also generates a <code>.impeccable/design.json</code> sidecar with your project's actual component snippets (button, input, nav) and tonal ramps, rendered live below the tokens.`;
|
||||
return box;
|
||||
}
|
||||
|
||||
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
getDesignSidecarPath,
|
||||
getLiveDir,
|
||||
getLiveAnnotationsDir,
|
||||
IMPECCABLE_COMMAND_PREFIX,
|
||||
readLiveServerInfo,
|
||||
removeLiveServerInfo,
|
||||
resolveDesignSidecarPath,
|
||||
@@ -413,6 +414,7 @@ function createRequestHandler({ detectScript, liveScriptParts }) {
|
||||
token: state.token,
|
||||
port: state.port,
|
||||
vocabulary: LIVE_COMMANDS,
|
||||
commandPrefix: IMPECCABLE_COMMAND_PREFIX,
|
||||
parts,
|
||||
});
|
||||
res.writeHead(200, {
|
||||
|
||||
@@ -32,10 +32,11 @@ export function readLiveBrowserScriptParts(parts, readFile = (filePath) => fs.re
|
||||
}));
|
||||
}
|
||||
|
||||
export function assembleLiveBrowserScript({ token, port, vocabulary, parts }) {
|
||||
export function assembleLiveBrowserScript({ token, port, vocabulary, commandPrefix = '/', parts }) {
|
||||
const prelude =
|
||||
`window.__IMPECCABLE_TOKEN__ = '${token}';\n` +
|
||||
`window.__IMPECCABLE_PORT__ = ${port};\n` +
|
||||
`window.__IMPECCABLE_COMMAND_PREFIX__ = ${JSON.stringify(commandPrefix)};\n` +
|
||||
// Canonical command vocabulary (values + labels + icons). live-browser.js
|
||||
// builds its action picker from this instead of an inline copy.
|
||||
`window.__IMPECCABLE_VOCAB__ = ${JSON.stringify(vocabulary)};\n`;
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
* node <scripts_path>/pin.mjs pin <command>
|
||||
* node <scripts_path>/pin.mjs unpin <command>
|
||||
*
|
||||
* `pin audit` creates a lightweight /audit skill that redirects to /impeccable audit.
|
||||
* `pin audit` creates a lightweight audit skill that redirects to Impeccable's audit workflow.
|
||||
* `unpin audit` removes that shortcut.
|
||||
*
|
||||
* The script discovers harness directories (.claude/skills, .cursor/skills, etc.)
|
||||
@@ -14,7 +14,7 @@
|
||||
*/
|
||||
|
||||
import { existsSync, readFileSync, writeFileSync, mkdirSync, rmSync, readdirSync } from 'node:fs';
|
||||
import { join, resolve, dirname } from 'node:path';
|
||||
import { basename, join, resolve, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
@@ -25,6 +25,8 @@ const HARNESS_DIRS = [
|
||||
'.trae', '.trae-cn', '.pi', '.opencode', '.kiro', '.rovodev',
|
||||
];
|
||||
|
||||
const CODEX_HARNESSES = new Set(['.codex', '.agents']);
|
||||
|
||||
// Valid sub-command names
|
||||
const VALID_COMMANDS = [
|
||||
'craft', 'init', 'extract', 'document', 'shape',
|
||||
@@ -87,8 +89,12 @@ function loadCommandMetadata() {
|
||||
/**
|
||||
* Generate a pinned skill's SKILL.md content.
|
||||
*/
|
||||
function generatePinnedSkill(command, metadata) {
|
||||
const desc = metadata[command]?.description || `Shortcut for /impeccable ${command}.`;
|
||||
function commandPrefixForSkillsDir(skillsDir) {
|
||||
return CODEX_HARNESSES.has(basename(dirname(skillsDir))) ? '$' : '/';
|
||||
}
|
||||
|
||||
function generatePinnedSkill(command, metadata, commandPrefix) {
|
||||
const desc = metadata[command]?.description || `Shortcut for ${commandPrefix}impeccable ${command}.`;
|
||||
const hint = metadata[command]?.argumentHint || '[target]';
|
||||
|
||||
return `---
|
||||
@@ -100,9 +106,9 @@ user-invocable: true
|
||||
|
||||
${PIN_MARKER}
|
||||
|
||||
This is a pinned shortcut for \`{{command_prefix}}impeccable ${command}\`.
|
||||
This is a pinned shortcut for \`${commandPrefix}impeccable ${command}\`.
|
||||
|
||||
Invoke {{command_prefix}}impeccable ${command}, passing along any arguments provided here, and follow its instructions.
|
||||
Invoke ${commandPrefix}impeccable ${command}, passing along any arguments provided here, and follow its instructions.
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -118,10 +124,11 @@ function pin(command, projectRoot) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const content = generatePinnedSkill(command, metadata);
|
||||
let created = 0;
|
||||
|
||||
for (const skillsDir of harnessDirs) {
|
||||
const commandPrefix = commandPrefixForSkillsDir(skillsDir);
|
||||
const content = generatePinnedSkill(command, metadata, commandPrefix);
|
||||
// Check if skill already exists (and isn't a pin)
|
||||
const skillDir = join(skillsDir, command);
|
||||
if (existsSync(skillDir)) {
|
||||
@@ -143,7 +150,7 @@ function pin(command, projectRoot) {
|
||||
|
||||
if (created > 0) {
|
||||
console.log(`\nPinned '${command}' as a standalone shortcut in ${created} location(s).`);
|
||||
console.log(`You can now use /${command} directly.`);
|
||||
console.log('Use the pinned command directly in each harness.');
|
||||
}
|
||||
|
||||
return created > 0;
|
||||
@@ -177,7 +184,7 @@ function unpin(command, projectRoot) {
|
||||
|
||||
if (removed > 0) {
|
||||
console.log(`\nUnpinned '${command}' from ${removed} location(s).`);
|
||||
console.log(`Use /impeccable ${command} to access it.`);
|
||||
console.log(`Use Impeccable's '${command}' workflow directly to access it.`);
|
||||
} else {
|
||||
console.log(`No pinned '${command}' shortcut found.`);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: impeccable
|
||||
description: Use when the user wants to design, redesign, shape, critique, audit, polish, clarify, distill, harden, optimize, adapt, animate, colorize, extract, or otherwise improve a frontend interface. Covers websites, landing pages, dashboards, product UI, app shells, components, forms, settings, onboarding, and empty states. Handles UX review, visual hierarchy, information architecture, cognitive load, accessibility, performance, responsive behavior, theming, anti-patterns, typography, fonts, spacing, layout, alignment, color, motion, micro-interactions, UX copy, error states, edge cases, i18n, and reusable design systems or tokens. Also use for bland designs that need to become bolder or more delightful, loud designs that should become quieter, live browser iteration on UI elements, or ambitious visual effects that should feel technically extraordinary. Not for backend-only or non-UI tasks.
|
||||
version: 3.9.0
|
||||
version: 3.9.1
|
||||
license: Apache 2.0
|
||||
---
|
||||
|
||||
@@ -11,11 +11,12 @@ Designs and iterates production-grade frontend interfaces. Real working code, co
|
||||
|
||||
You MUST do these steps before proceeding:
|
||||
|
||||
1. Run `node .cursor/skills/impeccable/scripts/context.mjs` once per session. If the request names or implies a file, route, or app inside a monorepo, infer the concrete path and run `node .cursor/skills/impeccable/scripts/context.mjs --target <path>` instead. If you've already seen its output in this conversation, do not re-run it. The script either prints the project's PRODUCT.md (and DESIGN.md when present) as a markdown block, or tells you it's missing. Follow whatever it prints. **If it reports `NO_PRODUCT_MD`, stop and follow `reference/init.md` before doing anything else.** If the output ends with an `UPDATE_AVAILABLE` directive, follow it (ask the user once about updating, then continue). It never blocks the current task.
|
||||
2. If the user invoked a sub-command (`craft`, `shape`, `audit`, `polish`, ...), you MUST read `reference/<command>.md` next. Non-optional. The reference defines the command's flow; without it you will skip steps the user expects.
|
||||
1. Run `node .cursor/skills/impeccable/scripts/context.mjs` once per session; if the runtime shows this skill's loaded base directory, run `node <skill-base-dir>/scripts/context.mjs` instead. Keep cwd/workdir at the user's project, not the skill directory. If the request names or implies a file, route, or app inside a monorepo, infer the concrete path and append `--target <path>` to the same command. If you've already seen its output in this conversation, do not re-run it. The script either prints the project's PRODUCT.md (and DESIGN.md when present) as a markdown block, or tells you it's missing. Follow whatever it prints. **If it reports `NO_PRODUCT_MD`:** divert into `reference/init.md` first when the user invoked `init`, `teach`, `craft`, or `shape`, or when their wording clearly maps to one of those from-scratch build flows (for example: "build/create/make a landing page", "design a new app", or "shape a feature"). Captured product context is the point of those flows. For any other command, a scoped evaluate / refine / enhance / fix / iterate request against existing code, do **not** divert into init. The existing code is the context: proceed with the requested command, infer the register from the surface in focus (step 4), and offer `/impeccable init` once as a suggestion the user can take later. A missing PRODUCT.md must never block a scoped request. If the output ends with an `UPDATE_AVAILABLE` directive, follow it (ask the user once about updating, then continue). It never blocks the current task.
|
||||
2. If the user invoked a sub-command (`craft`, `shape`, `audit`, `polish`, ...), you MUST read the command's reference next: **`reference/<command>.md`, or the native variant from the Commands table** (e.g. `reference/audit.native.md`) **when the project platform is native** (`ios` / `android` / `adaptive`, per the `context.mjs` directive). One file, not both. Non-optional. The reference defines the command's flow; without it you will skip steps the user expects.
|
||||
3. Familiarize yourself with any existing design system, conventions, and components in the code. Read at least one project file (CSS / tokens / theme / a representative component or page). **Required even when you've loaded a sub-command reference in step 2.** Don't reinvent the wheel; use what's there when it works, branch out when the UX wins.
|
||||
4. Read the matching register reference. **This is non-optional; skipping it produces generic output.** If the project is marketing, a landing page, a campaign, long-form content, or a portfolio (design IS the product), read `reference/brand.md`. If it is app UI, admin, a dashboard, or a tool (design SERVES the product), read `reference/product.md`. Pick by first match: (1) task cue ("landing page" vs "dashboard"); (2) surface in focus (the page, file, or route being worked on); (3) `register` field in PRODUCT.md.
|
||||
5. **If the project is brand-new (no existing CSS tokens / theme / committed brand colors found in step 3)**, run `node .cursor/skills/impeccable/scripts/palette.mjs` to receive a brand seed color and composition guidance. This is the anchor for your primary brand color. Compose the rest of the palette (bg, surface, ink, accent, muted) around it per the script's instructions. Use OKLCH throughout. **Skip this step only if step 3 found committed brand colors in existing tokens; in that case identity-preservation wins.**
|
||||
5. **If PRODUCT.md's `## Platform` is `ios` or `android`**, also read `reference/<platform>.md` (HIG / Material 3 conventions). `adaptive` (cross-platform, ships both) reads both files. `web`, absent, or unrecognized: nothing extra to read. `context.mjs` prints the directive when one applies.
|
||||
6. **If the project is brand-new (no existing CSS tokens / theme / committed brand colors found in step 3)**, run `node .cursor/skills/impeccable/scripts/palette.mjs` to receive a brand seed color and composition guidance. This is the anchor for your primary brand color. Compose the rest of the palette (bg, surface, ink, accent, muted) around it per the script's instructions. Use OKLCH throughout. **Skip this step only if step 3 found committed brand colors in existing tokens; in that case identity-preservation wins.**
|
||||
|
||||
## Design guidance
|
||||
|
||||
@@ -104,7 +105,7 @@ If someone could look at this interface and say "AI made that" without doubt, it
|
||||
| `document` | Build | Generate DESIGN.md from existing project code | [reference/document.md](reference/document.md) |
|
||||
| `extract [target]` | Build | Pull reusable tokens and components into design system | [reference/extract.md](reference/extract.md) |
|
||||
| `critique [target]` | Evaluate | UX design review with heuristic scoring | [reference/critique.md](reference/critique.md) |
|
||||
| `audit [target]` | Evaluate | Technical quality checks (a11y, perf, responsive) | [reference/audit.md](reference/audit.md) |
|
||||
| `audit [target]` | Evaluate | Technical quality checks (a11y, perf, responsive) | [reference/audit.md](reference/audit.md) · native: [reference/audit.native.md](reference/audit.native.md) |
|
||||
| `polish [target]` | Refine | Final quality pass before shipping | [reference/polish.md](reference/polish.md) |
|
||||
| `bolder [target]` | Refine | Amplify safe or bland designs | [reference/bolder.md](reference/bolder.md) |
|
||||
| `quieter [target]` | Refine | Tone down aggressive or overstimulating designs | [reference/quieter.md](reference/quieter.md) |
|
||||
@@ -118,7 +119,7 @@ If someone could look at this interface and say "AI made that" without doubt, it
|
||||
| `delight [target]` | Enhance | Add personality and memorable touches | [reference/delight.md](reference/delight.md) |
|
||||
| `overdrive [target]` | Enhance | Push past conventional limits | [reference/overdrive.md](reference/overdrive.md) |
|
||||
| `clarify [target]` | Fix | Improve UX copy, labels, and error messages | [reference/clarify.md](reference/clarify.md) |
|
||||
| `adapt [target]` | Fix | Adapt for different devices and screen sizes | [reference/adapt.md](reference/adapt.md) |
|
||||
| `adapt [target]` | Fix | Adapt for different devices and screen sizes | [reference/adapt.md](reference/adapt.md) · native: [reference/adapt.native.md](reference/adapt.native.md) |
|
||||
| `optimize [target]` | Fix | Diagnose and fix UI performance | [reference/optimize.md](reference/optimize.md) |
|
||||
| `live` | Iterate | Visual variant mode: pick elements in the browser, generate alternatives | [reference/live.md](reference/live.md) |
|
||||
|
||||
@@ -126,26 +127,26 @@ Plus three management commands: `pin <command>`, `unpin <command>`, and `hooks <
|
||||
|
||||
### Routing rules
|
||||
|
||||
1. **No argument**: the user is asking "what should I do?" Make the menu context-aware instead of static. Setup has already run `context.mjs`; if that reported `NO_PRODUCT_MD` you are already in init (setup), so finish that and skip this. Otherwise run `node .cursor/skills/impeccable/scripts/context-signals.mjs` once and read its JSON, then lead with the **2-3 highest-value next commands**, each with a one-line reason pulled from the signals, followed by the full menu (the table above, grouped by category). **Never auto-run a command; the recommendation is a suggestion the user confirms.**
|
||||
1. **No argument**: the user is asking "what should I do?" Make the menu context-aware instead of static. Setup has already run `context.mjs`; if that reported `NO_PRODUCT_MD` the project has no captured context yet, so lead the menu with `/impeccable init` as the top recommendation (one line on why) and still show the rest below; don't silently jump into init. Otherwise run `node .cursor/skills/impeccable/scripts/context-signals.mjs` once and read its JSON, then lead with the **2-3 highest-value next commands**, each with a one-line reason pulled from the signals, followed by the full menu (the table above, grouped by category). **Never auto-run a command; the recommendation is a suggestion the user confirms.**
|
||||
|
||||
Reason over the signals; there is no score to obey:
|
||||
- `setup.hasDesign` false while `setup.hasCode` true → `document` (capture the visual system).
|
||||
- `critique.latest` is `null` → the project has never been critiqued; for a set-up project with a real surface, offering `/impeccable critique <surface>` is a strong default.
|
||||
- `critique.latest` with a low `score` or non-zero `p0` / `p1` → `polish` (it reads that snapshot as its backlog), or re-run `critique` if the snapshot looks stale.
|
||||
- `git.changedFiles` pointing at one surface → scope `audit` or `polish` to those files specifically, naming them.
|
||||
- `devServer.running` true → `live` is available for in-browser iteration; if false, don't lead with `live`.
|
||||
- `devServer.running` true → `live` is available for in-browser iteration; if false, don't lead with `live`. **`live` and the bundled `detect.mjs` are web-only.** If `setup.platform` is `ios`, `android`, or `adaptive`, don't lead with either; the browser overlay and the HTML rule engine don't apply to native app code.
|
||||
- Otherwise group by intent exactly as init's "Recommend starting points" step does (build new / improve what's there / iterate visually), tailored to `setup.register`.
|
||||
|
||||
**If `scan.targets` is non-empty, run `node .cursor/skills/impeccable/scripts/detect.mjs --json <scan.targets joined by spaces>` once** (the bundled detector over local files: no network, no npx). `scan.via` tells you what they are: `git-changes` (the markup/style files in your dirty tree, the most relevant set), `source-dir` (e.g. `src`, `app`), `html`, or `root`. Fold the hits into your picks: many quality / contrast hits → `audit` or `polish`; a specific slop family → the matching command (gradient text or eyebrows → `quieter` / `typeset`, flat or gray palette → `colorize`, and so on). It's a real, current signal that beats guessing. If detect errors or the tree is large and slow, skip it and recommend the user run `audit` themselves; never block the suggestion on it.
|
||||
**If `scan.targets` is non-empty and `setup.platform` is not `ios`/`android`/`adaptive`, run `node .cursor/skills/impeccable/scripts/detect.mjs --json <scan.targets joined by spaces>` once** (the bundled detector over local files: no network, no npx; it reads HTML/CSS, so skip it for native projects). `scan.via` tells you what they are: `git-changes` (the markup/style files in your dirty tree, the most relevant set), `source-dir` (e.g. `src`, `app`), `html`, or `root`. Fold the hits into your picks: many quality / contrast hits → `audit` or `polish`; a specific slop family → the matching command (gradient text or eyebrows → `quieter` / `typeset`, flat or gray palette → `colorize`, and so on). It's a real, current signal that beats guessing. If detect errors or the tree is large and slow, skip it and recommend the user run `audit` themselves; never block the suggestion on it.
|
||||
|
||||
Keep it to 2-3 pointed picks with the exact command to type. The menu stays the fallback; the recommendation is the lede.
|
||||
2. **First word matches a command** (table above OR `pin` / `unpin` / `hooks`): load its reference file and follow its instructions. Everything after the command name is the target.
|
||||
3. **First word doesn't match, but the intent clearly maps to one command** (e.g. "fix the spacing" → `layout`, "rewrite this error message" → `clarify`, "the colors feel flat" → `colorize`): load that command's reference and proceed as if invoked. If two commands could fit, ask once which.
|
||||
2. **First word matches a command** (table above OR `pin` / `unpin` / `hooks`): load its reference file (on native platforms, the table's native variant; Setup step 2's one-file rule) and follow its instructions. Everything after the command name is the target.
|
||||
3. **First word doesn't match, but the intent clearly maps to one command** (e.g. "fix the spacing" → `layout`, "rewrite this error message" → `clarify`, "the colors feel flat" → `colorize`): load that command's reference (same native-variant rule) and proceed as if invoked. If two commands could fit, ask once which.
|
||||
4. **No clear command match**: general design invocation. Apply the setup steps, the General rules, and the loaded register reference, using the full argument as context.
|
||||
|
||||
Setup (context gathering, register) is already loaded by then; sub-commands don't re-invoke `/impeccable`.
|
||||
|
||||
If the first word is `craft`, setup still runs first, but [reference/craft.md](reference/craft.md) owns the rest of the flow. If setup invokes `init` as a blocker, finish init, refresh context, then resume the original command and target.
|
||||
If the first word is `craft` or `shape`, or routing rule 3 clearly maps the user's intent to either command, setup still runs first, but the matching reference ([reference/craft.md](reference/craft.md) or [reference/shape.md](reference/shape.md)) owns the rest of the flow. Both are from-scratch build flows: if setup invokes `init` as a blocker, finish init, refresh context, then resume the original command and target.
|
||||
|
||||
`teach` is a deprecated alias for `init`: if the user types it, load [reference/init.md](reference/init.md) and proceed as if they ran `init`.
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
Adapt an existing design to a different context: another screen size, device, platform, or use case. The trap is treating adaptation as scaling. The job is rethinking the experience for the new context.
|
||||
|
||||
**Web only** (mobile web included). Native platforms (`ios` / `android` / `adaptive`) route to [adapt.native.md](adapt.native.md) instead; if the project is native, switch to it now.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
> **Additional context needed**: target platforms/devices and usage contexts.
|
||||
|
||||
Adapt an existing **native** design (`ios` / `android` / `adaptive`) to a different context: another device class, orientation, platform, or origin. The trap is treating adaptation as scaling. The job is rethinking the experience for the new context, inside the platform conventions of [ios.md](ios.md) / [android.md](android.md); read the target platform's reference before planning if Setup hasn't already.
|
||||
|
||||
## Assess Adaptation Challenge
|
||||
|
||||
1. **Source context**: what was it designed for, and what assumptions did it make? (Phone-only? Portrait-only? One platform's idioms? A website?)
|
||||
2. **Target context**: which device class (phone, tablet, foldable), orientation, platform, and usage posture (one-handed on the go vs two-handed at rest)?
|
||||
3. **What breaks**: navigation that doesn't fit the target, layouts that stretch instead of restructure, gestures or controls that don't exist there?
|
||||
|
||||
## Adaptation Strategies
|
||||
|
||||
### Phone → Tablet (iPad / large screens)
|
||||
|
||||
- **Restructure, don't stretch.** A scaled-up phone UI on a tablet is the failure mode. Use size classes (iOS) / window size classes (Android) to switch structure.
|
||||
- **Navigation changes shape**: tab bar stays or becomes a sidebar on iPad; Android navigation bar becomes a rail or drawer on expanded width.
|
||||
- **Use the width**: split view / master-detail (list + detail side by side), multi-column grids, popovers where phones used sheets.
|
||||
- **Multitasking is a size, not an edge case**: iPad Split View and Android multi-window can hand you a phone-width window on a tablet; size-class-driven layout handles both for free.
|
||||
|
||||
### Orientation & foldables
|
||||
|
||||
- Landscape restructures (side-by-side panes, repositioned controls); never clip or letterbox. Lock orientation only when the task truly demands it.
|
||||
- Foldables (Android): react to posture and hinge via window size classes; test folded, unfolded, and tabletop.
|
||||
|
||||
### Platform → platform (iOS ↔ Android)
|
||||
|
||||
Translate idioms; never transplant them:
|
||||
|
||||
| iOS | Android |
|
||||
|---|---|
|
||||
| Tab bar | Navigation bar / rail / drawer |
|
||||
| Edge-swipe back, back chevron | Predictive Back gesture / button |
|
||||
| Switch, segmented control, system pickers | Material switch, chips, Material pickers |
|
||||
| Action sheet | Bottom sheet / Material dialog |
|
||||
| SF Symbols, SF Pro, Dynamic Type | Material Symbols, Roboto, sp scaling |
|
||||
| Semantic system colors, materials | Material color roles, tonal elevation |
|
||||
| System push/sheet transitions | Container transform, shared-axis, fade-through |
|
||||
|
||||
Rebuild navigation and controls in the target's vocabulary; carry over the brand's expressive layer (palette intent, type accent, motion personality) through the target's theming system.
|
||||
|
||||
### Web → native (porting a website or web app)
|
||||
|
||||
Reconform, don't reflow. Replace web navigation with the platform's model, HTML-shaped controls with platform controls, hover affordances with touch-first ones, and px-based type with Dynamic Type / sp. Then treat the result to the full platform reference; the slop test there is the acceptance bar.
|
||||
|
||||
## Implement & Verify
|
||||
|
||||
- Drive structure from **size classes / window size classes**, never from device-model checks.
|
||||
- Respect safe areas and window insets in every new configuration (notch, hinge, status bar, keyboard).
|
||||
- Test on simulators for breadth, then real hardware for truth: at least one phone and one tablet per shipped platform, both orientations, split-screen where supported.
|
||||
|
||||
When the adaptation feels native to each context, hand off to `/impeccable polish` for the final pass.
|
||||
|
||||
**NEVER**:
|
||||
- Ship a stretched phone layout on a tablet
|
||||
- Port one platform's controls or navigation onto the other
|
||||
- Hide core functionality on smaller devices (if it matters, make it work)
|
||||
- Lock orientation to dodge a layout bug
|
||||
- Trust simulators alone (posture, gestures, and performance need hardware)
|
||||
@@ -0,0 +1,40 @@
|
||||
# Android platform
|
||||
|
||||
For native Android apps: Jetpack Compose, Android Views, React Native, Expo, Flutter shipping to Android hardware.
|
||||
|
||||
On native, register narrows. Material Design 3 governs structure, navigation, and interaction whatever the register; brand expresses through Material's theming (color roles, type scale, shape, motion). A Material-everywhere cross-platform app that also ships to iPhone still owes iOS its OS guarantees on that hardware: safe-area insets, Reduce Motion, edge-swipe back.
|
||||
|
||||
## The Android slop test
|
||||
|
||||
Would a fluent Android user trust this app, or trip on off-spec components? The most common tell is an iOS app wearing Android's skin: a bottom-only navigation copied from iPhone, a back arrow that ignores the system Back gesture, Cupertino-shaped switches and dialogs. Material 3 is the rulebook; follow its components and theme the brand through it.
|
||||
|
||||
## Layout & structure
|
||||
|
||||
- **Material navigation, matched to size.** Navigation bar (bottom, 3–5 destinations) on compact width; navigation rail or drawer on expanded width. Never ship a phone bottom-bar untouched on a tablet.
|
||||
- **System Back always works.** Honor the predictive Back gesture and Back button; never trap the user or hijack the gesture.
|
||||
- **Edge-to-edge with window insets.** Apply the status bar, navigation bar, display cutout, and IME insets so content never hides behind system bars or the keyboard.
|
||||
- **Top app bar for screen context**; pair with a FAB when the screen has a single primary action.
|
||||
|
||||
## Touch targets
|
||||
|
||||
- **48×48 dp minimum** for every touch target, with at least 8 dp between them.
|
||||
|
||||
## Typography
|
||||
|
||||
- **Material type scale.** Display, Headline, Title, Body, Label roles (large/medium/small each). Map text to roles; never hand-pick sizes per screen.
|
||||
- **Roboto is the system face**; theme a brand face in through the type scale, keeping body, labels, and controls legible and consistent.
|
||||
- **sp units, never fixed px**, so type follows the system font-size setting.
|
||||
|
||||
## Color & theming
|
||||
|
||||
- **Material color roles** (primary, on-primary, surface, surface-variant, secondary-container, outline, error). Role tokens resolve light/dark and contrast variants automatically; raw hex breaks there.
|
||||
- **Dynamic Color (Material You)** where it fits: derive the scheme from the user's wallpaper on Android 12+, with a static fallback.
|
||||
- **Dark theme is a first-class scheme.** Design and test it; never a quick invert.
|
||||
- **Tonal elevation.** Convey elevation through the standard surface tonal levels (plus shadow where appropriate); no arbitrary drop shadows.
|
||||
|
||||
## Components & motion
|
||||
|
||||
- **Material components.** Buttons (filled / tonal / outlined / text), FAB, switches, chips, snackbars, bottom sheets, Material dialogs, navigation bar/rail/drawer. Never port iOS controls or invent equivalents.
|
||||
- **One FAB, one primary action.** Never stack FABs or spend one on a secondary task.
|
||||
- **Snackbars for transient feedback** (actionable when useful, never a toast for that); dialogs only for decisions that must interrupt.
|
||||
- **Material motion patterns.** Container transform, shared-axis, fade-through, with standard easing and durations; honor the system Remove animations setting with a crossfade or instant cut.
|
||||
@@ -10,6 +10,8 @@ Brand: motion is part of the voice; one well-rehearsed entrance beats scattered
|
||||
|
||||
Product: 150–250 ms on most transitions. Motion conveys state: feedback, reveal, loading, transitions between views. No page-load choreography; users are in a task and won't wait for it.
|
||||
|
||||
Native (`ios` / `android` / `adaptive`): implementation follows the Motion section of [ios.md](ios.md) / [android.md](android.md) (read it first if Setup hasn't already): system transitions and OS Reduce Motion, never the web tooling below.
|
||||
|
||||
---
|
||||
|
||||
## Assess Animation Opportunities
|
||||
|
||||
@@ -2,6 +2,8 @@ Run systematic **technical** quality checks and generate a comprehensive report.
|
||||
|
||||
This is a code-level audit, not a design critique. Check what's measurable and verifiable in the implementation.
|
||||
|
||||
**Web only.** Native platforms (`ios` / `android` / `adaptive`) route to [audit.native.md](audit.native.md) instead; if the project is native, switch to it now.
|
||||
|
||||
## Diagnostic Scan
|
||||
|
||||
Run comprehensive checks across 5 dimensions. Score each dimension 0-4 using the criteria below.
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
Run systematic **technical** quality checks on a native app (`ios` / `android` / `adaptive`) and generate a comprehensive report. Don't fix issues; document them for other commands to address.
|
||||
|
||||
This is a code-level audit, not a design critique. Audit from source (SwiftUI / UIKit / Compose / React Native / Flutter); no browser tooling or `detect.mjs` applies. Score against the platform reference(s): [ios.md](ios.md) / [android.md](android.md), both for `adaptive`. Read them before scoring if Setup hasn't already. The report skeleton mirrors [audit.md](audit.md); keep the two in sync when changing it.
|
||||
|
||||
## Diagnostic Scan
|
||||
|
||||
Run comprehensive checks across 5 dimensions. Score each dimension 0-4 using the criteria below.
|
||||
|
||||
### 1. Accessibility (VoiceOver / TalkBack)
|
||||
|
||||
**Check for**:
|
||||
- **Missing labels**: interactive elements without accessibility labels, traits/roles, or state announcements
|
||||
- **Reading and focus order**: illogical traversal, unreachable controls, focus lost on navigation
|
||||
- **Text scaling**: fixed point sizes defeating Dynamic Type (iOS) or px instead of sp (Android); layouts that clip or overlap at large sizes
|
||||
- **Touch targets**: below 44 pt (iOS) / 48 dp (Android), or crammed without spacing
|
||||
- **Reduce Motion ignored**: parallax and large slides with no crossfade alternative
|
||||
- **Contrast**: text failing contrast in either appearance, light or dark
|
||||
|
||||
**Score 0-4**: 0=Screen reader unusable, 1=Major gaps (unlabeled controls, no scaling), 2=Partial (labels exist, order or scaling breaks), 3=Good (minor gaps), 4=Excellent (labeled, ordered, scales cleanly, Reduce Motion honored)
|
||||
|
||||
### 2. Performance
|
||||
|
||||
**Check for**:
|
||||
- **Slow startup**: heavy work on launch before first frame
|
||||
- **Unvirtualized lists**: long content without FlatList / LazyColumn / List recycling
|
||||
- **Main-thread jank**: synchronous work in scroll or gesture paths, dropped frames on 60/120 Hz
|
||||
- **Wasted rendering**: unnecessary re-renders (React Native) or recompositions (Compose); missing memoization/keys
|
||||
- **Image handling**: full-size images decoded for thumbnails, no caching
|
||||
- **App weight**: bloated JS bundle or binary, unused dependencies
|
||||
|
||||
**Score 0-4**: 0=Janky everywhere, 1=Major problems (unvirtualized lists, slow launch), 2=Partial, 3=Good (minor improvements possible), 4=Excellent (fast launch, smooth scroll, lean)
|
||||
|
||||
### 3. Appearance & Theming
|
||||
|
||||
**Check for**:
|
||||
- **Hard-coded colors**: raw hex instead of semantic system colors (iOS) / Material color roles (Android) / design tokens
|
||||
- **Broken dark appearance**: missing dark variants, poor contrast in dark, quick inverts
|
||||
- **Dynamic Color** (Android 12+): no static fallback scheme, or ignored where it fits
|
||||
- **Off-platform materials**: hand-rolled blur/glassmorphism instead of system materials or tonal elevation
|
||||
|
||||
**Score 0-4**: 0=Hard-coded everything, 1=Minimal tokens, 2=Partial (tokens exist, inconsistently used), 3=Good (minor hard-coded values), 4=Excellent (semantic throughout, both appearances first-class)
|
||||
|
||||
### 4. Platform Conformance (CRITICAL)
|
||||
|
||||
Score against the loaded platform reference(s), including their slop tests. **Check for**:
|
||||
- **Broken system gestures**: edge-swipe back disabled (iOS), predictive Back hijacked (Android)
|
||||
- **Inset violations**: content under the notch, Dynamic Island, home indicator, status bar, or keyboard
|
||||
- **Off-platform navigation**: custom global nav, overloaded tab bars, iOS patterns on Android or vice versa
|
||||
- **Web-shaped controls**: HTML-style buttons, custom toggles, hover-dependent affordances
|
||||
- **Icon drift**: mixed icon sets instead of SF Symbols / Material Symbols
|
||||
- **AI tells**: the shared absolute bans still apply (AI palette, gradient text, hero metrics)
|
||||
|
||||
**Score 0-4**: 0=Web port (nothing native), 1=Heavy violations (3-4 kinds), 2=Some (1-2 noticeable), 3=Mostly conformant (subtle issues), 4=Fully native (a fluent user trusts every screen)
|
||||
|
||||
### 5. Adaptivity
|
||||
|
||||
**Check for**:
|
||||
- **Stretched phone layouts**: tablet/iPad rendering a scaled-up phone UI instead of using size classes / window size classes
|
||||
- **Orientation breakage**: landscape clipping, ignored, or locked without reason
|
||||
- **Keyboard/IME handling**: inputs hidden behind the keyboard, no inset adjustment
|
||||
- **Multitasking**: iPad Split View / Android multi-window breaking layout
|
||||
- **Foldables**: hinge-unaware layouts on posture change (Android)
|
||||
|
||||
**Score 0-4**: 0=One screen size only, 1=Major breakage (landscape or tablet broken), 2=Partial, 3=Good (minor edge cases), 4=Excellent (adapts across sizes, orientations, and windowing)
|
||||
|
||||
## Generate Report
|
||||
|
||||
### Audit Health Score
|
||||
|
||||
| # | Dimension | Score | Key Finding |
|
||||
|---|-----------|-------|-------------|
|
||||
| 1 | Accessibility | ? | [most critical issue or "--"] |
|
||||
| 2 | Performance | ? | |
|
||||
| 3 | Appearance & Theming | ? | |
|
||||
| 4 | Platform Conformance | ? | |
|
||||
| 5 | Adaptivity | ? | |
|
||||
| **Total** | | **??/20** | **[Rating band]** |
|
||||
|
||||
**Rating bands**: 18-20 Excellent (minor polish), 14-17 Good (address weak dimensions), 10-13 Acceptable (significant work needed), 6-9 Poor (major overhaul), 0-5 Critical (fundamental issues)
|
||||
|
||||
### Platform Conformance Verdict
|
||||
**Start here.** Pass/fail: does this read as a native app or a ported website? List specific violations. Be brutally honest.
|
||||
|
||||
### Executive Summary
|
||||
- Audit Health Score: **??/20** ([rating band])
|
||||
- Total issues found (count by severity: P0/P1/P2/P3)
|
||||
- Top 3-5 critical issues
|
||||
- Recommended next steps
|
||||
|
||||
### Detailed Findings by Severity
|
||||
|
||||
Tag every issue with **P0-P3 severity**:
|
||||
- **P0 Blocking**: Prevents task completion. Fix immediately
|
||||
- **P1 Major**: Significant difficulty or platform-guideline violation. Fix before release
|
||||
- **P2 Minor**: Annoyance, workaround exists. Fix in next pass
|
||||
- **P3 Polish**: Nice-to-fix, no real user impact. Fix if time permits
|
||||
|
||||
For each issue, document:
|
||||
- **[P?] Issue name**
|
||||
- **Location**: Screen, file, line
|
||||
- **Category**: Accessibility / Performance / Theming / Conformance / Adaptivity
|
||||
- **Impact**: How it affects users
|
||||
- **Guideline**: The HIG / Material rule it violates (if applicable)
|
||||
- **Recommendation**: How to fix it
|
||||
- **Suggested command**: Which command to use (prefer: /impeccable adapt, /impeccable animate, /impeccable audit, /impeccable bolder, /impeccable clarify, /impeccable colorize, /impeccable critique, /impeccable delight, /impeccable distill, /impeccable document, /impeccable harden, /impeccable layout, /impeccable onboard, /impeccable optimize, /impeccable overdrive, /impeccable polish, /impeccable quieter, /impeccable shape, /impeccable typeset)
|
||||
|
||||
### Patterns & Systemic Issues
|
||||
|
||||
Identify recurring problems that indicate systemic gaps rather than one-off mistakes:
|
||||
- "Hard-coded colors appear in 15+ screens, should use semantic colors"
|
||||
- "Touch targets consistently below 44 pt throughout the tab bar and list rows"
|
||||
|
||||
### Positive Findings
|
||||
|
||||
Note what's working well: good practices to maintain and replicate.
|
||||
|
||||
## Recommended Actions
|
||||
|
||||
List recommended commands in priority order (P0 first, then P1, then P2):
|
||||
|
||||
1. **[P?] `/command-name`**: Brief description (specific context from audit findings)
|
||||
2. **[P?] `/command-name`**: Brief description (specific context)
|
||||
|
||||
**Rules**: Only recommend commands from: /impeccable adapt, /impeccable animate, /impeccable audit, /impeccable bolder, /impeccable clarify, /impeccable colorize, /impeccable critique, /impeccable delight, /impeccable distill, /impeccable document, /impeccable harden, /impeccable layout, /impeccable onboard, /impeccable optimize, /impeccable overdrive, /impeccable polish, /impeccable quieter, /impeccable shape, /impeccable typeset. Map findings to the most appropriate command. End with `/impeccable polish` as the final step if any fixes were recommended.
|
||||
|
||||
After presenting the summary, tell the user:
|
||||
|
||||
> You can ask me to run these one at a time, all at once, or in any order you prefer.
|
||||
>
|
||||
> Re-run `/impeccable audit` after fixes to see your score improve.
|
||||
|
||||
**IMPORTANT**: Be thorough but actionable. Too many P3 issues creates noise. Focus on what actually matters.
|
||||
|
||||
**NEVER**:
|
||||
- Report issues without explaining impact (why does this matter?)
|
||||
- Provide generic recommendations (be specific and actionable)
|
||||
- Skip positive findings (celebrate what works)
|
||||
- Forget to prioritize (everything can't be P0)
|
||||
- Report false positives without verification
|
||||
@@ -6,6 +6,8 @@ The hook runs the impeccable design detector on direct file edits to design-rele
|
||||
|
||||
This command toggles the hook **per project** by editing `.impeccable/config.json` (the unified Impeccable config; hook runtime settings live under its `hook` key, and shared detector ignores live under `detector`). Per-developer overrides, including the install consent decision (`hook.consent`) the CLI records, live in the gitignored `.impeccable/config.local.json`. Set `hook.enabled: false` to turn the hook off, `hook.quiet: true` to silence the clean/pending acks, or `hook.auditLog` to a file path for an NDJSON log. The legacy `IMPECCABLE_HOOK_DISABLED`, `IMPECCABLE_HOOK_QUIET`, and `IMPECCABLE_HOOK_LOG` env vars are still honored and override these config values when set.
|
||||
|
||||
Declare server-side template extensions under **`detector.extensions`** when the project uses Blade, Twig, ERB, or Handlebars files; the hook skips them otherwise because they sit outside the built-in extension list. One entry per extension, `{ "ext": ".blade.php", "engine": "html" }`. `engine` picks the analyzer (`html` for markup templates, `text` for JS/TS/CSS-like files) and defaults to `html`. Match against the end of the filename, so double extensions like `.blade.php` and `.html.erb` work. Config only adds extensions; the built-in list always applies.
|
||||
|
||||
Manual `npx impeccable detect` scans use the same project filter config by default: `detector.ignoreRules`, `detector.ignoreFiles`, `detector.ignoreValues`, and `detector.designSystem.enabled`. `hook.enabled` only controls automatic hook execution, not manual CLI scans. Use `npx impeccable detect --no-config ...` for a raw detector run that ignores project config/context. Use `npx impeccable ignores ...` for direct CLI CRUD on the same detector ignores.
|
||||
|
||||
Supported harnesses: Claude Code (`.claude/settings.local.json` in the project, which is gitignored so the hook stays machine-local; a hook you move into the shared `settings.json` is honored in place too), Codex (`.codex/hooks.json` in the project), Cursor (`.cursor/hooks.json` in the project), and GitHub Copilot (`.github/hooks/impeccable.json` in the project, a team-shared committed file that both the Copilot CLI and the cloud agent read). For the Copilot CLI, repo-level hooks fire once `.github/hooks/impeccable.json` is committed to the repository's default branch.
|
||||
@@ -79,7 +81,7 @@ node .cursor/skills/impeccable/scripts/hook-admin.mjs ignore-file "src/legacy/Ca
|
||||
|
||||
## Constraints
|
||||
|
||||
- Never modify `.impeccable/config.json` or `.impeccable/config.local.json` by hand from this command. Always go through `hook-admin.mjs` so writes stay validated and the file shape stays consistent.
|
||||
- Never modify `.impeccable/config.json` or `.impeccable/config.local.json` by hand from this command. Always go through `hook-admin.mjs` so writes stay validated and the file shape stays consistent. One exception: `detector.extensions` has no admin action, so when the user asks to cover a template stack, edit that one field in `.impeccable/config.json` directly and leave the rest of the file untouched.
|
||||
- Do not edit the hook scripts themselves (`hook.mjs`, `hook-lib.mjs`, `hook-before-edit.mjs`) from this flow. Those are skill plumbing.
|
||||
- Cursor can block a proposed write when the detector finds a real issue. Claude Code, Codex, and GitHub Copilot do not block the edit; they emit a post-edit reminder instead. Disabling stops both blocking and reminders.
|
||||
- The hook is bundled with the Impeccable skill and installed through project-local manifests: `.claude/settings.local.json`, `.codex/hooks.json`, `.cursor/hooks.json`, and `.github/hooks/impeccable.json`. On Codex, the user must approve the hook via `/hooks` the first time. On Cursor, confirm hooks are enabled under Settings -> Hooks. On GitHub Copilot, the CLI loads `.github/hooks/impeccable.json` once it is committed to the repository's default branch, and the cloud agent reads it from the repo directly.
|
||||
|
||||
@@ -16,6 +16,7 @@ Decision tree:
|
||||
- **Neither file exists (empty project or no context yet)**: do Steps 2-4 (write PRODUCT.md), then decide on DESIGN.md based on whether there's code to analyze.
|
||||
- **PRODUCT.md exists, DESIGN.md missing**: skip to Step 5 and offer to run `/impeccable document` for DESIGN.md.
|
||||
- **PRODUCT.md exists but has no `## Register` section (legacy)**: add it. Infer a hypothesis from the codebase (see Step 2), confirm with the user, write the field.
|
||||
- **PRODUCT.md exists but has no `## Platform` section (legacy)**: add it the same way, but only when the project is native (`ios` / `android` / `adaptive`) or the user wants it explicit; a missing field already means `web`.
|
||||
- **Both exist**: ask the user directly to clarify what you cannot infer. Ask which file to refresh. Skip the one the user doesn't want changed.
|
||||
- **Just DESIGN.md exists (unusual)**: do Steps 2-4 to produce PRODUCT.md.
|
||||
|
||||
@@ -41,26 +42,34 @@ Also form a **register hypothesis** from what you find:
|
||||
|
||||
Register is a hypothesis at this point, not a decision; Step 3 confirms it.
|
||||
|
||||
Also form a **platform hypothesis**:
|
||||
|
||||
- Native signals: React Native / Expo (`react-native`, `expo`), Flutter (`pubspec.yaml`, `flutter`), SwiftUI / UIKit (`.swift`, `.xcodeproj`, an `ios/` app target), Jetpack Compose / Android (`build.gradle`, an `android/` app module, `AndroidManifest.xml`). An `ios/` and/or `android/` directory that is a real app target, not just a Capacitor/Cordova wrapper around a website.
|
||||
- Web signals (the default): a web framework (Vite, Next, Nuxt, SvelteKit, Astro), an HTML entry, a CSS/Tailwind setup, no native app target.
|
||||
|
||||
Values: `web` / `ios` / `android` / `adaptive` (one codebase, ships both, adapts per OS). Mobile web is still `web`. Like register, this is a hypothesis; Step 3 confirms it.
|
||||
|
||||
Note what you've learned and what remains unclear. Also note any rough edges worth a follow-up command (thin hierarchy, flat or gray palette, missing error/empty states, dull copy); Step 7 turns these into concrete recommendations without re-analyzing.
|
||||
|
||||
## Step 3: Ask strategic questions (for PRODUCT.md)
|
||||
|
||||
ask the user directly to clarify what you cannot infer. Ask only about what you couldn't infer from the codebase.
|
||||
ask the user directly to clarify what you cannot infer. Ask about anything the codebase doesn't answer with strong, explicit evidence.
|
||||
|
||||
### Interview mode, not confirmation mode
|
||||
|
||||
If the repo is empty or the user's brief is sparse, run a short interview before proposing PRODUCT.md. Do **not** turn a one-sentence request into a complete inferred PRODUCT.md and ask for blanket confirmation.
|
||||
|
||||
- Use the harness's structured question tool when one exists. Otherwise, ask directly in chat and stop.
|
||||
- Ask **2-3 questions per round**, then wait for answers.
|
||||
- Use the harness's structured question tool when one exists. Otherwise, ask directly in chat and stop: one question at a time, with lettered options where the crawl suggests likely answers, waiting for each answer before the next.
|
||||
- Keep skill vocabulary (register, belief ladder, anti-references) out of question text; ask for the thing in words the user would use. For the brand register, ask like a magazine editor profiling the brand: curious and narrative, drawing out the story, the feel, and what a visitor should come to believe.
|
||||
- Ask in focused rounds and wait for answers between them. Keep **one topic per question**; add rounds rather than fold several topics into one either-or choice. Options obey the same rule: an option answers only the question asked; never write a compound option that bundles a feeling with a business outcome or names an additional audience.
|
||||
- Use inferred answers as hypotheses or options, not as finished facts.
|
||||
- Complete at least one real user-answer round before drafting PRODUCT.md, unless every required answer is directly discoverable from repo docs.
|
||||
- Round 1 should establish register, users/purpose, and desired outcome.
|
||||
- Round 2 should establish brand personality or references, anti-references, and accessibility needs.
|
||||
- Round 1 should establish register, platform, users, purpose, positioning, and desired outcome.
|
||||
- Round 2 should establish brand personality or references, anti-references, and accessibility needs, plus conversion & proof for the brand register.
|
||||
|
||||
### Minimum viable interview
|
||||
|
||||
Ask enough to complete PRODUCT.md. At minimum, cover register confirmation, users and purpose, brand personality, anti-references, and accessibility needs unless each answer is directly discoverable from repo context. After at least one interview round, you may propose inferred answers, but the user must confirm them before you write PRODUCT.md. Never synthesize PRODUCT.md from the original task prompt alone.
|
||||
Ask enough to complete PRODUCT.md. At minimum, cover register confirmation, **platform confirmation** (`web` / `ios` / `android` / `adaptive`), users, purpose, positioning, brand personality, anti-references, and accessibility needs (plus conversion & proof for the brand register) unless each answer is directly discoverable from repo context. Never let the template's default `web` stand unconfirmed for a native or cross-platform repo. After at least one interview round, you may propose inferred answers, but the user must confirm them before you write PRODUCT.md. Never synthesize PRODUCT.md from the original task prompt alone.
|
||||
|
||||
### Register (ask first; it shapes everything below)
|
||||
|
||||
@@ -68,20 +77,42 @@ Every design task is either **brand** (marketing, landing, campaign, long-form c
|
||||
|
||||
If Step 2 produced a clear hypothesis, lead with it: *"From the codebase, this looks like a [brand / product] surface. Does that match your intent, or should we treat it differently?"*
|
||||
|
||||
If the signal is genuinely split (e.g. a product with a big marketing landing), ask the user directly to clarify what you cannot infer. Ask which register describes the **primary** surface. The register can be overridden per task later, but PRODUCT.md carries one default.
|
||||
If the signal is genuinely split (e.g. a product with a big marketing landing), ask the user directly to clarify what you cannot infer. Ask which register describes the **primary** surface. The register can be overridden per task later, but PRODUCT.md carries one default. Settle the default before drafting any register-dependent questions; never batch brand-only questions (Conversion & proof) into the same round as the question that decides the register.
|
||||
|
||||
### Platform (ask right after register)
|
||||
|
||||
Every project targets **web** (includes responsive mobile web), **ios**, **android**, or **adaptive** (one codebase, ships both, adapts per OS: Flutter, React Native, KMP). Platform picks the native rulebook: HIG for `ios`, Material 3 for `android`, both for `adaptive`, none for `web`.
|
||||
|
||||
If Step 2 produced a clear hypothesis, lead with it: *"From the codebase, this looks like a [web / ios / android / adaptive] project. Does that match?"* For cross-platform apps, decide by the **design language the app renders**, not the toolchain: one look on both platforms (Flutter's Material-everywhere default) takes that platform's value; genuine per-OS adaptation (Cupertino on iOS, Material on Android) is `adaptive`. When in doubt, `web`.
|
||||
|
||||
A monorepo shipping both a website and a native app gets a PRODUCT.md per app, each with its own `## Platform`; the root PRODUCT.md carries the primary surface's platform.
|
||||
|
||||
### Users & Purpose
|
||||
- Who uses this? What's their context when using it?
|
||||
- What job are they trying to get done?
|
||||
- For brand: what emotions should the interface evoke? (confidence, delight, calm, urgency)
|
||||
- What is this for? A purpose stated in README or docs is a hypothesis, not strong evidence; confirm it, don't transcribe it.
|
||||
- What does success look like?
|
||||
- If more than one kind of user is plausible, confirm a primary and secondary audience; don't manufacture a split that isn't there. An audience implied by another answer (a success metric, a CTA) is still unconfirmed; ask before writing it as secondary.
|
||||
- If the surface speaks to a different audience than the people who use the product, ask the user to name both.
|
||||
- For brand: what emotions should the interface evoke? (confidence, delight, calm, urgency) Ask this standalone; don't fold emotions into the success question.
|
||||
- For product: what workflow are they in? What's the primary task on any given screen?
|
||||
|
||||
### Positioning
|
||||
- In one line, what does this do that nothing else does? The single strategic claim every screen reinforces.
|
||||
|
||||
### Brand & Personality
|
||||
- How would you describe the brand personality in 3 words?
|
||||
- Reference sites or apps that capture the right feel? What specifically about them?
|
||||
- Push for specific named references with the *specific* thing about them that fits this brand, not generic "modern" adjectives or category-bucket lanes.
|
||||
- What should this explicitly NOT look like? Any anti-references?
|
||||
|
||||
### Conversion & proof (brand register only)
|
||||
- What's the primary CTA?
|
||||
- What's the secondary fallback, for visitors not ready for the primary?
|
||||
- The one line a visitor should remember after 10 seconds.
|
||||
- What must the visitor believe, in order, before taking the primary CTA? (The template's belief ladder.)
|
||||
- What proof is on hand? Ask the user to hand over any testimonials, case studies, press, or client/partner logos they already have. If you can receive files directly, collect them; otherwise create `.impeccable/assets/proof/` and ask the user to add files there. Reference supplied files by path; record text proof inline.
|
||||
|
||||
### Accessibility & Inclusion
|
||||
- Specific accessibility requirements? (WCAG level, known user needs)
|
||||
- Considerations for reduced motion, color blindness, or other accommodations?
|
||||
@@ -90,7 +121,7 @@ Skip questions where the answer is already clear. **Do NOT ask about colors, fon
|
||||
|
||||
## Step 4: Write PRODUCT.md
|
||||
|
||||
Write PRODUCT.md only after the user has confirmed the strategic answers from Step 3. If an inferred answer is uncertain or unconfirmed, ask before writing.
|
||||
Write PRODUCT.md only after the user has confirmed the strategic answers from Step 3. If an inferred answer is uncertain or unconfirmed, ask before writing. Confirmed means what the user actually said yes to; do not pad a confirmed answer with extras they never picked (additional anti-references, audiences, roadmap claims, a WCAG level), whether drawn from the crawl, another answer, or your own option text. If an extra belongs in the doc, ask about it first.
|
||||
|
||||
Synthesize into a strategic document:
|
||||
|
||||
@@ -101,12 +132,26 @@ Synthesize into a strategic document:
|
||||
|
||||
product
|
||||
|
||||
## Platform
|
||||
|
||||
web
|
||||
|
||||
## Users
|
||||
[Who they are, their context, the job to be done]
|
||||
[Who they are, their context, the job to be done. Primary audience; a secondary audience or a surface-vs-user split only when they apply.]
|
||||
|
||||
## Product Purpose
|
||||
[What this product does, why it exists, what success looks like]
|
||||
|
||||
## Positioning
|
||||
[The single strategic claim every screen reinforces. Not a visual rule, not an anti-reference.]
|
||||
|
||||
## Conversion & proof
|
||||
[Brand register only. Product register: omit this section entirely, heading included.]
|
||||
- Primary and secondary CTA: [...]
|
||||
- The line a visitor remembers after 10 seconds: [...]
|
||||
- Belief ladder: [...]
|
||||
- Proof on hand: [testimonials, case studies, press, or logos, referenced by path]
|
||||
|
||||
## Brand Personality
|
||||
[Voice, tone, 3-word personality, emotional goals]
|
||||
|
||||
@@ -120,7 +165,9 @@ product
|
||||
[WCAG level, known user needs, considerations]
|
||||
```
|
||||
|
||||
Register is either `brand` or `product` as a bare value. No prose, no commentary.
|
||||
Register is either `brand` or `product` as a bare value. No prose, no commentary. Platform is `web`, `ios`, `android`, or `adaptive`, also a bare value; omit the section only on legacy files you're leaving untouched, otherwise write `web` explicitly.
|
||||
|
||||
Write fields as prose, and use bold sparingly: only where a word carries a decision, never as a label lead-in on every line.
|
||||
|
||||
Write to `PROJECT_ROOT/PRODUCT.md`. If `.impeccable.md` existed, the loader already renamed it; merge into that content rather than starting from scratch.
|
||||
|
||||
@@ -137,6 +184,8 @@ If the user prefers to skip, mention they can run `/impeccable document` any tim
|
||||
|
||||
## Step 6: Configure live mode (when code exists)
|
||||
|
||||
**Skip this step when the platform is native** (`ios` / `android` / `adaptive`): live mode drives a browser overlay. A hybrid wrapper or Expo web target serving HTML doesn't change that.
|
||||
|
||||
If the project has code with HTML entries and a dev server (the same "code exists" condition that puts `/impeccable document` in scan mode), pre-configure live mode now. You already identified the framework and the served HTML entry in Step 2, so this is nearly free, and it spares the user the first-time setup detour when they later run `/impeccable live`.
|
||||
|
||||
**Skip this step for empty / pre-implementation projects** (nothing to inject into yet). Tell the user live mode will configure itself the first time they run it once there's code.
|
||||
@@ -154,16 +203,16 @@ Writing the config file is harmless and needs no consent; only the CSP **source-
|
||||
## Step 7: Recommend starting points, then wrap up
|
||||
|
||||
Summarize tersely:
|
||||
- Register captured (brand / product)
|
||||
- Register captured (brand / product) and platform captured (web / ios / android / adaptive)
|
||||
- What was written (PRODUCT.md, DESIGN.md, live config, or a subset)
|
||||
- The 3-5 strategic principles from PRODUCT.md that will guide future work
|
||||
- If DESIGN.md or live config is pending, one line on how to set it up later
|
||||
|
||||
Then recommend the **best commands to run next**, drawn from what your Step 2 crawl already surfaced. Do not run a fresh analysis here; surface observations you already have. Tailor to register and to what you saw, offer the 2-4 most relevant (not a menu dump), and give the exact command to type. Group by intent:
|
||||
Then recommend the **best commands to run next**, drawn from what your Step 2 crawl already surfaced. Do not run a fresh analysis here; surface observations you already have. Tailor to register **and platform**, offer the 2-4 most relevant (not a menu dump), and give the exact command to type. Group by intent:
|
||||
|
||||
- **Build something new**: `/impeccable craft <feature>` (shape, then build end-to-end) or `/impeccable shape <feature>` (plan first). Lead with this for empty or early-stage projects.
|
||||
- **Improve what's there**: name the specific surface. `/impeccable critique <page>` for a scored UX review; `/impeccable audit <area>` for a11y / perf / responsive checks; `/impeccable polish <component>` for a pre-ship pass. When the crawl flagged a specific weakness, point the matching command at it: thin hierarchy or spacing → `layout`, flat or gray palette → `colorize`, missing error / empty states → `harden` or `onboard`, dull or unclear copy → `clarify`.
|
||||
- **Iterate visually**: `/impeccable live` (configured in Step 6) to pick elements in the browser and generate variants in place.
|
||||
- **Iterate visually** (web only): `/impeccable live` (configured in Step 6) to pick elements in the browser and generate variants in place. **Skip this group for native platforms.**
|
||||
|
||||
The full command menu is one bare `/impeccable` away; keep this list short and pointed.
|
||||
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
# iOS platform
|
||||
|
||||
For native iOS / iPadOS apps: SwiftUI, UIKit, React Native, Expo, Flutter shipping to Apple hardware.
|
||||
|
||||
On native, register narrows. HIG conformance governs structure, navigation, and interaction whatever the register; brand expresses through the expressive layer the platform provides (tint, type, motion, content). Calm, Duolingo, and Spotify carry strong identity entirely inside HIG conventions.
|
||||
|
||||
## The iOS slop test
|
||||
|
||||
Would a fluent iPhone user trust this app, or pause at off-spec controls? The tell is "ported from a website": reinvented navigation bars, custom back gestures, web-shaped buttons, hover-dependent affordances. Default to the platform's components; depart only for a reason the user would thank you for.
|
||||
|
||||
## Layout & structure
|
||||
|
||||
- **Safe area.** Lay out inside the safe-area insets. No controls under the notch, Dynamic Island, home indicator, or rounded corners.
|
||||
- **System navigation.** Tab bar for 2–5 top-level sections (sections, never actions), navigation stack for hierarchy, sheet for self-contained tasks. No custom global nav, no mixed metaphors.
|
||||
- **Edge-swipe back stays alive.** The left-edge back gesture is muscle memory; never disable or overlay it.
|
||||
- **Large titles** on top-level screens, collapsing to inline on scroll. Deep detail screens stay inline.
|
||||
|
||||
## Touch targets
|
||||
|
||||
- **44×44 pt minimum** for every tappable control, with breathing room between adjacent targets.
|
||||
|
||||
## Typography
|
||||
|
||||
- **Dynamic Type.** Use the system text styles (Large Title through Caption) so text follows the user's reading size. No hard-coded point sizes.
|
||||
- **San Francisco carries the UI.** Body, labels, and controls stay on SF Pro / SF Compact; a brand face may appear in display moments.
|
||||
- **11 pt floor**; Body is 17 pt.
|
||||
|
||||
## Color & materials
|
||||
|
||||
- **Semantic system colors** (label, secondaryLabel, systemBackground, separator, tint). They adapt to Dark Mode and increased contrast automatically; raw hex breaks there.
|
||||
- **Dark Mode is a first-class appearance.** Design and test both.
|
||||
- **One tint color** drives interactive elements; decoration is not its job.
|
||||
- **System materials** for blur and translucency behind bars and sheets; no hand-rolled glassmorphism.
|
||||
|
||||
## Components & controls
|
||||
|
||||
- **Platform controls.** Switch, segmented control, stepper, system pickers, action sheets, alerts, context menus, swipe actions. Reinventing these for flavor is the most common native slop.
|
||||
- **SF Symbols** for iconography: baseline-aligned, Dynamic Type-aware, weight and scale variants. Don't mix in a web icon set.
|
||||
- **Deliberate modality.** Sheet for a focused dismissible sub-task, full-screen cover for immersion. Clear Cancel/Done; honor swipe-to-dismiss unless data loss requires a guard.
|
||||
- **Grouped/inset lists** for settings-shaped content; no bespoke card stacks.
|
||||
|
||||
## Motion
|
||||
|
||||
- **System transitions.** Push slides, sheets rise, dismiss reverses the entrance. Custom transitions that fight the navigation model disorient.
|
||||
- **Honor Reduce Motion.** Crossfade instead of parallax and large slides.
|
||||
@@ -8,11 +8,33 @@ Brand: asymmetric compositions, fluid spacing with `clamp()`, intentional grid-b
|
||||
|
||||
Product: predictable grids, consistent densities, familiar navigation patterns. Responsive behavior is structural (collapse sidebar, responsive table), not fluid typography. Consistency IS an affordance.
|
||||
|
||||
Native (`ios` / `android` / `adaptive`): structure follows the Layout section of [ios.md](ios.md) / [android.md](android.md) (read it first if Setup hasn't already): platform navigation, insets, and touch targets, never the CSS tooling below.
|
||||
|
||||
---
|
||||
|
||||
## Two isolated assessments (required)
|
||||
|
||||
Spawn two parallel sub-agents whenever a sub-agent/Task tool is exposed: one for the layout assessment, one for the mechanical pre-scan. If the harness needs explicit user permission for sub-agents, stop and ask before proceeding. Isolation is the point: detector output anchors visual judgment toward what the scan can see, so neither sub-agent gets the other's output. Each assessment runs in its own sub-agent; running either one in this context when a sub-agent tool exists is not permitted, even when it is faster; the fallback below is only for sessions with no sub-agent tool. Give each a self-contained prompt (target files, register, documented spacing scale when present, and its instructions below); do not assume it can read this file.
|
||||
|
||||
**Sub-agent A (layout assessment)**: give it the full [Assess Current Layout](#assess-current-layout) checklist below, verbatim, in its prompt. It works through every item and returns per-item findings citing file, selector, or value.
|
||||
|
||||
**Sub-agent B (mechanical pre-scan)**: run the bundled detector scoped to layout:
|
||||
|
||||
```bash
|
||||
node .cursor/skills/impeccable/scripts/detect.mjs --json --scope layout [target files or dirs]
|
||||
```
|
||||
|
||||
A missing `node` on PATH is not permission to skip: hunt for a runtime (`command -v node`, nvm or Homebrew paths, the harness's own bundled node) and run it by full path. If none exists, halt the scan and report that Node must be installed (the parent relays this to the user); do **not** substitute grep for the detector or proceed unscanned. The detector abstains on arbitrary Tailwind spacing (`gap-[13px]`, `p-[7px]`) and ad-hoc `z-index` stacks, so when the project documents a spacing scale, also grep `gap-\[`, `p[trblxy]?-\[`, `m[trblxy]?-\[`, `z-\[` and judge those hits against it. Return the findings JSON plus the grep verdicts.
|
||||
|
||||
**If no sub-agent tool is exposed (or the user declined)**: run both yourself, assessment first, pre-scan second, so the deterministic findings can't anchor the visual judgment. Keep that order even when the scan feels quicker to start with.
|
||||
|
||||
**Synthesize** once both are done: merge into a single findings list, noting where they agree and what each caught alone. Fix every finding, or list it as a deliberate exception for the user to accept. A clean scan is a floor, not a verdict: a monotone grid with uniform spacing passes every detector rule, which is exactly what the assessment exists to catch. State in your final summary which path ran (parallel sub-agents or single-context fallback).
|
||||
|
||||
---
|
||||
|
||||
## Assess Current Layout
|
||||
|
||||
Analyze what's weak about the current spatial design:
|
||||
This checklist is sub-agent A's brief (on the fallback path, work through it yourself before the pre-scan). Analyze what's weak about the current spatial design:
|
||||
|
||||
1. **Spacing**:
|
||||
- Is spacing consistent or arbitrary? (Random padding/margin values)
|
||||
@@ -138,6 +160,8 @@ Create a systematic plan:
|
||||
- **Consistency**: Is the spacing system applied uniformly?
|
||||
- **Responsiveness**: Does the layout adapt gracefully across screen sizes?
|
||||
|
||||
Answer each item above by citing the file, selector, or value that satisfies it; never a bare yes. Then re-run the pre-scan and fix until the count of unresolved items and unaccepted findings is zero.
|
||||
|
||||
When the rhythm and hierarchy land, hand off to `/impeccable polish` for the final pass.
|
||||
|
||||
## Live-mode signature params
|
||||
|
||||
@@ -10,9 +10,29 @@ Product: system fonts and familiar sans stacks are legitimate here. One well-tun
|
||||
|
||||
---
|
||||
|
||||
## Two isolated assessments (required)
|
||||
|
||||
Spawn two parallel sub-agents whenever a sub-agent/Task tool is exposed: one for the typography assessment, one for the mechanical pre-scan. If the harness needs explicit user permission for sub-agents, stop and ask before proceeding. Isolation is the point: detector output anchors visual judgment toward what the scan can see, so neither sub-agent gets the other's output. Each assessment runs in its own sub-agent; running either one in this context when a sub-agent tool exists is not permitted, even when it is faster; the fallback below is only for sessions with no sub-agent tool. Give each a self-contained prompt (target files, register, **DESIGN.md** content when present, and its instructions below); do not assume it can read this file.
|
||||
|
||||
**Sub-agent A (typography assessment)**: give it the full [Assess Current Typography](#assess-current-typography) checklist below, verbatim, in its prompt. It works through every item and returns per-item findings citing file, selector, or value.
|
||||
|
||||
**Sub-agent B (mechanical pre-scan)**: run the bundled detector scoped to type:
|
||||
|
||||
```bash
|
||||
node .cursor/skills/impeccable/scripts/detect.mjs --json --scope type [target files or dirs]
|
||||
```
|
||||
|
||||
A missing `node` on PATH is not permission to skip: hunt for a runtime (`command -v node`, nvm or Homebrew paths, the harness's own bundled node) and run it by full path. If none exists, halt the scan and report that Node must be installed (the parent relays this to the user); do **not** substitute grep for the detector or proceed unscanned. The scan checks literal font sizes against the **DESIGN.md** ramp but abstains on `em`, `%`, `clamp()`, and line-heights, so also grep `font-size\s*:`, `fontSize`, `text-\[`, `leading-\[` and judge those hits against the spec. Return the findings JSON plus the grep verdicts.
|
||||
|
||||
**If no sub-agent tool is exposed (or the user declined)**: run both yourself, assessment first, pre-scan second, so the deterministic findings can't anchor the visual judgment. Keep that order even when the scan feels quicker to start with.
|
||||
|
||||
**Synthesize** once both are done: merge into a single findings list, noting where they agree and what each caught alone. Fix every finding, or list it as a deliberate exception for the user to accept. A clean scan is a floor, not a verdict: a generic font stack at a flat scale passes every detector rule, which is exactly what the assessment exists to catch. State in your final summary which path ran (parallel sub-agents or single-context fallback).
|
||||
|
||||
---
|
||||
|
||||
## Assess Current Typography
|
||||
|
||||
Analyze what's weak or generic about the current type:
|
||||
This checklist is sub-agent A's brief (on the fallback path, work through it yourself before the pre-scan). Analyze what's weak or generic about the current type:
|
||||
|
||||
1. **Font choices**:
|
||||
- Are we using invisible defaults? (Inter, Roboto, Arial, Open Sans, system defaults)
|
||||
@@ -109,6 +129,8 @@ Build a clear type scale:
|
||||
- **Performance**: Are web fonts loading efficiently without layout shift?
|
||||
- **Accessibility**: Does text meet WCAG contrast ratios? Is it zoomable to 200%?
|
||||
|
||||
Answer each item above by citing the file, selector, or value that satisfies it; never a bare yes. Then re-run the pre-scan and fix until the count of unresolved items and unaccepted findings is zero.
|
||||
|
||||
When the type carries the hierarchy on its own, hand off to `/impeccable polish` for the final pass.
|
||||
|
||||
## Live-mode signature params
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Context-signals gatherer for the bare `{{command_prefix}}impeccable`
|
||||
* Context-signals gatherer for the bare Impeccable invocation
|
||||
* (no-argument) path. Collects cheap, deterministic signals about the current
|
||||
* project and emits them as JSON.
|
||||
*
|
||||
@@ -21,7 +21,7 @@ import net from 'node:net';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { loadContext, extractRegister } from './context.mjs';
|
||||
import { loadContext, extractRegister, extractPlatform } from './context.mjs';
|
||||
import { getCritiqueDir } from './lib/impeccable-paths.mjs';
|
||||
|
||||
/** Is there code here at all, or just context files / an empty repo? */
|
||||
@@ -197,6 +197,7 @@ export async function gatherSignals(cwd = process.cwd()) {
|
||||
designPath: ctx.designPath,
|
||||
hasCode: hasCode(cwd),
|
||||
register: extractRegister(ctx.product),
|
||||
platform: extractPlatform(ctx.product),
|
||||
},
|
||||
critique: { latest: latestCritique(cwd) },
|
||||
git,
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
/**
|
||||
* 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.
|
||||
* markdown block on stdout, or prints a `NO_PRODUCT_MD:` message when no
|
||||
* PRODUCT.md is found anywhere. The skill keys off that message to branch:
|
||||
* from-scratch build commands (init / teach / craft / shape) and clear
|
||||
* build/shape intent divert into the init flow, while scoped commands proceed
|
||||
* using the existing code as context.
|
||||
*
|
||||
* Path resolution (first match wins):
|
||||
* 1. Active project root, if PRODUCT.md or DESIGN.md is there
|
||||
@@ -21,6 +23,7 @@ import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { parseTargetOptions } from './lib/target-args.mjs';
|
||||
import { IMPECCABLE_COMMAND } from './lib/provider.mjs';
|
||||
|
||||
const PRODUCT_NAMES = ['PRODUCT.md', 'Product.md', 'product.md'];
|
||||
const DESIGN_NAMES = ['DESIGN.md', 'Design.md', 'design.md'];
|
||||
@@ -691,24 +694,60 @@ function escapeRegExp(value) {
|
||||
return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the first non-empty line under a bare `## <heading>` section of
|
||||
* PRODUCT.md (e.g. `## Register`, `## Platform`). Returns null when the
|
||||
* section is absent. The heading match is exact (`\s*$`) so near-miss
|
||||
* headings like `## Register guidelines` don't shadow the real field.
|
||||
*/
|
||||
export function extractSectionValue(product, heading) {
|
||||
if (!product) return null;
|
||||
const headingRe = new RegExp(`^##\\s+${escapeRegExp(heading)}\\s*$`, 'i');
|
||||
const lines = product.split('\n');
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
if (headingRe.test(lines[i].trim())) {
|
||||
for (let j = i + 1; j < lines.length; j++) {
|
||||
const next = lines[j].trim();
|
||||
// A new heading before any value means the section is empty.
|
||||
if (/^#{1,6}\s/.test(next)) return null;
|
||||
if (next) return next;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
const word = (extractSectionValue(product, 'Register') || '').toLowerCase();
|
||||
return word === 'brand' || word === 'product' ? word : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull the platform (`web`, `ios`, `android`, or `adaptive`) out of PRODUCT.md
|
||||
* by looking for a `## Platform` section and reading the first non-empty line
|
||||
* that follows it. `adaptive` is for cross-platform apps (Flutter, React
|
||||
* Native) that ship both iOS and Android from one codebase; a line that names
|
||||
* both targets (e.g. `ios, android`) is also read as `adaptive`. Returns null
|
||||
* when the file is legacy / platform-less, which the skill treats as `web`
|
||||
* (the default the general rules already assume).
|
||||
*/
|
||||
export function extractPlatform(product) {
|
||||
const value = (extractSectionValue(product, 'Platform') || '').toLowerCase();
|
||||
if (!value) return null;
|
||||
if (value === 'web' || value === 'ios' || value === 'android' || value === 'adaptive') return value;
|
||||
// A short list naming both native targets (`ios, android`, `ios and
|
||||
// android`) = adaptive. Only list separators and the two platform words may
|
||||
// appear; anything else (prose, negations) is unrecognized and falls
|
||||
// through to the CLI's WARNING path.
|
||||
const tokens = value.split(/[\s,+&/]+/).filter(t => t && t !== 'and');
|
||||
if (tokens.length >= 2 && tokens.every(t => t === 'ios' || t === 'android')
|
||||
&& tokens.includes('ios') && tokens.includes('android')) {
|
||||
return 'adaptive';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -860,8 +899,11 @@ async function cli() {
|
||||
// — 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.',
|
||||
'Follow SKILL.md Setup step 1: for `init`, `teach`, `craft`, `shape`, ' +
|
||||
'or wording that clearly maps to a from-scratch build/shape flow, load ' +
|
||||
'reference/init.md and write PRODUCT.md first; for any other (scoped) ' +
|
||||
'command against existing code, proceed using the code as context and ' +
|
||||
`offer \`${IMPECCABLE_COMMAND} init\` as a suggestion (do not block).`,
|
||||
];
|
||||
parts.push(buildResolvedContextDirective(ctx, cliOptions, { targetExists }));
|
||||
if (shouldWarnMissingTarget(ctx, targetProvided, targetExists)) {
|
||||
@@ -884,6 +926,26 @@ async function cli() {
|
||||
? `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);
|
||||
const platform = extractPlatform(ctx.product);
|
||||
const nativeRefs =
|
||||
platform === 'adaptive' ? ['ios', 'android'] : platform === 'ios' || platform === 'android' ? [platform] : [];
|
||||
if (nativeRefs.length) {
|
||||
const refList = nativeRefs.map(p => `\`reference/${p}.md\``).join(' and ');
|
||||
const label = platform === 'adaptive' ? '`adaptive` (both iOS and Android)' : `\`${platform}\``;
|
||||
parts.push(
|
||||
`NEXT STEP: This project targets ${label}. Also read ${refList} for native conventions, in addition to the register reference.`,
|
||||
);
|
||||
} else if (!platform) {
|
||||
// A `## Platform` section that names something we don't recognize (a
|
||||
// toolchain like `flutter`, a typo) would otherwise silently fall back to
|
||||
// web — the wrong default exactly when the user tried to say "native".
|
||||
const rawPlatform = extractSectionValue(ctx.product, 'Platform');
|
||||
if (rawPlatform) {
|
||||
parts.push(
|
||||
`WARNING: PRODUCT.md's \`## Platform\` value \`${rawPlatform}\` is not recognized; treating the project as \`web\`. Valid values are \`web\`, \`ios\`, \`android\`, or \`adaptive\` (cross-platform, ships both). If this project is native, fix the field (name the design language the app renders, not the toolchain) and surface it to the user.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (updateDirective) parts.push(updateDirective);
|
||||
process.stdout.write(parts.join('\n\n---\n\n') + '\n');
|
||||
}
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
/**
|
||||
* Critique persistence helper.
|
||||
*
|
||||
* Each run of /impeccable critique writes a per-target snapshot to
|
||||
* Each critique run writes a per-target snapshot to
|
||||
* .impeccable/critique/<timestamp>__<slug>.md
|
||||
* with a small YAML frontmatter carrying the score + P0/P1 counts.
|
||||
*
|
||||
* /impeccable polish reads the latest matching snapshot at start as its
|
||||
* The polish workflow reads the latest matching snapshot at start as its
|
||||
* fix backlog. No other skill auto-reads critique output.
|
||||
*
|
||||
* The slug is derived mechanically from the *resolved* primary artifact
|
||||
|
||||
@@ -2,6 +2,7 @@ import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import { loadDesignSystemForCwd } from '../design-system.mjs';
|
||||
import { RULE_SCOPES, filterByScopes } from '../registry/antipatterns.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';
|
||||
@@ -93,6 +94,8 @@ Options:
|
||||
--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)
|
||||
--scope <name> Only report rules in the given design domain
|
||||
(type, layout). Comma-separated.
|
||||
--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
|
||||
@@ -151,6 +154,33 @@ async function detectCli() {
|
||||
const providers = [];
|
||||
if (args.includes('--gpt')) providers.push('gpt');
|
||||
if (args.includes('--gemini')) providers.push('gemini');
|
||||
const scopes = [];
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
if (args[i] !== '--scope' && !args[i].startsWith('--scope=')) continue;
|
||||
const inline = args[i].startsWith('--scope=');
|
||||
const value = inline ? args[i].slice('--scope='.length) : args[i + 1];
|
||||
const parsed = (value && !value.startsWith('--'))
|
||||
? value.split(',').map(s => s.trim()).filter(Boolean)
|
||||
: [];
|
||||
// A bare `--scope` would otherwise fall out of `targets` and scan unscoped;
|
||||
// fail loudly so a mistyped pre-scan never runs the wrong rule set.
|
||||
if (parsed.length === 0) {
|
||||
process.stderr.write(
|
||||
`Error: --scope requires a value. Valid scopes: ${[...RULE_SCOPES].join(', ')}\n`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
scopes.push(...parsed);
|
||||
args.splice(i, inline ? 1 : 2);
|
||||
i -= 1;
|
||||
}
|
||||
const unknownScopes = scopes.filter(s => !RULE_SCOPES.has(s));
|
||||
if (unknownScopes.length > 0) {
|
||||
process.stderr.write(
|
||||
`Error: unknown --scope value(s): ${unknownScopes.join(', ')}. Valid scopes: ${[...RULE_SCOPES].join(', ')}\n`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
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
|
||||
@@ -276,6 +306,7 @@ async function detectCli() {
|
||||
}
|
||||
|
||||
allFindings = filterDetectionFindings(allFindings, detectionConfig);
|
||||
allFindings = filterByScopes(allFindings, scopes);
|
||||
|
||||
if (allFindings.length > 0) {
|
||||
if (jsonMode) process.stdout.write(formatFindings(allFindings, true) + '\n');
|
||||
|
||||
@@ -9,6 +9,8 @@ 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 FONT_SIZE_TOLERANCE_PX = 0.5;
|
||||
const FONT_SIZE_LITERAL_RE = /^-?[\d.]+(?:px|rem)$/;
|
||||
|
||||
const CSS_COLOR_RE = /#[0-9a-f]{3,8}\b|rgba?\([^)]+\)|oklch\([^)]+\)|hsla?\([^)]+\)/gi;
|
||||
const FONT_DECL_RE = /font-family\s*:\s*([^;}\n]+)/gi;
|
||||
@@ -16,6 +18,9 @@ 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 FONT_SIZE_DECL_RE = /font-size\s*:\s*([^;}\n]+)/gi;
|
||||
const FONT_SIZE_JS_RE = /fontSize\s*[:=]\s*["'`]([^"'`]+)["'`]/g;
|
||||
const TAILWIND_FONT_SIZE_RE = /\btext-\[(-?[\d.]+(?:px|rem))\]/g;
|
||||
const STATIC_DESIGN_SKIP_TAGS = new Set(['head', 'title', 'meta', 'link', 'style', 'script', 'noscript', 'template', 'source']);
|
||||
|
||||
function firstExisting(dir, names) {
|
||||
@@ -283,6 +288,18 @@ function addTypographyFonts(out, typography) {
|
||||
}
|
||||
}
|
||||
|
||||
function addTypographySizes(out, typography) {
|
||||
if (!typography || typeof typography !== 'object') return;
|
||||
for (const role of Object.values(typography)) {
|
||||
if (!role || typeof role !== 'object') continue;
|
||||
const raw = String(role.fontSize ?? '').trim().toLowerCase();
|
||||
if (!FONT_SIZE_LITERAL_RE.test(raw)) continue;
|
||||
const px = resolveLengthPx(raw, 16);
|
||||
if (px == null || !Number.isFinite(px) || px <= 0) continue;
|
||||
out.allowedFontSizes.push({ value: raw, px });
|
||||
}
|
||||
}
|
||||
|
||||
function addRoundedScale(out, rounded) {
|
||||
if (!rounded || typeof rounded !== 'object') return;
|
||||
for (const [rawName, value] of Object.entries(rounded)) {
|
||||
@@ -340,10 +357,12 @@ function normalizeDesignSystem(input = {}) {
|
||||
allowedFonts: new Set(),
|
||||
allowedColorKeys: new Map(),
|
||||
allowedRadii: [],
|
||||
allowedFontSizes: [],
|
||||
hasPillRadius: false,
|
||||
};
|
||||
|
||||
addTypographyFonts(out, frontmatter.typography);
|
||||
addTypographySizes(out, frontmatter.typography);
|
||||
addColorObject(out, frontmatter.colors);
|
||||
addSidecarColors(out, sidecar);
|
||||
addRoundedScale(out, frontmatter.rounded);
|
||||
@@ -352,6 +371,7 @@ function normalizeDesignSystem(input = {}) {
|
||||
out.hasFonts = out.allowedFonts.size > 0;
|
||||
out.hasColors = out.allowedColorKeys.size > 0;
|
||||
out.hasRadii = out.allowedRadii.length > 0;
|
||||
out.hasFontSizes = out.allowedFontSizes.length > 0;
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -418,6 +438,17 @@ function isAllowedRadiusRaw(raw, designSystem) {
|
||||
return designSystem.allowedRadii.some(entry => Math.abs(entry.px - px) <= RADIUS_TOLERANCE_PX);
|
||||
}
|
||||
|
||||
function isAllowedFontSizeRaw(raw, designSystem) {
|
||||
if (!designSystem?.hasFontSizes) return true;
|
||||
const text = String(raw || '').trim().toLowerCase().replace(/\s*!important\s*$/, '');
|
||||
if (!FONT_SIZE_LITERAL_RE.test(text)) return true;
|
||||
const px = resolveLengthPx(text, 16);
|
||||
if (px == null || !Number.isFinite(px) || px <= 0) return true;
|
||||
return designSystem.allowedFontSizes.some(
|
||||
entry => Math.abs(entry.px - px) <= FONT_SIZE_TOLERANCE_PX,
|
||||
);
|
||||
}
|
||||
|
||||
function lineLooksCommented(line) {
|
||||
const trimmed = String(line || '').trim();
|
||||
return trimmed.startsWith('//') || trimmed.startsWith('/*') || trimmed.startsWith('*') || trimmed.startsWith('<!--');
|
||||
@@ -509,6 +540,18 @@ function checkRadiusValue(value, filePath, line, designSystem, context) {
|
||||
return findings;
|
||||
}
|
||||
|
||||
function checkFontSizeValue(value, filePath, line, designSystem, context) {
|
||||
const token = String(value || '').trim();
|
||||
if (isAllowedFontSizeRaw(token, designSystem)) return [];
|
||||
return [makeDesignFinding(
|
||||
'design-system-font-size',
|
||||
filePath,
|
||||
`${context}: ${token} is off the DESIGN.md type ramp`,
|
||||
line,
|
||||
{ ignoreValue: token },
|
||||
)];
|
||||
}
|
||||
|
||||
function checkSourceDesignSystem(content, filePath, options = {}) {
|
||||
const designSystem = options.designSystem;
|
||||
if (!designSystem?.present) return [];
|
||||
@@ -567,6 +610,18 @@ function checkSourceDesignSystem(content, filePath, options = {}) {
|
||||
findings.push(...checkRadiusValue(match[1], filePath, lineNum, designSystem, 'borderRadius'));
|
||||
}
|
||||
}
|
||||
|
||||
if (designSystem.hasFontSizes) {
|
||||
for (const match of line.matchAll(FONT_SIZE_DECL_RE)) {
|
||||
findings.push(...checkFontSizeValue(match[1], filePath, lineNum, designSystem, 'font-size'));
|
||||
}
|
||||
for (const match of line.matchAll(FONT_SIZE_JS_RE)) {
|
||||
findings.push(...checkFontSizeValue(match[1], filePath, lineNum, designSystem, 'fontSize'));
|
||||
}
|
||||
for (const match of line.matchAll(TAILWIND_FONT_SIZE_RE)) {
|
||||
findings.push(...checkFontSizeValue(match[1], filePath, lineNum, designSystem, 'text-[…] class'));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return dedupeDesignFindings(findings);
|
||||
@@ -581,6 +636,8 @@ function sampleText(el) {
|
||||
return text ? ` "${text.slice(0, 40)}"` : '';
|
||||
}
|
||||
|
||||
// Font-size design-system checks are source-scan-only (see checkSourceDesignSystem).
|
||||
// Computed font-size cascades and clamp() ramps resolve to off-ramp px in the browser.
|
||||
function collectStaticDesignSystemFindings(document, window, filePath, designSystem) {
|
||||
if (!designSystem?.present) return [];
|
||||
const findings = [];
|
||||
@@ -698,6 +755,12 @@ function canonicalDesignFindingKey(item) {
|
||||
const label = String(value || '').trim().toLowerCase();
|
||||
return label ? `${item.antipattern}:radius:${label}` : null;
|
||||
}
|
||||
if (item.antipattern === 'design-system-font-size') {
|
||||
const px = resolveLengthPx(String(value || '').trim(), 16);
|
||||
if (px != null && Number.isFinite(px)) return `${item.antipattern}:font-size:${Math.round(px * 100) / 100}`;
|
||||
const label = String(value || '').trim().toLowerCase();
|
||||
return label ? `${item.antipattern}:font-size:${label}` : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -744,6 +807,7 @@ export {
|
||||
isAllowedFont,
|
||||
isAllowedColorRaw,
|
||||
isAllowedRadiusRaw,
|
||||
isAllowedFontSizeRaw,
|
||||
checkSourceDesignSystem,
|
||||
collectStaticDesignSystemFindings,
|
||||
mergeDesignSystemFindings,
|
||||
|
||||
@@ -123,6 +123,7 @@ const ANTIPATTERNS = [
|
||||
{
|
||||
id: 'overused-font',
|
||||
category: 'slop',
|
||||
scopes: ['type'],
|
||||
name: 'Overused font',
|
||||
description:
|
||||
'Inter, Roboto, Fraunces, Geist, Plus Jakarta Sans, and Space Grotesk are used on so many sites they no longer feel distinctive. Each new wave of AI-generated UIs converges on the same handful of faces. Choose a face that gives your interface personality.',
|
||||
@@ -132,6 +133,7 @@ const ANTIPATTERNS = [
|
||||
{
|
||||
id: 'single-font',
|
||||
category: 'slop',
|
||||
scopes: ['type'],
|
||||
name: 'Single font for everything',
|
||||
description:
|
||||
'Only one font family is used for the entire page. Pair a distinctive display font with a refined body font to create typographic hierarchy.',
|
||||
@@ -141,6 +143,7 @@ const ANTIPATTERNS = [
|
||||
{
|
||||
id: 'flat-type-hierarchy',
|
||||
category: 'slop',
|
||||
scopes: ['type'],
|
||||
name: 'Flat type hierarchy',
|
||||
description:
|
||||
'Font sizes are too close together — no clear visual hierarchy. Use fewer sizes with more contrast (aim for at least a 1.25 ratio between steps).',
|
||||
@@ -177,6 +180,7 @@ const ANTIPATTERNS = [
|
||||
{
|
||||
id: 'nested-cards',
|
||||
category: 'slop',
|
||||
scopes: ['layout'],
|
||||
name: 'Nested cards',
|
||||
description:
|
||||
'Cards inside cards create visual noise and excessive depth. Flatten the hierarchy — use spacing, typography, and dividers instead of nesting containers.',
|
||||
@@ -186,6 +190,7 @@ const ANTIPATTERNS = [
|
||||
{
|
||||
id: 'monotonous-spacing',
|
||||
category: 'slop',
|
||||
scopes: ['layout'],
|
||||
name: 'Monotonous spacing',
|
||||
description:
|
||||
'The same spacing value used everywhere — no rhythm, no variation. Use tight groupings for related items and generous separations between sections.',
|
||||
@@ -213,6 +218,7 @@ const ANTIPATTERNS = [
|
||||
{
|
||||
id: 'icon-tile-stack',
|
||||
category: 'slop',
|
||||
scopes: ['layout'],
|
||||
name: 'Icon tile stacked above heading',
|
||||
description:
|
||||
'A small rounded-square icon container above a heading is the universal AI feature-card template — every generator outputs this exact shape. Try a side-by-side icon and heading, or let the icon sit in flow without its own container.',
|
||||
@@ -222,6 +228,7 @@ const ANTIPATTERNS = [
|
||||
{
|
||||
id: 'italic-serif-display',
|
||||
category: 'slop',
|
||||
scopes: ['type'],
|
||||
name: 'Italic serif display headline',
|
||||
description:
|
||||
'Oversized italic serif (Fraunces, Recoleta, Playfair, Newsreader-italic) as the primary hero headline reads as taste in isolation but has become the universal AI-startup landing page hero. Set roman, or move to a non-serif display face. Editorial / magazine register may legitimately want this — judge by context.',
|
||||
@@ -231,6 +238,7 @@ const ANTIPATTERNS = [
|
||||
{
|
||||
id: 'hero-eyebrow-chip',
|
||||
category: 'slop',
|
||||
scopes: ['type'],
|
||||
name: 'Hero eyebrow / pill chip',
|
||||
description:
|
||||
'A tiny uppercase letter-spaced label sitting immediately above an oversized hero headline — or the same shape rendered as a pill chip — is now the default AI SaaS hero. Drop the eyebrow, integrate the kicker into the headline, or run it as a navigation breadcrumb instead.',
|
||||
@@ -240,6 +248,7 @@ const ANTIPATTERNS = [
|
||||
{
|
||||
id: 'repeated-section-kickers',
|
||||
category: 'slop',
|
||||
scopes: ['type'],
|
||||
severity: 'advisory',
|
||||
name: 'Repeated section kicker labels',
|
||||
description:
|
||||
@@ -250,6 +259,7 @@ const ANTIPATTERNS = [
|
||||
{
|
||||
id: 'numbered-section-markers',
|
||||
category: 'slop',
|
||||
scopes: ['layout'],
|
||||
severity: 'advisory',
|
||||
name: 'Numbered section markers (01 / 02 / 03)',
|
||||
description:
|
||||
@@ -287,6 +297,7 @@ const ANTIPATTERNS = [
|
||||
{
|
||||
id: 'oversized-h1',
|
||||
category: 'slop',
|
||||
scopes: ['type'],
|
||||
name: 'Oversized hero headline',
|
||||
description:
|
||||
'A full-sentence headline set at display size ends up dominating the viewport, leaving no room for anything else above the fold. A punchy one- or two-word headline at that size is fine — the problem is a long headline blown up too large. Set long headlines smaller, or tighten the copy.',
|
||||
@@ -296,6 +307,7 @@ const ANTIPATTERNS = [
|
||||
{
|
||||
id: 'extreme-negative-tracking',
|
||||
category: 'slop',
|
||||
scopes: ['type'],
|
||||
name: 'Crushed letter spacing',
|
||||
description:
|
||||
'Letter-spacing pulled tighter than the point where characters keep their own shapes costs legibility. Tighten display type optically, not destructively.',
|
||||
@@ -341,6 +353,7 @@ const ANTIPATTERNS = [
|
||||
{
|
||||
id: 'line-length',
|
||||
category: 'quality',
|
||||
scopes: ['type', 'layout'],
|
||||
name: 'Line length too long',
|
||||
description:
|
||||
'Text lines wider than ~80 characters are hard to read. The eye loses its place tracking back to the start of the next line. Add a max-width (65ch to 75ch) to text containers.',
|
||||
@@ -350,6 +363,7 @@ const ANTIPATTERNS = [
|
||||
{
|
||||
id: 'cramped-padding',
|
||||
category: 'quality',
|
||||
scopes: ['layout'],
|
||||
name: 'Cramped padding',
|
||||
description:
|
||||
'Text is too close to the edge of its container. Two shapes: (1) an element with its own text where the padding is too low for the font size, and (2) a wrapper with text-bearing children and near-zero padding against a visible boundary (border, outline, or non-transparent background) — children land flush against the boundary line. Add at least 8px (ideally 12–16px) of padding inside bordered, outlined, or colored containers.',
|
||||
@@ -359,6 +373,7 @@ const ANTIPATTERNS = [
|
||||
{
|
||||
id: 'body-text-viewport-edge',
|
||||
category: 'quality',
|
||||
scopes: ['layout'],
|
||||
name: 'Body text touching viewport edge',
|
||||
description:
|
||||
'Body paragraphs render flush against the left or right viewport edge with no container providing horizontal padding. Wrap content in a container with at least 16px (ideally 24-32px) of horizontal padding, or apply max-width with mx-auto.',
|
||||
@@ -366,6 +381,7 @@ const ANTIPATTERNS = [
|
||||
{
|
||||
id: 'tight-leading',
|
||||
category: 'quality',
|
||||
scopes: ['type'],
|
||||
name: 'Tight line height',
|
||||
description:
|
||||
'Line height below 1.3x the font size makes multi-line text hard to read. Use 1.5 to 1.7 for body text so lines have room to breathe.',
|
||||
@@ -373,6 +389,7 @@ const ANTIPATTERNS = [
|
||||
{
|
||||
id: 'skipped-heading',
|
||||
category: 'quality',
|
||||
scopes: ['type'],
|
||||
name: 'Skipped heading level',
|
||||
description:
|
||||
'Heading levels should not skip (e.g. h1 then h3 with no h2). Screen readers use heading hierarchy for navigation. Skipping levels breaks the document outline.',
|
||||
@@ -380,6 +397,7 @@ const ANTIPATTERNS = [
|
||||
{
|
||||
id: 'justified-text',
|
||||
category: 'quality',
|
||||
scopes: ['type'],
|
||||
name: 'Justified text',
|
||||
description:
|
||||
'Justified text without hyphenation creates uneven word spacing ("rivers of white"). Use text-align: left for body text, or enable hyphens: auto if you must justify.',
|
||||
@@ -387,6 +405,7 @@ const ANTIPATTERNS = [
|
||||
{
|
||||
id: 'tiny-text',
|
||||
category: 'quality',
|
||||
scopes: ['type'],
|
||||
name: 'Tiny body text',
|
||||
description:
|
||||
'Body text below 12px is hard to read, especially on high-DPI screens. Use at least 14px for body content, 16px is ideal.',
|
||||
@@ -394,6 +413,7 @@ const ANTIPATTERNS = [
|
||||
{
|
||||
id: 'all-caps-body',
|
||||
category: 'quality',
|
||||
scopes: ['type'],
|
||||
name: 'All-caps body text',
|
||||
description:
|
||||
'Long passages in uppercase are hard to read. We recognize words by shape (ascenders and descenders), which all-caps removes. Reserve uppercase for short labels and headings.',
|
||||
@@ -403,6 +423,7 @@ const ANTIPATTERNS = [
|
||||
{
|
||||
id: 'wide-tracking',
|
||||
category: 'quality',
|
||||
scopes: ['type'],
|
||||
name: 'Wide letter spacing on body text',
|
||||
description:
|
||||
'Letter spacing above 0.05em on body text disrupts natural character groupings and slows reading. Reserve wide tracking for short uppercase labels only.',
|
||||
@@ -410,6 +431,7 @@ const ANTIPATTERNS = [
|
||||
{
|
||||
id: 'text-overflow',
|
||||
category: 'quality',
|
||||
scopes: ['layout'],
|
||||
name: 'Content overflowing its container',
|
||||
description:
|
||||
'Content renders wider than its container, spilling out or forcing a horizontal scrollbar. Let text wrap, constrain widths, or give the region a deliberate scroll affordance.',
|
||||
@@ -419,6 +441,7 @@ const ANTIPATTERNS = [
|
||||
{
|
||||
id: 'clipped-overflow-container',
|
||||
category: 'quality',
|
||||
scopes: ['layout'],
|
||||
name: 'Positioned child clipped by overflow container',
|
||||
description:
|
||||
'A clipping container (overflow hidden or clip) wrapping an absolutely-positioned child cuts off tooltips, menus, and popovers that need to escape. Let the overflow be visible, or move the positioned layer out of the clip.',
|
||||
@@ -428,6 +451,7 @@ const ANTIPATTERNS = [
|
||||
{
|
||||
id: 'design-system-font',
|
||||
category: 'quality',
|
||||
scopes: ['type'],
|
||||
name: 'Font outside DESIGN.md',
|
||||
description:
|
||||
'A font is used that is not declared in DESIGN.md typography. Use the documented type system or update DESIGN.md if this is an intentional brand addition.',
|
||||
@@ -454,6 +478,17 @@ const ANTIPATTERNS = [
|
||||
skillSection: 'Visual Details',
|
||||
skillGuideline: 'border radius outside the project design system',
|
||||
},
|
||||
{
|
||||
id: 'design-system-font-size',
|
||||
category: 'quality',
|
||||
severity: 'advisory',
|
||||
scopes: ['type'],
|
||||
name: 'Font size outside DESIGN.md',
|
||||
description:
|
||||
'A literal font-size is off the type ramp documented in DESIGN.md typography. Use a documented size step or update the design system if the new step is intentional.',
|
||||
skillSection: 'Typography',
|
||||
skillGuideline: 'font size outside the project design system',
|
||||
},
|
||||
|
||||
// ── Provider tells: opt-in via --gpt / --gemini (gated off by default) ──
|
||||
{
|
||||
@@ -628,6 +663,36 @@ function colorToHex(c) {
|
||||
return '#' + [c.r, c.g, c.b].map(v => v.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
|
||||
// --- cli/engine/shared/fonts.mjs ---
|
||||
const GOOGLE_FONTS_URL_RE = /fonts\.googleapis\.com\/css2?\?[^"'\s)<>]*/gi;
|
||||
|
||||
function normalizeGoogleFontFamilyParam(value) {
|
||||
return String(value || '')
|
||||
.split('|')
|
||||
.map(part => part.split(':')[0].trim().toLowerCase())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function extractGoogleFontFamilies(text) {
|
||||
const families = [];
|
||||
if (!text) return families;
|
||||
|
||||
GOOGLE_FONTS_URL_RE.lastIndex = 0;
|
||||
let urlMatch;
|
||||
while ((urlMatch = GOOGLE_FONTS_URL_RE.exec(text)) !== null) {
|
||||
const url = urlMatch[0];
|
||||
const queryStart = url.indexOf('?');
|
||||
if (queryStart === -1) continue;
|
||||
|
||||
const params = new URLSearchParams(url.slice(queryStart + 1).replace(/&/g, '&'));
|
||||
for (const value of params.getAll('family')) {
|
||||
families.push(...normalizeGoogleFontFamilyParam(value));
|
||||
}
|
||||
}
|
||||
|
||||
return families;
|
||||
}
|
||||
|
||||
// --- cli/engine/rules/checks.mjs ---
|
||||
const DETECTOR_IS_BROWSER = typeof window !== 'undefined';
|
||||
|
||||
@@ -2682,14 +2747,9 @@ function checkPageTypography(doc, win) {
|
||||
|
||||
// Check Google Fonts links in HTML
|
||||
const html = doc.documentElement?.outerHTML || '';
|
||||
const gfRe = /fonts\.googleapis\.com\/css2?\?family=([^&"'\s]+)/gi;
|
||||
let m;
|
||||
while ((m = gfRe.exec(html)) !== null) {
|
||||
const families = m[1].split('|').map(f => f.split(':')[0].replace(/\+/g, ' ').toLowerCase());
|
||||
for (const f of families) {
|
||||
fonts.add(f);
|
||||
if (OVERUSED_FONTS.has(f)) overusedFound.add(f);
|
||||
}
|
||||
for (const f of extractGoogleFontFamilies(html)) {
|
||||
fonts.add(f);
|
||||
if (OVERUSED_FONTS.has(f)) overusedFound.add(f);
|
||||
}
|
||||
|
||||
// Also parse raw HTML/style content for font-family (jsdom may not expose all via CSSOM)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { GENERIC_FONTS } from '../../shared/constants.mjs';
|
||||
import { GENERIC_FONTS, OVERUSED_FONTS } from '../../shared/constants.mjs';
|
||||
import { isNeutralColor } from '../../shared/color.mjs';
|
||||
import { extractGoogleFontFamilies } from '../../shared/fonts.mjs';
|
||||
import { checkSourceDesignSystem } from '../../design-system.mjs';
|
||||
import { isFullPage } from '../../shared/page.mjs';
|
||||
import { applyInlineIgnores } from '../../shared/inline-ignores.mjs';
|
||||
@@ -38,6 +39,10 @@ function shouldRunPageAnalyzers(content, filePath) {
|
||||
return !ext || PAGE_ANALYZER_EXTS.has(ext);
|
||||
}
|
||||
|
||||
function firstOverusedGoogleFont(text) {
|
||||
return extractGoogleFontFamilies(text).find(f => OVERUSED_FONTS.has(f)) || '';
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -88,9 +93,12 @@ const REGEX_MATCHERS = [
|
||||
{ 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, ' ')}` },
|
||||
{ id: 'overused-font', regex: /fonts\.googleapis\.com\/css2?\?[^"'\s)<>]*/gi,
|
||||
test: (m) => {
|
||||
m.overusedGoogleFont = firstOverusedGoogleFont(m[0]);
|
||||
return Boolean(m.overusedGoogleFont);
|
||||
},
|
||||
fmt: (m) => `Google Fonts: ${m.overusedGoogleFont || firstOverusedGoogleFont(m[0])}` },
|
||||
// --- 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),
|
||||
@@ -170,10 +178,7 @@ const REGEX_ANALYZERS = [
|
||||
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);
|
||||
}
|
||||
for (const f of extractGoogleFontFamilies(content)) fonts.add(f);
|
||||
if (fonts.size !== 1 || content.split('\n').length < 20) return [];
|
||||
const name = [...fonts][0];
|
||||
const lines = content.split('\n');
|
||||
|
||||
@@ -21,6 +21,7 @@ const ANTIPATTERNS = [
|
||||
{
|
||||
id: 'overused-font',
|
||||
category: 'slop',
|
||||
scopes: ['type'],
|
||||
name: 'Overused font',
|
||||
description:
|
||||
'Inter, Roboto, Fraunces, Geist, Plus Jakarta Sans, and Space Grotesk are used on so many sites they no longer feel distinctive. Each new wave of AI-generated UIs converges on the same handful of faces. Choose a face that gives your interface personality.',
|
||||
@@ -30,6 +31,7 @@ const ANTIPATTERNS = [
|
||||
{
|
||||
id: 'single-font',
|
||||
category: 'slop',
|
||||
scopes: ['type'],
|
||||
name: 'Single font for everything',
|
||||
description:
|
||||
'Only one font family is used for the entire page. Pair a distinctive display font with a refined body font to create typographic hierarchy.',
|
||||
@@ -39,6 +41,7 @@ const ANTIPATTERNS = [
|
||||
{
|
||||
id: 'flat-type-hierarchy',
|
||||
category: 'slop',
|
||||
scopes: ['type'],
|
||||
name: 'Flat type hierarchy',
|
||||
description:
|
||||
'Font sizes are too close together — no clear visual hierarchy. Use fewer sizes with more contrast (aim for at least a 1.25 ratio between steps).',
|
||||
@@ -75,6 +78,7 @@ const ANTIPATTERNS = [
|
||||
{
|
||||
id: 'nested-cards',
|
||||
category: 'slop',
|
||||
scopes: ['layout'],
|
||||
name: 'Nested cards',
|
||||
description:
|
||||
'Cards inside cards create visual noise and excessive depth. Flatten the hierarchy — use spacing, typography, and dividers instead of nesting containers.',
|
||||
@@ -84,6 +88,7 @@ const ANTIPATTERNS = [
|
||||
{
|
||||
id: 'monotonous-spacing',
|
||||
category: 'slop',
|
||||
scopes: ['layout'],
|
||||
name: 'Monotonous spacing',
|
||||
description:
|
||||
'The same spacing value used everywhere — no rhythm, no variation. Use tight groupings for related items and generous separations between sections.',
|
||||
@@ -111,6 +116,7 @@ const ANTIPATTERNS = [
|
||||
{
|
||||
id: 'icon-tile-stack',
|
||||
category: 'slop',
|
||||
scopes: ['layout'],
|
||||
name: 'Icon tile stacked above heading',
|
||||
description:
|
||||
'A small rounded-square icon container above a heading is the universal AI feature-card template — every generator outputs this exact shape. Try a side-by-side icon and heading, or let the icon sit in flow without its own container.',
|
||||
@@ -120,6 +126,7 @@ const ANTIPATTERNS = [
|
||||
{
|
||||
id: 'italic-serif-display',
|
||||
category: 'slop',
|
||||
scopes: ['type'],
|
||||
name: 'Italic serif display headline',
|
||||
description:
|
||||
'Oversized italic serif (Fraunces, Recoleta, Playfair, Newsreader-italic) as the primary hero headline reads as taste in isolation but has become the universal AI-startup landing page hero. Set roman, or move to a non-serif display face. Editorial / magazine register may legitimately want this — judge by context.',
|
||||
@@ -129,6 +136,7 @@ const ANTIPATTERNS = [
|
||||
{
|
||||
id: 'hero-eyebrow-chip',
|
||||
category: 'slop',
|
||||
scopes: ['type'],
|
||||
name: 'Hero eyebrow / pill chip',
|
||||
description:
|
||||
'A tiny uppercase letter-spaced label sitting immediately above an oversized hero headline — or the same shape rendered as a pill chip — is now the default AI SaaS hero. Drop the eyebrow, integrate the kicker into the headline, or run it as a navigation breadcrumb instead.',
|
||||
@@ -138,6 +146,7 @@ const ANTIPATTERNS = [
|
||||
{
|
||||
id: 'repeated-section-kickers',
|
||||
category: 'slop',
|
||||
scopes: ['type'],
|
||||
severity: 'advisory',
|
||||
name: 'Repeated section kicker labels',
|
||||
description:
|
||||
@@ -148,6 +157,7 @@ const ANTIPATTERNS = [
|
||||
{
|
||||
id: 'numbered-section-markers',
|
||||
category: 'slop',
|
||||
scopes: ['layout'],
|
||||
severity: 'advisory',
|
||||
name: 'Numbered section markers (01 / 02 / 03)',
|
||||
description:
|
||||
@@ -185,6 +195,7 @@ const ANTIPATTERNS = [
|
||||
{
|
||||
id: 'oversized-h1',
|
||||
category: 'slop',
|
||||
scopes: ['type'],
|
||||
name: 'Oversized hero headline',
|
||||
description:
|
||||
'A full-sentence headline set at display size ends up dominating the viewport, leaving no room for anything else above the fold. A punchy one- or two-word headline at that size is fine — the problem is a long headline blown up too large. Set long headlines smaller, or tighten the copy.',
|
||||
@@ -194,6 +205,7 @@ const ANTIPATTERNS = [
|
||||
{
|
||||
id: 'extreme-negative-tracking',
|
||||
category: 'slop',
|
||||
scopes: ['type'],
|
||||
name: 'Crushed letter spacing',
|
||||
description:
|
||||
'Letter-spacing pulled tighter than the point where characters keep their own shapes costs legibility. Tighten display type optically, not destructively.',
|
||||
@@ -239,6 +251,7 @@ const ANTIPATTERNS = [
|
||||
{
|
||||
id: 'line-length',
|
||||
category: 'quality',
|
||||
scopes: ['type', 'layout'],
|
||||
name: 'Line length too long',
|
||||
description:
|
||||
'Text lines wider than ~80 characters are hard to read. The eye loses its place tracking back to the start of the next line. Add a max-width (65ch to 75ch) to text containers.',
|
||||
@@ -248,6 +261,7 @@ const ANTIPATTERNS = [
|
||||
{
|
||||
id: 'cramped-padding',
|
||||
category: 'quality',
|
||||
scopes: ['layout'],
|
||||
name: 'Cramped padding',
|
||||
description:
|
||||
'Text is too close to the edge of its container. Two shapes: (1) an element with its own text where the padding is too low for the font size, and (2) a wrapper with text-bearing children and near-zero padding against a visible boundary (border, outline, or non-transparent background) — children land flush against the boundary line. Add at least 8px (ideally 12–16px) of padding inside bordered, outlined, or colored containers.',
|
||||
@@ -257,6 +271,7 @@ const ANTIPATTERNS = [
|
||||
{
|
||||
id: 'body-text-viewport-edge',
|
||||
category: 'quality',
|
||||
scopes: ['layout'],
|
||||
name: 'Body text touching viewport edge',
|
||||
description:
|
||||
'Body paragraphs render flush against the left or right viewport edge with no container providing horizontal padding. Wrap content in a container with at least 16px (ideally 24-32px) of horizontal padding, or apply max-width with mx-auto.',
|
||||
@@ -264,6 +279,7 @@ const ANTIPATTERNS = [
|
||||
{
|
||||
id: 'tight-leading',
|
||||
category: 'quality',
|
||||
scopes: ['type'],
|
||||
name: 'Tight line height',
|
||||
description:
|
||||
'Line height below 1.3x the font size makes multi-line text hard to read. Use 1.5 to 1.7 for body text so lines have room to breathe.',
|
||||
@@ -271,6 +287,7 @@ const ANTIPATTERNS = [
|
||||
{
|
||||
id: 'skipped-heading',
|
||||
category: 'quality',
|
||||
scopes: ['type'],
|
||||
name: 'Skipped heading level',
|
||||
description:
|
||||
'Heading levels should not skip (e.g. h1 then h3 with no h2). Screen readers use heading hierarchy for navigation. Skipping levels breaks the document outline.',
|
||||
@@ -278,6 +295,7 @@ const ANTIPATTERNS = [
|
||||
{
|
||||
id: 'justified-text',
|
||||
category: 'quality',
|
||||
scopes: ['type'],
|
||||
name: 'Justified text',
|
||||
description:
|
||||
'Justified text without hyphenation creates uneven word spacing ("rivers of white"). Use text-align: left for body text, or enable hyphens: auto if you must justify.',
|
||||
@@ -285,6 +303,7 @@ const ANTIPATTERNS = [
|
||||
{
|
||||
id: 'tiny-text',
|
||||
category: 'quality',
|
||||
scopes: ['type'],
|
||||
name: 'Tiny body text',
|
||||
description:
|
||||
'Body text below 12px is hard to read, especially on high-DPI screens. Use at least 14px for body content, 16px is ideal.',
|
||||
@@ -292,6 +311,7 @@ const ANTIPATTERNS = [
|
||||
{
|
||||
id: 'all-caps-body',
|
||||
category: 'quality',
|
||||
scopes: ['type'],
|
||||
name: 'All-caps body text',
|
||||
description:
|
||||
'Long passages in uppercase are hard to read. We recognize words by shape (ascenders and descenders), which all-caps removes. Reserve uppercase for short labels and headings.',
|
||||
@@ -301,6 +321,7 @@ const ANTIPATTERNS = [
|
||||
{
|
||||
id: 'wide-tracking',
|
||||
category: 'quality',
|
||||
scopes: ['type'],
|
||||
name: 'Wide letter spacing on body text',
|
||||
description:
|
||||
'Letter spacing above 0.05em on body text disrupts natural character groupings and slows reading. Reserve wide tracking for short uppercase labels only.',
|
||||
@@ -308,6 +329,7 @@ const ANTIPATTERNS = [
|
||||
{
|
||||
id: 'text-overflow',
|
||||
category: 'quality',
|
||||
scopes: ['layout'],
|
||||
name: 'Content overflowing its container',
|
||||
description:
|
||||
'Content renders wider than its container, spilling out or forcing a horizontal scrollbar. Let text wrap, constrain widths, or give the region a deliberate scroll affordance.',
|
||||
@@ -317,6 +339,7 @@ const ANTIPATTERNS = [
|
||||
{
|
||||
id: 'clipped-overflow-container',
|
||||
category: 'quality',
|
||||
scopes: ['layout'],
|
||||
name: 'Positioned child clipped by overflow container',
|
||||
description:
|
||||
'A clipping container (overflow hidden or clip) wrapping an absolutely-positioned child cuts off tooltips, menus, and popovers that need to escape. Let the overflow be visible, or move the positioned layer out of the clip.',
|
||||
@@ -326,6 +349,7 @@ const ANTIPATTERNS = [
|
||||
{
|
||||
id: 'design-system-font',
|
||||
category: 'quality',
|
||||
scopes: ['type'],
|
||||
name: 'Font outside DESIGN.md',
|
||||
description:
|
||||
'A font is used that is not declared in DESIGN.md typography. Use the documented type system or update DESIGN.md if this is an intentional brand addition.',
|
||||
@@ -352,6 +376,17 @@ const ANTIPATTERNS = [
|
||||
skillSection: 'Visual Details',
|
||||
skillGuideline: 'border radius outside the project design system',
|
||||
},
|
||||
{
|
||||
id: 'design-system-font-size',
|
||||
category: 'quality',
|
||||
severity: 'advisory',
|
||||
scopes: ['type'],
|
||||
name: 'Font size outside DESIGN.md',
|
||||
description:
|
||||
'A literal font-size is off the type ramp documented in DESIGN.md typography. Use a documented size step or update the design system if the new step is intentional.',
|
||||
skillSection: 'Typography',
|
||||
skillGuideline: 'font size outside the project design system',
|
||||
},
|
||||
|
||||
// ── Provider tells: opt-in via --gpt / --gemini (gated off by default) ──
|
||||
{
|
||||
@@ -448,12 +483,32 @@ function filterByProviders(findings, providers = []) {
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// Set of scope tags rules can declare (e.g. 'type', 'layout'). Used by the
|
||||
// CLI --scope flag to narrow output to one design domain.
|
||||
const RULE_SCOPES = new Set(
|
||||
ANTIPATTERNS.flatMap(rule => rule.scopes || []),
|
||||
);
|
||||
|
||||
// Keep only findings whose rule declares at least one of the requested
|
||||
// scopes. An empty scope list means no filtering (default CLI behavior).
|
||||
function filterByScopes(findings, scopes = []) {
|
||||
if (!scopes || scopes.length === 0) return findings;
|
||||
const enabled = new Set(scopes);
|
||||
return findings.filter(f => {
|
||||
const rule = getAntipattern(f.antipattern);
|
||||
return (rule?.scopes || []).some(scope => enabled.has(scope));
|
||||
});
|
||||
}
|
||||
|
||||
export {
|
||||
ANTIPATTERNS,
|
||||
RULE_SCOPES,
|
||||
RULE_ENGINE_SUPPORT,
|
||||
GATED_PROVIDERS,
|
||||
getAntipattern,
|
||||
getRulesForCategory,
|
||||
getRuleEngineSupport,
|
||||
filterByProviders,
|
||||
filterByScopes,
|
||||
};
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
parseRgb,
|
||||
relativeLuminance,
|
||||
} from '../shared/color.mjs';
|
||||
import { extractGoogleFontFamilies } from '../shared/fonts.mjs';
|
||||
|
||||
const DETECTOR_IS_BROWSER = typeof window !== 'undefined';
|
||||
|
||||
@@ -2072,14 +2073,9 @@ function checkPageTypography(doc, win) {
|
||||
|
||||
// Check Google Fonts links in HTML
|
||||
const html = doc.documentElement?.outerHTML || '';
|
||||
const gfRe = /fonts\.googleapis\.com\/css2?\?family=([^&"'\s]+)/gi;
|
||||
let m;
|
||||
while ((m = gfRe.exec(html)) !== null) {
|
||||
const families = m[1].split('|').map(f => f.split(':')[0].replace(/\+/g, ' ').toLowerCase());
|
||||
for (const f of families) {
|
||||
fonts.add(f);
|
||||
if (OVERUSED_FONTS.has(f)) overusedFound.add(f);
|
||||
}
|
||||
for (const f of extractGoogleFontFamilies(html)) {
|
||||
fonts.add(f);
|
||||
if (OVERUSED_FONTS.has(f)) overusedFound.add(f);
|
||||
}
|
||||
|
||||
// Also parse raw HTML/style content for font-family (jsdom may not expose all via CSSOM)
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
const GOOGLE_FONTS_URL_RE = /fonts\.googleapis\.com\/css2?\?[^"'\s)<>]*/gi;
|
||||
|
||||
function normalizeGoogleFontFamilyParam(value) {
|
||||
return String(value || '')
|
||||
.split('|')
|
||||
.map(part => part.split(':')[0].trim().toLowerCase())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function extractGoogleFontFamilies(text) {
|
||||
const families = [];
|
||||
if (!text) return families;
|
||||
|
||||
GOOGLE_FONTS_URL_RE.lastIndex = 0;
|
||||
let urlMatch;
|
||||
while ((urlMatch = GOOGLE_FONTS_URL_RE.exec(text)) !== null) {
|
||||
const url = urlMatch[0];
|
||||
const queryStart = url.indexOf('?');
|
||||
if (queryStart === -1) continue;
|
||||
|
||||
const params = new URLSearchParams(url.slice(queryStart + 1).replace(/&/g, '&'));
|
||||
for (const value of params.getAll('family')) {
|
||||
families.push(...normalizeGoogleFontFamilyParam(value));
|
||||
}
|
||||
}
|
||||
|
||||
return families;
|
||||
}
|
||||
|
||||
export { extractGoogleFontFamilies };
|
||||
@@ -1,6 +1,6 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* `/impeccable hooks <on|off|status|reset>` — manage the design hook runtime
|
||||
* The Impeccable hooks command manages the design hook runtime
|
||||
* via the `hook` key and shared detector ignores via the `detector` key in
|
||||
* .impeccable/config.json / .impeccable/config.local.json.
|
||||
*
|
||||
@@ -21,6 +21,7 @@
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { IMPECCABLE_COMMAND } from './lib/provider.mjs';
|
||||
|
||||
import {
|
||||
getConfigPath,
|
||||
@@ -184,7 +185,7 @@ function writeHookConfig(cwd, hookConfig, opts = {}) {
|
||||
const existing = existingRaw && typeof existingRaw === 'object' && !Array.isArray(existingRaw) ? existingRaw : {};
|
||||
const existingHook = stripDetectorKeys(hookSection(existing));
|
||||
// Merge over the existing hook object so fields the merge helpers don't manage
|
||||
// (consent, quiet, auditLog) survive a `/impeccable hooks` edit.
|
||||
// (consent, quiet, auditLog) survive an Impeccable hooks edit.
|
||||
const next = { ...existing, hook: { ...existingHook, ...hookConfig } };
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
fs.writeFileSync(filePath, JSON.stringify(next, null, 2) + '\n');
|
||||
@@ -513,9 +514,9 @@ function parseIgnoreRuleArgs(args) {
|
||||
function addIgnoreRule(cwd, args) {
|
||||
const parsed = parseIgnoreRuleArgs(args);
|
||||
const rule = parsed.rule;
|
||||
if (!rule) throw new Error('Pass a rule id, e.g. /impeccable hooks ignore-rule side-tab');
|
||||
if (!rule) throw new Error(`Pass a rule id, e.g. ${IMPECCABLE_COMMAND} hooks ignore-rule side-tab`);
|
||||
if (rule === 'overused-font' && !parsed.allValues) {
|
||||
throw new Error('overused-font is value-specific by default. Use /impeccable hooks ignore-value overused-font <font> for a confirmed font, or /impeccable hooks ignore-rule overused-font --all-values only when the user asked to ignore overused fonts generally.');
|
||||
throw new Error(`overused-font is value-specific by default. Use ${IMPECCABLE_COMMAND} hooks ignore-value overused-font <font> for a confirmed font, or ${IMPECCABLE_COMMAND} hooks ignore-rule overused-font --all-values only when the user asked to ignore overused fonts generally.`);
|
||||
}
|
||||
const config = mergeDetectorConfig(readRawDetectorConfig(cwd));
|
||||
if (!config.ignoreRules.includes(rule)) config.ignoreRules.push(rule);
|
||||
@@ -524,7 +525,7 @@ function addIgnoreRule(cwd, args) {
|
||||
}
|
||||
|
||||
function addIgnoreFile(cwd, glob) {
|
||||
if (!glob) throw new Error('Pass a glob, e.g. /impeccable hooks ignore-file "src/legacy/**"');
|
||||
if (!glob) throw new Error(`Pass a glob, e.g. ${IMPECCABLE_COMMAND} hooks ignore-file "src/legacy/**"`);
|
||||
const config = mergeDetectorConfig(readRawDetectorConfig(cwd));
|
||||
if (!config.ignoreFiles.includes(glob)) config.ignoreFiles.push(glob);
|
||||
writeDetectorConfig(cwd, config);
|
||||
@@ -569,7 +570,7 @@ function parseIgnoreValueArgs(args) {
|
||||
function addIgnoreValue(cwd, args) {
|
||||
const parsed = parseIgnoreValueArgs(args);
|
||||
if (!parsed.rule || !parsed.value) {
|
||||
throw new Error('Pass a rule id and value, e.g. /impeccable hooks ignore-value overused-font Inter');
|
||||
throw new Error(`Pass a rule id and value, e.g. ${IMPECCABLE_COMMAND} hooks ignore-value overused-font Inter`);
|
||||
}
|
||||
|
||||
if (parsed.shared && parsed.local) {
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import {
|
||||
@@ -21,13 +22,17 @@ import {
|
||||
appendDesignSystemNote,
|
||||
designSystemOptions,
|
||||
filterFindings,
|
||||
isNativePlatform,
|
||||
loadDetector,
|
||||
matchConfiguredExtension,
|
||||
matchesAnyGlob,
|
||||
persistCache,
|
||||
readCache,
|
||||
readConfig,
|
||||
renderTemplate,
|
||||
resolveCacheCwd,
|
||||
resolveProjectCwd,
|
||||
resolveProjectPlatform,
|
||||
truthy,
|
||||
writeAuditLog,
|
||||
} from './hook-lib.mjs';
|
||||
@@ -332,6 +337,22 @@ function isInsideProject(filePath, cwd) {
|
||||
}
|
||||
}
|
||||
|
||||
// The static HTML engine reads its input from disk, but preToolUse only has
|
||||
// the proposed content. Stage it in a temp file so html-engine targets get the
|
||||
// same DOM-structural rules pre-write that runHook applies post-edit.
|
||||
async function detectProposedHtml(detector, content, filePath, scanOptions) {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'impeccable-pre-'));
|
||||
const tmpFile = path.join(dir, path.basename(filePath));
|
||||
try {
|
||||
fs.writeFileSync(tmpFile, content);
|
||||
const findings = await detector.detectHtml(tmpFile, scanOptions);
|
||||
// Findings carry the temp path; remap so file-scoped ignores still match.
|
||||
return (findings || []).map((f) => (f && typeof f === 'object' ? { ...f, file: filePath } : f));
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
function cursorBlockMessage(findings, filePath, config, cwd) {
|
||||
const rendered = renderTemplate(findings, filePath, config, { cwd });
|
||||
const blocked = rendered.replace(
|
||||
@@ -379,9 +400,12 @@ async function main() {
|
||||
return allow({ skipped: 'stdin-empty' });
|
||||
}
|
||||
|
||||
const cwd = resolveProjectCwd(event);
|
||||
const sessionCwd = resolveProjectCwd(event);
|
||||
const started = Date.now();
|
||||
const filePath = proposedFilePath(event, cwd);
|
||||
const filePath = proposedFilePath(event, sessionCwd);
|
||||
// Re-key config/cache to the edited file's project root when the session
|
||||
// was launched from a non-project umbrella directory (issue #305).
|
||||
const cwd = resolveCacheCwd(filePath, sessionCwd);
|
||||
const audit = {
|
||||
harness: 'cursor',
|
||||
cwd,
|
||||
@@ -394,9 +418,13 @@ async function main() {
|
||||
if (SENSITIVE_PATH.test(filePath)) return allow({ ...audit, skipped: 'sensitive', durationMs: Date.now() - started });
|
||||
if (GENERATED_PATH.test(filePath)) return allow({ ...audit, skipped: 'generated', durationMs: Date.now() - started });
|
||||
|
||||
// Config is read before the extension gate so `detector.extensions` entries
|
||||
// (e.g. `.blade.php` template files, issue #316) can widen it.
|
||||
const config = readConfig(cwd);
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
audit.ext = ext;
|
||||
if (!ALLOWED_EXTS.has(ext)) return allow({ ...audit, skipped: 'extension', durationMs: Date.now() - started });
|
||||
const configuredExt = matchConfiguredExtension(filePath, config.extensions);
|
||||
audit.ext = configuredExt ? configuredExt.ext : ext;
|
||||
if (!ALLOWED_EXTS.has(ext) && !configuredExt) return allow({ ...audit, skipped: 'extension', durationMs: Date.now() - started });
|
||||
|
||||
const contentResult = proposedContent(event, cwd, filePath);
|
||||
if (contentResult && typeof contentResult === 'object' && contentResult.skipped) {
|
||||
@@ -405,9 +433,14 @@ async function main() {
|
||||
const content = typeof contentResult === 'string' ? contentResult : '';
|
||||
if (!content) return allow({ ...audit, skipped: 'no-proposed-content', durationMs: Date.now() - started });
|
||||
|
||||
const config = readConfig(cwd);
|
||||
if (config.enabled === false) return allow({ ...audit, skipped: 'config-disabled', durationMs: Date.now() - started });
|
||||
|
||||
// Web rule engine, native project: stand aside (see resolveProjectPlatform).
|
||||
const platform = resolveProjectPlatform(cwd);
|
||||
if (isNativePlatform(platform)) {
|
||||
return allow({ ...audit, skipped: 'native-platform', platform, durationMs: Date.now() - started });
|
||||
}
|
||||
|
||||
const rel = relativePath(filePath, cwd);
|
||||
if (matchesAnyGlob(rel, config.ignoreFiles) || matchesAnyGlob(filePath, config.ignoreFiles)) {
|
||||
return allow({ ...audit, skipped: 'config-ignore-file', durationMs: Date.now() - started });
|
||||
@@ -419,9 +452,16 @@ async function main() {
|
||||
}
|
||||
const scanOptions = designSystemOptions(config, detector, cwd);
|
||||
|
||||
// Mirror runHook's engine routing so template issues the HTML engine catches
|
||||
// post-edit cannot slip past the pre-write gate.
|
||||
const useHtmlEngine = configuredExt
|
||||
? configuredExt.engine === 'html'
|
||||
: (ext === '.html' || ext === '.htm');
|
||||
let findings = [];
|
||||
try {
|
||||
findings = await detector.detectText(content, filePath, scanOptions);
|
||||
findings = useHtmlEngine && typeof detector.detectHtml === 'function'
|
||||
? await detectProposedHtml(detector, content, filePath, scanOptions)
|
||||
: await detector.detectText(content, filePath, scanOptions);
|
||||
} catch {
|
||||
return allow({ ...audit, error: 'detector-threw', durationMs: Date.now() - started });
|
||||
}
|
||||
|
||||
@@ -9,15 +9,17 @@
|
||||
* ENVELOPE_PREFIX, ALLOWED_EXTS, ACK_EXTS, SENSITIVE_PATH, GENERATED_PATH, TRUTHY
|
||||
* truthy(value)
|
||||
* readConfig(cwd) / DEFAULT_CONFIG / getConfigPath(cwd) / getLocalConfigPath(cwd)
|
||||
* resolveProjectPlatform(cwd) / isNativePlatform(platform)
|
||||
* normalizeIgnoreValue(value)
|
||||
* readCache(cwd) / persistCache(cwd, cache)
|
||||
* readCache(cwd) / persistCache(cwd, cache) / resolveCacheCwd(primaryFile, sessionCwd)
|
||||
* bumpEditCount(cache, sessionId, filePath) -> number
|
||||
* suppressionNotice(filePath)
|
||||
* filterFindings(findings, content, ext, config)
|
||||
* matchConfiguredExtension(filePath, extensions)
|
||||
* dedupeAgainstCache(findings, cache, sessionId, filePath)
|
||||
* renderTemplate(findings, filePath, config, opts)
|
||||
* renderCleanAck(filePath, opts) / renderPendingAck(filePath, known, opts)
|
||||
* shouldEmitAckForFile(filePath)
|
||||
* shouldEmitAckForFile(filePath, config?)
|
||||
* writeAuditLog(env, entry)
|
||||
* loadDetector() -> Promise<{ detectText, detectHtml }>
|
||||
* matchesAnyGlob(filePath, globs)
|
||||
@@ -35,8 +37,11 @@
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { pathToFileURL, fileURLToPath } from 'node:url';
|
||||
import { extractPlatform, loadContext } from './context.mjs';
|
||||
import { IMPECCABLE_COMMAND } from './lib/provider.mjs';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
@@ -77,6 +82,7 @@ export const DEFAULT_CONFIG = Object.freeze({
|
||||
ignoreRules: [],
|
||||
ignoreFiles: [],
|
||||
ignoreValues: [],
|
||||
extensions: [],
|
||||
limits: { maxFindings: 5, maxChars: 8000 },
|
||||
});
|
||||
|
||||
@@ -134,6 +140,59 @@ export function resolveProjectCwd(event, fallback = process.cwd()) {
|
||||
|| fallback;
|
||||
}
|
||||
|
||||
function looksLikeProjectRoot(dir) {
|
||||
return ['.git', 'package.json', '.impeccable'].some((marker) => {
|
||||
try { return fs.existsSync(path.join(dir, marker)); } catch { return false; }
|
||||
});
|
||||
}
|
||||
|
||||
// Where `.impeccable/` (cache + config) lives for this event. Normally the
|
||||
// session cwd, untouched. But when the agent was launched from an umbrella
|
||||
// directory that is not itself a project (no .git, package.json, or
|
||||
// .impeccable), key to the edited file's nearest project root instead, so a
|
||||
// multi-project launch dir doesn't accumulate a shared cross-project cache
|
||||
// (issue #305). Climbing stops at the home dir, falling back to the session
|
||||
// cwd when no marker is found.
|
||||
export function resolveCacheCwd(primaryFile, sessionCwd) {
|
||||
const base = path.resolve(sessionCwd || process.cwd());
|
||||
if (!primaryFile || typeof primaryFile !== 'string' || hasPathTraversal(primaryFile)) return base;
|
||||
if (looksLikeProjectRoot(base)) return base;
|
||||
let dir;
|
||||
try {
|
||||
dir = path.dirname(path.resolve(primaryFile));
|
||||
} catch {
|
||||
return base;
|
||||
}
|
||||
const home = path.resolve(os.homedir());
|
||||
while (true) {
|
||||
if (dir === home) return base;
|
||||
if (looksLikeProjectRoot(dir)) return dir;
|
||||
const parent = path.dirname(dir);
|
||||
if (parent === dir) return base;
|
||||
dir = parent;
|
||||
}
|
||||
}
|
||||
|
||||
// The detector's rules are web rules (HTML/CSS shapes), but a React Native or
|
||||
// Flutter project is made of the exact extensions the hook watches (.tsx, .ts,
|
||||
// .js), so without this gate every native screen edit would draw web-shaped
|
||||
// findings that contradict the native platform references. PRODUCT.md's
|
||||
// `## Platform` field decides: `ios` / `android` / `adaptive` projects skip
|
||||
// the scan entirely. Resolution goes through loadContext so the hook reads the
|
||||
// same PRODUCT.md the skill does (alternate context dirs, monorepo fallback).
|
||||
export function resolveProjectPlatform(cwd) {
|
||||
try {
|
||||
const ctx = loadContext(cwd);
|
||||
return extractPlatform(ctx && ctx.product);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function isNativePlatform(platform) {
|
||||
return platform === 'ios' || platform === 'android' || platform === 'adaptive';
|
||||
}
|
||||
|
||||
export function readConfig(cwd) {
|
||||
const config = cloneDefaultConfig();
|
||||
// Hook runtime settings live under `hook`; detector filters live under
|
||||
@@ -168,6 +227,7 @@ function cloneDefaultConfig() {
|
||||
ignoreRules: [],
|
||||
ignoreFiles: [],
|
||||
ignoreValues: [],
|
||||
extensions: [],
|
||||
designSystem: { ...DEFAULT_CONFIG.designSystem },
|
||||
limits: { ...DEFAULT_CONFIG.limits },
|
||||
};
|
||||
@@ -190,9 +250,55 @@ function applyDetectorConfigSource(config, raw) {
|
||||
if (Array.isArray(raw.ignoreValues)) {
|
||||
config.ignoreValues = mergeIgnoreValues(config.ignoreValues, raw.ignoreValues);
|
||||
}
|
||||
if (Array.isArray(raw.extensions)) {
|
||||
config.extensions = mergeExtensions(config.extensions, raw.extensions);
|
||||
}
|
||||
return config;
|
||||
}
|
||||
|
||||
// Extra scanned extensions from `detector.extensions` config. Entries are
|
||||
// `{ ext, engine }` (engine 'html' | 'text', default 'html' — the common case
|
||||
// for server-side templates) or bare strings as shorthand. Extensions are
|
||||
// matched against the end of the filename, not path.extname, so double
|
||||
// extensions like `.blade.php` and `.html.erb` work (issue #316).
|
||||
function normalizeExtensionEntries(entries) {
|
||||
if (!Array.isArray(entries)) return [];
|
||||
const out = [];
|
||||
for (const entry of entries) {
|
||||
const raw = typeof entry === 'string' ? entry : entry?.ext;
|
||||
if (typeof raw !== 'string') continue;
|
||||
let ext = raw.trim().toLowerCase();
|
||||
if (!ext) continue;
|
||||
if (!ext.startsWith('.')) ext = `.${ext}`;
|
||||
const engine = (!(typeof entry === 'string') && entry?.engine === 'text') ? 'text' : 'html';
|
||||
out.push({ ext, engine });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function mergeExtensions(existing, incoming) {
|
||||
const map = new Map();
|
||||
for (const entry of normalizeExtensionEntries(existing)) map.set(entry.ext, entry);
|
||||
for (const entry of normalizeExtensionEntries(incoming)) map.set(entry.ext, entry);
|
||||
return Array.from(map.values());
|
||||
}
|
||||
|
||||
export function matchConfiguredExtension(filePath, extensions) {
|
||||
if (!Array.isArray(extensions) || extensions.length === 0) return null;
|
||||
const name = path.basename(String(filePath || '')).toLowerCase();
|
||||
if (!name) return null;
|
||||
// The longest matching suffix wins, so `.blade.php` beats a broader `.php`
|
||||
// entry regardless of config order.
|
||||
let best = null;
|
||||
for (const entry of normalizeExtensionEntries(extensions)) {
|
||||
if (name.length > entry.ext.length && name.endsWith(entry.ext)
|
||||
&& (!best || entry.ext.length > best.ext.length)) {
|
||||
best = entry;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
function applyConfigSource(config, raw) {
|
||||
if (!raw || typeof raw !== 'object') return config;
|
||||
if (Object.prototype.hasOwnProperty.call(raw, 'enabled')) {
|
||||
@@ -556,7 +662,7 @@ export function bumpEditCount(cache, sessionId, filePath) {
|
||||
}
|
||||
|
||||
export function suppressionNotice(filePath) {
|
||||
return `${ENVELOPE_PREFIX} Suppressing further design hints on ${filePath}. More than ${EDIT_COUNT_THRESHOLD} edits in this session reached. Run /impeccable audit to revisit.`;
|
||||
return `${ENVELOPE_PREFIX} Suppressing further design hints on ${filePath}. More than ${EDIT_COUNT_THRESHOLD} edits in this session reached. Run ${IMPECCABLE_COMMAND} audit to revisit.`;
|
||||
}
|
||||
|
||||
// Glob → RegExp. Supports `**`, `*`, `?`, and `{a,b}` alternation.
|
||||
@@ -628,11 +734,13 @@ export function filterFindings(findings, _content, _ext, config) {
|
||||
function isIgnoredFindingValue(finding, ignoreValues) {
|
||||
if (!Array.isArray(ignoreValues) || ignoreValues.length === 0) return false;
|
||||
const rule = normalizeIgnoreRule(finding.antipattern);
|
||||
if (!rule) return false;
|
||||
// File-scoped wildcards suppress rules with no extractable value, such as side-tab.
|
||||
const value = extractFindingIgnoreValue(finding);
|
||||
if (!rule || !value) return false;
|
||||
return ignoreValues.some((entry) => {
|
||||
if (entry.rule !== rule) return false;
|
||||
const wildcardValue = entry.value === '*';
|
||||
if (entry.rule !== rule || (!wildcardValue && !ignoreValueMatches(rule, entry.value, value))) return false;
|
||||
if (!wildcardValue && (!value || !ignoreValueMatches(rule, entry.value, value))) return false;
|
||||
if (!Array.isArray(entry.files) || entry.files.length === 0) return !wildcardValue;
|
||||
return findingMatchesScopedIgnoreFile(finding, entry.files);
|
||||
});
|
||||
@@ -770,7 +878,7 @@ export function renderTemplate(findings, filePath, config, opts = {}) {
|
||||
const header = `${ENVELOPE_PREFIX} Design hook findings requiring review in ${display} (${total} issue(s)):`;
|
||||
const lines = shown.map((f) => formatFindingLine(f));
|
||||
const more = remaining > 0
|
||||
? `... and ${remaining} more (see /impeccable audit).`
|
||||
? `... and ${remaining} more (see ${IMPECCABLE_COMMAND} audit).`
|
||||
: null;
|
||||
const footer = directiveFooter(display);
|
||||
|
||||
@@ -814,7 +922,7 @@ function renderGroupedTemplate(groups, config, opts = {}) {
|
||||
shownCount += shown.length;
|
||||
const hidden = group.findings.length - shown.length;
|
||||
if (hidden > 0) {
|
||||
lines.push(`- ... ${hidden} more in ${display} (see /impeccable audit).`);
|
||||
lines.push(`- ... ${hidden} more in ${display} (see ${IMPECCABLE_COMMAND} audit).`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -830,7 +938,7 @@ function clampGroupedToBudget(header, lines, footer, maxChars) {
|
||||
const assemble = (linesArr, omitted) => [
|
||||
header,
|
||||
...linesArr,
|
||||
...(omitted ? ['... and more (see /impeccable audit).'] : []),
|
||||
...(omitted ? [`... and more (see ${IMPECCABLE_COMMAND} audit).`] : []),
|
||||
'',
|
||||
footer,
|
||||
].join('\n');
|
||||
@@ -863,7 +971,7 @@ function clampToBudget(header, lines, more, footer, maxChars) {
|
||||
let assembled = assemble(working, moreText);
|
||||
while (assembled.length > maxChars && working.length > 1) {
|
||||
working.pop();
|
||||
moreText = '... and more (see /impeccable audit).';
|
||||
moreText = `... and more (see ${IMPECCABLE_COMMAND} audit).`;
|
||||
assembled = assemble(working, moreText);
|
||||
}
|
||||
if (assembled.length > maxChars) {
|
||||
@@ -895,7 +1003,7 @@ function formatFindingIgnoreCommand(finding) {
|
||||
const value = extractFindingIgnoreValueRaw(finding);
|
||||
const valueArg = quoteCommandArg(value);
|
||||
const reason = quoteCommandArg(`User confirmed ${value} is intentional`);
|
||||
return `/impeccable hooks ignore-value ${rule} ${valueArg} --shared --reason ${reason}`;
|
||||
return `${IMPECCABLE_COMMAND} hooks ignore-value ${rule} ${valueArg} --shared --reason ${reason}`;
|
||||
}
|
||||
|
||||
function quoteCommandArg(value) {
|
||||
@@ -1319,8 +1427,12 @@ export function renderPendingAck(filePath, knownFindings, opts = {}) {
|
||||
return `${ENVELOPE_PREFIX} Design hook scanned ${display}. Still has ${count} finding(s) flagged earlier this session (${sample}${more}). Handle them before finalizing — the previous reminder still applies.`;
|
||||
}
|
||||
|
||||
export function shouldEmitAckForFile(filePath) {
|
||||
return ACK_EXTS.has(path.extname(String(filePath || '')).toLowerCase());
|
||||
export function shouldEmitAckForFile(filePath, config = null) {
|
||||
if (ACK_EXTS.has(path.extname(String(filePath || '')).toLowerCase())) return true;
|
||||
// Configured html-engine extensions are declared UI markup, so they get the
|
||||
// clean/pending acks; text-engine ones stay quiet like plain .ts/.js.
|
||||
const configured = matchConfiguredExtension(filePath, config?.extensions);
|
||||
return Boolean(configured && configured.engine === 'html');
|
||||
}
|
||||
|
||||
export function designSystemOptions(config, detector, projectCwd) {
|
||||
@@ -1336,7 +1448,7 @@ export function designSystemOptions(config, detector, projectCwd) {
|
||||
|
||||
export function appendDesignSystemNote(text, scanOptions) {
|
||||
if (!text || !scanOptions?.designSystem?.mdNewerThanJson) return text;
|
||||
return `${text}\n\n${ENVELOPE_PREFIX} DESIGN.md is newer than .impeccable/design.json. Run /impeccable document to refresh the design-system sidecar.`;
|
||||
return `${text}\n\n${ENVELOPE_PREFIX} DESIGN.md is newer than .impeccable/design.json. Run ${IMPECCABLE_COMMAND} document to refresh the design-system sidecar.`;
|
||||
}
|
||||
|
||||
// The directive footer is the part of the hook output that steers model
|
||||
@@ -1353,16 +1465,16 @@ export function appendDesignSystemNote(text, scanOptions) {
|
||||
// raw envelope. Asking the model to surface the resolution in its
|
||||
// reply is the cheapest way to make the feedback loop visible.
|
||||
function directiveFooter(display, opts = {}) {
|
||||
const ignoreFileCommand = `/impeccable hooks ignore-file ${quoteCommandArg(display)}`;
|
||||
const ignoreFileCommand = `${IMPECCABLE_COMMAND} hooks ignore-file ${quoteCommandArg(display)}`;
|
||||
const fileIgnoreGuidance = opts.grouped
|
||||
? 'run `/impeccable hooks ignore-file <path>` for the specific file'
|
||||
? `run \`${IMPECCABLE_COMMAND} hooks ignore-file <path>\` for the specific file`
|
||||
: `run \`${ignoreFileCommand}\``;
|
||||
return [
|
||||
'Handle these before finalizing: fix findings that are real design problems, or explicitly classify contextually intentional findings as false positives. Acknowledge what you changed or why you are leaving a finding unchanged.',
|
||||
'',
|
||||
'Use context judgment before editing. A finding is not automatically a defect; literal or domain-appropriate motion, intentional demos or fixtures, documentation of bad design, and user-confirmed choices can be valid as-is.',
|
||||
'',
|
||||
`Do not change intentional design just to satisfy the hook, and do not silence a real finding with an inline ignore comment to skip fixing it. Suppress a finding only after the user explicitly confirms it is intentional. Prefer a config ignore (one reviewable place, the commands below); reach for an inline \`impeccable-disable <rule>\` comment only when the waiver must travel with a file that leaves the repo, such as an exported or standalone document. Prefer the narrowest persisted exception: run the exact \`/impeccable hooks ignore-value ... --shared\` command shown next to a value-specific finding. For \`overused-font\`, use \`ignore-value\` for a specific font and use \`/impeccable hooks ignore-rule overused-font --all-values\` only when the user asks to ignore overused fonts generally. For file-specific findings without an ignore-value command, ${fileIgnoreGuidance}; use \`/impeccable hooks ignore-rule <id>\` only when the user asks to suppress the whole non-value-specific rule. Run /impeccable audit for the full pass.`,
|
||||
`Do not change intentional design just to satisfy the hook, and do not silence a real finding with an inline ignore comment to skip fixing it. Suppress a finding only after the user explicitly confirms it is intentional. Prefer a config ignore (one reviewable place, the commands below); reach for an inline \`impeccable-disable <rule>\` comment only when the waiver must travel with a file that leaves the repo, such as an exported or standalone document. Prefer the narrowest persisted exception: run the exact \`${IMPECCABLE_COMMAND} hooks ignore-value ... --shared\` command shown next to a value-specific finding. For \`overused-font\`, use \`ignore-value\` for a specific font and use \`${IMPECCABLE_COMMAND} hooks ignore-rule overused-font --all-values\` only when the user asks to ignore overused fonts generally. For file-specific findings without an ignore-value command, ${fileIgnoreGuidance}; use \`${IMPECCABLE_COMMAND} hooks ignore-rule <id>\` only when the user asks to suppress the whole non-value-specific rule. Run ${IMPECCABLE_COMMAND} audit for the full pass.`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
@@ -1402,9 +1514,10 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now =
|
||||
event = normalizeHookEvent(event, cwd, harness);
|
||||
audit.harness = harness;
|
||||
|
||||
const projectCwd = event.cwd || cwd;
|
||||
const sessionCwd = event.cwd || cwd;
|
||||
const primaryFiles = normalizeScanTargets(resolveTargetFiles(event, sessionCwd), sessionCwd);
|
||||
const projectCwd = resolveCacheCwd(primaryFiles[0], sessionCwd);
|
||||
audit.cwd = projectCwd;
|
||||
const primaryFiles = normalizeScanTargets(resolveTargetFiles(event, projectCwd), projectCwd);
|
||||
const primaryFileSet = new Set(primaryFiles);
|
||||
const targetFiles = expandScanTargets(primaryFiles, projectCwd);
|
||||
audit.session = event.session_id || null;
|
||||
@@ -1419,11 +1532,16 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now =
|
||||
return result({ skipped: 'config-disabled', durationMs: Date.now() - started });
|
||||
}
|
||||
|
||||
const platform = resolveProjectPlatform(projectCwd);
|
||||
if (isNativePlatform(platform)) {
|
||||
return result({ skipped: 'native-platform', platform, durationMs: Date.now() - started });
|
||||
}
|
||||
|
||||
const cache = readCache(projectCwd);
|
||||
const sessionId = event.session_id || 'unknown';
|
||||
const det = detector || await loadDetector();
|
||||
if (!det || typeof det.detectText !== 'function') {
|
||||
persistCache(projectCwd, cache);
|
||||
// Cache is not mutated yet at this point; nothing to persist.
|
||||
return result({ skipped: 'detector-missing', durationMs: Date.now() - started });
|
||||
}
|
||||
const scanOptions = designSystemOptions(config, det, projectCwd);
|
||||
@@ -1435,6 +1553,7 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now =
|
||||
let detectorThrewAny = false;
|
||||
let lastSkip = 'no-scannable-file';
|
||||
let suppressedHit = false;
|
||||
let cacheDirty = false;
|
||||
|
||||
for (const filePath of targetFiles) {
|
||||
audit.file = filePath;
|
||||
@@ -1449,8 +1568,9 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now =
|
||||
}
|
||||
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
audit.ext = ext;
|
||||
if (!ALLOWED_EXTS.has(ext)) {
|
||||
const configuredExt = matchConfiguredExtension(filePath, config.extensions);
|
||||
audit.ext = configuredExt ? configuredExt.ext : ext;
|
||||
if (!ALLOWED_EXTS.has(ext) && !configuredExt) {
|
||||
lastSkip = 'extension';
|
||||
continue;
|
||||
}
|
||||
@@ -1467,6 +1587,7 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now =
|
||||
|
||||
if (primaryFileSet.has(filePath)) {
|
||||
const editCount = bumpEditCount(cache, sessionId, filePath);
|
||||
cacheDirty = true;
|
||||
audit.editCount = editCount;
|
||||
|
||||
if (editCount > EDIT_COUNT_THRESHOLD) {
|
||||
@@ -1483,7 +1604,10 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now =
|
||||
const content = fs.readFileSync(filePath, 'utf-8');
|
||||
let findings;
|
||||
let detectorThrew = false;
|
||||
if ((ext === '.html' || ext === '.htm') && typeof det.detectHtml === 'function') {
|
||||
const useHtmlEngine = configuredExt
|
||||
? configuredExt.engine === 'html'
|
||||
: (ext === '.html' || ext === '.htm');
|
||||
if (useHtmlEngine && typeof det.detectHtml === 'function') {
|
||||
try { findings = await det.detectHtml(filePath, scanOptions); } catch { findings = []; detectorThrew = true; }
|
||||
} else {
|
||||
try { findings = await det.detectText(content, filePath, scanOptions); } catch { findings = []; detectorThrew = true; }
|
||||
@@ -1496,6 +1620,7 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now =
|
||||
|
||||
if (fresh.length > 0) {
|
||||
rememberFindings(cache, sessionId, filePath, fresh);
|
||||
cacheDirty = true;
|
||||
freshGroups.push({ filePath, findings: fresh });
|
||||
continue;
|
||||
}
|
||||
@@ -1513,7 +1638,15 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now =
|
||||
}
|
||||
}
|
||||
|
||||
persistCache(projectCwd, cache);
|
||||
// Persist only when the write is earned: fresh findings justify creating
|
||||
// `.impeccable/` (dedup and suppression need it), and an already-present
|
||||
// `.impeccable/` dir marks a project that opted in. A non-UI edit, or a
|
||||
// clean UI edit in a project with no Impeccable footprint, must be a
|
||||
// no-op on disk (issues #344, #305).
|
||||
if (freshGroups.length > 0
|
||||
|| (cacheDirty && fs.existsSync(path.join(projectCwd, '.impeccable')))) {
|
||||
persistCache(projectCwd, cache);
|
||||
}
|
||||
|
||||
if (freshGroups.length > 0) {
|
||||
const firstGroup = freshGroups[0];
|
||||
@@ -1548,7 +1681,7 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now =
|
||||
return result({ emitted: false, quiet: true, durationMs: Date.now() - started });
|
||||
}
|
||||
|
||||
if (pendingWinner && shouldEmitAckForFile(pendingWinner.filePath)) {
|
||||
if (pendingWinner && shouldEmitAckForFile(pendingWinner.filePath, config)) {
|
||||
const text = appendDesignSystemNote(renderPendingAck(pendingWinner.filePath, pendingWinner.known, { cwd: projectCwd }), scanOptions);
|
||||
return {
|
||||
exitCode: 0,
|
||||
@@ -1582,7 +1715,7 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now =
|
||||
};
|
||||
}
|
||||
|
||||
if (cleanWinner && shouldEmitAckForFile(cleanWinner.filePath)) {
|
||||
if (cleanWinner && shouldEmitAckForFile(cleanWinner.filePath, config)) {
|
||||
const text = appendDesignSystemNote(renderCleanAck(cleanWinner.filePath, { cwd: projectCwd }), scanOptions);
|
||||
return {
|
||||
exitCode: 0,
|
||||
|
||||
@@ -459,11 +459,13 @@ export function filterDetectionFindings(findings, config) {
|
||||
function isIgnoredFindingValue(finding, ignoreValues) {
|
||||
if (!Array.isArray(ignoreValues) || ignoreValues.length === 0) return false;
|
||||
const rule = normalizeIgnoreRule(finding.antipattern);
|
||||
if (!rule) return false;
|
||||
// File-scoped wildcards suppress rules with no extractable value, such as side-tab.
|
||||
const value = extractFindingIgnoreValue(finding);
|
||||
if (!rule || !value) return false;
|
||||
return ignoreValues.some((entry) => {
|
||||
if (entry.rule !== rule) return false;
|
||||
const wildcardValue = entry.value === '*';
|
||||
if (entry.rule !== rule || (!wildcardValue && !ignoreValueMatches(rule, entry.value, value))) return false;
|
||||
if (!wildcardValue && (!value || !ignoreValueMatches(rule, entry.value, value))) return false;
|
||||
if (!Array.isArray(entry.files) || entry.files.length === 0) return !wildcardValue;
|
||||
return findingMatchesScopedIgnoreFile(finding, entry.files);
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { resolveProjectRoot } from '../context.mjs';
|
||||
export { IMPECCABLE_COMMAND_PREFIX } from './provider.mjs';
|
||||
|
||||
export const IMPECCABLE_DIR = '.impeccable';
|
||||
export const LIVE_DIR = 'live';
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
// Source scripts default to slash commands. The provider build replaces only
|
||||
// this exact declaration, avoiding heuristic rewrites across executable code.
|
||||
export const IMPECCABLE_COMMAND_PREFIX = "/";
|
||||
export const IMPECCABLE_COMMAND = `${IMPECCABLE_COMMAND_PREFIX}impeccable`;
|
||||
@@ -57,6 +57,7 @@
|
||||
const Z = { highlight: 100001, bar: 100005, picker: 100007, toast: 100010 };
|
||||
const EASE = 'cubic-bezier(0.22, 1, 0.36, 1)'; // ease-out-quint
|
||||
const PREFIX = 'impeccable-live';
|
||||
const IMPECCABLE_COMMAND = (window.__IMPECCABLE_COMMAND_PREFIX__ || '/') + 'impeccable';
|
||||
const PICK_CURSOR_STYLE_ID = PREFIX + '-pick-cursor-style';
|
||||
const MANUAL_APPLY_STATE_TTL_MS = 15 * 60 * 1000;
|
||||
const sessionState = window.__IMPECCABLE_LIVE_SESSION__?.createLiveBrowserSessionState({
|
||||
@@ -153,6 +154,7 @@
|
||||
let scrollLockRaf = null;
|
||||
let scrollLockAbort = null;
|
||||
const SCROLL_ANCHOR_LOCK_ID = 'impeccable-scroll-anchor-lock';
|
||||
const VARIANT_STATE_STYLE_ID = 'impeccable-variant-state';
|
||||
|
||||
// Dedicated key for scroll position - SEPARATE from LS_KEY so that
|
||||
// saveSession's state updates don't clobber a carefully-captured scrollY.
|
||||
@@ -3035,16 +3037,26 @@
|
||||
function applyParamValue(variantEl, param, value) {
|
||||
if (!variantEl) return;
|
||||
const attr = 'data-p-' + param.id;
|
||||
if (param.kind === 'range') {
|
||||
variantEl.style.setProperty('--p-' + param.id, String(value));
|
||||
} else if (param.kind === 'toggle') {
|
||||
if (param.kind === 'toggle') {
|
||||
const on = !!value;
|
||||
variantEl.style.setProperty('--p-' + param.id, on ? '1' : '0');
|
||||
if (on) variantEl.setAttribute(attr, 'on');
|
||||
else variantEl.removeAttribute(attr);
|
||||
} else if (param.kind === 'steps') {
|
||||
variantEl.setAttribute(attr, String(value));
|
||||
}
|
||||
// Svelte component variants are client-mounted into
|
||||
// [data-impeccable-component-mount] with no [data-impeccable-variant="N"]
|
||||
// wrapper for the state stylesheet to target, and the element is not SSR'd,
|
||||
// so there is no React hydration to mismatch. Drive range/toggle --p-* inline
|
||||
// on the mounted element so scoped preview CSS resolves them.
|
||||
if (svelteComponentSession?.sessionId === currentSessionId) {
|
||||
if (param.kind === 'range') variantEl.style.setProperty('--p-' + param.id, String(value));
|
||||
else if (param.kind === 'toggle') variantEl.style.setProperty('--p-' + param.id, value ? '1' : '0');
|
||||
return;
|
||||
}
|
||||
// range/toggle --p-* custom properties are driven through the injected
|
||||
// variant-state stylesheet so we never mutate inline style on SSR'd divs.
|
||||
updateVariantStateStylesheet(currentSessionId, visibleVariant);
|
||||
}
|
||||
|
||||
function applyParamDefaults(variantEl, params) {
|
||||
@@ -4714,6 +4726,7 @@
|
||||
paramsCurrentValues = {};
|
||||
tuneOpen = false;
|
||||
hideParamsPanel();
|
||||
if (currentSessionId && visibleVariant) updateVariantStateStylesheet(currentSessionId, visibleVariant);
|
||||
return;
|
||||
}
|
||||
applyParamDefaults(variantEl, params);
|
||||
@@ -4771,20 +4784,7 @@
|
||||
|
||||
function isVariantShown(el) {
|
||||
if (!el) return false;
|
||||
if (el.hidden) return false;
|
||||
if (el.style?.display === 'none') return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function setVariantShown(el, shown) {
|
||||
if (!el) return;
|
||||
if (shown) {
|
||||
el.removeAttribute('hidden');
|
||||
el.style.display = '';
|
||||
} else {
|
||||
el.setAttribute('hidden', '');
|
||||
el.style.display = 'none';
|
||||
}
|
||||
return getComputedStyle(el).display !== 'none';
|
||||
}
|
||||
|
||||
function scheduleCyclingBarSync(sessionId, variantNum) {
|
||||
@@ -4823,11 +4823,7 @@
|
||||
}
|
||||
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||||
if (!wrapper) return false;
|
||||
for (const child of wrapper.children) {
|
||||
const v = child.dataset ? child.dataset.impeccableVariant : null;
|
||||
if (!v) continue;
|
||||
setVariantShown(child, v === String(num));
|
||||
}
|
||||
updateVariantStateStylesheet(sessionId, num);
|
||||
// Unconditional refresh - covers first-reveal (no-op if state isn't
|
||||
// CYCLING yet, the subsequent CYCLING transition triggers its own
|
||||
// refresh) and every cycle step.
|
||||
@@ -5492,6 +5488,7 @@
|
||||
if (pendingSvelteComponentRetryObserver) { pendingSvelteComponentRetryObserver.disconnect(); pendingSvelteComponentRetryObserver = null; }
|
||||
if (pendingVariantAnchorRetryObserver) { pendingVariantAnchorRetryObserver.disconnect(); pendingVariantAnchorRetryObserver = null; }
|
||||
stopScrollLock();
|
||||
removeVariantStateStylesheet();
|
||||
clearSession();
|
||||
clearHandled();
|
||||
resetSessionFileMeta();
|
||||
@@ -5806,6 +5803,68 @@
|
||||
return variantDiv;
|
||||
}
|
||||
|
||||
// Variant visibility and range/toggle params are expressed through ONE
|
||||
// injected stylesheet, never inline attributes on the variant divs. Those
|
||||
// divs are scaffolded into page source, so SSR frameworks (Next.js App
|
||||
// Router) server-render them; toggling their `hidden` / inline `style` /
|
||||
// `--p-*` client-side trips a React 19 hydration mismatch on the next
|
||||
// Fast-Refresh re-render — the same failure mode the scroll-anchor (#276)
|
||||
// and pick-cursor (#286) fixes address. A stylesheet rule has the same
|
||||
// computed effect without mutating any hydrated element's attributes.
|
||||
// (steps params keep driving `data-p-*` attributes, matching scoped CSS.)
|
||||
const VARIANT_HIDE_DECL = 'display: none !important;';
|
||||
const VARIANT_SHOW_DECL = 'display: block !important;';
|
||||
|
||||
// Build a direct-child variant selector for a session. With `num`, targets a
|
||||
// single variant (`… > [data-impeccable-variant="N"]`); without it, targets
|
||||
// every variant via the bare `[data-impeccable-variant]` attribute.
|
||||
function variantStateSelector(sessionId, num) {
|
||||
const wrapper = '[data-impeccable-variants="' + sessionId + '"]';
|
||||
const variant = num == null
|
||||
? '[data-impeccable-variant]'
|
||||
: '[data-impeccable-variant="' + num + '"]';
|
||||
return wrapper + ' > ' + variant;
|
||||
}
|
||||
|
||||
// Serialize the visible variant's knob values into `--p-<id>` custom-property
|
||||
// declarations. Only range (number) and toggle (boolean) values become a
|
||||
// custom property; steps params drive `data-p-*` attributes instead.
|
||||
function variantParamDecls(values) {
|
||||
return Object.entries(values || {})
|
||||
.map(([id, val]) => {
|
||||
if (typeof val === 'number') return ' --p-' + id + ': ' + val + ';';
|
||||
if (typeof val === 'boolean') return ' --p-' + id + ': ' + (val ? '1' : '0') + ';';
|
||||
return '';
|
||||
})
|
||||
.join('');
|
||||
}
|
||||
|
||||
function updateVariantStateStylesheet(sessionId, num) {
|
||||
if (!sessionId || num == null || num < 1) return;
|
||||
|
||||
let styleEl = document.getElementById(VARIANT_STATE_STYLE_ID);
|
||||
if (!styleEl) {
|
||||
styleEl = document.createElement('style');
|
||||
styleEl.id = VARIANT_STATE_STYLE_ID;
|
||||
(document.head || document.documentElement).appendChild(styleEl);
|
||||
}
|
||||
|
||||
// Hide every variant except the visible one (incl. the SSR'd "original").
|
||||
const hideOthers = variantStateSelector(sessionId)
|
||||
+ ':not([data-impeccable-variant="' + num + '"]) { ' + VARIANT_HIDE_DECL + ' }';
|
||||
|
||||
// Force-show the visible variant (beats the source inline display:none on
|
||||
// v2/v3) and apply its knob values as custom properties.
|
||||
const showVisible = variantStateSelector(sessionId, num)
|
||||
+ ' { ' + VARIANT_SHOW_DECL + variantParamDecls(paramsCurrentValues) + ' }';
|
||||
|
||||
styleEl.textContent = hideOthers + '\n' + showVisible + '\n';
|
||||
}
|
||||
|
||||
function removeVariantStateStylesheet() {
|
||||
document.getElementById(VARIANT_STATE_STYLE_ID)?.remove();
|
||||
}
|
||||
|
||||
// Hold window.scrollY at a fixed value across DOM mutations inside the
|
||||
// session's wrapper (HMR patches, variant inserts, cycle swaps).
|
||||
function startScrollLock(sessionId, initialTargetY) {
|
||||
@@ -6087,7 +6146,7 @@
|
||||
switch (msg.type) {
|
||||
case 'connected':
|
||||
hasProjectContext = !!msg.hasProjectContext;
|
||||
if (!hasProjectContext) showToast('No PRODUCT.md found. Variants will be brand-agnostic. Run /impeccable init to generate one.', 7000);
|
||||
if (!hasProjectContext) showToast(`No PRODUCT.md found. Variants will be brand-agnostic. Run ${IMPECCABLE_COMMAND} init to generate one.`, 7000);
|
||||
console.log('[impeccable] Live mode connected.');
|
||||
syncAgentPollingUi(!!msg.agentPolling);
|
||||
startAgentStatusPoll();
|
||||
@@ -7636,6 +7695,7 @@ void main() {
|
||||
stopScrollTracking();
|
||||
if (variantObserver) { variantObserver.disconnect(); variantObserver = null; }
|
||||
stopScrollLock();
|
||||
removeVariantStateStylesheet();
|
||||
clearScrollY();
|
||||
clearSession();
|
||||
resetSessionFileMeta();
|
||||
@@ -7897,6 +7957,7 @@ void main() {
|
||||
if (variantObserver) { variantObserver.disconnect(); variantObserver = null; }
|
||||
if (pendingVariantAnchorRetryObserver) { pendingVariantAnchorRetryObserver.disconnect(); pendingVariantAnchorRetryObserver = null; }
|
||||
stopScrollLock();
|
||||
removeVariantStateStylesheet();
|
||||
clearScrollY();
|
||||
finalizeInsertSession();
|
||||
clearSession();
|
||||
@@ -7928,7 +7989,7 @@ void main() {
|
||||
const barTopFromBottom = barRect && barRect.height > 0
|
||||
? Math.max(16, window.innerHeight - barRect.top + 12)
|
||||
: 16;
|
||||
toastEl = el('div', {
|
||||
const currentToast = el('div', {
|
||||
position: 'fixed', bottom: barTopFromBottom + 'px', left: '50%',
|
||||
transform: 'translateX(-50%) translateY(8px)',
|
||||
background: C.ink, color: C.white,
|
||||
@@ -7938,19 +7999,24 @@ void main() {
|
||||
transition: 'opacity 0.25s ' + EASE + ', transform 0.25s ' + EASE,
|
||||
pointerEvents: 'none', maxWidth: '420px', textAlign: 'center',
|
||||
});
|
||||
toastEl.id = PREFIX + '-toast';
|
||||
toastEl.textContent = message;
|
||||
uiAppend(toastEl);
|
||||
toastEl = currentToast;
|
||||
currentToast.id = PREFIX + '-toast';
|
||||
currentToast.textContent = message;
|
||||
uiAppend(currentToast);
|
||||
requestAnimationFrame(() => {
|
||||
toastEl.style.opacity = '1';
|
||||
toastEl.style.transform = 'translateX(-50%) translateY(0)';
|
||||
if (toastEl !== currentToast) return;
|
||||
currentToast.style.opacity = '1';
|
||||
currentToast.style.transform = 'translateX(-50%) translateY(0)';
|
||||
});
|
||||
setTimeout(() => {
|
||||
if (toastEl) {
|
||||
toastEl.style.opacity = '0';
|
||||
toastEl.style.transform = 'translateX(-50%) translateY(8px)';
|
||||
setTimeout(() => { if (toastEl) { toastEl.remove(); toastEl = null; } }, 250);
|
||||
}
|
||||
if (toastEl !== currentToast) return;
|
||||
currentToast.style.opacity = '0';
|
||||
currentToast.style.transform = 'translateX(-50%) translateY(8px)';
|
||||
setTimeout(() => {
|
||||
if (toastEl !== currentToast) return;
|
||||
currentToast.remove();
|
||||
toastEl = null;
|
||||
}, 250);
|
||||
}, duration);
|
||||
}
|
||||
|
||||
@@ -9984,6 +10050,7 @@ void main() {
|
||||
window.postMessage({ source: 'impeccable-command', action: 'remove' }, '*');
|
||||
setLiveState('IDLE');
|
||||
document.getElementById(PICK_CURSOR_STYLE_ID)?.remove();
|
||||
removeVariantStateStylesheet();
|
||||
window.__IMPECCABLE_LIVE_INIT__ = false;
|
||||
console.log('[impeccable] Live mode exited.');
|
||||
}
|
||||
@@ -10472,7 +10539,7 @@ void main() {
|
||||
if (designState.present === false) {
|
||||
const empty = document.createElement('div');
|
||||
empty.className = 'empty';
|
||||
empty.innerHTML = `<strong>No DESIGN.md yet</strong>Create one by running <code>/impeccable document</code> in your terminal, then re-open this panel.`;
|
||||
empty.innerHTML = `<strong>No DESIGN.md yet</strong>Create one by running <code>${IMPECCABLE_COMMAND} document</code> in your terminal, then re-open this panel.`;
|
||||
body.appendChild(empty);
|
||||
return;
|
||||
}
|
||||
@@ -10502,7 +10569,7 @@ void main() {
|
||||
box.className = 'stale';
|
||||
box.innerHTML = `
|
||||
<span class="stale-dot"></span>
|
||||
<span class="stale-text"><strong>DESIGN.md is newer than .impeccable/design.json.</strong> Run <code>/impeccable document</code> to refresh the sidecar.</span>
|
||||
<span class="stale-text"><strong>DESIGN.md is newer than .impeccable/design.json.</strong> Run <code>${IMPECCABLE_COMMAND} document</code> to refresh the sidecar.</span>
|
||||
`;
|
||||
return box;
|
||||
}
|
||||
@@ -10510,7 +10577,7 @@ void main() {
|
||||
function renderParsedMdCta() {
|
||||
const box = document.createElement('div');
|
||||
box.className = 'parsed-md-cta';
|
||||
box.innerHTML = `<strong>Basic view</strong>This panel reads the tokens in your <code>DESIGN.md</code> frontmatter. Running <code>/impeccable document</code> also generates a <code>.impeccable/design.json</code> sidecar with your project's actual component snippets (button, input, nav) and tonal ramps, rendered live below the tokens.`;
|
||||
box.innerHTML = `<strong>Basic view</strong>This panel reads the tokens in your <code>DESIGN.md</code> frontmatter. Running <code>${IMPECCABLE_COMMAND} document</code> also generates a <code>.impeccable/design.json</code> sidecar with your project's actual component snippets (button, input, nav) and tonal ramps, rendered live below the tokens.`;
|
||||
return box;
|
||||
}
|
||||
|
||||
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
getDesignSidecarPath,
|
||||
getLiveDir,
|
||||
getLiveAnnotationsDir,
|
||||
IMPECCABLE_COMMAND_PREFIX,
|
||||
readLiveServerInfo,
|
||||
removeLiveServerInfo,
|
||||
resolveDesignSidecarPath,
|
||||
@@ -413,6 +414,7 @@ function createRequestHandler({ detectScript, liveScriptParts }) {
|
||||
token: state.token,
|
||||
port: state.port,
|
||||
vocabulary: LIVE_COMMANDS,
|
||||
commandPrefix: IMPECCABLE_COMMAND_PREFIX,
|
||||
parts,
|
||||
});
|
||||
res.writeHead(200, {
|
||||
|
||||
@@ -32,10 +32,11 @@ export function readLiveBrowserScriptParts(parts, readFile = (filePath) => fs.re
|
||||
}));
|
||||
}
|
||||
|
||||
export function assembleLiveBrowserScript({ token, port, vocabulary, parts }) {
|
||||
export function assembleLiveBrowserScript({ token, port, vocabulary, commandPrefix = '/', parts }) {
|
||||
const prelude =
|
||||
`window.__IMPECCABLE_TOKEN__ = '${token}';\n` +
|
||||
`window.__IMPECCABLE_PORT__ = ${port};\n` +
|
||||
`window.__IMPECCABLE_COMMAND_PREFIX__ = ${JSON.stringify(commandPrefix)};\n` +
|
||||
// Canonical command vocabulary (values + labels + icons). live-browser.js
|
||||
// builds its action picker from this instead of an inline copy.
|
||||
`window.__IMPECCABLE_VOCAB__ = ${JSON.stringify(vocabulary)};\n`;
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
* node <scripts_path>/pin.mjs pin <command>
|
||||
* node <scripts_path>/pin.mjs unpin <command>
|
||||
*
|
||||
* `pin audit` creates a lightweight /audit skill that redirects to /impeccable audit.
|
||||
* `pin audit` creates a lightweight audit skill that redirects to Impeccable's audit workflow.
|
||||
* `unpin audit` removes that shortcut.
|
||||
*
|
||||
* The script discovers harness directories (.claude/skills, .cursor/skills, etc.)
|
||||
@@ -14,7 +14,7 @@
|
||||
*/
|
||||
|
||||
import { existsSync, readFileSync, writeFileSync, mkdirSync, rmSync, readdirSync } from 'node:fs';
|
||||
import { join, resolve, dirname } from 'node:path';
|
||||
import { basename, join, resolve, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
@@ -25,6 +25,8 @@ const HARNESS_DIRS = [
|
||||
'.trae', '.trae-cn', '.pi', '.opencode', '.kiro', '.rovodev',
|
||||
];
|
||||
|
||||
const CODEX_HARNESSES = new Set(['.codex', '.agents']);
|
||||
|
||||
// Valid sub-command names
|
||||
const VALID_COMMANDS = [
|
||||
'craft', 'init', 'extract', 'document', 'shape',
|
||||
@@ -87,8 +89,12 @@ function loadCommandMetadata() {
|
||||
/**
|
||||
* Generate a pinned skill's SKILL.md content.
|
||||
*/
|
||||
function generatePinnedSkill(command, metadata) {
|
||||
const desc = metadata[command]?.description || `Shortcut for /impeccable ${command}.`;
|
||||
function commandPrefixForSkillsDir(skillsDir) {
|
||||
return CODEX_HARNESSES.has(basename(dirname(skillsDir))) ? '$' : '/';
|
||||
}
|
||||
|
||||
function generatePinnedSkill(command, metadata, commandPrefix) {
|
||||
const desc = metadata[command]?.description || `Shortcut for ${commandPrefix}impeccable ${command}.`;
|
||||
const hint = metadata[command]?.argumentHint || '[target]';
|
||||
|
||||
return `---
|
||||
@@ -100,9 +106,9 @@ user-invocable: true
|
||||
|
||||
${PIN_MARKER}
|
||||
|
||||
This is a pinned shortcut for \`{{command_prefix}}impeccable ${command}\`.
|
||||
This is a pinned shortcut for \`${commandPrefix}impeccable ${command}\`.
|
||||
|
||||
Invoke {{command_prefix}}impeccable ${command}, passing along any arguments provided here, and follow its instructions.
|
||||
Invoke ${commandPrefix}impeccable ${command}, passing along any arguments provided here, and follow its instructions.
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -118,10 +124,11 @@ function pin(command, projectRoot) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const content = generatePinnedSkill(command, metadata);
|
||||
let created = 0;
|
||||
|
||||
for (const skillsDir of harnessDirs) {
|
||||
const commandPrefix = commandPrefixForSkillsDir(skillsDir);
|
||||
const content = generatePinnedSkill(command, metadata, commandPrefix);
|
||||
// Check if skill already exists (and isn't a pin)
|
||||
const skillDir = join(skillsDir, command);
|
||||
if (existsSync(skillDir)) {
|
||||
@@ -143,7 +150,7 @@ function pin(command, projectRoot) {
|
||||
|
||||
if (created > 0) {
|
||||
console.log(`\nPinned '${command}' as a standalone shortcut in ${created} location(s).`);
|
||||
console.log(`You can now use /${command} directly.`);
|
||||
console.log('Use the pinned command directly in each harness.');
|
||||
}
|
||||
|
||||
return created > 0;
|
||||
@@ -177,7 +184,7 @@ function unpin(command, projectRoot) {
|
||||
|
||||
if (removed > 0) {
|
||||
console.log(`\nUnpinned '${command}' from ${removed} location(s).`);
|
||||
console.log(`Use /impeccable ${command} to access it.`);
|
||||
console.log(`Use Impeccable's '${command}' workflow directly to access it.`);
|
||||
} else {
|
||||
console.log(`No pinned '${command}' shortcut found.`);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: impeccable
|
||||
description: Use when the user wants to design, redesign, shape, critique, audit, polish, clarify, distill, harden, optimize, adapt, animate, colorize, extract, or otherwise improve a frontend interface. Covers websites, landing pages, dashboards, product UI, app shells, components, forms, settings, onboarding, and empty states. Handles UX review, visual hierarchy, information architecture, cognitive load, accessibility, performance, responsive behavior, theming, anti-patterns, typography, fonts, spacing, layout, alignment, color, motion, micro-interactions, UX copy, error states, edge cases, i18n, and reusable design systems or tokens. Also use for bland designs that need to become bolder or more delightful, loud designs that should become quieter, live browser iteration on UI elements, or ambitious visual effects that should feel technically extraordinary. Not for backend-only or non-UI tasks.
|
||||
version: 3.9.0
|
||||
version: 3.9.1
|
||||
---
|
||||
|
||||
Designs and iterates production-grade frontend interfaces. Real working code, committed design choices, exceptional craft.
|
||||
@@ -10,11 +10,12 @@ Designs and iterates production-grade frontend interfaces. Real working code, co
|
||||
|
||||
You MUST do these steps before proceeding:
|
||||
|
||||
1. Run `node .gemini/skills/impeccable/scripts/context.mjs` once per session. If the request names or implies a file, route, or app inside a monorepo, infer the concrete path and run `node .gemini/skills/impeccable/scripts/context.mjs --target <path>` instead. If you've already seen its output in this conversation, do not re-run it. The script either prints the project's PRODUCT.md (and DESIGN.md when present) as a markdown block, or tells you it's missing. Follow whatever it prints. **If it reports `NO_PRODUCT_MD`, stop and follow `reference/init.md` before doing anything else.** If the output ends with an `UPDATE_AVAILABLE` directive, follow it (ask the user once about updating, then continue). It never blocks the current task.
|
||||
2. If the user invoked a sub-command (`craft`, `shape`, `audit`, `polish`, ...), you MUST read `reference/<command>.md` next. Non-optional. The reference defines the command's flow; without it you will skip steps the user expects.
|
||||
1. Run `node .gemini/skills/impeccable/scripts/context.mjs` once per session; if the runtime shows this skill's loaded base directory, run `node <skill-base-dir>/scripts/context.mjs` instead. Keep cwd/workdir at the user's project, not the skill directory. If the request names or implies a file, route, or app inside a monorepo, infer the concrete path and append `--target <path>` to the same command. If you've already seen its output in this conversation, do not re-run it. The script either prints the project's PRODUCT.md (and DESIGN.md when present) as a markdown block, or tells you it's missing. Follow whatever it prints. **If it reports `NO_PRODUCT_MD`:** divert into `reference/init.md` first when the user invoked `init`, `teach`, `craft`, or `shape`, or when their wording clearly maps to one of those from-scratch build flows (for example: "build/create/make a landing page", "design a new app", or "shape a feature"). Captured product context is the point of those flows. For any other command, a scoped evaluate / refine / enhance / fix / iterate request against existing code, do **not** divert into init. The existing code is the context: proceed with the requested command, infer the register from the surface in focus (step 4), and offer `/impeccable init` once as a suggestion the user can take later. A missing PRODUCT.md must never block a scoped request. If the output ends with an `UPDATE_AVAILABLE` directive, follow it (ask the user once about updating, then continue). It never blocks the current task.
|
||||
2. If the user invoked a sub-command (`craft`, `shape`, `audit`, `polish`, ...), you MUST read the command's reference next: **`reference/<command>.md`, or the native variant from the Commands table** (e.g. `reference/audit.native.md`) **when the project platform is native** (`ios` / `android` / `adaptive`, per the `context.mjs` directive). One file, not both. Non-optional. The reference defines the command's flow; without it you will skip steps the user expects.
|
||||
3. Familiarize yourself with any existing design system, conventions, and components in the code. Read at least one project file (CSS / tokens / theme / a representative component or page). **Required even when you've loaded a sub-command reference in step 2.** Don't reinvent the wheel; use what's there when it works, branch out when the UX wins.
|
||||
4. Read the matching register reference. **This is non-optional; skipping it produces generic output.** If the project is marketing, a landing page, a campaign, long-form content, or a portfolio (design IS the product), read `reference/brand.md`. If it is app UI, admin, a dashboard, or a tool (design SERVES the product), read `reference/product.md`. Pick by first match: (1) task cue ("landing page" vs "dashboard"); (2) surface in focus (the page, file, or route being worked on); (3) `register` field in PRODUCT.md.
|
||||
5. **If the project is brand-new (no existing CSS tokens / theme / committed brand colors found in step 3)**, run `node .gemini/skills/impeccable/scripts/palette.mjs` to receive a brand seed color and composition guidance. This is the anchor for your primary brand color. Compose the rest of the palette (bg, surface, ink, accent, muted) around it per the script's instructions. Use OKLCH throughout. **Skip this step only if step 3 found committed brand colors in existing tokens; in that case identity-preservation wins.**
|
||||
5. **If PRODUCT.md's `## Platform` is `ios` or `android`**, also read `reference/<platform>.md` (HIG / Material 3 conventions). `adaptive` (cross-platform, ships both) reads both files. `web`, absent, or unrecognized: nothing extra to read. `context.mjs` prints the directive when one applies.
|
||||
6. **If the project is brand-new (no existing CSS tokens / theme / committed brand colors found in step 3)**, run `node .gemini/skills/impeccable/scripts/palette.mjs` to receive a brand seed color and composition guidance. This is the anchor for your primary brand color. Compose the rest of the palette (bg, surface, ink, accent, muted) around it per the script's instructions. Use OKLCH throughout. **Skip this step only if step 3 found committed brand colors in existing tokens; in that case identity-preservation wins.**
|
||||
|
||||
## Design guidance
|
||||
|
||||
@@ -105,7 +106,7 @@ If someone could look at this interface and say "AI made that" without doubt, it
|
||||
| `document` | Build | Generate DESIGN.md from existing project code | [reference/document.md](reference/document.md) |
|
||||
| `extract [target]` | Build | Pull reusable tokens and components into design system | [reference/extract.md](reference/extract.md) |
|
||||
| `critique [target]` | Evaluate | UX design review with heuristic scoring | [reference/critique.md](reference/critique.md) |
|
||||
| `audit [target]` | Evaluate | Technical quality checks (a11y, perf, responsive) | [reference/audit.md](reference/audit.md) |
|
||||
| `audit [target]` | Evaluate | Technical quality checks (a11y, perf, responsive) | [reference/audit.md](reference/audit.md) · native: [reference/audit.native.md](reference/audit.native.md) |
|
||||
| `polish [target]` | Refine | Final quality pass before shipping | [reference/polish.md](reference/polish.md) |
|
||||
| `bolder [target]` | Refine | Amplify safe or bland designs | [reference/bolder.md](reference/bolder.md) |
|
||||
| `quieter [target]` | Refine | Tone down aggressive or overstimulating designs | [reference/quieter.md](reference/quieter.md) |
|
||||
@@ -119,7 +120,7 @@ If someone could look at this interface and say "AI made that" without doubt, it
|
||||
| `delight [target]` | Enhance | Add personality and memorable touches | [reference/delight.md](reference/delight.md) |
|
||||
| `overdrive [target]` | Enhance | Push past conventional limits | [reference/overdrive.md](reference/overdrive.md) |
|
||||
| `clarify [target]` | Fix | Improve UX copy, labels, and error messages | [reference/clarify.md](reference/clarify.md) |
|
||||
| `adapt [target]` | Fix | Adapt for different devices and screen sizes | [reference/adapt.md](reference/adapt.md) |
|
||||
| `adapt [target]` | Fix | Adapt for different devices and screen sizes | [reference/adapt.md](reference/adapt.md) · native: [reference/adapt.native.md](reference/adapt.native.md) |
|
||||
| `optimize [target]` | Fix | Diagnose and fix UI performance | [reference/optimize.md](reference/optimize.md) |
|
||||
| `live` | Iterate | Visual variant mode: pick elements in the browser, generate alternatives | [reference/live.md](reference/live.md) |
|
||||
|
||||
@@ -127,26 +128,26 @@ Plus three management commands: `pin <command>`, `unpin <command>`, and `hooks <
|
||||
|
||||
### Routing rules
|
||||
|
||||
1. **No argument**: the user is asking "what should I do?" Make the menu context-aware instead of static. Setup has already run `context.mjs`; if that reported `NO_PRODUCT_MD` you are already in init (setup), so finish that and skip this. Otherwise run `node .gemini/skills/impeccable/scripts/context-signals.mjs` once and read its JSON, then lead with the **2-3 highest-value next commands**, each with a one-line reason pulled from the signals, followed by the full menu (the table above, grouped by category). **Never auto-run a command; the recommendation is a suggestion the user confirms.**
|
||||
1. **No argument**: the user is asking "what should I do?" Make the menu context-aware instead of static. Setup has already run `context.mjs`; if that reported `NO_PRODUCT_MD` the project has no captured context yet, so lead the menu with `/impeccable init` as the top recommendation (one line on why) and still show the rest below; don't silently jump into init. Otherwise run `node .gemini/skills/impeccable/scripts/context-signals.mjs` once and read its JSON, then lead with the **2-3 highest-value next commands**, each with a one-line reason pulled from the signals, followed by the full menu (the table above, grouped by category). **Never auto-run a command; the recommendation is a suggestion the user confirms.**
|
||||
|
||||
Reason over the signals; there is no score to obey:
|
||||
- `setup.hasDesign` false while `setup.hasCode` true → `document` (capture the visual system).
|
||||
- `critique.latest` is `null` → the project has never been critiqued; for a set-up project with a real surface, offering `/impeccable critique <surface>` is a strong default.
|
||||
- `critique.latest` with a low `score` or non-zero `p0` / `p1` → `polish` (it reads that snapshot as its backlog), or re-run `critique` if the snapshot looks stale.
|
||||
- `git.changedFiles` pointing at one surface → scope `audit` or `polish` to those files specifically, naming them.
|
||||
- `devServer.running` true → `live` is available for in-browser iteration; if false, don't lead with `live`.
|
||||
- `devServer.running` true → `live` is available for in-browser iteration; if false, don't lead with `live`. **`live` and the bundled `detect.mjs` are web-only.** If `setup.platform` is `ios`, `android`, or `adaptive`, don't lead with either; the browser overlay and the HTML rule engine don't apply to native app code.
|
||||
- Otherwise group by intent exactly as init's "Recommend starting points" step does (build new / improve what's there / iterate visually), tailored to `setup.register`.
|
||||
|
||||
**If `scan.targets` is non-empty, run `node .gemini/skills/impeccable/scripts/detect.mjs --json <scan.targets joined by spaces>` once** (the bundled detector over local files: no network, no npx). `scan.via` tells you what they are: `git-changes` (the markup/style files in your dirty tree, the most relevant set), `source-dir` (e.g. `src`, `app`), `html`, or `root`. Fold the hits into your picks: many quality / contrast hits → `audit` or `polish`; a specific slop family → the matching command (gradient text or eyebrows → `quieter` / `typeset`, flat or gray palette → `colorize`, and so on). It's a real, current signal that beats guessing. If detect errors or the tree is large and slow, skip it and recommend the user run `audit` themselves; never block the suggestion on it.
|
||||
**If `scan.targets` is non-empty and `setup.platform` is not `ios`/`android`/`adaptive`, run `node .gemini/skills/impeccable/scripts/detect.mjs --json <scan.targets joined by spaces>` once** (the bundled detector over local files: no network, no npx; it reads HTML/CSS, so skip it for native projects). `scan.via` tells you what they are: `git-changes` (the markup/style files in your dirty tree, the most relevant set), `source-dir` (e.g. `src`, `app`), `html`, or `root`. Fold the hits into your picks: many quality / contrast hits → `audit` or `polish`; a specific slop family → the matching command (gradient text or eyebrows → `quieter` / `typeset`, flat or gray palette → `colorize`, and so on). It's a real, current signal that beats guessing. If detect errors or the tree is large and slow, skip it and recommend the user run `audit` themselves; never block the suggestion on it.
|
||||
|
||||
Keep it to 2-3 pointed picks with the exact command to type. The menu stays the fallback; the recommendation is the lede.
|
||||
2. **First word matches a command** (table above OR `pin` / `unpin` / `hooks`): load its reference file and follow its instructions. Everything after the command name is the target.
|
||||
3. **First word doesn't match, but the intent clearly maps to one command** (e.g. "fix the spacing" → `layout`, "rewrite this error message" → `clarify`, "the colors feel flat" → `colorize`): load that command's reference and proceed as if invoked. If two commands could fit, ask once which.
|
||||
2. **First word matches a command** (table above OR `pin` / `unpin` / `hooks`): load its reference file (on native platforms, the table's native variant; Setup step 2's one-file rule) and follow its instructions. Everything after the command name is the target.
|
||||
3. **First word doesn't match, but the intent clearly maps to one command** (e.g. "fix the spacing" → `layout`, "rewrite this error message" → `clarify`, "the colors feel flat" → `colorize`): load that command's reference (same native-variant rule) and proceed as if invoked. If two commands could fit, ask once which.
|
||||
4. **No clear command match**: general design invocation. Apply the setup steps, the General rules, and the loaded register reference, using the full argument as context.
|
||||
|
||||
Setup (context gathering, register) is already loaded by then; sub-commands don't re-invoke `/impeccable`.
|
||||
|
||||
If the first word is `craft`, setup still runs first, but [reference/craft.md](reference/craft.md) owns the rest of the flow. If setup invokes `init` as a blocker, finish init, refresh context, then resume the original command and target.
|
||||
If the first word is `craft` or `shape`, or routing rule 3 clearly maps the user's intent to either command, setup still runs first, but the matching reference ([reference/craft.md](reference/craft.md) or [reference/shape.md](reference/shape.md)) owns the rest of the flow. Both are from-scratch build flows: if setup invokes `init` as a blocker, finish init, refresh context, then resume the original command and target.
|
||||
|
||||
`teach` is a deprecated alias for `init`: if the user types it, load [reference/init.md](reference/init.md) and proceed as if they ran `init`.
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
Adapt an existing design to a different context: another screen size, device, platform, or use case. The trap is treating adaptation as scaling. The job is rethinking the experience for the new context.
|
||||
|
||||
**Web only** (mobile web included). Native platforms (`ios` / `android` / `adaptive`) route to [adapt.native.md](adapt.native.md) instead; if the project is native, switch to it now.
|
||||
|
||||
---
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user