mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-11 21:57:14 +03:00
Compare commits
124
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1098879103 | ||
|
|
a3d7009e33 | ||
|
|
e0afd4fcea | ||
|
|
3600edc5e9 | ||
|
|
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 | ||
|
|
e83e437cdd | ||
|
|
f604d31d54 | ||
|
|
f5c1bd65ae | ||
|
|
5844c40177 | ||
|
|
9dc97ce648 | ||
|
|
b3108c1697 | ||
|
|
4ac0348032 | ||
|
|
7f0262f809 | ||
|
|
1a3f5d78bd | ||
|
|
c979ac37c3 | ||
|
|
bcd16381cf | ||
|
|
19e0174da2 | ||
|
|
88227f7935 | ||
|
|
3590bf9e37 | ||
|
|
616820dcff | ||
|
|
a4ff58ef51 | ||
|
|
da2cda06ed | ||
|
|
467efe4632 | ||
|
|
2520317f94 | ||
|
|
b7d2ad5589 | ||
|
|
d2ab4ddee6 | ||
|
|
a031d5de92 | ||
|
|
867fab2188 | ||
|
|
609bbfbd5b | ||
|
|
da18929df0 | ||
|
|
a110ec5ed7 | ||
|
|
8eedb150c5 | ||
|
|
55d11fb2ad | ||
|
|
776c019041 | ||
|
|
68a15b6be4 | ||
|
|
42be79eab5 | ||
|
|
1e4e74793a | ||
|
|
0306b41949 | ||
|
|
f1e9b3df3a | ||
|
|
2f9dc05978 | ||
|
|
221064858e | ||
|
|
41ff946121 | ||
|
|
793feda5a0 | ||
|
|
c0d50e36da | ||
|
|
67e8757401 | ||
|
|
1fd1eb11bc | ||
|
|
a42d4a7060 | ||
|
|
a1560fb0f5 | ||
|
|
d5403f9d65 | ||
|
|
1f4021b16c | ||
|
|
046a8593f5 | ||
|
|
e371c99f08 | ||
|
|
d949abd180 | ||
|
|
07667ed08f | ||
|
|
1c897a09c8 | ||
|
|
617b3a6e5e | ||
|
|
c7539c867d | ||
|
|
4f50db2bca | ||
|
|
f726894373 | ||
|
|
99a284a0d9 | ||
|
|
b86f2cc353 | ||
|
|
8b0c895703 | ||
|
|
1268f10b76 | ||
|
|
795e8ed5e5 |
@@ -1,6 +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.1
|
||||
---
|
||||
|
||||
Designs and iterates production-grade frontend interfaces. Real working code, committed design choices, exceptional craft.
|
||||
@@ -9,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 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
|
||||
|
||||
@@ -92,6 +94,7 @@ Match-and-refuse. If you're about to write any of these, rewrite the element wit
|
||||
- **`border-radius: 32px+` on cards / sections / inputs.** You over-round. Cards top out at 12–16px; full-pill is fine for tags/buttons. Picking 24/28/32/40px on a card is the codex tell; no brand wants "insanely rounded".
|
||||
- **Hand-drawn / sketchy SVG illustrations.** Class names like `loose-sketch`, `*-sketch`, `doodle`, `wavy`; `feTurbulence` / `feDisplacementMap` "paper grain" filters; 5-to-30 path crude scenes meant to depict a tangible subject (an otter, a table-and-fork, an album cover). All of these read as amateurish, not whimsical. If you can't render the scene with real assets, ship no illustration. Don't attempt sketchy SVG as a fallback.
|
||||
- **`repeating-linear-gradient(...)` stripe backgrounds.** Diagonal stripes in `body:before` or section backgrounds are pure codex decoration. Don't.
|
||||
- **Decorative grid backgrounds.** Two-axis CSS grid overlays built from `linear-gradient(... 1px, transparent 1px)` plus `background-size` are a Codex tell unless the surface is an actual canvas, map, blueprint, or measurement tool. Use product structure, real artifacts, or a plain surface instead.
|
||||
- **Meta-criticism copy.** Naming a concept then layering an ironic modifier, or staging a strawman to "correct" it. Make the specific claim instead.
|
||||
|
||||
### The AI slop test
|
||||
@@ -113,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) |
|
||||
@@ -127,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) |
|
||||
|
||||
@@ -135,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
|
||||
@@ -1,12 +1,12 @@
|
||||
When asked for "bolder," AI defaults to the same tired tricks: cyan/purple gradients, glassmorphism, neon accents on dark backgrounds, gradient text on metrics. These are the opposite of bold. Reject them first, then increase visual impact and personality through stronger hierarchy, committed scale, and decisive type.
|
||||
When asked for "bolder," AI defaults to the same tired tricks: cyan/purple gradients, glassmorphism, neon accents on dark backgrounds, gradient text on metrics. These are the opposite of bold. Reject them first, then increase visual impact by making the existing design language more decisive, specific, and committed.
|
||||
|
||||
---
|
||||
|
||||
## Register
|
||||
|
||||
Brand: "bolder" means distinctive. Extreme scale, unexpected color, typographic risk, committed POV.
|
||||
Brand: "bolder" means distinctive. Express a stronger point of view through hierarchy, pacing, proportion, copy, evidence, and one committed visual idea.
|
||||
|
||||
Product: "bolder" rarely means theatrics; those undermine trust. It means stronger hierarchy, clearer weight contrast, one sharper accent, more committed density. The amplification is in clarity, not drama.
|
||||
Product: "bolder" rarely means theatrics; those undermine trust. It means stronger hierarchy, clearer weight contrast, sharper information density, and more decisive prioritization. The amplification is in clarity, not drama.
|
||||
|
||||
---
|
||||
|
||||
@@ -15,98 +15,105 @@ Product: "bolder" rarely means theatrics; those undermine trust. It means strong
|
||||
Analyze what makes the design feel too safe or boring:
|
||||
|
||||
1. **Identify weakness sources**:
|
||||
- **Generic choices**: System fonts, basic colors, standard layouts
|
||||
- **Timid scale**: Everything is medium-sized with no drama
|
||||
- **Low contrast**: Everything has similar visual weight
|
||||
- **Static**: No motion, no energy, no life
|
||||
- **Predictable**: Standard patterns with no surprises
|
||||
- **Flat hierarchy**: Nothing stands out or commands attention
|
||||
- **Generic choices**: The page could belong to any product in the category.
|
||||
- **Timid scale**: Everything is medium-sized with no clear lead.
|
||||
- **Low contrast**: Important and supporting elements have similar visual weight.
|
||||
- **Static**: The surface has no meaningful moment of emphasis.
|
||||
- **Predictable**: The composition follows a default pattern without a point of view.
|
||||
- **Flat hierarchy**: Nothing stands out or commands attention.
|
||||
|
||||
2. **Understand the context**:
|
||||
- What's the brand personality? (How far can we push?)
|
||||
- What's the purpose? (Marketing can be bolder than financial dashboards)
|
||||
- Who's the audience? (What will resonate?)
|
||||
- What are the constraints? (Brand guidelines, accessibility, performance)
|
||||
- What is the brand personality?
|
||||
- What is the purpose of this surface?
|
||||
- Who is the audience?
|
||||
- What design system, tokens, components, and visual conventions already exist?
|
||||
|
||||
If any of these are unclear 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.
|
||||
|
||||
**CRITICAL**: "Bolder" doesn't mean chaotic or garish. It means distinctive, memorable, and confident. Think intentional drama, not random chaos.
|
||||
**CRITICAL**: "Bolder" does not mean chaotic or garish. It means distinctive, memorable, and confident. Think intentional drama, not random noise.
|
||||
|
||||
**WARNING - AI SLOP TRAP**: Review ALL the DON'T guidelines from the parent impeccable skill (already loaded in this context) before proceeding. Bold means distinctive, not "more effects."
|
||||
|
||||
## Design-System Lock
|
||||
|
||||
If the project has `DESIGN.md`, tokens, theme variables, or established component styles, treat that system as the boundary. Make the existing language stronger before adding new language.
|
||||
|
||||
Do not invent new colors, gradients, radii, shadows, fonts, decorative backgrounds, or effects just because the request says "bolder." A bolder pass should usually change emphasis, proportion, rhythm, density, contrast, copy, artifact specificity, and layout relationships while staying inside the documented system.
|
||||
|
||||
If the existing system is genuinely too limited to express the bolder direction, stop and ask the user before expanding it. Name the exact additions, the role each would play, and why the current system cannot do the job. If the user approves expansion, update the design system or tokens alongside the implementation.
|
||||
|
||||
## Plan Amplification
|
||||
|
||||
Create a strategy to increase impact while maintaining coherence:
|
||||
|
||||
- **Focal point**: What should be the hero moment? (Pick ONE, make it amazing)
|
||||
- **Personality direction**: Maximalist chaos? Elegant drama? Playful energy? Dark moody? Choose a lane.
|
||||
- **Risk budget**: How experimental can we be? Push boundaries within constraints.
|
||||
- **Hierarchy amplification**: Make big things BIGGER, small things smaller (increase contrast)
|
||||
- **Focal point**: Pick one thing the viewer should remember, then make the rest support it.
|
||||
- **System levers**: Identify which existing tokens, components, layout patterns, and copy structures can carry more weight.
|
||||
- **Risk budget**: Decide how far the surface can push while still feeling like the same product or brand.
|
||||
- **Hierarchy amplification**: Increase contrast between primary, secondary, and tertiary content instead of making every element louder.
|
||||
|
||||
**IMPORTANT**: Bold design must still be usable. Impact without function is just decoration.
|
||||
|
||||
## Amplify the Design
|
||||
|
||||
Systematically increase impact across these dimensions:
|
||||
Systematically increase impact through intention, not a menu of effects:
|
||||
|
||||
### Typography Amplification
|
||||
- **Replace generic fonts**: Swap system fonts for distinctive choices (see the parent skill's typography guidelines and the [Reference Material section of typeset.md](typeset.md#reference-material) for inspiration)
|
||||
- **Extreme scale**: Create dramatic size jumps (3x-5x differences, not 1.5x)
|
||||
- **Weight contrast**: Pair 900 weights with 200 weights, not 600 with 400
|
||||
- **Unexpected choices**: Variable fonts, display fonts for headlines, condensed/extended widths, monospace as intentional accent (not as lazy "dev tool" default)
|
||||
- Strengthen the existing type hierarchy before changing typefaces.
|
||||
- Make important text meaningfully more dominant, and make supporting text quieter.
|
||||
- Use weight, measure, spacing, and line breaks to sharpen the point of view.
|
||||
- Add or replace fonts only after user-approved design-system expansion.
|
||||
|
||||
### Color Intensification
|
||||
- **Increase saturation**: Shift to more vibrant, energetic colors (but not neon)
|
||||
- **Bold palette**: Introduce unexpected color combinations. Avoid the purple-blue gradient AI slop
|
||||
- **Dominant color strategy**: Let one bold color own 60% of the design
|
||||
- **Sharp accents**: High-contrast accent colors that pop
|
||||
- **Tinted neutrals**: Replace pure grays with tinted grays that harmonize with your palette
|
||||
- **Rich gradients**: Intentional multi-stop gradients (not generic purple-to-blue)
|
||||
### Color Amplification
|
||||
- Use the existing palette more decisively before adding colors.
|
||||
- Shift the proportion, placement, and contrast of documented colors to clarify meaning.
|
||||
- Treat any new color, gradient, or tint ramp as a design-system expansion that requires user approval.
|
||||
- Keep color tied to hierarchy, state, or brand meaning; do not use it as surface decoration.
|
||||
|
||||
### Spatial Drama
|
||||
- **Extreme scale jumps**: Make important elements 3-5x larger than surroundings
|
||||
- **Break the grid**: Let hero elements escape containers and cross boundaries
|
||||
- **Asymmetric layouts**: Replace centered, balanced layouts with tension-filled asymmetry
|
||||
- **Generous space**: Use white space dramatically (100-200px gaps, not 20-40px)
|
||||
- **Overlap**: Layer elements intentionally for depth
|
||||
### Spatial Amplification
|
||||
- Change proportion, density, alignment, and sequencing so the composition has a stronger point of view.
|
||||
- Create clearer contrast between dense evidence and open breathing room.
|
||||
- Let layout express priority and narrative order before adding ornament.
|
||||
- Preserve responsive behavior and avoid text overflow at every breakpoint.
|
||||
|
||||
### Visual Effects
|
||||
- **Dramatic shadows**: Large, soft shadows for elevation (but not generic drop shadows on rounded rectangles)
|
||||
- **Background treatments**: Mesh patterns, noise textures, geometric patterns, intentional gradients (not purple-to-blue)
|
||||
- **Texture & depth**: Grain, halftone, duotone, layered elements. NOT glassmorphism (it's overused AI slop)
|
||||
- **Borders & frames**: Thick borders, decorative frames, custom shapes (not rounded rectangles with colored border on one side)
|
||||
- **Custom elements**: Illustrative elements, custom icons, decorative details that reinforce brand
|
||||
### Surface Amplification
|
||||
- Use existing surface, border, radius, and shadow rules more deliberately.
|
||||
- Remove timid half-measures: either give an element a clear role or simplify it.
|
||||
- Add texture, depth, illustration, or decorative treatments only when already established by the system or explicitly approved.
|
||||
- Make real product artifacts, imagery, data, or copy carry attention before reaching for effects.
|
||||
|
||||
### Motion & Animation
|
||||
- **Hero moment**: One signature entrance, once. Not on every visit and not on every section.
|
||||
- **Micro-interactions**: Satisfying hover effects, click feedback, state changes.
|
||||
- **Transitions**: Smooth, noticeable transitions using ease-out-quart/quint/expo (not bounce or elastic, which cheapen the effect).
|
||||
- **Bolder ≠ scroll-fade-rise on every section.** That's the saturated AI default, the opposite of bold.
|
||||
- Design one meaningful moment of emphasis when motion genuinely supports the point.
|
||||
- Make interaction feedback feel more decisive without becoming distracting.
|
||||
- Keep transitions smooth and intentional.
|
||||
- **Bolder != scroll-fade-rise on every section.** That's the saturated AI default, the opposite of bold.
|
||||
|
||||
### Composition Boldness
|
||||
- **Hero moments**: Create clear focal points with dramatic treatment
|
||||
- **Diagonal flows**: Escape horizontal/vertical rigidity with diagonal arrangements
|
||||
- **Full-bleed elements**: Use full viewport width/height for impact
|
||||
- **Unexpected proportions**: Golden ratio? Throw it out. Try 70/30, 80/20 splits
|
||||
- Make the dominant idea unmistakable.
|
||||
- Use layout tension, sequencing, contrast, and restraint to create a stronger read.
|
||||
- Let the page's structure communicate priority before adding decorative layers.
|
||||
- If every element is louder, the composition is not bolder; it is flatter.
|
||||
|
||||
**NEVER**:
|
||||
- Add effects randomly without purpose (chaos ≠ bold)
|
||||
- Sacrifice readability for aesthetics (body text must be readable)
|
||||
- Make everything bold (then nothing is bold; you need contrast)
|
||||
- Ignore accessibility (bold design must still meet WCAG standards)
|
||||
- Overwhelm with motion (animation fatigue is real)
|
||||
- Copy trendy aesthetics blindly (bold means distinctive, not derivative)
|
||||
- Add undocumented design-system primitives without user approval
|
||||
- Add effects randomly without purpose
|
||||
- Hide weak hierarchy behind decoration
|
||||
- Sacrifice readability for aesthetics
|
||||
- Make everything bold; contrast is the point
|
||||
- Ignore accessibility
|
||||
- Overwhelm with motion
|
||||
- Copy trendy aesthetics blindly
|
||||
|
||||
## Verify Quality
|
||||
|
||||
Ensure amplification maintains usability and coherence:
|
||||
|
||||
- **System-faithful**: Did the pass make the existing design language stronger before adding anything new?
|
||||
- **No undocumented drift**: Are new colors, gradients, shadows, radii, fonts, and effects either absent or explicitly approved and documented?
|
||||
- **NOT AI slop**: Does this look like every other AI-generated "bold" design? If yes, start over.
|
||||
- **Still functional**: Can users accomplish tasks without distraction?
|
||||
- **Coherent**: Does everything feel intentional and unified?
|
||||
- **Memorable**: Will users remember this experience?
|
||||
- **Performant**: Do all these effects run smoothly?
|
||||
- **Accessible**: Does it still meet accessibility standards?
|
||||
- **Memorable**: Will users remember this experience for the intended reason?
|
||||
- **Performant and accessible**: Does the result stay fast, readable, responsive, and WCAG-conscious?
|
||||
|
||||
**The test**: If you showed this to someone and said "AI made this bolder," would they believe you immediately? If yes, you've failed. Bold means distinctive, not "more AI effects."
|
||||
|
||||
|
||||
@@ -5,8 +5,9 @@ Resolve one stable target, run two independent assessments, synthesize a design
|
||||
### Hard Invariants
|
||||
|
||||
- Assessment A (design review) and Assessment B (detector/browser evidence) are both required.
|
||||
- Assessment A and B MUST run as two isolated sub-agents whenever a sub-agent/Task tool is exposed. Running them inline in this context is "possible" but is NOT permitted; it is a degraded run. Inline is allowed ONLY when no sub-agent tool exists (or the user declined, on harnesses that ask).
|
||||
- If you degrade for any reason, the report's first line MUST be a banner: `⚠️ DEGRADED: single-context (<reason>)`. A silent degraded critique is a failed critique.
|
||||
- Assessment A must finish before detector findings enter the parent synthesis context. Detector output is deterministic, but it still anchors judgment.
|
||||
- If sub-agents are unavailable, fall back sequentially: finish and record Assessment A first, then run Assessment B, then synthesize.
|
||||
- A skipped detector is a failed critique run unless `detect.mjs` is missing or crashes after a real attempt.
|
||||
- Viewable targets require browser inspection when available.
|
||||
- Any local server started only for critique visualization must run in the background, have a recorded stop method, and be stopped before final reporting unless the user asks to keep it.
|
||||
@@ -27,14 +28,21 @@ Resolve one stable target, run two independent assessments, synthesize a design
|
||||
|
||||
### Assessment Orchestration
|
||||
|
||||
Delegate Assessment A and Assessment B to separate sub-agents when possible. They must not see each other's output. Do not show findings to the user until synthesis.
|
||||
Delegate Assessment A and Assessment B to separate sub-agents. They must not see each other's output. Do not show findings to the user until synthesis.
|
||||
|
||||
Codex sub-agent gate:
|
||||
Sub-agent gate (all harnesses):
|
||||
- Unless a harness-specific gate below overrides this, spawn A and B as two isolated, parallel sub-agents whenever a sub-agent/Task tool is exposed. This is the default and is mandatory; do not run them inline because it is faster.
|
||||
- "Unavailable" means exactly one thing: no sub-agent/Task tool is exposed in this session (or, on harnesses that ask, the user declined). It does not mean inconvenient.
|
||||
- If and only if sub-agents are unavailable, fall back sequentially: finish and record Assessment A, then run Assessment B, then synthesize, and emit the degraded banner.
|
||||
- Whichever path you take, declare it in the report header (see Report header provenance). Skipping sub-agents without the banner is the most common failure of this command.
|
||||
|
||||
Codex sub-agent gate (overrides the default above; Codex's permission model requires asking before spawning):
|
||||
- Asking is the normal path, not a degradation. Approving and spawning is the dual-agent path; do not emit the degraded banner just for asking.
|
||||
- If `spawn_agent` is exposed and the user explicitly allowed sub-agents, delegation, or parallel agent work, spawn A and B immediately.
|
||||
- If `spawn_agent` is exposed but the user did not explicitly allow sub-agents, ask exactly once: "Impeccable critique is designed to run two independent sub-agents for an unanchored assessment. May I use sub-agents for this critique?" Then stop until the user answers.
|
||||
- If allowed, spawn A and B. If declined, run sequentially and report `Assessment independence: degraded (sub-agents declined by user)`.
|
||||
- If `spawn_agent` is not exposed, do not ask; run sequentially and report `Assessment independence: degraded (spawn_agent unavailable in this session)`.
|
||||
- If spawning fails after permission, run sequentially and report `Assessment independence: degraded (sub-agent spawn failed: <exact error>)`.
|
||||
- If allowed, spawn A and B. If declined, run sequentially and lead the report with `⚠️ DEGRADED: single-context (sub-agents declined by user)`.
|
||||
- If `spawn_agent` is not exposed, do not ask; run sequentially and lead with `⚠️ DEGRADED: single-context (spawn_agent unavailable in this session)`.
|
||||
- If spawning fails after permission, run sequentially and lead with `⚠️ DEGRADED: single-context (sub-agent spawn failed: <exact error>)`.
|
||||
Prefer `fork_context: false` with self-contained prompts containing cwd, target, live URL, references, product context, and output contract. If using `fork_context: true`, omit `agent_type`, `model`, and `reasoning_effort`.
|
||||
|
||||
If browser automation is available, each assessment creates its own new tab. Never reuse an existing tab, even if it is already at the right URL.
|
||||
@@ -69,7 +77,7 @@ node .agents/skills/impeccable/scripts/detect.mjs --json [target]
|
||||
|
||||
Browser visualization is required for a viewable target when browser automation is available. Use a localhost dev/static URL for local files; avoid `file://` unless the available browser explicitly supports this workflow. Overlay flow:
|
||||
|
||||
1. Create a fresh tab and navigate.
|
||||
1. Create a fresh tab and navigate. Prefer the harness's native/browser-canvas screenshot path before hand-rolling a Playwright/Puppeteer script; only fall back to a custom script when no native browser tool is exposed.
|
||||
2. Preflight mutable injection by setting `document.title` and appending a `<script>` tag. Read-only evaluate APIs do not count.
|
||||
3. If mutation is unavailable, skip live server, browser presentation, and injection; report fallback signal.
|
||||
4. If mutation is available, start `node .agents/skills/impeccable/scripts/live-server.mjs --background`, present the browser if supported, label `[Human]`, scroll top, inject `http://localhost:PORT/detect.js`, wait 2-3 seconds, read `impeccable` console messages, then stop the live server.
|
||||
@@ -93,6 +101,12 @@ Codex final-answer note: `$impeccable critique` produces a report artifact, so t
|
||||
|
||||
Structure your feedback as a design director would:
|
||||
|
||||
#### Report header provenance
|
||||
|
||||
The report's first line MUST declare how the assessments were run, so a degraded run is never silent:
|
||||
- Dual-agent: `Method: dual-agent (A: <agent-id> · B: <agent-id>)`
|
||||
- Degraded: `⚠️ DEGRADED: single-context (<reason, e.g. no sub-agent tool exposed>)`
|
||||
|
||||
#### Design Health Score
|
||||
> *Consult the [Heuristics Scoring Guide](#heuristics-scoring-guide) section below.*
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
Generate a `DESIGN.md` file at the project root that captures the current visual design system, so AI agents generating new screens stay on-brand.
|
||||
|
||||
DESIGN.md follows the [official Google Stitch DESIGN.md format](https://stitch.withgoogle.com/docs/design-md/format/): YAML frontmatter carrying machine-readable design tokens, followed by a markdown body with exactly six sections in a fixed order. **Tokens are normative; prose provides context for how to apply them.** Sections may be omitted when not relevant, but **do not reorder them and do not rename them**. Section headers must match the spec character-for-character so the file stays parseable by other DESIGN.md-aware tools (Stitch itself, awesome-design-md, skill-rest, etc.).
|
||||
DESIGN.md follows the [official DESIGN.md format spec](https://raw.githubusercontent.com/google-labs-code/design.md/main/docs/spec.md): YAML frontmatter carrying machine-readable design tokens, followed by a markdown body with exactly six sections in a fixed order. **Tokens are normative; prose provides context for how to apply them.** Sections may be omitted when not relevant, but **do not reorder them and do not rename them**. Section headers must match the spec character-for-character so the file stays parseable by other DESIGN.md-aware tools (Stitch itself, awesome-design-md, skill-rest, etc.).
|
||||
|
||||
## The frontmatter: token schema
|
||||
|
||||
|
||||
@@ -2,13 +2,15 @@
|
||||
|
||||
Manage the **design detector hook** for the current project.
|
||||
|
||||
The hook runs the impeccable design detector on direct file edits to design-relevant files (`.tsx`, `.jsx`, `.html`, `.vue`, `.svelte`, `.astro`, `.css`, `.scss`, `.sass`, `.less`, `.ts`, `.js`). Claude Code and Codex use `PostToolUse` and push a short system reminder into the agent's context after the edit; findings get a correction prompt, pending issues get a re-nudge, and clean UI-ish files get a short ack unless quiet mode is on (`hook.quiet` in config). Plain `.ts` and `.js` files are still scanned, but stay quiet unless the detector finds something. Cursor uses `preToolUse` to block bad proposed writes before they land and stays silent when it allows a clean write.
|
||||
The hook runs the impeccable design detector on direct file edits to design-relevant files (`.tsx`, `.jsx`, `.html`, `.vue`, `.svelte`, `.astro`, `.css`, `.scss`, `.sass`, `.less`, `.ts`, `.js`). Claude Code, Codex, and GitHub Copilot use a post-tool-use hook and push a short system reminder into the agent's context after the edit; findings get a correction prompt, pending issues get a re-nudge, and clean UI-ish files get a short ack unless quiet mode is on (`hook.quiet` in config). Plain `.ts` and `.js` files are still scanned, but stay quiet unless the detector finds something. Cursor uses `preToolUse` to block bad proposed writes before they land and stays silent when it allows a clean write.
|
||||
|
||||
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), and Cursor (`.cursor/hooks.json` in the project).
|
||||
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.
|
||||
|
||||
@@ -51,7 +53,7 @@ Prefer the narrowest exception:
|
||||
- For value-specific findings such as `overused-font` and `bounce-easing`, use `ignore-value` when the user confirms the specific value. Do not use `ignore-rule overused-font` for a specific font.
|
||||
- If the finding has no value-specific command, such as `side-tab`, prefer `ignore-file <path>` for the current file.
|
||||
- Use `ignore-rule <id>` only when the user asks to suppress that whole rule across the project. For broad overused-font suppression, use `ignore-rule overused-font --all-values` only when the user asks to ignore overused fonts generally.
|
||||
- Do not add source comments such as `impeccable: ignore`; inline comments pollute code and are not a supported suppression mechanism.
|
||||
- Prefer config ignores (the commands above) by default; they keep suppressions in one reviewable place. Reach for an inline comment only when the waiver must travel with a single file that leaves the repo (a generated/exported standalone document, an emailed HTML file). The supported marker is `impeccable-disable <rule>` (whole file) or `impeccable-disable-line` / `impeccable-disable-next-line` (one line), in any comment syntax, with an optional reason after `:` or `--`. The detector honors it by default; `--no-inline-ignores` or `--no-config` bypasses it.
|
||||
|
||||
Example value-specific exception:
|
||||
|
||||
@@ -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 and Codex 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`, and `.cursor/hooks.json`. On Codex, the user must approve the hook via `/hooks` the first time. On Cursor, confirm hooks are enabled under Settings -> Hooks.
|
||||
- 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.
|
||||
|
||||
## Failure modes
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
The setup command for a project. One codebase crawl feeds everything it writes:
|
||||
|
||||
- **PRODUCT.md** (strategic): root project file for register, target users, product purpose, brand personality, anti-references, strategic design principles. Answers "who/what/why".
|
||||
- **DESIGN.md** (visual): root project file for visual theme, color palette, typography, components, layout. Follows the [Google Stitch DESIGN.md format](https://stitch.withgoogle.com/docs/design-md/format/). Answers "how it looks".
|
||||
- **DESIGN.md** (visual): root project file for visual theme, color palette, typography, components, layout. Follows the [DESIGN.md format spec](https://raw.githubusercontent.com/google-labs-code/design.md/main/docs/spec.md). Answers "how it looks".
|
||||
- **`.impeccable/live/config.json`** (live mode): pre-configured so `$impeccable live` boots straight into variant mode with no first-time detour.
|
||||
|
||||
It closes by pointing the user at the best command to run next. Every other impeccable command reads PRODUCT.md and DESIGN.md before doing any work.
|
||||
@@ -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,7 +10,7 @@ Codex: run live helper commands, the app dev server, and any dependency-installi
|
||||
|
||||
Execute in order. No step skipped, no step reordered.
|
||||
|
||||
1. `live.mjs`: boot.
|
||||
1. `live.mjs`: boot. 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/live.mjs --target <path>` instead; then run the rest of this live session from the returned `projectRoot`.
|
||||
2. Open the app URL that serves `pageFile` (infer from `package.json`, docs, terminal output, or an open tab). Never use `serverPort`; it's the helper, not the app. **Cursor:** `browser_navigate` to that URL before polling; do not skip. **Other harnesses:** use the available browser tool; if the URL is uncertain, ask the user once.
|
||||
3. Poll loop with the default long timeout (600000 ms). After every event or `--reply`, run `live-poll.mjs` again immediately. Never pass a short `--timeout=`.
|
||||
|
||||
|
||||
@@ -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,15 +1,18 @@
|
||||
/**
|
||||
* 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. cwd, if PRODUCT.md or DESIGN.md is there
|
||||
* 2. .agents/context/ then docs/
|
||||
* 3. $IMPECCABLE_CONTEXT_DIR (absolute or cwd-relative) — power-user
|
||||
* 1. Active project root, if PRODUCT.md or DESIGN.md is there
|
||||
* 2. Active project .agents/context/ then docs/
|
||||
* 3. Monorepo root context, using the same order, as a per-file fallback
|
||||
* 4. $IMPECCABLE_CONTEXT_DIR (absolute or cwd-relative) — power-user
|
||||
* escape hatch, only consulted when defaults are empty
|
||||
* 4. cwd as a "nothing found" default
|
||||
* 5. Active project root as a "nothing found" default
|
||||
*
|
||||
* `resolveContextDir()` and `loadContext()` are also exported for the
|
||||
* server-side scripts (live.mjs, live-server.mjs) that need the structured
|
||||
@@ -19,10 +22,26 @@ import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { parseTargetOptions } from './lib/target-args.mjs';
|
||||
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'];
|
||||
const FALLBACK_DIRS = ['.agents/context', 'docs'];
|
||||
const MONOREPO_MARKER_FILES = ['pnpm-workspace.yaml', 'turbo.json', 'nx.json', 'lerna.json'];
|
||||
const MONOREPO_FALLBACK_PROJECT_DIRS = ['apps', 'packages'];
|
||||
const WORKSPACE_DISCOVERY_IGNORED_DIRS = new Set([
|
||||
'node_modules',
|
||||
'.git',
|
||||
'dist',
|
||||
'build',
|
||||
'.next',
|
||||
'.nuxt',
|
||||
'.svelte-kit',
|
||||
'.turbo',
|
||||
'.cache',
|
||||
'coverage',
|
||||
]);
|
||||
|
||||
// ─── Update check ──────────────────────────────────────────────────────────
|
||||
// Piggyback a lightweight skill-version check on the once-per-session boot.
|
||||
@@ -38,41 +57,623 @@ const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; // throttle the network poll to o
|
||||
const RENOTIFY_INTERVAL_MS = 7 * 24 * 60 * 60 * 1000; // don't re-surface the same version for a week
|
||||
const FETCH_TIMEOUT_MS = 1200;
|
||||
|
||||
export function resolveContextDir(cwd = process.cwd()) {
|
||||
if (firstExisting(cwd, [...PRODUCT_NAMES, ...DESIGN_NAMES])) {
|
||||
return cwd;
|
||||
}
|
||||
for (const rel of FALLBACK_DIRS) {
|
||||
const candidate = path.resolve(cwd, rel);
|
||||
if (firstExisting(candidate, [...PRODUCT_NAMES, ...DESIGN_NAMES])) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
const envDir = process.env.IMPECCABLE_CONTEXT_DIR;
|
||||
if (envDir && envDir.trim()) {
|
||||
const trimmed = envDir.trim();
|
||||
return path.isAbsolute(trimmed) ? trimmed : path.resolve(cwd, trimmed);
|
||||
}
|
||||
return cwd;
|
||||
export function resolveContextDir(cwd = process.cwd(), options = {}) {
|
||||
return resolveContext(cwd, options).contextDir;
|
||||
}
|
||||
|
||||
export function loadContext(cwd = process.cwd()) {
|
||||
const contextDir = resolveContextDir(cwd);
|
||||
const productPath = firstExisting(contextDir, PRODUCT_NAMES);
|
||||
const designPath = firstExisting(contextDir, DESIGN_NAMES);
|
||||
export function loadContext(cwd = process.cwd(), options = {}) {
|
||||
const resolved = resolveContext(cwd, options);
|
||||
const absCwd = path.resolve(cwd);
|
||||
const productPath = resolved.productPath;
|
||||
const designPath = resolved.designPath;
|
||||
const product = productPath ? safeRead(productPath) : null;
|
||||
const design = designPath ? safeRead(designPath) : null;
|
||||
return {
|
||||
hasProduct: !!product,
|
||||
product,
|
||||
productPath: productPath ? path.relative(cwd, productPath) : null,
|
||||
productPath: productPath ? path.relative(absCwd, productPath) : null,
|
||||
hasDesign: !!design,
|
||||
design,
|
||||
designPath: designPath ? path.relative(cwd, designPath) : null,
|
||||
contextDir,
|
||||
designPath: designPath ? path.relative(absCwd, designPath) : null,
|
||||
contextDir: resolved.contextDir,
|
||||
productContextDir: productPath ? path.dirname(productPath) : null,
|
||||
designContextDir: designPath ? path.dirname(designPath) : null,
|
||||
projectRoot: resolved.projectRoot,
|
||||
repoRoot: resolved.repoRoot,
|
||||
isMonorepo: resolved.isMonorepo,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveContext(cwd = process.cwd(), options = {}) {
|
||||
const absCwd = path.resolve(cwd);
|
||||
const project = resolveProject(absCwd, options);
|
||||
const projectContextDir = resolveLocalContextDir(project.projectRoot);
|
||||
const rootContextDir = project.isMonorepo && project.repoRoot !== project.projectRoot
|
||||
? resolveLocalContextDir(project.repoRoot)
|
||||
: null;
|
||||
|
||||
let productPath =
|
||||
(projectContextDir ? firstExisting(projectContextDir, PRODUCT_NAMES) : null)
|
||||
|| (rootContextDir ? firstExisting(rootContextDir, PRODUCT_NAMES) : null);
|
||||
let designPath =
|
||||
(projectContextDir ? firstExisting(projectContextDir, DESIGN_NAMES) : null)
|
||||
|| (rootContextDir ? firstExisting(rootContextDir, DESIGN_NAMES) : null);
|
||||
|
||||
let envContextDir = null;
|
||||
if (!productPath && !designPath) {
|
||||
envContextDir = resolveEnvContextDir(absCwd);
|
||||
if (envContextDir) {
|
||||
productPath = firstExisting(envContextDir, PRODUCT_NAMES);
|
||||
designPath = firstExisting(envContextDir, DESIGN_NAMES);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
contextDir: productPath
|
||||
? path.dirname(productPath)
|
||||
: designPath
|
||||
? path.dirname(designPath)
|
||||
: envContextDir || project.projectRoot,
|
||||
productPath,
|
||||
designPath,
|
||||
projectRoot: project.projectRoot,
|
||||
repoRoot: project.repoRoot,
|
||||
isMonorepo: project.isMonorepo,
|
||||
targetDir: project.targetDir,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveProjectRoot(cwd = process.cwd(), options = {}) {
|
||||
return resolveProject(cwd, options).projectRoot;
|
||||
}
|
||||
|
||||
export function resolveTargetSelection(cwd = process.cwd(), options = {}) {
|
||||
if (hasTargetOption(options)) return null;
|
||||
const project = resolveProject(cwd);
|
||||
if (
|
||||
!project.isMonorepo
|
||||
|| !project.projectRoot
|
||||
|| !project.repoRoot
|
||||
|| path.resolve(project.projectRoot) !== path.resolve(project.repoRoot)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const targetCandidates = discoverTargetCandidates(project.repoRoot);
|
||||
// No discoverable child apps (e.g. `workspaces: ["."]`, a root-only workspace,
|
||||
// or a marker file with no apps/packages children): there is nothing to choose,
|
||||
// so treat the repo root as the active project rather than blocking on an empty
|
||||
// selection prompt that the user cannot answer.
|
||||
if (targetCandidates.length === 0) return null;
|
||||
return {
|
||||
targetPath: null,
|
||||
projectRoot: project.projectRoot,
|
||||
repoRoot: project.repoRoot,
|
||||
targetCandidates,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveProject(cwd = process.cwd(), options = {}) {
|
||||
const absCwd = path.resolve(cwd);
|
||||
const targetDir = resolveTargetDir(absCwd, options);
|
||||
let repoRoot = findMonorepoRoot(targetDir);
|
||||
if (!repoRoot && targetDir !== absCwd) {
|
||||
const cwdRepoRoot = findMonorepoRoot(absCwd);
|
||||
if (cwdRepoRoot && isPathInside(targetDir, cwdRepoRoot)) {
|
||||
repoRoot = cwdRepoRoot;
|
||||
}
|
||||
}
|
||||
if (!repoRoot) {
|
||||
return {
|
||||
targetDir,
|
||||
projectRoot: absCwd,
|
||||
repoRoot: absCwd,
|
||||
isMonorepo: false,
|
||||
};
|
||||
}
|
||||
return {
|
||||
targetDir,
|
||||
projectRoot: resolveWorkspaceProjectRoot(repoRoot, targetDir) || repoRoot,
|
||||
repoRoot,
|
||||
isMonorepo: true,
|
||||
};
|
||||
}
|
||||
|
||||
function isPathInside(candidate, root) {
|
||||
const rel = path.relative(root, candidate);
|
||||
return !!rel && !rel.startsWith('..') && !path.isAbsolute(rel);
|
||||
}
|
||||
|
||||
function resolveLocalContextDir(root) {
|
||||
if (firstExisting(root, [...PRODUCT_NAMES, ...DESIGN_NAMES])) {
|
||||
return root;
|
||||
}
|
||||
for (const rel of FALLBACK_DIRS) {
|
||||
const candidate = path.resolve(root, rel);
|
||||
if (firstExisting(candidate, [...PRODUCT_NAMES, ...DESIGN_NAMES])) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolveEnvContextDir(cwd) {
|
||||
const envDir = process.env.IMPECCABLE_CONTEXT_DIR;
|
||||
if (!envDir || !envDir.trim()) return null;
|
||||
const trimmed = envDir.trim();
|
||||
return path.isAbsolute(trimmed) ? trimmed : path.resolve(cwd, trimmed);
|
||||
}
|
||||
|
||||
function resolveTargetDir(cwd, options = {}) {
|
||||
const targetPath = options && typeof options === 'object' ? options.targetPath : null;
|
||||
if (!targetPath || !String(targetPath).trim()) return cwd;
|
||||
const abs = path.isAbsolute(targetPath) ? targetPath : path.resolve(cwd, targetPath);
|
||||
try {
|
||||
const stat = fs.statSync(abs);
|
||||
return stat.isDirectory() ? abs : path.dirname(abs);
|
||||
} catch {
|
||||
return path.extname(abs) ? path.dirname(abs) : abs;
|
||||
}
|
||||
}
|
||||
|
||||
function findMonorepoRoot(startDir) {
|
||||
let dir = path.resolve(startDir);
|
||||
const homeDir = path.resolve(os.homedir());
|
||||
while (true) {
|
||||
if (dir === homeDir) return null;
|
||||
// isMonorepoRoot is checked before hasGitBoundary on purpose: a workspace
|
||||
// root that also carries its own .git is still recognized. The trade-off is
|
||||
// deliberate — a directory with a monorepo *marker* but no workspace patterns
|
||||
// and no apps/packages children is not a monorepo root, so its .git stops
|
||||
// traversal and a further-up root is not searched. The nested .git is treated
|
||||
// as an independent project boundary, which is the intended isolation.
|
||||
if (isMonorepoRoot(dir)) return dir;
|
||||
if (hasGitBoundary(dir)) return null;
|
||||
const parent = path.dirname(dir);
|
||||
if (parent === dir) return null;
|
||||
dir = parent;
|
||||
}
|
||||
}
|
||||
|
||||
function isMonorepoRoot(dir) {
|
||||
if (readWorkspacePatterns(dir).some((pattern) => !normalizeWorkspacePattern(pattern).startsWith('!'))) return true;
|
||||
if (!MONOREPO_MARKER_FILES.some((file) => fs.existsSync(path.join(dir, file)))) return false;
|
||||
return hasFallbackWorkspaceChildren(dir);
|
||||
}
|
||||
|
||||
function hasGitBoundary(dir) {
|
||||
return fs.existsSync(path.join(dir, '.git'));
|
||||
}
|
||||
|
||||
function hasFallbackWorkspaceChildren(dir) {
|
||||
for (const name of MONOREPO_FALLBACK_PROJECT_DIRS) {
|
||||
const base = path.join(dir, name);
|
||||
let entries;
|
||||
try {
|
||||
entries = fs.readdirSync(base, { withFileTypes: true });
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (entries.some((entry) => entry.isDirectory() && !isIgnoredWorkspaceDiscoveryDir(entry.name))) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function discoverTargetCandidates(repoRoot) {
|
||||
const roots = new Map();
|
||||
const patterns = readWorkspacePatterns(repoRoot);
|
||||
for (const pattern of patterns) {
|
||||
for (const root of discoverRootsForPattern(repoRoot, pattern)) {
|
||||
roots.set(path.relative(repoRoot, root).split(path.sep).join('/'), root);
|
||||
}
|
||||
}
|
||||
if (MONOREPO_MARKER_FILES.some((file) => fs.existsSync(path.join(repoRoot, file)))) {
|
||||
for (const name of MONOREPO_FALLBACK_PROJECT_DIRS) {
|
||||
const base = path.join(repoRoot, name);
|
||||
let entries;
|
||||
try {
|
||||
entries = fs.readdirSync(base, { withFileTypes: true });
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory() || isIgnoredWorkspaceDiscoveryDir(entry.name)) continue;
|
||||
const root = path.join(base, entry.name);
|
||||
roots.set(path.relative(repoRoot, root).split(path.sep).join('/'), root);
|
||||
}
|
||||
}
|
||||
}
|
||||
return [...roots.entries()]
|
||||
.filter(([rel]) => rel && !rel.startsWith('..'))
|
||||
// Honor negated workspace patterns (e.g. "!packages/internal"). resolveWorkspaceProjectRoot
|
||||
// sends an excluded package back to the repo root, so an excluded folder must not appear as a
|
||||
// selectable target — choosing it would silently resolve to the root instead.
|
||||
.filter(([rel]) => !isExcludedByWorkspacePattern(rel.split('/').filter(Boolean), patterns))
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([rel, root]) => {
|
||||
const targetExample = findTargetExample(repoRoot, root);
|
||||
return {
|
||||
name: path.basename(root),
|
||||
path: rel,
|
||||
targetExample,
|
||||
...resolveCandidateContextSummary(repoRoot, root, targetExample),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function resolveCandidateContextSummary(repoRoot, projectRoot, targetPath) {
|
||||
const ctx = resolveContext(repoRoot, { targetPath });
|
||||
return {
|
||||
productStatus: contextSourceStatus(ctx.productPath, repoRoot, projectRoot),
|
||||
productPath: contextSourcePath(ctx.productPath, repoRoot),
|
||||
designStatus: contextSourceStatus(ctx.designPath, repoRoot, projectRoot),
|
||||
designPath: contextSourcePath(ctx.designPath, repoRoot),
|
||||
};
|
||||
}
|
||||
|
||||
// Selection candidates surface one of four statuses: 'child' (a canonical
|
||||
// PRODUCT.md/DESIGN.md directly in the app root), 'inherited' (resolved from the
|
||||
// repo root in a monorepo), 'missing' (no file found), and 'fallback'. 'fallback'
|
||||
// intentionally covers two non-canonical locations: a file inside the project
|
||||
// root but in a subdirectory (FALLBACK_DIRS, e.g. `.agents/context/`), and a file
|
||||
// outside both the project and repo roots (IMPECCABLE_CONTEXT_DIR override).
|
||||
function contextSourceStatus(filePath, repoRoot, projectRoot) {
|
||||
if (!filePath) return 'missing';
|
||||
const absPath = path.resolve(filePath);
|
||||
const absProjectRoot = path.resolve(projectRoot);
|
||||
const absRepoRoot = path.resolve(repoRoot);
|
||||
if (isPathInsideOrEqual(absPath, absProjectRoot)) {
|
||||
return path.dirname(absPath) === absProjectRoot ? 'child' : 'fallback';
|
||||
}
|
||||
if (absProjectRoot !== absRepoRoot && isPathInsideOrEqual(absPath, absRepoRoot)) {
|
||||
return 'inherited';
|
||||
}
|
||||
return 'fallback';
|
||||
}
|
||||
|
||||
function contextSourcePath(filePath, repoRoot) {
|
||||
if (!filePath) return null;
|
||||
const rel = path.relative(repoRoot, filePath);
|
||||
if (rel && !rel.startsWith('..') && !path.isAbsolute(rel)) {
|
||||
return rel.split(path.sep).join('/');
|
||||
}
|
||||
return filePath;
|
||||
}
|
||||
|
||||
function discoverRootsForPattern(repoRoot, rawPattern) {
|
||||
const pattern = normalizeWorkspacePattern(rawPattern);
|
||||
if (!pattern || pattern.startsWith('!')) return [];
|
||||
const segments = pattern.split('/').filter(Boolean);
|
||||
if (!segments.length) return [];
|
||||
const firstGlobIndex = segments.findIndex((segment) => segment.includes('*'));
|
||||
const literalPrefix = firstGlobIndex === -1 ? segments : segments.slice(0, firstGlobIndex);
|
||||
const base = path.join(repoRoot, ...literalPrefix);
|
||||
if (!fs.existsSync(base)) return [];
|
||||
if (segments.includes('**')) {
|
||||
const packageRoots = [];
|
||||
walkDirs(base, (dir) => {
|
||||
if (dir !== base && isCandidateProjectRoot(dir)) packageRoots.push(dir);
|
||||
});
|
||||
if (packageRoots.length) return packageRoots;
|
||||
return directChildDirs(base);
|
||||
}
|
||||
return expandSimplePattern(repoRoot, segments);
|
||||
}
|
||||
|
||||
function expandSimplePattern(repoRoot, patternSegments, index = 0, current = repoRoot) {
|
||||
if (index >= patternSegments.length) return fs.existsSync(current) ? [current] : [];
|
||||
const segment = patternSegments[index];
|
||||
if (!segment.includes('*')) {
|
||||
return expandSimplePattern(repoRoot, patternSegments, index + 1, path.join(current, segment));
|
||||
}
|
||||
let entries;
|
||||
try {
|
||||
entries = fs.readdirSync(current, { withFileTypes: true });
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
const roots = [];
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory() || isIgnoredWorkspaceDiscoveryDir(entry.name)) continue;
|
||||
if (!segmentMatches(segment, entry.name)) continue;
|
||||
roots.push(...expandSimplePattern(repoRoot, patternSegments, index + 1, path.join(current, entry.name)));
|
||||
}
|
||||
return roots;
|
||||
}
|
||||
|
||||
function directChildDirs(dir) {
|
||||
try {
|
||||
return fs.readdirSync(dir, { withFileTypes: true })
|
||||
.filter((entry) => entry.isDirectory() && !isIgnoredWorkspaceDiscoveryDir(entry.name))
|
||||
.map((entry) => path.join(dir, entry.name));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function walkDirs(root, visit) {
|
||||
let entries;
|
||||
try {
|
||||
entries = fs.readdirSync(root, { withFileTypes: true });
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory() || isIgnoredWorkspaceDiscoveryDir(entry.name)) continue;
|
||||
const dir = path.join(root, entry.name);
|
||||
visit(dir);
|
||||
walkDirs(dir, visit);
|
||||
}
|
||||
}
|
||||
|
||||
function isCandidateProjectRoot(dir) {
|
||||
return !!(
|
||||
fs.existsSync(path.join(dir, 'package.json'))
|
||||
|| firstExisting(dir, [...PRODUCT_NAMES, ...DESIGN_NAMES])
|
||||
|| fs.existsSync(path.join(dir, 'src'))
|
||||
|| fs.existsSync(path.join(dir, 'app'))
|
||||
|| fs.existsSync(path.join(dir, 'pages'))
|
||||
|| fs.existsSync(path.join(dir, 'public'))
|
||||
);
|
||||
}
|
||||
|
||||
function isIgnoredWorkspaceDiscoveryDir(name) {
|
||||
return name.startsWith('.') || WORKSPACE_DISCOVERY_IGNORED_DIRS.has(name);
|
||||
}
|
||||
|
||||
function findTargetExample(repoRoot, projectRoot) {
|
||||
const examples = [
|
||||
'src/App.jsx',
|
||||
'src/App.tsx',
|
||||
'src/main.jsx',
|
||||
'src/main.tsx',
|
||||
'src/index.jsx',
|
||||
'src/index.ts',
|
||||
'app/page.tsx',
|
||||
'pages/index.tsx',
|
||||
'public/index.html',
|
||||
];
|
||||
for (const rel of examples) {
|
||||
const abs = path.join(projectRoot, rel);
|
||||
if (fs.existsSync(abs)) return path.relative(repoRoot, abs).split(path.sep).join('/');
|
||||
}
|
||||
return path.relative(repoRoot, projectRoot).split(path.sep).join('/');
|
||||
}
|
||||
|
||||
function resolveWorkspaceProjectRoot(repoRoot, targetDir) {
|
||||
const rel = path.relative(repoRoot, targetDir);
|
||||
if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return repoRoot;
|
||||
const relSegments = rel.split(path.sep).filter(Boolean);
|
||||
const patterns = readWorkspacePatterns(repoRoot);
|
||||
const excluded = isExcludedByWorkspacePattern(relSegments, patterns);
|
||||
if (!excluded) {
|
||||
for (const pattern of patterns) {
|
||||
const projectRoot = projectRootFromWorkspacePattern(repoRoot, relSegments, pattern);
|
||||
if (projectRoot) return projectRoot;
|
||||
}
|
||||
}
|
||||
if (excluded) return repoRoot;
|
||||
if (
|
||||
relSegments.length >= 2
|
||||
&& MONOREPO_FALLBACK_PROJECT_DIRS.includes(relSegments[0])
|
||||
) {
|
||||
return path.join(repoRoot, relSegments[0], relSegments[1]);
|
||||
}
|
||||
const nearest = nearestProjectLikeRoot(repoRoot, targetDir);
|
||||
if (nearest) return nearest;
|
||||
return repoRoot;
|
||||
}
|
||||
|
||||
function isExcludedByWorkspacePattern(relSegments, patterns) {
|
||||
return patterns.some((rawPattern) => {
|
||||
const pattern = normalizeWorkspacePattern(rawPattern);
|
||||
if (!pattern.startsWith('!')) return false;
|
||||
return workspacePatternMatchesRel(pattern.slice(1), relSegments);
|
||||
});
|
||||
}
|
||||
|
||||
function nearestProjectLikeRoot(repoRoot, targetDir) {
|
||||
let dir = path.resolve(targetDir);
|
||||
const stop = path.resolve(repoRoot);
|
||||
while (dir && dir !== stop) {
|
||||
if (
|
||||
firstExisting(dir, [...PRODUCT_NAMES, ...DESIGN_NAMES])
|
||||
|| fs.existsSync(path.join(dir, 'package.json'))
|
||||
) {
|
||||
return dir;
|
||||
}
|
||||
const parent = path.dirname(dir);
|
||||
if (parent === dir) break;
|
||||
dir = parent;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function nearestPackageRootBetween(repoRoot, targetDir, stopDir) {
|
||||
let dir = path.resolve(targetDir);
|
||||
const stop = path.resolve(stopDir || repoRoot);
|
||||
const root = path.resolve(repoRoot);
|
||||
while (dir && dir !== stop && isPathInsideOrEqual(dir, root)) {
|
||||
if (fs.existsSync(path.join(dir, 'package.json'))) return dir;
|
||||
const parent = path.dirname(dir);
|
||||
if (parent === dir) break;
|
||||
dir = parent;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isPathInsideOrEqual(candidate, root) {
|
||||
return path.resolve(candidate) === path.resolve(root) || isPathInside(candidate, root);
|
||||
}
|
||||
|
||||
function workspacePatternMatchesRel(pattern, relSegments) {
|
||||
const patternSegments = normalizeWorkspacePattern(pattern).split('/').filter(Boolean);
|
||||
if (!patternSegments.length) return false;
|
||||
if (patternSegments.includes('**')) {
|
||||
const firstGlobIndex = patternSegments.findIndex((segment) => segment.includes('*'));
|
||||
const literalPrefix = firstGlobIndex === -1
|
||||
? patternSegments
|
||||
: patternSegments.slice(0, firstGlobIndex);
|
||||
if (relSegments.length < literalPrefix.length + 1) return false;
|
||||
for (let i = 0; i < literalPrefix.length; i++) {
|
||||
if (!segmentMatches(literalPrefix[i], relSegments[i])) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (relSegments.length < patternSegments.length) return false;
|
||||
for (let i = 0; i < patternSegments.length; i++) {
|
||||
if (!segmentMatches(patternSegments[i], relSegments[i])) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function readWorkspacePatterns(repoRoot) {
|
||||
return [
|
||||
...readPackageWorkspaces(repoRoot),
|
||||
...readPnpmWorkspaces(repoRoot),
|
||||
...readLernaWorkspaces(repoRoot),
|
||||
].filter(Boolean);
|
||||
}
|
||||
|
||||
function readPackageWorkspaces(repoRoot) {
|
||||
const pkg = readJson(path.join(repoRoot, 'package.json'));
|
||||
const workspaces = pkg?.workspaces;
|
||||
if (Array.isArray(workspaces)) return workspaces;
|
||||
if (Array.isArray(workspaces?.packages)) return workspaces.packages;
|
||||
return [];
|
||||
}
|
||||
|
||||
function readLernaWorkspaces(repoRoot) {
|
||||
const lerna = readJson(path.join(repoRoot, 'lerna.json'));
|
||||
return Array.isArray(lerna?.packages) ? lerna.packages : [];
|
||||
}
|
||||
|
||||
function readPnpmWorkspaces(repoRoot) {
|
||||
try {
|
||||
const body = fs.readFileSync(path.join(repoRoot, 'pnpm-workspace.yaml'), 'utf-8');
|
||||
const patterns = [];
|
||||
let inPackages = false;
|
||||
for (const line of body.split(/\r?\n/)) {
|
||||
const trimmed = stripYamlInlineComment(line).trim();
|
||||
if (!trimmed || trimmed.startsWith('#')) continue;
|
||||
const flowMatch = trimmed.match(/^packages:\s*\[(.*)\]\s*$/);
|
||||
if (flowMatch) {
|
||||
patterns.push(...parseYamlFlowList(flowMatch[1]));
|
||||
inPackages = false;
|
||||
continue;
|
||||
}
|
||||
if (/^packages:\s*$/.test(trimmed)) {
|
||||
inPackages = true;
|
||||
continue;
|
||||
}
|
||||
if (inPackages && /^[A-Za-z0-9_-]+:\s*/.test(trimmed)) break;
|
||||
if (inPackages) {
|
||||
const match = trimmed.match(/^-\s*(.+)$/);
|
||||
if (match) patterns.push(unquoteYamlValue(match[1]));
|
||||
}
|
||||
}
|
||||
return patterns;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function stripYamlInlineComment(line) {
|
||||
let quote = null;
|
||||
for (let i = 0; i < line.length; i++) {
|
||||
const ch = line[i];
|
||||
if ((ch === '"' || ch === "'") && line[i - 1] !== '\\') {
|
||||
quote = quote === ch ? null : quote || ch;
|
||||
continue;
|
||||
}
|
||||
if (ch === '#' && !quote) return line.slice(0, i);
|
||||
}
|
||||
return line;
|
||||
}
|
||||
|
||||
function parseYamlFlowList(body) {
|
||||
const items = [];
|
||||
let quote = null;
|
||||
let current = '';
|
||||
for (let i = 0; i < body.length; i++) {
|
||||
const ch = body[i];
|
||||
if ((ch === '"' || ch === "'") && body[i - 1] !== '\\') {
|
||||
quote = quote === ch ? null : quote || ch;
|
||||
current += ch;
|
||||
continue;
|
||||
}
|
||||
if (ch === ',' && !quote) {
|
||||
const value = unquoteYamlValue(current);
|
||||
if (value) items.push(value);
|
||||
current = '';
|
||||
continue;
|
||||
}
|
||||
current += ch;
|
||||
}
|
||||
const value = unquoteYamlValue(current);
|
||||
if (value) items.push(value);
|
||||
return items;
|
||||
}
|
||||
|
||||
function unquoteYamlValue(value) {
|
||||
return String(value || '')
|
||||
.trim()
|
||||
.replace(/^['"]|['"]$/g, '');
|
||||
}
|
||||
|
||||
function readJson(filePath) {
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function projectRootFromWorkspacePattern(repoRoot, relSegments, rawPattern) {
|
||||
const pattern = normalizeWorkspacePattern(rawPattern);
|
||||
if (!pattern || pattern.startsWith('!')) return null;
|
||||
const patternSegments = pattern.split('/').filter(Boolean);
|
||||
if (!patternSegments.length) return null;
|
||||
if (patternSegments.includes('**')) {
|
||||
return projectRootFromDoubleStarPattern(repoRoot, relSegments, patternSegments);
|
||||
}
|
||||
if (relSegments.length < patternSegments.length) return null;
|
||||
for (let i = 0; i < patternSegments.length; i++) {
|
||||
if (!segmentMatches(patternSegments[i], relSegments[i])) return null;
|
||||
}
|
||||
return path.join(repoRoot, ...relSegments.slice(0, patternSegments.length));
|
||||
}
|
||||
|
||||
function projectRootFromDoubleStarPattern(repoRoot, relSegments, patternSegments) {
|
||||
const firstGlobIndex = patternSegments.findIndex((segment) => segment.includes('*'));
|
||||
const literalPrefix = firstGlobIndex === -1
|
||||
? patternSegments
|
||||
: patternSegments.slice(0, firstGlobIndex);
|
||||
if (relSegments.length < literalPrefix.length + 1) return null;
|
||||
for (let i = 0; i < literalPrefix.length; i++) {
|
||||
if (!segmentMatches(literalPrefix[i], relSegments[i])) return null;
|
||||
}
|
||||
const prefixDir = path.join(repoRoot, ...literalPrefix);
|
||||
const targetDir = path.join(repoRoot, ...relSegments);
|
||||
const packageRoot = nearestPackageRootBetween(repoRoot, targetDir, prefixDir);
|
||||
if (packageRoot) return packageRoot;
|
||||
return path.join(repoRoot, ...relSegments.slice(0, literalPrefix.length + 1));
|
||||
}
|
||||
|
||||
function normalizeWorkspacePattern(pattern) {
|
||||
return String(pattern || '')
|
||||
.trim()
|
||||
.replace(/^['"]|['"]$/g, '')
|
||||
.replace(/^\.\//, '')
|
||||
.replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
function segmentMatches(patternSegment, relSegment) {
|
||||
if (patternSegment === '*') return true;
|
||||
if (!patternSegment.includes('*')) return patternSegment === relSegment;
|
||||
const re = new RegExp(`^${escapeRegExp(patternSegment).replace(/\\\*/g, '[^/]*')}$`);
|
||||
return re.test(relSegment);
|
||||
}
|
||||
|
||||
function firstExisting(dir, names) {
|
||||
for (const name of names) {
|
||||
const abs = path.join(dir, name);
|
||||
@@ -89,24 +690,64 @@ function safeRead(p) {
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -233,7 +874,24 @@ async function computeUpdateDirective(now = Date.now()) {
|
||||
}
|
||||
|
||||
async function cli() {
|
||||
const ctx = loadContext(process.cwd());
|
||||
let cliOptions;
|
||||
try {
|
||||
cliOptions = parseCliOptions(process.argv.slice(2));
|
||||
} catch (err) {
|
||||
if (err?.name === 'TargetArgError') {
|
||||
process.stderr.write(`${err.message}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
const targetProvided = hasTargetOption(cliOptions);
|
||||
const targetExists = targetProvided ? pathExistsForTarget(process.cwd(), cliOptions.targetPath) : null;
|
||||
const selection = resolveTargetSelection(process.cwd(), cliOptions);
|
||||
if (selection) {
|
||||
process.stdout.write(buildTargetSelectionDirective(selection) + '\n');
|
||||
process.exit(0);
|
||||
}
|
||||
const ctx = loadContext(process.cwd(), cliOptions);
|
||||
const updateDirective = await computeUpdateDirective();
|
||||
|
||||
if (!ctx.hasProduct) {
|
||||
@@ -241,9 +899,16 @@ 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)) {
|
||||
parts.push(buildMissingTargetDirective());
|
||||
}
|
||||
if (updateDirective) parts.push(updateDirective);
|
||||
process.stdout.write(parts.join('\n\n---\n\n') + '\n');
|
||||
process.exit(0);
|
||||
@@ -252,15 +917,93 @@ async function cli() {
|
||||
if (ctx.hasDesign) {
|
||||
parts.push(`# DESIGN.md\n\n${ctx.design.trim()}`);
|
||||
}
|
||||
parts.push(buildResolvedContextDirective(ctx, cliOptions, { targetExists }));
|
||||
if (shouldWarnMissingTarget(ctx, targetProvided, targetExists)) {
|
||||
parts.push(buildMissingTargetDirective());
|
||||
}
|
||||
const register = extractRegister(ctx.product);
|
||||
const next = register
|
||||
? `NEXT STEP: This project's register is \`${register}\`. You MUST now read \`reference/${register}.md\` before producing any design output.`
|
||||
: `NEXT STEP: You MUST now read the matching register reference (\`reference/brand.md\` or \`reference/product.md\`) before producing any design output. Pick based on PRODUCT.md above.`;
|
||||
parts.push(next);
|
||||
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');
|
||||
}
|
||||
|
||||
function parseCliOptions(args) {
|
||||
return parseTargetOptions(args, { strict: true });
|
||||
}
|
||||
|
||||
function hasTargetOption(options) {
|
||||
return !!(options && typeof options.targetPath === 'string' && options.targetPath.trim());
|
||||
}
|
||||
|
||||
function pathExistsForTarget(cwd, targetPath) {
|
||||
const abs = path.isAbsolute(targetPath) ? targetPath : path.resolve(cwd, targetPath);
|
||||
return fs.existsSync(abs);
|
||||
}
|
||||
|
||||
function buildResolvedContextDirective(ctx, options, { targetExists = null } = {}) {
|
||||
const targetPath = hasTargetOption(options) ? options.targetPath : null;
|
||||
return `RESOLVED_CONTEXT:\n${JSON.stringify({
|
||||
targetPath,
|
||||
...(targetPath ? { targetExists } : {}),
|
||||
projectRoot: ctx.projectRoot,
|
||||
repoRoot: ctx.repoRoot,
|
||||
productPath: ctx.productPath,
|
||||
designPath: ctx.designPath,
|
||||
}, null, 2)}`;
|
||||
}
|
||||
|
||||
function shouldWarnMissingTarget(ctx, targetProvided, targetExists = null) {
|
||||
if (ctx.isMonorepo && targetProvided && targetExists === false) return true;
|
||||
return !!(
|
||||
ctx.isMonorepo
|
||||
&& (!targetProvided || targetExists === false)
|
||||
&& ctx.projectRoot
|
||||
&& ctx.repoRoot
|
||||
&& path.resolve(ctx.projectRoot) === path.resolve(ctx.repoRoot)
|
||||
);
|
||||
}
|
||||
|
||||
function buildMissingTargetDirective() {
|
||||
const script = process.argv[1] || 'context.mjs';
|
||||
return (
|
||||
'MONOREPO_TARGET_REQUIRED: This is a monorepo and context.mjs ran without --target. ' +
|
||||
'If the user named a file, route, or child app, do not answer from this output. ' +
|
||||
`Rerun \`node ${script} --target <path>\` and answer from that run's RESOLVED_CONTEXT fields.`
|
||||
);
|
||||
}
|
||||
|
||||
function buildTargetSelectionDirective(selection) {
|
||||
return (
|
||||
`TARGET_SELECTION_REQUIRED:\n${JSON.stringify(selection, null, 2)}\n\n` +
|
||||
'Show each app with its productStatus/productPath and designStatus/designPath so the user can see child overrides, inherited root files, fallback files, or missing files before choosing. ' +
|
||||
'Ask the user which app Impeccable should use, then rerun Impeccable helper commands from that child app cwd using this same scripts directory. ' +
|
||||
'Use `--target <path>` only as a fallback when changing cwd is not possible, or when the user explicitly named a file/path.'
|
||||
);
|
||||
}
|
||||
|
||||
// Run cli() only when this module is the entry point. Compare realpaths
|
||||
// rather than endsWith(): a loose suffix match also fires for unrelated
|
||||
// scripts like `load-context.mjs`, and realpath tolerates symlinked
|
||||
|
||||
@@ -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';
|
||||
@@ -22,6 +23,10 @@ import {
|
||||
// Output formatting
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function formatFindingSummary(count) {
|
||||
return `${count} anti-pattern${count === 1 ? '' : 's'} found.`;
|
||||
}
|
||||
|
||||
function formatFindings(findings, jsonMode) {
|
||||
if (jsonMode) return JSON.stringify(findings, null, 2);
|
||||
|
||||
@@ -39,7 +44,7 @@ function formatFindings(findings, jsonMode) {
|
||||
out.push(` → ${item.description}`);
|
||||
}
|
||||
}
|
||||
out.push(`\n${findings.length} anti-pattern${findings.length === 1 ? '' : 's'} found.`);
|
||||
out.push(`\n${formatFindingSummary(findings.length)}`);
|
||||
return out.join('\n');
|
||||
}
|
||||
|
||||
@@ -86,9 +91,14 @@ Scan files or URLs for UI anti-patterns and design quality issues.
|
||||
|
||||
Options:
|
||||
--json Output results as JSON
|
||||
--quiet In text mode, only print the final findings count
|
||||
--gpt Also report GPT-specific provider tells (off by default)
|
||||
--gemini Also report Gemini-specific provider tells (off by default)
|
||||
--no-config Do not apply project config, detector ignores, or DESIGN.md
|
||||
--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
|
||||
--no-design-system Do not load local DESIGN.md / .impeccable/design.json context
|
||||
--help Show this help message
|
||||
|
||||
@@ -97,6 +107,14 @@ Project config:
|
||||
settings: detector.ignoreRules, detector.ignoreFiles, detector.ignoreValues,
|
||||
and detector.designSystem.enabled.
|
||||
|
||||
Inline ignores:
|
||||
In-file comments waive a finding where it lives and travel with the file:
|
||||
<!-- impeccable-disable overused-font -- exported brand doc -->
|
||||
.brand { font-family: Inter } /* impeccable-disable-line overused-font */
|
||||
// impeccable-disable-next-line bounce-easing: intentional bounce
|
||||
impeccable-disable applies to the whole file; -line / -next-line are scoped.
|
||||
List one or more rule ids (comma-separated), or omit them / use * for all.
|
||||
|
||||
Detection modes:
|
||||
HTML files Static HTML/CSS analysis (default, catches linked CSS)
|
||||
Non-HTML files Regex pattern matching (CSS, JSX, TSX, etc.)
|
||||
@@ -118,6 +136,7 @@ async function detectCli() {
|
||||
});
|
||||
if (args[0] === 'detect') args = args.slice(1);
|
||||
const jsonMode = args.includes('--json');
|
||||
const quietMode = args.includes('--quiet');
|
||||
const helpMode = args.includes('--help');
|
||||
// --fast (regex-only) is deprecated: since the jsdom removal, the static
|
||||
// HTML/CSS analysis is fast and covers every rule, so the regex-only path
|
||||
@@ -135,9 +154,41 @@ 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;
|
||||
const scanOptions = designSystem ? { providers, designSystem } : { providers };
|
||||
// Inline `impeccable-disable*` waivers are part of the scanned file, so they
|
||||
// apply by default. `--no-config` (raw scan) and the dedicated
|
||||
// `--no-inline-ignores` both turn them off.
|
||||
const inlineIgnoresEnabled = configEnabled && !args.includes('--no-inline-ignores');
|
||||
const scanOptions = { providers, inlineIgnores: inlineIgnoresEnabled };
|
||||
if (designSystem) scanOptions.designSystem = designSystem;
|
||||
const targets = args.filter(a => !a.startsWith('--'));
|
||||
|
||||
if (helpMode) { printUsage(); process.exit(0); }
|
||||
@@ -169,8 +220,8 @@ async function detectCli() {
|
||||
catch { process.stderr.write(`Warning: cannot access ${target}\n`); continue; }
|
||||
|
||||
if (stat.isDirectory()) {
|
||||
// Check for framework dev server config (skip in JSON mode to avoid polluting output)
|
||||
if (!jsonMode) {
|
||||
// Check for framework dev server config (skip in JSON/quiet modes to avoid polluting output)
|
||||
if (!jsonMode && !quietMode) {
|
||||
const fwConfig = detectFrameworkConfig(resolved);
|
||||
if (fwConfig) {
|
||||
const probe = await isPortListening(fwConfig.port, fwConfig.fingerprint);
|
||||
@@ -200,7 +251,7 @@ async function detectCli() {
|
||||
const htmlCount = files.filter(f => HTML_EXTENSIONS.has(path.extname(f).toLowerCase())).length;
|
||||
|
||||
// Warn and confirm if scanning many files (static HTML/CSS processes each HTML file)
|
||||
if (files.length > 50 && process.stdin.isTTY && !jsonMode) {
|
||||
if (files.length > 50 && process.stdin.isTTY && !jsonMode && !quietMode) {
|
||||
process.stderr.write(
|
||||
`\nFound ${files.length} files (${htmlCount} HTML) in ${target}.\n` +
|
||||
`Scanning may take a while${htmlCount > 10 ? ' (static HTML/CSS processes each HTML file individually)' : ''}.\n` +
|
||||
@@ -255,9 +306,11 @@ async function detectCli() {
|
||||
}
|
||||
|
||||
allFindings = filterDetectionFindings(allFindings, detectionConfig);
|
||||
allFindings = filterByScopes(allFindings, scopes);
|
||||
|
||||
if (allFindings.length > 0) {
|
||||
if (jsonMode) process.stdout.write(formatFindings(allFindings, true) + '\n');
|
||||
else if (quietMode) process.stderr.write(formatFindingSummary(allFindings.length) + '\n');
|
||||
else process.stderr.write(formatFindings(allFindings, false) + '\n');
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
@@ -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) ──
|
||||
{
|
||||
@@ -478,6 +513,17 @@ const ANTIPATTERNS = [
|
||||
skillSection: 'Visual Details',
|
||||
skillGuideline: 'repeating-gradient decorative stripes',
|
||||
},
|
||||
{
|
||||
id: 'codex-grid-background',
|
||||
category: 'slop',
|
||||
severity: 'advisory',
|
||||
gated: 'gpt',
|
||||
name: 'Decorative grid-line background',
|
||||
description:
|
||||
'A two-axis grid drawn with hairline linear-gradient layers ("1px, transparent 1px" on both axes) is a recurring generated-UI signature. Reserve grid overlays for actual canvas, map, blueprint, or measurement surfaces; elsewhere use product structure or a plain surface.',
|
||||
skillSection: 'Visual Details',
|
||||
skillGuideline: 'two-axis grid-line gradient background',
|
||||
},
|
||||
{
|
||||
id: 'theater-slop-phrase',
|
||||
category: 'slop',
|
||||
@@ -617,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';
|
||||
|
||||
@@ -1172,6 +1248,42 @@ function checkHtmlPatterns(html) {
|
||||
findings.push({ id: 'repeating-stripes-gradient', snippet: 'repeating-gradient decorative stripes' });
|
||||
}
|
||||
|
||||
// --- Provider tells (gated): two-axis grid-line background (Codex/GPT) ---
|
||||
// The Codex grid tell is two hairline `linear-gradient(... <color> 1px,
|
||||
// transparent 1px)` layers (one per axis) tiled by a repeating
|
||||
// `background-size` cell. Both signals must co-occur in the SAME style block
|
||||
// (a CSS rule body or one inline `style="..."`): two hairline stops WITHOUT a
|
||||
// tiling background-size is a fixed crosshair, not a grid, and a single
|
||||
// hairline is a legitimate ruled line. Scoping to one block also stops
|
||||
// unrelated single-axis rules on separate elements from adding up across the
|
||||
// page. Count hairlines only inside `background`/`background-image` values so
|
||||
// a hairline in an unrelated property (mask-image, border-image) can't stand
|
||||
// in for the second axis. Colors like `oklch(96% 0.012 82 / 0.055)` carry
|
||||
// nested parens, so match the hairline stop directly rather than parsing
|
||||
// whole gradient layers.
|
||||
{
|
||||
const hairlineRe = /\b\d{1,3}px\s*,\s*transparent\s+\d{1,3}px/gi;
|
||||
const gridSizeRe = /background-size\s*:[^;{}"']*\b\d{1,3}px\b/i;
|
||||
const bgDeclRe = /\bbackground(?:-image)?\s*:\s*([^;{}"']*)/gi;
|
||||
const blockRe = /\{([^{}]*)\}|style\s*=\s*"([^"]*)"|style\s*=\s*'([^']*)'/gi;
|
||||
let blk;
|
||||
while ((blk = blockRe.exec(html)) !== null) {
|
||||
const block = blk[1] || blk[2] || blk[3] || '';
|
||||
if (!gridSizeRe.test(block)) continue;
|
||||
let hairlineCount = 0;
|
||||
let bm;
|
||||
bgDeclRe.lastIndex = 0;
|
||||
while ((bm = bgDeclRe.exec(block)) !== null) {
|
||||
const stops = bm[1].match(hairlineRe);
|
||||
if (stops) hairlineCount += stops.length;
|
||||
}
|
||||
if (hairlineCount >= 2) {
|
||||
findings.push({ id: 'codex-grid-background', snippet: 'two-axis grid-line gradient background' });
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Provider tells (gated): "X theater" framing copy (GPT) ---
|
||||
// Lives here (regex-on-HTML) rather than in the text-content analyzers so it
|
||||
// runs in the bundled browser path too, not just the CLI/static path.
|
||||
@@ -2635,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,6 +1,9 @@
|
||||
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';
|
||||
import { finding } from '../../findings.mjs';
|
||||
import { filterByProviders } from '../../registry/antipatterns.mjs';
|
||||
import { profileFindings, profileStep } from '../../profile/profiler.mjs';
|
||||
@@ -36,11 +39,16 @@ 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+(#[0-9a-f]{3,8}|rgba?\([^)]+\)|\w+)/i);
|
||||
const m = str.match(/solid\s+((?:rgba?|hsla?|oklch|oklab|lab|lch|hwb|color)\([^)]*\)|#[0-9a-f]{3,8}\b|[a-z]+)/i);
|
||||
if (!m) return false;
|
||||
const c = m[1].toLowerCase();
|
||||
if (['gray', 'grey', 'silver', 'white', 'black', 'transparent', 'currentcolor'].includes(c)) return true;
|
||||
if (/^(?:rgba?|hsla?|oklch|oklab|lab|lch|hwb)\(/i.test(c)) return isNeutralColor(c);
|
||||
const hex = c.match(/^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/);
|
||||
if (hex) {
|
||||
const [r, g, b] = [parseInt(hex[1], 16), parseInt(hex[2], 16), parseInt(hex[3], 16)];
|
||||
@@ -57,10 +65,10 @@ function isNeutralBorderColor(str) {
|
||||
const REGEX_MATCHERS = [
|
||||
// --- Side-tab ---
|
||||
{ id: 'side-tab', regex: /\bborder-[lrse]-(\d+)\b/g,
|
||||
test: (m, line) => { const n = +m[1]; return hasRounded(line) ? n >= 1 : n >= 4; },
|
||||
test: (m, line) => { const n = +m[1]; return hasRounded(line) ? n >= 2 : n >= 4; },
|
||||
fmt: (m) => m[0] },
|
||||
{ id: 'side-tab', regex: /border-(?:left|right)\s*:\s*(\d+)px\s+solid[^;]*/gi,
|
||||
test: (m, line) => { if (isSafeElement(line)) return false; if (isNeutralBorderColor(m[0])) return false; const n = +m[1]; return hasBorderRadius(line) ? n >= 1 : n >= 3; },
|
||||
test: (m, line) => { if (isSafeElement(line)) return false; if (isNeutralBorderColor(m[0])) return false; const n = +m[1]; return hasBorderRadius(line) ? n >= 2 : n >= 3; },
|
||||
fmt: (m) => m[0].replace(/\s*;?\s*$/, '') },
|
||||
{ id: 'side-tab', regex: /border-(?:left|right)-width\s*:\s*(\d+)px/gi,
|
||||
test: (m, line) => !isSafeElement(line) && +m[1] >= 3,
|
||||
@@ -85,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),
|
||||
@@ -167,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');
|
||||
@@ -547,7 +555,10 @@ function detectText(content, filePath, options = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
return filterByProviders(deduped, options?.providers);
|
||||
const byProvider = filterByProviders(deduped, options?.providers);
|
||||
// Inline `impeccable-disable*` waivers travel with the file; honor them unless
|
||||
// explicitly bypassed (`--no-config` / `--no-inline-ignores`).
|
||||
return options?.inlineIgnores === false ? byProvider : applyInlineIgnores(byProvider, content);
|
||||
}
|
||||
|
||||
export {
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
mergeDesignSystemFindings,
|
||||
} from '../../design-system.mjs';
|
||||
import { isFullPage } from '../../shared/page.mjs';
|
||||
import { applyInlineIgnores } from '../../shared/inline-ignores.mjs';
|
||||
import { finding } from '../../findings.mjs';
|
||||
import { profileFindings, profileStep, profileStepAsync } from '../../profile/profiler.mjs';
|
||||
import {
|
||||
@@ -223,7 +224,11 @@ async function detectHtml(filePath, options = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
return filterByProviders(findings, options.providers);
|
||||
const byProvider = filterByProviders(findings, options.providers);
|
||||
// Static-HTML findings carry no line number, so only whole-file
|
||||
// `impeccable-disable` directives apply here — exactly the standalone-document
|
||||
// waiver this primitive targets. Bypassed by `--no-config` / `--no-inline-ignores`.
|
||||
return options?.inlineIgnores === false ? byProvider : applyInlineIgnores(byProvider, html);
|
||||
}
|
||||
|
||||
export { checkStaticPageTypography, STATIC_ELEMENT_RULES, detectHtml };
|
||||
|
||||
@@ -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) ──
|
||||
{
|
||||
@@ -376,6 +411,17 @@ const ANTIPATTERNS = [
|
||||
skillSection: 'Visual Details',
|
||||
skillGuideline: 'repeating-gradient decorative stripes',
|
||||
},
|
||||
{
|
||||
id: 'codex-grid-background',
|
||||
category: 'slop',
|
||||
severity: 'advisory',
|
||||
gated: 'gpt',
|
||||
name: 'Decorative grid-line background',
|
||||
description:
|
||||
'A two-axis grid drawn with hairline linear-gradient layers ("1px, transparent 1px" on both axes) is a recurring generated-UI signature. Reserve grid overlays for actual canvas, map, blueprint, or measurement surfaces; elsewhere use product structure or a plain surface.',
|
||||
skillSection: 'Visual Details',
|
||||
skillGuideline: 'two-axis grid-line gradient background',
|
||||
},
|
||||
{
|
||||
id: 'theater-slop-phrase',
|
||||
category: 'slop',
|
||||
@@ -437,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';
|
||||
|
||||
@@ -573,6 +574,42 @@ function checkHtmlPatterns(html) {
|
||||
findings.push({ id: 'repeating-stripes-gradient', snippet: 'repeating-gradient decorative stripes' });
|
||||
}
|
||||
|
||||
// --- Provider tells (gated): two-axis grid-line background (Codex/GPT) ---
|
||||
// The Codex grid tell is two hairline `linear-gradient(... <color> 1px,
|
||||
// transparent 1px)` layers (one per axis) tiled by a repeating
|
||||
// `background-size` cell. Both signals must co-occur in the SAME style block
|
||||
// (a CSS rule body or one inline `style="..."`): two hairline stops WITHOUT a
|
||||
// tiling background-size is a fixed crosshair, not a grid, and a single
|
||||
// hairline is a legitimate ruled line. Scoping to one block also stops
|
||||
// unrelated single-axis rules on separate elements from adding up across the
|
||||
// page. Count hairlines only inside `background`/`background-image` values so
|
||||
// a hairline in an unrelated property (mask-image, border-image) can't stand
|
||||
// in for the second axis. Colors like `oklch(96% 0.012 82 / 0.055)` carry
|
||||
// nested parens, so match the hairline stop directly rather than parsing
|
||||
// whole gradient layers.
|
||||
{
|
||||
const hairlineRe = /\b\d{1,3}px\s*,\s*transparent\s+\d{1,3}px/gi;
|
||||
const gridSizeRe = /background-size\s*:[^;{}"']*\b\d{1,3}px\b/i;
|
||||
const bgDeclRe = /\bbackground(?:-image)?\s*:\s*([^;{}"']*)/gi;
|
||||
const blockRe = /\{([^{}]*)\}|style\s*=\s*"([^"]*)"|style\s*=\s*'([^']*)'/gi;
|
||||
let blk;
|
||||
while ((blk = blockRe.exec(html)) !== null) {
|
||||
const block = blk[1] || blk[2] || blk[3] || '';
|
||||
if (!gridSizeRe.test(block)) continue;
|
||||
let hairlineCount = 0;
|
||||
let bm;
|
||||
bgDeclRe.lastIndex = 0;
|
||||
while ((bm = bgDeclRe.exec(block)) !== null) {
|
||||
const stops = bm[1].match(hairlineRe);
|
||||
if (stops) hairlineCount += stops.length;
|
||||
}
|
||||
if (hairlineCount >= 2) {
|
||||
findings.push({ id: 'codex-grid-background', snippet: 'two-axis grid-line gradient background' });
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Provider tells (gated): "X theater" framing copy (GPT) ---
|
||||
// Lives here (regex-on-HTML) rather than in the text-content analyzers so it
|
||||
// runs in the bundled browser path too, not just the CLI/static path.
|
||||
@@ -2036,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 };
|
||||
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* Inline, in-file ignore directives — eslint-disable-style waivers that live at
|
||||
* the point they apply and travel with the artifact instead of (or alongside)
|
||||
* an ignore in `.impeccable/config.json`.
|
||||
*
|
||||
* A config ignore is the right default for repo-wide policy. This complements it
|
||||
* for the one case config can't cover: a waiver that belongs to a single file and
|
||||
* needs to follow that file when it leaves the repo — a generated/exported
|
||||
* standalone document, an emailed HTML file, a snippet scanned out of context.
|
||||
*
|
||||
* Comment-syntax-agnostic: the directive is a raw token matched anywhere on a
|
||||
* line, so the same marker works across every comment style impeccable scans —
|
||||
* `//`, `/* *\/`, `<!-- -->`, `#`, `{/* *\/}`, `{# #}`. Trailing comment closers
|
||||
* are stripped before the rule list is parsed.
|
||||
*
|
||||
* Syntax (reason optional; eslint `--` or biome `:` separator):
|
||||
*
|
||||
* impeccable-disable <rule>[, <rule>...] [-- reason] whole file
|
||||
* impeccable-disable-line <rule>... [-- reason] the same line
|
||||
* impeccable-disable-next-line <rule>... [-- reason] the following line
|
||||
* impeccable-disable bare / `*` = every rule
|
||||
*
|
||||
* Examples:
|
||||
*
|
||||
* <!-- impeccable-disable overused-font -- exported brand doc, font is first-party -->
|
||||
* .brand { font-family: Inter; } /* impeccable-disable-line overused-font *\/
|
||||
* // impeccable-disable-next-line bounce-easing: intentional playful affordance
|
||||
*
|
||||
* Behavior is suppression, for parity with config ignores: a matched directive
|
||||
* drops the finding. The inline reason is self-documenting in the diff; it is not
|
||||
* required and is discarded at scan time (only used here to keep reason words out
|
||||
* of the parsed rule list).
|
||||
*/
|
||||
|
||||
const DIRECTIVE_RE = /impeccable-(disable-next-line|disable-line|disable)\b[ \t]*([^\n\r]*)/gi;
|
||||
|
||||
// Trailing comment closers, so `*/`, `*/}`, `-->`, `*}`, `#}`, `%>`, `}}` don't
|
||||
// leak into the rule list. Anchored to end-of-line; the leading `\s*` mops up the
|
||||
// space before the closer. `--+>` covers `-->` and any longer dash run.
|
||||
const TRAILING_CLOSER_RE = /\s*(?:\*\/\}?|--+>|\*\}|#\}|%>|\}\})\s*$/;
|
||||
|
||||
function normalizeRule(token) {
|
||||
return String(token || '').trim().toLowerCase();
|
||||
}
|
||||
|
||||
// Split the directive remainder into rule tokens, dropping any human reason that
|
||||
// follows an eslint-style `--` or biome-style `:` separator. Rule ids only ever
|
||||
// contain single hyphens (`overused-font`, `bounce-easing`), so `--` and `:`
|
||||
// are unambiguous separators.
|
||||
function parseRuleList(remainder) {
|
||||
let text = String(remainder || '').replace(TRAILING_CLOSER_RE, '').trim();
|
||||
// Cut off a human reason at the first `--` (eslint) or `:` (biome) separator.
|
||||
const reasonSep = text.match(/\s*(?:--+|:)\s*/);
|
||||
if (reasonSep) text = text.slice(0, reasonSep.index);
|
||||
const tokens = text.split(/[\s,]+/).map(normalizeRule).filter(Boolean);
|
||||
if (tokens.length === 0 || tokens.includes('*')) return ['*'];
|
||||
return tokens;
|
||||
}
|
||||
|
||||
function addRules(set, rules) {
|
||||
for (const rule of rules) set.add(rule);
|
||||
}
|
||||
|
||||
function getSet(map, key) {
|
||||
let set = map.get(key);
|
||||
if (!set) {
|
||||
set = new Set();
|
||||
map.set(key, set);
|
||||
}
|
||||
return set;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse every inline ignore directive in a file's raw text.
|
||||
*
|
||||
* Returns sets keyed by the 1-based line the directive *targets* so matching is a
|
||||
* direct lookup:
|
||||
* - file: rules disabled for the whole file
|
||||
* - line: line -> rules disabled on that exact line (disable-line)
|
||||
* - nextLine: line -> rules disabled on that line (disable-next-line on line-1)
|
||||
*
|
||||
* `*` in any set means "every rule".
|
||||
*/
|
||||
function parseInlineIgnores(content) {
|
||||
const result = { file: new Set(), line: new Map(), nextLine: new Map() };
|
||||
const text = typeof content === 'string' ? content : '';
|
||||
// Cheap bail-out: the substring must be present for any directive to exist.
|
||||
// Case-insensitive to match DIRECTIVE_RE's `i` flag (e.g. `Impeccable-Disable`).
|
||||
if (!/impeccable-disable/i.test(text)) return result;
|
||||
|
||||
// Split on `\n` only, exactly as detectText numbers lines, so directive line
|
||||
// keys line up with finding `line` values (incl. on `\r`-only line endings).
|
||||
// The directive regex excludes `\r`, so a trailing `\r` on `\r\n` files is
|
||||
// never captured into the rule list.
|
||||
const lines = text.split('\n');
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
DIRECTIVE_RE.lastIndex = 0;
|
||||
let m;
|
||||
while ((m = DIRECTIVE_RE.exec(lines[i])) !== null) {
|
||||
const variant = m[1].toLowerCase();
|
||||
const rules = parseRuleList(m[2]);
|
||||
if (variant === 'disable') {
|
||||
addRules(result.file, rules);
|
||||
} else if (variant === 'disable-line') {
|
||||
addRules(getSet(result.line, i + 1), rules);
|
||||
} else {
|
||||
// disable-next-line on line i+1 targets line i+2.
|
||||
addRules(getSet(result.nextLine, i + 2), rules);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function setMatches(set, rule) {
|
||||
return Boolean(set) && (set.has('*') || set.has(rule));
|
||||
}
|
||||
|
||||
function isInlineIgnored(finding, directives) {
|
||||
const rule = normalizeRule(finding && finding.antipattern);
|
||||
if (!rule) return false;
|
||||
if (setMatches(directives.file, rule)) return true;
|
||||
const line = Number(finding && finding.line) || 0;
|
||||
if (line > 0) {
|
||||
if (setMatches(directives.line.get(line), rule)) return true;
|
||||
if (setMatches(directives.nextLine.get(line), rule)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function hasDirectives(directives) {
|
||||
return directives.file.size > 0 || directives.line.size > 0 || directives.nextLine.size > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop findings waived by an inline directive in the same file's source text.
|
||||
* Findings without a usable line number (e.g. static-HTML page-level findings)
|
||||
* are only matched by whole-file directives — which is the standalone-document
|
||||
* case this primitive exists for.
|
||||
*/
|
||||
function applyInlineIgnores(findings, content) {
|
||||
if (!Array.isArray(findings) || findings.length === 0) return findings;
|
||||
const directives = parseInlineIgnores(content);
|
||||
if (!hasDirectives(directives)) return findings;
|
||||
return findings.filter((finding) => !isInlineIgnored(finding, directives));
|
||||
}
|
||||
|
||||
export { parseInlineIgnores, applyInlineIgnores, isInlineIgnored };
|
||||
@@ -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,
|
||||
@@ -75,7 +76,6 @@ const HOOK_MANIFEST_TARGETS = [
|
||||
skillRel: '.agents/skills/impeccable',
|
||||
destRel: '.codex/hooks.json',
|
||||
manifest: () => ({
|
||||
description: 'Impeccable design detector: runs after Edit/Write/apply_patch on UI files and surfaces findings as system reminders.',
|
||||
hooks: {
|
||||
PostToolUse: [
|
||||
{
|
||||
@@ -83,7 +83,7 @@ const HOOK_MANIFEST_TARGETS = [
|
||||
hooks: [
|
||||
{
|
||||
type: 'command',
|
||||
command: 'node "$(git rev-parse --show-toplevel)/.agents/skills/impeccable/scripts/hook.mjs"',
|
||||
command: 'node ".agents/skills/impeccable/scripts/hook.mjs"',
|
||||
timeout: TIMEOUT_SECONDS,
|
||||
statusMessage: STATUS_MESSAGE,
|
||||
},
|
||||
@@ -109,6 +109,28 @@ const HOOK_MANIFEST_TARGETS = [
|
||||
},
|
||||
}),
|
||||
},
|
||||
{
|
||||
// GitHub Copilot reads repo-level hooks from `.github/hooks/*.json`. The same
|
||||
// manifest is honored by the CLI (once committed to the default branch) and
|
||||
// the cloud/app agent. Schema differs: lowercase `postToolUse`, flat entries,
|
||||
// `bash`/`timeoutSec`, and a `matcher` regex against the `edit`/`create` tools.
|
||||
provider: '.github',
|
||||
skillRel: '.github/skills/impeccable',
|
||||
destRel: '.github/hooks/impeccable.json',
|
||||
manifest: () => ({
|
||||
version: 1,
|
||||
hooks: {
|
||||
postToolUse: [
|
||||
{
|
||||
type: 'command',
|
||||
matcher: 'edit|create|apply_patch',
|
||||
bash: 'node "$(git rev-parse --show-toplevel)/.github/skills/impeccable/scripts/hook.mjs"',
|
||||
timeoutSec: TIMEOUT_SECONDS,
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
},
|
||||
];
|
||||
|
||||
function readRawConfigFile(filePath) {
|
||||
@@ -163,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');
|
||||
@@ -400,7 +422,10 @@ function valueHasImpeccableHookMarker(value) {
|
||||
|
||||
function stripImpeccableHookEntry(entry) {
|
||||
if (!entry || typeof entry !== 'object') return entry;
|
||||
if (valueHasImpeccableHookMarker(entry.command) || valueHasImpeccableHookMarker(entry.args)) {
|
||||
// `command`/`args`: Claude/Codex/Cursor. `bash`/`powershell`: GitHub Copilot's
|
||||
// flat entry shape, where the marker lives under the shell-command keys.
|
||||
if (valueHasImpeccableHookMarker(entry.command) || valueHasImpeccableHookMarker(entry.args)
|
||||
|| valueHasImpeccableHookMarker(entry.bash) || valueHasImpeccableHookMarker(entry.powershell)) {
|
||||
return null;
|
||||
}
|
||||
if (!Array.isArray(entry.hooks)) return entry;
|
||||
@@ -489,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);
|
||||
@@ -500,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);
|
||||
@@ -545,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) {
|
||||
@@ -959,13 +1067,114 @@ export function resolveTargetFiles(event, projectCwd) {
|
||||
export function resolveHarness(env = {}, event = null) {
|
||||
const explicit = env?.IMPECCABLE_HOOK_HARNESS;
|
||||
if (explicit === 'cursor') return 'cursor';
|
||||
if (explicit === 'github') return 'github';
|
||||
if (explicit === 'claude' || explicit === 'codex') return 'claude';
|
||||
// GitHub Copilot's postToolUse event uses camelCase `toolName`/`toolArgs` and
|
||||
// has no `tool_name`/`tool_input`. That shape is the discriminator.
|
||||
if (event && typeof event === 'object'
|
||||
&& (typeof event.toolName === 'string' || event.toolArgs !== undefined)
|
||||
&& event.tool_name === undefined && event.tool_input === undefined) {
|
||||
return 'github';
|
||||
}
|
||||
if (typeof event?.conversation_id === 'string' && event.conversation_id) return 'cursor';
|
||||
return 'claude';
|
||||
}
|
||||
|
||||
// GitHub Copilot's postToolUse payload is
|
||||
// { sessionId, timestamp, cwd, toolName, toolArgs, toolResult }
|
||||
// mapped onto the internal `{ tool_name, tool_input, cwd, session_id }` shape.
|
||||
// `toolArgs` shape depends on the tool: the `edit`/`create`/`view` tools send a
|
||||
// JSON *string* (double-encoded) carrying the file under `path`, e.g.
|
||||
// "{\"path\":\"/abs/app.tsx\",\"old_str\":\"...\",\"new_str\":\"...\"}",
|
||||
// while `apply_patch` sends a raw OpenAI-format patch string (handled below in
|
||||
// normalizeGitHubEvent). The detector reads the file from disk after the tool
|
||||
// ran, so only the path (not the proposed content) is needed here.
|
||||
export function parseGitHubToolArgs(toolArgs) {
|
||||
if (toolArgs && typeof toolArgs === 'object' && !Array.isArray(toolArgs)) return toolArgs;
|
||||
if (typeof toolArgs === 'string' && toolArgs.trim()) {
|
||||
try {
|
||||
const parsed = JSON.parse(toolArgs);
|
||||
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
// Copilot's `apply_patch` tool (used by interactive sessions and the cloud
|
||||
// agent) sends a raw OpenAI-format patch string in toolArgs, not JSON:
|
||||
// *** Begin Patch
|
||||
// *** Add File: /abs/app.css
|
||||
// +body { ... }
|
||||
// *** End Patch
|
||||
// The `view`/`edit`/`create` tools (seen in `copilot -p` runs) instead send a
|
||||
// JSON string with the path under `path`. Both must map onto the internal shape.
|
||||
const APPLY_PATCH_MARKER = /\*\*\* (?:Begin Patch|Add File:|Update File:|Delete File:)/;
|
||||
|
||||
function looksLikeApplyPatch(rawArgs) {
|
||||
if (typeof rawArgs !== 'string' || !APPLY_PATCH_MARKER.test(rawArgs)) return false;
|
||||
// Guard against an edit/create payload whose edited *content* happens to
|
||||
// contain patch markers: that payload is a JSON object string, whereas a real
|
||||
// apply_patch payload is a raw patch string that does not parse as JSON. Only
|
||||
// treat non-JSON-object strings as apply_patch so edit events still get their
|
||||
// `path` extracted.
|
||||
try {
|
||||
const parsed = JSON.parse(rawArgs);
|
||||
if (parsed && typeof parsed === 'object') return false;
|
||||
} catch { /* not JSON → genuine raw patch */ }
|
||||
return true;
|
||||
}
|
||||
|
||||
function applyPatchText(rawArgs) {
|
||||
if (typeof rawArgs === 'string') {
|
||||
if (APPLY_PATCH_MARKER.test(rawArgs)) return rawArgs;
|
||||
// Defensive: a future Copilot build might JSON-wrap the patch.
|
||||
const parsed = parseGitHubToolArgs(rawArgs);
|
||||
return parsed.patch || parsed.input || parsed.command || '';
|
||||
}
|
||||
if (rawArgs && typeof rawArgs === 'object' && !Array.isArray(rawArgs)) {
|
||||
return rawArgs.patch || rawArgs.input || rawArgs.command || '';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function normalizeGitHubEvent(event, projectCwd) {
|
||||
const cwd = event.cwd || envProjectDir(projectCwd) || projectCwd;
|
||||
const sessionId = event.sessionId || event.session_id || 'unknown';
|
||||
const toolName = event.toolName || event.tool_name || null;
|
||||
const toolInput = event.tool_input && typeof event.tool_input === 'object' ? { ...event.tool_input } : {};
|
||||
const rawArgs = event.toolArgs;
|
||||
|
||||
let normalizedToolName = toolName;
|
||||
if (toolName === 'apply_patch' || looksLikeApplyPatch(rawArgs)) {
|
||||
// resolveTargetFiles() reads the touched paths from tool_input.command when
|
||||
// tool_name is 'apply_patch', so normalize the name even if a future build
|
||||
// sends the patch under a different tool label.
|
||||
const patch = applyPatchText(rawArgs);
|
||||
if (patch) {
|
||||
toolInput.command = patch;
|
||||
normalizedToolName = 'apply_patch';
|
||||
}
|
||||
} else {
|
||||
const args = parseGitHubToolArgs(rawArgs);
|
||||
const filePath = args.path || args.file_path || args.filePath || args.target_file;
|
||||
if (typeof filePath === 'string' && filePath) toolInput.file_path = filePath;
|
||||
}
|
||||
|
||||
return {
|
||||
...event,
|
||||
cwd,
|
||||
session_id: sessionId,
|
||||
tool_name: normalizedToolName,
|
||||
tool_input: toolInput,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeHookEvent(event, projectCwd, harness = 'claude') {
|
||||
if (!event || typeof event !== 'object' || harness !== 'cursor') return event;
|
||||
if (!event || typeof event !== 'object') return event;
|
||||
if (harness === 'github') return normalizeGitHubEvent(event, projectCwd);
|
||||
if (harness !== 'cursor') return event;
|
||||
|
||||
const cwd = event.cwd
|
||||
|| (Array.isArray(event.workspace_roots) && event.workspace_roots[0])
|
||||
@@ -1200,12 +1409,12 @@ export function setDetectorForTesting(impl) {
|
||||
// session" so the model knows it's a re-mind, not a new finding.
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
|
||||
const STEER_LINE = 'Keep typography hierarchy, spacing rhythm, and color contrast intentional on the next change.';
|
||||
const STEER_LINE = 'That does not mean the design is good: keep following the project design system and the impeccable skill guidance.';
|
||||
|
||||
export function renderCleanAck(filePath, opts = {}) {
|
||||
const cwd = opts.cwd || process.cwd();
|
||||
const display = relativize(filePath, cwd);
|
||||
return `${ENVELOPE_PREFIX} Design hook scanned ${display}. No anti-patterns. ${STEER_LINE}`;
|
||||
return `${ENVELOPE_PREFIX} Design hook scanned ${display}. No deterministic design-quality issues found. ${STEER_LINE}`;
|
||||
}
|
||||
|
||||
export function renderPendingAck(filePath, knownFindings, opts = {}) {
|
||||
@@ -1218,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) {
|
||||
@@ -1235,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
|
||||
@@ -1252,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. Do not add source comments such as \`impeccable: ignore\`; those pollute the code and do not suppress hook findings. Persist hook ignores only after the user explicitly confirms the finding is intentional. 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');
|
||||
}
|
||||
|
||||
@@ -1301,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;
|
||||
@@ -1318,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);
|
||||
@@ -1334,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;
|
||||
@@ -1348,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;
|
||||
}
|
||||
@@ -1366,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) {
|
||||
@@ -1382,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; }
|
||||
@@ -1395,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;
|
||||
}
|
||||
@@ -1412,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];
|
||||
@@ -1447,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,
|
||||
@@ -1481,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,
|
||||
@@ -1520,6 +1754,11 @@ export function payload(text, eventName = 'PostToolUse', harness = 'claude') {
|
||||
if (harness === 'cursor') {
|
||||
return JSON.stringify({ additional_context: text });
|
||||
}
|
||||
// GitHub Copilot's postToolUse hook injects context via a top-level
|
||||
// `additionalContext` string (alongside an optional `modifiedResult`).
|
||||
if (harness === 'github') {
|
||||
return JSON.stringify({ additionalContext: text });
|
||||
}
|
||||
return JSON.stringify({
|
||||
hookSpecificOutput: { hookEventName: eventName, additionalContext: text },
|
||||
});
|
||||
|
||||
@@ -0,0 +1,640 @@
|
||||
/**
|
||||
* CLI-side reader/writer for the unified `.impeccable` config.
|
||||
*
|
||||
* The CLI (published to npm) and the skill scripts (bundled into the install)
|
||||
* live in separate trees and cannot share runtime code, so this duplicates a
|
||||
* small slice of skill/scripts/hook-lib.mjs — the config-path layout, detector
|
||||
* ignore semantics, and the `.git/info/exclude` handling. Keep the schema,
|
||||
* ignore filtering, and exclude marker in sync if either side changes.
|
||||
*
|
||||
* Schema (config.json shared / config.local.json gitignored, per-developer):
|
||||
* {
|
||||
* "detector": { "ignoreRules": [], "ignoreFiles": [], "ignoreValues": [], "designSystem": { "enabled": true } },
|
||||
* "hook": { "consent": "accepted" | "declined", ... },
|
||||
* "updateCheck": bool
|
||||
* }
|
||||
*/
|
||||
|
||||
import { existsSync, readFileSync, writeFileSync, mkdirSync, statSync } from 'node:fs';
|
||||
import { join, dirname, isAbsolute, relative, resolve, sep } from 'node:path';
|
||||
|
||||
export function getConfigPath(root) {
|
||||
return join(root, '.impeccable', 'config.json');
|
||||
}
|
||||
|
||||
export function getLocalConfigPath(root) {
|
||||
return join(root, '.impeccable', 'config.local.json');
|
||||
}
|
||||
|
||||
function safeReadJson(filePath) {
|
||||
try {
|
||||
const raw = JSON.parse(readFileSync(filePath, 'utf-8'));
|
||||
return raw && typeof raw === 'object' && !Array.isArray(raw) ? raw : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function hookSection(raw) {
|
||||
return raw && raw.hook && typeof raw.hook === 'object' && !Array.isArray(raw.hook) ? raw.hook : null;
|
||||
}
|
||||
|
||||
function detectorSection(raw) {
|
||||
return raw && raw.detector && typeof raw.detector === 'object' && !Array.isArray(raw.detector) ? raw.detector : null;
|
||||
}
|
||||
|
||||
const DETECTOR_CONFIG_KEYS = new Set(['ignoreRules', 'ignoreFiles', 'ignoreValues', 'designSystem']);
|
||||
|
||||
const DEFAULT_DETECTION_CONFIG = Object.freeze({
|
||||
ignoreRules: [],
|
||||
ignoreFiles: [],
|
||||
ignoreValues: [],
|
||||
designSystem: { enabled: true },
|
||||
});
|
||||
|
||||
function cloneDetectionConfig() {
|
||||
return {
|
||||
ignoreRules: [],
|
||||
ignoreFiles: [],
|
||||
ignoreValues: [],
|
||||
designSystem: { ...DEFAULT_DETECTION_CONFIG.designSystem },
|
||||
};
|
||||
}
|
||||
|
||||
function cloneRawDetectionConfig() {
|
||||
return {
|
||||
ignoreRules: [],
|
||||
ignoreFiles: [],
|
||||
ignoreValues: [],
|
||||
};
|
||||
}
|
||||
|
||||
function applyDetectionConfigSource(config, raw) {
|
||||
if (!raw || typeof raw !== 'object') return config;
|
||||
if (raw.designSystem && typeof raw.designSystem === 'object' && !Array.isArray(raw.designSystem)) {
|
||||
config.designSystem = {
|
||||
...config.designSystem,
|
||||
enabled: raw.designSystem.enabled === false ? false : true,
|
||||
};
|
||||
}
|
||||
if (Array.isArray(raw.ignoreRules)) {
|
||||
config.ignoreRules = uniqueStrings([...config.ignoreRules, ...raw.ignoreRules]);
|
||||
}
|
||||
if (Array.isArray(raw.ignoreFiles)) {
|
||||
config.ignoreFiles = uniqueStrings([...config.ignoreFiles, ...raw.ignoreFiles]);
|
||||
}
|
||||
if (Array.isArray(raw.ignoreValues)) {
|
||||
config.ignoreValues = mergeIgnoreValues(config.ignoreValues, raw.ignoreValues);
|
||||
}
|
||||
return config;
|
||||
}
|
||||
|
||||
function uniqueStrings(values) {
|
||||
return Array.from(new Set(values.map(String)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Detector filters shared by `npx impeccable detect` and the design hook.
|
||||
* `hook.enabled` remains hook lifecycle state; manual CLI scans still run when
|
||||
* the hook is disabled, but they honor the same ignore rules and design-system
|
||||
* toggle.
|
||||
*/
|
||||
export function readDetectionConfig(root) {
|
||||
const config = cloneDetectionConfig();
|
||||
for (const filePath of [getConfigPath(root), getLocalConfigPath(root)]) {
|
||||
const raw = safeReadJson(filePath);
|
||||
// Back-compat: old builds stored detector filters under hook.*.
|
||||
applyDetectionConfigSource(config, hookSection(raw));
|
||||
applyDetectionConfigSource(config, detectorSection(raw));
|
||||
}
|
||||
return config;
|
||||
}
|
||||
|
||||
export function readRawDetectionConfig(root, opts = {}) {
|
||||
const raw = safeReadJson(opts.local ? getLocalConfigPath(root) : getConfigPath(root));
|
||||
const config = cloneRawDetectionConfig();
|
||||
applyDetectionConfigSource(config, hookSection(raw));
|
||||
applyDetectionConfigSource(config, detectorSection(raw));
|
||||
return config;
|
||||
}
|
||||
|
||||
export function writeDetectionConfig(root, detectorConfig, opts = {}) {
|
||||
const filePath = opts.local ? getLocalConfigPath(root) : getConfigPath(root);
|
||||
if (opts.local) ensureConfigGitExclude(root);
|
||||
const existing = safeReadJson(filePath) || {};
|
||||
const existingHook = hookSection(existing);
|
||||
const nextHook = stripDetectorKeys(existingHook);
|
||||
const nextDetector = {
|
||||
...(detectorSection(existing) || {}),
|
||||
...normalizeDetectionConfigForWrite(detectorConfig),
|
||||
};
|
||||
const next = {
|
||||
...existing,
|
||||
detector: nextDetector,
|
||||
};
|
||||
if (nextHook && Object.keys(nextHook).length > 0) {
|
||||
next.hook = nextHook;
|
||||
} else {
|
||||
delete next.hook;
|
||||
}
|
||||
mkdirSync(dirname(filePath), { recursive: true });
|
||||
writeFileSync(filePath, `${JSON.stringify(next, null, 2)}\n`);
|
||||
return filePath;
|
||||
}
|
||||
|
||||
function normalizeDetectionConfigForWrite(config) {
|
||||
const out = {};
|
||||
if (Array.isArray(config?.ignoreRules)) {
|
||||
out.ignoreRules = uniqueStrings(config.ignoreRules.map((rule) => normalizeIgnoreRule(rule)).filter(Boolean));
|
||||
}
|
||||
if (Array.isArray(config?.ignoreFiles)) {
|
||||
out.ignoreFiles = uniqueStrings(config.ignoreFiles.filter(v => typeof v === 'string' && v.trim()).map(v => v.trim()));
|
||||
}
|
||||
out.ignoreValues = normalizeIgnoreValueEntries(config?.ignoreValues || []);
|
||||
if (config?.designSystem && typeof config.designSystem === 'object' && !Array.isArray(config.designSystem)) {
|
||||
out.designSystem = {
|
||||
enabled: config.designSystem.enabled === false ? false : true,
|
||||
};
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function stripDetectorKeys(raw) {
|
||||
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
|
||||
const out = {};
|
||||
for (const [key, value] of Object.entries(raw)) {
|
||||
if (!DETECTOR_CONFIG_KEYS.has(key)) out[key] = value;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function normalizeIgnoreValue(value) {
|
||||
return String(value || '')
|
||||
.trim()
|
||||
.replace(/^["']|["']$/g, '')
|
||||
.replace(/\+/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
function normalizeIgnoreRule(rule) {
|
||||
return String(rule || '').trim().toLowerCase();
|
||||
}
|
||||
|
||||
function colorIgnoreKey(value) {
|
||||
const color = parseIgnoreColor(value);
|
||||
if (!color) return '';
|
||||
return `${color.r},${color.g},${color.b},${Math.round(color.a * 255)}`;
|
||||
}
|
||||
|
||||
function parseIgnoreColor(value) {
|
||||
const text = String(value || '').trim().toLowerCase();
|
||||
if (!text) return null;
|
||||
|
||||
const hex = text.match(/^#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/i);
|
||||
if (hex) return parseHexIgnoreColor(hex[1]);
|
||||
|
||||
const rgb = text.match(/^rgba?\((.*)\)$/i);
|
||||
if (rgb) {
|
||||
const parts = splitColorArgs(rgb[1]);
|
||||
if (parts.length < 3 || parts.length > 4) return null;
|
||||
const r = parseRgbChannel(parts[0]);
|
||||
const g = parseRgbChannel(parts[1]);
|
||||
const b = parseRgbChannel(parts[2]);
|
||||
const a = parts[3] === undefined ? 1 : parseAlphaChannel(parts[3]);
|
||||
if ([r, g, b, a].some((v) => v === null)) return null;
|
||||
return { r, g, b, a };
|
||||
}
|
||||
|
||||
const hsl = text.match(/^hsla?\((.*)\)$/i);
|
||||
if (hsl) {
|
||||
const parts = splitColorArgs(hsl[1]);
|
||||
if (parts.length < 3 || parts.length > 4) return null;
|
||||
const h = parseHueChannel(parts[0]);
|
||||
const s = parsePercentChannel(parts[1]);
|
||||
const l = parsePercentChannel(parts[2]);
|
||||
const a = parts[3] === undefined ? 1 : parseAlphaChannel(parts[3]);
|
||||
if ([h, s, l, a].some((v) => v === null)) return null;
|
||||
return hslToRgb(h, s, l, a);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseHexIgnoreColor(hex) {
|
||||
if (hex.length === 3 || hex.length === 4) {
|
||||
const r = parseInt(hex[0] + hex[0], 16);
|
||||
const g = parseInt(hex[1] + hex[1], 16);
|
||||
const b = parseInt(hex[2] + hex[2], 16);
|
||||
const a = hex.length === 4 ? parseInt(hex[3] + hex[3], 16) / 255 : 1;
|
||||
return { r, g, b, a };
|
||||
}
|
||||
const r = parseInt(hex.slice(0, 2), 16);
|
||||
const g = parseInt(hex.slice(2, 4), 16);
|
||||
const b = parseInt(hex.slice(4, 6), 16);
|
||||
const a = hex.length === 8 ? parseInt(hex.slice(6, 8), 16) / 255 : 1;
|
||||
return { r, g, b, a };
|
||||
}
|
||||
|
||||
function splitColorArgs(body) {
|
||||
const text = String(body || '').trim();
|
||||
if (!text) return [];
|
||||
if (text.includes(',')) {
|
||||
const parts = text.split(',').map((part) => part.trim()).filter(Boolean);
|
||||
const last = parts[parts.length - 1];
|
||||
if (last && last.includes('/')) {
|
||||
const split = last.split('/').map((part) => part.trim()).filter(Boolean);
|
||||
return [...parts.slice(0, -1), ...split];
|
||||
}
|
||||
return parts;
|
||||
}
|
||||
return text.replace(/\s*\/\s*/g, ' / ').split(/\s+/).filter((part) => part && part !== '/');
|
||||
}
|
||||
|
||||
function parseRgbChannel(raw) {
|
||||
const text = String(raw || '').trim();
|
||||
const match = text.match(/^(-?\d*\.?\d+)(%)?$/);
|
||||
if (!match) return null;
|
||||
const value = Number.parseFloat(match[1]);
|
||||
if (!Number.isFinite(value)) return null;
|
||||
const scaled = match[2] ? value * 2.55 : value;
|
||||
if (scaled < 0 || scaled > 255) return null;
|
||||
return Math.round(scaled);
|
||||
}
|
||||
|
||||
function parseAlphaChannel(raw) {
|
||||
const text = String(raw || '').trim();
|
||||
const match = text.match(/^(-?\d*\.?\d+)(%)?$/);
|
||||
if (!match) return null;
|
||||
const value = Number.parseFloat(match[1]);
|
||||
if (!Number.isFinite(value)) return null;
|
||||
const alpha = match[2] ? value / 100 : value;
|
||||
return alpha >= 0 && alpha <= 1 ? alpha : null;
|
||||
}
|
||||
|
||||
function parseHueChannel(raw) {
|
||||
const text = String(raw || '').trim();
|
||||
const match = text.match(/^(-?\d*\.?\d+)(deg|rad|turn|grad)?$/);
|
||||
if (!match) return null;
|
||||
const value = Number.parseFloat(match[1]);
|
||||
if (!Number.isFinite(value)) return null;
|
||||
const unit = match[2] || 'deg';
|
||||
if (unit === 'turn') return value * 360;
|
||||
if (unit === 'rad') return value * (180 / Math.PI);
|
||||
if (unit === 'grad') return value * 0.9;
|
||||
return value;
|
||||
}
|
||||
|
||||
function parsePercentChannel(raw) {
|
||||
const text = String(raw || '').trim();
|
||||
const match = text.match(/^(-?\d*\.?\d+)%$/);
|
||||
if (!match) return null;
|
||||
const value = Number.parseFloat(match[1]);
|
||||
if (!Number.isFinite(value)) return null;
|
||||
return value >= 0 && value <= 100 ? value / 100 : null;
|
||||
}
|
||||
|
||||
function hslToRgb(hue, saturation, lightness, alpha) {
|
||||
const h = (((hue % 360) + 360) % 360) / 360;
|
||||
if (saturation === 0) {
|
||||
const gray = clampByte(Math.round(lightness * 255));
|
||||
return { r: gray, g: gray, b: gray, a: alpha };
|
||||
}
|
||||
const q = lightness < 0.5
|
||||
? lightness * (1 + saturation)
|
||||
: lightness + saturation - lightness * saturation;
|
||||
const p = 2 * lightness - q;
|
||||
const toRgb = (t) => {
|
||||
let channel = t;
|
||||
if (channel < 0) channel += 1;
|
||||
if (channel > 1) channel -= 1;
|
||||
if (channel < 1 / 6) return p + (q - p) * 6 * channel;
|
||||
if (channel < 1 / 2) return q;
|
||||
if (channel < 2 / 3) return p + (q - p) * (2 / 3 - channel) * 6;
|
||||
return p;
|
||||
};
|
||||
return {
|
||||
r: clampByte(Math.round(toRgb(h + 1 / 3) * 255)),
|
||||
g: clampByte(Math.round(toRgb(h) * 255)),
|
||||
b: clampByte(Math.round(toRgb(h - 1 / 3) * 255)),
|
||||
a: alpha,
|
||||
};
|
||||
}
|
||||
|
||||
function clampByte(value) {
|
||||
return Math.min(255, Math.max(0, value));
|
||||
}
|
||||
|
||||
function ignoreValueMatches(rule, entryValue, findingValue) {
|
||||
if (entryValue === findingValue) return true;
|
||||
if (rule !== 'design-system-color') return false;
|
||||
const entryColor = colorIgnoreKey(entryValue);
|
||||
return Boolean(entryColor && entryColor === colorIgnoreKey(findingValue));
|
||||
}
|
||||
|
||||
export function normalizeIgnoreValueEntries(entries) {
|
||||
if (!Array.isArray(entries)) return [];
|
||||
const out = [];
|
||||
for (const entry of entries) {
|
||||
if (!entry || typeof entry !== 'object') continue;
|
||||
const rule = normalizeIgnoreRule(entry.rule);
|
||||
const value = normalizeIgnoreValue(entry.value);
|
||||
if (!rule || !value) continue;
|
||||
const normalized = { rule, value };
|
||||
const files = uniqueStrings([
|
||||
...(typeof entry.file === 'string' && entry.file.trim() ? [entry.file.trim()] : []),
|
||||
...(Array.isArray(entry.files) ? entry.files.filter(v => typeof v === 'string' && v.trim()).map(v => v.trim()) : []),
|
||||
]);
|
||||
if (files.length > 0) normalized.files = files;
|
||||
if (typeof entry.reason === 'string' && entry.reason.trim()) {
|
||||
normalized.reason = entry.reason.trim();
|
||||
}
|
||||
if (typeof entry.createdAt === 'string' && entry.createdAt.trim()) {
|
||||
normalized.createdAt = entry.createdAt.trim();
|
||||
}
|
||||
out.push(normalized);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function mergeIgnoreValues(existing, incoming) {
|
||||
const map = new Map();
|
||||
for (const entry of normalizeIgnoreValueEntries(existing)) {
|
||||
map.set(`${entry.rule}\0${entry.value}\0${ignoreValueFilesKey(entry.files)}`, entry);
|
||||
}
|
||||
for (const entry of normalizeIgnoreValueEntries(incoming)) {
|
||||
map.set(`${entry.rule}\0${entry.value}\0${ignoreValueFilesKey(entry.files)}`, entry);
|
||||
}
|
||||
return Array.from(map.values());
|
||||
}
|
||||
|
||||
function ignoreValueFilesKey(files) {
|
||||
return Array.isArray(files) && files.length > 0 ? files.join('\x1f') : '';
|
||||
}
|
||||
|
||||
// Glob -> RegExp. Supports `**`, `*`, `?`, and `{a,b}` alternation.
|
||||
function globToRegex(glob) {
|
||||
let re = '^';
|
||||
let i = 0;
|
||||
while (i < glob.length) {
|
||||
const c = glob[i];
|
||||
if (c === '*') {
|
||||
if (glob[i + 1] === '*') {
|
||||
re += '.*';
|
||||
i += 2;
|
||||
if (glob[i] === '/') i += 1;
|
||||
} else {
|
||||
re += '[^/]*';
|
||||
i += 1;
|
||||
}
|
||||
} else if (c === '?') {
|
||||
re += '[^/]';
|
||||
i += 1;
|
||||
} else if (c === '{') {
|
||||
const end = glob.indexOf('}', i);
|
||||
if (end === -1) { re += '\\{'; i += 1; continue; }
|
||||
const parts = glob.slice(i + 1, end).split(',').map((p) => p.replace(/[.+^$()|[\]\\]/g, '\\$&'));
|
||||
re += `(?:${parts.join('|')})`;
|
||||
i = end + 1;
|
||||
} else if (/[.+^$()|[\]\\]/.test(c)) {
|
||||
re += `\\${c}`;
|
||||
i += 1;
|
||||
} else {
|
||||
re += c;
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
re += '$';
|
||||
return new RegExp(re);
|
||||
}
|
||||
|
||||
export function matchesAnyGlob(filePath, globs) {
|
||||
if (!Array.isArray(globs) || globs.length === 0) return false;
|
||||
const normalized = String(filePath || '').split(sep).join('/');
|
||||
for (const glob of globs) {
|
||||
try {
|
||||
const re = globToRegex(String(glob));
|
||||
if (re.test(normalized)) return true;
|
||||
const base = normalized.split('/').pop();
|
||||
if (re.test(base)) return true;
|
||||
} catch {
|
||||
/* malformed glob, skip */
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function shouldIgnoreDetectionFile(filePath, root, config) {
|
||||
const globs = config?.ignoreFiles || [];
|
||||
if (!Array.isArray(globs) || globs.length === 0) return false;
|
||||
const raw = String(filePath || '').trim();
|
||||
if (!raw) return false;
|
||||
if (matchesAnyGlob(raw, globs)) return true;
|
||||
|
||||
try {
|
||||
const abs = isAbsolute(raw) ? raw : resolve(root, raw);
|
||||
if (matchesAnyGlob(abs, globs)) return true;
|
||||
const rel = relative(root, abs);
|
||||
if (rel && !rel.startsWith('..') && !isAbsolute(rel)) {
|
||||
return matchesAnyGlob(rel, globs);
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function filterDetectionFindings(findings, config) {
|
||||
if (!Array.isArray(findings) || findings.length === 0) return [];
|
||||
const ignoreRules = new Set((config?.ignoreRules || []).map((rule) => normalizeIgnoreRule(rule)));
|
||||
const ignoreValues = normalizeIgnoreValueEntries(config?.ignoreValues || []);
|
||||
return findings.filter((finding) => {
|
||||
if (!finding || typeof finding !== 'object') return false;
|
||||
if (ignoreRules.has(normalizeIgnoreRule(finding.antipattern))) return false;
|
||||
if (isIgnoredFindingValue(finding, ignoreValues)) return false;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
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);
|
||||
return ignoreValues.some((entry) => {
|
||||
if (entry.rule !== rule) return false;
|
||||
const wildcardValue = entry.value === '*';
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
function findingMatchesScopedIgnoreFile(finding, globs) {
|
||||
const filePath = String(finding?.file || '').trim();
|
||||
if (!filePath) return false;
|
||||
if (matchesAnyGlob(filePath, globs)) return true;
|
||||
|
||||
const normalized = filePath.split(sep).join('/');
|
||||
const parts = normalized.split('/').filter(Boolean);
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
const suffix = parts.slice(i).join('/');
|
||||
if (matchesAnyGlob(suffix, globs)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function extractFindingIgnoreValue(finding) {
|
||||
if (!finding || typeof finding !== 'object') return '';
|
||||
const rule = normalizeIgnoreRule(finding.antipattern);
|
||||
const directValueRules = new Set([
|
||||
'overused-font',
|
||||
'bounce-easing',
|
||||
'design-system-font',
|
||||
'design-system-color',
|
||||
'design-system-radius',
|
||||
]);
|
||||
if (!directValueRules.has(rule)) return '';
|
||||
return normalizeIgnoreValue(extractFindingIgnoreValueRaw(finding, rule));
|
||||
}
|
||||
|
||||
function extractFindingIgnoreValueRaw(finding, rule = normalizeIgnoreRule(finding?.antipattern)) {
|
||||
const direct = cleanIgnoreValueDisplay(finding.ignoreValue || finding.value || '');
|
||||
if (direct) return direct;
|
||||
|
||||
const candidates = [finding.detail, finding.snippet].filter((v) => typeof v === 'string' && v);
|
||||
for (const text of candidates) {
|
||||
if (rule === 'bounce-easing') {
|
||||
const motion = extractMotionIgnoreValue(text);
|
||||
if (motion) return motion;
|
||||
continue;
|
||||
}
|
||||
|
||||
const primary = text.match(/Primary font:\s*([^()\n;]+)/i);
|
||||
if (primary) return cleanIgnoreValueDisplay(primary[1]);
|
||||
|
||||
const family = text.match(/font-family\s*:\s*["']?([^'",;\n]+)/i);
|
||||
if (family) return cleanIgnoreValueDisplay(family[1]);
|
||||
|
||||
const google = text.match(/[?&]family=([^&:;\n]+)/i);
|
||||
if (google) {
|
||||
try {
|
||||
return cleanIgnoreValueDisplay(decodeURIComponent(google[1]));
|
||||
} catch {
|
||||
return cleanIgnoreValueDisplay(google[1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
function extractMotionIgnoreValue(text) {
|
||||
const tailwind = text.match(/\banimate-bounce\b/i);
|
||||
if (tailwind) return cleanIgnoreValueDisplay(tailwind[0]);
|
||||
|
||||
const bezier = text.match(/cubic-bezier\([^)]+\)/i);
|
||||
if (bezier) return cleanIgnoreValueDisplay(bezier[0]);
|
||||
|
||||
const animation = text.match(/animation(?:-name)?\s*:\s*([^;\n]+)/i);
|
||||
if (animation) {
|
||||
const token = animation[1]
|
||||
.split(/[,\s]+/)
|
||||
.find((part) => /bounce|elastic|wobble|jiggle|spring/i.test(part));
|
||||
if (token) return cleanIgnoreValueDisplay(token);
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
function cleanIgnoreValueDisplay(value) {
|
||||
return String(value || '')
|
||||
.trim()
|
||||
.replace(/^["']|["']$/g, '')
|
||||
.replace(/\+/g, ' ')
|
||||
.replace(/\s+/g, ' ');
|
||||
}
|
||||
|
||||
/**
|
||||
* The recorded design-hook decision: 'accepted' | 'declined' | undefined.
|
||||
* config.local.json (per-developer) overrides config.json.
|
||||
*/
|
||||
export function getHookConsent(root) {
|
||||
let consent;
|
||||
for (const filePath of [getConfigPath(root), getLocalConfigPath(root)]) {
|
||||
const hook = hookSection(safeReadJson(filePath));
|
||||
if (hook && (hook.consent === 'accepted' || hook.consent === 'declined')) consent = hook.consent;
|
||||
}
|
||||
return consent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist the per-developer decision to config.local.json, preserving any
|
||||
* sibling keys, and ensure the file is gitignored.
|
||||
*/
|
||||
export function setHookConsent(root, value) {
|
||||
const filePath = getLocalConfigPath(root);
|
||||
const existing = safeReadJson(filePath) || {};
|
||||
const hook = hookSection(existing) || {};
|
||||
const next = { ...existing, hook: { ...hook, consent: value } };
|
||||
mkdirSync(dirname(filePath), { recursive: true });
|
||||
writeFileSync(filePath, `${JSON.stringify(next, null, 2)}\n`);
|
||||
ensureConfigGitExclude(root);
|
||||
return filePath;
|
||||
}
|
||||
|
||||
const EXCLUDE_OPEN = '# impeccable-config-ignore-start';
|
||||
const EXCLUDE_CLOSE = '# impeccable-config-ignore-end';
|
||||
const EXCLUDE_PATTERNS = ['.impeccable/config.local.json'];
|
||||
|
||||
/**
|
||||
* Add config.local.json to `.git/info/exclude` so a developer's decision is
|
||||
* never committed. Idempotent via marker comments. Best-effort; returns false
|
||||
* when there is no resolvable git dir.
|
||||
*/
|
||||
export function ensureConfigGitExclude(root) {
|
||||
try {
|
||||
const gitDir = resolveGitDir(root);
|
||||
if (!gitDir) return false;
|
||||
const target = join(gitDir, 'info', 'exclude');
|
||||
const existing = existsSync(target) ? readFileSync(target, 'utf-8') : '';
|
||||
const block = [EXCLUDE_OPEN, ...EXCLUDE_PATTERNS, EXCLUDE_CLOSE].join('\n');
|
||||
const markerRe = new RegExp(`${escapeRegExp(EXCLUDE_OPEN)}[\\s\\S]*?${escapeRegExp(EXCLUDE_CLOSE)}`);
|
||||
let updated;
|
||||
if (markerRe.test(existing)) {
|
||||
updated = existing.replace(markerRe, block);
|
||||
} else {
|
||||
const prefix = existing.length === 0 ? '' : existing.endsWith('\n') ? existing : `${existing}\n`;
|
||||
updated = `${prefix}${block}\n`;
|
||||
}
|
||||
if (updated !== existing) {
|
||||
mkdirSync(dirname(target), { recursive: true });
|
||||
writeFileSync(target, updated);
|
||||
}
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveGitDir(root) {
|
||||
const dotGit = join(root, '.git');
|
||||
if (!existsSync(dotGit)) return null;
|
||||
try {
|
||||
if (statSync(dotGit).isDirectory()) return dotGit;
|
||||
// A `.git` file (worktree/submodule) points elsewhere: "gitdir: <path>".
|
||||
const match = readFileSync(dotGit, 'utf-8').match(/gitdir:\s*(.+)/);
|
||||
if (match) {
|
||||
const resolved = match[1].trim();
|
||||
return isAbsolute(resolved) ? resolved : join(root, resolved);
|
||||
}
|
||||
} catch {
|
||||
/* fall through */
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function escapeRegExp(value) {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
@@ -1,50 +1,53 @@
|
||||
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';
|
||||
export const CRITIQUE_DIR = 'critique';
|
||||
|
||||
export function getImpeccableDir(cwd = process.cwd()) {
|
||||
return path.join(cwd, IMPECCABLE_DIR);
|
||||
export function getImpeccableDir(cwd = process.cwd(), options = {}) {
|
||||
return path.join(resolveProjectRoot(cwd, options), IMPECCABLE_DIR);
|
||||
}
|
||||
|
||||
export function getDesignSidecarPath(cwd = process.cwd()) {
|
||||
return path.join(getImpeccableDir(cwd), 'design.json');
|
||||
export function getDesignSidecarPath(cwd = process.cwd(), options = {}) {
|
||||
return path.join(getImpeccableDir(cwd, options), 'design.json');
|
||||
}
|
||||
|
||||
export function getDesignSidecarCandidates(cwd = process.cwd(), contextDir = cwd) {
|
||||
export function getDesignSidecarCandidates(cwd = process.cwd(), contextDir = cwd, options = {}) {
|
||||
const projectRoot = resolveProjectRoot(cwd, options);
|
||||
const candidates = [
|
||||
getDesignSidecarPath(cwd),
|
||||
path.join(cwd, 'DESIGN.json'),
|
||||
getDesignSidecarPath(cwd, options),
|
||||
path.join(projectRoot, 'DESIGN.json'),
|
||||
];
|
||||
const contextLegacy = path.join(contextDir, 'DESIGN.json');
|
||||
if (!candidates.includes(contextLegacy)) candidates.push(contextLegacy);
|
||||
return candidates;
|
||||
}
|
||||
|
||||
export function resolveDesignSidecarPath(cwd = process.cwd(), contextDir = cwd) {
|
||||
return firstExisting(getDesignSidecarCandidates(cwd, contextDir));
|
||||
export function resolveDesignSidecarPath(cwd = process.cwd(), contextDir = cwd, options = {}) {
|
||||
return firstExisting(getDesignSidecarCandidates(cwd, contextDir, options));
|
||||
}
|
||||
|
||||
export function getLiveDir(cwd = process.cwd()) {
|
||||
return path.join(getImpeccableDir(cwd), LIVE_DIR);
|
||||
export function getLiveDir(cwd = process.cwd(), options = {}) {
|
||||
return path.join(getImpeccableDir(cwd, options), LIVE_DIR);
|
||||
}
|
||||
|
||||
export function getLiveConfigPath(cwd = process.cwd()) {
|
||||
return path.join(getLiveDir(cwd), 'config.json');
|
||||
export function getLiveConfigPath(cwd = process.cwd(), options = {}) {
|
||||
return path.join(getLiveDir(cwd, options), 'config.json');
|
||||
}
|
||||
|
||||
export function getLegacyLiveConfigPath(scriptsDir) {
|
||||
return path.join(scriptsDir, 'config.json');
|
||||
}
|
||||
|
||||
export function resolveLiveConfigPath({ cwd = process.cwd(), scriptsDir, env = process.env } = {}) {
|
||||
export function resolveLiveConfigPath({ cwd = process.cwd(), scriptsDir, env = process.env, targetPath } = {}) {
|
||||
if (env.IMPECCABLE_LIVE_CONFIG && env.IMPECCABLE_LIVE_CONFIG.trim()) {
|
||||
const configured = env.IMPECCABLE_LIVE_CONFIG.trim();
|
||||
return path.isAbsolute(configured) ? configured : path.resolve(cwd, configured);
|
||||
}
|
||||
const primary = getLiveConfigPath(cwd);
|
||||
const primary = getLiveConfigPath(cwd, { targetPath });
|
||||
if (fs.existsSync(primary)) return primary;
|
||||
if (scriptsDir) {
|
||||
const legacy = getLegacyLiveConfigPath(scriptsDir);
|
||||
@@ -53,16 +56,16 @@ export function resolveLiveConfigPath({ cwd = process.cwd(), scriptsDir, env = p
|
||||
return primary;
|
||||
}
|
||||
|
||||
export function getLiveServerPath(cwd = process.cwd()) {
|
||||
return path.join(getLiveDir(cwd), 'server.json');
|
||||
export function getLiveServerPath(cwd = process.cwd(), options = {}) {
|
||||
return path.join(getLiveDir(cwd, options), 'server.json');
|
||||
}
|
||||
|
||||
export function getLegacyLiveServerPath(cwd = process.cwd()) {
|
||||
return path.join(cwd, '.impeccable-live.json');
|
||||
export function getLegacyLiveServerPath(cwd = process.cwd(), options = {}) {
|
||||
return path.join(resolveProjectRoot(cwd, options), '.impeccable-live.json');
|
||||
}
|
||||
|
||||
export function readLiveServerInfo(cwd = process.cwd()) {
|
||||
for (const filePath of [getLiveServerPath(cwd), getLegacyLiveServerPath(cwd)]) {
|
||||
export function readLiveServerInfo(cwd = process.cwd(), options = {}) {
|
||||
for (const filePath of [getLiveServerPath(cwd, options), getLegacyLiveServerPath(cwd, options)]) {
|
||||
try {
|
||||
const info = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
||||
if (info && typeof info.pid === 'number' && !isLiveServerPidReachable(info.pid)) {
|
||||
@@ -88,37 +91,37 @@ export function isLiveServerPidReachable(pid) {
|
||||
}
|
||||
}
|
||||
|
||||
export function writeLiveServerInfo(cwd = process.cwd(), info) {
|
||||
const filePath = getLiveServerPath(cwd);
|
||||
export function writeLiveServerInfo(cwd = process.cwd(), info, options = {}) {
|
||||
const filePath = getLiveServerPath(cwd, options);
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
fs.writeFileSync(filePath, JSON.stringify(info));
|
||||
return filePath;
|
||||
}
|
||||
|
||||
export function removeLiveServerInfo(cwd = process.cwd()) {
|
||||
for (const filePath of [getLiveServerPath(cwd), getLegacyLiveServerPath(cwd)]) {
|
||||
export function removeLiveServerInfo(cwd = process.cwd(), options = {}) {
|
||||
for (const filePath of [getLiveServerPath(cwd, options), getLegacyLiveServerPath(cwd, options)]) {
|
||||
try { fs.unlinkSync(filePath); } catch {}
|
||||
}
|
||||
}
|
||||
|
||||
export function getLiveSessionsDir(cwd = process.cwd()) {
|
||||
return path.join(getLiveDir(cwd), 'sessions');
|
||||
export function getLiveSessionsDir(cwd = process.cwd(), options = {}) {
|
||||
return path.join(getLiveDir(cwd, options), 'sessions');
|
||||
}
|
||||
|
||||
export function getLegacyLiveSessionsDir(cwd = process.cwd()) {
|
||||
return path.join(cwd, '.impeccable-live', 'sessions');
|
||||
export function getLegacyLiveSessionsDir(cwd = process.cwd(), options = {}) {
|
||||
return path.join(resolveProjectRoot(cwd, options), '.impeccable-live', 'sessions');
|
||||
}
|
||||
|
||||
export function getLiveAnnotationsDir(cwd = process.cwd()) {
|
||||
return path.join(getLiveDir(cwd), 'annotations');
|
||||
export function getLiveAnnotationsDir(cwd = process.cwd(), options = {}) {
|
||||
return path.join(getLiveDir(cwd, options), 'annotations');
|
||||
}
|
||||
|
||||
export function getCritiqueDir(cwd = process.cwd()) {
|
||||
return path.join(getImpeccableDir(cwd), CRITIQUE_DIR);
|
||||
export function getCritiqueDir(cwd = process.cwd(), options = {}) {
|
||||
return path.join(getImpeccableDir(cwd, options), CRITIQUE_DIR);
|
||||
}
|
||||
|
||||
export function getLegacyLiveAnnotationsDir(cwd = process.cwd()) {
|
||||
return path.join(cwd, '.impeccable-live', 'annotations');
|
||||
export function getLegacyLiveAnnotationsDir(cwd = process.cwd(), options = {}) {
|
||||
return path.join(resolveProjectRoot(cwd, options), '.impeccable-live', 'annotations');
|
||||
}
|
||||
|
||||
function firstExisting(paths) {
|
||||
|
||||
@@ -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`;
|
||||
@@ -0,0 +1,42 @@
|
||||
class TargetArgError extends Error {
|
||||
constructor(message, code) {
|
||||
super(message);
|
||||
this.name = 'TargetArgError';
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
export function parseTargetPath(args = [], { strict = false } = {}) {
|
||||
let targetPath = null;
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const arg = String(args[i]);
|
||||
if (arg === '--target' || arg === '-t') {
|
||||
const next = args[i + 1];
|
||||
if (next && !String(next).startsWith('-')) {
|
||||
targetPath = String(next);
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (strict) {
|
||||
throw new TargetArgError('--target requires a path value.', 'TARGET_VALUE_MISSING');
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (arg.startsWith('--target=')) {
|
||||
const value = arg.slice('--target='.length);
|
||||
if (value) {
|
||||
targetPath = value;
|
||||
continue;
|
||||
}
|
||||
if (strict) {
|
||||
throw new TargetArgError('--target requires a path value.', 'TARGET_VALUE_MISSING');
|
||||
}
|
||||
}
|
||||
}
|
||||
return targetPath;
|
||||
}
|
||||
|
||||
export function parseTargetOptions(args = [], options = {}) {
|
||||
const targetPath = parseTargetPath(args, options);
|
||||
return targetPath ? { targetPath } : {};
|
||||
}
|
||||
@@ -57,7 +57,8 @@
|
||||
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 PICK_CURSOR_CLASS = PREFIX + '-pick-cursor';
|
||||
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({
|
||||
prefix: PREFIX,
|
||||
@@ -152,6 +153,8 @@
|
||||
let scrollLockTargetY = null;
|
||||
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.
|
||||
@@ -1915,45 +1918,45 @@
|
||||
syncPageInteractionCursor();
|
||||
}
|
||||
|
||||
let pageInteractionCursorActive = false;
|
||||
|
||||
function ensurePickCursorStyle() {
|
||||
if (document.getElementById(PREFIX + '-pick-cursor-style')) return;
|
||||
const style = document.createElement('style');
|
||||
style.id = PREFIX + '-pick-cursor-style';
|
||||
/**
|
||||
* Drive the page-level pick / insert cursor through the textContent of one
|
||||
* injected <style>, never by mutating <html> (className or inline style).
|
||||
* Frameworks that server-render the <html>/<body> roots (Next.js App Router)
|
||||
* report a React 19 hydration mismatch when the client adds an attribute the
|
||||
* server HTML never emitted, so a `class`/inline `style` toggled on
|
||||
* `document.documentElement` trips "a tree hydrated but some attributes ...
|
||||
* didn't match" on the next Fast-Refresh re-render. Keying the cursor off a
|
||||
* stable-id <style> keeps the effect off the hydrated host elements (same
|
||||
* shape as the scroll-anchor lock). A falsy cursor clears the rule.
|
||||
*/
|
||||
function setPageInteractionCursor(cursor) {
|
||||
let style = document.getElementById(PICK_CURSOR_STYLE_ID);
|
||||
if (!cursor) {
|
||||
if (style) style.textContent = '';
|
||||
return;
|
||||
}
|
||||
if (!style) {
|
||||
style = document.createElement('style');
|
||||
style.id = PICK_CURSOR_STYLE_ID;
|
||||
// Styles the host page, not the chrome - inside the adapter's shadow UI
|
||||
// root (uiAppendStyle's target) these selectors would match nothing.
|
||||
(document.head || document.documentElement).appendChild(style);
|
||||
}
|
||||
style.textContent =
|
||||
'html.' + PICK_CURSOR_CLASS + ' * { cursor: crosshair !important; }\n'
|
||||
+ 'html.' + PICK_CURSOR_CLASS + ' [id^="' + PREFIX + '"],\n'
|
||||
+ 'html.' + PICK_CURSOR_CLASS + ' [id^="' + PREFIX + '"] * { cursor: revert !important; }';
|
||||
// Styles the host page, not the chrome - inside the adapter's shadow UI
|
||||
// root (uiAppendStyle's target) these selectors would match nothing.
|
||||
document.head.appendChild(style);
|
||||
'* { cursor: ' + cursor + ' !important; }\n'
|
||||
+ '[id^="' + PREFIX + '"],\n'
|
||||
+ '[id^="' + PREFIX + '"] * { cursor: revert !important; }';
|
||||
}
|
||||
|
||||
/** Page-level cursor while pick or insert mode is targeting page elements. */
|
||||
function syncPageInteractionCursor() {
|
||||
const pickCursor = state === 'PICKING' && pickActive && !insertActive;
|
||||
let axisCursor = '';
|
||||
if (state === 'PICKING' && insertActive) {
|
||||
axisCursor = insertHoverAnchor ? cursorForInsertAxis(insertHoverAxis || 'column') : '';
|
||||
}
|
||||
|
||||
if (pickCursor) {
|
||||
ensurePickCursorStyle();
|
||||
document.documentElement.classList.add(PICK_CURSOR_CLASS);
|
||||
document.documentElement.style.cursor = '';
|
||||
pageInteractionCursorActive = true;
|
||||
return;
|
||||
}
|
||||
|
||||
document.documentElement.classList.remove(PICK_CURSOR_CLASS);
|
||||
if (axisCursor) {
|
||||
document.documentElement.style.cursor = axisCursor;
|
||||
pageInteractionCursorActive = true;
|
||||
} else if (pageInteractionCursorActive) {
|
||||
document.documentElement.style.cursor = '';
|
||||
pageInteractionCursorActive = false;
|
||||
let cursor = '';
|
||||
if (state === 'PICKING' && pickActive && !insertActive) {
|
||||
cursor = 'crosshair';
|
||||
} else if (state === 'PICKING' && insertActive && insertHoverAnchor) {
|
||||
cursor = cursorForInsertAxis(insertHoverAxis || 'column');
|
||||
}
|
||||
setPageInteractionCursor(cursor);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -3034,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) {
|
||||
@@ -4713,6 +4726,7 @@
|
||||
paramsCurrentValues = {};
|
||||
tuneOpen = false;
|
||||
hideParamsPanel();
|
||||
if (currentSessionId && visibleVariant) updateVariantStateStylesheet(currentSessionId, visibleVariant);
|
||||
return;
|
||||
}
|
||||
applyParamDefaults(variantEl, params);
|
||||
@@ -4770,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) {
|
||||
@@ -4822,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.
|
||||
@@ -5491,6 +5488,7 @@
|
||||
if (pendingSvelteComponentRetryObserver) { pendingSvelteComponentRetryObserver.disconnect(); pendingSvelteComponentRetryObserver = null; }
|
||||
if (pendingVariantAnchorRetryObserver) { pendingVariantAnchorRetryObserver.disconnect(); pendingVariantAnchorRetryObserver = null; }
|
||||
stopScrollLock();
|
||||
removeVariantStateStylesheet();
|
||||
clearSession();
|
||||
clearHandled();
|
||||
resetSessionFileMeta();
|
||||
@@ -5805,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) {
|
||||
@@ -5815,10 +5875,22 @@
|
||||
|
||||
try { history.scrollRestoration = 'manual'; } catch {}
|
||||
|
||||
const prevHtmlAnchor = document.documentElement.style.overflowAnchor;
|
||||
const prevBodyAnchor = document.body.style.overflowAnchor;
|
||||
document.documentElement.style.overflowAnchor = 'none';
|
||||
document.body.style.overflowAnchor = 'none';
|
||||
// Suppress the browser's scroll-anchoring on the scroll root so it can't
|
||||
// fight our manual scroll correction. Apply this as a stylesheet rule, not
|
||||
// as inline `style` on <html>/<body>: those elements are server-rendered by
|
||||
// frameworks like Next.js App Router, and mutating their inline style makes
|
||||
// React 19 report a hydration mismatch on the next Fast-Refresh re-render.
|
||||
// A <style> rule has the same computed effect without touching any hydrated
|
||||
// element's attributes. Like the inline version, it is recreated on every
|
||||
// startScrollLock call, so reload survival (driven by the persisted scroll
|
||||
// key) is unaffected.
|
||||
let anchorLockStyle = document.getElementById(SCROLL_ANCHOR_LOCK_ID);
|
||||
if (!anchorLockStyle) {
|
||||
anchorLockStyle = document.createElement('style');
|
||||
anchorLockStyle.id = SCROLL_ANCHOR_LOCK_ID;
|
||||
anchorLockStyle.textContent = 'html,body{overflow-anchor:none !important;}';
|
||||
(document.head || document.documentElement).appendChild(anchorLockStyle);
|
||||
}
|
||||
|
||||
const correct = (why) => {
|
||||
scrollLockRaf = null;
|
||||
@@ -5853,8 +5925,7 @@
|
||||
|
||||
scrollLockAbort = new AbortController();
|
||||
scrollLockAbort.signal.addEventListener('abort', () => {
|
||||
document.documentElement.style.overflowAnchor = prevHtmlAnchor;
|
||||
document.body.style.overflowAnchor = prevBodyAnchor;
|
||||
document.getElementById(SCROLL_ANCHOR_LOCK_ID)?.remove();
|
||||
}, { once: true });
|
||||
const sig = { signal: scrollLockAbort.signal };
|
||||
// Track whether the most recent scroll came from a user gesture. We
|
||||
@@ -6075,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();
|
||||
@@ -6489,10 +6560,13 @@
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (isPageEditableElement(deepActive) && !isInlineEditActive(deepActive)) {
|
||||
return;
|
||||
}
|
||||
// While a contenteditable text-leaf is focused, let the browser handle
|
||||
// all keys except Escape. Escape cancels the current edit (restores
|
||||
// original text) and blurs without saving, staying in CONFIGURING.
|
||||
if (e.target.isContentEditable && inlineEditRows.some((r) => r.el === e.target)) {
|
||||
if (e.target.isContentEditable && isInlineEditActive(e.target)) {
|
||||
if (e.key !== 'Escape') return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
@@ -7621,6 +7695,7 @@ void main() {
|
||||
stopScrollTracking();
|
||||
if (variantObserver) { variantObserver.disconnect(); variantObserver = null; }
|
||||
stopScrollLock();
|
||||
removeVariantStateStylesheet();
|
||||
clearScrollY();
|
||||
clearSession();
|
||||
resetSessionFileMeta();
|
||||
@@ -7882,6 +7957,7 @@ void main() {
|
||||
if (variantObserver) { variantObserver.disconnect(); variantObserver = null; }
|
||||
if (pendingVariantAnchorRetryObserver) { pendingVariantAnchorRetryObserver.disconnect(); pendingVariantAnchorRetryObserver = null; }
|
||||
stopScrollLock();
|
||||
removeVariantStateStylesheet();
|
||||
clearScrollY();
|
||||
finalizeInsertSession();
|
||||
clearSession();
|
||||
@@ -7913,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,
|
||||
@@ -7923,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);
|
||||
}
|
||||
|
||||
@@ -8186,7 +8267,7 @@ void main() {
|
||||
let voiceInterimBase = '';
|
||||
/** @type {{ mode: 'steer'|'configure', input: HTMLInputElement, submit: () => void, beforeStart?: () => void } | null} */
|
||||
let voiceCtx = null;
|
||||
const PAGE_CHAT_COLLAPSED_W = '88px';
|
||||
const PAGE_CHAT_COLLAPSED_W = '104px';
|
||||
const PAGE_CHAT_PROCESSING_W = '76px';
|
||||
const PAGE_CHAT_PLACEHOLDER_COLLAPSED = 'Steer…';
|
||||
const PAGE_CHAT_PLACEHOLDER_EXPANDED = 'Steer the page…';
|
||||
@@ -8197,7 +8278,7 @@ void main() {
|
||||
const GLOBAL_BAR_SECTION_GAP = 8;
|
||||
const GLOBAL_BAR_INNER_GAP = 2;
|
||||
const GLOBAL_BAR_INNER_PAD_LEFT = 2;
|
||||
const PAGE_CHAT_EXPANDED_W = 'min(280px, 38vw)';
|
||||
const PAGE_CHAT_EXPANDED_MAX_W = 280;
|
||||
const ICON_PAGE_CHAT =
|
||||
'<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>';
|
||||
const ICON_PAGE_VOICE =
|
||||
@@ -8277,6 +8358,52 @@ void main() {
|
||||
return barPaletteForTheme(globalBarEl?.dataset.theme || detectPageTheme());
|
||||
}
|
||||
|
||||
function globalBarModeToggles() {
|
||||
return [
|
||||
uiGetById(PREFIX + '-pick-toggle'),
|
||||
uiGetById(PREFIX + '-insert-toggle'),
|
||||
uiGetById(PREFIX + '-detect-toggle'),
|
||||
uiGetById(PREFIX + '-design-toggle'),
|
||||
].filter(Boolean);
|
||||
}
|
||||
|
||||
function applyGlobalBarLabelState(expandInactive, forceCollapse = false) {
|
||||
globalBarModeToggles().forEach((toggle) => {
|
||||
if (forceCollapse) toggle._collapseLabel?.(true);
|
||||
else if (expandInactive || toggle.dataset.active === 'true') toggle._expandLabel?.();
|
||||
else toggle._collapseLabel?.();
|
||||
});
|
||||
}
|
||||
|
||||
function syncGlobalBarExpandedLabels(expanded = globalBarEl?.matches(':hover')) {
|
||||
const expandInactive = !!(expanded && !pageChatExpanded);
|
||||
applyGlobalBarLabelState(expandInactive, pageChatExpanded);
|
||||
|
||||
if (expandInactive && globalBarEl && globalBarEl.scrollWidth > window.innerWidth - 16) {
|
||||
applyGlobalBarLabelState(false);
|
||||
}
|
||||
}
|
||||
|
||||
function pageChatCollapsedWidthPx() {
|
||||
const parsed = parseFloat(PAGE_CHAT_COLLAPSED_W);
|
||||
return Number.isFinite(parsed) ? parsed : 104;
|
||||
}
|
||||
|
||||
function pageChatExpandedWidth() {
|
||||
if (!pageChatEl || !globalBarEl) return PAGE_CHAT_EXPANDED_MAX_W + 'px';
|
||||
const currentChatWidth = pageChatEl.getBoundingClientRect().width || pageChatCollapsedWidthPx();
|
||||
const barWidth = Math.max(globalBarEl.getBoundingClientRect().width || 0, globalBarEl.scrollWidth || 0);
|
||||
const nonChatWidth = Math.max(0, barWidth - currentChatWidth);
|
||||
const available = window.innerWidth - 16 - nonChatWidth;
|
||||
const next = Math.max(pageChatCollapsedWidthPx(), Math.min(PAGE_CHAT_EXPANDED_MAX_W, available));
|
||||
return Math.round(next) + 'px';
|
||||
}
|
||||
|
||||
function syncPageChatExpandedWidth() {
|
||||
if (!pageChatEl || !pageChatExpanded) return;
|
||||
pageChatEl.style.width = pageChatExpandedWidth();
|
||||
}
|
||||
|
||||
function syncPageChatChrome() {
|
||||
if (!pageChatEl) return;
|
||||
const P = pageChatPalette();
|
||||
@@ -8312,6 +8439,21 @@ void main() {
|
||||
&& !steerLocked;
|
||||
}
|
||||
|
||||
function isPageEditableElement(el) {
|
||||
if (!el || own(el)) return false;
|
||||
if (/^(INPUT|TEXTAREA|SELECT)$/.test(el.tagName || '')) return true;
|
||||
return !!el.isContentEditable;
|
||||
}
|
||||
|
||||
function isInlineEditActive(el) {
|
||||
return !!el && inlineEditRows.some((r) => r.el === el);
|
||||
}
|
||||
|
||||
function isPageEditableActive() {
|
||||
const active = activeElementDeep();
|
||||
return isPageEditableElement(active) && !isInlineEditActive(active);
|
||||
}
|
||||
|
||||
function pageHasHostTextSelection() {
|
||||
const sel = window.getSelection?.();
|
||||
if (!sel || sel.isCollapsed) return false;
|
||||
@@ -8325,6 +8467,7 @@ void main() {
|
||||
function shouldSteerAutoFocus() {
|
||||
return shouldFocusSteerChat()
|
||||
&& !steerFocusSuspended
|
||||
&& !isPageEditableActive()
|
||||
&& performance.now() >= steerFocusPauseUntil;
|
||||
}
|
||||
|
||||
@@ -8562,7 +8705,8 @@ void main() {
|
||||
if (!pageChatEl || !pageChatInput) return false;
|
||||
pageChatExpanded = true;
|
||||
pageChatEl.dataset.expanded = 'true';
|
||||
pageChatEl.style.width = PAGE_CHAT_EXPANDED_W;
|
||||
syncGlobalBarExpandedLabels(false);
|
||||
pageChatEl.style.width = pageChatExpandedWidth();
|
||||
pageChatEl.style.cursor = steerLocked ? 'default' : 'text';
|
||||
pageChatInput.placeholder = PAGE_CHAT_PLACEHOLDER_EXPANDED;
|
||||
if (pageChatHint) {
|
||||
@@ -8657,7 +8801,7 @@ void main() {
|
||||
pageChatEl.setAttribute('aria-label', 'Steer the page');
|
||||
pageChatExpanded = keepExpanded;
|
||||
pageChatEl.dataset.expanded = keepExpanded ? 'true' : 'false';
|
||||
pageChatEl.style.width = keepExpanded ? PAGE_CHAT_EXPANDED_W : PAGE_CHAT_COLLAPSED_W;
|
||||
pageChatEl.style.width = keepExpanded ? pageChatExpandedWidth() : PAGE_CHAT_COLLAPSED_W;
|
||||
pageChatEl.style.cursor = 'pointer';
|
||||
if (pageChatInput) {
|
||||
pageChatInput.disabled = false;
|
||||
@@ -8971,6 +9115,7 @@ void main() {
|
||||
pageChatEl.dataset.expanded = 'false';
|
||||
pageChatEl.style.width = PAGE_CHAT_COLLAPSED_W;
|
||||
pageChatEl.style.cursor = 'pointer';
|
||||
syncGlobalBarExpandedLabels(globalBarEl?.matches(':hover'));
|
||||
if (blur) {
|
||||
pageChatInput.blur();
|
||||
pageChatInput.style.pointerEvents = 'none';
|
||||
@@ -9270,6 +9415,7 @@ void main() {
|
||||
zIndex: Z.bar + 5,
|
||||
display: 'flex', alignItems: 'stretch',
|
||||
gap: '0',
|
||||
width: 'max-content',
|
||||
background: P.surface,
|
||||
border: '1px solid ' + P.border,
|
||||
borderRadius: '8px',
|
||||
@@ -9277,6 +9423,8 @@ void main() {
|
||||
fontFamily: FONT, fontSize: '12px', lineHeight: '1',
|
||||
opacity: '0',
|
||||
overflow: 'hidden', // clip the full-bleed brand mark to the bar radius
|
||||
maxWidth: 'calc(100vw - 16px)',
|
||||
boxSizing: 'border-box',
|
||||
transition: 'opacity 0.3s ' + EASE + ', transform 0.3s ' + EASE,
|
||||
});
|
||||
globalBarEl.id = PREFIX + '-global-bar';
|
||||
@@ -9325,6 +9473,7 @@ void main() {
|
||||
const inner = el('div', {
|
||||
display: 'flex', alignItems: 'center',
|
||||
padding: '4px 5px 4px ' + GLOBAL_BAR_INNER_PAD_LEFT + 'px', gap: GLOBAL_BAR_INNER_GAP + 'px',
|
||||
flex: '0 0 auto',
|
||||
});
|
||||
inner.id = PREFIX + '-global-bar-inner';
|
||||
globalBarEl.appendChild(inner);
|
||||
@@ -9333,7 +9482,10 @@ void main() {
|
||||
function makeIconBtn({ id, svg, label, ariaLabel, labelFont, onClick }) {
|
||||
const b = el('button', {
|
||||
position: 'relative',
|
||||
display: 'inline-flex', alignItems: 'center',
|
||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||
boxSizing: 'border-box',
|
||||
flex: '0 0 auto',
|
||||
minWidth: '30px',
|
||||
padding: '6px 8px', borderRadius: '7px',
|
||||
border: 'none', background: 'transparent',
|
||||
color: P.textDim, fontFamily: FONT, fontSize: '11.5px', fontWeight: '500',
|
||||
@@ -9352,8 +9504,8 @@ void main() {
|
||||
if (!labelEl) return;
|
||||
labelEl.style.maxWidth = '120px'; labelEl.style.opacity = '1'; labelEl.style.marginLeft = '6px'; labelEl.style.transform = 'translateX(0)';
|
||||
};
|
||||
const collapse = () => {
|
||||
if (!labelEl || b.dataset.active === 'true') return;
|
||||
const collapse = (force = false) => {
|
||||
if (!labelEl || (!force && b.dataset.active === 'true')) return;
|
||||
labelEl.style.maxWidth = '0'; labelEl.style.opacity = '0'; labelEl.style.marginLeft = '0'; labelEl.style.transform = 'translateX(-4px)';
|
||||
};
|
||||
// Per-button hover only changes color (no layout). The label expand/
|
||||
@@ -9604,6 +9756,7 @@ void main() {
|
||||
width: '1px', height: '18px',
|
||||
background: P.hairline,
|
||||
margin: '0 4px 0 2px',
|
||||
flexShrink: '0',
|
||||
});
|
||||
inner.appendChild(divider);
|
||||
|
||||
@@ -9620,6 +9773,7 @@ void main() {
|
||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||
padding: '0', boxSizing: 'border-box',
|
||||
width: '24px', height: '24px', borderRadius: '6px',
|
||||
flexShrink: '0',
|
||||
border: 'none', background: 'transparent',
|
||||
color: P.textDim, fontFamily: FONT, fontSize: '0', lineHeight: '0',
|
||||
cursor: 'pointer', transition: 'color 0.12s ease, background 0.12s ease',
|
||||
@@ -9632,16 +9786,16 @@ void main() {
|
||||
exitBtn.addEventListener('click', () => { sendEvent({ type: 'exit' }); teardown(); });
|
||||
inner.appendChild(exitBtn);
|
||||
|
||||
// Bar-level hover: expand every toggle's label at once; collapse on leave.
|
||||
// Bar-level hover: expand mode labels unless Steer is using the space.
|
||||
// Buttons with dataset.active="true" ignore collapse (their label stays).
|
||||
const toggles = [pickBtn, insertBtn, detectBtn, designBtn];
|
||||
globalBarEl.addEventListener('mouseenter', () => {
|
||||
toggles.forEach((t) => t._expandLabel && t._expandLabel());
|
||||
syncGlobalBarExpandedLabels(true);
|
||||
syncPageChatExpandedWidth();
|
||||
schedulePendingDockPosition();
|
||||
setTimeout(schedulePendingDockPosition, 260);
|
||||
});
|
||||
globalBarEl.addEventListener('mouseleave', () => {
|
||||
toggles.forEach((t) => t._collapseLabel && t._collapseLabel());
|
||||
syncGlobalBarExpandedLabels(false);
|
||||
schedulePendingDockPosition();
|
||||
setTimeout(schedulePendingDockPosition, 260);
|
||||
});
|
||||
@@ -9659,6 +9813,7 @@ void main() {
|
||||
pendingDockResizeObserver.observe(globalBarEl);
|
||||
}
|
||||
window.addEventListener('resize', positionPendingDock);
|
||||
window.addEventListener('resize', syncPageChatExpandedWidth);
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
globalBarEl.style.opacity = '1';
|
||||
@@ -9705,9 +9860,7 @@ void main() {
|
||||
// If the bar is currently under the cursor, keep all labels expanded -
|
||||
// otherwise clicking a toggle that deactivates (e.g. closing DESIGN.md)
|
||||
// would collapse its label while the user's mouse is still on the bar.
|
||||
if (globalBarEl && globalBarEl.matches(':hover')) {
|
||||
[pickToggle, insertToggle, detectToggle, designToggle].forEach((t) => t?._expandLabel?.());
|
||||
}
|
||||
syncGlobalBarExpandedLabels(globalBarEl && globalBarEl.matches(':hover'));
|
||||
|
||||
if (detectBadge) {
|
||||
detectBadge.style.display = (detectActive && detectCount > 0) ? 'inline' : 'none';
|
||||
@@ -9896,7 +10049,8 @@ void main() {
|
||||
// Remove detection overlays
|
||||
window.postMessage({ source: 'impeccable-command', action: 'remove' }, '*');
|
||||
setLiveState('IDLE');
|
||||
document.getElementById(PREFIX + '-pick-cursor-style')?.remove();
|
||||
document.getElementById(PICK_CURSOR_STYLE_ID)?.remove();
|
||||
removeVariantStateStylesheet();
|
||||
window.__IMPECCABLE_LIVE_INIT__ = false;
|
||||
console.log('[impeccable] Live mode exited.');
|
||||
}
|
||||
@@ -10385,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;
|
||||
}
|
||||
@@ -10415,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;
|
||||
}
|
||||
@@ -10423,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;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
* CLI client for the live variant mode poll/reply protocol.
|
||||
*
|
||||
* Usage:
|
||||
* npx impeccable poll # Block until browser event, print JSON
|
||||
* npx impeccable poll --stream # Experimental: keep polling; one JSON line per event
|
||||
* npx impeccable poll --timeout=600000 # Custom timeout (ms); default is long-poll friendly
|
||||
* npx impeccable poll --reply <id> done # Reply "done" to event <id>
|
||||
* npx impeccable poll --reply <id> error "msg" # Reply with error
|
||||
* node <scripts_path>/live-poll.mjs # Block until browser event, print JSON
|
||||
* node <scripts_path>/live-poll.mjs --stream # Experimental: keep polling; one JSON line per event
|
||||
* node <scripts_path>/live-poll.mjs --timeout=600000 # Custom timeout (ms); default is long-poll friendly
|
||||
* node <scripts_path>/live-poll.mjs --reply <id> done # Reply "done" to event <id>
|
||||
* node <scripts_path>/live-poll.mjs --reply <id> error "msg" # Reply with error
|
||||
*/
|
||||
|
||||
import { execFileSync } from 'node:child_process';
|
||||
@@ -15,6 +15,11 @@ import { fileURLToPath } from 'node:url';
|
||||
import { completionAckForAcceptResult, completionTypeForAcceptResult } from './live/completion.mjs';
|
||||
import { readLiveServerInfo } from './lib/impeccable-paths.mjs';
|
||||
|
||||
// Absolute path to a sibling script in this skill's scripts dir, so runtime
|
||||
// error hints print a directly-runnable command instead of a placeholder.
|
||||
const SELF_DIR = path.dirname(fileURLToPath(import.meta.url));
|
||||
const scriptCmd = (name) => `node "${path.join(SELF_DIR, name)}"`;
|
||||
|
||||
// Node's built-in fetch (undici under the hood) enforces a 300s headers
|
||||
// timeout that can't be lowered per-request. We cap each request below
|
||||
// that ceiling and loop in `pollOnce` to synthesize a long poll without
|
||||
@@ -27,7 +32,7 @@ const EVENT_TYPES_NEEDING_AGENT_REPLY = new Set(['generate', 'steer', 'manual_ed
|
||||
function readServerInfo() {
|
||||
const record = readLiveServerInfo(process.cwd());
|
||||
if (!record) {
|
||||
console.error('No running live server found. Start one with: npx impeccable live');
|
||||
console.error(`No running live server found. Start one with: ${scriptCmd('live.mjs')}`);
|
||||
process.exit(1);
|
||||
}
|
||||
return record.info;
|
||||
@@ -82,7 +87,7 @@ export function parseReplyArgs(args) {
|
||||
}
|
||||
|
||||
function validateReplyArgs({ id, status }) {
|
||||
const usage = "Usage: npx impeccable poll --reply <id> <status> [--file path] [--data '<json>'] [message]";
|
||||
const usage = `Usage: ${scriptCmd('live-poll.mjs')} --reply <id> <status> [--file path] [--data '<json>'] [message]`;
|
||||
if (!id || id.startsWith('--')) {
|
||||
const err = new Error(`${usage}\nMissing event id after --reply.`);
|
||||
err.code = 'INVALID_REPLY_ARGS';
|
||||
@@ -283,11 +288,11 @@ export async function runPollStream(base, token, {
|
||||
function handlePollError(err) {
|
||||
if (err.code === 'AUTH_FAILED') {
|
||||
console.error(err.message);
|
||||
console.error('Try restarting: npx impeccable live stop && npx impeccable live');
|
||||
console.error(`Try restarting: ${scriptCmd('live-server.mjs')} stop && ${scriptCmd('live.mjs')}`);
|
||||
process.exit(1);
|
||||
}
|
||||
if (err.cause?.code === 'ECONNREFUSED') {
|
||||
console.error('Live server not running. Start one with: npx impeccable live');
|
||||
console.error(`Live server not running. Start one with: ${scriptCmd('live.mjs')}`);
|
||||
process.exit(1);
|
||||
}
|
||||
if (err.code === 'ACK_TIMEOUT') {
|
||||
@@ -331,7 +336,7 @@ Harness note:
|
||||
const info = readServerInfo();
|
||||
const base = `http://localhost:${info.port}`;
|
||||
|
||||
// Reply mode: npx impeccable poll --reply <id> <status> [--file path] [--data '<json>'] [message]
|
||||
// Reply mode: node <scripts_path>/live-poll.mjs --reply <id> <status> [--file path] [--data '<json>'] [message]
|
||||
if (args.includes('--reply')) {
|
||||
let reply;
|
||||
try {
|
||||
@@ -345,7 +350,7 @@ Harness note:
|
||||
await postReply(base, info.token, reply);
|
||||
} catch (err) {
|
||||
if (err.cause?.code === 'ECONNREFUSED') {
|
||||
console.error('Live server not running. Start one with: npx impeccable live');
|
||||
console.error(`Live server not running. Start one with: ${scriptCmd('live.mjs')}`);
|
||||
} else {
|
||||
console.error('Reply failed:', err.message);
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ import path from 'node:path';
|
||||
import net from 'node:net';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { parseDesignMd } from './lib/design-parser.mjs';
|
||||
import { resolveContextDir } from './context.mjs';
|
||||
import { loadContext } from './context.mjs';
|
||||
import {
|
||||
assembleLiveBrowserScript,
|
||||
assertLiveBrowserScriptParts,
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
getDesignSidecarPath,
|
||||
getLiveDir,
|
||||
getLiveAnnotationsDir,
|
||||
IMPECCABLE_COMMAND_PREFIX,
|
||||
readLiveServerInfo,
|
||||
removeLiveServerInfo,
|
||||
resolveDesignSidecarPath,
|
||||
@@ -55,7 +56,11 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
// PRODUCT.md / DESIGN.md live wherever context.mjs resolves. The generated
|
||||
// DESIGN sidecar is project-local at .impeccable/design.json, with legacy
|
||||
// DESIGN.json fallback for existing projects.
|
||||
const CONTEXT_DIR = resolveContextDir(process.cwd());
|
||||
const PROJECT_CONTEXT = loadContext(process.cwd());
|
||||
const CONTEXT_DIR = PROJECT_CONTEXT.contextDir;
|
||||
const DESIGN_MD_PATH = PROJECT_CONTEXT.designPath
|
||||
? path.resolve(process.cwd(), PROJECT_CONTEXT.designPath)
|
||||
: null;
|
||||
const DEFAULT_POLL_TIMEOUT = 600_000; // 10 min — agent re-polls on timeout anyway
|
||||
const SSE_HEARTBEAT_INTERVAL = 30_000; // keepalive ping every 30s
|
||||
|
||||
@@ -371,10 +376,7 @@ function hasProjectContext() {
|
||||
// PRODUCT.md carries brand voice / anti-references — that's what determines
|
||||
// whether variants are brand-aware. DESIGN.md (visual tokens) is a separate
|
||||
// concern, surfaced by the design panel's own empty state.
|
||||
try {
|
||||
fs.accessSync(path.join(CONTEXT_DIR, 'PRODUCT.md'), fs.constants.R_OK);
|
||||
return true;
|
||||
} catch { return false; }
|
||||
return !!PROJECT_CONTEXT.hasProduct;
|
||||
}
|
||||
|
||||
function statOrNull(filePath) {
|
||||
@@ -412,6 +414,7 @@ function createRequestHandler({ detectScript, liveScriptParts }) {
|
||||
token: state.token,
|
||||
port: state.port,
|
||||
vocabulary: LIVE_COMMANDS,
|
||||
commandPrefix: IMPECCABLE_COMMAND_PREFIX,
|
||||
parts,
|
||||
});
|
||||
res.writeHead(200, {
|
||||
@@ -549,8 +552,8 @@ function createRequestHandler({ detectScript, liveScriptParts }) {
|
||||
const token = url.searchParams.get('token');
|
||||
if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; }
|
||||
|
||||
const mdPath = path.join(CONTEXT_DIR, 'DESIGN.md');
|
||||
const jsonPath = resolveDesignSidecarPath(process.cwd(), CONTEXT_DIR) || getDesignSidecarPath(process.cwd());
|
||||
const mdPath = DESIGN_MD_PATH;
|
||||
const jsonPath = resolveDesignSidecarPath(process.cwd(), PROJECT_CONTEXT.designContextDir || CONTEXT_DIR) || getDesignSidecarPath(process.cwd());
|
||||
const mdStat = statOrNull(mdPath);
|
||||
const jsonStat = statOrNull(jsonPath);
|
||||
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import path from 'node:path';
|
||||
import { resolveProjectRoot } from './context.mjs';
|
||||
import { parseTargetPath } from './lib/target-args.mjs';
|
||||
|
||||
export function resolveLiveTarget(cwd = process.cwd(), args = []) {
|
||||
const originalCwd = path.resolve(cwd);
|
||||
let targetPath = null;
|
||||
try {
|
||||
targetPath = parseTargetPath(args, { strict: true });
|
||||
} catch (err) {
|
||||
if (err?.name === 'TargetArgError') {
|
||||
process.stderr.write(`${err.message}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
const absoluteTargetPath = targetPath
|
||||
? path.isAbsolute(targetPath) ? targetPath : path.resolve(originalCwd, targetPath)
|
||||
: null;
|
||||
const projectRoot = targetPath
|
||||
? resolveProjectRoot(originalCwd, { targetPath: absoluteTargetPath })
|
||||
: originalCwd;
|
||||
return {
|
||||
originalCwd,
|
||||
projectRoot,
|
||||
targetPath,
|
||||
absoluteTargetPath,
|
||||
targetOptions: absoluteTargetPath ? { targetPath: absoluteTargetPath } : {},
|
||||
};
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
* CLI helper: find an element in source and wrap it in a variant container.
|
||||
*
|
||||
* Usage:
|
||||
* npx impeccable wrap --id SESSION_ID --count N --query "hero-combined-left" [--file path]
|
||||
* node <scripts_path>/live-wrap.mjs --id SESSION_ID --count N --query "hero-combined-left" [--file path]
|
||||
*
|
||||
* Searches project files for the element matching the query (class name, ID, or
|
||||
* text snippet), wraps it with the variant scaffolding, and prints the file path
|
||||
|
||||
@@ -21,14 +21,16 @@ import { execSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { loadContext } from './context.mjs';
|
||||
import { loadContext, resolveTargetSelection } from './context.mjs';
|
||||
import { resolveFiles } from './live-inject.mjs';
|
||||
import { readLiveServerInfo } from './lib/impeccable-paths.mjs';
|
||||
import { resolveLiveTarget } from './live-target.mjs';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
async function liveCli() {
|
||||
const args = process.argv.slice(2);
|
||||
const liveTarget = resolveLiveTarget(process.cwd(), args);
|
||||
|
||||
if (args.includes('--help') || args.includes('-h')) {
|
||||
console.log(`Usage: node live.mjs
|
||||
@@ -38,37 +40,78 @@ Prepare everything for live variant mode in a single command:
|
||||
- Starts (or reuses) the live server in the background
|
||||
- Injects the browser script tag
|
||||
- Reads PRODUCT.md / DESIGN.md for project context
|
||||
- In monorepos, choose a child app first; --target <path> is the fallback/manual path
|
||||
|
||||
On success, prints a JSON blob with:
|
||||
{ ok, serverPort, serverToken, pageFile, hasContext, context }
|
||||
{ ok, serverPort, serverToken, pageFiles, projectRoot, repoRoot, targetPath, productPath, designPath }
|
||||
|
||||
On target_selection_required, prints:
|
||||
{ ok: false, error: "target_selection_required", targetCandidates }
|
||||
|
||||
On config_missing, prints:
|
||||
{ ok: false, error: "config_missing", configPath, hint }
|
||||
|
||||
The agent should then:
|
||||
1. If config_missing, create the config and re-run this script
|
||||
2. Optionally open the project's dev/preview URL in the browser (see reference/live.md—not serverPort)
|
||||
3. Enter the poll loop: node live-poll.mjs`);
|
||||
1. If target_selection_required, ask which app to use and rerun from that child cwd
|
||||
2. If config_missing, create the config and re-run this script
|
||||
3. Optionally open the project's dev/preview URL in the browser (see reference/live.md—not serverPort)
|
||||
4. Enter the poll loop: node live-poll.mjs`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const targetSelection = resolveTargetSelection(liveTarget.originalCwd, liveTarget.targetOptions);
|
||||
if (targetSelection) {
|
||||
console.log(JSON.stringify({
|
||||
ok: false,
|
||||
error: 'target_selection_required',
|
||||
...targetSelection,
|
||||
hint: 'Ask the user which app Impeccable should use, then rerun live from that child app cwd. Use --target <path> only as a fallback or explicit path diagnostic.',
|
||||
}, null, 2));
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const ctx = loadContext(liveTarget.originalCwd, liveTarget.targetOptions);
|
||||
const activeCwd = ctx.projectRoot;
|
||||
const outputTargetPath = liveTarget.targetPath || null;
|
||||
|
||||
const missingContext = missingLiveContext(ctx);
|
||||
if (missingContext.length > 0) {
|
||||
console.log(JSON.stringify({
|
||||
ok: false,
|
||||
error: 'context_missing',
|
||||
missing: missingContext,
|
||||
nextCommand: missingContext.includes('PRODUCT.md') ? 'init' : 'document',
|
||||
targetPath: outputTargetPath,
|
||||
projectRoot: ctx.projectRoot,
|
||||
repoRoot: ctx.repoRoot,
|
||||
productPath: ctx.productPath,
|
||||
designPath: ctx.designPath,
|
||||
}, null, 2));
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// 1. Check config (fail fast if missing — no point starting anything else)
|
||||
const checkOut = runScript('live-inject.mjs', ['--check']);
|
||||
const checkOut = runScript('live-inject.mjs', ['--check'], { cwd: activeCwd });
|
||||
const checkResult = safeParse(checkOut);
|
||||
if (!checkResult || !checkResult.ok) {
|
||||
console.log(JSON.stringify(checkResult || { ok: false, error: 'check_failed', raw: checkOut }));
|
||||
console.log(JSON.stringify({
|
||||
...(checkResult || { ok: false, error: 'check_failed', raw: checkOut }),
|
||||
targetPath: outputTargetPath,
|
||||
projectRoot: ctx.projectRoot,
|
||||
repoRoot: ctx.repoRoot,
|
||||
}));
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// 2. Start server (or reuse existing)
|
||||
const serverInfo = ensureServerRunning();
|
||||
const serverInfo = ensureServerRunning(activeCwd);
|
||||
if (!serverInfo) {
|
||||
console.log(JSON.stringify({ ok: false, error: 'server_start_failed' }));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// 3. Inject the script tag at the current port
|
||||
const injectOut = runScript('live-inject.mjs', ['--port', String(serverInfo.port)]);
|
||||
const injectOut = runScript('live-inject.mjs', ['--port', String(serverInfo.port)], { cwd: activeCwd });
|
||||
const injectResult = safeParse(injectOut);
|
||||
if (!injectResult || !injectResult.ok) {
|
||||
console.log(JSON.stringify({
|
||||
@@ -80,22 +123,23 @@ The agent should then:
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// 4. Load PRODUCT.md + DESIGN.md context.
|
||||
const ctx = loadContext(process.cwd());
|
||||
|
||||
// 5. Compute drift-heal: compare resolved inject targets against the
|
||||
// 4. Compute drift-heal: compare resolved inject targets against the
|
||||
// project's HTML files. Orphans are HTML files not covered by config.
|
||||
// Warning only — the agent decides whether to act.
|
||||
const resolvedFiles = resolveFiles(process.cwd(), checkResult.config);
|
||||
const drift = scanForDrift(process.cwd(), resolvedFiles, checkResult.config);
|
||||
const resolvedFiles = resolveFiles(activeCwd, checkResult.config);
|
||||
const drift = scanForDrift(activeCwd, resolvedFiles, checkResult.config);
|
||||
|
||||
// 6. Emit everything the agent needs
|
||||
// 5. Emit everything the agent needs
|
||||
console.log(JSON.stringify({
|
||||
ok: true,
|
||||
serverPort: serverInfo.port,
|
||||
serverToken: serverInfo.token,
|
||||
pageFiles: resolvedFiles,
|
||||
liveConfigPath: checkResult.path,
|
||||
configDrift: drift,
|
||||
targetPath: outputTargetPath,
|
||||
projectRoot: ctx.projectRoot,
|
||||
repoRoot: ctx.repoRoot,
|
||||
hasProduct: ctx.hasProduct,
|
||||
product: ctx.product,
|
||||
productPath: ctx.productPath,
|
||||
@@ -105,6 +149,13 @@ The agent should then:
|
||||
}, null, 2));
|
||||
}
|
||||
|
||||
function missingLiveContext(ctx) {
|
||||
const missing = [];
|
||||
if (!ctx.hasProduct) missing.push('PRODUCT.md');
|
||||
if (!ctx.hasDesign) missing.push('DESIGN.md');
|
||||
return missing;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drift-heal scan. Walks the project for HTML files under common
|
||||
* page-source directories (public/, src/, app/, pages/) and reports any
|
||||
@@ -201,11 +252,11 @@ function globToRegex(pattern) {
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function runScript(name, args) {
|
||||
function runScript(name, args, options = {}) {
|
||||
const scriptPath = path.join(__dirname, name);
|
||||
const cmd = `node "${scriptPath}" ${args.map(a => `"${a}"`).join(' ')}`;
|
||||
try {
|
||||
return execSync(cmd, { encoding: 'utf-8', cwd: process.cwd(), timeout: 15_000 });
|
||||
return execSync(cmd, { encoding: 'utf-8', cwd: options.cwd || process.cwd(), timeout: 15_000 });
|
||||
} catch (err) {
|
||||
// execSync throws on non-zero exit; return stdout if any
|
||||
return err.stdout || err.message || '';
|
||||
@@ -219,10 +270,10 @@ function safeParse(out) {
|
||||
/**
|
||||
* Return { pid, port, token } for the running live server, starting one if needed.
|
||||
*/
|
||||
function ensureServerRunning() {
|
||||
function ensureServerRunning(cwd = process.cwd()) {
|
||||
// Try to reuse an existing server
|
||||
try {
|
||||
const existing = readLiveServerInfo(process.cwd())?.info;
|
||||
const existing = readLiveServerInfo(cwd)?.info;
|
||||
if (existing && existing.pid) {
|
||||
try {
|
||||
process.kill(existing.pid, 0); // throws if dead
|
||||
@@ -232,7 +283,7 @@ function ensureServerRunning() {
|
||||
} catch { /* no PID file */ }
|
||||
|
||||
// Start a new server
|
||||
const out = runScript('live-server.mjs', ['--background']);
|
||||
const out = runScript('live-server.mjs', ['--background'], { cwd });
|
||||
return safeParse(out);
|
||||
}
|
||||
|
||||
|
||||
@@ -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.7.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.7.0",
|
||||
"version": "3.9.1",
|
||||
"author": {
|
||||
"name": "Paul Bakaus",
|
||||
"email": "paul@paulbakaus.com"
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
---
|
||||
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.7.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
|
||||
allowed-tools:
|
||||
- Bash(npx impeccable *)
|
||||
- Bash(node .claude/skills/impeccable/scripts/*)
|
||||
---
|
||||
|
||||
Designs and iterates production-grade frontend interfaces. Real working code, committed design choices, exceptional craft.
|
||||
@@ -15,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 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
|
||||
|
||||
@@ -108,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) |
|
||||
@@ -122,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) |
|
||||
|
||||
@@ -130,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
|
||||
@@ -1,12 +1,12 @@
|
||||
When asked for "bolder," AI defaults to the same tired tricks: cyan/purple gradients, glassmorphism, neon accents on dark backgrounds, gradient text on metrics. These are the opposite of bold. Reject them first, then increase visual impact and personality through stronger hierarchy, committed scale, and decisive type.
|
||||
When asked for "bolder," AI defaults to the same tired tricks: cyan/purple gradients, glassmorphism, neon accents on dark backgrounds, gradient text on metrics. These are the opposite of bold. Reject them first, then increase visual impact by making the existing design language more decisive, specific, and committed.
|
||||
|
||||
---
|
||||
|
||||
## Register
|
||||
|
||||
Brand: "bolder" means distinctive. Extreme scale, unexpected color, typographic risk, committed POV.
|
||||
Brand: "bolder" means distinctive. Express a stronger point of view through hierarchy, pacing, proportion, copy, evidence, and one committed visual idea.
|
||||
|
||||
Product: "bolder" rarely means theatrics; those undermine trust. It means stronger hierarchy, clearer weight contrast, one sharper accent, more committed density. The amplification is in clarity, not drama.
|
||||
Product: "bolder" rarely means theatrics; those undermine trust. It means stronger hierarchy, clearer weight contrast, sharper information density, and more decisive prioritization. The amplification is in clarity, not drama.
|
||||
|
||||
---
|
||||
|
||||
@@ -15,98 +15,105 @@ Product: "bolder" rarely means theatrics; those undermine trust. It means strong
|
||||
Analyze what makes the design feel too safe or boring:
|
||||
|
||||
1. **Identify weakness sources**:
|
||||
- **Generic choices**: System fonts, basic colors, standard layouts
|
||||
- **Timid scale**: Everything is medium-sized with no drama
|
||||
- **Low contrast**: Everything has similar visual weight
|
||||
- **Static**: No motion, no energy, no life
|
||||
- **Predictable**: Standard patterns with no surprises
|
||||
- **Flat hierarchy**: Nothing stands out or commands attention
|
||||
- **Generic choices**: The page could belong to any product in the category.
|
||||
- **Timid scale**: Everything is medium-sized with no clear lead.
|
||||
- **Low contrast**: Important and supporting elements have similar visual weight.
|
||||
- **Static**: The surface has no meaningful moment of emphasis.
|
||||
- **Predictable**: The composition follows a default pattern without a point of view.
|
||||
- **Flat hierarchy**: Nothing stands out or commands attention.
|
||||
|
||||
2. **Understand the context**:
|
||||
- What's the brand personality? (How far can we push?)
|
||||
- What's the purpose? (Marketing can be bolder than financial dashboards)
|
||||
- Who's the audience? (What will resonate?)
|
||||
- What are the constraints? (Brand guidelines, accessibility, performance)
|
||||
- What is the brand personality?
|
||||
- What is the purpose of this surface?
|
||||
- Who is the audience?
|
||||
- What design system, tokens, components, and visual conventions already exist?
|
||||
|
||||
If any of these are unclear from the codebase, STOP and call the AskUserQuestion tool to clarify.
|
||||
|
||||
**CRITICAL**: "Bolder" doesn't mean chaotic or garish. It means distinctive, memorable, and confident. Think intentional drama, not random chaos.
|
||||
**CRITICAL**: "Bolder" does not mean chaotic or garish. It means distinctive, memorable, and confident. Think intentional drama, not random noise.
|
||||
|
||||
**WARNING - AI SLOP TRAP**: Review ALL the DON'T guidelines from the parent impeccable skill (already loaded in this context) before proceeding. Bold means distinctive, not "more effects."
|
||||
|
||||
## Design-System Lock
|
||||
|
||||
If the project has `DESIGN.md`, tokens, theme variables, or established component styles, treat that system as the boundary. Make the existing language stronger before adding new language.
|
||||
|
||||
Do not invent new colors, gradients, radii, shadows, fonts, decorative backgrounds, or effects just because the request says "bolder." A bolder pass should usually change emphasis, proportion, rhythm, density, contrast, copy, artifact specificity, and layout relationships while staying inside the documented system.
|
||||
|
||||
If the existing system is genuinely too limited to express the bolder direction, stop and ask the user before expanding it. Name the exact additions, the role each would play, and why the current system cannot do the job. If the user approves expansion, update the design system or tokens alongside the implementation.
|
||||
|
||||
## Plan Amplification
|
||||
|
||||
Create a strategy to increase impact while maintaining coherence:
|
||||
|
||||
- **Focal point**: What should be the hero moment? (Pick ONE, make it amazing)
|
||||
- **Personality direction**: Maximalist chaos? Elegant drama? Playful energy? Dark moody? Choose a lane.
|
||||
- **Risk budget**: How experimental can we be? Push boundaries within constraints.
|
||||
- **Hierarchy amplification**: Make big things BIGGER, small things smaller (increase contrast)
|
||||
- **Focal point**: Pick one thing the viewer should remember, then make the rest support it.
|
||||
- **System levers**: Identify which existing tokens, components, layout patterns, and copy structures can carry more weight.
|
||||
- **Risk budget**: Decide how far the surface can push while still feeling like the same product or brand.
|
||||
- **Hierarchy amplification**: Increase contrast between primary, secondary, and tertiary content instead of making every element louder.
|
||||
|
||||
**IMPORTANT**: Bold design must still be usable. Impact without function is just decoration.
|
||||
|
||||
## Amplify the Design
|
||||
|
||||
Systematically increase impact across these dimensions:
|
||||
Systematically increase impact through intention, not a menu of effects:
|
||||
|
||||
### Typography Amplification
|
||||
- **Replace generic fonts**: Swap system fonts for distinctive choices (see the parent skill's typography guidelines and the [Reference Material section of typeset.md](typeset.md#reference-material) for inspiration)
|
||||
- **Extreme scale**: Create dramatic size jumps (3x-5x differences, not 1.5x)
|
||||
- **Weight contrast**: Pair 900 weights with 200 weights, not 600 with 400
|
||||
- **Unexpected choices**: Variable fonts, display fonts for headlines, condensed/extended widths, monospace as intentional accent (not as lazy "dev tool" default)
|
||||
- Strengthen the existing type hierarchy before changing typefaces.
|
||||
- Make important text meaningfully more dominant, and make supporting text quieter.
|
||||
- Use weight, measure, spacing, and line breaks to sharpen the point of view.
|
||||
- Add or replace fonts only after user-approved design-system expansion.
|
||||
|
||||
### Color Intensification
|
||||
- **Increase saturation**: Shift to more vibrant, energetic colors (but not neon)
|
||||
- **Bold palette**: Introduce unexpected color combinations. Avoid the purple-blue gradient AI slop
|
||||
- **Dominant color strategy**: Let one bold color own 60% of the design
|
||||
- **Sharp accents**: High-contrast accent colors that pop
|
||||
- **Tinted neutrals**: Replace pure grays with tinted grays that harmonize with your palette
|
||||
- **Rich gradients**: Intentional multi-stop gradients (not generic purple-to-blue)
|
||||
### Color Amplification
|
||||
- Use the existing palette more decisively before adding colors.
|
||||
- Shift the proportion, placement, and contrast of documented colors to clarify meaning.
|
||||
- Treat any new color, gradient, or tint ramp as a design-system expansion that requires user approval.
|
||||
- Keep color tied to hierarchy, state, or brand meaning; do not use it as surface decoration.
|
||||
|
||||
### Spatial Drama
|
||||
- **Extreme scale jumps**: Make important elements 3-5x larger than surroundings
|
||||
- **Break the grid**: Let hero elements escape containers and cross boundaries
|
||||
- **Asymmetric layouts**: Replace centered, balanced layouts with tension-filled asymmetry
|
||||
- **Generous space**: Use white space dramatically (100-200px gaps, not 20-40px)
|
||||
- **Overlap**: Layer elements intentionally for depth
|
||||
### Spatial Amplification
|
||||
- Change proportion, density, alignment, and sequencing so the composition has a stronger point of view.
|
||||
- Create clearer contrast between dense evidence and open breathing room.
|
||||
- Let layout express priority and narrative order before adding ornament.
|
||||
- Preserve responsive behavior and avoid text overflow at every breakpoint.
|
||||
|
||||
### Visual Effects
|
||||
- **Dramatic shadows**: Large, soft shadows for elevation (but not generic drop shadows on rounded rectangles)
|
||||
- **Background treatments**: Mesh patterns, noise textures, geometric patterns, intentional gradients (not purple-to-blue)
|
||||
- **Texture & depth**: Grain, halftone, duotone, layered elements. NOT glassmorphism (it's overused AI slop)
|
||||
- **Borders & frames**: Thick borders, decorative frames, custom shapes (not rounded rectangles with colored border on one side)
|
||||
- **Custom elements**: Illustrative elements, custom icons, decorative details that reinforce brand
|
||||
### Surface Amplification
|
||||
- Use existing surface, border, radius, and shadow rules more deliberately.
|
||||
- Remove timid half-measures: either give an element a clear role or simplify it.
|
||||
- Add texture, depth, illustration, or decorative treatments only when already established by the system or explicitly approved.
|
||||
- Make real product artifacts, imagery, data, or copy carry attention before reaching for effects.
|
||||
|
||||
### Motion & Animation
|
||||
- **Hero moment**: One signature entrance, once. Not on every visit and not on every section.
|
||||
- **Micro-interactions**: Satisfying hover effects, click feedback, state changes.
|
||||
- **Transitions**: Smooth, noticeable transitions using ease-out-quart/quint/expo (not bounce or elastic, which cheapen the effect).
|
||||
- **Bolder ≠ scroll-fade-rise on every section.** That's the saturated AI default, the opposite of bold.
|
||||
- Design one meaningful moment of emphasis when motion genuinely supports the point.
|
||||
- Make interaction feedback feel more decisive without becoming distracting.
|
||||
- Keep transitions smooth and intentional.
|
||||
- **Bolder != scroll-fade-rise on every section.** That's the saturated AI default, the opposite of bold.
|
||||
|
||||
### Composition Boldness
|
||||
- **Hero moments**: Create clear focal points with dramatic treatment
|
||||
- **Diagonal flows**: Escape horizontal/vertical rigidity with diagonal arrangements
|
||||
- **Full-bleed elements**: Use full viewport width/height for impact
|
||||
- **Unexpected proportions**: Golden ratio? Throw it out. Try 70/30, 80/20 splits
|
||||
- Make the dominant idea unmistakable.
|
||||
- Use layout tension, sequencing, contrast, and restraint to create a stronger read.
|
||||
- Let the page's structure communicate priority before adding decorative layers.
|
||||
- If every element is louder, the composition is not bolder; it is flatter.
|
||||
|
||||
**NEVER**:
|
||||
- Add effects randomly without purpose (chaos ≠ bold)
|
||||
- Sacrifice readability for aesthetics (body text must be readable)
|
||||
- Make everything bold (then nothing is bold; you need contrast)
|
||||
- Ignore accessibility (bold design must still meet WCAG standards)
|
||||
- Overwhelm with motion (animation fatigue is real)
|
||||
- Copy trendy aesthetics blindly (bold means distinctive, not derivative)
|
||||
- Add undocumented design-system primitives without user approval
|
||||
- Add effects randomly without purpose
|
||||
- Hide weak hierarchy behind decoration
|
||||
- Sacrifice readability for aesthetics
|
||||
- Make everything bold; contrast is the point
|
||||
- Ignore accessibility
|
||||
- Overwhelm with motion
|
||||
- Copy trendy aesthetics blindly
|
||||
|
||||
## Verify Quality
|
||||
|
||||
Ensure amplification maintains usability and coherence:
|
||||
|
||||
- **System-faithful**: Did the pass make the existing design language stronger before adding anything new?
|
||||
- **No undocumented drift**: Are new colors, gradients, shadows, radii, fonts, and effects either absent or explicitly approved and documented?
|
||||
- **NOT AI slop**: Does this look like every other AI-generated "bold" design? If yes, start over.
|
||||
- **Still functional**: Can users accomplish tasks without distraction?
|
||||
- **Coherent**: Does everything feel intentional and unified?
|
||||
- **Memorable**: Will users remember this experience?
|
||||
- **Performant**: Do all these effects run smoothly?
|
||||
- **Accessible**: Does it still meet accessibility standards?
|
||||
- **Memorable**: Will users remember this experience for the intended reason?
|
||||
- **Performant and accessible**: Does the result stay fast, readable, responsive, and WCAG-conscious?
|
||||
|
||||
**The test**: If you showed this to someone and said "AI made this bolder," would they believe you immediately? If yes, you've failed. Bold means distinctive, not "more AI effects."
|
||||
|
||||
|
||||
@@ -5,8 +5,9 @@ Resolve one stable target, run two independent assessments, synthesize a design
|
||||
### Hard Invariants
|
||||
|
||||
- Assessment A (design review) and Assessment B (detector/browser evidence) are both required.
|
||||
- Assessment A and B MUST run as two isolated sub-agents whenever a sub-agent/Task tool is exposed. Running them inline in this context is "possible" but is NOT permitted; it is a degraded run. Inline is allowed ONLY when no sub-agent tool exists (or the user declined, on harnesses that ask).
|
||||
- If you degrade for any reason, the report's first line MUST be a banner: `⚠️ DEGRADED: single-context (<reason>)`. A silent degraded critique is a failed critique.
|
||||
- Assessment A must finish before detector findings enter the parent synthesis context. Detector output is deterministic, but it still anchors judgment.
|
||||
- If sub-agents are unavailable, fall back sequentially: finish and record Assessment A first, then run Assessment B, then synthesize.
|
||||
- A skipped detector is a failed critique run unless `detect.mjs` is missing or crashes after a real attempt.
|
||||
- Viewable targets require browser inspection when available.
|
||||
- Any local server started only for critique visualization must run in the background, have a recorded stop method, and be stopped before final reporting unless the user asks to keep it.
|
||||
@@ -27,7 +28,13 @@ Resolve one stable target, run two independent assessments, synthesize a design
|
||||
|
||||
### Assessment Orchestration
|
||||
|
||||
Delegate Assessment A and Assessment B to separate sub-agents when possible. They must not see each other's output. Do not show findings to the user until synthesis.
|
||||
Delegate Assessment A and Assessment B to separate sub-agents. They must not see each other's output. Do not show findings to the user until synthesis.
|
||||
|
||||
Sub-agent gate (all harnesses):
|
||||
- Unless a harness-specific gate below overrides this, spawn A and B as two isolated, parallel sub-agents whenever a sub-agent/Task tool is exposed. This is the default and is mandatory; do not run them inline because it is faster.
|
||||
- "Unavailable" means exactly one thing: no sub-agent/Task tool is exposed in this session (or, on harnesses that ask, the user declined). It does not mean inconvenient.
|
||||
- If and only if sub-agents are unavailable, fall back sequentially: finish and record Assessment A, then run Assessment B, then synthesize, and emit the degraded banner.
|
||||
- Whichever path you take, declare it in the report header (see Report header provenance). Skipping sub-agents without the banner is the most common failure of this command.
|
||||
|
||||
If browser automation is available, each assessment creates its own new tab. Never reuse an existing tab, even if it is already at the right URL.
|
||||
|
||||
@@ -61,7 +68,7 @@ node .claude/skills/impeccable/scripts/detect.mjs --json [target]
|
||||
|
||||
Browser visualization is required for a viewable target when browser automation is available. Use a localhost dev/static URL for local files; avoid `file://` unless the available browser explicitly supports this workflow. Overlay flow:
|
||||
|
||||
1. Create a fresh tab and navigate.
|
||||
1. Create a fresh tab and navigate. Prefer the harness's native/browser-canvas screenshot path before hand-rolling a Playwright/Puppeteer script; only fall back to a custom script when no native browser tool is exposed.
|
||||
2. Preflight mutable injection by setting `document.title` and appending a `<script>` tag. Read-only evaluate APIs do not count.
|
||||
3. If mutation is unavailable, skip live server, browser presentation, and injection; report fallback signal.
|
||||
4. If mutation is available, start `node .claude/skills/impeccable/scripts/live-server.mjs --background`, present the browser if supported, label `[Human]`, scroll top, inject `http://localhost:PORT/detect.js`, wait 2-3 seconds, read `impeccable` console messages, then stop the live server.
|
||||
@@ -79,6 +86,12 @@ The chat response is the primary user-facing deliverable. Present the full struc
|
||||
|
||||
Structure your feedback as a design director would:
|
||||
|
||||
#### Report header provenance
|
||||
|
||||
The report's first line MUST declare how the assessments were run, so a degraded run is never silent:
|
||||
- Dual-agent: `Method: dual-agent (A: <agent-id> · B: <agent-id>)`
|
||||
- Degraded: `⚠️ DEGRADED: single-context (<reason, e.g. no sub-agent tool exposed>)`
|
||||
|
||||
#### Design Health Score
|
||||
> *Consult the [Heuristics Scoring Guide](#heuristics-scoring-guide) section below.*
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
Generate a `DESIGN.md` file at the project root that captures the current visual design system, so AI agents generating new screens stay on-brand.
|
||||
|
||||
DESIGN.md follows the [official Google Stitch DESIGN.md format](https://stitch.withgoogle.com/docs/design-md/format/): YAML frontmatter carrying machine-readable design tokens, followed by a markdown body with exactly six sections in a fixed order. **Tokens are normative; prose provides context for how to apply them.** Sections may be omitted when not relevant, but **do not reorder them and do not rename them**. Section headers must match the spec character-for-character so the file stays parseable by other DESIGN.md-aware tools (Stitch itself, awesome-design-md, skill-rest, etc.).
|
||||
DESIGN.md follows the [official DESIGN.md format spec](https://raw.githubusercontent.com/google-labs-code/design.md/main/docs/spec.md): YAML frontmatter carrying machine-readable design tokens, followed by a markdown body with exactly six sections in a fixed order. **Tokens are normative; prose provides context for how to apply them.** Sections may be omitted when not relevant, but **do not reorder them and do not rename them**. Section headers must match the spec character-for-character so the file stays parseable by other DESIGN.md-aware tools (Stitch itself, awesome-design-md, skill-rest, etc.).
|
||||
|
||||
## The frontmatter: token schema
|
||||
|
||||
|
||||
@@ -2,13 +2,15 @@
|
||||
|
||||
Manage the **design detector hook** for the current project.
|
||||
|
||||
The hook runs the impeccable design detector on direct file edits to design-relevant files (`.tsx`, `.jsx`, `.html`, `.vue`, `.svelte`, `.astro`, `.css`, `.scss`, `.sass`, `.less`, `.ts`, `.js`). Claude Code and Codex use `PostToolUse` and push a short system reminder into the agent's context after the edit; findings get a correction prompt, pending issues get a re-nudge, and clean UI-ish files get a short ack unless quiet mode is on (`hook.quiet` in config). Plain `.ts` and `.js` files are still scanned, but stay quiet unless the detector finds something. Cursor uses `preToolUse` to block bad proposed writes before they land and stays silent when it allows a clean write.
|
||||
The hook runs the impeccable design detector on direct file edits to design-relevant files (`.tsx`, `.jsx`, `.html`, `.vue`, `.svelte`, `.astro`, `.css`, `.scss`, `.sass`, `.less`, `.ts`, `.js`). Claude Code, Codex, and GitHub Copilot use a post-tool-use hook and push a short system reminder into the agent's context after the edit; findings get a correction prompt, pending issues get a re-nudge, and clean UI-ish files get a short ack unless quiet mode is on (`hook.quiet` in config). Plain `.ts` and `.js` files are still scanned, but stay quiet unless the detector finds something. Cursor uses `preToolUse` to block bad proposed writes before they land and stays silent when it allows a clean write.
|
||||
|
||||
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), and Cursor (`.cursor/hooks.json` in the project).
|
||||
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.
|
||||
|
||||
@@ -51,7 +53,7 @@ Prefer the narrowest exception:
|
||||
- For value-specific findings such as `overused-font` and `bounce-easing`, use `ignore-value` when the user confirms the specific value. Do not use `ignore-rule overused-font` for a specific font.
|
||||
- If the finding has no value-specific command, such as `side-tab`, prefer `ignore-file <path>` for the current file.
|
||||
- Use `ignore-rule <id>` only when the user asks to suppress that whole rule across the project. For broad overused-font suppression, use `ignore-rule overused-font --all-values` only when the user asks to ignore overused fonts generally.
|
||||
- Do not add source comments such as `impeccable: ignore`; inline comments pollute code and are not a supported suppression mechanism.
|
||||
- Prefer config ignores (the commands above) by default; they keep suppressions in one reviewable place. Reach for an inline comment only when the waiver must travel with a single file that leaves the repo (a generated/exported standalone document, an emailed HTML file). The supported marker is `impeccable-disable <rule>` (whole file) or `impeccable-disable-line` / `impeccable-disable-next-line` (one line), in any comment syntax, with an optional reason after `:` or `--`. The detector honors it by default; `--no-inline-ignores` or `--no-config` bypasses it.
|
||||
|
||||
Example value-specific exception:
|
||||
|
||||
@@ -79,10 +81,10 @@ 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 and Codex 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`, and `.cursor/hooks.json`. On Codex, the user must approve the hook via `/hooks` the first time. On Cursor, confirm hooks are enabled under Settings -> Hooks.
|
||||
- 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.
|
||||
|
||||
## Failure modes
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
The setup command for a project. One codebase crawl feeds everything it writes:
|
||||
|
||||
- **PRODUCT.md** (strategic): root project file for register, target users, product purpose, brand personality, anti-references, strategic design principles. Answers "who/what/why".
|
||||
- **DESIGN.md** (visual): root project file for visual theme, color palette, typography, components, layout. Follows the [Google Stitch DESIGN.md format](https://stitch.withgoogle.com/docs/design-md/format/). Answers "how it looks".
|
||||
- **DESIGN.md** (visual): root project file for visual theme, color palette, typography, components, layout. Follows the [DESIGN.md format spec](https://raw.githubusercontent.com/google-labs-code/design.md/main/docs/spec.md). Answers "how it looks".
|
||||
- **`.impeccable/live/config.json`** (live mode): pre-configured so `/impeccable live` boots straight into variant mode with no first-time detour.
|
||||
|
||||
It closes by pointing the user at the best command to run next. Every other impeccable command reads PRODUCT.md and DESIGN.md before doing any work.
|
||||
@@ -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
|
||||
|
||||
@@ -8,7 +8,7 @@ A running dev server with hot module replacement (Vite, Next.js, Bun, etc.), OR
|
||||
|
||||
Execute in order. No step skipped, no step reordered.
|
||||
|
||||
1. `live.mjs`: boot.
|
||||
1. `live.mjs`: boot. 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/live.mjs --target <path>` instead; then run the rest of this live session from the returned `projectRoot`.
|
||||
2. Open the app URL that serves `pageFile` (infer from `package.json`, docs, terminal output, or an open tab). Never use `serverPort`; it's the helper, not the app. **Cursor:** `browser_navigate` to that URL before polling; do not skip. **Other harnesses:** use the available browser tool; if the URL is uncertain, ask the user once.
|
||||
3. Poll loop with the default long timeout (600000 ms). After every event or `--reply`, run `live-poll.mjs` again immediately. Never pass a short `--timeout=`.
|
||||
|
||||
|
||||
@@ -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,15 +1,18 @@
|
||||
/**
|
||||
* 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. cwd, if PRODUCT.md or DESIGN.md is there
|
||||
* 2. .agents/context/ then docs/
|
||||
* 3. $IMPECCABLE_CONTEXT_DIR (absolute or cwd-relative) — power-user
|
||||
* 1. Active project root, if PRODUCT.md or DESIGN.md is there
|
||||
* 2. Active project .agents/context/ then docs/
|
||||
* 3. Monorepo root context, using the same order, as a per-file fallback
|
||||
* 4. $IMPECCABLE_CONTEXT_DIR (absolute or cwd-relative) — power-user
|
||||
* escape hatch, only consulted when defaults are empty
|
||||
* 4. cwd as a "nothing found" default
|
||||
* 5. Active project root as a "nothing found" default
|
||||
*
|
||||
* `resolveContextDir()` and `loadContext()` are also exported for the
|
||||
* server-side scripts (live.mjs, live-server.mjs) that need the structured
|
||||
@@ -19,10 +22,26 @@ import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { parseTargetOptions } from './lib/target-args.mjs';
|
||||
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'];
|
||||
const FALLBACK_DIRS = ['.agents/context', 'docs'];
|
||||
const MONOREPO_MARKER_FILES = ['pnpm-workspace.yaml', 'turbo.json', 'nx.json', 'lerna.json'];
|
||||
const MONOREPO_FALLBACK_PROJECT_DIRS = ['apps', 'packages'];
|
||||
const WORKSPACE_DISCOVERY_IGNORED_DIRS = new Set([
|
||||
'node_modules',
|
||||
'.git',
|
||||
'dist',
|
||||
'build',
|
||||
'.next',
|
||||
'.nuxt',
|
||||
'.svelte-kit',
|
||||
'.turbo',
|
||||
'.cache',
|
||||
'coverage',
|
||||
]);
|
||||
|
||||
// ─── Update check ──────────────────────────────────────────────────────────
|
||||
// Piggyback a lightweight skill-version check on the once-per-session boot.
|
||||
@@ -38,41 +57,623 @@ const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; // throttle the network poll to o
|
||||
const RENOTIFY_INTERVAL_MS = 7 * 24 * 60 * 60 * 1000; // don't re-surface the same version for a week
|
||||
const FETCH_TIMEOUT_MS = 1200;
|
||||
|
||||
export function resolveContextDir(cwd = process.cwd()) {
|
||||
if (firstExisting(cwd, [...PRODUCT_NAMES, ...DESIGN_NAMES])) {
|
||||
return cwd;
|
||||
}
|
||||
for (const rel of FALLBACK_DIRS) {
|
||||
const candidate = path.resolve(cwd, rel);
|
||||
if (firstExisting(candidate, [...PRODUCT_NAMES, ...DESIGN_NAMES])) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
const envDir = process.env.IMPECCABLE_CONTEXT_DIR;
|
||||
if (envDir && envDir.trim()) {
|
||||
const trimmed = envDir.trim();
|
||||
return path.isAbsolute(trimmed) ? trimmed : path.resolve(cwd, trimmed);
|
||||
}
|
||||
return cwd;
|
||||
export function resolveContextDir(cwd = process.cwd(), options = {}) {
|
||||
return resolveContext(cwd, options).contextDir;
|
||||
}
|
||||
|
||||
export function loadContext(cwd = process.cwd()) {
|
||||
const contextDir = resolveContextDir(cwd);
|
||||
const productPath = firstExisting(contextDir, PRODUCT_NAMES);
|
||||
const designPath = firstExisting(contextDir, DESIGN_NAMES);
|
||||
export function loadContext(cwd = process.cwd(), options = {}) {
|
||||
const resolved = resolveContext(cwd, options);
|
||||
const absCwd = path.resolve(cwd);
|
||||
const productPath = resolved.productPath;
|
||||
const designPath = resolved.designPath;
|
||||
const product = productPath ? safeRead(productPath) : null;
|
||||
const design = designPath ? safeRead(designPath) : null;
|
||||
return {
|
||||
hasProduct: !!product,
|
||||
product,
|
||||
productPath: productPath ? path.relative(cwd, productPath) : null,
|
||||
productPath: productPath ? path.relative(absCwd, productPath) : null,
|
||||
hasDesign: !!design,
|
||||
design,
|
||||
designPath: designPath ? path.relative(cwd, designPath) : null,
|
||||
contextDir,
|
||||
designPath: designPath ? path.relative(absCwd, designPath) : null,
|
||||
contextDir: resolved.contextDir,
|
||||
productContextDir: productPath ? path.dirname(productPath) : null,
|
||||
designContextDir: designPath ? path.dirname(designPath) : null,
|
||||
projectRoot: resolved.projectRoot,
|
||||
repoRoot: resolved.repoRoot,
|
||||
isMonorepo: resolved.isMonorepo,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveContext(cwd = process.cwd(), options = {}) {
|
||||
const absCwd = path.resolve(cwd);
|
||||
const project = resolveProject(absCwd, options);
|
||||
const projectContextDir = resolveLocalContextDir(project.projectRoot);
|
||||
const rootContextDir = project.isMonorepo && project.repoRoot !== project.projectRoot
|
||||
? resolveLocalContextDir(project.repoRoot)
|
||||
: null;
|
||||
|
||||
let productPath =
|
||||
(projectContextDir ? firstExisting(projectContextDir, PRODUCT_NAMES) : null)
|
||||
|| (rootContextDir ? firstExisting(rootContextDir, PRODUCT_NAMES) : null);
|
||||
let designPath =
|
||||
(projectContextDir ? firstExisting(projectContextDir, DESIGN_NAMES) : null)
|
||||
|| (rootContextDir ? firstExisting(rootContextDir, DESIGN_NAMES) : null);
|
||||
|
||||
let envContextDir = null;
|
||||
if (!productPath && !designPath) {
|
||||
envContextDir = resolveEnvContextDir(absCwd);
|
||||
if (envContextDir) {
|
||||
productPath = firstExisting(envContextDir, PRODUCT_NAMES);
|
||||
designPath = firstExisting(envContextDir, DESIGN_NAMES);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
contextDir: productPath
|
||||
? path.dirname(productPath)
|
||||
: designPath
|
||||
? path.dirname(designPath)
|
||||
: envContextDir || project.projectRoot,
|
||||
productPath,
|
||||
designPath,
|
||||
projectRoot: project.projectRoot,
|
||||
repoRoot: project.repoRoot,
|
||||
isMonorepo: project.isMonorepo,
|
||||
targetDir: project.targetDir,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveProjectRoot(cwd = process.cwd(), options = {}) {
|
||||
return resolveProject(cwd, options).projectRoot;
|
||||
}
|
||||
|
||||
export function resolveTargetSelection(cwd = process.cwd(), options = {}) {
|
||||
if (hasTargetOption(options)) return null;
|
||||
const project = resolveProject(cwd);
|
||||
if (
|
||||
!project.isMonorepo
|
||||
|| !project.projectRoot
|
||||
|| !project.repoRoot
|
||||
|| path.resolve(project.projectRoot) !== path.resolve(project.repoRoot)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const targetCandidates = discoverTargetCandidates(project.repoRoot);
|
||||
// No discoverable child apps (e.g. `workspaces: ["."]`, a root-only workspace,
|
||||
// or a marker file with no apps/packages children): there is nothing to choose,
|
||||
// so treat the repo root as the active project rather than blocking on an empty
|
||||
// selection prompt that the user cannot answer.
|
||||
if (targetCandidates.length === 0) return null;
|
||||
return {
|
||||
targetPath: null,
|
||||
projectRoot: project.projectRoot,
|
||||
repoRoot: project.repoRoot,
|
||||
targetCandidates,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveProject(cwd = process.cwd(), options = {}) {
|
||||
const absCwd = path.resolve(cwd);
|
||||
const targetDir = resolveTargetDir(absCwd, options);
|
||||
let repoRoot = findMonorepoRoot(targetDir);
|
||||
if (!repoRoot && targetDir !== absCwd) {
|
||||
const cwdRepoRoot = findMonorepoRoot(absCwd);
|
||||
if (cwdRepoRoot && isPathInside(targetDir, cwdRepoRoot)) {
|
||||
repoRoot = cwdRepoRoot;
|
||||
}
|
||||
}
|
||||
if (!repoRoot) {
|
||||
return {
|
||||
targetDir,
|
||||
projectRoot: absCwd,
|
||||
repoRoot: absCwd,
|
||||
isMonorepo: false,
|
||||
};
|
||||
}
|
||||
return {
|
||||
targetDir,
|
||||
projectRoot: resolveWorkspaceProjectRoot(repoRoot, targetDir) || repoRoot,
|
||||
repoRoot,
|
||||
isMonorepo: true,
|
||||
};
|
||||
}
|
||||
|
||||
function isPathInside(candidate, root) {
|
||||
const rel = path.relative(root, candidate);
|
||||
return !!rel && !rel.startsWith('..') && !path.isAbsolute(rel);
|
||||
}
|
||||
|
||||
function resolveLocalContextDir(root) {
|
||||
if (firstExisting(root, [...PRODUCT_NAMES, ...DESIGN_NAMES])) {
|
||||
return root;
|
||||
}
|
||||
for (const rel of FALLBACK_DIRS) {
|
||||
const candidate = path.resolve(root, rel);
|
||||
if (firstExisting(candidate, [...PRODUCT_NAMES, ...DESIGN_NAMES])) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolveEnvContextDir(cwd) {
|
||||
const envDir = process.env.IMPECCABLE_CONTEXT_DIR;
|
||||
if (!envDir || !envDir.trim()) return null;
|
||||
const trimmed = envDir.trim();
|
||||
return path.isAbsolute(trimmed) ? trimmed : path.resolve(cwd, trimmed);
|
||||
}
|
||||
|
||||
function resolveTargetDir(cwd, options = {}) {
|
||||
const targetPath = options && typeof options === 'object' ? options.targetPath : null;
|
||||
if (!targetPath || !String(targetPath).trim()) return cwd;
|
||||
const abs = path.isAbsolute(targetPath) ? targetPath : path.resolve(cwd, targetPath);
|
||||
try {
|
||||
const stat = fs.statSync(abs);
|
||||
return stat.isDirectory() ? abs : path.dirname(abs);
|
||||
} catch {
|
||||
return path.extname(abs) ? path.dirname(abs) : abs;
|
||||
}
|
||||
}
|
||||
|
||||
function findMonorepoRoot(startDir) {
|
||||
let dir = path.resolve(startDir);
|
||||
const homeDir = path.resolve(os.homedir());
|
||||
while (true) {
|
||||
if (dir === homeDir) return null;
|
||||
// isMonorepoRoot is checked before hasGitBoundary on purpose: a workspace
|
||||
// root that also carries its own .git is still recognized. The trade-off is
|
||||
// deliberate — a directory with a monorepo *marker* but no workspace patterns
|
||||
// and no apps/packages children is not a monorepo root, so its .git stops
|
||||
// traversal and a further-up root is not searched. The nested .git is treated
|
||||
// as an independent project boundary, which is the intended isolation.
|
||||
if (isMonorepoRoot(dir)) return dir;
|
||||
if (hasGitBoundary(dir)) return null;
|
||||
const parent = path.dirname(dir);
|
||||
if (parent === dir) return null;
|
||||
dir = parent;
|
||||
}
|
||||
}
|
||||
|
||||
function isMonorepoRoot(dir) {
|
||||
if (readWorkspacePatterns(dir).some((pattern) => !normalizeWorkspacePattern(pattern).startsWith('!'))) return true;
|
||||
if (!MONOREPO_MARKER_FILES.some((file) => fs.existsSync(path.join(dir, file)))) return false;
|
||||
return hasFallbackWorkspaceChildren(dir);
|
||||
}
|
||||
|
||||
function hasGitBoundary(dir) {
|
||||
return fs.existsSync(path.join(dir, '.git'));
|
||||
}
|
||||
|
||||
function hasFallbackWorkspaceChildren(dir) {
|
||||
for (const name of MONOREPO_FALLBACK_PROJECT_DIRS) {
|
||||
const base = path.join(dir, name);
|
||||
let entries;
|
||||
try {
|
||||
entries = fs.readdirSync(base, { withFileTypes: true });
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (entries.some((entry) => entry.isDirectory() && !isIgnoredWorkspaceDiscoveryDir(entry.name))) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function discoverTargetCandidates(repoRoot) {
|
||||
const roots = new Map();
|
||||
const patterns = readWorkspacePatterns(repoRoot);
|
||||
for (const pattern of patterns) {
|
||||
for (const root of discoverRootsForPattern(repoRoot, pattern)) {
|
||||
roots.set(path.relative(repoRoot, root).split(path.sep).join('/'), root);
|
||||
}
|
||||
}
|
||||
if (MONOREPO_MARKER_FILES.some((file) => fs.existsSync(path.join(repoRoot, file)))) {
|
||||
for (const name of MONOREPO_FALLBACK_PROJECT_DIRS) {
|
||||
const base = path.join(repoRoot, name);
|
||||
let entries;
|
||||
try {
|
||||
entries = fs.readdirSync(base, { withFileTypes: true });
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory() || isIgnoredWorkspaceDiscoveryDir(entry.name)) continue;
|
||||
const root = path.join(base, entry.name);
|
||||
roots.set(path.relative(repoRoot, root).split(path.sep).join('/'), root);
|
||||
}
|
||||
}
|
||||
}
|
||||
return [...roots.entries()]
|
||||
.filter(([rel]) => rel && !rel.startsWith('..'))
|
||||
// Honor negated workspace patterns (e.g. "!packages/internal"). resolveWorkspaceProjectRoot
|
||||
// sends an excluded package back to the repo root, so an excluded folder must not appear as a
|
||||
// selectable target — choosing it would silently resolve to the root instead.
|
||||
.filter(([rel]) => !isExcludedByWorkspacePattern(rel.split('/').filter(Boolean), patterns))
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([rel, root]) => {
|
||||
const targetExample = findTargetExample(repoRoot, root);
|
||||
return {
|
||||
name: path.basename(root),
|
||||
path: rel,
|
||||
targetExample,
|
||||
...resolveCandidateContextSummary(repoRoot, root, targetExample),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function resolveCandidateContextSummary(repoRoot, projectRoot, targetPath) {
|
||||
const ctx = resolveContext(repoRoot, { targetPath });
|
||||
return {
|
||||
productStatus: contextSourceStatus(ctx.productPath, repoRoot, projectRoot),
|
||||
productPath: contextSourcePath(ctx.productPath, repoRoot),
|
||||
designStatus: contextSourceStatus(ctx.designPath, repoRoot, projectRoot),
|
||||
designPath: contextSourcePath(ctx.designPath, repoRoot),
|
||||
};
|
||||
}
|
||||
|
||||
// Selection candidates surface one of four statuses: 'child' (a canonical
|
||||
// PRODUCT.md/DESIGN.md directly in the app root), 'inherited' (resolved from the
|
||||
// repo root in a monorepo), 'missing' (no file found), and 'fallback'. 'fallback'
|
||||
// intentionally covers two non-canonical locations: a file inside the project
|
||||
// root but in a subdirectory (FALLBACK_DIRS, e.g. `.agents/context/`), and a file
|
||||
// outside both the project and repo roots (IMPECCABLE_CONTEXT_DIR override).
|
||||
function contextSourceStatus(filePath, repoRoot, projectRoot) {
|
||||
if (!filePath) return 'missing';
|
||||
const absPath = path.resolve(filePath);
|
||||
const absProjectRoot = path.resolve(projectRoot);
|
||||
const absRepoRoot = path.resolve(repoRoot);
|
||||
if (isPathInsideOrEqual(absPath, absProjectRoot)) {
|
||||
return path.dirname(absPath) === absProjectRoot ? 'child' : 'fallback';
|
||||
}
|
||||
if (absProjectRoot !== absRepoRoot && isPathInsideOrEqual(absPath, absRepoRoot)) {
|
||||
return 'inherited';
|
||||
}
|
||||
return 'fallback';
|
||||
}
|
||||
|
||||
function contextSourcePath(filePath, repoRoot) {
|
||||
if (!filePath) return null;
|
||||
const rel = path.relative(repoRoot, filePath);
|
||||
if (rel && !rel.startsWith('..') && !path.isAbsolute(rel)) {
|
||||
return rel.split(path.sep).join('/');
|
||||
}
|
||||
return filePath;
|
||||
}
|
||||
|
||||
function discoverRootsForPattern(repoRoot, rawPattern) {
|
||||
const pattern = normalizeWorkspacePattern(rawPattern);
|
||||
if (!pattern || pattern.startsWith('!')) return [];
|
||||
const segments = pattern.split('/').filter(Boolean);
|
||||
if (!segments.length) return [];
|
||||
const firstGlobIndex = segments.findIndex((segment) => segment.includes('*'));
|
||||
const literalPrefix = firstGlobIndex === -1 ? segments : segments.slice(0, firstGlobIndex);
|
||||
const base = path.join(repoRoot, ...literalPrefix);
|
||||
if (!fs.existsSync(base)) return [];
|
||||
if (segments.includes('**')) {
|
||||
const packageRoots = [];
|
||||
walkDirs(base, (dir) => {
|
||||
if (dir !== base && isCandidateProjectRoot(dir)) packageRoots.push(dir);
|
||||
});
|
||||
if (packageRoots.length) return packageRoots;
|
||||
return directChildDirs(base);
|
||||
}
|
||||
return expandSimplePattern(repoRoot, segments);
|
||||
}
|
||||
|
||||
function expandSimplePattern(repoRoot, patternSegments, index = 0, current = repoRoot) {
|
||||
if (index >= patternSegments.length) return fs.existsSync(current) ? [current] : [];
|
||||
const segment = patternSegments[index];
|
||||
if (!segment.includes('*')) {
|
||||
return expandSimplePattern(repoRoot, patternSegments, index + 1, path.join(current, segment));
|
||||
}
|
||||
let entries;
|
||||
try {
|
||||
entries = fs.readdirSync(current, { withFileTypes: true });
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
const roots = [];
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory() || isIgnoredWorkspaceDiscoveryDir(entry.name)) continue;
|
||||
if (!segmentMatches(segment, entry.name)) continue;
|
||||
roots.push(...expandSimplePattern(repoRoot, patternSegments, index + 1, path.join(current, entry.name)));
|
||||
}
|
||||
return roots;
|
||||
}
|
||||
|
||||
function directChildDirs(dir) {
|
||||
try {
|
||||
return fs.readdirSync(dir, { withFileTypes: true })
|
||||
.filter((entry) => entry.isDirectory() && !isIgnoredWorkspaceDiscoveryDir(entry.name))
|
||||
.map((entry) => path.join(dir, entry.name));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function walkDirs(root, visit) {
|
||||
let entries;
|
||||
try {
|
||||
entries = fs.readdirSync(root, { withFileTypes: true });
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory() || isIgnoredWorkspaceDiscoveryDir(entry.name)) continue;
|
||||
const dir = path.join(root, entry.name);
|
||||
visit(dir);
|
||||
walkDirs(dir, visit);
|
||||
}
|
||||
}
|
||||
|
||||
function isCandidateProjectRoot(dir) {
|
||||
return !!(
|
||||
fs.existsSync(path.join(dir, 'package.json'))
|
||||
|| firstExisting(dir, [...PRODUCT_NAMES, ...DESIGN_NAMES])
|
||||
|| fs.existsSync(path.join(dir, 'src'))
|
||||
|| fs.existsSync(path.join(dir, 'app'))
|
||||
|| fs.existsSync(path.join(dir, 'pages'))
|
||||
|| fs.existsSync(path.join(dir, 'public'))
|
||||
);
|
||||
}
|
||||
|
||||
function isIgnoredWorkspaceDiscoveryDir(name) {
|
||||
return name.startsWith('.') || WORKSPACE_DISCOVERY_IGNORED_DIRS.has(name);
|
||||
}
|
||||
|
||||
function findTargetExample(repoRoot, projectRoot) {
|
||||
const examples = [
|
||||
'src/App.jsx',
|
||||
'src/App.tsx',
|
||||
'src/main.jsx',
|
||||
'src/main.tsx',
|
||||
'src/index.jsx',
|
||||
'src/index.ts',
|
||||
'app/page.tsx',
|
||||
'pages/index.tsx',
|
||||
'public/index.html',
|
||||
];
|
||||
for (const rel of examples) {
|
||||
const abs = path.join(projectRoot, rel);
|
||||
if (fs.existsSync(abs)) return path.relative(repoRoot, abs).split(path.sep).join('/');
|
||||
}
|
||||
return path.relative(repoRoot, projectRoot).split(path.sep).join('/');
|
||||
}
|
||||
|
||||
function resolveWorkspaceProjectRoot(repoRoot, targetDir) {
|
||||
const rel = path.relative(repoRoot, targetDir);
|
||||
if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return repoRoot;
|
||||
const relSegments = rel.split(path.sep).filter(Boolean);
|
||||
const patterns = readWorkspacePatterns(repoRoot);
|
||||
const excluded = isExcludedByWorkspacePattern(relSegments, patterns);
|
||||
if (!excluded) {
|
||||
for (const pattern of patterns) {
|
||||
const projectRoot = projectRootFromWorkspacePattern(repoRoot, relSegments, pattern);
|
||||
if (projectRoot) return projectRoot;
|
||||
}
|
||||
}
|
||||
if (excluded) return repoRoot;
|
||||
if (
|
||||
relSegments.length >= 2
|
||||
&& MONOREPO_FALLBACK_PROJECT_DIRS.includes(relSegments[0])
|
||||
) {
|
||||
return path.join(repoRoot, relSegments[0], relSegments[1]);
|
||||
}
|
||||
const nearest = nearestProjectLikeRoot(repoRoot, targetDir);
|
||||
if (nearest) return nearest;
|
||||
return repoRoot;
|
||||
}
|
||||
|
||||
function isExcludedByWorkspacePattern(relSegments, patterns) {
|
||||
return patterns.some((rawPattern) => {
|
||||
const pattern = normalizeWorkspacePattern(rawPattern);
|
||||
if (!pattern.startsWith('!')) return false;
|
||||
return workspacePatternMatchesRel(pattern.slice(1), relSegments);
|
||||
});
|
||||
}
|
||||
|
||||
function nearestProjectLikeRoot(repoRoot, targetDir) {
|
||||
let dir = path.resolve(targetDir);
|
||||
const stop = path.resolve(repoRoot);
|
||||
while (dir && dir !== stop) {
|
||||
if (
|
||||
firstExisting(dir, [...PRODUCT_NAMES, ...DESIGN_NAMES])
|
||||
|| fs.existsSync(path.join(dir, 'package.json'))
|
||||
) {
|
||||
return dir;
|
||||
}
|
||||
const parent = path.dirname(dir);
|
||||
if (parent === dir) break;
|
||||
dir = parent;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function nearestPackageRootBetween(repoRoot, targetDir, stopDir) {
|
||||
let dir = path.resolve(targetDir);
|
||||
const stop = path.resolve(stopDir || repoRoot);
|
||||
const root = path.resolve(repoRoot);
|
||||
while (dir && dir !== stop && isPathInsideOrEqual(dir, root)) {
|
||||
if (fs.existsSync(path.join(dir, 'package.json'))) return dir;
|
||||
const parent = path.dirname(dir);
|
||||
if (parent === dir) break;
|
||||
dir = parent;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isPathInsideOrEqual(candidate, root) {
|
||||
return path.resolve(candidate) === path.resolve(root) || isPathInside(candidate, root);
|
||||
}
|
||||
|
||||
function workspacePatternMatchesRel(pattern, relSegments) {
|
||||
const patternSegments = normalizeWorkspacePattern(pattern).split('/').filter(Boolean);
|
||||
if (!patternSegments.length) return false;
|
||||
if (patternSegments.includes('**')) {
|
||||
const firstGlobIndex = patternSegments.findIndex((segment) => segment.includes('*'));
|
||||
const literalPrefix = firstGlobIndex === -1
|
||||
? patternSegments
|
||||
: patternSegments.slice(0, firstGlobIndex);
|
||||
if (relSegments.length < literalPrefix.length + 1) return false;
|
||||
for (let i = 0; i < literalPrefix.length; i++) {
|
||||
if (!segmentMatches(literalPrefix[i], relSegments[i])) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (relSegments.length < patternSegments.length) return false;
|
||||
for (let i = 0; i < patternSegments.length; i++) {
|
||||
if (!segmentMatches(patternSegments[i], relSegments[i])) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function readWorkspacePatterns(repoRoot) {
|
||||
return [
|
||||
...readPackageWorkspaces(repoRoot),
|
||||
...readPnpmWorkspaces(repoRoot),
|
||||
...readLernaWorkspaces(repoRoot),
|
||||
].filter(Boolean);
|
||||
}
|
||||
|
||||
function readPackageWorkspaces(repoRoot) {
|
||||
const pkg = readJson(path.join(repoRoot, 'package.json'));
|
||||
const workspaces = pkg?.workspaces;
|
||||
if (Array.isArray(workspaces)) return workspaces;
|
||||
if (Array.isArray(workspaces?.packages)) return workspaces.packages;
|
||||
return [];
|
||||
}
|
||||
|
||||
function readLernaWorkspaces(repoRoot) {
|
||||
const lerna = readJson(path.join(repoRoot, 'lerna.json'));
|
||||
return Array.isArray(lerna?.packages) ? lerna.packages : [];
|
||||
}
|
||||
|
||||
function readPnpmWorkspaces(repoRoot) {
|
||||
try {
|
||||
const body = fs.readFileSync(path.join(repoRoot, 'pnpm-workspace.yaml'), 'utf-8');
|
||||
const patterns = [];
|
||||
let inPackages = false;
|
||||
for (const line of body.split(/\r?\n/)) {
|
||||
const trimmed = stripYamlInlineComment(line).trim();
|
||||
if (!trimmed || trimmed.startsWith('#')) continue;
|
||||
const flowMatch = trimmed.match(/^packages:\s*\[(.*)\]\s*$/);
|
||||
if (flowMatch) {
|
||||
patterns.push(...parseYamlFlowList(flowMatch[1]));
|
||||
inPackages = false;
|
||||
continue;
|
||||
}
|
||||
if (/^packages:\s*$/.test(trimmed)) {
|
||||
inPackages = true;
|
||||
continue;
|
||||
}
|
||||
if (inPackages && /^[A-Za-z0-9_-]+:\s*/.test(trimmed)) break;
|
||||
if (inPackages) {
|
||||
const match = trimmed.match(/^-\s*(.+)$/);
|
||||
if (match) patterns.push(unquoteYamlValue(match[1]));
|
||||
}
|
||||
}
|
||||
return patterns;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function stripYamlInlineComment(line) {
|
||||
let quote = null;
|
||||
for (let i = 0; i < line.length; i++) {
|
||||
const ch = line[i];
|
||||
if ((ch === '"' || ch === "'") && line[i - 1] !== '\\') {
|
||||
quote = quote === ch ? null : quote || ch;
|
||||
continue;
|
||||
}
|
||||
if (ch === '#' && !quote) return line.slice(0, i);
|
||||
}
|
||||
return line;
|
||||
}
|
||||
|
||||
function parseYamlFlowList(body) {
|
||||
const items = [];
|
||||
let quote = null;
|
||||
let current = '';
|
||||
for (let i = 0; i < body.length; i++) {
|
||||
const ch = body[i];
|
||||
if ((ch === '"' || ch === "'") && body[i - 1] !== '\\') {
|
||||
quote = quote === ch ? null : quote || ch;
|
||||
current += ch;
|
||||
continue;
|
||||
}
|
||||
if (ch === ',' && !quote) {
|
||||
const value = unquoteYamlValue(current);
|
||||
if (value) items.push(value);
|
||||
current = '';
|
||||
continue;
|
||||
}
|
||||
current += ch;
|
||||
}
|
||||
const value = unquoteYamlValue(current);
|
||||
if (value) items.push(value);
|
||||
return items;
|
||||
}
|
||||
|
||||
function unquoteYamlValue(value) {
|
||||
return String(value || '')
|
||||
.trim()
|
||||
.replace(/^['"]|['"]$/g, '');
|
||||
}
|
||||
|
||||
function readJson(filePath) {
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function projectRootFromWorkspacePattern(repoRoot, relSegments, rawPattern) {
|
||||
const pattern = normalizeWorkspacePattern(rawPattern);
|
||||
if (!pattern || pattern.startsWith('!')) return null;
|
||||
const patternSegments = pattern.split('/').filter(Boolean);
|
||||
if (!patternSegments.length) return null;
|
||||
if (patternSegments.includes('**')) {
|
||||
return projectRootFromDoubleStarPattern(repoRoot, relSegments, patternSegments);
|
||||
}
|
||||
if (relSegments.length < patternSegments.length) return null;
|
||||
for (let i = 0; i < patternSegments.length; i++) {
|
||||
if (!segmentMatches(patternSegments[i], relSegments[i])) return null;
|
||||
}
|
||||
return path.join(repoRoot, ...relSegments.slice(0, patternSegments.length));
|
||||
}
|
||||
|
||||
function projectRootFromDoubleStarPattern(repoRoot, relSegments, patternSegments) {
|
||||
const firstGlobIndex = patternSegments.findIndex((segment) => segment.includes('*'));
|
||||
const literalPrefix = firstGlobIndex === -1
|
||||
? patternSegments
|
||||
: patternSegments.slice(0, firstGlobIndex);
|
||||
if (relSegments.length < literalPrefix.length + 1) return null;
|
||||
for (let i = 0; i < literalPrefix.length; i++) {
|
||||
if (!segmentMatches(literalPrefix[i], relSegments[i])) return null;
|
||||
}
|
||||
const prefixDir = path.join(repoRoot, ...literalPrefix);
|
||||
const targetDir = path.join(repoRoot, ...relSegments);
|
||||
const packageRoot = nearestPackageRootBetween(repoRoot, targetDir, prefixDir);
|
||||
if (packageRoot) return packageRoot;
|
||||
return path.join(repoRoot, ...relSegments.slice(0, literalPrefix.length + 1));
|
||||
}
|
||||
|
||||
function normalizeWorkspacePattern(pattern) {
|
||||
return String(pattern || '')
|
||||
.trim()
|
||||
.replace(/^['"]|['"]$/g, '')
|
||||
.replace(/^\.\//, '')
|
||||
.replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
function segmentMatches(patternSegment, relSegment) {
|
||||
if (patternSegment === '*') return true;
|
||||
if (!patternSegment.includes('*')) return patternSegment === relSegment;
|
||||
const re = new RegExp(`^${escapeRegExp(patternSegment).replace(/\\\*/g, '[^/]*')}$`);
|
||||
return re.test(relSegment);
|
||||
}
|
||||
|
||||
function firstExisting(dir, names) {
|
||||
for (const name of names) {
|
||||
const abs = path.join(dir, name);
|
||||
@@ -89,24 +690,64 @@ function safeRead(p) {
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -233,7 +874,24 @@ async function computeUpdateDirective(now = Date.now()) {
|
||||
}
|
||||
|
||||
async function cli() {
|
||||
const ctx = loadContext(process.cwd());
|
||||
let cliOptions;
|
||||
try {
|
||||
cliOptions = parseCliOptions(process.argv.slice(2));
|
||||
} catch (err) {
|
||||
if (err?.name === 'TargetArgError') {
|
||||
process.stderr.write(`${err.message}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
const targetProvided = hasTargetOption(cliOptions);
|
||||
const targetExists = targetProvided ? pathExistsForTarget(process.cwd(), cliOptions.targetPath) : null;
|
||||
const selection = resolveTargetSelection(process.cwd(), cliOptions);
|
||||
if (selection) {
|
||||
process.stdout.write(buildTargetSelectionDirective(selection) + '\n');
|
||||
process.exit(0);
|
||||
}
|
||||
const ctx = loadContext(process.cwd(), cliOptions);
|
||||
const updateDirective = await computeUpdateDirective();
|
||||
|
||||
if (!ctx.hasProduct) {
|
||||
@@ -241,9 +899,16 @@ 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)) {
|
||||
parts.push(buildMissingTargetDirective());
|
||||
}
|
||||
if (updateDirective) parts.push(updateDirective);
|
||||
process.stdout.write(parts.join('\n\n---\n\n') + '\n');
|
||||
process.exit(0);
|
||||
@@ -252,15 +917,93 @@ async function cli() {
|
||||
if (ctx.hasDesign) {
|
||||
parts.push(`# DESIGN.md\n\n${ctx.design.trim()}`);
|
||||
}
|
||||
parts.push(buildResolvedContextDirective(ctx, cliOptions, { targetExists }));
|
||||
if (shouldWarnMissingTarget(ctx, targetProvided, targetExists)) {
|
||||
parts.push(buildMissingTargetDirective());
|
||||
}
|
||||
const register = extractRegister(ctx.product);
|
||||
const next = register
|
||||
? `NEXT STEP: This project's register is \`${register}\`. You MUST now read \`reference/${register}.md\` before producing any design output.`
|
||||
: `NEXT STEP: You MUST now read the matching register reference (\`reference/brand.md\` or \`reference/product.md\`) before producing any design output. Pick based on PRODUCT.md above.`;
|
||||
parts.push(next);
|
||||
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');
|
||||
}
|
||||
|
||||
function parseCliOptions(args) {
|
||||
return parseTargetOptions(args, { strict: true });
|
||||
}
|
||||
|
||||
function hasTargetOption(options) {
|
||||
return !!(options && typeof options.targetPath === 'string' && options.targetPath.trim());
|
||||
}
|
||||
|
||||
function pathExistsForTarget(cwd, targetPath) {
|
||||
const abs = path.isAbsolute(targetPath) ? targetPath : path.resolve(cwd, targetPath);
|
||||
return fs.existsSync(abs);
|
||||
}
|
||||
|
||||
function buildResolvedContextDirective(ctx, options, { targetExists = null } = {}) {
|
||||
const targetPath = hasTargetOption(options) ? options.targetPath : null;
|
||||
return `RESOLVED_CONTEXT:\n${JSON.stringify({
|
||||
targetPath,
|
||||
...(targetPath ? { targetExists } : {}),
|
||||
projectRoot: ctx.projectRoot,
|
||||
repoRoot: ctx.repoRoot,
|
||||
productPath: ctx.productPath,
|
||||
designPath: ctx.designPath,
|
||||
}, null, 2)}`;
|
||||
}
|
||||
|
||||
function shouldWarnMissingTarget(ctx, targetProvided, targetExists = null) {
|
||||
if (ctx.isMonorepo && targetProvided && targetExists === false) return true;
|
||||
return !!(
|
||||
ctx.isMonorepo
|
||||
&& (!targetProvided || targetExists === false)
|
||||
&& ctx.projectRoot
|
||||
&& ctx.repoRoot
|
||||
&& path.resolve(ctx.projectRoot) === path.resolve(ctx.repoRoot)
|
||||
);
|
||||
}
|
||||
|
||||
function buildMissingTargetDirective() {
|
||||
const script = process.argv[1] || 'context.mjs';
|
||||
return (
|
||||
'MONOREPO_TARGET_REQUIRED: This is a monorepo and context.mjs ran without --target. ' +
|
||||
'If the user named a file, route, or child app, do not answer from this output. ' +
|
||||
`Rerun \`node ${script} --target <path>\` and answer from that run's RESOLVED_CONTEXT fields.`
|
||||
);
|
||||
}
|
||||
|
||||
function buildTargetSelectionDirective(selection) {
|
||||
return (
|
||||
`TARGET_SELECTION_REQUIRED:\n${JSON.stringify(selection, null, 2)}\n\n` +
|
||||
'Show each app with its productStatus/productPath and designStatus/designPath so the user can see child overrides, inherited root files, fallback files, or missing files before choosing. ' +
|
||||
'Ask the user which app Impeccable should use, then rerun Impeccable helper commands from that child app cwd using this same scripts directory. ' +
|
||||
'Use `--target <path>` only as a fallback when changing cwd is not possible, or when the user explicitly named a file/path.'
|
||||
);
|
||||
}
|
||||
|
||||
// Run cli() only when this module is the entry point. Compare realpaths
|
||||
// rather than endsWith(): a loose suffix match also fires for unrelated
|
||||
// scripts like `load-context.mjs`, and realpath tolerates symlinked
|
||||
|
||||
@@ -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';
|
||||
@@ -22,6 +23,10 @@ import {
|
||||
// Output formatting
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function formatFindingSummary(count) {
|
||||
return `${count} anti-pattern${count === 1 ? '' : 's'} found.`;
|
||||
}
|
||||
|
||||
function formatFindings(findings, jsonMode) {
|
||||
if (jsonMode) return JSON.stringify(findings, null, 2);
|
||||
|
||||
@@ -39,7 +44,7 @@ function formatFindings(findings, jsonMode) {
|
||||
out.push(` → ${item.description}`);
|
||||
}
|
||||
}
|
||||
out.push(`\n${findings.length} anti-pattern${findings.length === 1 ? '' : 's'} found.`);
|
||||
out.push(`\n${formatFindingSummary(findings.length)}`);
|
||||
return out.join('\n');
|
||||
}
|
||||
|
||||
@@ -86,9 +91,14 @@ Scan files or URLs for UI anti-patterns and design quality issues.
|
||||
|
||||
Options:
|
||||
--json Output results as JSON
|
||||
--quiet In text mode, only print the final findings count
|
||||
--gpt Also report GPT-specific provider tells (off by default)
|
||||
--gemini Also report Gemini-specific provider tells (off by default)
|
||||
--no-config Do not apply project config, detector ignores, or DESIGN.md
|
||||
--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
|
||||
--no-design-system Do not load local DESIGN.md / .impeccable/design.json context
|
||||
--help Show this help message
|
||||
|
||||
@@ -97,6 +107,14 @@ Project config:
|
||||
settings: detector.ignoreRules, detector.ignoreFiles, detector.ignoreValues,
|
||||
and detector.designSystem.enabled.
|
||||
|
||||
Inline ignores:
|
||||
In-file comments waive a finding where it lives and travel with the file:
|
||||
<!-- impeccable-disable overused-font -- exported brand doc -->
|
||||
.brand { font-family: Inter } /* impeccable-disable-line overused-font */
|
||||
// impeccable-disable-next-line bounce-easing: intentional bounce
|
||||
impeccable-disable applies to the whole file; -line / -next-line are scoped.
|
||||
List one or more rule ids (comma-separated), or omit them / use * for all.
|
||||
|
||||
Detection modes:
|
||||
HTML files Static HTML/CSS analysis (default, catches linked CSS)
|
||||
Non-HTML files Regex pattern matching (CSS, JSX, TSX, etc.)
|
||||
@@ -118,6 +136,7 @@ async function detectCli() {
|
||||
});
|
||||
if (args[0] === 'detect') args = args.slice(1);
|
||||
const jsonMode = args.includes('--json');
|
||||
const quietMode = args.includes('--quiet');
|
||||
const helpMode = args.includes('--help');
|
||||
// --fast (regex-only) is deprecated: since the jsdom removal, the static
|
||||
// HTML/CSS analysis is fast and covers every rule, so the regex-only path
|
||||
@@ -135,9 +154,41 @@ 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;
|
||||
const scanOptions = designSystem ? { providers, designSystem } : { providers };
|
||||
// Inline `impeccable-disable*` waivers are part of the scanned file, so they
|
||||
// apply by default. `--no-config` (raw scan) and the dedicated
|
||||
// `--no-inline-ignores` both turn them off.
|
||||
const inlineIgnoresEnabled = configEnabled && !args.includes('--no-inline-ignores');
|
||||
const scanOptions = { providers, inlineIgnores: inlineIgnoresEnabled };
|
||||
if (designSystem) scanOptions.designSystem = designSystem;
|
||||
const targets = args.filter(a => !a.startsWith('--'));
|
||||
|
||||
if (helpMode) { printUsage(); process.exit(0); }
|
||||
@@ -169,8 +220,8 @@ async function detectCli() {
|
||||
catch { process.stderr.write(`Warning: cannot access ${target}\n`); continue; }
|
||||
|
||||
if (stat.isDirectory()) {
|
||||
// Check for framework dev server config (skip in JSON mode to avoid polluting output)
|
||||
if (!jsonMode) {
|
||||
// Check for framework dev server config (skip in JSON/quiet modes to avoid polluting output)
|
||||
if (!jsonMode && !quietMode) {
|
||||
const fwConfig = detectFrameworkConfig(resolved);
|
||||
if (fwConfig) {
|
||||
const probe = await isPortListening(fwConfig.port, fwConfig.fingerprint);
|
||||
@@ -200,7 +251,7 @@ async function detectCli() {
|
||||
const htmlCount = files.filter(f => HTML_EXTENSIONS.has(path.extname(f).toLowerCase())).length;
|
||||
|
||||
// Warn and confirm if scanning many files (static HTML/CSS processes each HTML file)
|
||||
if (files.length > 50 && process.stdin.isTTY && !jsonMode) {
|
||||
if (files.length > 50 && process.stdin.isTTY && !jsonMode && !quietMode) {
|
||||
process.stderr.write(
|
||||
`\nFound ${files.length} files (${htmlCount} HTML) in ${target}.\n` +
|
||||
`Scanning may take a while${htmlCount > 10 ? ' (static HTML/CSS processes each HTML file individually)' : ''}.\n` +
|
||||
@@ -255,9 +306,11 @@ async function detectCli() {
|
||||
}
|
||||
|
||||
allFindings = filterDetectionFindings(allFindings, detectionConfig);
|
||||
allFindings = filterByScopes(allFindings, scopes);
|
||||
|
||||
if (allFindings.length > 0) {
|
||||
if (jsonMode) process.stdout.write(formatFindings(allFindings, true) + '\n');
|
||||
else if (quietMode) process.stderr.write(formatFindingSummary(allFindings.length) + '\n');
|
||||
else process.stderr.write(formatFindings(allFindings, false) + '\n');
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
@@ -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) ──
|
||||
{
|
||||
@@ -478,6 +513,17 @@ const ANTIPATTERNS = [
|
||||
skillSection: 'Visual Details',
|
||||
skillGuideline: 'repeating-gradient decorative stripes',
|
||||
},
|
||||
{
|
||||
id: 'codex-grid-background',
|
||||
category: 'slop',
|
||||
severity: 'advisory',
|
||||
gated: 'gpt',
|
||||
name: 'Decorative grid-line background',
|
||||
description:
|
||||
'A two-axis grid drawn with hairline linear-gradient layers ("1px, transparent 1px" on both axes) is a recurring generated-UI signature. Reserve grid overlays for actual canvas, map, blueprint, or measurement surfaces; elsewhere use product structure or a plain surface.',
|
||||
skillSection: 'Visual Details',
|
||||
skillGuideline: 'two-axis grid-line gradient background',
|
||||
},
|
||||
{
|
||||
id: 'theater-slop-phrase',
|
||||
category: 'slop',
|
||||
@@ -617,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';
|
||||
|
||||
@@ -1172,6 +1248,42 @@ function checkHtmlPatterns(html) {
|
||||
findings.push({ id: 'repeating-stripes-gradient', snippet: 'repeating-gradient decorative stripes' });
|
||||
}
|
||||
|
||||
// --- Provider tells (gated): two-axis grid-line background (Codex/GPT) ---
|
||||
// The Codex grid tell is two hairline `linear-gradient(... <color> 1px,
|
||||
// transparent 1px)` layers (one per axis) tiled by a repeating
|
||||
// `background-size` cell. Both signals must co-occur in the SAME style block
|
||||
// (a CSS rule body or one inline `style="..."`): two hairline stops WITHOUT a
|
||||
// tiling background-size is a fixed crosshair, not a grid, and a single
|
||||
// hairline is a legitimate ruled line. Scoping to one block also stops
|
||||
// unrelated single-axis rules on separate elements from adding up across the
|
||||
// page. Count hairlines only inside `background`/`background-image` values so
|
||||
// a hairline in an unrelated property (mask-image, border-image) can't stand
|
||||
// in for the second axis. Colors like `oklch(96% 0.012 82 / 0.055)` carry
|
||||
// nested parens, so match the hairline stop directly rather than parsing
|
||||
// whole gradient layers.
|
||||
{
|
||||
const hairlineRe = /\b\d{1,3}px\s*,\s*transparent\s+\d{1,3}px/gi;
|
||||
const gridSizeRe = /background-size\s*:[^;{}"']*\b\d{1,3}px\b/i;
|
||||
const bgDeclRe = /\bbackground(?:-image)?\s*:\s*([^;{}"']*)/gi;
|
||||
const blockRe = /\{([^{}]*)\}|style\s*=\s*"([^"]*)"|style\s*=\s*'([^']*)'/gi;
|
||||
let blk;
|
||||
while ((blk = blockRe.exec(html)) !== null) {
|
||||
const block = blk[1] || blk[2] || blk[3] || '';
|
||||
if (!gridSizeRe.test(block)) continue;
|
||||
let hairlineCount = 0;
|
||||
let bm;
|
||||
bgDeclRe.lastIndex = 0;
|
||||
while ((bm = bgDeclRe.exec(block)) !== null) {
|
||||
const stops = bm[1].match(hairlineRe);
|
||||
if (stops) hairlineCount += stops.length;
|
||||
}
|
||||
if (hairlineCount >= 2) {
|
||||
findings.push({ id: 'codex-grid-background', snippet: 'two-axis grid-line gradient background' });
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Provider tells (gated): "X theater" framing copy (GPT) ---
|
||||
// Lives here (regex-on-HTML) rather than in the text-content analyzers so it
|
||||
// runs in the bundled browser path too, not just the CLI/static path.
|
||||
@@ -2635,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,6 +1,9 @@
|
||||
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';
|
||||
import { finding } from '../../findings.mjs';
|
||||
import { filterByProviders } from '../../registry/antipatterns.mjs';
|
||||
import { profileFindings, profileStep } from '../../profile/profiler.mjs';
|
||||
@@ -36,11 +39,16 @@ 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+(#[0-9a-f]{3,8}|rgba?\([^)]+\)|\w+)/i);
|
||||
const m = str.match(/solid\s+((?:rgba?|hsla?|oklch|oklab|lab|lch|hwb|color)\([^)]*\)|#[0-9a-f]{3,8}\b|[a-z]+)/i);
|
||||
if (!m) return false;
|
||||
const c = m[1].toLowerCase();
|
||||
if (['gray', 'grey', 'silver', 'white', 'black', 'transparent', 'currentcolor'].includes(c)) return true;
|
||||
if (/^(?:rgba?|hsla?|oklch|oklab|lab|lch|hwb)\(/i.test(c)) return isNeutralColor(c);
|
||||
const hex = c.match(/^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/);
|
||||
if (hex) {
|
||||
const [r, g, b] = [parseInt(hex[1], 16), parseInt(hex[2], 16), parseInt(hex[3], 16)];
|
||||
@@ -57,10 +65,10 @@ function isNeutralBorderColor(str) {
|
||||
const REGEX_MATCHERS = [
|
||||
// --- Side-tab ---
|
||||
{ id: 'side-tab', regex: /\bborder-[lrse]-(\d+)\b/g,
|
||||
test: (m, line) => { const n = +m[1]; return hasRounded(line) ? n >= 1 : n >= 4; },
|
||||
test: (m, line) => { const n = +m[1]; return hasRounded(line) ? n >= 2 : n >= 4; },
|
||||
fmt: (m) => m[0] },
|
||||
{ id: 'side-tab', regex: /border-(?:left|right)\s*:\s*(\d+)px\s+solid[^;]*/gi,
|
||||
test: (m, line) => { if (isSafeElement(line)) return false; if (isNeutralBorderColor(m[0])) return false; const n = +m[1]; return hasBorderRadius(line) ? n >= 1 : n >= 3; },
|
||||
test: (m, line) => { if (isSafeElement(line)) return false; if (isNeutralBorderColor(m[0])) return false; const n = +m[1]; return hasBorderRadius(line) ? n >= 2 : n >= 3; },
|
||||
fmt: (m) => m[0].replace(/\s*;?\s*$/, '') },
|
||||
{ id: 'side-tab', regex: /border-(?:left|right)-width\s*:\s*(\d+)px/gi,
|
||||
test: (m, line) => !isSafeElement(line) && +m[1] >= 3,
|
||||
@@ -85,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),
|
||||
@@ -167,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');
|
||||
@@ -547,7 +555,10 @@ function detectText(content, filePath, options = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
return filterByProviders(deduped, options?.providers);
|
||||
const byProvider = filterByProviders(deduped, options?.providers);
|
||||
// Inline `impeccable-disable*` waivers travel with the file; honor them unless
|
||||
// explicitly bypassed (`--no-config` / `--no-inline-ignores`).
|
||||
return options?.inlineIgnores === false ? byProvider : applyInlineIgnores(byProvider, content);
|
||||
}
|
||||
|
||||
export {
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
mergeDesignSystemFindings,
|
||||
} from '../../design-system.mjs';
|
||||
import { isFullPage } from '../../shared/page.mjs';
|
||||
import { applyInlineIgnores } from '../../shared/inline-ignores.mjs';
|
||||
import { finding } from '../../findings.mjs';
|
||||
import { profileFindings, profileStep, profileStepAsync } from '../../profile/profiler.mjs';
|
||||
import {
|
||||
@@ -223,7 +224,11 @@ async function detectHtml(filePath, options = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
return filterByProviders(findings, options.providers);
|
||||
const byProvider = filterByProviders(findings, options.providers);
|
||||
// Static-HTML findings carry no line number, so only whole-file
|
||||
// `impeccable-disable` directives apply here — exactly the standalone-document
|
||||
// waiver this primitive targets. Bypassed by `--no-config` / `--no-inline-ignores`.
|
||||
return options?.inlineIgnores === false ? byProvider : applyInlineIgnores(byProvider, html);
|
||||
}
|
||||
|
||||
export { checkStaticPageTypography, STATIC_ELEMENT_RULES, detectHtml };
|
||||
|
||||
@@ -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) ──
|
||||
{
|
||||
@@ -376,6 +411,17 @@ const ANTIPATTERNS = [
|
||||
skillSection: 'Visual Details',
|
||||
skillGuideline: 'repeating-gradient decorative stripes',
|
||||
},
|
||||
{
|
||||
id: 'codex-grid-background',
|
||||
category: 'slop',
|
||||
severity: 'advisory',
|
||||
gated: 'gpt',
|
||||
name: 'Decorative grid-line background',
|
||||
description:
|
||||
'A two-axis grid drawn with hairline linear-gradient layers ("1px, transparent 1px" on both axes) is a recurring generated-UI signature. Reserve grid overlays for actual canvas, map, blueprint, or measurement surfaces; elsewhere use product structure or a plain surface.',
|
||||
skillSection: 'Visual Details',
|
||||
skillGuideline: 'two-axis grid-line gradient background',
|
||||
},
|
||||
{
|
||||
id: 'theater-slop-phrase',
|
||||
category: 'slop',
|
||||
@@ -437,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';
|
||||
|
||||
@@ -573,6 +574,42 @@ function checkHtmlPatterns(html) {
|
||||
findings.push({ id: 'repeating-stripes-gradient', snippet: 'repeating-gradient decorative stripes' });
|
||||
}
|
||||
|
||||
// --- Provider tells (gated): two-axis grid-line background (Codex/GPT) ---
|
||||
// The Codex grid tell is two hairline `linear-gradient(... <color> 1px,
|
||||
// transparent 1px)` layers (one per axis) tiled by a repeating
|
||||
// `background-size` cell. Both signals must co-occur in the SAME style block
|
||||
// (a CSS rule body or one inline `style="..."`): two hairline stops WITHOUT a
|
||||
// tiling background-size is a fixed crosshair, not a grid, and a single
|
||||
// hairline is a legitimate ruled line. Scoping to one block also stops
|
||||
// unrelated single-axis rules on separate elements from adding up across the
|
||||
// page. Count hairlines only inside `background`/`background-image` values so
|
||||
// a hairline in an unrelated property (mask-image, border-image) can't stand
|
||||
// in for the second axis. Colors like `oklch(96% 0.012 82 / 0.055)` carry
|
||||
// nested parens, so match the hairline stop directly rather than parsing
|
||||
// whole gradient layers.
|
||||
{
|
||||
const hairlineRe = /\b\d{1,3}px\s*,\s*transparent\s+\d{1,3}px/gi;
|
||||
const gridSizeRe = /background-size\s*:[^;{}"']*\b\d{1,3}px\b/i;
|
||||
const bgDeclRe = /\bbackground(?:-image)?\s*:\s*([^;{}"']*)/gi;
|
||||
const blockRe = /\{([^{}]*)\}|style\s*=\s*"([^"]*)"|style\s*=\s*'([^']*)'/gi;
|
||||
let blk;
|
||||
while ((blk = blockRe.exec(html)) !== null) {
|
||||
const block = blk[1] || blk[2] || blk[3] || '';
|
||||
if (!gridSizeRe.test(block)) continue;
|
||||
let hairlineCount = 0;
|
||||
let bm;
|
||||
bgDeclRe.lastIndex = 0;
|
||||
while ((bm = bgDeclRe.exec(block)) !== null) {
|
||||
const stops = bm[1].match(hairlineRe);
|
||||
if (stops) hairlineCount += stops.length;
|
||||
}
|
||||
if (hairlineCount >= 2) {
|
||||
findings.push({ id: 'codex-grid-background', snippet: 'two-axis grid-line gradient background' });
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Provider tells (gated): "X theater" framing copy (GPT) ---
|
||||
// Lives here (regex-on-HTML) rather than in the text-content analyzers so it
|
||||
// runs in the bundled browser path too, not just the CLI/static path.
|
||||
@@ -2036,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 };
|
||||
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* Inline, in-file ignore directives — eslint-disable-style waivers that live at
|
||||
* the point they apply and travel with the artifact instead of (or alongside)
|
||||
* an ignore in `.impeccable/config.json`.
|
||||
*
|
||||
* A config ignore is the right default for repo-wide policy. This complements it
|
||||
* for the one case config can't cover: a waiver that belongs to a single file and
|
||||
* needs to follow that file when it leaves the repo — a generated/exported
|
||||
* standalone document, an emailed HTML file, a snippet scanned out of context.
|
||||
*
|
||||
* Comment-syntax-agnostic: the directive is a raw token matched anywhere on a
|
||||
* line, so the same marker works across every comment style impeccable scans —
|
||||
* `//`, `/* *\/`, `<!-- -->`, `#`, `{/* *\/}`, `{# #}`. Trailing comment closers
|
||||
* are stripped before the rule list is parsed.
|
||||
*
|
||||
* Syntax (reason optional; eslint `--` or biome `:` separator):
|
||||
*
|
||||
* impeccable-disable <rule>[, <rule>...] [-- reason] whole file
|
||||
* impeccable-disable-line <rule>... [-- reason] the same line
|
||||
* impeccable-disable-next-line <rule>... [-- reason] the following line
|
||||
* impeccable-disable bare / `*` = every rule
|
||||
*
|
||||
* Examples:
|
||||
*
|
||||
* <!-- impeccable-disable overused-font -- exported brand doc, font is first-party -->
|
||||
* .brand { font-family: Inter; } /* impeccable-disable-line overused-font *\/
|
||||
* // impeccable-disable-next-line bounce-easing: intentional playful affordance
|
||||
*
|
||||
* Behavior is suppression, for parity with config ignores: a matched directive
|
||||
* drops the finding. The inline reason is self-documenting in the diff; it is not
|
||||
* required and is discarded at scan time (only used here to keep reason words out
|
||||
* of the parsed rule list).
|
||||
*/
|
||||
|
||||
const DIRECTIVE_RE = /impeccable-(disable-next-line|disable-line|disable)\b[ \t]*([^\n\r]*)/gi;
|
||||
|
||||
// Trailing comment closers, so `*/`, `*/}`, `-->`, `*}`, `#}`, `%>`, `}}` don't
|
||||
// leak into the rule list. Anchored to end-of-line; the leading `\s*` mops up the
|
||||
// space before the closer. `--+>` covers `-->` and any longer dash run.
|
||||
const TRAILING_CLOSER_RE = /\s*(?:\*\/\}?|--+>|\*\}|#\}|%>|\}\})\s*$/;
|
||||
|
||||
function normalizeRule(token) {
|
||||
return String(token || '').trim().toLowerCase();
|
||||
}
|
||||
|
||||
// Split the directive remainder into rule tokens, dropping any human reason that
|
||||
// follows an eslint-style `--` or biome-style `:` separator. Rule ids only ever
|
||||
// contain single hyphens (`overused-font`, `bounce-easing`), so `--` and `:`
|
||||
// are unambiguous separators.
|
||||
function parseRuleList(remainder) {
|
||||
let text = String(remainder || '').replace(TRAILING_CLOSER_RE, '').trim();
|
||||
// Cut off a human reason at the first `--` (eslint) or `:` (biome) separator.
|
||||
const reasonSep = text.match(/\s*(?:--+|:)\s*/);
|
||||
if (reasonSep) text = text.slice(0, reasonSep.index);
|
||||
const tokens = text.split(/[\s,]+/).map(normalizeRule).filter(Boolean);
|
||||
if (tokens.length === 0 || tokens.includes('*')) return ['*'];
|
||||
return tokens;
|
||||
}
|
||||
|
||||
function addRules(set, rules) {
|
||||
for (const rule of rules) set.add(rule);
|
||||
}
|
||||
|
||||
function getSet(map, key) {
|
||||
let set = map.get(key);
|
||||
if (!set) {
|
||||
set = new Set();
|
||||
map.set(key, set);
|
||||
}
|
||||
return set;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse every inline ignore directive in a file's raw text.
|
||||
*
|
||||
* Returns sets keyed by the 1-based line the directive *targets* so matching is a
|
||||
* direct lookup:
|
||||
* - file: rules disabled for the whole file
|
||||
* - line: line -> rules disabled on that exact line (disable-line)
|
||||
* - nextLine: line -> rules disabled on that line (disable-next-line on line-1)
|
||||
*
|
||||
* `*` in any set means "every rule".
|
||||
*/
|
||||
function parseInlineIgnores(content) {
|
||||
const result = { file: new Set(), line: new Map(), nextLine: new Map() };
|
||||
const text = typeof content === 'string' ? content : '';
|
||||
// Cheap bail-out: the substring must be present for any directive to exist.
|
||||
// Case-insensitive to match DIRECTIVE_RE's `i` flag (e.g. `Impeccable-Disable`).
|
||||
if (!/impeccable-disable/i.test(text)) return result;
|
||||
|
||||
// Split on `\n` only, exactly as detectText numbers lines, so directive line
|
||||
// keys line up with finding `line` values (incl. on `\r`-only line endings).
|
||||
// The directive regex excludes `\r`, so a trailing `\r` on `\r\n` files is
|
||||
// never captured into the rule list.
|
||||
const lines = text.split('\n');
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
DIRECTIVE_RE.lastIndex = 0;
|
||||
let m;
|
||||
while ((m = DIRECTIVE_RE.exec(lines[i])) !== null) {
|
||||
const variant = m[1].toLowerCase();
|
||||
const rules = parseRuleList(m[2]);
|
||||
if (variant === 'disable') {
|
||||
addRules(result.file, rules);
|
||||
} else if (variant === 'disable-line') {
|
||||
addRules(getSet(result.line, i + 1), rules);
|
||||
} else {
|
||||
// disable-next-line on line i+1 targets line i+2.
|
||||
addRules(getSet(result.nextLine, i + 2), rules);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function setMatches(set, rule) {
|
||||
return Boolean(set) && (set.has('*') || set.has(rule));
|
||||
}
|
||||
|
||||
function isInlineIgnored(finding, directives) {
|
||||
const rule = normalizeRule(finding && finding.antipattern);
|
||||
if (!rule) return false;
|
||||
if (setMatches(directives.file, rule)) return true;
|
||||
const line = Number(finding && finding.line) || 0;
|
||||
if (line > 0) {
|
||||
if (setMatches(directives.line.get(line), rule)) return true;
|
||||
if (setMatches(directives.nextLine.get(line), rule)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function hasDirectives(directives) {
|
||||
return directives.file.size > 0 || directives.line.size > 0 || directives.nextLine.size > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop findings waived by an inline directive in the same file's source text.
|
||||
* Findings without a usable line number (e.g. static-HTML page-level findings)
|
||||
* are only matched by whole-file directives — which is the standalone-document
|
||||
* case this primitive exists for.
|
||||
*/
|
||||
function applyInlineIgnores(findings, content) {
|
||||
if (!Array.isArray(findings) || findings.length === 0) return findings;
|
||||
const directives = parseInlineIgnores(content);
|
||||
if (!hasDirectives(directives)) return findings;
|
||||
return findings.filter((finding) => !isInlineIgnored(finding, directives));
|
||||
}
|
||||
|
||||
export { parseInlineIgnores, applyInlineIgnores, isInlineIgnored };
|
||||
@@ -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,
|
||||
@@ -75,7 +76,6 @@ const HOOK_MANIFEST_TARGETS = [
|
||||
skillRel: '.agents/skills/impeccable',
|
||||
destRel: '.codex/hooks.json',
|
||||
manifest: () => ({
|
||||
description: 'Impeccable design detector: runs after Edit/Write/apply_patch on UI files and surfaces findings as system reminders.',
|
||||
hooks: {
|
||||
PostToolUse: [
|
||||
{
|
||||
@@ -83,7 +83,7 @@ const HOOK_MANIFEST_TARGETS = [
|
||||
hooks: [
|
||||
{
|
||||
type: 'command',
|
||||
command: 'node "$(git rev-parse --show-toplevel)/.agents/skills/impeccable/scripts/hook.mjs"',
|
||||
command: 'node ".agents/skills/impeccable/scripts/hook.mjs"',
|
||||
timeout: TIMEOUT_SECONDS,
|
||||
statusMessage: STATUS_MESSAGE,
|
||||
},
|
||||
@@ -109,6 +109,28 @@ const HOOK_MANIFEST_TARGETS = [
|
||||
},
|
||||
}),
|
||||
},
|
||||
{
|
||||
// GitHub Copilot reads repo-level hooks from `.github/hooks/*.json`. The same
|
||||
// manifest is honored by the CLI (once committed to the default branch) and
|
||||
// the cloud/app agent. Schema differs: lowercase `postToolUse`, flat entries,
|
||||
// `bash`/`timeoutSec`, and a `matcher` regex against the `edit`/`create` tools.
|
||||
provider: '.github',
|
||||
skillRel: '.github/skills/impeccable',
|
||||
destRel: '.github/hooks/impeccable.json',
|
||||
manifest: () => ({
|
||||
version: 1,
|
||||
hooks: {
|
||||
postToolUse: [
|
||||
{
|
||||
type: 'command',
|
||||
matcher: 'edit|create|apply_patch',
|
||||
bash: 'node "$(git rev-parse --show-toplevel)/.github/skills/impeccable/scripts/hook.mjs"',
|
||||
timeoutSec: TIMEOUT_SECONDS,
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
},
|
||||
];
|
||||
|
||||
function readRawConfigFile(filePath) {
|
||||
@@ -163,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');
|
||||
@@ -400,7 +422,10 @@ function valueHasImpeccableHookMarker(value) {
|
||||
|
||||
function stripImpeccableHookEntry(entry) {
|
||||
if (!entry || typeof entry !== 'object') return entry;
|
||||
if (valueHasImpeccableHookMarker(entry.command) || valueHasImpeccableHookMarker(entry.args)) {
|
||||
// `command`/`args`: Claude/Codex/Cursor. `bash`/`powershell`: GitHub Copilot's
|
||||
// flat entry shape, where the marker lives under the shell-command keys.
|
||||
if (valueHasImpeccableHookMarker(entry.command) || valueHasImpeccableHookMarker(entry.args)
|
||||
|| valueHasImpeccableHookMarker(entry.bash) || valueHasImpeccableHookMarker(entry.powershell)) {
|
||||
return null;
|
||||
}
|
||||
if (!Array.isArray(entry.hooks)) return entry;
|
||||
@@ -489,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);
|
||||
@@ -500,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);
|
||||
@@ -545,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) {
|
||||
@@ -959,13 +1067,114 @@ export function resolveTargetFiles(event, projectCwd) {
|
||||
export function resolveHarness(env = {}, event = null) {
|
||||
const explicit = env?.IMPECCABLE_HOOK_HARNESS;
|
||||
if (explicit === 'cursor') return 'cursor';
|
||||
if (explicit === 'github') return 'github';
|
||||
if (explicit === 'claude' || explicit === 'codex') return 'claude';
|
||||
// GitHub Copilot's postToolUse event uses camelCase `toolName`/`toolArgs` and
|
||||
// has no `tool_name`/`tool_input`. That shape is the discriminator.
|
||||
if (event && typeof event === 'object'
|
||||
&& (typeof event.toolName === 'string' || event.toolArgs !== undefined)
|
||||
&& event.tool_name === undefined && event.tool_input === undefined) {
|
||||
return 'github';
|
||||
}
|
||||
if (typeof event?.conversation_id === 'string' && event.conversation_id) return 'cursor';
|
||||
return 'claude';
|
||||
}
|
||||
|
||||
// GitHub Copilot's postToolUse payload is
|
||||
// { sessionId, timestamp, cwd, toolName, toolArgs, toolResult }
|
||||
// mapped onto the internal `{ tool_name, tool_input, cwd, session_id }` shape.
|
||||
// `toolArgs` shape depends on the tool: the `edit`/`create`/`view` tools send a
|
||||
// JSON *string* (double-encoded) carrying the file under `path`, e.g.
|
||||
// "{\"path\":\"/abs/app.tsx\",\"old_str\":\"...\",\"new_str\":\"...\"}",
|
||||
// while `apply_patch` sends a raw OpenAI-format patch string (handled below in
|
||||
// normalizeGitHubEvent). The detector reads the file from disk after the tool
|
||||
// ran, so only the path (not the proposed content) is needed here.
|
||||
export function parseGitHubToolArgs(toolArgs) {
|
||||
if (toolArgs && typeof toolArgs === 'object' && !Array.isArray(toolArgs)) return toolArgs;
|
||||
if (typeof toolArgs === 'string' && toolArgs.trim()) {
|
||||
try {
|
||||
const parsed = JSON.parse(toolArgs);
|
||||
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
// Copilot's `apply_patch` tool (used by interactive sessions and the cloud
|
||||
// agent) sends a raw OpenAI-format patch string in toolArgs, not JSON:
|
||||
// *** Begin Patch
|
||||
// *** Add File: /abs/app.css
|
||||
// +body { ... }
|
||||
// *** End Patch
|
||||
// The `view`/`edit`/`create` tools (seen in `copilot -p` runs) instead send a
|
||||
// JSON string with the path under `path`. Both must map onto the internal shape.
|
||||
const APPLY_PATCH_MARKER = /\*\*\* (?:Begin Patch|Add File:|Update File:|Delete File:)/;
|
||||
|
||||
function looksLikeApplyPatch(rawArgs) {
|
||||
if (typeof rawArgs !== 'string' || !APPLY_PATCH_MARKER.test(rawArgs)) return false;
|
||||
// Guard against an edit/create payload whose edited *content* happens to
|
||||
// contain patch markers: that payload is a JSON object string, whereas a real
|
||||
// apply_patch payload is a raw patch string that does not parse as JSON. Only
|
||||
// treat non-JSON-object strings as apply_patch so edit events still get their
|
||||
// `path` extracted.
|
||||
try {
|
||||
const parsed = JSON.parse(rawArgs);
|
||||
if (parsed && typeof parsed === 'object') return false;
|
||||
} catch { /* not JSON → genuine raw patch */ }
|
||||
return true;
|
||||
}
|
||||
|
||||
function applyPatchText(rawArgs) {
|
||||
if (typeof rawArgs === 'string') {
|
||||
if (APPLY_PATCH_MARKER.test(rawArgs)) return rawArgs;
|
||||
// Defensive: a future Copilot build might JSON-wrap the patch.
|
||||
const parsed = parseGitHubToolArgs(rawArgs);
|
||||
return parsed.patch || parsed.input || parsed.command || '';
|
||||
}
|
||||
if (rawArgs && typeof rawArgs === 'object' && !Array.isArray(rawArgs)) {
|
||||
return rawArgs.patch || rawArgs.input || rawArgs.command || '';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function normalizeGitHubEvent(event, projectCwd) {
|
||||
const cwd = event.cwd || envProjectDir(projectCwd) || projectCwd;
|
||||
const sessionId = event.sessionId || event.session_id || 'unknown';
|
||||
const toolName = event.toolName || event.tool_name || null;
|
||||
const toolInput = event.tool_input && typeof event.tool_input === 'object' ? { ...event.tool_input } : {};
|
||||
const rawArgs = event.toolArgs;
|
||||
|
||||
let normalizedToolName = toolName;
|
||||
if (toolName === 'apply_patch' || looksLikeApplyPatch(rawArgs)) {
|
||||
// resolveTargetFiles() reads the touched paths from tool_input.command when
|
||||
// tool_name is 'apply_patch', so normalize the name even if a future build
|
||||
// sends the patch under a different tool label.
|
||||
const patch = applyPatchText(rawArgs);
|
||||
if (patch) {
|
||||
toolInput.command = patch;
|
||||
normalizedToolName = 'apply_patch';
|
||||
}
|
||||
} else {
|
||||
const args = parseGitHubToolArgs(rawArgs);
|
||||
const filePath = args.path || args.file_path || args.filePath || args.target_file;
|
||||
if (typeof filePath === 'string' && filePath) toolInput.file_path = filePath;
|
||||
}
|
||||
|
||||
return {
|
||||
...event,
|
||||
cwd,
|
||||
session_id: sessionId,
|
||||
tool_name: normalizedToolName,
|
||||
tool_input: toolInput,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeHookEvent(event, projectCwd, harness = 'claude') {
|
||||
if (!event || typeof event !== 'object' || harness !== 'cursor') return event;
|
||||
if (!event || typeof event !== 'object') return event;
|
||||
if (harness === 'github') return normalizeGitHubEvent(event, projectCwd);
|
||||
if (harness !== 'cursor') return event;
|
||||
|
||||
const cwd = event.cwd
|
||||
|| (Array.isArray(event.workspace_roots) && event.workspace_roots[0])
|
||||
@@ -1200,12 +1409,12 @@ export function setDetectorForTesting(impl) {
|
||||
// session" so the model knows it's a re-mind, not a new finding.
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
|
||||
const STEER_LINE = 'Keep typography hierarchy, spacing rhythm, and color contrast intentional on the next change.';
|
||||
const STEER_LINE = 'That does not mean the design is good: keep following the project design system and the impeccable skill guidance.';
|
||||
|
||||
export function renderCleanAck(filePath, opts = {}) {
|
||||
const cwd = opts.cwd || process.cwd();
|
||||
const display = relativize(filePath, cwd);
|
||||
return `${ENVELOPE_PREFIX} Design hook scanned ${display}. No anti-patterns. ${STEER_LINE}`;
|
||||
return `${ENVELOPE_PREFIX} Design hook scanned ${display}. No deterministic design-quality issues found. ${STEER_LINE}`;
|
||||
}
|
||||
|
||||
export function renderPendingAck(filePath, knownFindings, opts = {}) {
|
||||
@@ -1218,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) {
|
||||
@@ -1235,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
|
||||
@@ -1252,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. Do not add source comments such as \`impeccable: ignore\`; those pollute the code and do not suppress hook findings. Persist hook ignores only after the user explicitly confirms the finding is intentional. 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');
|
||||
}
|
||||
|
||||
@@ -1301,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;
|
||||
@@ -1318,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);
|
||||
@@ -1334,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;
|
||||
@@ -1348,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;
|
||||
}
|
||||
@@ -1366,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) {
|
||||
@@ -1382,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; }
|
||||
@@ -1395,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;
|
||||
}
|
||||
@@ -1412,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];
|
||||
@@ -1447,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,
|
||||
@@ -1481,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,
|
||||
@@ -1520,6 +1754,11 @@ export function payload(text, eventName = 'PostToolUse', harness = 'claude') {
|
||||
if (harness === 'cursor') {
|
||||
return JSON.stringify({ additional_context: text });
|
||||
}
|
||||
// GitHub Copilot's postToolUse hook injects context via a top-level
|
||||
// `additionalContext` string (alongside an optional `modifiedResult`).
|
||||
if (harness === 'github') {
|
||||
return JSON.stringify({ additionalContext: text });
|
||||
}
|
||||
return JSON.stringify({
|
||||
hookSpecificOutput: { hookEventName: eventName, additionalContext: text },
|
||||
});
|
||||
|
||||
@@ -0,0 +1,640 @@
|
||||
/**
|
||||
* CLI-side reader/writer for the unified `.impeccable` config.
|
||||
*
|
||||
* The CLI (published to npm) and the skill scripts (bundled into the install)
|
||||
* live in separate trees and cannot share runtime code, so this duplicates a
|
||||
* small slice of skill/scripts/hook-lib.mjs — the config-path layout, detector
|
||||
* ignore semantics, and the `.git/info/exclude` handling. Keep the schema,
|
||||
* ignore filtering, and exclude marker in sync if either side changes.
|
||||
*
|
||||
* Schema (config.json shared / config.local.json gitignored, per-developer):
|
||||
* {
|
||||
* "detector": { "ignoreRules": [], "ignoreFiles": [], "ignoreValues": [], "designSystem": { "enabled": true } },
|
||||
* "hook": { "consent": "accepted" | "declined", ... },
|
||||
* "updateCheck": bool
|
||||
* }
|
||||
*/
|
||||
|
||||
import { existsSync, readFileSync, writeFileSync, mkdirSync, statSync } from 'node:fs';
|
||||
import { join, dirname, isAbsolute, relative, resolve, sep } from 'node:path';
|
||||
|
||||
export function getConfigPath(root) {
|
||||
return join(root, '.impeccable', 'config.json');
|
||||
}
|
||||
|
||||
export function getLocalConfigPath(root) {
|
||||
return join(root, '.impeccable', 'config.local.json');
|
||||
}
|
||||
|
||||
function safeReadJson(filePath) {
|
||||
try {
|
||||
const raw = JSON.parse(readFileSync(filePath, 'utf-8'));
|
||||
return raw && typeof raw === 'object' && !Array.isArray(raw) ? raw : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function hookSection(raw) {
|
||||
return raw && raw.hook && typeof raw.hook === 'object' && !Array.isArray(raw.hook) ? raw.hook : null;
|
||||
}
|
||||
|
||||
function detectorSection(raw) {
|
||||
return raw && raw.detector && typeof raw.detector === 'object' && !Array.isArray(raw.detector) ? raw.detector : null;
|
||||
}
|
||||
|
||||
const DETECTOR_CONFIG_KEYS = new Set(['ignoreRules', 'ignoreFiles', 'ignoreValues', 'designSystem']);
|
||||
|
||||
const DEFAULT_DETECTION_CONFIG = Object.freeze({
|
||||
ignoreRules: [],
|
||||
ignoreFiles: [],
|
||||
ignoreValues: [],
|
||||
designSystem: { enabled: true },
|
||||
});
|
||||
|
||||
function cloneDetectionConfig() {
|
||||
return {
|
||||
ignoreRules: [],
|
||||
ignoreFiles: [],
|
||||
ignoreValues: [],
|
||||
designSystem: { ...DEFAULT_DETECTION_CONFIG.designSystem },
|
||||
};
|
||||
}
|
||||
|
||||
function cloneRawDetectionConfig() {
|
||||
return {
|
||||
ignoreRules: [],
|
||||
ignoreFiles: [],
|
||||
ignoreValues: [],
|
||||
};
|
||||
}
|
||||
|
||||
function applyDetectionConfigSource(config, raw) {
|
||||
if (!raw || typeof raw !== 'object') return config;
|
||||
if (raw.designSystem && typeof raw.designSystem === 'object' && !Array.isArray(raw.designSystem)) {
|
||||
config.designSystem = {
|
||||
...config.designSystem,
|
||||
enabled: raw.designSystem.enabled === false ? false : true,
|
||||
};
|
||||
}
|
||||
if (Array.isArray(raw.ignoreRules)) {
|
||||
config.ignoreRules = uniqueStrings([...config.ignoreRules, ...raw.ignoreRules]);
|
||||
}
|
||||
if (Array.isArray(raw.ignoreFiles)) {
|
||||
config.ignoreFiles = uniqueStrings([...config.ignoreFiles, ...raw.ignoreFiles]);
|
||||
}
|
||||
if (Array.isArray(raw.ignoreValues)) {
|
||||
config.ignoreValues = mergeIgnoreValues(config.ignoreValues, raw.ignoreValues);
|
||||
}
|
||||
return config;
|
||||
}
|
||||
|
||||
function uniqueStrings(values) {
|
||||
return Array.from(new Set(values.map(String)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Detector filters shared by `npx impeccable detect` and the design hook.
|
||||
* `hook.enabled` remains hook lifecycle state; manual CLI scans still run when
|
||||
* the hook is disabled, but they honor the same ignore rules and design-system
|
||||
* toggle.
|
||||
*/
|
||||
export function readDetectionConfig(root) {
|
||||
const config = cloneDetectionConfig();
|
||||
for (const filePath of [getConfigPath(root), getLocalConfigPath(root)]) {
|
||||
const raw = safeReadJson(filePath);
|
||||
// Back-compat: old builds stored detector filters under hook.*.
|
||||
applyDetectionConfigSource(config, hookSection(raw));
|
||||
applyDetectionConfigSource(config, detectorSection(raw));
|
||||
}
|
||||
return config;
|
||||
}
|
||||
|
||||
export function readRawDetectionConfig(root, opts = {}) {
|
||||
const raw = safeReadJson(opts.local ? getLocalConfigPath(root) : getConfigPath(root));
|
||||
const config = cloneRawDetectionConfig();
|
||||
applyDetectionConfigSource(config, hookSection(raw));
|
||||
applyDetectionConfigSource(config, detectorSection(raw));
|
||||
return config;
|
||||
}
|
||||
|
||||
export function writeDetectionConfig(root, detectorConfig, opts = {}) {
|
||||
const filePath = opts.local ? getLocalConfigPath(root) : getConfigPath(root);
|
||||
if (opts.local) ensureConfigGitExclude(root);
|
||||
const existing = safeReadJson(filePath) || {};
|
||||
const existingHook = hookSection(existing);
|
||||
const nextHook = stripDetectorKeys(existingHook);
|
||||
const nextDetector = {
|
||||
...(detectorSection(existing) || {}),
|
||||
...normalizeDetectionConfigForWrite(detectorConfig),
|
||||
};
|
||||
const next = {
|
||||
...existing,
|
||||
detector: nextDetector,
|
||||
};
|
||||
if (nextHook && Object.keys(nextHook).length > 0) {
|
||||
next.hook = nextHook;
|
||||
} else {
|
||||
delete next.hook;
|
||||
}
|
||||
mkdirSync(dirname(filePath), { recursive: true });
|
||||
writeFileSync(filePath, `${JSON.stringify(next, null, 2)}\n`);
|
||||
return filePath;
|
||||
}
|
||||
|
||||
function normalizeDetectionConfigForWrite(config) {
|
||||
const out = {};
|
||||
if (Array.isArray(config?.ignoreRules)) {
|
||||
out.ignoreRules = uniqueStrings(config.ignoreRules.map((rule) => normalizeIgnoreRule(rule)).filter(Boolean));
|
||||
}
|
||||
if (Array.isArray(config?.ignoreFiles)) {
|
||||
out.ignoreFiles = uniqueStrings(config.ignoreFiles.filter(v => typeof v === 'string' && v.trim()).map(v => v.trim()));
|
||||
}
|
||||
out.ignoreValues = normalizeIgnoreValueEntries(config?.ignoreValues || []);
|
||||
if (config?.designSystem && typeof config.designSystem === 'object' && !Array.isArray(config.designSystem)) {
|
||||
out.designSystem = {
|
||||
enabled: config.designSystem.enabled === false ? false : true,
|
||||
};
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function stripDetectorKeys(raw) {
|
||||
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
|
||||
const out = {};
|
||||
for (const [key, value] of Object.entries(raw)) {
|
||||
if (!DETECTOR_CONFIG_KEYS.has(key)) out[key] = value;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function normalizeIgnoreValue(value) {
|
||||
return String(value || '')
|
||||
.trim()
|
||||
.replace(/^["']|["']$/g, '')
|
||||
.replace(/\+/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
function normalizeIgnoreRule(rule) {
|
||||
return String(rule || '').trim().toLowerCase();
|
||||
}
|
||||
|
||||
function colorIgnoreKey(value) {
|
||||
const color = parseIgnoreColor(value);
|
||||
if (!color) return '';
|
||||
return `${color.r},${color.g},${color.b},${Math.round(color.a * 255)}`;
|
||||
}
|
||||
|
||||
function parseIgnoreColor(value) {
|
||||
const text = String(value || '').trim().toLowerCase();
|
||||
if (!text) return null;
|
||||
|
||||
const hex = text.match(/^#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/i);
|
||||
if (hex) return parseHexIgnoreColor(hex[1]);
|
||||
|
||||
const rgb = text.match(/^rgba?\((.*)\)$/i);
|
||||
if (rgb) {
|
||||
const parts = splitColorArgs(rgb[1]);
|
||||
if (parts.length < 3 || parts.length > 4) return null;
|
||||
const r = parseRgbChannel(parts[0]);
|
||||
const g = parseRgbChannel(parts[1]);
|
||||
const b = parseRgbChannel(parts[2]);
|
||||
const a = parts[3] === undefined ? 1 : parseAlphaChannel(parts[3]);
|
||||
if ([r, g, b, a].some((v) => v === null)) return null;
|
||||
return { r, g, b, a };
|
||||
}
|
||||
|
||||
const hsl = text.match(/^hsla?\((.*)\)$/i);
|
||||
if (hsl) {
|
||||
const parts = splitColorArgs(hsl[1]);
|
||||
if (parts.length < 3 || parts.length > 4) return null;
|
||||
const h = parseHueChannel(parts[0]);
|
||||
const s = parsePercentChannel(parts[1]);
|
||||
const l = parsePercentChannel(parts[2]);
|
||||
const a = parts[3] === undefined ? 1 : parseAlphaChannel(parts[3]);
|
||||
if ([h, s, l, a].some((v) => v === null)) return null;
|
||||
return hslToRgb(h, s, l, a);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseHexIgnoreColor(hex) {
|
||||
if (hex.length === 3 || hex.length === 4) {
|
||||
const r = parseInt(hex[0] + hex[0], 16);
|
||||
const g = parseInt(hex[1] + hex[1], 16);
|
||||
const b = parseInt(hex[2] + hex[2], 16);
|
||||
const a = hex.length === 4 ? parseInt(hex[3] + hex[3], 16) / 255 : 1;
|
||||
return { r, g, b, a };
|
||||
}
|
||||
const r = parseInt(hex.slice(0, 2), 16);
|
||||
const g = parseInt(hex.slice(2, 4), 16);
|
||||
const b = parseInt(hex.slice(4, 6), 16);
|
||||
const a = hex.length === 8 ? parseInt(hex.slice(6, 8), 16) / 255 : 1;
|
||||
return { r, g, b, a };
|
||||
}
|
||||
|
||||
function splitColorArgs(body) {
|
||||
const text = String(body || '').trim();
|
||||
if (!text) return [];
|
||||
if (text.includes(',')) {
|
||||
const parts = text.split(',').map((part) => part.trim()).filter(Boolean);
|
||||
const last = parts[parts.length - 1];
|
||||
if (last && last.includes('/')) {
|
||||
const split = last.split('/').map((part) => part.trim()).filter(Boolean);
|
||||
return [...parts.slice(0, -1), ...split];
|
||||
}
|
||||
return parts;
|
||||
}
|
||||
return text.replace(/\s*\/\s*/g, ' / ').split(/\s+/).filter((part) => part && part !== '/');
|
||||
}
|
||||
|
||||
function parseRgbChannel(raw) {
|
||||
const text = String(raw || '').trim();
|
||||
const match = text.match(/^(-?\d*\.?\d+)(%)?$/);
|
||||
if (!match) return null;
|
||||
const value = Number.parseFloat(match[1]);
|
||||
if (!Number.isFinite(value)) return null;
|
||||
const scaled = match[2] ? value * 2.55 : value;
|
||||
if (scaled < 0 || scaled > 255) return null;
|
||||
return Math.round(scaled);
|
||||
}
|
||||
|
||||
function parseAlphaChannel(raw) {
|
||||
const text = String(raw || '').trim();
|
||||
const match = text.match(/^(-?\d*\.?\d+)(%)?$/);
|
||||
if (!match) return null;
|
||||
const value = Number.parseFloat(match[1]);
|
||||
if (!Number.isFinite(value)) return null;
|
||||
const alpha = match[2] ? value / 100 : value;
|
||||
return alpha >= 0 && alpha <= 1 ? alpha : null;
|
||||
}
|
||||
|
||||
function parseHueChannel(raw) {
|
||||
const text = String(raw || '').trim();
|
||||
const match = text.match(/^(-?\d*\.?\d+)(deg|rad|turn|grad)?$/);
|
||||
if (!match) return null;
|
||||
const value = Number.parseFloat(match[1]);
|
||||
if (!Number.isFinite(value)) return null;
|
||||
const unit = match[2] || 'deg';
|
||||
if (unit === 'turn') return value * 360;
|
||||
if (unit === 'rad') return value * (180 / Math.PI);
|
||||
if (unit === 'grad') return value * 0.9;
|
||||
return value;
|
||||
}
|
||||
|
||||
function parsePercentChannel(raw) {
|
||||
const text = String(raw || '').trim();
|
||||
const match = text.match(/^(-?\d*\.?\d+)%$/);
|
||||
if (!match) return null;
|
||||
const value = Number.parseFloat(match[1]);
|
||||
if (!Number.isFinite(value)) return null;
|
||||
return value >= 0 && value <= 100 ? value / 100 : null;
|
||||
}
|
||||
|
||||
function hslToRgb(hue, saturation, lightness, alpha) {
|
||||
const h = (((hue % 360) + 360) % 360) / 360;
|
||||
if (saturation === 0) {
|
||||
const gray = clampByte(Math.round(lightness * 255));
|
||||
return { r: gray, g: gray, b: gray, a: alpha };
|
||||
}
|
||||
const q = lightness < 0.5
|
||||
? lightness * (1 + saturation)
|
||||
: lightness + saturation - lightness * saturation;
|
||||
const p = 2 * lightness - q;
|
||||
const toRgb = (t) => {
|
||||
let channel = t;
|
||||
if (channel < 0) channel += 1;
|
||||
if (channel > 1) channel -= 1;
|
||||
if (channel < 1 / 6) return p + (q - p) * 6 * channel;
|
||||
if (channel < 1 / 2) return q;
|
||||
if (channel < 2 / 3) return p + (q - p) * (2 / 3 - channel) * 6;
|
||||
return p;
|
||||
};
|
||||
return {
|
||||
r: clampByte(Math.round(toRgb(h + 1 / 3) * 255)),
|
||||
g: clampByte(Math.round(toRgb(h) * 255)),
|
||||
b: clampByte(Math.round(toRgb(h - 1 / 3) * 255)),
|
||||
a: alpha,
|
||||
};
|
||||
}
|
||||
|
||||
function clampByte(value) {
|
||||
return Math.min(255, Math.max(0, value));
|
||||
}
|
||||
|
||||
function ignoreValueMatches(rule, entryValue, findingValue) {
|
||||
if (entryValue === findingValue) return true;
|
||||
if (rule !== 'design-system-color') return false;
|
||||
const entryColor = colorIgnoreKey(entryValue);
|
||||
return Boolean(entryColor && entryColor === colorIgnoreKey(findingValue));
|
||||
}
|
||||
|
||||
export function normalizeIgnoreValueEntries(entries) {
|
||||
if (!Array.isArray(entries)) return [];
|
||||
const out = [];
|
||||
for (const entry of entries) {
|
||||
if (!entry || typeof entry !== 'object') continue;
|
||||
const rule = normalizeIgnoreRule(entry.rule);
|
||||
const value = normalizeIgnoreValue(entry.value);
|
||||
if (!rule || !value) continue;
|
||||
const normalized = { rule, value };
|
||||
const files = uniqueStrings([
|
||||
...(typeof entry.file === 'string' && entry.file.trim() ? [entry.file.trim()] : []),
|
||||
...(Array.isArray(entry.files) ? entry.files.filter(v => typeof v === 'string' && v.trim()).map(v => v.trim()) : []),
|
||||
]);
|
||||
if (files.length > 0) normalized.files = files;
|
||||
if (typeof entry.reason === 'string' && entry.reason.trim()) {
|
||||
normalized.reason = entry.reason.trim();
|
||||
}
|
||||
if (typeof entry.createdAt === 'string' && entry.createdAt.trim()) {
|
||||
normalized.createdAt = entry.createdAt.trim();
|
||||
}
|
||||
out.push(normalized);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function mergeIgnoreValues(existing, incoming) {
|
||||
const map = new Map();
|
||||
for (const entry of normalizeIgnoreValueEntries(existing)) {
|
||||
map.set(`${entry.rule}\0${entry.value}\0${ignoreValueFilesKey(entry.files)}`, entry);
|
||||
}
|
||||
for (const entry of normalizeIgnoreValueEntries(incoming)) {
|
||||
map.set(`${entry.rule}\0${entry.value}\0${ignoreValueFilesKey(entry.files)}`, entry);
|
||||
}
|
||||
return Array.from(map.values());
|
||||
}
|
||||
|
||||
function ignoreValueFilesKey(files) {
|
||||
return Array.isArray(files) && files.length > 0 ? files.join('\x1f') : '';
|
||||
}
|
||||
|
||||
// Glob -> RegExp. Supports `**`, `*`, `?`, and `{a,b}` alternation.
|
||||
function globToRegex(glob) {
|
||||
let re = '^';
|
||||
let i = 0;
|
||||
while (i < glob.length) {
|
||||
const c = glob[i];
|
||||
if (c === '*') {
|
||||
if (glob[i + 1] === '*') {
|
||||
re += '.*';
|
||||
i += 2;
|
||||
if (glob[i] === '/') i += 1;
|
||||
} else {
|
||||
re += '[^/]*';
|
||||
i += 1;
|
||||
}
|
||||
} else if (c === '?') {
|
||||
re += '[^/]';
|
||||
i += 1;
|
||||
} else if (c === '{') {
|
||||
const end = glob.indexOf('}', i);
|
||||
if (end === -1) { re += '\\{'; i += 1; continue; }
|
||||
const parts = glob.slice(i + 1, end).split(',').map((p) => p.replace(/[.+^$()|[\]\\]/g, '\\$&'));
|
||||
re += `(?:${parts.join('|')})`;
|
||||
i = end + 1;
|
||||
} else if (/[.+^$()|[\]\\]/.test(c)) {
|
||||
re += `\\${c}`;
|
||||
i += 1;
|
||||
} else {
|
||||
re += c;
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
re += '$';
|
||||
return new RegExp(re);
|
||||
}
|
||||
|
||||
export function matchesAnyGlob(filePath, globs) {
|
||||
if (!Array.isArray(globs) || globs.length === 0) return false;
|
||||
const normalized = String(filePath || '').split(sep).join('/');
|
||||
for (const glob of globs) {
|
||||
try {
|
||||
const re = globToRegex(String(glob));
|
||||
if (re.test(normalized)) return true;
|
||||
const base = normalized.split('/').pop();
|
||||
if (re.test(base)) return true;
|
||||
} catch {
|
||||
/* malformed glob, skip */
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function shouldIgnoreDetectionFile(filePath, root, config) {
|
||||
const globs = config?.ignoreFiles || [];
|
||||
if (!Array.isArray(globs) || globs.length === 0) return false;
|
||||
const raw = String(filePath || '').trim();
|
||||
if (!raw) return false;
|
||||
if (matchesAnyGlob(raw, globs)) return true;
|
||||
|
||||
try {
|
||||
const abs = isAbsolute(raw) ? raw : resolve(root, raw);
|
||||
if (matchesAnyGlob(abs, globs)) return true;
|
||||
const rel = relative(root, abs);
|
||||
if (rel && !rel.startsWith('..') && !isAbsolute(rel)) {
|
||||
return matchesAnyGlob(rel, globs);
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function filterDetectionFindings(findings, config) {
|
||||
if (!Array.isArray(findings) || findings.length === 0) return [];
|
||||
const ignoreRules = new Set((config?.ignoreRules || []).map((rule) => normalizeIgnoreRule(rule)));
|
||||
const ignoreValues = normalizeIgnoreValueEntries(config?.ignoreValues || []);
|
||||
return findings.filter((finding) => {
|
||||
if (!finding || typeof finding !== 'object') return false;
|
||||
if (ignoreRules.has(normalizeIgnoreRule(finding.antipattern))) return false;
|
||||
if (isIgnoredFindingValue(finding, ignoreValues)) return false;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
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);
|
||||
return ignoreValues.some((entry) => {
|
||||
if (entry.rule !== rule) return false;
|
||||
const wildcardValue = entry.value === '*';
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
function findingMatchesScopedIgnoreFile(finding, globs) {
|
||||
const filePath = String(finding?.file || '').trim();
|
||||
if (!filePath) return false;
|
||||
if (matchesAnyGlob(filePath, globs)) return true;
|
||||
|
||||
const normalized = filePath.split(sep).join('/');
|
||||
const parts = normalized.split('/').filter(Boolean);
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
const suffix = parts.slice(i).join('/');
|
||||
if (matchesAnyGlob(suffix, globs)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function extractFindingIgnoreValue(finding) {
|
||||
if (!finding || typeof finding !== 'object') return '';
|
||||
const rule = normalizeIgnoreRule(finding.antipattern);
|
||||
const directValueRules = new Set([
|
||||
'overused-font',
|
||||
'bounce-easing',
|
||||
'design-system-font',
|
||||
'design-system-color',
|
||||
'design-system-radius',
|
||||
]);
|
||||
if (!directValueRules.has(rule)) return '';
|
||||
return normalizeIgnoreValue(extractFindingIgnoreValueRaw(finding, rule));
|
||||
}
|
||||
|
||||
function extractFindingIgnoreValueRaw(finding, rule = normalizeIgnoreRule(finding?.antipattern)) {
|
||||
const direct = cleanIgnoreValueDisplay(finding.ignoreValue || finding.value || '');
|
||||
if (direct) return direct;
|
||||
|
||||
const candidates = [finding.detail, finding.snippet].filter((v) => typeof v === 'string' && v);
|
||||
for (const text of candidates) {
|
||||
if (rule === 'bounce-easing') {
|
||||
const motion = extractMotionIgnoreValue(text);
|
||||
if (motion) return motion;
|
||||
continue;
|
||||
}
|
||||
|
||||
const primary = text.match(/Primary font:\s*([^()\n;]+)/i);
|
||||
if (primary) return cleanIgnoreValueDisplay(primary[1]);
|
||||
|
||||
const family = text.match(/font-family\s*:\s*["']?([^'",;\n]+)/i);
|
||||
if (family) return cleanIgnoreValueDisplay(family[1]);
|
||||
|
||||
const google = text.match(/[?&]family=([^&:;\n]+)/i);
|
||||
if (google) {
|
||||
try {
|
||||
return cleanIgnoreValueDisplay(decodeURIComponent(google[1]));
|
||||
} catch {
|
||||
return cleanIgnoreValueDisplay(google[1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
function extractMotionIgnoreValue(text) {
|
||||
const tailwind = text.match(/\banimate-bounce\b/i);
|
||||
if (tailwind) return cleanIgnoreValueDisplay(tailwind[0]);
|
||||
|
||||
const bezier = text.match(/cubic-bezier\([^)]+\)/i);
|
||||
if (bezier) return cleanIgnoreValueDisplay(bezier[0]);
|
||||
|
||||
const animation = text.match(/animation(?:-name)?\s*:\s*([^;\n]+)/i);
|
||||
if (animation) {
|
||||
const token = animation[1]
|
||||
.split(/[,\s]+/)
|
||||
.find((part) => /bounce|elastic|wobble|jiggle|spring/i.test(part));
|
||||
if (token) return cleanIgnoreValueDisplay(token);
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
function cleanIgnoreValueDisplay(value) {
|
||||
return String(value || '')
|
||||
.trim()
|
||||
.replace(/^["']|["']$/g, '')
|
||||
.replace(/\+/g, ' ')
|
||||
.replace(/\s+/g, ' ');
|
||||
}
|
||||
|
||||
/**
|
||||
* The recorded design-hook decision: 'accepted' | 'declined' | undefined.
|
||||
* config.local.json (per-developer) overrides config.json.
|
||||
*/
|
||||
export function getHookConsent(root) {
|
||||
let consent;
|
||||
for (const filePath of [getConfigPath(root), getLocalConfigPath(root)]) {
|
||||
const hook = hookSection(safeReadJson(filePath));
|
||||
if (hook && (hook.consent === 'accepted' || hook.consent === 'declined')) consent = hook.consent;
|
||||
}
|
||||
return consent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist the per-developer decision to config.local.json, preserving any
|
||||
* sibling keys, and ensure the file is gitignored.
|
||||
*/
|
||||
export function setHookConsent(root, value) {
|
||||
const filePath = getLocalConfigPath(root);
|
||||
const existing = safeReadJson(filePath) || {};
|
||||
const hook = hookSection(existing) || {};
|
||||
const next = { ...existing, hook: { ...hook, consent: value } };
|
||||
mkdirSync(dirname(filePath), { recursive: true });
|
||||
writeFileSync(filePath, `${JSON.stringify(next, null, 2)}\n`);
|
||||
ensureConfigGitExclude(root);
|
||||
return filePath;
|
||||
}
|
||||
|
||||
const EXCLUDE_OPEN = '# impeccable-config-ignore-start';
|
||||
const EXCLUDE_CLOSE = '# impeccable-config-ignore-end';
|
||||
const EXCLUDE_PATTERNS = ['.impeccable/config.local.json'];
|
||||
|
||||
/**
|
||||
* Add config.local.json to `.git/info/exclude` so a developer's decision is
|
||||
* never committed. Idempotent via marker comments. Best-effort; returns false
|
||||
* when there is no resolvable git dir.
|
||||
*/
|
||||
export function ensureConfigGitExclude(root) {
|
||||
try {
|
||||
const gitDir = resolveGitDir(root);
|
||||
if (!gitDir) return false;
|
||||
const target = join(gitDir, 'info', 'exclude');
|
||||
const existing = existsSync(target) ? readFileSync(target, 'utf-8') : '';
|
||||
const block = [EXCLUDE_OPEN, ...EXCLUDE_PATTERNS, EXCLUDE_CLOSE].join('\n');
|
||||
const markerRe = new RegExp(`${escapeRegExp(EXCLUDE_OPEN)}[\\s\\S]*?${escapeRegExp(EXCLUDE_CLOSE)}`);
|
||||
let updated;
|
||||
if (markerRe.test(existing)) {
|
||||
updated = existing.replace(markerRe, block);
|
||||
} else {
|
||||
const prefix = existing.length === 0 ? '' : existing.endsWith('\n') ? existing : `${existing}\n`;
|
||||
updated = `${prefix}${block}\n`;
|
||||
}
|
||||
if (updated !== existing) {
|
||||
mkdirSync(dirname(target), { recursive: true });
|
||||
writeFileSync(target, updated);
|
||||
}
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveGitDir(root) {
|
||||
const dotGit = join(root, '.git');
|
||||
if (!existsSync(dotGit)) return null;
|
||||
try {
|
||||
if (statSync(dotGit).isDirectory()) return dotGit;
|
||||
// A `.git` file (worktree/submodule) points elsewhere: "gitdir: <path>".
|
||||
const match = readFileSync(dotGit, 'utf-8').match(/gitdir:\s*(.+)/);
|
||||
if (match) {
|
||||
const resolved = match[1].trim();
|
||||
return isAbsolute(resolved) ? resolved : join(root, resolved);
|
||||
}
|
||||
} catch {
|
||||
/* fall through */
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function escapeRegExp(value) {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
@@ -1,50 +1,53 @@
|
||||
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';
|
||||
export const CRITIQUE_DIR = 'critique';
|
||||
|
||||
export function getImpeccableDir(cwd = process.cwd()) {
|
||||
return path.join(cwd, IMPECCABLE_DIR);
|
||||
export function getImpeccableDir(cwd = process.cwd(), options = {}) {
|
||||
return path.join(resolveProjectRoot(cwd, options), IMPECCABLE_DIR);
|
||||
}
|
||||
|
||||
export function getDesignSidecarPath(cwd = process.cwd()) {
|
||||
return path.join(getImpeccableDir(cwd), 'design.json');
|
||||
export function getDesignSidecarPath(cwd = process.cwd(), options = {}) {
|
||||
return path.join(getImpeccableDir(cwd, options), 'design.json');
|
||||
}
|
||||
|
||||
export function getDesignSidecarCandidates(cwd = process.cwd(), contextDir = cwd) {
|
||||
export function getDesignSidecarCandidates(cwd = process.cwd(), contextDir = cwd, options = {}) {
|
||||
const projectRoot = resolveProjectRoot(cwd, options);
|
||||
const candidates = [
|
||||
getDesignSidecarPath(cwd),
|
||||
path.join(cwd, 'DESIGN.json'),
|
||||
getDesignSidecarPath(cwd, options),
|
||||
path.join(projectRoot, 'DESIGN.json'),
|
||||
];
|
||||
const contextLegacy = path.join(contextDir, 'DESIGN.json');
|
||||
if (!candidates.includes(contextLegacy)) candidates.push(contextLegacy);
|
||||
return candidates;
|
||||
}
|
||||
|
||||
export function resolveDesignSidecarPath(cwd = process.cwd(), contextDir = cwd) {
|
||||
return firstExisting(getDesignSidecarCandidates(cwd, contextDir));
|
||||
export function resolveDesignSidecarPath(cwd = process.cwd(), contextDir = cwd, options = {}) {
|
||||
return firstExisting(getDesignSidecarCandidates(cwd, contextDir, options));
|
||||
}
|
||||
|
||||
export function getLiveDir(cwd = process.cwd()) {
|
||||
return path.join(getImpeccableDir(cwd), LIVE_DIR);
|
||||
export function getLiveDir(cwd = process.cwd(), options = {}) {
|
||||
return path.join(getImpeccableDir(cwd, options), LIVE_DIR);
|
||||
}
|
||||
|
||||
export function getLiveConfigPath(cwd = process.cwd()) {
|
||||
return path.join(getLiveDir(cwd), 'config.json');
|
||||
export function getLiveConfigPath(cwd = process.cwd(), options = {}) {
|
||||
return path.join(getLiveDir(cwd, options), 'config.json');
|
||||
}
|
||||
|
||||
export function getLegacyLiveConfigPath(scriptsDir) {
|
||||
return path.join(scriptsDir, 'config.json');
|
||||
}
|
||||
|
||||
export function resolveLiveConfigPath({ cwd = process.cwd(), scriptsDir, env = process.env } = {}) {
|
||||
export function resolveLiveConfigPath({ cwd = process.cwd(), scriptsDir, env = process.env, targetPath } = {}) {
|
||||
if (env.IMPECCABLE_LIVE_CONFIG && env.IMPECCABLE_LIVE_CONFIG.trim()) {
|
||||
const configured = env.IMPECCABLE_LIVE_CONFIG.trim();
|
||||
return path.isAbsolute(configured) ? configured : path.resolve(cwd, configured);
|
||||
}
|
||||
const primary = getLiveConfigPath(cwd);
|
||||
const primary = getLiveConfigPath(cwd, { targetPath });
|
||||
if (fs.existsSync(primary)) return primary;
|
||||
if (scriptsDir) {
|
||||
const legacy = getLegacyLiveConfigPath(scriptsDir);
|
||||
@@ -53,16 +56,16 @@ export function resolveLiveConfigPath({ cwd = process.cwd(), scriptsDir, env = p
|
||||
return primary;
|
||||
}
|
||||
|
||||
export function getLiveServerPath(cwd = process.cwd()) {
|
||||
return path.join(getLiveDir(cwd), 'server.json');
|
||||
export function getLiveServerPath(cwd = process.cwd(), options = {}) {
|
||||
return path.join(getLiveDir(cwd, options), 'server.json');
|
||||
}
|
||||
|
||||
export function getLegacyLiveServerPath(cwd = process.cwd()) {
|
||||
return path.join(cwd, '.impeccable-live.json');
|
||||
export function getLegacyLiveServerPath(cwd = process.cwd(), options = {}) {
|
||||
return path.join(resolveProjectRoot(cwd, options), '.impeccable-live.json');
|
||||
}
|
||||
|
||||
export function readLiveServerInfo(cwd = process.cwd()) {
|
||||
for (const filePath of [getLiveServerPath(cwd), getLegacyLiveServerPath(cwd)]) {
|
||||
export function readLiveServerInfo(cwd = process.cwd(), options = {}) {
|
||||
for (const filePath of [getLiveServerPath(cwd, options), getLegacyLiveServerPath(cwd, options)]) {
|
||||
try {
|
||||
const info = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
||||
if (info && typeof info.pid === 'number' && !isLiveServerPidReachable(info.pid)) {
|
||||
@@ -88,37 +91,37 @@ export function isLiveServerPidReachable(pid) {
|
||||
}
|
||||
}
|
||||
|
||||
export function writeLiveServerInfo(cwd = process.cwd(), info) {
|
||||
const filePath = getLiveServerPath(cwd);
|
||||
export function writeLiveServerInfo(cwd = process.cwd(), info, options = {}) {
|
||||
const filePath = getLiveServerPath(cwd, options);
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
fs.writeFileSync(filePath, JSON.stringify(info));
|
||||
return filePath;
|
||||
}
|
||||
|
||||
export function removeLiveServerInfo(cwd = process.cwd()) {
|
||||
for (const filePath of [getLiveServerPath(cwd), getLegacyLiveServerPath(cwd)]) {
|
||||
export function removeLiveServerInfo(cwd = process.cwd(), options = {}) {
|
||||
for (const filePath of [getLiveServerPath(cwd, options), getLegacyLiveServerPath(cwd, options)]) {
|
||||
try { fs.unlinkSync(filePath); } catch {}
|
||||
}
|
||||
}
|
||||
|
||||
export function getLiveSessionsDir(cwd = process.cwd()) {
|
||||
return path.join(getLiveDir(cwd), 'sessions');
|
||||
export function getLiveSessionsDir(cwd = process.cwd(), options = {}) {
|
||||
return path.join(getLiveDir(cwd, options), 'sessions');
|
||||
}
|
||||
|
||||
export function getLegacyLiveSessionsDir(cwd = process.cwd()) {
|
||||
return path.join(cwd, '.impeccable-live', 'sessions');
|
||||
export function getLegacyLiveSessionsDir(cwd = process.cwd(), options = {}) {
|
||||
return path.join(resolveProjectRoot(cwd, options), '.impeccable-live', 'sessions');
|
||||
}
|
||||
|
||||
export function getLiveAnnotationsDir(cwd = process.cwd()) {
|
||||
return path.join(getLiveDir(cwd), 'annotations');
|
||||
export function getLiveAnnotationsDir(cwd = process.cwd(), options = {}) {
|
||||
return path.join(getLiveDir(cwd, options), 'annotations');
|
||||
}
|
||||
|
||||
export function getCritiqueDir(cwd = process.cwd()) {
|
||||
return path.join(getImpeccableDir(cwd), CRITIQUE_DIR);
|
||||
export function getCritiqueDir(cwd = process.cwd(), options = {}) {
|
||||
return path.join(getImpeccableDir(cwd, options), CRITIQUE_DIR);
|
||||
}
|
||||
|
||||
export function getLegacyLiveAnnotationsDir(cwd = process.cwd()) {
|
||||
return path.join(cwd, '.impeccable-live', 'annotations');
|
||||
export function getLegacyLiveAnnotationsDir(cwd = process.cwd(), options = {}) {
|
||||
return path.join(resolveProjectRoot(cwd, options), '.impeccable-live', 'annotations');
|
||||
}
|
||||
|
||||
function firstExisting(paths) {
|
||||
|
||||
@@ -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`;
|
||||
@@ -0,0 +1,42 @@
|
||||
class TargetArgError extends Error {
|
||||
constructor(message, code) {
|
||||
super(message);
|
||||
this.name = 'TargetArgError';
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
export function parseTargetPath(args = [], { strict = false } = {}) {
|
||||
let targetPath = null;
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const arg = String(args[i]);
|
||||
if (arg === '--target' || arg === '-t') {
|
||||
const next = args[i + 1];
|
||||
if (next && !String(next).startsWith('-')) {
|
||||
targetPath = String(next);
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (strict) {
|
||||
throw new TargetArgError('--target requires a path value.', 'TARGET_VALUE_MISSING');
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (arg.startsWith('--target=')) {
|
||||
const value = arg.slice('--target='.length);
|
||||
if (value) {
|
||||
targetPath = value;
|
||||
continue;
|
||||
}
|
||||
if (strict) {
|
||||
throw new TargetArgError('--target requires a path value.', 'TARGET_VALUE_MISSING');
|
||||
}
|
||||
}
|
||||
}
|
||||
return targetPath;
|
||||
}
|
||||
|
||||
export function parseTargetOptions(args = [], options = {}) {
|
||||
const targetPath = parseTargetPath(args, options);
|
||||
return targetPath ? { targetPath } : {};
|
||||
}
|
||||
@@ -57,7 +57,8 @@
|
||||
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 PICK_CURSOR_CLASS = PREFIX + '-pick-cursor';
|
||||
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({
|
||||
prefix: PREFIX,
|
||||
@@ -152,6 +153,8 @@
|
||||
let scrollLockTargetY = null;
|
||||
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.
|
||||
@@ -1915,45 +1918,45 @@
|
||||
syncPageInteractionCursor();
|
||||
}
|
||||
|
||||
let pageInteractionCursorActive = false;
|
||||
|
||||
function ensurePickCursorStyle() {
|
||||
if (document.getElementById(PREFIX + '-pick-cursor-style')) return;
|
||||
const style = document.createElement('style');
|
||||
style.id = PREFIX + '-pick-cursor-style';
|
||||
/**
|
||||
* Drive the page-level pick / insert cursor through the textContent of one
|
||||
* injected <style>, never by mutating <html> (className or inline style).
|
||||
* Frameworks that server-render the <html>/<body> roots (Next.js App Router)
|
||||
* report a React 19 hydration mismatch when the client adds an attribute the
|
||||
* server HTML never emitted, so a `class`/inline `style` toggled on
|
||||
* `document.documentElement` trips "a tree hydrated but some attributes ...
|
||||
* didn't match" on the next Fast-Refresh re-render. Keying the cursor off a
|
||||
* stable-id <style> keeps the effect off the hydrated host elements (same
|
||||
* shape as the scroll-anchor lock). A falsy cursor clears the rule.
|
||||
*/
|
||||
function setPageInteractionCursor(cursor) {
|
||||
let style = document.getElementById(PICK_CURSOR_STYLE_ID);
|
||||
if (!cursor) {
|
||||
if (style) style.textContent = '';
|
||||
return;
|
||||
}
|
||||
if (!style) {
|
||||
style = document.createElement('style');
|
||||
style.id = PICK_CURSOR_STYLE_ID;
|
||||
// Styles the host page, not the chrome - inside the adapter's shadow UI
|
||||
// root (uiAppendStyle's target) these selectors would match nothing.
|
||||
(document.head || document.documentElement).appendChild(style);
|
||||
}
|
||||
style.textContent =
|
||||
'html.' + PICK_CURSOR_CLASS + ' * { cursor: crosshair !important; }\n'
|
||||
+ 'html.' + PICK_CURSOR_CLASS + ' [id^="' + PREFIX + '"],\n'
|
||||
+ 'html.' + PICK_CURSOR_CLASS + ' [id^="' + PREFIX + '"] * { cursor: revert !important; }';
|
||||
// Styles the host page, not the chrome - inside the adapter's shadow UI
|
||||
// root (uiAppendStyle's target) these selectors would match nothing.
|
||||
document.head.appendChild(style);
|
||||
'* { cursor: ' + cursor + ' !important; }\n'
|
||||
+ '[id^="' + PREFIX + '"],\n'
|
||||
+ '[id^="' + PREFIX + '"] * { cursor: revert !important; }';
|
||||
}
|
||||
|
||||
/** Page-level cursor while pick or insert mode is targeting page elements. */
|
||||
function syncPageInteractionCursor() {
|
||||
const pickCursor = state === 'PICKING' && pickActive && !insertActive;
|
||||
let axisCursor = '';
|
||||
if (state === 'PICKING' && insertActive) {
|
||||
axisCursor = insertHoverAnchor ? cursorForInsertAxis(insertHoverAxis || 'column') : '';
|
||||
}
|
||||
|
||||
if (pickCursor) {
|
||||
ensurePickCursorStyle();
|
||||
document.documentElement.classList.add(PICK_CURSOR_CLASS);
|
||||
document.documentElement.style.cursor = '';
|
||||
pageInteractionCursorActive = true;
|
||||
return;
|
||||
}
|
||||
|
||||
document.documentElement.classList.remove(PICK_CURSOR_CLASS);
|
||||
if (axisCursor) {
|
||||
document.documentElement.style.cursor = axisCursor;
|
||||
pageInteractionCursorActive = true;
|
||||
} else if (pageInteractionCursorActive) {
|
||||
document.documentElement.style.cursor = '';
|
||||
pageInteractionCursorActive = false;
|
||||
let cursor = '';
|
||||
if (state === 'PICKING' && pickActive && !insertActive) {
|
||||
cursor = 'crosshair';
|
||||
} else if (state === 'PICKING' && insertActive && insertHoverAnchor) {
|
||||
cursor = cursorForInsertAxis(insertHoverAxis || 'column');
|
||||
}
|
||||
setPageInteractionCursor(cursor);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -3034,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) {
|
||||
@@ -4713,6 +4726,7 @@
|
||||
paramsCurrentValues = {};
|
||||
tuneOpen = false;
|
||||
hideParamsPanel();
|
||||
if (currentSessionId && visibleVariant) updateVariantStateStylesheet(currentSessionId, visibleVariant);
|
||||
return;
|
||||
}
|
||||
applyParamDefaults(variantEl, params);
|
||||
@@ -4770,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) {
|
||||
@@ -4822,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.
|
||||
@@ -5491,6 +5488,7 @@
|
||||
if (pendingSvelteComponentRetryObserver) { pendingSvelteComponentRetryObserver.disconnect(); pendingSvelteComponentRetryObserver = null; }
|
||||
if (pendingVariantAnchorRetryObserver) { pendingVariantAnchorRetryObserver.disconnect(); pendingVariantAnchorRetryObserver = null; }
|
||||
stopScrollLock();
|
||||
removeVariantStateStylesheet();
|
||||
clearSession();
|
||||
clearHandled();
|
||||
resetSessionFileMeta();
|
||||
@@ -5805,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) {
|
||||
@@ -5815,10 +5875,22 @@
|
||||
|
||||
try { history.scrollRestoration = 'manual'; } catch {}
|
||||
|
||||
const prevHtmlAnchor = document.documentElement.style.overflowAnchor;
|
||||
const prevBodyAnchor = document.body.style.overflowAnchor;
|
||||
document.documentElement.style.overflowAnchor = 'none';
|
||||
document.body.style.overflowAnchor = 'none';
|
||||
// Suppress the browser's scroll-anchoring on the scroll root so it can't
|
||||
// fight our manual scroll correction. Apply this as a stylesheet rule, not
|
||||
// as inline `style` on <html>/<body>: those elements are server-rendered by
|
||||
// frameworks like Next.js App Router, and mutating their inline style makes
|
||||
// React 19 report a hydration mismatch on the next Fast-Refresh re-render.
|
||||
// A <style> rule has the same computed effect without touching any hydrated
|
||||
// element's attributes. Like the inline version, it is recreated on every
|
||||
// startScrollLock call, so reload survival (driven by the persisted scroll
|
||||
// key) is unaffected.
|
||||
let anchorLockStyle = document.getElementById(SCROLL_ANCHOR_LOCK_ID);
|
||||
if (!anchorLockStyle) {
|
||||
anchorLockStyle = document.createElement('style');
|
||||
anchorLockStyle.id = SCROLL_ANCHOR_LOCK_ID;
|
||||
anchorLockStyle.textContent = 'html,body{overflow-anchor:none !important;}';
|
||||
(document.head || document.documentElement).appendChild(anchorLockStyle);
|
||||
}
|
||||
|
||||
const correct = (why) => {
|
||||
scrollLockRaf = null;
|
||||
@@ -5853,8 +5925,7 @@
|
||||
|
||||
scrollLockAbort = new AbortController();
|
||||
scrollLockAbort.signal.addEventListener('abort', () => {
|
||||
document.documentElement.style.overflowAnchor = prevHtmlAnchor;
|
||||
document.body.style.overflowAnchor = prevBodyAnchor;
|
||||
document.getElementById(SCROLL_ANCHOR_LOCK_ID)?.remove();
|
||||
}, { once: true });
|
||||
const sig = { signal: scrollLockAbort.signal };
|
||||
// Track whether the most recent scroll came from a user gesture. We
|
||||
@@ -6075,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();
|
||||
@@ -6489,10 +6560,13 @@
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (isPageEditableElement(deepActive) && !isInlineEditActive(deepActive)) {
|
||||
return;
|
||||
}
|
||||
// While a contenteditable text-leaf is focused, let the browser handle
|
||||
// all keys except Escape. Escape cancels the current edit (restores
|
||||
// original text) and blurs without saving, staying in CONFIGURING.
|
||||
if (e.target.isContentEditable && inlineEditRows.some((r) => r.el === e.target)) {
|
||||
if (e.target.isContentEditable && isInlineEditActive(e.target)) {
|
||||
if (e.key !== 'Escape') return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
@@ -7621,6 +7695,7 @@ void main() {
|
||||
stopScrollTracking();
|
||||
if (variantObserver) { variantObserver.disconnect(); variantObserver = null; }
|
||||
stopScrollLock();
|
||||
removeVariantStateStylesheet();
|
||||
clearScrollY();
|
||||
clearSession();
|
||||
resetSessionFileMeta();
|
||||
@@ -7882,6 +7957,7 @@ void main() {
|
||||
if (variantObserver) { variantObserver.disconnect(); variantObserver = null; }
|
||||
if (pendingVariantAnchorRetryObserver) { pendingVariantAnchorRetryObserver.disconnect(); pendingVariantAnchorRetryObserver = null; }
|
||||
stopScrollLock();
|
||||
removeVariantStateStylesheet();
|
||||
clearScrollY();
|
||||
finalizeInsertSession();
|
||||
clearSession();
|
||||
@@ -7913,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,
|
||||
@@ -7923,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);
|
||||
}
|
||||
|
||||
@@ -8186,7 +8267,7 @@ void main() {
|
||||
let voiceInterimBase = '';
|
||||
/** @type {{ mode: 'steer'|'configure', input: HTMLInputElement, submit: () => void, beforeStart?: () => void } | null} */
|
||||
let voiceCtx = null;
|
||||
const PAGE_CHAT_COLLAPSED_W = '88px';
|
||||
const PAGE_CHAT_COLLAPSED_W = '104px';
|
||||
const PAGE_CHAT_PROCESSING_W = '76px';
|
||||
const PAGE_CHAT_PLACEHOLDER_COLLAPSED = 'Steer…';
|
||||
const PAGE_CHAT_PLACEHOLDER_EXPANDED = 'Steer the page…';
|
||||
@@ -8197,7 +8278,7 @@ void main() {
|
||||
const GLOBAL_BAR_SECTION_GAP = 8;
|
||||
const GLOBAL_BAR_INNER_GAP = 2;
|
||||
const GLOBAL_BAR_INNER_PAD_LEFT = 2;
|
||||
const PAGE_CHAT_EXPANDED_W = 'min(280px, 38vw)';
|
||||
const PAGE_CHAT_EXPANDED_MAX_W = 280;
|
||||
const ICON_PAGE_CHAT =
|
||||
'<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>';
|
||||
const ICON_PAGE_VOICE =
|
||||
@@ -8277,6 +8358,52 @@ void main() {
|
||||
return barPaletteForTheme(globalBarEl?.dataset.theme || detectPageTheme());
|
||||
}
|
||||
|
||||
function globalBarModeToggles() {
|
||||
return [
|
||||
uiGetById(PREFIX + '-pick-toggle'),
|
||||
uiGetById(PREFIX + '-insert-toggle'),
|
||||
uiGetById(PREFIX + '-detect-toggle'),
|
||||
uiGetById(PREFIX + '-design-toggle'),
|
||||
].filter(Boolean);
|
||||
}
|
||||
|
||||
function applyGlobalBarLabelState(expandInactive, forceCollapse = false) {
|
||||
globalBarModeToggles().forEach((toggle) => {
|
||||
if (forceCollapse) toggle._collapseLabel?.(true);
|
||||
else if (expandInactive || toggle.dataset.active === 'true') toggle._expandLabel?.();
|
||||
else toggle._collapseLabel?.();
|
||||
});
|
||||
}
|
||||
|
||||
function syncGlobalBarExpandedLabels(expanded = globalBarEl?.matches(':hover')) {
|
||||
const expandInactive = !!(expanded && !pageChatExpanded);
|
||||
applyGlobalBarLabelState(expandInactive, pageChatExpanded);
|
||||
|
||||
if (expandInactive && globalBarEl && globalBarEl.scrollWidth > window.innerWidth - 16) {
|
||||
applyGlobalBarLabelState(false);
|
||||
}
|
||||
}
|
||||
|
||||
function pageChatCollapsedWidthPx() {
|
||||
const parsed = parseFloat(PAGE_CHAT_COLLAPSED_W);
|
||||
return Number.isFinite(parsed) ? parsed : 104;
|
||||
}
|
||||
|
||||
function pageChatExpandedWidth() {
|
||||
if (!pageChatEl || !globalBarEl) return PAGE_CHAT_EXPANDED_MAX_W + 'px';
|
||||
const currentChatWidth = pageChatEl.getBoundingClientRect().width || pageChatCollapsedWidthPx();
|
||||
const barWidth = Math.max(globalBarEl.getBoundingClientRect().width || 0, globalBarEl.scrollWidth || 0);
|
||||
const nonChatWidth = Math.max(0, barWidth - currentChatWidth);
|
||||
const available = window.innerWidth - 16 - nonChatWidth;
|
||||
const next = Math.max(pageChatCollapsedWidthPx(), Math.min(PAGE_CHAT_EXPANDED_MAX_W, available));
|
||||
return Math.round(next) + 'px';
|
||||
}
|
||||
|
||||
function syncPageChatExpandedWidth() {
|
||||
if (!pageChatEl || !pageChatExpanded) return;
|
||||
pageChatEl.style.width = pageChatExpandedWidth();
|
||||
}
|
||||
|
||||
function syncPageChatChrome() {
|
||||
if (!pageChatEl) return;
|
||||
const P = pageChatPalette();
|
||||
@@ -8312,6 +8439,21 @@ void main() {
|
||||
&& !steerLocked;
|
||||
}
|
||||
|
||||
function isPageEditableElement(el) {
|
||||
if (!el || own(el)) return false;
|
||||
if (/^(INPUT|TEXTAREA|SELECT)$/.test(el.tagName || '')) return true;
|
||||
return !!el.isContentEditable;
|
||||
}
|
||||
|
||||
function isInlineEditActive(el) {
|
||||
return !!el && inlineEditRows.some((r) => r.el === el);
|
||||
}
|
||||
|
||||
function isPageEditableActive() {
|
||||
const active = activeElementDeep();
|
||||
return isPageEditableElement(active) && !isInlineEditActive(active);
|
||||
}
|
||||
|
||||
function pageHasHostTextSelection() {
|
||||
const sel = window.getSelection?.();
|
||||
if (!sel || sel.isCollapsed) return false;
|
||||
@@ -8325,6 +8467,7 @@ void main() {
|
||||
function shouldSteerAutoFocus() {
|
||||
return shouldFocusSteerChat()
|
||||
&& !steerFocusSuspended
|
||||
&& !isPageEditableActive()
|
||||
&& performance.now() >= steerFocusPauseUntil;
|
||||
}
|
||||
|
||||
@@ -8562,7 +8705,8 @@ void main() {
|
||||
if (!pageChatEl || !pageChatInput) return false;
|
||||
pageChatExpanded = true;
|
||||
pageChatEl.dataset.expanded = 'true';
|
||||
pageChatEl.style.width = PAGE_CHAT_EXPANDED_W;
|
||||
syncGlobalBarExpandedLabels(false);
|
||||
pageChatEl.style.width = pageChatExpandedWidth();
|
||||
pageChatEl.style.cursor = steerLocked ? 'default' : 'text';
|
||||
pageChatInput.placeholder = PAGE_CHAT_PLACEHOLDER_EXPANDED;
|
||||
if (pageChatHint) {
|
||||
@@ -8657,7 +8801,7 @@ void main() {
|
||||
pageChatEl.setAttribute('aria-label', 'Steer the page');
|
||||
pageChatExpanded = keepExpanded;
|
||||
pageChatEl.dataset.expanded = keepExpanded ? 'true' : 'false';
|
||||
pageChatEl.style.width = keepExpanded ? PAGE_CHAT_EXPANDED_W : PAGE_CHAT_COLLAPSED_W;
|
||||
pageChatEl.style.width = keepExpanded ? pageChatExpandedWidth() : PAGE_CHAT_COLLAPSED_W;
|
||||
pageChatEl.style.cursor = 'pointer';
|
||||
if (pageChatInput) {
|
||||
pageChatInput.disabled = false;
|
||||
@@ -8971,6 +9115,7 @@ void main() {
|
||||
pageChatEl.dataset.expanded = 'false';
|
||||
pageChatEl.style.width = PAGE_CHAT_COLLAPSED_W;
|
||||
pageChatEl.style.cursor = 'pointer';
|
||||
syncGlobalBarExpandedLabels(globalBarEl?.matches(':hover'));
|
||||
if (blur) {
|
||||
pageChatInput.blur();
|
||||
pageChatInput.style.pointerEvents = 'none';
|
||||
@@ -9270,6 +9415,7 @@ void main() {
|
||||
zIndex: Z.bar + 5,
|
||||
display: 'flex', alignItems: 'stretch',
|
||||
gap: '0',
|
||||
width: 'max-content',
|
||||
background: P.surface,
|
||||
border: '1px solid ' + P.border,
|
||||
borderRadius: '8px',
|
||||
@@ -9277,6 +9423,8 @@ void main() {
|
||||
fontFamily: FONT, fontSize: '12px', lineHeight: '1',
|
||||
opacity: '0',
|
||||
overflow: 'hidden', // clip the full-bleed brand mark to the bar radius
|
||||
maxWidth: 'calc(100vw - 16px)',
|
||||
boxSizing: 'border-box',
|
||||
transition: 'opacity 0.3s ' + EASE + ', transform 0.3s ' + EASE,
|
||||
});
|
||||
globalBarEl.id = PREFIX + '-global-bar';
|
||||
@@ -9325,6 +9473,7 @@ void main() {
|
||||
const inner = el('div', {
|
||||
display: 'flex', alignItems: 'center',
|
||||
padding: '4px 5px 4px ' + GLOBAL_BAR_INNER_PAD_LEFT + 'px', gap: GLOBAL_BAR_INNER_GAP + 'px',
|
||||
flex: '0 0 auto',
|
||||
});
|
||||
inner.id = PREFIX + '-global-bar-inner';
|
||||
globalBarEl.appendChild(inner);
|
||||
@@ -9333,7 +9482,10 @@ void main() {
|
||||
function makeIconBtn({ id, svg, label, ariaLabel, labelFont, onClick }) {
|
||||
const b = el('button', {
|
||||
position: 'relative',
|
||||
display: 'inline-flex', alignItems: 'center',
|
||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||
boxSizing: 'border-box',
|
||||
flex: '0 0 auto',
|
||||
minWidth: '30px',
|
||||
padding: '6px 8px', borderRadius: '7px',
|
||||
border: 'none', background: 'transparent',
|
||||
color: P.textDim, fontFamily: FONT, fontSize: '11.5px', fontWeight: '500',
|
||||
@@ -9352,8 +9504,8 @@ void main() {
|
||||
if (!labelEl) return;
|
||||
labelEl.style.maxWidth = '120px'; labelEl.style.opacity = '1'; labelEl.style.marginLeft = '6px'; labelEl.style.transform = 'translateX(0)';
|
||||
};
|
||||
const collapse = () => {
|
||||
if (!labelEl || b.dataset.active === 'true') return;
|
||||
const collapse = (force = false) => {
|
||||
if (!labelEl || (!force && b.dataset.active === 'true')) return;
|
||||
labelEl.style.maxWidth = '0'; labelEl.style.opacity = '0'; labelEl.style.marginLeft = '0'; labelEl.style.transform = 'translateX(-4px)';
|
||||
};
|
||||
// Per-button hover only changes color (no layout). The label expand/
|
||||
@@ -9604,6 +9756,7 @@ void main() {
|
||||
width: '1px', height: '18px',
|
||||
background: P.hairline,
|
||||
margin: '0 4px 0 2px',
|
||||
flexShrink: '0',
|
||||
});
|
||||
inner.appendChild(divider);
|
||||
|
||||
@@ -9620,6 +9773,7 @@ void main() {
|
||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||
padding: '0', boxSizing: 'border-box',
|
||||
width: '24px', height: '24px', borderRadius: '6px',
|
||||
flexShrink: '0',
|
||||
border: 'none', background: 'transparent',
|
||||
color: P.textDim, fontFamily: FONT, fontSize: '0', lineHeight: '0',
|
||||
cursor: 'pointer', transition: 'color 0.12s ease, background 0.12s ease',
|
||||
@@ -9632,16 +9786,16 @@ void main() {
|
||||
exitBtn.addEventListener('click', () => { sendEvent({ type: 'exit' }); teardown(); });
|
||||
inner.appendChild(exitBtn);
|
||||
|
||||
// Bar-level hover: expand every toggle's label at once; collapse on leave.
|
||||
// Bar-level hover: expand mode labels unless Steer is using the space.
|
||||
// Buttons with dataset.active="true" ignore collapse (their label stays).
|
||||
const toggles = [pickBtn, insertBtn, detectBtn, designBtn];
|
||||
globalBarEl.addEventListener('mouseenter', () => {
|
||||
toggles.forEach((t) => t._expandLabel && t._expandLabel());
|
||||
syncGlobalBarExpandedLabels(true);
|
||||
syncPageChatExpandedWidth();
|
||||
schedulePendingDockPosition();
|
||||
setTimeout(schedulePendingDockPosition, 260);
|
||||
});
|
||||
globalBarEl.addEventListener('mouseleave', () => {
|
||||
toggles.forEach((t) => t._collapseLabel && t._collapseLabel());
|
||||
syncGlobalBarExpandedLabels(false);
|
||||
schedulePendingDockPosition();
|
||||
setTimeout(schedulePendingDockPosition, 260);
|
||||
});
|
||||
@@ -9659,6 +9813,7 @@ void main() {
|
||||
pendingDockResizeObserver.observe(globalBarEl);
|
||||
}
|
||||
window.addEventListener('resize', positionPendingDock);
|
||||
window.addEventListener('resize', syncPageChatExpandedWidth);
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
globalBarEl.style.opacity = '1';
|
||||
@@ -9705,9 +9860,7 @@ void main() {
|
||||
// If the bar is currently under the cursor, keep all labels expanded -
|
||||
// otherwise clicking a toggle that deactivates (e.g. closing DESIGN.md)
|
||||
// would collapse its label while the user's mouse is still on the bar.
|
||||
if (globalBarEl && globalBarEl.matches(':hover')) {
|
||||
[pickToggle, insertToggle, detectToggle, designToggle].forEach((t) => t?._expandLabel?.());
|
||||
}
|
||||
syncGlobalBarExpandedLabels(globalBarEl && globalBarEl.matches(':hover'));
|
||||
|
||||
if (detectBadge) {
|
||||
detectBadge.style.display = (detectActive && detectCount > 0) ? 'inline' : 'none';
|
||||
@@ -9896,7 +10049,8 @@ void main() {
|
||||
// Remove detection overlays
|
||||
window.postMessage({ source: 'impeccable-command', action: 'remove' }, '*');
|
||||
setLiveState('IDLE');
|
||||
document.getElementById(PREFIX + '-pick-cursor-style')?.remove();
|
||||
document.getElementById(PICK_CURSOR_STYLE_ID)?.remove();
|
||||
removeVariantStateStylesheet();
|
||||
window.__IMPECCABLE_LIVE_INIT__ = false;
|
||||
console.log('[impeccable] Live mode exited.');
|
||||
}
|
||||
@@ -10385,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;
|
||||
}
|
||||
@@ -10415,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;
|
||||
}
|
||||
@@ -10423,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;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
* CLI client for the live variant mode poll/reply protocol.
|
||||
*
|
||||
* Usage:
|
||||
* npx impeccable poll # Block until browser event, print JSON
|
||||
* npx impeccable poll --stream # Experimental: keep polling; one JSON line per event
|
||||
* npx impeccable poll --timeout=600000 # Custom timeout (ms); default is long-poll friendly
|
||||
* npx impeccable poll --reply <id> done # Reply "done" to event <id>
|
||||
* npx impeccable poll --reply <id> error "msg" # Reply with error
|
||||
* node <scripts_path>/live-poll.mjs # Block until browser event, print JSON
|
||||
* node <scripts_path>/live-poll.mjs --stream # Experimental: keep polling; one JSON line per event
|
||||
* node <scripts_path>/live-poll.mjs --timeout=600000 # Custom timeout (ms); default is long-poll friendly
|
||||
* node <scripts_path>/live-poll.mjs --reply <id> done # Reply "done" to event <id>
|
||||
* node <scripts_path>/live-poll.mjs --reply <id> error "msg" # Reply with error
|
||||
*/
|
||||
|
||||
import { execFileSync } from 'node:child_process';
|
||||
@@ -15,6 +15,11 @@ import { fileURLToPath } from 'node:url';
|
||||
import { completionAckForAcceptResult, completionTypeForAcceptResult } from './live/completion.mjs';
|
||||
import { readLiveServerInfo } from './lib/impeccable-paths.mjs';
|
||||
|
||||
// Absolute path to a sibling script in this skill's scripts dir, so runtime
|
||||
// error hints print a directly-runnable command instead of a placeholder.
|
||||
const SELF_DIR = path.dirname(fileURLToPath(import.meta.url));
|
||||
const scriptCmd = (name) => `node "${path.join(SELF_DIR, name)}"`;
|
||||
|
||||
// Node's built-in fetch (undici under the hood) enforces a 300s headers
|
||||
// timeout that can't be lowered per-request. We cap each request below
|
||||
// that ceiling and loop in `pollOnce` to synthesize a long poll without
|
||||
@@ -27,7 +32,7 @@ const EVENT_TYPES_NEEDING_AGENT_REPLY = new Set(['generate', 'steer', 'manual_ed
|
||||
function readServerInfo() {
|
||||
const record = readLiveServerInfo(process.cwd());
|
||||
if (!record) {
|
||||
console.error('No running live server found. Start one with: npx impeccable live');
|
||||
console.error(`No running live server found. Start one with: ${scriptCmd('live.mjs')}`);
|
||||
process.exit(1);
|
||||
}
|
||||
return record.info;
|
||||
@@ -82,7 +87,7 @@ export function parseReplyArgs(args) {
|
||||
}
|
||||
|
||||
function validateReplyArgs({ id, status }) {
|
||||
const usage = "Usage: npx impeccable poll --reply <id> <status> [--file path] [--data '<json>'] [message]";
|
||||
const usage = `Usage: ${scriptCmd('live-poll.mjs')} --reply <id> <status> [--file path] [--data '<json>'] [message]`;
|
||||
if (!id || id.startsWith('--')) {
|
||||
const err = new Error(`${usage}\nMissing event id after --reply.`);
|
||||
err.code = 'INVALID_REPLY_ARGS';
|
||||
@@ -283,11 +288,11 @@ export async function runPollStream(base, token, {
|
||||
function handlePollError(err) {
|
||||
if (err.code === 'AUTH_FAILED') {
|
||||
console.error(err.message);
|
||||
console.error('Try restarting: npx impeccable live stop && npx impeccable live');
|
||||
console.error(`Try restarting: ${scriptCmd('live-server.mjs')} stop && ${scriptCmd('live.mjs')}`);
|
||||
process.exit(1);
|
||||
}
|
||||
if (err.cause?.code === 'ECONNREFUSED') {
|
||||
console.error('Live server not running. Start one with: npx impeccable live');
|
||||
console.error(`Live server not running. Start one with: ${scriptCmd('live.mjs')}`);
|
||||
process.exit(1);
|
||||
}
|
||||
if (err.code === 'ACK_TIMEOUT') {
|
||||
@@ -331,7 +336,7 @@ Harness note:
|
||||
const info = readServerInfo();
|
||||
const base = `http://localhost:${info.port}`;
|
||||
|
||||
// Reply mode: npx impeccable poll --reply <id> <status> [--file path] [--data '<json>'] [message]
|
||||
// Reply mode: node <scripts_path>/live-poll.mjs --reply <id> <status> [--file path] [--data '<json>'] [message]
|
||||
if (args.includes('--reply')) {
|
||||
let reply;
|
||||
try {
|
||||
@@ -345,7 +350,7 @@ Harness note:
|
||||
await postReply(base, info.token, reply);
|
||||
} catch (err) {
|
||||
if (err.cause?.code === 'ECONNREFUSED') {
|
||||
console.error('Live server not running. Start one with: npx impeccable live');
|
||||
console.error(`Live server not running. Start one with: ${scriptCmd('live.mjs')}`);
|
||||
} else {
|
||||
console.error('Reply failed:', err.message);
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ import path from 'node:path';
|
||||
import net from 'node:net';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { parseDesignMd } from './lib/design-parser.mjs';
|
||||
import { resolveContextDir } from './context.mjs';
|
||||
import { loadContext } from './context.mjs';
|
||||
import {
|
||||
assembleLiveBrowserScript,
|
||||
assertLiveBrowserScriptParts,
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
getDesignSidecarPath,
|
||||
getLiveDir,
|
||||
getLiveAnnotationsDir,
|
||||
IMPECCABLE_COMMAND_PREFIX,
|
||||
readLiveServerInfo,
|
||||
removeLiveServerInfo,
|
||||
resolveDesignSidecarPath,
|
||||
@@ -55,7 +56,11 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
// PRODUCT.md / DESIGN.md live wherever context.mjs resolves. The generated
|
||||
// DESIGN sidecar is project-local at .impeccable/design.json, with legacy
|
||||
// DESIGN.json fallback for existing projects.
|
||||
const CONTEXT_DIR = resolveContextDir(process.cwd());
|
||||
const PROJECT_CONTEXT = loadContext(process.cwd());
|
||||
const CONTEXT_DIR = PROJECT_CONTEXT.contextDir;
|
||||
const DESIGN_MD_PATH = PROJECT_CONTEXT.designPath
|
||||
? path.resolve(process.cwd(), PROJECT_CONTEXT.designPath)
|
||||
: null;
|
||||
const DEFAULT_POLL_TIMEOUT = 600_000; // 10 min — agent re-polls on timeout anyway
|
||||
const SSE_HEARTBEAT_INTERVAL = 30_000; // keepalive ping every 30s
|
||||
|
||||
@@ -371,10 +376,7 @@ function hasProjectContext() {
|
||||
// PRODUCT.md carries brand voice / anti-references — that's what determines
|
||||
// whether variants are brand-aware. DESIGN.md (visual tokens) is a separate
|
||||
// concern, surfaced by the design panel's own empty state.
|
||||
try {
|
||||
fs.accessSync(path.join(CONTEXT_DIR, 'PRODUCT.md'), fs.constants.R_OK);
|
||||
return true;
|
||||
} catch { return false; }
|
||||
return !!PROJECT_CONTEXT.hasProduct;
|
||||
}
|
||||
|
||||
function statOrNull(filePath) {
|
||||
@@ -412,6 +414,7 @@ function createRequestHandler({ detectScript, liveScriptParts }) {
|
||||
token: state.token,
|
||||
port: state.port,
|
||||
vocabulary: LIVE_COMMANDS,
|
||||
commandPrefix: IMPECCABLE_COMMAND_PREFIX,
|
||||
parts,
|
||||
});
|
||||
res.writeHead(200, {
|
||||
@@ -549,8 +552,8 @@ function createRequestHandler({ detectScript, liveScriptParts }) {
|
||||
const token = url.searchParams.get('token');
|
||||
if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; }
|
||||
|
||||
const mdPath = path.join(CONTEXT_DIR, 'DESIGN.md');
|
||||
const jsonPath = resolveDesignSidecarPath(process.cwd(), CONTEXT_DIR) || getDesignSidecarPath(process.cwd());
|
||||
const mdPath = DESIGN_MD_PATH;
|
||||
const jsonPath = resolveDesignSidecarPath(process.cwd(), PROJECT_CONTEXT.designContextDir || CONTEXT_DIR) || getDesignSidecarPath(process.cwd());
|
||||
const mdStat = statOrNull(mdPath);
|
||||
const jsonStat = statOrNull(jsonPath);
|
||||
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import path from 'node:path';
|
||||
import { resolveProjectRoot } from './context.mjs';
|
||||
import { parseTargetPath } from './lib/target-args.mjs';
|
||||
|
||||
export function resolveLiveTarget(cwd = process.cwd(), args = []) {
|
||||
const originalCwd = path.resolve(cwd);
|
||||
let targetPath = null;
|
||||
try {
|
||||
targetPath = parseTargetPath(args, { strict: true });
|
||||
} catch (err) {
|
||||
if (err?.name === 'TargetArgError') {
|
||||
process.stderr.write(`${err.message}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
const absoluteTargetPath = targetPath
|
||||
? path.isAbsolute(targetPath) ? targetPath : path.resolve(originalCwd, targetPath)
|
||||
: null;
|
||||
const projectRoot = targetPath
|
||||
? resolveProjectRoot(originalCwd, { targetPath: absoluteTargetPath })
|
||||
: originalCwd;
|
||||
return {
|
||||
originalCwd,
|
||||
projectRoot,
|
||||
targetPath,
|
||||
absoluteTargetPath,
|
||||
targetOptions: absoluteTargetPath ? { targetPath: absoluteTargetPath } : {},
|
||||
};
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
* CLI helper: find an element in source and wrap it in a variant container.
|
||||
*
|
||||
* Usage:
|
||||
* npx impeccable wrap --id SESSION_ID --count N --query "hero-combined-left" [--file path]
|
||||
* node <scripts_path>/live-wrap.mjs --id SESSION_ID --count N --query "hero-combined-left" [--file path]
|
||||
*
|
||||
* Searches project files for the element matching the query (class name, ID, or
|
||||
* text snippet), wraps it with the variant scaffolding, and prints the file path
|
||||
|
||||
@@ -21,14 +21,16 @@ import { execSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { loadContext } from './context.mjs';
|
||||
import { loadContext, resolveTargetSelection } from './context.mjs';
|
||||
import { resolveFiles } from './live-inject.mjs';
|
||||
import { readLiveServerInfo } from './lib/impeccable-paths.mjs';
|
||||
import { resolveLiveTarget } from './live-target.mjs';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
async function liveCli() {
|
||||
const args = process.argv.slice(2);
|
||||
const liveTarget = resolveLiveTarget(process.cwd(), args);
|
||||
|
||||
if (args.includes('--help') || args.includes('-h')) {
|
||||
console.log(`Usage: node live.mjs
|
||||
@@ -38,37 +40,78 @@ Prepare everything for live variant mode in a single command:
|
||||
- Starts (or reuses) the live server in the background
|
||||
- Injects the browser script tag
|
||||
- Reads PRODUCT.md / DESIGN.md for project context
|
||||
- In monorepos, choose a child app first; --target <path> is the fallback/manual path
|
||||
|
||||
On success, prints a JSON blob with:
|
||||
{ ok, serverPort, serverToken, pageFile, hasContext, context }
|
||||
{ ok, serverPort, serverToken, pageFiles, projectRoot, repoRoot, targetPath, productPath, designPath }
|
||||
|
||||
On target_selection_required, prints:
|
||||
{ ok: false, error: "target_selection_required", targetCandidates }
|
||||
|
||||
On config_missing, prints:
|
||||
{ ok: false, error: "config_missing", configPath, hint }
|
||||
|
||||
The agent should then:
|
||||
1. If config_missing, create the config and re-run this script
|
||||
2. Optionally open the project's dev/preview URL in the browser (see reference/live.md—not serverPort)
|
||||
3. Enter the poll loop: node live-poll.mjs`);
|
||||
1. If target_selection_required, ask which app to use and rerun from that child cwd
|
||||
2. If config_missing, create the config and re-run this script
|
||||
3. Optionally open the project's dev/preview URL in the browser (see reference/live.md—not serverPort)
|
||||
4. Enter the poll loop: node live-poll.mjs`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const targetSelection = resolveTargetSelection(liveTarget.originalCwd, liveTarget.targetOptions);
|
||||
if (targetSelection) {
|
||||
console.log(JSON.stringify({
|
||||
ok: false,
|
||||
error: 'target_selection_required',
|
||||
...targetSelection,
|
||||
hint: 'Ask the user which app Impeccable should use, then rerun live from that child app cwd. Use --target <path> only as a fallback or explicit path diagnostic.',
|
||||
}, null, 2));
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const ctx = loadContext(liveTarget.originalCwd, liveTarget.targetOptions);
|
||||
const activeCwd = ctx.projectRoot;
|
||||
const outputTargetPath = liveTarget.targetPath || null;
|
||||
|
||||
const missingContext = missingLiveContext(ctx);
|
||||
if (missingContext.length > 0) {
|
||||
console.log(JSON.stringify({
|
||||
ok: false,
|
||||
error: 'context_missing',
|
||||
missing: missingContext,
|
||||
nextCommand: missingContext.includes('PRODUCT.md') ? 'init' : 'document',
|
||||
targetPath: outputTargetPath,
|
||||
projectRoot: ctx.projectRoot,
|
||||
repoRoot: ctx.repoRoot,
|
||||
productPath: ctx.productPath,
|
||||
designPath: ctx.designPath,
|
||||
}, null, 2));
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// 1. Check config (fail fast if missing — no point starting anything else)
|
||||
const checkOut = runScript('live-inject.mjs', ['--check']);
|
||||
const checkOut = runScript('live-inject.mjs', ['--check'], { cwd: activeCwd });
|
||||
const checkResult = safeParse(checkOut);
|
||||
if (!checkResult || !checkResult.ok) {
|
||||
console.log(JSON.stringify(checkResult || { ok: false, error: 'check_failed', raw: checkOut }));
|
||||
console.log(JSON.stringify({
|
||||
...(checkResult || { ok: false, error: 'check_failed', raw: checkOut }),
|
||||
targetPath: outputTargetPath,
|
||||
projectRoot: ctx.projectRoot,
|
||||
repoRoot: ctx.repoRoot,
|
||||
}));
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// 2. Start server (or reuse existing)
|
||||
const serverInfo = ensureServerRunning();
|
||||
const serverInfo = ensureServerRunning(activeCwd);
|
||||
if (!serverInfo) {
|
||||
console.log(JSON.stringify({ ok: false, error: 'server_start_failed' }));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// 3. Inject the script tag at the current port
|
||||
const injectOut = runScript('live-inject.mjs', ['--port', String(serverInfo.port)]);
|
||||
const injectOut = runScript('live-inject.mjs', ['--port', String(serverInfo.port)], { cwd: activeCwd });
|
||||
const injectResult = safeParse(injectOut);
|
||||
if (!injectResult || !injectResult.ok) {
|
||||
console.log(JSON.stringify({
|
||||
@@ -80,22 +123,23 @@ The agent should then:
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// 4. Load PRODUCT.md + DESIGN.md context.
|
||||
const ctx = loadContext(process.cwd());
|
||||
|
||||
// 5. Compute drift-heal: compare resolved inject targets against the
|
||||
// 4. Compute drift-heal: compare resolved inject targets against the
|
||||
// project's HTML files. Orphans are HTML files not covered by config.
|
||||
// Warning only — the agent decides whether to act.
|
||||
const resolvedFiles = resolveFiles(process.cwd(), checkResult.config);
|
||||
const drift = scanForDrift(process.cwd(), resolvedFiles, checkResult.config);
|
||||
const resolvedFiles = resolveFiles(activeCwd, checkResult.config);
|
||||
const drift = scanForDrift(activeCwd, resolvedFiles, checkResult.config);
|
||||
|
||||
// 6. Emit everything the agent needs
|
||||
// 5. Emit everything the agent needs
|
||||
console.log(JSON.stringify({
|
||||
ok: true,
|
||||
serverPort: serverInfo.port,
|
||||
serverToken: serverInfo.token,
|
||||
pageFiles: resolvedFiles,
|
||||
liveConfigPath: checkResult.path,
|
||||
configDrift: drift,
|
||||
targetPath: outputTargetPath,
|
||||
projectRoot: ctx.projectRoot,
|
||||
repoRoot: ctx.repoRoot,
|
||||
hasProduct: ctx.hasProduct,
|
||||
product: ctx.product,
|
||||
productPath: ctx.productPath,
|
||||
@@ -105,6 +149,13 @@ The agent should then:
|
||||
}, null, 2));
|
||||
}
|
||||
|
||||
function missingLiveContext(ctx) {
|
||||
const missing = [];
|
||||
if (!ctx.hasProduct) missing.push('PRODUCT.md');
|
||||
if (!ctx.hasDesign) missing.push('DESIGN.md');
|
||||
return missing;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drift-heal scan. Walks the project for HTML files under common
|
||||
* page-source directories (public/, src/, app/, pages/) and reports any
|
||||
@@ -201,11 +252,11 @@ function globToRegex(pattern) {
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function runScript(name, args) {
|
||||
function runScript(name, args, options = {}) {
|
||||
const scriptPath = path.join(__dirname, name);
|
||||
const cmd = `node "${scriptPath}" ${args.map(a => `"${a}"`).join(' ')}`;
|
||||
try {
|
||||
return execSync(cmd, { encoding: 'utf-8', cwd: process.cwd(), timeout: 15_000 });
|
||||
return execSync(cmd, { encoding: 'utf-8', cwd: options.cwd || process.cwd(), timeout: 15_000 });
|
||||
} catch (err) {
|
||||
// execSync throws on non-zero exit; return stdout if any
|
||||
return err.stdout || err.message || '';
|
||||
@@ -219,10 +270,10 @@ function safeParse(out) {
|
||||
/**
|
||||
* Return { pid, port, token } for the running live server, starting one if needed.
|
||||
*/
|
||||
function ensureServerRunning() {
|
||||
function ensureServerRunning(cwd = process.cwd()) {
|
||||
// Try to reuse an existing server
|
||||
try {
|
||||
const existing = readLiveServerInfo(process.cwd())?.info;
|
||||
const existing = readLiveServerInfo(cwd)?.info;
|
||||
if (existing && existing.pid) {
|
||||
try {
|
||||
process.kill(existing.pid, 0); // throws if dead
|
||||
@@ -232,7 +283,7 @@ function ensureServerRunning() {
|
||||
} catch { /* no PID file */ }
|
||||
|
||||
// Start a new server
|
||||
const out = runScript('live-server.mjs', ['--background']);
|
||||
const out = runScript('live-server.mjs', ['--background'], { cwd });
|
||||
return safeParse(out);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
-2
@@ -1,5 +1,4 @@
|
||||
{
|
||||
"description": "Impeccable design detector: runs after Edit/Write/apply_patch on UI files and surfaces findings as system reminders.",
|
||||
"hooks": {
|
||||
"PostToolUse": [
|
||||
{
|
||||
@@ -7,7 +6,7 @@
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node \"$(git rev-parse --show-toplevel)/.agents/skills/impeccable/scripts/hook.mjs\"",
|
||||
"command": "node \".agents/skills/impeccable/scripts/hook.mjs\"",
|
||||
"timeout": 5,
|
||||
"statusMessage": "Checking UI changes"
|
||||
}
|
||||
|
||||
@@ -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.7.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 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
|
||||
@@ -1,12 +1,12 @@
|
||||
When asked for "bolder," AI defaults to the same tired tricks: cyan/purple gradients, glassmorphism, neon accents on dark backgrounds, gradient text on metrics. These are the opposite of bold. Reject them first, then increase visual impact and personality through stronger hierarchy, committed scale, and decisive type.
|
||||
When asked for "bolder," AI defaults to the same tired tricks: cyan/purple gradients, glassmorphism, neon accents on dark backgrounds, gradient text on metrics. These are the opposite of bold. Reject them first, then increase visual impact by making the existing design language more decisive, specific, and committed.
|
||||
|
||||
---
|
||||
|
||||
## Register
|
||||
|
||||
Brand: "bolder" means distinctive. Extreme scale, unexpected color, typographic risk, committed POV.
|
||||
Brand: "bolder" means distinctive. Express a stronger point of view through hierarchy, pacing, proportion, copy, evidence, and one committed visual idea.
|
||||
|
||||
Product: "bolder" rarely means theatrics; those undermine trust. It means stronger hierarchy, clearer weight contrast, one sharper accent, more committed density. The amplification is in clarity, not drama.
|
||||
Product: "bolder" rarely means theatrics; those undermine trust. It means stronger hierarchy, clearer weight contrast, sharper information density, and more decisive prioritization. The amplification is in clarity, not drama.
|
||||
|
||||
---
|
||||
|
||||
@@ -15,98 +15,105 @@ Product: "bolder" rarely means theatrics; those undermine trust. It means strong
|
||||
Analyze what makes the design feel too safe or boring:
|
||||
|
||||
1. **Identify weakness sources**:
|
||||
- **Generic choices**: System fonts, basic colors, standard layouts
|
||||
- **Timid scale**: Everything is medium-sized with no drama
|
||||
- **Low contrast**: Everything has similar visual weight
|
||||
- **Static**: No motion, no energy, no life
|
||||
- **Predictable**: Standard patterns with no surprises
|
||||
- **Flat hierarchy**: Nothing stands out or commands attention
|
||||
- **Generic choices**: The page could belong to any product in the category.
|
||||
- **Timid scale**: Everything is medium-sized with no clear lead.
|
||||
- **Low contrast**: Important and supporting elements have similar visual weight.
|
||||
- **Static**: The surface has no meaningful moment of emphasis.
|
||||
- **Predictable**: The composition follows a default pattern without a point of view.
|
||||
- **Flat hierarchy**: Nothing stands out or commands attention.
|
||||
|
||||
2. **Understand the context**:
|
||||
- What's the brand personality? (How far can we push?)
|
||||
- What's the purpose? (Marketing can be bolder than financial dashboards)
|
||||
- Who's the audience? (What will resonate?)
|
||||
- What are the constraints? (Brand guidelines, accessibility, performance)
|
||||
- What is the brand personality?
|
||||
- What is the purpose of this surface?
|
||||
- Who is the audience?
|
||||
- What design system, tokens, components, and visual conventions already exist?
|
||||
|
||||
If any of these are unclear from the codebase, ask the user directly to clarify what you cannot infer.
|
||||
|
||||
**CRITICAL**: "Bolder" doesn't mean chaotic or garish. It means distinctive, memorable, and confident. Think intentional drama, not random chaos.
|
||||
**CRITICAL**: "Bolder" does not mean chaotic or garish. It means distinctive, memorable, and confident. Think intentional drama, not random noise.
|
||||
|
||||
**WARNING - AI SLOP TRAP**: Review ALL the DON'T guidelines from the parent impeccable skill (already loaded in this context) before proceeding. Bold means distinctive, not "more effects."
|
||||
|
||||
## Design-System Lock
|
||||
|
||||
If the project has `DESIGN.md`, tokens, theme variables, or established component styles, treat that system as the boundary. Make the existing language stronger before adding new language.
|
||||
|
||||
Do not invent new colors, gradients, radii, shadows, fonts, decorative backgrounds, or effects just because the request says "bolder." A bolder pass should usually change emphasis, proportion, rhythm, density, contrast, copy, artifact specificity, and layout relationships while staying inside the documented system.
|
||||
|
||||
If the existing system is genuinely too limited to express the bolder direction, stop and ask the user before expanding it. Name the exact additions, the role each would play, and why the current system cannot do the job. If the user approves expansion, update the design system or tokens alongside the implementation.
|
||||
|
||||
## Plan Amplification
|
||||
|
||||
Create a strategy to increase impact while maintaining coherence:
|
||||
|
||||
- **Focal point**: What should be the hero moment? (Pick ONE, make it amazing)
|
||||
- **Personality direction**: Maximalist chaos? Elegant drama? Playful energy? Dark moody? Choose a lane.
|
||||
- **Risk budget**: How experimental can we be? Push boundaries within constraints.
|
||||
- **Hierarchy amplification**: Make big things BIGGER, small things smaller (increase contrast)
|
||||
- **Focal point**: Pick one thing the viewer should remember, then make the rest support it.
|
||||
- **System levers**: Identify which existing tokens, components, layout patterns, and copy structures can carry more weight.
|
||||
- **Risk budget**: Decide how far the surface can push while still feeling like the same product or brand.
|
||||
- **Hierarchy amplification**: Increase contrast between primary, secondary, and tertiary content instead of making every element louder.
|
||||
|
||||
**IMPORTANT**: Bold design must still be usable. Impact without function is just decoration.
|
||||
|
||||
## Amplify the Design
|
||||
|
||||
Systematically increase impact across these dimensions:
|
||||
Systematically increase impact through intention, not a menu of effects:
|
||||
|
||||
### Typography Amplification
|
||||
- **Replace generic fonts**: Swap system fonts for distinctive choices (see the parent skill's typography guidelines and the [Reference Material section of typeset.md](typeset.md#reference-material) for inspiration)
|
||||
- **Extreme scale**: Create dramatic size jumps (3x-5x differences, not 1.5x)
|
||||
- **Weight contrast**: Pair 900 weights with 200 weights, not 600 with 400
|
||||
- **Unexpected choices**: Variable fonts, display fonts for headlines, condensed/extended widths, monospace as intentional accent (not as lazy "dev tool" default)
|
||||
- Strengthen the existing type hierarchy before changing typefaces.
|
||||
- Make important text meaningfully more dominant, and make supporting text quieter.
|
||||
- Use weight, measure, spacing, and line breaks to sharpen the point of view.
|
||||
- Add or replace fonts only after user-approved design-system expansion.
|
||||
|
||||
### Color Intensification
|
||||
- **Increase saturation**: Shift to more vibrant, energetic colors (but not neon)
|
||||
- **Bold palette**: Introduce unexpected color combinations. Avoid the purple-blue gradient AI slop
|
||||
- **Dominant color strategy**: Let one bold color own 60% of the design
|
||||
- **Sharp accents**: High-contrast accent colors that pop
|
||||
- **Tinted neutrals**: Replace pure grays with tinted grays that harmonize with your palette
|
||||
- **Rich gradients**: Intentional multi-stop gradients (not generic purple-to-blue)
|
||||
### Color Amplification
|
||||
- Use the existing palette more decisively before adding colors.
|
||||
- Shift the proportion, placement, and contrast of documented colors to clarify meaning.
|
||||
- Treat any new color, gradient, or tint ramp as a design-system expansion that requires user approval.
|
||||
- Keep color tied to hierarchy, state, or brand meaning; do not use it as surface decoration.
|
||||
|
||||
### Spatial Drama
|
||||
- **Extreme scale jumps**: Make important elements 3-5x larger than surroundings
|
||||
- **Break the grid**: Let hero elements escape containers and cross boundaries
|
||||
- **Asymmetric layouts**: Replace centered, balanced layouts with tension-filled asymmetry
|
||||
- **Generous space**: Use white space dramatically (100-200px gaps, not 20-40px)
|
||||
- **Overlap**: Layer elements intentionally for depth
|
||||
### Spatial Amplification
|
||||
- Change proportion, density, alignment, and sequencing so the composition has a stronger point of view.
|
||||
- Create clearer contrast between dense evidence and open breathing room.
|
||||
- Let layout express priority and narrative order before adding ornament.
|
||||
- Preserve responsive behavior and avoid text overflow at every breakpoint.
|
||||
|
||||
### Visual Effects
|
||||
- **Dramatic shadows**: Large, soft shadows for elevation (but not generic drop shadows on rounded rectangles)
|
||||
- **Background treatments**: Mesh patterns, noise textures, geometric patterns, intentional gradients (not purple-to-blue)
|
||||
- **Texture & depth**: Grain, halftone, duotone, layered elements. NOT glassmorphism (it's overused AI slop)
|
||||
- **Borders & frames**: Thick borders, decorative frames, custom shapes (not rounded rectangles with colored border on one side)
|
||||
- **Custom elements**: Illustrative elements, custom icons, decorative details that reinforce brand
|
||||
### Surface Amplification
|
||||
- Use existing surface, border, radius, and shadow rules more deliberately.
|
||||
- Remove timid half-measures: either give an element a clear role or simplify it.
|
||||
- Add texture, depth, illustration, or decorative treatments only when already established by the system or explicitly approved.
|
||||
- Make real product artifacts, imagery, data, or copy carry attention before reaching for effects.
|
||||
|
||||
### Motion & Animation
|
||||
- **Hero moment**: One signature entrance, once. Not on every visit and not on every section.
|
||||
- **Micro-interactions**: Satisfying hover effects, click feedback, state changes.
|
||||
- **Transitions**: Smooth, noticeable transitions using ease-out-quart/quint/expo (not bounce or elastic, which cheapen the effect).
|
||||
- **Bolder ≠ scroll-fade-rise on every section.** That's the saturated AI default, the opposite of bold.
|
||||
- Design one meaningful moment of emphasis when motion genuinely supports the point.
|
||||
- Make interaction feedback feel more decisive without becoming distracting.
|
||||
- Keep transitions smooth and intentional.
|
||||
- **Bolder != scroll-fade-rise on every section.** That's the saturated AI default, the opposite of bold.
|
||||
|
||||
### Composition Boldness
|
||||
- **Hero moments**: Create clear focal points with dramatic treatment
|
||||
- **Diagonal flows**: Escape horizontal/vertical rigidity with diagonal arrangements
|
||||
- **Full-bleed elements**: Use full viewport width/height for impact
|
||||
- **Unexpected proportions**: Golden ratio? Throw it out. Try 70/30, 80/20 splits
|
||||
- Make the dominant idea unmistakable.
|
||||
- Use layout tension, sequencing, contrast, and restraint to create a stronger read.
|
||||
- Let the page's structure communicate priority before adding decorative layers.
|
||||
- If every element is louder, the composition is not bolder; it is flatter.
|
||||
|
||||
**NEVER**:
|
||||
- Add effects randomly without purpose (chaos ≠ bold)
|
||||
- Sacrifice readability for aesthetics (body text must be readable)
|
||||
- Make everything bold (then nothing is bold; you need contrast)
|
||||
- Ignore accessibility (bold design must still meet WCAG standards)
|
||||
- Overwhelm with motion (animation fatigue is real)
|
||||
- Copy trendy aesthetics blindly (bold means distinctive, not derivative)
|
||||
- Add undocumented design-system primitives without user approval
|
||||
- Add effects randomly without purpose
|
||||
- Hide weak hierarchy behind decoration
|
||||
- Sacrifice readability for aesthetics
|
||||
- Make everything bold; contrast is the point
|
||||
- Ignore accessibility
|
||||
- Overwhelm with motion
|
||||
- Copy trendy aesthetics blindly
|
||||
|
||||
## Verify Quality
|
||||
|
||||
Ensure amplification maintains usability and coherence:
|
||||
|
||||
- **System-faithful**: Did the pass make the existing design language stronger before adding anything new?
|
||||
- **No undocumented drift**: Are new colors, gradients, shadows, radii, fonts, and effects either absent or explicitly approved and documented?
|
||||
- **NOT AI slop**: Does this look like every other AI-generated "bold" design? If yes, start over.
|
||||
- **Still functional**: Can users accomplish tasks without distraction?
|
||||
- **Coherent**: Does everything feel intentional and unified?
|
||||
- **Memorable**: Will users remember this experience?
|
||||
- **Performant**: Do all these effects run smoothly?
|
||||
- **Accessible**: Does it still meet accessibility standards?
|
||||
- **Memorable**: Will users remember this experience for the intended reason?
|
||||
- **Performant and accessible**: Does the result stay fast, readable, responsive, and WCAG-conscious?
|
||||
|
||||
**The test**: If you showed this to someone and said "AI made this bolder," would they believe you immediately? If yes, you've failed. Bold means distinctive, not "more AI effects."
|
||||
|
||||
|
||||
@@ -5,8 +5,9 @@ Resolve one stable target, run two independent assessments, synthesize a design
|
||||
### Hard Invariants
|
||||
|
||||
- Assessment A (design review) and Assessment B (detector/browser evidence) are both required.
|
||||
- Assessment A and B MUST run as two isolated sub-agents whenever a sub-agent/Task tool is exposed. Running them inline in this context is "possible" but is NOT permitted; it is a degraded run. Inline is allowed ONLY when no sub-agent tool exists (or the user declined, on harnesses that ask).
|
||||
- If you degrade for any reason, the report's first line MUST be a banner: `⚠️ DEGRADED: single-context (<reason>)`. A silent degraded critique is a failed critique.
|
||||
- Assessment A must finish before detector findings enter the parent synthesis context. Detector output is deterministic, but it still anchors judgment.
|
||||
- If sub-agents are unavailable, fall back sequentially: finish and record Assessment A first, then run Assessment B, then synthesize.
|
||||
- A skipped detector is a failed critique run unless `detect.mjs` is missing or crashes after a real attempt.
|
||||
- Viewable targets require browser inspection when available.
|
||||
- Any local server started only for critique visualization must run in the background, have a recorded stop method, and be stopped before final reporting unless the user asks to keep it.
|
||||
@@ -27,7 +28,13 @@ Resolve one stable target, run two independent assessments, synthesize a design
|
||||
|
||||
### Assessment Orchestration
|
||||
|
||||
Delegate Assessment A and Assessment B to separate sub-agents when possible. They must not see each other's output. Do not show findings to the user until synthesis.
|
||||
Delegate Assessment A and Assessment B to separate sub-agents. They must not see each other's output. Do not show findings to the user until synthesis.
|
||||
|
||||
Sub-agent gate (all harnesses):
|
||||
- Unless a harness-specific gate below overrides this, spawn A and B as two isolated, parallel sub-agents whenever a sub-agent/Task tool is exposed. This is the default and is mandatory; do not run them inline because it is faster.
|
||||
- "Unavailable" means exactly one thing: no sub-agent/Task tool is exposed in this session (or, on harnesses that ask, the user declined). It does not mean inconvenient.
|
||||
- If and only if sub-agents are unavailable, fall back sequentially: finish and record Assessment A, then run Assessment B, then synthesize, and emit the degraded banner.
|
||||
- Whichever path you take, declare it in the report header (see Report header provenance). Skipping sub-agents without the banner is the most common failure of this command.
|
||||
|
||||
If browser automation is available, each assessment creates its own new tab. Never reuse an existing tab, even if it is already at the right URL.
|
||||
|
||||
@@ -61,7 +68,7 @@ node .cursor/skills/impeccable/scripts/detect.mjs --json [target]
|
||||
|
||||
Browser visualization is required for a viewable target when browser automation is available. Use a localhost dev/static URL for local files; avoid `file://` unless the available browser explicitly supports this workflow. Overlay flow:
|
||||
|
||||
1. Create a fresh tab and navigate.
|
||||
1. Create a fresh tab and navigate. Prefer the harness's native/browser-canvas screenshot path before hand-rolling a Playwright/Puppeteer script; only fall back to a custom script when no native browser tool is exposed.
|
||||
2. Preflight mutable injection by setting `document.title` and appending a `<script>` tag. Read-only evaluate APIs do not count.
|
||||
3. If mutation is unavailable, skip live server, browser presentation, and injection; report fallback signal.
|
||||
4. If mutation is available, start `node .cursor/skills/impeccable/scripts/live-server.mjs --background`, present the browser if supported, label `[Human]`, scroll top, inject `http://localhost:PORT/detect.js`, wait 2-3 seconds, read `impeccable` console messages, then stop the live server.
|
||||
@@ -79,6 +86,12 @@ The chat response is the primary user-facing deliverable. Present the full struc
|
||||
|
||||
Structure your feedback as a design director would:
|
||||
|
||||
#### Report header provenance
|
||||
|
||||
The report's first line MUST declare how the assessments were run, so a degraded run is never silent:
|
||||
- Dual-agent: `Method: dual-agent (A: <agent-id> · B: <agent-id>)`
|
||||
- Degraded: `⚠️ DEGRADED: single-context (<reason, e.g. no sub-agent tool exposed>)`
|
||||
|
||||
#### Design Health Score
|
||||
> *Consult the [Heuristics Scoring Guide](#heuristics-scoring-guide) section below.*
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
Generate a `DESIGN.md` file at the project root that captures the current visual design system, so AI agents generating new screens stay on-brand.
|
||||
|
||||
DESIGN.md follows the [official Google Stitch DESIGN.md format](https://stitch.withgoogle.com/docs/design-md/format/): YAML frontmatter carrying machine-readable design tokens, followed by a markdown body with exactly six sections in a fixed order. **Tokens are normative; prose provides context for how to apply them.** Sections may be omitted when not relevant, but **do not reorder them and do not rename them**. Section headers must match the spec character-for-character so the file stays parseable by other DESIGN.md-aware tools (Stitch itself, awesome-design-md, skill-rest, etc.).
|
||||
DESIGN.md follows the [official DESIGN.md format spec](https://raw.githubusercontent.com/google-labs-code/design.md/main/docs/spec.md): YAML frontmatter carrying machine-readable design tokens, followed by a markdown body with exactly six sections in a fixed order. **Tokens are normative; prose provides context for how to apply them.** Sections may be omitted when not relevant, but **do not reorder them and do not rename them**. Section headers must match the spec character-for-character so the file stays parseable by other DESIGN.md-aware tools (Stitch itself, awesome-design-md, skill-rest, etc.).
|
||||
|
||||
## The frontmatter: token schema
|
||||
|
||||
|
||||
@@ -2,13 +2,15 @@
|
||||
|
||||
Manage the **design detector hook** for the current project.
|
||||
|
||||
The hook runs the impeccable design detector on direct file edits to design-relevant files (`.tsx`, `.jsx`, `.html`, `.vue`, `.svelte`, `.astro`, `.css`, `.scss`, `.sass`, `.less`, `.ts`, `.js`). Claude Code and Codex use `PostToolUse` and push a short system reminder into the agent's context after the edit; findings get a correction prompt, pending issues get a re-nudge, and clean UI-ish files get a short ack unless quiet mode is on (`hook.quiet` in config). Plain `.ts` and `.js` files are still scanned, but stay quiet unless the detector finds something. Cursor uses `preToolUse` to block bad proposed writes before they land and stays silent when it allows a clean write.
|
||||
The hook runs the impeccable design detector on direct file edits to design-relevant files (`.tsx`, `.jsx`, `.html`, `.vue`, `.svelte`, `.astro`, `.css`, `.scss`, `.sass`, `.less`, `.ts`, `.js`). Claude Code, Codex, and GitHub Copilot use a post-tool-use hook and push a short system reminder into the agent's context after the edit; findings get a correction prompt, pending issues get a re-nudge, and clean UI-ish files get a short ack unless quiet mode is on (`hook.quiet` in config). Plain `.ts` and `.js` files are still scanned, but stay quiet unless the detector finds something. Cursor uses `preToolUse` to block bad proposed writes before they land and stays silent when it allows a clean write.
|
||||
|
||||
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), and Cursor (`.cursor/hooks.json` in the project).
|
||||
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.
|
||||
|
||||
@@ -51,7 +53,7 @@ Prefer the narrowest exception:
|
||||
- For value-specific findings such as `overused-font` and `bounce-easing`, use `ignore-value` when the user confirms the specific value. Do not use `ignore-rule overused-font` for a specific font.
|
||||
- If the finding has no value-specific command, such as `side-tab`, prefer `ignore-file <path>` for the current file.
|
||||
- Use `ignore-rule <id>` only when the user asks to suppress that whole rule across the project. For broad overused-font suppression, use `ignore-rule overused-font --all-values` only when the user asks to ignore overused fonts generally.
|
||||
- Do not add source comments such as `impeccable: ignore`; inline comments pollute code and are not a supported suppression mechanism.
|
||||
- Prefer config ignores (the commands above) by default; they keep suppressions in one reviewable place. Reach for an inline comment only when the waiver must travel with a single file that leaves the repo (a generated/exported standalone document, an emailed HTML file). The supported marker is `impeccable-disable <rule>` (whole file) or `impeccable-disable-line` / `impeccable-disable-next-line` (one line), in any comment syntax, with an optional reason after `:` or `--`. The detector honors it by default; `--no-inline-ignores` or `--no-config` bypasses it.
|
||||
|
||||
Example value-specific exception:
|
||||
|
||||
@@ -79,10 +81,10 @@ 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 and Codex 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`, and `.cursor/hooks.json`. On Codex, the user must approve the hook via `/hooks` the first time. On Cursor, confirm hooks are enabled under Settings -> Hooks.
|
||||
- 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.
|
||||
|
||||
## Failure modes
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user