mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-11 21:57:14 +03:00
Compare commits
77
Commits
cli-v2.3.2
...
cli-v3.0.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6443980117 | ||
|
|
636249cae0 | ||
|
|
32c01595e2 | ||
|
|
9b0b63c04f | ||
|
|
50f68ffffc | ||
|
|
8cf2be110d | ||
|
|
9c0012d4e1 | ||
|
|
0ec64aad1b | ||
|
|
672517f76e | ||
|
|
92d6141cdf | ||
|
|
5b5e487a4f | ||
|
|
1863a44b23 | ||
|
|
324cec73a4 | ||
|
|
5fbe37c97c | ||
|
|
d37be057ea | ||
|
|
05f9797957 | ||
|
|
983b75cabc | ||
|
|
8735be3712 | ||
|
|
e3e22007a9 | ||
|
|
c169b84f20 | ||
|
|
f24f9fca8b | ||
|
|
f636bd065a | ||
|
|
f81f63a485 | ||
|
|
c2ee19540b | ||
|
|
018a16eb13 | ||
|
|
b41836ce0e | ||
|
|
b498b79afb | ||
|
|
325aeaf239 | ||
|
|
972f5b18be | ||
|
|
55a54c0fbd | ||
|
|
3fbf64698c | ||
|
|
b4e4fe1079 | ||
|
|
9381269a82 | ||
|
|
3d1be6238c | ||
|
|
6f71b1d938 | ||
|
|
642adb5e84 | ||
|
|
c719501cbd | ||
|
|
13b142b677 | ||
|
|
d0c934c03b | ||
|
|
b210dd71e7 | ||
|
|
afb8029a50 | ||
|
|
cbd6d80c26 | ||
|
|
82801a4894 | ||
|
|
1aedbcf538 | ||
|
|
fdcc1ba548 | ||
|
|
26da817aea | ||
|
|
0df1e23a0e | ||
|
|
81c5042561 | ||
|
|
924d4b97f0 | ||
|
|
270b64897a | ||
|
|
75fc95947e | ||
|
|
397d3cb4b7 | ||
|
|
08d50f215b | ||
|
|
17eedd67bb | ||
|
|
4e251061b8 | ||
|
|
6788085015 | ||
|
|
5fb30e03e6 | ||
|
|
1f975a69e4 | ||
|
|
0939528b95 | ||
|
|
347a0c06a2 | ||
|
|
6c7c04866c | ||
|
|
198aa91719 | ||
|
|
1d5d745823 | ||
|
|
58e9fceede | ||
|
|
05942485d6 | ||
|
|
5083000125 | ||
|
|
4fda4a0ece | ||
|
|
be83085dbd | ||
|
|
2a605f63ca | ||
|
|
23dcaa79e0 | ||
|
|
8e25f9955f | ||
|
|
6163ca0529 | ||
|
|
69b5f3af49 | ||
|
|
3f8d002b76 | ||
|
|
d3f0275356 | ||
|
|
ab3a13245c | ||
|
|
ea3e66b984 |
@@ -29,16 +29,12 @@ Produce ready-to-ship, production-grade code, not prototypes or starting points.
|
||||
#### Typography
|
||||
|
||||
- Cap body line length at 65–75ch.
|
||||
- Hierarchy through scale + weight contrast (≥1.25 ratio between steps). Avoid flat scales.
|
||||
- Cap font-family count at 3 (display + body + optional mono). More than 3 reads as indecision, not richness. One well-tuned family with weight contrast usually beats three competing typefaces.
|
||||
- Don't pair fonts that are similar but not identical (two geometric sans-serifs, two humanist sans-serifs). Pair on a contrast axis (serif + sans, geometric + humanist) or use one family in multiple weights.
|
||||
- No all-caps body copy. Reserve uppercase for short labels (≤4 words), section eyebrows (used sparingly per the Absolute bans), and badges. Sentences in ALL CAPS are unreadable at body sizes.
|
||||
- Hero / display heading ceiling: clamp() max ≤ 6rem (~96px). Above that the page is shouting, not designing.
|
||||
- Display heading letter-spacing floor: ≥ -0.04em. Anything tighter and letters touch; cramped, not "designed".
|
||||
- Use `text-wrap: balance` on h1–h3 for even line lengths; `text-wrap: pretty` on long prose to reduce orphans.
|
||||
|
||||
Two hard typographic ceilings you currently miss:
|
||||
- Hero clamp() max ≤ 6rem. 8–11rem (128–176px) reads as comically loud, not bold.
|
||||
One hard typographic ceiling you currently miss:
|
||||
- Display letter-spacing ≥ -0.04em. Your default of -0.05 to -0.085em on display H1s makes the letters touch and reads as cramped. -0.02 to -0.03em is plenty for tight grotesque display; -0.04em is the floor.
|
||||
|
||||
#### Layout
|
||||
@@ -63,15 +59,6 @@ Two hard typographic ceilings you currently miss:
|
||||
|
||||
- Dropdowns rendered with `position: absolute` inside an `overflow: hidden` or `overflow: auto` container will be clipped. Use the native `<dialog>` / popover API, `position: fixed`, or a portal to escape the stacking context.
|
||||
|
||||
### Copy
|
||||
|
||||
- Every word earns its place. No restated headings, no intros that repeat the title.
|
||||
- **No em dashes.** Use commas, colons, semicolons, periods, or parentheses. Also not `--`.
|
||||
- **No aphoristic-cadence body copy as a default voice.** Don't fall into the rhythm of "serious statement, then punchy short negation" as the page's recurring voice. If three or more section copy blocks on the page land on a short rebuttal-shaped sentence, rewrite. Specific, not aphoristic.
|
||||
- **No marketing buzzwords.** The streamline / empower / supercharge / leverage / unleash / transform / seamless / world-class / enterprise-grade / next-generation / cutting-edge / game-changer / mission-critical family of phrases. Pick a specific noun and a verb that describes what the product literally does.
|
||||
- Button labels: verb + object. "Save changes" beats "OK"; "Delete project" beats "Yes". The label should say what will happen.
|
||||
- Link text needs standalone meaning. "View pricing plans" beats "Click here"; screen readers announce links out of context.
|
||||
|
||||
### New projects only (when no prior work exists)
|
||||
|
||||
#### Color & Theme
|
||||
@@ -105,7 +92,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.
|
||||
- **"X theater" / "actually X" / "not just X, it's Y" copy.** "Productivity theater", "engagement theater", "growth theater": instant AI slop. Choose a specific noun, not a meta-criticism phrase.
|
||||
- **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
|
||||
|
||||
@@ -144,7 +131,7 @@ If someone could look at this interface and say "AI made that" without doubt, it
|
||||
| `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) |
|
||||
|
||||
Plus two management commands: `pin <command>` and `unpin <command>`, detailed below.
|
||||
Plus three management commands: `pin <command>`, `unpin <command>`, and `hooks <on|off|status|...>`, detailed below.
|
||||
|
||||
### Routing rules
|
||||
|
||||
@@ -161,7 +148,7 @@ Plus two management commands: `pin <command>` and `unpin <command>`, detailed be
|
||||
**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.
|
||||
|
||||
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**: load its reference file and follow its instructions. Everything after the command name is the target.
|
||||
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.
|
||||
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.
|
||||
|
||||
@@ -179,4 +166,8 @@ If the first word is `craft`, setup still runs first, but [reference/craft.md](r
|
||||
node .agents/skills/impeccable/scripts/pin.mjs <pin|unpin> <command>
|
||||
```
|
||||
|
||||
Valid `<command>` is any command from the table above. Report the script's result concisely. Confirm the new shortcut on success, relay stderr verbatim on error.
|
||||
Valid `<command>` is any command from the table above. Report the script's result concisely. Confirm the new shortcut on success, relay stderr verbatim on error.
|
||||
|
||||
## Hooks
|
||||
|
||||
`$impeccable hooks <on|off|status|ignore-rule|ignore-file|ignore-value|reset>` manages the design detector hook for this project. The hook auto-runs the detector after direct UI file edits and surfaces findings as system reminders. Full flow is in [reference/hooks.md](reference/hooks.md); load it when the user invokes `$impeccable hooks` with any argument.
|
||||
@@ -60,7 +60,7 @@ Brand surfaces have permission for Committed, Full palette, and Drenched strateg
|
||||
- Name a real reference before picking a strategy. "Klim Type Foundry #ff4500 orange drench", "Stripe purple-on-white restraint", "Liquid Death acid-green full palette", "Mailchimp yellow full palette", "Condé Nast Traveler muted navy restraint", "Vercel pure black monochrome". Unnamed ambition becomes beige.
|
||||
- Palette IS voice. A calm brand and a restless brand should not share palette mechanics.
|
||||
- When the strategy is Committed or Drenched, color carries the brand. Don't hedge with neutrals around the edges. Commit.
|
||||
- Don't converge across projects. If the last brand surface was restrained-on-cream, this one is not.
|
||||
- Don't converge across projects. Each brand surface differentiates from the last.
|
||||
- When a cultural-symbol palette is the obvious pull, reach past it. Let the cultural reading come from typography, imagery, and copy, not the palette.
|
||||
|
||||
## Layout
|
||||
@@ -74,7 +74,7 @@ Brand surfaces have permission for Committed, Full palette, and Drenched strateg
|
||||
|
||||
Brand surfaces lean on imagery. A restaurant, hotel, magazine, or product landing page without any imagery reads as incomplete, not as restrained. A solid-color rectangle where a hero image should go is worse than a representative stock photo.
|
||||
|
||||
**When the brief implies imagery (restaurants, hotels, magazines, photography, hobbyist communities, food, travel, fashion, product), you must ship imagery.** Zero images is a bug, not a design choice. "Restraint" is not an excuse. If the approved comp or brief is image-led, ship real project assets, generated raster assets, or a credible canvas/SVG/WebGL scene. Do not replace photographic, architectural, product, or place imagery with generic CSS panels, decorative diagrams, cards, bullets, or copy.
|
||||
**When the brief implies imagery, you must ship imagery.** Zero images is a bug, not a design choice. "Restraint" is not an excuse. If the approved comp or brief is image-led, ship real project assets, generated raster assets, or a credible canvas/SVG/WebGL scene. Do not replace photographic, architectural, product, or place imagery with generic CSS panels, decorative diagrams, cards, bullets, or copy.
|
||||
|
||||
- **For greenfield work without local assets, use stock imagery.** Unsplash is the default. The URL shape is `https://images.unsplash.com/photo-{id}?auto=format&fit=crop&w=1600&q=80`. **Verify the URLs before referencing them.** If you have an image-search MCP, web-fetch tool, or browser access, use it to find real photo IDs and confirm they resolve. Guessed IDs (even ones that look real) often 404 and ship as broken-image placeholders. Without a verification path, pick fewer photos you're confident exist over more that you guessed; never substitute colored `<div>` placeholders.
|
||||
- **Search for the brand's physical object**, not the generic category: "handmade pasta on a scratched wooden table" beats "Italian food"; "cypress trees above a limestone hotel facade at dusk" beats "luxury hotel".
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
# $impeccable hooks
|
||||
|
||||
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.
|
||||
|
||||
This command toggles the hook **per project** by editing `.impeccable/config.json` (the unified Impeccable config; hook settings live under its `hook` key). 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.
|
||||
|
||||
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).
|
||||
|
||||
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.
|
||||
|
||||
## Routing
|
||||
|
||||
The first argument is the action. Defaults to `status`.
|
||||
|
||||
| Action | What it does |
|
||||
|---|---|
|
||||
| `status` | Print current state, shared/local config paths, ignored rules / files / values, env override. |
|
||||
| `on` | Set `enabled: true` in `.impeccable/config.json`, record local hook consent as accepted, and install/repair provider hook manifests when the skill is installed. |
|
||||
| `off` | Set `enabled: false` in `.impeccable/config.json`. |
|
||||
| `ignore-rule <id>` | Append `<id>` to `ignoreRules`; for `overused-font`, requires `--all-values`. |
|
||||
| `ignore-file <glob>` | Append `<glob>` to `ignoreFiles`. |
|
||||
| `ignore-value <id> <value> [--shared] [--reason "..."]` | Append a rule/value suppression to shared `.impeccable/config.json`. |
|
||||
| `ignore-value <id> <value> --local [--reason "..."]` | Append a private rule/value suppression to `.impeccable/config.local.json`. |
|
||||
| `reset` | Delete the project config, dedup cache, and Cursor pending queue. |
|
||||
|
||||
## Flow
|
||||
|
||||
1. Resolve the action from the user's argument. If no action was given, default to `status`.
|
||||
2. Invoke the admin script and pass the user's output through verbatim:
|
||||
|
||||
```bash
|
||||
node .agents/skills/impeccable/scripts/hook-admin.mjs <action> [args...]
|
||||
```
|
||||
|
||||
3. If `<action>` is `off`, follow up with a one-line note: "Done. New edits will not trigger the design hook in this project until you run `$impeccable hooks on`."
|
||||
4. If `<action>` is `on`, follow up with: "Done. The design hook will fire after the next Edit/Write/MultiEdit on a UI file."
|
||||
5. If `<action>` is `ignore-value`, `ignore-file`, or `ignore-rule`, just print the script output. The default scope is shared `.impeccable/config.json`; add `--local` only when the user explicitly asks for a private exception.
|
||||
6. If `<action>` is `status`, just print the script output. Do not add commentary unless the user asked a follow-up question.
|
||||
|
||||
## Intentional findings
|
||||
|
||||
The hook itself never writes ignore config. Persist an exception only after the user explicitly confirms the flagged issue is intentional, and always go through `hook-admin.mjs`.
|
||||
|
||||
Prefer the narrowest exception:
|
||||
|
||||
- If the finding line shows an exact `ignore-value` command, run that command. This writes shared `.impeccable/config.json` by default.
|
||||
- For `overused-font`, use `ignore-value` when the user confirms a specific font. 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.
|
||||
|
||||
Example value-specific exception:
|
||||
|
||||
```bash
|
||||
node .agents/skills/impeccable/scripts/hook-admin.mjs ignore-value overused-font Inter --shared --reason "User confirmed Inter is intentional"
|
||||
```
|
||||
|
||||
Example whole-rule font exception:
|
||||
|
||||
```bash
|
||||
node .agents/skills/impeccable/scripts/hook-admin.mjs ignore-rule overused-font --all-values --reason "User asked to ignore overused fonts generally"
|
||||
```
|
||||
|
||||
Example file-scoped exception:
|
||||
|
||||
```bash
|
||||
node .agents/skills/impeccable/scripts/hook-admin.mjs ignore-file "src/legacy/Card.tsx"
|
||||
```
|
||||
|
||||
## 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.
|
||||
- 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.
|
||||
|
||||
## Failure modes
|
||||
|
||||
- If `.impeccable/config.json` or `.impeccable/config.local.json` is unreadable or malformed, the hook ignores that file and uses the remaining valid config/defaults. `hook-admin.mjs status` will show malformed files as ignored.
|
||||
- If the user asks to "disable the hook" globally, lead with `$impeccable hooks off` (persistent for this project; writes `hook.enabled: false` to config). The legacy `IMPECCABLE_HOOK_DISABLED=1` env var also works as a one-shot override that follows the shell.
|
||||
@@ -113,7 +113,9 @@ node .agents/skills/impeccable/scripts/live-insert.mjs --id EVENT_ID --count EVE
|
||||
|
||||
The scaffold has **no** `data-impeccable-variant="original"`. Variants are net-new HTML+CSS inserted at `insertLine`. Load `brand.md` or `product.md` (freeform only, no action sub-command). Write all variants in one edit, then `--reply done`.
|
||||
|
||||
On accept/discard, `live-accept.mjs` removes the wrapper block; the anchor element is untouched.
|
||||
For Svelte/SvelteKit targets, `live-insert.mjs` returns `previewMode: "svelte-component"` with `mode: "insert"`, `file` pointing at a temporary `node_modules/.impeccable-live/<id>/manifest.json`, `componentDir` pointing at the variant component files, and `sourceFile` pointing at the real `.svelte` route. Write each inserted variant as a real Svelte component (`v1.svelte`, `v2.svelte`, …) under `componentDir`. Insert variants must be non-empty net-new content with a single top-level root, no `data-impeccable-*` attributes, and CSS in each component's `<style>` block. Do **not** edit the route source during generation; the browser mounts the temporary component before/after the live anchor while the user cycles variants. On Accept, `live-accept.mjs` inserts the selected component markup into `sourceFile` immediately and deletes the temp session after the source write succeeds.
|
||||
|
||||
For non-Svelte targets, on accept/discard, `live-accept.mjs` removes the wrapper block; the anchor element is untouched.
|
||||
|
||||
### Replace mode (default)
|
||||
|
||||
@@ -151,6 +153,25 @@ If `--text` matches multiple candidates equally well, wrap exits with `{ error:
|
||||
|
||||
Output on success: `{ file, insertLine, commentSyntax, styleMode, styleTag, cssSelectorPrefixExamples, cssAuthoring }`.
|
||||
|
||||
For Svelte/SvelteKit targets, `live-wrap.mjs` returns `previewMode: "svelte-component"` with `file` pointing at a temporary `node_modules/.impeccable-live/<id>/manifest.json`, `componentDir` pointing at the variant component files, and `sourceFile` pointing at the real `.svelte` route. Write each variant as a real Svelte component (`v1.svelte`, `v2.svelte`, …) under `componentDir`; use the `propContract` prop names for dynamic text (`{propName}`), not literal snapshot strings. Put variant CSS in each component's `<style>` block with semantic class selectors (no `@scope`, no `data-impeccable-*`). Reply with `--file` set to the manifest path; the browser dynamically imports and mounts the compiled components so Svelte HMR does not reset page state while the user cycles variants. On Accept, `live-accept.mjs` inlines the accepted component back into `sourceFile` immediately after source promotion succeeds.
|
||||
|
||||
**Params on the Svelte component path go in a sidecar, never as an attribute.** Svelte parses `{` inside an attribute value as the start of an expression, so a `data-impeccable-params='[{…}]'` attribute on a component element fails to compile (`Expected token }`). Declare params for this path in `componentDir/params.json`, keyed by variant number, using the exact param schema from section 7:
|
||||
|
||||
```json
|
||||
{
|
||||
"1": [
|
||||
{"id":"density","kind":"steps","default":"snug","label":"Density","options":[
|
||||
{"value":"airy","label":"Airy"},{"value":"snug","label":"Snug"},{"value":"packed","label":"Packed"}
|
||||
]}
|
||||
],
|
||||
"2": [
|
||||
{"id":"accent","kind":"range","min":0,"max":1,"step":0.05,"default":0.5,"label":"Accent"}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Author the component `<style>` against `var(--p-<id>, default)` for `range`/`toggle` and `[data-p-<id>="…"]` for `steps`; wrap those selectors in `:global(...)` so the knob values the runtime sets on the mounted root reach your rules. The browser reads `params.json`, docks the panel, and drives `--p-*` / `data-p-*` on the mounted component exactly as it does for the HTML/JSX path.
|
||||
|
||||
`styleMode` controls how preview CSS must be authored. Treat it as a detected capability mode, not a framework guess:
|
||||
|
||||
- `scoped`: use `@scope ([data-impeccable-variant="N"])` rules.
|
||||
@@ -342,7 +363,7 @@ Each variant can expose **coarse** knobs alongside the full HTML/CSS replacement
|
||||
|
||||
**Hard cap per variant**: at most **four** parameters so the panel stays legible; rare fifth only if the reference explicitly allows it.
|
||||
|
||||
**How to declare.** Put a JSON manifest on the variant wrapper:
|
||||
**How to declare.** Put a JSON manifest on the variant wrapper (HTML/JSX path). **On the Svelte `svelte-component` path, do not use this attribute** (Svelte can't compile `{` inside an attribute value). Declare params in `componentDir/params.json` keyed by variant number instead (see the Svelte component paragraph in the wrap section). The param schema below is identical for both paths.
|
||||
|
||||
```html
|
||||
<div data-impeccable-variant="1" data-impeccable-params='[
|
||||
@@ -456,7 +477,7 @@ Do these five steps in the current thread, synchronously, before the next poll.
|
||||
1. **Locate the carbonize block** in the source file (`_acceptResult.file`). It's bracketed by `<!-- impeccable-carbonize-start SESSION_ID -->` and `<!-- impeccable-carbonize-end SESSION_ID -->` and contains a `<style data-impeccable-css="SESSION_ID">` element. If the variant declared parameters, an `<!-- impeccable-param-values SESSION_ID: {...} -->` comment sits alongside the style tag with the user's chosen values; read it first; it drives steps 3 and 4 below.
|
||||
2. **Move the CSS rules** into the project's real stylesheet. Which stylesheet depends on the project (e.g. `site/styles/workflow.css` for an Astro project, or the component's co-located CSS file for a Vite/Next project; pick whichever already owns styling for the surrounding element).
|
||||
3. **Bake in parameter values while rewriting selectors.** For `@scope ([data-impeccable-variant="N"])` wrappers: retarget to real, semantic classes on the accepted HTML (`.why-visual--v2 .v2-label { … }`). For `:scope[data-p-<id>="VALUE"]` selectors: keep only the branch matching the chosen value from the param-values comment; drop the others (they're dead after accept). For `var(--p-<id>, DEFAULT)` in the CSS: either substitute the literal value, or if the param is still useful as a knob going forward, leave the var and update its initial declaration to the chosen value.
|
||||
4. **Unwrap the accepted content.** Delete the `<div data-impeccable-variant="N" style="display: contents">` that wraps it. Drop `data-impeccable-params` and any `data-p-*` attributes from it; those are live-mode plumbing, not source.
|
||||
4. **Unwrap the accepted content.** Delete the inner `<div data-impeccable-variant="N" style="display: contents">` that wraps it. On JSX/TSX, also delete the outer `<div data-impeccable-carbonize="SESSION_ID" style={{ display: 'contents' }}>` wrapper if present (accept adds it so ternary/`return` slots keep a single root). Drop `data-impeccable-params` and any `data-p-*` attributes; those are live-mode plumbing, not source.
|
||||
5. **Delete the inline `<style>` block, the `<!-- impeccable-param-values -->` comment if present, and both `<!-- impeccable-carbonize-start/end -->` markers.** Also drop any `@scope` rules for variants other than the accepted one; those are dead code now.
|
||||
|
||||
After the file is clean, run `live-complete.mjs --id SESSION_ID`, verify it reports `phase: "completed"`, then poll again.
|
||||
|
||||
@@ -1,284 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Cleans up deprecated Impeccable skill files, symlinks, and
|
||||
* skills-lock.json entries left over from previous versions.
|
||||
*
|
||||
* Safe to run repeatedly -- it is a no-op when nothing needs cleaning.
|
||||
*
|
||||
* Usage (from the project root):
|
||||
* node {{scripts_path}}/cleanup-deprecated.mjs
|
||||
*
|
||||
* What it does:
|
||||
* 1. Finds every harness-specific skills directory (.claude/skills,
|
||||
* .cursor/skills, .agents/skills, etc.).
|
||||
* 2. For each deprecated skill name (with and without i- prefix),
|
||||
* checks if the directory exists and its SKILL.md mentions
|
||||
* "impeccable" (to avoid deleting unrelated user skills).
|
||||
* 3. Deletes confirmed matches (files, directories, or symlinks).
|
||||
* 4. Removes the corresponding entries from skills-lock.json.
|
||||
*/
|
||||
|
||||
import { existsSync, readFileSync, writeFileSync, rmSync, readdirSync, statSync, lstatSync, unlinkSync } from 'node:fs';
|
||||
import { join, resolve } from 'node:path';
|
||||
|
||||
// Skills that were renamed, merged, or folded in v2.0, v2.1, and v3.0.
|
||||
const DEPRECATED_NAMES = [
|
||||
// v2.0 renames
|
||||
'frontend-design', // renamed to impeccable
|
||||
'teach-impeccable', // folded into /impeccable init
|
||||
// v2.1 merges
|
||||
'arrange', // renamed to layout
|
||||
'normalize', // merged into polish
|
||||
'onboard', // merged into harden
|
||||
'extract', // merged into /impeccable extract
|
||||
// v3.0 consolidation: all standalone skills -> /impeccable sub-commands
|
||||
'adapt',
|
||||
'animate',
|
||||
'audit',
|
||||
'bolder',
|
||||
'clarify',
|
||||
'colorize',
|
||||
'critique',
|
||||
'delight',
|
||||
'distill',
|
||||
'harden',
|
||||
'layout',
|
||||
'optimize',
|
||||
'overdrive',
|
||||
'polish',
|
||||
'quieter',
|
||||
'shape',
|
||||
'typeset',
|
||||
];
|
||||
|
||||
// All known harness directories that may contain a skills/ subfolder.
|
||||
const HARNESS_DIRS = [
|
||||
'.claude', '.cursor', '.gemini', '.codex', '.agents',
|
||||
'.trae', '.trae-cn', '.pi', '.opencode', '.kiro', '.rovodev',
|
||||
];
|
||||
|
||||
// Per-skill fingerprints for SKILL.md bodies that never mentioned
|
||||
// "impeccable" in their v2.x source. Used as a last-resort match
|
||||
// when no skills-lock.json exists and the word heuristic fails.
|
||||
// The strings are lifted verbatim from the v2.x frontmatter
|
||||
// descriptions, so collisions with hand-written user skills are
|
||||
// vanishingly unlikely.
|
||||
const SKILL_FINGERPRINTS = {
|
||||
harden: 'Make interfaces production-ready: error handling, empty states',
|
||||
optimize: 'Diagnoses and fixes UI performance across loading speed',
|
||||
};
|
||||
|
||||
/**
|
||||
* Walk up from startDir until we find a directory that looks like a
|
||||
* project root (has package.json, .git, or skills-lock.json).
|
||||
*/
|
||||
export function findProjectRoot(startDir = process.cwd()) {
|
||||
let dir = resolve(startDir);
|
||||
const { root } = { root: '/' };
|
||||
while (dir !== root) {
|
||||
if (
|
||||
existsSync(join(dir, 'package.json')) ||
|
||||
existsSync(join(dir, '.git')) ||
|
||||
existsSync(join(dir, 'skills-lock.json'))
|
||||
) {
|
||||
return dir;
|
||||
}
|
||||
const parent = resolve(dir, '..');
|
||||
if (parent === dir) break;
|
||||
dir = parent;
|
||||
}
|
||||
return resolve(startDir);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load skills-lock.json from the project root, or null if missing/unreadable.
|
||||
*/
|
||||
export function loadLock(projectRoot) {
|
||||
const lockPath = join(projectRoot, 'skills-lock.json');
|
||||
if (!existsSync(lockPath)) return null;
|
||||
try {
|
||||
return JSON.parse(readFileSync(lockPath, 'utf-8'));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether a skill directory belongs to Impeccable. Three layered
|
||||
* signals, in order of reliability:
|
||||
* 1. Lock source equals "pbakaus/impeccable" (authoritative).
|
||||
* 2. SKILL.md body contains the word "impeccable".
|
||||
* 3. SKILL.md body contains a per-skill fingerprint (for harden and
|
||||
* optimize, whose v2.x SKILL.md never mentioned the pack name).
|
||||
*/
|
||||
export function isImpeccableSkill(skillDir, { skillName, lock } = {}) {
|
||||
// 1. Authoritative: the lock file claims this skill is ours.
|
||||
if (skillName && lock?.skills?.[skillName]?.source === 'pbakaus/impeccable') {
|
||||
return true;
|
||||
}
|
||||
const skillMd = join(skillDir, 'SKILL.md');
|
||||
if (!existsSync(skillMd)) return false;
|
||||
let content;
|
||||
try {
|
||||
content = readFileSync(skillMd, 'utf-8');
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
// 2. Word-level content heuristic.
|
||||
if (/impeccable/i.test(content)) return true;
|
||||
// 3. Per-skill fingerprint for old skills that never mentioned the pack.
|
||||
// Strip the i- prefix so both `harden` and `i-harden` resolve to the
|
||||
// same fingerprint entry.
|
||||
const unprefixed = skillName?.startsWith('i-') ? skillName.slice(2) : skillName;
|
||||
const fingerprint = unprefixed && SKILL_FINGERPRINTS[unprefixed];
|
||||
if (fingerprint && content.includes(fingerprint)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the full list of names to check: each deprecated name, plus
|
||||
* its i-prefixed variant.
|
||||
*/
|
||||
export function buildTargetNames() {
|
||||
const names = [];
|
||||
for (const name of DEPRECATED_NAMES) {
|
||||
names.push(name);
|
||||
names.push(`i-${name}`);
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find every skills directory across all harness dirs in the project.
|
||||
* Returns absolute paths that exist on disk.
|
||||
*/
|
||||
export function findSkillsDirs(projectRoot) {
|
||||
const dirs = [];
|
||||
for (const harness of HARNESS_DIRS) {
|
||||
const candidate = join(projectRoot, harness, 'skills');
|
||||
if (existsSync(candidate)) {
|
||||
dirs.push(candidate);
|
||||
}
|
||||
}
|
||||
return dirs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove deprecated skill directories/symlinks from all harness dirs.
|
||||
* Reads skills-lock.json so the authoritative "source" field can
|
||||
* drive deletion even when SKILL.md never mentions impeccable.
|
||||
* Returns an array of paths that were deleted.
|
||||
*/
|
||||
export function removeDeprecatedSkills(projectRoot, lock) {
|
||||
if (lock === undefined) lock = loadLock(projectRoot);
|
||||
const targets = buildTargetNames();
|
||||
const skillsDirs = findSkillsDirs(projectRoot);
|
||||
const deleted = [];
|
||||
|
||||
for (const skillsDir of skillsDirs) {
|
||||
for (const name of targets) {
|
||||
const skillPath = join(skillsDir, name);
|
||||
|
||||
// Use lstat to detect symlinks (existsSync follows symlinks and
|
||||
// returns false for dangling ones).
|
||||
let stat;
|
||||
try {
|
||||
stat = lstatSync(skillPath);
|
||||
} catch {
|
||||
continue; // does not exist at all
|
||||
}
|
||||
|
||||
if (stat.isSymbolicLink()) {
|
||||
// Symlink: check the target if it's alive, otherwise treat
|
||||
// dangling symlinks to deprecated names as safe to remove.
|
||||
const targetAlive = existsSync(skillPath);
|
||||
const isMatch = targetAlive
|
||||
? isImpeccableSkill(skillPath, { skillName: name, lock })
|
||||
: true;
|
||||
if (isMatch) {
|
||||
unlinkSync(skillPath);
|
||||
deleted.push(skillPath);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Regular directory -- verify it belongs to impeccable
|
||||
if (isImpeccableSkill(skillPath, { skillName: name, lock })) {
|
||||
rmSync(skillPath, { recursive: true, force: true });
|
||||
deleted.push(skillPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return deleted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove deprecated entries from skills-lock.json.
|
||||
* Only removes entries whose source is "pbakaus/impeccable".
|
||||
* Returns the list of removed skill names.
|
||||
*/
|
||||
export function cleanSkillsLock(projectRoot) {
|
||||
const lockPath = join(projectRoot, 'skills-lock.json');
|
||||
if (!existsSync(lockPath)) return [];
|
||||
|
||||
let lock;
|
||||
try {
|
||||
lock = JSON.parse(readFileSync(lockPath, 'utf-8'));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!lock.skills || typeof lock.skills !== 'object') return [];
|
||||
|
||||
const targets = buildTargetNames();
|
||||
const removed = [];
|
||||
|
||||
for (const name of targets) {
|
||||
const entry = lock.skills[name];
|
||||
if (!entry) continue;
|
||||
// Only remove if it belongs to impeccable
|
||||
if (entry.source === 'pbakaus/impeccable') {
|
||||
delete lock.skills[name];
|
||||
removed.push(name);
|
||||
}
|
||||
}
|
||||
|
||||
if (removed.length > 0) {
|
||||
writeFileSync(lockPath, JSON.stringify(lock, null, 2) + '\n', 'utf-8');
|
||||
}
|
||||
|
||||
return removed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the full cleanup. Returns a summary object.
|
||||
*
|
||||
* Order matters: read the lock and delete directories first, then
|
||||
* strip lock entries. Otherwise the authoritative signal is gone by
|
||||
* the time directory deletion runs.
|
||||
*/
|
||||
export function cleanup(projectRoot) {
|
||||
const root = projectRoot || findProjectRoot();
|
||||
const lock = loadLock(root);
|
||||
const deletedPaths = removeDeprecatedSkills(root, lock);
|
||||
const removedLockEntries = cleanSkillsLock(root);
|
||||
return { deletedPaths, removedLockEntries, projectRoot: root };
|
||||
}
|
||||
|
||||
// CLI entry point
|
||||
if (process.argv[1] && resolve(process.argv[1]) === resolve(new URL(import.meta.url).pathname)) {
|
||||
const result = cleanup();
|
||||
if (result.deletedPaths.length === 0 && result.removedLockEntries.length === 0) {
|
||||
console.log('No deprecated Impeccable skills found. Nothing to clean up.');
|
||||
} else {
|
||||
if (result.deletedPaths.length > 0) {
|
||||
console.log(`Removed ${result.deletedPaths.length} deprecated skill(s):`);
|
||||
for (const p of result.deletedPaths) console.log(` - ${p}`);
|
||||
}
|
||||
if (result.removedLockEntries.length > 0) {
|
||||
console.log(`Cleaned ${result.removedLockEntries.length} entry/entries from skills-lock.json:`);
|
||||
for (const name of result.removedLockEntries) console.log(` - ${name}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -22,7 +22,7 @@ import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { loadContext, extractRegister } from './context.mjs';
|
||||
import { getCritiqueDir } from './impeccable-paths.mjs';
|
||||
import { getCritiqueDir } from './lib/impeccable-paths.mjs';
|
||||
|
||||
/** Is there code here at all, or just context files / an empty repo? */
|
||||
function hasCode(cwd) {
|
||||
|
||||
@@ -184,9 +184,23 @@ function buildUpdateDirective(localVersion, latestVersion) {
|
||||
* the user's home dir) and re-surfaces a given version at most once per week so
|
||||
* the agent never nags. Opt out entirely with IMPECCABLE_NO_UPDATE_CHECK=1.
|
||||
*/
|
||||
// Read the unified config's top-level `updateCheck` (local overrides shared).
|
||||
// Inlined rather than importing hook-lib so the boot path stays lightweight.
|
||||
function updateCheckDisabledByConfig(cwd = process.cwd()) {
|
||||
let value;
|
||||
for (const name of ['config.json', 'config.local.json']) {
|
||||
try {
|
||||
const raw = JSON.parse(fs.readFileSync(path.join(cwd, '.impeccable', name), 'utf-8'));
|
||||
if (raw && typeof raw === 'object' && typeof raw.updateCheck === 'boolean') value = raw.updateCheck;
|
||||
} catch { /* missing or malformed: ignore */ }
|
||||
}
|
||||
return value === false;
|
||||
}
|
||||
|
||||
async function computeUpdateDirective(now = Date.now()) {
|
||||
try {
|
||||
if (process.env.IMPECCABLE_NO_UPDATE_CHECK) return null;
|
||||
if (updateCheckDisabledByConfig()) return null;
|
||||
const localVersion = readLocalSkillVersion();
|
||||
if (!localVersion) return null;
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
import { getCritiqueDir } from './impeccable-paths.mjs';
|
||||
import { getCritiqueDir } from './lib/impeccable-paths.mjs';
|
||||
|
||||
const SLUG_MAX = 50;
|
||||
|
||||
|
||||
@@ -660,6 +660,7 @@ if (IS_BROWSER) {
|
||||
if (el.closest('.impeccable-overlay, .impeccable-label, .impeccable-banner, .impeccable-tooltip')) continue;
|
||||
if (el.closest('[id^="impeccable-live-"]')) continue;
|
||||
if (el === document.body || el === document.documentElement) continue;
|
||||
if (!isRenderedForBrowserRule(el)) continue;
|
||||
|
||||
const tag = el.tagName.toLowerCase();
|
||||
const style = getComputedStyle(el);
|
||||
@@ -1091,6 +1092,7 @@ if (IS_BROWSER) {
|
||||
return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'stale selector' };
|
||||
}
|
||||
if (!el) return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'missing element' };
|
||||
if (!isRenderedForBrowserRule(el)) return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'hidden element' };
|
||||
|
||||
const blockingReason = (candidate.reasons || []).find(reason =>
|
||||
reason === 'background-clip text' ||
|
||||
@@ -1437,13 +1439,20 @@ if (IS_BROWSER) {
|
||||
return true;
|
||||
}
|
||||
|
||||
function postSerializedFindings(groupMap) {
|
||||
function scanResultMeta(options = {}) {
|
||||
const scanId = options.scanId;
|
||||
if (typeof scanId !== 'string' && typeof scanId !== 'number') return {};
|
||||
return { scanId: String(scanId) };
|
||||
}
|
||||
|
||||
function postSerializedFindings(groupMap, options = {}) {
|
||||
if (!EXTENSION_MODE) return;
|
||||
const allFindings = browserFindingsFromMap(groupMap);
|
||||
window.postMessage({
|
||||
source: 'impeccable-results',
|
||||
findings: serializeFindings(allFindings),
|
||||
count: allFindings.length,
|
||||
...scanResultMeta(options),
|
||||
}, '*');
|
||||
}
|
||||
|
||||
@@ -1497,7 +1506,7 @@ if (IS_BROWSER) {
|
||||
rememberVisualContrastAnalysis(result);
|
||||
const added = addVisualContrastResult(groupMap, result, { decorate: true });
|
||||
if (added) {
|
||||
postSerializedFindings(groupMap);
|
||||
postSerializedFindings(groupMap, options);
|
||||
window.dispatchEvent(new CustomEvent('impeccable-visual-contrast-resolved', {
|
||||
detail: {
|
||||
selector: result.selector,
|
||||
@@ -1565,7 +1574,7 @@ if (IS_BROWSER) {
|
||||
overlayIndex = 0;
|
||||
}
|
||||
|
||||
function renderBrowserFindings(collected) {
|
||||
function renderBrowserFindings(collected, options = {}) {
|
||||
const { allFindings, pageLevelFindings } = collected;
|
||||
|
||||
for (const { el, findings } of allFindings) {
|
||||
@@ -1585,6 +1594,7 @@ if (IS_BROWSER) {
|
||||
source: 'impeccable-results',
|
||||
findings: serializeFindings(allFindings),
|
||||
count: allFindings.length,
|
||||
...scanResultMeta(options),
|
||||
}, '*');
|
||||
}
|
||||
|
||||
@@ -1599,11 +1609,11 @@ if (IS_BROWSER) {
|
||||
clearOverlays();
|
||||
const generation = scanGeneration;
|
||||
const collected = collectBrowserFindings();
|
||||
const allFindings = renderBrowserFindings(collected);
|
||||
const allFindings = renderBrowserFindings(collected, options);
|
||||
if (shouldRunVisualContrast(options)) {
|
||||
addVisualContrastFindings(collected.groupMap, options, { decorate: true, generation })
|
||||
.then(() => {
|
||||
if (generation === scanGeneration) postSerializedFindings(collected.groupMap);
|
||||
if (generation === scanGeneration) postSerializedFindings(collected.groupMap, options);
|
||||
})
|
||||
.catch(err => {
|
||||
reportVisualContrastError(err);
|
||||
@@ -1618,10 +1628,10 @@ if (IS_BROWSER) {
|
||||
if (shouldRunVisualContrast(options)) {
|
||||
const collected = await collectBrowserFindingsAsync(options, { generation, scheduleLazy: true });
|
||||
if (generation !== scanGeneration) return [];
|
||||
return renderBrowserFindings(collected);
|
||||
return renderBrowserFindings(collected, options);
|
||||
}
|
||||
lastVisualContrastAnalyses = [];
|
||||
return renderBrowserFindings(collectBrowserFindings());
|
||||
return renderBrowserFindings(collectBrowserFindings(), options);
|
||||
};
|
||||
|
||||
const detect = function(options = {}) {
|
||||
|
||||
@@ -1544,11 +1544,16 @@ function parseAnyColor(s) {
|
||||
// OKLCH parser. Tailwind v4's CSS minifier squishes the space after
|
||||
// `%` ("21.5%.02 50"), so the separator between L and C may be absent.
|
||||
// Match L (with optional %), then C and H separated permissively.
|
||||
m = str.match(/oklch\(\s*([\d.]+)(%?)\s*[\s,]*\s*([\d.]+)\s*[\s,]+\s*([-\d.]+)(?:deg)?\s*\)/i);
|
||||
m = str.match(/oklch\(\s*([\d.]+)(%?)\s*[\s,]*\s*([\d.]+)\s*[\s,]+\s*([-\d.]+)(?:deg)?(?:\s*\/\s*([\d.]+)(%)?)?\s*\)/i);
|
||||
if (m) {
|
||||
const Lnum = parseFloat(m[1]);
|
||||
const L = m[2] === '%' ? Lnum / 100 : Lnum;
|
||||
return oklchToRgb(L, parseFloat(m[3]), parseFloat(m[4]));
|
||||
const rgb = oklchToRgb(L, parseFloat(m[3]), parseFloat(m[4]));
|
||||
if (m[5] !== undefined) {
|
||||
const alpha = parseFloat(m[5]);
|
||||
rgb.a = m[6] === '%' ? alpha / 100 : alpha;
|
||||
}
|
||||
return rgb;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -1577,9 +1582,19 @@ const REPEATED_KICKER_SKIP_SELECTOR = [
|
||||
'[role="navigation"]',
|
||||
'[aria-label*="breadcrumb" i]',
|
||||
'[class*="breadcrumb" i]',
|
||||
'[aria-hidden="true"]',
|
||||
'[data-impeccable-allow-kickers]',
|
||||
].join(',');
|
||||
|
||||
const REPEATED_KICKER_CARD_CONTEXT_SELECTOR = [
|
||||
'article',
|
||||
'button',
|
||||
'a',
|
||||
'li',
|
||||
'[role="listitem"]',
|
||||
'[role="option"]',
|
||||
].join(',');
|
||||
|
||||
function cleanInlineText(el) {
|
||||
return [...el.childNodes]
|
||||
.filter(n => n.nodeType === 3)
|
||||
@@ -1589,6 +1604,11 @@ function cleanInlineText(el) {
|
||||
.trim();
|
||||
}
|
||||
|
||||
function isRepeatedKickerCardContext(heading, kicker) {
|
||||
const item = heading.closest?.(REPEATED_KICKER_CARD_CONTEXT_SELECTOR);
|
||||
return Boolean(item && (!item.contains || item.contains(kicker)));
|
||||
}
|
||||
|
||||
function isRepeatedKickerCandidate(opts) {
|
||||
const {
|
||||
headingTag,
|
||||
@@ -1602,6 +1622,7 @@ function isRepeatedKickerCandidate(opts) {
|
||||
} = opts;
|
||||
if (!['h2', 'h3', 'h4'].includes(headingTag)) return false;
|
||||
if (!headingText || headingText.length < 3) return false;
|
||||
if (/^\/[\w-]+/i.test(headingText.replace(/^"|"$/g, '').trim())) return false;
|
||||
if (!(headingFontSize >= 20)) return false;
|
||||
if (!kickerTag || HEADING_TAGS.has(kickerTag)) return false;
|
||||
if (!['p', 'span', 'div', 'small'].includes(kickerTag)) return false;
|
||||
@@ -1623,6 +1644,7 @@ function collectRepeatedSectionKickerCandidates(doc, getStyle, resolveLetterSpac
|
||||
if (heading.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue;
|
||||
const kicker = heading.previousElementSibling;
|
||||
if (!kicker || kicker.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue;
|
||||
if (isRepeatedKickerCardContext(heading, kicker)) continue;
|
||||
|
||||
const headingStyle = getStyle(heading);
|
||||
const kickerStyle = getStyle(kicker);
|
||||
@@ -1805,6 +1827,84 @@ function resolveLengthPx(value, fontSizePx) {
|
||||
return num * fontSizePx;
|
||||
}
|
||||
|
||||
function cssColorIsTransparent(value) {
|
||||
if (!value) return true;
|
||||
const str = String(value).trim().toLowerCase();
|
||||
if (!str || str === 'transparent' || str === 'rgba(0, 0, 0, 0)') return true;
|
||||
const parsed = parseAnyColor(str);
|
||||
if (parsed) return (parsed.a ?? 1) <= 0.05;
|
||||
return /^rgba\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*,\s*0(?:\.0+)?\s*\)$/.test(str);
|
||||
}
|
||||
|
||||
function colorsNearlyMatch(a, b) {
|
||||
const ca = parseAnyColor(a);
|
||||
const cb = parseAnyColor(b);
|
||||
if (!ca || !cb) return false;
|
||||
const alphaDelta = Math.abs((ca.a ?? 1) - (cb.a ?? 1));
|
||||
const channelDelta = Math.max(
|
||||
Math.abs(ca.r - cb.r),
|
||||
Math.abs(ca.g - cb.g),
|
||||
Math.abs(ca.b - cb.b),
|
||||
);
|
||||
return alphaDelta <= 0.03 && channelDelta <= 3;
|
||||
}
|
||||
|
||||
function getComputedStyleFor(win, el) {
|
||||
if (win && typeof win.getComputedStyle === 'function') {
|
||||
try { return win.getComputedStyle(el); } catch {}
|
||||
}
|
||||
if (typeof getComputedStyle === 'function') {
|
||||
try { return getComputedStyle(el); } catch {}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function hasVisibleBackgroundBoundary(style, el, win) {
|
||||
const bg = style?.backgroundColor || '';
|
||||
if (cssColorIsTransparent(bg)) return false;
|
||||
|
||||
let parent = el?.parentElement || null;
|
||||
while (parent) {
|
||||
const parentStyle = getComputedStyleFor(win, parent);
|
||||
const parentBg = parentStyle?.backgroundColor || '';
|
||||
if (!cssColorIsTransparent(parentBg)) {
|
||||
return !colorsNearlyMatch(bg, parentBg);
|
||||
}
|
||||
parent = parent.parentElement;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
const TEXT_EDGE_TAGS = new Set(['A', 'BUTTON', 'CODE', 'DD', 'DT', 'FIGCAPTION', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'LI', 'P', 'PRE', 'SPAN', 'TD', 'TH']);
|
||||
|
||||
function hasMeaningfulDirectText(node) {
|
||||
if (!node?.childNodes) return false;
|
||||
for (const child of node.childNodes) {
|
||||
if (child.nodeType === 3 && child.textContent.trim().length > 4) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function textDescendantsFlushSides(el, rect) {
|
||||
const flush = { top: false, right: false, bottom: false, left: false };
|
||||
if (!rect || !el?.querySelectorAll) return flush;
|
||||
const TEXT_EDGE_THRESHOLD = 4;
|
||||
const candidates = el.querySelectorAll('a, button, code, dd, dt, figcaption, h1, h2, h3, h4, h5, h6, li, p, pre, span, td, th');
|
||||
for (const node of candidates) {
|
||||
if (!TEXT_EDGE_TAGS.has(node.tagName) || !hasMeaningfulDirectText(node)) continue;
|
||||
let nodeRect = null;
|
||||
try { nodeRect = node.getBoundingClientRect(); } catch {}
|
||||
if (!nodeRect || nodeRect.width <= 0 || nodeRect.height <= 0) continue;
|
||||
if (nodeRect.bottom < rect.top || nodeRect.top > rect.bottom || nodeRect.right < rect.left || nodeRect.left > rect.right) continue;
|
||||
if (nodeRect.top - rect.top <= TEXT_EDGE_THRESHOLD) flush.top = true;
|
||||
if (rect.right - nodeRect.right <= TEXT_EDGE_THRESHOLD) flush.right = true;
|
||||
if (rect.bottom - nodeRect.bottom <= TEXT_EDGE_THRESHOLD) flush.bottom = true;
|
||||
if (nodeRect.left - rect.left <= TEXT_EDGE_THRESHOLD) flush.left = true;
|
||||
}
|
||||
return flush;
|
||||
}
|
||||
|
||||
// Pure quality checks. Most run on computed CSS and DOM-only inputs (work in
|
||||
// jsdom and the browser). Two checks (line-length, cramped-padding) gate on
|
||||
// element rect dimensions, which jsdom can't compute — pass `rect: null` from
|
||||
@@ -1834,7 +1934,8 @@ function checkQuality(opts) {
|
||||
// font-size — bigger text demands proportionally more padding.
|
||||
// vertical: max(4px, fontSize × 0.3)
|
||||
// horizontal: max(8px, fontSize × 0.5)
|
||||
if (rect && hasDirectText && textLen > 20 && rect.width > 100 && rect.height > 30) {
|
||||
const isInlineCode = tag === 'code' && !(el.closest && el.closest('pre'));
|
||||
if (!isInlineCode && rect && hasDirectText && textLen > 20 && rect.width > 100 && rect.height > 30) {
|
||||
const borders = {
|
||||
top: parseFloat(style.borderTopWidth) || 0,
|
||||
right: parseFloat(style.borderRightWidth) || 0,
|
||||
@@ -1842,7 +1943,7 @@ function checkQuality(opts) {
|
||||
left: parseFloat(style.borderLeftWidth) || 0,
|
||||
};
|
||||
const borderCount = Object.values(borders).filter(w => w > 0).length;
|
||||
const hasBg = style.backgroundColor && style.backgroundColor !== 'rgba(0, 0, 0, 0)';
|
||||
const hasBg = hasVisibleBackgroundBoundary(style, el, win);
|
||||
if (borderCount >= 2 || hasBg) {
|
||||
const vPads = [], hPads = [];
|
||||
if (hasBg || borders.top > 0) vPads.push(parseFloat(style.paddingTop) || 0);
|
||||
@@ -1890,10 +1991,6 @@ function checkQuality(opts) {
|
||||
!['fixed', 'absolute'].includes(elPosition) &&
|
||||
el.children && el.children.length > 0
|
||||
) {
|
||||
const isTransparent = (c) =>
|
||||
!c || c === 'transparent' || c === 'rgba(0, 0, 0, 0)' ||
|
||||
/^rgba\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*,\s*0(?:\.0+)?\s*\)$/.test(c);
|
||||
|
||||
const borderW = {
|
||||
top: parseFloat(style.borderTopWidth) || 0,
|
||||
right: parseFloat(style.borderRightWidth) || 0,
|
||||
@@ -1901,10 +1998,10 @@ function checkQuality(opts) {
|
||||
left: parseFloat(style.borderLeftWidth) || 0,
|
||||
};
|
||||
const borderVisible = {
|
||||
top: borderW.top > 0 && !isTransparent(style.borderTopColor),
|
||||
right: borderW.right > 0 && !isTransparent(style.borderRightColor),
|
||||
bottom: borderW.bottom > 0 && !isTransparent(style.borderBottomColor),
|
||||
left: borderW.left > 0 && !isTransparent(style.borderLeftColor),
|
||||
top: borderW.top > 0 && !cssColorIsTransparent(style.borderTopColor),
|
||||
right: borderW.right > 0 && !cssColorIsTransparent(style.borderRightColor),
|
||||
bottom: borderW.bottom > 0 && !cssColorIsTransparent(style.borderBottomColor),
|
||||
left: borderW.left > 0 && !cssColorIsTransparent(style.borderLeftColor),
|
||||
};
|
||||
// Outline detection. jsdom decomposes `border` shorthand into
|
||||
// border{Top,…}Width/Color but does NOT decompose `outline` —
|
||||
@@ -1924,8 +2021,8 @@ function checkQuality(opts) {
|
||||
if (cMatch) outlineColorVal = cMatch[1];
|
||||
}
|
||||
}
|
||||
const outlineVisible = outlineW > 0 && !isTransparent(outlineColorVal) && outlineStyleVal && outlineStyleVal !== 'none';
|
||||
const bgVisible = !isTransparent(style.backgroundColor);
|
||||
const outlineVisible = outlineW > 0 && !cssColorIsTransparent(outlineColorVal) && outlineStyleVal && outlineStyleVal !== 'none';
|
||||
const bgVisible = hasVisibleBackgroundBoundary(style, el, win);
|
||||
|
||||
const anyVisible = borderVisible.top || borderVisible.right || borderVisible.bottom || borderVisible.left || outlineVisible || bgVisible;
|
||||
if (anyVisible) {
|
||||
@@ -1953,13 +2050,7 @@ function checkQuality(opts) {
|
||||
const CHILD_INSULATE_THRESHOLD = 4;
|
||||
const childrenInsulate = { top: false, right: false, bottom: false, left: false };
|
||||
for (const child of el.children) {
|
||||
let childStyle = null;
|
||||
if (win && typeof win.getComputedStyle === 'function') {
|
||||
try { childStyle = win.getComputedStyle(child); } catch {}
|
||||
}
|
||||
if (!childStyle && typeof getComputedStyle === 'function') {
|
||||
try { childStyle = getComputedStyle(child); } catch {}
|
||||
}
|
||||
let childStyle = getComputedStyleFor(win, child);
|
||||
if (!childStyle) continue;
|
||||
const childPad = {
|
||||
top: resolveLengthPx(childStyle.paddingTop, fontSize) ?? 0,
|
||||
@@ -1967,15 +2058,37 @@ function checkQuality(opts) {
|
||||
bottom: resolveLengthPx(childStyle.paddingBottom, fontSize) ?? 0,
|
||||
left: resolveLengthPx(childStyle.paddingLeft, fontSize) ?? 0,
|
||||
};
|
||||
const childMargin = {
|
||||
top: resolveLengthPx(childStyle.marginTop, fontSize) ?? 0,
|
||||
right: resolveLengthPx(childStyle.marginRight, fontSize) ?? 0,
|
||||
bottom: resolveLengthPx(childStyle.marginBottom, fontSize) ?? 0,
|
||||
left: resolveLengthPx(childStyle.marginLeft, fontSize) ?? 0,
|
||||
};
|
||||
if (rect && typeof child.getBoundingClientRect === 'function') {
|
||||
try {
|
||||
const childRect = child.getBoundingClientRect();
|
||||
if (childRect && childRect.width > 0 && childRect.height > 0) {
|
||||
if (childRect.top - rect.top >= CHILD_INSULATE_THRESHOLD) childrenInsulate.top = true;
|
||||
if (rect.right - childRect.right >= CHILD_INSULATE_THRESHOLD) childrenInsulate.right = true;
|
||||
if (rect.bottom - childRect.bottom >= CHILD_INSULATE_THRESHOLD) childrenInsulate.bottom = true;
|
||||
if (childRect.left - rect.left >= CHILD_INSULATE_THRESHOLD) childrenInsulate.left = true;
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
for (const s of ['top', 'right', 'bottom', 'left']) {
|
||||
if (childPad[s] >= CHILD_INSULATE_THRESHOLD) childrenInsulate[s] = true;
|
||||
if (childPad[s] >= CHILD_INSULATE_THRESHOLD || childMargin[s] >= CHILD_INSULATE_THRESHOLD) {
|
||||
childrenInsulate[s] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const textFlush = rect ? textDescendantsFlushSides(el, rect) : null;
|
||||
const fullBleedBgBand = rect && viewportWidth > 0 && rect.width >= viewportWidth * 0.94 && bgVisible && !outlineVisible;
|
||||
const flushSides = [];
|
||||
for (const side of ['top', 'right', 'bottom', 'left']) {
|
||||
const sideBounded = borderVisible[side] || outlineVisible || bgVisible;
|
||||
if (sideBounded && pad[side] <= PAD_THRESHOLD && !childrenInsulate[side]) {
|
||||
const bgBoundsSide = bgVisible && !(fullBleedBgBand && (side === 'left' || side === 'right'));
|
||||
const sideBounded = borderVisible[side] || outlineVisible || bgBoundsSide;
|
||||
if (sideBounded && pad[side] <= PAD_THRESHOLD && !childrenInsulate[side] && (!textFlush || textFlush[side])) {
|
||||
flushSides.push(side);
|
||||
}
|
||||
}
|
||||
@@ -2069,7 +2182,7 @@ function checkQuality(opts) {
|
||||
// Only flag actual body content, not UI labels (buttons, tabs, badges, captions, footer text, etc.)
|
||||
if (hasDirectText && textLen > 20 && fontSize < 12) {
|
||||
const skipTags = ['sub', 'sup', 'code', 'kbd', 'samp', 'var', 'caption', 'figcaption'];
|
||||
const inUIContext = el.closest && el.closest('button, a, label, summary, [role="button"], [role="link"], [role="tab"], [role="menuitem"], [role="option"], nav, footer, [class*="badge" i], [class*="chip" i], [class*="pill" i], [class*="tag" i], [class*="label" i], [class*="caption" i]');
|
||||
const inUIContext = el.closest && el.closest('button, a, label, summary, pre, [role="button"], [role="link"], [role="tab"], [role="menuitem"], [role="option"], nav, footer, [aria-hidden="true"], [class*="badge" i], [class*="caption" i], [class*="chip" i], [class*="code" i], [class*="console" i], [class*="diff" i], [class*="label" i], [class*="meta" i], [class*="mock" i], [class*="pill" i], [class*="preview" i], [class*="tag" i], [class*="terminal" i], [class*="writes" i]');
|
||||
const isUppercase = style.textTransform === 'uppercase';
|
||||
if (!skipTags.includes(tag) && !inUIContext && !isUppercase) {
|
||||
findings.push({ id: 'tiny-text', snippet: `${fontSize}px body text` });
|
||||
@@ -2677,17 +2790,28 @@ function checkCreamPalette(doc, win) {
|
||||
}
|
||||
|
||||
// ─── Oversized hero headline ────────────────────────────────────────────────
|
||||
// Fires when a *long* headline is set at display size, so a full sentence ends
|
||||
// up dominating the viewport. A punchy one- or two-word headline at the same
|
||||
// size is a legitimate stylistic choice and must pass — length, not size
|
||||
// alone, is the tell.
|
||||
// Fires when a *long* headline is set at display size and actually dominates
|
||||
// the viewport. A punchy one- or two-word headline at the same size is a
|
||||
// legitimate stylistic choice, and a large-but-contained two-line hero should
|
||||
// pass too — length and viewport share together are the tell.
|
||||
const OVERSIZED_H1_FONT_PX = 72;
|
||||
const OVERSIZED_H1_MIN_CHARS = 40;
|
||||
function checkOversizedH1({ tag, fontSize, headingText }) {
|
||||
const OVERSIZED_H1_MIN_VIEWPORT_HEIGHT_RATIO = 0.28;
|
||||
const OVERSIZED_H1_MIN_VIEWPORT_AREA_RATIO = 0.25;
|
||||
function checkOversizedH1({ tag, fontSize, headingText, rect = null, viewportWidth = 0, viewportHeight = 0 }) {
|
||||
if (tag !== 'h1') return [];
|
||||
const textLen = headingText.length;
|
||||
if (fontSize >= OVERSIZED_H1_FONT_PX && textLen >= OVERSIZED_H1_MIN_CHARS) {
|
||||
return [{ id: 'oversized-h1', snippet: `${Math.round(fontSize)}px h1, ${textLen} chars "${headingText.slice(0, 60)}"` }];
|
||||
let viewportDetail = '';
|
||||
if (rect && viewportWidth > 0 && viewportHeight > 0) {
|
||||
const heightRatio = rect.height / viewportHeight;
|
||||
const areaRatio = (rect.width * rect.height) / (viewportWidth * viewportHeight);
|
||||
const dominatesViewport = heightRatio >= OVERSIZED_H1_MIN_VIEWPORT_HEIGHT_RATIO
|
||||
|| areaRatio >= OVERSIZED_H1_MIN_VIEWPORT_AREA_RATIO;
|
||||
if (!dominatesViewport) return [];
|
||||
viewportDetail = `, ${Math.round(heightRatio * 100)}vh`;
|
||||
}
|
||||
return [{ id: 'oversized-h1', snippet: `${Math.round(fontSize)}px h1, ${textLen} chars${viewportDetail} "${headingText.slice(0, 60)}"` }];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
@@ -2705,31 +2829,54 @@ function checkElementOversizedH1DOM(el) {
|
||||
const style = getComputedStyle(el);
|
||||
const fontSize = parseFloat(style.fontSize) || 0;
|
||||
const headingText = (el.textContent || '').trim().replace(/\s+/g, ' ');
|
||||
return checkOversizedH1({ tag, fontSize, headingText });
|
||||
const rect = el.getBoundingClientRect();
|
||||
const viewportWidth = (typeof window !== 'undefined' ? window.innerWidth : 0) || 0;
|
||||
const viewportHeight = (typeof window !== 'undefined' ? window.innerHeight : 0) || 0;
|
||||
return checkOversizedH1({ tag, fontSize, headingText, rect, viewportWidth, viewportHeight });
|
||||
}
|
||||
|
||||
// ─── GPT tell: hairline border + wide diffuse shadow (gated --gpt) ────────────
|
||||
function shadowMaxBlurPx(boxShadow) {
|
||||
const CSS_COLOR_TOKEN_RE = /(?:rgba?|hsla?|oklch|oklab|lab|lch|color)\([^)]*\)|#[0-9a-fA-F]{3,8}\b|\b(?:black|white|transparent|currentcolor)\b/gi;
|
||||
|
||||
function shadowLayerAlpha(layer) {
|
||||
CSS_COLOR_TOKEN_RE.lastIndex = 0;
|
||||
const match = CSS_COLOR_TOKEN_RE.exec(layer);
|
||||
if (!match) return 1;
|
||||
if (match[0].toLowerCase() === 'transparent') return 0;
|
||||
const parsed = parseAnyColor(match[0]);
|
||||
return parsed ? (parsed.a ?? 1) : 1;
|
||||
}
|
||||
|
||||
function shadowMaxBlurPx(boxShadow, { minAlpha = 0 } = {}) {
|
||||
if (!boxShadow || boxShadow === 'none') return 0;
|
||||
let maxBlur = 0;
|
||||
// Split into layers on commas not inside parentheses (rgba(...) etc.).
|
||||
for (const layer of boxShadow.split(/,(?![^()]*\))/)) {
|
||||
if (shadowLayerAlpha(layer) < minAlpha) continue;
|
||||
// Strip colors and keywords (rgba()/hsl()/hex/named/inset/px), leaving the
|
||||
// ordered length tokens: offsetX offsetY blur [spread]. Static jsdom keeps
|
||||
// unitless zeros ("0 0 24px"); browsers normalize to px ("0px 0px 24px") —
|
||||
// both reduce to the same numbers here.
|
||||
const cleaned = layer.replace(/rgba?\([^)]*\)|hsla?\([^)]*\)|#[0-9a-f]+|\b[a-z]+\b/gi, ' ');
|
||||
const cleaned = layer.replace(CSS_COLOR_TOKEN_RE, ' ').replace(/\b[a-z]+\b/gi, ' ');
|
||||
const nums = [...cleaned.matchAll(/-?\d*\.?\d+/g)].map(m => parseFloat(m[0]));
|
||||
if (nums.length >= 3) maxBlur = Math.max(maxBlur, nums[2]);
|
||||
}
|
||||
return maxBlur;
|
||||
}
|
||||
|
||||
function checkGptThinBorderWideShadow({ borderWidths, boxShadow }) {
|
||||
const maxBorder = Math.max(0, ...borderWidths);
|
||||
const hasThinBorder = maxBorder > 0 && maxBorder <= 1.5;
|
||||
const blur = shadowMaxBlurPx(boxShadow);
|
||||
if (hasThinBorder && blur >= 16) {
|
||||
function cssColorAlpha(value) {
|
||||
if (cssColorIsTransparent(value)) return 0;
|
||||
const parsed = parseAnyColor(value);
|
||||
return parsed ? (parsed.a ?? 1) : 1;
|
||||
}
|
||||
|
||||
function checkGptThinBorderWideShadow({ borderWidths, borderColors, boxShadow }) {
|
||||
const visibleThinBorders = borderWidths
|
||||
.map((width, index) => ({ width, alpha: cssColorAlpha(borderColors?.[index] || '') }))
|
||||
.filter(({ width, alpha }) => width > 0 && width <= 1.5 && alpha >= 0.28);
|
||||
const maxBorder = Math.max(0, ...visibleThinBorders.map(({ width }) => width));
|
||||
const blur = shadowMaxBlurPx(boxShadow, { minAlpha: 0.12 });
|
||||
if (visibleThinBorders.length >= 2 && blur >= 16) {
|
||||
return [{ id: 'gpt-thin-border-wide-shadow', snippet: `${maxBorder}px border + ${Math.round(blur)}px shadow blur` }];
|
||||
}
|
||||
return [];
|
||||
@@ -2744,13 +2891,22 @@ function borderWidthsFromStyle(style) {
|
||||
];
|
||||
}
|
||||
|
||||
function borderColorsFromStyle(style) {
|
||||
return [
|
||||
style.borderTopColor || '',
|
||||
style.borderRightColor || '',
|
||||
style.borderBottomColor || '',
|
||||
style.borderLeftColor || '',
|
||||
];
|
||||
}
|
||||
|
||||
function checkElementGptBorderShadow(el, style) {
|
||||
return checkGptThinBorderWideShadow({ borderWidths: borderWidthsFromStyle(style), boxShadow: style.boxShadow || '' });
|
||||
return checkGptThinBorderWideShadow({ borderWidths: borderWidthsFromStyle(style), borderColors: borderColorsFromStyle(style), boxShadow: style.boxShadow || '' });
|
||||
}
|
||||
|
||||
function checkElementGptBorderShadowDOM(el) {
|
||||
const style = getComputedStyle(el);
|
||||
return checkGptThinBorderWideShadow({ borderWidths: borderWidthsFromStyle(style), boxShadow: style.boxShadow || '' });
|
||||
return checkGptThinBorderWideShadow({ borderWidths: borderWidthsFromStyle(style), borderColors: borderColorsFromStyle(style), boxShadow: style.boxShadow || '' });
|
||||
}
|
||||
|
||||
// ─── Clipped overflow container ───────────────────────────────────────────────
|
||||
@@ -2763,17 +2919,131 @@ function classSelector(el) {
|
||||
return tokens.length ? `${tag}.${tokens.join('.')}` : tag;
|
||||
}
|
||||
|
||||
function positionedChildIsDecorative(child) {
|
||||
if (!child || typeof child.getAttribute !== 'function') return false;
|
||||
if (child.closest?.('[aria-hidden="true"]')) return true;
|
||||
const role = (child.getAttribute('role') || '').toLowerCase();
|
||||
if (role === 'none' || role === 'presentation') return true;
|
||||
const tag = child.tagName ? child.tagName.toLowerCase() : '';
|
||||
if (['img', 'svg', 'canvas', 'video'].includes(tag)) return true;
|
||||
const ident = `${child.getAttribute('class') || ''} ${child.getAttribute('id') || ''}`;
|
||||
if (
|
||||
/\b(art|bg|background|badge|blob|crop|decor|dot|glow|grain|image|mask|ornament|overlay|photo|scrim|shadow|shine|texture)\b/i.test(ident) &&
|
||||
!positionedChildHasSubstantiveContent(child)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const POSITIONED_CHILD_INTERACTIVE_SELECTOR = [
|
||||
'a[href]',
|
||||
'button',
|
||||
'input',
|
||||
'select',
|
||||
'summary',
|
||||
'textarea',
|
||||
'[tabindex]:not([tabindex="-1"])',
|
||||
'[role="button"]',
|
||||
'[role="dialog"]',
|
||||
'[role="link"]',
|
||||
'[role="listbox"]',
|
||||
'[role="menu"]',
|
||||
'[role="menuitem"]',
|
||||
'[role="option"]',
|
||||
'[role="tooltip"]',
|
||||
].join(',');
|
||||
|
||||
function positionedChildHasSubstantiveContent(child) {
|
||||
const text = (child.textContent || '').replace(/\s+/g, ' ').trim();
|
||||
if (text.length > 0) return true;
|
||||
if (typeof child.matches === 'function') {
|
||||
try {
|
||||
if (child.matches(POSITIONED_CHILD_INTERACTIVE_SELECTOR)) return true;
|
||||
} catch {}
|
||||
}
|
||||
if (typeof child.querySelector === 'function') {
|
||||
try {
|
||||
if (child.querySelector(POSITIONED_CHILD_INTERACTIVE_SELECTOR)) return true;
|
||||
} catch {}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function clippingContainerIsIntentionalViewport(el) {
|
||||
if (!el || typeof el.getAttribute !== 'function') return false;
|
||||
const roleDescription = (el.getAttribute('aria-roledescription') || '').toLowerCase();
|
||||
if (/\b(carousel|slider)\b/.test(roleDescription)) return true;
|
||||
const ident = `${el.getAttribute('class') || ''} ${el.getAttribute('id') || ''}`.toLowerCase();
|
||||
return /\b(carousel|comparison|compare|fisheye|marquee|preview|scroller|slider|slideshow|split|viewport)\b/.test(ident) ||
|
||||
/\b(demo-area|demo-stage|demo-viewport)\b/.test(ident);
|
||||
}
|
||||
|
||||
function elementRect(el) {
|
||||
if (!el || typeof el.getBoundingClientRect !== 'function') return null;
|
||||
try {
|
||||
const rect = el.getBoundingClientRect();
|
||||
if (!rect) return null;
|
||||
const values = [rect.top, rect.right, rect.bottom, rect.left, rect.width, rect.height];
|
||||
if (!values.every(Number.isFinite)) return null;
|
||||
if (rect.width <= 0 && rect.height <= 0) return null;
|
||||
return rect;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function positionedStyleImpliesEscape(style) {
|
||||
const values = [
|
||||
style.top,
|
||||
style.right,
|
||||
style.bottom,
|
||||
style.left,
|
||||
style.inset,
|
||||
style.insetBlock,
|
||||
style.insetInline,
|
||||
style.insetBlockStart,
|
||||
style.insetBlockEnd,
|
||||
style.insetInlineStart,
|
||||
style.insetInlineEnd,
|
||||
].filter(Boolean).map(value => String(value).trim().toLowerCase());
|
||||
for (const value of values) {
|
||||
if (/(^|[\s(])-+(?:\d|\.)/.test(value)) return true;
|
||||
if (/(^|[\s(])100(?:\.0+)?%/.test(value)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function positionedChildEscapesClip(el, child, clipX, clipY) {
|
||||
const parentRect = elementRect(el);
|
||||
const childRect = elementRect(child);
|
||||
if (!parentRect || !childRect) return null;
|
||||
const threshold = 2;
|
||||
return Boolean(
|
||||
(clipX && (childRect.left < parentRect.left - threshold || childRect.right > parentRect.right + threshold)) ||
|
||||
(clipY && (childRect.top < parentRect.top - threshold || childRect.bottom > parentRect.bottom + threshold))
|
||||
);
|
||||
}
|
||||
|
||||
function checkClippedOverflow(el, style, getStyle) {
|
||||
const clips = (v) => v === 'hidden' || v === 'clip';
|
||||
const scrolls = (v) => v === 'auto' || v === 'scroll';
|
||||
const ox = style.overflowX || '', oy = style.overflowY || '', ov = style.overflow || '';
|
||||
const anyClip = clips(ox) || clips(oy) || clips(ov);
|
||||
const clipX = clips(ox) || clips(ov);
|
||||
const clipY = clips(oy) || clips(ov);
|
||||
const anyClip = clipX || clipY;
|
||||
const anyScroll = scrolls(ox) || scrolls(oy) || scrolls(ov);
|
||||
if (!anyClip || anyScroll) return [];
|
||||
if (clippingContainerIsIntentionalViewport(el)) return [];
|
||||
if (!el.querySelectorAll) return [];
|
||||
for (const child of el.querySelectorAll('*')) {
|
||||
const pos = (getStyle(child).position) || '';
|
||||
const childStyle = getStyle(child);
|
||||
const pos = childStyle.position || '';
|
||||
if (pos === 'absolute' || pos === 'fixed') {
|
||||
if (positionedChildIsDecorative(child)) continue;
|
||||
const escapes = positionedChildEscapesClip(el, child, clipX, clipY);
|
||||
if (escapes === false) continue;
|
||||
if (escapes === null && !positionedStyleImpliesEscape(childStyle)) continue;
|
||||
return [{ id: 'clipped-overflow-container', snippet: `${classSelector(el)} clips a positioned child` }];
|
||||
}
|
||||
}
|
||||
@@ -2792,14 +3062,94 @@ function checkElementClippedOverflowDOM(el) {
|
||||
// ─── Text overflow (browser-only: needs scrollWidth/clientWidth) ──────────────
|
||||
const TEXT_OVERFLOW_SKIP_TAGS = new Set(['pre', 'code', 'textarea', 'svg', 'canvas', 'select', 'option', 'marquee']);
|
||||
|
||||
function metricLengthPx(value, fontSizePx = 16) {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) return value;
|
||||
if (typeof value !== 'string') return null;
|
||||
return resolveLengthPx(value, fontSizePx);
|
||||
}
|
||||
|
||||
function firstMetricLengthPx(fontSizePx, ...values) {
|
||||
for (const value of values) {
|
||||
const parsed = metricLengthPx(value, fontSizePx);
|
||||
if (parsed !== null) return parsed;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function expandBoxShorthand(parts) {
|
||||
if (parts.length === 1) return [parts[0], parts[0], parts[0], parts[0]];
|
||||
if (parts.length === 2) return [parts[0], parts[1], parts[0], parts[1]];
|
||||
if (parts.length === 3) return [parts[0], parts[1], parts[2], parts[1]];
|
||||
return [parts[0], parts[1], parts[2], parts[3]];
|
||||
}
|
||||
|
||||
function clippedByInset(clipPath) {
|
||||
const match = String(clipPath || '').trim().toLowerCase().match(/^inset\s*\(([^)]*)\)$/);
|
||||
if (!match) return false;
|
||||
const beforeRound = match[1].split(/\s+round\s+/)[0].trim();
|
||||
if (!beforeRound) return false;
|
||||
const values = expandBoxShorthand(beforeRound.split(/\s+/).slice(0, 4));
|
||||
const percents = values.map(value => String(value).trim().match(/^(-?\d+(?:\.\d+)?)%$/));
|
||||
if (percents.some(match => !match)) return false;
|
||||
const [top, right, bottom, left] = percents.map(match => parseFloat(match[1]));
|
||||
return top + bottom >= 100 || left + right >= 100;
|
||||
}
|
||||
|
||||
function clippedByRect(clip) {
|
||||
const match = String(clip || '').trim().toLowerCase().match(/^rect\s*\(([^)]*)\)$/);
|
||||
if (!match) return false;
|
||||
const values = match[1].split(/[,\s]+/).map(value => value.trim()).filter(Boolean);
|
||||
if (values.length !== 4) return false;
|
||||
const [top, right, bottom, left] = values.map(value => metricLengthPx(value, 16));
|
||||
if ([top, right, bottom, left].some(value => value === null)) return false;
|
||||
return bottom <= top || right <= left;
|
||||
}
|
||||
|
||||
function isScreenReaderOnlyTextStyle(style, metrics = {}) {
|
||||
if (!style) return false;
|
||||
const overflowValues = [style.overflow, style.overflowX, style.overflowY]
|
||||
.map(value => String(value || '').toLowerCase());
|
||||
const clipsOverflow = overflowValues.some(value => value === 'hidden' || value === 'clip');
|
||||
|
||||
const fontSize = metricLengthPx(style.fontSize, 16) || 16;
|
||||
const width = firstMetricLengthPx(fontSize, metrics.width, metrics.clientWidth, style.width, style.inlineSize);
|
||||
const height = firstMetricLengthPx(fontSize, metrics.height, metrics.clientHeight, style.height, style.blockSize);
|
||||
const isTiny = width !== null && height !== null && width <= 2 && height <= 2;
|
||||
const isAbsolutelyHidden = String(style.position || '').toLowerCase() === 'absolute' && isTiny && clipsOverflow;
|
||||
|
||||
const clipPath = String(style.clipPath || style.webkitClipPath || '').trim();
|
||||
const clip = String(style.clip || '').trim();
|
||||
return isAbsolutelyHidden || clippedByInset(clipPath) || clippedByRect(clip);
|
||||
}
|
||||
|
||||
function isRenderedForBrowserRule(el) {
|
||||
for (let cur = el; cur && cur.nodeType === 1; cur = cur.parentElement) {
|
||||
if (cur.getAttribute?.('aria-hidden') === 'true') return false;
|
||||
const style = getComputedStyle(cur);
|
||||
const visibility = String(style.visibility || '').toLowerCase();
|
||||
if (style.display === 'none' || visibility === 'hidden' || visibility === 'collapse') return false;
|
||||
if ((parseFloat(style.opacity) || 0) <= 0.01) return false;
|
||||
if (String(style.contentVisibility || '').toLowerCase() === 'hidden') return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function checkElementTextOverflowDOM(el) {
|
||||
const tag = el.tagName.toLowerCase();
|
||||
if (TEXT_OVERFLOW_SKIP_TAGS.has(tag)) return [];
|
||||
if (!isRenderedForBrowserRule(el)) return [];
|
||||
// Only the element that actually owns overflowing text — not its ancestors,
|
||||
// which inherit a wider scrollWidth from the spilling descendant.
|
||||
const hasDirectText = [...el.childNodes].some(n => n.nodeType === 3 && n.textContent.trim().length > 0);
|
||||
if (!hasDirectText) return [];
|
||||
const style = getComputedStyle(el);
|
||||
const rect = el.getBoundingClientRect ? el.getBoundingClientRect() : null;
|
||||
if (isScreenReaderOnlyTextStyle(style, {
|
||||
width: rect?.width,
|
||||
height: rect?.height,
|
||||
clientWidth: el.clientWidth,
|
||||
clientHeight: el.clientHeight,
|
||||
})) return [];
|
||||
const isScrollRegion = (s) => /(auto|scroll)/.test(s.overflowX || '') || /(auto|scroll)/.test(s.overflow || '');
|
||||
if (isScrollRegion(style)) return [];
|
||||
// A scrollable ancestor means this overflow is intentional and scrollable.
|
||||
@@ -3476,6 +3826,7 @@ if (IS_BROWSER) {
|
||||
if (el.closest('.impeccable-overlay, .impeccable-label, .impeccable-banner, .impeccable-tooltip')) continue;
|
||||
if (el.closest('[id^="impeccable-live-"]')) continue;
|
||||
if (el === document.body || el === document.documentElement) continue;
|
||||
if (!isRenderedForBrowserRule(el)) continue;
|
||||
|
||||
const tag = el.tagName.toLowerCase();
|
||||
const style = getComputedStyle(el);
|
||||
@@ -3907,6 +4258,7 @@ if (IS_BROWSER) {
|
||||
return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'stale selector' };
|
||||
}
|
||||
if (!el) return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'missing element' };
|
||||
if (!isRenderedForBrowserRule(el)) return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'hidden element' };
|
||||
|
||||
const blockingReason = (candidate.reasons || []).find(reason =>
|
||||
reason === 'background-clip text' ||
|
||||
@@ -4253,13 +4605,20 @@ if (IS_BROWSER) {
|
||||
return true;
|
||||
}
|
||||
|
||||
function postSerializedFindings(groupMap) {
|
||||
function scanResultMeta(options = {}) {
|
||||
const scanId = options.scanId;
|
||||
if (typeof scanId !== 'string' && typeof scanId !== 'number') return {};
|
||||
return { scanId: String(scanId) };
|
||||
}
|
||||
|
||||
function postSerializedFindings(groupMap, options = {}) {
|
||||
if (!EXTENSION_MODE) return;
|
||||
const allFindings = browserFindingsFromMap(groupMap);
|
||||
window.postMessage({
|
||||
source: 'impeccable-results',
|
||||
findings: serializeFindings(allFindings),
|
||||
count: allFindings.length,
|
||||
...scanResultMeta(options),
|
||||
}, '*');
|
||||
}
|
||||
|
||||
@@ -4313,7 +4672,7 @@ if (IS_BROWSER) {
|
||||
rememberVisualContrastAnalysis(result);
|
||||
const added = addVisualContrastResult(groupMap, result, { decorate: true });
|
||||
if (added) {
|
||||
postSerializedFindings(groupMap);
|
||||
postSerializedFindings(groupMap, options);
|
||||
window.dispatchEvent(new CustomEvent('impeccable-visual-contrast-resolved', {
|
||||
detail: {
|
||||
selector: result.selector,
|
||||
@@ -4381,7 +4740,7 @@ if (IS_BROWSER) {
|
||||
overlayIndex = 0;
|
||||
}
|
||||
|
||||
function renderBrowserFindings(collected) {
|
||||
function renderBrowserFindings(collected, options = {}) {
|
||||
const { allFindings, pageLevelFindings } = collected;
|
||||
|
||||
for (const { el, findings } of allFindings) {
|
||||
@@ -4401,6 +4760,7 @@ if (IS_BROWSER) {
|
||||
source: 'impeccable-results',
|
||||
findings: serializeFindings(allFindings),
|
||||
count: allFindings.length,
|
||||
...scanResultMeta(options),
|
||||
}, '*');
|
||||
}
|
||||
|
||||
@@ -4415,11 +4775,11 @@ if (IS_BROWSER) {
|
||||
clearOverlays();
|
||||
const generation = scanGeneration;
|
||||
const collected = collectBrowserFindings();
|
||||
const allFindings = renderBrowserFindings(collected);
|
||||
const allFindings = renderBrowserFindings(collected, options);
|
||||
if (shouldRunVisualContrast(options)) {
|
||||
addVisualContrastFindings(collected.groupMap, options, { decorate: true, generation })
|
||||
.then(() => {
|
||||
if (generation === scanGeneration) postSerializedFindings(collected.groupMap);
|
||||
if (generation === scanGeneration) postSerializedFindings(collected.groupMap, options);
|
||||
})
|
||||
.catch(err => {
|
||||
reportVisualContrastError(err);
|
||||
@@ -4434,10 +4794,10 @@ if (IS_BROWSER) {
|
||||
if (shouldRunVisualContrast(options)) {
|
||||
const collected = await collectBrowserFindingsAsync(options, { generation, scheduleLazy: true });
|
||||
if (generation !== scanGeneration) return [];
|
||||
return renderBrowserFindings(collected);
|
||||
return renderBrowserFindings(collected, options);
|
||||
}
|
||||
lastVisualContrastAnalyses = [];
|
||||
return renderBrowserFindings(collectBrowserFindings());
|
||||
return renderBrowserFindings(collectBrowserFindings(), options);
|
||||
};
|
||||
|
||||
const detect = function(options = {}) {
|
||||
|
||||
@@ -23,6 +23,18 @@ function stripHtmlToText(html) {
|
||||
.replace(/\s+/g, ' ');
|
||||
}
|
||||
|
||||
const PAGE_ANALYZER_EXTS = new Set(['.html', '.htm', '.astro', '.vue', '.svelte']);
|
||||
|
||||
function extFromFilePath(filePath) {
|
||||
return filePath ? (filePath.match(/\.\w+$/)?.[0] || '').toLowerCase() : '';
|
||||
}
|
||||
|
||||
function shouldRunPageAnalyzers(content, filePath) {
|
||||
if (!isFullPage(content)) return false;
|
||||
const ext = extFromFilePath(filePath);
|
||||
return !ext || PAGE_ANALYZER_EXTS.has(ext);
|
||||
}
|
||||
|
||||
function isNeutralBorderColor(str) {
|
||||
const m = str.match(/solid\s+(#[0-9a-f]{3,8}|rgba?\([^)]+\)|\w+)/i);
|
||||
if (!m) return false;
|
||||
@@ -422,7 +434,7 @@ const TEXT_CONTENT_ANALYZER_IDS = [
|
||||
|
||||
function runTextContentAnalyzers(content, filePath, options = {}) {
|
||||
const profile = options?.profile;
|
||||
if (!isFullPage(content)) return [];
|
||||
if (!shouldRunPageAnalyzers(content, filePath)) return [];
|
||||
// The 4 text-content analyzers are at indices 3-6 in REGEX_ANALYZERS.
|
||||
const findings = [];
|
||||
for (let i = 0; i < TEXT_CONTENT_ANALYZER_IDS.length; i++) {
|
||||
@@ -442,11 +454,11 @@ function detectText(content, filePath, options = {}) {
|
||||
const profile = options?.profile;
|
||||
const findings = [];
|
||||
const lines = content.split('\n');
|
||||
const ext = filePath ? (filePath.match(/\.\w+$/)?.[0] || '').toLowerCase() : '';
|
||||
const ext = extFromFilePath(filePath);
|
||||
|
||||
// Run regex matchers on the full file content (catches Tailwind classes, inline styles)
|
||||
// Enable block context for CSS files where related properties span multiple lines
|
||||
const cssLike = new Set(['.css', '.scss', '.less']);
|
||||
const cssLike = new Set(['.css', '.scss', '.sass', '.less']);
|
||||
findings.push(...runRegexMatchers(lines, filePath, 0, cssLike.has(ext) || null, {
|
||||
profile,
|
||||
phase: 'source',
|
||||
@@ -498,7 +510,7 @@ function detectText(content, filePath, options = {}) {
|
||||
}
|
||||
|
||||
// Page-level analyzers only run on full pages
|
||||
if (isFullPage(content)) {
|
||||
if (shouldRunPageAnalyzers(content, filePath)) {
|
||||
const analyzerIds = [
|
||||
'single-font',
|
||||
'flat-type-hierarchy',
|
||||
|
||||
@@ -267,7 +267,16 @@ const STATIC_DEFAULT_STYLE = {
|
||||
paddingRight: '0px',
|
||||
paddingBottom: '0px',
|
||||
paddingLeft: '0px',
|
||||
marginTop: '0px',
|
||||
marginRight: '0px',
|
||||
marginBottom: '0px',
|
||||
marginLeft: '0px',
|
||||
position: 'static',
|
||||
top: 'auto',
|
||||
right: 'auto',
|
||||
bottom: 'auto',
|
||||
left: 'auto',
|
||||
inset: '',
|
||||
display: '',
|
||||
overflow: 'visible',
|
||||
overflowX: 'visible',
|
||||
@@ -312,7 +321,16 @@ const STATIC_PROP_MAP = {
|
||||
'padding-right': 'paddingRight',
|
||||
'padding-bottom': 'paddingBottom',
|
||||
'padding-left': 'paddingLeft',
|
||||
'margin-top': 'marginTop',
|
||||
'margin-right': 'marginRight',
|
||||
'margin-bottom': 'marginBottom',
|
||||
'margin-left': 'marginLeft',
|
||||
'position': 'position',
|
||||
'top': 'top',
|
||||
'right': 'right',
|
||||
'bottom': 'bottom',
|
||||
'left': 'left',
|
||||
'inset': 'inset',
|
||||
'display': 'display',
|
||||
'overflow': 'overflow',
|
||||
'overflow-x': 'overflowX',
|
||||
@@ -579,6 +597,15 @@ function expandStaticDeclaration(prop, value) {
|
||||
['paddingLeft', vals[3]],
|
||||
];
|
||||
}
|
||||
if (p === 'margin') {
|
||||
const vals = expandStaticBoxValues(splitCssTokens(v));
|
||||
return [
|
||||
['marginTop', vals[0]],
|
||||
['marginRight', vals[1]],
|
||||
['marginBottom', vals[2]],
|
||||
['marginLeft', vals[3]],
|
||||
];
|
||||
}
|
||||
if (p === 'font') return parseStaticFont(v);
|
||||
if (p === 'transition') {
|
||||
const parsed = parseStaticTransition(v);
|
||||
|
||||
@@ -11,7 +11,7 @@ const SKIP_DIRS = new Set([
|
||||
]);
|
||||
|
||||
const SCANNABLE_EXTENSIONS = new Set([
|
||||
'.html', '.htm', '.css', '.scss', '.less',
|
||||
'.html', '.htm', '.css', '.scss', '.sass', '.less',
|
||||
'.jsx', '.tsx', '.js', '.ts',
|
||||
'.vue', '.svelte', '.astro',
|
||||
]);
|
||||
|
||||
@@ -974,11 +974,16 @@ function parseAnyColor(s) {
|
||||
// OKLCH parser. Tailwind v4's CSS minifier squishes the space after
|
||||
// `%` ("21.5%.02 50"), so the separator between L and C may be absent.
|
||||
// Match L (with optional %), then C and H separated permissively.
|
||||
m = str.match(/oklch\(\s*([\d.]+)(%?)\s*[\s,]*\s*([\d.]+)\s*[\s,]+\s*([-\d.]+)(?:deg)?\s*\)/i);
|
||||
m = str.match(/oklch\(\s*([\d.]+)(%?)\s*[\s,]*\s*([\d.]+)\s*[\s,]+\s*([-\d.]+)(?:deg)?(?:\s*\/\s*([\d.]+)(%)?)?\s*\)/i);
|
||||
if (m) {
|
||||
const Lnum = parseFloat(m[1]);
|
||||
const L = m[2] === '%' ? Lnum / 100 : Lnum;
|
||||
return oklchToRgb(L, parseFloat(m[3]), parseFloat(m[4]));
|
||||
const rgb = oklchToRgb(L, parseFloat(m[3]), parseFloat(m[4]));
|
||||
if (m[5] !== undefined) {
|
||||
const alpha = parseFloat(m[5]);
|
||||
rgb.a = m[6] === '%' ? alpha / 100 : alpha;
|
||||
}
|
||||
return rgb;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -1007,9 +1012,19 @@ const REPEATED_KICKER_SKIP_SELECTOR = [
|
||||
'[role="navigation"]',
|
||||
'[aria-label*="breadcrumb" i]',
|
||||
'[class*="breadcrumb" i]',
|
||||
'[aria-hidden="true"]',
|
||||
'[data-impeccable-allow-kickers]',
|
||||
].join(',');
|
||||
|
||||
const REPEATED_KICKER_CARD_CONTEXT_SELECTOR = [
|
||||
'article',
|
||||
'button',
|
||||
'a',
|
||||
'li',
|
||||
'[role="listitem"]',
|
||||
'[role="option"]',
|
||||
].join(',');
|
||||
|
||||
function cleanInlineText(el) {
|
||||
return [...el.childNodes]
|
||||
.filter(n => n.nodeType === 3)
|
||||
@@ -1019,6 +1034,11 @@ function cleanInlineText(el) {
|
||||
.trim();
|
||||
}
|
||||
|
||||
function isRepeatedKickerCardContext(heading, kicker) {
|
||||
const item = heading.closest?.(REPEATED_KICKER_CARD_CONTEXT_SELECTOR);
|
||||
return Boolean(item && (!item.contains || item.contains(kicker)));
|
||||
}
|
||||
|
||||
function isRepeatedKickerCandidate(opts) {
|
||||
const {
|
||||
headingTag,
|
||||
@@ -1032,6 +1052,7 @@ function isRepeatedKickerCandidate(opts) {
|
||||
} = opts;
|
||||
if (!['h2', 'h3', 'h4'].includes(headingTag)) return false;
|
||||
if (!headingText || headingText.length < 3) return false;
|
||||
if (/^\/[\w-]+/i.test(headingText.replace(/^"|"$/g, '').trim())) return false;
|
||||
if (!(headingFontSize >= 20)) return false;
|
||||
if (!kickerTag || HEADING_TAGS.has(kickerTag)) return false;
|
||||
if (!['p', 'span', 'div', 'small'].includes(kickerTag)) return false;
|
||||
@@ -1053,6 +1074,7 @@ function collectRepeatedSectionKickerCandidates(doc, getStyle, resolveLetterSpac
|
||||
if (heading.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue;
|
||||
const kicker = heading.previousElementSibling;
|
||||
if (!kicker || kicker.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue;
|
||||
if (isRepeatedKickerCardContext(heading, kicker)) continue;
|
||||
|
||||
const headingStyle = getStyle(heading);
|
||||
const kickerStyle = getStyle(kicker);
|
||||
@@ -1235,6 +1257,84 @@ function resolveLengthPx(value, fontSizePx) {
|
||||
return num * fontSizePx;
|
||||
}
|
||||
|
||||
function cssColorIsTransparent(value) {
|
||||
if (!value) return true;
|
||||
const str = String(value).trim().toLowerCase();
|
||||
if (!str || str === 'transparent' || str === 'rgba(0, 0, 0, 0)') return true;
|
||||
const parsed = parseAnyColor(str);
|
||||
if (parsed) return (parsed.a ?? 1) <= 0.05;
|
||||
return /^rgba\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*,\s*0(?:\.0+)?\s*\)$/.test(str);
|
||||
}
|
||||
|
||||
function colorsNearlyMatch(a, b) {
|
||||
const ca = parseAnyColor(a);
|
||||
const cb = parseAnyColor(b);
|
||||
if (!ca || !cb) return false;
|
||||
const alphaDelta = Math.abs((ca.a ?? 1) - (cb.a ?? 1));
|
||||
const channelDelta = Math.max(
|
||||
Math.abs(ca.r - cb.r),
|
||||
Math.abs(ca.g - cb.g),
|
||||
Math.abs(ca.b - cb.b),
|
||||
);
|
||||
return alphaDelta <= 0.03 && channelDelta <= 3;
|
||||
}
|
||||
|
||||
function getComputedStyleFor(win, el) {
|
||||
if (win && typeof win.getComputedStyle === 'function') {
|
||||
try { return win.getComputedStyle(el); } catch {}
|
||||
}
|
||||
if (typeof getComputedStyle === 'function') {
|
||||
try { return getComputedStyle(el); } catch {}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function hasVisibleBackgroundBoundary(style, el, win) {
|
||||
const bg = style?.backgroundColor || '';
|
||||
if (cssColorIsTransparent(bg)) return false;
|
||||
|
||||
let parent = el?.parentElement || null;
|
||||
while (parent) {
|
||||
const parentStyle = getComputedStyleFor(win, parent);
|
||||
const parentBg = parentStyle?.backgroundColor || '';
|
||||
if (!cssColorIsTransparent(parentBg)) {
|
||||
return !colorsNearlyMatch(bg, parentBg);
|
||||
}
|
||||
parent = parent.parentElement;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
const TEXT_EDGE_TAGS = new Set(['A', 'BUTTON', 'CODE', 'DD', 'DT', 'FIGCAPTION', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'LI', 'P', 'PRE', 'SPAN', 'TD', 'TH']);
|
||||
|
||||
function hasMeaningfulDirectText(node) {
|
||||
if (!node?.childNodes) return false;
|
||||
for (const child of node.childNodes) {
|
||||
if (child.nodeType === 3 && child.textContent.trim().length > 4) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function textDescendantsFlushSides(el, rect) {
|
||||
const flush = { top: false, right: false, bottom: false, left: false };
|
||||
if (!rect || !el?.querySelectorAll) return flush;
|
||||
const TEXT_EDGE_THRESHOLD = 4;
|
||||
const candidates = el.querySelectorAll('a, button, code, dd, dt, figcaption, h1, h2, h3, h4, h5, h6, li, p, pre, span, td, th');
|
||||
for (const node of candidates) {
|
||||
if (!TEXT_EDGE_TAGS.has(node.tagName) || !hasMeaningfulDirectText(node)) continue;
|
||||
let nodeRect = null;
|
||||
try { nodeRect = node.getBoundingClientRect(); } catch {}
|
||||
if (!nodeRect || nodeRect.width <= 0 || nodeRect.height <= 0) continue;
|
||||
if (nodeRect.bottom < rect.top || nodeRect.top > rect.bottom || nodeRect.right < rect.left || nodeRect.left > rect.right) continue;
|
||||
if (nodeRect.top - rect.top <= TEXT_EDGE_THRESHOLD) flush.top = true;
|
||||
if (rect.right - nodeRect.right <= TEXT_EDGE_THRESHOLD) flush.right = true;
|
||||
if (rect.bottom - nodeRect.bottom <= TEXT_EDGE_THRESHOLD) flush.bottom = true;
|
||||
if (nodeRect.left - rect.left <= TEXT_EDGE_THRESHOLD) flush.left = true;
|
||||
}
|
||||
return flush;
|
||||
}
|
||||
|
||||
// Pure quality checks. Most run on computed CSS and DOM-only inputs (work in
|
||||
// jsdom and the browser). Two checks (line-length, cramped-padding) gate on
|
||||
// element rect dimensions, which jsdom can't compute — pass `rect: null` from
|
||||
@@ -1264,7 +1364,8 @@ function checkQuality(opts) {
|
||||
// font-size — bigger text demands proportionally more padding.
|
||||
// vertical: max(4px, fontSize × 0.3)
|
||||
// horizontal: max(8px, fontSize × 0.5)
|
||||
if (rect && hasDirectText && textLen > 20 && rect.width > 100 && rect.height > 30) {
|
||||
const isInlineCode = tag === 'code' && !(el.closest && el.closest('pre'));
|
||||
if (!isInlineCode && rect && hasDirectText && textLen > 20 && rect.width > 100 && rect.height > 30) {
|
||||
const borders = {
|
||||
top: parseFloat(style.borderTopWidth) || 0,
|
||||
right: parseFloat(style.borderRightWidth) || 0,
|
||||
@@ -1272,7 +1373,7 @@ function checkQuality(opts) {
|
||||
left: parseFloat(style.borderLeftWidth) || 0,
|
||||
};
|
||||
const borderCount = Object.values(borders).filter(w => w > 0).length;
|
||||
const hasBg = style.backgroundColor && style.backgroundColor !== 'rgba(0, 0, 0, 0)';
|
||||
const hasBg = hasVisibleBackgroundBoundary(style, el, win);
|
||||
if (borderCount >= 2 || hasBg) {
|
||||
const vPads = [], hPads = [];
|
||||
if (hasBg || borders.top > 0) vPads.push(parseFloat(style.paddingTop) || 0);
|
||||
@@ -1320,10 +1421,6 @@ function checkQuality(opts) {
|
||||
!['fixed', 'absolute'].includes(elPosition) &&
|
||||
el.children && el.children.length > 0
|
||||
) {
|
||||
const isTransparent = (c) =>
|
||||
!c || c === 'transparent' || c === 'rgba(0, 0, 0, 0)' ||
|
||||
/^rgba\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*,\s*0(?:\.0+)?\s*\)$/.test(c);
|
||||
|
||||
const borderW = {
|
||||
top: parseFloat(style.borderTopWidth) || 0,
|
||||
right: parseFloat(style.borderRightWidth) || 0,
|
||||
@@ -1331,10 +1428,10 @@ function checkQuality(opts) {
|
||||
left: parseFloat(style.borderLeftWidth) || 0,
|
||||
};
|
||||
const borderVisible = {
|
||||
top: borderW.top > 0 && !isTransparent(style.borderTopColor),
|
||||
right: borderW.right > 0 && !isTransparent(style.borderRightColor),
|
||||
bottom: borderW.bottom > 0 && !isTransparent(style.borderBottomColor),
|
||||
left: borderW.left > 0 && !isTransparent(style.borderLeftColor),
|
||||
top: borderW.top > 0 && !cssColorIsTransparent(style.borderTopColor),
|
||||
right: borderW.right > 0 && !cssColorIsTransparent(style.borderRightColor),
|
||||
bottom: borderW.bottom > 0 && !cssColorIsTransparent(style.borderBottomColor),
|
||||
left: borderW.left > 0 && !cssColorIsTransparent(style.borderLeftColor),
|
||||
};
|
||||
// Outline detection. jsdom decomposes `border` shorthand into
|
||||
// border{Top,…}Width/Color but does NOT decompose `outline` —
|
||||
@@ -1354,8 +1451,8 @@ function checkQuality(opts) {
|
||||
if (cMatch) outlineColorVal = cMatch[1];
|
||||
}
|
||||
}
|
||||
const outlineVisible = outlineW > 0 && !isTransparent(outlineColorVal) && outlineStyleVal && outlineStyleVal !== 'none';
|
||||
const bgVisible = !isTransparent(style.backgroundColor);
|
||||
const outlineVisible = outlineW > 0 && !cssColorIsTransparent(outlineColorVal) && outlineStyleVal && outlineStyleVal !== 'none';
|
||||
const bgVisible = hasVisibleBackgroundBoundary(style, el, win);
|
||||
|
||||
const anyVisible = borderVisible.top || borderVisible.right || borderVisible.bottom || borderVisible.left || outlineVisible || bgVisible;
|
||||
if (anyVisible) {
|
||||
@@ -1383,13 +1480,7 @@ function checkQuality(opts) {
|
||||
const CHILD_INSULATE_THRESHOLD = 4;
|
||||
const childrenInsulate = { top: false, right: false, bottom: false, left: false };
|
||||
for (const child of el.children) {
|
||||
let childStyle = null;
|
||||
if (win && typeof win.getComputedStyle === 'function') {
|
||||
try { childStyle = win.getComputedStyle(child); } catch {}
|
||||
}
|
||||
if (!childStyle && typeof getComputedStyle === 'function') {
|
||||
try { childStyle = getComputedStyle(child); } catch {}
|
||||
}
|
||||
let childStyle = getComputedStyleFor(win, child);
|
||||
if (!childStyle) continue;
|
||||
const childPad = {
|
||||
top: resolveLengthPx(childStyle.paddingTop, fontSize) ?? 0,
|
||||
@@ -1397,15 +1488,37 @@ function checkQuality(opts) {
|
||||
bottom: resolveLengthPx(childStyle.paddingBottom, fontSize) ?? 0,
|
||||
left: resolveLengthPx(childStyle.paddingLeft, fontSize) ?? 0,
|
||||
};
|
||||
const childMargin = {
|
||||
top: resolveLengthPx(childStyle.marginTop, fontSize) ?? 0,
|
||||
right: resolveLengthPx(childStyle.marginRight, fontSize) ?? 0,
|
||||
bottom: resolveLengthPx(childStyle.marginBottom, fontSize) ?? 0,
|
||||
left: resolveLengthPx(childStyle.marginLeft, fontSize) ?? 0,
|
||||
};
|
||||
if (rect && typeof child.getBoundingClientRect === 'function') {
|
||||
try {
|
||||
const childRect = child.getBoundingClientRect();
|
||||
if (childRect && childRect.width > 0 && childRect.height > 0) {
|
||||
if (childRect.top - rect.top >= CHILD_INSULATE_THRESHOLD) childrenInsulate.top = true;
|
||||
if (rect.right - childRect.right >= CHILD_INSULATE_THRESHOLD) childrenInsulate.right = true;
|
||||
if (rect.bottom - childRect.bottom >= CHILD_INSULATE_THRESHOLD) childrenInsulate.bottom = true;
|
||||
if (childRect.left - rect.left >= CHILD_INSULATE_THRESHOLD) childrenInsulate.left = true;
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
for (const s of ['top', 'right', 'bottom', 'left']) {
|
||||
if (childPad[s] >= CHILD_INSULATE_THRESHOLD) childrenInsulate[s] = true;
|
||||
if (childPad[s] >= CHILD_INSULATE_THRESHOLD || childMargin[s] >= CHILD_INSULATE_THRESHOLD) {
|
||||
childrenInsulate[s] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const textFlush = rect ? textDescendantsFlushSides(el, rect) : null;
|
||||
const fullBleedBgBand = rect && viewportWidth > 0 && rect.width >= viewportWidth * 0.94 && bgVisible && !outlineVisible;
|
||||
const flushSides = [];
|
||||
for (const side of ['top', 'right', 'bottom', 'left']) {
|
||||
const sideBounded = borderVisible[side] || outlineVisible || bgVisible;
|
||||
if (sideBounded && pad[side] <= PAD_THRESHOLD && !childrenInsulate[side]) {
|
||||
const bgBoundsSide = bgVisible && !(fullBleedBgBand && (side === 'left' || side === 'right'));
|
||||
const sideBounded = borderVisible[side] || outlineVisible || bgBoundsSide;
|
||||
if (sideBounded && pad[side] <= PAD_THRESHOLD && !childrenInsulate[side] && (!textFlush || textFlush[side])) {
|
||||
flushSides.push(side);
|
||||
}
|
||||
}
|
||||
@@ -1499,7 +1612,7 @@ function checkQuality(opts) {
|
||||
// Only flag actual body content, not UI labels (buttons, tabs, badges, captions, footer text, etc.)
|
||||
if (hasDirectText && textLen > 20 && fontSize < 12) {
|
||||
const skipTags = ['sub', 'sup', 'code', 'kbd', 'samp', 'var', 'caption', 'figcaption'];
|
||||
const inUIContext = el.closest && el.closest('button, a, label, summary, [role="button"], [role="link"], [role="tab"], [role="menuitem"], [role="option"], nav, footer, [class*="badge" i], [class*="chip" i], [class*="pill" i], [class*="tag" i], [class*="label" i], [class*="caption" i]');
|
||||
const inUIContext = el.closest && el.closest('button, a, label, summary, pre, [role="button"], [role="link"], [role="tab"], [role="menuitem"], [role="option"], nav, footer, [aria-hidden="true"], [class*="badge" i], [class*="caption" i], [class*="chip" i], [class*="code" i], [class*="console" i], [class*="diff" i], [class*="label" i], [class*="meta" i], [class*="mock" i], [class*="pill" i], [class*="preview" i], [class*="tag" i], [class*="terminal" i], [class*="writes" i]');
|
||||
const isUppercase = style.textTransform === 'uppercase';
|
||||
if (!skipTags.includes(tag) && !inUIContext && !isUppercase) {
|
||||
findings.push({ id: 'tiny-text', snippet: `${fontSize}px body text` });
|
||||
@@ -2107,17 +2220,28 @@ function checkCreamPalette(doc, win) {
|
||||
}
|
||||
|
||||
// ─── Oversized hero headline ────────────────────────────────────────────────
|
||||
// Fires when a *long* headline is set at display size, so a full sentence ends
|
||||
// up dominating the viewport. A punchy one- or two-word headline at the same
|
||||
// size is a legitimate stylistic choice and must pass — length, not size
|
||||
// alone, is the tell.
|
||||
// Fires when a *long* headline is set at display size and actually dominates
|
||||
// the viewport. A punchy one- or two-word headline at the same size is a
|
||||
// legitimate stylistic choice, and a large-but-contained two-line hero should
|
||||
// pass too — length and viewport share together are the tell.
|
||||
const OVERSIZED_H1_FONT_PX = 72;
|
||||
const OVERSIZED_H1_MIN_CHARS = 40;
|
||||
function checkOversizedH1({ tag, fontSize, headingText }) {
|
||||
const OVERSIZED_H1_MIN_VIEWPORT_HEIGHT_RATIO = 0.28;
|
||||
const OVERSIZED_H1_MIN_VIEWPORT_AREA_RATIO = 0.25;
|
||||
function checkOversizedH1({ tag, fontSize, headingText, rect = null, viewportWidth = 0, viewportHeight = 0 }) {
|
||||
if (tag !== 'h1') return [];
|
||||
const textLen = headingText.length;
|
||||
if (fontSize >= OVERSIZED_H1_FONT_PX && textLen >= OVERSIZED_H1_MIN_CHARS) {
|
||||
return [{ id: 'oversized-h1', snippet: `${Math.round(fontSize)}px h1, ${textLen} chars "${headingText.slice(0, 60)}"` }];
|
||||
let viewportDetail = '';
|
||||
if (rect && viewportWidth > 0 && viewportHeight > 0) {
|
||||
const heightRatio = rect.height / viewportHeight;
|
||||
const areaRatio = (rect.width * rect.height) / (viewportWidth * viewportHeight);
|
||||
const dominatesViewport = heightRatio >= OVERSIZED_H1_MIN_VIEWPORT_HEIGHT_RATIO
|
||||
|| areaRatio >= OVERSIZED_H1_MIN_VIEWPORT_AREA_RATIO;
|
||||
if (!dominatesViewport) return [];
|
||||
viewportDetail = `, ${Math.round(heightRatio * 100)}vh`;
|
||||
}
|
||||
return [{ id: 'oversized-h1', snippet: `${Math.round(fontSize)}px h1, ${textLen} chars${viewportDetail} "${headingText.slice(0, 60)}"` }];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
@@ -2135,31 +2259,54 @@ function checkElementOversizedH1DOM(el) {
|
||||
const style = getComputedStyle(el);
|
||||
const fontSize = parseFloat(style.fontSize) || 0;
|
||||
const headingText = (el.textContent || '').trim().replace(/\s+/g, ' ');
|
||||
return checkOversizedH1({ tag, fontSize, headingText });
|
||||
const rect = el.getBoundingClientRect();
|
||||
const viewportWidth = (typeof window !== 'undefined' ? window.innerWidth : 0) || 0;
|
||||
const viewportHeight = (typeof window !== 'undefined' ? window.innerHeight : 0) || 0;
|
||||
return checkOversizedH1({ tag, fontSize, headingText, rect, viewportWidth, viewportHeight });
|
||||
}
|
||||
|
||||
// ─── GPT tell: hairline border + wide diffuse shadow (gated --gpt) ────────────
|
||||
function shadowMaxBlurPx(boxShadow) {
|
||||
const CSS_COLOR_TOKEN_RE = /(?:rgba?|hsla?|oklch|oklab|lab|lch|color)\([^)]*\)|#[0-9a-fA-F]{3,8}\b|\b(?:black|white|transparent|currentcolor)\b/gi;
|
||||
|
||||
function shadowLayerAlpha(layer) {
|
||||
CSS_COLOR_TOKEN_RE.lastIndex = 0;
|
||||
const match = CSS_COLOR_TOKEN_RE.exec(layer);
|
||||
if (!match) return 1;
|
||||
if (match[0].toLowerCase() === 'transparent') return 0;
|
||||
const parsed = parseAnyColor(match[0]);
|
||||
return parsed ? (parsed.a ?? 1) : 1;
|
||||
}
|
||||
|
||||
function shadowMaxBlurPx(boxShadow, { minAlpha = 0 } = {}) {
|
||||
if (!boxShadow || boxShadow === 'none') return 0;
|
||||
let maxBlur = 0;
|
||||
// Split into layers on commas not inside parentheses (rgba(...) etc.).
|
||||
for (const layer of boxShadow.split(/,(?![^()]*\))/)) {
|
||||
if (shadowLayerAlpha(layer) < minAlpha) continue;
|
||||
// Strip colors and keywords (rgba()/hsl()/hex/named/inset/px), leaving the
|
||||
// ordered length tokens: offsetX offsetY blur [spread]. Static jsdom keeps
|
||||
// unitless zeros ("0 0 24px"); browsers normalize to px ("0px 0px 24px") —
|
||||
// both reduce to the same numbers here.
|
||||
const cleaned = layer.replace(/rgba?\([^)]*\)|hsla?\([^)]*\)|#[0-9a-f]+|\b[a-z]+\b/gi, ' ');
|
||||
const cleaned = layer.replace(CSS_COLOR_TOKEN_RE, ' ').replace(/\b[a-z]+\b/gi, ' ');
|
||||
const nums = [...cleaned.matchAll(/-?\d*\.?\d+/g)].map(m => parseFloat(m[0]));
|
||||
if (nums.length >= 3) maxBlur = Math.max(maxBlur, nums[2]);
|
||||
}
|
||||
return maxBlur;
|
||||
}
|
||||
|
||||
function checkGptThinBorderWideShadow({ borderWidths, boxShadow }) {
|
||||
const maxBorder = Math.max(0, ...borderWidths);
|
||||
const hasThinBorder = maxBorder > 0 && maxBorder <= 1.5;
|
||||
const blur = shadowMaxBlurPx(boxShadow);
|
||||
if (hasThinBorder && blur >= 16) {
|
||||
function cssColorAlpha(value) {
|
||||
if (cssColorIsTransparent(value)) return 0;
|
||||
const parsed = parseAnyColor(value);
|
||||
return parsed ? (parsed.a ?? 1) : 1;
|
||||
}
|
||||
|
||||
function checkGptThinBorderWideShadow({ borderWidths, borderColors, boxShadow }) {
|
||||
const visibleThinBorders = borderWidths
|
||||
.map((width, index) => ({ width, alpha: cssColorAlpha(borderColors?.[index] || '') }))
|
||||
.filter(({ width, alpha }) => width > 0 && width <= 1.5 && alpha >= 0.28);
|
||||
const maxBorder = Math.max(0, ...visibleThinBorders.map(({ width }) => width));
|
||||
const blur = shadowMaxBlurPx(boxShadow, { minAlpha: 0.12 });
|
||||
if (visibleThinBorders.length >= 2 && blur >= 16) {
|
||||
return [{ id: 'gpt-thin-border-wide-shadow', snippet: `${maxBorder}px border + ${Math.round(blur)}px shadow blur` }];
|
||||
}
|
||||
return [];
|
||||
@@ -2174,13 +2321,22 @@ function borderWidthsFromStyle(style) {
|
||||
];
|
||||
}
|
||||
|
||||
function borderColorsFromStyle(style) {
|
||||
return [
|
||||
style.borderTopColor || '',
|
||||
style.borderRightColor || '',
|
||||
style.borderBottomColor || '',
|
||||
style.borderLeftColor || '',
|
||||
];
|
||||
}
|
||||
|
||||
function checkElementGptBorderShadow(el, style) {
|
||||
return checkGptThinBorderWideShadow({ borderWidths: borderWidthsFromStyle(style), boxShadow: style.boxShadow || '' });
|
||||
return checkGptThinBorderWideShadow({ borderWidths: borderWidthsFromStyle(style), borderColors: borderColorsFromStyle(style), boxShadow: style.boxShadow || '' });
|
||||
}
|
||||
|
||||
function checkElementGptBorderShadowDOM(el) {
|
||||
const style = getComputedStyle(el);
|
||||
return checkGptThinBorderWideShadow({ borderWidths: borderWidthsFromStyle(style), boxShadow: style.boxShadow || '' });
|
||||
return checkGptThinBorderWideShadow({ borderWidths: borderWidthsFromStyle(style), borderColors: borderColorsFromStyle(style), boxShadow: style.boxShadow || '' });
|
||||
}
|
||||
|
||||
// ─── Clipped overflow container ───────────────────────────────────────────────
|
||||
@@ -2193,17 +2349,131 @@ function classSelector(el) {
|
||||
return tokens.length ? `${tag}.${tokens.join('.')}` : tag;
|
||||
}
|
||||
|
||||
function positionedChildIsDecorative(child) {
|
||||
if (!child || typeof child.getAttribute !== 'function') return false;
|
||||
if (child.closest?.('[aria-hidden="true"]')) return true;
|
||||
const role = (child.getAttribute('role') || '').toLowerCase();
|
||||
if (role === 'none' || role === 'presentation') return true;
|
||||
const tag = child.tagName ? child.tagName.toLowerCase() : '';
|
||||
if (['img', 'svg', 'canvas', 'video'].includes(tag)) return true;
|
||||
const ident = `${child.getAttribute('class') || ''} ${child.getAttribute('id') || ''}`;
|
||||
if (
|
||||
/\b(art|bg|background|badge|blob|crop|decor|dot|glow|grain|image|mask|ornament|overlay|photo|scrim|shadow|shine|texture)\b/i.test(ident) &&
|
||||
!positionedChildHasSubstantiveContent(child)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const POSITIONED_CHILD_INTERACTIVE_SELECTOR = [
|
||||
'a[href]',
|
||||
'button',
|
||||
'input',
|
||||
'select',
|
||||
'summary',
|
||||
'textarea',
|
||||
'[tabindex]:not([tabindex="-1"])',
|
||||
'[role="button"]',
|
||||
'[role="dialog"]',
|
||||
'[role="link"]',
|
||||
'[role="listbox"]',
|
||||
'[role="menu"]',
|
||||
'[role="menuitem"]',
|
||||
'[role="option"]',
|
||||
'[role="tooltip"]',
|
||||
].join(',');
|
||||
|
||||
function positionedChildHasSubstantiveContent(child) {
|
||||
const text = (child.textContent || '').replace(/\s+/g, ' ').trim();
|
||||
if (text.length > 0) return true;
|
||||
if (typeof child.matches === 'function') {
|
||||
try {
|
||||
if (child.matches(POSITIONED_CHILD_INTERACTIVE_SELECTOR)) return true;
|
||||
} catch {}
|
||||
}
|
||||
if (typeof child.querySelector === 'function') {
|
||||
try {
|
||||
if (child.querySelector(POSITIONED_CHILD_INTERACTIVE_SELECTOR)) return true;
|
||||
} catch {}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function clippingContainerIsIntentionalViewport(el) {
|
||||
if (!el || typeof el.getAttribute !== 'function') return false;
|
||||
const roleDescription = (el.getAttribute('aria-roledescription') || '').toLowerCase();
|
||||
if (/\b(carousel|slider)\b/.test(roleDescription)) return true;
|
||||
const ident = `${el.getAttribute('class') || ''} ${el.getAttribute('id') || ''}`.toLowerCase();
|
||||
return /\b(carousel|comparison|compare|fisheye|marquee|preview|scroller|slider|slideshow|split|viewport)\b/.test(ident) ||
|
||||
/\b(demo-area|demo-stage|demo-viewport)\b/.test(ident);
|
||||
}
|
||||
|
||||
function elementRect(el) {
|
||||
if (!el || typeof el.getBoundingClientRect !== 'function') return null;
|
||||
try {
|
||||
const rect = el.getBoundingClientRect();
|
||||
if (!rect) return null;
|
||||
const values = [rect.top, rect.right, rect.bottom, rect.left, rect.width, rect.height];
|
||||
if (!values.every(Number.isFinite)) return null;
|
||||
if (rect.width <= 0 && rect.height <= 0) return null;
|
||||
return rect;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function positionedStyleImpliesEscape(style) {
|
||||
const values = [
|
||||
style.top,
|
||||
style.right,
|
||||
style.bottom,
|
||||
style.left,
|
||||
style.inset,
|
||||
style.insetBlock,
|
||||
style.insetInline,
|
||||
style.insetBlockStart,
|
||||
style.insetBlockEnd,
|
||||
style.insetInlineStart,
|
||||
style.insetInlineEnd,
|
||||
].filter(Boolean).map(value => String(value).trim().toLowerCase());
|
||||
for (const value of values) {
|
||||
if (/(^|[\s(])-+(?:\d|\.)/.test(value)) return true;
|
||||
if (/(^|[\s(])100(?:\.0+)?%/.test(value)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function positionedChildEscapesClip(el, child, clipX, clipY) {
|
||||
const parentRect = elementRect(el);
|
||||
const childRect = elementRect(child);
|
||||
if (!parentRect || !childRect) return null;
|
||||
const threshold = 2;
|
||||
return Boolean(
|
||||
(clipX && (childRect.left < parentRect.left - threshold || childRect.right > parentRect.right + threshold)) ||
|
||||
(clipY && (childRect.top < parentRect.top - threshold || childRect.bottom > parentRect.bottom + threshold))
|
||||
);
|
||||
}
|
||||
|
||||
function checkClippedOverflow(el, style, getStyle) {
|
||||
const clips = (v) => v === 'hidden' || v === 'clip';
|
||||
const scrolls = (v) => v === 'auto' || v === 'scroll';
|
||||
const ox = style.overflowX || '', oy = style.overflowY || '', ov = style.overflow || '';
|
||||
const anyClip = clips(ox) || clips(oy) || clips(ov);
|
||||
const clipX = clips(ox) || clips(ov);
|
||||
const clipY = clips(oy) || clips(ov);
|
||||
const anyClip = clipX || clipY;
|
||||
const anyScroll = scrolls(ox) || scrolls(oy) || scrolls(ov);
|
||||
if (!anyClip || anyScroll) return [];
|
||||
if (clippingContainerIsIntentionalViewport(el)) return [];
|
||||
if (!el.querySelectorAll) return [];
|
||||
for (const child of el.querySelectorAll('*')) {
|
||||
const pos = (getStyle(child).position) || '';
|
||||
const childStyle = getStyle(child);
|
||||
const pos = childStyle.position || '';
|
||||
if (pos === 'absolute' || pos === 'fixed') {
|
||||
if (positionedChildIsDecorative(child)) continue;
|
||||
const escapes = positionedChildEscapesClip(el, child, clipX, clipY);
|
||||
if (escapes === false) continue;
|
||||
if (escapes === null && !positionedStyleImpliesEscape(childStyle)) continue;
|
||||
return [{ id: 'clipped-overflow-container', snippet: `${classSelector(el)} clips a positioned child` }];
|
||||
}
|
||||
}
|
||||
@@ -2222,14 +2492,94 @@ function checkElementClippedOverflowDOM(el) {
|
||||
// ─── Text overflow (browser-only: needs scrollWidth/clientWidth) ──────────────
|
||||
const TEXT_OVERFLOW_SKIP_TAGS = new Set(['pre', 'code', 'textarea', 'svg', 'canvas', 'select', 'option', 'marquee']);
|
||||
|
||||
function metricLengthPx(value, fontSizePx = 16) {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) return value;
|
||||
if (typeof value !== 'string') return null;
|
||||
return resolveLengthPx(value, fontSizePx);
|
||||
}
|
||||
|
||||
function firstMetricLengthPx(fontSizePx, ...values) {
|
||||
for (const value of values) {
|
||||
const parsed = metricLengthPx(value, fontSizePx);
|
||||
if (parsed !== null) return parsed;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function expandBoxShorthand(parts) {
|
||||
if (parts.length === 1) return [parts[0], parts[0], parts[0], parts[0]];
|
||||
if (parts.length === 2) return [parts[0], parts[1], parts[0], parts[1]];
|
||||
if (parts.length === 3) return [parts[0], parts[1], parts[2], parts[1]];
|
||||
return [parts[0], parts[1], parts[2], parts[3]];
|
||||
}
|
||||
|
||||
function clippedByInset(clipPath) {
|
||||
const match = String(clipPath || '').trim().toLowerCase().match(/^inset\s*\(([^)]*)\)$/);
|
||||
if (!match) return false;
|
||||
const beforeRound = match[1].split(/\s+round\s+/)[0].trim();
|
||||
if (!beforeRound) return false;
|
||||
const values = expandBoxShorthand(beforeRound.split(/\s+/).slice(0, 4));
|
||||
const percents = values.map(value => String(value).trim().match(/^(-?\d+(?:\.\d+)?)%$/));
|
||||
if (percents.some(match => !match)) return false;
|
||||
const [top, right, bottom, left] = percents.map(match => parseFloat(match[1]));
|
||||
return top + bottom >= 100 || left + right >= 100;
|
||||
}
|
||||
|
||||
function clippedByRect(clip) {
|
||||
const match = String(clip || '').trim().toLowerCase().match(/^rect\s*\(([^)]*)\)$/);
|
||||
if (!match) return false;
|
||||
const values = match[1].split(/[,\s]+/).map(value => value.trim()).filter(Boolean);
|
||||
if (values.length !== 4) return false;
|
||||
const [top, right, bottom, left] = values.map(value => metricLengthPx(value, 16));
|
||||
if ([top, right, bottom, left].some(value => value === null)) return false;
|
||||
return bottom <= top || right <= left;
|
||||
}
|
||||
|
||||
function isScreenReaderOnlyTextStyle(style, metrics = {}) {
|
||||
if (!style) return false;
|
||||
const overflowValues = [style.overflow, style.overflowX, style.overflowY]
|
||||
.map(value => String(value || '').toLowerCase());
|
||||
const clipsOverflow = overflowValues.some(value => value === 'hidden' || value === 'clip');
|
||||
|
||||
const fontSize = metricLengthPx(style.fontSize, 16) || 16;
|
||||
const width = firstMetricLengthPx(fontSize, metrics.width, metrics.clientWidth, style.width, style.inlineSize);
|
||||
const height = firstMetricLengthPx(fontSize, metrics.height, metrics.clientHeight, style.height, style.blockSize);
|
||||
const isTiny = width !== null && height !== null && width <= 2 && height <= 2;
|
||||
const isAbsolutelyHidden = String(style.position || '').toLowerCase() === 'absolute' && isTiny && clipsOverflow;
|
||||
|
||||
const clipPath = String(style.clipPath || style.webkitClipPath || '').trim();
|
||||
const clip = String(style.clip || '').trim();
|
||||
return isAbsolutelyHidden || clippedByInset(clipPath) || clippedByRect(clip);
|
||||
}
|
||||
|
||||
function isRenderedForBrowserRule(el) {
|
||||
for (let cur = el; cur && cur.nodeType === 1; cur = cur.parentElement) {
|
||||
if (cur.getAttribute?.('aria-hidden') === 'true') return false;
|
||||
const style = getComputedStyle(cur);
|
||||
const visibility = String(style.visibility || '').toLowerCase();
|
||||
if (style.display === 'none' || visibility === 'hidden' || visibility === 'collapse') return false;
|
||||
if ((parseFloat(style.opacity) || 0) <= 0.01) return false;
|
||||
if (String(style.contentVisibility || '').toLowerCase() === 'hidden') return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function checkElementTextOverflowDOM(el) {
|
||||
const tag = el.tagName.toLowerCase();
|
||||
if (TEXT_OVERFLOW_SKIP_TAGS.has(tag)) return [];
|
||||
if (!isRenderedForBrowserRule(el)) return [];
|
||||
// Only the element that actually owns overflowing text — not its ancestors,
|
||||
// which inherit a wider scrollWidth from the spilling descendant.
|
||||
const hasDirectText = [...el.childNodes].some(n => n.nodeType === 3 && n.textContent.trim().length > 0);
|
||||
if (!hasDirectText) return [];
|
||||
const style = getComputedStyle(el);
|
||||
const rect = el.getBoundingClientRect ? el.getBoundingClientRect() : null;
|
||||
if (isScreenReaderOnlyTextStyle(style, {
|
||||
width: rect?.width,
|
||||
height: rect?.height,
|
||||
clientWidth: el.clientWidth,
|
||||
clientHeight: el.clientHeight,
|
||||
})) return [];
|
||||
const isScrollRegion = (s) => /(auto|scroll)/.test(s.overflowX || '') || /(auto|scroll)/.test(s.overflow || '');
|
||||
if (isScrollRegion(style)) return [];
|
||||
// A scrollable ancestor means this overflow is intentional and scrollable.
|
||||
@@ -2312,5 +2662,6 @@ export {
|
||||
checkClippedOverflow,
|
||||
checkElementClippedOverflow,
|
||||
checkElementClippedOverflowDOM,
|
||||
isScreenReaderOnlyTextStyle,
|
||||
checkElementTextOverflowDOM,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,574 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* `/impeccable hooks <on|off|status|reset>` — manage the design hook
|
||||
* via the `hook` key of .impeccable/config.json and .impeccable/config.local.json
|
||||
* in the current project.
|
||||
*
|
||||
* Usage:
|
||||
* node hook-admin.mjs status # print current state
|
||||
* node hook-admin.mjs on # set enabled: true
|
||||
* node hook-admin.mjs off # set enabled: false
|
||||
* node hook-admin.mjs ignore-rule <rule-id> # append to ignoreRules
|
||||
* node hook-admin.mjs ignore-rule overused-font --all-values
|
||||
* node hook-admin.mjs ignore-file <glob> # append to ignoreFiles
|
||||
* node hook-admin.mjs ignore-value <rule> <value> # append to shared ignoreValues
|
||||
* node hook-admin.mjs ignore-value <rule> <value> --local
|
||||
* node hook-admin.mjs reset # remove all config + cache
|
||||
*
|
||||
* Designed to be invoked by the LLM from the reference/hooks.md flow.
|
||||
* Output is human-readable; the harness will pass it back to the user.
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import {
|
||||
getConfigPath,
|
||||
getLocalConfigPath,
|
||||
getCachePath,
|
||||
getPendingPath,
|
||||
readConfig,
|
||||
DEFAULT_CONFIG,
|
||||
ensureHookGitExcludes,
|
||||
normalizeIgnoreValue,
|
||||
normalizeIgnoreValueEntries,
|
||||
} from './hook-lib.mjs';
|
||||
|
||||
const ACTIONS = new Set(['status', 'on', 'off', 'ignore-rule', 'ignore-file', 'ignore-value', 'reset']);
|
||||
const IMPECCABLE_HOOK_COMMAND_MARKERS = [
|
||||
'skills/impeccable/scripts/hook-probe.mjs',
|
||||
'skills/impeccable/scripts/hook.mjs',
|
||||
'skills/impeccable/scripts/hook-before-edit.mjs',
|
||||
'skills/impeccable/scripts/hook-after-edit.mjs',
|
||||
'skills/impeccable/scripts/hook-stop.mjs',
|
||||
];
|
||||
const TIMEOUT_SECONDS = 5;
|
||||
const STATUS_MESSAGE = 'Checking UI changes';
|
||||
|
||||
const HOOK_MANIFEST_TARGETS = [
|
||||
{
|
||||
provider: '.claude',
|
||||
skillRel: '.claude/skills/impeccable',
|
||||
destRel: '.claude/settings.local.json',
|
||||
sharedDestRel: '.claude/settings.json',
|
||||
manifest: () => ({
|
||||
description: 'Impeccable design detector: runs after Edit/Write/MultiEdit on UI files and surfaces findings as system reminders.',
|
||||
hooks: {
|
||||
PostToolUse: [
|
||||
{
|
||||
matcher: 'Edit|Write|MultiEdit',
|
||||
hooks: [
|
||||
{
|
||||
type: 'command',
|
||||
command: 'node "${CLAUDE_PROJECT_DIR}/.claude/skills/impeccable/scripts/hook.mjs"',
|
||||
timeout: TIMEOUT_SECONDS,
|
||||
statusMessage: STATUS_MESSAGE,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
},
|
||||
{
|
||||
provider: '.agents',
|
||||
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: [
|
||||
{
|
||||
matcher: 'Edit|Write|apply_patch',
|
||||
hooks: [
|
||||
{
|
||||
type: 'command',
|
||||
command: 'node "$(git rev-parse --show-toplevel)/.agents/skills/impeccable/scripts/hook.mjs"',
|
||||
timeout: TIMEOUT_SECONDS,
|
||||
statusMessage: STATUS_MESSAGE,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
},
|
||||
{
|
||||
provider: '.cursor',
|
||||
skillRel: '.cursor/skills/impeccable',
|
||||
destRel: '.cursor/hooks.json',
|
||||
manifest: () => ({
|
||||
version: 1,
|
||||
hooks: {
|
||||
preToolUse: [
|
||||
{
|
||||
command: 'node ".cursor/skills/impeccable/scripts/hook-before-edit.mjs"',
|
||||
timeout: TIMEOUT_SECONDS,
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
},
|
||||
];
|
||||
|
||||
function readRawConfigFile(filePath) {
|
||||
if (!fs.existsSync(filePath)) return { exists: false, malformed: false, raw: null };
|
||||
try {
|
||||
return { exists: true, malformed: false, raw: JSON.parse(fs.readFileSync(filePath, 'utf-8')) };
|
||||
} catch {
|
||||
return { exists: true, malformed: true, raw: null };
|
||||
}
|
||||
}
|
||||
|
||||
// The hook settings to edit: the unified file's `hook` subtree.
|
||||
function readRawConfig(cwd, opts = {}) {
|
||||
const unified = readRawConfigFile(opts.local ? getLocalConfigPath(cwd) : getConfigPath(cwd)).raw;
|
||||
if (unified && typeof unified === 'object' && unified.hook && typeof unified.hook === 'object') {
|
||||
return unified.hook;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Write the hook config back under the `hook` key of the unified file, leaving
|
||||
// any sibling keys (e.g. updateCheck) untouched.
|
||||
function writeConfig(cwd, hookConfig, opts = {}) {
|
||||
const filePath = opts.local ? getLocalConfigPath(cwd) : getConfigPath(cwd);
|
||||
if (opts.local) ensureHookGitExcludes(cwd);
|
||||
const existingRaw = readRawConfigFile(filePath).raw;
|
||||
const existing = existingRaw && typeof existingRaw === 'object' && !Array.isArray(existingRaw) ? existingRaw : {};
|
||||
const existingHook = existing.hook && typeof existing.hook === 'object' && !Array.isArray(existing.hook) ? existing.hook : {};
|
||||
// Merge over the existing hook object so fields the merge helpers don't manage
|
||||
// (consent, quiet, auditLog) survive a `/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');
|
||||
return filePath;
|
||||
}
|
||||
|
||||
function mergeConfig(existing) {
|
||||
// Persist the full shape so /impeccable hooks edits leave a complete file
|
||||
// for the user to see, not an unhelpful `{"enabled":false}`.
|
||||
const base = existing && typeof existing === 'object' ? existing : {};
|
||||
return {
|
||||
enabled: base.enabled === false ? false : true,
|
||||
ignoreRules: Array.isArray(base.ignoreRules) ? Array.from(new Set(base.ignoreRules.map(String))) : [],
|
||||
ignoreFiles: Array.isArray(base.ignoreFiles) ? Array.from(new Set(base.ignoreFiles.map(String))) : [],
|
||||
ignoreValues: normalizeIgnoreValueEntries(base.ignoreValues || []),
|
||||
limits: {
|
||||
maxFindings: Number.isFinite(base?.limits?.maxFindings) ? base.limits.maxFindings : DEFAULT_CONFIG.limits.maxFindings,
|
||||
maxChars: Number.isFinite(base?.limits?.maxChars) ? base.limits.maxChars : DEFAULT_CONFIG.limits.maxChars,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function mergeLocalConfig(existing) {
|
||||
const base = existing && typeof existing === 'object' ? existing : {};
|
||||
const out = {};
|
||||
if (Object.prototype.hasOwnProperty.call(base, 'enabled')) {
|
||||
out.enabled = base.enabled === false ? false : true;
|
||||
}
|
||||
if (Array.isArray(base.ignoreRules)) {
|
||||
out.ignoreRules = Array.from(new Set(base.ignoreRules.map(String)));
|
||||
}
|
||||
if (Array.isArray(base.ignoreFiles)) {
|
||||
out.ignoreFiles = Array.from(new Set(base.ignoreFiles.map(String)));
|
||||
}
|
||||
out.ignoreValues = normalizeIgnoreValueEntries(base.ignoreValues || []);
|
||||
if (base.limits && typeof base.limits === 'object') {
|
||||
const limits = {};
|
||||
if (Number.isFinite(base.limits.maxFindings)) limits.maxFindings = base.limits.maxFindings;
|
||||
if (Number.isFinite(base.limits.maxChars)) limits.maxChars = base.limits.maxChars;
|
||||
if (Object.keys(limits).length) out.limits = limits;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function statusReport(cwd) {
|
||||
const shared = readRawConfigFile(getConfigPath(cwd));
|
||||
const local = readRawConfigFile(getLocalConfigPath(cwd));
|
||||
const cfg = readConfig(cwd);
|
||||
const envKill = process.env.IMPECCABLE_HOOK_DISABLED;
|
||||
const envState = envKill ? `IMPECCABLE_HOOK_DISABLED=${envKill}` : 'unset';
|
||||
const cfgPath = path.relative(cwd, getConfigPath(cwd)) || '.impeccable/config.json';
|
||||
const localPath = path.relative(cwd, getLocalConfigPath(cwd)) || '.impeccable/config.local.json';
|
||||
const cachePath = path.relative(cwd, getCachePath(cwd)) || '.impeccable/hook.cache.json';
|
||||
const fileState = (info, relPath, absent) => {
|
||||
if (info.malformed) return `${relPath} (malformed; ignored)`;
|
||||
if (info.exists) return relPath;
|
||||
return `${relPath} (${absent})`;
|
||||
};
|
||||
const ignoreValues = cfg.ignoreValues.map((entry) => `${entry.rule}=${entry.value}`);
|
||||
|
||||
const lines = [
|
||||
`Impeccable design hook`,
|
||||
` state: ${cfg.enabled ? 'enabled' : 'disabled'}`,
|
||||
` shared file: ${fileState(shared, cfgPath, 'using defaults; file not present')}`,
|
||||
` local file: ${fileState(local, localPath, 'not present')}`,
|
||||
` ignoreRules: ${cfg.ignoreRules.length ? cfg.ignoreRules.join(', ') : '(none)'}`,
|
||||
` ignoreFiles: ${cfg.ignoreFiles.length ? cfg.ignoreFiles.join(', ') : '(none)'}`,
|
||||
` ignoreValues: ${ignoreValues.length ? ignoreValues.join(', ') : '(none)'}`,
|
||||
` maxFindings: ${cfg.limits.maxFindings}`,
|
||||
` maxChars: ${cfg.limits.maxChars}`,
|
||||
` env override: ${envState}`,
|
||||
` cache file: ${fs.existsSync(getCachePath(cwd)) ? cachePath : `${cachePath} (not present)`}`,
|
||||
];
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function setEnabled(cwd, value) {
|
||||
const config = mergeConfig(readRawConfig(cwd));
|
||||
config.enabled = value;
|
||||
const target = writeConfig(cwd, config);
|
||||
if (!value) {
|
||||
return `Design hook disabled for this project (wrote ${path.relative(cwd, target) || target}).`;
|
||||
}
|
||||
|
||||
const localTarget = writeConfig(cwd, { consent: 'accepted' }, { local: true });
|
||||
const repaired = repairHookManifests(cwd);
|
||||
const parts = [
|
||||
`Design hook enabled for this project (wrote ${path.relative(cwd, target) || target}).`,
|
||||
`Recorded local hook consent in ${path.relative(cwd, localTarget) || localTarget}.`,
|
||||
];
|
||||
if (repaired.written.length > 0) {
|
||||
parts.push(`Installed or repaired hook manifests for: ${repaired.written.join(', ')}.`);
|
||||
} else if (repaired.already.length > 0) {
|
||||
parts.push(`Hook manifests already installed for: ${repaired.already.join(', ')}.`);
|
||||
} else {
|
||||
parts.push('No installed provider skill folders found to repair.');
|
||||
}
|
||||
if (repaired.backups.length > 0) {
|
||||
parts.push(`Backed up malformed manifest(s): ${repaired.backups.map((filePath) => path.relative(cwd, filePath) || filePath).join(', ')}.`);
|
||||
}
|
||||
return parts.join(' ');
|
||||
}
|
||||
|
||||
function repairHookManifests(cwd) {
|
||||
const result = { written: [], already: [], backups: [] };
|
||||
for (const target of HOOK_MANIFEST_TARGETS) {
|
||||
if (!fs.existsSync(path.join(cwd, target.skillRel))) continue;
|
||||
const dest = path.join(cwd, target.destRel);
|
||||
const sharedDest = target.sharedDestRel ? path.join(cwd, target.sharedDestRel) : null;
|
||||
|
||||
if (sharedDest && fileHasImpeccableHookMarker(sharedDest)) {
|
||||
pruneImpeccableHookFromManifest(dest);
|
||||
result.already.push(target.provider);
|
||||
continue;
|
||||
}
|
||||
|
||||
const fresh = target.manifest();
|
||||
let next = fresh;
|
||||
if (fs.existsSync(dest)) {
|
||||
try {
|
||||
next = mergeHookManifests(JSON.parse(fs.readFileSync(dest, 'utf-8')), fresh);
|
||||
} catch {
|
||||
const backup = `${dest}.bak`;
|
||||
fs.copyFileSync(dest, backup);
|
||||
result.backups.push(backup);
|
||||
}
|
||||
}
|
||||
|
||||
const serialized = `${JSON.stringify(next, null, 2)}\n`;
|
||||
const current = fs.existsSync(dest) ? safeReadText(dest) : null;
|
||||
if (current === serialized) {
|
||||
result.already.push(target.provider);
|
||||
continue;
|
||||
}
|
||||
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
||||
fs.writeFileSync(dest, serialized);
|
||||
result.written.push(target.provider);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function safeReadText(filePath) {
|
||||
try {
|
||||
return fs.readFileSync(filePath, 'utf-8');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function mergeHookManifests(existing, fresh) {
|
||||
const existingObject = existing && typeof existing === 'object' && !Array.isArray(existing) ? existing : {};
|
||||
const freshObject = fresh && typeof fresh === 'object' && !Array.isArray(fresh) ? fresh : {};
|
||||
const existingHooks = existingObject.hooks && typeof existingObject.hooks === 'object' && !Array.isArray(existingObject.hooks)
|
||||
? existingObject.hooks
|
||||
: {};
|
||||
const freshHooks = freshObject.hooks && typeof freshObject.hooks === 'object' && !Array.isArray(freshObject.hooks)
|
||||
? freshObject.hooks
|
||||
: {};
|
||||
|
||||
const merged = { ...existingObject, hooks: {} };
|
||||
if (freshObject.version !== undefined) merged.version = freshObject.version;
|
||||
if (freshObject.description !== undefined) merged.description = freshObject.description;
|
||||
|
||||
const hookEvents = new Set([...Object.keys(existingHooks), ...Object.keys(freshHooks)]);
|
||||
for (const event of hookEvents) {
|
||||
const preserved = stripImpeccableHookEntries(existingHooks[event]);
|
||||
const added = Array.isArray(freshHooks[event]) ? freshHooks[event] : [];
|
||||
const mergedEntries = [...preserved, ...added];
|
||||
if (mergedEntries.length > 0) merged.hooks[event] = mergedEntries;
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
function fileHasImpeccableHookMarker(filePath) {
|
||||
if (!fs.existsSync(filePath)) return false;
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return false;
|
||||
if (!parsed.hooks || typeof parsed.hooks !== 'object') return false;
|
||||
return valueHasImpeccableHookMarker(parsed.hooks);
|
||||
}
|
||||
|
||||
function valueHasImpeccableHookMarker(value) {
|
||||
if (typeof value === 'string') {
|
||||
return IMPECCABLE_HOOK_COMMAND_MARKERS.some((marker) => value.includes(marker));
|
||||
}
|
||||
if (Array.isArray(value)) return value.some(valueHasImpeccableHookMarker);
|
||||
if (value && typeof value === 'object') return Object.values(value).some(valueHasImpeccableHookMarker);
|
||||
return false;
|
||||
}
|
||||
|
||||
function stripImpeccableHookEntry(entry) {
|
||||
if (!entry || typeof entry !== 'object') return entry;
|
||||
if (valueHasImpeccableHookMarker(entry.command) || valueHasImpeccableHookMarker(entry.args)) {
|
||||
return null;
|
||||
}
|
||||
if (!Array.isArray(entry.hooks)) return entry;
|
||||
|
||||
const strippedHooks = entry.hooks
|
||||
.map(stripImpeccableHookEntry)
|
||||
.filter(Boolean);
|
||||
|
||||
if (strippedHooks.length === 0 && entry.hooks.some(valueHasImpeccableHookMarker)) {
|
||||
return null;
|
||||
}
|
||||
return { ...entry, hooks: strippedHooks };
|
||||
}
|
||||
|
||||
function stripImpeccableHookEntries(entries) {
|
||||
if (!Array.isArray(entries)) return [];
|
||||
return entries
|
||||
.map(stripImpeccableHookEntry)
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function pruneImpeccableHookFromManifest(manifestPath) {
|
||||
if (!fileHasImpeccableHookMarker(manifestPath)) return false;
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
||||
const existingHooks = parsed.hooks && typeof parsed.hooks === 'object' && !Array.isArray(parsed.hooks)
|
||||
? parsed.hooks
|
||||
: {};
|
||||
const cleanedHooks = {};
|
||||
for (const [event, entries] of Object.entries(existingHooks)) {
|
||||
const kept = stripImpeccableHookEntries(entries);
|
||||
if (kept.length > 0) cleanedHooks[event] = kept;
|
||||
}
|
||||
|
||||
const next = { ...parsed };
|
||||
if (Object.keys(cleanedHooks).length > 0) {
|
||||
next.hooks = cleanedHooks;
|
||||
} else {
|
||||
delete next.hooks;
|
||||
delete next.description;
|
||||
delete next.version;
|
||||
}
|
||||
|
||||
if (Object.keys(next).length === 0) {
|
||||
fs.rmSync(manifestPath, { force: true });
|
||||
} else {
|
||||
fs.writeFileSync(manifestPath, `${JSON.stringify(next, null, 2)}\n`);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function normalizeRuleId(rule) {
|
||||
return String(rule || '').trim().toLowerCase();
|
||||
}
|
||||
|
||||
function parseIgnoreRuleArgs(args) {
|
||||
const positionals = [];
|
||||
let allValues = false;
|
||||
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const arg = String(args[i] || '');
|
||||
if (arg === '--all-values') {
|
||||
allValues = true;
|
||||
} else if (arg === '--reason') {
|
||||
while (i + 1 < args.length && !String(args[i + 1]).startsWith('--')) i++;
|
||||
} else if (arg.startsWith('--reason=')) {
|
||||
// Accepted for command symmetry; ignoreRules stores rule ids only.
|
||||
} else if (arg.startsWith('--')) {
|
||||
throw new Error(`Unknown ignore-rule flag: ${arg}`);
|
||||
} else {
|
||||
positionals.push(arg);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
rule: normalizeRuleId(positionals[0]),
|
||||
allValues,
|
||||
};
|
||||
}
|
||||
|
||||
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 === '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.');
|
||||
}
|
||||
const config = mergeConfig(readRawConfig(cwd));
|
||||
if (!config.ignoreRules.includes(rule)) config.ignoreRules.push(rule);
|
||||
writeConfig(cwd, config);
|
||||
return `Added "${rule}" to ignoreRules. Current: ${config.ignoreRules.join(', ')}`;
|
||||
}
|
||||
|
||||
function addIgnoreFile(cwd, glob) {
|
||||
if (!glob) throw new Error('Pass a glob, e.g. /impeccable hooks ignore-file "src/legacy/**"');
|
||||
const config = mergeConfig(readRawConfig(cwd));
|
||||
if (!config.ignoreFiles.includes(glob)) config.ignoreFiles.push(glob);
|
||||
writeConfig(cwd, config);
|
||||
return `Added "${glob}" to ignoreFiles. Current: ${config.ignoreFiles.join(', ')}`;
|
||||
}
|
||||
|
||||
function parseIgnoreValueArgs(args) {
|
||||
const positionals = [];
|
||||
let shared = false;
|
||||
let local = false;
|
||||
let reason = '';
|
||||
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const arg = args[i];
|
||||
if (arg === '--shared') {
|
||||
shared = true;
|
||||
} else if (arg === '--local') {
|
||||
local = true;
|
||||
} else if (arg === '--reason') {
|
||||
const chunks = [];
|
||||
while (i + 1 < args.length && !String(args[i + 1]).startsWith('--')) {
|
||||
chunks.push(args[++i]);
|
||||
}
|
||||
reason = chunks.join(' ').trim();
|
||||
} else if (String(arg).startsWith('--reason=')) {
|
||||
reason = String(arg).slice('--reason='.length).trim();
|
||||
} else {
|
||||
positionals.push(arg);
|
||||
}
|
||||
}
|
||||
|
||||
const [rule, ...valueParts] = positionals;
|
||||
return {
|
||||
rule: String(rule || '').trim().toLowerCase(),
|
||||
value: normalizeIgnoreValue(valueParts.join(' ')),
|
||||
shared,
|
||||
local,
|
||||
reason,
|
||||
};
|
||||
}
|
||||
|
||||
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');
|
||||
}
|
||||
|
||||
if (parsed.shared && parsed.local) {
|
||||
throw new Error('Pass only one scope flag: --shared or --local');
|
||||
}
|
||||
|
||||
const local = parsed.local;
|
||||
const config = local
|
||||
? mergeLocalConfig(readRawConfig(cwd, { local: true }))
|
||||
: mergeConfig(readRawConfig(cwd, { local: false }));
|
||||
const key = `${parsed.rule}\0${parsed.value}`;
|
||||
const existing = config.ignoreValues.find((entry) => `${entry.rule}\0${entry.value}` === key);
|
||||
|
||||
if (existing) {
|
||||
if (parsed.reason) existing.reason = parsed.reason;
|
||||
} else {
|
||||
const entry = {
|
||||
rule: parsed.rule,
|
||||
value: parsed.value,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
if (parsed.reason) entry.reason = parsed.reason;
|
||||
config.ignoreValues.push(entry);
|
||||
}
|
||||
|
||||
const target = writeConfig(cwd, config, { local });
|
||||
const scope = local ? 'local ignoreValues' : 'shared ignoreValues';
|
||||
return `Added ${parsed.rule}=${parsed.value} to ${scope} (${path.relative(cwd, target) || target}).`;
|
||||
}
|
||||
|
||||
function reset(cwd) {
|
||||
const removed = [];
|
||||
// Unified files may hold non-hook keys (e.g. updateCheck); strip only the
|
||||
// hook subtree and keep the rest, deleting the file only if nothing remains.
|
||||
for (const filePath of [getConfigPath(cwd), getLocalConfigPath(cwd)]) {
|
||||
try {
|
||||
const raw = readRawConfigFile(filePath).raw;
|
||||
if (!raw || typeof raw !== 'object' || Array.isArray(raw) || !('hook' in raw)) continue;
|
||||
const { hook, ...rest } = raw;
|
||||
if (Object.keys(rest).length === 0) {
|
||||
fs.unlinkSync(filePath);
|
||||
} else {
|
||||
fs.writeFileSync(filePath, JSON.stringify(rest, null, 2) + '\n');
|
||||
}
|
||||
removed.push(path.relative(cwd, filePath) || filePath);
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
// State files are wholly ours; delete outright.
|
||||
for (const filePath of [getCachePath(cwd), getPendingPath(cwd)]) {
|
||||
try {
|
||||
if (fs.existsSync(filePath)) {
|
||||
fs.unlinkSync(filePath);
|
||||
removed.push(path.relative(cwd, filePath) || filePath);
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
return removed.length
|
||||
? `Reset design hook config and cache (removed: ${removed.join(', ')}).`
|
||||
: 'No hook config or cache to remove. Already at defaults.';
|
||||
}
|
||||
|
||||
function main() {
|
||||
const [, , actionArg, ...rest] = process.argv;
|
||||
const action = (actionArg || 'status').toLowerCase();
|
||||
const cwd = process.cwd();
|
||||
|
||||
if (!ACTIONS.has(action)) {
|
||||
process.stderr.write(`Unknown action: ${action}\nValid: ${Array.from(ACTIONS).join(', ')}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
try {
|
||||
let out = '';
|
||||
switch (action) {
|
||||
case 'status': out = statusReport(cwd); break;
|
||||
case 'on': out = setEnabled(cwd, true); break;
|
||||
case 'off': out = setEnabled(cwd, false); break;
|
||||
case 'ignore-rule': out = addIgnoreRule(cwd, rest); break;
|
||||
case 'ignore-file': out = addIgnoreFile(cwd, rest[0]); break;
|
||||
case 'ignore-value': out = addIgnoreValue(cwd, rest); break;
|
||||
case 'reset': out = reset(cwd); break;
|
||||
}
|
||||
process.stdout.write(out + '\n');
|
||||
} catch (err) {
|
||||
process.stderr.write(`Error: ${err.message || err}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,473 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Impeccable design hook — Cursor preToolUse write gate.
|
||||
*
|
||||
* Cursor's stop hook is not consistently dispatched by the headless agent, so
|
||||
* this hook checks proposed Write/Edit content before it lands. It only denies
|
||||
* writes when the real detector finds an issue in the proposed UI content.
|
||||
*
|
||||
* Contract: never break a turn accidentally. On malformed input or internal
|
||||
* errors, allow the tool and exit 0.
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import {
|
||||
ALLOWED_EXTS,
|
||||
EDIT_COUNT_THRESHOLD,
|
||||
GENERATED_PATH,
|
||||
SENSITIVE_PATH,
|
||||
filterFindings,
|
||||
loadDetector,
|
||||
matchesAnyGlob,
|
||||
persistCache,
|
||||
readCache,
|
||||
readConfig,
|
||||
renderTemplate,
|
||||
resolveProjectCwd,
|
||||
truthy,
|
||||
writeAuditLog,
|
||||
} from './hook-lib.mjs';
|
||||
|
||||
async function readStdin() {
|
||||
if (process.stdin.isTTY) return '';
|
||||
const chunks = [];
|
||||
for await (const chunk of process.stdin) chunks.push(chunk);
|
||||
return Buffer.concat(chunks).toString('utf-8');
|
||||
}
|
||||
|
||||
function done(payload = null) {
|
||||
if (payload) process.stdout.write(JSON.stringify(payload));
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
function allow(extra = {}, payload = {}) {
|
||||
writeAuditLog(process.env, {
|
||||
ts: new Date().toISOString(),
|
||||
event: 'preToolUse',
|
||||
...extra,
|
||||
});
|
||||
return done({ permission: 'allow', ...payload });
|
||||
}
|
||||
|
||||
function deny(message, audit) {
|
||||
writeAuditLog(process.env, {
|
||||
ts: new Date().toISOString(),
|
||||
event: 'preToolUse',
|
||||
blocked: true,
|
||||
...audit,
|
||||
});
|
||||
return done({
|
||||
permission: 'deny',
|
||||
user_message: message,
|
||||
agent_message: message,
|
||||
});
|
||||
}
|
||||
|
||||
function toolInput(event) {
|
||||
return event?.tool_input && typeof event.tool_input === 'object' ? event.tool_input : {};
|
||||
}
|
||||
|
||||
function proposedFilePath(event, cwd) {
|
||||
const input = toolInput(event);
|
||||
const raw = input.file_path || input.path || input.target_file || event?.file_path;
|
||||
const candidate = typeof raw === 'string' && raw.trim()
|
||||
? raw
|
||||
: shellWriteDestination(shellCommand(input));
|
||||
if (typeof candidate !== 'string' || !candidate.trim()) return '';
|
||||
return path.isAbsolute(candidate) ? candidate : path.resolve(cwd, candidate);
|
||||
}
|
||||
|
||||
function proposedContent(event, cwd, filePath) {
|
||||
const input = toolInput(event);
|
||||
for (const key of ['content', 'streamContent', 'text']) {
|
||||
if (typeof input[key] === 'string') return input[key];
|
||||
}
|
||||
|
||||
const editProjection = projectedEditContent(input, filePath, cwd);
|
||||
if (editProjection !== undefined) return editProjection;
|
||||
|
||||
if (hasFragmentEditContent(input)) {
|
||||
return { skipped: 'fragment-only-edit' };
|
||||
}
|
||||
|
||||
const command = shellCommand(input);
|
||||
const pythonContent = shellPythonWriteContent(command);
|
||||
if (pythonContent) return pythonContent;
|
||||
const shellContent = shellHereDocContent(command);
|
||||
if (shellContent) return shellContent;
|
||||
const copiedContent = shellCopiedFileContent(command, cwd);
|
||||
if (copiedContent) return copiedContent;
|
||||
return '';
|
||||
}
|
||||
|
||||
function hasFragmentEditContent(input) {
|
||||
if (!input || typeof input !== 'object') return false;
|
||||
if (typeof input.new_string === 'string' || typeof input.newString === 'string' || typeof input.new_str === 'string' || typeof input.replacement === 'string') {
|
||||
return true;
|
||||
}
|
||||
return Array.isArray(input.edits) && input.edits.some((edit) => edit && typeof edit === 'object');
|
||||
}
|
||||
|
||||
function projectedEditContent(input, filePath, cwd) {
|
||||
if (!filePath) return undefined;
|
||||
const singleOld = firstString(input, ['old_string', 'oldString', 'old_str', 'target']);
|
||||
const singleNew = firstString(input, ['new_string', 'newString', 'new_str', 'replacement']);
|
||||
if (singleOld !== undefined || singleNew !== undefined) {
|
||||
if (singleOld === undefined || singleNew === undefined) return { skipped: 'fragment-only-edit' };
|
||||
const original = readExistingProjectFile(filePath, cwd);
|
||||
if (original === null) return { skipped: 'edit-original-unreadable' };
|
||||
const projected = replaceOnce(original, singleOld, singleNew);
|
||||
return projected === null ? { skipped: 'edit-old-string-missing' } : projected;
|
||||
}
|
||||
|
||||
if (!Array.isArray(input.edits)) return undefined;
|
||||
const original = readExistingProjectFile(filePath, cwd);
|
||||
if (original === null) return { skipped: 'edit-original-unreadable' };
|
||||
|
||||
let projected = original;
|
||||
for (const edit of input.edits) {
|
||||
if (!edit || typeof edit !== 'object') return { skipped: 'fragment-only-edit' };
|
||||
const oldString = firstString(edit, ['old_string', 'oldString', 'old_str', 'target']);
|
||||
const newString = firstString(edit, ['new_string', 'newString', 'new_str', 'replacement']);
|
||||
if (oldString === undefined || newString === undefined) return { skipped: 'fragment-only-edit' };
|
||||
const next = replaceOnce(projected, oldString, newString);
|
||||
if (next === null) return { skipped: 'edit-old-string-missing' };
|
||||
projected = next;
|
||||
}
|
||||
return projected;
|
||||
}
|
||||
|
||||
function firstString(obj, keys) {
|
||||
for (const key of keys) {
|
||||
if (typeof obj?.[key] === 'string') return obj[key];
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function replaceOnce(original, oldString, newString) {
|
||||
if (oldString === '') return null;
|
||||
const index = original.indexOf(oldString);
|
||||
if (index === -1) return null;
|
||||
return `${original.slice(0, index)}${newString}${original.slice(index + oldString.length)}`;
|
||||
}
|
||||
|
||||
function readExistingProjectFile(filePath, cwd) {
|
||||
if (!isInsideProject(filePath, cwd)) return null;
|
||||
if (SENSITIVE_PATH.test(filePath) || GENERATED_PATH.test(filePath)) return null;
|
||||
try {
|
||||
const stat = fs.statSync(filePath);
|
||||
if (!stat.isFile() || stat.size > 1024 * 1024) return null;
|
||||
return fs.readFileSync(filePath, 'utf-8');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function shellCommand(input) {
|
||||
if (typeof input.command === 'string') return input.command;
|
||||
if (input.args && typeof input.args.command === 'string') return input.args.command;
|
||||
return '';
|
||||
}
|
||||
|
||||
function shellRedirectPath(command) {
|
||||
if (!command || typeof command !== 'string') return '';
|
||||
const match = command.match(/(?:^|[\s;&|])(?:>>?|1>>?)\s*(?:"([^"]+)"|'([^']+)'|([^<>\s]+))/);
|
||||
return (match?.[1] || match?.[2] || match?.[3] || '').trim();
|
||||
}
|
||||
|
||||
function shellWriteDestination(command) {
|
||||
return shellRedirectPath(command) || shellTeeDestination(command) || shellCopyPaths(command)?.dest || shellPythonWriteDestination(command) || '';
|
||||
}
|
||||
|
||||
function shellPythonWriteDestination(command) {
|
||||
if (!/\bpython(?:3)?\b/.test(command || '')) return '';
|
||||
const directPath = firstMatch(command, /(?:^|[^\w.])(?:pathlib\.)?Path\(\s*(["'])(.*?)\1\s*\)\s*\.write_text\s*\(/);
|
||||
if (directPath) return directPath;
|
||||
|
||||
const pathsByVar = new Map();
|
||||
const assignmentRe = /\b([A-Za-z_]\w*)\s*=\s*(?:pathlib\.)?Path\(\s*(["'])(.*?)\2\s*\)/g;
|
||||
let assignment;
|
||||
while ((assignment = assignmentRe.exec(command))) {
|
||||
pathsByVar.set(assignment[1], assignment[3]);
|
||||
}
|
||||
|
||||
const writeVarRe = /\b([A-Za-z_]\w*)\.write_text\s*\(/g;
|
||||
let writeVar;
|
||||
while ((writeVar = writeVarRe.exec(command))) {
|
||||
const candidate = pathsByVar.get(writeVar[1]);
|
||||
if (candidate) return candidate;
|
||||
}
|
||||
|
||||
return firstMatch(command, /\bopen\(\s*(["'])(.*?)\1\s*,\s*(["'])[wax](?:\+)?b?\3/);
|
||||
}
|
||||
|
||||
function firstMatch(value, re) {
|
||||
const match = String(value || '').match(re);
|
||||
return (match?.[2] || '').trim();
|
||||
}
|
||||
|
||||
function shellTeeDestination(command) {
|
||||
const words = shellWords(command);
|
||||
const teeIndex = words.findIndex((word) => path.basename(word) === 'tee');
|
||||
if (teeIndex === -1) return '';
|
||||
for (const word of words.slice(teeIndex + 1)) {
|
||||
if (['&&', '||', ';', '|'].includes(word)) break;
|
||||
if (word === '--') continue;
|
||||
if (word.startsWith('-')) continue;
|
||||
return word;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function shellCopiedFileContent(command, cwd) {
|
||||
const source = shellCopyPaths(command)?.source;
|
||||
if (!source) return '';
|
||||
const sourcePath = path.isAbsolute(source) ? source : path.resolve(cwd, source);
|
||||
if (!isInsideProject(sourcePath, cwd)) return '';
|
||||
if (SENSITIVE_PATH.test(sourcePath) || GENERATED_PATH.test(sourcePath)) return '';
|
||||
try {
|
||||
const stat = fs.statSync(sourcePath);
|
||||
if (!stat.isFile() || stat.size > 1024 * 1024) return '';
|
||||
return fs.readFileSync(sourcePath, 'utf-8');
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function shellCopyPaths(command) {
|
||||
const words = shellWords(command);
|
||||
if (words.length < 3 || path.basename(words[0]) !== 'cp') return null;
|
||||
const args = [];
|
||||
for (const word of words.slice(1)) {
|
||||
if (['&&', '||', ';', '|'].includes(word)) break;
|
||||
if (word === '--') continue;
|
||||
if (word.startsWith('-')) continue;
|
||||
args.push(word);
|
||||
}
|
||||
if (args.length < 2) return null;
|
||||
return { source: args[args.length - 2], dest: args[args.length - 1] };
|
||||
}
|
||||
|
||||
function shellWords(command) {
|
||||
if (!command || typeof command !== 'string') return [];
|
||||
const words = [];
|
||||
const re = /"((?:\\"|[^"])*)"|'((?:\\'|[^'])*)'|([^\s]+)/g;
|
||||
let match;
|
||||
while ((match = re.exec(command))) {
|
||||
words.push((match[1] ?? match[2] ?? match[3] ?? '').replace(/\\(["'])/g, '$1'));
|
||||
}
|
||||
return words;
|
||||
}
|
||||
|
||||
function shellHereDocContent(command) {
|
||||
if (!command || typeof command !== 'string') return '';
|
||||
const markerMatch = command.match(/<<-?\s*['"]?([A-Za-z0-9_.-]+)['"]?[^\r\n]*\r?\n/);
|
||||
if (!markerMatch) return '';
|
||||
const marker = markerMatch[1];
|
||||
const start = (markerMatch.index || 0) + markerMatch[0].length;
|
||||
const rest = command.slice(start);
|
||||
const endRe = new RegExp(`\\r?\\n${escapeRegExp(marker)}(?:\\r?\\n|$)`);
|
||||
const end = rest.search(endRe);
|
||||
return end >= 0 ? rest.slice(0, end) : '';
|
||||
}
|
||||
|
||||
function shellPythonWriteContent(command) {
|
||||
if (!/\bpython(?:3)?\b/.test(command || '')) return '';
|
||||
const script = shellHereDocContent(command) || command;
|
||||
return pythonStringArg(script, /\.write_text\s*\(\s*/g) || pythonStringArg(script, /\.write\s*\(\s*/g);
|
||||
}
|
||||
|
||||
function pythonStringArg(script, prefixRe) {
|
||||
let prefix;
|
||||
while ((prefix = prefixRe.exec(script))) {
|
||||
const start = prefixRe.lastIndex;
|
||||
const triple = script.slice(start, start + 3);
|
||||
if (triple === "'''" || triple === '"""') {
|
||||
const end = script.indexOf(triple, start + 3);
|
||||
if (end !== -1) return script.slice(start + 3, end);
|
||||
continue;
|
||||
}
|
||||
const quote = script[start];
|
||||
if (quote !== '"' && quote !== "'") continue;
|
||||
let out = '';
|
||||
for (let i = start + 1; i < script.length; i++) {
|
||||
const ch = script[i];
|
||||
if (ch === '\\') {
|
||||
out += script[i + 1] || '';
|
||||
i += 1;
|
||||
} else if (ch === quote) {
|
||||
return out;
|
||||
} else {
|
||||
out += ch;
|
||||
}
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function escapeRegExp(value) {
|
||||
return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
function relativePath(filePath, cwd) {
|
||||
try {
|
||||
const rel = path.relative(cwd, filePath);
|
||||
if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return filePath;
|
||||
return rel.split(path.sep).join('/');
|
||||
} catch {
|
||||
return filePath;
|
||||
}
|
||||
}
|
||||
|
||||
function isInsideProject(filePath, cwd) {
|
||||
try {
|
||||
const rel = path.relative(cwd, filePath);
|
||||
return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function cursorBlockMessage(findings, filePath, config, cwd) {
|
||||
const rendered = renderTemplate(findings, filePath, config, { cwd });
|
||||
const blocked = rendered.replace(
|
||||
'[impeccable@1] Required design corrections',
|
||||
'[impeccable@1] Impeccable design hook blocked this write before it landed. Required design corrections',
|
||||
);
|
||||
return blocked.length > 4000 ? `${blocked.slice(0, 3984)}\n...(truncated)` : blocked;
|
||||
}
|
||||
|
||||
function findingSignature(findings) {
|
||||
return findings
|
||||
.map((finding) => `${finding.antipattern || 'unknown'}:${finding.line || 0}`)
|
||||
.sort()
|
||||
.join('|');
|
||||
}
|
||||
|
||||
function bumpCursorDenial(cache, sessionId, filePath, findings) {
|
||||
const session = cache.sessions[sessionId] || { updatedAt: Date.now(), files: {} };
|
||||
cache.sessions[sessionId] = session;
|
||||
session.updatedAt = Date.now();
|
||||
const fileEntry = session.files[filePath] || { editCount: 0, findings: [] };
|
||||
session.files[filePath] = fileEntry;
|
||||
const key = findingSignature(findings);
|
||||
fileEntry.cursorDenials = fileEntry.cursorDenials && typeof fileEntry.cursorDenials === 'object'
|
||||
? fileEntry.cursorDenials
|
||||
: {};
|
||||
fileEntry.cursorDenials[key] = (fileEntry.cursorDenials[key] || 0) + 1;
|
||||
return { key, count: fileEntry.cursorDenials[key] };
|
||||
}
|
||||
|
||||
async function main() {
|
||||
if (truthy(process.env.IMPECCABLE_HOOK_DISABLED)) {
|
||||
return allow({ skipped: 'env-disabled' });
|
||||
}
|
||||
|
||||
let event = null;
|
||||
try {
|
||||
const raw = await readStdin();
|
||||
if (raw) event = JSON.parse(raw);
|
||||
} catch {
|
||||
return allow({ skipped: 'stdin-malformed' });
|
||||
}
|
||||
|
||||
if (!event || typeof event !== 'object') {
|
||||
return allow({ skipped: 'stdin-empty' });
|
||||
}
|
||||
|
||||
const cwd = resolveProjectCwd(event);
|
||||
const started = Date.now();
|
||||
const filePath = proposedFilePath(event, cwd);
|
||||
const audit = {
|
||||
harness: 'cursor',
|
||||
cwd,
|
||||
tool: event.tool_name || null,
|
||||
file: filePath || null,
|
||||
};
|
||||
|
||||
if (!filePath) return allow({ ...audit, skipped: 'no-file-path', durationMs: Date.now() - started });
|
||||
if (!isInsideProject(filePath, cwd)) return allow({ ...audit, skipped: 'outside-project', durationMs: Date.now() - started });
|
||||
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 });
|
||||
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
audit.ext = ext;
|
||||
if (!ALLOWED_EXTS.has(ext)) return allow({ ...audit, skipped: 'extension', durationMs: Date.now() - started });
|
||||
|
||||
const contentResult = proposedContent(event, cwd, filePath);
|
||||
if (contentResult && typeof contentResult === 'object' && contentResult.skipped) {
|
||||
return allow({ ...audit, skipped: contentResult.skipped, durationMs: Date.now() - started });
|
||||
}
|
||||
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 });
|
||||
|
||||
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 });
|
||||
}
|
||||
|
||||
const detector = await loadDetector();
|
||||
if (!detector || typeof detector.detectText !== 'function') {
|
||||
return allow({ ...audit, skipped: 'detector-missing', durationMs: Date.now() - started });
|
||||
}
|
||||
|
||||
let findings = [];
|
||||
try {
|
||||
findings = await detector.detectText(content, filePath);
|
||||
} catch {
|
||||
return allow({ ...audit, error: 'detector-threw', durationMs: Date.now() - started });
|
||||
}
|
||||
|
||||
const filtered = filterFindings(findings || [], content, ext, config);
|
||||
if (filtered.length === 0) {
|
||||
return allow({
|
||||
...audit,
|
||||
findings: (findings || []).length,
|
||||
blockedFindings: 0,
|
||||
durationMs: Date.now() - started,
|
||||
});
|
||||
}
|
||||
|
||||
const message = cursorBlockMessage(filtered, filePath, config, cwd);
|
||||
const sessionId = event.session_id || event.conversation_id || 'unknown';
|
||||
const cache = readCache(cwd);
|
||||
const denial = bumpCursorDenial(cache, sessionId, filePath, filtered);
|
||||
persistCache(cwd, cache);
|
||||
if (denial.count > EDIT_COUNT_THRESHOLD) {
|
||||
const warning = `${message}\n\nThis is the ${denial.count}th repeated denial for the same file and finding signature, so Impeccable is allowing this write to avoid a loop. Reconsider the issue immediately after the tool runs.`;
|
||||
return allow({
|
||||
...audit,
|
||||
findings: (findings || []).length,
|
||||
blockedFindings: filtered.length,
|
||||
cursorDenialKey: denial.key,
|
||||
cursorDenialCount: denial.count,
|
||||
downgraded: true,
|
||||
chars: warning.length,
|
||||
durationMs: Date.now() - started,
|
||||
}, {
|
||||
user_message: warning,
|
||||
agent_message: warning,
|
||||
});
|
||||
}
|
||||
return deny(message, {
|
||||
...audit,
|
||||
findings: (findings || []).length,
|
||||
blockedFindings: filtered.length,
|
||||
cursorDenialKey: denial.key,
|
||||
cursorDenialCount: denial.count,
|
||||
chars: message.length,
|
||||
durationMs: Date.now() - started,
|
||||
});
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
if (process.env.IMPECCABLE_HOOK_DEBUG) {
|
||||
process.stderr.write(`[impeccable-hook-before-edit] ${err}\n`);
|
||||
}
|
||||
done({ permission: 'allow' });
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,61 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Impeccable design hook — PostToolUse entry point.
|
||||
*
|
||||
* Reads the Claude Code / Codex / Cursor hook event from stdin, runs the design
|
||||
* detector against the touched file, and emits a system reminder via
|
||||
* `hookSpecificOutput.additionalContext` when findings exist.
|
||||
*
|
||||
* Contract: never break a turn. Always exit 0. Clean files emit a small ack
|
||||
* unless quiet mode is enabled.
|
||||
*
|
||||
* Most logic lives in `hook-lib.mjs` so it is unit-testable without a
|
||||
* subprocess. This file is the thin stdin/stdout adapter.
|
||||
*/
|
||||
|
||||
import { runHook, writeAuditLog } from './hook-lib.mjs';
|
||||
|
||||
async function readStdin() {
|
||||
if (process.stdin.isTTY) return '';
|
||||
const chunks = [];
|
||||
for await (const chunk of process.stdin) chunks.push(chunk);
|
||||
return Buffer.concat(chunks).toString('utf-8');
|
||||
}
|
||||
|
||||
async function main() {
|
||||
// Snapshot the inherited env FIRST so the re-entrancy guard checks the
|
||||
// parent's value, not the value we are about to export for any child
|
||||
// processes the hook might ever spawn.
|
||||
const inheritedEnv = { ...process.env };
|
||||
process.env.IMPECCABLE_HOOK_DEPTH = process.env.IMPECCABLE_HOOK_DEPTH || '1';
|
||||
|
||||
let stdinJson = '';
|
||||
try { stdinJson = await readStdin(); } catch { /* fall through */ }
|
||||
|
||||
const result = await runHook({
|
||||
stdinJson,
|
||||
env: inheritedEnv,
|
||||
cwd: process.cwd(),
|
||||
});
|
||||
|
||||
writeAuditLog(process.env, result.audit, process.cwd());
|
||||
|
||||
if (result.stdout) process.stdout.write(result.stdout);
|
||||
process.exit(result.exitCode || 0);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
// Last-ditch: never break the agent's turn even if something we did not
|
||||
// anticipate goes wrong. Audit-log the failure if logging is enabled.
|
||||
try {
|
||||
writeAuditLog(process.env, {
|
||||
ts: new Date().toISOString(),
|
||||
event: 'PostToolUse',
|
||||
error: String(err && err.message ? err.message : err),
|
||||
});
|
||||
} catch { /* swallow */ }
|
||||
if (process.env.IMPECCABLE_HOOK_DEBUG) {
|
||||
process.stderr.write(`[impeccable-hook] ${err}\n`);
|
||||
}
|
||||
process.exit(0);
|
||||
});
|
||||
@@ -15,8 +15,14 @@
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { isGeneratedFile } from './is-generated.mjs';
|
||||
import { readBuffer as readManualEditsBuffer, writeBuffer as writeManualEditsBuffer } from './live-manual-edits-buffer.mjs';
|
||||
import { isGeneratedFile } from './lib/is-generated.mjs';
|
||||
import { readBuffer as readManualEditsBuffer, writeBuffer as writeManualEditsBuffer } from './live/manual-edits-buffer.mjs';
|
||||
import {
|
||||
applyDeferredSvelteComponentAccepts,
|
||||
findSvelteComponentManifest,
|
||||
inlineSvelteComponentAccept,
|
||||
removeSvelteComponentSession,
|
||||
} from './live/svelte-component.mjs';
|
||||
|
||||
const EXTENSIONS = ['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro'];
|
||||
|
||||
@@ -41,6 +47,9 @@ Required:
|
||||
|
||||
Options:
|
||||
--page-url URL Current browser page URL; scopes staged copy-edit cleanup
|
||||
--defer-source-write
|
||||
Deprecated compatibility flag. Svelte component accepts
|
||||
now write the real source immediately.
|
||||
|
||||
Output (JSON):
|
||||
{ handled, file, carbonize }`);
|
||||
@@ -64,18 +73,67 @@ Output (JSON):
|
||||
|
||||
// Find the file containing this session's markers
|
||||
const found = findSessionFile(id, process.cwd());
|
||||
if (!found) {
|
||||
const svelteComponentManifest = found ? null : findSvelteComponentManifest(id, process.cwd());
|
||||
|
||||
if (!found && !svelteComponentManifest) {
|
||||
console.log(JSON.stringify({ handled: false, error: 'Session markers not found for id: ' + id }));
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (svelteComponentManifest) {
|
||||
if (isDiscard) {
|
||||
removeSvelteComponentSession(id, process.cwd());
|
||||
console.log(JSON.stringify({
|
||||
handled: true,
|
||||
file: svelteComponentManifest.sourceFile,
|
||||
carbonize: false,
|
||||
previewMode: 'svelte-component',
|
||||
componentDir: svelteComponentManifest.componentDir,
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
let result;
|
||||
try {
|
||||
result = inlineSvelteComponentAccept(
|
||||
svelteComponentManifest,
|
||||
variantNum,
|
||||
paramValues,
|
||||
process.cwd(),
|
||||
);
|
||||
} catch (err) {
|
||||
result = {
|
||||
handled: false,
|
||||
error: err.message,
|
||||
file: svelteComponentManifest.sourceFile,
|
||||
sourceFile: svelteComponentManifest.sourceFile,
|
||||
previewMode: 'svelte-component',
|
||||
componentDir: svelteComponentManifest.componentDir,
|
||||
};
|
||||
}
|
||||
if (result.carbonize) {
|
||||
result.todo = 'REQUIRED before next poll: carbonize cleanup in ' + result.file + '. See reference/live.md "Required after accept".';
|
||||
}
|
||||
console.log(JSON.stringify({ handled: result.handled !== false, ...result }));
|
||||
return;
|
||||
}
|
||||
|
||||
const { file: targetFile, content, lines } = found;
|
||||
const relFile = path.relative(process.cwd(), targetFile);
|
||||
const previewBlock = findMarkerBlock(id, lines);
|
||||
const sourceShadowPreview = previewBlock
|
||||
? readSourceShadowPreviewMeta(content, id)
|
||||
: null;
|
||||
|
||||
if (sourceShadowPreview) {
|
||||
console.log(JSON.stringify({
|
||||
handled: false,
|
||||
error: 'source_shadow_preview_deprecated',
|
||||
hint: 'Svelte live mode now uses svelte-component injection. Re-wrap the element and regenerate variants.',
|
||||
}));
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// Bail if the session lives in a generated file. The agent manually wrote
|
||||
// the wrapper there for preview, and is responsible for writing the
|
||||
// accepted variant to true source (or cleaning up on discard). See
|
||||
// "Handle fallback" in live.md.
|
||||
if (isGeneratedFile(targetFile, { cwd: process.cwd() })) {
|
||||
console.log(JSON.stringify({
|
||||
handled: false,
|
||||
@@ -207,6 +265,71 @@ function handleDiscard(id, lines, targetFile) {
|
||||
// Accept
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Build carbonize stitch-in lines. JSX targets occupy a single child slot
|
||||
* (ternary branch, return value, etc.) — the same constraint as live-wrap.
|
||||
* When isJsx, tuck markers + <style> + variant wrapper inside one outer
|
||||
* <div data-impeccable-carbonize> so the slot keeps a single root node.
|
||||
*/
|
||||
function buildCarbonizeReplacement({
|
||||
indent,
|
||||
commentSyntax,
|
||||
isJsx,
|
||||
id,
|
||||
variantNum,
|
||||
cssContent,
|
||||
paramValues,
|
||||
restored,
|
||||
}) {
|
||||
const lines = [];
|
||||
if (!cssContent) {
|
||||
lines.push(...restored);
|
||||
return lines;
|
||||
}
|
||||
|
||||
const variantStyleAttr = isJsx
|
||||
? "style={{ display: 'contents' }}"
|
||||
: 'style="display: contents"';
|
||||
|
||||
const pushCarbonizeBody = (bodyIndent) => {
|
||||
const bodyRestored = reindentContent(restored, indent, bodyIndent + ' ');
|
||||
lines.push(bodyIndent + commentSyntax.open + ' impeccable-carbonize-start ' + id + ' ' + commentSyntax.close);
|
||||
lines.push(bodyIndent + '<style data-impeccable-css="' + id + '">' + (isJsx ? '{`' : ''));
|
||||
for (const cssLine of cssContent) {
|
||||
lines.push(bodyIndent + cssLine.trimStart());
|
||||
}
|
||||
lines.push(bodyIndent + (isJsx ? '`}</style>' : '</style>'));
|
||||
if (paramValues && Object.keys(paramValues).length > 0) {
|
||||
lines.push(
|
||||
bodyIndent + commentSyntax.open + ' impeccable-param-values ' + id + ': ' + JSON.stringify(paramValues) + ' ' + commentSyntax.close,
|
||||
);
|
||||
}
|
||||
lines.push(bodyIndent + commentSyntax.open + ' impeccable-carbonize-end ' + id + ' ' + commentSyntax.close);
|
||||
lines.push(bodyIndent + '<div data-impeccable-variant="' + variantNum + '" ' + variantStyleAttr + '>');
|
||||
lines.push(...bodyRestored);
|
||||
lines.push(bodyIndent + '</div>');
|
||||
};
|
||||
|
||||
if (isJsx) {
|
||||
const wrapperStyle = 'style={{ display: "contents" }}';
|
||||
lines.push(indent + '<div data-impeccable-carbonize="' + id + '" ' + wrapperStyle + '>');
|
||||
pushCarbonizeBody(indent + ' ');
|
||||
lines.push(indent + '</div>');
|
||||
} else {
|
||||
pushCarbonizeBody(indent);
|
||||
}
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
function reindentContent(contentLines, fromIndent, toIndent) {
|
||||
return contentLines.map((line) => {
|
||||
if (line.trim() === '') return '';
|
||||
if (line.startsWith(fromIndent)) return toIndent + line.slice(fromIndent.length);
|
||||
return toIndent + line.trimStart();
|
||||
});
|
||||
}
|
||||
|
||||
function handleAccept(id, variantNum, lines, targetFile, paramValues) {
|
||||
const block = findMarkerBlock(id, lines);
|
||||
if (!block) return { handled: false, error: 'Markers not found' };
|
||||
@@ -235,45 +358,17 @@ function handleAccept(id, variantNum, lines, targetFile, paramValues) {
|
||||
const hasHelperAttrs = variantText.includes('data-impeccable-variant');
|
||||
const needsCarbonize = !!(cssContent || hasHelperAttrs);
|
||||
|
||||
// Build the replacement
|
||||
const restored = deindentContent(variantContent, indent);
|
||||
const replacement = [];
|
||||
|
||||
if (cssContent) {
|
||||
replacement.push(indent + commentSyntax.open + ' impeccable-carbonize-start ' + id + ' ' + commentSyntax.close);
|
||||
// JSX targets need the CSS body wrapped in a template literal so that the
|
||||
// `{` and `}` in CSS rules don't get parsed as JSX expressions.
|
||||
replacement.push(indent + '<style data-impeccable-css="' + id + '">' + (isJsx ? '{`' : ''));
|
||||
// Re-indent CSS content to match
|
||||
for (const cssLine of cssContent) {
|
||||
replacement.push(indent + cssLine.trimStart());
|
||||
}
|
||||
replacement.push(indent + (isJsx ? '`}</style>' : '</style>'));
|
||||
if (paramValues && Object.keys(paramValues).length > 0) {
|
||||
// Preserve the user's knob positions for the carbonize-cleanup agent
|
||||
// to bake into the final CSS when it collapses scoped rules.
|
||||
replacement.push(indent + commentSyntax.open + ' impeccable-param-values ' + id + ': ' + JSON.stringify(paramValues) + ' ' + commentSyntax.close);
|
||||
}
|
||||
replacement.push(indent + commentSyntax.open + ' impeccable-carbonize-end ' + id + ' ' + commentSyntax.close);
|
||||
}
|
||||
|
||||
// Keep the `@scope ([data-impeccable-variant="N"])` selectors in the
|
||||
// carbonize CSS block working visually by re-wrapping the accepted content
|
||||
// in a data-impeccable-variant="N" div with `display: contents` (so layout
|
||||
// isn't affected). The carbonize agent strips this attribute + wrapper when
|
||||
// it moves the CSS to a proper stylesheet.
|
||||
//
|
||||
// Style attribute syntax has to follow the host file's flavor — JSX files
|
||||
// need the object form, otherwise React 19 throws "Failed to set indexed
|
||||
// property [0] on CSSStyleDeclaration" while parsing the string char-by-char.
|
||||
if (cssContent) {
|
||||
const styleAttr = isJsx ? "style={{ display: 'contents' }}" : 'style="display: contents"';
|
||||
replacement.push(indent + '<div data-impeccable-variant="' + variantNum + '" ' + styleAttr + '>');
|
||||
replacement.push(...restored);
|
||||
replacement.push(indent + '</div>');
|
||||
} else {
|
||||
replacement.push(...restored);
|
||||
}
|
||||
const replacement = buildCarbonizeReplacement({
|
||||
indent,
|
||||
commentSyntax,
|
||||
isJsx,
|
||||
id,
|
||||
variantNum,
|
||||
cssContent,
|
||||
paramValues,
|
||||
restored,
|
||||
});
|
||||
|
||||
const newLines = [
|
||||
...lines.slice(0, replaceRange.start),
|
||||
@@ -285,6 +380,34 @@ function handleAccept(id, variantNum, lines, targetFile, paramValues) {
|
||||
return { carbonize: needsCarbonize, acceptedOriginalText: originalContent.join('\n') };
|
||||
}
|
||||
|
||||
function readSourceShadowPreviewMeta(content, id) {
|
||||
const escaped = escapeRegExp(id);
|
||||
const wrapperRe = new RegExp('<[^>]+data-impeccable-variants=(["\'])' + escaped + '\\1[^>]*>');
|
||||
const match = String(content || '').match(wrapperRe);
|
||||
if (!match) return null;
|
||||
const tag = match[0];
|
||||
if (readHtmlAttr(tag, 'data-impeccable-preview') !== 'source-shadow') return null;
|
||||
const sourceFile = readHtmlAttr(tag, 'data-impeccable-source-file');
|
||||
const sourceStartLine = Number(readHtmlAttr(tag, 'data-impeccable-source-start'));
|
||||
const sourceEndLine = Number(readHtmlAttr(tag, 'data-impeccable-source-end'));
|
||||
if (!sourceFile || !Number.isFinite(sourceStartLine) || !Number.isFinite(sourceEndLine)) return null;
|
||||
return { sourceFile, sourceStartLine, sourceEndLine };
|
||||
}
|
||||
|
||||
function readHtmlAttr(tag, name) {
|
||||
const match = String(tag || '').match(new RegExp('\\s' + escapeRegExp(name) + '\\s*=\\s*(["\'])(.*?)\\1'));
|
||||
if (!match) return null;
|
||||
return decodeHtmlAttr(match[2]);
|
||||
}
|
||||
|
||||
function decodeHtmlAttr(value) {
|
||||
return String(value || '')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/&/g, '&');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Parsing helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -686,4 +809,4 @@ if (_running?.endsWith('live-accept.mjs') || _running?.endsWith('live-accept.mjs
|
||||
acceptCli();
|
||||
}
|
||||
|
||||
export { findMarkerBlock, extractOriginal, extractVariant, extractCss, deindentContent, detectCommentSyntax, scrubManualEditsAgainstFile, scrubManualEditsAgainstOriginalBlock };
|
||||
export { findMarkerBlock, extractOriginal, extractVariant, extractCss, deindentContent, detectCommentSyntax, scrubManualEditsAgainstFile, scrubManualEditsAgainstOriginalBlock, applyDeferredSvelteComponentAccepts };
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* Browser-side DOM helpers for Impeccable live mode.
|
||||
*
|
||||
* Kept separate from live-browser.js so future browser script parts can share
|
||||
* chrome mounting, lookup, focus, and picker helpers without depending on the
|
||||
* full overlay UI bundle.
|
||||
*/
|
||||
(function (root) {
|
||||
'use strict';
|
||||
if (!root) return;
|
||||
|
||||
function createLiveBrowserDomHelpers({
|
||||
prefix,
|
||||
skipTags,
|
||||
document: doc = root.document,
|
||||
css = root.CSS,
|
||||
crypto = root.crypto,
|
||||
} = {}) {
|
||||
if (!prefix) throw new Error('prefix required');
|
||||
if (!doc) throw new Error('document required');
|
||||
const tagsToSkip = skipTags || new Set();
|
||||
|
||||
function own(el) {
|
||||
return el && (el.id?.startsWith(prefix) || el.closest?.('[id^="' + prefix + '"]'));
|
||||
}
|
||||
|
||||
function pickable(el) {
|
||||
if (!el || el.nodeType !== 1) return false;
|
||||
if (tagsToSkip.has(String(el.tagName || '').toLowerCase())) return false;
|
||||
if (own(el)) return false;
|
||||
const r = el.getBoundingClientRect();
|
||||
return r.width >= 20 && r.height >= 20;
|
||||
}
|
||||
|
||||
function desc(el) {
|
||||
if (!el) return '';
|
||||
let s = el.tagName.toLowerCase();
|
||||
if (el.id) s += '#' + el.id;
|
||||
else if (el.classList.length) s += '.' + [...el.classList].slice(0, 2).join('.');
|
||||
return s;
|
||||
}
|
||||
|
||||
function rectIsUsableAnchor(rect) {
|
||||
return !!rect && rect.width > 0.5 && rect.height > 0.5;
|
||||
}
|
||||
|
||||
function makeFrozenAnchor(el) {
|
||||
if (!el || !el.getBoundingClientRect) return null;
|
||||
const r = el.getBoundingClientRect();
|
||||
if (!rectIsUsableAnchor(r)) return null;
|
||||
const rect = {
|
||||
x: r.x, y: r.y,
|
||||
top: r.top, left: r.left,
|
||||
right: r.right, bottom: r.bottom,
|
||||
width: r.width, height: r.height,
|
||||
};
|
||||
return {
|
||||
__impeccableFrozenAnchor: true,
|
||||
tagName: el.tagName || 'DIV',
|
||||
id: el.id || '',
|
||||
classList: el.classList ? [...el.classList] : [],
|
||||
hasAttribute: () => false,
|
||||
getBoundingClientRect: () => rect,
|
||||
};
|
||||
}
|
||||
|
||||
function id8() {
|
||||
if (crypto?.randomUUID) return crypto.randomUUID().replace(/-/g, '').slice(0, 8);
|
||||
return (Math.random().toString(16).slice(2) + Date.now().toString(16)).slice(0, 8);
|
||||
}
|
||||
|
||||
function cssId(id) {
|
||||
if (css?.escape) return css.escape(id);
|
||||
return String(id).replace(/([ !"#$%&'()*+,./:;<=>?@[\\\]^`{|}~])/g, '\\$1');
|
||||
}
|
||||
|
||||
function liveUiRoot() {
|
||||
const uiRoot = root.__IMPECCABLE_LIVE_UI_ROOT__;
|
||||
if (uiRoot && typeof uiRoot.appendChild === 'function') return uiRoot;
|
||||
return doc.body;
|
||||
}
|
||||
|
||||
function uiAppend(el) {
|
||||
liveUiRoot().appendChild(el);
|
||||
return el;
|
||||
}
|
||||
|
||||
function uiAppendStyle(styleEl) {
|
||||
const uiRoot = liveUiRoot();
|
||||
if (uiRoot && uiRoot !== doc.body) uiRoot.appendChild(styleEl);
|
||||
else doc.head.appendChild(styleEl);
|
||||
return styleEl;
|
||||
}
|
||||
|
||||
function uiGetById(id) {
|
||||
const uiRoot = liveUiRoot();
|
||||
if (uiRoot?.getElementById) {
|
||||
const found = uiRoot.getElementById(id);
|
||||
if (found) return found;
|
||||
}
|
||||
if (uiRoot?.querySelector) {
|
||||
const found = uiRoot.querySelector('#' + cssId(id));
|
||||
if (found) return found;
|
||||
}
|
||||
return doc.getElementById(id);
|
||||
}
|
||||
|
||||
function activeElementDeep() {
|
||||
let active = doc.activeElement;
|
||||
while (active?.shadowRoot?.activeElement) active = active.shadowRoot.activeElement;
|
||||
return active;
|
||||
}
|
||||
|
||||
function defangOutsideHandlers(rootEl, { setPointerEvents = true } = {}) {
|
||||
if (!rootEl) return;
|
||||
if (setPointerEvents) {
|
||||
rootEl.style.setProperty('pointer-events', 'auto', 'important');
|
||||
}
|
||||
const stop = (e) => e.stopPropagation();
|
||||
rootEl.addEventListener('pointerdown', stop);
|
||||
rootEl.addEventListener('mousedown', stop);
|
||||
rootEl.addEventListener('focusin', stop);
|
||||
}
|
||||
|
||||
return {
|
||||
own,
|
||||
pickable,
|
||||
desc,
|
||||
rectIsUsableAnchor,
|
||||
makeFrozenAnchor,
|
||||
id8,
|
||||
cssId,
|
||||
liveUiRoot,
|
||||
uiAppend,
|
||||
uiAppendStyle,
|
||||
uiGetById,
|
||||
activeElementDeep,
|
||||
defangOutsideHandlers,
|
||||
};
|
||||
}
|
||||
|
||||
root.__IMPECCABLE_LIVE_DOM__ = {
|
||||
version: 1,
|
||||
createLiveBrowserDomHelpers,
|
||||
};
|
||||
})(typeof window !== 'undefined' ? window : globalThis);
|
||||
File diff suppressed because it is too large
Load Diff
@@ -16,8 +16,8 @@
|
||||
*/
|
||||
|
||||
import { buildManualEditEvidence } from './live-manual-edit-evidence.mjs';
|
||||
import { readBuffer, readBufferStrict, writeBuffer, countByPage } from './live-manual-edits-buffer.mjs';
|
||||
import { isGeneratedFile } from './is-generated.mjs';
|
||||
import { readBuffer, readBufferStrict, writeBuffer, countByPage } from './live/manual-edits-buffer.mjs';
|
||||
import { isGeneratedFile } from './lib/is-generated.mjs';
|
||||
import {
|
||||
runCopyEditBatchAgent,
|
||||
runCopyEditPostApplyChecks,
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
* Canonical durable completion acknowledgement for Impeccable live sessions.
|
||||
*/
|
||||
|
||||
import { createLiveSessionStore } from './live-session-store.mjs';
|
||||
import { readLiveServerInfo } from './impeccable-paths.mjs';
|
||||
import { createLiveSessionStore } from './live/session-store.mjs';
|
||||
import { readLiveServerInfo } from './lib/impeccable-paths.mjs';
|
||||
|
||||
function parseArgs(argv) {
|
||||
const out = { status: 'complete' };
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
* Output JSON: { discarded: N, entries: [...discardedEntries], totalCount: N }
|
||||
*/
|
||||
|
||||
import { readBuffer, removeEntries, truncateBuffer } from './live-manual-edits-buffer.mjs';
|
||||
import { readBuffer, removeEntries, truncateBuffer } from './live/manual-edits-buffer.mjs';
|
||||
|
||||
function argVal(args, name) {
|
||||
const prefix = name + '=';
|
||||
|
||||
@@ -16,12 +16,41 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { resolveLiveConfigPath } from './impeccable-paths.mjs';
|
||||
import { resolveLiveConfigPath } from './lib/impeccable-paths.mjs';
|
||||
import {
|
||||
applySvelteKitLiveAdapter,
|
||||
detectSvelteKitProject,
|
||||
removeSvelteKitLiveAdapter,
|
||||
} from './live/sveltekit-adapter.mjs';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const CONFIG_PATH = resolveLiveConfigPath({ cwd: process.cwd(), scriptsDir: __dirname });
|
||||
const MARKER_OPEN_TEXT = 'impeccable-live-start';
|
||||
const MARKER_CLOSE_TEXT = 'impeccable-live-end';
|
||||
const IGNORE_MARKER_OPEN = '# impeccable-live-ignore-start';
|
||||
const IGNORE_MARKER_CLOSE = '# impeccable-live-ignore-end';
|
||||
|
||||
export const LIVE_IGNORE_PATTERNS = Object.freeze([
|
||||
'.impeccable/hook.cache.json',
|
||||
'.impeccable/hook.pending.json',
|
||||
'.impeccable/config.local.json',
|
||||
'.impeccable/live/server.json',
|
||||
'.impeccable/live/sessions/',
|
||||
'.impeccable/live/previews/',
|
||||
'.impeccable/live/annotations/',
|
||||
'.impeccable/live/cache/',
|
||||
'.impeccable/live/manual-edit-apply-transaction.json',
|
||||
'.impeccable/live/manual-edit-events.jsonl',
|
||||
'.impeccable/live/manual-edit-evidence/',
|
||||
'.impeccable/live/pending-manual-edits.json',
|
||||
'.impeccable/live/deferred-svelte-component-accepts.json',
|
||||
'.impeccable-live.json',
|
||||
'.impeccable-live/',
|
||||
'node_modules/.impeccable-live/',
|
||||
'src/lib/impeccable/ImpeccableLiveRoot.svelte',
|
||||
'src/lib/impeccable/__runtime.js',
|
||||
'src/lib/impeccable/[0-9a-f]*/',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Hard-excluded directory patterns. These are NEVER user-facing pages and
|
||||
@@ -83,8 +112,14 @@ Output (JSON):
|
||||
validateConfig(config);
|
||||
|
||||
const resolvedFiles = resolveFiles(process.cwd(), config);
|
||||
const svelteKit = detectSvelteKitProject(process.cwd(), config);
|
||||
|
||||
if (args.includes('--remove')) {
|
||||
if (svelteKit) {
|
||||
const adapterResult = removeSvelteKitLiveAdapter({ cwd: process.cwd(), config });
|
||||
console.log(JSON.stringify({ ok: true, adapter: 'sveltekit', results: [adapterResult] }));
|
||||
return;
|
||||
}
|
||||
const results = resolvedFiles.map((relFile) => {
|
||||
const absFile = path.resolve(process.cwd(), relFile);
|
||||
if (!fs.existsSync(absFile)) return { file: relFile, error: 'file_not_found' };
|
||||
@@ -110,6 +145,13 @@ Output (JSON):
|
||||
console.error(JSON.stringify({ ok: false, error: 'missing_port' }));
|
||||
process.exit(1);
|
||||
}
|
||||
const gitIgnore = ensureLiveGitIgnores(process.cwd());
|
||||
|
||||
if (svelteKit) {
|
||||
const adapterResult = applySvelteKitLiveAdapter({ cwd: process.cwd(), port, config });
|
||||
console.log(JSON.stringify({ ok: true, port, adapter: 'sveltekit', gitIgnore, results: [adapterResult] }));
|
||||
return;
|
||||
}
|
||||
|
||||
const results = resolvedFiles.map((relFile) => {
|
||||
const absFile = path.resolve(process.cwd(), relFile);
|
||||
@@ -129,10 +171,68 @@ Output (JSON):
|
||||
};
|
||||
});
|
||||
const anyInserted = results.some((r) => r.inserted);
|
||||
console.log(JSON.stringify({ ok: anyInserted, port, results }));
|
||||
console.log(JSON.stringify({ ok: anyInserted, port, gitIgnore, results }));
|
||||
if (!anyInserted) process.exit(1);
|
||||
}
|
||||
|
||||
export function ensureLiveGitIgnores(cwd = process.cwd()) {
|
||||
const target = resolveIgnoreTarget(cwd);
|
||||
const existing = fs.existsSync(target.path) ? fs.readFileSync(target.path, 'utf-8') : '';
|
||||
const block = [
|
||||
IGNORE_MARKER_OPEN,
|
||||
...LIVE_IGNORE_PATTERNS,
|
||||
IGNORE_MARKER_CLOSE,
|
||||
].join('\n');
|
||||
const markerRe = new RegExp(`${escapeRegExp(IGNORE_MARKER_OPEN)}[\\s\\S]*?${escapeRegExp(IGNORE_MARKER_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}${prefix.endsWith('\n\n') || prefix === '' ? '' : '\n'}${block}\n`;
|
||||
}
|
||||
|
||||
if (updated !== existing) {
|
||||
fs.mkdirSync(path.dirname(target.path), { recursive: true });
|
||||
fs.writeFileSync(target.path, updated, 'utf-8');
|
||||
}
|
||||
|
||||
return {
|
||||
file: path.relative(cwd, target.path).split(path.sep).join('/'),
|
||||
mode: target.mode,
|
||||
changed: updated !== existing,
|
||||
patterns: [...LIVE_IGNORE_PATTERNS],
|
||||
};
|
||||
}
|
||||
|
||||
function resolveIgnoreTarget(cwd) {
|
||||
const gitExcludePath = resolveGitInfoExcludePath(cwd);
|
||||
if (gitExcludePath) {
|
||||
return { path: gitExcludePath, mode: 'git-info-exclude' };
|
||||
}
|
||||
return { path: path.join(cwd, '.gitignore'), mode: 'gitignore' };
|
||||
}
|
||||
|
||||
function resolveGitInfoExcludePath(cwd) {
|
||||
const dotGit = path.join(cwd, '.git');
|
||||
if (!fs.existsSync(dotGit)) return null;
|
||||
|
||||
const stat = fs.statSync(dotGit);
|
||||
if (stat.isDirectory()) return path.join(dotGit, 'info', 'exclude');
|
||||
if (!stat.isFile()) return null;
|
||||
|
||||
const body = fs.readFileSync(dotGit, 'utf-8').trim();
|
||||
const match = body.match(/^gitdir:\s*(.+)$/i);
|
||||
if (!match) return null;
|
||||
const gitDir = path.isAbsolute(match[1]) ? match[1] : path.resolve(cwd, match[1]);
|
||||
return path.join(gitDir, 'info', 'exclude');
|
||||
}
|
||||
|
||||
function escapeRegExp(value) {
|
||||
return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
/**
|
||||
* Expand config.files (which may contain glob patterns) into a literal list
|
||||
* of existing file paths relative to rootDir. Literal entries pass through;
|
||||
@@ -270,8 +370,26 @@ function buildTagBlock(syntax, port, filePath) {
|
||||
);
|
||||
}
|
||||
|
||||
function detectLineEnding(content) {
|
||||
if (content.includes('\r\n')) return '\r\n';
|
||||
if (content.includes('\r')) return '\r';
|
||||
return '\n';
|
||||
}
|
||||
|
||||
function normalizeLineEndings(content, lineEnding) {
|
||||
return lineEnding === '\n' ? content : content.replace(/\n/g, lineEnding);
|
||||
}
|
||||
|
||||
function readLineEndingAt(content, index) {
|
||||
if (content[index] === '\r' && content[index + 1] === '\n') return '\r\n';
|
||||
if (content[index] === '\n') return '\n';
|
||||
if (content[index] === '\r') return '\r';
|
||||
return '';
|
||||
}
|
||||
|
||||
function insertTag(content, config, port, filePath) {
|
||||
const block = buildTagBlock(config.commentSyntax, port, filePath);
|
||||
const lineEnding = detectLineEnding(content);
|
||||
const block = normalizeLineEndings(buildTagBlock(config.commentSyntax, port, filePath), lineEnding);
|
||||
// insertBefore: match the LAST occurrence. Anchors like `</body>` naturally
|
||||
// belong at the end, and the same literal can appear earlier in code blocks
|
||||
// within rendered documentation pages.
|
||||
@@ -285,9 +403,15 @@ function insertTag(content, config, port, filePath) {
|
||||
const idx = content.indexOf(config.insertAfter);
|
||||
if (idx === -1) return content;
|
||||
const after = idx + config.insertAfter.length;
|
||||
// Preserve a single trailing newline if the anchor didn't end with one
|
||||
const prefix = content[after] === '\n' ? content.slice(0, after + 1) : content.slice(0, after) + '\n';
|
||||
return prefix + block + content.slice(prefix.length);
|
||||
// Preserve an existing trailing newline if the anchor already has one.
|
||||
// Slice the remainder from the original anchor offset, not prefix.length:
|
||||
// in the no-newline case prefix is one char longer than the anchor (the
|
||||
// appended '\n'), so slicing by prefix.length would drop the first real
|
||||
// character after the anchor (#227).
|
||||
const existingNewline = readLineEndingAt(content, after);
|
||||
const prefix = content.slice(0, after) + (existingNewline || lineEnding);
|
||||
const rest = content.slice(after + existingNewline.length);
|
||||
return prefix + block + rest;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -303,8 +427,8 @@ function insertTag(content, config, port, filePath) {
|
||||
*/
|
||||
function removeTag(content, _syntax) {
|
||||
const patterns = [
|
||||
/([ \t]*)<!--\s*impeccable-live-start\s*-->[\s\S]*?<!--\s*impeccable-live-end\s*-->([ \t]*(?:\n|$)?)/,
|
||||
/([ \t]*)\{\/\*\s*impeccable-live-start\s*\*\/\}[\s\S]*?\{\/\*\s*impeccable-live-end\s*\*\/\}([ \t]*(?:\n|$)?)/,
|
||||
/([ \t]*)<!--\s*impeccable-live-start\s*-->[\s\S]*?<!--\s*impeccable-live-end\s*-->([ \t]*(?:\r\n|\n|\r|$)?)/,
|
||||
/([ \t]*)\{\/\*\s*impeccable-live-start\s*\*\/\}[\s\S]*?\{\/\*\s*impeccable-live-end\s*\*\/\}([ \t]*(?:\r\n|\n|\r|$)?)/,
|
||||
];
|
||||
for (const pat of patterns) {
|
||||
let changed = false;
|
||||
@@ -312,7 +436,7 @@ function removeTag(content, _syntax) {
|
||||
do {
|
||||
content = next;
|
||||
next = content.replace(pat, (_match, leadingIndent, trailing = '') => {
|
||||
if (trailing.includes('\n')) return leadingIndent;
|
||||
if (/[\r\n]/.test(trailing)) return leadingIndent;
|
||||
return leadingIndent || trailing || '';
|
||||
});
|
||||
if (next !== content) changed = true;
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { isGeneratedFile } from './is-generated.mjs';
|
||||
import { isGeneratedFile } from './lib/is-generated.mjs';
|
||||
import {
|
||||
buildSearchQueries,
|
||||
findElement,
|
||||
@@ -21,6 +21,11 @@ import {
|
||||
buildCssAuthoring,
|
||||
buildCssSelectorPrefixExamples,
|
||||
} from './live-wrap.mjs';
|
||||
import {
|
||||
buildSvelteComponentCssAuthoring,
|
||||
scaffoldSvelteComponentInsertSession,
|
||||
shouldUseSvelteComponentInjection,
|
||||
} from './live/svelte-component.mjs';
|
||||
|
||||
const INSERT_POSITIONS = new Set(['before', 'after']);
|
||||
|
||||
@@ -192,6 +197,41 @@ Output (JSON):
|
||||
const styleMode = detectStyleMode(targetFile);
|
||||
const isJsx = commentSyntax.open === '{/*';
|
||||
const spliceIndex = computeInsertLine(startLine, endLine, position);
|
||||
const relTargetFile = path.relative(process.cwd(), targetFile).split(path.sep).join('/');
|
||||
|
||||
if (shouldUseSvelteComponentInjection(targetFile)) {
|
||||
const session = scaffoldSvelteComponentInsertSession({
|
||||
id,
|
||||
count,
|
||||
sourceFile: relTargetFile,
|
||||
insertLine: spliceIndex + 1,
|
||||
position,
|
||||
anchorStartLine: startLine + 1,
|
||||
anchorEndLine: endLine + 1,
|
||||
anchorLines: lines.slice(startLine, endLine + 1),
|
||||
cwd: process.cwd(),
|
||||
});
|
||||
console.log(JSON.stringify({
|
||||
mode: 'insert',
|
||||
position,
|
||||
file: session.manifestFile,
|
||||
sourceFile: relTargetFile,
|
||||
previewMode: 'svelte-component',
|
||||
componentDir: session.componentDir,
|
||||
propContract: session.propContract,
|
||||
insertLine: 1,
|
||||
sourceInsertLine: spliceIndex + 1,
|
||||
anchorStartLine: startLine + 1,
|
||||
anchorEndLine: endLine + 1,
|
||||
commentSyntax,
|
||||
styleMode: 'svelte-component',
|
||||
styleTag: null,
|
||||
cssSelectorPrefixExamples: [],
|
||||
cssAuthoring: buildSvelteComponentCssAuthoring(count),
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
const indent = lines[spliceIndex]?.match(/^(\s*)/)?.[1]
|
||||
?? lines[startLine]?.match(/^(\s*)/)?.[1]
|
||||
?? '';
|
||||
@@ -216,7 +256,7 @@ Output (JSON):
|
||||
console.log(JSON.stringify({
|
||||
mode: 'insert',
|
||||
position,
|
||||
file: path.relative(process.cwd(), targetFile),
|
||||
file: relTargetFile,
|
||||
insertLine: insertLine + 1,
|
||||
commentSyntax,
|
||||
styleMode: styleMode.mode,
|
||||
|
||||
@@ -10,8 +10,8 @@
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { isGeneratedFile } from './is-generated.mjs';
|
||||
import { readBuffer, getBufferPath } from './live-manual-edits-buffer.mjs';
|
||||
import { isGeneratedFile } from './lib/is-generated.mjs';
|
||||
import { readBuffer, getBufferPath } from './live/manual-edits-buffer.mjs';
|
||||
|
||||
const EVIDENCE_VERSION = 1;
|
||||
const TEXT_EXTENSIONS = new Set(['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro', '.js', '.mjs', '.ts']);
|
||||
|
||||
@@ -12,14 +12,15 @@
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { completionAckForAcceptResult, completionTypeForAcceptResult } from './live-completion.mjs';
|
||||
import { readLiveServerInfo } from './impeccable-paths.mjs';
|
||||
import { completionAckForAcceptResult, completionTypeForAcceptResult } from './live/completion.mjs';
|
||||
import { readLiveServerInfo } from './lib/impeccable-paths.mjs';
|
||||
|
||||
// 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
|
||||
// depending on the standalone undici package.
|
||||
export const PER_REQUEST_TIMEOUT_MS = 270_000;
|
||||
export const DEFAULT_EVENT_LEASE_MS = 600_000;
|
||||
|
||||
const EVENT_TYPES_NEEDING_AGENT_REPLY = new Set(['generate', 'steer', 'manual_edit_apply']);
|
||||
|
||||
@@ -156,7 +157,7 @@ export async function fetchNextEvent(base, token, { totalDeadline } = {}) {
|
||||
? totalDeadline - Date.now()
|
||||
: PER_REQUEST_TIMEOUT_MS;
|
||||
const slice = Math.min(Math.max(remaining, 1000), PER_REQUEST_TIMEOUT_MS);
|
||||
const res = await fetch(`${base}/poll?token=${token}&timeout=${slice}`);
|
||||
const res = await fetch(`${base}/poll?token=${token}&timeout=${slice}&leaseMs=${DEFAULT_EVENT_LEASE_MS}`);
|
||||
|
||||
if (res.status === 401) {
|
||||
const err = new Error('Authentication failed. The server token may have changed.');
|
||||
@@ -317,7 +318,7 @@ Modes:
|
||||
Options:
|
||||
--timeout=MS One-shot poll timeout in ms (default: 600000). Ignored in --stream mode
|
||||
--ack-timeout=MS Stream mode: max wait for --reply after generate/steer (default: 600000)
|
||||
--file PATH Attach a source file path to the reply (generate flow)
|
||||
--file PATH Attach a source file path to the reply (generate/steer flow)
|
||||
--data JSON Attach a JSON result object to the reply (manual_edit_apply flow). Must be valid JSON
|
||||
--help Show this help message
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* Recover the next agent action from the durable live-session journal.
|
||||
*/
|
||||
|
||||
import { createLiveSessionStore } from './live-session-store.mjs';
|
||||
import { createLiveSessionStore } from './live/session-store.mjs';
|
||||
|
||||
function manualApplyReplyCommand(eventOrId = 'EVENT_ID') {
|
||||
const id = typeof eventOrId === 'string' ? eventOrId : eventOrId?.id || 'EVENT_ID';
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,8 +3,8 @@
|
||||
* Print durable recovery status for Impeccable live sessions.
|
||||
*/
|
||||
|
||||
import { createLiveSessionStore } from './live-session-store.mjs';
|
||||
import { readLiveServerInfo } from './impeccable-paths.mjs';
|
||||
import { createLiveSessionStore } from './live/session-store.mjs';
|
||||
import { readLiveServerInfo } from './lib/impeccable-paths.mjs';
|
||||
import { manualApplyResumeHint } from './live-resume.mjs';
|
||||
|
||||
function readServerInfo() {
|
||||
|
||||
@@ -13,8 +13,13 @@
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { isGeneratedFile } from './is-generated.mjs';
|
||||
import { readBuffer as readManualEditsBuffer } from './live-manual-edits-buffer.mjs';
|
||||
import { isGeneratedFile } from './lib/is-generated.mjs';
|
||||
import { readBuffer as readManualEditsBuffer } from './live/manual-edits-buffer.mjs';
|
||||
import {
|
||||
buildSvelteComponentCssAuthoring,
|
||||
scaffoldSvelteComponentSession,
|
||||
shouldUseSvelteComponentInjection,
|
||||
} from './live/svelte-component.mjs';
|
||||
|
||||
const EXTENSIONS = ['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro'];
|
||||
|
||||
@@ -262,6 +267,8 @@ The agent should insert variant HTML at insertLine.`);
|
||||
.map((l) => (l.trim() === '' ? '' : indent + extra + l.slice(originalBaseIndent)))
|
||||
.join('\n');
|
||||
const originalIndented = reindentOriginal(' ');
|
||||
const relTargetFile = path.relative(process.cwd(), targetFile).split(path.sep).join('/');
|
||||
const useSvelteComponent = shouldUseSvelteComponentInjection(targetFile);
|
||||
|
||||
// Wrapper attributes differ by syntax. HTML allows plain string attrs;
|
||||
// JSX requires object-literal style and parses string attrs as HTML (which
|
||||
@@ -302,38 +309,75 @@ The agent should insert variant HTML at insertLine.`);
|
||||
indent + commentSyntax.open + ' impeccable-variants-end ' + id + ' ' + commentSyntax.close,
|
||||
];
|
||||
|
||||
// Replace the original element with the wrapper
|
||||
const newLines = [
|
||||
...lines.slice(0, startLine),
|
||||
...wrapperLines,
|
||||
...lines.slice(endLine + 1),
|
||||
];
|
||||
fs.writeFileSync(targetFile, newLines.join('\n'), 'utf-8');
|
||||
let outputFile = targetFile;
|
||||
let outputLines;
|
||||
let outputStartLine = startLine + 1;
|
||||
let outputEndLine = startLine + wrapperLines.length + (originalLines.length - 1);
|
||||
let insertLine;
|
||||
let svelteSession = null;
|
||||
|
||||
// Calculate insert line (the "insert below this line" comment).
|
||||
// 0-indexed file position. Both HTML and JSX wrappers have 6 lines above
|
||||
// the insert marker (HTML: start-comment + outer-div + Original-comment +
|
||||
// original-div + content + close-original-div; JSX: outer-div +
|
||||
// start-comment + Original-comment + original-div + content +
|
||||
// close-original-div). Multi-line originals push the marker by their
|
||||
// extra line count.
|
||||
const insertLine = startLine + 6 + (originalLines.length - 1);
|
||||
if (useSvelteComponent) {
|
||||
// Svelte/SvelteKit resets component-local state on markup HMR updates.
|
||||
// Keep generation source-neutral: agents write real variant components
|
||||
// under the generated componentDir, the browser mounts them into the live
|
||||
// DOM, and live-accept.mjs inlines the accepted variant back into the route.
|
||||
svelteSession = scaffoldSvelteComponentSession({
|
||||
id,
|
||||
count,
|
||||
sourceFile: relTargetFile,
|
||||
sourceStartLine: startLine + 1,
|
||||
sourceEndLine: endLine + 1,
|
||||
originalLines,
|
||||
cwd: process.cwd(),
|
||||
});
|
||||
outputFile = path.resolve(process.cwd(), svelteSession.manifestFile);
|
||||
outputStartLine = 1;
|
||||
outputEndLine = 1;
|
||||
insertLine = 1;
|
||||
} else {
|
||||
// Replace the original element with the wrapper
|
||||
const newLines = [
|
||||
...lines.slice(0, startLine),
|
||||
...wrapperLines,
|
||||
...lines.slice(endLine + 1),
|
||||
];
|
||||
fs.writeFileSync(targetFile, newLines.join('\n'), 'utf-8');
|
||||
|
||||
// Calculate insert line (the "insert below this line" comment).
|
||||
// 0-indexed file position. Both HTML and JSX wrappers have 6 lines above
|
||||
// the insert marker (HTML: start-comment + outer-div + Original-comment +
|
||||
// original-div + content + close-original-div; JSX: outer-div +
|
||||
// start-comment + Original-comment + original-div + content +
|
||||
// close-original-div). Multi-line originals push the marker by their
|
||||
// extra line count.
|
||||
insertLine = startLine + 6 + (originalLines.length - 1) + 1;
|
||||
}
|
||||
|
||||
const outputRelFile = path.relative(process.cwd(), outputFile).split(path.sep).join('/');
|
||||
|
||||
const svelteComponentAuthoring = useSvelteComponent ? buildSvelteComponentCssAuthoring(count) : null;
|
||||
|
||||
console.log(JSON.stringify({
|
||||
file: path.relative(process.cwd(), targetFile),
|
||||
startLine: startLine + 1, // 1-indexed for the agent
|
||||
file: outputRelFile,
|
||||
sourceFile: useSvelteComponent ? relTargetFile : undefined,
|
||||
previewMode: useSvelteComponent ? 'svelte-component' : undefined,
|
||||
componentDir: svelteSession?.componentDir,
|
||||
propContract: svelteSession?.propContract,
|
||||
sourceStartLine: useSvelteComponent ? startLine + 1 : undefined,
|
||||
sourceEndLine: useSvelteComponent ? endLine + 1 : undefined,
|
||||
startLine: outputStartLine, // 1-indexed for the agent
|
||||
// wrapperLines is an array but one element (the original-content slot)
|
||||
// is a `\n`-joined multi-line string, so the actual file-row count is
|
||||
// wrapperLines.length + (originalLines.length - 1). Without the offset,
|
||||
// endLine pointed inside the wrapper for any picked element that
|
||||
// spanned more than one source line.
|
||||
endLine: startLine + wrapperLines.length + (originalLines.length - 1), // 1-indexed
|
||||
insertLine: insertLine + 1, // 1-indexed: where variants go
|
||||
endLine: outputEndLine, // 1-indexed
|
||||
insertLine, // 1-indexed: where variants go
|
||||
commentSyntax: commentSyntax,
|
||||
styleMode: styleMode.mode,
|
||||
styleTag: styleMode.styleTag,
|
||||
cssSelectorPrefixExamples: buildCssSelectorPrefixExamples(styleMode.mode, count),
|
||||
cssAuthoring: buildCssAuthoring(styleMode, count),
|
||||
styleMode: useSvelteComponent ? 'svelte-component' : styleMode.mode,
|
||||
styleTag: useSvelteComponent ? null : styleMode.styleTag,
|
||||
cssSelectorPrefixExamples: useSvelteComponent ? [] : buildCssSelectorPrefixExamples(styleMode.mode, count),
|
||||
cssAuthoring: useSvelteComponent ? svelteComponentAuthoring : buildCssAuthoring(styleMode, count),
|
||||
originalLineCount: originalLines.length,
|
||||
}));
|
||||
}
|
||||
@@ -527,6 +571,14 @@ function splitClassList(classes) {
|
||||
return String(classes).split(/[,\s]+/).map(c => c.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
function attrEscapeDouble(str) {
|
||||
return String(str)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
}
|
||||
|
||||
function detectCommentSyntax(filePath) {
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
if (ext === '.jsx' || ext === '.tsx') {
|
||||
|
||||
@@ -23,7 +23,7 @@ import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { loadContext } from './context.mjs';
|
||||
import { resolveFiles } from './live-inject.mjs';
|
||||
import { readLiveServerInfo } from './impeccable-paths.mjs';
|
||||
import { readLiveServerInfo } from './lib/impeccable-paths.mjs';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
export const LIVE_BROWSER_SCRIPT_PARTS = Object.freeze([
|
||||
Object.freeze({ name: 'session-state', file: 'live-browser-session.js' }),
|
||||
Object.freeze({ name: 'dom-helpers', file: 'live-browser-dom.js' }),
|
||||
Object.freeze({ name: 'browser-ui', file: 'live-browser.js' }),
|
||||
]);
|
||||
|
||||
export function resolveLiveBrowserScriptParts(scriptsDir, parts = LIVE_BROWSER_SCRIPT_PARTS) {
|
||||
if (!scriptsDir) throw new Error('scriptsDir is required');
|
||||
return parts.map((part, index) => ({
|
||||
...part,
|
||||
index,
|
||||
path: path.join(scriptsDir, part.file),
|
||||
}));
|
||||
}
|
||||
|
||||
export function assertLiveBrowserScriptParts(parts, exists = fs.existsSync) {
|
||||
for (const part of parts) {
|
||||
if (!exists(part.path)) {
|
||||
throw new Error(`Live browser script part missing: ${part.name} (${part.path})`);
|
||||
}
|
||||
}
|
||||
return parts;
|
||||
}
|
||||
|
||||
export function readLiveBrowserScriptParts(parts, readFile = (filePath) => fs.readFileSync(filePath, 'utf-8')) {
|
||||
return parts.map((part) => ({
|
||||
...part,
|
||||
source: readFile(part.path),
|
||||
}));
|
||||
}
|
||||
|
||||
export function assembleLiveBrowserScript({ token, port, vocabulary, parts }) {
|
||||
const prelude =
|
||||
`window.__IMPECCABLE_TOKEN__ = '${token}';\n` +
|
||||
`window.__IMPECCABLE_PORT__ = ${port};\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`;
|
||||
|
||||
const body = parts.map((part) => {
|
||||
const file = part.file || path.basename(part.path || '');
|
||||
return `// --- impeccable live script part: ${part.name} (${file}) ---\n${part.source}`;
|
||||
}).join('\n');
|
||||
|
||||
return prelude + body;
|
||||
}
|
||||
+1
@@ -3,6 +3,7 @@ export function completionTypeForAcceptResult(eventType, acceptResult) {
|
||||
if (acceptResult?.handled === true && acceptResult?.carbonize === true) return 'agent_done';
|
||||
if (acceptResult?.handled === true) return 'complete';
|
||||
if (acceptResult?.mode === 'error') return 'error';
|
||||
if (eventType === 'accept' && acceptResult?.previewMode === 'svelte-component') return 'error';
|
||||
return 'agent_done';
|
||||
}
|
||||
|
||||
+6
-5
@@ -3,12 +3,13 @@
|
||||
* Extracted for unit testing (insert mode rules).
|
||||
*/
|
||||
|
||||
import { canCreateInsert } from './live-insert-ui.mjs';
|
||||
import { canCreateInsert } from './insert-ui.mjs';
|
||||
|
||||
export const VISUAL_ACTIONS = [
|
||||
'impeccable', 'bolder', 'quieter', 'distill', 'polish', 'typeset',
|
||||
'colorize', 'layout', 'adapt', 'animate', 'delight', 'overdrive',
|
||||
];
|
||||
// The accepted visual action values come from the canonical vocabulary so the
|
||||
// validator, the picker UI, and the marketing demo never drift. Imported (not
|
||||
// just re-exported) so it is also in scope for the validators below.
|
||||
import { VISUAL_ACTIONS } from './vocabulary.mjs';
|
||||
export { VISUAL_ACTIONS };
|
||||
|
||||
const ID_PATTERN = /^[0-9a-f]{8}$/;
|
||||
const VARIANT_ID_PATTERN = /^[0-9]{1,3}$/;
|
||||
@@ -0,0 +1,939 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { getLiveDir } from '../lib/impeccable-paths.mjs';
|
||||
import { readBuffer as readManualEditsBuffer } from './manual-edits-buffer.mjs';
|
||||
|
||||
const APPLY_EVENT_HARD_TIMEOUT_MS = Number(process.env.IMPECCABLE_LIVE_APPLY_EVENT_HARD_TIMEOUT_MS || 150_000);
|
||||
const APPLY_EVENT_SOFT_DEADLINE_MS = Number(process.env.IMPECCABLE_LIVE_APPLY_EVENT_SOFT_DEADLINE_MS || 120_000);
|
||||
const DEFAULT_MANUAL_EDIT_APPLY_CHUNK_SIZE = 3;
|
||||
const MIN_MANUAL_EDIT_APPLY_CHUNK_SIZE = 1;
|
||||
const MAX_MANUAL_EDIT_APPLY_CHUNK_SIZE = 20;
|
||||
const MANUAL_APPLY_COMPACT_TEXT_LIMIT = 240;
|
||||
const MANUAL_APPLY_COMPACT_NEARBY_LIMIT = 4;
|
||||
|
||||
export function createManualApplyController({
|
||||
pendingEvents,
|
||||
pendingApplyDeferreds,
|
||||
timedOutApplyIds,
|
||||
enqueueEvent,
|
||||
acknowledgePendingEvent,
|
||||
flushPendingPolls,
|
||||
recordManualEditActivity,
|
||||
cwd = () => process.cwd(),
|
||||
} = {}) {
|
||||
const projectCwd = () => typeof cwd === 'function' ? cwd() : cwd || process.cwd();
|
||||
|
||||
function tombstoneTimedOutApplyId(eventId, details = {}) {
|
||||
if (!eventId) return;
|
||||
timedOutApplyIds.set(eventId, details);
|
||||
if (timedOutApplyIds.size <= 200) return;
|
||||
const oldest = timedOutApplyIds.keys().next().value;
|
||||
timedOutApplyIds.delete(oldest);
|
||||
}
|
||||
|
||||
function pushApplyEventAndWait(batch, pageUrl, chunk = null, repair = null) {
|
||||
const cwdValue = projectCwd();
|
||||
const eventId = randomUUID().replace(/-/g, '').slice(0, 8);
|
||||
const evidencePath = writeManualApplyEvidence(eventId, batch, cwdValue);
|
||||
const event = {
|
||||
type: 'manual_edit_apply',
|
||||
id: eventId,
|
||||
pageUrl,
|
||||
batch: compactManualApplyBatch(batch, cwdValue),
|
||||
evidencePath,
|
||||
agentAction: buildManualApplyAgentAction(eventId),
|
||||
schemaVersion: 1,
|
||||
deadlineMs: APPLY_EVENT_SOFT_DEADLINE_MS,
|
||||
};
|
||||
if (chunk) event.chunk = chunk;
|
||||
if (repair) event.repair = repair;
|
||||
const rollbackSnapshot = snapshotApplyEventFiles(batch, cwdValue);
|
||||
recordManualEditActivity('manual_edit_apply_dispatched', {
|
||||
id: eventId,
|
||||
pageUrl,
|
||||
chunk,
|
||||
repair,
|
||||
entryCount: Array.isArray(batch.entries) ? batch.entries.length : 0,
|
||||
opCount: countManualApplyOps(batch),
|
||||
fileCount: collectManualApplyFiles(batch, [], cwdValue).length,
|
||||
});
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
pendingApplyDeferreds.delete(eventId);
|
||||
tombstoneTimedOutApplyId(eventId, { batch, rollbackSnapshot, cwd: cwdValue });
|
||||
acknowledgePendingEvent(eventId);
|
||||
removeManualApplyEvidence(evidencePath, cwdValue);
|
||||
recordManualEditActivity('manual_edit_apply_timeout', {
|
||||
id: eventId,
|
||||
pageUrl,
|
||||
chunk,
|
||||
entryCount: Array.isArray(batch.entries) ? batch.entries.length : 0,
|
||||
opCount: countManualApplyOps(batch),
|
||||
});
|
||||
reject(new Error('chat_agent_timeout'));
|
||||
}, APPLY_EVENT_HARD_TIMEOUT_MS);
|
||||
pendingApplyDeferreds.set(eventId, { resolve, reject, timer, event, batch, pageUrl, rollbackSnapshot, cwd: cwdValue });
|
||||
enqueueEvent(event);
|
||||
});
|
||||
}
|
||||
|
||||
async function pushBatchInChunksAndWait(batch, pageUrl, context = {}) {
|
||||
const repair = context?.repair || batch?.repair || null;
|
||||
if (repair) return pushApplyEventAndWait(batch, pageUrl, null, repair);
|
||||
const chunks = splitManualApplyBatch(batch, manualEditApplyChunkSize());
|
||||
if (chunks.length <= 1) return pushApplyEventAndWait(batch, pageUrl);
|
||||
|
||||
const expectedOpsByEntry = new Map();
|
||||
for (const entry of batch?.entries || []) {
|
||||
expectedOpsByEntry.set(entry.id, Array.isArray(entry.ops) ? entry.ops.length : 0);
|
||||
}
|
||||
|
||||
const appliedOpsByEntry = new Map();
|
||||
const failedByEntry = new Map();
|
||||
const files = new Set();
|
||||
const notes = [];
|
||||
let aborted = false;
|
||||
|
||||
for (const chunk of chunks) {
|
||||
if (aborted) {
|
||||
markChunkEntriesFailed(failedByEntry, chunk, 'manual_edit_chunk_aborted');
|
||||
continue;
|
||||
}
|
||||
|
||||
let result;
|
||||
try {
|
||||
result = normalizeApplyChunkResult(await pushApplyEventAndWait(chunk.batch, pageUrl, chunk.meta));
|
||||
} catch (err) {
|
||||
markChunkEntriesFailed(failedByEntry, chunk, err.message || 'chat_agent_error');
|
||||
aborted = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const file of result.files) files.add(file);
|
||||
notes.push(...result.notes);
|
||||
|
||||
const chunkFailedIds = new Set();
|
||||
for (const item of result.failed) {
|
||||
const entryId = item.entryId || item.id;
|
||||
if (!entryId) continue;
|
||||
chunkFailedIds.add(entryId);
|
||||
if (!failedByEntry.has(entryId)) {
|
||||
failedByEntry.set(entryId, {
|
||||
entryId,
|
||||
reason: item.reason || item.message || 'failed',
|
||||
candidates: Array.isArray(item.candidates) ? item.candidates : [],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (result.status === 'error') {
|
||||
markChunkEntriesFailed(failedByEntry, chunk, result.message || firstFailureReason(result) || 'chat_agent_error');
|
||||
aborted = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
const reportedAppliedIds = new Set(result.appliedEntryIds);
|
||||
for (const entryId of reportedAppliedIds) {
|
||||
if (!chunk.entryIds.has(entryId) || chunkFailedIds.has(entryId)) continue;
|
||||
appliedOpsByEntry.set(entryId, (appliedOpsByEntry.get(entryId) || 0) + (chunk.opCountsByEntry.get(entryId) || 0));
|
||||
}
|
||||
|
||||
for (const entryId of chunk.entryIds) {
|
||||
if (reportedAppliedIds.has(entryId) || chunkFailedIds.has(entryId)) continue;
|
||||
if (!failedByEntry.has(entryId)) {
|
||||
failedByEntry.set(entryId, { entryId, reason: 'not_reported_applied', candidates: [] });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const appliedEntryIds = [];
|
||||
for (const [entryId, expectedOps] of expectedOpsByEntry.entries()) {
|
||||
if (failedByEntry.has(entryId)) continue;
|
||||
if ((appliedOpsByEntry.get(entryId) || 0) === expectedOps && expectedOps > 0) {
|
||||
appliedEntryIds.push(entryId);
|
||||
} else if (!failedByEntry.has(entryId)) {
|
||||
failedByEntry.set(entryId, { entryId, reason: 'not_reported_applied', candidates: [] });
|
||||
}
|
||||
}
|
||||
|
||||
const failed = [...failedByEntry.values()];
|
||||
return {
|
||||
status: failed.length === 0 ? 'done' : appliedEntryIds.length > 0 ? 'partial' : 'error',
|
||||
appliedEntryIds,
|
||||
failed,
|
||||
files: [...files],
|
||||
notes,
|
||||
};
|
||||
}
|
||||
|
||||
function getDeferred(eventId) {
|
||||
return pendingApplyDeferreds.get(eventId) || null;
|
||||
}
|
||||
|
||||
function hasTimedOutId(eventId) {
|
||||
return timedOutApplyIds.has(eventId);
|
||||
}
|
||||
|
||||
function resolveDeferred(eventId, body) {
|
||||
const deferred = pendingApplyDeferreds.get(eventId);
|
||||
if (!deferred) return false;
|
||||
pendingApplyDeferreds.delete(eventId);
|
||||
clearTimeout(deferred.timer);
|
||||
removeManualApplyEvidence(deferred.event?.evidencePath, deferred.cwd || projectCwd());
|
||||
deferred.resolve(body);
|
||||
return true;
|
||||
}
|
||||
|
||||
function rejectDeferred(eventId, reason) {
|
||||
const deferred = pendingApplyDeferreds.get(eventId);
|
||||
if (!deferred) return false;
|
||||
pendingApplyDeferreds.delete(eventId);
|
||||
clearTimeout(deferred.timer);
|
||||
removeManualApplyEvidence(deferred.event?.evidencePath, deferred.cwd || projectCwd());
|
||||
deferred.reject(new Error(reason || 'chat_agent_error'));
|
||||
return true;
|
||||
}
|
||||
|
||||
function referencedManualApplyEvidencePaths(cwdValue = projectCwd()) {
|
||||
const referenced = new Set();
|
||||
const add = (event) => {
|
||||
const fullPath = normalizeManualApplyEvidencePath(event?.evidencePath, cwdValue);
|
||||
if (fullPath) referenced.add(fullPath);
|
||||
};
|
||||
for (const entry of pendingEvents) add(entry.event);
|
||||
for (const deferred of pendingApplyDeferreds.values()) add(deferred.event);
|
||||
return referenced;
|
||||
}
|
||||
|
||||
function pruneStaleEvidence(cwdValue = projectCwd()) {
|
||||
const dir = manualApplyEvidenceDir(cwdValue);
|
||||
if (!fs.existsSync(dir)) return [];
|
||||
const referenced = referencedManualApplyEvidencePaths(cwdValue);
|
||||
const removed = [];
|
||||
for (const name of fs.readdirSync(dir)) {
|
||||
if (!name.endsWith('.json')) continue;
|
||||
const fullPath = path.join(dir, name);
|
||||
if (referenced.has(fullPath)) continue;
|
||||
try {
|
||||
fs.unlinkSync(fullPath);
|
||||
removed.push(fullPath);
|
||||
} catch {
|
||||
// Stale evidence cleanup is best-effort; Apply verification never relies
|
||||
// on deleting these files.
|
||||
}
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
|
||||
function rollbackTimedOutReply(msg) {
|
||||
const details = timedOutApplyIds.get(msg.id);
|
||||
if (!details) return { rolledBackFiles: [], rollbackFailures: [] };
|
||||
timedOutApplyIds.delete(msg.id);
|
||||
return rollbackApplySnapshot(
|
||||
details.batch,
|
||||
details.rollbackSnapshot,
|
||||
msg.data?.files || [],
|
||||
'stale_manual_edit_apply_reply',
|
||||
details.cwd || projectCwd(),
|
||||
);
|
||||
}
|
||||
|
||||
function cancelPendingEvents(pageUrl, reason = 'manual_edit_discarded') {
|
||||
const canceledById = new Map();
|
||||
const shouldCancel = (event) => event?.type === 'manual_edit_apply' && (!pageUrl || event.pageUrl === pageUrl);
|
||||
|
||||
for (let i = pendingEvents.length - 1; i >= 0; i -= 1) {
|
||||
const event = pendingEvents[i]?.event;
|
||||
if (!shouldCancel(event)) continue;
|
||||
pendingEvents.splice(i, 1);
|
||||
removeManualApplyEvidence(event.evidencePath, projectCwd());
|
||||
canceledById.set(event.id, {
|
||||
id: event.id,
|
||||
pageUrl: event.pageUrl,
|
||||
entryCount: event.batch?.entries?.length || 0,
|
||||
});
|
||||
}
|
||||
|
||||
for (const [eventId, deferred] of [...pendingApplyDeferreds.entries()]) {
|
||||
if (!shouldCancel(deferred.event)) continue;
|
||||
pendingApplyDeferreds.delete(eventId);
|
||||
clearTimeout(deferred.timer);
|
||||
const cwdValue = deferred.cwd || projectCwd();
|
||||
const rollback = rollbackApplySnapshot(deferred.batch, deferred.rollbackSnapshot, [], reason, cwdValue);
|
||||
tombstoneTimedOutApplyId(eventId, {
|
||||
batch: deferred.batch,
|
||||
rollbackSnapshot: deferred.rollbackSnapshot,
|
||||
reason,
|
||||
cwd: cwdValue,
|
||||
});
|
||||
removeManualApplyEvidence(deferred.event?.evidencePath, cwdValue);
|
||||
canceledById.set(eventId, {
|
||||
id: eventId,
|
||||
pageUrl: deferred.pageUrl,
|
||||
entryCount: deferred.batch?.entries?.length || 0,
|
||||
rolledBackFiles: rollback.rolledBackFiles,
|
||||
rollbackFailures: rollback.rollbackFailures,
|
||||
});
|
||||
deferred.reject(new Error(reason));
|
||||
}
|
||||
|
||||
if (canceledById.size > 0) flushPendingPolls();
|
||||
return [...canceledById.values()];
|
||||
}
|
||||
|
||||
return {
|
||||
buildAgentAction: buildManualApplyAgentAction,
|
||||
cancelPendingEvents,
|
||||
clearTransaction: (transactionId = null) => clearManualApplyTransaction(projectCwd(), transactionId),
|
||||
countOps: countManualApplyOps,
|
||||
getDeferred,
|
||||
hasTimedOutId,
|
||||
pruneStaleEvidence,
|
||||
pushBatchInChunksAndWait,
|
||||
readTransaction: () => readManualApplyTransaction(projectCwd()),
|
||||
rejectDeferred,
|
||||
resolveDeferred,
|
||||
rollbackTimedOutReply,
|
||||
rollbackTransaction: (opts = {}) => rollbackManualApplyTransaction({
|
||||
cwd: projectCwd(),
|
||||
recordManualEditActivity,
|
||||
...opts,
|
||||
}),
|
||||
summarizeEvent: (event = {}, batch = event.batch) => summarizeManualApplyEvent(event, batch, projectCwd()),
|
||||
validateResultMessage: validateManualApplyResultMessage,
|
||||
writeTransaction: (opts = {}) => writeManualApplyTransaction({ cwd: projectCwd(), ...opts }),
|
||||
};
|
||||
}
|
||||
|
||||
export function manualEditApplyChunkSize(env = process.env) {
|
||||
const raw = Number(env.IMPECCABLE_LIVE_MANUAL_EDIT_CHUNK_SIZE);
|
||||
if (!Number.isFinite(raw)) return DEFAULT_MANUAL_EDIT_APPLY_CHUNK_SIZE;
|
||||
const size = Math.trunc(raw);
|
||||
return Math.max(MIN_MANUAL_EDIT_APPLY_CHUNK_SIZE, Math.min(MAX_MANUAL_EDIT_APPLY_CHUNK_SIZE, size));
|
||||
}
|
||||
|
||||
export function countManualApplyOps(entriesOrBatch) {
|
||||
const entries = Array.isArray(entriesOrBatch)
|
||||
? entriesOrBatch
|
||||
: Array.isArray(entriesOrBatch?.entries) ? entriesOrBatch.entries : [];
|
||||
let count = 0;
|
||||
for (const entry of entries) count += Array.isArray(entry.ops) ? entry.ops.length : 0;
|
||||
return count;
|
||||
}
|
||||
|
||||
export function writeManualApplyEvidence(eventId, batch, cwd = process.cwd()) {
|
||||
const dir = manualApplyEvidenceDir(cwd);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
const evidencePath = path.join(dir, `${eventId}.json`);
|
||||
fs.writeFileSync(evidencePath, JSON.stringify(batch, null, 2) + '\n', 'utf-8');
|
||||
return evidencePath;
|
||||
}
|
||||
|
||||
export function manualApplyEvidenceDir(cwd = process.cwd()) {
|
||||
return path.join(getLiveDir(cwd), 'manual-edit-evidence');
|
||||
}
|
||||
|
||||
export function normalizeManualApplyEvidencePath(evidencePath, cwd = process.cwd()) {
|
||||
if (!evidencePath || typeof evidencePath !== 'string') return null;
|
||||
const fullPath = path.isAbsolute(evidencePath) ? evidencePath : path.resolve(cwd, evidencePath);
|
||||
const evidenceDir = manualApplyEvidenceDir(cwd);
|
||||
const relative = path.relative(evidenceDir, fullPath);
|
||||
if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) return null;
|
||||
if (path.extname(relative) !== '.json') return null;
|
||||
return fullPath;
|
||||
}
|
||||
|
||||
export function removeManualApplyEvidence(evidencePath, cwd = process.cwd()) {
|
||||
const fullPath = normalizeManualApplyEvidencePath(evidencePath, cwd);
|
||||
if (!fullPath) return false;
|
||||
try {
|
||||
fs.unlinkSync(fullPath);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function compactManualApplyBatch(batch = {}, cwd = process.cwd()) {
|
||||
const entries = (batch.entries || []).map(compactManualApplyEntry);
|
||||
const candidates = compactManualApplyCandidates(batch.candidates || [], cwd);
|
||||
return {
|
||||
version: batch.version,
|
||||
pageUrl: batch.pageUrl || null,
|
||||
count: batch.count,
|
||||
entries,
|
||||
ops: entries.flatMap((entry) => entry.ops.map((op) => ({ ...op, entryId: entry.id }))),
|
||||
candidates: candidates.length > 0 ? candidates : undefined,
|
||||
context: batch.context ? {
|
||||
bufferPath: batch.context.bufferPath,
|
||||
totalEntries: batch.context.totalEntries,
|
||||
totalOps: batch.context.totalOps,
|
||||
chunkIndex: batch.context.chunkIndex,
|
||||
chunkTotal: batch.context.chunkTotal,
|
||||
totalApplyOps: batch.context.totalApplyOps,
|
||||
} : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function compactManualApplyCandidates(candidates, cwd = process.cwd()) {
|
||||
return (Array.isArray(candidates) ? candidates : [])
|
||||
.slice(0, 24)
|
||||
.map((candidate) => ({
|
||||
entryId: candidate.entryId,
|
||||
ref: candidate.ref,
|
||||
sourceHint: compactManualApplySourceMatch(candidate.sourceHint, cwd),
|
||||
textMatches: compactManualApplySourceMatches(candidate.textMatches, 8, cwd),
|
||||
objectKeyMatches: compactManualApplySourceMatches(candidate.objectKeyMatches, 8, cwd),
|
||||
contextTextMatches: compactManualApplySourceMatches(candidate.contextTextMatches, 8, cwd),
|
||||
locatorMatches: compactManualApplySourceMatches(candidate.locatorMatches, 6, cwd),
|
||||
}));
|
||||
}
|
||||
|
||||
function compactManualApplySourceMatches(matches, limit, cwd) {
|
||||
return (Array.isArray(matches) ? matches : [])
|
||||
.slice(0, limit)
|
||||
.map((match) => compactManualApplySourceMatch(match, cwd))
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function compactManualApplySourceMatch(match, cwd) {
|
||||
if (!match || typeof match !== 'object') return null;
|
||||
const file = match.relativeFile || match.file;
|
||||
if (!file && !match.line) return null;
|
||||
return {
|
||||
file: summarizeManualLogFile(file, cwd),
|
||||
line: match.line || null,
|
||||
column: match.column || null,
|
||||
reason: match.reason || match.kind || undefined,
|
||||
status: match.status || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function compactManualApplyEntry(entry = {}) {
|
||||
return {
|
||||
id: entry.id,
|
||||
pageUrl: entry.pageUrl,
|
||||
stagedAt: entry.stagedAt || null,
|
||||
element: compactManualApplyContext(entry.element),
|
||||
ops: (entry.ops || []).map(compactManualApplyOp),
|
||||
};
|
||||
}
|
||||
|
||||
function compactManualApplyOp(op = {}) {
|
||||
return {
|
||||
entryId: op.entryId,
|
||||
ref: op.ref,
|
||||
contextRef: op.contextRef,
|
||||
tag: op.tag,
|
||||
elementId: op.elementId,
|
||||
classes: Array.isArray(op.classes) ? op.classes : [],
|
||||
originalText: op.originalText,
|
||||
newText: op.newText,
|
||||
deleted: op.deleted === true || undefined,
|
||||
sourceHint: op.sourceHint || null,
|
||||
leaf: compactManualApplyContext(op.leaf),
|
||||
nearbyEditableTexts: compactNearbyManualEditTexts(op.nearbyEditableTexts),
|
||||
container: compactManualApplyContext(op.container),
|
||||
contextHints: Array.isArray(op.contextHints) ? op.contextHints.slice(0, 8) : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function compactManualApplyContext(value) {
|
||||
if (!value || typeof value !== 'object') return null;
|
||||
return {
|
||||
ref: value.ref,
|
||||
tagName: value.tagName || value.tag || null,
|
||||
id: value.id || null,
|
||||
classes: Array.isArray(value.classes) ? value.classes : [],
|
||||
textContent: truncateManualApplyText(value.textContent, MANUAL_APPLY_COMPACT_TEXT_LIMIT),
|
||||
};
|
||||
}
|
||||
|
||||
function compactNearbyManualEditTexts(items) {
|
||||
return (Array.isArray(items) ? items : [])
|
||||
.slice(0, MANUAL_APPLY_COMPACT_NEARBY_LIMIT)
|
||||
.map((item) => typeof item === 'string' ? { text: truncateManualApplyText(item, MANUAL_APPLY_COMPACT_TEXT_LIMIT) } : {
|
||||
ref: item?.ref,
|
||||
tag: item?.tag,
|
||||
classes: Array.isArray(item?.classes) ? item.classes : [],
|
||||
text: truncateManualApplyText(item?.text, MANUAL_APPLY_COMPACT_TEXT_LIMIT),
|
||||
});
|
||||
}
|
||||
|
||||
function truncateManualApplyText(value, max) {
|
||||
if (typeof value !== 'string') return value || null;
|
||||
return value.length > max ? value.slice(0, max) : value;
|
||||
}
|
||||
|
||||
function normalizeApplyChunkResult(result) {
|
||||
const status = result?.status === 'partial' ? 'partial' : result?.status === 'error' ? 'error' : 'done';
|
||||
return {
|
||||
status,
|
||||
message: typeof result?.message === 'string' ? result.message : null,
|
||||
appliedEntryIds: Array.isArray(result?.appliedEntryIds) ? result.appliedEntryIds.filter((id) => typeof id === 'string') : [],
|
||||
failed: Array.isArray(result?.failed) ? result.failed.filter(Boolean) : [],
|
||||
files: Array.isArray(result?.files) ? result.files.filter((file) => typeof file === 'string') : [],
|
||||
notes: Array.isArray(result?.notes) ? result.notes.filter((note) => typeof note === 'string') : [],
|
||||
};
|
||||
}
|
||||
|
||||
function manualApplyResultShapeHint(eventId = 'EVENT_ID') {
|
||||
return `Use live-poll.mjs --reply ${eventId} done --data '{"status":"done","appliedEntryIds":["ENTRY_ID"],"failed":[],"files":["src/page.html"],"notes":[]}'`;
|
||||
}
|
||||
|
||||
function invalidManualApplyResult(reason, eventId, extra = {}) {
|
||||
return {
|
||||
ok: false,
|
||||
body: {
|
||||
error: 'invalid_manual_apply_result',
|
||||
reason,
|
||||
hint: manualApplyResultShapeHint(eventId),
|
||||
...extra,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function validateManualApplyResultMessage(msg, deferred) {
|
||||
let data = msg?.data;
|
||||
const eventId = msg?.id || deferred?.event?.id || 'EVENT_ID';
|
||||
if (!data || typeof data !== 'object' || Array.isArray(data)) {
|
||||
return invalidManualApplyResult('missing_result_data', eventId);
|
||||
}
|
||||
if ('entries' in data || 'ops' in data) {
|
||||
return invalidManualApplyResult('summary_result_not_allowed', eventId);
|
||||
}
|
||||
if (!['done', 'partial', 'error'].includes(data.status)) {
|
||||
return invalidManualApplyResult('invalid_status', eventId, { status: data.status ?? null });
|
||||
}
|
||||
|
||||
for (const key of ['appliedEntryIds', 'failed', 'files', 'notes']) {
|
||||
if (!Array.isArray(data[key])) {
|
||||
return invalidManualApplyResult(`${key}_must_be_array`, eventId);
|
||||
}
|
||||
}
|
||||
|
||||
for (const [index, value] of data.appliedEntryIds.entries()) {
|
||||
if (typeof value !== 'string' || !value) {
|
||||
return invalidManualApplyResult('appliedEntryIds_must_contain_strings', eventId, { index });
|
||||
}
|
||||
}
|
||||
for (const [index, value] of data.files.entries()) {
|
||||
if (typeof value !== 'string' || !value) {
|
||||
return invalidManualApplyResult('files_must_contain_strings', eventId, { index });
|
||||
}
|
||||
}
|
||||
for (const [index, value] of data.notes.entries()) {
|
||||
if (typeof value !== 'string') {
|
||||
return invalidManualApplyResult('notes_must_contain_strings', eventId, { index });
|
||||
}
|
||||
}
|
||||
for (const [index, item] of data.failed.entries()) {
|
||||
if (!item || typeof item !== 'object' || Array.isArray(item)) {
|
||||
return invalidManualApplyResult('failed_must_contain_objects', eventId, { index });
|
||||
}
|
||||
if (typeof item.entryId !== 'string' || !item.entryId) {
|
||||
return invalidManualApplyResult('failed_entryId_required', eventId, { index });
|
||||
}
|
||||
if (typeof item.reason !== 'string' || !item.reason) {
|
||||
return invalidManualApplyResult('failed_reason_required', eventId, { index });
|
||||
}
|
||||
}
|
||||
|
||||
const eventEntryIds = new Set((deferred?.batch?.entries || []).map((entry) => entry.id).filter(Boolean));
|
||||
for (const entryId of data.appliedEntryIds) {
|
||||
if (eventEntryIds.size > 0 && !eventEntryIds.has(entryId)) {
|
||||
return invalidManualApplyResult('applied_entry_id_not_in_event', eventId, { entryId });
|
||||
}
|
||||
}
|
||||
for (const item of data.failed) {
|
||||
if (eventEntryIds.size > 0 && !eventEntryIds.has(item.entryId)) {
|
||||
return invalidManualApplyResult('failed_entry_id_not_in_event', eventId, { entryId: item.entryId });
|
||||
}
|
||||
}
|
||||
|
||||
if (data.status === 'done') {
|
||||
if (data.failed.length > 0) {
|
||||
return invalidManualApplyResult('done_result_has_failed_entries', eventId);
|
||||
}
|
||||
if (countManualApplyOps(deferred?.batch) > 0 && data.appliedEntryIds.length === 0) {
|
||||
return invalidManualApplyResult('done_result_missing_applied_entry_ids', eventId);
|
||||
}
|
||||
}
|
||||
if (data.status === 'partial' && data.appliedEntryIds.length === 0 && data.failed.length === 0) {
|
||||
return invalidManualApplyResult('partial_result_has_no_entries', eventId);
|
||||
}
|
||||
if (data.status === 'error' && data.appliedEntryIds.length > 0) {
|
||||
return invalidManualApplyResult('error_result_has_applied_entries', eventId);
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
result: {
|
||||
status: data.status,
|
||||
message: typeof data.message === 'string' ? data.message : undefined,
|
||||
appliedEntryIds: data.appliedEntryIds,
|
||||
failed: data.failed,
|
||||
files: data.files,
|
||||
notes: data.notes,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function firstFailureReason(result) {
|
||||
const first = Array.isArray(result?.failed) ? result.failed.find(Boolean) : null;
|
||||
return first?.reason || first?.message || null;
|
||||
}
|
||||
|
||||
function markChunkEntriesFailed(failedByEntry, chunk, reason) {
|
||||
for (const entryId of chunk.entryIds) {
|
||||
if (failedByEntry.has(entryId)) continue;
|
||||
failedByEntry.set(entryId, { entryId, reason, candidates: [] });
|
||||
}
|
||||
}
|
||||
|
||||
export function splitManualApplyBatch(batch, maxOps) {
|
||||
const totalOpCount = countManualApplyOps(batch);
|
||||
if (totalOpCount <= maxOps) {
|
||||
return [{
|
||||
batch,
|
||||
meta: null,
|
||||
entryIds: new Set((batch?.entries || []).map((entry) => entry.id).filter(Boolean)),
|
||||
opCountsByEntry: new Map((batch?.entries || []).map((entry) => [entry.id, Array.isArray(entry.ops) ? entry.ops.length : 0])),
|
||||
}];
|
||||
}
|
||||
|
||||
const rawChunks = [];
|
||||
let current = createManualApplyChunkBuilder();
|
||||
for (const entry of batch?.entries || []) {
|
||||
const ops = entry.ops || [];
|
||||
if (ops.length <= maxOps) {
|
||||
if (current.opCount > 0 && current.opCount + ops.length > maxOps) {
|
||||
rawChunks.push(current);
|
||||
current = createManualApplyChunkBuilder();
|
||||
}
|
||||
for (const op of ops) addOpToManualApplyChunk(current, entry, op);
|
||||
continue;
|
||||
}
|
||||
if (current.opCount > 0) {
|
||||
rawChunks.push(current);
|
||||
current = createManualApplyChunkBuilder();
|
||||
}
|
||||
for (const op of ops) {
|
||||
if (current.opCount >= maxOps) {
|
||||
rawChunks.push(current);
|
||||
current = createManualApplyChunkBuilder();
|
||||
}
|
||||
addOpToManualApplyChunk(current, entry, op);
|
||||
}
|
||||
}
|
||||
if (current.opCount > 0) rawChunks.push(current);
|
||||
|
||||
return rawChunks.map((chunk, index) => ({
|
||||
batch: {
|
||||
...batch,
|
||||
count: chunk.opCount,
|
||||
entries: chunk.entries,
|
||||
ops: chunk.ops,
|
||||
candidates: filterManualApplyChunkCandidates(batch, chunk.refsByEntry),
|
||||
context: {
|
||||
...(batch?.context || {}),
|
||||
totalEntries: chunk.entries.length,
|
||||
totalOps: chunk.opCount,
|
||||
chunkIndex: index + 1,
|
||||
chunkTotal: rawChunks.length,
|
||||
totalApplyOps: totalOpCount,
|
||||
},
|
||||
},
|
||||
meta: {
|
||||
index: index + 1,
|
||||
total: rawChunks.length,
|
||||
opCount: chunk.opCount,
|
||||
totalOpCount,
|
||||
},
|
||||
entryIds: new Set(chunk.entries.map((entry) => entry.id).filter(Boolean)),
|
||||
opCountsByEntry: chunk.opCountsByEntry,
|
||||
}));
|
||||
}
|
||||
|
||||
function createManualApplyChunkBuilder() {
|
||||
return {
|
||||
entries: [],
|
||||
entryById: new Map(),
|
||||
entryIds: new Set(),
|
||||
ops: [],
|
||||
refsByEntry: new Map(),
|
||||
opCountsByEntry: new Map(),
|
||||
opCount: 0,
|
||||
};
|
||||
}
|
||||
|
||||
function addOpToManualApplyChunk(chunk, entry, op) {
|
||||
let chunkEntry = chunk.entryById.get(entry.id);
|
||||
if (!chunkEntry) {
|
||||
chunkEntry = { ...entry, ops: [] };
|
||||
chunk.entryById.set(entry.id, chunkEntry);
|
||||
chunk.entryIds.add(entry.id);
|
||||
chunk.entries.push(chunkEntry);
|
||||
}
|
||||
chunkEntry.ops.push(op);
|
||||
chunk.ops.push({ ...op, entryId: op.entryId || entry.id });
|
||||
if (!chunk.refsByEntry.has(entry.id)) chunk.refsByEntry.set(entry.id, new Set());
|
||||
if (op.ref) chunk.refsByEntry.get(entry.id).add(op.ref);
|
||||
chunk.opCountsByEntry.set(entry.id, (chunk.opCountsByEntry.get(entry.id) || 0) + 1);
|
||||
chunk.opCount += 1;
|
||||
}
|
||||
|
||||
function filterManualApplyChunkCandidates(batch, refsByEntry) {
|
||||
return (batch?.candidates || []).filter((candidate) => {
|
||||
const refs = refsByEntry.get(candidate.entryId);
|
||||
if (!refs) return false;
|
||||
if (!candidate.ref) return true;
|
||||
return refs.has(candidate.ref);
|
||||
});
|
||||
}
|
||||
|
||||
export function snapshotApplyEventFiles(batch, cwd = process.cwd()) {
|
||||
const snapshot = new Map();
|
||||
for (const relativeFile of collectManualApplyFiles(batch, [], cwd)) {
|
||||
const absolute = path.resolve(cwd, relativeFile);
|
||||
try {
|
||||
snapshot.set(relativeFile, {
|
||||
exists: fs.existsSync(absolute),
|
||||
content: fs.existsSync(absolute) ? fs.readFileSync(absolute, 'utf-8') : '',
|
||||
});
|
||||
} catch {
|
||||
// If a file cannot be read before dispatch, do not attempt late rollback.
|
||||
}
|
||||
}
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
export function manualApplyTransactionPath(cwd = process.cwd()) {
|
||||
return path.join(getLiveDir(cwd), 'manual-edit-apply-transaction.json');
|
||||
}
|
||||
|
||||
export function readManualApplyTransaction(cwd = process.cwd()) {
|
||||
const file = manualApplyTransactionPath(cwd);
|
||||
if (!fs.existsSync(file)) return null;
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(file, 'utf-8'));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function writeManualApplyTransaction({ cwd = process.cwd(), pageUrl = null, batch }) {
|
||||
const file = manualApplyTransactionPath(cwd);
|
||||
const files = collectManualApplyFiles(batch, [], cwd);
|
||||
const transaction = {
|
||||
version: 1,
|
||||
id: randomUUID().replace(/-/g, '').slice(0, 8),
|
||||
createdAt: new Date().toISOString(),
|
||||
pageUrl,
|
||||
entryIds: (batch?.entries || []).map((entry) => entry.id).filter(Boolean),
|
||||
files: files.map((relativeFile) => {
|
||||
const absolute = path.resolve(cwd, relativeFile);
|
||||
const exists = fs.existsSync(absolute);
|
||||
return {
|
||||
file: relativeFile,
|
||||
exists,
|
||||
content: exists ? fs.readFileSync(absolute, 'utf-8') : '',
|
||||
};
|
||||
}),
|
||||
};
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||
fs.writeFileSync(`${file}.tmp`, JSON.stringify(transaction, null, 2) + '\n', 'utf-8');
|
||||
fs.renameSync(`${file}.tmp`, file);
|
||||
return transaction;
|
||||
}
|
||||
|
||||
export function clearManualApplyTransaction(cwd = process.cwd(), transactionId = null) {
|
||||
const file = manualApplyTransactionPath(cwd);
|
||||
if (!fs.existsSync(file)) return false;
|
||||
if (transactionId) {
|
||||
const existing = readManualApplyTransaction(cwd);
|
||||
if (existing?.id && existing.id !== transactionId) return false;
|
||||
}
|
||||
try {
|
||||
fs.unlinkSync(file);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function rollbackManualApplyTransaction({
|
||||
cwd = process.cwd(),
|
||||
pageUrl = null,
|
||||
reason = 'manual_edit_transaction_rollback',
|
||||
recordManualEditActivity = null,
|
||||
} = {}) {
|
||||
const transaction = readManualApplyTransaction(cwd);
|
||||
if (!transaction) return null;
|
||||
if (pageUrl && transaction.pageUrl && transaction.pageUrl !== pageUrl) return null;
|
||||
|
||||
let pendingIds = new Set();
|
||||
try {
|
||||
const buffer = readManualEditsBuffer(cwd);
|
||||
pendingIds = new Set((buffer.entries || []).map((entry) => entry.id).filter(Boolean));
|
||||
} catch {
|
||||
pendingIds = new Set(transaction.entryIds || []);
|
||||
}
|
||||
const shouldRollback = (transaction.entryIds || []).some((id) => pendingIds.has(id));
|
||||
if (!shouldRollback) {
|
||||
clearManualApplyTransaction(cwd, transaction.id);
|
||||
return { id: transaction.id, reason, rolledBackFiles: [], rollbackFailures: [], skipped: 'entries_not_pending' };
|
||||
}
|
||||
|
||||
const rolledBackFiles = [];
|
||||
const rollbackFailures = [];
|
||||
for (const item of transaction.files || []) {
|
||||
const relativeFile = normalizeProjectFile(item.file, cwd);
|
||||
if (!relativeFile) continue;
|
||||
const absolute = path.resolve(cwd, relativeFile);
|
||||
try {
|
||||
if (item.exists) {
|
||||
fs.mkdirSync(path.dirname(absolute), { recursive: true });
|
||||
fs.writeFileSync(absolute, item.content || '', 'utf-8');
|
||||
} else if (fs.existsSync(absolute)) {
|
||||
fs.rmSync(absolute);
|
||||
}
|
||||
rolledBackFiles.push(relativeFile);
|
||||
} catch (err) {
|
||||
rollbackFailures.push({ file: relativeFile, reason: 'restore_failed', message: err.message || String(err) });
|
||||
}
|
||||
}
|
||||
clearManualApplyTransaction(cwd, transaction.id);
|
||||
recordManualEditActivity?.('manual_edit_transaction_rolled_back', {
|
||||
id: transaction.id,
|
||||
pageUrl: transaction.pageUrl || null,
|
||||
reason,
|
||||
entryIds: transaction.entryIds || [],
|
||||
rolledBackFiles: rolledBackFiles.map((file) => summarizeManualLogFile(file, cwd)).filter(Boolean),
|
||||
rollbackFailures: summarizeManualDiagnostics(rollbackFailures, cwd),
|
||||
});
|
||||
return { id: transaction.id, reason, rolledBackFiles, rollbackFailures };
|
||||
}
|
||||
|
||||
export function collectManualApplyFiles(batch, extraFiles = [], cwd = process.cwd()) {
|
||||
const files = [];
|
||||
for (const entry of batch?.entries || []) {
|
||||
for (const op of entry.ops || []) files.push(op.sourceHint?.file);
|
||||
}
|
||||
for (const candidate of batch?.candidates || []) {
|
||||
files.push(candidate.sourceHint?.relativeFile, candidate.sourceHint?.file);
|
||||
for (const item of candidate.textMatches || []) files.push(item.file);
|
||||
for (const item of candidate.objectKeyMatches || []) files.push(item.file);
|
||||
for (const item of candidate.locatorMatches || []) files.push(item.file);
|
||||
for (const item of candidate.contextTextMatches || []) files.push(item.file);
|
||||
}
|
||||
files.push(...(extraFiles || []));
|
||||
return [...new Set(files)]
|
||||
.map((file) => normalizeProjectFile(file, cwd))
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function normalizeProjectFile(file, cwd = process.cwd()) {
|
||||
if (!file || typeof file !== 'string') return null;
|
||||
const absolute = path.isAbsolute(file) ? file : path.resolve(cwd, file);
|
||||
const relative = path.relative(cwd, absolute);
|
||||
if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) return null;
|
||||
return relative;
|
||||
}
|
||||
|
||||
export function rollbackApplySnapshot(
|
||||
batch,
|
||||
rollbackSnapshot,
|
||||
extraFiles = [],
|
||||
_reason = 'manual_edit_apply_snapshot_rollback',
|
||||
cwd = process.cwd(),
|
||||
) {
|
||||
const scope = collectManualApplyFiles(batch, extraFiles, cwd);
|
||||
const rolledBackFiles = [];
|
||||
const rollbackFailures = [];
|
||||
for (const relativeFile of scope) {
|
||||
const before = rollbackSnapshot?.get(relativeFile);
|
||||
if (!before) continue;
|
||||
const absolute = path.resolve(cwd, relativeFile);
|
||||
try {
|
||||
if (before.exists) {
|
||||
fs.mkdirSync(path.dirname(absolute), { recursive: true });
|
||||
fs.writeFileSync(absolute, before.content, 'utf-8');
|
||||
} else if (fs.existsSync(absolute)) {
|
||||
fs.rmSync(absolute);
|
||||
}
|
||||
rolledBackFiles.push(relativeFile);
|
||||
} catch (err) {
|
||||
rollbackFailures.push({ file: relativeFile, reason: 'restore_failed', message: err.message || String(err) });
|
||||
}
|
||||
}
|
||||
return { rolledBackFiles, rollbackFailures };
|
||||
}
|
||||
|
||||
function manualApplyReplyCommand(eventOrId = 'EVENT_ID') {
|
||||
const id = typeof eventOrId === 'string' ? eventOrId : eventOrId?.id || 'EVENT_ID';
|
||||
return `live-poll.mjs --reply ${id} done --data '<json>'`;
|
||||
}
|
||||
|
||||
export function buildManualApplyAgentAction(eventOrId = 'EVENT_ID') {
|
||||
return {
|
||||
kind: 'manual_edit_apply',
|
||||
required: 'apply_source_edits_then_reply',
|
||||
replyCommand: manualApplyReplyCommand(eventOrId),
|
||||
warning: 'Polling only leases this work item; it does not commit source edits.',
|
||||
};
|
||||
}
|
||||
|
||||
export function summarizeManualApplyEvent(event = {}, batch = event.batch, cwd = process.cwd()) {
|
||||
const entries = Array.isArray(batch?.entries) ? batch.entries : [];
|
||||
const opCount = entries.reduce((sum, entry) => sum + (Array.isArray(entry.ops) ? entry.ops.length : 0), 0);
|
||||
return {
|
||||
pageUrl: event.pageUrl || null,
|
||||
chunk: event.chunk || null,
|
||||
entryCount: entries.length,
|
||||
opCount,
|
||||
files: collectManualApplyFiles(batch, [], cwd),
|
||||
};
|
||||
}
|
||||
|
||||
export function summarizeManualApplyFailures(failed, cwd = process.cwd()) {
|
||||
if (!Array.isArray(failed)) return [];
|
||||
return failed.slice(0, 20).map((item) => ({
|
||||
id: item.id || item.entryId || null,
|
||||
reason: item.reason || item.message || 'failed',
|
||||
message: compactManualLogText(item.message, 300),
|
||||
files: Array.isArray(item.files) ? item.files.slice(0, 12).map((file) => summarizeManualLogFile(file, cwd)).filter(Boolean) : undefined,
|
||||
checks: summarizeManualDiagnostics(item.checks, cwd),
|
||||
failures: summarizeManualDiagnostics(item.failures, cwd),
|
||||
candidates: summarizeManualDiagnostics(item.candidates, cwd),
|
||||
}));
|
||||
}
|
||||
|
||||
export function summarizeManualDiagnostics(items, cwd = process.cwd()) {
|
||||
if (!Array.isArray(items) || items.length === 0) return undefined;
|
||||
return items.slice(0, 12).map((item) => ({
|
||||
reason: item.reason || item.kind || undefined,
|
||||
detail: compactManualLogText(item.detail, 220),
|
||||
message: compactManualLogText(item.message, 300),
|
||||
file: summarizeManualLogFile(item.file || item.relativeFile, cwd),
|
||||
line: item.line || undefined,
|
||||
ref: compactManualLogText(item.ref, 180),
|
||||
marker: compactManualLogText(item.marker, 120),
|
||||
files: Array.isArray(item.files) ? item.files.slice(0, 8).map((file) => summarizeManualLogFile(file, cwd)).filter(Boolean) : undefined,
|
||||
}));
|
||||
}
|
||||
|
||||
export function summarizeManualLogFile(file, cwd = process.cwd()) {
|
||||
if (!file || typeof file !== 'string') return undefined;
|
||||
if (!path.isAbsolute(file)) return file;
|
||||
const relative = path.relative(cwd, file);
|
||||
return relative && !relative.startsWith('..') && !path.isAbsolute(relative) ? relative : file;
|
||||
}
|
||||
|
||||
export function compactManualLogText(value, max = 200) {
|
||||
if (typeof value !== 'string') return undefined;
|
||||
const normalized = value.replace(/\s+/g, ' ').trim();
|
||||
if (normalized.length <= max) return normalized;
|
||||
return normalized.slice(0, max) + `... [truncated ${normalized.length - max} chars]`;
|
||||
}
|
||||
@@ -0,0 +1,357 @@
|
||||
import { validateEvent } from './event-validation.mjs';
|
||||
import {
|
||||
countByPage as countPendingByPage,
|
||||
readBuffer as readManualEditsBuffer,
|
||||
removeEntries as removeManualEditEntries,
|
||||
stageEntry as stageManualEditEntry,
|
||||
truncateBuffer as truncateManualEditsBuffer,
|
||||
} from './manual-edits-buffer.mjs';
|
||||
import {
|
||||
summarizeManualApplyFailures,
|
||||
summarizeManualDiagnostics,
|
||||
summarizeManualLogFile,
|
||||
} from './manual-apply.mjs';
|
||||
import { buildManualEditEvidence } from '../live-manual-edit-evidence.mjs';
|
||||
import { commitManualEdits } from '../live-commit-manual-edits.mjs';
|
||||
|
||||
export function createManualEditRoutes({
|
||||
getToken,
|
||||
manualApply,
|
||||
recordManualEditActivity,
|
||||
getManualEditStatus,
|
||||
chatAgentLikelyActive,
|
||||
cwd = () => process.cwd(),
|
||||
env = () => process.env,
|
||||
} = {}) {
|
||||
const projectCwd = () => typeof cwd === 'function' ? cwd() : cwd || process.cwd();
|
||||
const currentEnv = () => typeof env === 'function' ? env() : env || process.env;
|
||||
|
||||
return function handleManualEditRoute(req, res, url) {
|
||||
const p = url.pathname;
|
||||
|
||||
// Save stages entries; Apply commits the staged page batch through the
|
||||
// local AI copy-edit runner.
|
||||
if (p === '/manual-edit-stash' && req.method === 'POST') {
|
||||
let body = '';
|
||||
req.on('data', (c) => { body += c; });
|
||||
req.on('end', () => {
|
||||
let msg;
|
||||
try { msg = JSON.parse(body); } catch {
|
||||
sendJson(res, 400, { error: 'Invalid JSON' });
|
||||
return;
|
||||
}
|
||||
if (msg.token !== getToken()) {
|
||||
sendJson(res, 401, { error: 'Unauthorized' });
|
||||
return;
|
||||
}
|
||||
const error = validateEvent({ ...msg, type: 'manual_edits' });
|
||||
if (error) {
|
||||
sendJson(res, 400, { error });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
stageManualEditEntry(projectCwd(), {
|
||||
id: msg.id,
|
||||
pageUrl: msg.pageUrl,
|
||||
element: msg.element,
|
||||
ops: msg.ops,
|
||||
});
|
||||
} catch (err) {
|
||||
sendJson(res, 500, { error: 'stash_write_failed', message: err.message });
|
||||
return;
|
||||
}
|
||||
const { totalCount, perPage } = countPendingByPage(projectCwd());
|
||||
const pendingCount = perPage[msg.pageUrl] || 0;
|
||||
recordManualEditActivity('manual_edit_stashed', {
|
||||
id: msg.id,
|
||||
pageUrl: msg.pageUrl,
|
||||
opCount: msg.ops.length,
|
||||
pendingCount,
|
||||
totalCount,
|
||||
hintedFileCount: new Set((msg.ops || []).map((op) => summarizeManualLogFile(op.sourceHint?.file, projectCwd())).filter(Boolean)).size,
|
||||
});
|
||||
sendJson(res, 200, { ok: true, pendingCount, totalCount, perPage });
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
if (p === '/manual-edit-stash' && req.method === 'GET') {
|
||||
const token = url.searchParams.get('token');
|
||||
if (token !== getToken()) { res.writeHead(401); res.end('Unauthorized'); return true; }
|
||||
const pageUrl = url.searchParams.get('pageUrl') || '';
|
||||
const { totalCount, perPage } = countPendingByPage(projectCwd());
|
||||
const buffer = readManualEditsBuffer(projectCwd());
|
||||
const entriesForPage = pageUrl ? buffer.entries.filter((e) => e.pageUrl === pageUrl) : buffer.entries;
|
||||
sendJson(res, 200, {
|
||||
count: pageUrl ? (perPage[pageUrl] || 0) : totalCount,
|
||||
totalCount,
|
||||
perPage,
|
||||
entries: entriesForPage,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
if (p === '/manual-edit-commit' && req.method === 'POST') {
|
||||
const token = url.searchParams.get('token');
|
||||
if (token !== getToken()) { res.writeHead(401); res.end('Unauthorized'); return true; }
|
||||
const pageUrl = url.searchParams.get('pageUrl');
|
||||
const asyncMode = /^(1|true|yes)$/i.test(url.searchParams.get('async') || '');
|
||||
const repairOnly = /^(1|true|yes)$/i.test(url.searchParams.get('repair') || '');
|
||||
const existingTransaction = manualApply.readTransaction();
|
||||
if (repairOnly && !existingTransaction) {
|
||||
sendJson(res, 409, { error: 'manual_edit_repair_transaction_missing' });
|
||||
return true;
|
||||
}
|
||||
const recoveredTransaction = repairOnly ? null : manualApply.rollbackTransaction({
|
||||
pageUrl,
|
||||
reason: 'manual_edit_commit_recovered_abandoned_transaction',
|
||||
});
|
||||
const before = getManualEditStatus();
|
||||
const pendingCount = pageUrl ? (before.perPage[pageUrl] || 0) : before.totalCount;
|
||||
recordManualEditActivity('manual_edit_commit_started', {
|
||||
pageUrl,
|
||||
repairOnly,
|
||||
pendingCount,
|
||||
totalCount: before.totalCount,
|
||||
recoveredTransaction: recoveredTransaction ? {
|
||||
id: recoveredTransaction.id,
|
||||
reason: recoveredTransaction.reason,
|
||||
skipped: recoveredTransaction.skipped,
|
||||
rolledBackFiles: recoveredTransaction.rolledBackFiles,
|
||||
rollbackFailures: summarizeManualDiagnostics(recoveredTransaction.rollbackFailures, projectCwd()),
|
||||
} : null,
|
||||
...summarizePendingManualEditBatch(projectCwd(), pageUrl),
|
||||
});
|
||||
if (asyncMode) {
|
||||
sendJson(res, 202, {
|
||||
status: 'started',
|
||||
pendingCount,
|
||||
totalCount: before.totalCount,
|
||||
perPage: before.perPage,
|
||||
});
|
||||
}
|
||||
(async () => {
|
||||
let result;
|
||||
let routedProvider = 'subprocess';
|
||||
let transaction = null;
|
||||
let commitBatch = null;
|
||||
try {
|
||||
if (pendingCount > 0) {
|
||||
const transactionBatch = buildManualEditEvidence({ cwd: projectCwd(), pageUrl });
|
||||
commitBatch = transactionBatch;
|
||||
if (!repairOnly && manualApply.countOps(transactionBatch) > 0) {
|
||||
transaction = manualApply.writeTransaction({
|
||||
pageUrl,
|
||||
batch: transactionBatch,
|
||||
});
|
||||
} else if (repairOnly && existingTransaction) {
|
||||
transaction = existingTransaction;
|
||||
}
|
||||
}
|
||||
const envValue = currentEnv();
|
||||
const requestedMode = (envValue.IMPECCABLE_LIVE_COPY_AGENT || 'auto').trim().toLowerCase();
|
||||
const useChatRoute = requestedMode === 'chat'
|
||||
|| (requestedMode === 'auto' && chatAgentLikelyActive());
|
||||
if (useChatRoute) {
|
||||
routedProvider = 'chat';
|
||||
const timeoutMs = Number(envValue.IMPECCABLE_LIVE_COPY_AGENT_TIMEOUT_MS || 120000);
|
||||
result = await commitManualEdits({
|
||||
cwd: projectCwd(),
|
||||
pageUrl,
|
||||
provider: 'chat',
|
||||
env: envValue,
|
||||
timeoutMs,
|
||||
chatAvailable: chatAgentLikelyActive,
|
||||
applyBatchToSource: (batch, context) => manualApply.pushBatchInChunksAndWait(batch, pageUrl, context),
|
||||
repairOnly,
|
||||
transactionId: transaction?.id || existingTransaction?.id || null,
|
||||
batch: commitBatch,
|
||||
});
|
||||
} else {
|
||||
const timeoutMs = Number(envValue.IMPECCABLE_LIVE_COPY_AGENT_TIMEOUT_MS || 120000);
|
||||
const provider = ['codex', 'claude', 'mock'].includes(requestedMode) ? requestedMode : undefined;
|
||||
result = await commitManualEdits({
|
||||
cwd: projectCwd(),
|
||||
pageUrl,
|
||||
provider,
|
||||
env: envValue,
|
||||
timeoutMs,
|
||||
chatAvailable: chatAgentLikelyActive,
|
||||
repairOnly,
|
||||
transactionId: transaction?.id || existingTransaction?.id || null,
|
||||
batch: commitBatch,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
if (transaction) {
|
||||
manualApply.rollbackTransaction({
|
||||
pageUrl,
|
||||
reason: 'manual_edit_commit_exception',
|
||||
});
|
||||
}
|
||||
const message = err.stderr?.toString?.() || err.message;
|
||||
recordManualEditActivity('manual_edit_commit_failed', {
|
||||
pageUrl,
|
||||
provider: routedProvider,
|
||||
error: 'manual_edit_commit_failed',
|
||||
message,
|
||||
transactionId: transaction?.id || null,
|
||||
});
|
||||
if (!asyncMode) {
|
||||
sendJson(res, 500, {
|
||||
error: 'manual_edit_commit_failed',
|
||||
message,
|
||||
});
|
||||
}
|
||||
return;
|
||||
} finally {
|
||||
if (transaction) {
|
||||
const shouldKeepTransaction = result?.needsManualDecision === true;
|
||||
if (!shouldKeepTransaction) manualApply.clearTransaction(transaction.id);
|
||||
}
|
||||
}
|
||||
const { totalCount, perPage } = countPendingByPage(projectCwd());
|
||||
if (result?.needsManualDecision) {
|
||||
recordManualEditActivity('manual_edit_repair_needs_decision', {
|
||||
pageUrl,
|
||||
provider: routedProvider,
|
||||
transactionId: transaction?.id || existingTransaction?.id || null,
|
||||
repair: result.repair || null,
|
||||
failed: summarizeManualApplyFailures(result.failed, projectCwd()),
|
||||
files: Array.isArray(result.files) ? result.files.slice(0, 20).map((file) => summarizeManualLogFile(file, projectCwd())).filter(Boolean) : [],
|
||||
remainingCount: pageUrl ? (perPage[pageUrl] || 0) : totalCount,
|
||||
totalCount,
|
||||
});
|
||||
} else {
|
||||
recordManualEditActivity('manual_edit_commit_done', {
|
||||
pageUrl,
|
||||
provider: routedProvider,
|
||||
reason: result.reason || null,
|
||||
repair: result.repair || null,
|
||||
appliedCount: Array.isArray(result.applied) ? result.applied.length : 0,
|
||||
failedCount: Array.isArray(result.failed) ? result.failed.length : 0,
|
||||
failed: summarizeManualApplyFailures(result.failed, projectCwd()),
|
||||
files: Array.isArray(result.files) ? result.files.slice(0, 20).map((file) => summarizeManualLogFile(file, projectCwd())).filter(Boolean) : [],
|
||||
warnings: summarizeManualDiagnostics(result.warnings, projectCwd()),
|
||||
rolledBackFiles: Array.isArray(result.rolledBackFiles) ? result.rolledBackFiles.slice(0, 20).map((file) => summarizeManualLogFile(file, projectCwd())).filter(Boolean) : [],
|
||||
rollbackFailures: summarizeManualDiagnostics(result.rollbackFailures, projectCwd()),
|
||||
unreportedFiles: Array.isArray(result.unreportedFiles) ? result.unreportedFiles.slice(0, 20).map((file) => summarizeManualLogFile(file, projectCwd())).filter(Boolean) : undefined,
|
||||
noteCount: Array.isArray(result.notes) ? result.notes.length : 0,
|
||||
cleared: result.cleared || 0,
|
||||
remainingCount: pageUrl ? (perPage[pageUrl] || 0) : totalCount,
|
||||
totalCount,
|
||||
});
|
||||
}
|
||||
if (!asyncMode) {
|
||||
sendJson(res, 200, { ...result, totalCount, perPage });
|
||||
}
|
||||
})();
|
||||
return true;
|
||||
}
|
||||
|
||||
if (p === '/manual-edit-repair-decision' && req.method === 'POST') {
|
||||
let body = '';
|
||||
req.on('data', (chunk) => { body += chunk; });
|
||||
req.on('end', () => {
|
||||
let payload = {};
|
||||
try { payload = body ? JSON.parse(body) : {}; } catch {
|
||||
sendJson(res, 400, { error: 'Invalid JSON' });
|
||||
return;
|
||||
}
|
||||
const token = payload.token || url.searchParams.get('token');
|
||||
if (token !== getToken()) { res.writeHead(401); res.end('Unauthorized'); return; }
|
||||
const pageUrl = payload.pageUrl || url.searchParams.get('pageUrl') || null;
|
||||
const action = String(payload.action || url.searchParams.get('action') || '').trim().toLowerCase();
|
||||
if (action !== 'rollback') {
|
||||
sendJson(res, 400, { error: 'unsupported_manual_edit_repair_decision', action });
|
||||
return;
|
||||
}
|
||||
const rollback = manualApply.rollbackTransaction({
|
||||
pageUrl,
|
||||
reason: 'manual_edit_user_requested_rollback',
|
||||
});
|
||||
const { totalCount, perPage } = countPendingByPage(projectCwd());
|
||||
const response = {
|
||||
action,
|
||||
pageUrl,
|
||||
rollback,
|
||||
remainingCount: pageUrl ? (perPage[pageUrl] || 0) : totalCount,
|
||||
totalCount,
|
||||
perPage,
|
||||
};
|
||||
recordManualEditActivity('manual_edit_repair_rollback_done', response);
|
||||
sendJson(res, 200, response);
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
if (p === '/manual-edit-discard' && req.method === 'POST') {
|
||||
const token = url.searchParams.get('token');
|
||||
if (token !== getToken()) { res.writeHead(401); res.end('Unauthorized'); return true; }
|
||||
const pageUrl = url.searchParams.get('pageUrl');
|
||||
let discarded;
|
||||
let discardedEntries = [];
|
||||
let canceledApplyEvents = [];
|
||||
let transactionRollback = null;
|
||||
try {
|
||||
const buffer = readManualEditsBuffer(projectCwd());
|
||||
transactionRollback = manualApply.rollbackTransaction({
|
||||
pageUrl,
|
||||
reason: 'manual_edit_discarded',
|
||||
});
|
||||
if (pageUrl) {
|
||||
discardedEntries = buffer.entries.filter((entry) => entry.pageUrl === pageUrl);
|
||||
discarded = removeManualEditEntries(projectCwd(), (entry) => entry.pageUrl === pageUrl);
|
||||
} else {
|
||||
discardedEntries = buffer.entries;
|
||||
discarded = truncateManualEditsBuffer(projectCwd());
|
||||
}
|
||||
canceledApplyEvents = manualApply.cancelPendingEvents(pageUrl);
|
||||
} catch (err) {
|
||||
sendJson(res, 500, { error: 'discard_failed', message: err.message });
|
||||
return true;
|
||||
}
|
||||
const { totalCount, perPage } = countPendingByPage(projectCwd());
|
||||
recordManualEditActivity('manual_edit_discarded', {
|
||||
pageUrl,
|
||||
discarded,
|
||||
canceledApplyIds: canceledApplyEvents.map((event) => event.id),
|
||||
transactionRollback: transactionRollback ? {
|
||||
id: transactionRollback.id,
|
||||
rolledBackFiles: transactionRollback.rolledBackFiles?.map((file) => summarizeManualLogFile(file, projectCwd())).filter(Boolean) || [],
|
||||
rollbackFailures: summarizeManualDiagnostics(transactionRollback.rollbackFailures, projectCwd()),
|
||||
skipped: transactionRollback.skipped,
|
||||
} : undefined,
|
||||
totalCount,
|
||||
});
|
||||
sendJson(res, 200, { discarded, entries: discardedEntries, canceledApplyEvents, totalCount, perPage });
|
||||
return true;
|
||||
}
|
||||
|
||||
if (p === '/manual-edit' && req.method === 'POST') {
|
||||
sendJson(res, 410, { error: '/manual-edit is removed; use /manual-edit-stash and /manual-edit-commit for staged copy edits.' });
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
}
|
||||
|
||||
function sendJson(res, status, body) {
|
||||
res.writeHead(status, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify(body));
|
||||
}
|
||||
|
||||
function summarizePendingManualEditBatch(cwd, pageUrl = null) {
|
||||
try {
|
||||
const buffer = readManualEditsBuffer(cwd);
|
||||
const entries = (buffer.entries || [])
|
||||
.filter((entry) => !pageUrl || entry.pageUrl === pageUrl);
|
||||
return {
|
||||
pendingEntryCount: entries.length,
|
||||
pendingOpCount: entries.reduce((sum, entry) => sum + (entry.ops?.length || 0), 0),
|
||||
};
|
||||
} catch (err) {
|
||||
return { pendingSummaryError: err.message || String(err) };
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -12,7 +12,7 @@
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { getLiveDir } from './impeccable-paths.mjs';
|
||||
import { getLiveDir } from '../lib/impeccable-paths.mjs';
|
||||
|
||||
const BUFFER_VERSION = 1;
|
||||
const BUFFER_FILENAME = 'pending-manual-edits.json';
|
||||
+21
-3
@@ -1,6 +1,6 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { getLegacyLiveSessionsDir, getLiveSessionsDir } from './impeccable-paths.mjs';
|
||||
import { getLegacyLiveSessionsDir, getLiveSessionsDir } from '../lib/impeccable-paths.mjs';
|
||||
|
||||
const COMPLETED_PHASES = new Set(['completed', 'discarded']);
|
||||
|
||||
@@ -106,6 +106,8 @@ function baseSnapshot(id) {
|
||||
phase: 'new',
|
||||
pageUrl: null,
|
||||
sourceFile: null,
|
||||
previewFile: null,
|
||||
previewMode: null,
|
||||
expectedVariants: 0,
|
||||
arrivedVariants: 0,
|
||||
visibleVariant: null,
|
||||
@@ -177,8 +179,10 @@ function applyEvent(snapshot, entry, inheritedDiagnostics = []) {
|
||||
case 'variants_ready':
|
||||
case 'agent_done':
|
||||
next.phase = event.carbonize === true ? 'carbonize_required' : 'variants_ready';
|
||||
next.sourceFile = event.file ?? next.sourceFile;
|
||||
next.arrivedVariants = event.arrivedVariants ?? (next.arrivedVariants ?? next.expectedVariants);
|
||||
next.sourceFile = event.sourceFile ?? event.file ?? next.sourceFile;
|
||||
next.previewFile = event.previewFile ?? next.previewFile;
|
||||
next.previewMode = event.previewMode ?? next.previewMode;
|
||||
next.arrivedVariants = event.arrivedVariants ?? (next.expectedVariants || next.arrivedVariants || 0);
|
||||
next.pendingEventSeq = null;
|
||||
next.pendingEvent = null;
|
||||
if (event.carbonize === true) {
|
||||
@@ -190,12 +194,19 @@ function applyEvent(snapshot, entry, inheritedDiagnostics = []) {
|
||||
}
|
||||
break;
|
||||
case 'checkpoint':
|
||||
if (COMPLETED_PHASES.has(next.phase)) {
|
||||
next.diagnostics.push({ error: 'checkpoint_after_terminal_ignored', phase: event.phase ?? null, revision: event.revision ?? null });
|
||||
break;
|
||||
}
|
||||
if ((event.revision ?? 0) >= (next.checkpointRevision ?? 0)) {
|
||||
next.phase = event.phase ?? next.phase;
|
||||
next.checkpointRevision = event.revision ?? next.checkpointRevision;
|
||||
next.activeOwner = event.owner ?? next.activeOwner;
|
||||
next.arrivedVariants = event.arrivedVariants ?? next.arrivedVariants;
|
||||
next.visibleVariant = event.visibleVariant ?? next.visibleVariant;
|
||||
next.sourceFile = event.sourceFile ?? next.sourceFile;
|
||||
next.previewFile = event.previewFile ?? next.previewFile;
|
||||
next.previewMode = event.previewMode ?? next.previewMode;
|
||||
if (event.paramValues) next.paramValues = { ...event.paramValues };
|
||||
} else {
|
||||
next.diagnostics.push({ error: 'stale_checkpoint_ignored', revision: event.revision });
|
||||
@@ -223,6 +234,10 @@ function applyEvent(snapshot, entry, inheritedDiagnostics = []) {
|
||||
break;
|
||||
case 'steer_done':
|
||||
next.phase = 'steer_done';
|
||||
next.sourceFile = event.sourceFile ?? event.file ?? next.sourceFile;
|
||||
next.previewFile = event.previewFile ?? next.previewFile;
|
||||
next.previewMode = event.previewMode ?? next.previewMode;
|
||||
next.message = event.message ?? next.message;
|
||||
next.pendingEventSeq = null;
|
||||
next.pendingEvent = null;
|
||||
break;
|
||||
@@ -238,6 +253,9 @@ function applyEvent(snapshot, entry, inheritedDiagnostics = []) {
|
||||
break;
|
||||
case 'complete':
|
||||
next.phase = 'completed';
|
||||
next.sourceFile = event.sourceFile ?? event.file ?? next.sourceFile;
|
||||
next.previewFile = event.previewFile ?? next.previewFile;
|
||||
next.previewMode = event.previewMode ?? next.previewMode;
|
||||
next.pendingEventSeq = null;
|
||||
next.pendingEvent = null;
|
||||
break;
|
||||
@@ -0,0 +1,826 @@
|
||||
/**
|
||||
* Svelte live-mode component injection helpers.
|
||||
*
|
||||
* Variants are real .svelte components under node_modules/.impeccable-live/<session-id>/.
|
||||
* The browser mounts them via Svelte 5 mount(); accept inlines the chosen
|
||||
* variant back into the route source with props mapped to original bindings.
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
import { createHash } from 'node:crypto';
|
||||
|
||||
export const SVELTE_COMPONENT_ROOT = 'node_modules/.impeccable-live';
|
||||
export const SVELTE_RUNTIME_FILE = `${SVELTE_COMPONENT_ROOT}/__runtime.js`;
|
||||
export const DEFERRED_ACCEPTS_FILE = '.impeccable/live/deferred-svelte-component-accepts.json';
|
||||
|
||||
const MUSTACHE_RE = /\{([^{}]+)\}/g;
|
||||
|
||||
export function shouldUseSvelteComponentInjection(filePath) {
|
||||
if (/^(0|false|no)$/i.test(process.env.IMPECCABLE_LIVE_SVELTE_COMPONENT || '')) return false;
|
||||
return path.extname(filePath).toLowerCase() === '.svelte';
|
||||
}
|
||||
|
||||
export function componentSessionDir(id, cwd = process.cwd()) {
|
||||
return path.join(cwd, SVELTE_COMPONENT_ROOT, id);
|
||||
}
|
||||
|
||||
export function manifestPathForSession(id, cwd = process.cwd()) {
|
||||
return path.join(componentSessionDir(id, cwd), 'manifest.json');
|
||||
}
|
||||
|
||||
export function ensureRuntimeHelper(cwd = process.cwd()) {
|
||||
const file = path.join(cwd, SVELTE_RUNTIME_FILE);
|
||||
if (fs.existsSync(file)) return file;
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||
fs.writeFileSync(file, `export { mount, unmount } from 'svelte';\n`, 'utf-8');
|
||||
return file;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract ordered unique mustache expressions from markup (not inside <!-- -->).
|
||||
*/
|
||||
export function extractMustacheExpressions(text) {
|
||||
const expressions = [];
|
||||
const seen = new Set();
|
||||
const lines = String(text || '').split('\n');
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (trimmed.startsWith('<!--')) continue;
|
||||
let match;
|
||||
MUSTACHE_RE.lastIndex = 0;
|
||||
while ((match = MUSTACHE_RE.exec(line)) !== null) {
|
||||
const expr = match[1].trim();
|
||||
if (!expr || seen.has(expr)) continue;
|
||||
seen.add(expr);
|
||||
expressions.push(expr);
|
||||
}
|
||||
}
|
||||
return expressions;
|
||||
}
|
||||
|
||||
export function buildPropContract(expressions) {
|
||||
return expressions.map((expr, index) => {
|
||||
const derived = derivePropName(expr, index);
|
||||
return {
|
||||
prop: derived,
|
||||
expr,
|
||||
placeholder: `{${expr}}`,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function derivePropName(expr, index) {
|
||||
const tail = expr.match(/(?:\.|\[)(\w+)\s*\]?$/);
|
||||
if (tail && tail[1] && /^[A-Za-z_$][\w$]*$/.test(tail[1])) {
|
||||
return tail[1];
|
||||
}
|
||||
return `prop${index}`;
|
||||
}
|
||||
|
||||
export function substituteExprsWithProps(markup, contract) {
|
||||
let out = String(markup || '');
|
||||
for (const entry of contract) {
|
||||
out = out.split(entry.placeholder).join(`{${entry.prop}}`);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function substitutePropsWithExprs(markup, contract) {
|
||||
let out = String(markup || '');
|
||||
for (const entry of contract) {
|
||||
out = out.split(`{${entry.prop}}`).join(`{${entry.expr}}`);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function parseSvelteComponentFile(content) {
|
||||
const text = String(content || '');
|
||||
const scriptMatch = text.match(/^([\s\S]*?)<script\b[^>]*>[\s\S]*?<\/script>/i);
|
||||
const withoutScript = scriptMatch ? text.slice(scriptMatch[0].length) : text;
|
||||
const styleMatch = withoutScript.match(/<style\b[^>]*>[\s\S]*?<\/style\s*>/i);
|
||||
const styleBlock = styleMatch ? styleMatch[0] : '';
|
||||
const markup = styleMatch
|
||||
? withoutScript.slice(0, styleMatch.index).trim()
|
||||
: withoutScript.trim();
|
||||
const cssLines = styleBlock
|
||||
? styleBlock
|
||||
.replace(/^<style\b[^>]*>/i, '')
|
||||
.replace(/<\/style\s*>$/i, '')
|
||||
.split('\n')
|
||||
.map((line) => line.trimEnd())
|
||||
: [];
|
||||
while (cssLines.length > 0 && cssLines[0].trim() === '') cssLines.shift();
|
||||
while (cssLines.length > 0 && cssLines[cssLines.length - 1].trim() === '') cssLines.pop();
|
||||
return { markup, cssLines, styleBlock };
|
||||
}
|
||||
|
||||
function buildPropsScript(contract) {
|
||||
if (contract.length === 0) {
|
||||
return '<script>\n /** @type {Record<string, never>} */\n let {} = $props();\n</script>\n';
|
||||
}
|
||||
const names = contract.map((c) => c.prop).join(', ');
|
||||
const typeFields = contract.map((c) => ` ${c.prop}: string;`).join('\n');
|
||||
return `<script>\n /** @type {{\n${typeFields}\n }} */\n let { ${names} } = $props();\n</script>\n`;
|
||||
}
|
||||
|
||||
function buildVariantStub(variantNum, originalWithProps, contract) {
|
||||
const propsComment = contract.length > 0
|
||||
? `\n<!-- Props: ${contract.map((c) => `${c.prop} <- {${c.expr}}`).join(', ')} -->\n`
|
||||
: '';
|
||||
return `${buildPropsScript(contract)}${propsComment}${originalWithProps.trim()}\n\n<style>\n /* Variant ${variantNum}: add scoped CSS here */\n</style>\n`;
|
||||
}
|
||||
|
||||
function buildInsertVariantStub(variantNum) {
|
||||
return `${buildPropsScript([])}<div class="impeccable-insert-preview">Insert variant ${variantNum}</div>\n\n<style>\n .impeccable-insert-preview { display: block; }\n</style>\n`;
|
||||
}
|
||||
|
||||
export function scaffoldSvelteComponentSession({
|
||||
id,
|
||||
count,
|
||||
sourceFile,
|
||||
sourceStartLine,
|
||||
sourceEndLine,
|
||||
originalLines,
|
||||
cwd = process.cwd(),
|
||||
}) {
|
||||
ensureRuntimeHelper(cwd);
|
||||
const dir = componentSessionDir(id, cwd);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
|
||||
const originalMarkup = originalLines.join('\n');
|
||||
const contract = buildPropContract(extractMustacheExpressions(originalMarkup));
|
||||
const originalWithProps = substituteExprsWithProps(originalMarkup, contract);
|
||||
|
||||
const manifest = {
|
||||
id,
|
||||
previewMode: 'svelte-component',
|
||||
sourceFile: sourceFile.split(path.sep).join('/'),
|
||||
sourceStartLine,
|
||||
sourceEndLine,
|
||||
count,
|
||||
propContract: contract,
|
||||
originalMarkup,
|
||||
componentDir: path.relative(cwd, dir).split(path.sep).join('/'),
|
||||
runtimeModule: `/${SVELTE_RUNTIME_FILE}`,
|
||||
};
|
||||
|
||||
fs.writeFileSync(path.join(dir, 'manifest.json'), JSON.stringify(manifest, null, 2) + '\n', 'utf-8');
|
||||
|
||||
for (let n = 1; n <= count; n++) {
|
||||
const variantFile = path.join(dir, `v${n}.svelte`);
|
||||
if (!fs.existsSync(variantFile)) {
|
||||
fs.writeFileSync(variantFile, buildVariantStub(n, originalWithProps, contract), 'utf-8');
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
manifest,
|
||||
manifestFile: path.relative(cwd, path.join(dir, 'manifest.json')).split(path.sep).join('/'),
|
||||
componentDir: manifest.componentDir,
|
||||
propContract: contract,
|
||||
};
|
||||
}
|
||||
|
||||
export function scaffoldSvelteComponentInsertSession({
|
||||
id,
|
||||
count,
|
||||
sourceFile,
|
||||
insertLine,
|
||||
position,
|
||||
anchorStartLine,
|
||||
anchorEndLine,
|
||||
anchorLines,
|
||||
cwd = process.cwd(),
|
||||
}) {
|
||||
ensureRuntimeHelper(cwd);
|
||||
const dir = componentSessionDir(id, cwd);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
|
||||
const anchorMarkup = (anchorLines || []).join('\n');
|
||||
const manifest = {
|
||||
id,
|
||||
mode: 'insert',
|
||||
previewMode: 'svelte-component',
|
||||
sourceFile: sourceFile.split(path.sep).join('/'),
|
||||
insertLine,
|
||||
position,
|
||||
anchorStartLine,
|
||||
anchorEndLine,
|
||||
originalMarkup: anchorMarkup,
|
||||
anchorMarkup,
|
||||
count,
|
||||
propContract: [],
|
||||
componentDir: path.relative(cwd, dir).split(path.sep).join('/'),
|
||||
runtimeModule: `/${SVELTE_RUNTIME_FILE}`,
|
||||
};
|
||||
|
||||
fs.writeFileSync(path.join(dir, 'manifest.json'), JSON.stringify(manifest, null, 2) + '\n', 'utf-8');
|
||||
|
||||
for (let n = 1; n <= count; n++) {
|
||||
const variantFile = path.join(dir, `v${n}.svelte`);
|
||||
if (!fs.existsSync(variantFile)) {
|
||||
fs.writeFileSync(variantFile, buildInsertVariantStub(n), 'utf-8');
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
manifest,
|
||||
manifestFile: path.relative(cwd, path.join(dir, 'manifest.json')).split(path.sep).join('/'),
|
||||
componentDir: manifest.componentDir,
|
||||
propContract: [],
|
||||
};
|
||||
}
|
||||
|
||||
export function findSvelteComponentManifest(id, cwd = process.cwd()) {
|
||||
const direct = manifestPathForSession(id, cwd);
|
||||
if (fs.existsSync(direct)) {
|
||||
return readManifest(direct);
|
||||
}
|
||||
const root = path.join(cwd, SVELTE_COMPONENT_ROOT);
|
||||
if (!fs.existsSync(root)) return null;
|
||||
for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
const candidate = path.join(root, entry.name, 'manifest.json');
|
||||
if (!fs.existsSync(candidate)) continue;
|
||||
try {
|
||||
const manifest = readManifest(candidate);
|
||||
if (manifest?.id === id) return { ...manifest, manifestPath: candidate };
|
||||
} catch { /* skip */ }
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function readManifest(manifestPath) {
|
||||
const data = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
|
||||
return {
|
||||
...data,
|
||||
manifestPath,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveSourceFile(sourceFile, cwd = process.cwd()) {
|
||||
if (!sourceFile || path.isAbsolute(sourceFile)) {
|
||||
throw new Error('Invalid svelte-component source file');
|
||||
}
|
||||
const full = path.resolve(cwd, sourceFile);
|
||||
const rel = path.relative(cwd, full);
|
||||
if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) {
|
||||
throw new Error('Svelte-component source file escapes project root');
|
||||
}
|
||||
if (!fs.existsSync(full)) {
|
||||
throw new Error('Svelte-component source file not found: ' + sourceFile);
|
||||
}
|
||||
return full;
|
||||
}
|
||||
|
||||
function appendCssToSvelteStyle(lines, cssLines) {
|
||||
const closeIdx = findLastStyleCloseLine(lines);
|
||||
const prepared = ['', ...cssLines.map((line) => (line.trim() === '' ? '' : ' ' + line.trimStart()))];
|
||||
if (closeIdx === -1) {
|
||||
return [...lines, '', '<style>', ...prepared.slice(1), '</style>'];
|
||||
}
|
||||
return [
|
||||
...lines.slice(0, closeIdx),
|
||||
...prepared,
|
||||
...lines.slice(closeIdx),
|
||||
];
|
||||
}
|
||||
|
||||
function findLastStyleCloseLine(lines) {
|
||||
for (let i = lines.length - 1; i >= 0; i--) {
|
||||
if (/<\/style\s*>/.test(lines[i])) return i;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
function bakeParamValuesInCss(cssLines, paramValues) {
|
||||
if (!paramValues || Object.keys(paramValues).length === 0) return cssLines;
|
||||
return cssLines.map((line) => {
|
||||
let out = line;
|
||||
for (const [key, value] of Object.entries(paramValues)) {
|
||||
const varName = `--p-${key}`;
|
||||
out = out.replace(new RegExp(`var\\(${escapeRegExp(varName)}(?:,\\s*[^)]+)?\\)`, 'g'), String(value));
|
||||
}
|
||||
return out;
|
||||
});
|
||||
}
|
||||
|
||||
function sanitizeAcceptedSvelteCss(cssLines, variantNum, paramValues = null, rootTag = 'div') {
|
||||
const css = String((cssLines || []).join('\n'));
|
||||
if (!/data-impeccable-variant|impeccable-variant-ready/.test(css)) return cssLines;
|
||||
|
||||
const rules = parseCssRules(css);
|
||||
const output = [];
|
||||
for (const rule of rules) {
|
||||
appendSanitizedCssRule(output, rule, variantNum, paramValues, rootTag);
|
||||
}
|
||||
return output.join('\n')
|
||||
.split('\n')
|
||||
.map((line) => line.trimEnd())
|
||||
.filter((line) => line.trim() !== '');
|
||||
}
|
||||
|
||||
function appendSanitizedCssRule(output, rule, variantNum, paramValues, rootTag) {
|
||||
const prelude = rule.prelude.trim();
|
||||
const body = rule.body.trim();
|
||||
if (!prelude || !body || /--impeccable-variant-ready\s*:/.test(body)) return;
|
||||
|
||||
if (/^@scope\b/i.test(prelude)) {
|
||||
if (/data-impeccable-variant/.test(prelude) && !selectorHasVariant(prelude, variantNum)) return;
|
||||
const inner = parseCssRules(body);
|
||||
for (const innerRule of inner) {
|
||||
const rewrittenPrelude = rewriteAcceptedSvelteSelector(innerRule.prelude, variantNum, paramValues, rootTag, true);
|
||||
if (!rewrittenPrelude || /--impeccable-variant-ready\s*:/.test(innerRule.body)) continue;
|
||||
output.push(formatCssRule(rewrittenPrelude, innerRule.body.trim()));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const rewrittenPrelude = rewriteAcceptedSvelteSelector(prelude, variantNum, paramValues, rootTag, false);
|
||||
if (!rewrittenPrelude) return;
|
||||
output.push(formatCssRule(rewrittenPrelude, body));
|
||||
}
|
||||
|
||||
function parseCssRules(css) {
|
||||
const rules = [];
|
||||
const text = String(css || '');
|
||||
let i = 0;
|
||||
while (i < text.length) {
|
||||
while (i < text.length && /\s/.test(text[i])) i++;
|
||||
const preludeStart = i;
|
||||
while (i < text.length && text[i] !== '{') i++;
|
||||
if (i >= text.length) break;
|
||||
const prelude = text.slice(preludeStart, i).trim();
|
||||
i++;
|
||||
const bodyStart = i;
|
||||
let depth = 1;
|
||||
let quote = null;
|
||||
let comment = false;
|
||||
while (i < text.length && depth > 0) {
|
||||
const ch = text[i];
|
||||
const next = text[i + 1];
|
||||
if (comment) {
|
||||
if (ch === '*' && next === '/') {
|
||||
comment = false;
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (quote) {
|
||||
if (ch === '\\') {
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
if (ch === quote) quote = null;
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (ch === '/' && next === '*') {
|
||||
comment = true;
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
if (ch === '"' || ch === "'") {
|
||||
quote = ch;
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (ch === '{') depth++;
|
||||
else if (ch === '}') depth--;
|
||||
i++;
|
||||
}
|
||||
const body = text.slice(bodyStart, Math.max(bodyStart, i - 1));
|
||||
if (prelude) rules.push({ prelude, body });
|
||||
}
|
||||
return rules;
|
||||
}
|
||||
|
||||
function rewriteAcceptedSvelteSelector(prelude, variantNum, paramValues, rootTag, fromScope) {
|
||||
const selectors = splitSelectorList(prelude);
|
||||
const rewritten = [];
|
||||
for (const selector of selectors) {
|
||||
const next = rewriteAcceptedSvelteSelectorPart(selector, variantNum, paramValues, rootTag, fromScope);
|
||||
if (next) rewritten.push(next);
|
||||
}
|
||||
return rewritten.join(', ');
|
||||
}
|
||||
|
||||
function rewriteAcceptedSvelteSelectorPart(selector, variantNum, paramValues, rootTag, fromScope) {
|
||||
let out = selector.trim();
|
||||
const hasVariant = /data-impeccable-variant/.test(out);
|
||||
if (hasVariant && !selectorHasVariant(out, variantNum)) return '';
|
||||
if (hasVariant) {
|
||||
out = out.replace(variantSelectorRegex(variantNum), '');
|
||||
out = out.replace(/\[data-impeccable-variant=(["']).*?\1\]/g, '');
|
||||
}
|
||||
|
||||
const paramResult = rewriteParamSelectors(out, paramValues);
|
||||
if (!paramResult.keep) return '';
|
||||
out = paramResult.selector;
|
||||
|
||||
out = out
|
||||
.replace(/:scope(?:\[[^\]]+\])?\s*>\s*/g, '')
|
||||
.replace(/:scope(?:\[[^\]]+\])?/g, rootTag || '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
|
||||
out = out.replace(/^[>+~]\s*/, '').trim();
|
||||
if (!out && (hasVariant || fromScope)) return rootTag || ':global(*)';
|
||||
return out;
|
||||
}
|
||||
|
||||
function rewriteParamSelectors(selector, paramValues) {
|
||||
let keep = true;
|
||||
const next = selector.replace(/\[data-p-([A-Za-z0-9_-]+)(?:=(["'])(.*?)\2)?\]/g, (_match, key, _quote, expected) => {
|
||||
if (!paramValues || !Object.prototype.hasOwnProperty.call(paramValues, key)) return '';
|
||||
const actual = paramValues[key];
|
||||
if (expected != null && String(actual) !== String(expected)) {
|
||||
keep = false;
|
||||
return '';
|
||||
}
|
||||
if (expected == null && (actual === false || actual == null || actual === 'false' || actual === 'off' || actual === '0')) {
|
||||
keep = false;
|
||||
return '';
|
||||
}
|
||||
return '';
|
||||
});
|
||||
return { keep, selector: next };
|
||||
}
|
||||
|
||||
function splitSelectorList(prelude) {
|
||||
const selectors = [];
|
||||
let start = 0;
|
||||
let bracket = 0;
|
||||
let paren = 0;
|
||||
let quote = null;
|
||||
for (let i = 0; i < prelude.length; i++) {
|
||||
const ch = prelude[i];
|
||||
if (quote) {
|
||||
if (ch === '\\') i++;
|
||||
else if (ch === quote) quote = null;
|
||||
continue;
|
||||
}
|
||||
if (ch === '"' || ch === "'") {
|
||||
quote = ch;
|
||||
continue;
|
||||
}
|
||||
if (ch === '[') bracket++;
|
||||
else if (ch === ']') bracket = Math.max(0, bracket - 1);
|
||||
else if (ch === '(') paren++;
|
||||
else if (ch === ')') paren = Math.max(0, paren - 1);
|
||||
else if (ch === ',' && bracket === 0 && paren === 0) {
|
||||
selectors.push(prelude.slice(start, i));
|
||||
start = i + 1;
|
||||
}
|
||||
}
|
||||
selectors.push(prelude.slice(start));
|
||||
return selectors;
|
||||
}
|
||||
|
||||
function selectorHasVariant(selector, variantNum) {
|
||||
return variantSelectorRegex(variantNum).test(selector);
|
||||
}
|
||||
|
||||
function variantSelectorRegex(variantNum) {
|
||||
return new RegExp(`\\[data-impeccable-variant=(["'])${escapeRegExp(String(variantNum))}\\1\\]`, 'g');
|
||||
}
|
||||
|
||||
function formatCssRule(selector, body) {
|
||||
return `${selector} { ${body.trim()} }`;
|
||||
}
|
||||
|
||||
function escapeRegExp(value) {
|
||||
return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
export function inlineSvelteComponentAccept(manifest, variantNum, paramValues = null, cwd = process.cwd()) {
|
||||
const sourceFile = resolveSourceFile(manifest.sourceFile, cwd);
|
||||
const variantPath = path.join(cwd, manifest.componentDir, `v${variantNum}.svelte`);
|
||||
const resultBase = {
|
||||
file: manifest.sourceFile,
|
||||
sourceFile: manifest.sourceFile,
|
||||
previewMode: 'svelte-component',
|
||||
componentDir: manifest.componentDir,
|
||||
carbonize: false,
|
||||
};
|
||||
if (!fs.existsSync(variantPath)) {
|
||||
return { handled: false, error: `Variant ${variantNum} not found`, ...resultBase };
|
||||
}
|
||||
|
||||
const { markup, cssLines } = parseSvelteComponentFile(fs.readFileSync(variantPath, 'utf-8'));
|
||||
if (manifest.mode === 'insert') {
|
||||
return inlineSvelteComponentInsertAccept({
|
||||
manifest,
|
||||
markup,
|
||||
cssLines,
|
||||
variantNum,
|
||||
paramValues,
|
||||
sourceFile,
|
||||
resultBase,
|
||||
cwd,
|
||||
});
|
||||
}
|
||||
|
||||
const rootTag = matchOpeningTag(markup)?.tag || 'div';
|
||||
const contract = manifest.propContract || [];
|
||||
const mergedMarkup = mergeOriginalTopLevelAttrs(markup, manifest.originalMarkup || '');
|
||||
const restoredMarkup = substitutePropsWithExprs(mergedMarkup, contract)
|
||||
.split('\n')
|
||||
.map((line) => line.trimEnd());
|
||||
|
||||
const sourceContent = fs.readFileSync(sourceFile, 'utf-8');
|
||||
const sourceLines = sourceContent.split('\n');
|
||||
const start = Number(manifest.sourceStartLine) - 1;
|
||||
const end = Number(manifest.sourceEndLine) - 1;
|
||||
if (!Number.isInteger(start) || !Number.isInteger(end) || start < 0 || end < start || end >= sourceLines.length) {
|
||||
return { handled: false, error: 'Invalid source line range for ' + manifest.sourceFile, ...resultBase };
|
||||
}
|
||||
|
||||
const indent = sourceLines[start].match(/^(\s*)/)?.[1] || '';
|
||||
const indentedMarkup = restoredMarkup.map((line) => {
|
||||
if (line.trim() === '') return '';
|
||||
return indent + line.trimStart();
|
||||
});
|
||||
|
||||
let newLines = [
|
||||
...sourceLines.slice(0, start),
|
||||
...indentedMarkup,
|
||||
...sourceLines.slice(end + 1),
|
||||
];
|
||||
|
||||
const sanitizedCss = sanitizeAcceptedSvelteCss(cssLines, variantNum, paramValues, rootTag);
|
||||
const bakedCss = bakeParamValuesInCss(sanitizedCss, paramValues);
|
||||
if (bakedCss.length > 0) {
|
||||
newLines = appendCssToSvelteStyle(newLines, bakedCss);
|
||||
}
|
||||
|
||||
try {
|
||||
fs.writeFileSync(sourceFile, newLines.join('\n'), 'utf-8');
|
||||
} catch (err) {
|
||||
return { handled: false, error: 'Failed to write Svelte source: ' + err.message, ...resultBase };
|
||||
}
|
||||
removeSvelteComponentSession(manifest.id, cwd);
|
||||
|
||||
return {
|
||||
handled: true,
|
||||
...resultBase,
|
||||
};
|
||||
}
|
||||
|
||||
function inlineSvelteComponentInsertAccept({
|
||||
manifest,
|
||||
markup,
|
||||
cssLines,
|
||||
variantNum,
|
||||
paramValues,
|
||||
sourceFile,
|
||||
resultBase,
|
||||
cwd,
|
||||
}) {
|
||||
if (!svelteMarkupHasVisibleContent(markup)) {
|
||||
return { handled: false, error: 'Accepted Svelte insert variant is empty', ...resultBase };
|
||||
}
|
||||
if (/\bdata-impeccable-[\w-]*\s*=/.test(markup)) {
|
||||
return { handled: false, error: 'Accepted Svelte insert variant contains preview-only data-impeccable attributes', ...resultBase };
|
||||
}
|
||||
|
||||
const rootTag = matchOpeningTag(markup)?.tag || 'div';
|
||||
const restoredMarkup = String(markup || '')
|
||||
.split('\n')
|
||||
.map((line) => line.trimEnd());
|
||||
const sourceContent = fs.readFileSync(sourceFile, 'utf-8');
|
||||
const sourceLines = sourceContent.split('\n');
|
||||
const insertIndex = Number(manifest.insertLine) - 1;
|
||||
if (!Number.isInteger(insertIndex) || insertIndex < 0 || insertIndex > sourceLines.length) {
|
||||
return { handled: false, error: 'Invalid insert line for ' + manifest.sourceFile, ...resultBase };
|
||||
}
|
||||
|
||||
const nearbyLine = sourceLines[insertIndex] ?? sourceLines[insertIndex - 1] ?? '';
|
||||
const indent = nearbyLine.match(/^(\s*)/)?.[1] || '';
|
||||
const indentedMarkup = restoredMarkup.map((line) => {
|
||||
if (line.trim() === '') return '';
|
||||
return indent + line.trimStart();
|
||||
});
|
||||
|
||||
let newLines = [
|
||||
...sourceLines.slice(0, insertIndex),
|
||||
...indentedMarkup,
|
||||
...sourceLines.slice(insertIndex),
|
||||
];
|
||||
|
||||
const sanitizedCss = sanitizeAcceptedSvelteCss(cssLines, variantNum, paramValues, rootTag);
|
||||
const bakedCss = bakeParamValuesInCss(sanitizedCss, paramValues);
|
||||
if (bakedCss.length > 0) {
|
||||
newLines = appendCssToSvelteStyle(newLines, bakedCss);
|
||||
}
|
||||
|
||||
try {
|
||||
fs.writeFileSync(sourceFile, newLines.join('\n'), 'utf-8');
|
||||
} catch (err) {
|
||||
return { handled: false, error: 'Failed to write Svelte source: ' + err.message, ...resultBase };
|
||||
}
|
||||
removeSvelteComponentSession(manifest.id, cwd);
|
||||
|
||||
return {
|
||||
handled: true,
|
||||
...resultBase,
|
||||
};
|
||||
}
|
||||
|
||||
function svelteMarkupHasVisibleContent(markup) {
|
||||
const text = String(markup || '')
|
||||
.replace(/<script[\s\S]*?<\/script>/gi, '')
|
||||
.replace(/<style[\s\S]*?<\/style>/gi, '')
|
||||
.replace(/<!--[\s\S]*?-->/g, '')
|
||||
.replace(/<[^>]+>/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
if (text.length > 0) return true;
|
||||
return /<(img|svg|canvas|video|audio|picture|input|button|select|textarea)\b/i.test(markup || '');
|
||||
}
|
||||
|
||||
function mergeOriginalTopLevelAttrs(markup, originalMarkup) {
|
||||
const variantOpen = matchOpeningTag(markup);
|
||||
const originalOpen = matchOpeningTag(originalMarkup);
|
||||
if (!variantOpen || !originalOpen) return markup;
|
||||
if (variantOpen.tag.toLowerCase() !== originalOpen.tag.toLowerCase()) return markup;
|
||||
|
||||
const variantAttrs = parseAttrSegments(variantOpen.attrs);
|
||||
const originalAttrs = parseAttrSegments(originalOpen.attrs);
|
||||
const additions = [];
|
||||
let attrs = variantOpen.attrs;
|
||||
|
||||
const originalClass = originalAttrs.get('class');
|
||||
const variantClass = variantAttrs.get('class');
|
||||
if (originalClass && variantClass) {
|
||||
const merged = mergeStaticClassAttr(originalClass, variantClass);
|
||||
if (merged) {
|
||||
attrs = attrs.slice(0, variantClass.start) + merged + attrs.slice(variantClass.end);
|
||||
variantAttrs.set('class', { ...variantClass, raw: merged });
|
||||
}
|
||||
} else if (originalClass && !variantClass) {
|
||||
additions.push(originalClass.raw);
|
||||
}
|
||||
|
||||
for (const [name, attr] of originalAttrs) {
|
||||
if (name === 'class') continue;
|
||||
if (!variantAttrs.has(name)) additions.push(attr.raw);
|
||||
}
|
||||
|
||||
if (additions.length === 0 && attrs === variantOpen.attrs) return markup;
|
||||
const nextOpen = variantOpen.prefix
|
||||
+ variantOpen.tag
|
||||
+ attrs
|
||||
+ additions.map((attr) => ' ' + attr.trim()).join('')
|
||||
+ variantOpen.close;
|
||||
return markup.slice(0, variantOpen.index) + nextOpen + markup.slice(variantOpen.index + variantOpen.raw.length);
|
||||
}
|
||||
|
||||
function matchOpeningTag(markup) {
|
||||
const match = String(markup || '').match(/^(\s*<)([A-Za-z][\w:-]*)([^>]*?)(\/?>)/);
|
||||
if (!match) return null;
|
||||
return {
|
||||
raw: match[0],
|
||||
prefix: match[1],
|
||||
tag: match[2],
|
||||
attrs: match[3] || '',
|
||||
close: match[4],
|
||||
index: match.index || 0,
|
||||
};
|
||||
}
|
||||
|
||||
function parseAttrSegments(attrs) {
|
||||
const out = new Map();
|
||||
const re = /([A-Za-z_:][\w:.-]*)(?:\s*=\s*(?:"[^"]*"|'[^']*'|\{[^}]*\}|[^\s"'>=]+))?/g;
|
||||
let match;
|
||||
while ((match = re.exec(attrs))) {
|
||||
const raw = match[0];
|
||||
const name = match[1];
|
||||
out.set(name, {
|
||||
name,
|
||||
raw,
|
||||
start: match.index,
|
||||
end: match.index + raw.length,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function mergeStaticClassAttr(originalClass, variantClass) {
|
||||
const originalValue = originalClass.raw.match(/class\s*=\s*(["'])(.*?)\1/);
|
||||
const variantValue = variantClass.raw.match(/class\s*=\s*(["'])(.*?)\1/);
|
||||
if (!originalValue || !variantValue) return null;
|
||||
const quote = variantValue[1];
|
||||
const classes = [
|
||||
...variantValue[2].split(/\s+/),
|
||||
...originalValue[2].split(/\s+/),
|
||||
].filter(Boolean);
|
||||
return `class=${quote}${[...new Set(classes)].join(' ')}${quote}`;
|
||||
}
|
||||
|
||||
export function removeSvelteComponentSession(id, cwd = process.cwd()) {
|
||||
const dir = componentSessionDir(id, cwd);
|
||||
try {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
} catch { /* non-fatal */ }
|
||||
}
|
||||
|
||||
export function removeAllSvelteComponentSessions(cwd = process.cwd()) {
|
||||
const root = path.join(cwd, SVELTE_COMPONENT_ROOT);
|
||||
if (!fs.existsSync(root)) return;
|
||||
for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
if (entry.name.startsWith('__')) continue;
|
||||
try {
|
||||
fs.rmSync(path.join(root, entry.name), { recursive: true, force: true });
|
||||
} catch { /* non-fatal */ }
|
||||
}
|
||||
}
|
||||
|
||||
export function deferredAcceptsPath(cwd = process.cwd()) {
|
||||
const key = createHash('sha1').update(path.resolve(cwd)).digest('hex').slice(0, 16);
|
||||
return path.join(os.tmpdir(), 'impeccable-live', key, 'deferred-svelte-component-accepts.json');
|
||||
}
|
||||
|
||||
export function readDeferredAccepts(cwd = process.cwd()) {
|
||||
const file = deferredAcceptsPath(cwd);
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(file, 'utf-8'));
|
||||
} catch {
|
||||
return { accepts: [] };
|
||||
}
|
||||
}
|
||||
|
||||
export function writeDeferredAccept(entry, cwd = process.cwd()) {
|
||||
const file = deferredAcceptsPath(cwd);
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||
const data = readDeferredAccepts(cwd);
|
||||
data.accepts = (data.accepts || []).filter((item) => item.id !== entry.id);
|
||||
data.accepts.push({ ...entry, createdAt: new Date().toISOString() });
|
||||
fs.writeFileSync(file, JSON.stringify(data, null, 2) + '\n', 'utf-8');
|
||||
}
|
||||
|
||||
export function applyDeferredSvelteComponentAccepts(cwd = process.cwd()) {
|
||||
const file = deferredAcceptsPath(cwd);
|
||||
const data = readDeferredAccepts(cwd);
|
||||
const pending = Array.isArray(data.accepts) ? data.accepts : [];
|
||||
const results = [];
|
||||
const remaining = [];
|
||||
for (const entry of pending) {
|
||||
try {
|
||||
const manifest = findSvelteComponentManifest(entry.id, cwd);
|
||||
if (!manifest) {
|
||||
results.push({ id: entry.id, ok: false, error: 'manifest not found' });
|
||||
remaining.push(entry);
|
||||
continue;
|
||||
}
|
||||
const result = inlineSvelteComponentAccept(
|
||||
manifest,
|
||||
entry.variantNum,
|
||||
entry.paramValues || null,
|
||||
cwd,
|
||||
);
|
||||
results.push({ id: entry.id, ok: result.handled !== false, result });
|
||||
if (result.handled === false) remaining.push(entry);
|
||||
} catch (err) {
|
||||
results.push({ id: entry.id, ok: false, error: err.message });
|
||||
remaining.push(entry);
|
||||
}
|
||||
}
|
||||
if (remaining.length > 0) {
|
||||
fs.writeFileSync(file, JSON.stringify({ accepts: remaining }, null, 2) + '\n', 'utf-8');
|
||||
} else {
|
||||
try { fs.rmSync(file, { force: true }); } catch {}
|
||||
}
|
||||
return { applied: results.filter((r) => r.ok).length, failed: results.filter((r) => !r.ok).length, results };
|
||||
}
|
||||
|
||||
export function buildSvelteComponentCssAuthoring(count) {
|
||||
const variantNumbers = Array.from({ length: count }, (_, i) => i + 1);
|
||||
return {
|
||||
mode: 'svelte-component',
|
||||
styleTag: null,
|
||||
strategy: 'component-style-block',
|
||||
rulePattern: '.semantic-class { ... }',
|
||||
selectorExamples: variantNumbers.map(() => '.expense-row { padding: 22px; }'),
|
||||
requirements: [
|
||||
'Write each variant as a real Svelte component file (v1.svelte, v2.svelte, ...).',
|
||||
'Keep the prop names from propContract; bind dynamic text with {propName}, not literal snapshot text.',
|
||||
'Put variant CSS in the component <style> block using semantic class selectors.',
|
||||
'Author param-driven CSS against var(--p-<id>, default) and [data-p-<id>] using :global(...) so the runtime knob values reach the mounted root.',
|
||||
'Declare params in componentDir/params.json keyed by variant number (e.g. {"1": [...], "2": [...]}), NOT as a data-impeccable-params attribute.',
|
||||
'Do not use @scope or data-impeccable-variant selectors in component files.',
|
||||
'Do not edit the route source file during generation; only edit files under componentDir.',
|
||||
],
|
||||
forbidden: [
|
||||
'Do not use @scope blocks in Svelte component variants.',
|
||||
'Do not copy live DOM snapshot text into markup when propContract provides bindings.',
|
||||
'Do not add data-impeccable-* attributes inside component files. Svelte parses { in attribute values as an expression, so data-impeccable-params with JSON breaks the build; use componentDir/params.json instead.',
|
||||
],
|
||||
paramsFile: 'params.json',
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
/**
|
||||
* SvelteKit live-mode adapter.
|
||||
*
|
||||
* SvelteKit must not be patched through src/app.html. That file is a document
|
||||
* template, not framework-owned component chrome. The adapter keeps SvelteKit
|
||||
* work limited to mounting a dev-only shadow host from +layout.svelte; the
|
||||
* actual live UI remains the shared plain-DOM browser chrome.
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
export const SVELTE_LIVE_ROOT_COMPONENT = 'src/lib/impeccable/ImpeccableLiveRoot.svelte';
|
||||
export const SVELTE_LAYOUT_MARKER_OPEN = '<!-- impeccable-live-svelte-start -->';
|
||||
export const SVELTE_LAYOUT_MARKER_CLOSE = '<!-- impeccable-live-svelte-end -->';
|
||||
export const SVELTE_ROOT_IMPORT = "import ImpeccableLiveRoot from '$lib/impeccable/ImpeccableLiveRoot.svelte';";
|
||||
|
||||
export function detectSvelteKitProject(cwd = process.cwd(), config = null) {
|
||||
const appHtml = findSvelteKitAppHtml(cwd, config);
|
||||
if (!appHtml) return null;
|
||||
const hasTemplateMarkers = fileIncludes(path.join(cwd, appHtml), '%sveltekit.body%')
|
||||
&& fileIncludes(path.join(cwd, appHtml), '%sveltekit.head%');
|
||||
if (!hasTemplateMarkers) return null;
|
||||
|
||||
const hasSvelteConfig = fs.existsSync(path.join(cwd, 'svelte.config.js'))
|
||||
|| fs.existsSync(path.join(cwd, 'svelte.config.mjs'))
|
||||
|| fs.existsSync(path.join(cwd, 'svelte.config.cjs'))
|
||||
|| fs.existsSync(path.join(cwd, 'svelte.config.ts'));
|
||||
const hasKitPackage = packageHasSvelteKit(cwd);
|
||||
if (!hasSvelteConfig && !hasKitPackage) return null;
|
||||
|
||||
return {
|
||||
appHtml,
|
||||
layoutFile: findSvelteKitLayout(cwd),
|
||||
rootComponent: SVELTE_LIVE_ROOT_COMPONENT,
|
||||
};
|
||||
}
|
||||
|
||||
export function applySvelteKitLiveAdapter({ cwd = process.cwd(), port, config = null } = {}) {
|
||||
if (!Number.isFinite(Number(port))) {
|
||||
throw new Error('SvelteKit live adapter requires a numeric port');
|
||||
}
|
||||
const detected = detectSvelteKitProject(cwd, config);
|
||||
if (!detected) return null;
|
||||
|
||||
ensureSvelteLiveRootComponent(cwd, Number(port));
|
||||
|
||||
const layoutRel = detected.layoutFile;
|
||||
const layoutAbs = path.join(cwd, layoutRel);
|
||||
fs.mkdirSync(path.dirname(layoutAbs), { recursive: true });
|
||||
const layoutExisted = fs.existsSync(layoutAbs);
|
||||
const before = layoutExisted ? fs.readFileSync(layoutAbs, 'utf-8') : defaultSvelteLayout();
|
||||
const after = patchSvelteLayout(before);
|
||||
fs.writeFileSync(layoutAbs, after, 'utf-8');
|
||||
|
||||
return {
|
||||
file: layoutRel,
|
||||
adapter: 'sveltekit',
|
||||
inserted: after !== before || !layoutExisted,
|
||||
appHtmlUntouched: true,
|
||||
rootComponent: SVELTE_LIVE_ROOT_COMPONENT,
|
||||
};
|
||||
}
|
||||
|
||||
export function removeSvelteKitLiveAdapter({ cwd = process.cwd(), config = null } = {}) {
|
||||
const detected = detectSvelteKitProject(cwd, config);
|
||||
if (!detected) return null;
|
||||
|
||||
const layoutAbs = path.join(cwd, detected.layoutFile);
|
||||
let removed = false;
|
||||
if (fs.existsSync(layoutAbs)) {
|
||||
const before = fs.readFileSync(layoutAbs, 'utf-8');
|
||||
const after = unpatchSvelteLayout(before);
|
||||
if (after !== before) {
|
||||
fs.writeFileSync(layoutAbs, after, 'utf-8');
|
||||
removed = true;
|
||||
}
|
||||
}
|
||||
|
||||
const rootAbs = path.join(cwd, SVELTE_LIVE_ROOT_COMPONENT);
|
||||
if (fs.existsSync(rootAbs)) {
|
||||
fs.rmSync(rootAbs, { force: true });
|
||||
removed = true;
|
||||
}
|
||||
|
||||
pruneEmptyDir(path.dirname(rootAbs), path.join(cwd, 'src'));
|
||||
|
||||
return {
|
||||
file: detected.layoutFile,
|
||||
adapter: 'sveltekit',
|
||||
removed,
|
||||
appHtmlUntouched: true,
|
||||
rootComponent: SVELTE_LIVE_ROOT_COMPONENT,
|
||||
};
|
||||
}
|
||||
|
||||
export function patchSvelteLayout(content) {
|
||||
let out = String(content || '');
|
||||
if (!out.includes(SVELTE_ROOT_IMPORT)) {
|
||||
const scriptMatch = out.match(/<script(?:\s[^>]*)?>/i);
|
||||
if (scriptMatch) {
|
||||
const insertAt = scriptMatch.index + scriptMatch[0].length;
|
||||
out = out.slice(0, insertAt) + '\n ' + SVELTE_ROOT_IMPORT + out.slice(insertAt);
|
||||
} else {
|
||||
out = `<script>\n ${SVELTE_ROOT_IMPORT}\n</script>\n\n` + out;
|
||||
}
|
||||
}
|
||||
|
||||
if (!out.includes(SVELTE_LAYOUT_MARKER_OPEN)) {
|
||||
const block = `${SVELTE_LAYOUT_MARKER_OPEN}\n<ImpeccableLiveRoot />\n${SVELTE_LAYOUT_MARKER_CLOSE}\n`;
|
||||
const renderMatch = out.match(/\{@render\s+children(?:\?\.)?\(\)\s*\}/);
|
||||
const slotMatch = out.match(/<slot\s*\/?>/);
|
||||
const match = renderMatch || slotMatch;
|
||||
if (match) {
|
||||
out = out.slice(0, match.index) + block + out.slice(match.index);
|
||||
} else {
|
||||
out = out.replace(/\s*$/, '\n\n' + block);
|
||||
}
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
export function unpatchSvelteLayout(content) {
|
||||
let out = String(content || '');
|
||||
const blockRe = new RegExp(
|
||||
'([ \\t]*)' + escapeRegExp(SVELTE_LAYOUT_MARKER_OPEN)
|
||||
+ '\\n<ImpeccableLiveRoot\\s*/>\\n'
|
||||
+ escapeRegExp(SVELTE_LAYOUT_MARKER_CLOSE)
|
||||
+ '\\n?',
|
||||
'g',
|
||||
);
|
||||
out = out.replace(blockRe, '$1');
|
||||
out = out.replace(new RegExp('^\\s*' + escapeRegExp(SVELTE_ROOT_IMPORT) + '\\s*\\n?', 'gm'), '');
|
||||
out = out.replace(/<script>\s*<\/script>\s*\n?/g, '');
|
||||
return out.replace(/\n{3,}/g, '\n\n');
|
||||
}
|
||||
|
||||
export function ensureSvelteLiveRootComponent(cwd, port) {
|
||||
const file = path.join(cwd, SVELTE_LIVE_ROOT_COMPONENT);
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||
fs.writeFileSync(file, buildSvelteLiveRootComponent(port), 'utf-8');
|
||||
return file;
|
||||
}
|
||||
|
||||
export function buildSvelteLiveRootComponent(port) {
|
||||
return `<script>
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
const LIVE_URL = 'http://localhost:${Number(port)}/live.js';
|
||||
const HOST_ID = 'impeccable-live-root';
|
||||
|
||||
onMount(() => {
|
||||
let host = document.querySelector('impeccable-live-root#' + HOST_ID) || document.getElementById(HOST_ID);
|
||||
if (!host) {
|
||||
host = document.createElement('impeccable-live-root');
|
||||
host.id = HOST_ID;
|
||||
document.body.appendChild(host);
|
||||
}
|
||||
|
||||
host.dataset.impeccableLiveAdapter = 'sveltekit';
|
||||
host.style.setProperty('all', 'initial', 'important');
|
||||
host.style.setProperty('display', 'block', 'important');
|
||||
host.style.setProperty('position', 'fixed', 'important');
|
||||
host.style.setProperty('top', '0', 'important');
|
||||
host.style.setProperty('left', '0', 'important');
|
||||
host.style.setProperty('width', '0', 'important');
|
||||
host.style.setProperty('height', '0', 'important');
|
||||
host.style.setProperty('overflow', 'visible', 'important');
|
||||
host.style.setProperty('z-index', '2147483000', 'important');
|
||||
host.style.setProperty('pointer-events', 'none', 'important');
|
||||
|
||||
const root = host.shadowRoot || host.attachShadow({ mode: 'open' });
|
||||
if (!root.querySelector('style[data-impeccable-live-reset]')) {
|
||||
const reset = document.createElement('style');
|
||||
reset.dataset.impeccableLiveReset = 'true';
|
||||
reset.textContent = ':host, :host *, * { box-sizing: border-box; }';
|
||||
root.appendChild(reset);
|
||||
}
|
||||
|
||||
window.__IMPECCABLE_LIVE_ADAPTER__ = 'sveltekit';
|
||||
window.__IMPECCABLE_LIVE_UI_ROOT__ = root;
|
||||
window.__IMPECCABLE_LIVE_CHROME_MOUNT__ = {
|
||||
adapter: 'sveltekit',
|
||||
version: 1,
|
||||
host,
|
||||
root,
|
||||
};
|
||||
|
||||
const script = document.createElement('script');
|
||||
script.src = LIVE_URL;
|
||||
script.async = true;
|
||||
script.dataset.impeccableLiveScript = 'true';
|
||||
document.head.appendChild(script);
|
||||
|
||||
return () => {
|
||||
script.remove();
|
||||
if (window.__IMPECCABLE_LIVE_UI_ROOT__ === root) delete window.__IMPECCABLE_LIVE_UI_ROOT__;
|
||||
if (window.__IMPECCABLE_LIVE_CHROME_MOUNT__?.root === root) delete window.__IMPECCABLE_LIVE_CHROME_MOUNT__;
|
||||
if (window.__IMPECCABLE_LIVE_ADAPTER__ === 'sveltekit') delete window.__IMPECCABLE_LIVE_ADAPTER__;
|
||||
};
|
||||
});
|
||||
</script>
|
||||
`;
|
||||
}
|
||||
|
||||
function findSvelteKitAppHtml(cwd, config) {
|
||||
const files = Array.isArray(config?.files) ? config.files : ['src/app.html'];
|
||||
for (const rel of files) {
|
||||
if (rel.includes('*')) continue;
|
||||
const normalized = rel.split(path.sep).join('/');
|
||||
if (!normalized.endsWith('app.html')) continue;
|
||||
const abs = path.join(cwd, normalized);
|
||||
if (fs.existsSync(abs)) return normalized;
|
||||
}
|
||||
const fallback = 'src/app.html';
|
||||
return fs.existsSync(path.join(cwd, fallback)) ? fallback : null;
|
||||
}
|
||||
|
||||
function findSvelteKitLayout(cwd) {
|
||||
const candidates = [
|
||||
'src/routes/+layout.svelte',
|
||||
'src/routes/(app)/+layout.svelte',
|
||||
];
|
||||
for (const rel of candidates) {
|
||||
if (fs.existsSync(path.join(cwd, rel))) return rel;
|
||||
}
|
||||
return 'src/routes/+layout.svelte';
|
||||
}
|
||||
|
||||
function defaultSvelteLayout() {
|
||||
return `<script>\n let { children } = $props();\n</script>\n\n{@render children?.()}\n`;
|
||||
}
|
||||
|
||||
function packageHasSvelteKit(cwd) {
|
||||
const file = path.join(cwd, 'package.json');
|
||||
if (!fs.existsSync(file)) return false;
|
||||
try {
|
||||
const pkg = JSON.parse(fs.readFileSync(file, 'utf-8'));
|
||||
const deps = {
|
||||
...(pkg.dependencies || {}),
|
||||
...(pkg.devDependencies || {}),
|
||||
...(pkg.peerDependencies || {}),
|
||||
};
|
||||
return Boolean(deps['@sveltejs/kit'] || deps['@sveltejs/vite-plugin-svelte'] || deps.svelte);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function fileIncludes(file, text) {
|
||||
try {
|
||||
return fs.readFileSync(file, 'utf-8').includes(text);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function pruneEmptyDir(dir, stopDir) {
|
||||
let current = dir;
|
||||
while (current.startsWith(stopDir) && current !== stopDir) {
|
||||
try {
|
||||
if (fs.readdirSync(current).length > 0) return;
|
||||
fs.rmdirSync(current);
|
||||
current = path.dirname(current);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function escapeRegExp(value) {
|
||||
return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
/**
|
||||
* Framework-neutral Impeccable live chrome contract.
|
||||
*
|
||||
* The production browser bundle is intentionally plain DOM so Svelte, React,
|
||||
* Vue, and static adapters can all mount the same chrome. This module is the
|
||||
* testable contract/inventory for that bundle; live-browser.js mirrors these
|
||||
* values at runtime because it is served as a standalone script.
|
||||
*/
|
||||
|
||||
export const LIVE_CHROME_MOUNT_CONTRACT = Object.freeze([
|
||||
'root',
|
||||
'transport',
|
||||
'state',
|
||||
'actions',
|
||||
]);
|
||||
|
||||
export const LIVE_UI_SURFACES = Object.freeze([
|
||||
{
|
||||
key: 'global-bottom-bar',
|
||||
ids: [
|
||||
'impeccable-live-global-bar',
|
||||
'impeccable-live-global-bar-brand',
|
||||
'impeccable-live-pick-toggle',
|
||||
'impeccable-live-insert-toggle',
|
||||
'impeccable-live-detect-toggle',
|
||||
'impeccable-live-detect-badge',
|
||||
'impeccable-live-design-toggle',
|
||||
'impeccable-live-page-chat',
|
||||
'impeccable-live-page-chat-input',
|
||||
'impeccable-live-page-chat-voice',
|
||||
],
|
||||
states: ['rest', 'hover', 'focus-visible', 'pressed', 'active', 'tooltip'],
|
||||
},
|
||||
{
|
||||
key: 'pending-copy-edit-dock',
|
||||
ids: ['impeccable-live-pending-dock'],
|
||||
states: ['closed', 'open', 'hover', 'pressed', 'loading', 'rollback', 'keep-fixing'],
|
||||
},
|
||||
{
|
||||
key: 'element-selection-chrome',
|
||||
ids: [
|
||||
'impeccable-live-highlight',
|
||||
'impeccable-live-tooltip',
|
||||
'impeccable-live-bar',
|
||||
'impeccable-live-selection-pill',
|
||||
'impeccable-live-input',
|
||||
'impeccable-live-configure-voice',
|
||||
'impeccable-live-configure-bar-tooltip',
|
||||
],
|
||||
states: ['rest', 'hover', 'focus-visible', 'pressed', 'disabled'],
|
||||
},
|
||||
{
|
||||
key: 'action-picker',
|
||||
ids: ['impeccable-live-picker'],
|
||||
states: ['closed', 'open', 'option-hover', 'option-focus'],
|
||||
},
|
||||
{
|
||||
key: 'edit-chrome',
|
||||
ids: ['impeccable-live-edit-badge'],
|
||||
states: ['enabled', 'disabled', 'editing', 'cancel', 'save', 'edited-content'],
|
||||
},
|
||||
{
|
||||
key: 'generating-row',
|
||||
ids: ['impeccable-live-bar', 'impeccable-live-shader'],
|
||||
states: ['action-label', 'animated-dots', 'generating', 'done'],
|
||||
},
|
||||
{
|
||||
key: 'variant-cycling-row',
|
||||
ids: ['impeccable-live-bar', 'impeccable-live-params-panel'],
|
||||
states: ['variant-1', 'variant-2', 'variant-3', 'left-disabled', 'right-disabled', 'dot-click', 'accept', 'discard'],
|
||||
},
|
||||
{
|
||||
key: 'variant-params-panel',
|
||||
ids: ['impeccable-live-params-panel'],
|
||||
states: ['closed', 'open-above', 'open-below', 'range', 'steps', 'toggle'],
|
||||
},
|
||||
{
|
||||
key: 'saving-confirmed-rows',
|
||||
ids: ['impeccable-live-bar'],
|
||||
states: ['saving', 'applying-variant', 'confirmed'],
|
||||
},
|
||||
{
|
||||
key: 'insert-mode-chrome',
|
||||
ids: [
|
||||
'impeccable-live-insert-line',
|
||||
'impeccable-live-insert-placeholder',
|
||||
'impeccable-live-placeholder-resize',
|
||||
'impeccable-live-insert-input',
|
||||
'impeccable-live-insert-voice',
|
||||
'impeccable-live-insert-create',
|
||||
'impeccable-live-insert-create-tooltip',
|
||||
],
|
||||
states: ['toggle-active', 'line', 'placeholder', 'resize', 'enabled', 'disabled', 'tooltip'],
|
||||
},
|
||||
{
|
||||
key: 'annotation-chrome',
|
||||
ids: [
|
||||
'impeccable-live-annot',
|
||||
'impeccable-live-annot-svg',
|
||||
'impeccable-live-annot-pins',
|
||||
'impeccable-live-annot-clear',
|
||||
],
|
||||
states: ['overlay', 'drawing', 'pin', 'pin-edit', 'clear'],
|
||||
},
|
||||
{
|
||||
key: 'design-system-panel',
|
||||
ids: ['impeccable-live-design-host'],
|
||||
states: ['closed', 'open', 'tabs', 'token-tiles', 'copy'],
|
||||
},
|
||||
{
|
||||
key: 'toasts-and-errors',
|
||||
ids: ['impeccable-live-toast'],
|
||||
states: ['normal', 'error', 'no-variants-mounted'],
|
||||
},
|
||||
{
|
||||
key: 'css-isolation-boundary',
|
||||
ids: ['impeccable-live-root'],
|
||||
states: ['shadow-root', 'style-tags', 'hostile-css'],
|
||||
},
|
||||
]);
|
||||
|
||||
export const LIVE_UI_COMPONENT_IDS = Object.freeze([
|
||||
...new Set(LIVE_UI_SURFACES.flatMap((surface) => surface.ids)),
|
||||
]);
|
||||
|
||||
export function resolveLiveUiRoot(env = globalThis) {
|
||||
const doc = env?.document;
|
||||
const explicit = env?.__IMPECCABLE_LIVE_UI_ROOT__
|
||||
|| env?.window?.__IMPECCABLE_LIVE_UI_ROOT__;
|
||||
if (explicit && typeof explicit.appendChild === 'function') return explicit;
|
||||
return doc?.body || null;
|
||||
}
|
||||
|
||||
export function getLiveUiElementById(id, env = globalThis) {
|
||||
const doc = env?.document;
|
||||
const root = resolveLiveUiRoot(env);
|
||||
if (!id) return null;
|
||||
if (root?.getElementById) {
|
||||
const found = root.getElementById(id);
|
||||
if (found) return found;
|
||||
}
|
||||
if (root?.querySelector) {
|
||||
const found = root.querySelector('#' + escapeCssIdent(id));
|
||||
if (found) return found;
|
||||
}
|
||||
return doc?.getElementById?.(id) || null;
|
||||
}
|
||||
|
||||
export function appendToLiveUiRoot(el, env = globalThis) {
|
||||
const root = resolveLiveUiRoot(env);
|
||||
if (!root) throw new Error('Impeccable live UI root is not available');
|
||||
root.appendChild(el);
|
||||
return el;
|
||||
}
|
||||
|
||||
export function appendStyleToLiveUiRoot(styleEl, env = globalThis) {
|
||||
const doc = env?.document;
|
||||
const root = resolveLiveUiRoot(env);
|
||||
if (root && root !== doc?.body) {
|
||||
root.appendChild(styleEl);
|
||||
} else {
|
||||
(doc?.head || doc?.body || root).appendChild(styleEl);
|
||||
}
|
||||
return styleEl;
|
||||
}
|
||||
|
||||
export function activeElementDeep(doc = globalThis.document) {
|
||||
let active = doc?.activeElement || null;
|
||||
while (active?.shadowRoot?.activeElement) {
|
||||
active = active.shadowRoot.activeElement;
|
||||
}
|
||||
return active;
|
||||
}
|
||||
|
||||
function escapeCssIdent(value) {
|
||||
if (typeof CSS !== 'undefined' && typeof CSS.escape === 'function') {
|
||||
return CSS.escape(String(value));
|
||||
}
|
||||
return String(value).replace(/([ !"#$%&'()*+,./:;<=>?@[\\\]^`{|}~])/g, '\\$1');
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* Canonical design-command vocabulary for Live Mode: each command's value, human
|
||||
* label, and SVG icon. Icons stack above the chip label; strokes use currentColor
|
||||
* so the icon recolors when its chip is selected.
|
||||
*
|
||||
* Single source of truth, consumed by:
|
||||
* - skill/scripts/live/event-validation.mjs — re-exports VISUAL_ACTIONS.
|
||||
* - skill/scripts/live-browser.js — the real picker. It is served raw and
|
||||
* injected as an IIFE, so it cannot import this at runtime; live-server.mjs
|
||||
* serializes LIVE_COMMANDS into window.__IMPECCABLE_VOCAB__ alongside the
|
||||
* token/port, and live-browser.js builds its ICONS + ACTIONS from that.
|
||||
* - site/components/LiveDemoPalette.astro — the marketing demo palette (imported
|
||||
* at build time).
|
||||
*
|
||||
* Add, rename, or reorder a verb here and all three follow.
|
||||
*/
|
||||
|
||||
const ICON_ATTRS = 'width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" style="display:block"';
|
||||
|
||||
export const LIVE_COMMANDS = [
|
||||
{ value: 'impeccable', label: 'Freeform', icon: `<svg ${ICON_ATTRS}><path d="M4 20l4-1L18 9l-3-3L5 16z"/><path d="M14 7l3 3"/></svg>` },
|
||||
{ value: 'bolder', label: 'Bolder', icon: `<svg ${ICON_ATTRS}><rect x="6" y="12" width="4" height="7" rx="0.5"/><rect x="14" y="5" width="4" height="14" rx="0.5"/></svg>` },
|
||||
{ value: 'quieter', label: 'Quieter', icon: `<svg ${ICON_ATTRS}><rect x="6" y="5" width="4" height="14" rx="0.5"/><rect x="14" y="12" width="4" height="7" rx="0.5"/></svg>` },
|
||||
{ value: 'distill', label: 'Distill', icon: `<svg ${ICON_ATTRS}><path d="M4 5h16l-6 8v7l-4-2v-5z"/></svg>` },
|
||||
{ value: 'polish', label: 'Polish', icon: `<svg ${ICON_ATTRS}><path d="M15 3l1 3 3 1-3 1-1 3-1-3-3-1 3-1z"/><path d="M7 13l0.6 1.8 1.8 0.6-1.8 0.6-0.6 1.8-0.6-1.8-1.8-0.6 1.8-0.6z"/></svg>` },
|
||||
{ value: 'typeset', label: 'Typeset', icon: `<svg ${ICON_ATTRS}><path d="M5 6h14" stroke-width="2.6"/><path d="M5 12h9" stroke-width="1.9"/><path d="M5 18h5" stroke-width="1.3"/></svg>` },
|
||||
{ value: 'colorize', label: 'Colorize', icon: `<svg ${ICON_ATTRS}><circle cx="9" cy="10" r="5"/><circle cx="15" cy="10" r="5"/><circle cx="12" cy="15" r="5"/></svg>` },
|
||||
{ value: 'layout', label: 'Layout', icon: `<svg ${ICON_ATTRS}><rect x="3" y="4" width="8" height="16" rx="0.5"/><rect x="13" y="4" width="8" height="7" rx="0.5"/><rect x="13" y="13" width="8" height="7" rx="0.5"/></svg>` },
|
||||
{ value: 'adapt', label: 'Adapt', icon: `<svg ${ICON_ATTRS}><rect x="2.5" y="5" width="12" height="11" rx="1"/><line x1="2.5" y1="19" x2="14.5" y2="19"/><rect x="16.5" y="8" width="5" height="11" rx="1"/></svg>` },
|
||||
{ value: 'animate', label: 'Animate', icon: `<svg ${ICON_ATTRS}><path d="M3 18c4-4 6-10 10-10"/><path d="M13 8c3 0 5 5 8 10"/><circle cx="13" cy="8" r="1.6" fill="currentColor" stroke="none"/></svg>` },
|
||||
{ value: 'delight', label: 'Delight', icon: `<svg ${ICON_ATTRS}><path d="M12 3l2 6 6 2-6 2-2 6-2-6-6-2 6-2z"/></svg>` },
|
||||
{ value: 'overdrive', label: 'Overdrive', icon: `<svg ${ICON_ATTRS}><path d="M13 3L5 13h5l-1 8 9-12h-6z"/></svg>` },
|
||||
];
|
||||
|
||||
// Action values accepted by the live event protocol, in palette order.
|
||||
export const VISUAL_ACTIONS = LIVE_COMMANDS.map((c) => c.value);
|
||||
@@ -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.5.0",
|
||||
"version": "3.6.0",
|
||||
"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.5.0",
|
||||
"version": "3.6.0",
|
||||
"author": {
|
||||
"name": "Paul Bakaus",
|
||||
"email": "paul@paulbakaus.com"
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"description": "Impeccable design detector: runs after Edit/Write/MultiEdit on UI files and surfaces findings as system reminders.",
|
||||
"hooks": {
|
||||
"PostToolUse": [
|
||||
{
|
||||
"matcher": "Edit|Write|MultiEdit",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node \"${CLAUDE_PROJECT_DIR}/.claude/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.5.0
|
||||
version: 3.6.0
|
||||
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
|
||||
@@ -35,10 +35,7 @@ Produce ready-to-ship, production-grade code, not prototypes or starting points.
|
||||
#### Typography
|
||||
|
||||
- Cap body line length at 65–75ch.
|
||||
- Hierarchy through scale + weight contrast (≥1.25 ratio between steps). Avoid flat scales.
|
||||
- Cap font-family count at 3 (display + body + optional mono). More than 3 reads as indecision, not richness. One well-tuned family with weight contrast usually beats three competing typefaces.
|
||||
- Don't pair fonts that are similar but not identical (two geometric sans-serifs, two humanist sans-serifs). Pair on a contrast axis (serif + sans, geometric + humanist) or use one family in multiple weights.
|
||||
- No all-caps body copy. Reserve uppercase for short labels (≤4 words), section eyebrows (used sparingly per the Absolute bans), and badges. Sentences in ALL CAPS are unreadable at body sizes.
|
||||
- Hero / display heading ceiling: clamp() max ≤ 6rem (~96px). Above that the page is shouting, not designing.
|
||||
- Display heading letter-spacing floor: ≥ -0.04em. Anything tighter and letters touch; cramped, not "designed".
|
||||
- Use `text-wrap: balance` on h1–h3 for even line lengths; `text-wrap: pretty` on long prose to reduce orphans.
|
||||
@@ -65,15 +62,6 @@ Produce ready-to-ship, production-grade code, not prototypes or starting points.
|
||||
|
||||
- Dropdowns rendered with `position: absolute` inside an `overflow: hidden` or `overflow: auto` container will be clipped. Use the native `<dialog>` / popover API, `position: fixed`, or a portal to escape the stacking context.
|
||||
|
||||
### Copy
|
||||
|
||||
- Every word earns its place. No restated headings, no intros that repeat the title.
|
||||
- **No em dashes.** Use commas, colons, semicolons, periods, or parentheses. Also not `--`.
|
||||
- **No aphoristic-cadence body copy as a default voice.** Don't fall into the rhythm of "serious statement, then punchy short negation" as the page's recurring voice. If three or more section copy blocks on the page land on a short rebuttal-shaped sentence, rewrite. Specific, not aphoristic.
|
||||
- **No marketing buzzwords.** The streamline / empower / supercharge / leverage / unleash / transform / seamless / world-class / enterprise-grade / next-generation / cutting-edge / game-changer / mission-critical family of phrases. Pick a specific noun and a verb that describes what the product literally does.
|
||||
- Button labels: verb + object. "Save changes" beats "OK"; "Delete project" beats "Yes". The label should say what will happen.
|
||||
- Link text needs standalone meaning. "View pricing plans" beats "Click here"; screen readers announce links out of context.
|
||||
|
||||
### New projects only (when no prior work exists)
|
||||
|
||||
#### Color & Theme
|
||||
@@ -138,7 +126,7 @@ If someone could look at this interface and say "AI made that" without doubt, it
|
||||
| `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) |
|
||||
|
||||
Plus two management commands: `pin <command>` and `unpin <command>`, detailed below.
|
||||
Plus three management commands: `pin <command>`, `unpin <command>`, and `hooks <on|off|status|...>`, detailed below.
|
||||
|
||||
### Routing rules
|
||||
|
||||
@@ -155,7 +143,7 @@ Plus two management commands: `pin <command>` and `unpin <command>`, detailed be
|
||||
**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.
|
||||
|
||||
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**: load its reference file and follow its instructions. Everything after the command name is the target.
|
||||
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.
|
||||
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.
|
||||
|
||||
@@ -173,4 +161,8 @@ If the first word is `craft`, setup still runs first, but [reference/craft.md](r
|
||||
node .claude/skills/impeccable/scripts/pin.mjs <pin|unpin> <command>
|
||||
```
|
||||
|
||||
Valid `<command>` is any command from the table above. Report the script's result concisely. Confirm the new shortcut on success, relay stderr verbatim on error.
|
||||
Valid `<command>` is any command from the table above. Report the script's result concisely. Confirm the new shortcut on success, relay stderr verbatim on error.
|
||||
|
||||
## Hooks
|
||||
|
||||
`/impeccable hooks <on|off|status|ignore-rule|ignore-file|ignore-value|reset>` manages the design detector hook for this project. The hook auto-runs the detector after direct UI file edits and surfaces findings as system reminders. Full flow is in [reference/hooks.md](reference/hooks.md); load it when the user invokes `/impeccable hooks` with any argument.
|
||||
@@ -60,7 +60,7 @@ Brand surfaces have permission for Committed, Full palette, and Drenched strateg
|
||||
- Name a real reference before picking a strategy. "Klim Type Foundry #ff4500 orange drench", "Stripe purple-on-white restraint", "Liquid Death acid-green full palette", "Mailchimp yellow full palette", "Condé Nast Traveler muted navy restraint", "Vercel pure black monochrome". Unnamed ambition becomes beige.
|
||||
- Palette IS voice. A calm brand and a restless brand should not share palette mechanics.
|
||||
- When the strategy is Committed or Drenched, color carries the brand. Don't hedge with neutrals around the edges. Commit.
|
||||
- Don't converge across projects. If the last brand surface was restrained-on-cream, this one is not.
|
||||
- Don't converge across projects. Each brand surface differentiates from the last.
|
||||
- When a cultural-symbol palette is the obvious pull, reach past it. Let the cultural reading come from typography, imagery, and copy, not the palette.
|
||||
|
||||
## Layout
|
||||
@@ -74,7 +74,7 @@ Brand surfaces have permission for Committed, Full palette, and Drenched strateg
|
||||
|
||||
Brand surfaces lean on imagery. A restaurant, hotel, magazine, or product landing page without any imagery reads as incomplete, not as restrained. A solid-color rectangle where a hero image should go is worse than a representative stock photo.
|
||||
|
||||
**When the brief implies imagery (restaurants, hotels, magazines, photography, hobbyist communities, food, travel, fashion, product), you must ship imagery.** Zero images is a bug, not a design choice. "Restraint" is not an excuse. If the approved comp or brief is image-led, ship real project assets, generated raster assets, or a credible canvas/SVG/WebGL scene. Do not replace photographic, architectural, product, or place imagery with generic CSS panels, decorative diagrams, cards, bullets, or copy.
|
||||
**When the brief implies imagery, you must ship imagery.** Zero images is a bug, not a design choice. "Restraint" is not an excuse. If the approved comp or brief is image-led, ship real project assets, generated raster assets, or a credible canvas/SVG/WebGL scene. Do not replace photographic, architectural, product, or place imagery with generic CSS panels, decorative diagrams, cards, bullets, or copy.
|
||||
|
||||
- **For greenfield work without local assets, use stock imagery.** Unsplash is the default. The URL shape is `https://images.unsplash.com/photo-{id}?auto=format&fit=crop&w=1600&q=80`. **Verify the URLs before referencing them.** If you have an image-search MCP, web-fetch tool, or browser access, use it to find real photo IDs and confirm they resolve. Guessed IDs (even ones that look real) often 404 and ship as broken-image placeholders. Without a verification path, pick fewer photos you're confident exist over more that you guessed; never substitute colored `<div>` placeholders.
|
||||
- **Search for the brand's physical object**, not the generic category: "handmade pasta on a scratched wooden table" beats "Italian food"; "cypress trees above a limestone hotel facade at dusk" beats "luxury hotel".
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
# /impeccable hooks
|
||||
|
||||
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.
|
||||
|
||||
This command toggles the hook **per project** by editing `.impeccable/config.json` (the unified Impeccable config; hook settings live under its `hook` key). 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.
|
||||
|
||||
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).
|
||||
|
||||
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.
|
||||
|
||||
## Routing
|
||||
|
||||
The first argument is the action. Defaults to `status`.
|
||||
|
||||
| Action | What it does |
|
||||
|---|---|
|
||||
| `status` | Print current state, shared/local config paths, ignored rules / files / values, env override. |
|
||||
| `on` | Set `enabled: true` in `.impeccable/config.json`, record local hook consent as accepted, and install/repair provider hook manifests when the skill is installed. |
|
||||
| `off` | Set `enabled: false` in `.impeccable/config.json`. |
|
||||
| `ignore-rule <id>` | Append `<id>` to `ignoreRules`; for `overused-font`, requires `--all-values`. |
|
||||
| `ignore-file <glob>` | Append `<glob>` to `ignoreFiles`. |
|
||||
| `ignore-value <id> <value> [--shared] [--reason "..."]` | Append a rule/value suppression to shared `.impeccable/config.json`. |
|
||||
| `ignore-value <id> <value> --local [--reason "..."]` | Append a private rule/value suppression to `.impeccable/config.local.json`. |
|
||||
| `reset` | Delete the project config, dedup cache, and Cursor pending queue. |
|
||||
|
||||
## Flow
|
||||
|
||||
1. Resolve the action from the user's argument. If no action was given, default to `status`.
|
||||
2. Invoke the admin script and pass the user's output through verbatim:
|
||||
|
||||
```bash
|
||||
node .claude/skills/impeccable/scripts/hook-admin.mjs <action> [args...]
|
||||
```
|
||||
|
||||
3. If `<action>` is `off`, follow up with a one-line note: "Done. New edits will not trigger the design hook in this project until you run `/impeccable hooks on`."
|
||||
4. If `<action>` is `on`, follow up with: "Done. The design hook will fire after the next Edit/Write/MultiEdit on a UI file."
|
||||
5. If `<action>` is `ignore-value`, `ignore-file`, or `ignore-rule`, just print the script output. The default scope is shared `.impeccable/config.json`; add `--local` only when the user explicitly asks for a private exception.
|
||||
6. If `<action>` is `status`, just print the script output. Do not add commentary unless the user asked a follow-up question.
|
||||
|
||||
## Intentional findings
|
||||
|
||||
The hook itself never writes ignore config. Persist an exception only after the user explicitly confirms the flagged issue is intentional, and always go through `hook-admin.mjs`.
|
||||
|
||||
Prefer the narrowest exception:
|
||||
|
||||
- If the finding line shows an exact `ignore-value` command, run that command. This writes shared `.impeccable/config.json` by default.
|
||||
- For `overused-font`, use `ignore-value` when the user confirms a specific font. 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.
|
||||
|
||||
Example value-specific exception:
|
||||
|
||||
```bash
|
||||
node .claude/skills/impeccable/scripts/hook-admin.mjs ignore-value overused-font Inter --shared --reason "User confirmed Inter is intentional"
|
||||
```
|
||||
|
||||
Example whole-rule font exception:
|
||||
|
||||
```bash
|
||||
node .claude/skills/impeccable/scripts/hook-admin.mjs ignore-rule overused-font --all-values --reason "User asked to ignore overused fonts generally"
|
||||
```
|
||||
|
||||
Example file-scoped exception:
|
||||
|
||||
```bash
|
||||
node .claude/skills/impeccable/scripts/hook-admin.mjs ignore-file "src/legacy/Card.tsx"
|
||||
```
|
||||
|
||||
## 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.
|
||||
- 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.
|
||||
|
||||
## Failure modes
|
||||
|
||||
- If `.impeccable/config.json` or `.impeccable/config.local.json` is unreadable or malformed, the hook ignores that file and uses the remaining valid config/defaults. `hook-admin.mjs status` will show malformed files as ignored.
|
||||
- If the user asks to "disable the hook" globally, lead with `/impeccable hooks off` (persistent for this project; writes `hook.enabled: false` to config). The legacy `IMPECCABLE_HOOK_DISABLED=1` env var also works as a one-shot override that follows the shell.
|
||||
@@ -111,7 +111,9 @@ node .claude/skills/impeccable/scripts/live-insert.mjs --id EVENT_ID --count EVE
|
||||
|
||||
The scaffold has **no** `data-impeccable-variant="original"`. Variants are net-new HTML+CSS inserted at `insertLine`. Load `brand.md` or `product.md` (freeform only, no action sub-command). Write all variants in one edit, then `--reply done`.
|
||||
|
||||
On accept/discard, `live-accept.mjs` removes the wrapper block; the anchor element is untouched.
|
||||
For Svelte/SvelteKit targets, `live-insert.mjs` returns `previewMode: "svelte-component"` with `mode: "insert"`, `file` pointing at a temporary `node_modules/.impeccable-live/<id>/manifest.json`, `componentDir` pointing at the variant component files, and `sourceFile` pointing at the real `.svelte` route. Write each inserted variant as a real Svelte component (`v1.svelte`, `v2.svelte`, …) under `componentDir`. Insert variants must be non-empty net-new content with a single top-level root, no `data-impeccable-*` attributes, and CSS in each component's `<style>` block. Do **not** edit the route source during generation; the browser mounts the temporary component before/after the live anchor while the user cycles variants. On Accept, `live-accept.mjs` inserts the selected component markup into `sourceFile` immediately and deletes the temp session after the source write succeeds.
|
||||
|
||||
For non-Svelte targets, on accept/discard, `live-accept.mjs` removes the wrapper block; the anchor element is untouched.
|
||||
|
||||
### Replace mode (default)
|
||||
|
||||
@@ -149,6 +151,25 @@ If `--text` matches multiple candidates equally well, wrap exits with `{ error:
|
||||
|
||||
Output on success: `{ file, insertLine, commentSyntax, styleMode, styleTag, cssSelectorPrefixExamples, cssAuthoring }`.
|
||||
|
||||
For Svelte/SvelteKit targets, `live-wrap.mjs` returns `previewMode: "svelte-component"` with `file` pointing at a temporary `node_modules/.impeccable-live/<id>/manifest.json`, `componentDir` pointing at the variant component files, and `sourceFile` pointing at the real `.svelte` route. Write each variant as a real Svelte component (`v1.svelte`, `v2.svelte`, …) under `componentDir`; use the `propContract` prop names for dynamic text (`{propName}`), not literal snapshot strings. Put variant CSS in each component's `<style>` block with semantic class selectors (no `@scope`, no `data-impeccable-*`). Reply with `--file` set to the manifest path; the browser dynamically imports and mounts the compiled components so Svelte HMR does not reset page state while the user cycles variants. On Accept, `live-accept.mjs` inlines the accepted component back into `sourceFile` immediately after source promotion succeeds.
|
||||
|
||||
**Params on the Svelte component path go in a sidecar, never as an attribute.** Svelte parses `{` inside an attribute value as the start of an expression, so a `data-impeccable-params='[{…}]'` attribute on a component element fails to compile (`Expected token }`). Declare params for this path in `componentDir/params.json`, keyed by variant number, using the exact param schema from section 7:
|
||||
|
||||
```json
|
||||
{
|
||||
"1": [
|
||||
{"id":"density","kind":"steps","default":"snug","label":"Density","options":[
|
||||
{"value":"airy","label":"Airy"},{"value":"snug","label":"Snug"},{"value":"packed","label":"Packed"}
|
||||
]}
|
||||
],
|
||||
"2": [
|
||||
{"id":"accent","kind":"range","min":0,"max":1,"step":0.05,"default":0.5,"label":"Accent"}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Author the component `<style>` against `var(--p-<id>, default)` for `range`/`toggle` and `[data-p-<id>="…"]` for `steps`; wrap those selectors in `:global(...)` so the knob values the runtime sets on the mounted root reach your rules. The browser reads `params.json`, docks the panel, and drives `--p-*` / `data-p-*` on the mounted component exactly as it does for the HTML/JSX path.
|
||||
|
||||
`styleMode` controls how preview CSS must be authored. Treat it as a detected capability mode, not a framework guess:
|
||||
|
||||
- `scoped`: use `@scope ([data-impeccable-variant="N"])` rules.
|
||||
@@ -340,7 +361,7 @@ Each variant can expose **coarse** knobs alongside the full HTML/CSS replacement
|
||||
|
||||
**Hard cap per variant**: at most **four** parameters so the panel stays legible; rare fifth only if the reference explicitly allows it.
|
||||
|
||||
**How to declare.** Put a JSON manifest on the variant wrapper:
|
||||
**How to declare.** Put a JSON manifest on the variant wrapper (HTML/JSX path). **On the Svelte `svelte-component` path, do not use this attribute** (Svelte can't compile `{` inside an attribute value). Declare params in `componentDir/params.json` keyed by variant number instead (see the Svelte component paragraph in the wrap section). The param schema below is identical for both paths.
|
||||
|
||||
```html
|
||||
<div data-impeccable-variant="1" data-impeccable-params='[
|
||||
@@ -454,7 +475,7 @@ Do these five steps in the current thread, synchronously, before the next poll.
|
||||
1. **Locate the carbonize block** in the source file (`_acceptResult.file`). It's bracketed by `<!-- impeccable-carbonize-start SESSION_ID -->` and `<!-- impeccable-carbonize-end SESSION_ID -->` and contains a `<style data-impeccable-css="SESSION_ID">` element. If the variant declared parameters, an `<!-- impeccable-param-values SESSION_ID: {...} -->` comment sits alongside the style tag with the user's chosen values; read it first; it drives steps 3 and 4 below.
|
||||
2. **Move the CSS rules** into the project's real stylesheet. Which stylesheet depends on the project (e.g. `site/styles/workflow.css` for an Astro project, or the component's co-located CSS file for a Vite/Next project; pick whichever already owns styling for the surrounding element).
|
||||
3. **Bake in parameter values while rewriting selectors.** For `@scope ([data-impeccable-variant="N"])` wrappers: retarget to real, semantic classes on the accepted HTML (`.why-visual--v2 .v2-label { … }`). For `:scope[data-p-<id>="VALUE"]` selectors: keep only the branch matching the chosen value from the param-values comment; drop the others (they're dead after accept). For `var(--p-<id>, DEFAULT)` in the CSS: either substitute the literal value, or if the param is still useful as a knob going forward, leave the var and update its initial declaration to the chosen value.
|
||||
4. **Unwrap the accepted content.** Delete the `<div data-impeccable-variant="N" style="display: contents">` that wraps it. Drop `data-impeccable-params` and any `data-p-*` attributes from it; those are live-mode plumbing, not source.
|
||||
4. **Unwrap the accepted content.** Delete the inner `<div data-impeccable-variant="N" style="display: contents">` that wraps it. On JSX/TSX, also delete the outer `<div data-impeccable-carbonize="SESSION_ID" style={{ display: 'contents' }}>` wrapper if present (accept adds it so ternary/`return` slots keep a single root). Drop `data-impeccable-params` and any `data-p-*` attributes; those are live-mode plumbing, not source.
|
||||
5. **Delete the inline `<style>` block, the `<!-- impeccable-param-values -->` comment if present, and both `<!-- impeccable-carbonize-start/end -->` markers.** Also drop any `@scope` rules for variants other than the accepted one; those are dead code now.
|
||||
|
||||
After the file is clean, run `live-complete.mjs --id SESSION_ID`, verify it reports `phase: "completed"`, then poll again.
|
||||
|
||||
@@ -1,284 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Cleans up deprecated Impeccable skill files, symlinks, and
|
||||
* skills-lock.json entries left over from previous versions.
|
||||
*
|
||||
* Safe to run repeatedly -- it is a no-op when nothing needs cleaning.
|
||||
*
|
||||
* Usage (from the project root):
|
||||
* node {{scripts_path}}/cleanup-deprecated.mjs
|
||||
*
|
||||
* What it does:
|
||||
* 1. Finds every harness-specific skills directory (.claude/skills,
|
||||
* .cursor/skills, .agents/skills, etc.).
|
||||
* 2. For each deprecated skill name (with and without i- prefix),
|
||||
* checks if the directory exists and its SKILL.md mentions
|
||||
* "impeccable" (to avoid deleting unrelated user skills).
|
||||
* 3. Deletes confirmed matches (files, directories, or symlinks).
|
||||
* 4. Removes the corresponding entries from skills-lock.json.
|
||||
*/
|
||||
|
||||
import { existsSync, readFileSync, writeFileSync, rmSync, readdirSync, statSync, lstatSync, unlinkSync } from 'node:fs';
|
||||
import { join, resolve } from 'node:path';
|
||||
|
||||
// Skills that were renamed, merged, or folded in v2.0, v2.1, and v3.0.
|
||||
const DEPRECATED_NAMES = [
|
||||
// v2.0 renames
|
||||
'frontend-design', // renamed to impeccable
|
||||
'teach-impeccable', // folded into /impeccable init
|
||||
// v2.1 merges
|
||||
'arrange', // renamed to layout
|
||||
'normalize', // merged into polish
|
||||
'onboard', // merged into harden
|
||||
'extract', // merged into /impeccable extract
|
||||
// v3.0 consolidation: all standalone skills -> /impeccable sub-commands
|
||||
'adapt',
|
||||
'animate',
|
||||
'audit',
|
||||
'bolder',
|
||||
'clarify',
|
||||
'colorize',
|
||||
'critique',
|
||||
'delight',
|
||||
'distill',
|
||||
'harden',
|
||||
'layout',
|
||||
'optimize',
|
||||
'overdrive',
|
||||
'polish',
|
||||
'quieter',
|
||||
'shape',
|
||||
'typeset',
|
||||
];
|
||||
|
||||
// All known harness directories that may contain a skills/ subfolder.
|
||||
const HARNESS_DIRS = [
|
||||
'.claude', '.cursor', '.gemini', '.codex', '.agents',
|
||||
'.trae', '.trae-cn', '.pi', '.opencode', '.kiro', '.rovodev',
|
||||
];
|
||||
|
||||
// Per-skill fingerprints for SKILL.md bodies that never mentioned
|
||||
// "impeccable" in their v2.x source. Used as a last-resort match
|
||||
// when no skills-lock.json exists and the word heuristic fails.
|
||||
// The strings are lifted verbatim from the v2.x frontmatter
|
||||
// descriptions, so collisions with hand-written user skills are
|
||||
// vanishingly unlikely.
|
||||
const SKILL_FINGERPRINTS = {
|
||||
harden: 'Make interfaces production-ready: error handling, empty states',
|
||||
optimize: 'Diagnoses and fixes UI performance across loading speed',
|
||||
};
|
||||
|
||||
/**
|
||||
* Walk up from startDir until we find a directory that looks like a
|
||||
* project root (has package.json, .git, or skills-lock.json).
|
||||
*/
|
||||
export function findProjectRoot(startDir = process.cwd()) {
|
||||
let dir = resolve(startDir);
|
||||
const { root } = { root: '/' };
|
||||
while (dir !== root) {
|
||||
if (
|
||||
existsSync(join(dir, 'package.json')) ||
|
||||
existsSync(join(dir, '.git')) ||
|
||||
existsSync(join(dir, 'skills-lock.json'))
|
||||
) {
|
||||
return dir;
|
||||
}
|
||||
const parent = resolve(dir, '..');
|
||||
if (parent === dir) break;
|
||||
dir = parent;
|
||||
}
|
||||
return resolve(startDir);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load skills-lock.json from the project root, or null if missing/unreadable.
|
||||
*/
|
||||
export function loadLock(projectRoot) {
|
||||
const lockPath = join(projectRoot, 'skills-lock.json');
|
||||
if (!existsSync(lockPath)) return null;
|
||||
try {
|
||||
return JSON.parse(readFileSync(lockPath, 'utf-8'));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether a skill directory belongs to Impeccable. Three layered
|
||||
* signals, in order of reliability:
|
||||
* 1. Lock source equals "pbakaus/impeccable" (authoritative).
|
||||
* 2. SKILL.md body contains the word "impeccable".
|
||||
* 3. SKILL.md body contains a per-skill fingerprint (for harden and
|
||||
* optimize, whose v2.x SKILL.md never mentioned the pack name).
|
||||
*/
|
||||
export function isImpeccableSkill(skillDir, { skillName, lock } = {}) {
|
||||
// 1. Authoritative: the lock file claims this skill is ours.
|
||||
if (skillName && lock?.skills?.[skillName]?.source === 'pbakaus/impeccable') {
|
||||
return true;
|
||||
}
|
||||
const skillMd = join(skillDir, 'SKILL.md');
|
||||
if (!existsSync(skillMd)) return false;
|
||||
let content;
|
||||
try {
|
||||
content = readFileSync(skillMd, 'utf-8');
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
// 2. Word-level content heuristic.
|
||||
if (/impeccable/i.test(content)) return true;
|
||||
// 3. Per-skill fingerprint for old skills that never mentioned the pack.
|
||||
// Strip the i- prefix so both `harden` and `i-harden` resolve to the
|
||||
// same fingerprint entry.
|
||||
const unprefixed = skillName?.startsWith('i-') ? skillName.slice(2) : skillName;
|
||||
const fingerprint = unprefixed && SKILL_FINGERPRINTS[unprefixed];
|
||||
if (fingerprint && content.includes(fingerprint)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the full list of names to check: each deprecated name, plus
|
||||
* its i-prefixed variant.
|
||||
*/
|
||||
export function buildTargetNames() {
|
||||
const names = [];
|
||||
for (const name of DEPRECATED_NAMES) {
|
||||
names.push(name);
|
||||
names.push(`i-${name}`);
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find every skills directory across all harness dirs in the project.
|
||||
* Returns absolute paths that exist on disk.
|
||||
*/
|
||||
export function findSkillsDirs(projectRoot) {
|
||||
const dirs = [];
|
||||
for (const harness of HARNESS_DIRS) {
|
||||
const candidate = join(projectRoot, harness, 'skills');
|
||||
if (existsSync(candidate)) {
|
||||
dirs.push(candidate);
|
||||
}
|
||||
}
|
||||
return dirs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove deprecated skill directories/symlinks from all harness dirs.
|
||||
* Reads skills-lock.json so the authoritative "source" field can
|
||||
* drive deletion even when SKILL.md never mentions impeccable.
|
||||
* Returns an array of paths that were deleted.
|
||||
*/
|
||||
export function removeDeprecatedSkills(projectRoot, lock) {
|
||||
if (lock === undefined) lock = loadLock(projectRoot);
|
||||
const targets = buildTargetNames();
|
||||
const skillsDirs = findSkillsDirs(projectRoot);
|
||||
const deleted = [];
|
||||
|
||||
for (const skillsDir of skillsDirs) {
|
||||
for (const name of targets) {
|
||||
const skillPath = join(skillsDir, name);
|
||||
|
||||
// Use lstat to detect symlinks (existsSync follows symlinks and
|
||||
// returns false for dangling ones).
|
||||
let stat;
|
||||
try {
|
||||
stat = lstatSync(skillPath);
|
||||
} catch {
|
||||
continue; // does not exist at all
|
||||
}
|
||||
|
||||
if (stat.isSymbolicLink()) {
|
||||
// Symlink: check the target if it's alive, otherwise treat
|
||||
// dangling symlinks to deprecated names as safe to remove.
|
||||
const targetAlive = existsSync(skillPath);
|
||||
const isMatch = targetAlive
|
||||
? isImpeccableSkill(skillPath, { skillName: name, lock })
|
||||
: true;
|
||||
if (isMatch) {
|
||||
unlinkSync(skillPath);
|
||||
deleted.push(skillPath);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Regular directory -- verify it belongs to impeccable
|
||||
if (isImpeccableSkill(skillPath, { skillName: name, lock })) {
|
||||
rmSync(skillPath, { recursive: true, force: true });
|
||||
deleted.push(skillPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return deleted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove deprecated entries from skills-lock.json.
|
||||
* Only removes entries whose source is "pbakaus/impeccable".
|
||||
* Returns the list of removed skill names.
|
||||
*/
|
||||
export function cleanSkillsLock(projectRoot) {
|
||||
const lockPath = join(projectRoot, 'skills-lock.json');
|
||||
if (!existsSync(lockPath)) return [];
|
||||
|
||||
let lock;
|
||||
try {
|
||||
lock = JSON.parse(readFileSync(lockPath, 'utf-8'));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!lock.skills || typeof lock.skills !== 'object') return [];
|
||||
|
||||
const targets = buildTargetNames();
|
||||
const removed = [];
|
||||
|
||||
for (const name of targets) {
|
||||
const entry = lock.skills[name];
|
||||
if (!entry) continue;
|
||||
// Only remove if it belongs to impeccable
|
||||
if (entry.source === 'pbakaus/impeccable') {
|
||||
delete lock.skills[name];
|
||||
removed.push(name);
|
||||
}
|
||||
}
|
||||
|
||||
if (removed.length > 0) {
|
||||
writeFileSync(lockPath, JSON.stringify(lock, null, 2) + '\n', 'utf-8');
|
||||
}
|
||||
|
||||
return removed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the full cleanup. Returns a summary object.
|
||||
*
|
||||
* Order matters: read the lock and delete directories first, then
|
||||
* strip lock entries. Otherwise the authoritative signal is gone by
|
||||
* the time directory deletion runs.
|
||||
*/
|
||||
export function cleanup(projectRoot) {
|
||||
const root = projectRoot || findProjectRoot();
|
||||
const lock = loadLock(root);
|
||||
const deletedPaths = removeDeprecatedSkills(root, lock);
|
||||
const removedLockEntries = cleanSkillsLock(root);
|
||||
return { deletedPaths, removedLockEntries, projectRoot: root };
|
||||
}
|
||||
|
||||
// CLI entry point
|
||||
if (process.argv[1] && resolve(process.argv[1]) === resolve(new URL(import.meta.url).pathname)) {
|
||||
const result = cleanup();
|
||||
if (result.deletedPaths.length === 0 && result.removedLockEntries.length === 0) {
|
||||
console.log('No deprecated Impeccable skills found. Nothing to clean up.');
|
||||
} else {
|
||||
if (result.deletedPaths.length > 0) {
|
||||
console.log(`Removed ${result.deletedPaths.length} deprecated skill(s):`);
|
||||
for (const p of result.deletedPaths) console.log(` - ${p}`);
|
||||
}
|
||||
if (result.removedLockEntries.length > 0) {
|
||||
console.log(`Cleaned ${result.removedLockEntries.length} entry/entries from skills-lock.json:`);
|
||||
for (const name of result.removedLockEntries) console.log(` - ${name}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -22,7 +22,7 @@ import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { loadContext, extractRegister } from './context.mjs';
|
||||
import { getCritiqueDir } from './impeccable-paths.mjs';
|
||||
import { getCritiqueDir } from './lib/impeccable-paths.mjs';
|
||||
|
||||
/** Is there code here at all, or just context files / an empty repo? */
|
||||
function hasCode(cwd) {
|
||||
|
||||
@@ -184,9 +184,23 @@ function buildUpdateDirective(localVersion, latestVersion) {
|
||||
* the user's home dir) and re-surfaces a given version at most once per week so
|
||||
* the agent never nags. Opt out entirely with IMPECCABLE_NO_UPDATE_CHECK=1.
|
||||
*/
|
||||
// Read the unified config's top-level `updateCheck` (local overrides shared).
|
||||
// Inlined rather than importing hook-lib so the boot path stays lightweight.
|
||||
function updateCheckDisabledByConfig(cwd = process.cwd()) {
|
||||
let value;
|
||||
for (const name of ['config.json', 'config.local.json']) {
|
||||
try {
|
||||
const raw = JSON.parse(fs.readFileSync(path.join(cwd, '.impeccable', name), 'utf-8'));
|
||||
if (raw && typeof raw === 'object' && typeof raw.updateCheck === 'boolean') value = raw.updateCheck;
|
||||
} catch { /* missing or malformed: ignore */ }
|
||||
}
|
||||
return value === false;
|
||||
}
|
||||
|
||||
async function computeUpdateDirective(now = Date.now()) {
|
||||
try {
|
||||
if (process.env.IMPECCABLE_NO_UPDATE_CHECK) return null;
|
||||
if (updateCheckDisabledByConfig()) return null;
|
||||
const localVersion = readLocalSkillVersion();
|
||||
if (!localVersion) return null;
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
import { getCritiqueDir } from './impeccable-paths.mjs';
|
||||
import { getCritiqueDir } from './lib/impeccable-paths.mjs';
|
||||
|
||||
const SLUG_MAX = 50;
|
||||
|
||||
|
||||
@@ -660,6 +660,7 @@ if (IS_BROWSER) {
|
||||
if (el.closest('.impeccable-overlay, .impeccable-label, .impeccable-banner, .impeccable-tooltip')) continue;
|
||||
if (el.closest('[id^="impeccable-live-"]')) continue;
|
||||
if (el === document.body || el === document.documentElement) continue;
|
||||
if (!isRenderedForBrowserRule(el)) continue;
|
||||
|
||||
const tag = el.tagName.toLowerCase();
|
||||
const style = getComputedStyle(el);
|
||||
@@ -1091,6 +1092,7 @@ if (IS_BROWSER) {
|
||||
return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'stale selector' };
|
||||
}
|
||||
if (!el) return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'missing element' };
|
||||
if (!isRenderedForBrowserRule(el)) return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'hidden element' };
|
||||
|
||||
const blockingReason = (candidate.reasons || []).find(reason =>
|
||||
reason === 'background-clip text' ||
|
||||
@@ -1437,13 +1439,20 @@ if (IS_BROWSER) {
|
||||
return true;
|
||||
}
|
||||
|
||||
function postSerializedFindings(groupMap) {
|
||||
function scanResultMeta(options = {}) {
|
||||
const scanId = options.scanId;
|
||||
if (typeof scanId !== 'string' && typeof scanId !== 'number') return {};
|
||||
return { scanId: String(scanId) };
|
||||
}
|
||||
|
||||
function postSerializedFindings(groupMap, options = {}) {
|
||||
if (!EXTENSION_MODE) return;
|
||||
const allFindings = browserFindingsFromMap(groupMap);
|
||||
window.postMessage({
|
||||
source: 'impeccable-results',
|
||||
findings: serializeFindings(allFindings),
|
||||
count: allFindings.length,
|
||||
...scanResultMeta(options),
|
||||
}, '*');
|
||||
}
|
||||
|
||||
@@ -1497,7 +1506,7 @@ if (IS_BROWSER) {
|
||||
rememberVisualContrastAnalysis(result);
|
||||
const added = addVisualContrastResult(groupMap, result, { decorate: true });
|
||||
if (added) {
|
||||
postSerializedFindings(groupMap);
|
||||
postSerializedFindings(groupMap, options);
|
||||
window.dispatchEvent(new CustomEvent('impeccable-visual-contrast-resolved', {
|
||||
detail: {
|
||||
selector: result.selector,
|
||||
@@ -1565,7 +1574,7 @@ if (IS_BROWSER) {
|
||||
overlayIndex = 0;
|
||||
}
|
||||
|
||||
function renderBrowserFindings(collected) {
|
||||
function renderBrowserFindings(collected, options = {}) {
|
||||
const { allFindings, pageLevelFindings } = collected;
|
||||
|
||||
for (const { el, findings } of allFindings) {
|
||||
@@ -1585,6 +1594,7 @@ if (IS_BROWSER) {
|
||||
source: 'impeccable-results',
|
||||
findings: serializeFindings(allFindings),
|
||||
count: allFindings.length,
|
||||
...scanResultMeta(options),
|
||||
}, '*');
|
||||
}
|
||||
|
||||
@@ -1599,11 +1609,11 @@ if (IS_BROWSER) {
|
||||
clearOverlays();
|
||||
const generation = scanGeneration;
|
||||
const collected = collectBrowserFindings();
|
||||
const allFindings = renderBrowserFindings(collected);
|
||||
const allFindings = renderBrowserFindings(collected, options);
|
||||
if (shouldRunVisualContrast(options)) {
|
||||
addVisualContrastFindings(collected.groupMap, options, { decorate: true, generation })
|
||||
.then(() => {
|
||||
if (generation === scanGeneration) postSerializedFindings(collected.groupMap);
|
||||
if (generation === scanGeneration) postSerializedFindings(collected.groupMap, options);
|
||||
})
|
||||
.catch(err => {
|
||||
reportVisualContrastError(err);
|
||||
@@ -1618,10 +1628,10 @@ if (IS_BROWSER) {
|
||||
if (shouldRunVisualContrast(options)) {
|
||||
const collected = await collectBrowserFindingsAsync(options, { generation, scheduleLazy: true });
|
||||
if (generation !== scanGeneration) return [];
|
||||
return renderBrowserFindings(collected);
|
||||
return renderBrowserFindings(collected, options);
|
||||
}
|
||||
lastVisualContrastAnalyses = [];
|
||||
return renderBrowserFindings(collectBrowserFindings());
|
||||
return renderBrowserFindings(collectBrowserFindings(), options);
|
||||
};
|
||||
|
||||
const detect = function(options = {}) {
|
||||
|
||||
@@ -1544,11 +1544,16 @@ function parseAnyColor(s) {
|
||||
// OKLCH parser. Tailwind v4's CSS minifier squishes the space after
|
||||
// `%` ("21.5%.02 50"), so the separator between L and C may be absent.
|
||||
// Match L (with optional %), then C and H separated permissively.
|
||||
m = str.match(/oklch\(\s*([\d.]+)(%?)\s*[\s,]*\s*([\d.]+)\s*[\s,]+\s*([-\d.]+)(?:deg)?\s*\)/i);
|
||||
m = str.match(/oklch\(\s*([\d.]+)(%?)\s*[\s,]*\s*([\d.]+)\s*[\s,]+\s*([-\d.]+)(?:deg)?(?:\s*\/\s*([\d.]+)(%)?)?\s*\)/i);
|
||||
if (m) {
|
||||
const Lnum = parseFloat(m[1]);
|
||||
const L = m[2] === '%' ? Lnum / 100 : Lnum;
|
||||
return oklchToRgb(L, parseFloat(m[3]), parseFloat(m[4]));
|
||||
const rgb = oklchToRgb(L, parseFloat(m[3]), parseFloat(m[4]));
|
||||
if (m[5] !== undefined) {
|
||||
const alpha = parseFloat(m[5]);
|
||||
rgb.a = m[6] === '%' ? alpha / 100 : alpha;
|
||||
}
|
||||
return rgb;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -1577,9 +1582,19 @@ const REPEATED_KICKER_SKIP_SELECTOR = [
|
||||
'[role="navigation"]',
|
||||
'[aria-label*="breadcrumb" i]',
|
||||
'[class*="breadcrumb" i]',
|
||||
'[aria-hidden="true"]',
|
||||
'[data-impeccable-allow-kickers]',
|
||||
].join(',');
|
||||
|
||||
const REPEATED_KICKER_CARD_CONTEXT_SELECTOR = [
|
||||
'article',
|
||||
'button',
|
||||
'a',
|
||||
'li',
|
||||
'[role="listitem"]',
|
||||
'[role="option"]',
|
||||
].join(',');
|
||||
|
||||
function cleanInlineText(el) {
|
||||
return [...el.childNodes]
|
||||
.filter(n => n.nodeType === 3)
|
||||
@@ -1589,6 +1604,11 @@ function cleanInlineText(el) {
|
||||
.trim();
|
||||
}
|
||||
|
||||
function isRepeatedKickerCardContext(heading, kicker) {
|
||||
const item = heading.closest?.(REPEATED_KICKER_CARD_CONTEXT_SELECTOR);
|
||||
return Boolean(item && (!item.contains || item.contains(kicker)));
|
||||
}
|
||||
|
||||
function isRepeatedKickerCandidate(opts) {
|
||||
const {
|
||||
headingTag,
|
||||
@@ -1602,6 +1622,7 @@ function isRepeatedKickerCandidate(opts) {
|
||||
} = opts;
|
||||
if (!['h2', 'h3', 'h4'].includes(headingTag)) return false;
|
||||
if (!headingText || headingText.length < 3) return false;
|
||||
if (/^\/[\w-]+/i.test(headingText.replace(/^"|"$/g, '').trim())) return false;
|
||||
if (!(headingFontSize >= 20)) return false;
|
||||
if (!kickerTag || HEADING_TAGS.has(kickerTag)) return false;
|
||||
if (!['p', 'span', 'div', 'small'].includes(kickerTag)) return false;
|
||||
@@ -1623,6 +1644,7 @@ function collectRepeatedSectionKickerCandidates(doc, getStyle, resolveLetterSpac
|
||||
if (heading.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue;
|
||||
const kicker = heading.previousElementSibling;
|
||||
if (!kicker || kicker.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue;
|
||||
if (isRepeatedKickerCardContext(heading, kicker)) continue;
|
||||
|
||||
const headingStyle = getStyle(heading);
|
||||
const kickerStyle = getStyle(kicker);
|
||||
@@ -1805,6 +1827,84 @@ function resolveLengthPx(value, fontSizePx) {
|
||||
return num * fontSizePx;
|
||||
}
|
||||
|
||||
function cssColorIsTransparent(value) {
|
||||
if (!value) return true;
|
||||
const str = String(value).trim().toLowerCase();
|
||||
if (!str || str === 'transparent' || str === 'rgba(0, 0, 0, 0)') return true;
|
||||
const parsed = parseAnyColor(str);
|
||||
if (parsed) return (parsed.a ?? 1) <= 0.05;
|
||||
return /^rgba\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*,\s*0(?:\.0+)?\s*\)$/.test(str);
|
||||
}
|
||||
|
||||
function colorsNearlyMatch(a, b) {
|
||||
const ca = parseAnyColor(a);
|
||||
const cb = parseAnyColor(b);
|
||||
if (!ca || !cb) return false;
|
||||
const alphaDelta = Math.abs((ca.a ?? 1) - (cb.a ?? 1));
|
||||
const channelDelta = Math.max(
|
||||
Math.abs(ca.r - cb.r),
|
||||
Math.abs(ca.g - cb.g),
|
||||
Math.abs(ca.b - cb.b),
|
||||
);
|
||||
return alphaDelta <= 0.03 && channelDelta <= 3;
|
||||
}
|
||||
|
||||
function getComputedStyleFor(win, el) {
|
||||
if (win && typeof win.getComputedStyle === 'function') {
|
||||
try { return win.getComputedStyle(el); } catch {}
|
||||
}
|
||||
if (typeof getComputedStyle === 'function') {
|
||||
try { return getComputedStyle(el); } catch {}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function hasVisibleBackgroundBoundary(style, el, win) {
|
||||
const bg = style?.backgroundColor || '';
|
||||
if (cssColorIsTransparent(bg)) return false;
|
||||
|
||||
let parent = el?.parentElement || null;
|
||||
while (parent) {
|
||||
const parentStyle = getComputedStyleFor(win, parent);
|
||||
const parentBg = parentStyle?.backgroundColor || '';
|
||||
if (!cssColorIsTransparent(parentBg)) {
|
||||
return !colorsNearlyMatch(bg, parentBg);
|
||||
}
|
||||
parent = parent.parentElement;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
const TEXT_EDGE_TAGS = new Set(['A', 'BUTTON', 'CODE', 'DD', 'DT', 'FIGCAPTION', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'LI', 'P', 'PRE', 'SPAN', 'TD', 'TH']);
|
||||
|
||||
function hasMeaningfulDirectText(node) {
|
||||
if (!node?.childNodes) return false;
|
||||
for (const child of node.childNodes) {
|
||||
if (child.nodeType === 3 && child.textContent.trim().length > 4) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function textDescendantsFlushSides(el, rect) {
|
||||
const flush = { top: false, right: false, bottom: false, left: false };
|
||||
if (!rect || !el?.querySelectorAll) return flush;
|
||||
const TEXT_EDGE_THRESHOLD = 4;
|
||||
const candidates = el.querySelectorAll('a, button, code, dd, dt, figcaption, h1, h2, h3, h4, h5, h6, li, p, pre, span, td, th');
|
||||
for (const node of candidates) {
|
||||
if (!TEXT_EDGE_TAGS.has(node.tagName) || !hasMeaningfulDirectText(node)) continue;
|
||||
let nodeRect = null;
|
||||
try { nodeRect = node.getBoundingClientRect(); } catch {}
|
||||
if (!nodeRect || nodeRect.width <= 0 || nodeRect.height <= 0) continue;
|
||||
if (nodeRect.bottom < rect.top || nodeRect.top > rect.bottom || nodeRect.right < rect.left || nodeRect.left > rect.right) continue;
|
||||
if (nodeRect.top - rect.top <= TEXT_EDGE_THRESHOLD) flush.top = true;
|
||||
if (rect.right - nodeRect.right <= TEXT_EDGE_THRESHOLD) flush.right = true;
|
||||
if (rect.bottom - nodeRect.bottom <= TEXT_EDGE_THRESHOLD) flush.bottom = true;
|
||||
if (nodeRect.left - rect.left <= TEXT_EDGE_THRESHOLD) flush.left = true;
|
||||
}
|
||||
return flush;
|
||||
}
|
||||
|
||||
// Pure quality checks. Most run on computed CSS and DOM-only inputs (work in
|
||||
// jsdom and the browser). Two checks (line-length, cramped-padding) gate on
|
||||
// element rect dimensions, which jsdom can't compute — pass `rect: null` from
|
||||
@@ -1834,7 +1934,8 @@ function checkQuality(opts) {
|
||||
// font-size — bigger text demands proportionally more padding.
|
||||
// vertical: max(4px, fontSize × 0.3)
|
||||
// horizontal: max(8px, fontSize × 0.5)
|
||||
if (rect && hasDirectText && textLen > 20 && rect.width > 100 && rect.height > 30) {
|
||||
const isInlineCode = tag === 'code' && !(el.closest && el.closest('pre'));
|
||||
if (!isInlineCode && rect && hasDirectText && textLen > 20 && rect.width > 100 && rect.height > 30) {
|
||||
const borders = {
|
||||
top: parseFloat(style.borderTopWidth) || 0,
|
||||
right: parseFloat(style.borderRightWidth) || 0,
|
||||
@@ -1842,7 +1943,7 @@ function checkQuality(opts) {
|
||||
left: parseFloat(style.borderLeftWidth) || 0,
|
||||
};
|
||||
const borderCount = Object.values(borders).filter(w => w > 0).length;
|
||||
const hasBg = style.backgroundColor && style.backgroundColor !== 'rgba(0, 0, 0, 0)';
|
||||
const hasBg = hasVisibleBackgroundBoundary(style, el, win);
|
||||
if (borderCount >= 2 || hasBg) {
|
||||
const vPads = [], hPads = [];
|
||||
if (hasBg || borders.top > 0) vPads.push(parseFloat(style.paddingTop) || 0);
|
||||
@@ -1890,10 +1991,6 @@ function checkQuality(opts) {
|
||||
!['fixed', 'absolute'].includes(elPosition) &&
|
||||
el.children && el.children.length > 0
|
||||
) {
|
||||
const isTransparent = (c) =>
|
||||
!c || c === 'transparent' || c === 'rgba(0, 0, 0, 0)' ||
|
||||
/^rgba\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*,\s*0(?:\.0+)?\s*\)$/.test(c);
|
||||
|
||||
const borderW = {
|
||||
top: parseFloat(style.borderTopWidth) || 0,
|
||||
right: parseFloat(style.borderRightWidth) || 0,
|
||||
@@ -1901,10 +1998,10 @@ function checkQuality(opts) {
|
||||
left: parseFloat(style.borderLeftWidth) || 0,
|
||||
};
|
||||
const borderVisible = {
|
||||
top: borderW.top > 0 && !isTransparent(style.borderTopColor),
|
||||
right: borderW.right > 0 && !isTransparent(style.borderRightColor),
|
||||
bottom: borderW.bottom > 0 && !isTransparent(style.borderBottomColor),
|
||||
left: borderW.left > 0 && !isTransparent(style.borderLeftColor),
|
||||
top: borderW.top > 0 && !cssColorIsTransparent(style.borderTopColor),
|
||||
right: borderW.right > 0 && !cssColorIsTransparent(style.borderRightColor),
|
||||
bottom: borderW.bottom > 0 && !cssColorIsTransparent(style.borderBottomColor),
|
||||
left: borderW.left > 0 && !cssColorIsTransparent(style.borderLeftColor),
|
||||
};
|
||||
// Outline detection. jsdom decomposes `border` shorthand into
|
||||
// border{Top,…}Width/Color but does NOT decompose `outline` —
|
||||
@@ -1924,8 +2021,8 @@ function checkQuality(opts) {
|
||||
if (cMatch) outlineColorVal = cMatch[1];
|
||||
}
|
||||
}
|
||||
const outlineVisible = outlineW > 0 && !isTransparent(outlineColorVal) && outlineStyleVal && outlineStyleVal !== 'none';
|
||||
const bgVisible = !isTransparent(style.backgroundColor);
|
||||
const outlineVisible = outlineW > 0 && !cssColorIsTransparent(outlineColorVal) && outlineStyleVal && outlineStyleVal !== 'none';
|
||||
const bgVisible = hasVisibleBackgroundBoundary(style, el, win);
|
||||
|
||||
const anyVisible = borderVisible.top || borderVisible.right || borderVisible.bottom || borderVisible.left || outlineVisible || bgVisible;
|
||||
if (anyVisible) {
|
||||
@@ -1953,13 +2050,7 @@ function checkQuality(opts) {
|
||||
const CHILD_INSULATE_THRESHOLD = 4;
|
||||
const childrenInsulate = { top: false, right: false, bottom: false, left: false };
|
||||
for (const child of el.children) {
|
||||
let childStyle = null;
|
||||
if (win && typeof win.getComputedStyle === 'function') {
|
||||
try { childStyle = win.getComputedStyle(child); } catch {}
|
||||
}
|
||||
if (!childStyle && typeof getComputedStyle === 'function') {
|
||||
try { childStyle = getComputedStyle(child); } catch {}
|
||||
}
|
||||
let childStyle = getComputedStyleFor(win, child);
|
||||
if (!childStyle) continue;
|
||||
const childPad = {
|
||||
top: resolveLengthPx(childStyle.paddingTop, fontSize) ?? 0,
|
||||
@@ -1967,15 +2058,37 @@ function checkQuality(opts) {
|
||||
bottom: resolveLengthPx(childStyle.paddingBottom, fontSize) ?? 0,
|
||||
left: resolveLengthPx(childStyle.paddingLeft, fontSize) ?? 0,
|
||||
};
|
||||
const childMargin = {
|
||||
top: resolveLengthPx(childStyle.marginTop, fontSize) ?? 0,
|
||||
right: resolveLengthPx(childStyle.marginRight, fontSize) ?? 0,
|
||||
bottom: resolveLengthPx(childStyle.marginBottom, fontSize) ?? 0,
|
||||
left: resolveLengthPx(childStyle.marginLeft, fontSize) ?? 0,
|
||||
};
|
||||
if (rect && typeof child.getBoundingClientRect === 'function') {
|
||||
try {
|
||||
const childRect = child.getBoundingClientRect();
|
||||
if (childRect && childRect.width > 0 && childRect.height > 0) {
|
||||
if (childRect.top - rect.top >= CHILD_INSULATE_THRESHOLD) childrenInsulate.top = true;
|
||||
if (rect.right - childRect.right >= CHILD_INSULATE_THRESHOLD) childrenInsulate.right = true;
|
||||
if (rect.bottom - childRect.bottom >= CHILD_INSULATE_THRESHOLD) childrenInsulate.bottom = true;
|
||||
if (childRect.left - rect.left >= CHILD_INSULATE_THRESHOLD) childrenInsulate.left = true;
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
for (const s of ['top', 'right', 'bottom', 'left']) {
|
||||
if (childPad[s] >= CHILD_INSULATE_THRESHOLD) childrenInsulate[s] = true;
|
||||
if (childPad[s] >= CHILD_INSULATE_THRESHOLD || childMargin[s] >= CHILD_INSULATE_THRESHOLD) {
|
||||
childrenInsulate[s] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const textFlush = rect ? textDescendantsFlushSides(el, rect) : null;
|
||||
const fullBleedBgBand = rect && viewportWidth > 0 && rect.width >= viewportWidth * 0.94 && bgVisible && !outlineVisible;
|
||||
const flushSides = [];
|
||||
for (const side of ['top', 'right', 'bottom', 'left']) {
|
||||
const sideBounded = borderVisible[side] || outlineVisible || bgVisible;
|
||||
if (sideBounded && pad[side] <= PAD_THRESHOLD && !childrenInsulate[side]) {
|
||||
const bgBoundsSide = bgVisible && !(fullBleedBgBand && (side === 'left' || side === 'right'));
|
||||
const sideBounded = borderVisible[side] || outlineVisible || bgBoundsSide;
|
||||
if (sideBounded && pad[side] <= PAD_THRESHOLD && !childrenInsulate[side] && (!textFlush || textFlush[side])) {
|
||||
flushSides.push(side);
|
||||
}
|
||||
}
|
||||
@@ -2069,7 +2182,7 @@ function checkQuality(opts) {
|
||||
// Only flag actual body content, not UI labels (buttons, tabs, badges, captions, footer text, etc.)
|
||||
if (hasDirectText && textLen > 20 && fontSize < 12) {
|
||||
const skipTags = ['sub', 'sup', 'code', 'kbd', 'samp', 'var', 'caption', 'figcaption'];
|
||||
const inUIContext = el.closest && el.closest('button, a, label, summary, [role="button"], [role="link"], [role="tab"], [role="menuitem"], [role="option"], nav, footer, [class*="badge" i], [class*="chip" i], [class*="pill" i], [class*="tag" i], [class*="label" i], [class*="caption" i]');
|
||||
const inUIContext = el.closest && el.closest('button, a, label, summary, pre, [role="button"], [role="link"], [role="tab"], [role="menuitem"], [role="option"], nav, footer, [aria-hidden="true"], [class*="badge" i], [class*="caption" i], [class*="chip" i], [class*="code" i], [class*="console" i], [class*="diff" i], [class*="label" i], [class*="meta" i], [class*="mock" i], [class*="pill" i], [class*="preview" i], [class*="tag" i], [class*="terminal" i], [class*="writes" i]');
|
||||
const isUppercase = style.textTransform === 'uppercase';
|
||||
if (!skipTags.includes(tag) && !inUIContext && !isUppercase) {
|
||||
findings.push({ id: 'tiny-text', snippet: `${fontSize}px body text` });
|
||||
@@ -2677,17 +2790,28 @@ function checkCreamPalette(doc, win) {
|
||||
}
|
||||
|
||||
// ─── Oversized hero headline ────────────────────────────────────────────────
|
||||
// Fires when a *long* headline is set at display size, so a full sentence ends
|
||||
// up dominating the viewport. A punchy one- or two-word headline at the same
|
||||
// size is a legitimate stylistic choice and must pass — length, not size
|
||||
// alone, is the tell.
|
||||
// Fires when a *long* headline is set at display size and actually dominates
|
||||
// the viewport. A punchy one- or two-word headline at the same size is a
|
||||
// legitimate stylistic choice, and a large-but-contained two-line hero should
|
||||
// pass too — length and viewport share together are the tell.
|
||||
const OVERSIZED_H1_FONT_PX = 72;
|
||||
const OVERSIZED_H1_MIN_CHARS = 40;
|
||||
function checkOversizedH1({ tag, fontSize, headingText }) {
|
||||
const OVERSIZED_H1_MIN_VIEWPORT_HEIGHT_RATIO = 0.28;
|
||||
const OVERSIZED_H1_MIN_VIEWPORT_AREA_RATIO = 0.25;
|
||||
function checkOversizedH1({ tag, fontSize, headingText, rect = null, viewportWidth = 0, viewportHeight = 0 }) {
|
||||
if (tag !== 'h1') return [];
|
||||
const textLen = headingText.length;
|
||||
if (fontSize >= OVERSIZED_H1_FONT_PX && textLen >= OVERSIZED_H1_MIN_CHARS) {
|
||||
return [{ id: 'oversized-h1', snippet: `${Math.round(fontSize)}px h1, ${textLen} chars "${headingText.slice(0, 60)}"` }];
|
||||
let viewportDetail = '';
|
||||
if (rect && viewportWidth > 0 && viewportHeight > 0) {
|
||||
const heightRatio = rect.height / viewportHeight;
|
||||
const areaRatio = (rect.width * rect.height) / (viewportWidth * viewportHeight);
|
||||
const dominatesViewport = heightRatio >= OVERSIZED_H1_MIN_VIEWPORT_HEIGHT_RATIO
|
||||
|| areaRatio >= OVERSIZED_H1_MIN_VIEWPORT_AREA_RATIO;
|
||||
if (!dominatesViewport) return [];
|
||||
viewportDetail = `, ${Math.round(heightRatio * 100)}vh`;
|
||||
}
|
||||
return [{ id: 'oversized-h1', snippet: `${Math.round(fontSize)}px h1, ${textLen} chars${viewportDetail} "${headingText.slice(0, 60)}"` }];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
@@ -2705,31 +2829,54 @@ function checkElementOversizedH1DOM(el) {
|
||||
const style = getComputedStyle(el);
|
||||
const fontSize = parseFloat(style.fontSize) || 0;
|
||||
const headingText = (el.textContent || '').trim().replace(/\s+/g, ' ');
|
||||
return checkOversizedH1({ tag, fontSize, headingText });
|
||||
const rect = el.getBoundingClientRect();
|
||||
const viewportWidth = (typeof window !== 'undefined' ? window.innerWidth : 0) || 0;
|
||||
const viewportHeight = (typeof window !== 'undefined' ? window.innerHeight : 0) || 0;
|
||||
return checkOversizedH1({ tag, fontSize, headingText, rect, viewportWidth, viewportHeight });
|
||||
}
|
||||
|
||||
// ─── GPT tell: hairline border + wide diffuse shadow (gated --gpt) ────────────
|
||||
function shadowMaxBlurPx(boxShadow) {
|
||||
const CSS_COLOR_TOKEN_RE = /(?:rgba?|hsla?|oklch|oklab|lab|lch|color)\([^)]*\)|#[0-9a-fA-F]{3,8}\b|\b(?:black|white|transparent|currentcolor)\b/gi;
|
||||
|
||||
function shadowLayerAlpha(layer) {
|
||||
CSS_COLOR_TOKEN_RE.lastIndex = 0;
|
||||
const match = CSS_COLOR_TOKEN_RE.exec(layer);
|
||||
if (!match) return 1;
|
||||
if (match[0].toLowerCase() === 'transparent') return 0;
|
||||
const parsed = parseAnyColor(match[0]);
|
||||
return parsed ? (parsed.a ?? 1) : 1;
|
||||
}
|
||||
|
||||
function shadowMaxBlurPx(boxShadow, { minAlpha = 0 } = {}) {
|
||||
if (!boxShadow || boxShadow === 'none') return 0;
|
||||
let maxBlur = 0;
|
||||
// Split into layers on commas not inside parentheses (rgba(...) etc.).
|
||||
for (const layer of boxShadow.split(/,(?![^()]*\))/)) {
|
||||
if (shadowLayerAlpha(layer) < minAlpha) continue;
|
||||
// Strip colors and keywords (rgba()/hsl()/hex/named/inset/px), leaving the
|
||||
// ordered length tokens: offsetX offsetY blur [spread]. Static jsdom keeps
|
||||
// unitless zeros ("0 0 24px"); browsers normalize to px ("0px 0px 24px") —
|
||||
// both reduce to the same numbers here.
|
||||
const cleaned = layer.replace(/rgba?\([^)]*\)|hsla?\([^)]*\)|#[0-9a-f]+|\b[a-z]+\b/gi, ' ');
|
||||
const cleaned = layer.replace(CSS_COLOR_TOKEN_RE, ' ').replace(/\b[a-z]+\b/gi, ' ');
|
||||
const nums = [...cleaned.matchAll(/-?\d*\.?\d+/g)].map(m => parseFloat(m[0]));
|
||||
if (nums.length >= 3) maxBlur = Math.max(maxBlur, nums[2]);
|
||||
}
|
||||
return maxBlur;
|
||||
}
|
||||
|
||||
function checkGptThinBorderWideShadow({ borderWidths, boxShadow }) {
|
||||
const maxBorder = Math.max(0, ...borderWidths);
|
||||
const hasThinBorder = maxBorder > 0 && maxBorder <= 1.5;
|
||||
const blur = shadowMaxBlurPx(boxShadow);
|
||||
if (hasThinBorder && blur >= 16) {
|
||||
function cssColorAlpha(value) {
|
||||
if (cssColorIsTransparent(value)) return 0;
|
||||
const parsed = parseAnyColor(value);
|
||||
return parsed ? (parsed.a ?? 1) : 1;
|
||||
}
|
||||
|
||||
function checkGptThinBorderWideShadow({ borderWidths, borderColors, boxShadow }) {
|
||||
const visibleThinBorders = borderWidths
|
||||
.map((width, index) => ({ width, alpha: cssColorAlpha(borderColors?.[index] || '') }))
|
||||
.filter(({ width, alpha }) => width > 0 && width <= 1.5 && alpha >= 0.28);
|
||||
const maxBorder = Math.max(0, ...visibleThinBorders.map(({ width }) => width));
|
||||
const blur = shadowMaxBlurPx(boxShadow, { minAlpha: 0.12 });
|
||||
if (visibleThinBorders.length >= 2 && blur >= 16) {
|
||||
return [{ id: 'gpt-thin-border-wide-shadow', snippet: `${maxBorder}px border + ${Math.round(blur)}px shadow blur` }];
|
||||
}
|
||||
return [];
|
||||
@@ -2744,13 +2891,22 @@ function borderWidthsFromStyle(style) {
|
||||
];
|
||||
}
|
||||
|
||||
function borderColorsFromStyle(style) {
|
||||
return [
|
||||
style.borderTopColor || '',
|
||||
style.borderRightColor || '',
|
||||
style.borderBottomColor || '',
|
||||
style.borderLeftColor || '',
|
||||
];
|
||||
}
|
||||
|
||||
function checkElementGptBorderShadow(el, style) {
|
||||
return checkGptThinBorderWideShadow({ borderWidths: borderWidthsFromStyle(style), boxShadow: style.boxShadow || '' });
|
||||
return checkGptThinBorderWideShadow({ borderWidths: borderWidthsFromStyle(style), borderColors: borderColorsFromStyle(style), boxShadow: style.boxShadow || '' });
|
||||
}
|
||||
|
||||
function checkElementGptBorderShadowDOM(el) {
|
||||
const style = getComputedStyle(el);
|
||||
return checkGptThinBorderWideShadow({ borderWidths: borderWidthsFromStyle(style), boxShadow: style.boxShadow || '' });
|
||||
return checkGptThinBorderWideShadow({ borderWidths: borderWidthsFromStyle(style), borderColors: borderColorsFromStyle(style), boxShadow: style.boxShadow || '' });
|
||||
}
|
||||
|
||||
// ─── Clipped overflow container ───────────────────────────────────────────────
|
||||
@@ -2763,17 +2919,131 @@ function classSelector(el) {
|
||||
return tokens.length ? `${tag}.${tokens.join('.')}` : tag;
|
||||
}
|
||||
|
||||
function positionedChildIsDecorative(child) {
|
||||
if (!child || typeof child.getAttribute !== 'function') return false;
|
||||
if (child.closest?.('[aria-hidden="true"]')) return true;
|
||||
const role = (child.getAttribute('role') || '').toLowerCase();
|
||||
if (role === 'none' || role === 'presentation') return true;
|
||||
const tag = child.tagName ? child.tagName.toLowerCase() : '';
|
||||
if (['img', 'svg', 'canvas', 'video'].includes(tag)) return true;
|
||||
const ident = `${child.getAttribute('class') || ''} ${child.getAttribute('id') || ''}`;
|
||||
if (
|
||||
/\b(art|bg|background|badge|blob|crop|decor|dot|glow|grain|image|mask|ornament|overlay|photo|scrim|shadow|shine|texture)\b/i.test(ident) &&
|
||||
!positionedChildHasSubstantiveContent(child)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const POSITIONED_CHILD_INTERACTIVE_SELECTOR = [
|
||||
'a[href]',
|
||||
'button',
|
||||
'input',
|
||||
'select',
|
||||
'summary',
|
||||
'textarea',
|
||||
'[tabindex]:not([tabindex="-1"])',
|
||||
'[role="button"]',
|
||||
'[role="dialog"]',
|
||||
'[role="link"]',
|
||||
'[role="listbox"]',
|
||||
'[role="menu"]',
|
||||
'[role="menuitem"]',
|
||||
'[role="option"]',
|
||||
'[role="tooltip"]',
|
||||
].join(',');
|
||||
|
||||
function positionedChildHasSubstantiveContent(child) {
|
||||
const text = (child.textContent || '').replace(/\s+/g, ' ').trim();
|
||||
if (text.length > 0) return true;
|
||||
if (typeof child.matches === 'function') {
|
||||
try {
|
||||
if (child.matches(POSITIONED_CHILD_INTERACTIVE_SELECTOR)) return true;
|
||||
} catch {}
|
||||
}
|
||||
if (typeof child.querySelector === 'function') {
|
||||
try {
|
||||
if (child.querySelector(POSITIONED_CHILD_INTERACTIVE_SELECTOR)) return true;
|
||||
} catch {}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function clippingContainerIsIntentionalViewport(el) {
|
||||
if (!el || typeof el.getAttribute !== 'function') return false;
|
||||
const roleDescription = (el.getAttribute('aria-roledescription') || '').toLowerCase();
|
||||
if (/\b(carousel|slider)\b/.test(roleDescription)) return true;
|
||||
const ident = `${el.getAttribute('class') || ''} ${el.getAttribute('id') || ''}`.toLowerCase();
|
||||
return /\b(carousel|comparison|compare|fisheye|marquee|preview|scroller|slider|slideshow|split|viewport)\b/.test(ident) ||
|
||||
/\b(demo-area|demo-stage|demo-viewport)\b/.test(ident);
|
||||
}
|
||||
|
||||
function elementRect(el) {
|
||||
if (!el || typeof el.getBoundingClientRect !== 'function') return null;
|
||||
try {
|
||||
const rect = el.getBoundingClientRect();
|
||||
if (!rect) return null;
|
||||
const values = [rect.top, rect.right, rect.bottom, rect.left, rect.width, rect.height];
|
||||
if (!values.every(Number.isFinite)) return null;
|
||||
if (rect.width <= 0 && rect.height <= 0) return null;
|
||||
return rect;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function positionedStyleImpliesEscape(style) {
|
||||
const values = [
|
||||
style.top,
|
||||
style.right,
|
||||
style.bottom,
|
||||
style.left,
|
||||
style.inset,
|
||||
style.insetBlock,
|
||||
style.insetInline,
|
||||
style.insetBlockStart,
|
||||
style.insetBlockEnd,
|
||||
style.insetInlineStart,
|
||||
style.insetInlineEnd,
|
||||
].filter(Boolean).map(value => String(value).trim().toLowerCase());
|
||||
for (const value of values) {
|
||||
if (/(^|[\s(])-+(?:\d|\.)/.test(value)) return true;
|
||||
if (/(^|[\s(])100(?:\.0+)?%/.test(value)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function positionedChildEscapesClip(el, child, clipX, clipY) {
|
||||
const parentRect = elementRect(el);
|
||||
const childRect = elementRect(child);
|
||||
if (!parentRect || !childRect) return null;
|
||||
const threshold = 2;
|
||||
return Boolean(
|
||||
(clipX && (childRect.left < parentRect.left - threshold || childRect.right > parentRect.right + threshold)) ||
|
||||
(clipY && (childRect.top < parentRect.top - threshold || childRect.bottom > parentRect.bottom + threshold))
|
||||
);
|
||||
}
|
||||
|
||||
function checkClippedOverflow(el, style, getStyle) {
|
||||
const clips = (v) => v === 'hidden' || v === 'clip';
|
||||
const scrolls = (v) => v === 'auto' || v === 'scroll';
|
||||
const ox = style.overflowX || '', oy = style.overflowY || '', ov = style.overflow || '';
|
||||
const anyClip = clips(ox) || clips(oy) || clips(ov);
|
||||
const clipX = clips(ox) || clips(ov);
|
||||
const clipY = clips(oy) || clips(ov);
|
||||
const anyClip = clipX || clipY;
|
||||
const anyScroll = scrolls(ox) || scrolls(oy) || scrolls(ov);
|
||||
if (!anyClip || anyScroll) return [];
|
||||
if (clippingContainerIsIntentionalViewport(el)) return [];
|
||||
if (!el.querySelectorAll) return [];
|
||||
for (const child of el.querySelectorAll('*')) {
|
||||
const pos = (getStyle(child).position) || '';
|
||||
const childStyle = getStyle(child);
|
||||
const pos = childStyle.position || '';
|
||||
if (pos === 'absolute' || pos === 'fixed') {
|
||||
if (positionedChildIsDecorative(child)) continue;
|
||||
const escapes = positionedChildEscapesClip(el, child, clipX, clipY);
|
||||
if (escapes === false) continue;
|
||||
if (escapes === null && !positionedStyleImpliesEscape(childStyle)) continue;
|
||||
return [{ id: 'clipped-overflow-container', snippet: `${classSelector(el)} clips a positioned child` }];
|
||||
}
|
||||
}
|
||||
@@ -2792,14 +3062,94 @@ function checkElementClippedOverflowDOM(el) {
|
||||
// ─── Text overflow (browser-only: needs scrollWidth/clientWidth) ──────────────
|
||||
const TEXT_OVERFLOW_SKIP_TAGS = new Set(['pre', 'code', 'textarea', 'svg', 'canvas', 'select', 'option', 'marquee']);
|
||||
|
||||
function metricLengthPx(value, fontSizePx = 16) {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) return value;
|
||||
if (typeof value !== 'string') return null;
|
||||
return resolveLengthPx(value, fontSizePx);
|
||||
}
|
||||
|
||||
function firstMetricLengthPx(fontSizePx, ...values) {
|
||||
for (const value of values) {
|
||||
const parsed = metricLengthPx(value, fontSizePx);
|
||||
if (parsed !== null) return parsed;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function expandBoxShorthand(parts) {
|
||||
if (parts.length === 1) return [parts[0], parts[0], parts[0], parts[0]];
|
||||
if (parts.length === 2) return [parts[0], parts[1], parts[0], parts[1]];
|
||||
if (parts.length === 3) return [parts[0], parts[1], parts[2], parts[1]];
|
||||
return [parts[0], parts[1], parts[2], parts[3]];
|
||||
}
|
||||
|
||||
function clippedByInset(clipPath) {
|
||||
const match = String(clipPath || '').trim().toLowerCase().match(/^inset\s*\(([^)]*)\)$/);
|
||||
if (!match) return false;
|
||||
const beforeRound = match[1].split(/\s+round\s+/)[0].trim();
|
||||
if (!beforeRound) return false;
|
||||
const values = expandBoxShorthand(beforeRound.split(/\s+/).slice(0, 4));
|
||||
const percents = values.map(value => String(value).trim().match(/^(-?\d+(?:\.\d+)?)%$/));
|
||||
if (percents.some(match => !match)) return false;
|
||||
const [top, right, bottom, left] = percents.map(match => parseFloat(match[1]));
|
||||
return top + bottom >= 100 || left + right >= 100;
|
||||
}
|
||||
|
||||
function clippedByRect(clip) {
|
||||
const match = String(clip || '').trim().toLowerCase().match(/^rect\s*\(([^)]*)\)$/);
|
||||
if (!match) return false;
|
||||
const values = match[1].split(/[,\s]+/).map(value => value.trim()).filter(Boolean);
|
||||
if (values.length !== 4) return false;
|
||||
const [top, right, bottom, left] = values.map(value => metricLengthPx(value, 16));
|
||||
if ([top, right, bottom, left].some(value => value === null)) return false;
|
||||
return bottom <= top || right <= left;
|
||||
}
|
||||
|
||||
function isScreenReaderOnlyTextStyle(style, metrics = {}) {
|
||||
if (!style) return false;
|
||||
const overflowValues = [style.overflow, style.overflowX, style.overflowY]
|
||||
.map(value => String(value || '').toLowerCase());
|
||||
const clipsOverflow = overflowValues.some(value => value === 'hidden' || value === 'clip');
|
||||
|
||||
const fontSize = metricLengthPx(style.fontSize, 16) || 16;
|
||||
const width = firstMetricLengthPx(fontSize, metrics.width, metrics.clientWidth, style.width, style.inlineSize);
|
||||
const height = firstMetricLengthPx(fontSize, metrics.height, metrics.clientHeight, style.height, style.blockSize);
|
||||
const isTiny = width !== null && height !== null && width <= 2 && height <= 2;
|
||||
const isAbsolutelyHidden = String(style.position || '').toLowerCase() === 'absolute' && isTiny && clipsOverflow;
|
||||
|
||||
const clipPath = String(style.clipPath || style.webkitClipPath || '').trim();
|
||||
const clip = String(style.clip || '').trim();
|
||||
return isAbsolutelyHidden || clippedByInset(clipPath) || clippedByRect(clip);
|
||||
}
|
||||
|
||||
function isRenderedForBrowserRule(el) {
|
||||
for (let cur = el; cur && cur.nodeType === 1; cur = cur.parentElement) {
|
||||
if (cur.getAttribute?.('aria-hidden') === 'true') return false;
|
||||
const style = getComputedStyle(cur);
|
||||
const visibility = String(style.visibility || '').toLowerCase();
|
||||
if (style.display === 'none' || visibility === 'hidden' || visibility === 'collapse') return false;
|
||||
if ((parseFloat(style.opacity) || 0) <= 0.01) return false;
|
||||
if (String(style.contentVisibility || '').toLowerCase() === 'hidden') return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function checkElementTextOverflowDOM(el) {
|
||||
const tag = el.tagName.toLowerCase();
|
||||
if (TEXT_OVERFLOW_SKIP_TAGS.has(tag)) return [];
|
||||
if (!isRenderedForBrowserRule(el)) return [];
|
||||
// Only the element that actually owns overflowing text — not its ancestors,
|
||||
// which inherit a wider scrollWidth from the spilling descendant.
|
||||
const hasDirectText = [...el.childNodes].some(n => n.nodeType === 3 && n.textContent.trim().length > 0);
|
||||
if (!hasDirectText) return [];
|
||||
const style = getComputedStyle(el);
|
||||
const rect = el.getBoundingClientRect ? el.getBoundingClientRect() : null;
|
||||
if (isScreenReaderOnlyTextStyle(style, {
|
||||
width: rect?.width,
|
||||
height: rect?.height,
|
||||
clientWidth: el.clientWidth,
|
||||
clientHeight: el.clientHeight,
|
||||
})) return [];
|
||||
const isScrollRegion = (s) => /(auto|scroll)/.test(s.overflowX || '') || /(auto|scroll)/.test(s.overflow || '');
|
||||
if (isScrollRegion(style)) return [];
|
||||
// A scrollable ancestor means this overflow is intentional and scrollable.
|
||||
@@ -3476,6 +3826,7 @@ if (IS_BROWSER) {
|
||||
if (el.closest('.impeccable-overlay, .impeccable-label, .impeccable-banner, .impeccable-tooltip')) continue;
|
||||
if (el.closest('[id^="impeccable-live-"]')) continue;
|
||||
if (el === document.body || el === document.documentElement) continue;
|
||||
if (!isRenderedForBrowserRule(el)) continue;
|
||||
|
||||
const tag = el.tagName.toLowerCase();
|
||||
const style = getComputedStyle(el);
|
||||
@@ -3907,6 +4258,7 @@ if (IS_BROWSER) {
|
||||
return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'stale selector' };
|
||||
}
|
||||
if (!el) return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'missing element' };
|
||||
if (!isRenderedForBrowserRule(el)) return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'hidden element' };
|
||||
|
||||
const blockingReason = (candidate.reasons || []).find(reason =>
|
||||
reason === 'background-clip text' ||
|
||||
@@ -4253,13 +4605,20 @@ if (IS_BROWSER) {
|
||||
return true;
|
||||
}
|
||||
|
||||
function postSerializedFindings(groupMap) {
|
||||
function scanResultMeta(options = {}) {
|
||||
const scanId = options.scanId;
|
||||
if (typeof scanId !== 'string' && typeof scanId !== 'number') return {};
|
||||
return { scanId: String(scanId) };
|
||||
}
|
||||
|
||||
function postSerializedFindings(groupMap, options = {}) {
|
||||
if (!EXTENSION_MODE) return;
|
||||
const allFindings = browserFindingsFromMap(groupMap);
|
||||
window.postMessage({
|
||||
source: 'impeccable-results',
|
||||
findings: serializeFindings(allFindings),
|
||||
count: allFindings.length,
|
||||
...scanResultMeta(options),
|
||||
}, '*');
|
||||
}
|
||||
|
||||
@@ -4313,7 +4672,7 @@ if (IS_BROWSER) {
|
||||
rememberVisualContrastAnalysis(result);
|
||||
const added = addVisualContrastResult(groupMap, result, { decorate: true });
|
||||
if (added) {
|
||||
postSerializedFindings(groupMap);
|
||||
postSerializedFindings(groupMap, options);
|
||||
window.dispatchEvent(new CustomEvent('impeccable-visual-contrast-resolved', {
|
||||
detail: {
|
||||
selector: result.selector,
|
||||
@@ -4381,7 +4740,7 @@ if (IS_BROWSER) {
|
||||
overlayIndex = 0;
|
||||
}
|
||||
|
||||
function renderBrowserFindings(collected) {
|
||||
function renderBrowserFindings(collected, options = {}) {
|
||||
const { allFindings, pageLevelFindings } = collected;
|
||||
|
||||
for (const { el, findings } of allFindings) {
|
||||
@@ -4401,6 +4760,7 @@ if (IS_BROWSER) {
|
||||
source: 'impeccable-results',
|
||||
findings: serializeFindings(allFindings),
|
||||
count: allFindings.length,
|
||||
...scanResultMeta(options),
|
||||
}, '*');
|
||||
}
|
||||
|
||||
@@ -4415,11 +4775,11 @@ if (IS_BROWSER) {
|
||||
clearOverlays();
|
||||
const generation = scanGeneration;
|
||||
const collected = collectBrowserFindings();
|
||||
const allFindings = renderBrowserFindings(collected);
|
||||
const allFindings = renderBrowserFindings(collected, options);
|
||||
if (shouldRunVisualContrast(options)) {
|
||||
addVisualContrastFindings(collected.groupMap, options, { decorate: true, generation })
|
||||
.then(() => {
|
||||
if (generation === scanGeneration) postSerializedFindings(collected.groupMap);
|
||||
if (generation === scanGeneration) postSerializedFindings(collected.groupMap, options);
|
||||
})
|
||||
.catch(err => {
|
||||
reportVisualContrastError(err);
|
||||
@@ -4434,10 +4794,10 @@ if (IS_BROWSER) {
|
||||
if (shouldRunVisualContrast(options)) {
|
||||
const collected = await collectBrowserFindingsAsync(options, { generation, scheduleLazy: true });
|
||||
if (generation !== scanGeneration) return [];
|
||||
return renderBrowserFindings(collected);
|
||||
return renderBrowserFindings(collected, options);
|
||||
}
|
||||
lastVisualContrastAnalyses = [];
|
||||
return renderBrowserFindings(collectBrowserFindings());
|
||||
return renderBrowserFindings(collectBrowserFindings(), options);
|
||||
};
|
||||
|
||||
const detect = function(options = {}) {
|
||||
|
||||
@@ -23,6 +23,18 @@ function stripHtmlToText(html) {
|
||||
.replace(/\s+/g, ' ');
|
||||
}
|
||||
|
||||
const PAGE_ANALYZER_EXTS = new Set(['.html', '.htm', '.astro', '.vue', '.svelte']);
|
||||
|
||||
function extFromFilePath(filePath) {
|
||||
return filePath ? (filePath.match(/\.\w+$/)?.[0] || '').toLowerCase() : '';
|
||||
}
|
||||
|
||||
function shouldRunPageAnalyzers(content, filePath) {
|
||||
if (!isFullPage(content)) return false;
|
||||
const ext = extFromFilePath(filePath);
|
||||
return !ext || PAGE_ANALYZER_EXTS.has(ext);
|
||||
}
|
||||
|
||||
function isNeutralBorderColor(str) {
|
||||
const m = str.match(/solid\s+(#[0-9a-f]{3,8}|rgba?\([^)]+\)|\w+)/i);
|
||||
if (!m) return false;
|
||||
@@ -422,7 +434,7 @@ const TEXT_CONTENT_ANALYZER_IDS = [
|
||||
|
||||
function runTextContentAnalyzers(content, filePath, options = {}) {
|
||||
const profile = options?.profile;
|
||||
if (!isFullPage(content)) return [];
|
||||
if (!shouldRunPageAnalyzers(content, filePath)) return [];
|
||||
// The 4 text-content analyzers are at indices 3-6 in REGEX_ANALYZERS.
|
||||
const findings = [];
|
||||
for (let i = 0; i < TEXT_CONTENT_ANALYZER_IDS.length; i++) {
|
||||
@@ -442,11 +454,11 @@ function detectText(content, filePath, options = {}) {
|
||||
const profile = options?.profile;
|
||||
const findings = [];
|
||||
const lines = content.split('\n');
|
||||
const ext = filePath ? (filePath.match(/\.\w+$/)?.[0] || '').toLowerCase() : '';
|
||||
const ext = extFromFilePath(filePath);
|
||||
|
||||
// Run regex matchers on the full file content (catches Tailwind classes, inline styles)
|
||||
// Enable block context for CSS files where related properties span multiple lines
|
||||
const cssLike = new Set(['.css', '.scss', '.less']);
|
||||
const cssLike = new Set(['.css', '.scss', '.sass', '.less']);
|
||||
findings.push(...runRegexMatchers(lines, filePath, 0, cssLike.has(ext) || null, {
|
||||
profile,
|
||||
phase: 'source',
|
||||
@@ -498,7 +510,7 @@ function detectText(content, filePath, options = {}) {
|
||||
}
|
||||
|
||||
// Page-level analyzers only run on full pages
|
||||
if (isFullPage(content)) {
|
||||
if (shouldRunPageAnalyzers(content, filePath)) {
|
||||
const analyzerIds = [
|
||||
'single-font',
|
||||
'flat-type-hierarchy',
|
||||
|
||||
@@ -267,7 +267,16 @@ const STATIC_DEFAULT_STYLE = {
|
||||
paddingRight: '0px',
|
||||
paddingBottom: '0px',
|
||||
paddingLeft: '0px',
|
||||
marginTop: '0px',
|
||||
marginRight: '0px',
|
||||
marginBottom: '0px',
|
||||
marginLeft: '0px',
|
||||
position: 'static',
|
||||
top: 'auto',
|
||||
right: 'auto',
|
||||
bottom: 'auto',
|
||||
left: 'auto',
|
||||
inset: '',
|
||||
display: '',
|
||||
overflow: 'visible',
|
||||
overflowX: 'visible',
|
||||
@@ -312,7 +321,16 @@ const STATIC_PROP_MAP = {
|
||||
'padding-right': 'paddingRight',
|
||||
'padding-bottom': 'paddingBottom',
|
||||
'padding-left': 'paddingLeft',
|
||||
'margin-top': 'marginTop',
|
||||
'margin-right': 'marginRight',
|
||||
'margin-bottom': 'marginBottom',
|
||||
'margin-left': 'marginLeft',
|
||||
'position': 'position',
|
||||
'top': 'top',
|
||||
'right': 'right',
|
||||
'bottom': 'bottom',
|
||||
'left': 'left',
|
||||
'inset': 'inset',
|
||||
'display': 'display',
|
||||
'overflow': 'overflow',
|
||||
'overflow-x': 'overflowX',
|
||||
@@ -579,6 +597,15 @@ function expandStaticDeclaration(prop, value) {
|
||||
['paddingLeft', vals[3]],
|
||||
];
|
||||
}
|
||||
if (p === 'margin') {
|
||||
const vals = expandStaticBoxValues(splitCssTokens(v));
|
||||
return [
|
||||
['marginTop', vals[0]],
|
||||
['marginRight', vals[1]],
|
||||
['marginBottom', vals[2]],
|
||||
['marginLeft', vals[3]],
|
||||
];
|
||||
}
|
||||
if (p === 'font') return parseStaticFont(v);
|
||||
if (p === 'transition') {
|
||||
const parsed = parseStaticTransition(v);
|
||||
|
||||
@@ -11,7 +11,7 @@ const SKIP_DIRS = new Set([
|
||||
]);
|
||||
|
||||
const SCANNABLE_EXTENSIONS = new Set([
|
||||
'.html', '.htm', '.css', '.scss', '.less',
|
||||
'.html', '.htm', '.css', '.scss', '.sass', '.less',
|
||||
'.jsx', '.tsx', '.js', '.ts',
|
||||
'.vue', '.svelte', '.astro',
|
||||
]);
|
||||
|
||||
@@ -974,11 +974,16 @@ function parseAnyColor(s) {
|
||||
// OKLCH parser. Tailwind v4's CSS minifier squishes the space after
|
||||
// `%` ("21.5%.02 50"), so the separator between L and C may be absent.
|
||||
// Match L (with optional %), then C and H separated permissively.
|
||||
m = str.match(/oklch\(\s*([\d.]+)(%?)\s*[\s,]*\s*([\d.]+)\s*[\s,]+\s*([-\d.]+)(?:deg)?\s*\)/i);
|
||||
m = str.match(/oklch\(\s*([\d.]+)(%?)\s*[\s,]*\s*([\d.]+)\s*[\s,]+\s*([-\d.]+)(?:deg)?(?:\s*\/\s*([\d.]+)(%)?)?\s*\)/i);
|
||||
if (m) {
|
||||
const Lnum = parseFloat(m[1]);
|
||||
const L = m[2] === '%' ? Lnum / 100 : Lnum;
|
||||
return oklchToRgb(L, parseFloat(m[3]), parseFloat(m[4]));
|
||||
const rgb = oklchToRgb(L, parseFloat(m[3]), parseFloat(m[4]));
|
||||
if (m[5] !== undefined) {
|
||||
const alpha = parseFloat(m[5]);
|
||||
rgb.a = m[6] === '%' ? alpha / 100 : alpha;
|
||||
}
|
||||
return rgb;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -1007,9 +1012,19 @@ const REPEATED_KICKER_SKIP_SELECTOR = [
|
||||
'[role="navigation"]',
|
||||
'[aria-label*="breadcrumb" i]',
|
||||
'[class*="breadcrumb" i]',
|
||||
'[aria-hidden="true"]',
|
||||
'[data-impeccable-allow-kickers]',
|
||||
].join(',');
|
||||
|
||||
const REPEATED_KICKER_CARD_CONTEXT_SELECTOR = [
|
||||
'article',
|
||||
'button',
|
||||
'a',
|
||||
'li',
|
||||
'[role="listitem"]',
|
||||
'[role="option"]',
|
||||
].join(',');
|
||||
|
||||
function cleanInlineText(el) {
|
||||
return [...el.childNodes]
|
||||
.filter(n => n.nodeType === 3)
|
||||
@@ -1019,6 +1034,11 @@ function cleanInlineText(el) {
|
||||
.trim();
|
||||
}
|
||||
|
||||
function isRepeatedKickerCardContext(heading, kicker) {
|
||||
const item = heading.closest?.(REPEATED_KICKER_CARD_CONTEXT_SELECTOR);
|
||||
return Boolean(item && (!item.contains || item.contains(kicker)));
|
||||
}
|
||||
|
||||
function isRepeatedKickerCandidate(opts) {
|
||||
const {
|
||||
headingTag,
|
||||
@@ -1032,6 +1052,7 @@ function isRepeatedKickerCandidate(opts) {
|
||||
} = opts;
|
||||
if (!['h2', 'h3', 'h4'].includes(headingTag)) return false;
|
||||
if (!headingText || headingText.length < 3) return false;
|
||||
if (/^\/[\w-]+/i.test(headingText.replace(/^"|"$/g, '').trim())) return false;
|
||||
if (!(headingFontSize >= 20)) return false;
|
||||
if (!kickerTag || HEADING_TAGS.has(kickerTag)) return false;
|
||||
if (!['p', 'span', 'div', 'small'].includes(kickerTag)) return false;
|
||||
@@ -1053,6 +1074,7 @@ function collectRepeatedSectionKickerCandidates(doc, getStyle, resolveLetterSpac
|
||||
if (heading.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue;
|
||||
const kicker = heading.previousElementSibling;
|
||||
if (!kicker || kicker.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue;
|
||||
if (isRepeatedKickerCardContext(heading, kicker)) continue;
|
||||
|
||||
const headingStyle = getStyle(heading);
|
||||
const kickerStyle = getStyle(kicker);
|
||||
@@ -1235,6 +1257,84 @@ function resolveLengthPx(value, fontSizePx) {
|
||||
return num * fontSizePx;
|
||||
}
|
||||
|
||||
function cssColorIsTransparent(value) {
|
||||
if (!value) return true;
|
||||
const str = String(value).trim().toLowerCase();
|
||||
if (!str || str === 'transparent' || str === 'rgba(0, 0, 0, 0)') return true;
|
||||
const parsed = parseAnyColor(str);
|
||||
if (parsed) return (parsed.a ?? 1) <= 0.05;
|
||||
return /^rgba\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*,\s*0(?:\.0+)?\s*\)$/.test(str);
|
||||
}
|
||||
|
||||
function colorsNearlyMatch(a, b) {
|
||||
const ca = parseAnyColor(a);
|
||||
const cb = parseAnyColor(b);
|
||||
if (!ca || !cb) return false;
|
||||
const alphaDelta = Math.abs((ca.a ?? 1) - (cb.a ?? 1));
|
||||
const channelDelta = Math.max(
|
||||
Math.abs(ca.r - cb.r),
|
||||
Math.abs(ca.g - cb.g),
|
||||
Math.abs(ca.b - cb.b),
|
||||
);
|
||||
return alphaDelta <= 0.03 && channelDelta <= 3;
|
||||
}
|
||||
|
||||
function getComputedStyleFor(win, el) {
|
||||
if (win && typeof win.getComputedStyle === 'function') {
|
||||
try { return win.getComputedStyle(el); } catch {}
|
||||
}
|
||||
if (typeof getComputedStyle === 'function') {
|
||||
try { return getComputedStyle(el); } catch {}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function hasVisibleBackgroundBoundary(style, el, win) {
|
||||
const bg = style?.backgroundColor || '';
|
||||
if (cssColorIsTransparent(bg)) return false;
|
||||
|
||||
let parent = el?.parentElement || null;
|
||||
while (parent) {
|
||||
const parentStyle = getComputedStyleFor(win, parent);
|
||||
const parentBg = parentStyle?.backgroundColor || '';
|
||||
if (!cssColorIsTransparent(parentBg)) {
|
||||
return !colorsNearlyMatch(bg, parentBg);
|
||||
}
|
||||
parent = parent.parentElement;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
const TEXT_EDGE_TAGS = new Set(['A', 'BUTTON', 'CODE', 'DD', 'DT', 'FIGCAPTION', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'LI', 'P', 'PRE', 'SPAN', 'TD', 'TH']);
|
||||
|
||||
function hasMeaningfulDirectText(node) {
|
||||
if (!node?.childNodes) return false;
|
||||
for (const child of node.childNodes) {
|
||||
if (child.nodeType === 3 && child.textContent.trim().length > 4) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function textDescendantsFlushSides(el, rect) {
|
||||
const flush = { top: false, right: false, bottom: false, left: false };
|
||||
if (!rect || !el?.querySelectorAll) return flush;
|
||||
const TEXT_EDGE_THRESHOLD = 4;
|
||||
const candidates = el.querySelectorAll('a, button, code, dd, dt, figcaption, h1, h2, h3, h4, h5, h6, li, p, pre, span, td, th');
|
||||
for (const node of candidates) {
|
||||
if (!TEXT_EDGE_TAGS.has(node.tagName) || !hasMeaningfulDirectText(node)) continue;
|
||||
let nodeRect = null;
|
||||
try { nodeRect = node.getBoundingClientRect(); } catch {}
|
||||
if (!nodeRect || nodeRect.width <= 0 || nodeRect.height <= 0) continue;
|
||||
if (nodeRect.bottom < rect.top || nodeRect.top > rect.bottom || nodeRect.right < rect.left || nodeRect.left > rect.right) continue;
|
||||
if (nodeRect.top - rect.top <= TEXT_EDGE_THRESHOLD) flush.top = true;
|
||||
if (rect.right - nodeRect.right <= TEXT_EDGE_THRESHOLD) flush.right = true;
|
||||
if (rect.bottom - nodeRect.bottom <= TEXT_EDGE_THRESHOLD) flush.bottom = true;
|
||||
if (nodeRect.left - rect.left <= TEXT_EDGE_THRESHOLD) flush.left = true;
|
||||
}
|
||||
return flush;
|
||||
}
|
||||
|
||||
// Pure quality checks. Most run on computed CSS and DOM-only inputs (work in
|
||||
// jsdom and the browser). Two checks (line-length, cramped-padding) gate on
|
||||
// element rect dimensions, which jsdom can't compute — pass `rect: null` from
|
||||
@@ -1264,7 +1364,8 @@ function checkQuality(opts) {
|
||||
// font-size — bigger text demands proportionally more padding.
|
||||
// vertical: max(4px, fontSize × 0.3)
|
||||
// horizontal: max(8px, fontSize × 0.5)
|
||||
if (rect && hasDirectText && textLen > 20 && rect.width > 100 && rect.height > 30) {
|
||||
const isInlineCode = tag === 'code' && !(el.closest && el.closest('pre'));
|
||||
if (!isInlineCode && rect && hasDirectText && textLen > 20 && rect.width > 100 && rect.height > 30) {
|
||||
const borders = {
|
||||
top: parseFloat(style.borderTopWidth) || 0,
|
||||
right: parseFloat(style.borderRightWidth) || 0,
|
||||
@@ -1272,7 +1373,7 @@ function checkQuality(opts) {
|
||||
left: parseFloat(style.borderLeftWidth) || 0,
|
||||
};
|
||||
const borderCount = Object.values(borders).filter(w => w > 0).length;
|
||||
const hasBg = style.backgroundColor && style.backgroundColor !== 'rgba(0, 0, 0, 0)';
|
||||
const hasBg = hasVisibleBackgroundBoundary(style, el, win);
|
||||
if (borderCount >= 2 || hasBg) {
|
||||
const vPads = [], hPads = [];
|
||||
if (hasBg || borders.top > 0) vPads.push(parseFloat(style.paddingTop) || 0);
|
||||
@@ -1320,10 +1421,6 @@ function checkQuality(opts) {
|
||||
!['fixed', 'absolute'].includes(elPosition) &&
|
||||
el.children && el.children.length > 0
|
||||
) {
|
||||
const isTransparent = (c) =>
|
||||
!c || c === 'transparent' || c === 'rgba(0, 0, 0, 0)' ||
|
||||
/^rgba\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*,\s*0(?:\.0+)?\s*\)$/.test(c);
|
||||
|
||||
const borderW = {
|
||||
top: parseFloat(style.borderTopWidth) || 0,
|
||||
right: parseFloat(style.borderRightWidth) || 0,
|
||||
@@ -1331,10 +1428,10 @@ function checkQuality(opts) {
|
||||
left: parseFloat(style.borderLeftWidth) || 0,
|
||||
};
|
||||
const borderVisible = {
|
||||
top: borderW.top > 0 && !isTransparent(style.borderTopColor),
|
||||
right: borderW.right > 0 && !isTransparent(style.borderRightColor),
|
||||
bottom: borderW.bottom > 0 && !isTransparent(style.borderBottomColor),
|
||||
left: borderW.left > 0 && !isTransparent(style.borderLeftColor),
|
||||
top: borderW.top > 0 && !cssColorIsTransparent(style.borderTopColor),
|
||||
right: borderW.right > 0 && !cssColorIsTransparent(style.borderRightColor),
|
||||
bottom: borderW.bottom > 0 && !cssColorIsTransparent(style.borderBottomColor),
|
||||
left: borderW.left > 0 && !cssColorIsTransparent(style.borderLeftColor),
|
||||
};
|
||||
// Outline detection. jsdom decomposes `border` shorthand into
|
||||
// border{Top,…}Width/Color but does NOT decompose `outline` —
|
||||
@@ -1354,8 +1451,8 @@ function checkQuality(opts) {
|
||||
if (cMatch) outlineColorVal = cMatch[1];
|
||||
}
|
||||
}
|
||||
const outlineVisible = outlineW > 0 && !isTransparent(outlineColorVal) && outlineStyleVal && outlineStyleVal !== 'none';
|
||||
const bgVisible = !isTransparent(style.backgroundColor);
|
||||
const outlineVisible = outlineW > 0 && !cssColorIsTransparent(outlineColorVal) && outlineStyleVal && outlineStyleVal !== 'none';
|
||||
const bgVisible = hasVisibleBackgroundBoundary(style, el, win);
|
||||
|
||||
const anyVisible = borderVisible.top || borderVisible.right || borderVisible.bottom || borderVisible.left || outlineVisible || bgVisible;
|
||||
if (anyVisible) {
|
||||
@@ -1383,13 +1480,7 @@ function checkQuality(opts) {
|
||||
const CHILD_INSULATE_THRESHOLD = 4;
|
||||
const childrenInsulate = { top: false, right: false, bottom: false, left: false };
|
||||
for (const child of el.children) {
|
||||
let childStyle = null;
|
||||
if (win && typeof win.getComputedStyle === 'function') {
|
||||
try { childStyle = win.getComputedStyle(child); } catch {}
|
||||
}
|
||||
if (!childStyle && typeof getComputedStyle === 'function') {
|
||||
try { childStyle = getComputedStyle(child); } catch {}
|
||||
}
|
||||
let childStyle = getComputedStyleFor(win, child);
|
||||
if (!childStyle) continue;
|
||||
const childPad = {
|
||||
top: resolveLengthPx(childStyle.paddingTop, fontSize) ?? 0,
|
||||
@@ -1397,15 +1488,37 @@ function checkQuality(opts) {
|
||||
bottom: resolveLengthPx(childStyle.paddingBottom, fontSize) ?? 0,
|
||||
left: resolveLengthPx(childStyle.paddingLeft, fontSize) ?? 0,
|
||||
};
|
||||
const childMargin = {
|
||||
top: resolveLengthPx(childStyle.marginTop, fontSize) ?? 0,
|
||||
right: resolveLengthPx(childStyle.marginRight, fontSize) ?? 0,
|
||||
bottom: resolveLengthPx(childStyle.marginBottom, fontSize) ?? 0,
|
||||
left: resolveLengthPx(childStyle.marginLeft, fontSize) ?? 0,
|
||||
};
|
||||
if (rect && typeof child.getBoundingClientRect === 'function') {
|
||||
try {
|
||||
const childRect = child.getBoundingClientRect();
|
||||
if (childRect && childRect.width > 0 && childRect.height > 0) {
|
||||
if (childRect.top - rect.top >= CHILD_INSULATE_THRESHOLD) childrenInsulate.top = true;
|
||||
if (rect.right - childRect.right >= CHILD_INSULATE_THRESHOLD) childrenInsulate.right = true;
|
||||
if (rect.bottom - childRect.bottom >= CHILD_INSULATE_THRESHOLD) childrenInsulate.bottom = true;
|
||||
if (childRect.left - rect.left >= CHILD_INSULATE_THRESHOLD) childrenInsulate.left = true;
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
for (const s of ['top', 'right', 'bottom', 'left']) {
|
||||
if (childPad[s] >= CHILD_INSULATE_THRESHOLD) childrenInsulate[s] = true;
|
||||
if (childPad[s] >= CHILD_INSULATE_THRESHOLD || childMargin[s] >= CHILD_INSULATE_THRESHOLD) {
|
||||
childrenInsulate[s] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const textFlush = rect ? textDescendantsFlushSides(el, rect) : null;
|
||||
const fullBleedBgBand = rect && viewportWidth > 0 && rect.width >= viewportWidth * 0.94 && bgVisible && !outlineVisible;
|
||||
const flushSides = [];
|
||||
for (const side of ['top', 'right', 'bottom', 'left']) {
|
||||
const sideBounded = borderVisible[side] || outlineVisible || bgVisible;
|
||||
if (sideBounded && pad[side] <= PAD_THRESHOLD && !childrenInsulate[side]) {
|
||||
const bgBoundsSide = bgVisible && !(fullBleedBgBand && (side === 'left' || side === 'right'));
|
||||
const sideBounded = borderVisible[side] || outlineVisible || bgBoundsSide;
|
||||
if (sideBounded && pad[side] <= PAD_THRESHOLD && !childrenInsulate[side] && (!textFlush || textFlush[side])) {
|
||||
flushSides.push(side);
|
||||
}
|
||||
}
|
||||
@@ -1499,7 +1612,7 @@ function checkQuality(opts) {
|
||||
// Only flag actual body content, not UI labels (buttons, tabs, badges, captions, footer text, etc.)
|
||||
if (hasDirectText && textLen > 20 && fontSize < 12) {
|
||||
const skipTags = ['sub', 'sup', 'code', 'kbd', 'samp', 'var', 'caption', 'figcaption'];
|
||||
const inUIContext = el.closest && el.closest('button, a, label, summary, [role="button"], [role="link"], [role="tab"], [role="menuitem"], [role="option"], nav, footer, [class*="badge" i], [class*="chip" i], [class*="pill" i], [class*="tag" i], [class*="label" i], [class*="caption" i]');
|
||||
const inUIContext = el.closest && el.closest('button, a, label, summary, pre, [role="button"], [role="link"], [role="tab"], [role="menuitem"], [role="option"], nav, footer, [aria-hidden="true"], [class*="badge" i], [class*="caption" i], [class*="chip" i], [class*="code" i], [class*="console" i], [class*="diff" i], [class*="label" i], [class*="meta" i], [class*="mock" i], [class*="pill" i], [class*="preview" i], [class*="tag" i], [class*="terminal" i], [class*="writes" i]');
|
||||
const isUppercase = style.textTransform === 'uppercase';
|
||||
if (!skipTags.includes(tag) && !inUIContext && !isUppercase) {
|
||||
findings.push({ id: 'tiny-text', snippet: `${fontSize}px body text` });
|
||||
@@ -2107,17 +2220,28 @@ function checkCreamPalette(doc, win) {
|
||||
}
|
||||
|
||||
// ─── Oversized hero headline ────────────────────────────────────────────────
|
||||
// Fires when a *long* headline is set at display size, so a full sentence ends
|
||||
// up dominating the viewport. A punchy one- or two-word headline at the same
|
||||
// size is a legitimate stylistic choice and must pass — length, not size
|
||||
// alone, is the tell.
|
||||
// Fires when a *long* headline is set at display size and actually dominates
|
||||
// the viewport. A punchy one- or two-word headline at the same size is a
|
||||
// legitimate stylistic choice, and a large-but-contained two-line hero should
|
||||
// pass too — length and viewport share together are the tell.
|
||||
const OVERSIZED_H1_FONT_PX = 72;
|
||||
const OVERSIZED_H1_MIN_CHARS = 40;
|
||||
function checkOversizedH1({ tag, fontSize, headingText }) {
|
||||
const OVERSIZED_H1_MIN_VIEWPORT_HEIGHT_RATIO = 0.28;
|
||||
const OVERSIZED_H1_MIN_VIEWPORT_AREA_RATIO = 0.25;
|
||||
function checkOversizedH1({ tag, fontSize, headingText, rect = null, viewportWidth = 0, viewportHeight = 0 }) {
|
||||
if (tag !== 'h1') return [];
|
||||
const textLen = headingText.length;
|
||||
if (fontSize >= OVERSIZED_H1_FONT_PX && textLen >= OVERSIZED_H1_MIN_CHARS) {
|
||||
return [{ id: 'oversized-h1', snippet: `${Math.round(fontSize)}px h1, ${textLen} chars "${headingText.slice(0, 60)}"` }];
|
||||
let viewportDetail = '';
|
||||
if (rect && viewportWidth > 0 && viewportHeight > 0) {
|
||||
const heightRatio = rect.height / viewportHeight;
|
||||
const areaRatio = (rect.width * rect.height) / (viewportWidth * viewportHeight);
|
||||
const dominatesViewport = heightRatio >= OVERSIZED_H1_MIN_VIEWPORT_HEIGHT_RATIO
|
||||
|| areaRatio >= OVERSIZED_H1_MIN_VIEWPORT_AREA_RATIO;
|
||||
if (!dominatesViewport) return [];
|
||||
viewportDetail = `, ${Math.round(heightRatio * 100)}vh`;
|
||||
}
|
||||
return [{ id: 'oversized-h1', snippet: `${Math.round(fontSize)}px h1, ${textLen} chars${viewportDetail} "${headingText.slice(0, 60)}"` }];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
@@ -2135,31 +2259,54 @@ function checkElementOversizedH1DOM(el) {
|
||||
const style = getComputedStyle(el);
|
||||
const fontSize = parseFloat(style.fontSize) || 0;
|
||||
const headingText = (el.textContent || '').trim().replace(/\s+/g, ' ');
|
||||
return checkOversizedH1({ tag, fontSize, headingText });
|
||||
const rect = el.getBoundingClientRect();
|
||||
const viewportWidth = (typeof window !== 'undefined' ? window.innerWidth : 0) || 0;
|
||||
const viewportHeight = (typeof window !== 'undefined' ? window.innerHeight : 0) || 0;
|
||||
return checkOversizedH1({ tag, fontSize, headingText, rect, viewportWidth, viewportHeight });
|
||||
}
|
||||
|
||||
// ─── GPT tell: hairline border + wide diffuse shadow (gated --gpt) ────────────
|
||||
function shadowMaxBlurPx(boxShadow) {
|
||||
const CSS_COLOR_TOKEN_RE = /(?:rgba?|hsla?|oklch|oklab|lab|lch|color)\([^)]*\)|#[0-9a-fA-F]{3,8}\b|\b(?:black|white|transparent|currentcolor)\b/gi;
|
||||
|
||||
function shadowLayerAlpha(layer) {
|
||||
CSS_COLOR_TOKEN_RE.lastIndex = 0;
|
||||
const match = CSS_COLOR_TOKEN_RE.exec(layer);
|
||||
if (!match) return 1;
|
||||
if (match[0].toLowerCase() === 'transparent') return 0;
|
||||
const parsed = parseAnyColor(match[0]);
|
||||
return parsed ? (parsed.a ?? 1) : 1;
|
||||
}
|
||||
|
||||
function shadowMaxBlurPx(boxShadow, { minAlpha = 0 } = {}) {
|
||||
if (!boxShadow || boxShadow === 'none') return 0;
|
||||
let maxBlur = 0;
|
||||
// Split into layers on commas not inside parentheses (rgba(...) etc.).
|
||||
for (const layer of boxShadow.split(/,(?![^()]*\))/)) {
|
||||
if (shadowLayerAlpha(layer) < minAlpha) continue;
|
||||
// Strip colors and keywords (rgba()/hsl()/hex/named/inset/px), leaving the
|
||||
// ordered length tokens: offsetX offsetY blur [spread]. Static jsdom keeps
|
||||
// unitless zeros ("0 0 24px"); browsers normalize to px ("0px 0px 24px") —
|
||||
// both reduce to the same numbers here.
|
||||
const cleaned = layer.replace(/rgba?\([^)]*\)|hsla?\([^)]*\)|#[0-9a-f]+|\b[a-z]+\b/gi, ' ');
|
||||
const cleaned = layer.replace(CSS_COLOR_TOKEN_RE, ' ').replace(/\b[a-z]+\b/gi, ' ');
|
||||
const nums = [...cleaned.matchAll(/-?\d*\.?\d+/g)].map(m => parseFloat(m[0]));
|
||||
if (nums.length >= 3) maxBlur = Math.max(maxBlur, nums[2]);
|
||||
}
|
||||
return maxBlur;
|
||||
}
|
||||
|
||||
function checkGptThinBorderWideShadow({ borderWidths, boxShadow }) {
|
||||
const maxBorder = Math.max(0, ...borderWidths);
|
||||
const hasThinBorder = maxBorder > 0 && maxBorder <= 1.5;
|
||||
const blur = shadowMaxBlurPx(boxShadow);
|
||||
if (hasThinBorder && blur >= 16) {
|
||||
function cssColorAlpha(value) {
|
||||
if (cssColorIsTransparent(value)) return 0;
|
||||
const parsed = parseAnyColor(value);
|
||||
return parsed ? (parsed.a ?? 1) : 1;
|
||||
}
|
||||
|
||||
function checkGptThinBorderWideShadow({ borderWidths, borderColors, boxShadow }) {
|
||||
const visibleThinBorders = borderWidths
|
||||
.map((width, index) => ({ width, alpha: cssColorAlpha(borderColors?.[index] || '') }))
|
||||
.filter(({ width, alpha }) => width > 0 && width <= 1.5 && alpha >= 0.28);
|
||||
const maxBorder = Math.max(0, ...visibleThinBorders.map(({ width }) => width));
|
||||
const blur = shadowMaxBlurPx(boxShadow, { minAlpha: 0.12 });
|
||||
if (visibleThinBorders.length >= 2 && blur >= 16) {
|
||||
return [{ id: 'gpt-thin-border-wide-shadow', snippet: `${maxBorder}px border + ${Math.round(blur)}px shadow blur` }];
|
||||
}
|
||||
return [];
|
||||
@@ -2174,13 +2321,22 @@ function borderWidthsFromStyle(style) {
|
||||
];
|
||||
}
|
||||
|
||||
function borderColorsFromStyle(style) {
|
||||
return [
|
||||
style.borderTopColor || '',
|
||||
style.borderRightColor || '',
|
||||
style.borderBottomColor || '',
|
||||
style.borderLeftColor || '',
|
||||
];
|
||||
}
|
||||
|
||||
function checkElementGptBorderShadow(el, style) {
|
||||
return checkGptThinBorderWideShadow({ borderWidths: borderWidthsFromStyle(style), boxShadow: style.boxShadow || '' });
|
||||
return checkGptThinBorderWideShadow({ borderWidths: borderWidthsFromStyle(style), borderColors: borderColorsFromStyle(style), boxShadow: style.boxShadow || '' });
|
||||
}
|
||||
|
||||
function checkElementGptBorderShadowDOM(el) {
|
||||
const style = getComputedStyle(el);
|
||||
return checkGptThinBorderWideShadow({ borderWidths: borderWidthsFromStyle(style), boxShadow: style.boxShadow || '' });
|
||||
return checkGptThinBorderWideShadow({ borderWidths: borderWidthsFromStyle(style), borderColors: borderColorsFromStyle(style), boxShadow: style.boxShadow || '' });
|
||||
}
|
||||
|
||||
// ─── Clipped overflow container ───────────────────────────────────────────────
|
||||
@@ -2193,17 +2349,131 @@ function classSelector(el) {
|
||||
return tokens.length ? `${tag}.${tokens.join('.')}` : tag;
|
||||
}
|
||||
|
||||
function positionedChildIsDecorative(child) {
|
||||
if (!child || typeof child.getAttribute !== 'function') return false;
|
||||
if (child.closest?.('[aria-hidden="true"]')) return true;
|
||||
const role = (child.getAttribute('role') || '').toLowerCase();
|
||||
if (role === 'none' || role === 'presentation') return true;
|
||||
const tag = child.tagName ? child.tagName.toLowerCase() : '';
|
||||
if (['img', 'svg', 'canvas', 'video'].includes(tag)) return true;
|
||||
const ident = `${child.getAttribute('class') || ''} ${child.getAttribute('id') || ''}`;
|
||||
if (
|
||||
/\b(art|bg|background|badge|blob|crop|decor|dot|glow|grain|image|mask|ornament|overlay|photo|scrim|shadow|shine|texture)\b/i.test(ident) &&
|
||||
!positionedChildHasSubstantiveContent(child)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const POSITIONED_CHILD_INTERACTIVE_SELECTOR = [
|
||||
'a[href]',
|
||||
'button',
|
||||
'input',
|
||||
'select',
|
||||
'summary',
|
||||
'textarea',
|
||||
'[tabindex]:not([tabindex="-1"])',
|
||||
'[role="button"]',
|
||||
'[role="dialog"]',
|
||||
'[role="link"]',
|
||||
'[role="listbox"]',
|
||||
'[role="menu"]',
|
||||
'[role="menuitem"]',
|
||||
'[role="option"]',
|
||||
'[role="tooltip"]',
|
||||
].join(',');
|
||||
|
||||
function positionedChildHasSubstantiveContent(child) {
|
||||
const text = (child.textContent || '').replace(/\s+/g, ' ').trim();
|
||||
if (text.length > 0) return true;
|
||||
if (typeof child.matches === 'function') {
|
||||
try {
|
||||
if (child.matches(POSITIONED_CHILD_INTERACTIVE_SELECTOR)) return true;
|
||||
} catch {}
|
||||
}
|
||||
if (typeof child.querySelector === 'function') {
|
||||
try {
|
||||
if (child.querySelector(POSITIONED_CHILD_INTERACTIVE_SELECTOR)) return true;
|
||||
} catch {}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function clippingContainerIsIntentionalViewport(el) {
|
||||
if (!el || typeof el.getAttribute !== 'function') return false;
|
||||
const roleDescription = (el.getAttribute('aria-roledescription') || '').toLowerCase();
|
||||
if (/\b(carousel|slider)\b/.test(roleDescription)) return true;
|
||||
const ident = `${el.getAttribute('class') || ''} ${el.getAttribute('id') || ''}`.toLowerCase();
|
||||
return /\b(carousel|comparison|compare|fisheye|marquee|preview|scroller|slider|slideshow|split|viewport)\b/.test(ident) ||
|
||||
/\b(demo-area|demo-stage|demo-viewport)\b/.test(ident);
|
||||
}
|
||||
|
||||
function elementRect(el) {
|
||||
if (!el || typeof el.getBoundingClientRect !== 'function') return null;
|
||||
try {
|
||||
const rect = el.getBoundingClientRect();
|
||||
if (!rect) return null;
|
||||
const values = [rect.top, rect.right, rect.bottom, rect.left, rect.width, rect.height];
|
||||
if (!values.every(Number.isFinite)) return null;
|
||||
if (rect.width <= 0 && rect.height <= 0) return null;
|
||||
return rect;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function positionedStyleImpliesEscape(style) {
|
||||
const values = [
|
||||
style.top,
|
||||
style.right,
|
||||
style.bottom,
|
||||
style.left,
|
||||
style.inset,
|
||||
style.insetBlock,
|
||||
style.insetInline,
|
||||
style.insetBlockStart,
|
||||
style.insetBlockEnd,
|
||||
style.insetInlineStart,
|
||||
style.insetInlineEnd,
|
||||
].filter(Boolean).map(value => String(value).trim().toLowerCase());
|
||||
for (const value of values) {
|
||||
if (/(^|[\s(])-+(?:\d|\.)/.test(value)) return true;
|
||||
if (/(^|[\s(])100(?:\.0+)?%/.test(value)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function positionedChildEscapesClip(el, child, clipX, clipY) {
|
||||
const parentRect = elementRect(el);
|
||||
const childRect = elementRect(child);
|
||||
if (!parentRect || !childRect) return null;
|
||||
const threshold = 2;
|
||||
return Boolean(
|
||||
(clipX && (childRect.left < parentRect.left - threshold || childRect.right > parentRect.right + threshold)) ||
|
||||
(clipY && (childRect.top < parentRect.top - threshold || childRect.bottom > parentRect.bottom + threshold))
|
||||
);
|
||||
}
|
||||
|
||||
function checkClippedOverflow(el, style, getStyle) {
|
||||
const clips = (v) => v === 'hidden' || v === 'clip';
|
||||
const scrolls = (v) => v === 'auto' || v === 'scroll';
|
||||
const ox = style.overflowX || '', oy = style.overflowY || '', ov = style.overflow || '';
|
||||
const anyClip = clips(ox) || clips(oy) || clips(ov);
|
||||
const clipX = clips(ox) || clips(ov);
|
||||
const clipY = clips(oy) || clips(ov);
|
||||
const anyClip = clipX || clipY;
|
||||
const anyScroll = scrolls(ox) || scrolls(oy) || scrolls(ov);
|
||||
if (!anyClip || anyScroll) return [];
|
||||
if (clippingContainerIsIntentionalViewport(el)) return [];
|
||||
if (!el.querySelectorAll) return [];
|
||||
for (const child of el.querySelectorAll('*')) {
|
||||
const pos = (getStyle(child).position) || '';
|
||||
const childStyle = getStyle(child);
|
||||
const pos = childStyle.position || '';
|
||||
if (pos === 'absolute' || pos === 'fixed') {
|
||||
if (positionedChildIsDecorative(child)) continue;
|
||||
const escapes = positionedChildEscapesClip(el, child, clipX, clipY);
|
||||
if (escapes === false) continue;
|
||||
if (escapes === null && !positionedStyleImpliesEscape(childStyle)) continue;
|
||||
return [{ id: 'clipped-overflow-container', snippet: `${classSelector(el)} clips a positioned child` }];
|
||||
}
|
||||
}
|
||||
@@ -2222,14 +2492,94 @@ function checkElementClippedOverflowDOM(el) {
|
||||
// ─── Text overflow (browser-only: needs scrollWidth/clientWidth) ──────────────
|
||||
const TEXT_OVERFLOW_SKIP_TAGS = new Set(['pre', 'code', 'textarea', 'svg', 'canvas', 'select', 'option', 'marquee']);
|
||||
|
||||
function metricLengthPx(value, fontSizePx = 16) {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) return value;
|
||||
if (typeof value !== 'string') return null;
|
||||
return resolveLengthPx(value, fontSizePx);
|
||||
}
|
||||
|
||||
function firstMetricLengthPx(fontSizePx, ...values) {
|
||||
for (const value of values) {
|
||||
const parsed = metricLengthPx(value, fontSizePx);
|
||||
if (parsed !== null) return parsed;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function expandBoxShorthand(parts) {
|
||||
if (parts.length === 1) return [parts[0], parts[0], parts[0], parts[0]];
|
||||
if (parts.length === 2) return [parts[0], parts[1], parts[0], parts[1]];
|
||||
if (parts.length === 3) return [parts[0], parts[1], parts[2], parts[1]];
|
||||
return [parts[0], parts[1], parts[2], parts[3]];
|
||||
}
|
||||
|
||||
function clippedByInset(clipPath) {
|
||||
const match = String(clipPath || '').trim().toLowerCase().match(/^inset\s*\(([^)]*)\)$/);
|
||||
if (!match) return false;
|
||||
const beforeRound = match[1].split(/\s+round\s+/)[0].trim();
|
||||
if (!beforeRound) return false;
|
||||
const values = expandBoxShorthand(beforeRound.split(/\s+/).slice(0, 4));
|
||||
const percents = values.map(value => String(value).trim().match(/^(-?\d+(?:\.\d+)?)%$/));
|
||||
if (percents.some(match => !match)) return false;
|
||||
const [top, right, bottom, left] = percents.map(match => parseFloat(match[1]));
|
||||
return top + bottom >= 100 || left + right >= 100;
|
||||
}
|
||||
|
||||
function clippedByRect(clip) {
|
||||
const match = String(clip || '').trim().toLowerCase().match(/^rect\s*\(([^)]*)\)$/);
|
||||
if (!match) return false;
|
||||
const values = match[1].split(/[,\s]+/).map(value => value.trim()).filter(Boolean);
|
||||
if (values.length !== 4) return false;
|
||||
const [top, right, bottom, left] = values.map(value => metricLengthPx(value, 16));
|
||||
if ([top, right, bottom, left].some(value => value === null)) return false;
|
||||
return bottom <= top || right <= left;
|
||||
}
|
||||
|
||||
function isScreenReaderOnlyTextStyle(style, metrics = {}) {
|
||||
if (!style) return false;
|
||||
const overflowValues = [style.overflow, style.overflowX, style.overflowY]
|
||||
.map(value => String(value || '').toLowerCase());
|
||||
const clipsOverflow = overflowValues.some(value => value === 'hidden' || value === 'clip');
|
||||
|
||||
const fontSize = metricLengthPx(style.fontSize, 16) || 16;
|
||||
const width = firstMetricLengthPx(fontSize, metrics.width, metrics.clientWidth, style.width, style.inlineSize);
|
||||
const height = firstMetricLengthPx(fontSize, metrics.height, metrics.clientHeight, style.height, style.blockSize);
|
||||
const isTiny = width !== null && height !== null && width <= 2 && height <= 2;
|
||||
const isAbsolutelyHidden = String(style.position || '').toLowerCase() === 'absolute' && isTiny && clipsOverflow;
|
||||
|
||||
const clipPath = String(style.clipPath || style.webkitClipPath || '').trim();
|
||||
const clip = String(style.clip || '').trim();
|
||||
return isAbsolutelyHidden || clippedByInset(clipPath) || clippedByRect(clip);
|
||||
}
|
||||
|
||||
function isRenderedForBrowserRule(el) {
|
||||
for (let cur = el; cur && cur.nodeType === 1; cur = cur.parentElement) {
|
||||
if (cur.getAttribute?.('aria-hidden') === 'true') return false;
|
||||
const style = getComputedStyle(cur);
|
||||
const visibility = String(style.visibility || '').toLowerCase();
|
||||
if (style.display === 'none' || visibility === 'hidden' || visibility === 'collapse') return false;
|
||||
if ((parseFloat(style.opacity) || 0) <= 0.01) return false;
|
||||
if (String(style.contentVisibility || '').toLowerCase() === 'hidden') return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function checkElementTextOverflowDOM(el) {
|
||||
const tag = el.tagName.toLowerCase();
|
||||
if (TEXT_OVERFLOW_SKIP_TAGS.has(tag)) return [];
|
||||
if (!isRenderedForBrowserRule(el)) return [];
|
||||
// Only the element that actually owns overflowing text — not its ancestors,
|
||||
// which inherit a wider scrollWidth from the spilling descendant.
|
||||
const hasDirectText = [...el.childNodes].some(n => n.nodeType === 3 && n.textContent.trim().length > 0);
|
||||
if (!hasDirectText) return [];
|
||||
const style = getComputedStyle(el);
|
||||
const rect = el.getBoundingClientRect ? el.getBoundingClientRect() : null;
|
||||
if (isScreenReaderOnlyTextStyle(style, {
|
||||
width: rect?.width,
|
||||
height: rect?.height,
|
||||
clientWidth: el.clientWidth,
|
||||
clientHeight: el.clientHeight,
|
||||
})) return [];
|
||||
const isScrollRegion = (s) => /(auto|scroll)/.test(s.overflowX || '') || /(auto|scroll)/.test(s.overflow || '');
|
||||
if (isScrollRegion(style)) return [];
|
||||
// A scrollable ancestor means this overflow is intentional and scrollable.
|
||||
@@ -2312,5 +2662,6 @@ export {
|
||||
checkClippedOverflow,
|
||||
checkElementClippedOverflow,
|
||||
checkElementClippedOverflowDOM,
|
||||
isScreenReaderOnlyTextStyle,
|
||||
checkElementTextOverflowDOM,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,574 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* `/impeccable hooks <on|off|status|reset>` — manage the design hook
|
||||
* via the `hook` key of .impeccable/config.json and .impeccable/config.local.json
|
||||
* in the current project.
|
||||
*
|
||||
* Usage:
|
||||
* node hook-admin.mjs status # print current state
|
||||
* node hook-admin.mjs on # set enabled: true
|
||||
* node hook-admin.mjs off # set enabled: false
|
||||
* node hook-admin.mjs ignore-rule <rule-id> # append to ignoreRules
|
||||
* node hook-admin.mjs ignore-rule overused-font --all-values
|
||||
* node hook-admin.mjs ignore-file <glob> # append to ignoreFiles
|
||||
* node hook-admin.mjs ignore-value <rule> <value> # append to shared ignoreValues
|
||||
* node hook-admin.mjs ignore-value <rule> <value> --local
|
||||
* node hook-admin.mjs reset # remove all config + cache
|
||||
*
|
||||
* Designed to be invoked by the LLM from the reference/hooks.md flow.
|
||||
* Output is human-readable; the harness will pass it back to the user.
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import {
|
||||
getConfigPath,
|
||||
getLocalConfigPath,
|
||||
getCachePath,
|
||||
getPendingPath,
|
||||
readConfig,
|
||||
DEFAULT_CONFIG,
|
||||
ensureHookGitExcludes,
|
||||
normalizeIgnoreValue,
|
||||
normalizeIgnoreValueEntries,
|
||||
} from './hook-lib.mjs';
|
||||
|
||||
const ACTIONS = new Set(['status', 'on', 'off', 'ignore-rule', 'ignore-file', 'ignore-value', 'reset']);
|
||||
const IMPECCABLE_HOOK_COMMAND_MARKERS = [
|
||||
'skills/impeccable/scripts/hook-probe.mjs',
|
||||
'skills/impeccable/scripts/hook.mjs',
|
||||
'skills/impeccable/scripts/hook-before-edit.mjs',
|
||||
'skills/impeccable/scripts/hook-after-edit.mjs',
|
||||
'skills/impeccable/scripts/hook-stop.mjs',
|
||||
];
|
||||
const TIMEOUT_SECONDS = 5;
|
||||
const STATUS_MESSAGE = 'Checking UI changes';
|
||||
|
||||
const HOOK_MANIFEST_TARGETS = [
|
||||
{
|
||||
provider: '.claude',
|
||||
skillRel: '.claude/skills/impeccable',
|
||||
destRel: '.claude/settings.local.json',
|
||||
sharedDestRel: '.claude/settings.json',
|
||||
manifest: () => ({
|
||||
description: 'Impeccable design detector: runs after Edit/Write/MultiEdit on UI files and surfaces findings as system reminders.',
|
||||
hooks: {
|
||||
PostToolUse: [
|
||||
{
|
||||
matcher: 'Edit|Write|MultiEdit',
|
||||
hooks: [
|
||||
{
|
||||
type: 'command',
|
||||
command: 'node "${CLAUDE_PROJECT_DIR}/.claude/skills/impeccable/scripts/hook.mjs"',
|
||||
timeout: TIMEOUT_SECONDS,
|
||||
statusMessage: STATUS_MESSAGE,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
},
|
||||
{
|
||||
provider: '.agents',
|
||||
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: [
|
||||
{
|
||||
matcher: 'Edit|Write|apply_patch',
|
||||
hooks: [
|
||||
{
|
||||
type: 'command',
|
||||
command: 'node "$(git rev-parse --show-toplevel)/.agents/skills/impeccable/scripts/hook.mjs"',
|
||||
timeout: TIMEOUT_SECONDS,
|
||||
statusMessage: STATUS_MESSAGE,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
},
|
||||
{
|
||||
provider: '.cursor',
|
||||
skillRel: '.cursor/skills/impeccable',
|
||||
destRel: '.cursor/hooks.json',
|
||||
manifest: () => ({
|
||||
version: 1,
|
||||
hooks: {
|
||||
preToolUse: [
|
||||
{
|
||||
command: 'node ".cursor/skills/impeccable/scripts/hook-before-edit.mjs"',
|
||||
timeout: TIMEOUT_SECONDS,
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
},
|
||||
];
|
||||
|
||||
function readRawConfigFile(filePath) {
|
||||
if (!fs.existsSync(filePath)) return { exists: false, malformed: false, raw: null };
|
||||
try {
|
||||
return { exists: true, malformed: false, raw: JSON.parse(fs.readFileSync(filePath, 'utf-8')) };
|
||||
} catch {
|
||||
return { exists: true, malformed: true, raw: null };
|
||||
}
|
||||
}
|
||||
|
||||
// The hook settings to edit: the unified file's `hook` subtree.
|
||||
function readRawConfig(cwd, opts = {}) {
|
||||
const unified = readRawConfigFile(opts.local ? getLocalConfigPath(cwd) : getConfigPath(cwd)).raw;
|
||||
if (unified && typeof unified === 'object' && unified.hook && typeof unified.hook === 'object') {
|
||||
return unified.hook;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Write the hook config back under the `hook` key of the unified file, leaving
|
||||
// any sibling keys (e.g. updateCheck) untouched.
|
||||
function writeConfig(cwd, hookConfig, opts = {}) {
|
||||
const filePath = opts.local ? getLocalConfigPath(cwd) : getConfigPath(cwd);
|
||||
if (opts.local) ensureHookGitExcludes(cwd);
|
||||
const existingRaw = readRawConfigFile(filePath).raw;
|
||||
const existing = existingRaw && typeof existingRaw === 'object' && !Array.isArray(existingRaw) ? existingRaw : {};
|
||||
const existingHook = existing.hook && typeof existing.hook === 'object' && !Array.isArray(existing.hook) ? existing.hook : {};
|
||||
// Merge over the existing hook object so fields the merge helpers don't manage
|
||||
// (consent, quiet, auditLog) survive a `/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');
|
||||
return filePath;
|
||||
}
|
||||
|
||||
function mergeConfig(existing) {
|
||||
// Persist the full shape so /impeccable hooks edits leave a complete file
|
||||
// for the user to see, not an unhelpful `{"enabled":false}`.
|
||||
const base = existing && typeof existing === 'object' ? existing : {};
|
||||
return {
|
||||
enabled: base.enabled === false ? false : true,
|
||||
ignoreRules: Array.isArray(base.ignoreRules) ? Array.from(new Set(base.ignoreRules.map(String))) : [],
|
||||
ignoreFiles: Array.isArray(base.ignoreFiles) ? Array.from(new Set(base.ignoreFiles.map(String))) : [],
|
||||
ignoreValues: normalizeIgnoreValueEntries(base.ignoreValues || []),
|
||||
limits: {
|
||||
maxFindings: Number.isFinite(base?.limits?.maxFindings) ? base.limits.maxFindings : DEFAULT_CONFIG.limits.maxFindings,
|
||||
maxChars: Number.isFinite(base?.limits?.maxChars) ? base.limits.maxChars : DEFAULT_CONFIG.limits.maxChars,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function mergeLocalConfig(existing) {
|
||||
const base = existing && typeof existing === 'object' ? existing : {};
|
||||
const out = {};
|
||||
if (Object.prototype.hasOwnProperty.call(base, 'enabled')) {
|
||||
out.enabled = base.enabled === false ? false : true;
|
||||
}
|
||||
if (Array.isArray(base.ignoreRules)) {
|
||||
out.ignoreRules = Array.from(new Set(base.ignoreRules.map(String)));
|
||||
}
|
||||
if (Array.isArray(base.ignoreFiles)) {
|
||||
out.ignoreFiles = Array.from(new Set(base.ignoreFiles.map(String)));
|
||||
}
|
||||
out.ignoreValues = normalizeIgnoreValueEntries(base.ignoreValues || []);
|
||||
if (base.limits && typeof base.limits === 'object') {
|
||||
const limits = {};
|
||||
if (Number.isFinite(base.limits.maxFindings)) limits.maxFindings = base.limits.maxFindings;
|
||||
if (Number.isFinite(base.limits.maxChars)) limits.maxChars = base.limits.maxChars;
|
||||
if (Object.keys(limits).length) out.limits = limits;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function statusReport(cwd) {
|
||||
const shared = readRawConfigFile(getConfigPath(cwd));
|
||||
const local = readRawConfigFile(getLocalConfigPath(cwd));
|
||||
const cfg = readConfig(cwd);
|
||||
const envKill = process.env.IMPECCABLE_HOOK_DISABLED;
|
||||
const envState = envKill ? `IMPECCABLE_HOOK_DISABLED=${envKill}` : 'unset';
|
||||
const cfgPath = path.relative(cwd, getConfigPath(cwd)) || '.impeccable/config.json';
|
||||
const localPath = path.relative(cwd, getLocalConfigPath(cwd)) || '.impeccable/config.local.json';
|
||||
const cachePath = path.relative(cwd, getCachePath(cwd)) || '.impeccable/hook.cache.json';
|
||||
const fileState = (info, relPath, absent) => {
|
||||
if (info.malformed) return `${relPath} (malformed; ignored)`;
|
||||
if (info.exists) return relPath;
|
||||
return `${relPath} (${absent})`;
|
||||
};
|
||||
const ignoreValues = cfg.ignoreValues.map((entry) => `${entry.rule}=${entry.value}`);
|
||||
|
||||
const lines = [
|
||||
`Impeccable design hook`,
|
||||
` state: ${cfg.enabled ? 'enabled' : 'disabled'}`,
|
||||
` shared file: ${fileState(shared, cfgPath, 'using defaults; file not present')}`,
|
||||
` local file: ${fileState(local, localPath, 'not present')}`,
|
||||
` ignoreRules: ${cfg.ignoreRules.length ? cfg.ignoreRules.join(', ') : '(none)'}`,
|
||||
` ignoreFiles: ${cfg.ignoreFiles.length ? cfg.ignoreFiles.join(', ') : '(none)'}`,
|
||||
` ignoreValues: ${ignoreValues.length ? ignoreValues.join(', ') : '(none)'}`,
|
||||
` maxFindings: ${cfg.limits.maxFindings}`,
|
||||
` maxChars: ${cfg.limits.maxChars}`,
|
||||
` env override: ${envState}`,
|
||||
` cache file: ${fs.existsSync(getCachePath(cwd)) ? cachePath : `${cachePath} (not present)`}`,
|
||||
];
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function setEnabled(cwd, value) {
|
||||
const config = mergeConfig(readRawConfig(cwd));
|
||||
config.enabled = value;
|
||||
const target = writeConfig(cwd, config);
|
||||
if (!value) {
|
||||
return `Design hook disabled for this project (wrote ${path.relative(cwd, target) || target}).`;
|
||||
}
|
||||
|
||||
const localTarget = writeConfig(cwd, { consent: 'accepted' }, { local: true });
|
||||
const repaired = repairHookManifests(cwd);
|
||||
const parts = [
|
||||
`Design hook enabled for this project (wrote ${path.relative(cwd, target) || target}).`,
|
||||
`Recorded local hook consent in ${path.relative(cwd, localTarget) || localTarget}.`,
|
||||
];
|
||||
if (repaired.written.length > 0) {
|
||||
parts.push(`Installed or repaired hook manifests for: ${repaired.written.join(', ')}.`);
|
||||
} else if (repaired.already.length > 0) {
|
||||
parts.push(`Hook manifests already installed for: ${repaired.already.join(', ')}.`);
|
||||
} else {
|
||||
parts.push('No installed provider skill folders found to repair.');
|
||||
}
|
||||
if (repaired.backups.length > 0) {
|
||||
parts.push(`Backed up malformed manifest(s): ${repaired.backups.map((filePath) => path.relative(cwd, filePath) || filePath).join(', ')}.`);
|
||||
}
|
||||
return parts.join(' ');
|
||||
}
|
||||
|
||||
function repairHookManifests(cwd) {
|
||||
const result = { written: [], already: [], backups: [] };
|
||||
for (const target of HOOK_MANIFEST_TARGETS) {
|
||||
if (!fs.existsSync(path.join(cwd, target.skillRel))) continue;
|
||||
const dest = path.join(cwd, target.destRel);
|
||||
const sharedDest = target.sharedDestRel ? path.join(cwd, target.sharedDestRel) : null;
|
||||
|
||||
if (sharedDest && fileHasImpeccableHookMarker(sharedDest)) {
|
||||
pruneImpeccableHookFromManifest(dest);
|
||||
result.already.push(target.provider);
|
||||
continue;
|
||||
}
|
||||
|
||||
const fresh = target.manifest();
|
||||
let next = fresh;
|
||||
if (fs.existsSync(dest)) {
|
||||
try {
|
||||
next = mergeHookManifests(JSON.parse(fs.readFileSync(dest, 'utf-8')), fresh);
|
||||
} catch {
|
||||
const backup = `${dest}.bak`;
|
||||
fs.copyFileSync(dest, backup);
|
||||
result.backups.push(backup);
|
||||
}
|
||||
}
|
||||
|
||||
const serialized = `${JSON.stringify(next, null, 2)}\n`;
|
||||
const current = fs.existsSync(dest) ? safeReadText(dest) : null;
|
||||
if (current === serialized) {
|
||||
result.already.push(target.provider);
|
||||
continue;
|
||||
}
|
||||
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
||||
fs.writeFileSync(dest, serialized);
|
||||
result.written.push(target.provider);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function safeReadText(filePath) {
|
||||
try {
|
||||
return fs.readFileSync(filePath, 'utf-8');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function mergeHookManifests(existing, fresh) {
|
||||
const existingObject = existing && typeof existing === 'object' && !Array.isArray(existing) ? existing : {};
|
||||
const freshObject = fresh && typeof fresh === 'object' && !Array.isArray(fresh) ? fresh : {};
|
||||
const existingHooks = existingObject.hooks && typeof existingObject.hooks === 'object' && !Array.isArray(existingObject.hooks)
|
||||
? existingObject.hooks
|
||||
: {};
|
||||
const freshHooks = freshObject.hooks && typeof freshObject.hooks === 'object' && !Array.isArray(freshObject.hooks)
|
||||
? freshObject.hooks
|
||||
: {};
|
||||
|
||||
const merged = { ...existingObject, hooks: {} };
|
||||
if (freshObject.version !== undefined) merged.version = freshObject.version;
|
||||
if (freshObject.description !== undefined) merged.description = freshObject.description;
|
||||
|
||||
const hookEvents = new Set([...Object.keys(existingHooks), ...Object.keys(freshHooks)]);
|
||||
for (const event of hookEvents) {
|
||||
const preserved = stripImpeccableHookEntries(existingHooks[event]);
|
||||
const added = Array.isArray(freshHooks[event]) ? freshHooks[event] : [];
|
||||
const mergedEntries = [...preserved, ...added];
|
||||
if (mergedEntries.length > 0) merged.hooks[event] = mergedEntries;
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
function fileHasImpeccableHookMarker(filePath) {
|
||||
if (!fs.existsSync(filePath)) return false;
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return false;
|
||||
if (!parsed.hooks || typeof parsed.hooks !== 'object') return false;
|
||||
return valueHasImpeccableHookMarker(parsed.hooks);
|
||||
}
|
||||
|
||||
function valueHasImpeccableHookMarker(value) {
|
||||
if (typeof value === 'string') {
|
||||
return IMPECCABLE_HOOK_COMMAND_MARKERS.some((marker) => value.includes(marker));
|
||||
}
|
||||
if (Array.isArray(value)) return value.some(valueHasImpeccableHookMarker);
|
||||
if (value && typeof value === 'object') return Object.values(value).some(valueHasImpeccableHookMarker);
|
||||
return false;
|
||||
}
|
||||
|
||||
function stripImpeccableHookEntry(entry) {
|
||||
if (!entry || typeof entry !== 'object') return entry;
|
||||
if (valueHasImpeccableHookMarker(entry.command) || valueHasImpeccableHookMarker(entry.args)) {
|
||||
return null;
|
||||
}
|
||||
if (!Array.isArray(entry.hooks)) return entry;
|
||||
|
||||
const strippedHooks = entry.hooks
|
||||
.map(stripImpeccableHookEntry)
|
||||
.filter(Boolean);
|
||||
|
||||
if (strippedHooks.length === 0 && entry.hooks.some(valueHasImpeccableHookMarker)) {
|
||||
return null;
|
||||
}
|
||||
return { ...entry, hooks: strippedHooks };
|
||||
}
|
||||
|
||||
function stripImpeccableHookEntries(entries) {
|
||||
if (!Array.isArray(entries)) return [];
|
||||
return entries
|
||||
.map(stripImpeccableHookEntry)
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function pruneImpeccableHookFromManifest(manifestPath) {
|
||||
if (!fileHasImpeccableHookMarker(manifestPath)) return false;
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
||||
const existingHooks = parsed.hooks && typeof parsed.hooks === 'object' && !Array.isArray(parsed.hooks)
|
||||
? parsed.hooks
|
||||
: {};
|
||||
const cleanedHooks = {};
|
||||
for (const [event, entries] of Object.entries(existingHooks)) {
|
||||
const kept = stripImpeccableHookEntries(entries);
|
||||
if (kept.length > 0) cleanedHooks[event] = kept;
|
||||
}
|
||||
|
||||
const next = { ...parsed };
|
||||
if (Object.keys(cleanedHooks).length > 0) {
|
||||
next.hooks = cleanedHooks;
|
||||
} else {
|
||||
delete next.hooks;
|
||||
delete next.description;
|
||||
delete next.version;
|
||||
}
|
||||
|
||||
if (Object.keys(next).length === 0) {
|
||||
fs.rmSync(manifestPath, { force: true });
|
||||
} else {
|
||||
fs.writeFileSync(manifestPath, `${JSON.stringify(next, null, 2)}\n`);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function normalizeRuleId(rule) {
|
||||
return String(rule || '').trim().toLowerCase();
|
||||
}
|
||||
|
||||
function parseIgnoreRuleArgs(args) {
|
||||
const positionals = [];
|
||||
let allValues = false;
|
||||
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const arg = String(args[i] || '');
|
||||
if (arg === '--all-values') {
|
||||
allValues = true;
|
||||
} else if (arg === '--reason') {
|
||||
while (i + 1 < args.length && !String(args[i + 1]).startsWith('--')) i++;
|
||||
} else if (arg.startsWith('--reason=')) {
|
||||
// Accepted for command symmetry; ignoreRules stores rule ids only.
|
||||
} else if (arg.startsWith('--')) {
|
||||
throw new Error(`Unknown ignore-rule flag: ${arg}`);
|
||||
} else {
|
||||
positionals.push(arg);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
rule: normalizeRuleId(positionals[0]),
|
||||
allValues,
|
||||
};
|
||||
}
|
||||
|
||||
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 === '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.');
|
||||
}
|
||||
const config = mergeConfig(readRawConfig(cwd));
|
||||
if (!config.ignoreRules.includes(rule)) config.ignoreRules.push(rule);
|
||||
writeConfig(cwd, config);
|
||||
return `Added "${rule}" to ignoreRules. Current: ${config.ignoreRules.join(', ')}`;
|
||||
}
|
||||
|
||||
function addIgnoreFile(cwd, glob) {
|
||||
if (!glob) throw new Error('Pass a glob, e.g. /impeccable hooks ignore-file "src/legacy/**"');
|
||||
const config = mergeConfig(readRawConfig(cwd));
|
||||
if (!config.ignoreFiles.includes(glob)) config.ignoreFiles.push(glob);
|
||||
writeConfig(cwd, config);
|
||||
return `Added "${glob}" to ignoreFiles. Current: ${config.ignoreFiles.join(', ')}`;
|
||||
}
|
||||
|
||||
function parseIgnoreValueArgs(args) {
|
||||
const positionals = [];
|
||||
let shared = false;
|
||||
let local = false;
|
||||
let reason = '';
|
||||
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const arg = args[i];
|
||||
if (arg === '--shared') {
|
||||
shared = true;
|
||||
} else if (arg === '--local') {
|
||||
local = true;
|
||||
} else if (arg === '--reason') {
|
||||
const chunks = [];
|
||||
while (i + 1 < args.length && !String(args[i + 1]).startsWith('--')) {
|
||||
chunks.push(args[++i]);
|
||||
}
|
||||
reason = chunks.join(' ').trim();
|
||||
} else if (String(arg).startsWith('--reason=')) {
|
||||
reason = String(arg).slice('--reason='.length).trim();
|
||||
} else {
|
||||
positionals.push(arg);
|
||||
}
|
||||
}
|
||||
|
||||
const [rule, ...valueParts] = positionals;
|
||||
return {
|
||||
rule: String(rule || '').trim().toLowerCase(),
|
||||
value: normalizeIgnoreValue(valueParts.join(' ')),
|
||||
shared,
|
||||
local,
|
||||
reason,
|
||||
};
|
||||
}
|
||||
|
||||
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');
|
||||
}
|
||||
|
||||
if (parsed.shared && parsed.local) {
|
||||
throw new Error('Pass only one scope flag: --shared or --local');
|
||||
}
|
||||
|
||||
const local = parsed.local;
|
||||
const config = local
|
||||
? mergeLocalConfig(readRawConfig(cwd, { local: true }))
|
||||
: mergeConfig(readRawConfig(cwd, { local: false }));
|
||||
const key = `${parsed.rule}\0${parsed.value}`;
|
||||
const existing = config.ignoreValues.find((entry) => `${entry.rule}\0${entry.value}` === key);
|
||||
|
||||
if (existing) {
|
||||
if (parsed.reason) existing.reason = parsed.reason;
|
||||
} else {
|
||||
const entry = {
|
||||
rule: parsed.rule,
|
||||
value: parsed.value,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
if (parsed.reason) entry.reason = parsed.reason;
|
||||
config.ignoreValues.push(entry);
|
||||
}
|
||||
|
||||
const target = writeConfig(cwd, config, { local });
|
||||
const scope = local ? 'local ignoreValues' : 'shared ignoreValues';
|
||||
return `Added ${parsed.rule}=${parsed.value} to ${scope} (${path.relative(cwd, target) || target}).`;
|
||||
}
|
||||
|
||||
function reset(cwd) {
|
||||
const removed = [];
|
||||
// Unified files may hold non-hook keys (e.g. updateCheck); strip only the
|
||||
// hook subtree and keep the rest, deleting the file only if nothing remains.
|
||||
for (const filePath of [getConfigPath(cwd), getLocalConfigPath(cwd)]) {
|
||||
try {
|
||||
const raw = readRawConfigFile(filePath).raw;
|
||||
if (!raw || typeof raw !== 'object' || Array.isArray(raw) || !('hook' in raw)) continue;
|
||||
const { hook, ...rest } = raw;
|
||||
if (Object.keys(rest).length === 0) {
|
||||
fs.unlinkSync(filePath);
|
||||
} else {
|
||||
fs.writeFileSync(filePath, JSON.stringify(rest, null, 2) + '\n');
|
||||
}
|
||||
removed.push(path.relative(cwd, filePath) || filePath);
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
// State files are wholly ours; delete outright.
|
||||
for (const filePath of [getCachePath(cwd), getPendingPath(cwd)]) {
|
||||
try {
|
||||
if (fs.existsSync(filePath)) {
|
||||
fs.unlinkSync(filePath);
|
||||
removed.push(path.relative(cwd, filePath) || filePath);
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
return removed.length
|
||||
? `Reset design hook config and cache (removed: ${removed.join(', ')}).`
|
||||
: 'No hook config or cache to remove. Already at defaults.';
|
||||
}
|
||||
|
||||
function main() {
|
||||
const [, , actionArg, ...rest] = process.argv;
|
||||
const action = (actionArg || 'status').toLowerCase();
|
||||
const cwd = process.cwd();
|
||||
|
||||
if (!ACTIONS.has(action)) {
|
||||
process.stderr.write(`Unknown action: ${action}\nValid: ${Array.from(ACTIONS).join(', ')}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
try {
|
||||
let out = '';
|
||||
switch (action) {
|
||||
case 'status': out = statusReport(cwd); break;
|
||||
case 'on': out = setEnabled(cwd, true); break;
|
||||
case 'off': out = setEnabled(cwd, false); break;
|
||||
case 'ignore-rule': out = addIgnoreRule(cwd, rest); break;
|
||||
case 'ignore-file': out = addIgnoreFile(cwd, rest[0]); break;
|
||||
case 'ignore-value': out = addIgnoreValue(cwd, rest); break;
|
||||
case 'reset': out = reset(cwd); break;
|
||||
}
|
||||
process.stdout.write(out + '\n');
|
||||
} catch (err) {
|
||||
process.stderr.write(`Error: ${err.message || err}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,473 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Impeccable design hook — Cursor preToolUse write gate.
|
||||
*
|
||||
* Cursor's stop hook is not consistently dispatched by the headless agent, so
|
||||
* this hook checks proposed Write/Edit content before it lands. It only denies
|
||||
* writes when the real detector finds an issue in the proposed UI content.
|
||||
*
|
||||
* Contract: never break a turn accidentally. On malformed input or internal
|
||||
* errors, allow the tool and exit 0.
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import {
|
||||
ALLOWED_EXTS,
|
||||
EDIT_COUNT_THRESHOLD,
|
||||
GENERATED_PATH,
|
||||
SENSITIVE_PATH,
|
||||
filterFindings,
|
||||
loadDetector,
|
||||
matchesAnyGlob,
|
||||
persistCache,
|
||||
readCache,
|
||||
readConfig,
|
||||
renderTemplate,
|
||||
resolveProjectCwd,
|
||||
truthy,
|
||||
writeAuditLog,
|
||||
} from './hook-lib.mjs';
|
||||
|
||||
async function readStdin() {
|
||||
if (process.stdin.isTTY) return '';
|
||||
const chunks = [];
|
||||
for await (const chunk of process.stdin) chunks.push(chunk);
|
||||
return Buffer.concat(chunks).toString('utf-8');
|
||||
}
|
||||
|
||||
function done(payload = null) {
|
||||
if (payload) process.stdout.write(JSON.stringify(payload));
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
function allow(extra = {}, payload = {}) {
|
||||
writeAuditLog(process.env, {
|
||||
ts: new Date().toISOString(),
|
||||
event: 'preToolUse',
|
||||
...extra,
|
||||
});
|
||||
return done({ permission: 'allow', ...payload });
|
||||
}
|
||||
|
||||
function deny(message, audit) {
|
||||
writeAuditLog(process.env, {
|
||||
ts: new Date().toISOString(),
|
||||
event: 'preToolUse',
|
||||
blocked: true,
|
||||
...audit,
|
||||
});
|
||||
return done({
|
||||
permission: 'deny',
|
||||
user_message: message,
|
||||
agent_message: message,
|
||||
});
|
||||
}
|
||||
|
||||
function toolInput(event) {
|
||||
return event?.tool_input && typeof event.tool_input === 'object' ? event.tool_input : {};
|
||||
}
|
||||
|
||||
function proposedFilePath(event, cwd) {
|
||||
const input = toolInput(event);
|
||||
const raw = input.file_path || input.path || input.target_file || event?.file_path;
|
||||
const candidate = typeof raw === 'string' && raw.trim()
|
||||
? raw
|
||||
: shellWriteDestination(shellCommand(input));
|
||||
if (typeof candidate !== 'string' || !candidate.trim()) return '';
|
||||
return path.isAbsolute(candidate) ? candidate : path.resolve(cwd, candidate);
|
||||
}
|
||||
|
||||
function proposedContent(event, cwd, filePath) {
|
||||
const input = toolInput(event);
|
||||
for (const key of ['content', 'streamContent', 'text']) {
|
||||
if (typeof input[key] === 'string') return input[key];
|
||||
}
|
||||
|
||||
const editProjection = projectedEditContent(input, filePath, cwd);
|
||||
if (editProjection !== undefined) return editProjection;
|
||||
|
||||
if (hasFragmentEditContent(input)) {
|
||||
return { skipped: 'fragment-only-edit' };
|
||||
}
|
||||
|
||||
const command = shellCommand(input);
|
||||
const pythonContent = shellPythonWriteContent(command);
|
||||
if (pythonContent) return pythonContent;
|
||||
const shellContent = shellHereDocContent(command);
|
||||
if (shellContent) return shellContent;
|
||||
const copiedContent = shellCopiedFileContent(command, cwd);
|
||||
if (copiedContent) return copiedContent;
|
||||
return '';
|
||||
}
|
||||
|
||||
function hasFragmentEditContent(input) {
|
||||
if (!input || typeof input !== 'object') return false;
|
||||
if (typeof input.new_string === 'string' || typeof input.newString === 'string' || typeof input.new_str === 'string' || typeof input.replacement === 'string') {
|
||||
return true;
|
||||
}
|
||||
return Array.isArray(input.edits) && input.edits.some((edit) => edit && typeof edit === 'object');
|
||||
}
|
||||
|
||||
function projectedEditContent(input, filePath, cwd) {
|
||||
if (!filePath) return undefined;
|
||||
const singleOld = firstString(input, ['old_string', 'oldString', 'old_str', 'target']);
|
||||
const singleNew = firstString(input, ['new_string', 'newString', 'new_str', 'replacement']);
|
||||
if (singleOld !== undefined || singleNew !== undefined) {
|
||||
if (singleOld === undefined || singleNew === undefined) return { skipped: 'fragment-only-edit' };
|
||||
const original = readExistingProjectFile(filePath, cwd);
|
||||
if (original === null) return { skipped: 'edit-original-unreadable' };
|
||||
const projected = replaceOnce(original, singleOld, singleNew);
|
||||
return projected === null ? { skipped: 'edit-old-string-missing' } : projected;
|
||||
}
|
||||
|
||||
if (!Array.isArray(input.edits)) return undefined;
|
||||
const original = readExistingProjectFile(filePath, cwd);
|
||||
if (original === null) return { skipped: 'edit-original-unreadable' };
|
||||
|
||||
let projected = original;
|
||||
for (const edit of input.edits) {
|
||||
if (!edit || typeof edit !== 'object') return { skipped: 'fragment-only-edit' };
|
||||
const oldString = firstString(edit, ['old_string', 'oldString', 'old_str', 'target']);
|
||||
const newString = firstString(edit, ['new_string', 'newString', 'new_str', 'replacement']);
|
||||
if (oldString === undefined || newString === undefined) return { skipped: 'fragment-only-edit' };
|
||||
const next = replaceOnce(projected, oldString, newString);
|
||||
if (next === null) return { skipped: 'edit-old-string-missing' };
|
||||
projected = next;
|
||||
}
|
||||
return projected;
|
||||
}
|
||||
|
||||
function firstString(obj, keys) {
|
||||
for (const key of keys) {
|
||||
if (typeof obj?.[key] === 'string') return obj[key];
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function replaceOnce(original, oldString, newString) {
|
||||
if (oldString === '') return null;
|
||||
const index = original.indexOf(oldString);
|
||||
if (index === -1) return null;
|
||||
return `${original.slice(0, index)}${newString}${original.slice(index + oldString.length)}`;
|
||||
}
|
||||
|
||||
function readExistingProjectFile(filePath, cwd) {
|
||||
if (!isInsideProject(filePath, cwd)) return null;
|
||||
if (SENSITIVE_PATH.test(filePath) || GENERATED_PATH.test(filePath)) return null;
|
||||
try {
|
||||
const stat = fs.statSync(filePath);
|
||||
if (!stat.isFile() || stat.size > 1024 * 1024) return null;
|
||||
return fs.readFileSync(filePath, 'utf-8');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function shellCommand(input) {
|
||||
if (typeof input.command === 'string') return input.command;
|
||||
if (input.args && typeof input.args.command === 'string') return input.args.command;
|
||||
return '';
|
||||
}
|
||||
|
||||
function shellRedirectPath(command) {
|
||||
if (!command || typeof command !== 'string') return '';
|
||||
const match = command.match(/(?:^|[\s;&|])(?:>>?|1>>?)\s*(?:"([^"]+)"|'([^']+)'|([^<>\s]+))/);
|
||||
return (match?.[1] || match?.[2] || match?.[3] || '').trim();
|
||||
}
|
||||
|
||||
function shellWriteDestination(command) {
|
||||
return shellRedirectPath(command) || shellTeeDestination(command) || shellCopyPaths(command)?.dest || shellPythonWriteDestination(command) || '';
|
||||
}
|
||||
|
||||
function shellPythonWriteDestination(command) {
|
||||
if (!/\bpython(?:3)?\b/.test(command || '')) return '';
|
||||
const directPath = firstMatch(command, /(?:^|[^\w.])(?:pathlib\.)?Path\(\s*(["'])(.*?)\1\s*\)\s*\.write_text\s*\(/);
|
||||
if (directPath) return directPath;
|
||||
|
||||
const pathsByVar = new Map();
|
||||
const assignmentRe = /\b([A-Za-z_]\w*)\s*=\s*(?:pathlib\.)?Path\(\s*(["'])(.*?)\2\s*\)/g;
|
||||
let assignment;
|
||||
while ((assignment = assignmentRe.exec(command))) {
|
||||
pathsByVar.set(assignment[1], assignment[3]);
|
||||
}
|
||||
|
||||
const writeVarRe = /\b([A-Za-z_]\w*)\.write_text\s*\(/g;
|
||||
let writeVar;
|
||||
while ((writeVar = writeVarRe.exec(command))) {
|
||||
const candidate = pathsByVar.get(writeVar[1]);
|
||||
if (candidate) return candidate;
|
||||
}
|
||||
|
||||
return firstMatch(command, /\bopen\(\s*(["'])(.*?)\1\s*,\s*(["'])[wax](?:\+)?b?\3/);
|
||||
}
|
||||
|
||||
function firstMatch(value, re) {
|
||||
const match = String(value || '').match(re);
|
||||
return (match?.[2] || '').trim();
|
||||
}
|
||||
|
||||
function shellTeeDestination(command) {
|
||||
const words = shellWords(command);
|
||||
const teeIndex = words.findIndex((word) => path.basename(word) === 'tee');
|
||||
if (teeIndex === -1) return '';
|
||||
for (const word of words.slice(teeIndex + 1)) {
|
||||
if (['&&', '||', ';', '|'].includes(word)) break;
|
||||
if (word === '--') continue;
|
||||
if (word.startsWith('-')) continue;
|
||||
return word;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function shellCopiedFileContent(command, cwd) {
|
||||
const source = shellCopyPaths(command)?.source;
|
||||
if (!source) return '';
|
||||
const sourcePath = path.isAbsolute(source) ? source : path.resolve(cwd, source);
|
||||
if (!isInsideProject(sourcePath, cwd)) return '';
|
||||
if (SENSITIVE_PATH.test(sourcePath) || GENERATED_PATH.test(sourcePath)) return '';
|
||||
try {
|
||||
const stat = fs.statSync(sourcePath);
|
||||
if (!stat.isFile() || stat.size > 1024 * 1024) return '';
|
||||
return fs.readFileSync(sourcePath, 'utf-8');
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function shellCopyPaths(command) {
|
||||
const words = shellWords(command);
|
||||
if (words.length < 3 || path.basename(words[0]) !== 'cp') return null;
|
||||
const args = [];
|
||||
for (const word of words.slice(1)) {
|
||||
if (['&&', '||', ';', '|'].includes(word)) break;
|
||||
if (word === '--') continue;
|
||||
if (word.startsWith('-')) continue;
|
||||
args.push(word);
|
||||
}
|
||||
if (args.length < 2) return null;
|
||||
return { source: args[args.length - 2], dest: args[args.length - 1] };
|
||||
}
|
||||
|
||||
function shellWords(command) {
|
||||
if (!command || typeof command !== 'string') return [];
|
||||
const words = [];
|
||||
const re = /"((?:\\"|[^"])*)"|'((?:\\'|[^'])*)'|([^\s]+)/g;
|
||||
let match;
|
||||
while ((match = re.exec(command))) {
|
||||
words.push((match[1] ?? match[2] ?? match[3] ?? '').replace(/\\(["'])/g, '$1'));
|
||||
}
|
||||
return words;
|
||||
}
|
||||
|
||||
function shellHereDocContent(command) {
|
||||
if (!command || typeof command !== 'string') return '';
|
||||
const markerMatch = command.match(/<<-?\s*['"]?([A-Za-z0-9_.-]+)['"]?[^\r\n]*\r?\n/);
|
||||
if (!markerMatch) return '';
|
||||
const marker = markerMatch[1];
|
||||
const start = (markerMatch.index || 0) + markerMatch[0].length;
|
||||
const rest = command.slice(start);
|
||||
const endRe = new RegExp(`\\r?\\n${escapeRegExp(marker)}(?:\\r?\\n|$)`);
|
||||
const end = rest.search(endRe);
|
||||
return end >= 0 ? rest.slice(0, end) : '';
|
||||
}
|
||||
|
||||
function shellPythonWriteContent(command) {
|
||||
if (!/\bpython(?:3)?\b/.test(command || '')) return '';
|
||||
const script = shellHereDocContent(command) || command;
|
||||
return pythonStringArg(script, /\.write_text\s*\(\s*/g) || pythonStringArg(script, /\.write\s*\(\s*/g);
|
||||
}
|
||||
|
||||
function pythonStringArg(script, prefixRe) {
|
||||
let prefix;
|
||||
while ((prefix = prefixRe.exec(script))) {
|
||||
const start = prefixRe.lastIndex;
|
||||
const triple = script.slice(start, start + 3);
|
||||
if (triple === "'''" || triple === '"""') {
|
||||
const end = script.indexOf(triple, start + 3);
|
||||
if (end !== -1) return script.slice(start + 3, end);
|
||||
continue;
|
||||
}
|
||||
const quote = script[start];
|
||||
if (quote !== '"' && quote !== "'") continue;
|
||||
let out = '';
|
||||
for (let i = start + 1; i < script.length; i++) {
|
||||
const ch = script[i];
|
||||
if (ch === '\\') {
|
||||
out += script[i + 1] || '';
|
||||
i += 1;
|
||||
} else if (ch === quote) {
|
||||
return out;
|
||||
} else {
|
||||
out += ch;
|
||||
}
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function escapeRegExp(value) {
|
||||
return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
function relativePath(filePath, cwd) {
|
||||
try {
|
||||
const rel = path.relative(cwd, filePath);
|
||||
if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return filePath;
|
||||
return rel.split(path.sep).join('/');
|
||||
} catch {
|
||||
return filePath;
|
||||
}
|
||||
}
|
||||
|
||||
function isInsideProject(filePath, cwd) {
|
||||
try {
|
||||
const rel = path.relative(cwd, filePath);
|
||||
return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function cursorBlockMessage(findings, filePath, config, cwd) {
|
||||
const rendered = renderTemplate(findings, filePath, config, { cwd });
|
||||
const blocked = rendered.replace(
|
||||
'[impeccable@1] Required design corrections',
|
||||
'[impeccable@1] Impeccable design hook blocked this write before it landed. Required design corrections',
|
||||
);
|
||||
return blocked.length > 4000 ? `${blocked.slice(0, 3984)}\n...(truncated)` : blocked;
|
||||
}
|
||||
|
||||
function findingSignature(findings) {
|
||||
return findings
|
||||
.map((finding) => `${finding.antipattern || 'unknown'}:${finding.line || 0}`)
|
||||
.sort()
|
||||
.join('|');
|
||||
}
|
||||
|
||||
function bumpCursorDenial(cache, sessionId, filePath, findings) {
|
||||
const session = cache.sessions[sessionId] || { updatedAt: Date.now(), files: {} };
|
||||
cache.sessions[sessionId] = session;
|
||||
session.updatedAt = Date.now();
|
||||
const fileEntry = session.files[filePath] || { editCount: 0, findings: [] };
|
||||
session.files[filePath] = fileEntry;
|
||||
const key = findingSignature(findings);
|
||||
fileEntry.cursorDenials = fileEntry.cursorDenials && typeof fileEntry.cursorDenials === 'object'
|
||||
? fileEntry.cursorDenials
|
||||
: {};
|
||||
fileEntry.cursorDenials[key] = (fileEntry.cursorDenials[key] || 0) + 1;
|
||||
return { key, count: fileEntry.cursorDenials[key] };
|
||||
}
|
||||
|
||||
async function main() {
|
||||
if (truthy(process.env.IMPECCABLE_HOOK_DISABLED)) {
|
||||
return allow({ skipped: 'env-disabled' });
|
||||
}
|
||||
|
||||
let event = null;
|
||||
try {
|
||||
const raw = await readStdin();
|
||||
if (raw) event = JSON.parse(raw);
|
||||
} catch {
|
||||
return allow({ skipped: 'stdin-malformed' });
|
||||
}
|
||||
|
||||
if (!event || typeof event !== 'object') {
|
||||
return allow({ skipped: 'stdin-empty' });
|
||||
}
|
||||
|
||||
const cwd = resolveProjectCwd(event);
|
||||
const started = Date.now();
|
||||
const filePath = proposedFilePath(event, cwd);
|
||||
const audit = {
|
||||
harness: 'cursor',
|
||||
cwd,
|
||||
tool: event.tool_name || null,
|
||||
file: filePath || null,
|
||||
};
|
||||
|
||||
if (!filePath) return allow({ ...audit, skipped: 'no-file-path', durationMs: Date.now() - started });
|
||||
if (!isInsideProject(filePath, cwd)) return allow({ ...audit, skipped: 'outside-project', durationMs: Date.now() - started });
|
||||
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 });
|
||||
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
audit.ext = ext;
|
||||
if (!ALLOWED_EXTS.has(ext)) return allow({ ...audit, skipped: 'extension', durationMs: Date.now() - started });
|
||||
|
||||
const contentResult = proposedContent(event, cwd, filePath);
|
||||
if (contentResult && typeof contentResult === 'object' && contentResult.skipped) {
|
||||
return allow({ ...audit, skipped: contentResult.skipped, durationMs: Date.now() - started });
|
||||
}
|
||||
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 });
|
||||
|
||||
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 });
|
||||
}
|
||||
|
||||
const detector = await loadDetector();
|
||||
if (!detector || typeof detector.detectText !== 'function') {
|
||||
return allow({ ...audit, skipped: 'detector-missing', durationMs: Date.now() - started });
|
||||
}
|
||||
|
||||
let findings = [];
|
||||
try {
|
||||
findings = await detector.detectText(content, filePath);
|
||||
} catch {
|
||||
return allow({ ...audit, error: 'detector-threw', durationMs: Date.now() - started });
|
||||
}
|
||||
|
||||
const filtered = filterFindings(findings || [], content, ext, config);
|
||||
if (filtered.length === 0) {
|
||||
return allow({
|
||||
...audit,
|
||||
findings: (findings || []).length,
|
||||
blockedFindings: 0,
|
||||
durationMs: Date.now() - started,
|
||||
});
|
||||
}
|
||||
|
||||
const message = cursorBlockMessage(filtered, filePath, config, cwd);
|
||||
const sessionId = event.session_id || event.conversation_id || 'unknown';
|
||||
const cache = readCache(cwd);
|
||||
const denial = bumpCursorDenial(cache, sessionId, filePath, filtered);
|
||||
persistCache(cwd, cache);
|
||||
if (denial.count > EDIT_COUNT_THRESHOLD) {
|
||||
const warning = `${message}\n\nThis is the ${denial.count}th repeated denial for the same file and finding signature, so Impeccable is allowing this write to avoid a loop. Reconsider the issue immediately after the tool runs.`;
|
||||
return allow({
|
||||
...audit,
|
||||
findings: (findings || []).length,
|
||||
blockedFindings: filtered.length,
|
||||
cursorDenialKey: denial.key,
|
||||
cursorDenialCount: denial.count,
|
||||
downgraded: true,
|
||||
chars: warning.length,
|
||||
durationMs: Date.now() - started,
|
||||
}, {
|
||||
user_message: warning,
|
||||
agent_message: warning,
|
||||
});
|
||||
}
|
||||
return deny(message, {
|
||||
...audit,
|
||||
findings: (findings || []).length,
|
||||
blockedFindings: filtered.length,
|
||||
cursorDenialKey: denial.key,
|
||||
cursorDenialCount: denial.count,
|
||||
chars: message.length,
|
||||
durationMs: Date.now() - started,
|
||||
});
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
if (process.env.IMPECCABLE_HOOK_DEBUG) {
|
||||
process.stderr.write(`[impeccable-hook-before-edit] ${err}\n`);
|
||||
}
|
||||
done({ permission: 'allow' });
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,61 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Impeccable design hook — PostToolUse entry point.
|
||||
*
|
||||
* Reads the Claude Code / Codex / Cursor hook event from stdin, runs the design
|
||||
* detector against the touched file, and emits a system reminder via
|
||||
* `hookSpecificOutput.additionalContext` when findings exist.
|
||||
*
|
||||
* Contract: never break a turn. Always exit 0. Clean files emit a small ack
|
||||
* unless quiet mode is enabled.
|
||||
*
|
||||
* Most logic lives in `hook-lib.mjs` so it is unit-testable without a
|
||||
* subprocess. This file is the thin stdin/stdout adapter.
|
||||
*/
|
||||
|
||||
import { runHook, writeAuditLog } from './hook-lib.mjs';
|
||||
|
||||
async function readStdin() {
|
||||
if (process.stdin.isTTY) return '';
|
||||
const chunks = [];
|
||||
for await (const chunk of process.stdin) chunks.push(chunk);
|
||||
return Buffer.concat(chunks).toString('utf-8');
|
||||
}
|
||||
|
||||
async function main() {
|
||||
// Snapshot the inherited env FIRST so the re-entrancy guard checks the
|
||||
// parent's value, not the value we are about to export for any child
|
||||
// processes the hook might ever spawn.
|
||||
const inheritedEnv = { ...process.env };
|
||||
process.env.IMPECCABLE_HOOK_DEPTH = process.env.IMPECCABLE_HOOK_DEPTH || '1';
|
||||
|
||||
let stdinJson = '';
|
||||
try { stdinJson = await readStdin(); } catch { /* fall through */ }
|
||||
|
||||
const result = await runHook({
|
||||
stdinJson,
|
||||
env: inheritedEnv,
|
||||
cwd: process.cwd(),
|
||||
});
|
||||
|
||||
writeAuditLog(process.env, result.audit, process.cwd());
|
||||
|
||||
if (result.stdout) process.stdout.write(result.stdout);
|
||||
process.exit(result.exitCode || 0);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
// Last-ditch: never break the agent's turn even if something we did not
|
||||
// anticipate goes wrong. Audit-log the failure if logging is enabled.
|
||||
try {
|
||||
writeAuditLog(process.env, {
|
||||
ts: new Date().toISOString(),
|
||||
event: 'PostToolUse',
|
||||
error: String(err && err.message ? err.message : err),
|
||||
});
|
||||
} catch { /* swallow */ }
|
||||
if (process.env.IMPECCABLE_HOOK_DEBUG) {
|
||||
process.stderr.write(`[impeccable-hook] ${err}\n`);
|
||||
}
|
||||
process.exit(0);
|
||||
});
|
||||
@@ -15,8 +15,14 @@
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { isGeneratedFile } from './is-generated.mjs';
|
||||
import { readBuffer as readManualEditsBuffer, writeBuffer as writeManualEditsBuffer } from './live-manual-edits-buffer.mjs';
|
||||
import { isGeneratedFile } from './lib/is-generated.mjs';
|
||||
import { readBuffer as readManualEditsBuffer, writeBuffer as writeManualEditsBuffer } from './live/manual-edits-buffer.mjs';
|
||||
import {
|
||||
applyDeferredSvelteComponentAccepts,
|
||||
findSvelteComponentManifest,
|
||||
inlineSvelteComponentAccept,
|
||||
removeSvelteComponentSession,
|
||||
} from './live/svelte-component.mjs';
|
||||
|
||||
const EXTENSIONS = ['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro'];
|
||||
|
||||
@@ -41,6 +47,9 @@ Required:
|
||||
|
||||
Options:
|
||||
--page-url URL Current browser page URL; scopes staged copy-edit cleanup
|
||||
--defer-source-write
|
||||
Deprecated compatibility flag. Svelte component accepts
|
||||
now write the real source immediately.
|
||||
|
||||
Output (JSON):
|
||||
{ handled, file, carbonize }`);
|
||||
@@ -64,18 +73,67 @@ Output (JSON):
|
||||
|
||||
// Find the file containing this session's markers
|
||||
const found = findSessionFile(id, process.cwd());
|
||||
if (!found) {
|
||||
const svelteComponentManifest = found ? null : findSvelteComponentManifest(id, process.cwd());
|
||||
|
||||
if (!found && !svelteComponentManifest) {
|
||||
console.log(JSON.stringify({ handled: false, error: 'Session markers not found for id: ' + id }));
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (svelteComponentManifest) {
|
||||
if (isDiscard) {
|
||||
removeSvelteComponentSession(id, process.cwd());
|
||||
console.log(JSON.stringify({
|
||||
handled: true,
|
||||
file: svelteComponentManifest.sourceFile,
|
||||
carbonize: false,
|
||||
previewMode: 'svelte-component',
|
||||
componentDir: svelteComponentManifest.componentDir,
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
let result;
|
||||
try {
|
||||
result = inlineSvelteComponentAccept(
|
||||
svelteComponentManifest,
|
||||
variantNum,
|
||||
paramValues,
|
||||
process.cwd(),
|
||||
);
|
||||
} catch (err) {
|
||||
result = {
|
||||
handled: false,
|
||||
error: err.message,
|
||||
file: svelteComponentManifest.sourceFile,
|
||||
sourceFile: svelteComponentManifest.sourceFile,
|
||||
previewMode: 'svelte-component',
|
||||
componentDir: svelteComponentManifest.componentDir,
|
||||
};
|
||||
}
|
||||
if (result.carbonize) {
|
||||
result.todo = 'REQUIRED before next poll: carbonize cleanup in ' + result.file + '. See reference/live.md "Required after accept".';
|
||||
}
|
||||
console.log(JSON.stringify({ handled: result.handled !== false, ...result }));
|
||||
return;
|
||||
}
|
||||
|
||||
const { file: targetFile, content, lines } = found;
|
||||
const relFile = path.relative(process.cwd(), targetFile);
|
||||
const previewBlock = findMarkerBlock(id, lines);
|
||||
const sourceShadowPreview = previewBlock
|
||||
? readSourceShadowPreviewMeta(content, id)
|
||||
: null;
|
||||
|
||||
if (sourceShadowPreview) {
|
||||
console.log(JSON.stringify({
|
||||
handled: false,
|
||||
error: 'source_shadow_preview_deprecated',
|
||||
hint: 'Svelte live mode now uses svelte-component injection. Re-wrap the element and regenerate variants.',
|
||||
}));
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// Bail if the session lives in a generated file. The agent manually wrote
|
||||
// the wrapper there for preview, and is responsible for writing the
|
||||
// accepted variant to true source (or cleaning up on discard). See
|
||||
// "Handle fallback" in live.md.
|
||||
if (isGeneratedFile(targetFile, { cwd: process.cwd() })) {
|
||||
console.log(JSON.stringify({
|
||||
handled: false,
|
||||
@@ -207,6 +265,71 @@ function handleDiscard(id, lines, targetFile) {
|
||||
// Accept
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Build carbonize stitch-in lines. JSX targets occupy a single child slot
|
||||
* (ternary branch, return value, etc.) — the same constraint as live-wrap.
|
||||
* When isJsx, tuck markers + <style> + variant wrapper inside one outer
|
||||
* <div data-impeccable-carbonize> so the slot keeps a single root node.
|
||||
*/
|
||||
function buildCarbonizeReplacement({
|
||||
indent,
|
||||
commentSyntax,
|
||||
isJsx,
|
||||
id,
|
||||
variantNum,
|
||||
cssContent,
|
||||
paramValues,
|
||||
restored,
|
||||
}) {
|
||||
const lines = [];
|
||||
if (!cssContent) {
|
||||
lines.push(...restored);
|
||||
return lines;
|
||||
}
|
||||
|
||||
const variantStyleAttr = isJsx
|
||||
? "style={{ display: 'contents' }}"
|
||||
: 'style="display: contents"';
|
||||
|
||||
const pushCarbonizeBody = (bodyIndent) => {
|
||||
const bodyRestored = reindentContent(restored, indent, bodyIndent + ' ');
|
||||
lines.push(bodyIndent + commentSyntax.open + ' impeccable-carbonize-start ' + id + ' ' + commentSyntax.close);
|
||||
lines.push(bodyIndent + '<style data-impeccable-css="' + id + '">' + (isJsx ? '{`' : ''));
|
||||
for (const cssLine of cssContent) {
|
||||
lines.push(bodyIndent + cssLine.trimStart());
|
||||
}
|
||||
lines.push(bodyIndent + (isJsx ? '`}</style>' : '</style>'));
|
||||
if (paramValues && Object.keys(paramValues).length > 0) {
|
||||
lines.push(
|
||||
bodyIndent + commentSyntax.open + ' impeccable-param-values ' + id + ': ' + JSON.stringify(paramValues) + ' ' + commentSyntax.close,
|
||||
);
|
||||
}
|
||||
lines.push(bodyIndent + commentSyntax.open + ' impeccable-carbonize-end ' + id + ' ' + commentSyntax.close);
|
||||
lines.push(bodyIndent + '<div data-impeccable-variant="' + variantNum + '" ' + variantStyleAttr + '>');
|
||||
lines.push(...bodyRestored);
|
||||
lines.push(bodyIndent + '</div>');
|
||||
};
|
||||
|
||||
if (isJsx) {
|
||||
const wrapperStyle = 'style={{ display: "contents" }}';
|
||||
lines.push(indent + '<div data-impeccable-carbonize="' + id + '" ' + wrapperStyle + '>');
|
||||
pushCarbonizeBody(indent + ' ');
|
||||
lines.push(indent + '</div>');
|
||||
} else {
|
||||
pushCarbonizeBody(indent);
|
||||
}
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
function reindentContent(contentLines, fromIndent, toIndent) {
|
||||
return contentLines.map((line) => {
|
||||
if (line.trim() === '') return '';
|
||||
if (line.startsWith(fromIndent)) return toIndent + line.slice(fromIndent.length);
|
||||
return toIndent + line.trimStart();
|
||||
});
|
||||
}
|
||||
|
||||
function handleAccept(id, variantNum, lines, targetFile, paramValues) {
|
||||
const block = findMarkerBlock(id, lines);
|
||||
if (!block) return { handled: false, error: 'Markers not found' };
|
||||
@@ -235,45 +358,17 @@ function handleAccept(id, variantNum, lines, targetFile, paramValues) {
|
||||
const hasHelperAttrs = variantText.includes('data-impeccable-variant');
|
||||
const needsCarbonize = !!(cssContent || hasHelperAttrs);
|
||||
|
||||
// Build the replacement
|
||||
const restored = deindentContent(variantContent, indent);
|
||||
const replacement = [];
|
||||
|
||||
if (cssContent) {
|
||||
replacement.push(indent + commentSyntax.open + ' impeccable-carbonize-start ' + id + ' ' + commentSyntax.close);
|
||||
// JSX targets need the CSS body wrapped in a template literal so that the
|
||||
// `{` and `}` in CSS rules don't get parsed as JSX expressions.
|
||||
replacement.push(indent + '<style data-impeccable-css="' + id + '">' + (isJsx ? '{`' : ''));
|
||||
// Re-indent CSS content to match
|
||||
for (const cssLine of cssContent) {
|
||||
replacement.push(indent + cssLine.trimStart());
|
||||
}
|
||||
replacement.push(indent + (isJsx ? '`}</style>' : '</style>'));
|
||||
if (paramValues && Object.keys(paramValues).length > 0) {
|
||||
// Preserve the user's knob positions for the carbonize-cleanup agent
|
||||
// to bake into the final CSS when it collapses scoped rules.
|
||||
replacement.push(indent + commentSyntax.open + ' impeccable-param-values ' + id + ': ' + JSON.stringify(paramValues) + ' ' + commentSyntax.close);
|
||||
}
|
||||
replacement.push(indent + commentSyntax.open + ' impeccable-carbonize-end ' + id + ' ' + commentSyntax.close);
|
||||
}
|
||||
|
||||
// Keep the `@scope ([data-impeccable-variant="N"])` selectors in the
|
||||
// carbonize CSS block working visually by re-wrapping the accepted content
|
||||
// in a data-impeccable-variant="N" div with `display: contents` (so layout
|
||||
// isn't affected). The carbonize agent strips this attribute + wrapper when
|
||||
// it moves the CSS to a proper stylesheet.
|
||||
//
|
||||
// Style attribute syntax has to follow the host file's flavor — JSX files
|
||||
// need the object form, otherwise React 19 throws "Failed to set indexed
|
||||
// property [0] on CSSStyleDeclaration" while parsing the string char-by-char.
|
||||
if (cssContent) {
|
||||
const styleAttr = isJsx ? "style={{ display: 'contents' }}" : 'style="display: contents"';
|
||||
replacement.push(indent + '<div data-impeccable-variant="' + variantNum + '" ' + styleAttr + '>');
|
||||
replacement.push(...restored);
|
||||
replacement.push(indent + '</div>');
|
||||
} else {
|
||||
replacement.push(...restored);
|
||||
}
|
||||
const replacement = buildCarbonizeReplacement({
|
||||
indent,
|
||||
commentSyntax,
|
||||
isJsx,
|
||||
id,
|
||||
variantNum,
|
||||
cssContent,
|
||||
paramValues,
|
||||
restored,
|
||||
});
|
||||
|
||||
const newLines = [
|
||||
...lines.slice(0, replaceRange.start),
|
||||
@@ -285,6 +380,34 @@ function handleAccept(id, variantNum, lines, targetFile, paramValues) {
|
||||
return { carbonize: needsCarbonize, acceptedOriginalText: originalContent.join('\n') };
|
||||
}
|
||||
|
||||
function readSourceShadowPreviewMeta(content, id) {
|
||||
const escaped = escapeRegExp(id);
|
||||
const wrapperRe = new RegExp('<[^>]+data-impeccable-variants=(["\'])' + escaped + '\\1[^>]*>');
|
||||
const match = String(content || '').match(wrapperRe);
|
||||
if (!match) return null;
|
||||
const tag = match[0];
|
||||
if (readHtmlAttr(tag, 'data-impeccable-preview') !== 'source-shadow') return null;
|
||||
const sourceFile = readHtmlAttr(tag, 'data-impeccable-source-file');
|
||||
const sourceStartLine = Number(readHtmlAttr(tag, 'data-impeccable-source-start'));
|
||||
const sourceEndLine = Number(readHtmlAttr(tag, 'data-impeccable-source-end'));
|
||||
if (!sourceFile || !Number.isFinite(sourceStartLine) || !Number.isFinite(sourceEndLine)) return null;
|
||||
return { sourceFile, sourceStartLine, sourceEndLine };
|
||||
}
|
||||
|
||||
function readHtmlAttr(tag, name) {
|
||||
const match = String(tag || '').match(new RegExp('\\s' + escapeRegExp(name) + '\\s*=\\s*(["\'])(.*?)\\1'));
|
||||
if (!match) return null;
|
||||
return decodeHtmlAttr(match[2]);
|
||||
}
|
||||
|
||||
function decodeHtmlAttr(value) {
|
||||
return String(value || '')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/&/g, '&');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Parsing helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -686,4 +809,4 @@ if (_running?.endsWith('live-accept.mjs') || _running?.endsWith('live-accept.mjs
|
||||
acceptCli();
|
||||
}
|
||||
|
||||
export { findMarkerBlock, extractOriginal, extractVariant, extractCss, deindentContent, detectCommentSyntax, scrubManualEditsAgainstFile, scrubManualEditsAgainstOriginalBlock };
|
||||
export { findMarkerBlock, extractOriginal, extractVariant, extractCss, deindentContent, detectCommentSyntax, scrubManualEditsAgainstFile, scrubManualEditsAgainstOriginalBlock, applyDeferredSvelteComponentAccepts };
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* Browser-side DOM helpers for Impeccable live mode.
|
||||
*
|
||||
* Kept separate from live-browser.js so future browser script parts can share
|
||||
* chrome mounting, lookup, focus, and picker helpers without depending on the
|
||||
* full overlay UI bundle.
|
||||
*/
|
||||
(function (root) {
|
||||
'use strict';
|
||||
if (!root) return;
|
||||
|
||||
function createLiveBrowserDomHelpers({
|
||||
prefix,
|
||||
skipTags,
|
||||
document: doc = root.document,
|
||||
css = root.CSS,
|
||||
crypto = root.crypto,
|
||||
} = {}) {
|
||||
if (!prefix) throw new Error('prefix required');
|
||||
if (!doc) throw new Error('document required');
|
||||
const tagsToSkip = skipTags || new Set();
|
||||
|
||||
function own(el) {
|
||||
return el && (el.id?.startsWith(prefix) || el.closest?.('[id^="' + prefix + '"]'));
|
||||
}
|
||||
|
||||
function pickable(el) {
|
||||
if (!el || el.nodeType !== 1) return false;
|
||||
if (tagsToSkip.has(String(el.tagName || '').toLowerCase())) return false;
|
||||
if (own(el)) return false;
|
||||
const r = el.getBoundingClientRect();
|
||||
return r.width >= 20 && r.height >= 20;
|
||||
}
|
||||
|
||||
function desc(el) {
|
||||
if (!el) return '';
|
||||
let s = el.tagName.toLowerCase();
|
||||
if (el.id) s += '#' + el.id;
|
||||
else if (el.classList.length) s += '.' + [...el.classList].slice(0, 2).join('.');
|
||||
return s;
|
||||
}
|
||||
|
||||
function rectIsUsableAnchor(rect) {
|
||||
return !!rect && rect.width > 0.5 && rect.height > 0.5;
|
||||
}
|
||||
|
||||
function makeFrozenAnchor(el) {
|
||||
if (!el || !el.getBoundingClientRect) return null;
|
||||
const r = el.getBoundingClientRect();
|
||||
if (!rectIsUsableAnchor(r)) return null;
|
||||
const rect = {
|
||||
x: r.x, y: r.y,
|
||||
top: r.top, left: r.left,
|
||||
right: r.right, bottom: r.bottom,
|
||||
width: r.width, height: r.height,
|
||||
};
|
||||
return {
|
||||
__impeccableFrozenAnchor: true,
|
||||
tagName: el.tagName || 'DIV',
|
||||
id: el.id || '',
|
||||
classList: el.classList ? [...el.classList] : [],
|
||||
hasAttribute: () => false,
|
||||
getBoundingClientRect: () => rect,
|
||||
};
|
||||
}
|
||||
|
||||
function id8() {
|
||||
if (crypto?.randomUUID) return crypto.randomUUID().replace(/-/g, '').slice(0, 8);
|
||||
return (Math.random().toString(16).slice(2) + Date.now().toString(16)).slice(0, 8);
|
||||
}
|
||||
|
||||
function cssId(id) {
|
||||
if (css?.escape) return css.escape(id);
|
||||
return String(id).replace(/([ !"#$%&'()*+,./:;<=>?@[\\\]^`{|}~])/g, '\\$1');
|
||||
}
|
||||
|
||||
function liveUiRoot() {
|
||||
const uiRoot = root.__IMPECCABLE_LIVE_UI_ROOT__;
|
||||
if (uiRoot && typeof uiRoot.appendChild === 'function') return uiRoot;
|
||||
return doc.body;
|
||||
}
|
||||
|
||||
function uiAppend(el) {
|
||||
liveUiRoot().appendChild(el);
|
||||
return el;
|
||||
}
|
||||
|
||||
function uiAppendStyle(styleEl) {
|
||||
const uiRoot = liveUiRoot();
|
||||
if (uiRoot && uiRoot !== doc.body) uiRoot.appendChild(styleEl);
|
||||
else doc.head.appendChild(styleEl);
|
||||
return styleEl;
|
||||
}
|
||||
|
||||
function uiGetById(id) {
|
||||
const uiRoot = liveUiRoot();
|
||||
if (uiRoot?.getElementById) {
|
||||
const found = uiRoot.getElementById(id);
|
||||
if (found) return found;
|
||||
}
|
||||
if (uiRoot?.querySelector) {
|
||||
const found = uiRoot.querySelector('#' + cssId(id));
|
||||
if (found) return found;
|
||||
}
|
||||
return doc.getElementById(id);
|
||||
}
|
||||
|
||||
function activeElementDeep() {
|
||||
let active = doc.activeElement;
|
||||
while (active?.shadowRoot?.activeElement) active = active.shadowRoot.activeElement;
|
||||
return active;
|
||||
}
|
||||
|
||||
function defangOutsideHandlers(rootEl, { setPointerEvents = true } = {}) {
|
||||
if (!rootEl) return;
|
||||
if (setPointerEvents) {
|
||||
rootEl.style.setProperty('pointer-events', 'auto', 'important');
|
||||
}
|
||||
const stop = (e) => e.stopPropagation();
|
||||
rootEl.addEventListener('pointerdown', stop);
|
||||
rootEl.addEventListener('mousedown', stop);
|
||||
rootEl.addEventListener('focusin', stop);
|
||||
}
|
||||
|
||||
return {
|
||||
own,
|
||||
pickable,
|
||||
desc,
|
||||
rectIsUsableAnchor,
|
||||
makeFrozenAnchor,
|
||||
id8,
|
||||
cssId,
|
||||
liveUiRoot,
|
||||
uiAppend,
|
||||
uiAppendStyle,
|
||||
uiGetById,
|
||||
activeElementDeep,
|
||||
defangOutsideHandlers,
|
||||
};
|
||||
}
|
||||
|
||||
root.__IMPECCABLE_LIVE_DOM__ = {
|
||||
version: 1,
|
||||
createLiveBrowserDomHelpers,
|
||||
};
|
||||
})(typeof window !== 'undefined' ? window : globalThis);
|
||||
File diff suppressed because it is too large
Load Diff
@@ -16,8 +16,8 @@
|
||||
*/
|
||||
|
||||
import { buildManualEditEvidence } from './live-manual-edit-evidence.mjs';
|
||||
import { readBuffer, readBufferStrict, writeBuffer, countByPage } from './live-manual-edits-buffer.mjs';
|
||||
import { isGeneratedFile } from './is-generated.mjs';
|
||||
import { readBuffer, readBufferStrict, writeBuffer, countByPage } from './live/manual-edits-buffer.mjs';
|
||||
import { isGeneratedFile } from './lib/is-generated.mjs';
|
||||
import {
|
||||
runCopyEditBatchAgent,
|
||||
runCopyEditPostApplyChecks,
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
* Canonical durable completion acknowledgement for Impeccable live sessions.
|
||||
*/
|
||||
|
||||
import { createLiveSessionStore } from './live-session-store.mjs';
|
||||
import { readLiveServerInfo } from './impeccable-paths.mjs';
|
||||
import { createLiveSessionStore } from './live/session-store.mjs';
|
||||
import { readLiveServerInfo } from './lib/impeccable-paths.mjs';
|
||||
|
||||
function parseArgs(argv) {
|
||||
const out = { status: 'complete' };
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
* Output JSON: { discarded: N, entries: [...discardedEntries], totalCount: N }
|
||||
*/
|
||||
|
||||
import { readBuffer, removeEntries, truncateBuffer } from './live-manual-edits-buffer.mjs';
|
||||
import { readBuffer, removeEntries, truncateBuffer } from './live/manual-edits-buffer.mjs';
|
||||
|
||||
function argVal(args, name) {
|
||||
const prefix = name + '=';
|
||||
|
||||
@@ -16,12 +16,41 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { resolveLiveConfigPath } from './impeccable-paths.mjs';
|
||||
import { resolveLiveConfigPath } from './lib/impeccable-paths.mjs';
|
||||
import {
|
||||
applySvelteKitLiveAdapter,
|
||||
detectSvelteKitProject,
|
||||
removeSvelteKitLiveAdapter,
|
||||
} from './live/sveltekit-adapter.mjs';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const CONFIG_PATH = resolveLiveConfigPath({ cwd: process.cwd(), scriptsDir: __dirname });
|
||||
const MARKER_OPEN_TEXT = 'impeccable-live-start';
|
||||
const MARKER_CLOSE_TEXT = 'impeccable-live-end';
|
||||
const IGNORE_MARKER_OPEN = '# impeccable-live-ignore-start';
|
||||
const IGNORE_MARKER_CLOSE = '# impeccable-live-ignore-end';
|
||||
|
||||
export const LIVE_IGNORE_PATTERNS = Object.freeze([
|
||||
'.impeccable/hook.cache.json',
|
||||
'.impeccable/hook.pending.json',
|
||||
'.impeccable/config.local.json',
|
||||
'.impeccable/live/server.json',
|
||||
'.impeccable/live/sessions/',
|
||||
'.impeccable/live/previews/',
|
||||
'.impeccable/live/annotations/',
|
||||
'.impeccable/live/cache/',
|
||||
'.impeccable/live/manual-edit-apply-transaction.json',
|
||||
'.impeccable/live/manual-edit-events.jsonl',
|
||||
'.impeccable/live/manual-edit-evidence/',
|
||||
'.impeccable/live/pending-manual-edits.json',
|
||||
'.impeccable/live/deferred-svelte-component-accepts.json',
|
||||
'.impeccable-live.json',
|
||||
'.impeccable-live/',
|
||||
'node_modules/.impeccable-live/',
|
||||
'src/lib/impeccable/ImpeccableLiveRoot.svelte',
|
||||
'src/lib/impeccable/__runtime.js',
|
||||
'src/lib/impeccable/[0-9a-f]*/',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Hard-excluded directory patterns. These are NEVER user-facing pages and
|
||||
@@ -83,8 +112,14 @@ Output (JSON):
|
||||
validateConfig(config);
|
||||
|
||||
const resolvedFiles = resolveFiles(process.cwd(), config);
|
||||
const svelteKit = detectSvelteKitProject(process.cwd(), config);
|
||||
|
||||
if (args.includes('--remove')) {
|
||||
if (svelteKit) {
|
||||
const adapterResult = removeSvelteKitLiveAdapter({ cwd: process.cwd(), config });
|
||||
console.log(JSON.stringify({ ok: true, adapter: 'sveltekit', results: [adapterResult] }));
|
||||
return;
|
||||
}
|
||||
const results = resolvedFiles.map((relFile) => {
|
||||
const absFile = path.resolve(process.cwd(), relFile);
|
||||
if (!fs.existsSync(absFile)) return { file: relFile, error: 'file_not_found' };
|
||||
@@ -110,6 +145,13 @@ Output (JSON):
|
||||
console.error(JSON.stringify({ ok: false, error: 'missing_port' }));
|
||||
process.exit(1);
|
||||
}
|
||||
const gitIgnore = ensureLiveGitIgnores(process.cwd());
|
||||
|
||||
if (svelteKit) {
|
||||
const adapterResult = applySvelteKitLiveAdapter({ cwd: process.cwd(), port, config });
|
||||
console.log(JSON.stringify({ ok: true, port, adapter: 'sveltekit', gitIgnore, results: [adapterResult] }));
|
||||
return;
|
||||
}
|
||||
|
||||
const results = resolvedFiles.map((relFile) => {
|
||||
const absFile = path.resolve(process.cwd(), relFile);
|
||||
@@ -129,10 +171,68 @@ Output (JSON):
|
||||
};
|
||||
});
|
||||
const anyInserted = results.some((r) => r.inserted);
|
||||
console.log(JSON.stringify({ ok: anyInserted, port, results }));
|
||||
console.log(JSON.stringify({ ok: anyInserted, port, gitIgnore, results }));
|
||||
if (!anyInserted) process.exit(1);
|
||||
}
|
||||
|
||||
export function ensureLiveGitIgnores(cwd = process.cwd()) {
|
||||
const target = resolveIgnoreTarget(cwd);
|
||||
const existing = fs.existsSync(target.path) ? fs.readFileSync(target.path, 'utf-8') : '';
|
||||
const block = [
|
||||
IGNORE_MARKER_OPEN,
|
||||
...LIVE_IGNORE_PATTERNS,
|
||||
IGNORE_MARKER_CLOSE,
|
||||
].join('\n');
|
||||
const markerRe = new RegExp(`${escapeRegExp(IGNORE_MARKER_OPEN)}[\\s\\S]*?${escapeRegExp(IGNORE_MARKER_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}${prefix.endsWith('\n\n') || prefix === '' ? '' : '\n'}${block}\n`;
|
||||
}
|
||||
|
||||
if (updated !== existing) {
|
||||
fs.mkdirSync(path.dirname(target.path), { recursive: true });
|
||||
fs.writeFileSync(target.path, updated, 'utf-8');
|
||||
}
|
||||
|
||||
return {
|
||||
file: path.relative(cwd, target.path).split(path.sep).join('/'),
|
||||
mode: target.mode,
|
||||
changed: updated !== existing,
|
||||
patterns: [...LIVE_IGNORE_PATTERNS],
|
||||
};
|
||||
}
|
||||
|
||||
function resolveIgnoreTarget(cwd) {
|
||||
const gitExcludePath = resolveGitInfoExcludePath(cwd);
|
||||
if (gitExcludePath) {
|
||||
return { path: gitExcludePath, mode: 'git-info-exclude' };
|
||||
}
|
||||
return { path: path.join(cwd, '.gitignore'), mode: 'gitignore' };
|
||||
}
|
||||
|
||||
function resolveGitInfoExcludePath(cwd) {
|
||||
const dotGit = path.join(cwd, '.git');
|
||||
if (!fs.existsSync(dotGit)) return null;
|
||||
|
||||
const stat = fs.statSync(dotGit);
|
||||
if (stat.isDirectory()) return path.join(dotGit, 'info', 'exclude');
|
||||
if (!stat.isFile()) return null;
|
||||
|
||||
const body = fs.readFileSync(dotGit, 'utf-8').trim();
|
||||
const match = body.match(/^gitdir:\s*(.+)$/i);
|
||||
if (!match) return null;
|
||||
const gitDir = path.isAbsolute(match[1]) ? match[1] : path.resolve(cwd, match[1]);
|
||||
return path.join(gitDir, 'info', 'exclude');
|
||||
}
|
||||
|
||||
function escapeRegExp(value) {
|
||||
return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
/**
|
||||
* Expand config.files (which may contain glob patterns) into a literal list
|
||||
* of existing file paths relative to rootDir. Literal entries pass through;
|
||||
@@ -270,8 +370,26 @@ function buildTagBlock(syntax, port, filePath) {
|
||||
);
|
||||
}
|
||||
|
||||
function detectLineEnding(content) {
|
||||
if (content.includes('\r\n')) return '\r\n';
|
||||
if (content.includes('\r')) return '\r';
|
||||
return '\n';
|
||||
}
|
||||
|
||||
function normalizeLineEndings(content, lineEnding) {
|
||||
return lineEnding === '\n' ? content : content.replace(/\n/g, lineEnding);
|
||||
}
|
||||
|
||||
function readLineEndingAt(content, index) {
|
||||
if (content[index] === '\r' && content[index + 1] === '\n') return '\r\n';
|
||||
if (content[index] === '\n') return '\n';
|
||||
if (content[index] === '\r') return '\r';
|
||||
return '';
|
||||
}
|
||||
|
||||
function insertTag(content, config, port, filePath) {
|
||||
const block = buildTagBlock(config.commentSyntax, port, filePath);
|
||||
const lineEnding = detectLineEnding(content);
|
||||
const block = normalizeLineEndings(buildTagBlock(config.commentSyntax, port, filePath), lineEnding);
|
||||
// insertBefore: match the LAST occurrence. Anchors like `</body>` naturally
|
||||
// belong at the end, and the same literal can appear earlier in code blocks
|
||||
// within rendered documentation pages.
|
||||
@@ -285,9 +403,15 @@ function insertTag(content, config, port, filePath) {
|
||||
const idx = content.indexOf(config.insertAfter);
|
||||
if (idx === -1) return content;
|
||||
const after = idx + config.insertAfter.length;
|
||||
// Preserve a single trailing newline if the anchor didn't end with one
|
||||
const prefix = content[after] === '\n' ? content.slice(0, after + 1) : content.slice(0, after) + '\n';
|
||||
return prefix + block + content.slice(prefix.length);
|
||||
// Preserve an existing trailing newline if the anchor already has one.
|
||||
// Slice the remainder from the original anchor offset, not prefix.length:
|
||||
// in the no-newline case prefix is one char longer than the anchor (the
|
||||
// appended '\n'), so slicing by prefix.length would drop the first real
|
||||
// character after the anchor (#227).
|
||||
const existingNewline = readLineEndingAt(content, after);
|
||||
const prefix = content.slice(0, after) + (existingNewline || lineEnding);
|
||||
const rest = content.slice(after + existingNewline.length);
|
||||
return prefix + block + rest;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -303,8 +427,8 @@ function insertTag(content, config, port, filePath) {
|
||||
*/
|
||||
function removeTag(content, _syntax) {
|
||||
const patterns = [
|
||||
/([ \t]*)<!--\s*impeccable-live-start\s*-->[\s\S]*?<!--\s*impeccable-live-end\s*-->([ \t]*(?:\n|$)?)/,
|
||||
/([ \t]*)\{\/\*\s*impeccable-live-start\s*\*\/\}[\s\S]*?\{\/\*\s*impeccable-live-end\s*\*\/\}([ \t]*(?:\n|$)?)/,
|
||||
/([ \t]*)<!--\s*impeccable-live-start\s*-->[\s\S]*?<!--\s*impeccable-live-end\s*-->([ \t]*(?:\r\n|\n|\r|$)?)/,
|
||||
/([ \t]*)\{\/\*\s*impeccable-live-start\s*\*\/\}[\s\S]*?\{\/\*\s*impeccable-live-end\s*\*\/\}([ \t]*(?:\r\n|\n|\r|$)?)/,
|
||||
];
|
||||
for (const pat of patterns) {
|
||||
let changed = false;
|
||||
@@ -312,7 +436,7 @@ function removeTag(content, _syntax) {
|
||||
do {
|
||||
content = next;
|
||||
next = content.replace(pat, (_match, leadingIndent, trailing = '') => {
|
||||
if (trailing.includes('\n')) return leadingIndent;
|
||||
if (/[\r\n]/.test(trailing)) return leadingIndent;
|
||||
return leadingIndent || trailing || '';
|
||||
});
|
||||
if (next !== content) changed = true;
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { isGeneratedFile } from './is-generated.mjs';
|
||||
import { isGeneratedFile } from './lib/is-generated.mjs';
|
||||
import {
|
||||
buildSearchQueries,
|
||||
findElement,
|
||||
@@ -21,6 +21,11 @@ import {
|
||||
buildCssAuthoring,
|
||||
buildCssSelectorPrefixExamples,
|
||||
} from './live-wrap.mjs';
|
||||
import {
|
||||
buildSvelteComponentCssAuthoring,
|
||||
scaffoldSvelteComponentInsertSession,
|
||||
shouldUseSvelteComponentInjection,
|
||||
} from './live/svelte-component.mjs';
|
||||
|
||||
const INSERT_POSITIONS = new Set(['before', 'after']);
|
||||
|
||||
@@ -192,6 +197,41 @@ Output (JSON):
|
||||
const styleMode = detectStyleMode(targetFile);
|
||||
const isJsx = commentSyntax.open === '{/*';
|
||||
const spliceIndex = computeInsertLine(startLine, endLine, position);
|
||||
const relTargetFile = path.relative(process.cwd(), targetFile).split(path.sep).join('/');
|
||||
|
||||
if (shouldUseSvelteComponentInjection(targetFile)) {
|
||||
const session = scaffoldSvelteComponentInsertSession({
|
||||
id,
|
||||
count,
|
||||
sourceFile: relTargetFile,
|
||||
insertLine: spliceIndex + 1,
|
||||
position,
|
||||
anchorStartLine: startLine + 1,
|
||||
anchorEndLine: endLine + 1,
|
||||
anchorLines: lines.slice(startLine, endLine + 1),
|
||||
cwd: process.cwd(),
|
||||
});
|
||||
console.log(JSON.stringify({
|
||||
mode: 'insert',
|
||||
position,
|
||||
file: session.manifestFile,
|
||||
sourceFile: relTargetFile,
|
||||
previewMode: 'svelte-component',
|
||||
componentDir: session.componentDir,
|
||||
propContract: session.propContract,
|
||||
insertLine: 1,
|
||||
sourceInsertLine: spliceIndex + 1,
|
||||
anchorStartLine: startLine + 1,
|
||||
anchorEndLine: endLine + 1,
|
||||
commentSyntax,
|
||||
styleMode: 'svelte-component',
|
||||
styleTag: null,
|
||||
cssSelectorPrefixExamples: [],
|
||||
cssAuthoring: buildSvelteComponentCssAuthoring(count),
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
const indent = lines[spliceIndex]?.match(/^(\s*)/)?.[1]
|
||||
?? lines[startLine]?.match(/^(\s*)/)?.[1]
|
||||
?? '';
|
||||
@@ -216,7 +256,7 @@ Output (JSON):
|
||||
console.log(JSON.stringify({
|
||||
mode: 'insert',
|
||||
position,
|
||||
file: path.relative(process.cwd(), targetFile),
|
||||
file: relTargetFile,
|
||||
insertLine: insertLine + 1,
|
||||
commentSyntax,
|
||||
styleMode: styleMode.mode,
|
||||
|
||||
@@ -10,8 +10,8 @@
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { isGeneratedFile } from './is-generated.mjs';
|
||||
import { readBuffer, getBufferPath } from './live-manual-edits-buffer.mjs';
|
||||
import { isGeneratedFile } from './lib/is-generated.mjs';
|
||||
import { readBuffer, getBufferPath } from './live/manual-edits-buffer.mjs';
|
||||
|
||||
const EVIDENCE_VERSION = 1;
|
||||
const TEXT_EXTENSIONS = new Set(['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro', '.js', '.mjs', '.ts']);
|
||||
|
||||
@@ -12,14 +12,15 @@
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { completionAckForAcceptResult, completionTypeForAcceptResult } from './live-completion.mjs';
|
||||
import { readLiveServerInfo } from './impeccable-paths.mjs';
|
||||
import { completionAckForAcceptResult, completionTypeForAcceptResult } from './live/completion.mjs';
|
||||
import { readLiveServerInfo } from './lib/impeccable-paths.mjs';
|
||||
|
||||
// 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
|
||||
// depending on the standalone undici package.
|
||||
export const PER_REQUEST_TIMEOUT_MS = 270_000;
|
||||
export const DEFAULT_EVENT_LEASE_MS = 600_000;
|
||||
|
||||
const EVENT_TYPES_NEEDING_AGENT_REPLY = new Set(['generate', 'steer', 'manual_edit_apply']);
|
||||
|
||||
@@ -156,7 +157,7 @@ export async function fetchNextEvent(base, token, { totalDeadline } = {}) {
|
||||
? totalDeadline - Date.now()
|
||||
: PER_REQUEST_TIMEOUT_MS;
|
||||
const slice = Math.min(Math.max(remaining, 1000), PER_REQUEST_TIMEOUT_MS);
|
||||
const res = await fetch(`${base}/poll?token=${token}&timeout=${slice}`);
|
||||
const res = await fetch(`${base}/poll?token=${token}&timeout=${slice}&leaseMs=${DEFAULT_EVENT_LEASE_MS}`);
|
||||
|
||||
if (res.status === 401) {
|
||||
const err = new Error('Authentication failed. The server token may have changed.');
|
||||
@@ -317,7 +318,7 @@ Modes:
|
||||
Options:
|
||||
--timeout=MS One-shot poll timeout in ms (default: 600000). Ignored in --stream mode
|
||||
--ack-timeout=MS Stream mode: max wait for --reply after generate/steer (default: 600000)
|
||||
--file PATH Attach a source file path to the reply (generate flow)
|
||||
--file PATH Attach a source file path to the reply (generate/steer flow)
|
||||
--data JSON Attach a JSON result object to the reply (manual_edit_apply flow). Must be valid JSON
|
||||
--help Show this help message
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* Recover the next agent action from the durable live-session journal.
|
||||
*/
|
||||
|
||||
import { createLiveSessionStore } from './live-session-store.mjs';
|
||||
import { createLiveSessionStore } from './live/session-store.mjs';
|
||||
|
||||
function manualApplyReplyCommand(eventOrId = 'EVENT_ID') {
|
||||
const id = typeof eventOrId === 'string' ? eventOrId : eventOrId?.id || 'EVENT_ID';
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,8 +3,8 @@
|
||||
* Print durable recovery status for Impeccable live sessions.
|
||||
*/
|
||||
|
||||
import { createLiveSessionStore } from './live-session-store.mjs';
|
||||
import { readLiveServerInfo } from './impeccable-paths.mjs';
|
||||
import { createLiveSessionStore } from './live/session-store.mjs';
|
||||
import { readLiveServerInfo } from './lib/impeccable-paths.mjs';
|
||||
import { manualApplyResumeHint } from './live-resume.mjs';
|
||||
|
||||
function readServerInfo() {
|
||||
|
||||
@@ -13,8 +13,13 @@
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { isGeneratedFile } from './is-generated.mjs';
|
||||
import { readBuffer as readManualEditsBuffer } from './live-manual-edits-buffer.mjs';
|
||||
import { isGeneratedFile } from './lib/is-generated.mjs';
|
||||
import { readBuffer as readManualEditsBuffer } from './live/manual-edits-buffer.mjs';
|
||||
import {
|
||||
buildSvelteComponentCssAuthoring,
|
||||
scaffoldSvelteComponentSession,
|
||||
shouldUseSvelteComponentInjection,
|
||||
} from './live/svelte-component.mjs';
|
||||
|
||||
const EXTENSIONS = ['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro'];
|
||||
|
||||
@@ -262,6 +267,8 @@ The agent should insert variant HTML at insertLine.`);
|
||||
.map((l) => (l.trim() === '' ? '' : indent + extra + l.slice(originalBaseIndent)))
|
||||
.join('\n');
|
||||
const originalIndented = reindentOriginal(' ');
|
||||
const relTargetFile = path.relative(process.cwd(), targetFile).split(path.sep).join('/');
|
||||
const useSvelteComponent = shouldUseSvelteComponentInjection(targetFile);
|
||||
|
||||
// Wrapper attributes differ by syntax. HTML allows plain string attrs;
|
||||
// JSX requires object-literal style and parses string attrs as HTML (which
|
||||
@@ -302,38 +309,75 @@ The agent should insert variant HTML at insertLine.`);
|
||||
indent + commentSyntax.open + ' impeccable-variants-end ' + id + ' ' + commentSyntax.close,
|
||||
];
|
||||
|
||||
// Replace the original element with the wrapper
|
||||
const newLines = [
|
||||
...lines.slice(0, startLine),
|
||||
...wrapperLines,
|
||||
...lines.slice(endLine + 1),
|
||||
];
|
||||
fs.writeFileSync(targetFile, newLines.join('\n'), 'utf-8');
|
||||
let outputFile = targetFile;
|
||||
let outputLines;
|
||||
let outputStartLine = startLine + 1;
|
||||
let outputEndLine = startLine + wrapperLines.length + (originalLines.length - 1);
|
||||
let insertLine;
|
||||
let svelteSession = null;
|
||||
|
||||
// Calculate insert line (the "insert below this line" comment).
|
||||
// 0-indexed file position. Both HTML and JSX wrappers have 6 lines above
|
||||
// the insert marker (HTML: start-comment + outer-div + Original-comment +
|
||||
// original-div + content + close-original-div; JSX: outer-div +
|
||||
// start-comment + Original-comment + original-div + content +
|
||||
// close-original-div). Multi-line originals push the marker by their
|
||||
// extra line count.
|
||||
const insertLine = startLine + 6 + (originalLines.length - 1);
|
||||
if (useSvelteComponent) {
|
||||
// Svelte/SvelteKit resets component-local state on markup HMR updates.
|
||||
// Keep generation source-neutral: agents write real variant components
|
||||
// under the generated componentDir, the browser mounts them into the live
|
||||
// DOM, and live-accept.mjs inlines the accepted variant back into the route.
|
||||
svelteSession = scaffoldSvelteComponentSession({
|
||||
id,
|
||||
count,
|
||||
sourceFile: relTargetFile,
|
||||
sourceStartLine: startLine + 1,
|
||||
sourceEndLine: endLine + 1,
|
||||
originalLines,
|
||||
cwd: process.cwd(),
|
||||
});
|
||||
outputFile = path.resolve(process.cwd(), svelteSession.manifestFile);
|
||||
outputStartLine = 1;
|
||||
outputEndLine = 1;
|
||||
insertLine = 1;
|
||||
} else {
|
||||
// Replace the original element with the wrapper
|
||||
const newLines = [
|
||||
...lines.slice(0, startLine),
|
||||
...wrapperLines,
|
||||
...lines.slice(endLine + 1),
|
||||
];
|
||||
fs.writeFileSync(targetFile, newLines.join('\n'), 'utf-8');
|
||||
|
||||
// Calculate insert line (the "insert below this line" comment).
|
||||
// 0-indexed file position. Both HTML and JSX wrappers have 6 lines above
|
||||
// the insert marker (HTML: start-comment + outer-div + Original-comment +
|
||||
// original-div + content + close-original-div; JSX: outer-div +
|
||||
// start-comment + Original-comment + original-div + content +
|
||||
// close-original-div). Multi-line originals push the marker by their
|
||||
// extra line count.
|
||||
insertLine = startLine + 6 + (originalLines.length - 1) + 1;
|
||||
}
|
||||
|
||||
const outputRelFile = path.relative(process.cwd(), outputFile).split(path.sep).join('/');
|
||||
|
||||
const svelteComponentAuthoring = useSvelteComponent ? buildSvelteComponentCssAuthoring(count) : null;
|
||||
|
||||
console.log(JSON.stringify({
|
||||
file: path.relative(process.cwd(), targetFile),
|
||||
startLine: startLine + 1, // 1-indexed for the agent
|
||||
file: outputRelFile,
|
||||
sourceFile: useSvelteComponent ? relTargetFile : undefined,
|
||||
previewMode: useSvelteComponent ? 'svelte-component' : undefined,
|
||||
componentDir: svelteSession?.componentDir,
|
||||
propContract: svelteSession?.propContract,
|
||||
sourceStartLine: useSvelteComponent ? startLine + 1 : undefined,
|
||||
sourceEndLine: useSvelteComponent ? endLine + 1 : undefined,
|
||||
startLine: outputStartLine, // 1-indexed for the agent
|
||||
// wrapperLines is an array but one element (the original-content slot)
|
||||
// is a `\n`-joined multi-line string, so the actual file-row count is
|
||||
// wrapperLines.length + (originalLines.length - 1). Without the offset,
|
||||
// endLine pointed inside the wrapper for any picked element that
|
||||
// spanned more than one source line.
|
||||
endLine: startLine + wrapperLines.length + (originalLines.length - 1), // 1-indexed
|
||||
insertLine: insertLine + 1, // 1-indexed: where variants go
|
||||
endLine: outputEndLine, // 1-indexed
|
||||
insertLine, // 1-indexed: where variants go
|
||||
commentSyntax: commentSyntax,
|
||||
styleMode: styleMode.mode,
|
||||
styleTag: styleMode.styleTag,
|
||||
cssSelectorPrefixExamples: buildCssSelectorPrefixExamples(styleMode.mode, count),
|
||||
cssAuthoring: buildCssAuthoring(styleMode, count),
|
||||
styleMode: useSvelteComponent ? 'svelte-component' : styleMode.mode,
|
||||
styleTag: useSvelteComponent ? null : styleMode.styleTag,
|
||||
cssSelectorPrefixExamples: useSvelteComponent ? [] : buildCssSelectorPrefixExamples(styleMode.mode, count),
|
||||
cssAuthoring: useSvelteComponent ? svelteComponentAuthoring : buildCssAuthoring(styleMode, count),
|
||||
originalLineCount: originalLines.length,
|
||||
}));
|
||||
}
|
||||
@@ -527,6 +571,14 @@ function splitClassList(classes) {
|
||||
return String(classes).split(/[,\s]+/).map(c => c.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
function attrEscapeDouble(str) {
|
||||
return String(str)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
}
|
||||
|
||||
function detectCommentSyntax(filePath) {
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
if (ext === '.jsx' || ext === '.tsx') {
|
||||
|
||||
@@ -23,7 +23,7 @@ import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { loadContext } from './context.mjs';
|
||||
import { resolveFiles } from './live-inject.mjs';
|
||||
import { readLiveServerInfo } from './impeccable-paths.mjs';
|
||||
import { readLiveServerInfo } from './lib/impeccable-paths.mjs';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
export const LIVE_BROWSER_SCRIPT_PARTS = Object.freeze([
|
||||
Object.freeze({ name: 'session-state', file: 'live-browser-session.js' }),
|
||||
Object.freeze({ name: 'dom-helpers', file: 'live-browser-dom.js' }),
|
||||
Object.freeze({ name: 'browser-ui', file: 'live-browser.js' }),
|
||||
]);
|
||||
|
||||
export function resolveLiveBrowserScriptParts(scriptsDir, parts = LIVE_BROWSER_SCRIPT_PARTS) {
|
||||
if (!scriptsDir) throw new Error('scriptsDir is required');
|
||||
return parts.map((part, index) => ({
|
||||
...part,
|
||||
index,
|
||||
path: path.join(scriptsDir, part.file),
|
||||
}));
|
||||
}
|
||||
|
||||
export function assertLiveBrowserScriptParts(parts, exists = fs.existsSync) {
|
||||
for (const part of parts) {
|
||||
if (!exists(part.path)) {
|
||||
throw new Error(`Live browser script part missing: ${part.name} (${part.path})`);
|
||||
}
|
||||
}
|
||||
return parts;
|
||||
}
|
||||
|
||||
export function readLiveBrowserScriptParts(parts, readFile = (filePath) => fs.readFileSync(filePath, 'utf-8')) {
|
||||
return parts.map((part) => ({
|
||||
...part,
|
||||
source: readFile(part.path),
|
||||
}));
|
||||
}
|
||||
|
||||
export function assembleLiveBrowserScript({ token, port, vocabulary, parts }) {
|
||||
const prelude =
|
||||
`window.__IMPECCABLE_TOKEN__ = '${token}';\n` +
|
||||
`window.__IMPECCABLE_PORT__ = ${port};\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`;
|
||||
|
||||
const body = parts.map((part) => {
|
||||
const file = part.file || path.basename(part.path || '');
|
||||
return `// --- impeccable live script part: ${part.name} (${file}) ---\n${part.source}`;
|
||||
}).join('\n');
|
||||
|
||||
return prelude + body;
|
||||
}
|
||||
+1
@@ -3,6 +3,7 @@ export function completionTypeForAcceptResult(eventType, acceptResult) {
|
||||
if (acceptResult?.handled === true && acceptResult?.carbonize === true) return 'agent_done';
|
||||
if (acceptResult?.handled === true) return 'complete';
|
||||
if (acceptResult?.mode === 'error') return 'error';
|
||||
if (eventType === 'accept' && acceptResult?.previewMode === 'svelte-component') return 'error';
|
||||
return 'agent_done';
|
||||
}
|
||||
|
||||
+6
-5
@@ -3,12 +3,13 @@
|
||||
* Extracted for unit testing (insert mode rules).
|
||||
*/
|
||||
|
||||
import { canCreateInsert } from './live-insert-ui.mjs';
|
||||
import { canCreateInsert } from './insert-ui.mjs';
|
||||
|
||||
export const VISUAL_ACTIONS = [
|
||||
'impeccable', 'bolder', 'quieter', 'distill', 'polish', 'typeset',
|
||||
'colorize', 'layout', 'adapt', 'animate', 'delight', 'overdrive',
|
||||
];
|
||||
// The accepted visual action values come from the canonical vocabulary so the
|
||||
// validator, the picker UI, and the marketing demo never drift. Imported (not
|
||||
// just re-exported) so it is also in scope for the validators below.
|
||||
import { VISUAL_ACTIONS } from './vocabulary.mjs';
|
||||
export { VISUAL_ACTIONS };
|
||||
|
||||
const ID_PATTERN = /^[0-9a-f]{8}$/;
|
||||
const VARIANT_ID_PATTERN = /^[0-9]{1,3}$/;
|
||||
@@ -0,0 +1,939 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { getLiveDir } from '../lib/impeccable-paths.mjs';
|
||||
import { readBuffer as readManualEditsBuffer } from './manual-edits-buffer.mjs';
|
||||
|
||||
const APPLY_EVENT_HARD_TIMEOUT_MS = Number(process.env.IMPECCABLE_LIVE_APPLY_EVENT_HARD_TIMEOUT_MS || 150_000);
|
||||
const APPLY_EVENT_SOFT_DEADLINE_MS = Number(process.env.IMPECCABLE_LIVE_APPLY_EVENT_SOFT_DEADLINE_MS || 120_000);
|
||||
const DEFAULT_MANUAL_EDIT_APPLY_CHUNK_SIZE = 3;
|
||||
const MIN_MANUAL_EDIT_APPLY_CHUNK_SIZE = 1;
|
||||
const MAX_MANUAL_EDIT_APPLY_CHUNK_SIZE = 20;
|
||||
const MANUAL_APPLY_COMPACT_TEXT_LIMIT = 240;
|
||||
const MANUAL_APPLY_COMPACT_NEARBY_LIMIT = 4;
|
||||
|
||||
export function createManualApplyController({
|
||||
pendingEvents,
|
||||
pendingApplyDeferreds,
|
||||
timedOutApplyIds,
|
||||
enqueueEvent,
|
||||
acknowledgePendingEvent,
|
||||
flushPendingPolls,
|
||||
recordManualEditActivity,
|
||||
cwd = () => process.cwd(),
|
||||
} = {}) {
|
||||
const projectCwd = () => typeof cwd === 'function' ? cwd() : cwd || process.cwd();
|
||||
|
||||
function tombstoneTimedOutApplyId(eventId, details = {}) {
|
||||
if (!eventId) return;
|
||||
timedOutApplyIds.set(eventId, details);
|
||||
if (timedOutApplyIds.size <= 200) return;
|
||||
const oldest = timedOutApplyIds.keys().next().value;
|
||||
timedOutApplyIds.delete(oldest);
|
||||
}
|
||||
|
||||
function pushApplyEventAndWait(batch, pageUrl, chunk = null, repair = null) {
|
||||
const cwdValue = projectCwd();
|
||||
const eventId = randomUUID().replace(/-/g, '').slice(0, 8);
|
||||
const evidencePath = writeManualApplyEvidence(eventId, batch, cwdValue);
|
||||
const event = {
|
||||
type: 'manual_edit_apply',
|
||||
id: eventId,
|
||||
pageUrl,
|
||||
batch: compactManualApplyBatch(batch, cwdValue),
|
||||
evidencePath,
|
||||
agentAction: buildManualApplyAgentAction(eventId),
|
||||
schemaVersion: 1,
|
||||
deadlineMs: APPLY_EVENT_SOFT_DEADLINE_MS,
|
||||
};
|
||||
if (chunk) event.chunk = chunk;
|
||||
if (repair) event.repair = repair;
|
||||
const rollbackSnapshot = snapshotApplyEventFiles(batch, cwdValue);
|
||||
recordManualEditActivity('manual_edit_apply_dispatched', {
|
||||
id: eventId,
|
||||
pageUrl,
|
||||
chunk,
|
||||
repair,
|
||||
entryCount: Array.isArray(batch.entries) ? batch.entries.length : 0,
|
||||
opCount: countManualApplyOps(batch),
|
||||
fileCount: collectManualApplyFiles(batch, [], cwdValue).length,
|
||||
});
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
pendingApplyDeferreds.delete(eventId);
|
||||
tombstoneTimedOutApplyId(eventId, { batch, rollbackSnapshot, cwd: cwdValue });
|
||||
acknowledgePendingEvent(eventId);
|
||||
removeManualApplyEvidence(evidencePath, cwdValue);
|
||||
recordManualEditActivity('manual_edit_apply_timeout', {
|
||||
id: eventId,
|
||||
pageUrl,
|
||||
chunk,
|
||||
entryCount: Array.isArray(batch.entries) ? batch.entries.length : 0,
|
||||
opCount: countManualApplyOps(batch),
|
||||
});
|
||||
reject(new Error('chat_agent_timeout'));
|
||||
}, APPLY_EVENT_HARD_TIMEOUT_MS);
|
||||
pendingApplyDeferreds.set(eventId, { resolve, reject, timer, event, batch, pageUrl, rollbackSnapshot, cwd: cwdValue });
|
||||
enqueueEvent(event);
|
||||
});
|
||||
}
|
||||
|
||||
async function pushBatchInChunksAndWait(batch, pageUrl, context = {}) {
|
||||
const repair = context?.repair || batch?.repair || null;
|
||||
if (repair) return pushApplyEventAndWait(batch, pageUrl, null, repair);
|
||||
const chunks = splitManualApplyBatch(batch, manualEditApplyChunkSize());
|
||||
if (chunks.length <= 1) return pushApplyEventAndWait(batch, pageUrl);
|
||||
|
||||
const expectedOpsByEntry = new Map();
|
||||
for (const entry of batch?.entries || []) {
|
||||
expectedOpsByEntry.set(entry.id, Array.isArray(entry.ops) ? entry.ops.length : 0);
|
||||
}
|
||||
|
||||
const appliedOpsByEntry = new Map();
|
||||
const failedByEntry = new Map();
|
||||
const files = new Set();
|
||||
const notes = [];
|
||||
let aborted = false;
|
||||
|
||||
for (const chunk of chunks) {
|
||||
if (aborted) {
|
||||
markChunkEntriesFailed(failedByEntry, chunk, 'manual_edit_chunk_aborted');
|
||||
continue;
|
||||
}
|
||||
|
||||
let result;
|
||||
try {
|
||||
result = normalizeApplyChunkResult(await pushApplyEventAndWait(chunk.batch, pageUrl, chunk.meta));
|
||||
} catch (err) {
|
||||
markChunkEntriesFailed(failedByEntry, chunk, err.message || 'chat_agent_error');
|
||||
aborted = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const file of result.files) files.add(file);
|
||||
notes.push(...result.notes);
|
||||
|
||||
const chunkFailedIds = new Set();
|
||||
for (const item of result.failed) {
|
||||
const entryId = item.entryId || item.id;
|
||||
if (!entryId) continue;
|
||||
chunkFailedIds.add(entryId);
|
||||
if (!failedByEntry.has(entryId)) {
|
||||
failedByEntry.set(entryId, {
|
||||
entryId,
|
||||
reason: item.reason || item.message || 'failed',
|
||||
candidates: Array.isArray(item.candidates) ? item.candidates : [],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (result.status === 'error') {
|
||||
markChunkEntriesFailed(failedByEntry, chunk, result.message || firstFailureReason(result) || 'chat_agent_error');
|
||||
aborted = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
const reportedAppliedIds = new Set(result.appliedEntryIds);
|
||||
for (const entryId of reportedAppliedIds) {
|
||||
if (!chunk.entryIds.has(entryId) || chunkFailedIds.has(entryId)) continue;
|
||||
appliedOpsByEntry.set(entryId, (appliedOpsByEntry.get(entryId) || 0) + (chunk.opCountsByEntry.get(entryId) || 0));
|
||||
}
|
||||
|
||||
for (const entryId of chunk.entryIds) {
|
||||
if (reportedAppliedIds.has(entryId) || chunkFailedIds.has(entryId)) continue;
|
||||
if (!failedByEntry.has(entryId)) {
|
||||
failedByEntry.set(entryId, { entryId, reason: 'not_reported_applied', candidates: [] });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const appliedEntryIds = [];
|
||||
for (const [entryId, expectedOps] of expectedOpsByEntry.entries()) {
|
||||
if (failedByEntry.has(entryId)) continue;
|
||||
if ((appliedOpsByEntry.get(entryId) || 0) === expectedOps && expectedOps > 0) {
|
||||
appliedEntryIds.push(entryId);
|
||||
} else if (!failedByEntry.has(entryId)) {
|
||||
failedByEntry.set(entryId, { entryId, reason: 'not_reported_applied', candidates: [] });
|
||||
}
|
||||
}
|
||||
|
||||
const failed = [...failedByEntry.values()];
|
||||
return {
|
||||
status: failed.length === 0 ? 'done' : appliedEntryIds.length > 0 ? 'partial' : 'error',
|
||||
appliedEntryIds,
|
||||
failed,
|
||||
files: [...files],
|
||||
notes,
|
||||
};
|
||||
}
|
||||
|
||||
function getDeferred(eventId) {
|
||||
return pendingApplyDeferreds.get(eventId) || null;
|
||||
}
|
||||
|
||||
function hasTimedOutId(eventId) {
|
||||
return timedOutApplyIds.has(eventId);
|
||||
}
|
||||
|
||||
function resolveDeferred(eventId, body) {
|
||||
const deferred = pendingApplyDeferreds.get(eventId);
|
||||
if (!deferred) return false;
|
||||
pendingApplyDeferreds.delete(eventId);
|
||||
clearTimeout(deferred.timer);
|
||||
removeManualApplyEvidence(deferred.event?.evidencePath, deferred.cwd || projectCwd());
|
||||
deferred.resolve(body);
|
||||
return true;
|
||||
}
|
||||
|
||||
function rejectDeferred(eventId, reason) {
|
||||
const deferred = pendingApplyDeferreds.get(eventId);
|
||||
if (!deferred) return false;
|
||||
pendingApplyDeferreds.delete(eventId);
|
||||
clearTimeout(deferred.timer);
|
||||
removeManualApplyEvidence(deferred.event?.evidencePath, deferred.cwd || projectCwd());
|
||||
deferred.reject(new Error(reason || 'chat_agent_error'));
|
||||
return true;
|
||||
}
|
||||
|
||||
function referencedManualApplyEvidencePaths(cwdValue = projectCwd()) {
|
||||
const referenced = new Set();
|
||||
const add = (event) => {
|
||||
const fullPath = normalizeManualApplyEvidencePath(event?.evidencePath, cwdValue);
|
||||
if (fullPath) referenced.add(fullPath);
|
||||
};
|
||||
for (const entry of pendingEvents) add(entry.event);
|
||||
for (const deferred of pendingApplyDeferreds.values()) add(deferred.event);
|
||||
return referenced;
|
||||
}
|
||||
|
||||
function pruneStaleEvidence(cwdValue = projectCwd()) {
|
||||
const dir = manualApplyEvidenceDir(cwdValue);
|
||||
if (!fs.existsSync(dir)) return [];
|
||||
const referenced = referencedManualApplyEvidencePaths(cwdValue);
|
||||
const removed = [];
|
||||
for (const name of fs.readdirSync(dir)) {
|
||||
if (!name.endsWith('.json')) continue;
|
||||
const fullPath = path.join(dir, name);
|
||||
if (referenced.has(fullPath)) continue;
|
||||
try {
|
||||
fs.unlinkSync(fullPath);
|
||||
removed.push(fullPath);
|
||||
} catch {
|
||||
// Stale evidence cleanup is best-effort; Apply verification never relies
|
||||
// on deleting these files.
|
||||
}
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
|
||||
function rollbackTimedOutReply(msg) {
|
||||
const details = timedOutApplyIds.get(msg.id);
|
||||
if (!details) return { rolledBackFiles: [], rollbackFailures: [] };
|
||||
timedOutApplyIds.delete(msg.id);
|
||||
return rollbackApplySnapshot(
|
||||
details.batch,
|
||||
details.rollbackSnapshot,
|
||||
msg.data?.files || [],
|
||||
'stale_manual_edit_apply_reply',
|
||||
details.cwd || projectCwd(),
|
||||
);
|
||||
}
|
||||
|
||||
function cancelPendingEvents(pageUrl, reason = 'manual_edit_discarded') {
|
||||
const canceledById = new Map();
|
||||
const shouldCancel = (event) => event?.type === 'manual_edit_apply' && (!pageUrl || event.pageUrl === pageUrl);
|
||||
|
||||
for (let i = pendingEvents.length - 1; i >= 0; i -= 1) {
|
||||
const event = pendingEvents[i]?.event;
|
||||
if (!shouldCancel(event)) continue;
|
||||
pendingEvents.splice(i, 1);
|
||||
removeManualApplyEvidence(event.evidencePath, projectCwd());
|
||||
canceledById.set(event.id, {
|
||||
id: event.id,
|
||||
pageUrl: event.pageUrl,
|
||||
entryCount: event.batch?.entries?.length || 0,
|
||||
});
|
||||
}
|
||||
|
||||
for (const [eventId, deferred] of [...pendingApplyDeferreds.entries()]) {
|
||||
if (!shouldCancel(deferred.event)) continue;
|
||||
pendingApplyDeferreds.delete(eventId);
|
||||
clearTimeout(deferred.timer);
|
||||
const cwdValue = deferred.cwd || projectCwd();
|
||||
const rollback = rollbackApplySnapshot(deferred.batch, deferred.rollbackSnapshot, [], reason, cwdValue);
|
||||
tombstoneTimedOutApplyId(eventId, {
|
||||
batch: deferred.batch,
|
||||
rollbackSnapshot: deferred.rollbackSnapshot,
|
||||
reason,
|
||||
cwd: cwdValue,
|
||||
});
|
||||
removeManualApplyEvidence(deferred.event?.evidencePath, cwdValue);
|
||||
canceledById.set(eventId, {
|
||||
id: eventId,
|
||||
pageUrl: deferred.pageUrl,
|
||||
entryCount: deferred.batch?.entries?.length || 0,
|
||||
rolledBackFiles: rollback.rolledBackFiles,
|
||||
rollbackFailures: rollback.rollbackFailures,
|
||||
});
|
||||
deferred.reject(new Error(reason));
|
||||
}
|
||||
|
||||
if (canceledById.size > 0) flushPendingPolls();
|
||||
return [...canceledById.values()];
|
||||
}
|
||||
|
||||
return {
|
||||
buildAgentAction: buildManualApplyAgentAction,
|
||||
cancelPendingEvents,
|
||||
clearTransaction: (transactionId = null) => clearManualApplyTransaction(projectCwd(), transactionId),
|
||||
countOps: countManualApplyOps,
|
||||
getDeferred,
|
||||
hasTimedOutId,
|
||||
pruneStaleEvidence,
|
||||
pushBatchInChunksAndWait,
|
||||
readTransaction: () => readManualApplyTransaction(projectCwd()),
|
||||
rejectDeferred,
|
||||
resolveDeferred,
|
||||
rollbackTimedOutReply,
|
||||
rollbackTransaction: (opts = {}) => rollbackManualApplyTransaction({
|
||||
cwd: projectCwd(),
|
||||
recordManualEditActivity,
|
||||
...opts,
|
||||
}),
|
||||
summarizeEvent: (event = {}, batch = event.batch) => summarizeManualApplyEvent(event, batch, projectCwd()),
|
||||
validateResultMessage: validateManualApplyResultMessage,
|
||||
writeTransaction: (opts = {}) => writeManualApplyTransaction({ cwd: projectCwd(), ...opts }),
|
||||
};
|
||||
}
|
||||
|
||||
export function manualEditApplyChunkSize(env = process.env) {
|
||||
const raw = Number(env.IMPECCABLE_LIVE_MANUAL_EDIT_CHUNK_SIZE);
|
||||
if (!Number.isFinite(raw)) return DEFAULT_MANUAL_EDIT_APPLY_CHUNK_SIZE;
|
||||
const size = Math.trunc(raw);
|
||||
return Math.max(MIN_MANUAL_EDIT_APPLY_CHUNK_SIZE, Math.min(MAX_MANUAL_EDIT_APPLY_CHUNK_SIZE, size));
|
||||
}
|
||||
|
||||
export function countManualApplyOps(entriesOrBatch) {
|
||||
const entries = Array.isArray(entriesOrBatch)
|
||||
? entriesOrBatch
|
||||
: Array.isArray(entriesOrBatch?.entries) ? entriesOrBatch.entries : [];
|
||||
let count = 0;
|
||||
for (const entry of entries) count += Array.isArray(entry.ops) ? entry.ops.length : 0;
|
||||
return count;
|
||||
}
|
||||
|
||||
export function writeManualApplyEvidence(eventId, batch, cwd = process.cwd()) {
|
||||
const dir = manualApplyEvidenceDir(cwd);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
const evidencePath = path.join(dir, `${eventId}.json`);
|
||||
fs.writeFileSync(evidencePath, JSON.stringify(batch, null, 2) + '\n', 'utf-8');
|
||||
return evidencePath;
|
||||
}
|
||||
|
||||
export function manualApplyEvidenceDir(cwd = process.cwd()) {
|
||||
return path.join(getLiveDir(cwd), 'manual-edit-evidence');
|
||||
}
|
||||
|
||||
export function normalizeManualApplyEvidencePath(evidencePath, cwd = process.cwd()) {
|
||||
if (!evidencePath || typeof evidencePath !== 'string') return null;
|
||||
const fullPath = path.isAbsolute(evidencePath) ? evidencePath : path.resolve(cwd, evidencePath);
|
||||
const evidenceDir = manualApplyEvidenceDir(cwd);
|
||||
const relative = path.relative(evidenceDir, fullPath);
|
||||
if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) return null;
|
||||
if (path.extname(relative) !== '.json') return null;
|
||||
return fullPath;
|
||||
}
|
||||
|
||||
export function removeManualApplyEvidence(evidencePath, cwd = process.cwd()) {
|
||||
const fullPath = normalizeManualApplyEvidencePath(evidencePath, cwd);
|
||||
if (!fullPath) return false;
|
||||
try {
|
||||
fs.unlinkSync(fullPath);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function compactManualApplyBatch(batch = {}, cwd = process.cwd()) {
|
||||
const entries = (batch.entries || []).map(compactManualApplyEntry);
|
||||
const candidates = compactManualApplyCandidates(batch.candidates || [], cwd);
|
||||
return {
|
||||
version: batch.version,
|
||||
pageUrl: batch.pageUrl || null,
|
||||
count: batch.count,
|
||||
entries,
|
||||
ops: entries.flatMap((entry) => entry.ops.map((op) => ({ ...op, entryId: entry.id }))),
|
||||
candidates: candidates.length > 0 ? candidates : undefined,
|
||||
context: batch.context ? {
|
||||
bufferPath: batch.context.bufferPath,
|
||||
totalEntries: batch.context.totalEntries,
|
||||
totalOps: batch.context.totalOps,
|
||||
chunkIndex: batch.context.chunkIndex,
|
||||
chunkTotal: batch.context.chunkTotal,
|
||||
totalApplyOps: batch.context.totalApplyOps,
|
||||
} : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function compactManualApplyCandidates(candidates, cwd = process.cwd()) {
|
||||
return (Array.isArray(candidates) ? candidates : [])
|
||||
.slice(0, 24)
|
||||
.map((candidate) => ({
|
||||
entryId: candidate.entryId,
|
||||
ref: candidate.ref,
|
||||
sourceHint: compactManualApplySourceMatch(candidate.sourceHint, cwd),
|
||||
textMatches: compactManualApplySourceMatches(candidate.textMatches, 8, cwd),
|
||||
objectKeyMatches: compactManualApplySourceMatches(candidate.objectKeyMatches, 8, cwd),
|
||||
contextTextMatches: compactManualApplySourceMatches(candidate.contextTextMatches, 8, cwd),
|
||||
locatorMatches: compactManualApplySourceMatches(candidate.locatorMatches, 6, cwd),
|
||||
}));
|
||||
}
|
||||
|
||||
function compactManualApplySourceMatches(matches, limit, cwd) {
|
||||
return (Array.isArray(matches) ? matches : [])
|
||||
.slice(0, limit)
|
||||
.map((match) => compactManualApplySourceMatch(match, cwd))
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function compactManualApplySourceMatch(match, cwd) {
|
||||
if (!match || typeof match !== 'object') return null;
|
||||
const file = match.relativeFile || match.file;
|
||||
if (!file && !match.line) return null;
|
||||
return {
|
||||
file: summarizeManualLogFile(file, cwd),
|
||||
line: match.line || null,
|
||||
column: match.column || null,
|
||||
reason: match.reason || match.kind || undefined,
|
||||
status: match.status || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function compactManualApplyEntry(entry = {}) {
|
||||
return {
|
||||
id: entry.id,
|
||||
pageUrl: entry.pageUrl,
|
||||
stagedAt: entry.stagedAt || null,
|
||||
element: compactManualApplyContext(entry.element),
|
||||
ops: (entry.ops || []).map(compactManualApplyOp),
|
||||
};
|
||||
}
|
||||
|
||||
function compactManualApplyOp(op = {}) {
|
||||
return {
|
||||
entryId: op.entryId,
|
||||
ref: op.ref,
|
||||
contextRef: op.contextRef,
|
||||
tag: op.tag,
|
||||
elementId: op.elementId,
|
||||
classes: Array.isArray(op.classes) ? op.classes : [],
|
||||
originalText: op.originalText,
|
||||
newText: op.newText,
|
||||
deleted: op.deleted === true || undefined,
|
||||
sourceHint: op.sourceHint || null,
|
||||
leaf: compactManualApplyContext(op.leaf),
|
||||
nearbyEditableTexts: compactNearbyManualEditTexts(op.nearbyEditableTexts),
|
||||
container: compactManualApplyContext(op.container),
|
||||
contextHints: Array.isArray(op.contextHints) ? op.contextHints.slice(0, 8) : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function compactManualApplyContext(value) {
|
||||
if (!value || typeof value !== 'object') return null;
|
||||
return {
|
||||
ref: value.ref,
|
||||
tagName: value.tagName || value.tag || null,
|
||||
id: value.id || null,
|
||||
classes: Array.isArray(value.classes) ? value.classes : [],
|
||||
textContent: truncateManualApplyText(value.textContent, MANUAL_APPLY_COMPACT_TEXT_LIMIT),
|
||||
};
|
||||
}
|
||||
|
||||
function compactNearbyManualEditTexts(items) {
|
||||
return (Array.isArray(items) ? items : [])
|
||||
.slice(0, MANUAL_APPLY_COMPACT_NEARBY_LIMIT)
|
||||
.map((item) => typeof item === 'string' ? { text: truncateManualApplyText(item, MANUAL_APPLY_COMPACT_TEXT_LIMIT) } : {
|
||||
ref: item?.ref,
|
||||
tag: item?.tag,
|
||||
classes: Array.isArray(item?.classes) ? item.classes : [],
|
||||
text: truncateManualApplyText(item?.text, MANUAL_APPLY_COMPACT_TEXT_LIMIT),
|
||||
});
|
||||
}
|
||||
|
||||
function truncateManualApplyText(value, max) {
|
||||
if (typeof value !== 'string') return value || null;
|
||||
return value.length > max ? value.slice(0, max) : value;
|
||||
}
|
||||
|
||||
function normalizeApplyChunkResult(result) {
|
||||
const status = result?.status === 'partial' ? 'partial' : result?.status === 'error' ? 'error' : 'done';
|
||||
return {
|
||||
status,
|
||||
message: typeof result?.message === 'string' ? result.message : null,
|
||||
appliedEntryIds: Array.isArray(result?.appliedEntryIds) ? result.appliedEntryIds.filter((id) => typeof id === 'string') : [],
|
||||
failed: Array.isArray(result?.failed) ? result.failed.filter(Boolean) : [],
|
||||
files: Array.isArray(result?.files) ? result.files.filter((file) => typeof file === 'string') : [],
|
||||
notes: Array.isArray(result?.notes) ? result.notes.filter((note) => typeof note === 'string') : [],
|
||||
};
|
||||
}
|
||||
|
||||
function manualApplyResultShapeHint(eventId = 'EVENT_ID') {
|
||||
return `Use live-poll.mjs --reply ${eventId} done --data '{"status":"done","appliedEntryIds":["ENTRY_ID"],"failed":[],"files":["src/page.html"],"notes":[]}'`;
|
||||
}
|
||||
|
||||
function invalidManualApplyResult(reason, eventId, extra = {}) {
|
||||
return {
|
||||
ok: false,
|
||||
body: {
|
||||
error: 'invalid_manual_apply_result',
|
||||
reason,
|
||||
hint: manualApplyResultShapeHint(eventId),
|
||||
...extra,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function validateManualApplyResultMessage(msg, deferred) {
|
||||
let data = msg?.data;
|
||||
const eventId = msg?.id || deferred?.event?.id || 'EVENT_ID';
|
||||
if (!data || typeof data !== 'object' || Array.isArray(data)) {
|
||||
return invalidManualApplyResult('missing_result_data', eventId);
|
||||
}
|
||||
if ('entries' in data || 'ops' in data) {
|
||||
return invalidManualApplyResult('summary_result_not_allowed', eventId);
|
||||
}
|
||||
if (!['done', 'partial', 'error'].includes(data.status)) {
|
||||
return invalidManualApplyResult('invalid_status', eventId, { status: data.status ?? null });
|
||||
}
|
||||
|
||||
for (const key of ['appliedEntryIds', 'failed', 'files', 'notes']) {
|
||||
if (!Array.isArray(data[key])) {
|
||||
return invalidManualApplyResult(`${key}_must_be_array`, eventId);
|
||||
}
|
||||
}
|
||||
|
||||
for (const [index, value] of data.appliedEntryIds.entries()) {
|
||||
if (typeof value !== 'string' || !value) {
|
||||
return invalidManualApplyResult('appliedEntryIds_must_contain_strings', eventId, { index });
|
||||
}
|
||||
}
|
||||
for (const [index, value] of data.files.entries()) {
|
||||
if (typeof value !== 'string' || !value) {
|
||||
return invalidManualApplyResult('files_must_contain_strings', eventId, { index });
|
||||
}
|
||||
}
|
||||
for (const [index, value] of data.notes.entries()) {
|
||||
if (typeof value !== 'string') {
|
||||
return invalidManualApplyResult('notes_must_contain_strings', eventId, { index });
|
||||
}
|
||||
}
|
||||
for (const [index, item] of data.failed.entries()) {
|
||||
if (!item || typeof item !== 'object' || Array.isArray(item)) {
|
||||
return invalidManualApplyResult('failed_must_contain_objects', eventId, { index });
|
||||
}
|
||||
if (typeof item.entryId !== 'string' || !item.entryId) {
|
||||
return invalidManualApplyResult('failed_entryId_required', eventId, { index });
|
||||
}
|
||||
if (typeof item.reason !== 'string' || !item.reason) {
|
||||
return invalidManualApplyResult('failed_reason_required', eventId, { index });
|
||||
}
|
||||
}
|
||||
|
||||
const eventEntryIds = new Set((deferred?.batch?.entries || []).map((entry) => entry.id).filter(Boolean));
|
||||
for (const entryId of data.appliedEntryIds) {
|
||||
if (eventEntryIds.size > 0 && !eventEntryIds.has(entryId)) {
|
||||
return invalidManualApplyResult('applied_entry_id_not_in_event', eventId, { entryId });
|
||||
}
|
||||
}
|
||||
for (const item of data.failed) {
|
||||
if (eventEntryIds.size > 0 && !eventEntryIds.has(item.entryId)) {
|
||||
return invalidManualApplyResult('failed_entry_id_not_in_event', eventId, { entryId: item.entryId });
|
||||
}
|
||||
}
|
||||
|
||||
if (data.status === 'done') {
|
||||
if (data.failed.length > 0) {
|
||||
return invalidManualApplyResult('done_result_has_failed_entries', eventId);
|
||||
}
|
||||
if (countManualApplyOps(deferred?.batch) > 0 && data.appliedEntryIds.length === 0) {
|
||||
return invalidManualApplyResult('done_result_missing_applied_entry_ids', eventId);
|
||||
}
|
||||
}
|
||||
if (data.status === 'partial' && data.appliedEntryIds.length === 0 && data.failed.length === 0) {
|
||||
return invalidManualApplyResult('partial_result_has_no_entries', eventId);
|
||||
}
|
||||
if (data.status === 'error' && data.appliedEntryIds.length > 0) {
|
||||
return invalidManualApplyResult('error_result_has_applied_entries', eventId);
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
result: {
|
||||
status: data.status,
|
||||
message: typeof data.message === 'string' ? data.message : undefined,
|
||||
appliedEntryIds: data.appliedEntryIds,
|
||||
failed: data.failed,
|
||||
files: data.files,
|
||||
notes: data.notes,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function firstFailureReason(result) {
|
||||
const first = Array.isArray(result?.failed) ? result.failed.find(Boolean) : null;
|
||||
return first?.reason || first?.message || null;
|
||||
}
|
||||
|
||||
function markChunkEntriesFailed(failedByEntry, chunk, reason) {
|
||||
for (const entryId of chunk.entryIds) {
|
||||
if (failedByEntry.has(entryId)) continue;
|
||||
failedByEntry.set(entryId, { entryId, reason, candidates: [] });
|
||||
}
|
||||
}
|
||||
|
||||
export function splitManualApplyBatch(batch, maxOps) {
|
||||
const totalOpCount = countManualApplyOps(batch);
|
||||
if (totalOpCount <= maxOps) {
|
||||
return [{
|
||||
batch,
|
||||
meta: null,
|
||||
entryIds: new Set((batch?.entries || []).map((entry) => entry.id).filter(Boolean)),
|
||||
opCountsByEntry: new Map((batch?.entries || []).map((entry) => [entry.id, Array.isArray(entry.ops) ? entry.ops.length : 0])),
|
||||
}];
|
||||
}
|
||||
|
||||
const rawChunks = [];
|
||||
let current = createManualApplyChunkBuilder();
|
||||
for (const entry of batch?.entries || []) {
|
||||
const ops = entry.ops || [];
|
||||
if (ops.length <= maxOps) {
|
||||
if (current.opCount > 0 && current.opCount + ops.length > maxOps) {
|
||||
rawChunks.push(current);
|
||||
current = createManualApplyChunkBuilder();
|
||||
}
|
||||
for (const op of ops) addOpToManualApplyChunk(current, entry, op);
|
||||
continue;
|
||||
}
|
||||
if (current.opCount > 0) {
|
||||
rawChunks.push(current);
|
||||
current = createManualApplyChunkBuilder();
|
||||
}
|
||||
for (const op of ops) {
|
||||
if (current.opCount >= maxOps) {
|
||||
rawChunks.push(current);
|
||||
current = createManualApplyChunkBuilder();
|
||||
}
|
||||
addOpToManualApplyChunk(current, entry, op);
|
||||
}
|
||||
}
|
||||
if (current.opCount > 0) rawChunks.push(current);
|
||||
|
||||
return rawChunks.map((chunk, index) => ({
|
||||
batch: {
|
||||
...batch,
|
||||
count: chunk.opCount,
|
||||
entries: chunk.entries,
|
||||
ops: chunk.ops,
|
||||
candidates: filterManualApplyChunkCandidates(batch, chunk.refsByEntry),
|
||||
context: {
|
||||
...(batch?.context || {}),
|
||||
totalEntries: chunk.entries.length,
|
||||
totalOps: chunk.opCount,
|
||||
chunkIndex: index + 1,
|
||||
chunkTotal: rawChunks.length,
|
||||
totalApplyOps: totalOpCount,
|
||||
},
|
||||
},
|
||||
meta: {
|
||||
index: index + 1,
|
||||
total: rawChunks.length,
|
||||
opCount: chunk.opCount,
|
||||
totalOpCount,
|
||||
},
|
||||
entryIds: new Set(chunk.entries.map((entry) => entry.id).filter(Boolean)),
|
||||
opCountsByEntry: chunk.opCountsByEntry,
|
||||
}));
|
||||
}
|
||||
|
||||
function createManualApplyChunkBuilder() {
|
||||
return {
|
||||
entries: [],
|
||||
entryById: new Map(),
|
||||
entryIds: new Set(),
|
||||
ops: [],
|
||||
refsByEntry: new Map(),
|
||||
opCountsByEntry: new Map(),
|
||||
opCount: 0,
|
||||
};
|
||||
}
|
||||
|
||||
function addOpToManualApplyChunk(chunk, entry, op) {
|
||||
let chunkEntry = chunk.entryById.get(entry.id);
|
||||
if (!chunkEntry) {
|
||||
chunkEntry = { ...entry, ops: [] };
|
||||
chunk.entryById.set(entry.id, chunkEntry);
|
||||
chunk.entryIds.add(entry.id);
|
||||
chunk.entries.push(chunkEntry);
|
||||
}
|
||||
chunkEntry.ops.push(op);
|
||||
chunk.ops.push({ ...op, entryId: op.entryId || entry.id });
|
||||
if (!chunk.refsByEntry.has(entry.id)) chunk.refsByEntry.set(entry.id, new Set());
|
||||
if (op.ref) chunk.refsByEntry.get(entry.id).add(op.ref);
|
||||
chunk.opCountsByEntry.set(entry.id, (chunk.opCountsByEntry.get(entry.id) || 0) + 1);
|
||||
chunk.opCount += 1;
|
||||
}
|
||||
|
||||
function filterManualApplyChunkCandidates(batch, refsByEntry) {
|
||||
return (batch?.candidates || []).filter((candidate) => {
|
||||
const refs = refsByEntry.get(candidate.entryId);
|
||||
if (!refs) return false;
|
||||
if (!candidate.ref) return true;
|
||||
return refs.has(candidate.ref);
|
||||
});
|
||||
}
|
||||
|
||||
export function snapshotApplyEventFiles(batch, cwd = process.cwd()) {
|
||||
const snapshot = new Map();
|
||||
for (const relativeFile of collectManualApplyFiles(batch, [], cwd)) {
|
||||
const absolute = path.resolve(cwd, relativeFile);
|
||||
try {
|
||||
snapshot.set(relativeFile, {
|
||||
exists: fs.existsSync(absolute),
|
||||
content: fs.existsSync(absolute) ? fs.readFileSync(absolute, 'utf-8') : '',
|
||||
});
|
||||
} catch {
|
||||
// If a file cannot be read before dispatch, do not attempt late rollback.
|
||||
}
|
||||
}
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
export function manualApplyTransactionPath(cwd = process.cwd()) {
|
||||
return path.join(getLiveDir(cwd), 'manual-edit-apply-transaction.json');
|
||||
}
|
||||
|
||||
export function readManualApplyTransaction(cwd = process.cwd()) {
|
||||
const file = manualApplyTransactionPath(cwd);
|
||||
if (!fs.existsSync(file)) return null;
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(file, 'utf-8'));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function writeManualApplyTransaction({ cwd = process.cwd(), pageUrl = null, batch }) {
|
||||
const file = manualApplyTransactionPath(cwd);
|
||||
const files = collectManualApplyFiles(batch, [], cwd);
|
||||
const transaction = {
|
||||
version: 1,
|
||||
id: randomUUID().replace(/-/g, '').slice(0, 8),
|
||||
createdAt: new Date().toISOString(),
|
||||
pageUrl,
|
||||
entryIds: (batch?.entries || []).map((entry) => entry.id).filter(Boolean),
|
||||
files: files.map((relativeFile) => {
|
||||
const absolute = path.resolve(cwd, relativeFile);
|
||||
const exists = fs.existsSync(absolute);
|
||||
return {
|
||||
file: relativeFile,
|
||||
exists,
|
||||
content: exists ? fs.readFileSync(absolute, 'utf-8') : '',
|
||||
};
|
||||
}),
|
||||
};
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||
fs.writeFileSync(`${file}.tmp`, JSON.stringify(transaction, null, 2) + '\n', 'utf-8');
|
||||
fs.renameSync(`${file}.tmp`, file);
|
||||
return transaction;
|
||||
}
|
||||
|
||||
export function clearManualApplyTransaction(cwd = process.cwd(), transactionId = null) {
|
||||
const file = manualApplyTransactionPath(cwd);
|
||||
if (!fs.existsSync(file)) return false;
|
||||
if (transactionId) {
|
||||
const existing = readManualApplyTransaction(cwd);
|
||||
if (existing?.id && existing.id !== transactionId) return false;
|
||||
}
|
||||
try {
|
||||
fs.unlinkSync(file);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function rollbackManualApplyTransaction({
|
||||
cwd = process.cwd(),
|
||||
pageUrl = null,
|
||||
reason = 'manual_edit_transaction_rollback',
|
||||
recordManualEditActivity = null,
|
||||
} = {}) {
|
||||
const transaction = readManualApplyTransaction(cwd);
|
||||
if (!transaction) return null;
|
||||
if (pageUrl && transaction.pageUrl && transaction.pageUrl !== pageUrl) return null;
|
||||
|
||||
let pendingIds = new Set();
|
||||
try {
|
||||
const buffer = readManualEditsBuffer(cwd);
|
||||
pendingIds = new Set((buffer.entries || []).map((entry) => entry.id).filter(Boolean));
|
||||
} catch {
|
||||
pendingIds = new Set(transaction.entryIds || []);
|
||||
}
|
||||
const shouldRollback = (transaction.entryIds || []).some((id) => pendingIds.has(id));
|
||||
if (!shouldRollback) {
|
||||
clearManualApplyTransaction(cwd, transaction.id);
|
||||
return { id: transaction.id, reason, rolledBackFiles: [], rollbackFailures: [], skipped: 'entries_not_pending' };
|
||||
}
|
||||
|
||||
const rolledBackFiles = [];
|
||||
const rollbackFailures = [];
|
||||
for (const item of transaction.files || []) {
|
||||
const relativeFile = normalizeProjectFile(item.file, cwd);
|
||||
if (!relativeFile) continue;
|
||||
const absolute = path.resolve(cwd, relativeFile);
|
||||
try {
|
||||
if (item.exists) {
|
||||
fs.mkdirSync(path.dirname(absolute), { recursive: true });
|
||||
fs.writeFileSync(absolute, item.content || '', 'utf-8');
|
||||
} else if (fs.existsSync(absolute)) {
|
||||
fs.rmSync(absolute);
|
||||
}
|
||||
rolledBackFiles.push(relativeFile);
|
||||
} catch (err) {
|
||||
rollbackFailures.push({ file: relativeFile, reason: 'restore_failed', message: err.message || String(err) });
|
||||
}
|
||||
}
|
||||
clearManualApplyTransaction(cwd, transaction.id);
|
||||
recordManualEditActivity?.('manual_edit_transaction_rolled_back', {
|
||||
id: transaction.id,
|
||||
pageUrl: transaction.pageUrl || null,
|
||||
reason,
|
||||
entryIds: transaction.entryIds || [],
|
||||
rolledBackFiles: rolledBackFiles.map((file) => summarizeManualLogFile(file, cwd)).filter(Boolean),
|
||||
rollbackFailures: summarizeManualDiagnostics(rollbackFailures, cwd),
|
||||
});
|
||||
return { id: transaction.id, reason, rolledBackFiles, rollbackFailures };
|
||||
}
|
||||
|
||||
export function collectManualApplyFiles(batch, extraFiles = [], cwd = process.cwd()) {
|
||||
const files = [];
|
||||
for (const entry of batch?.entries || []) {
|
||||
for (const op of entry.ops || []) files.push(op.sourceHint?.file);
|
||||
}
|
||||
for (const candidate of batch?.candidates || []) {
|
||||
files.push(candidate.sourceHint?.relativeFile, candidate.sourceHint?.file);
|
||||
for (const item of candidate.textMatches || []) files.push(item.file);
|
||||
for (const item of candidate.objectKeyMatches || []) files.push(item.file);
|
||||
for (const item of candidate.locatorMatches || []) files.push(item.file);
|
||||
for (const item of candidate.contextTextMatches || []) files.push(item.file);
|
||||
}
|
||||
files.push(...(extraFiles || []));
|
||||
return [...new Set(files)]
|
||||
.map((file) => normalizeProjectFile(file, cwd))
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function normalizeProjectFile(file, cwd = process.cwd()) {
|
||||
if (!file || typeof file !== 'string') return null;
|
||||
const absolute = path.isAbsolute(file) ? file : path.resolve(cwd, file);
|
||||
const relative = path.relative(cwd, absolute);
|
||||
if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) return null;
|
||||
return relative;
|
||||
}
|
||||
|
||||
export function rollbackApplySnapshot(
|
||||
batch,
|
||||
rollbackSnapshot,
|
||||
extraFiles = [],
|
||||
_reason = 'manual_edit_apply_snapshot_rollback',
|
||||
cwd = process.cwd(),
|
||||
) {
|
||||
const scope = collectManualApplyFiles(batch, extraFiles, cwd);
|
||||
const rolledBackFiles = [];
|
||||
const rollbackFailures = [];
|
||||
for (const relativeFile of scope) {
|
||||
const before = rollbackSnapshot?.get(relativeFile);
|
||||
if (!before) continue;
|
||||
const absolute = path.resolve(cwd, relativeFile);
|
||||
try {
|
||||
if (before.exists) {
|
||||
fs.mkdirSync(path.dirname(absolute), { recursive: true });
|
||||
fs.writeFileSync(absolute, before.content, 'utf-8');
|
||||
} else if (fs.existsSync(absolute)) {
|
||||
fs.rmSync(absolute);
|
||||
}
|
||||
rolledBackFiles.push(relativeFile);
|
||||
} catch (err) {
|
||||
rollbackFailures.push({ file: relativeFile, reason: 'restore_failed', message: err.message || String(err) });
|
||||
}
|
||||
}
|
||||
return { rolledBackFiles, rollbackFailures };
|
||||
}
|
||||
|
||||
function manualApplyReplyCommand(eventOrId = 'EVENT_ID') {
|
||||
const id = typeof eventOrId === 'string' ? eventOrId : eventOrId?.id || 'EVENT_ID';
|
||||
return `live-poll.mjs --reply ${id} done --data '<json>'`;
|
||||
}
|
||||
|
||||
export function buildManualApplyAgentAction(eventOrId = 'EVENT_ID') {
|
||||
return {
|
||||
kind: 'manual_edit_apply',
|
||||
required: 'apply_source_edits_then_reply',
|
||||
replyCommand: manualApplyReplyCommand(eventOrId),
|
||||
warning: 'Polling only leases this work item; it does not commit source edits.',
|
||||
};
|
||||
}
|
||||
|
||||
export function summarizeManualApplyEvent(event = {}, batch = event.batch, cwd = process.cwd()) {
|
||||
const entries = Array.isArray(batch?.entries) ? batch.entries : [];
|
||||
const opCount = entries.reduce((sum, entry) => sum + (Array.isArray(entry.ops) ? entry.ops.length : 0), 0);
|
||||
return {
|
||||
pageUrl: event.pageUrl || null,
|
||||
chunk: event.chunk || null,
|
||||
entryCount: entries.length,
|
||||
opCount,
|
||||
files: collectManualApplyFiles(batch, [], cwd),
|
||||
};
|
||||
}
|
||||
|
||||
export function summarizeManualApplyFailures(failed, cwd = process.cwd()) {
|
||||
if (!Array.isArray(failed)) return [];
|
||||
return failed.slice(0, 20).map((item) => ({
|
||||
id: item.id || item.entryId || null,
|
||||
reason: item.reason || item.message || 'failed',
|
||||
message: compactManualLogText(item.message, 300),
|
||||
files: Array.isArray(item.files) ? item.files.slice(0, 12).map((file) => summarizeManualLogFile(file, cwd)).filter(Boolean) : undefined,
|
||||
checks: summarizeManualDiagnostics(item.checks, cwd),
|
||||
failures: summarizeManualDiagnostics(item.failures, cwd),
|
||||
candidates: summarizeManualDiagnostics(item.candidates, cwd),
|
||||
}));
|
||||
}
|
||||
|
||||
export function summarizeManualDiagnostics(items, cwd = process.cwd()) {
|
||||
if (!Array.isArray(items) || items.length === 0) return undefined;
|
||||
return items.slice(0, 12).map((item) => ({
|
||||
reason: item.reason || item.kind || undefined,
|
||||
detail: compactManualLogText(item.detail, 220),
|
||||
message: compactManualLogText(item.message, 300),
|
||||
file: summarizeManualLogFile(item.file || item.relativeFile, cwd),
|
||||
line: item.line || undefined,
|
||||
ref: compactManualLogText(item.ref, 180),
|
||||
marker: compactManualLogText(item.marker, 120),
|
||||
files: Array.isArray(item.files) ? item.files.slice(0, 8).map((file) => summarizeManualLogFile(file, cwd)).filter(Boolean) : undefined,
|
||||
}));
|
||||
}
|
||||
|
||||
export function summarizeManualLogFile(file, cwd = process.cwd()) {
|
||||
if (!file || typeof file !== 'string') return undefined;
|
||||
if (!path.isAbsolute(file)) return file;
|
||||
const relative = path.relative(cwd, file);
|
||||
return relative && !relative.startsWith('..') && !path.isAbsolute(relative) ? relative : file;
|
||||
}
|
||||
|
||||
export function compactManualLogText(value, max = 200) {
|
||||
if (typeof value !== 'string') return undefined;
|
||||
const normalized = value.replace(/\s+/g, ' ').trim();
|
||||
if (normalized.length <= max) return normalized;
|
||||
return normalized.slice(0, max) + `... [truncated ${normalized.length - max} chars]`;
|
||||
}
|
||||
@@ -0,0 +1,357 @@
|
||||
import { validateEvent } from './event-validation.mjs';
|
||||
import {
|
||||
countByPage as countPendingByPage,
|
||||
readBuffer as readManualEditsBuffer,
|
||||
removeEntries as removeManualEditEntries,
|
||||
stageEntry as stageManualEditEntry,
|
||||
truncateBuffer as truncateManualEditsBuffer,
|
||||
} from './manual-edits-buffer.mjs';
|
||||
import {
|
||||
summarizeManualApplyFailures,
|
||||
summarizeManualDiagnostics,
|
||||
summarizeManualLogFile,
|
||||
} from './manual-apply.mjs';
|
||||
import { buildManualEditEvidence } from '../live-manual-edit-evidence.mjs';
|
||||
import { commitManualEdits } from '../live-commit-manual-edits.mjs';
|
||||
|
||||
export function createManualEditRoutes({
|
||||
getToken,
|
||||
manualApply,
|
||||
recordManualEditActivity,
|
||||
getManualEditStatus,
|
||||
chatAgentLikelyActive,
|
||||
cwd = () => process.cwd(),
|
||||
env = () => process.env,
|
||||
} = {}) {
|
||||
const projectCwd = () => typeof cwd === 'function' ? cwd() : cwd || process.cwd();
|
||||
const currentEnv = () => typeof env === 'function' ? env() : env || process.env;
|
||||
|
||||
return function handleManualEditRoute(req, res, url) {
|
||||
const p = url.pathname;
|
||||
|
||||
// Save stages entries; Apply commits the staged page batch through the
|
||||
// local AI copy-edit runner.
|
||||
if (p === '/manual-edit-stash' && req.method === 'POST') {
|
||||
let body = '';
|
||||
req.on('data', (c) => { body += c; });
|
||||
req.on('end', () => {
|
||||
let msg;
|
||||
try { msg = JSON.parse(body); } catch {
|
||||
sendJson(res, 400, { error: 'Invalid JSON' });
|
||||
return;
|
||||
}
|
||||
if (msg.token !== getToken()) {
|
||||
sendJson(res, 401, { error: 'Unauthorized' });
|
||||
return;
|
||||
}
|
||||
const error = validateEvent({ ...msg, type: 'manual_edits' });
|
||||
if (error) {
|
||||
sendJson(res, 400, { error });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
stageManualEditEntry(projectCwd(), {
|
||||
id: msg.id,
|
||||
pageUrl: msg.pageUrl,
|
||||
element: msg.element,
|
||||
ops: msg.ops,
|
||||
});
|
||||
} catch (err) {
|
||||
sendJson(res, 500, { error: 'stash_write_failed', message: err.message });
|
||||
return;
|
||||
}
|
||||
const { totalCount, perPage } = countPendingByPage(projectCwd());
|
||||
const pendingCount = perPage[msg.pageUrl] || 0;
|
||||
recordManualEditActivity('manual_edit_stashed', {
|
||||
id: msg.id,
|
||||
pageUrl: msg.pageUrl,
|
||||
opCount: msg.ops.length,
|
||||
pendingCount,
|
||||
totalCount,
|
||||
hintedFileCount: new Set((msg.ops || []).map((op) => summarizeManualLogFile(op.sourceHint?.file, projectCwd())).filter(Boolean)).size,
|
||||
});
|
||||
sendJson(res, 200, { ok: true, pendingCount, totalCount, perPage });
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
if (p === '/manual-edit-stash' && req.method === 'GET') {
|
||||
const token = url.searchParams.get('token');
|
||||
if (token !== getToken()) { res.writeHead(401); res.end('Unauthorized'); return true; }
|
||||
const pageUrl = url.searchParams.get('pageUrl') || '';
|
||||
const { totalCount, perPage } = countPendingByPage(projectCwd());
|
||||
const buffer = readManualEditsBuffer(projectCwd());
|
||||
const entriesForPage = pageUrl ? buffer.entries.filter((e) => e.pageUrl === pageUrl) : buffer.entries;
|
||||
sendJson(res, 200, {
|
||||
count: pageUrl ? (perPage[pageUrl] || 0) : totalCount,
|
||||
totalCount,
|
||||
perPage,
|
||||
entries: entriesForPage,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
if (p === '/manual-edit-commit' && req.method === 'POST') {
|
||||
const token = url.searchParams.get('token');
|
||||
if (token !== getToken()) { res.writeHead(401); res.end('Unauthorized'); return true; }
|
||||
const pageUrl = url.searchParams.get('pageUrl');
|
||||
const asyncMode = /^(1|true|yes)$/i.test(url.searchParams.get('async') || '');
|
||||
const repairOnly = /^(1|true|yes)$/i.test(url.searchParams.get('repair') || '');
|
||||
const existingTransaction = manualApply.readTransaction();
|
||||
if (repairOnly && !existingTransaction) {
|
||||
sendJson(res, 409, { error: 'manual_edit_repair_transaction_missing' });
|
||||
return true;
|
||||
}
|
||||
const recoveredTransaction = repairOnly ? null : manualApply.rollbackTransaction({
|
||||
pageUrl,
|
||||
reason: 'manual_edit_commit_recovered_abandoned_transaction',
|
||||
});
|
||||
const before = getManualEditStatus();
|
||||
const pendingCount = pageUrl ? (before.perPage[pageUrl] || 0) : before.totalCount;
|
||||
recordManualEditActivity('manual_edit_commit_started', {
|
||||
pageUrl,
|
||||
repairOnly,
|
||||
pendingCount,
|
||||
totalCount: before.totalCount,
|
||||
recoveredTransaction: recoveredTransaction ? {
|
||||
id: recoveredTransaction.id,
|
||||
reason: recoveredTransaction.reason,
|
||||
skipped: recoveredTransaction.skipped,
|
||||
rolledBackFiles: recoveredTransaction.rolledBackFiles,
|
||||
rollbackFailures: summarizeManualDiagnostics(recoveredTransaction.rollbackFailures, projectCwd()),
|
||||
} : null,
|
||||
...summarizePendingManualEditBatch(projectCwd(), pageUrl),
|
||||
});
|
||||
if (asyncMode) {
|
||||
sendJson(res, 202, {
|
||||
status: 'started',
|
||||
pendingCount,
|
||||
totalCount: before.totalCount,
|
||||
perPage: before.perPage,
|
||||
});
|
||||
}
|
||||
(async () => {
|
||||
let result;
|
||||
let routedProvider = 'subprocess';
|
||||
let transaction = null;
|
||||
let commitBatch = null;
|
||||
try {
|
||||
if (pendingCount > 0) {
|
||||
const transactionBatch = buildManualEditEvidence({ cwd: projectCwd(), pageUrl });
|
||||
commitBatch = transactionBatch;
|
||||
if (!repairOnly && manualApply.countOps(transactionBatch) > 0) {
|
||||
transaction = manualApply.writeTransaction({
|
||||
pageUrl,
|
||||
batch: transactionBatch,
|
||||
});
|
||||
} else if (repairOnly && existingTransaction) {
|
||||
transaction = existingTransaction;
|
||||
}
|
||||
}
|
||||
const envValue = currentEnv();
|
||||
const requestedMode = (envValue.IMPECCABLE_LIVE_COPY_AGENT || 'auto').trim().toLowerCase();
|
||||
const useChatRoute = requestedMode === 'chat'
|
||||
|| (requestedMode === 'auto' && chatAgentLikelyActive());
|
||||
if (useChatRoute) {
|
||||
routedProvider = 'chat';
|
||||
const timeoutMs = Number(envValue.IMPECCABLE_LIVE_COPY_AGENT_TIMEOUT_MS || 120000);
|
||||
result = await commitManualEdits({
|
||||
cwd: projectCwd(),
|
||||
pageUrl,
|
||||
provider: 'chat',
|
||||
env: envValue,
|
||||
timeoutMs,
|
||||
chatAvailable: chatAgentLikelyActive,
|
||||
applyBatchToSource: (batch, context) => manualApply.pushBatchInChunksAndWait(batch, pageUrl, context),
|
||||
repairOnly,
|
||||
transactionId: transaction?.id || existingTransaction?.id || null,
|
||||
batch: commitBatch,
|
||||
});
|
||||
} else {
|
||||
const timeoutMs = Number(envValue.IMPECCABLE_LIVE_COPY_AGENT_TIMEOUT_MS || 120000);
|
||||
const provider = ['codex', 'claude', 'mock'].includes(requestedMode) ? requestedMode : undefined;
|
||||
result = await commitManualEdits({
|
||||
cwd: projectCwd(),
|
||||
pageUrl,
|
||||
provider,
|
||||
env: envValue,
|
||||
timeoutMs,
|
||||
chatAvailable: chatAgentLikelyActive,
|
||||
repairOnly,
|
||||
transactionId: transaction?.id || existingTransaction?.id || null,
|
||||
batch: commitBatch,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
if (transaction) {
|
||||
manualApply.rollbackTransaction({
|
||||
pageUrl,
|
||||
reason: 'manual_edit_commit_exception',
|
||||
});
|
||||
}
|
||||
const message = err.stderr?.toString?.() || err.message;
|
||||
recordManualEditActivity('manual_edit_commit_failed', {
|
||||
pageUrl,
|
||||
provider: routedProvider,
|
||||
error: 'manual_edit_commit_failed',
|
||||
message,
|
||||
transactionId: transaction?.id || null,
|
||||
});
|
||||
if (!asyncMode) {
|
||||
sendJson(res, 500, {
|
||||
error: 'manual_edit_commit_failed',
|
||||
message,
|
||||
});
|
||||
}
|
||||
return;
|
||||
} finally {
|
||||
if (transaction) {
|
||||
const shouldKeepTransaction = result?.needsManualDecision === true;
|
||||
if (!shouldKeepTransaction) manualApply.clearTransaction(transaction.id);
|
||||
}
|
||||
}
|
||||
const { totalCount, perPage } = countPendingByPage(projectCwd());
|
||||
if (result?.needsManualDecision) {
|
||||
recordManualEditActivity('manual_edit_repair_needs_decision', {
|
||||
pageUrl,
|
||||
provider: routedProvider,
|
||||
transactionId: transaction?.id || existingTransaction?.id || null,
|
||||
repair: result.repair || null,
|
||||
failed: summarizeManualApplyFailures(result.failed, projectCwd()),
|
||||
files: Array.isArray(result.files) ? result.files.slice(0, 20).map((file) => summarizeManualLogFile(file, projectCwd())).filter(Boolean) : [],
|
||||
remainingCount: pageUrl ? (perPage[pageUrl] || 0) : totalCount,
|
||||
totalCount,
|
||||
});
|
||||
} else {
|
||||
recordManualEditActivity('manual_edit_commit_done', {
|
||||
pageUrl,
|
||||
provider: routedProvider,
|
||||
reason: result.reason || null,
|
||||
repair: result.repair || null,
|
||||
appliedCount: Array.isArray(result.applied) ? result.applied.length : 0,
|
||||
failedCount: Array.isArray(result.failed) ? result.failed.length : 0,
|
||||
failed: summarizeManualApplyFailures(result.failed, projectCwd()),
|
||||
files: Array.isArray(result.files) ? result.files.slice(0, 20).map((file) => summarizeManualLogFile(file, projectCwd())).filter(Boolean) : [],
|
||||
warnings: summarizeManualDiagnostics(result.warnings, projectCwd()),
|
||||
rolledBackFiles: Array.isArray(result.rolledBackFiles) ? result.rolledBackFiles.slice(0, 20).map((file) => summarizeManualLogFile(file, projectCwd())).filter(Boolean) : [],
|
||||
rollbackFailures: summarizeManualDiagnostics(result.rollbackFailures, projectCwd()),
|
||||
unreportedFiles: Array.isArray(result.unreportedFiles) ? result.unreportedFiles.slice(0, 20).map((file) => summarizeManualLogFile(file, projectCwd())).filter(Boolean) : undefined,
|
||||
noteCount: Array.isArray(result.notes) ? result.notes.length : 0,
|
||||
cleared: result.cleared || 0,
|
||||
remainingCount: pageUrl ? (perPage[pageUrl] || 0) : totalCount,
|
||||
totalCount,
|
||||
});
|
||||
}
|
||||
if (!asyncMode) {
|
||||
sendJson(res, 200, { ...result, totalCount, perPage });
|
||||
}
|
||||
})();
|
||||
return true;
|
||||
}
|
||||
|
||||
if (p === '/manual-edit-repair-decision' && req.method === 'POST') {
|
||||
let body = '';
|
||||
req.on('data', (chunk) => { body += chunk; });
|
||||
req.on('end', () => {
|
||||
let payload = {};
|
||||
try { payload = body ? JSON.parse(body) : {}; } catch {
|
||||
sendJson(res, 400, { error: 'Invalid JSON' });
|
||||
return;
|
||||
}
|
||||
const token = payload.token || url.searchParams.get('token');
|
||||
if (token !== getToken()) { res.writeHead(401); res.end('Unauthorized'); return; }
|
||||
const pageUrl = payload.pageUrl || url.searchParams.get('pageUrl') || null;
|
||||
const action = String(payload.action || url.searchParams.get('action') || '').trim().toLowerCase();
|
||||
if (action !== 'rollback') {
|
||||
sendJson(res, 400, { error: 'unsupported_manual_edit_repair_decision', action });
|
||||
return;
|
||||
}
|
||||
const rollback = manualApply.rollbackTransaction({
|
||||
pageUrl,
|
||||
reason: 'manual_edit_user_requested_rollback',
|
||||
});
|
||||
const { totalCount, perPage } = countPendingByPage(projectCwd());
|
||||
const response = {
|
||||
action,
|
||||
pageUrl,
|
||||
rollback,
|
||||
remainingCount: pageUrl ? (perPage[pageUrl] || 0) : totalCount,
|
||||
totalCount,
|
||||
perPage,
|
||||
};
|
||||
recordManualEditActivity('manual_edit_repair_rollback_done', response);
|
||||
sendJson(res, 200, response);
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
if (p === '/manual-edit-discard' && req.method === 'POST') {
|
||||
const token = url.searchParams.get('token');
|
||||
if (token !== getToken()) { res.writeHead(401); res.end('Unauthorized'); return true; }
|
||||
const pageUrl = url.searchParams.get('pageUrl');
|
||||
let discarded;
|
||||
let discardedEntries = [];
|
||||
let canceledApplyEvents = [];
|
||||
let transactionRollback = null;
|
||||
try {
|
||||
const buffer = readManualEditsBuffer(projectCwd());
|
||||
transactionRollback = manualApply.rollbackTransaction({
|
||||
pageUrl,
|
||||
reason: 'manual_edit_discarded',
|
||||
});
|
||||
if (pageUrl) {
|
||||
discardedEntries = buffer.entries.filter((entry) => entry.pageUrl === pageUrl);
|
||||
discarded = removeManualEditEntries(projectCwd(), (entry) => entry.pageUrl === pageUrl);
|
||||
} else {
|
||||
discardedEntries = buffer.entries;
|
||||
discarded = truncateManualEditsBuffer(projectCwd());
|
||||
}
|
||||
canceledApplyEvents = manualApply.cancelPendingEvents(pageUrl);
|
||||
} catch (err) {
|
||||
sendJson(res, 500, { error: 'discard_failed', message: err.message });
|
||||
return true;
|
||||
}
|
||||
const { totalCount, perPage } = countPendingByPage(projectCwd());
|
||||
recordManualEditActivity('manual_edit_discarded', {
|
||||
pageUrl,
|
||||
discarded,
|
||||
canceledApplyIds: canceledApplyEvents.map((event) => event.id),
|
||||
transactionRollback: transactionRollback ? {
|
||||
id: transactionRollback.id,
|
||||
rolledBackFiles: transactionRollback.rolledBackFiles?.map((file) => summarizeManualLogFile(file, projectCwd())).filter(Boolean) || [],
|
||||
rollbackFailures: summarizeManualDiagnostics(transactionRollback.rollbackFailures, projectCwd()),
|
||||
skipped: transactionRollback.skipped,
|
||||
} : undefined,
|
||||
totalCount,
|
||||
});
|
||||
sendJson(res, 200, { discarded, entries: discardedEntries, canceledApplyEvents, totalCount, perPage });
|
||||
return true;
|
||||
}
|
||||
|
||||
if (p === '/manual-edit' && req.method === 'POST') {
|
||||
sendJson(res, 410, { error: '/manual-edit is removed; use /manual-edit-stash and /manual-edit-commit for staged copy edits.' });
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
}
|
||||
|
||||
function sendJson(res, status, body) {
|
||||
res.writeHead(status, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify(body));
|
||||
}
|
||||
|
||||
function summarizePendingManualEditBatch(cwd, pageUrl = null) {
|
||||
try {
|
||||
const buffer = readManualEditsBuffer(cwd);
|
||||
const entries = (buffer.entries || [])
|
||||
.filter((entry) => !pageUrl || entry.pageUrl === pageUrl);
|
||||
return {
|
||||
pendingEntryCount: entries.length,
|
||||
pendingOpCount: entries.reduce((sum, entry) => sum + (entry.ops?.length || 0), 0),
|
||||
};
|
||||
} catch (err) {
|
||||
return { pendingSummaryError: err.message || String(err) };
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -12,7 +12,7 @@
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { getLiveDir } from './impeccable-paths.mjs';
|
||||
import { getLiveDir } from '../lib/impeccable-paths.mjs';
|
||||
|
||||
const BUFFER_VERSION = 1;
|
||||
const BUFFER_FILENAME = 'pending-manual-edits.json';
|
||||
+21
-3
@@ -1,6 +1,6 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { getLegacyLiveSessionsDir, getLiveSessionsDir } from './impeccable-paths.mjs';
|
||||
import { getLegacyLiveSessionsDir, getLiveSessionsDir } from '../lib/impeccable-paths.mjs';
|
||||
|
||||
const COMPLETED_PHASES = new Set(['completed', 'discarded']);
|
||||
|
||||
@@ -106,6 +106,8 @@ function baseSnapshot(id) {
|
||||
phase: 'new',
|
||||
pageUrl: null,
|
||||
sourceFile: null,
|
||||
previewFile: null,
|
||||
previewMode: null,
|
||||
expectedVariants: 0,
|
||||
arrivedVariants: 0,
|
||||
visibleVariant: null,
|
||||
@@ -177,8 +179,10 @@ function applyEvent(snapshot, entry, inheritedDiagnostics = []) {
|
||||
case 'variants_ready':
|
||||
case 'agent_done':
|
||||
next.phase = event.carbonize === true ? 'carbonize_required' : 'variants_ready';
|
||||
next.sourceFile = event.file ?? next.sourceFile;
|
||||
next.arrivedVariants = event.arrivedVariants ?? (next.arrivedVariants ?? next.expectedVariants);
|
||||
next.sourceFile = event.sourceFile ?? event.file ?? next.sourceFile;
|
||||
next.previewFile = event.previewFile ?? next.previewFile;
|
||||
next.previewMode = event.previewMode ?? next.previewMode;
|
||||
next.arrivedVariants = event.arrivedVariants ?? (next.expectedVariants || next.arrivedVariants || 0);
|
||||
next.pendingEventSeq = null;
|
||||
next.pendingEvent = null;
|
||||
if (event.carbonize === true) {
|
||||
@@ -190,12 +194,19 @@ function applyEvent(snapshot, entry, inheritedDiagnostics = []) {
|
||||
}
|
||||
break;
|
||||
case 'checkpoint':
|
||||
if (COMPLETED_PHASES.has(next.phase)) {
|
||||
next.diagnostics.push({ error: 'checkpoint_after_terminal_ignored', phase: event.phase ?? null, revision: event.revision ?? null });
|
||||
break;
|
||||
}
|
||||
if ((event.revision ?? 0) >= (next.checkpointRevision ?? 0)) {
|
||||
next.phase = event.phase ?? next.phase;
|
||||
next.checkpointRevision = event.revision ?? next.checkpointRevision;
|
||||
next.activeOwner = event.owner ?? next.activeOwner;
|
||||
next.arrivedVariants = event.arrivedVariants ?? next.arrivedVariants;
|
||||
next.visibleVariant = event.visibleVariant ?? next.visibleVariant;
|
||||
next.sourceFile = event.sourceFile ?? next.sourceFile;
|
||||
next.previewFile = event.previewFile ?? next.previewFile;
|
||||
next.previewMode = event.previewMode ?? next.previewMode;
|
||||
if (event.paramValues) next.paramValues = { ...event.paramValues };
|
||||
} else {
|
||||
next.diagnostics.push({ error: 'stale_checkpoint_ignored', revision: event.revision });
|
||||
@@ -223,6 +234,10 @@ function applyEvent(snapshot, entry, inheritedDiagnostics = []) {
|
||||
break;
|
||||
case 'steer_done':
|
||||
next.phase = 'steer_done';
|
||||
next.sourceFile = event.sourceFile ?? event.file ?? next.sourceFile;
|
||||
next.previewFile = event.previewFile ?? next.previewFile;
|
||||
next.previewMode = event.previewMode ?? next.previewMode;
|
||||
next.message = event.message ?? next.message;
|
||||
next.pendingEventSeq = null;
|
||||
next.pendingEvent = null;
|
||||
break;
|
||||
@@ -238,6 +253,9 @@ function applyEvent(snapshot, entry, inheritedDiagnostics = []) {
|
||||
break;
|
||||
case 'complete':
|
||||
next.phase = 'completed';
|
||||
next.sourceFile = event.sourceFile ?? event.file ?? next.sourceFile;
|
||||
next.previewFile = event.previewFile ?? next.previewFile;
|
||||
next.previewMode = event.previewMode ?? next.previewMode;
|
||||
next.pendingEventSeq = null;
|
||||
next.pendingEvent = null;
|
||||
break;
|
||||
@@ -0,0 +1,826 @@
|
||||
/**
|
||||
* Svelte live-mode component injection helpers.
|
||||
*
|
||||
* Variants are real .svelte components under node_modules/.impeccable-live/<session-id>/.
|
||||
* The browser mounts them via Svelte 5 mount(); accept inlines the chosen
|
||||
* variant back into the route source with props mapped to original bindings.
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
import { createHash } from 'node:crypto';
|
||||
|
||||
export const SVELTE_COMPONENT_ROOT = 'node_modules/.impeccable-live';
|
||||
export const SVELTE_RUNTIME_FILE = `${SVELTE_COMPONENT_ROOT}/__runtime.js`;
|
||||
export const DEFERRED_ACCEPTS_FILE = '.impeccable/live/deferred-svelte-component-accepts.json';
|
||||
|
||||
const MUSTACHE_RE = /\{([^{}]+)\}/g;
|
||||
|
||||
export function shouldUseSvelteComponentInjection(filePath) {
|
||||
if (/^(0|false|no)$/i.test(process.env.IMPECCABLE_LIVE_SVELTE_COMPONENT || '')) return false;
|
||||
return path.extname(filePath).toLowerCase() === '.svelte';
|
||||
}
|
||||
|
||||
export function componentSessionDir(id, cwd = process.cwd()) {
|
||||
return path.join(cwd, SVELTE_COMPONENT_ROOT, id);
|
||||
}
|
||||
|
||||
export function manifestPathForSession(id, cwd = process.cwd()) {
|
||||
return path.join(componentSessionDir(id, cwd), 'manifest.json');
|
||||
}
|
||||
|
||||
export function ensureRuntimeHelper(cwd = process.cwd()) {
|
||||
const file = path.join(cwd, SVELTE_RUNTIME_FILE);
|
||||
if (fs.existsSync(file)) return file;
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||
fs.writeFileSync(file, `export { mount, unmount } from 'svelte';\n`, 'utf-8');
|
||||
return file;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract ordered unique mustache expressions from markup (not inside <!-- -->).
|
||||
*/
|
||||
export function extractMustacheExpressions(text) {
|
||||
const expressions = [];
|
||||
const seen = new Set();
|
||||
const lines = String(text || '').split('\n');
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (trimmed.startsWith('<!--')) continue;
|
||||
let match;
|
||||
MUSTACHE_RE.lastIndex = 0;
|
||||
while ((match = MUSTACHE_RE.exec(line)) !== null) {
|
||||
const expr = match[1].trim();
|
||||
if (!expr || seen.has(expr)) continue;
|
||||
seen.add(expr);
|
||||
expressions.push(expr);
|
||||
}
|
||||
}
|
||||
return expressions;
|
||||
}
|
||||
|
||||
export function buildPropContract(expressions) {
|
||||
return expressions.map((expr, index) => {
|
||||
const derived = derivePropName(expr, index);
|
||||
return {
|
||||
prop: derived,
|
||||
expr,
|
||||
placeholder: `{${expr}}`,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function derivePropName(expr, index) {
|
||||
const tail = expr.match(/(?:\.|\[)(\w+)\s*\]?$/);
|
||||
if (tail && tail[1] && /^[A-Za-z_$][\w$]*$/.test(tail[1])) {
|
||||
return tail[1];
|
||||
}
|
||||
return `prop${index}`;
|
||||
}
|
||||
|
||||
export function substituteExprsWithProps(markup, contract) {
|
||||
let out = String(markup || '');
|
||||
for (const entry of contract) {
|
||||
out = out.split(entry.placeholder).join(`{${entry.prop}}`);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function substitutePropsWithExprs(markup, contract) {
|
||||
let out = String(markup || '');
|
||||
for (const entry of contract) {
|
||||
out = out.split(`{${entry.prop}}`).join(`{${entry.expr}}`);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function parseSvelteComponentFile(content) {
|
||||
const text = String(content || '');
|
||||
const scriptMatch = text.match(/^([\s\S]*?)<script\b[^>]*>[\s\S]*?<\/script>/i);
|
||||
const withoutScript = scriptMatch ? text.slice(scriptMatch[0].length) : text;
|
||||
const styleMatch = withoutScript.match(/<style\b[^>]*>[\s\S]*?<\/style\s*>/i);
|
||||
const styleBlock = styleMatch ? styleMatch[0] : '';
|
||||
const markup = styleMatch
|
||||
? withoutScript.slice(0, styleMatch.index).trim()
|
||||
: withoutScript.trim();
|
||||
const cssLines = styleBlock
|
||||
? styleBlock
|
||||
.replace(/^<style\b[^>]*>/i, '')
|
||||
.replace(/<\/style\s*>$/i, '')
|
||||
.split('\n')
|
||||
.map((line) => line.trimEnd())
|
||||
: [];
|
||||
while (cssLines.length > 0 && cssLines[0].trim() === '') cssLines.shift();
|
||||
while (cssLines.length > 0 && cssLines[cssLines.length - 1].trim() === '') cssLines.pop();
|
||||
return { markup, cssLines, styleBlock };
|
||||
}
|
||||
|
||||
function buildPropsScript(contract) {
|
||||
if (contract.length === 0) {
|
||||
return '<script>\n /** @type {Record<string, never>} */\n let {} = $props();\n</script>\n';
|
||||
}
|
||||
const names = contract.map((c) => c.prop).join(', ');
|
||||
const typeFields = contract.map((c) => ` ${c.prop}: string;`).join('\n');
|
||||
return `<script>\n /** @type {{\n${typeFields}\n }} */\n let { ${names} } = $props();\n</script>\n`;
|
||||
}
|
||||
|
||||
function buildVariantStub(variantNum, originalWithProps, contract) {
|
||||
const propsComment = contract.length > 0
|
||||
? `\n<!-- Props: ${contract.map((c) => `${c.prop} <- {${c.expr}}`).join(', ')} -->\n`
|
||||
: '';
|
||||
return `${buildPropsScript(contract)}${propsComment}${originalWithProps.trim()}\n\n<style>\n /* Variant ${variantNum}: add scoped CSS here */\n</style>\n`;
|
||||
}
|
||||
|
||||
function buildInsertVariantStub(variantNum) {
|
||||
return `${buildPropsScript([])}<div class="impeccable-insert-preview">Insert variant ${variantNum}</div>\n\n<style>\n .impeccable-insert-preview { display: block; }\n</style>\n`;
|
||||
}
|
||||
|
||||
export function scaffoldSvelteComponentSession({
|
||||
id,
|
||||
count,
|
||||
sourceFile,
|
||||
sourceStartLine,
|
||||
sourceEndLine,
|
||||
originalLines,
|
||||
cwd = process.cwd(),
|
||||
}) {
|
||||
ensureRuntimeHelper(cwd);
|
||||
const dir = componentSessionDir(id, cwd);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
|
||||
const originalMarkup = originalLines.join('\n');
|
||||
const contract = buildPropContract(extractMustacheExpressions(originalMarkup));
|
||||
const originalWithProps = substituteExprsWithProps(originalMarkup, contract);
|
||||
|
||||
const manifest = {
|
||||
id,
|
||||
previewMode: 'svelte-component',
|
||||
sourceFile: sourceFile.split(path.sep).join('/'),
|
||||
sourceStartLine,
|
||||
sourceEndLine,
|
||||
count,
|
||||
propContract: contract,
|
||||
originalMarkup,
|
||||
componentDir: path.relative(cwd, dir).split(path.sep).join('/'),
|
||||
runtimeModule: `/${SVELTE_RUNTIME_FILE}`,
|
||||
};
|
||||
|
||||
fs.writeFileSync(path.join(dir, 'manifest.json'), JSON.stringify(manifest, null, 2) + '\n', 'utf-8');
|
||||
|
||||
for (let n = 1; n <= count; n++) {
|
||||
const variantFile = path.join(dir, `v${n}.svelte`);
|
||||
if (!fs.existsSync(variantFile)) {
|
||||
fs.writeFileSync(variantFile, buildVariantStub(n, originalWithProps, contract), 'utf-8');
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
manifest,
|
||||
manifestFile: path.relative(cwd, path.join(dir, 'manifest.json')).split(path.sep).join('/'),
|
||||
componentDir: manifest.componentDir,
|
||||
propContract: contract,
|
||||
};
|
||||
}
|
||||
|
||||
export function scaffoldSvelteComponentInsertSession({
|
||||
id,
|
||||
count,
|
||||
sourceFile,
|
||||
insertLine,
|
||||
position,
|
||||
anchorStartLine,
|
||||
anchorEndLine,
|
||||
anchorLines,
|
||||
cwd = process.cwd(),
|
||||
}) {
|
||||
ensureRuntimeHelper(cwd);
|
||||
const dir = componentSessionDir(id, cwd);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
|
||||
const anchorMarkup = (anchorLines || []).join('\n');
|
||||
const manifest = {
|
||||
id,
|
||||
mode: 'insert',
|
||||
previewMode: 'svelte-component',
|
||||
sourceFile: sourceFile.split(path.sep).join('/'),
|
||||
insertLine,
|
||||
position,
|
||||
anchorStartLine,
|
||||
anchorEndLine,
|
||||
originalMarkup: anchorMarkup,
|
||||
anchorMarkup,
|
||||
count,
|
||||
propContract: [],
|
||||
componentDir: path.relative(cwd, dir).split(path.sep).join('/'),
|
||||
runtimeModule: `/${SVELTE_RUNTIME_FILE}`,
|
||||
};
|
||||
|
||||
fs.writeFileSync(path.join(dir, 'manifest.json'), JSON.stringify(manifest, null, 2) + '\n', 'utf-8');
|
||||
|
||||
for (let n = 1; n <= count; n++) {
|
||||
const variantFile = path.join(dir, `v${n}.svelte`);
|
||||
if (!fs.existsSync(variantFile)) {
|
||||
fs.writeFileSync(variantFile, buildInsertVariantStub(n), 'utf-8');
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
manifest,
|
||||
manifestFile: path.relative(cwd, path.join(dir, 'manifest.json')).split(path.sep).join('/'),
|
||||
componentDir: manifest.componentDir,
|
||||
propContract: [],
|
||||
};
|
||||
}
|
||||
|
||||
export function findSvelteComponentManifest(id, cwd = process.cwd()) {
|
||||
const direct = manifestPathForSession(id, cwd);
|
||||
if (fs.existsSync(direct)) {
|
||||
return readManifest(direct);
|
||||
}
|
||||
const root = path.join(cwd, SVELTE_COMPONENT_ROOT);
|
||||
if (!fs.existsSync(root)) return null;
|
||||
for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
const candidate = path.join(root, entry.name, 'manifest.json');
|
||||
if (!fs.existsSync(candidate)) continue;
|
||||
try {
|
||||
const manifest = readManifest(candidate);
|
||||
if (manifest?.id === id) return { ...manifest, manifestPath: candidate };
|
||||
} catch { /* skip */ }
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function readManifest(manifestPath) {
|
||||
const data = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
|
||||
return {
|
||||
...data,
|
||||
manifestPath,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveSourceFile(sourceFile, cwd = process.cwd()) {
|
||||
if (!sourceFile || path.isAbsolute(sourceFile)) {
|
||||
throw new Error('Invalid svelte-component source file');
|
||||
}
|
||||
const full = path.resolve(cwd, sourceFile);
|
||||
const rel = path.relative(cwd, full);
|
||||
if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) {
|
||||
throw new Error('Svelte-component source file escapes project root');
|
||||
}
|
||||
if (!fs.existsSync(full)) {
|
||||
throw new Error('Svelte-component source file not found: ' + sourceFile);
|
||||
}
|
||||
return full;
|
||||
}
|
||||
|
||||
function appendCssToSvelteStyle(lines, cssLines) {
|
||||
const closeIdx = findLastStyleCloseLine(lines);
|
||||
const prepared = ['', ...cssLines.map((line) => (line.trim() === '' ? '' : ' ' + line.trimStart()))];
|
||||
if (closeIdx === -1) {
|
||||
return [...lines, '', '<style>', ...prepared.slice(1), '</style>'];
|
||||
}
|
||||
return [
|
||||
...lines.slice(0, closeIdx),
|
||||
...prepared,
|
||||
...lines.slice(closeIdx),
|
||||
];
|
||||
}
|
||||
|
||||
function findLastStyleCloseLine(lines) {
|
||||
for (let i = lines.length - 1; i >= 0; i--) {
|
||||
if (/<\/style\s*>/.test(lines[i])) return i;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
function bakeParamValuesInCss(cssLines, paramValues) {
|
||||
if (!paramValues || Object.keys(paramValues).length === 0) return cssLines;
|
||||
return cssLines.map((line) => {
|
||||
let out = line;
|
||||
for (const [key, value] of Object.entries(paramValues)) {
|
||||
const varName = `--p-${key}`;
|
||||
out = out.replace(new RegExp(`var\\(${escapeRegExp(varName)}(?:,\\s*[^)]+)?\\)`, 'g'), String(value));
|
||||
}
|
||||
return out;
|
||||
});
|
||||
}
|
||||
|
||||
function sanitizeAcceptedSvelteCss(cssLines, variantNum, paramValues = null, rootTag = 'div') {
|
||||
const css = String((cssLines || []).join('\n'));
|
||||
if (!/data-impeccable-variant|impeccable-variant-ready/.test(css)) return cssLines;
|
||||
|
||||
const rules = parseCssRules(css);
|
||||
const output = [];
|
||||
for (const rule of rules) {
|
||||
appendSanitizedCssRule(output, rule, variantNum, paramValues, rootTag);
|
||||
}
|
||||
return output.join('\n')
|
||||
.split('\n')
|
||||
.map((line) => line.trimEnd())
|
||||
.filter((line) => line.trim() !== '');
|
||||
}
|
||||
|
||||
function appendSanitizedCssRule(output, rule, variantNum, paramValues, rootTag) {
|
||||
const prelude = rule.prelude.trim();
|
||||
const body = rule.body.trim();
|
||||
if (!prelude || !body || /--impeccable-variant-ready\s*:/.test(body)) return;
|
||||
|
||||
if (/^@scope\b/i.test(prelude)) {
|
||||
if (/data-impeccable-variant/.test(prelude) && !selectorHasVariant(prelude, variantNum)) return;
|
||||
const inner = parseCssRules(body);
|
||||
for (const innerRule of inner) {
|
||||
const rewrittenPrelude = rewriteAcceptedSvelteSelector(innerRule.prelude, variantNum, paramValues, rootTag, true);
|
||||
if (!rewrittenPrelude || /--impeccable-variant-ready\s*:/.test(innerRule.body)) continue;
|
||||
output.push(formatCssRule(rewrittenPrelude, innerRule.body.trim()));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const rewrittenPrelude = rewriteAcceptedSvelteSelector(prelude, variantNum, paramValues, rootTag, false);
|
||||
if (!rewrittenPrelude) return;
|
||||
output.push(formatCssRule(rewrittenPrelude, body));
|
||||
}
|
||||
|
||||
function parseCssRules(css) {
|
||||
const rules = [];
|
||||
const text = String(css || '');
|
||||
let i = 0;
|
||||
while (i < text.length) {
|
||||
while (i < text.length && /\s/.test(text[i])) i++;
|
||||
const preludeStart = i;
|
||||
while (i < text.length && text[i] !== '{') i++;
|
||||
if (i >= text.length) break;
|
||||
const prelude = text.slice(preludeStart, i).trim();
|
||||
i++;
|
||||
const bodyStart = i;
|
||||
let depth = 1;
|
||||
let quote = null;
|
||||
let comment = false;
|
||||
while (i < text.length && depth > 0) {
|
||||
const ch = text[i];
|
||||
const next = text[i + 1];
|
||||
if (comment) {
|
||||
if (ch === '*' && next === '/') {
|
||||
comment = false;
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (quote) {
|
||||
if (ch === '\\') {
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
if (ch === quote) quote = null;
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (ch === '/' && next === '*') {
|
||||
comment = true;
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
if (ch === '"' || ch === "'") {
|
||||
quote = ch;
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (ch === '{') depth++;
|
||||
else if (ch === '}') depth--;
|
||||
i++;
|
||||
}
|
||||
const body = text.slice(bodyStart, Math.max(bodyStart, i - 1));
|
||||
if (prelude) rules.push({ prelude, body });
|
||||
}
|
||||
return rules;
|
||||
}
|
||||
|
||||
function rewriteAcceptedSvelteSelector(prelude, variantNum, paramValues, rootTag, fromScope) {
|
||||
const selectors = splitSelectorList(prelude);
|
||||
const rewritten = [];
|
||||
for (const selector of selectors) {
|
||||
const next = rewriteAcceptedSvelteSelectorPart(selector, variantNum, paramValues, rootTag, fromScope);
|
||||
if (next) rewritten.push(next);
|
||||
}
|
||||
return rewritten.join(', ');
|
||||
}
|
||||
|
||||
function rewriteAcceptedSvelteSelectorPart(selector, variantNum, paramValues, rootTag, fromScope) {
|
||||
let out = selector.trim();
|
||||
const hasVariant = /data-impeccable-variant/.test(out);
|
||||
if (hasVariant && !selectorHasVariant(out, variantNum)) return '';
|
||||
if (hasVariant) {
|
||||
out = out.replace(variantSelectorRegex(variantNum), '');
|
||||
out = out.replace(/\[data-impeccable-variant=(["']).*?\1\]/g, '');
|
||||
}
|
||||
|
||||
const paramResult = rewriteParamSelectors(out, paramValues);
|
||||
if (!paramResult.keep) return '';
|
||||
out = paramResult.selector;
|
||||
|
||||
out = out
|
||||
.replace(/:scope(?:\[[^\]]+\])?\s*>\s*/g, '')
|
||||
.replace(/:scope(?:\[[^\]]+\])?/g, rootTag || '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
|
||||
out = out.replace(/^[>+~]\s*/, '').trim();
|
||||
if (!out && (hasVariant || fromScope)) return rootTag || ':global(*)';
|
||||
return out;
|
||||
}
|
||||
|
||||
function rewriteParamSelectors(selector, paramValues) {
|
||||
let keep = true;
|
||||
const next = selector.replace(/\[data-p-([A-Za-z0-9_-]+)(?:=(["'])(.*?)\2)?\]/g, (_match, key, _quote, expected) => {
|
||||
if (!paramValues || !Object.prototype.hasOwnProperty.call(paramValues, key)) return '';
|
||||
const actual = paramValues[key];
|
||||
if (expected != null && String(actual) !== String(expected)) {
|
||||
keep = false;
|
||||
return '';
|
||||
}
|
||||
if (expected == null && (actual === false || actual == null || actual === 'false' || actual === 'off' || actual === '0')) {
|
||||
keep = false;
|
||||
return '';
|
||||
}
|
||||
return '';
|
||||
});
|
||||
return { keep, selector: next };
|
||||
}
|
||||
|
||||
function splitSelectorList(prelude) {
|
||||
const selectors = [];
|
||||
let start = 0;
|
||||
let bracket = 0;
|
||||
let paren = 0;
|
||||
let quote = null;
|
||||
for (let i = 0; i < prelude.length; i++) {
|
||||
const ch = prelude[i];
|
||||
if (quote) {
|
||||
if (ch === '\\') i++;
|
||||
else if (ch === quote) quote = null;
|
||||
continue;
|
||||
}
|
||||
if (ch === '"' || ch === "'") {
|
||||
quote = ch;
|
||||
continue;
|
||||
}
|
||||
if (ch === '[') bracket++;
|
||||
else if (ch === ']') bracket = Math.max(0, bracket - 1);
|
||||
else if (ch === '(') paren++;
|
||||
else if (ch === ')') paren = Math.max(0, paren - 1);
|
||||
else if (ch === ',' && bracket === 0 && paren === 0) {
|
||||
selectors.push(prelude.slice(start, i));
|
||||
start = i + 1;
|
||||
}
|
||||
}
|
||||
selectors.push(prelude.slice(start));
|
||||
return selectors;
|
||||
}
|
||||
|
||||
function selectorHasVariant(selector, variantNum) {
|
||||
return variantSelectorRegex(variantNum).test(selector);
|
||||
}
|
||||
|
||||
function variantSelectorRegex(variantNum) {
|
||||
return new RegExp(`\\[data-impeccable-variant=(["'])${escapeRegExp(String(variantNum))}\\1\\]`, 'g');
|
||||
}
|
||||
|
||||
function formatCssRule(selector, body) {
|
||||
return `${selector} { ${body.trim()} }`;
|
||||
}
|
||||
|
||||
function escapeRegExp(value) {
|
||||
return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
export function inlineSvelteComponentAccept(manifest, variantNum, paramValues = null, cwd = process.cwd()) {
|
||||
const sourceFile = resolveSourceFile(manifest.sourceFile, cwd);
|
||||
const variantPath = path.join(cwd, manifest.componentDir, `v${variantNum}.svelte`);
|
||||
const resultBase = {
|
||||
file: manifest.sourceFile,
|
||||
sourceFile: manifest.sourceFile,
|
||||
previewMode: 'svelte-component',
|
||||
componentDir: manifest.componentDir,
|
||||
carbonize: false,
|
||||
};
|
||||
if (!fs.existsSync(variantPath)) {
|
||||
return { handled: false, error: `Variant ${variantNum} not found`, ...resultBase };
|
||||
}
|
||||
|
||||
const { markup, cssLines } = parseSvelteComponentFile(fs.readFileSync(variantPath, 'utf-8'));
|
||||
if (manifest.mode === 'insert') {
|
||||
return inlineSvelteComponentInsertAccept({
|
||||
manifest,
|
||||
markup,
|
||||
cssLines,
|
||||
variantNum,
|
||||
paramValues,
|
||||
sourceFile,
|
||||
resultBase,
|
||||
cwd,
|
||||
});
|
||||
}
|
||||
|
||||
const rootTag = matchOpeningTag(markup)?.tag || 'div';
|
||||
const contract = manifest.propContract || [];
|
||||
const mergedMarkup = mergeOriginalTopLevelAttrs(markup, manifest.originalMarkup || '');
|
||||
const restoredMarkup = substitutePropsWithExprs(mergedMarkup, contract)
|
||||
.split('\n')
|
||||
.map((line) => line.trimEnd());
|
||||
|
||||
const sourceContent = fs.readFileSync(sourceFile, 'utf-8');
|
||||
const sourceLines = sourceContent.split('\n');
|
||||
const start = Number(manifest.sourceStartLine) - 1;
|
||||
const end = Number(manifest.sourceEndLine) - 1;
|
||||
if (!Number.isInteger(start) || !Number.isInteger(end) || start < 0 || end < start || end >= sourceLines.length) {
|
||||
return { handled: false, error: 'Invalid source line range for ' + manifest.sourceFile, ...resultBase };
|
||||
}
|
||||
|
||||
const indent = sourceLines[start].match(/^(\s*)/)?.[1] || '';
|
||||
const indentedMarkup = restoredMarkup.map((line) => {
|
||||
if (line.trim() === '') return '';
|
||||
return indent + line.trimStart();
|
||||
});
|
||||
|
||||
let newLines = [
|
||||
...sourceLines.slice(0, start),
|
||||
...indentedMarkup,
|
||||
...sourceLines.slice(end + 1),
|
||||
];
|
||||
|
||||
const sanitizedCss = sanitizeAcceptedSvelteCss(cssLines, variantNum, paramValues, rootTag);
|
||||
const bakedCss = bakeParamValuesInCss(sanitizedCss, paramValues);
|
||||
if (bakedCss.length > 0) {
|
||||
newLines = appendCssToSvelteStyle(newLines, bakedCss);
|
||||
}
|
||||
|
||||
try {
|
||||
fs.writeFileSync(sourceFile, newLines.join('\n'), 'utf-8');
|
||||
} catch (err) {
|
||||
return { handled: false, error: 'Failed to write Svelte source: ' + err.message, ...resultBase };
|
||||
}
|
||||
removeSvelteComponentSession(manifest.id, cwd);
|
||||
|
||||
return {
|
||||
handled: true,
|
||||
...resultBase,
|
||||
};
|
||||
}
|
||||
|
||||
function inlineSvelteComponentInsertAccept({
|
||||
manifest,
|
||||
markup,
|
||||
cssLines,
|
||||
variantNum,
|
||||
paramValues,
|
||||
sourceFile,
|
||||
resultBase,
|
||||
cwd,
|
||||
}) {
|
||||
if (!svelteMarkupHasVisibleContent(markup)) {
|
||||
return { handled: false, error: 'Accepted Svelte insert variant is empty', ...resultBase };
|
||||
}
|
||||
if (/\bdata-impeccable-[\w-]*\s*=/.test(markup)) {
|
||||
return { handled: false, error: 'Accepted Svelte insert variant contains preview-only data-impeccable attributes', ...resultBase };
|
||||
}
|
||||
|
||||
const rootTag = matchOpeningTag(markup)?.tag || 'div';
|
||||
const restoredMarkup = String(markup || '')
|
||||
.split('\n')
|
||||
.map((line) => line.trimEnd());
|
||||
const sourceContent = fs.readFileSync(sourceFile, 'utf-8');
|
||||
const sourceLines = sourceContent.split('\n');
|
||||
const insertIndex = Number(manifest.insertLine) - 1;
|
||||
if (!Number.isInteger(insertIndex) || insertIndex < 0 || insertIndex > sourceLines.length) {
|
||||
return { handled: false, error: 'Invalid insert line for ' + manifest.sourceFile, ...resultBase };
|
||||
}
|
||||
|
||||
const nearbyLine = sourceLines[insertIndex] ?? sourceLines[insertIndex - 1] ?? '';
|
||||
const indent = nearbyLine.match(/^(\s*)/)?.[1] || '';
|
||||
const indentedMarkup = restoredMarkup.map((line) => {
|
||||
if (line.trim() === '') return '';
|
||||
return indent + line.trimStart();
|
||||
});
|
||||
|
||||
let newLines = [
|
||||
...sourceLines.slice(0, insertIndex),
|
||||
...indentedMarkup,
|
||||
...sourceLines.slice(insertIndex),
|
||||
];
|
||||
|
||||
const sanitizedCss = sanitizeAcceptedSvelteCss(cssLines, variantNum, paramValues, rootTag);
|
||||
const bakedCss = bakeParamValuesInCss(sanitizedCss, paramValues);
|
||||
if (bakedCss.length > 0) {
|
||||
newLines = appendCssToSvelteStyle(newLines, bakedCss);
|
||||
}
|
||||
|
||||
try {
|
||||
fs.writeFileSync(sourceFile, newLines.join('\n'), 'utf-8');
|
||||
} catch (err) {
|
||||
return { handled: false, error: 'Failed to write Svelte source: ' + err.message, ...resultBase };
|
||||
}
|
||||
removeSvelteComponentSession(manifest.id, cwd);
|
||||
|
||||
return {
|
||||
handled: true,
|
||||
...resultBase,
|
||||
};
|
||||
}
|
||||
|
||||
function svelteMarkupHasVisibleContent(markup) {
|
||||
const text = String(markup || '')
|
||||
.replace(/<script[\s\S]*?<\/script>/gi, '')
|
||||
.replace(/<style[\s\S]*?<\/style>/gi, '')
|
||||
.replace(/<!--[\s\S]*?-->/g, '')
|
||||
.replace(/<[^>]+>/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
if (text.length > 0) return true;
|
||||
return /<(img|svg|canvas|video|audio|picture|input|button|select|textarea)\b/i.test(markup || '');
|
||||
}
|
||||
|
||||
function mergeOriginalTopLevelAttrs(markup, originalMarkup) {
|
||||
const variantOpen = matchOpeningTag(markup);
|
||||
const originalOpen = matchOpeningTag(originalMarkup);
|
||||
if (!variantOpen || !originalOpen) return markup;
|
||||
if (variantOpen.tag.toLowerCase() !== originalOpen.tag.toLowerCase()) return markup;
|
||||
|
||||
const variantAttrs = parseAttrSegments(variantOpen.attrs);
|
||||
const originalAttrs = parseAttrSegments(originalOpen.attrs);
|
||||
const additions = [];
|
||||
let attrs = variantOpen.attrs;
|
||||
|
||||
const originalClass = originalAttrs.get('class');
|
||||
const variantClass = variantAttrs.get('class');
|
||||
if (originalClass && variantClass) {
|
||||
const merged = mergeStaticClassAttr(originalClass, variantClass);
|
||||
if (merged) {
|
||||
attrs = attrs.slice(0, variantClass.start) + merged + attrs.slice(variantClass.end);
|
||||
variantAttrs.set('class', { ...variantClass, raw: merged });
|
||||
}
|
||||
} else if (originalClass && !variantClass) {
|
||||
additions.push(originalClass.raw);
|
||||
}
|
||||
|
||||
for (const [name, attr] of originalAttrs) {
|
||||
if (name === 'class') continue;
|
||||
if (!variantAttrs.has(name)) additions.push(attr.raw);
|
||||
}
|
||||
|
||||
if (additions.length === 0 && attrs === variantOpen.attrs) return markup;
|
||||
const nextOpen = variantOpen.prefix
|
||||
+ variantOpen.tag
|
||||
+ attrs
|
||||
+ additions.map((attr) => ' ' + attr.trim()).join('')
|
||||
+ variantOpen.close;
|
||||
return markup.slice(0, variantOpen.index) + nextOpen + markup.slice(variantOpen.index + variantOpen.raw.length);
|
||||
}
|
||||
|
||||
function matchOpeningTag(markup) {
|
||||
const match = String(markup || '').match(/^(\s*<)([A-Za-z][\w:-]*)([^>]*?)(\/?>)/);
|
||||
if (!match) return null;
|
||||
return {
|
||||
raw: match[0],
|
||||
prefix: match[1],
|
||||
tag: match[2],
|
||||
attrs: match[3] || '',
|
||||
close: match[4],
|
||||
index: match.index || 0,
|
||||
};
|
||||
}
|
||||
|
||||
function parseAttrSegments(attrs) {
|
||||
const out = new Map();
|
||||
const re = /([A-Za-z_:][\w:.-]*)(?:\s*=\s*(?:"[^"]*"|'[^']*'|\{[^}]*\}|[^\s"'>=]+))?/g;
|
||||
let match;
|
||||
while ((match = re.exec(attrs))) {
|
||||
const raw = match[0];
|
||||
const name = match[1];
|
||||
out.set(name, {
|
||||
name,
|
||||
raw,
|
||||
start: match.index,
|
||||
end: match.index + raw.length,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function mergeStaticClassAttr(originalClass, variantClass) {
|
||||
const originalValue = originalClass.raw.match(/class\s*=\s*(["'])(.*?)\1/);
|
||||
const variantValue = variantClass.raw.match(/class\s*=\s*(["'])(.*?)\1/);
|
||||
if (!originalValue || !variantValue) return null;
|
||||
const quote = variantValue[1];
|
||||
const classes = [
|
||||
...variantValue[2].split(/\s+/),
|
||||
...originalValue[2].split(/\s+/),
|
||||
].filter(Boolean);
|
||||
return `class=${quote}${[...new Set(classes)].join(' ')}${quote}`;
|
||||
}
|
||||
|
||||
export function removeSvelteComponentSession(id, cwd = process.cwd()) {
|
||||
const dir = componentSessionDir(id, cwd);
|
||||
try {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
} catch { /* non-fatal */ }
|
||||
}
|
||||
|
||||
export function removeAllSvelteComponentSessions(cwd = process.cwd()) {
|
||||
const root = path.join(cwd, SVELTE_COMPONENT_ROOT);
|
||||
if (!fs.existsSync(root)) return;
|
||||
for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
if (entry.name.startsWith('__')) continue;
|
||||
try {
|
||||
fs.rmSync(path.join(root, entry.name), { recursive: true, force: true });
|
||||
} catch { /* non-fatal */ }
|
||||
}
|
||||
}
|
||||
|
||||
export function deferredAcceptsPath(cwd = process.cwd()) {
|
||||
const key = createHash('sha1').update(path.resolve(cwd)).digest('hex').slice(0, 16);
|
||||
return path.join(os.tmpdir(), 'impeccable-live', key, 'deferred-svelte-component-accepts.json');
|
||||
}
|
||||
|
||||
export function readDeferredAccepts(cwd = process.cwd()) {
|
||||
const file = deferredAcceptsPath(cwd);
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(file, 'utf-8'));
|
||||
} catch {
|
||||
return { accepts: [] };
|
||||
}
|
||||
}
|
||||
|
||||
export function writeDeferredAccept(entry, cwd = process.cwd()) {
|
||||
const file = deferredAcceptsPath(cwd);
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||
const data = readDeferredAccepts(cwd);
|
||||
data.accepts = (data.accepts || []).filter((item) => item.id !== entry.id);
|
||||
data.accepts.push({ ...entry, createdAt: new Date().toISOString() });
|
||||
fs.writeFileSync(file, JSON.stringify(data, null, 2) + '\n', 'utf-8');
|
||||
}
|
||||
|
||||
export function applyDeferredSvelteComponentAccepts(cwd = process.cwd()) {
|
||||
const file = deferredAcceptsPath(cwd);
|
||||
const data = readDeferredAccepts(cwd);
|
||||
const pending = Array.isArray(data.accepts) ? data.accepts : [];
|
||||
const results = [];
|
||||
const remaining = [];
|
||||
for (const entry of pending) {
|
||||
try {
|
||||
const manifest = findSvelteComponentManifest(entry.id, cwd);
|
||||
if (!manifest) {
|
||||
results.push({ id: entry.id, ok: false, error: 'manifest not found' });
|
||||
remaining.push(entry);
|
||||
continue;
|
||||
}
|
||||
const result = inlineSvelteComponentAccept(
|
||||
manifest,
|
||||
entry.variantNum,
|
||||
entry.paramValues || null,
|
||||
cwd,
|
||||
);
|
||||
results.push({ id: entry.id, ok: result.handled !== false, result });
|
||||
if (result.handled === false) remaining.push(entry);
|
||||
} catch (err) {
|
||||
results.push({ id: entry.id, ok: false, error: err.message });
|
||||
remaining.push(entry);
|
||||
}
|
||||
}
|
||||
if (remaining.length > 0) {
|
||||
fs.writeFileSync(file, JSON.stringify({ accepts: remaining }, null, 2) + '\n', 'utf-8');
|
||||
} else {
|
||||
try { fs.rmSync(file, { force: true }); } catch {}
|
||||
}
|
||||
return { applied: results.filter((r) => r.ok).length, failed: results.filter((r) => !r.ok).length, results };
|
||||
}
|
||||
|
||||
export function buildSvelteComponentCssAuthoring(count) {
|
||||
const variantNumbers = Array.from({ length: count }, (_, i) => i + 1);
|
||||
return {
|
||||
mode: 'svelte-component',
|
||||
styleTag: null,
|
||||
strategy: 'component-style-block',
|
||||
rulePattern: '.semantic-class { ... }',
|
||||
selectorExamples: variantNumbers.map(() => '.expense-row { padding: 22px; }'),
|
||||
requirements: [
|
||||
'Write each variant as a real Svelte component file (v1.svelte, v2.svelte, ...).',
|
||||
'Keep the prop names from propContract; bind dynamic text with {propName}, not literal snapshot text.',
|
||||
'Put variant CSS in the component <style> block using semantic class selectors.',
|
||||
'Author param-driven CSS against var(--p-<id>, default) and [data-p-<id>] using :global(...) so the runtime knob values reach the mounted root.',
|
||||
'Declare params in componentDir/params.json keyed by variant number (e.g. {"1": [...], "2": [...]}), NOT as a data-impeccable-params attribute.',
|
||||
'Do not use @scope or data-impeccable-variant selectors in component files.',
|
||||
'Do not edit the route source file during generation; only edit files under componentDir.',
|
||||
],
|
||||
forbidden: [
|
||||
'Do not use @scope blocks in Svelte component variants.',
|
||||
'Do not copy live DOM snapshot text into markup when propContract provides bindings.',
|
||||
'Do not add data-impeccable-* attributes inside component files. Svelte parses { in attribute values as an expression, so data-impeccable-params with JSON breaks the build; use componentDir/params.json instead.',
|
||||
],
|
||||
paramsFile: 'params.json',
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
/**
|
||||
* SvelteKit live-mode adapter.
|
||||
*
|
||||
* SvelteKit must not be patched through src/app.html. That file is a document
|
||||
* template, not framework-owned component chrome. The adapter keeps SvelteKit
|
||||
* work limited to mounting a dev-only shadow host from +layout.svelte; the
|
||||
* actual live UI remains the shared plain-DOM browser chrome.
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
export const SVELTE_LIVE_ROOT_COMPONENT = 'src/lib/impeccable/ImpeccableLiveRoot.svelte';
|
||||
export const SVELTE_LAYOUT_MARKER_OPEN = '<!-- impeccable-live-svelte-start -->';
|
||||
export const SVELTE_LAYOUT_MARKER_CLOSE = '<!-- impeccable-live-svelte-end -->';
|
||||
export const SVELTE_ROOT_IMPORT = "import ImpeccableLiveRoot from '$lib/impeccable/ImpeccableLiveRoot.svelte';";
|
||||
|
||||
export function detectSvelteKitProject(cwd = process.cwd(), config = null) {
|
||||
const appHtml = findSvelteKitAppHtml(cwd, config);
|
||||
if (!appHtml) return null;
|
||||
const hasTemplateMarkers = fileIncludes(path.join(cwd, appHtml), '%sveltekit.body%')
|
||||
&& fileIncludes(path.join(cwd, appHtml), '%sveltekit.head%');
|
||||
if (!hasTemplateMarkers) return null;
|
||||
|
||||
const hasSvelteConfig = fs.existsSync(path.join(cwd, 'svelte.config.js'))
|
||||
|| fs.existsSync(path.join(cwd, 'svelte.config.mjs'))
|
||||
|| fs.existsSync(path.join(cwd, 'svelte.config.cjs'))
|
||||
|| fs.existsSync(path.join(cwd, 'svelte.config.ts'));
|
||||
const hasKitPackage = packageHasSvelteKit(cwd);
|
||||
if (!hasSvelteConfig && !hasKitPackage) return null;
|
||||
|
||||
return {
|
||||
appHtml,
|
||||
layoutFile: findSvelteKitLayout(cwd),
|
||||
rootComponent: SVELTE_LIVE_ROOT_COMPONENT,
|
||||
};
|
||||
}
|
||||
|
||||
export function applySvelteKitLiveAdapter({ cwd = process.cwd(), port, config = null } = {}) {
|
||||
if (!Number.isFinite(Number(port))) {
|
||||
throw new Error('SvelteKit live adapter requires a numeric port');
|
||||
}
|
||||
const detected = detectSvelteKitProject(cwd, config);
|
||||
if (!detected) return null;
|
||||
|
||||
ensureSvelteLiveRootComponent(cwd, Number(port));
|
||||
|
||||
const layoutRel = detected.layoutFile;
|
||||
const layoutAbs = path.join(cwd, layoutRel);
|
||||
fs.mkdirSync(path.dirname(layoutAbs), { recursive: true });
|
||||
const layoutExisted = fs.existsSync(layoutAbs);
|
||||
const before = layoutExisted ? fs.readFileSync(layoutAbs, 'utf-8') : defaultSvelteLayout();
|
||||
const after = patchSvelteLayout(before);
|
||||
fs.writeFileSync(layoutAbs, after, 'utf-8');
|
||||
|
||||
return {
|
||||
file: layoutRel,
|
||||
adapter: 'sveltekit',
|
||||
inserted: after !== before || !layoutExisted,
|
||||
appHtmlUntouched: true,
|
||||
rootComponent: SVELTE_LIVE_ROOT_COMPONENT,
|
||||
};
|
||||
}
|
||||
|
||||
export function removeSvelteKitLiveAdapter({ cwd = process.cwd(), config = null } = {}) {
|
||||
const detected = detectSvelteKitProject(cwd, config);
|
||||
if (!detected) return null;
|
||||
|
||||
const layoutAbs = path.join(cwd, detected.layoutFile);
|
||||
let removed = false;
|
||||
if (fs.existsSync(layoutAbs)) {
|
||||
const before = fs.readFileSync(layoutAbs, 'utf-8');
|
||||
const after = unpatchSvelteLayout(before);
|
||||
if (after !== before) {
|
||||
fs.writeFileSync(layoutAbs, after, 'utf-8');
|
||||
removed = true;
|
||||
}
|
||||
}
|
||||
|
||||
const rootAbs = path.join(cwd, SVELTE_LIVE_ROOT_COMPONENT);
|
||||
if (fs.existsSync(rootAbs)) {
|
||||
fs.rmSync(rootAbs, { force: true });
|
||||
removed = true;
|
||||
}
|
||||
|
||||
pruneEmptyDir(path.dirname(rootAbs), path.join(cwd, 'src'));
|
||||
|
||||
return {
|
||||
file: detected.layoutFile,
|
||||
adapter: 'sveltekit',
|
||||
removed,
|
||||
appHtmlUntouched: true,
|
||||
rootComponent: SVELTE_LIVE_ROOT_COMPONENT,
|
||||
};
|
||||
}
|
||||
|
||||
export function patchSvelteLayout(content) {
|
||||
let out = String(content || '');
|
||||
if (!out.includes(SVELTE_ROOT_IMPORT)) {
|
||||
const scriptMatch = out.match(/<script(?:\s[^>]*)?>/i);
|
||||
if (scriptMatch) {
|
||||
const insertAt = scriptMatch.index + scriptMatch[0].length;
|
||||
out = out.slice(0, insertAt) + '\n ' + SVELTE_ROOT_IMPORT + out.slice(insertAt);
|
||||
} else {
|
||||
out = `<script>\n ${SVELTE_ROOT_IMPORT}\n</script>\n\n` + out;
|
||||
}
|
||||
}
|
||||
|
||||
if (!out.includes(SVELTE_LAYOUT_MARKER_OPEN)) {
|
||||
const block = `${SVELTE_LAYOUT_MARKER_OPEN}\n<ImpeccableLiveRoot />\n${SVELTE_LAYOUT_MARKER_CLOSE}\n`;
|
||||
const renderMatch = out.match(/\{@render\s+children(?:\?\.)?\(\)\s*\}/);
|
||||
const slotMatch = out.match(/<slot\s*\/?>/);
|
||||
const match = renderMatch || slotMatch;
|
||||
if (match) {
|
||||
out = out.slice(0, match.index) + block + out.slice(match.index);
|
||||
} else {
|
||||
out = out.replace(/\s*$/, '\n\n' + block);
|
||||
}
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
export function unpatchSvelteLayout(content) {
|
||||
let out = String(content || '');
|
||||
const blockRe = new RegExp(
|
||||
'([ \\t]*)' + escapeRegExp(SVELTE_LAYOUT_MARKER_OPEN)
|
||||
+ '\\n<ImpeccableLiveRoot\\s*/>\\n'
|
||||
+ escapeRegExp(SVELTE_LAYOUT_MARKER_CLOSE)
|
||||
+ '\\n?',
|
||||
'g',
|
||||
);
|
||||
out = out.replace(blockRe, '$1');
|
||||
out = out.replace(new RegExp('^\\s*' + escapeRegExp(SVELTE_ROOT_IMPORT) + '\\s*\\n?', 'gm'), '');
|
||||
out = out.replace(/<script>\s*<\/script>\s*\n?/g, '');
|
||||
return out.replace(/\n{3,}/g, '\n\n');
|
||||
}
|
||||
|
||||
export function ensureSvelteLiveRootComponent(cwd, port) {
|
||||
const file = path.join(cwd, SVELTE_LIVE_ROOT_COMPONENT);
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||
fs.writeFileSync(file, buildSvelteLiveRootComponent(port), 'utf-8');
|
||||
return file;
|
||||
}
|
||||
|
||||
export function buildSvelteLiveRootComponent(port) {
|
||||
return `<script>
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
const LIVE_URL = 'http://localhost:${Number(port)}/live.js';
|
||||
const HOST_ID = 'impeccable-live-root';
|
||||
|
||||
onMount(() => {
|
||||
let host = document.querySelector('impeccable-live-root#' + HOST_ID) || document.getElementById(HOST_ID);
|
||||
if (!host) {
|
||||
host = document.createElement('impeccable-live-root');
|
||||
host.id = HOST_ID;
|
||||
document.body.appendChild(host);
|
||||
}
|
||||
|
||||
host.dataset.impeccableLiveAdapter = 'sveltekit';
|
||||
host.style.setProperty('all', 'initial', 'important');
|
||||
host.style.setProperty('display', 'block', 'important');
|
||||
host.style.setProperty('position', 'fixed', 'important');
|
||||
host.style.setProperty('top', '0', 'important');
|
||||
host.style.setProperty('left', '0', 'important');
|
||||
host.style.setProperty('width', '0', 'important');
|
||||
host.style.setProperty('height', '0', 'important');
|
||||
host.style.setProperty('overflow', 'visible', 'important');
|
||||
host.style.setProperty('z-index', '2147483000', 'important');
|
||||
host.style.setProperty('pointer-events', 'none', 'important');
|
||||
|
||||
const root = host.shadowRoot || host.attachShadow({ mode: 'open' });
|
||||
if (!root.querySelector('style[data-impeccable-live-reset]')) {
|
||||
const reset = document.createElement('style');
|
||||
reset.dataset.impeccableLiveReset = 'true';
|
||||
reset.textContent = ':host, :host *, * { box-sizing: border-box; }';
|
||||
root.appendChild(reset);
|
||||
}
|
||||
|
||||
window.__IMPECCABLE_LIVE_ADAPTER__ = 'sveltekit';
|
||||
window.__IMPECCABLE_LIVE_UI_ROOT__ = root;
|
||||
window.__IMPECCABLE_LIVE_CHROME_MOUNT__ = {
|
||||
adapter: 'sveltekit',
|
||||
version: 1,
|
||||
host,
|
||||
root,
|
||||
};
|
||||
|
||||
const script = document.createElement('script');
|
||||
script.src = LIVE_URL;
|
||||
script.async = true;
|
||||
script.dataset.impeccableLiveScript = 'true';
|
||||
document.head.appendChild(script);
|
||||
|
||||
return () => {
|
||||
script.remove();
|
||||
if (window.__IMPECCABLE_LIVE_UI_ROOT__ === root) delete window.__IMPECCABLE_LIVE_UI_ROOT__;
|
||||
if (window.__IMPECCABLE_LIVE_CHROME_MOUNT__?.root === root) delete window.__IMPECCABLE_LIVE_CHROME_MOUNT__;
|
||||
if (window.__IMPECCABLE_LIVE_ADAPTER__ === 'sveltekit') delete window.__IMPECCABLE_LIVE_ADAPTER__;
|
||||
};
|
||||
});
|
||||
</script>
|
||||
`;
|
||||
}
|
||||
|
||||
function findSvelteKitAppHtml(cwd, config) {
|
||||
const files = Array.isArray(config?.files) ? config.files : ['src/app.html'];
|
||||
for (const rel of files) {
|
||||
if (rel.includes('*')) continue;
|
||||
const normalized = rel.split(path.sep).join('/');
|
||||
if (!normalized.endsWith('app.html')) continue;
|
||||
const abs = path.join(cwd, normalized);
|
||||
if (fs.existsSync(abs)) return normalized;
|
||||
}
|
||||
const fallback = 'src/app.html';
|
||||
return fs.existsSync(path.join(cwd, fallback)) ? fallback : null;
|
||||
}
|
||||
|
||||
function findSvelteKitLayout(cwd) {
|
||||
const candidates = [
|
||||
'src/routes/+layout.svelte',
|
||||
'src/routes/(app)/+layout.svelte',
|
||||
];
|
||||
for (const rel of candidates) {
|
||||
if (fs.existsSync(path.join(cwd, rel))) return rel;
|
||||
}
|
||||
return 'src/routes/+layout.svelte';
|
||||
}
|
||||
|
||||
function defaultSvelteLayout() {
|
||||
return `<script>\n let { children } = $props();\n</script>\n\n{@render children?.()}\n`;
|
||||
}
|
||||
|
||||
function packageHasSvelteKit(cwd) {
|
||||
const file = path.join(cwd, 'package.json');
|
||||
if (!fs.existsSync(file)) return false;
|
||||
try {
|
||||
const pkg = JSON.parse(fs.readFileSync(file, 'utf-8'));
|
||||
const deps = {
|
||||
...(pkg.dependencies || {}),
|
||||
...(pkg.devDependencies || {}),
|
||||
...(pkg.peerDependencies || {}),
|
||||
};
|
||||
return Boolean(deps['@sveltejs/kit'] || deps['@sveltejs/vite-plugin-svelte'] || deps.svelte);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function fileIncludes(file, text) {
|
||||
try {
|
||||
return fs.readFileSync(file, 'utf-8').includes(text);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function pruneEmptyDir(dir, stopDir) {
|
||||
let current = dir;
|
||||
while (current.startsWith(stopDir) && current !== stopDir) {
|
||||
try {
|
||||
if (fs.readdirSync(current).length > 0) return;
|
||||
fs.rmdirSync(current);
|
||||
current = path.dirname(current);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function escapeRegExp(value) {
|
||||
return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
/**
|
||||
* Framework-neutral Impeccable live chrome contract.
|
||||
*
|
||||
* The production browser bundle is intentionally plain DOM so Svelte, React,
|
||||
* Vue, and static adapters can all mount the same chrome. This module is the
|
||||
* testable contract/inventory for that bundle; live-browser.js mirrors these
|
||||
* values at runtime because it is served as a standalone script.
|
||||
*/
|
||||
|
||||
export const LIVE_CHROME_MOUNT_CONTRACT = Object.freeze([
|
||||
'root',
|
||||
'transport',
|
||||
'state',
|
||||
'actions',
|
||||
]);
|
||||
|
||||
export const LIVE_UI_SURFACES = Object.freeze([
|
||||
{
|
||||
key: 'global-bottom-bar',
|
||||
ids: [
|
||||
'impeccable-live-global-bar',
|
||||
'impeccable-live-global-bar-brand',
|
||||
'impeccable-live-pick-toggle',
|
||||
'impeccable-live-insert-toggle',
|
||||
'impeccable-live-detect-toggle',
|
||||
'impeccable-live-detect-badge',
|
||||
'impeccable-live-design-toggle',
|
||||
'impeccable-live-page-chat',
|
||||
'impeccable-live-page-chat-input',
|
||||
'impeccable-live-page-chat-voice',
|
||||
],
|
||||
states: ['rest', 'hover', 'focus-visible', 'pressed', 'active', 'tooltip'],
|
||||
},
|
||||
{
|
||||
key: 'pending-copy-edit-dock',
|
||||
ids: ['impeccable-live-pending-dock'],
|
||||
states: ['closed', 'open', 'hover', 'pressed', 'loading', 'rollback', 'keep-fixing'],
|
||||
},
|
||||
{
|
||||
key: 'element-selection-chrome',
|
||||
ids: [
|
||||
'impeccable-live-highlight',
|
||||
'impeccable-live-tooltip',
|
||||
'impeccable-live-bar',
|
||||
'impeccable-live-selection-pill',
|
||||
'impeccable-live-input',
|
||||
'impeccable-live-configure-voice',
|
||||
'impeccable-live-configure-bar-tooltip',
|
||||
],
|
||||
states: ['rest', 'hover', 'focus-visible', 'pressed', 'disabled'],
|
||||
},
|
||||
{
|
||||
key: 'action-picker',
|
||||
ids: ['impeccable-live-picker'],
|
||||
states: ['closed', 'open', 'option-hover', 'option-focus'],
|
||||
},
|
||||
{
|
||||
key: 'edit-chrome',
|
||||
ids: ['impeccable-live-edit-badge'],
|
||||
states: ['enabled', 'disabled', 'editing', 'cancel', 'save', 'edited-content'],
|
||||
},
|
||||
{
|
||||
key: 'generating-row',
|
||||
ids: ['impeccable-live-bar', 'impeccable-live-shader'],
|
||||
states: ['action-label', 'animated-dots', 'generating', 'done'],
|
||||
},
|
||||
{
|
||||
key: 'variant-cycling-row',
|
||||
ids: ['impeccable-live-bar', 'impeccable-live-params-panel'],
|
||||
states: ['variant-1', 'variant-2', 'variant-3', 'left-disabled', 'right-disabled', 'dot-click', 'accept', 'discard'],
|
||||
},
|
||||
{
|
||||
key: 'variant-params-panel',
|
||||
ids: ['impeccable-live-params-panel'],
|
||||
states: ['closed', 'open-above', 'open-below', 'range', 'steps', 'toggle'],
|
||||
},
|
||||
{
|
||||
key: 'saving-confirmed-rows',
|
||||
ids: ['impeccable-live-bar'],
|
||||
states: ['saving', 'applying-variant', 'confirmed'],
|
||||
},
|
||||
{
|
||||
key: 'insert-mode-chrome',
|
||||
ids: [
|
||||
'impeccable-live-insert-line',
|
||||
'impeccable-live-insert-placeholder',
|
||||
'impeccable-live-placeholder-resize',
|
||||
'impeccable-live-insert-input',
|
||||
'impeccable-live-insert-voice',
|
||||
'impeccable-live-insert-create',
|
||||
'impeccable-live-insert-create-tooltip',
|
||||
],
|
||||
states: ['toggle-active', 'line', 'placeholder', 'resize', 'enabled', 'disabled', 'tooltip'],
|
||||
},
|
||||
{
|
||||
key: 'annotation-chrome',
|
||||
ids: [
|
||||
'impeccable-live-annot',
|
||||
'impeccable-live-annot-svg',
|
||||
'impeccable-live-annot-pins',
|
||||
'impeccable-live-annot-clear',
|
||||
],
|
||||
states: ['overlay', 'drawing', 'pin', 'pin-edit', 'clear'],
|
||||
},
|
||||
{
|
||||
key: 'design-system-panel',
|
||||
ids: ['impeccable-live-design-host'],
|
||||
states: ['closed', 'open', 'tabs', 'token-tiles', 'copy'],
|
||||
},
|
||||
{
|
||||
key: 'toasts-and-errors',
|
||||
ids: ['impeccable-live-toast'],
|
||||
states: ['normal', 'error', 'no-variants-mounted'],
|
||||
},
|
||||
{
|
||||
key: 'css-isolation-boundary',
|
||||
ids: ['impeccable-live-root'],
|
||||
states: ['shadow-root', 'style-tags', 'hostile-css'],
|
||||
},
|
||||
]);
|
||||
|
||||
export const LIVE_UI_COMPONENT_IDS = Object.freeze([
|
||||
...new Set(LIVE_UI_SURFACES.flatMap((surface) => surface.ids)),
|
||||
]);
|
||||
|
||||
export function resolveLiveUiRoot(env = globalThis) {
|
||||
const doc = env?.document;
|
||||
const explicit = env?.__IMPECCABLE_LIVE_UI_ROOT__
|
||||
|| env?.window?.__IMPECCABLE_LIVE_UI_ROOT__;
|
||||
if (explicit && typeof explicit.appendChild === 'function') return explicit;
|
||||
return doc?.body || null;
|
||||
}
|
||||
|
||||
export function getLiveUiElementById(id, env = globalThis) {
|
||||
const doc = env?.document;
|
||||
const root = resolveLiveUiRoot(env);
|
||||
if (!id) return null;
|
||||
if (root?.getElementById) {
|
||||
const found = root.getElementById(id);
|
||||
if (found) return found;
|
||||
}
|
||||
if (root?.querySelector) {
|
||||
const found = root.querySelector('#' + escapeCssIdent(id));
|
||||
if (found) return found;
|
||||
}
|
||||
return doc?.getElementById?.(id) || null;
|
||||
}
|
||||
|
||||
export function appendToLiveUiRoot(el, env = globalThis) {
|
||||
const root = resolveLiveUiRoot(env);
|
||||
if (!root) throw new Error('Impeccable live UI root is not available');
|
||||
root.appendChild(el);
|
||||
return el;
|
||||
}
|
||||
|
||||
export function appendStyleToLiveUiRoot(styleEl, env = globalThis) {
|
||||
const doc = env?.document;
|
||||
const root = resolveLiveUiRoot(env);
|
||||
if (root && root !== doc?.body) {
|
||||
root.appendChild(styleEl);
|
||||
} else {
|
||||
(doc?.head || doc?.body || root).appendChild(styleEl);
|
||||
}
|
||||
return styleEl;
|
||||
}
|
||||
|
||||
export function activeElementDeep(doc = globalThis.document) {
|
||||
let active = doc?.activeElement || null;
|
||||
while (active?.shadowRoot?.activeElement) {
|
||||
active = active.shadowRoot.activeElement;
|
||||
}
|
||||
return active;
|
||||
}
|
||||
|
||||
function escapeCssIdent(value) {
|
||||
if (typeof CSS !== 'undefined' && typeof CSS.escape === 'function') {
|
||||
return CSS.escape(String(value));
|
||||
}
|
||||
return String(value).replace(/([ !"#$%&'()*+,./:;<=>?@[\\\]^`{|}~])/g, '\\$1');
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* Canonical design-command vocabulary for Live Mode: each command's value, human
|
||||
* label, and SVG icon. Icons stack above the chip label; strokes use currentColor
|
||||
* so the icon recolors when its chip is selected.
|
||||
*
|
||||
* Single source of truth, consumed by:
|
||||
* - skill/scripts/live/event-validation.mjs — re-exports VISUAL_ACTIONS.
|
||||
* - skill/scripts/live-browser.js — the real picker. It is served raw and
|
||||
* injected as an IIFE, so it cannot import this at runtime; live-server.mjs
|
||||
* serializes LIVE_COMMANDS into window.__IMPECCABLE_VOCAB__ alongside the
|
||||
* token/port, and live-browser.js builds its ICONS + ACTIONS from that.
|
||||
* - site/components/LiveDemoPalette.astro — the marketing demo palette (imported
|
||||
* at build time).
|
||||
*
|
||||
* Add, rename, or reorder a verb here and all three follow.
|
||||
*/
|
||||
|
||||
const ICON_ATTRS = 'width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" style="display:block"';
|
||||
|
||||
export const LIVE_COMMANDS = [
|
||||
{ value: 'impeccable', label: 'Freeform', icon: `<svg ${ICON_ATTRS}><path d="M4 20l4-1L18 9l-3-3L5 16z"/><path d="M14 7l3 3"/></svg>` },
|
||||
{ value: 'bolder', label: 'Bolder', icon: `<svg ${ICON_ATTRS}><rect x="6" y="12" width="4" height="7" rx="0.5"/><rect x="14" y="5" width="4" height="14" rx="0.5"/></svg>` },
|
||||
{ value: 'quieter', label: 'Quieter', icon: `<svg ${ICON_ATTRS}><rect x="6" y="5" width="4" height="14" rx="0.5"/><rect x="14" y="12" width="4" height="7" rx="0.5"/></svg>` },
|
||||
{ value: 'distill', label: 'Distill', icon: `<svg ${ICON_ATTRS}><path d="M4 5h16l-6 8v7l-4-2v-5z"/></svg>` },
|
||||
{ value: 'polish', label: 'Polish', icon: `<svg ${ICON_ATTRS}><path d="M15 3l1 3 3 1-3 1-1 3-1-3-3-1 3-1z"/><path d="M7 13l0.6 1.8 1.8 0.6-1.8 0.6-0.6 1.8-0.6-1.8-1.8-0.6 1.8-0.6z"/></svg>` },
|
||||
{ value: 'typeset', label: 'Typeset', icon: `<svg ${ICON_ATTRS}><path d="M5 6h14" stroke-width="2.6"/><path d="M5 12h9" stroke-width="1.9"/><path d="M5 18h5" stroke-width="1.3"/></svg>` },
|
||||
{ value: 'colorize', label: 'Colorize', icon: `<svg ${ICON_ATTRS}><circle cx="9" cy="10" r="5"/><circle cx="15" cy="10" r="5"/><circle cx="12" cy="15" r="5"/></svg>` },
|
||||
{ value: 'layout', label: 'Layout', icon: `<svg ${ICON_ATTRS}><rect x="3" y="4" width="8" height="16" rx="0.5"/><rect x="13" y="4" width="8" height="7" rx="0.5"/><rect x="13" y="13" width="8" height="7" rx="0.5"/></svg>` },
|
||||
{ value: 'adapt', label: 'Adapt', icon: `<svg ${ICON_ATTRS}><rect x="2.5" y="5" width="12" height="11" rx="1"/><line x1="2.5" y1="19" x2="14.5" y2="19"/><rect x="16.5" y="8" width="5" height="11" rx="1"/></svg>` },
|
||||
{ value: 'animate', label: 'Animate', icon: `<svg ${ICON_ATTRS}><path d="M3 18c4-4 6-10 10-10"/><path d="M13 8c3 0 5 5 8 10"/><circle cx="13" cy="8" r="1.6" fill="currentColor" stroke="none"/></svg>` },
|
||||
{ value: 'delight', label: 'Delight', icon: `<svg ${ICON_ATTRS}><path d="M12 3l2 6 6 2-6 2-2 6-2-6-6-2 6-2z"/></svg>` },
|
||||
{ value: 'overdrive', label: 'Overdrive', icon: `<svg ${ICON_ATTRS}><path d="M13 3L5 13h5l-1 8 9-12h-6z"/></svg>` },
|
||||
];
|
||||
|
||||
// Action values accepted by the live event protocol, in palette order.
|
||||
export const VISUAL_ACTIONS = LIVE_COMMANDS.map((c) => c.value);
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"description": "Impeccable design detector: runs after Edit/Write/apply_patch on UI files and surfaces findings as system reminders.",
|
||||
"hooks": {
|
||||
"PostToolUse": [
|
||||
{
|
||||
"matcher": "Edit|Write|apply_patch",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node \"$(git rev-parse --show-toplevel)/.agents/skills/impeccable/scripts/hook.mjs\"",
|
||||
"timeout": 5,
|
||||
"statusMessage": "Checking UI changes"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user