mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-15 07:36:50 +03:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5af53b3c1e | ||
|
|
112703d5bf |
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
name: impeccable
|
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.
|
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: 4.3.1
|
version: 4.3.0
|
||||||
license: Apache 2.0
|
license: Apache 2.0
|
||||||
allowed-tools:
|
allowed-tools:
|
||||||
- Bash(npx impeccable *)
|
- Bash(npx impeccable *)
|
||||||
@@ -66,8 +66,7 @@ Choose the mode from the requested surface, not the product, and persist it only
|
|||||||
| `clarify [target]` | Fix | Improve UX copy, labels, and error messages | [reference/clarify.md](reference/clarify.md) |
|
| `clarify [target]` | Fix | Improve UX copy, labels, and error messages | [reference/clarify.md](reference/clarify.md) |
|
||||||
| `adapt [target]` | Fix | Adapt for different devices and screen sizes | [reference/adapt.md](reference/adapt.md) · native: [reference/adapt.native.md](reference/adapt.native.md) |
|
| `adapt [target]` | Fix | Adapt for different devices and screen sizes | [reference/adapt.md](reference/adapt.md) · native: [reference/adapt.native.md](reference/adapt.native.md) |
|
||||||
| `optimize [target]` | Fix | Diagnose and fix UI performance | [reference/optimize.md](reference/optimize.md) |
|
| `optimize [target]` | Fix | Diagnose and fix UI performance | [reference/optimize.md](reference/optimize.md) |
|
||||||
| `live` | Iterate | Visual variant mode: pick elements in the browser, iterate on alternatives | [reference/live.md](reference/live.md) |
|
| `live` | Iterate | Visual variant mode: pick elements in the browser, generate alternatives | [reference/live.md](reference/live.md) |
|
||||||
| `generate [n] [action] [element]` | Iterate | Variants, versions, or alternatives of a named element to choose from in the live browser; no manual picking | [reference/generate.md](reference/generate.md) |
|
|
||||||
|
|
||||||
Routing:
|
Routing:
|
||||||
|
|
||||||
|
|||||||
@@ -188,12 +188,6 @@ Test thoroughly across contexts:
|
|||||||
- **Edge cases**: Very small screens (320px), very large screens (4K)
|
- **Edge cases**: Very small screens (320px), very large screens (4K)
|
||||||
- **Slow connections**: Test on throttled network
|
- **Slow connections**: Test on throttled network
|
||||||
|
|
||||||
**Custom controls** (sliders, drag surfaces, scrollable control strips): a before/after slider can pass every width check above and still refuse to drag on iOS, so exercise each one in scope in the same batched round as the checks above:
|
|
||||||
|
|
||||||
- **Primary gesture**: Tap it and confirm it responds as designed, then drag it with the target input method; the drag must complete, not just start
|
|
||||||
- **Scroll across it**: A swipe along the page's scroll axis across the control scrolls the page or container without activating it; a drag that starts on the control along its axis moves the control, not the page. Neither failure throws an error, so try both
|
|
||||||
- **Evidence**: Say what produced the evidence: an emulated viewport, synthesized touch input through a browser tool, which engine ran it (Chromium is not Safari), or a physical device. Screenshots and resized viewports verify layout, never a gesture. Name what stayed untested and move on; unreachable hardware is a reported gap, not a blocker
|
|
||||||
|
|
||||||
When the adaptation feels native to each context, hand off to `/impeccable polish` for the final pass.
|
When the adaptation feels native to each context, hand off to `/impeccable polish` for the final pass.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -48,12 +48,11 @@ Run comprehensive checks across 5 dimensions. Score each dimension 0-4 using the
|
|||||||
**Check for**:
|
**Check for**:
|
||||||
- **Fixed widths**: Hard-coded widths that break on mobile
|
- **Fixed widths**: Hard-coded widths that break on mobile
|
||||||
- **Touch targets**: Interactive elements < 44x44px
|
- **Touch targets**: Interactive elements < 44x44px
|
||||||
- **Broken touch interaction**: Custom sliders, drag surfaces, and scrollable control strips whose primary gesture fails under touch, that swallow page scroll or lose the drag to it, or that stay stuck after an interrupted gesture. Code tells: mouse-only handlers, no `touch-action` on a pointer-event drag surface, drag state that nothing clears on cancel, lost capture, or blur. Exercise the gesture when a browser tool can synthesize touch (a rendered viewport proves layout, not the gesture), then say what produced the evidence (emulated viewport, synthesized touch, which engine, physical device) and what stayed untested
|
|
||||||
- **Horizontal scroll**: Content overflow on narrow viewports
|
- **Horizontal scroll**: Content overflow on narrow viewports
|
||||||
- **Text scaling**: Layouts that break when text size increases
|
- **Text scaling**: Layouts that break when text size increases
|
||||||
- **Missing breakpoints**: No mobile/tablet variants
|
- **Missing breakpoints**: No mobile/tablet variants
|
||||||
|
|
||||||
**Score 0-4**: 0=Desktop-only (breaks on mobile), 1=Major issues (some breakpoints, many failures), 2=Partial (works on mobile, rough edges), 3=Good (responsive, minor touch target or overflow issues), 4=Excellent (fluid, all viewports, proper touch targets, gestures work under touch)
|
**Score 0-4**: 0=Desktop-only (breaks on mobile), 1=Major issues (some breakpoints, many failures), 2=Partial (works on mobile, rough edges), 3=Good (responsive, minor touch target or overflow issues), 4=Excellent (fluid, all viewports, proper touch targets)
|
||||||
|
|
||||||
### 5. Implementation Integrity (CRITICAL)
|
### 5. Implementation Integrity (CRITICAL)
|
||||||
|
|
||||||
|
|||||||
@@ -1,101 +0,0 @@
|
|||||||
> **Additional context needed**: only the target element, when the request does not name one that resolves uniquely on the page.
|
|
||||||
|
|
||||||
Generate is the fast lane into live mode: the user names an element, a direction, and a count in one sentence, and within a minute they are cycling through variants in their browser. One command boots the helper, hands the element to the overlay in the page your harness already shows (it scrolls to it, selects it, and fires the same Go a click fires) and returns the generate event; one edit writes the variants; one call replies and waits for the user's choice, which the helper bakes into source itself. This file owns the lane's plumbing; from the event onward the design work is [live.md](live.md)'s, unchanged, so read it in full now if you have not this session.
|
|
||||||
|
|
||||||
**Web only.** Live mode's browser overlay has no native equivalent; on `ios` / `android` / `adaptive` projects, decline this command and offer `bolder` or `quieter` on the source instead.
|
|
||||||
|
|
||||||
The plumbing is where the lane saves time: one command starts the session around the page your harness already shows, one call replies and waits, and nothing here is a browser you have to babysit. The design work is not where it saves time. Setup runs as for any command (`impeccable context`, this reference, craft-floor.md before the edit), and the variants are planned, written, and accepted exactly the way a live session plans, writes, and accepts them.
|
|
||||||
|
|
||||||
Three prohibitions cover the known ways this command goes wrong:
|
|
||||||
|
|
||||||
- **Never run init or document, and never ask for PRODUCT.md or DESIGN.md.** When they exist, the start command prints them under `boot` and you use them. When they do not, it says so (`contextMissing`, `contextNote`) and you extract the identity from the event (Step 3). A missing file is never a reason to interview the user inside this command; offer `init` in one line after the session ends.
|
|
||||||
- **Never hand-write a variants wrapper or invent a session id.** Only the browser mints session ids (8 hex characters, at Go). A missing event is fixed by rerunning Step 2, never with a direct source edit.
|
|
||||||
- **Do not act on hook findings while live markers are in the file**, and do not restyle variants to appease them; the accept verifies the file once the variant is permanent.
|
|
||||||
|
|
||||||
## Step 1: Parse the request
|
|
||||||
|
|
||||||
Three parts, all from the user's sentence:
|
|
||||||
|
|
||||||
- **A number in the request**: that is the count. **No number**: 3. The protocol caps count at 8.
|
|
||||||
- **The direction wording** maps onto the live action vocabulary; never invent a new action value:
|
|
||||||
- **bold, bolder, stronger, punchier**: `bolder`
|
|
||||||
- **quiet, calmer, softer, toned down**: `quieter`
|
|
||||||
- **simpler, minimal, stripped**: `distill`
|
|
||||||
- **refined, tightened, polished**: `polish`
|
|
||||||
- **font and type words**: `typeset`
|
|
||||||
- **color words**: `colorize`
|
|
||||||
- **arrangement and spacing words**: `layout`
|
|
||||||
- **device and breakpoint words**: `adapt`
|
|
||||||
- **motion words**: `animate`
|
|
||||||
- **playful words**: `delight`
|
|
||||||
- **rule-breaking words**: `overdrive`
|
|
||||||
- **Wording that carries intent but no vocabulary word** ("make it feel like a bank", "warmer", "more premium"): `impeccable`, with the user's wording passed as the prompt.
|
|
||||||
- **An action fits AND extra intent rides along** ("bolder, but keep it monochrome"): that action, with the rest as the prompt.
|
|
||||||
- **The wording names no direction at all** ("better", "improve", "nicer", "different", "fresh", "new", "redesign", "fix", "some options", "ideas", "alternatives", or just "variants" with nothing else): Ask the user directly to clarify what you cannot infer. Ask one question, offering the vocabulary: *"Which direction should the variants take? bolder, quieter, simpler (distill), polished, typography (typeset), color (colorize), layout, motion (animate), playful (delight), or rule-breaking (overdrive)."* Map the answer with this list; an answer that is still open ("surprise me", "you pick") is `impeccable` with the user's original wording as the prompt, and Step 2 starts on that answer.
|
|
||||||
- **The element description** ("the pricing cards", "the hero heading"): Step 2 resolves it to a selector.
|
|
||||||
|
|
||||||
Done when you hold an action from the vocabulary (asked for, when the request named no direction), a count from 1 to 8, and the element description.
|
|
||||||
|
|
||||||
## Step 2: Reuse the page, then start
|
|
||||||
|
|
||||||
**Reuse** the dev server already running and the tab your harness already shows it in; a second server or a second browser window is the failure this step prevents.
|
|
||||||
|
|
||||||
1. **Find the dev server**, cheapest source first, and stop at the first hit: the user's message, a browser tab already on the app (Claude Code: an origin in `tabs_context`), a server your harness started (Claude Code: `preview_list`), a terminal that printed its URL. Its origin is your `--dev-url`. **No hit**: leave `--dev-url` off and run the start command with no wait; the boot probes for a running server and its verdict names the move. `browser_needed` carries the `devUrl` it found: open it as in 2, then rerun with `--dev-url <devUrl> --wait-for-browser 60000`. `no_dev_server` means nothing serves the app: start the dev script the way the verdict says (Claude Code: `preview_start`; Cursor: a background terminal; Codex: an exec you yield from), wait for its URL, then rerun with `--dev-url <url>`.
|
|
||||||
2. **Open the page that renders the element in your browser, then start.** The route the request names, else the one `--target` serves; `--dev-url` takes only the origin.
|
|
||||||
- **Cursor** (`browser_navigate`) and **Claude Code** (`navigate`, which opens the Browser pane when it is closed and takes the `tabId` from `tabs_context` when a tab is already on that origin): open the URL, then run the start command with `--dev-url <url> --wait-for-browser 60000`. The boot injects the overlay and the page reloads into it while the command waits. Your browser tool is the only opener on these harnesses; the engine ignores `--open` there.
|
|
||||||
- **No browser tool** (Codex, others): run the start command with `--open --wait-for-browser 120000`; it opens the system browser, and the longer wait covers the user finding the tab. **`browser_open_failed` back**: tell the user the `url` in one line and rerun with `--wait-for-browser 120000`.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
.agent/skills/impeccable/scripts/impeccable live-generate --target src/App.jsx --dev-url http://127.0.0.1:5173/ --selector ".pricing-grid" --action bolder --count 3 --boot --wait-for-browser 60000
|
|
||||||
```
|
|
||||||
|
|
||||||
Run it in the foreground in Cursor and Claude Code (it returns within the wait); on Codex, in an exec you yield from, the way Step 3 runs the poll.
|
|
||||||
|
|
||||||
- `--target`: the file that renders the element when the request or the project makes it obvious; skip it otherwise.
|
|
||||||
- `--dev-url`: the origin from 1; omit it and the boot probes.
|
|
||||||
- `--selector`: a unique class first, then a landmark tag plus class, an id last (every variant mounts a copy of the element, so an id repeats in the DOM). **The request names a repeated component in plural** ("the pricing cards"): target the container that holds the set, so one scoped stylesheet restyles every instance. One read of the source file that renders the element is allowed when the selector is not obvious; `--dry-run` resolves and reports without starting anything when it is not certain.
|
|
||||||
- `--boot`: runs the lane's boot (PRODUCT.md and DESIGN.md loaded again for the helper, missing files tolerated, dev URL found, bottom bar hidden for the helper's lifetime) and reuses a helper that is already running. Its result rides along as `boot`.
|
|
||||||
- Also available: `--prompt`, `--text` (keep only matches whose visible text contains a snippet), `--index` (1-based pick among matches).
|
|
||||||
|
|
||||||
Read the output in this order: `boot` (or `boot.contextMissing` with `boot.contextNote`: the page is the source of truth, per the note), then `event`, the generate event for `sessionId`, with the same `_instructions` a user's Go gets. Every verdict carries `_instructions`, and they win over your recollection of this file; the ones whose move is a decision of yours:
|
|
||||||
|
|
||||||
- **`ambiguous`**: the candidates are listed; target their common container, or rerun with `--text "<visible text>"` or `--index <n>`.
|
|
||||||
- **`dev_server_gone`**: the dev server stopped answering while the command waited for the page (on Cursor, a server another chat started dies with that chat). Start it the way the verdict says, then rerun with `--dev-url <url>`.
|
|
||||||
- **`no_match`**: the tab is on a route that does not render the element (navigate to the right route, rerun), or the selector is wrong (derive a better one from the source, or add `--text`).
|
|
||||||
- **`config_missing` / `config_invalid`** under `bootError`: follow [live-setup.md](live-setup.md) first, then rerun.
|
|
||||||
- **`event: null`** with `ok: true`: the event was slower than the wait; run `.agent/skills/impeccable/scripts/impeccable live-poll` once to collect it, then continue.
|
|
||||||
|
|
||||||
Done when the output shows `ok: true`, a `sessionId`, and an `event`, reached with at most one server started and one tab opened by you.
|
|
||||||
|
|
||||||
## Step 3: Generate
|
|
||||||
|
|
||||||
The event is a standard `generate` event: the picked element's context, a preflighted scaffold, and `_instructions` naming the action's reference, the planning section, and the exact splice. Handle it exactly per live.md's **Handle generate**, which owns everything from the identity lock to the done reply: read the action's reference and craft-floor.md as it says, plan per section 4 (identity first, then mode, then three different primary axes, then the squint test), declare knobs per section 7, and deliver per section 6 (a complete replacement of the element per variant, the preview CSS plus every variant in one edit at the scaffold's splice). The lane changes nothing about what a variant may be: the moves a live session would make on this element (a promoted tier, a restructured set, a reordered card, a different surface) are open here too. Never screenshot the page; the overlay preview is the review channel until accept.
|
|
||||||
|
|
||||||
**Reply and wait in one call**, with the file you wrote:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
.agent/skills/impeccable/scripts/impeccable live-poll --reply EVENT_ID done --file src/App.jsx --then-poll
|
|
||||||
```
|
|
||||||
|
|
||||||
This replies done (the browser mounts the variants) and then blocks until the user's choice arrives, so run it the way your harness runs a long wait: **Claude Code** in the foreground with your tool's longest timeout (600000 ms), so you are paused until the choice arrives; **Codex** in a yielded foreground exec; **Cursor** in a background terminal with notify on `"type":"(accept|discard|variant_mount_failed|exit)"`. Never pass a short `--timeout=`. While it runs there is nothing else to do: never sleep and never poll its output on a timer; a harness that backgrounds it wakes you when it returns. `{"type":"timeout"}` means the user has not chosen yet: run `live-poll` again and keep waiting. If the edit fails after the browser flipped to GENERATING, `--reply EVENT_ID error "Short reason"` (without `--then-poll`) so the bar resets.
|
|
||||||
|
|
||||||
Then tell the user, in one line, where their variants are: *"Three [bolder] variants are live on [the pricing cards]: cycle with the floating bar's arrows, adjust the Tune knobs, and Accept the keeper."*
|
|
||||||
|
|
||||||
Outside the replace path, read the matching live.md section before acting: `scaffold.previewMode: "svelte-component"` (Svelte previews are edited as components, and their accept is mechanical), `mode: "insert"`, `variant_mount_failed`, `steer`, `manual_edit_apply`, and any `fallback: "agent-driven"` wrap error.
|
|
||||||
|
|
||||||
## Step 4: Accept and close
|
|
||||||
|
|
||||||
The call from Step 3 returns the user's choice. **`discard`**: nothing to do. **`accept`**: `_acceptResult.carbonize: true` is the normal case, and the cleanup is live.md's **Required after accept**, unchanged: move the accepted variant's rules into the stylesheet that already owns the element with real selectors, bake the chosen knob values in, unwrap the element and drop every `data-impeccable-*` attribute, delete the inline `<style>` block and both `impeccable-carbonize` markers, then `.agent/skills/impeccable/scripts/impeccable live-complete --id SESSION_ID` and confirm `phase: "completed"`. (`baked: true` appears only when the accept was run with `--bake`; then the helper already made the variant permanent and no `live-complete` is owed.)
|
|
||||||
|
|
||||||
Close without being asked, the moment the choice is handled:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
.agent/skills/impeccable/scripts/impeccable live-server stop
|
|
||||||
```
|
|
||||||
|
|
||||||
Stopping removes the injected script and reloads the page once: the user sees the accepted design with no overlay chrome, still served by their dev server. **Never kill or restart the dev server**, including one you started in Step 2.
|
|
||||||
|
|
||||||
- **The user asks for more variants before you closed**: skip the close, run Step 2 again for the next element (the helper is reused), and close after the last choice.
|
|
||||||
- **Interrupted or unsure of the state**: `.agent/skills/impeccable/scripts/impeccable live-status`, then `live-resume`; the journal under `.impeccable/live/sessions/` is canonical.
|
|
||||||
|
|
||||||
Done when the helper is stopped and the dev site still answers with the accepted design.
|
|
||||||
@@ -205,11 +205,6 @@ t('items', { count }) // Handles complex plural rules
|
|||||||
- Optimistic updates with rollback
|
- Optimistic updates with rollback
|
||||||
- Conflict resolution
|
- Conflict resolution
|
||||||
|
|
||||||
**Interrupted gestures** (custom sliders, drag surfaces, scrollable control strips):
|
|
||||||
- A second finger or pointer lands mid-drag: the first drag keeps its pointer or ends cleanly, never jumps to the new one
|
|
||||||
- The browser cancels the gesture to scroll (`pointercancel`), capture is lost (`lostpointercapture`), the pointer is released outside the control, or the window loses focus (`blur`) mid-drag: clear the dragging state and release capture
|
|
||||||
- After each of these, the next tap or drag works without a reload
|
|
||||||
|
|
||||||
**Permission states**:
|
**Permission states**:
|
||||||
- No permission to view
|
- No permission to view
|
||||||
- No permission to edit
|
- No permission to edit
|
||||||
@@ -309,7 +304,6 @@ const throttledScroll = throttle(handleScroll, 100);
|
|||||||
- Unit tests for edge cases
|
- Unit tests for edge cases
|
||||||
- Integration tests for error scenarios
|
- Integration tests for error scenarios
|
||||||
- E2E tests for critical paths
|
- E2E tests for critical paths
|
||||||
- A behavioral regression for each confirmed gesture fix, when the project's test runner can drive input
|
|
||||||
- Visual regression tests
|
- Visual regression tests
|
||||||
- Accessibility tests (axe, WAVE)
|
- Accessibility tests (axe, WAVE)
|
||||||
|
|
||||||
@@ -336,10 +330,7 @@ Test thoroughly with edge cases:
|
|||||||
- **Network issues**: Disable internet, throttle connection
|
- **Network issues**: Disable internet, throttle connection
|
||||||
- **Large datasets**: Test with 1000+ items
|
- **Large datasets**: Test with 1000+ items
|
||||||
- **Concurrent actions**: Click submit 10 times rapidly
|
- **Concurrent actions**: Click submit 10 times rapidly
|
||||||
- **Interrupted gestures**: Add a second finger mid-drag, scroll across the control, release outside it, switch windows mid-drag; then drag again
|
|
||||||
- **Errors**: Force API errors, test all error states
|
- **Errors**: Force API errors, test all error states
|
||||||
- **Empty**: Remove all data, test empty states
|
- **Empty**: Remove all data, test empty states
|
||||||
|
|
||||||
For gestures, say what produced the evidence (emulated viewport, synthesized touch, which engine, physical device) and name what stayed untested.
|
|
||||||
|
|
||||||
When edge cases are covered, hand off to `/impeccable polish` for the final pass.
|
When edge cases are covered, hand off to `/impeccable polish` for the final pass.
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ Reason over the signals; there is no score to obey:
|
|||||||
- `critique.latest` is `null` → the project has never been critiqued; for a set-up project with a real surface, offering `/impeccable critique <surface>` is a strong default.
|
- `critique.latest` is `null` → the project has never been critiqued; for a set-up project with a real surface, offering `/impeccable critique <surface>` is a strong default.
|
||||||
- `critique.latest` with a low `score` or non-zero `p0` / `p1` → `polish` (it reads that snapshot as its backlog and closes it when stale or cleared).
|
- `critique.latest` with a low `score` or non-zero `p0` / `p1` → `polish` (it reads that snapshot as its backlog and closes it when stale or cleared).
|
||||||
- `git.changedFiles` pointing at one surface → scope `audit` or `polish` to those files specifically, naming them.
|
- `git.changedFiles` pointing at one surface → scope `audit` or `polish` to those files specifically, naming them.
|
||||||
- `devServer.running` true → `live` is available for in-browser iteration, and `generate` for one-shot variant runs on a named element; if false, don't lead with either. **`live`, `generate`, and the bundled `impeccable detect` are web-only.** If `setup.platform` is `ios`, `android`, or `adaptive`, don't lead with any of them; the browser overlay and the HTML rule engine don't apply to native app code.
|
- `devServer.running` true → `live` is available for in-browser iteration; if false, don't lead with `live`. **`live` and the bundled `impeccable detect` are web-only.** If `setup.platform` is `ios`, `android`, or `adaptive`, don't lead with either; the browser overlay and the HTML rule engine don't apply to native app code.
|
||||||
- Otherwise group by intent (build new / improve what's there / iterate visually), tailored to the current surface and `setup.platform`.
|
- Otherwise group by intent (build new / improve what's there / iterate visually), tailored to the current surface and `setup.platform`.
|
||||||
|
|
||||||
**If `scan.targets` is non-empty and `setup.platform` is not `ios`/`android`/`adaptive`, run `.agent/skills/impeccable/scripts/impeccable detect --json <scan.targets joined by spaces>` once** (the bundled detector over local files: no network, no npx; it reads HTML/CSS, so skip it for native projects). `scan.via` tells you what they are: `git-changes` (the markup/style files in your dirty tree, the most relevant set), `source-dir` (e.g. `src`, `app`), `html`, or `root`. Fold the hits into your picks: many quality / contrast hits → `audit` or `polish`; a specific slop family → the matching command (gradient text or eyebrows → `quieter` / `typeset`, flat or gray palette → `colorize`, and so on). It's a real, current signal that beats guessing. If detect errors or the tree is large and slow, skip it and recommend the user run `audit` themselves; never block the suggestion on it.
|
**If `scan.targets` is non-empty and `setup.platform` is not `ios`/`android`/`adaptive`, run `.agent/skills/impeccable/scripts/impeccable detect --json <scan.targets joined by spaces>` once** (the bundled detector over local files: no network, no npx; it reads HTML/CSS, so skip it for native projects). `scan.via` tells you what they are: `git-changes` (the markup/style files in your dirty tree, the most relevant set), `source-dir` (e.g. `src`, `app`), `html`, or `root`. Fold the hits into your picks: many quality / contrast hits → `audit` or `polish`; a specific slop family → the matching command (gradient text or eyebrows → `quieter` / `typeset`, flat or gray palette → `colorize`, and so on). It's a real, current signal that beats guessing. If detect errors or the tree is large and slow, skip it and recommend the user run `audit` themselves; never block the suggestion on it.
|
||||||
|
|||||||
@@ -19,10 +19,6 @@
|
|||||||
"description": "Interactive live variant mode. Select elements in the browser, pick a design action, and get AI-generated HTML+CSS variants hot-swapped via HMR. Requires a running dev server. Use when you want to visually experiment with design alternatives in real time.",
|
"description": "Interactive live variant mode. Select elements in the browser, pick a design action, and get AI-generated HTML+CSS variants hot-swapped via HMR. Requires a running dev server. Use when you want to visually experiment with design alternatives in real time.",
|
||||||
"argumentHint": ""
|
"argumentHint": ""
|
||||||
},
|
},
|
||||||
"generate": {
|
|
||||||
"description": "Agent-driven live variant generation. Boots live mode, finds the named element on the open page, scrolls the browser to it, and delivers N variants in the requested direction for the user to cycle and accept. Use for requests that name an element and a direction, like 'generate 3 bold variants of the pricing cards', skipping manual element picking.",
|
|
||||||
"argumentHint": "[count] [direction] variants of [element]"
|
|
||||||
},
|
|
||||||
"adapt": {
|
"adapt": {
|
||||||
"description": "Adapt designs to work across different screen sizes, devices, contexts, or platforms. Implements breakpoints, fluid layouts, and touch targets. Use when the user mentions responsive design, mobile layouts, breakpoints, viewport adaptation, or cross-device compatibility.",
|
"description": "Adapt designs to work across different screen sizes, devices, contexts, or platforms. Implements breakpoints, fluid layouts, and touch targets. Use when the user mentions responsive design, mobile layouts, breakpoints, viewport adaptation, or cross-device compatibility.",
|
||||||
"argumentHint": "[target] [context (mobile, tablet, print...)]"
|
"argumentHint": "[target] [context (mobile, tablet, print...)]"
|
||||||
|
|||||||
@@ -87,11 +87,6 @@ fi
|
|||||||
# Last resort: fetch this version's binary for the current platform from the
|
# Last resort: fetch this version's binary for the current platform from the
|
||||||
# public release channel into the user cache. Needs network; sandboxes without
|
# public release channel into the user cache. Needs network; sandboxes without
|
||||||
# egress preinstall the binary on PATH instead.
|
# egress preinstall the binary on PATH instead.
|
||||||
setup_help() {
|
|
||||||
echo "Engine $version setup needs network access and write permission to $cache_root/bin/$version." >&2
|
|
||||||
echo "Run this launcher ($0) with engine-probe in a terminal that has those permissions, then retry the original command." >&2
|
|
||||||
echo "Alternatively, set IMPECCABLE_HOME to a writable cache location, or IMPECCABLE_BIN to a preinstalled engine binary." >&2
|
|
||||||
}
|
|
||||||
fetch_url() {
|
fetch_url() {
|
||||||
if command -v curl >/dev/null 2>&1; then
|
if command -v curl >/dev/null 2>&1; then
|
||||||
curl -fsSL --retry 2 -o "$tmp" "$1" 2>/dev/null
|
curl -fsSL --retry 2 -o "$tmp" "$1" 2>/dev/null
|
||||||
@@ -124,18 +119,7 @@ if [ -n "$version" ] && [ "$os" != unknown ] && [ "$arch" != unknown ]; then
|
|||||||
[ "$os" = windows ] && asset="$asset.exe"
|
[ "$os" = windows ] && asset="$asset.exe"
|
||||||
url="$base/engine-v$version/$asset"
|
url="$base/engine-v$version/$asset"
|
||||||
tmp="$cache_root/bin/$version/.impeccable.part.$$"
|
tmp="$cache_root/bin/$version/.impeccable.part.$$"
|
||||||
if ! mkdir -p "$cache_root/bin/$version" 2>/dev/null; then
|
mkdir -p "$cache_root/bin/$version" 2>/dev/null
|
||||||
echo "impeccable: engine $version is not installed; cannot create cache directory: $cache_root/bin/$version" >&2
|
|
||||||
setup_help
|
|
||||||
exit 127
|
|
||||||
fi
|
|
||||||
# Check the actual staging file, not just directory existence: a cache from
|
|
||||||
# an earlier run can be readable but no longer writable inside a sandbox.
|
|
||||||
if ! (umask 077; : > "$tmp") 2>/dev/null; then
|
|
||||||
echo "impeccable: engine $version is not installed; cannot write to cache directory: $cache_root/bin/$version" >&2
|
|
||||||
setup_help
|
|
||||||
exit 127
|
|
||||||
fi
|
|
||||||
fetched=0
|
fetched=0
|
||||||
if fetch_url "$url"; then
|
if fetch_url "$url"; then
|
||||||
fetched=1
|
fetched=1
|
||||||
@@ -196,9 +180,6 @@ if [ -n "$version" ] && [ "$os" != unknown ] && [ "$arch" != unknown ]; then
|
|||||||
exec "$cached" "$@"
|
exec "$cached" "$@"
|
||||||
fi
|
fi
|
||||||
rm -f "$tmp" 2>/dev/null
|
rm -f "$tmp" 2>/dev/null
|
||||||
echo "impeccable: could not download engine $version from $url; check network access, the release URL, and curl or wget availability." >&2
|
|
||||||
setup_help
|
|
||||||
exit 127
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "impeccable: no engine binary for $os-$arch found (looked in $bin, $cached, PATH)." >&2
|
echo "impeccable: no engine binary for $os-$arch found (looked in $bin, $cached, PATH)." >&2
|
||||||
|
|||||||
@@ -68,27 +68,18 @@ rem another launcher's probe: fail fast and quiet instead.
|
|||||||
if defined IMPECCABLE_LAUNCHER_PROBE exit /b 127
|
if defined IMPECCABLE_LAUNCHER_PROBE exit /b 127
|
||||||
if not defined version goto fail
|
if not defined version goto fail
|
||||||
where curl.exe >nul 2>nul
|
where curl.exe >nul 2>nul
|
||||||
if errorlevel 1 goto curl_missing
|
if errorlevel 1 goto fail
|
||||||
if not defined IMPECCABLE_DOWNLOAD_BASE set "IMPECCABLE_DOWNLOAD_BASE=https://github.com/pbakaus/impeccable/releases/download"
|
if not defined IMPECCABLE_DOWNLOAD_BASE set "IMPECCABLE_DOWNLOAD_BASE=https://github.com/pbakaus/impeccable/releases/download"
|
||||||
if exist "%IMPECCABLE_HOME%\bin\%version%\" goto cache_ready
|
if not exist "%IMPECCABLE_HOME%\bin\%version%" mkdir "%IMPECCABLE_HOME%\bin\%version%" >nul 2>nul
|
||||||
mkdir "%IMPECCABLE_HOME%\bin\%version%" >nul 2>nul
|
|
||||||
if errorlevel 1 goto cache_directory_failed
|
|
||||||
:cache_ready
|
|
||||||
rem Check the staging file too: an existing directory may be read-only.
|
|
||||||
rem Redirection failures do not reliably update ERRORLEVEL in cmd.exe;
|
|
||||||
rem branch on the command's failure directly. Never treat a directory as a
|
|
||||||
rem staging file (later del cleanup would prompt to delete its contents).
|
|
||||||
if exist "%cached%.part\" goto cache_write_failed
|
|
||||||
(type nul >"%cached%.part") 2>nul || goto cache_write_failed
|
|
||||||
set "asset=impeccable-windows-%arch%.exe"
|
set "asset=impeccable-windows-%arch%.exe"
|
||||||
set "url=%IMPECCABLE_DOWNLOAD_BASE%/engine-v%version%/%asset%"
|
set "url=%IMPECCABLE_DOWNLOAD_BASE%/engine-v%version%/%asset%"
|
||||||
curl.exe -fsSL -o "%cached%.part" "%url%" >nul 2>nul
|
curl.exe -fsSL -o "%cached%.part" "%url%" >nul 2>nul
|
||||||
if not errorlevel 1 goto verify
|
if not errorlevel 1 goto verify
|
||||||
if not "%arch%"=="arm64" goto download_failed
|
if not "%arch%"=="arm64" goto fail
|
||||||
set "asset=impeccable-windows-x64.exe"
|
set "asset=impeccable-windows-x64.exe"
|
||||||
set "url=%IMPECCABLE_DOWNLOAD_BASE%/engine-v%version%/%asset%"
|
set "url=%IMPECCABLE_DOWNLOAD_BASE%/engine-v%version%/%asset%"
|
||||||
curl.exe -fsSL -o "%cached%.part" "%url%" >nul 2>nul
|
curl.exe -fsSL -o "%cached%.part" "%url%" >nul 2>nul
|
||||||
if errorlevel 1 goto download_failed
|
if errorlevel 1 goto fail
|
||||||
|
|
||||||
:verify
|
:verify
|
||||||
call :check_download
|
call :check_download
|
||||||
@@ -185,28 +176,6 @@ if not errorlevel 1 set "probe_ok=1"
|
|||||||
del "%probe_tmp%" >nul 2>nul
|
del "%probe_tmp%" >nul 2>nul
|
||||||
exit /b 0
|
exit /b 0
|
||||||
|
|
||||||
:cache_directory_failed
|
|
||||||
echo impeccable: engine %version% is not installed; cannot create cache directory: "%IMPECCABLE_HOME%\bin\%version%" 1>&2
|
|
||||||
goto setup_failed
|
|
||||||
|
|
||||||
:cache_write_failed
|
|
||||||
echo impeccable: engine %version% is not installed; cannot write to cache directory: "%IMPECCABLE_HOME%\bin\%version%" 1>&2
|
|
||||||
goto setup_failed
|
|
||||||
|
|
||||||
:curl_missing
|
|
||||||
echo impeccable: cannot download engine %version%; curl.exe is unavailable. 1>&2
|
|
||||||
goto setup_failed
|
|
||||||
|
|
||||||
:download_failed
|
|
||||||
del "%cached%.part" >nul 2>nul
|
|
||||||
echo impeccable: could not download engine %version% from %url%; check network access and the release URL. 1>&2
|
|
||||||
|
|
||||||
:setup_failed
|
|
||||||
echo Engine %version% setup needs network access and write permission to "%IMPECCABLE_HOME%\bin\%version%". 1>&2
|
|
||||||
echo Run this launcher ("%~f0") with engine-probe in a terminal that has those permissions, then retry the original command. 1>&2
|
|
||||||
echo Alternatively, set IMPECCABLE_HOME to a writable cache location, or IMPECCABLE_BIN to a preinstalled engine binary. 1>&2
|
|
||||||
exit /b 127
|
|
||||||
|
|
||||||
:fail
|
:fail
|
||||||
del "%cached%.part" >nul 2>nul
|
del "%cached%.part" >nul 2>nul
|
||||||
echo impeccable: no engine binary found (looked in %bin%, %cached%, PATH). 1>&2
|
echo impeccable: no engine binary found (looked in %bin%, %cached%, PATH). 1>&2
|
||||||
|
|||||||
@@ -165,14 +165,6 @@
|
|||||||
}
|
}
|
||||||
let parameterGenerationState = 'idle';
|
let parameterGenerationState = 'idle';
|
||||||
let parameterReadyAnnouncedSession = null;
|
let parameterReadyAnnouncedSession = null;
|
||||||
// 'agent' when the generate verb fired this session's Go (the generate
|
|
||||||
// lane declares no knobs, so its bar never shows a pending Tune chip);
|
|
||||||
// null for every Go a user presses.
|
|
||||||
let sessionOrigin = null;
|
|
||||||
// The generate lane picks for the agent and never edits copy in the
|
|
||||||
// browser, so its selection carries no edit-copy badge (set on the
|
|
||||||
// agent-target pick, cleared with the session; a user's pick never sets it).
|
|
||||||
let editBadgeSuppressed = false;
|
|
||||||
let svelteComponentSession = null;
|
let svelteComponentSession = null;
|
||||||
let svelteRuntimePromise = null;
|
let svelteRuntimePromise = null;
|
||||||
let pendingSvelteComponentRetryObserver = null;
|
let pendingSvelteComponentRetryObserver = null;
|
||||||
@@ -991,20 +983,9 @@
|
|||||||
}
|
}
|
||||||
} catch { /* cross-origin */ }
|
} catch { /* cross-origin */ }
|
||||||
}
|
}
|
||||||
// The selector a mechanical bake would anchor lasting rules on, and how
|
|
||||||
// many elements it matches right now: the bake refuses anything but one,
|
|
||||||
// since its rules would restyle every match, not just this element.
|
|
||||||
const cssIdent = (s) => /^[A-Za-z_-][\w-]*$/.test(s);
|
|
||||||
const anchorClasses = [...el.classList].filter(cssIdent);
|
|
||||||
const anchor = el.id && cssIdent(el.id)
|
|
||||||
? '#' + el.id
|
|
||||||
: (anchorClasses.length ? el.tagName.toLowerCase() + '.' + anchorClasses.join('.') : null);
|
|
||||||
let anchorMatches = null;
|
|
||||||
if (anchor) { try { anchorMatches = document.querySelectorAll(anchor).length; } catch { anchorMatches = null; } }
|
|
||||||
return {
|
return {
|
||||||
tagName: el.tagName.toLowerCase(), id: el.id || null,
|
tagName: el.tagName.toLowerCase(), id: el.id || null,
|
||||||
classes: [...el.classList],
|
classes: [...el.classList],
|
||||||
anchor, anchorMatches,
|
|
||||||
textContent: (el.textContent || '').slice(0, 500),
|
textContent: (el.textContent || '').slice(0, 500),
|
||||||
outerHTML: sanitizedContextOuterHTML(el, 10000),
|
outerHTML: sanitizedContextOuterHTML(el, 10000),
|
||||||
computedStyles: {
|
computedStyles: {
|
||||||
@@ -2056,7 +2037,6 @@
|
|||||||
function setLiveState(next) {
|
function setLiveState(next) {
|
||||||
state = next;
|
state = next;
|
||||||
window.__IMPECCABLE_LIVE_STATE__ = next;
|
window.__IMPECCABLE_LIVE_STATE__ = next;
|
||||||
retryDeclinedAgentTargets();
|
|
||||||
syncPageInteractionCursor();
|
syncPageInteractionCursor();
|
||||||
// Whether a queued steer is still behind a generation is a function of this
|
// Whether a queued steer is still behind a generation is a function of this
|
||||||
// state, so the hint has to move with it, not only with the 5s poll.
|
// state, so the hint has to move with it, not only with the 5s poll.
|
||||||
@@ -4034,7 +4014,6 @@
|
|||||||
|
|
||||||
function hidePendingApplyDock() {
|
function hidePendingApplyDock() {
|
||||||
pendingApplyInFlight = false;
|
pendingApplyInFlight = false;
|
||||||
retryDeclinedAgentTargets();
|
|
||||||
clearStoredManualApplyState();
|
clearStoredManualApplyState();
|
||||||
if (pendingIntroAnimation) { pendingIntroAnimation.cancel(); pendingIntroAnimation = null; }
|
if (pendingIntroAnimation) { pendingIntroAnimation.cancel(); pendingIntroAnimation = null; }
|
||||||
if (pendingDockEl) pendingDockEl.style.display = 'none';
|
if (pendingDockEl) pendingDockEl.style.display = 'none';
|
||||||
@@ -4068,7 +4047,6 @@
|
|||||||
function setPendingApplyLoading(loading, count) {
|
function setPendingApplyLoading(loading, count) {
|
||||||
if (!pendingPillEl || !pendingPillLabelEl || !pendingPillCountEl || !pendingTrashBtn) return;
|
if (!pendingPillEl || !pendingPillLabelEl || !pendingPillCountEl || !pendingTrashBtn) return;
|
||||||
pendingApplyInFlight = loading === true;
|
pendingApplyInFlight = loading === true;
|
||||||
if (!pendingApplyInFlight) retryDeclinedAgentTargets();
|
|
||||||
const currentCount = count || parseInt(pendingPillEl.dataset.count || '0', 10) || 0;
|
const currentCount = count || parseInt(pendingPillEl.dataset.count || '0', 10) || 0;
|
||||||
if (pendingApplyInFlight) storeManualApplyState(currentCount);
|
if (pendingApplyInFlight) storeManualApplyState(currentCount);
|
||||||
else clearStoredManualApplyState();
|
else clearStoredManualApplyState();
|
||||||
@@ -4710,7 +4688,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderEditBadge(mode) {
|
function renderEditBadge(mode) {
|
||||||
if (editBadgeSuppressed || sessionOrigin === 'agent') mode = 'hidden';
|
|
||||||
if (mode === 'hidden' || !editBadgeEl) {
|
if (mode === 'hidden' || !editBadgeEl) {
|
||||||
hideConfigureBarTooltip();
|
hideConfigureBarTooltip();
|
||||||
if (editBadgeEl) editBadgeEl.style.display = 'none';
|
if (editBadgeEl) editBadgeEl.style.display = 'none';
|
||||||
@@ -6204,8 +6181,6 @@
|
|||||||
resetSessionFileMeta();
|
resetSessionFileMeta();
|
||||||
currentSessionId = null;
|
currentSessionId = null;
|
||||||
parameterGenerationState = 'idle';
|
parameterGenerationState = 'idle';
|
||||||
sessionOrigin = null;
|
|
||||||
editBadgeSuppressed = false;
|
|
||||||
parameterReadyAnnouncedSession = null;
|
parameterReadyAnnouncedSession = null;
|
||||||
expectedVariants = 0;
|
expectedVariants = 0;
|
||||||
arrivedVariants = 0;
|
arrivedVariants = 0;
|
||||||
@@ -7137,398 +7112,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
//
|
//
|
||||||
// ------------------------------------------------------------------
|
|
||||||
// Agent-initiated targeting (the `generate` command). The agent names an
|
|
||||||
// element by CSS selector over POST /agent-target; the server pushes an
|
|
||||||
// `agent_target` SSE message here. The overlay resolves the selector,
|
|
||||||
// scrolls the element into view, enters the same picked state a user
|
|
||||||
// click produces, and fires the normal Go pipeline, so everything
|
|
||||||
// downstream (generate event, variants, cycling, accept) is unchanged.
|
|
||||||
// The verdict goes back through POST /agent-target-result, which resolves
|
|
||||||
// the agent's held-open CLI call.
|
|
||||||
|
|
||||||
function postAgentTargetResult(targetId, result) {
|
|
||||||
fetch('http://localhost:' + PORT + '/agent-target-result?token=' + TOKEN, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ token: TOKEN, targetId, clientId: AGENT_TARGET_CLIENT_ID, ...result }),
|
|
||||||
}).catch(() => { /* server gone; nothing to report to */ });
|
|
||||||
}
|
|
||||||
|
|
||||||
function describeAgentTargetCandidate(el) {
|
|
||||||
return {
|
|
||||||
tag: el.tagName.toLowerCase(),
|
|
||||||
id: el.id || null,
|
|
||||||
classes: [...el.classList].filter((c) => !c.startsWith('impeccable-')),
|
|
||||||
text: (el.textContent || '').trim().slice(0, 80),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveAgentTargetElement(msg) {
|
|
||||||
let matched;
|
|
||||||
try {
|
|
||||||
matched = [...document.querySelectorAll(msg.selector)];
|
|
||||||
} catch {
|
|
||||||
return { error: { ok: false, error: 'invalid_selector', selector: msg.selector } };
|
|
||||||
}
|
|
||||||
let candidates = matched.filter((el) => pickable(el));
|
|
||||||
if (msg.text) {
|
|
||||||
const needle = String(msg.text).toLowerCase();
|
|
||||||
candidates = candidates.filter((el) => (el.textContent || '').toLowerCase().includes(needle));
|
|
||||||
}
|
|
||||||
if (candidates.length === 0) {
|
|
||||||
return {
|
|
||||||
error: {
|
|
||||||
ok: false,
|
|
||||||
error: 'no_match',
|
|
||||||
selector: msg.selector,
|
|
||||||
matchCount: 0,
|
|
||||||
// How many nodes the raw selector hit before the pickable/text
|
|
||||||
// filters: distinguishes a wrong selector from an unpickable match.
|
|
||||||
rawMatchCount: matched.length,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (Number.isInteger(msg.index)) {
|
|
||||||
const el = candidates[msg.index - 1];
|
|
||||||
if (!el) {
|
|
||||||
return { error: { ok: false, error: 'index_out_of_range', selector: msg.selector, matchCount: candidates.length } };
|
|
||||||
}
|
|
||||||
return { el, matchCount: candidates.length };
|
|
||||||
}
|
|
||||||
if (candidates.length > 1) {
|
|
||||||
return {
|
|
||||||
error: {
|
|
||||||
ok: false,
|
|
||||||
error: 'ambiguous',
|
|
||||||
selector: msg.selector,
|
|
||||||
matchCount: candidates.length,
|
|
||||||
candidates: candidates.slice(0, 8).map(describeAgentTargetCandidate),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return { el: candidates[0], matchCount: 1 };
|
|
||||||
}
|
|
||||||
|
|
||||||
function scrollAgentTargetIntoView(el, done) {
|
|
||||||
const rect = el.getBoundingClientRect();
|
|
||||||
if (rect.top >= 0 && rect.bottom <= window.innerHeight) { done(); return; }
|
|
||||||
let settled = false;
|
|
||||||
let fallback = null;
|
|
||||||
const finish = () => {
|
|
||||||
if (settled) return;
|
|
||||||
settled = true;
|
|
||||||
removeEventListener('scrollend', finish, true);
|
|
||||||
if (fallback) clearTimeout(fallback);
|
|
||||||
done();
|
|
||||||
};
|
|
||||||
// scrollend where supported; a timer covers engines without it and the
|
|
||||||
// no-movement case (element already at its final resting position).
|
|
||||||
addEventListener('scrollend', finish, true);
|
|
||||||
fallback = setTimeout(finish, 1200);
|
|
||||||
el.scrollIntoView({ block: 'center', behavior: 'smooth' });
|
|
||||||
}
|
|
||||||
|
|
||||||
// One id per page load: the server keys claims and roll-call reports on
|
|
||||||
// it, and only the tab that holds the lease can renew it.
|
|
||||||
const AGENT_TARGET_CLIENT_ID = id8();
|
|
||||||
|
|
||||||
// The agent target an agent-initiated Go is serving: set by
|
|
||||||
// actOnAgentTarget around its handleGo call, read once by handleGo.
|
|
||||||
let agentTargetForGo = null;
|
|
||||||
|
|
||||||
// The helper's word on its global bar. The generate lane asks the helper
|
|
||||||
// to keep it out of the way (`impeccable live --no-live-bar`, or an agent
|
|
||||||
// target carrying hideLiveBar), and the helper tells every connected tab
|
|
||||||
// at once (`live_bar`) and every later connection on `connected`, so the
|
|
||||||
// bar stays hidden in every tab, through reloads, the accept, and the
|
|
||||||
// bake, until the helper stops and takes the overlay with it. The variant
|
|
||||||
// controls still show.
|
|
||||||
let liveBarHiddenByHelper = false;
|
|
||||||
|
|
||||||
function applyLiveBarPreference(hidden) {
|
|
||||||
liveBarHiddenByHelper = hidden === true;
|
|
||||||
setLiveBarHidden(liveBarHiddenByHelper);
|
|
||||||
}
|
|
||||||
|
|
||||||
// A plain live session must never notice this code: hiding remembers the
|
|
||||||
// bar's own display value and restoring puts exactly that back, and a
|
|
||||||
// restore on a bar that is not hidden is a no-op, so the `connected`
|
|
||||||
// frame every session receives changes nothing unless the lane asked.
|
|
||||||
function setLiveBarHidden(hidden) {
|
|
||||||
if (!globalBarEl) return;
|
|
||||||
if (hidden) {
|
|
||||||
if (globalBarEl.style.display !== 'none') {
|
|
||||||
globalBarEl.dataset.liveBarDisplay = globalBarEl.style.display || 'flex';
|
|
||||||
globalBarEl.style.display = 'none';
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (globalBarEl.style.display === 'none') {
|
|
||||||
globalBarEl.style.display = globalBarEl.dataset.liveBarDisplay || 'flex';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function claimAgentTarget(targetId, report) {
|
|
||||||
return fetch('http://localhost:' + PORT + '/agent-target-claim?token=' + TOKEN, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ token: TOKEN, targetId, clientId: AGENT_TARGET_CLIENT_ID, ...report }),
|
|
||||||
}).then((res) => res.json())
|
|
||||||
.then((j) => ({ granted: !!j && j.granted === true, pending: !!j && j.pending === true }))
|
|
||||||
.catch(() => ({ granted: false, pending: false }));
|
|
||||||
}
|
|
||||||
|
|
||||||
// `exceptTargetId` is the target this call is about: a tab acting on it
|
|
||||||
// is not busy for itself, but it is busy for every other target, or two
|
|
||||||
// held requests could both be claimed here and the second Go would
|
|
||||||
// overwrite the session the first one minted.
|
|
||||||
function agentTargetBusyReason(exceptTargetId) {
|
|
||||||
if (pendingApplyInFlight) return 'manual_apply_in_flight';
|
|
||||||
if (state !== 'IDLE' && state !== 'PICKING' && state !== 'CONFIGURING') return 'session_active';
|
|
||||||
for (const [targetId, status] of agentTargetsSeen) {
|
|
||||||
if (status === 'acting' && targetId !== exceptTargetId) return 'agent_target_in_flight';
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Targets this tab declined as busy. A busy report is only this tab's word
|
|
||||||
// at that moment: the moment it is free again (setLiveState), it claims
|
|
||||||
// each of these as eligible, and the server drops the stale report, so a
|
|
||||||
// busy verdict is never built on a tab that has since gone idle. The
|
|
||||||
// server denies claims for resolved targets, so retries are harmless.
|
|
||||||
const busyDeclinedTargets = new Map();
|
|
||||||
|
|
||||||
function declineAgentTargetBusy(msg, busy) {
|
|
||||||
busyDeclinedTargets.set(msg.targetId, msg);
|
|
||||||
noteAgentTarget(msg.targetId, 'declined');
|
|
||||||
claimAgentTarget(msg.targetId, { eligible: false, state, reason: busy });
|
|
||||||
}
|
|
||||||
|
|
||||||
// A torn-down overlay, or one whose helper connection is gone, cannot
|
|
||||||
// serve a target and must not even claim one: it would hold the lease for
|
|
||||||
// a request it will never act on.
|
|
||||||
function agentTargetOverlayGone() {
|
|
||||||
return !evtSource;
|
|
||||||
}
|
|
||||||
|
|
||||||
// A denied claimant retries at this cadence, a little over the lease, so
|
|
||||||
// the first retry after a dead holder's lease lapses is granted.
|
|
||||||
const AGENT_TARGET_RESCUE_RETRY_MS = 3500;
|
|
||||||
|
|
||||||
// Claim the lease and act as the holder. A denied claim means another tab
|
|
||||||
// holds the lease. That holder can die before posting its result (reload,
|
|
||||||
// crash, even after renewing), and its lease lapses after ~3s, so this tab
|
|
||||||
// keeps retrying for as long as the server still holds the request: the
|
|
||||||
// answer's `pending` is the server's word that the request is alive, and
|
|
||||||
// it turns false the moment the request resolved or timed out, so no tab
|
|
||||||
// retries a request nobody awaits. A tab that turned busy meanwhile joins
|
|
||||||
// the roll call instead of taking a lease it cannot use. The first claim
|
|
||||||
// and the busy-to-idle re-claim share this.
|
|
||||||
function claimAndActOnAgentTarget(msg) {
|
|
||||||
if (agentTargetOverlayGone()) return;
|
|
||||||
const busy = agentTargetBusyReason(msg.targetId);
|
|
||||||
if (busy) { declineAgentTargetBusy(msg, busy); return; }
|
|
||||||
if (declineAgentTargetUnresolvable(msg)) return;
|
|
||||||
claimAgentTarget(msg.targetId, { eligible: true }).then((claim) => {
|
|
||||||
if (claim.granted) { noteAgentTarget(msg.targetId, 'acting'); actOnAgentTarget(msg); return; }
|
|
||||||
noteAgentTarget(msg.targetId, 'denied');
|
|
||||||
if (!claim.pending) return;
|
|
||||||
setTimeout(() => claimAndActOnAgentTarget(msg), AGENT_TARGET_RESCUE_RETRY_MS);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function retryDeclinedAgentTargets() {
|
|
||||||
if (busyDeclinedTargets.size === 0 || agentTargetBusyReason()) return;
|
|
||||||
for (const [targetId, msg] of busyDeclinedTargets) {
|
|
||||||
busyDeclinedTargets.delete(targetId);
|
|
||||||
claimAndActOnAgentTarget(msg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// This page's participation in each target it heard: 'acting' once a
|
|
||||||
// claim was granted, 'done' once it replied (or stood down from a lapsed
|
|
||||||
// lease), else the word it last gave. The server replays pending targets
|
|
||||||
// to every connection that opens. After a reconnect that overlapped the
|
|
||||||
// old connection the server still holds this page's word; after one that
|
|
||||||
// did not, it dropped the word on the close, so a replayed target is
|
|
||||||
// handled again: a busy or unresolvable page re-declines (idempotent), an
|
|
||||||
// idle page claims.
|
|
||||||
const agentTargetsSeen = new Map();
|
|
||||||
function noteAgentTarget(targetId, status) {
|
|
||||||
agentTargetsSeen.set(targetId, status);
|
|
||||||
if (agentTargetsSeen.size > 100) agentTargetsSeen.delete(agentTargetsSeen.keys().next().value);
|
|
||||||
}
|
|
||||||
|
|
||||||
// A target this page took a lease on is off-limits for a replay: while
|
|
||||||
// acting (a second claim or Go), and once done, because its result may
|
|
||||||
// still be on the wire and this tab is GENERATING by then, so handling
|
|
||||||
// the replay would decline busy, hand the lease back mid-resolution, and
|
|
||||||
// let another tab fire a second Go.
|
|
||||||
function agentTargetTaken(targetId) {
|
|
||||||
const status = agentTargetsSeen.get(targetId);
|
|
||||||
return status === 'acting' || status === 'done';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only a page that can resolve the target claims it. A tab whose page
|
|
||||||
// lacks the element declines with its resolution verdict instead, so a
|
|
||||||
// first-wins claim never lets the wrong page answer for a target that
|
|
||||||
// another page has. The server prefers a busy report (a tab that could
|
|
||||||
// serve later) over these, and returns the resolution verdict only when
|
|
||||||
// no connected page can serve.
|
|
||||||
//
|
|
||||||
// An element can be momentarily absent (a route still rendering, an HMR
|
|
||||||
// commit mid-swap), so a failed resolution is not this page's final word:
|
|
||||||
// it is re-checked a few times over about two seconds, claiming the
|
|
||||||
// moment the element mounts, and only the last miss is reported. The
|
|
||||||
// server's timeout still bounds the whole exchange.
|
|
||||||
// The page reports the miss at once (so the other overlays' words can
|
|
||||||
// complete the roll call) and keeps re-checking at this cadence for as
|
|
||||||
// long as the server says the request is pending: the server holds an
|
|
||||||
// all-no_match roll call open for a short grace precisely so a late mount
|
|
||||||
// can still be claimed, drops the stale report on an eligible claim, and
|
|
||||||
// ends the watch by answering pending:false once the request resolved or
|
|
||||||
// timed out.
|
|
||||||
const AGENT_TARGET_RESOLVE_WATCH_MS = 500;
|
|
||||||
|
|
||||||
function declineAgentTargetUnresolvable(msg) {
|
|
||||||
const probe = resolveAgentTargetElement(msg);
|
|
||||||
if (!probe.error) return false;
|
|
||||||
reportAgentTargetUnresolvable(msg, probe.error);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function reportAgentTargetUnresolvable(msg, error) {
|
|
||||||
noteAgentTarget(msg.targetId, 'declined');
|
|
||||||
claimAgentTarget(msg.targetId, { eligible: false, state, reason: 'no_match', result: error }).then((answer) => {
|
|
||||||
if (!answer.pending) return;
|
|
||||||
setTimeout(() => watchAgentTargetResolution(msg, error), AGENT_TARGET_RESOLVE_WATCH_MS);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function watchAgentTargetResolution(msg, lastError) {
|
|
||||||
if (agentTargetOverlayGone() || agentTargetTaken(msg.targetId)) return;
|
|
||||||
const busy = agentTargetBusyReason(msg.targetId);
|
|
||||||
if (busy) { declineAgentTargetBusy(msg, busy); return; }
|
|
||||||
const probe = resolveAgentTargetElement(msg);
|
|
||||||
if (!probe.error) { claimAndActOnAgentTarget(msg); return; }
|
|
||||||
// Still unresolvable: re-report (idempotent); the answer says whether
|
|
||||||
// the server is still holding the request open.
|
|
||||||
reportAgentTargetUnresolvable(msg, probe.error || lastError);
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleAgentTarget(msg) {
|
|
||||||
if (!msg || typeof msg.targetId !== 'string') return;
|
|
||||||
if (agentTargetTaken(msg.targetId)) return;
|
|
||||||
noteAgentTarget(msg.targetId, 'heard');
|
|
||||||
const busy = agentTargetBusyReason(msg.targetId);
|
|
||||||
if (busy) {
|
|
||||||
// Roll call: a busy tab reports itself and never acts. The server
|
|
||||||
// answers `busy` the moment every connected overlay has reported, so
|
|
||||||
// an idle tab elsewhere is never raced by a timer.
|
|
||||||
declineAgentTargetBusy(msg, busy);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (declineAgentTargetUnresolvable(msg)) return;
|
|
||||||
// Eligible tabs race for the server's lease and only the holder acts. A
|
|
||||||
// hidden tab yields a short head start so a visible one wins when both
|
|
||||||
// exist, and still serves the request on its own: the user finds the
|
|
||||||
// selection waiting when they return to it.
|
|
||||||
setTimeout(() => claimAndActOnAgentTarget(msg), document.hidden ? 150 : 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
function actOnAgentTarget(msg) {
|
|
||||||
if (agentTargetOverlayGone()) return;
|
|
||||||
// Every exit ends this tab's acting state, so a later target is not
|
|
||||||
// refused for a Go that already happened or never will.
|
|
||||||
const reply = (result) => { noteAgentTarget(msg.targetId, 'done'); postAgentTargetResult(msg.targetId, result); };
|
|
||||||
const busy = agentTargetBusyReason(msg.targetId);
|
|
||||||
if (busy) {
|
|
||||||
// Turned busy between claim and act: report it, which also hands the
|
|
||||||
// lease back so the roll call can complete or a rescuer can claim.
|
|
||||||
declineAgentTargetBusy(msg, busy);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const resolved = resolveAgentTargetElement(msg);
|
|
||||||
if (resolved.error) {
|
|
||||||
// The element went away between claim and act. A result would end the
|
|
||||||
// request for every tab; a decline hands the lease back so another
|
|
||||||
// page or a remount can still serve it.
|
|
||||||
reportAgentTargetUnresolvable(msg, resolved.error);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const el = resolved.el;
|
|
||||||
if (msg.dryRun) {
|
|
||||||
reply({
|
|
||||||
ok: true,
|
|
||||||
dryRun: true,
|
|
||||||
matchCount: resolved.matchCount,
|
|
||||||
element: describeAgentTargetCandidate(el),
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
scrollAgentTargetIntoView(el, () => {
|
|
||||||
// Torn down during the scroll settle: do not renew. The lease lapses
|
|
||||||
// for a rescuer instead of Go minting a session on a dismantled
|
|
||||||
// overlay.
|
|
||||||
if (agentTargetOverlayGone()) return;
|
|
||||||
// Renew the lease right before the irreversible part: a tab whose
|
|
||||||
// lease lapsed while it scrolled (a rescuer took over) stops here, so
|
|
||||||
// one request never gets two Go presses.
|
|
||||||
claimAgentTarget(msg.targetId, { eligible: true }).then((renewal) => {
|
|
||||||
if (!renewal.granted) { noteAgentTarget(msg.targetId, 'done'); return; }
|
|
||||||
// An insert placement left mid-configure gives way, exactly as a
|
|
||||||
// click outside it does in handleClick.
|
|
||||||
if (state === 'CONFIGURING' && configureKind === 'insert') cancelInsertConfigure();
|
|
||||||
// Mirror of the user-click pick entry in handleClick, minus the
|
|
||||||
// pick-mode gate (the agent's intent replaces the toggle); the entry
|
|
||||||
// goes through beginNewLiveConfiguration like every other pick so
|
|
||||||
// deferred recovery sees a fresh interaction revision.
|
|
||||||
selectedElement = el;
|
|
||||||
beginNewLiveConfiguration();
|
|
||||||
showHighlight(selectedElement);
|
|
||||||
clearAnnotations();
|
|
||||||
showAnnotOverlay(selectedElement);
|
|
||||||
showBar('configure');
|
|
||||||
editBadgeSuppressed = true;
|
|
||||||
renderEditBadge('hidden');
|
|
||||||
startScrollTracking();
|
|
||||||
maybePrefetchPage();
|
|
||||||
maybeWarnConditionalAncestor(selectedElement);
|
|
||||||
// Preset what the agent asked for, then fire the same Go a user press
|
|
||||||
// fires. handleGo reads exactly these inputs.
|
|
||||||
selectedAction = msg.action;
|
|
||||||
selectedCount = msg.count;
|
|
||||||
// updateBarContent rebuilds the configure row and replaces the input
|
|
||||||
// element, so the prompt must be written into the input it creates,
|
|
||||||
// never before (the action-chip click handler does the same dance).
|
|
||||||
updateBarContent('configure');
|
|
||||||
const input = uiGetById(PREFIX + '-input');
|
|
||||||
if (input) input.value = msg.prompt || '';
|
|
||||||
// The target rides on the generate event too: the helper resolves
|
|
||||||
// the request from whichever lands first, so a page that dies
|
|
||||||
// between Go and its result cannot leave the request pending for a
|
|
||||||
// second Go elsewhere.
|
|
||||||
const candidate = describeAgentTargetCandidate(el);
|
|
||||||
agentTargetForGo = { targetId: msg.targetId, matchCount: resolved.matchCount, action: msg.action, count: msg.count, element: candidate };
|
|
||||||
handleGo();
|
|
||||||
agentTargetForGo = null;
|
|
||||||
if (state === 'GENERATING' && currentSessionId) {
|
|
||||||
reply({
|
|
||||||
ok: true,
|
|
||||||
matchCount: resolved.matchCount,
|
|
||||||
sessionId: currentSessionId,
|
|
||||||
action: msg.action,
|
|
||||||
count: msg.count,
|
|
||||||
element: candidate,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
reply({ ok: false, error: 'go_failed', state });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// SSE (server→browser) + fetch POST (browser→server)
|
// SSE (server→browser) + fetch POST (browser→server)
|
||||||
// Zero-dependency replacement for WebSocket.
|
// Zero-dependency replacement for WebSocket.
|
||||||
//
|
//
|
||||||
@@ -7538,7 +7121,7 @@
|
|||||||
const SSE_MAX_RETRIES = 20; // generous: heartbeats keep the connection alive, so retries mean real trouble
|
const SSE_MAX_RETRIES = 20; // generous: heartbeats keep the connection alive, so retries mean real trouble
|
||||||
|
|
||||||
function connectSSE() {
|
function connectSSE() {
|
||||||
evtSource = new EventSource('http://localhost:' + PORT + '/events?token=' + TOKEN + '&clientId=' + AGENT_TARGET_CLIENT_ID);
|
evtSource = new EventSource('http://localhost:' + PORT + '/events?token=' + TOKEN);
|
||||||
|
|
||||||
evtSource.onopen = () => {
|
evtSource.onopen = () => {
|
||||||
sseRetries = 0; // reset on successful (re)connect
|
sseRetries = 0; // reset on successful (re)connect
|
||||||
@@ -7549,11 +7132,8 @@
|
|||||||
let msg; try { msg = JSON.parse(e.data); } catch { return; }
|
let msg; try { msg = JSON.parse(e.data); } catch { return; }
|
||||||
switch (msg.type) {
|
switch (msg.type) {
|
||||||
case 'connected':
|
case 'connected':
|
||||||
applyLiveBarPreference(msg.hideLiveBar === true);
|
|
||||||
hasProjectContext = !!msg.hasProjectContext;
|
hasProjectContext = !!msg.hasProjectContext;
|
||||||
// The generate lane runs without PRODUCT.md by design and never
|
if (!hasProjectContext) showToast(`No PRODUCT.md found. Variants will be brand-agnostic. Run ${IMPECCABLE_COMMAND} init to generate one.`, 7000);
|
||||||
// sends the user to init, so its quiet chrome skips this notice.
|
|
||||||
if (!hasProjectContext && !liveBarHiddenByHelper) showToast(`No PRODUCT.md found. Variants will be brand-agnostic. Run ${IMPECCABLE_COMMAND} init to generate one.`, 7000);
|
|
||||||
console.log('[impeccable] Live mode connected.');
|
console.log('[impeccable] Live mode connected.');
|
||||||
syncAgentPollingUi(!!msg.agentPolling);
|
syncAgentPollingUi(!!msg.agentPolling);
|
||||||
startAgentStatusPoll();
|
startAgentStatusPoll();
|
||||||
@@ -7563,15 +7143,9 @@
|
|||||||
syncPageInteractionCursor();
|
syncPageInteractionCursor();
|
||||||
syncPageChatFocus('sse-connected');
|
syncPageChatFocus('sse-connected');
|
||||||
break;
|
break;
|
||||||
case 'live_bar':
|
|
||||||
applyLiveBarPreference(msg.hidden === true);
|
|
||||||
break;
|
|
||||||
case 'agent_polling':
|
case 'agent_polling':
|
||||||
syncAgentPollingUi(!!msg.connected);
|
syncAgentPollingUi(!!msg.connected);
|
||||||
break;
|
break;
|
||||||
case 'agent_target':
|
|
||||||
handleAgentTarget(msg);
|
|
||||||
break;
|
|
||||||
case 'agent_phase':
|
case 'agent_phase':
|
||||||
if (msg.id === currentSessionId && (state === 'GENERATING' || state === 'CYCLING')) {
|
if (msg.id === currentSessionId && (state === 'GENERATING' || state === 'CYCLING')) {
|
||||||
// Advance the visible phase monotonically. A behind/resumed
|
// Advance the visible phase monotonically. A behind/resumed
|
||||||
@@ -7634,11 +7208,6 @@
|
|||||||
disableInlineEdit();
|
disableInlineEdit();
|
||||||
refreshParamsPanel();
|
refreshParamsPanel();
|
||||||
}
|
}
|
||||||
// The done reply is the agent's last word on this generation:
|
|
||||||
// with every variant mounted and no knobs declared, the Tune
|
|
||||||
// chip must stop spinning. A reload between the mount and this
|
|
||||||
// reply restored the pending state from the cache.
|
|
||||||
completeParameterGenerationIfReady();
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
// Source fallback when HMR did not land variants in this tab.
|
// Source fallback when HMR did not land variants in this tab.
|
||||||
@@ -7802,15 +7371,6 @@
|
|||||||
}).then(async res => {
|
}).then(async res => {
|
||||||
if (res.ok) return res;
|
if (res.ok) return res;
|
||||||
const body = await res.json().catch(() => ({}));
|
const body = await res.json().catch(() => ({}));
|
||||||
// The helper refused to open a session for an agent target it has
|
|
||||||
// already answered (another page served it after this page's lease
|
|
||||||
// lapsed mid-capture, or the request timed out): drop the local
|
|
||||||
// session and hand the surface back.
|
|
||||||
if (body.error === 'agent_target_already_served' && msg.type === 'generate'
|
|
||||||
&& msg.id && msg.id === currentSessionId) {
|
|
||||||
abandonSupersededGo(msg.id);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
// The server refused to journal progress for a session it has never
|
// The server refused to journal progress for a session it has never
|
||||||
// seen: this browser is carrying state from another project or a
|
// seen: this browser is carrying state from another project or a
|
||||||
// wiped store (two apps sharing a localhost port). Continuing to
|
// wiped store (two apps sharing a localhost port). Continuing to
|
||||||
@@ -7832,14 +7392,6 @@
|
|||||||
return sessionCreationGate.then(doSend);
|
return sessionCreationGate.then(doSend);
|
||||||
}
|
}
|
||||||
|
|
||||||
function abandonSupersededGo(sessionId) {
|
|
||||||
if (sessionId !== currentSessionId) return;
|
|
||||||
console.warn('[impeccable] The helper already answered this agent target; clearing session ' + sessionId + '.');
|
|
||||||
markSessionHandled();
|
|
||||||
cleanup({ instantChrome: true });
|
|
||||||
showToast('The helper already answered this request, so this session was cleared. Pick an element to start fresh.', 6000);
|
|
||||||
}
|
|
||||||
|
|
||||||
let abandonedForeignSessionId = null;
|
let abandonedForeignSessionId = null;
|
||||||
function abandonForeignSession(sessionId) {
|
function abandonForeignSession(sessionId) {
|
||||||
if (abandonedForeignSessionId === sessionId || sessionId !== currentSessionId) return;
|
if (abandonedForeignSessionId === sessionId || sessionId !== currentSessionId) return;
|
||||||
@@ -8244,7 +7796,6 @@
|
|||||||
visibleVariant = 0;
|
visibleVariant = 0;
|
||||||
generationPhase = 'queued';
|
generationPhase = 'queued';
|
||||||
parameterGenerationState = 'pending';
|
parameterGenerationState = 'pending';
|
||||||
sessionOrigin = agentTargetForGo ? 'agent' : null;
|
|
||||||
parameterReadyAnnouncedSession = null;
|
parameterReadyAnnouncedSession = null;
|
||||||
resetSessionFileMeta();
|
resetSessionFileMeta();
|
||||||
|
|
||||||
@@ -8270,24 +7821,6 @@
|
|||||||
};
|
};
|
||||||
if (snapshot.comments.length > 0) basePayload.comments = snapshot.comments;
|
if (snapshot.comments.length > 0) basePayload.comments = snapshot.comments;
|
||||||
if (snapshot.strokes.length > 0) basePayload.strokes = snapshot.strokes;
|
if (snapshot.strokes.length > 0) basePayload.strokes = snapshot.strokes;
|
||||||
if (agentTargetForGo) {
|
|
||||||
// An agent-initiated Go names the target it serves (see
|
|
||||||
// actOnAgentTarget): the helper resolves that request from this event
|
|
||||||
// as well as from the overlay's own result post.
|
|
||||||
basePayload.agentTarget = {
|
|
||||||
targetId: agentTargetForGo.targetId,
|
|
||||||
clientId: AGENT_TARGET_CLIENT_ID,
|
|
||||||
result: {
|
|
||||||
ok: true,
|
|
||||||
matchCount: agentTargetForGo.matchCount,
|
|
||||||
sessionId: currentSessionId,
|
|
||||||
action: agentTargetForGo.action,
|
|
||||||
count: agentTargetForGo.count,
|
|
||||||
element: agentTargetForGo.element,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
agentTargetForGo = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Hide the interactive overlay so it doesn't linger during generation.
|
// Hide the interactive overlay so it doesn't linger during generation.
|
||||||
hideAnnotOverlay();
|
hideAnnotOverlay();
|
||||||
@@ -8348,7 +7881,6 @@
|
|||||||
visibleVariant = 0;
|
visibleVariant = 0;
|
||||||
generationPhase = 'queued';
|
generationPhase = 'queued';
|
||||||
parameterGenerationState = 'pending';
|
parameterGenerationState = 'pending';
|
||||||
sessionOrigin = agentTargetForGo ? 'agent' : null;
|
|
||||||
parameterReadyAnnouncedSession = null;
|
parameterReadyAnnouncedSession = null;
|
||||||
resetSessionFileMeta();
|
resetSessionFileMeta();
|
||||||
selectedElement = placeholderElement;
|
selectedElement = placeholderElement;
|
||||||
@@ -9395,8 +8927,6 @@ void main() {
|
|||||||
pagePickSkipClick = false;
|
pagePickSkipClick = false;
|
||||||
currentSessionId = null;
|
currentSessionId = null;
|
||||||
parameterGenerationState = 'idle';
|
parameterGenerationState = 'idle';
|
||||||
sessionOrigin = null;
|
|
||||||
editBadgeSuppressed = false;
|
|
||||||
parameterReadyAnnouncedSession = null;
|
parameterReadyAnnouncedSession = null;
|
||||||
selectedAction = 'impeccable';
|
selectedAction = 'impeccable';
|
||||||
pendingAcceptedSession = null;
|
pendingAcceptedSession = null;
|
||||||
@@ -9488,7 +9018,6 @@ void main() {
|
|||||||
paramsCurrentValues = { ...saved.paramValues };
|
paramsCurrentValues = { ...saved.paramValues };
|
||||||
}
|
}
|
||||||
if (saved.parameterState) parameterGenerationState = saved.parameterState;
|
if (saved.parameterState) parameterGenerationState = saved.parameterState;
|
||||||
sessionOrigin = saved.origin === 'agent' ? 'agent' : null;
|
|
||||||
if (saved.generationPhase) generationPhase = saved.generationPhase;
|
if (saved.generationPhase) generationPhase = saved.generationPhase;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -9576,12 +9105,7 @@ void main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function restoreSessionWithoutWrapper(reason, activeSessions) {
|
function restoreSessionWithoutWrapper(reason, activeSessions) {
|
||||||
// The session cache is per origin, so a tab on another page of the same
|
const cached = loadSession();
|
||||||
// app sees this page's session too. Only the page that saved it may
|
|
||||||
// resume it: the server-adoption branch below already applies the same
|
|
||||||
// check, and a tab on another page has nothing to render for it.
|
|
||||||
const cachedRaw = loadSession();
|
|
||||||
const cached = cachedRaw?.id && !pageMatchesCurrent(cachedRaw.pageUrl) ? null : cachedRaw;
|
|
||||||
// localStorage is a cache, not a gate. A cleared tab, a second browser
|
// localStorage is a cache, not a gate. A cleared tab, a second browser
|
||||||
// profile, or a teardown that dropped local state all leave the durable
|
// profile, or a teardown that dropped local state all leave the durable
|
||||||
// server session as the only record of work in progress; adopt it instead
|
// server session as the only record of work in progress; adopt it instead
|
||||||
@@ -9694,7 +9218,6 @@ void main() {
|
|||||||
pageUrl: location.pathname,
|
pageUrl: location.pathname,
|
||||||
paramValues: { ...paramsCurrentValues },
|
paramValues: { ...paramsCurrentValues },
|
||||||
parameterState: parameterGenerationState,
|
parameterState: parameterGenerationState,
|
||||||
origin: sessionOrigin || undefined,
|
|
||||||
insertPlaceholder: insertPlaceholderSnapshot || undefined,
|
insertPlaceholder: insertPlaceholderSnapshot || undefined,
|
||||||
pickedAnchor: pickedAnchorSnapshot || undefined,
|
pickedAnchor: pickedAnchorSnapshot || undefined,
|
||||||
pickedAnchorViewportTop: Number.isFinite(pickedAnchorViewportTop) ? pickedAnchorViewportTop : undefined,
|
pickedAnchorViewportTop: Number.isFinite(pickedAnchorViewportTop) ? pickedAnchorViewportTop : undefined,
|
||||||
@@ -9820,8 +9343,6 @@ void main() {
|
|||||||
pagePickSkipClick = false;
|
pagePickSkipClick = false;
|
||||||
currentSessionId = null;
|
currentSessionId = null;
|
||||||
parameterGenerationState = 'idle';
|
parameterGenerationState = 'idle';
|
||||||
sessionOrigin = null;
|
|
||||||
editBadgeSuppressed = false;
|
|
||||||
parameterReadyAnnouncedSession = null;
|
parameterReadyAnnouncedSession = null;
|
||||||
selectedAction = 'impeccable';
|
selectedAction = 'impeccable';
|
||||||
renderEditBadge('hidden');
|
renderEditBadge('hidden');
|
||||||
@@ -10080,14 +9601,6 @@ void main() {
|
|||||||
|
|
||||||
const resumedState = arrivedVariants > 0 ? 'CYCLING' : 'GENERATING';
|
const resumedState = arrivedVariants > 0 ? 'CYCLING' : 'GENERATING';
|
||||||
|
|
||||||
// A reload between the variants mounting and the agent's done reply
|
|
||||||
// restores a pending Tune state from the cache; the helper knows whether
|
|
||||||
// that generation already finished.
|
|
||||||
if (arrivedVariants >= expectedVariants && expectedVariants > 0
|
|
||||||
&& (parameterGenerationState === 'pending' || parameterGenerationState === 'loading')) {
|
|
||||||
settleParameterStateFromHelper(sessionId);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find the visible variant's content element for highlight positioning.
|
// Find the visible variant's content element for highlight positioning.
|
||||||
const isInsert = wrapper.dataset.impeccableMode === 'insert';
|
const isInsert = wrapper.dataset.impeccableMode === 'insert';
|
||||||
const visEl = visibleVariant > 0 ? pickVariantContent(wrapper, visibleVariant) : null;
|
const visEl = visibleVariant > 0 ? pickVariantContent(wrapper, visibleVariant) : null;
|
||||||
@@ -11552,21 +11065,6 @@ void main() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// After a resume the cache may say the Tune knobs are still coming while
|
|
||||||
// the agent already replied done before the reload. The helper's session
|
|
||||||
// record settles it; otherwise the done reply on SSE does.
|
|
||||||
function settleParameterStateFromHelper(sessionId) {
|
|
||||||
fetch('http://localhost:' + PORT + '/status?token=' + TOKEN, { cache: 'no-store' })
|
|
||||||
.then((res) => (res.ok ? res.json() : null))
|
|
||||||
.then((data) => {
|
|
||||||
if (!data || sessionId !== currentSessionId) return;
|
|
||||||
const session = (data.activeSessions || []).find((s) => s && s.id === sessionId);
|
|
||||||
if (!session) return;
|
|
||||||
if (session.generationCompletedAt || session.generationPhase === 'completed') completeParameterGenerationIfReady();
|
|
||||||
})
|
|
||||||
.catch(() => { /* the done reply on SSE settles it otherwise */ });
|
|
||||||
}
|
|
||||||
|
|
||||||
function fetchAgentPollingStatus() {
|
function fetchAgentPollingStatus() {
|
||||||
fetch('http://localhost:' + PORT + '/status?token=' + TOKEN, { cache: 'no-store' })
|
fetch('http://localhost:' + PORT + '/status?token=' + TOKEN, { cache: 'no-store' })
|
||||||
.then((res) => (res.ok ? res.json() : null))
|
.then((res) => (res.ok ? res.json() : null))
|
||||||
@@ -11606,15 +11104,11 @@ void main() {
|
|||||||
uiAppendStyle(s);
|
uiAppendStyle(s);
|
||||||
}
|
}
|
||||||
|
|
||||||
// The generate lane's helper says so in the served script itself, so a
|
|
||||||
// lane session never draws the bar at all; every other session mounts
|
|
||||||
// it exactly as before.
|
|
||||||
const barHiddenFromStart = window.__IMPECCABLE_LIVE_BAR_HIDDEN__ === true;
|
|
||||||
globalBarEl = el('div', {
|
globalBarEl = el('div', {
|
||||||
position: 'fixed', bottom: '14px', left: '50%',
|
position: 'fixed', bottom: '14px', left: '50%',
|
||||||
transform: 'translateX(-50%) translateY(20px)',
|
transform: 'translateX(-50%) translateY(20px)',
|
||||||
zIndex: Z.bar + 5,
|
zIndex: Z.bar + 5,
|
||||||
display: barHiddenFromStart ? 'none' : 'flex', alignItems: 'stretch',
|
display: 'flex', alignItems: 'stretch',
|
||||||
gap: '0',
|
gap: '0',
|
||||||
width: 'max-content',
|
width: 'max-content',
|
||||||
background: P.surface,
|
background: P.surface,
|
||||||
@@ -11630,10 +11124,6 @@ void main() {
|
|||||||
});
|
});
|
||||||
globalBarEl.id = PREFIX + '-global-bar';
|
globalBarEl.id = PREFIX + '-global-bar';
|
||||||
globalBarEl.dataset.theme = theme;
|
globalBarEl.dataset.theme = theme;
|
||||||
if (barHiddenFromStart) {
|
|
||||||
liveBarHiddenByHelper = true;
|
|
||||||
globalBarEl.dataset.liveBarDisplay = 'flex';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Brand mark - kinpaku Impeccable icon (site header / favicon paths).
|
// Brand mark - kinpaku Impeccable icon (site header / favicon paths).
|
||||||
const brand = el('span', {
|
const brand = el('span', {
|
||||||
@@ -12029,9 +11519,6 @@ void main() {
|
|||||||
// Listen for detection results AND ready signal
|
// Listen for detection results AND ready signal
|
||||||
window.addEventListener('message', onDetectMessage);
|
window.addEventListener('message', onDetectMessage);
|
||||||
updateGlobalBarState();
|
updateGlobalBarState();
|
||||||
// The helper may already have said the bar stays hidden (a connect
|
|
||||||
// that raced the bar build, or a reload mid-lane): re-apply it here.
|
|
||||||
if (liveBarHiddenByHelper) setLiveBarHidden(true);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateGlobalBarState() {
|
function updateGlobalBarState() {
|
||||||
@@ -12228,13 +11715,6 @@ void main() {
|
|||||||
|
|
||||||
/** Full teardown: remove all UI, disconnect SSE, clean up. */
|
/** Full teardown: remove all UI, disconnect SSE, clean up. */
|
||||||
function teardown() {
|
function teardown() {
|
||||||
// Declined targets die with the overlay: the IDLE transition below must
|
|
||||||
// not re-claim a lease this page can no longer act on. So does the
|
|
||||||
// target ledger: an 'acting' entry from a Go that never happened must
|
|
||||||
// not refuse every target the next connection hears.
|
|
||||||
busyDeclinedTargets.clear();
|
|
||||||
agentTargetsSeen.clear();
|
|
||||||
liveBarHiddenByHelper = false;
|
|
||||||
stopAgentStatusPoll();
|
stopAgentStatusPoll();
|
||||||
hideAgentPollTooltip();
|
hideAgentPollTooltip();
|
||||||
if (agentPollTooltipEl) {
|
if (agentPollTooltipEl) {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
name: impeccable
|
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.
|
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.
|
||||||
metadata:
|
metadata:
|
||||||
version: 4.3.1
|
version: 4.3.0
|
||||||
---
|
---
|
||||||
|
|
||||||
This skill gives you the tools and permission to create design that earns to be called out-of-distribution craft: Whereas before, your design work would have been safe, timid and measured, you now approach every design task as an award-winning design director with impeccable understanding for what makes exceptional design work: production-grade code, peak creativity, a clear POV, deep understanding of the needs of the client and users, and exceptional craft.
|
This skill gives you the tools and permission to create design that earns to be called out-of-distribution craft: Whereas before, your design work would have been safe, timid and measured, you now approach every design task as an award-winning design director with impeccable understanding for what makes exceptional design work: production-grade code, peak creativity, a clear POV, deep understanding of the needs of the client and users, and exceptional craft.
|
||||||
@@ -63,8 +63,7 @@ Choose the mode from the requested surface, not the product, and persist it only
|
|||||||
| `clarify [target]` | Fix | Improve UX copy, labels, and error messages | [reference/clarify.md](reference/clarify.md) |
|
| `clarify [target]` | Fix | Improve UX copy, labels, and error messages | [reference/clarify.md](reference/clarify.md) |
|
||||||
| `adapt [target]` | Fix | Adapt for different devices and screen sizes | [reference/adapt.md](reference/adapt.md) · native: [reference/adapt.native.md](reference/adapt.native.md) |
|
| `adapt [target]` | Fix | Adapt for different devices and screen sizes | [reference/adapt.md](reference/adapt.md) · native: [reference/adapt.native.md](reference/adapt.native.md) |
|
||||||
| `optimize [target]` | Fix | Diagnose and fix UI performance | [reference/optimize.md](reference/optimize.md) |
|
| `optimize [target]` | Fix | Diagnose and fix UI performance | [reference/optimize.md](reference/optimize.md) |
|
||||||
| `live` | Iterate | Visual variant mode: pick elements in the browser, iterate on alternatives | [reference/live.md](reference/live.md) |
|
| `live` | Iterate | Visual variant mode: pick elements in the browser, generate alternatives | [reference/live.md](reference/live.md) |
|
||||||
| `generate [n] [action] [element]` | Iterate | Variants, versions, or alternatives of a named element to choose from in the live browser; no manual picking | [reference/generate.md](reference/generate.md) |
|
|
||||||
|
|
||||||
Routing:
|
Routing:
|
||||||
|
|
||||||
|
|||||||
@@ -188,12 +188,6 @@ Test thoroughly across contexts:
|
|||||||
- **Edge cases**: Very small screens (320px), very large screens (4K)
|
- **Edge cases**: Very small screens (320px), very large screens (4K)
|
||||||
- **Slow connections**: Test on throttled network
|
- **Slow connections**: Test on throttled network
|
||||||
|
|
||||||
**Custom controls** (sliders, drag surfaces, scrollable control strips): a before/after slider can pass every width check above and still refuse to drag on iOS, so exercise each one in scope in the same batched round as the checks above:
|
|
||||||
|
|
||||||
- **Primary gesture**: Tap it and confirm it responds as designed, then drag it with the target input method; the drag must complete, not just start
|
|
||||||
- **Scroll across it**: A swipe along the page's scroll axis across the control scrolls the page or container without activating it; a drag that starts on the control along its axis moves the control, not the page. Neither failure throws an error, so try both
|
|
||||||
- **Evidence**: Say what produced the evidence: an emulated viewport, synthesized touch input through a browser tool, which engine ran it (Chromium is not Safari), or a physical device. Screenshots and resized viewports verify layout, never a gesture. Name what stayed untested and move on; unreachable hardware is a reported gap, not a blocker
|
|
||||||
|
|
||||||
When the adaptation feels native to each context, hand off to `$impeccable polish` for the final pass.
|
When the adaptation feels native to each context, hand off to `$impeccable polish` for the final pass.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -48,12 +48,11 @@ Run comprehensive checks across 5 dimensions. Score each dimension 0-4 using the
|
|||||||
**Check for**:
|
**Check for**:
|
||||||
- **Fixed widths**: Hard-coded widths that break on mobile
|
- **Fixed widths**: Hard-coded widths that break on mobile
|
||||||
- **Touch targets**: Interactive elements < 44x44px
|
- **Touch targets**: Interactive elements < 44x44px
|
||||||
- **Broken touch interaction**: Custom sliders, drag surfaces, and scrollable control strips whose primary gesture fails under touch, that swallow page scroll or lose the drag to it, or that stay stuck after an interrupted gesture. Code tells: mouse-only handlers, no `touch-action` on a pointer-event drag surface, drag state that nothing clears on cancel, lost capture, or blur. Exercise the gesture when a browser tool can synthesize touch (a rendered viewport proves layout, not the gesture), then say what produced the evidence (emulated viewport, synthesized touch, which engine, physical device) and what stayed untested
|
|
||||||
- **Horizontal scroll**: Content overflow on narrow viewports
|
- **Horizontal scroll**: Content overflow on narrow viewports
|
||||||
- **Text scaling**: Layouts that break when text size increases
|
- **Text scaling**: Layouts that break when text size increases
|
||||||
- **Missing breakpoints**: No mobile/tablet variants
|
- **Missing breakpoints**: No mobile/tablet variants
|
||||||
|
|
||||||
**Score 0-4**: 0=Desktop-only (breaks on mobile), 1=Major issues (some breakpoints, many failures), 2=Partial (works on mobile, rough edges), 3=Good (responsive, minor touch target or overflow issues), 4=Excellent (fluid, all viewports, proper touch targets, gestures work under touch)
|
**Score 0-4**: 0=Desktop-only (breaks on mobile), 1=Major issues (some breakpoints, many failures), 2=Partial (works on mobile, rough edges), 3=Good (responsive, minor touch target or overflow issues), 4=Excellent (fluid, all viewports, proper touch targets)
|
||||||
|
|
||||||
### 5. Implementation Integrity (CRITICAL)
|
### 5. Implementation Integrity (CRITICAL)
|
||||||
|
|
||||||
|
|||||||
@@ -1,101 +0,0 @@
|
|||||||
> **Additional context needed**: only the target element, when the request does not name one that resolves uniquely on the page.
|
|
||||||
|
|
||||||
Generate is the fast lane into live mode: the user names an element, a direction, and a count in one sentence, and within a minute they are cycling through variants in their browser. One command boots the helper, hands the element to the overlay in the page your harness already shows (it scrolls to it, selects it, and fires the same Go a click fires) and returns the generate event; one edit writes the variants; one call replies and waits for the user's choice, which the helper bakes into source itself. This file owns the lane's plumbing; from the event onward the design work is [live.md](live.md)'s, unchanged, so read it in full now if you have not this session.
|
|
||||||
|
|
||||||
**Web only.** Live mode's browser overlay has no native equivalent; on `ios` / `android` / `adaptive` projects, decline this command and offer `bolder` or `quieter` on the source instead.
|
|
||||||
|
|
||||||
The plumbing is where the lane saves time: one command starts the session around the page your harness already shows, one call replies and waits, and nothing here is a browser you have to babysit. The design work is not where it saves time. Setup runs as for any command (`impeccable context`, this reference, craft-floor.md before the edit), and the variants are planned, written, and accepted exactly the way a live session plans, writes, and accepts them.
|
|
||||||
|
|
||||||
Three prohibitions cover the known ways this command goes wrong:
|
|
||||||
|
|
||||||
- **Never run init or document, and never ask for PRODUCT.md or DESIGN.md.** When they exist, the start command prints them under `boot` and you use them. When they do not, it says so (`contextMissing`, `contextNote`) and you extract the identity from the event (Step 3). A missing file is never a reason to interview the user inside this command; offer `init` in one line after the session ends.
|
|
||||||
- **Never hand-write a variants wrapper or invent a session id.** Only the browser mints session ids (8 hex characters, at Go). A missing event is fixed by rerunning Step 2, never with a direct source edit.
|
|
||||||
- **Do not act on hook findings while live markers are in the file**, and do not restyle variants to appease them; the accept verifies the file once the variant is permanent.
|
|
||||||
|
|
||||||
## Step 1: Parse the request
|
|
||||||
|
|
||||||
Three parts, all from the user's sentence:
|
|
||||||
|
|
||||||
- **A number in the request**: that is the count. **No number**: 3. The protocol caps count at 8.
|
|
||||||
- **The direction wording** maps onto the live action vocabulary; never invent a new action value:
|
|
||||||
- **bold, bolder, stronger, punchier**: `bolder`
|
|
||||||
- **quiet, calmer, softer, toned down**: `quieter`
|
|
||||||
- **simpler, minimal, stripped**: `distill`
|
|
||||||
- **refined, tightened, polished**: `polish`
|
|
||||||
- **font and type words**: `typeset`
|
|
||||||
- **color words**: `colorize`
|
|
||||||
- **arrangement and spacing words**: `layout`
|
|
||||||
- **device and breakpoint words**: `adapt`
|
|
||||||
- **motion words**: `animate`
|
|
||||||
- **playful words**: `delight`
|
|
||||||
- **rule-breaking words**: `overdrive`
|
|
||||||
- **Wording that carries intent but no vocabulary word** ("make it feel like a bank", "warmer", "more premium"): `impeccable`, with the user's wording passed as the prompt.
|
|
||||||
- **An action fits AND extra intent rides along** ("bolder, but keep it monochrome"): that action, with the rest as the prompt.
|
|
||||||
- **The wording names no direction at all** ("better", "improve", "nicer", "different", "fresh", "new", "redesign", "fix", "some options", "ideas", "alternatives", or just "variants" with nothing else): STOP and use Codex's structured user-input/question tool when available; if unavailable, ask directly in chat to clarify what you cannot infer. Ask one question, offering the vocabulary: *"Which direction should the variants take? bolder, quieter, simpler (distill), polished, typography (typeset), color (colorize), layout, motion (animate), playful (delight), or rule-breaking (overdrive)."* Map the answer with this list; an answer that is still open ("surprise me", "you pick") is `impeccable` with the user's original wording as the prompt, and Step 2 starts on that answer.
|
|
||||||
- **The element description** ("the pricing cards", "the hero heading"): Step 2 resolves it to a selector.
|
|
||||||
|
|
||||||
Done when you hold an action from the vocabulary (asked for, when the request named no direction), a count from 1 to 8, and the element description.
|
|
||||||
|
|
||||||
## Step 2: Reuse the page, then start
|
|
||||||
|
|
||||||
**Reuse** the dev server already running and the tab your harness already shows it in; a second server or a second browser window is the failure this step prevents.
|
|
||||||
|
|
||||||
1. **Find the dev server**, cheapest source first, and stop at the first hit: the user's message, a browser tab already on the app (Claude Code: an origin in `tabs_context`), a server your harness started (Claude Code: `preview_list`), a terminal that printed its URL. Its origin is your `--dev-url`. **No hit**: leave `--dev-url` off and run the start command with no wait; the boot probes for a running server and its verdict names the move. `browser_needed` carries the `devUrl` it found: open it as in 2, then rerun with `--dev-url <devUrl> --wait-for-browser 60000`. `no_dev_server` means nothing serves the app: start the dev script the way the verdict says (Claude Code: `preview_start`; Cursor: a background terminal; Codex: an exec you yield from), wait for its URL, then rerun with `--dev-url <url>`.
|
|
||||||
2. **Open the page that renders the element in your browser, then start.** The route the request names, else the one `--target` serves; `--dev-url` takes only the origin.
|
|
||||||
- **Cursor** (`browser_navigate`) and **Claude Code** (`navigate`, which opens the Browser pane when it is closed and takes the `tabId` from `tabs_context` when a tab is already on that origin): open the URL, then run the start command with `--dev-url <url> --wait-for-browser 60000`. The boot injects the overlay and the page reloads into it while the command waits. Your browser tool is the only opener on these harnesses; the engine ignores `--open` there.
|
|
||||||
- **No browser tool** (Codex, others): run the start command with `--open --wait-for-browser 120000`; it opens the system browser, and the longer wait covers the user finding the tab. **`browser_open_failed` back**: tell the user the `url` in one line and rerun with `--wait-for-browser 120000`.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
.agents/skills/impeccable/scripts/impeccable live-generate --target src/App.jsx --dev-url http://127.0.0.1:5173/ --selector ".pricing-grid" --action bolder --count 3 --boot --wait-for-browser 60000
|
|
||||||
```
|
|
||||||
|
|
||||||
Run it in the foreground in Cursor and Claude Code (it returns within the wait); on Codex, in an exec you yield from, the way Step 3 runs the poll.
|
|
||||||
|
|
||||||
- `--target`: the file that renders the element when the request or the project makes it obvious; skip it otherwise.
|
|
||||||
- `--dev-url`: the origin from 1; omit it and the boot probes.
|
|
||||||
- `--selector`: a unique class first, then a landmark tag plus class, an id last (every variant mounts a copy of the element, so an id repeats in the DOM). **The request names a repeated component in plural** ("the pricing cards"): target the container that holds the set, so one scoped stylesheet restyles every instance. One read of the source file that renders the element is allowed when the selector is not obvious; `--dry-run` resolves and reports without starting anything when it is not certain.
|
|
||||||
- `--boot`: runs the lane's boot (PRODUCT.md and DESIGN.md loaded again for the helper, missing files tolerated, dev URL found, bottom bar hidden for the helper's lifetime) and reuses a helper that is already running. Its result rides along as `boot`.
|
|
||||||
- Also available: `--prompt`, `--text` (keep only matches whose visible text contains a snippet), `--index` (1-based pick among matches).
|
|
||||||
|
|
||||||
Read the output in this order: `boot` (or `boot.contextMissing` with `boot.contextNote`: the page is the source of truth, per the note), then `event`, the generate event for `sessionId`, with the same `_instructions` a user's Go gets. Every verdict carries `_instructions`, and they win over your recollection of this file; the ones whose move is a decision of yours:
|
|
||||||
|
|
||||||
- **`ambiguous`**: the candidates are listed; target their common container, or rerun with `--text "<visible text>"` or `--index <n>`.
|
|
||||||
- **`dev_server_gone`**: the dev server stopped answering while the command waited for the page (on Cursor, a server another chat started dies with that chat). Start it the way the verdict says, then rerun with `--dev-url <url>`.
|
|
||||||
- **`no_match`**: the tab is on a route that does not render the element (navigate to the right route, rerun), or the selector is wrong (derive a better one from the source, or add `--text`).
|
|
||||||
- **`config_missing` / `config_invalid`** under `bootError`: follow [live-setup.md](live-setup.md) first, then rerun.
|
|
||||||
- **`event: null`** with `ok: true`: the event was slower than the wait; run `.agents/skills/impeccable/scripts/impeccable live-poll` once to collect it, then continue.
|
|
||||||
|
|
||||||
Done when the output shows `ok: true`, a `sessionId`, and an `event`, reached with at most one server started and one tab opened by you.
|
|
||||||
|
|
||||||
## Step 3: Generate
|
|
||||||
|
|
||||||
The event is a standard `generate` event: the picked element's context, a preflighted scaffold, and `_instructions` naming the action's reference, the planning section, and the exact splice. Handle it exactly per live.md's **Handle generate**, which owns everything from the identity lock to the done reply: read the action's reference and craft-floor.md as it says, plan per section 4 (identity first, then mode, then three different primary axes, then the squint test), declare knobs per section 7, and deliver per section 6 (a complete replacement of the element per variant, the preview CSS plus every variant in one edit at the scaffold's splice). The lane changes nothing about what a variant may be: the moves a live session would make on this element (a promoted tier, a restructured set, a reordered card, a different surface) are open here too. Never screenshot the page; the overlay preview is the review channel until accept.
|
|
||||||
|
|
||||||
**Reply and wait in one call**, with the file you wrote:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
.agents/skills/impeccable/scripts/impeccable live-poll --reply EVENT_ID done --file src/App.jsx --then-poll
|
|
||||||
```
|
|
||||||
|
|
||||||
This replies done (the browser mounts the variants) and then blocks until the user's choice arrives, so run it the way your harness runs a long wait: **Claude Code** in the foreground with your tool's longest timeout (600000 ms), so you are paused until the choice arrives; **Codex** in a yielded foreground exec; **Cursor** in a background terminal with notify on `"type":"(accept|discard|variant_mount_failed|exit)"`. Never pass a short `--timeout=`. While it runs there is nothing else to do: never sleep and never poll its output on a timer; a harness that backgrounds it wakes you when it returns. `{"type":"timeout"}` means the user has not chosen yet: run `live-poll` again and keep waiting. If the edit fails after the browser flipped to GENERATING, `--reply EVENT_ID error "Short reason"` (without `--then-poll`) so the bar resets.
|
|
||||||
|
|
||||||
Then tell the user, in one line, where their variants are: *"Three [bolder] variants are live on [the pricing cards]: cycle with the floating bar's arrows, adjust the Tune knobs, and Accept the keeper."*
|
|
||||||
|
|
||||||
Outside the replace path, read the matching live.md section before acting: `scaffold.previewMode: "svelte-component"` (Svelte previews are edited as components, and their accept is mechanical), `mode: "insert"`, `variant_mount_failed`, `steer`, `manual_edit_apply`, and any `fallback: "agent-driven"` wrap error.
|
|
||||||
|
|
||||||
## Step 4: Accept and close
|
|
||||||
|
|
||||||
The call from Step 3 returns the user's choice. **`discard`**: nothing to do. **`accept`**: `_acceptResult.carbonize: true` is the normal case, and the cleanup is live.md's **Required after accept**, unchanged: move the accepted variant's rules into the stylesheet that already owns the element with real selectors, bake the chosen knob values in, unwrap the element and drop every `data-impeccable-*` attribute, delete the inline `<style>` block and both `impeccable-carbonize` markers, then `.agents/skills/impeccable/scripts/impeccable live-complete --id SESSION_ID` and confirm `phase: "completed"`. (`baked: true` appears only when the accept was run with `--bake`; then the helper already made the variant permanent and no `live-complete` is owed.)
|
|
||||||
|
|
||||||
Close without being asked, the moment the choice is handled:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
.agents/skills/impeccable/scripts/impeccable live-server stop
|
|
||||||
```
|
|
||||||
|
|
||||||
Stopping removes the injected script and reloads the page once: the user sees the accepted design with no overlay chrome, still served by their dev server. **Never kill or restart the dev server**, including one you started in Step 2.
|
|
||||||
|
|
||||||
- **The user asks for more variants before you closed**: skip the close, run Step 2 again for the next element (the helper is reused), and close after the last choice.
|
|
||||||
- **Interrupted or unsure of the state**: `.agents/skills/impeccable/scripts/impeccable live-status`, then `live-resume`; the journal under `.impeccable/live/sessions/` is canonical.
|
|
||||||
|
|
||||||
Done when the helper is stopped and the dev site still answers with the accepted design.
|
|
||||||
@@ -205,11 +205,6 @@ t('items', { count }) // Handles complex plural rules
|
|||||||
- Optimistic updates with rollback
|
- Optimistic updates with rollback
|
||||||
- Conflict resolution
|
- Conflict resolution
|
||||||
|
|
||||||
**Interrupted gestures** (custom sliders, drag surfaces, scrollable control strips):
|
|
||||||
- A second finger or pointer lands mid-drag: the first drag keeps its pointer or ends cleanly, never jumps to the new one
|
|
||||||
- The browser cancels the gesture to scroll (`pointercancel`), capture is lost (`lostpointercapture`), the pointer is released outside the control, or the window loses focus (`blur`) mid-drag: clear the dragging state and release capture
|
|
||||||
- After each of these, the next tap or drag works without a reload
|
|
||||||
|
|
||||||
**Permission states**:
|
**Permission states**:
|
||||||
- No permission to view
|
- No permission to view
|
||||||
- No permission to edit
|
- No permission to edit
|
||||||
@@ -309,7 +304,6 @@ const throttledScroll = throttle(handleScroll, 100);
|
|||||||
- Unit tests for edge cases
|
- Unit tests for edge cases
|
||||||
- Integration tests for error scenarios
|
- Integration tests for error scenarios
|
||||||
- E2E tests for critical paths
|
- E2E tests for critical paths
|
||||||
- A behavioral regression for each confirmed gesture fix, when the project's test runner can drive input
|
|
||||||
- Visual regression tests
|
- Visual regression tests
|
||||||
- Accessibility tests (axe, WAVE)
|
- Accessibility tests (axe, WAVE)
|
||||||
|
|
||||||
@@ -336,10 +330,7 @@ Test thoroughly with edge cases:
|
|||||||
- **Network issues**: Disable internet, throttle connection
|
- **Network issues**: Disable internet, throttle connection
|
||||||
- **Large datasets**: Test with 1000+ items
|
- **Large datasets**: Test with 1000+ items
|
||||||
- **Concurrent actions**: Click submit 10 times rapidly
|
- **Concurrent actions**: Click submit 10 times rapidly
|
||||||
- **Interrupted gestures**: Add a second finger mid-drag, scroll across the control, release outside it, switch windows mid-drag; then drag again
|
|
||||||
- **Errors**: Force API errors, test all error states
|
- **Errors**: Force API errors, test all error states
|
||||||
- **Empty**: Remove all data, test empty states
|
- **Empty**: Remove all data, test empty states
|
||||||
|
|
||||||
For gestures, say what produced the evidence (emulated viewport, synthesized touch, which engine, physical device) and name what stayed untested.
|
|
||||||
|
|
||||||
When edge cases are covered, hand off to `$impeccable polish` for the final pass.
|
When edge cases are covered, hand off to `$impeccable polish` for the final pass.
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ Reason over the signals; there is no score to obey:
|
|||||||
- `critique.latest` is `null` → the project has never been critiqued; for a set-up project with a real surface, offering `$impeccable critique <surface>` is a strong default.
|
- `critique.latest` is `null` → the project has never been critiqued; for a set-up project with a real surface, offering `$impeccable critique <surface>` is a strong default.
|
||||||
- `critique.latest` with a low `score` or non-zero `p0` / `p1` → `polish` (it reads that snapshot as its backlog and closes it when stale or cleared).
|
- `critique.latest` with a low `score` or non-zero `p0` / `p1` → `polish` (it reads that snapshot as its backlog and closes it when stale or cleared).
|
||||||
- `git.changedFiles` pointing at one surface → scope `audit` or `polish` to those files specifically, naming them.
|
- `git.changedFiles` pointing at one surface → scope `audit` or `polish` to those files specifically, naming them.
|
||||||
- `devServer.running` true → `live` is available for in-browser iteration, and `generate` for one-shot variant runs on a named element; if false, don't lead with either. **`live`, `generate`, and the bundled `impeccable detect` are web-only.** If `setup.platform` is `ios`, `android`, or `adaptive`, don't lead with any of them; the browser overlay and the HTML rule engine don't apply to native app code.
|
- `devServer.running` true → `live` is available for in-browser iteration; if false, don't lead with `live`. **`live` and the bundled `impeccable detect` are web-only.** If `setup.platform` is `ios`, `android`, or `adaptive`, don't lead with either; the browser overlay and the HTML rule engine don't apply to native app code.
|
||||||
- Otherwise group by intent (build new / improve what's there / iterate visually), tailored to the current surface and `setup.platform`.
|
- Otherwise group by intent (build new / improve what's there / iterate visually), tailored to the current surface and `setup.platform`.
|
||||||
|
|
||||||
**If `scan.targets` is non-empty and `setup.platform` is not `ios`/`android`/`adaptive`, run `.agents/skills/impeccable/scripts/impeccable detect --json <scan.targets joined by spaces>` once** (the bundled detector over local files: no network, no npx; it reads HTML/CSS, so skip it for native projects). `scan.via` tells you what they are: `git-changes` (the markup/style files in your dirty tree, the most relevant set), `source-dir` (e.g. `src`, `app`), `html`, or `root`. Fold the hits into your picks: many quality / contrast hits → `audit` or `polish`; a specific slop family → the matching command (gradient text or eyebrows → `quieter` / `typeset`, flat or gray palette → `colorize`, and so on). It's a real, current signal that beats guessing. If detect errors or the tree is large and slow, skip it and recommend the user run `audit` themselves; never block the suggestion on it.
|
**If `scan.targets` is non-empty and `setup.platform` is not `ios`/`android`/`adaptive`, run `.agents/skills/impeccable/scripts/impeccable detect --json <scan.targets joined by spaces>` once** (the bundled detector over local files: no network, no npx; it reads HTML/CSS, so skip it for native projects). `scan.via` tells you what they are: `git-changes` (the markup/style files in your dirty tree, the most relevant set), `source-dir` (e.g. `src`, `app`), `html`, or `root`. Fold the hits into your picks: many quality / contrast hits → `audit` or `polish`; a specific slop family → the matching command (gradient text or eyebrows → `quieter` / `typeset`, flat or gray palette → `colorize`, and so on). It's a real, current signal that beats guessing. If detect errors or the tree is large and slow, skip it and recommend the user run `audit` themselves; never block the suggestion on it.
|
||||||
|
|||||||
@@ -19,10 +19,6 @@
|
|||||||
"description": "Interactive live variant mode. Select elements in the browser, pick a design action, and get AI-generated HTML+CSS variants hot-swapped via HMR. Requires a running dev server. Use when you want to visually experiment with design alternatives in real time.",
|
"description": "Interactive live variant mode. Select elements in the browser, pick a design action, and get AI-generated HTML+CSS variants hot-swapped via HMR. Requires a running dev server. Use when you want to visually experiment with design alternatives in real time.",
|
||||||
"argumentHint": ""
|
"argumentHint": ""
|
||||||
},
|
},
|
||||||
"generate": {
|
|
||||||
"description": "Agent-driven live variant generation. Boots live mode, finds the named element on the open page, scrolls the browser to it, and delivers N variants in the requested direction for the user to cycle and accept. Use for requests that name an element and a direction, like 'generate 3 bold variants of the pricing cards', skipping manual element picking.",
|
|
||||||
"argumentHint": "[count] [direction] variants of [element]"
|
|
||||||
},
|
|
||||||
"adapt": {
|
"adapt": {
|
||||||
"description": "Adapt designs to work across different screen sizes, devices, contexts, or platforms. Implements breakpoints, fluid layouts, and touch targets. Use when the user mentions responsive design, mobile layouts, breakpoints, viewport adaptation, or cross-device compatibility.",
|
"description": "Adapt designs to work across different screen sizes, devices, contexts, or platforms. Implements breakpoints, fluid layouts, and touch targets. Use when the user mentions responsive design, mobile layouts, breakpoints, viewport adaptation, or cross-device compatibility.",
|
||||||
"argumentHint": "[target] [context (mobile, tablet, print...)]"
|
"argumentHint": "[target] [context (mobile, tablet, print...)]"
|
||||||
|
|||||||
@@ -87,11 +87,6 @@ fi
|
|||||||
# Last resort: fetch this version's binary for the current platform from the
|
# Last resort: fetch this version's binary for the current platform from the
|
||||||
# public release channel into the user cache. Needs network; sandboxes without
|
# public release channel into the user cache. Needs network; sandboxes without
|
||||||
# egress preinstall the binary on PATH instead.
|
# egress preinstall the binary on PATH instead.
|
||||||
setup_help() {
|
|
||||||
echo "Engine $version setup needs network access and write permission to $cache_root/bin/$version." >&2
|
|
||||||
echo "Run this launcher ($0) with engine-probe in a terminal that has those permissions, then retry the original command." >&2
|
|
||||||
echo "Alternatively, set IMPECCABLE_HOME to a writable cache location, or IMPECCABLE_BIN to a preinstalled engine binary." >&2
|
|
||||||
}
|
|
||||||
fetch_url() {
|
fetch_url() {
|
||||||
if command -v curl >/dev/null 2>&1; then
|
if command -v curl >/dev/null 2>&1; then
|
||||||
curl -fsSL --retry 2 -o "$tmp" "$1" 2>/dev/null
|
curl -fsSL --retry 2 -o "$tmp" "$1" 2>/dev/null
|
||||||
@@ -124,18 +119,7 @@ if [ -n "$version" ] && [ "$os" != unknown ] && [ "$arch" != unknown ]; then
|
|||||||
[ "$os" = windows ] && asset="$asset.exe"
|
[ "$os" = windows ] && asset="$asset.exe"
|
||||||
url="$base/engine-v$version/$asset"
|
url="$base/engine-v$version/$asset"
|
||||||
tmp="$cache_root/bin/$version/.impeccable.part.$$"
|
tmp="$cache_root/bin/$version/.impeccable.part.$$"
|
||||||
if ! mkdir -p "$cache_root/bin/$version" 2>/dev/null; then
|
mkdir -p "$cache_root/bin/$version" 2>/dev/null
|
||||||
echo "impeccable: engine $version is not installed; cannot create cache directory: $cache_root/bin/$version" >&2
|
|
||||||
setup_help
|
|
||||||
exit 127
|
|
||||||
fi
|
|
||||||
# Check the actual staging file, not just directory existence: a cache from
|
|
||||||
# an earlier run can be readable but no longer writable inside a sandbox.
|
|
||||||
if ! (umask 077; : > "$tmp") 2>/dev/null; then
|
|
||||||
echo "impeccable: engine $version is not installed; cannot write to cache directory: $cache_root/bin/$version" >&2
|
|
||||||
setup_help
|
|
||||||
exit 127
|
|
||||||
fi
|
|
||||||
fetched=0
|
fetched=0
|
||||||
if fetch_url "$url"; then
|
if fetch_url "$url"; then
|
||||||
fetched=1
|
fetched=1
|
||||||
@@ -196,9 +180,6 @@ if [ -n "$version" ] && [ "$os" != unknown ] && [ "$arch" != unknown ]; then
|
|||||||
exec "$cached" "$@"
|
exec "$cached" "$@"
|
||||||
fi
|
fi
|
||||||
rm -f "$tmp" 2>/dev/null
|
rm -f "$tmp" 2>/dev/null
|
||||||
echo "impeccable: could not download engine $version from $url; check network access, the release URL, and curl or wget availability." >&2
|
|
||||||
setup_help
|
|
||||||
exit 127
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "impeccable: no engine binary for $os-$arch found (looked in $bin, $cached, PATH)." >&2
|
echo "impeccable: no engine binary for $os-$arch found (looked in $bin, $cached, PATH)." >&2
|
||||||
|
|||||||
@@ -68,27 +68,18 @@ rem another launcher's probe: fail fast and quiet instead.
|
|||||||
if defined IMPECCABLE_LAUNCHER_PROBE exit /b 127
|
if defined IMPECCABLE_LAUNCHER_PROBE exit /b 127
|
||||||
if not defined version goto fail
|
if not defined version goto fail
|
||||||
where curl.exe >nul 2>nul
|
where curl.exe >nul 2>nul
|
||||||
if errorlevel 1 goto curl_missing
|
if errorlevel 1 goto fail
|
||||||
if not defined IMPECCABLE_DOWNLOAD_BASE set "IMPECCABLE_DOWNLOAD_BASE=https://github.com/pbakaus/impeccable/releases/download"
|
if not defined IMPECCABLE_DOWNLOAD_BASE set "IMPECCABLE_DOWNLOAD_BASE=https://github.com/pbakaus/impeccable/releases/download"
|
||||||
if exist "%IMPECCABLE_HOME%\bin\%version%\" goto cache_ready
|
if not exist "%IMPECCABLE_HOME%\bin\%version%" mkdir "%IMPECCABLE_HOME%\bin\%version%" >nul 2>nul
|
||||||
mkdir "%IMPECCABLE_HOME%\bin\%version%" >nul 2>nul
|
|
||||||
if errorlevel 1 goto cache_directory_failed
|
|
||||||
:cache_ready
|
|
||||||
rem Check the staging file too: an existing directory may be read-only.
|
|
||||||
rem Redirection failures do not reliably update ERRORLEVEL in cmd.exe;
|
|
||||||
rem branch on the command's failure directly. Never treat a directory as a
|
|
||||||
rem staging file (later del cleanup would prompt to delete its contents).
|
|
||||||
if exist "%cached%.part\" goto cache_write_failed
|
|
||||||
(type nul >"%cached%.part") 2>nul || goto cache_write_failed
|
|
||||||
set "asset=impeccable-windows-%arch%.exe"
|
set "asset=impeccable-windows-%arch%.exe"
|
||||||
set "url=%IMPECCABLE_DOWNLOAD_BASE%/engine-v%version%/%asset%"
|
set "url=%IMPECCABLE_DOWNLOAD_BASE%/engine-v%version%/%asset%"
|
||||||
curl.exe -fsSL -o "%cached%.part" "%url%" >nul 2>nul
|
curl.exe -fsSL -o "%cached%.part" "%url%" >nul 2>nul
|
||||||
if not errorlevel 1 goto verify
|
if not errorlevel 1 goto verify
|
||||||
if not "%arch%"=="arm64" goto download_failed
|
if not "%arch%"=="arm64" goto fail
|
||||||
set "asset=impeccable-windows-x64.exe"
|
set "asset=impeccable-windows-x64.exe"
|
||||||
set "url=%IMPECCABLE_DOWNLOAD_BASE%/engine-v%version%/%asset%"
|
set "url=%IMPECCABLE_DOWNLOAD_BASE%/engine-v%version%/%asset%"
|
||||||
curl.exe -fsSL -o "%cached%.part" "%url%" >nul 2>nul
|
curl.exe -fsSL -o "%cached%.part" "%url%" >nul 2>nul
|
||||||
if errorlevel 1 goto download_failed
|
if errorlevel 1 goto fail
|
||||||
|
|
||||||
:verify
|
:verify
|
||||||
call :check_download
|
call :check_download
|
||||||
@@ -185,28 +176,6 @@ if not errorlevel 1 set "probe_ok=1"
|
|||||||
del "%probe_tmp%" >nul 2>nul
|
del "%probe_tmp%" >nul 2>nul
|
||||||
exit /b 0
|
exit /b 0
|
||||||
|
|
||||||
:cache_directory_failed
|
|
||||||
echo impeccable: engine %version% is not installed; cannot create cache directory: "%IMPECCABLE_HOME%\bin\%version%" 1>&2
|
|
||||||
goto setup_failed
|
|
||||||
|
|
||||||
:cache_write_failed
|
|
||||||
echo impeccable: engine %version% is not installed; cannot write to cache directory: "%IMPECCABLE_HOME%\bin\%version%" 1>&2
|
|
||||||
goto setup_failed
|
|
||||||
|
|
||||||
:curl_missing
|
|
||||||
echo impeccable: cannot download engine %version%; curl.exe is unavailable. 1>&2
|
|
||||||
goto setup_failed
|
|
||||||
|
|
||||||
:download_failed
|
|
||||||
del "%cached%.part" >nul 2>nul
|
|
||||||
echo impeccable: could not download engine %version% from %url%; check network access and the release URL. 1>&2
|
|
||||||
|
|
||||||
:setup_failed
|
|
||||||
echo Engine %version% setup needs network access and write permission to "%IMPECCABLE_HOME%\bin\%version%". 1>&2
|
|
||||||
echo Run this launcher ("%~f0") with engine-probe in a terminal that has those permissions, then retry the original command. 1>&2
|
|
||||||
echo Alternatively, set IMPECCABLE_HOME to a writable cache location, or IMPECCABLE_BIN to a preinstalled engine binary. 1>&2
|
|
||||||
exit /b 127
|
|
||||||
|
|
||||||
:fail
|
:fail
|
||||||
del "%cached%.part" >nul 2>nul
|
del "%cached%.part" >nul 2>nul
|
||||||
echo impeccable: no engine binary found (looked in %bin%, %cached%, PATH). 1>&2
|
echo impeccable: no engine binary found (looked in %bin%, %cached%, PATH). 1>&2
|
||||||
|
|||||||
@@ -165,14 +165,6 @@
|
|||||||
}
|
}
|
||||||
let parameterGenerationState = 'idle';
|
let parameterGenerationState = 'idle';
|
||||||
let parameterReadyAnnouncedSession = null;
|
let parameterReadyAnnouncedSession = null;
|
||||||
// 'agent' when the generate verb fired this session's Go (the generate
|
|
||||||
// lane declares no knobs, so its bar never shows a pending Tune chip);
|
|
||||||
// null for every Go a user presses.
|
|
||||||
let sessionOrigin = null;
|
|
||||||
// The generate lane picks for the agent and never edits copy in the
|
|
||||||
// browser, so its selection carries no edit-copy badge (set on the
|
|
||||||
// agent-target pick, cleared with the session; a user's pick never sets it).
|
|
||||||
let editBadgeSuppressed = false;
|
|
||||||
let svelteComponentSession = null;
|
let svelteComponentSession = null;
|
||||||
let svelteRuntimePromise = null;
|
let svelteRuntimePromise = null;
|
||||||
let pendingSvelteComponentRetryObserver = null;
|
let pendingSvelteComponentRetryObserver = null;
|
||||||
@@ -991,20 +983,9 @@
|
|||||||
}
|
}
|
||||||
} catch { /* cross-origin */ }
|
} catch { /* cross-origin */ }
|
||||||
}
|
}
|
||||||
// The selector a mechanical bake would anchor lasting rules on, and how
|
|
||||||
// many elements it matches right now: the bake refuses anything but one,
|
|
||||||
// since its rules would restyle every match, not just this element.
|
|
||||||
const cssIdent = (s) => /^[A-Za-z_-][\w-]*$/.test(s);
|
|
||||||
const anchorClasses = [...el.classList].filter(cssIdent);
|
|
||||||
const anchor = el.id && cssIdent(el.id)
|
|
||||||
? '#' + el.id
|
|
||||||
: (anchorClasses.length ? el.tagName.toLowerCase() + '.' + anchorClasses.join('.') : null);
|
|
||||||
let anchorMatches = null;
|
|
||||||
if (anchor) { try { anchorMatches = document.querySelectorAll(anchor).length; } catch { anchorMatches = null; } }
|
|
||||||
return {
|
return {
|
||||||
tagName: el.tagName.toLowerCase(), id: el.id || null,
|
tagName: el.tagName.toLowerCase(), id: el.id || null,
|
||||||
classes: [...el.classList],
|
classes: [...el.classList],
|
||||||
anchor, anchorMatches,
|
|
||||||
textContent: (el.textContent || '').slice(0, 500),
|
textContent: (el.textContent || '').slice(0, 500),
|
||||||
outerHTML: sanitizedContextOuterHTML(el, 10000),
|
outerHTML: sanitizedContextOuterHTML(el, 10000),
|
||||||
computedStyles: {
|
computedStyles: {
|
||||||
@@ -2056,7 +2037,6 @@
|
|||||||
function setLiveState(next) {
|
function setLiveState(next) {
|
||||||
state = next;
|
state = next;
|
||||||
window.__IMPECCABLE_LIVE_STATE__ = next;
|
window.__IMPECCABLE_LIVE_STATE__ = next;
|
||||||
retryDeclinedAgentTargets();
|
|
||||||
syncPageInteractionCursor();
|
syncPageInteractionCursor();
|
||||||
// Whether a queued steer is still behind a generation is a function of this
|
// Whether a queued steer is still behind a generation is a function of this
|
||||||
// state, so the hint has to move with it, not only with the 5s poll.
|
// state, so the hint has to move with it, not only with the 5s poll.
|
||||||
@@ -4034,7 +4014,6 @@
|
|||||||
|
|
||||||
function hidePendingApplyDock() {
|
function hidePendingApplyDock() {
|
||||||
pendingApplyInFlight = false;
|
pendingApplyInFlight = false;
|
||||||
retryDeclinedAgentTargets();
|
|
||||||
clearStoredManualApplyState();
|
clearStoredManualApplyState();
|
||||||
if (pendingIntroAnimation) { pendingIntroAnimation.cancel(); pendingIntroAnimation = null; }
|
if (pendingIntroAnimation) { pendingIntroAnimation.cancel(); pendingIntroAnimation = null; }
|
||||||
if (pendingDockEl) pendingDockEl.style.display = 'none';
|
if (pendingDockEl) pendingDockEl.style.display = 'none';
|
||||||
@@ -4068,7 +4047,6 @@
|
|||||||
function setPendingApplyLoading(loading, count) {
|
function setPendingApplyLoading(loading, count) {
|
||||||
if (!pendingPillEl || !pendingPillLabelEl || !pendingPillCountEl || !pendingTrashBtn) return;
|
if (!pendingPillEl || !pendingPillLabelEl || !pendingPillCountEl || !pendingTrashBtn) return;
|
||||||
pendingApplyInFlight = loading === true;
|
pendingApplyInFlight = loading === true;
|
||||||
if (!pendingApplyInFlight) retryDeclinedAgentTargets();
|
|
||||||
const currentCount = count || parseInt(pendingPillEl.dataset.count || '0', 10) || 0;
|
const currentCount = count || parseInt(pendingPillEl.dataset.count || '0', 10) || 0;
|
||||||
if (pendingApplyInFlight) storeManualApplyState(currentCount);
|
if (pendingApplyInFlight) storeManualApplyState(currentCount);
|
||||||
else clearStoredManualApplyState();
|
else clearStoredManualApplyState();
|
||||||
@@ -4710,7 +4688,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderEditBadge(mode) {
|
function renderEditBadge(mode) {
|
||||||
if (editBadgeSuppressed || sessionOrigin === 'agent') mode = 'hidden';
|
|
||||||
if (mode === 'hidden' || !editBadgeEl) {
|
if (mode === 'hidden' || !editBadgeEl) {
|
||||||
hideConfigureBarTooltip();
|
hideConfigureBarTooltip();
|
||||||
if (editBadgeEl) editBadgeEl.style.display = 'none';
|
if (editBadgeEl) editBadgeEl.style.display = 'none';
|
||||||
@@ -6204,8 +6181,6 @@
|
|||||||
resetSessionFileMeta();
|
resetSessionFileMeta();
|
||||||
currentSessionId = null;
|
currentSessionId = null;
|
||||||
parameterGenerationState = 'idle';
|
parameterGenerationState = 'idle';
|
||||||
sessionOrigin = null;
|
|
||||||
editBadgeSuppressed = false;
|
|
||||||
parameterReadyAnnouncedSession = null;
|
parameterReadyAnnouncedSession = null;
|
||||||
expectedVariants = 0;
|
expectedVariants = 0;
|
||||||
arrivedVariants = 0;
|
arrivedVariants = 0;
|
||||||
@@ -7137,398 +7112,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
//
|
//
|
||||||
// ------------------------------------------------------------------
|
|
||||||
// Agent-initiated targeting (the `generate` command). The agent names an
|
|
||||||
// element by CSS selector over POST /agent-target; the server pushes an
|
|
||||||
// `agent_target` SSE message here. The overlay resolves the selector,
|
|
||||||
// scrolls the element into view, enters the same picked state a user
|
|
||||||
// click produces, and fires the normal Go pipeline, so everything
|
|
||||||
// downstream (generate event, variants, cycling, accept) is unchanged.
|
|
||||||
// The verdict goes back through POST /agent-target-result, which resolves
|
|
||||||
// the agent's held-open CLI call.
|
|
||||||
|
|
||||||
function postAgentTargetResult(targetId, result) {
|
|
||||||
fetch('http://localhost:' + PORT + '/agent-target-result?token=' + TOKEN, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ token: TOKEN, targetId, clientId: AGENT_TARGET_CLIENT_ID, ...result }),
|
|
||||||
}).catch(() => { /* server gone; nothing to report to */ });
|
|
||||||
}
|
|
||||||
|
|
||||||
function describeAgentTargetCandidate(el) {
|
|
||||||
return {
|
|
||||||
tag: el.tagName.toLowerCase(),
|
|
||||||
id: el.id || null,
|
|
||||||
classes: [...el.classList].filter((c) => !c.startsWith('impeccable-')),
|
|
||||||
text: (el.textContent || '').trim().slice(0, 80),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveAgentTargetElement(msg) {
|
|
||||||
let matched;
|
|
||||||
try {
|
|
||||||
matched = [...document.querySelectorAll(msg.selector)];
|
|
||||||
} catch {
|
|
||||||
return { error: { ok: false, error: 'invalid_selector', selector: msg.selector } };
|
|
||||||
}
|
|
||||||
let candidates = matched.filter((el) => pickable(el));
|
|
||||||
if (msg.text) {
|
|
||||||
const needle = String(msg.text).toLowerCase();
|
|
||||||
candidates = candidates.filter((el) => (el.textContent || '').toLowerCase().includes(needle));
|
|
||||||
}
|
|
||||||
if (candidates.length === 0) {
|
|
||||||
return {
|
|
||||||
error: {
|
|
||||||
ok: false,
|
|
||||||
error: 'no_match',
|
|
||||||
selector: msg.selector,
|
|
||||||
matchCount: 0,
|
|
||||||
// How many nodes the raw selector hit before the pickable/text
|
|
||||||
// filters: distinguishes a wrong selector from an unpickable match.
|
|
||||||
rawMatchCount: matched.length,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (Number.isInteger(msg.index)) {
|
|
||||||
const el = candidates[msg.index - 1];
|
|
||||||
if (!el) {
|
|
||||||
return { error: { ok: false, error: 'index_out_of_range', selector: msg.selector, matchCount: candidates.length } };
|
|
||||||
}
|
|
||||||
return { el, matchCount: candidates.length };
|
|
||||||
}
|
|
||||||
if (candidates.length > 1) {
|
|
||||||
return {
|
|
||||||
error: {
|
|
||||||
ok: false,
|
|
||||||
error: 'ambiguous',
|
|
||||||
selector: msg.selector,
|
|
||||||
matchCount: candidates.length,
|
|
||||||
candidates: candidates.slice(0, 8).map(describeAgentTargetCandidate),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return { el: candidates[0], matchCount: 1 };
|
|
||||||
}
|
|
||||||
|
|
||||||
function scrollAgentTargetIntoView(el, done) {
|
|
||||||
const rect = el.getBoundingClientRect();
|
|
||||||
if (rect.top >= 0 && rect.bottom <= window.innerHeight) { done(); return; }
|
|
||||||
let settled = false;
|
|
||||||
let fallback = null;
|
|
||||||
const finish = () => {
|
|
||||||
if (settled) return;
|
|
||||||
settled = true;
|
|
||||||
removeEventListener('scrollend', finish, true);
|
|
||||||
if (fallback) clearTimeout(fallback);
|
|
||||||
done();
|
|
||||||
};
|
|
||||||
// scrollend where supported; a timer covers engines without it and the
|
|
||||||
// no-movement case (element already at its final resting position).
|
|
||||||
addEventListener('scrollend', finish, true);
|
|
||||||
fallback = setTimeout(finish, 1200);
|
|
||||||
el.scrollIntoView({ block: 'center', behavior: 'smooth' });
|
|
||||||
}
|
|
||||||
|
|
||||||
// One id per page load: the server keys claims and roll-call reports on
|
|
||||||
// it, and only the tab that holds the lease can renew it.
|
|
||||||
const AGENT_TARGET_CLIENT_ID = id8();
|
|
||||||
|
|
||||||
// The agent target an agent-initiated Go is serving: set by
|
|
||||||
// actOnAgentTarget around its handleGo call, read once by handleGo.
|
|
||||||
let agentTargetForGo = null;
|
|
||||||
|
|
||||||
// The helper's word on its global bar. The generate lane asks the helper
|
|
||||||
// to keep it out of the way (`impeccable live --no-live-bar`, or an agent
|
|
||||||
// target carrying hideLiveBar), and the helper tells every connected tab
|
|
||||||
// at once (`live_bar`) and every later connection on `connected`, so the
|
|
||||||
// bar stays hidden in every tab, through reloads, the accept, and the
|
|
||||||
// bake, until the helper stops and takes the overlay with it. The variant
|
|
||||||
// controls still show.
|
|
||||||
let liveBarHiddenByHelper = false;
|
|
||||||
|
|
||||||
function applyLiveBarPreference(hidden) {
|
|
||||||
liveBarHiddenByHelper = hidden === true;
|
|
||||||
setLiveBarHidden(liveBarHiddenByHelper);
|
|
||||||
}
|
|
||||||
|
|
||||||
// A plain live session must never notice this code: hiding remembers the
|
|
||||||
// bar's own display value and restoring puts exactly that back, and a
|
|
||||||
// restore on a bar that is not hidden is a no-op, so the `connected`
|
|
||||||
// frame every session receives changes nothing unless the lane asked.
|
|
||||||
function setLiveBarHidden(hidden) {
|
|
||||||
if (!globalBarEl) return;
|
|
||||||
if (hidden) {
|
|
||||||
if (globalBarEl.style.display !== 'none') {
|
|
||||||
globalBarEl.dataset.liveBarDisplay = globalBarEl.style.display || 'flex';
|
|
||||||
globalBarEl.style.display = 'none';
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (globalBarEl.style.display === 'none') {
|
|
||||||
globalBarEl.style.display = globalBarEl.dataset.liveBarDisplay || 'flex';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function claimAgentTarget(targetId, report) {
|
|
||||||
return fetch('http://localhost:' + PORT + '/agent-target-claim?token=' + TOKEN, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ token: TOKEN, targetId, clientId: AGENT_TARGET_CLIENT_ID, ...report }),
|
|
||||||
}).then((res) => res.json())
|
|
||||||
.then((j) => ({ granted: !!j && j.granted === true, pending: !!j && j.pending === true }))
|
|
||||||
.catch(() => ({ granted: false, pending: false }));
|
|
||||||
}
|
|
||||||
|
|
||||||
// `exceptTargetId` is the target this call is about: a tab acting on it
|
|
||||||
// is not busy for itself, but it is busy for every other target, or two
|
|
||||||
// held requests could both be claimed here and the second Go would
|
|
||||||
// overwrite the session the first one minted.
|
|
||||||
function agentTargetBusyReason(exceptTargetId) {
|
|
||||||
if (pendingApplyInFlight) return 'manual_apply_in_flight';
|
|
||||||
if (state !== 'IDLE' && state !== 'PICKING' && state !== 'CONFIGURING') return 'session_active';
|
|
||||||
for (const [targetId, status] of agentTargetsSeen) {
|
|
||||||
if (status === 'acting' && targetId !== exceptTargetId) return 'agent_target_in_flight';
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Targets this tab declined as busy. A busy report is only this tab's word
|
|
||||||
// at that moment: the moment it is free again (setLiveState), it claims
|
|
||||||
// each of these as eligible, and the server drops the stale report, so a
|
|
||||||
// busy verdict is never built on a tab that has since gone idle. The
|
|
||||||
// server denies claims for resolved targets, so retries are harmless.
|
|
||||||
const busyDeclinedTargets = new Map();
|
|
||||||
|
|
||||||
function declineAgentTargetBusy(msg, busy) {
|
|
||||||
busyDeclinedTargets.set(msg.targetId, msg);
|
|
||||||
noteAgentTarget(msg.targetId, 'declined');
|
|
||||||
claimAgentTarget(msg.targetId, { eligible: false, state, reason: busy });
|
|
||||||
}
|
|
||||||
|
|
||||||
// A torn-down overlay, or one whose helper connection is gone, cannot
|
|
||||||
// serve a target and must not even claim one: it would hold the lease for
|
|
||||||
// a request it will never act on.
|
|
||||||
function agentTargetOverlayGone() {
|
|
||||||
return !evtSource;
|
|
||||||
}
|
|
||||||
|
|
||||||
// A denied claimant retries at this cadence, a little over the lease, so
|
|
||||||
// the first retry after a dead holder's lease lapses is granted.
|
|
||||||
const AGENT_TARGET_RESCUE_RETRY_MS = 3500;
|
|
||||||
|
|
||||||
// Claim the lease and act as the holder. A denied claim means another tab
|
|
||||||
// holds the lease. That holder can die before posting its result (reload,
|
|
||||||
// crash, even after renewing), and its lease lapses after ~3s, so this tab
|
|
||||||
// keeps retrying for as long as the server still holds the request: the
|
|
||||||
// answer's `pending` is the server's word that the request is alive, and
|
|
||||||
// it turns false the moment the request resolved or timed out, so no tab
|
|
||||||
// retries a request nobody awaits. A tab that turned busy meanwhile joins
|
|
||||||
// the roll call instead of taking a lease it cannot use. The first claim
|
|
||||||
// and the busy-to-idle re-claim share this.
|
|
||||||
function claimAndActOnAgentTarget(msg) {
|
|
||||||
if (agentTargetOverlayGone()) return;
|
|
||||||
const busy = agentTargetBusyReason(msg.targetId);
|
|
||||||
if (busy) { declineAgentTargetBusy(msg, busy); return; }
|
|
||||||
if (declineAgentTargetUnresolvable(msg)) return;
|
|
||||||
claimAgentTarget(msg.targetId, { eligible: true }).then((claim) => {
|
|
||||||
if (claim.granted) { noteAgentTarget(msg.targetId, 'acting'); actOnAgentTarget(msg); return; }
|
|
||||||
noteAgentTarget(msg.targetId, 'denied');
|
|
||||||
if (!claim.pending) return;
|
|
||||||
setTimeout(() => claimAndActOnAgentTarget(msg), AGENT_TARGET_RESCUE_RETRY_MS);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function retryDeclinedAgentTargets() {
|
|
||||||
if (busyDeclinedTargets.size === 0 || agentTargetBusyReason()) return;
|
|
||||||
for (const [targetId, msg] of busyDeclinedTargets) {
|
|
||||||
busyDeclinedTargets.delete(targetId);
|
|
||||||
claimAndActOnAgentTarget(msg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// This page's participation in each target it heard: 'acting' once a
|
|
||||||
// claim was granted, 'done' once it replied (or stood down from a lapsed
|
|
||||||
// lease), else the word it last gave. The server replays pending targets
|
|
||||||
// to every connection that opens. After a reconnect that overlapped the
|
|
||||||
// old connection the server still holds this page's word; after one that
|
|
||||||
// did not, it dropped the word on the close, so a replayed target is
|
|
||||||
// handled again: a busy or unresolvable page re-declines (idempotent), an
|
|
||||||
// idle page claims.
|
|
||||||
const agentTargetsSeen = new Map();
|
|
||||||
function noteAgentTarget(targetId, status) {
|
|
||||||
agentTargetsSeen.set(targetId, status);
|
|
||||||
if (agentTargetsSeen.size > 100) agentTargetsSeen.delete(agentTargetsSeen.keys().next().value);
|
|
||||||
}
|
|
||||||
|
|
||||||
// A target this page took a lease on is off-limits for a replay: while
|
|
||||||
// acting (a second claim or Go), and once done, because its result may
|
|
||||||
// still be on the wire and this tab is GENERATING by then, so handling
|
|
||||||
// the replay would decline busy, hand the lease back mid-resolution, and
|
|
||||||
// let another tab fire a second Go.
|
|
||||||
function agentTargetTaken(targetId) {
|
|
||||||
const status = agentTargetsSeen.get(targetId);
|
|
||||||
return status === 'acting' || status === 'done';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only a page that can resolve the target claims it. A tab whose page
|
|
||||||
// lacks the element declines with its resolution verdict instead, so a
|
|
||||||
// first-wins claim never lets the wrong page answer for a target that
|
|
||||||
// another page has. The server prefers a busy report (a tab that could
|
|
||||||
// serve later) over these, and returns the resolution verdict only when
|
|
||||||
// no connected page can serve.
|
|
||||||
//
|
|
||||||
// An element can be momentarily absent (a route still rendering, an HMR
|
|
||||||
// commit mid-swap), so a failed resolution is not this page's final word:
|
|
||||||
// it is re-checked a few times over about two seconds, claiming the
|
|
||||||
// moment the element mounts, and only the last miss is reported. The
|
|
||||||
// server's timeout still bounds the whole exchange.
|
|
||||||
// The page reports the miss at once (so the other overlays' words can
|
|
||||||
// complete the roll call) and keeps re-checking at this cadence for as
|
|
||||||
// long as the server says the request is pending: the server holds an
|
|
||||||
// all-no_match roll call open for a short grace precisely so a late mount
|
|
||||||
// can still be claimed, drops the stale report on an eligible claim, and
|
|
||||||
// ends the watch by answering pending:false once the request resolved or
|
|
||||||
// timed out.
|
|
||||||
const AGENT_TARGET_RESOLVE_WATCH_MS = 500;
|
|
||||||
|
|
||||||
function declineAgentTargetUnresolvable(msg) {
|
|
||||||
const probe = resolveAgentTargetElement(msg);
|
|
||||||
if (!probe.error) return false;
|
|
||||||
reportAgentTargetUnresolvable(msg, probe.error);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function reportAgentTargetUnresolvable(msg, error) {
|
|
||||||
noteAgentTarget(msg.targetId, 'declined');
|
|
||||||
claimAgentTarget(msg.targetId, { eligible: false, state, reason: 'no_match', result: error }).then((answer) => {
|
|
||||||
if (!answer.pending) return;
|
|
||||||
setTimeout(() => watchAgentTargetResolution(msg, error), AGENT_TARGET_RESOLVE_WATCH_MS);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function watchAgentTargetResolution(msg, lastError) {
|
|
||||||
if (agentTargetOverlayGone() || agentTargetTaken(msg.targetId)) return;
|
|
||||||
const busy = agentTargetBusyReason(msg.targetId);
|
|
||||||
if (busy) { declineAgentTargetBusy(msg, busy); return; }
|
|
||||||
const probe = resolveAgentTargetElement(msg);
|
|
||||||
if (!probe.error) { claimAndActOnAgentTarget(msg); return; }
|
|
||||||
// Still unresolvable: re-report (idempotent); the answer says whether
|
|
||||||
// the server is still holding the request open.
|
|
||||||
reportAgentTargetUnresolvable(msg, probe.error || lastError);
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleAgentTarget(msg) {
|
|
||||||
if (!msg || typeof msg.targetId !== 'string') return;
|
|
||||||
if (agentTargetTaken(msg.targetId)) return;
|
|
||||||
noteAgentTarget(msg.targetId, 'heard');
|
|
||||||
const busy = agentTargetBusyReason(msg.targetId);
|
|
||||||
if (busy) {
|
|
||||||
// Roll call: a busy tab reports itself and never acts. The server
|
|
||||||
// answers `busy` the moment every connected overlay has reported, so
|
|
||||||
// an idle tab elsewhere is never raced by a timer.
|
|
||||||
declineAgentTargetBusy(msg, busy);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (declineAgentTargetUnresolvable(msg)) return;
|
|
||||||
// Eligible tabs race for the server's lease and only the holder acts. A
|
|
||||||
// hidden tab yields a short head start so a visible one wins when both
|
|
||||||
// exist, and still serves the request on its own: the user finds the
|
|
||||||
// selection waiting when they return to it.
|
|
||||||
setTimeout(() => claimAndActOnAgentTarget(msg), document.hidden ? 150 : 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
function actOnAgentTarget(msg) {
|
|
||||||
if (agentTargetOverlayGone()) return;
|
|
||||||
// Every exit ends this tab's acting state, so a later target is not
|
|
||||||
// refused for a Go that already happened or never will.
|
|
||||||
const reply = (result) => { noteAgentTarget(msg.targetId, 'done'); postAgentTargetResult(msg.targetId, result); };
|
|
||||||
const busy = agentTargetBusyReason(msg.targetId);
|
|
||||||
if (busy) {
|
|
||||||
// Turned busy between claim and act: report it, which also hands the
|
|
||||||
// lease back so the roll call can complete or a rescuer can claim.
|
|
||||||
declineAgentTargetBusy(msg, busy);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const resolved = resolveAgentTargetElement(msg);
|
|
||||||
if (resolved.error) {
|
|
||||||
// The element went away between claim and act. A result would end the
|
|
||||||
// request for every tab; a decline hands the lease back so another
|
|
||||||
// page or a remount can still serve it.
|
|
||||||
reportAgentTargetUnresolvable(msg, resolved.error);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const el = resolved.el;
|
|
||||||
if (msg.dryRun) {
|
|
||||||
reply({
|
|
||||||
ok: true,
|
|
||||||
dryRun: true,
|
|
||||||
matchCount: resolved.matchCount,
|
|
||||||
element: describeAgentTargetCandidate(el),
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
scrollAgentTargetIntoView(el, () => {
|
|
||||||
// Torn down during the scroll settle: do not renew. The lease lapses
|
|
||||||
// for a rescuer instead of Go minting a session on a dismantled
|
|
||||||
// overlay.
|
|
||||||
if (agentTargetOverlayGone()) return;
|
|
||||||
// Renew the lease right before the irreversible part: a tab whose
|
|
||||||
// lease lapsed while it scrolled (a rescuer took over) stops here, so
|
|
||||||
// one request never gets two Go presses.
|
|
||||||
claimAgentTarget(msg.targetId, { eligible: true }).then((renewal) => {
|
|
||||||
if (!renewal.granted) { noteAgentTarget(msg.targetId, 'done'); return; }
|
|
||||||
// An insert placement left mid-configure gives way, exactly as a
|
|
||||||
// click outside it does in handleClick.
|
|
||||||
if (state === 'CONFIGURING' && configureKind === 'insert') cancelInsertConfigure();
|
|
||||||
// Mirror of the user-click pick entry in handleClick, minus the
|
|
||||||
// pick-mode gate (the agent's intent replaces the toggle); the entry
|
|
||||||
// goes through beginNewLiveConfiguration like every other pick so
|
|
||||||
// deferred recovery sees a fresh interaction revision.
|
|
||||||
selectedElement = el;
|
|
||||||
beginNewLiveConfiguration();
|
|
||||||
showHighlight(selectedElement);
|
|
||||||
clearAnnotations();
|
|
||||||
showAnnotOverlay(selectedElement);
|
|
||||||
showBar('configure');
|
|
||||||
editBadgeSuppressed = true;
|
|
||||||
renderEditBadge('hidden');
|
|
||||||
startScrollTracking();
|
|
||||||
maybePrefetchPage();
|
|
||||||
maybeWarnConditionalAncestor(selectedElement);
|
|
||||||
// Preset what the agent asked for, then fire the same Go a user press
|
|
||||||
// fires. handleGo reads exactly these inputs.
|
|
||||||
selectedAction = msg.action;
|
|
||||||
selectedCount = msg.count;
|
|
||||||
// updateBarContent rebuilds the configure row and replaces the input
|
|
||||||
// element, so the prompt must be written into the input it creates,
|
|
||||||
// never before (the action-chip click handler does the same dance).
|
|
||||||
updateBarContent('configure');
|
|
||||||
const input = uiGetById(PREFIX + '-input');
|
|
||||||
if (input) input.value = msg.prompt || '';
|
|
||||||
// The target rides on the generate event too: the helper resolves
|
|
||||||
// the request from whichever lands first, so a page that dies
|
|
||||||
// between Go and its result cannot leave the request pending for a
|
|
||||||
// second Go elsewhere.
|
|
||||||
const candidate = describeAgentTargetCandidate(el);
|
|
||||||
agentTargetForGo = { targetId: msg.targetId, matchCount: resolved.matchCount, action: msg.action, count: msg.count, element: candidate };
|
|
||||||
handleGo();
|
|
||||||
agentTargetForGo = null;
|
|
||||||
if (state === 'GENERATING' && currentSessionId) {
|
|
||||||
reply({
|
|
||||||
ok: true,
|
|
||||||
matchCount: resolved.matchCount,
|
|
||||||
sessionId: currentSessionId,
|
|
||||||
action: msg.action,
|
|
||||||
count: msg.count,
|
|
||||||
element: candidate,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
reply({ ok: false, error: 'go_failed', state });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// SSE (server→browser) + fetch POST (browser→server)
|
// SSE (server→browser) + fetch POST (browser→server)
|
||||||
// Zero-dependency replacement for WebSocket.
|
// Zero-dependency replacement for WebSocket.
|
||||||
//
|
//
|
||||||
@@ -7538,7 +7121,7 @@
|
|||||||
const SSE_MAX_RETRIES = 20; // generous: heartbeats keep the connection alive, so retries mean real trouble
|
const SSE_MAX_RETRIES = 20; // generous: heartbeats keep the connection alive, so retries mean real trouble
|
||||||
|
|
||||||
function connectSSE() {
|
function connectSSE() {
|
||||||
evtSource = new EventSource('http://localhost:' + PORT + '/events?token=' + TOKEN + '&clientId=' + AGENT_TARGET_CLIENT_ID);
|
evtSource = new EventSource('http://localhost:' + PORT + '/events?token=' + TOKEN);
|
||||||
|
|
||||||
evtSource.onopen = () => {
|
evtSource.onopen = () => {
|
||||||
sseRetries = 0; // reset on successful (re)connect
|
sseRetries = 0; // reset on successful (re)connect
|
||||||
@@ -7549,11 +7132,8 @@
|
|||||||
let msg; try { msg = JSON.parse(e.data); } catch { return; }
|
let msg; try { msg = JSON.parse(e.data); } catch { return; }
|
||||||
switch (msg.type) {
|
switch (msg.type) {
|
||||||
case 'connected':
|
case 'connected':
|
||||||
applyLiveBarPreference(msg.hideLiveBar === true);
|
|
||||||
hasProjectContext = !!msg.hasProjectContext;
|
hasProjectContext = !!msg.hasProjectContext;
|
||||||
// The generate lane runs without PRODUCT.md by design and never
|
if (!hasProjectContext) showToast(`No PRODUCT.md found. Variants will be brand-agnostic. Run ${IMPECCABLE_COMMAND} init to generate one.`, 7000);
|
||||||
// sends the user to init, so its quiet chrome skips this notice.
|
|
||||||
if (!hasProjectContext && !liveBarHiddenByHelper) showToast(`No PRODUCT.md found. Variants will be brand-agnostic. Run ${IMPECCABLE_COMMAND} init to generate one.`, 7000);
|
|
||||||
console.log('[impeccable] Live mode connected.');
|
console.log('[impeccable] Live mode connected.');
|
||||||
syncAgentPollingUi(!!msg.agentPolling);
|
syncAgentPollingUi(!!msg.agentPolling);
|
||||||
startAgentStatusPoll();
|
startAgentStatusPoll();
|
||||||
@@ -7563,15 +7143,9 @@
|
|||||||
syncPageInteractionCursor();
|
syncPageInteractionCursor();
|
||||||
syncPageChatFocus('sse-connected');
|
syncPageChatFocus('sse-connected');
|
||||||
break;
|
break;
|
||||||
case 'live_bar':
|
|
||||||
applyLiveBarPreference(msg.hidden === true);
|
|
||||||
break;
|
|
||||||
case 'agent_polling':
|
case 'agent_polling':
|
||||||
syncAgentPollingUi(!!msg.connected);
|
syncAgentPollingUi(!!msg.connected);
|
||||||
break;
|
break;
|
||||||
case 'agent_target':
|
|
||||||
handleAgentTarget(msg);
|
|
||||||
break;
|
|
||||||
case 'agent_phase':
|
case 'agent_phase':
|
||||||
if (msg.id === currentSessionId && (state === 'GENERATING' || state === 'CYCLING')) {
|
if (msg.id === currentSessionId && (state === 'GENERATING' || state === 'CYCLING')) {
|
||||||
// Advance the visible phase monotonically. A behind/resumed
|
// Advance the visible phase monotonically. A behind/resumed
|
||||||
@@ -7634,11 +7208,6 @@
|
|||||||
disableInlineEdit();
|
disableInlineEdit();
|
||||||
refreshParamsPanel();
|
refreshParamsPanel();
|
||||||
}
|
}
|
||||||
// The done reply is the agent's last word on this generation:
|
|
||||||
// with every variant mounted and no knobs declared, the Tune
|
|
||||||
// chip must stop spinning. A reload between the mount and this
|
|
||||||
// reply restored the pending state from the cache.
|
|
||||||
completeParameterGenerationIfReady();
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
// Source fallback when HMR did not land variants in this tab.
|
// Source fallback when HMR did not land variants in this tab.
|
||||||
@@ -7802,15 +7371,6 @@
|
|||||||
}).then(async res => {
|
}).then(async res => {
|
||||||
if (res.ok) return res;
|
if (res.ok) return res;
|
||||||
const body = await res.json().catch(() => ({}));
|
const body = await res.json().catch(() => ({}));
|
||||||
// The helper refused to open a session for an agent target it has
|
|
||||||
// already answered (another page served it after this page's lease
|
|
||||||
// lapsed mid-capture, or the request timed out): drop the local
|
|
||||||
// session and hand the surface back.
|
|
||||||
if (body.error === 'agent_target_already_served' && msg.type === 'generate'
|
|
||||||
&& msg.id && msg.id === currentSessionId) {
|
|
||||||
abandonSupersededGo(msg.id);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
// The server refused to journal progress for a session it has never
|
// The server refused to journal progress for a session it has never
|
||||||
// seen: this browser is carrying state from another project or a
|
// seen: this browser is carrying state from another project or a
|
||||||
// wiped store (two apps sharing a localhost port). Continuing to
|
// wiped store (two apps sharing a localhost port). Continuing to
|
||||||
@@ -7832,14 +7392,6 @@
|
|||||||
return sessionCreationGate.then(doSend);
|
return sessionCreationGate.then(doSend);
|
||||||
}
|
}
|
||||||
|
|
||||||
function abandonSupersededGo(sessionId) {
|
|
||||||
if (sessionId !== currentSessionId) return;
|
|
||||||
console.warn('[impeccable] The helper already answered this agent target; clearing session ' + sessionId + '.');
|
|
||||||
markSessionHandled();
|
|
||||||
cleanup({ instantChrome: true });
|
|
||||||
showToast('The helper already answered this request, so this session was cleared. Pick an element to start fresh.', 6000);
|
|
||||||
}
|
|
||||||
|
|
||||||
let abandonedForeignSessionId = null;
|
let abandonedForeignSessionId = null;
|
||||||
function abandonForeignSession(sessionId) {
|
function abandonForeignSession(sessionId) {
|
||||||
if (abandonedForeignSessionId === sessionId || sessionId !== currentSessionId) return;
|
if (abandonedForeignSessionId === sessionId || sessionId !== currentSessionId) return;
|
||||||
@@ -8244,7 +7796,6 @@
|
|||||||
visibleVariant = 0;
|
visibleVariant = 0;
|
||||||
generationPhase = 'queued';
|
generationPhase = 'queued';
|
||||||
parameterGenerationState = 'pending';
|
parameterGenerationState = 'pending';
|
||||||
sessionOrigin = agentTargetForGo ? 'agent' : null;
|
|
||||||
parameterReadyAnnouncedSession = null;
|
parameterReadyAnnouncedSession = null;
|
||||||
resetSessionFileMeta();
|
resetSessionFileMeta();
|
||||||
|
|
||||||
@@ -8270,24 +7821,6 @@
|
|||||||
};
|
};
|
||||||
if (snapshot.comments.length > 0) basePayload.comments = snapshot.comments;
|
if (snapshot.comments.length > 0) basePayload.comments = snapshot.comments;
|
||||||
if (snapshot.strokes.length > 0) basePayload.strokes = snapshot.strokes;
|
if (snapshot.strokes.length > 0) basePayload.strokes = snapshot.strokes;
|
||||||
if (agentTargetForGo) {
|
|
||||||
// An agent-initiated Go names the target it serves (see
|
|
||||||
// actOnAgentTarget): the helper resolves that request from this event
|
|
||||||
// as well as from the overlay's own result post.
|
|
||||||
basePayload.agentTarget = {
|
|
||||||
targetId: agentTargetForGo.targetId,
|
|
||||||
clientId: AGENT_TARGET_CLIENT_ID,
|
|
||||||
result: {
|
|
||||||
ok: true,
|
|
||||||
matchCount: agentTargetForGo.matchCount,
|
|
||||||
sessionId: currentSessionId,
|
|
||||||
action: agentTargetForGo.action,
|
|
||||||
count: agentTargetForGo.count,
|
|
||||||
element: agentTargetForGo.element,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
agentTargetForGo = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Hide the interactive overlay so it doesn't linger during generation.
|
// Hide the interactive overlay so it doesn't linger during generation.
|
||||||
hideAnnotOverlay();
|
hideAnnotOverlay();
|
||||||
@@ -8348,7 +7881,6 @@
|
|||||||
visibleVariant = 0;
|
visibleVariant = 0;
|
||||||
generationPhase = 'queued';
|
generationPhase = 'queued';
|
||||||
parameterGenerationState = 'pending';
|
parameterGenerationState = 'pending';
|
||||||
sessionOrigin = agentTargetForGo ? 'agent' : null;
|
|
||||||
parameterReadyAnnouncedSession = null;
|
parameterReadyAnnouncedSession = null;
|
||||||
resetSessionFileMeta();
|
resetSessionFileMeta();
|
||||||
selectedElement = placeholderElement;
|
selectedElement = placeholderElement;
|
||||||
@@ -9395,8 +8927,6 @@ void main() {
|
|||||||
pagePickSkipClick = false;
|
pagePickSkipClick = false;
|
||||||
currentSessionId = null;
|
currentSessionId = null;
|
||||||
parameterGenerationState = 'idle';
|
parameterGenerationState = 'idle';
|
||||||
sessionOrigin = null;
|
|
||||||
editBadgeSuppressed = false;
|
|
||||||
parameterReadyAnnouncedSession = null;
|
parameterReadyAnnouncedSession = null;
|
||||||
selectedAction = 'impeccable';
|
selectedAction = 'impeccable';
|
||||||
pendingAcceptedSession = null;
|
pendingAcceptedSession = null;
|
||||||
@@ -9488,7 +9018,6 @@ void main() {
|
|||||||
paramsCurrentValues = { ...saved.paramValues };
|
paramsCurrentValues = { ...saved.paramValues };
|
||||||
}
|
}
|
||||||
if (saved.parameterState) parameterGenerationState = saved.parameterState;
|
if (saved.parameterState) parameterGenerationState = saved.parameterState;
|
||||||
sessionOrigin = saved.origin === 'agent' ? 'agent' : null;
|
|
||||||
if (saved.generationPhase) generationPhase = saved.generationPhase;
|
if (saved.generationPhase) generationPhase = saved.generationPhase;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -9576,12 +9105,7 @@ void main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function restoreSessionWithoutWrapper(reason, activeSessions) {
|
function restoreSessionWithoutWrapper(reason, activeSessions) {
|
||||||
// The session cache is per origin, so a tab on another page of the same
|
const cached = loadSession();
|
||||||
// app sees this page's session too. Only the page that saved it may
|
|
||||||
// resume it: the server-adoption branch below already applies the same
|
|
||||||
// check, and a tab on another page has nothing to render for it.
|
|
||||||
const cachedRaw = loadSession();
|
|
||||||
const cached = cachedRaw?.id && !pageMatchesCurrent(cachedRaw.pageUrl) ? null : cachedRaw;
|
|
||||||
// localStorage is a cache, not a gate. A cleared tab, a second browser
|
// localStorage is a cache, not a gate. A cleared tab, a second browser
|
||||||
// profile, or a teardown that dropped local state all leave the durable
|
// profile, or a teardown that dropped local state all leave the durable
|
||||||
// server session as the only record of work in progress; adopt it instead
|
// server session as the only record of work in progress; adopt it instead
|
||||||
@@ -9694,7 +9218,6 @@ void main() {
|
|||||||
pageUrl: location.pathname,
|
pageUrl: location.pathname,
|
||||||
paramValues: { ...paramsCurrentValues },
|
paramValues: { ...paramsCurrentValues },
|
||||||
parameterState: parameterGenerationState,
|
parameterState: parameterGenerationState,
|
||||||
origin: sessionOrigin || undefined,
|
|
||||||
insertPlaceholder: insertPlaceholderSnapshot || undefined,
|
insertPlaceholder: insertPlaceholderSnapshot || undefined,
|
||||||
pickedAnchor: pickedAnchorSnapshot || undefined,
|
pickedAnchor: pickedAnchorSnapshot || undefined,
|
||||||
pickedAnchorViewportTop: Number.isFinite(pickedAnchorViewportTop) ? pickedAnchorViewportTop : undefined,
|
pickedAnchorViewportTop: Number.isFinite(pickedAnchorViewportTop) ? pickedAnchorViewportTop : undefined,
|
||||||
@@ -9820,8 +9343,6 @@ void main() {
|
|||||||
pagePickSkipClick = false;
|
pagePickSkipClick = false;
|
||||||
currentSessionId = null;
|
currentSessionId = null;
|
||||||
parameterGenerationState = 'idle';
|
parameterGenerationState = 'idle';
|
||||||
sessionOrigin = null;
|
|
||||||
editBadgeSuppressed = false;
|
|
||||||
parameterReadyAnnouncedSession = null;
|
parameterReadyAnnouncedSession = null;
|
||||||
selectedAction = 'impeccable';
|
selectedAction = 'impeccable';
|
||||||
renderEditBadge('hidden');
|
renderEditBadge('hidden');
|
||||||
@@ -10080,14 +9601,6 @@ void main() {
|
|||||||
|
|
||||||
const resumedState = arrivedVariants > 0 ? 'CYCLING' : 'GENERATING';
|
const resumedState = arrivedVariants > 0 ? 'CYCLING' : 'GENERATING';
|
||||||
|
|
||||||
// A reload between the variants mounting and the agent's done reply
|
|
||||||
// restores a pending Tune state from the cache; the helper knows whether
|
|
||||||
// that generation already finished.
|
|
||||||
if (arrivedVariants >= expectedVariants && expectedVariants > 0
|
|
||||||
&& (parameterGenerationState === 'pending' || parameterGenerationState === 'loading')) {
|
|
||||||
settleParameterStateFromHelper(sessionId);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find the visible variant's content element for highlight positioning.
|
// Find the visible variant's content element for highlight positioning.
|
||||||
const isInsert = wrapper.dataset.impeccableMode === 'insert';
|
const isInsert = wrapper.dataset.impeccableMode === 'insert';
|
||||||
const visEl = visibleVariant > 0 ? pickVariantContent(wrapper, visibleVariant) : null;
|
const visEl = visibleVariant > 0 ? pickVariantContent(wrapper, visibleVariant) : null;
|
||||||
@@ -11552,21 +11065,6 @@ void main() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// After a resume the cache may say the Tune knobs are still coming while
|
|
||||||
// the agent already replied done before the reload. The helper's session
|
|
||||||
// record settles it; otherwise the done reply on SSE does.
|
|
||||||
function settleParameterStateFromHelper(sessionId) {
|
|
||||||
fetch('http://localhost:' + PORT + '/status?token=' + TOKEN, { cache: 'no-store' })
|
|
||||||
.then((res) => (res.ok ? res.json() : null))
|
|
||||||
.then((data) => {
|
|
||||||
if (!data || sessionId !== currentSessionId) return;
|
|
||||||
const session = (data.activeSessions || []).find((s) => s && s.id === sessionId);
|
|
||||||
if (!session) return;
|
|
||||||
if (session.generationCompletedAt || session.generationPhase === 'completed') completeParameterGenerationIfReady();
|
|
||||||
})
|
|
||||||
.catch(() => { /* the done reply on SSE settles it otherwise */ });
|
|
||||||
}
|
|
||||||
|
|
||||||
function fetchAgentPollingStatus() {
|
function fetchAgentPollingStatus() {
|
||||||
fetch('http://localhost:' + PORT + '/status?token=' + TOKEN, { cache: 'no-store' })
|
fetch('http://localhost:' + PORT + '/status?token=' + TOKEN, { cache: 'no-store' })
|
||||||
.then((res) => (res.ok ? res.json() : null))
|
.then((res) => (res.ok ? res.json() : null))
|
||||||
@@ -11606,15 +11104,11 @@ void main() {
|
|||||||
uiAppendStyle(s);
|
uiAppendStyle(s);
|
||||||
}
|
}
|
||||||
|
|
||||||
// The generate lane's helper says so in the served script itself, so a
|
|
||||||
// lane session never draws the bar at all; every other session mounts
|
|
||||||
// it exactly as before.
|
|
||||||
const barHiddenFromStart = window.__IMPECCABLE_LIVE_BAR_HIDDEN__ === true;
|
|
||||||
globalBarEl = el('div', {
|
globalBarEl = el('div', {
|
||||||
position: 'fixed', bottom: '14px', left: '50%',
|
position: 'fixed', bottom: '14px', left: '50%',
|
||||||
transform: 'translateX(-50%) translateY(20px)',
|
transform: 'translateX(-50%) translateY(20px)',
|
||||||
zIndex: Z.bar + 5,
|
zIndex: Z.bar + 5,
|
||||||
display: barHiddenFromStart ? 'none' : 'flex', alignItems: 'stretch',
|
display: 'flex', alignItems: 'stretch',
|
||||||
gap: '0',
|
gap: '0',
|
||||||
width: 'max-content',
|
width: 'max-content',
|
||||||
background: P.surface,
|
background: P.surface,
|
||||||
@@ -11630,10 +11124,6 @@ void main() {
|
|||||||
});
|
});
|
||||||
globalBarEl.id = PREFIX + '-global-bar';
|
globalBarEl.id = PREFIX + '-global-bar';
|
||||||
globalBarEl.dataset.theme = theme;
|
globalBarEl.dataset.theme = theme;
|
||||||
if (barHiddenFromStart) {
|
|
||||||
liveBarHiddenByHelper = true;
|
|
||||||
globalBarEl.dataset.liveBarDisplay = 'flex';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Brand mark - kinpaku Impeccable icon (site header / favicon paths).
|
// Brand mark - kinpaku Impeccable icon (site header / favicon paths).
|
||||||
const brand = el('span', {
|
const brand = el('span', {
|
||||||
@@ -12029,9 +11519,6 @@ void main() {
|
|||||||
// Listen for detection results AND ready signal
|
// Listen for detection results AND ready signal
|
||||||
window.addEventListener('message', onDetectMessage);
|
window.addEventListener('message', onDetectMessage);
|
||||||
updateGlobalBarState();
|
updateGlobalBarState();
|
||||||
// The helper may already have said the bar stays hidden (a connect
|
|
||||||
// that raced the bar build, or a reload mid-lane): re-apply it here.
|
|
||||||
if (liveBarHiddenByHelper) setLiveBarHidden(true);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateGlobalBarState() {
|
function updateGlobalBarState() {
|
||||||
@@ -12228,13 +11715,6 @@ void main() {
|
|||||||
|
|
||||||
/** Full teardown: remove all UI, disconnect SSE, clean up. */
|
/** Full teardown: remove all UI, disconnect SSE, clean up. */
|
||||||
function teardown() {
|
function teardown() {
|
||||||
// Declined targets die with the overlay: the IDLE transition below must
|
|
||||||
// not re-claim a lease this page can no longer act on. So does the
|
|
||||||
// target ledger: an 'acting' entry from a Go that never happened must
|
|
||||||
// not refuse every target the next connection hears.
|
|
||||||
busyDeclinedTargets.clear();
|
|
||||||
agentTargetsSeen.clear();
|
|
||||||
liveBarHiddenByHelper = false;
|
|
||||||
stopAgentStatusPoll();
|
stopAgentStatusPoll();
|
||||||
hideAgentPollTooltip();
|
hideAgentPollTooltip();
|
||||||
if (agentPollTooltipEl) {
|
if (agentPollTooltipEl) {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"$schema": "https://anthropic.com/claude-code/marketplace.schema.json",
|
"$schema": "https://anthropic.com/claude-code/marketplace.schema.json",
|
||||||
"name": "impeccable",
|
"name": "impeccable",
|
||||||
"metadata": {
|
"metadata": {
|
||||||
"description": "Design fluency for AI harnesses. 1 skill, 24 commands, and curated anti-patterns for impeccable frontend design."
|
"description": "Design fluency for AI harnesses. 1 skill, 23 commands, and curated anti-patterns for impeccable frontend design."
|
||||||
},
|
},
|
||||||
"owner": {
|
"owner": {
|
||||||
"name": "Paul Bakaus",
|
"name": "Paul Bakaus",
|
||||||
@@ -11,8 +11,8 @@
|
|||||||
"plugins": [
|
"plugins": [
|
||||||
{
|
{
|
||||||
"name": "impeccable",
|
"name": "impeccable",
|
||||||
"description": "Design fluency for frontend development. 1 skill with 24 commands (/impeccable polish, /impeccable audit, /impeccable critique, etc.) and curated anti-pattern detection.",
|
"description": "Design fluency for frontend development. 1 skill with 23 commands (/impeccable polish, /impeccable audit, /impeccable critique, etc.) and curated anti-pattern detection.",
|
||||||
"version": "4.3.1",
|
"version": "4.3.0",
|
||||||
"author": {
|
"author": {
|
||||||
"name": "Paul Bakaus",
|
"name": "Paul Bakaus",
|
||||||
"email": "paul@paulbakaus.com"
|
"email": "paul@paulbakaus.com"
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "impeccable",
|
"name": "impeccable",
|
||||||
"description": "Design fluency for frontend development. 1 skill with 24 commands (/impeccable polish, /impeccable audit, /impeccable critique, etc.) and curated anti-pattern detection.",
|
"description": "Design fluency for frontend development. 1 skill with 23 commands (/impeccable polish, /impeccable audit, /impeccable critique, etc.) and curated anti-pattern detection.",
|
||||||
"version": "4.3.1",
|
"version": "4.3.0",
|
||||||
"author": {
|
"author": {
|
||||||
"name": "Paul Bakaus",
|
"name": "Paul Bakaus",
|
||||||
"email": "paul@paulbakaus.com"
|
"email": "paul@paulbakaus.com"
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
---
|
---
|
||||||
name: impeccable
|
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.
|
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: 4.3.1
|
version: 4.3.0
|
||||||
user-invocable: true
|
user-invocable: true
|
||||||
argument-hint: "[shape · audit|critique · animate|bolder|colorize|delight|layout|overdrive|quieter|typeset · adapt|clarify|distill · harden|onboard|optimize|polish · init|document|extract|live|generate] [target]"
|
argument-hint: "[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
|
license: Apache 2.0
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -65,8 +65,7 @@ Choose the mode from the requested surface, not the product, and persist it only
|
|||||||
| `clarify [target]` | Fix | Improve UX copy, labels, and error messages | [reference/clarify.md](reference/clarify.md) |
|
| `clarify [target]` | Fix | Improve UX copy, labels, and error messages | [reference/clarify.md](reference/clarify.md) |
|
||||||
| `adapt [target]` | Fix | Adapt for different devices and screen sizes | [reference/adapt.md](reference/adapt.md) · native: [reference/adapt.native.md](reference/adapt.native.md) |
|
| `adapt [target]` | Fix | Adapt for different devices and screen sizes | [reference/adapt.md](reference/adapt.md) · native: [reference/adapt.native.md](reference/adapt.native.md) |
|
||||||
| `optimize [target]` | Fix | Diagnose and fix UI performance | [reference/optimize.md](reference/optimize.md) |
|
| `optimize [target]` | Fix | Diagnose and fix UI performance | [reference/optimize.md](reference/optimize.md) |
|
||||||
| `live` | Iterate | Visual variant mode: pick elements in the browser, iterate on alternatives | [reference/live.md](reference/live.md) |
|
| `live` | Iterate | Visual variant mode: pick elements in the browser, generate alternatives | [reference/live.md](reference/live.md) |
|
||||||
| `generate [n] [action] [element]` | Iterate | Variants, versions, or alternatives of a named element to choose from in the live browser; no manual picking | [reference/generate.md](reference/generate.md) |
|
|
||||||
|
|
||||||
Routing:
|
Routing:
|
||||||
|
|
||||||
|
|||||||
@@ -188,12 +188,6 @@ Test thoroughly across contexts:
|
|||||||
- **Edge cases**: Very small screens (320px), very large screens (4K)
|
- **Edge cases**: Very small screens (320px), very large screens (4K)
|
||||||
- **Slow connections**: Test on throttled network
|
- **Slow connections**: Test on throttled network
|
||||||
|
|
||||||
**Custom controls** (sliders, drag surfaces, scrollable control strips): a before/after slider can pass every width check above and still refuse to drag on iOS, so exercise each one in scope in the same batched round as the checks above:
|
|
||||||
|
|
||||||
- **Primary gesture**: Tap it and confirm it responds as designed, then drag it with the target input method; the drag must complete, not just start
|
|
||||||
- **Scroll across it**: A swipe along the page's scroll axis across the control scrolls the page or container without activating it; a drag that starts on the control along its axis moves the control, not the page. Neither failure throws an error, so try both
|
|
||||||
- **Evidence**: Say what produced the evidence: an emulated viewport, synthesized touch input through a browser tool, which engine ran it (Chromium is not Safari), or a physical device. Screenshots and resized viewports verify layout, never a gesture. Name what stayed untested and move on; unreachable hardware is a reported gap, not a blocker
|
|
||||||
|
|
||||||
When the adaptation feels native to each context, hand off to `/impeccable polish` for the final pass.
|
When the adaptation feels native to each context, hand off to `/impeccable polish` for the final pass.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -48,12 +48,11 @@ Run comprehensive checks across 5 dimensions. Score each dimension 0-4 using the
|
|||||||
**Check for**:
|
**Check for**:
|
||||||
- **Fixed widths**: Hard-coded widths that break on mobile
|
- **Fixed widths**: Hard-coded widths that break on mobile
|
||||||
- **Touch targets**: Interactive elements < 44x44px
|
- **Touch targets**: Interactive elements < 44x44px
|
||||||
- **Broken touch interaction**: Custom sliders, drag surfaces, and scrollable control strips whose primary gesture fails under touch, that swallow page scroll or lose the drag to it, or that stay stuck after an interrupted gesture. Code tells: mouse-only handlers, no `touch-action` on a pointer-event drag surface, drag state that nothing clears on cancel, lost capture, or blur. Exercise the gesture when a browser tool can synthesize touch (a rendered viewport proves layout, not the gesture), then say what produced the evidence (emulated viewport, synthesized touch, which engine, physical device) and what stayed untested
|
|
||||||
- **Horizontal scroll**: Content overflow on narrow viewports
|
- **Horizontal scroll**: Content overflow on narrow viewports
|
||||||
- **Text scaling**: Layouts that break when text size increases
|
- **Text scaling**: Layouts that break when text size increases
|
||||||
- **Missing breakpoints**: No mobile/tablet variants
|
- **Missing breakpoints**: No mobile/tablet variants
|
||||||
|
|
||||||
**Score 0-4**: 0=Desktop-only (breaks on mobile), 1=Major issues (some breakpoints, many failures), 2=Partial (works on mobile, rough edges), 3=Good (responsive, minor touch target or overflow issues), 4=Excellent (fluid, all viewports, proper touch targets, gestures work under touch)
|
**Score 0-4**: 0=Desktop-only (breaks on mobile), 1=Major issues (some breakpoints, many failures), 2=Partial (works on mobile, rough edges), 3=Good (responsive, minor touch target or overflow issues), 4=Excellent (fluid, all viewports, proper touch targets)
|
||||||
|
|
||||||
### 5. Implementation Integrity (CRITICAL)
|
### 5. Implementation Integrity (CRITICAL)
|
||||||
|
|
||||||
|
|||||||
@@ -1,101 +0,0 @@
|
|||||||
> **Additional context needed**: only the target element, when the request does not name one that resolves uniquely on the page.
|
|
||||||
|
|
||||||
Generate is the fast lane into live mode: the user names an element, a direction, and a count in one sentence, and within a minute they are cycling through variants in their browser. One command boots the helper, hands the element to the overlay in the page your harness already shows (it scrolls to it, selects it, and fires the same Go a click fires) and returns the generate event; one edit writes the variants; one call replies and waits for the user's choice, which the helper bakes into source itself. This file owns the lane's plumbing; from the event onward the design work is [live.md](live.md)'s, unchanged, so read it in full now if you have not this session.
|
|
||||||
|
|
||||||
**Web only.** Live mode's browser overlay has no native equivalent; on `ios` / `android` / `adaptive` projects, decline this command and offer `bolder` or `quieter` on the source instead.
|
|
||||||
|
|
||||||
The plumbing is where the lane saves time: one command starts the session around the page your harness already shows, one call replies and waits, and nothing here is a browser you have to babysit. The design work is not where it saves time. Setup runs as for any command (`impeccable context`, this reference, craft-floor.md before the edit), and the variants are planned, written, and accepted exactly the way a live session plans, writes, and accepts them.
|
|
||||||
|
|
||||||
Three prohibitions cover the known ways this command goes wrong:
|
|
||||||
|
|
||||||
- **Never run init or document, and never ask for PRODUCT.md or DESIGN.md.** When they exist, the start command prints them under `boot` and you use them. When they do not, it says so (`contextMissing`, `contextNote`) and you extract the identity from the event (Step 3). A missing file is never a reason to interview the user inside this command; offer `init` in one line after the session ends.
|
|
||||||
- **Never hand-write a variants wrapper or invent a session id.** Only the browser mints session ids (8 hex characters, at Go). A missing event is fixed by rerunning Step 2, never with a direct source edit.
|
|
||||||
- **Do not act on hook findings while live markers are in the file**, and do not restyle variants to appease them; the accept verifies the file once the variant is permanent.
|
|
||||||
|
|
||||||
## Step 1: Parse the request
|
|
||||||
|
|
||||||
Three parts, all from the user's sentence:
|
|
||||||
|
|
||||||
- **A number in the request**: that is the count. **No number**: 3. The protocol caps count at 8.
|
|
||||||
- **The direction wording** maps onto the live action vocabulary; never invent a new action value:
|
|
||||||
- **bold, bolder, stronger, punchier**: `bolder`
|
|
||||||
- **quiet, calmer, softer, toned down**: `quieter`
|
|
||||||
- **simpler, minimal, stripped**: `distill`
|
|
||||||
- **refined, tightened, polished**: `polish`
|
|
||||||
- **font and type words**: `typeset`
|
|
||||||
- **color words**: `colorize`
|
|
||||||
- **arrangement and spacing words**: `layout`
|
|
||||||
- **device and breakpoint words**: `adapt`
|
|
||||||
- **motion words**: `animate`
|
|
||||||
- **playful words**: `delight`
|
|
||||||
- **rule-breaking words**: `overdrive`
|
|
||||||
- **Wording that carries intent but no vocabulary word** ("make it feel like a bank", "warmer", "more premium"): `impeccable`, with the user's wording passed as the prompt.
|
|
||||||
- **An action fits AND extra intent rides along** ("bolder, but keep it monochrome"): that action, with the rest as the prompt.
|
|
||||||
- **The wording names no direction at all** ("better", "improve", "nicer", "different", "fresh", "new", "redesign", "fix", "some options", "ideas", "alternatives", or just "variants" with nothing else): STOP and call the AskUserQuestion tool to clarify. Ask one question, offering the vocabulary: *"Which direction should the variants take? bolder, quieter, simpler (distill), polished, typography (typeset), color (colorize), layout, motion (animate), playful (delight), or rule-breaking (overdrive)."* Map the answer with this list; an answer that is still open ("surprise me", "you pick") is `impeccable` with the user's original wording as the prompt, and Step 2 starts on that answer.
|
|
||||||
- **The element description** ("the pricing cards", "the hero heading"): Step 2 resolves it to a selector.
|
|
||||||
|
|
||||||
Done when you hold an action from the vocabulary (asked for, when the request named no direction), a count from 1 to 8, and the element description.
|
|
||||||
|
|
||||||
## Step 2: Reuse the page, then start
|
|
||||||
|
|
||||||
**Reuse** the dev server already running and the tab your harness already shows it in; a second server or a second browser window is the failure this step prevents.
|
|
||||||
|
|
||||||
1. **Find the dev server**, cheapest source first, and stop at the first hit: the user's message, a browser tab already on the app (Claude Code: an origin in `tabs_context`), a server your harness started (Claude Code: `preview_list`), a terminal that printed its URL. Its origin is your `--dev-url`. **No hit**: leave `--dev-url` off and run the start command with no wait; the boot probes for a running server and its verdict names the move. `browser_needed` carries the `devUrl` it found: open it as in 2, then rerun with `--dev-url <devUrl> --wait-for-browser 60000`. `no_dev_server` means nothing serves the app: start the dev script the way the verdict says (Claude Code: `preview_start`; Cursor: a background terminal; Codex: an exec you yield from), wait for its URL, then rerun with `--dev-url <url>`.
|
|
||||||
2. **Open the page that renders the element in your browser, then start.** The route the request names, else the one `--target` serves; `--dev-url` takes only the origin.
|
|
||||||
- **Cursor** (`browser_navigate`) and **Claude Code** (`navigate`, which opens the Browser pane when it is closed and takes the `tabId` from `tabs_context` when a tab is already on that origin): open the URL, then run the start command with `--dev-url <url> --wait-for-browser 60000`. The boot injects the overlay and the page reloads into it while the command waits. Your browser tool is the only opener on these harnesses; the engine ignores `--open` there.
|
|
||||||
- **No browser tool** (Codex, others): run the start command with `--open --wait-for-browser 120000`; it opens the system browser, and the longer wait covers the user finding the tab. **`browser_open_failed` back**: tell the user the `url` in one line and rerun with `--wait-for-browser 120000`.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
.claude/skills/impeccable/scripts/impeccable live-generate --target src/App.jsx --dev-url http://127.0.0.1:5173/ --selector ".pricing-grid" --action bolder --count 3 --boot --wait-for-browser 60000
|
|
||||||
```
|
|
||||||
|
|
||||||
Run it in the foreground in Cursor and Claude Code (it returns within the wait); on Codex, in an exec you yield from, the way Step 3 runs the poll.
|
|
||||||
|
|
||||||
- `--target`: the file that renders the element when the request or the project makes it obvious; skip it otherwise.
|
|
||||||
- `--dev-url`: the origin from 1; omit it and the boot probes.
|
|
||||||
- `--selector`: a unique class first, then a landmark tag plus class, an id last (every variant mounts a copy of the element, so an id repeats in the DOM). **The request names a repeated component in plural** ("the pricing cards"): target the container that holds the set, so one scoped stylesheet restyles every instance. One read of the source file that renders the element is allowed when the selector is not obvious; `--dry-run` resolves and reports without starting anything when it is not certain.
|
|
||||||
- `--boot`: runs the lane's boot (PRODUCT.md and DESIGN.md loaded again for the helper, missing files tolerated, dev URL found, bottom bar hidden for the helper's lifetime) and reuses a helper that is already running. Its result rides along as `boot`.
|
|
||||||
- Also available: `--prompt`, `--text` (keep only matches whose visible text contains a snippet), `--index` (1-based pick among matches).
|
|
||||||
|
|
||||||
Read the output in this order: `boot` (or `boot.contextMissing` with `boot.contextNote`: the page is the source of truth, per the note), then `event`, the generate event for `sessionId`, with the same `_instructions` a user's Go gets. Every verdict carries `_instructions`, and they win over your recollection of this file; the ones whose move is a decision of yours:
|
|
||||||
|
|
||||||
- **`ambiguous`**: the candidates are listed; target their common container, or rerun with `--text "<visible text>"` or `--index <n>`.
|
|
||||||
- **`dev_server_gone`**: the dev server stopped answering while the command waited for the page (on Cursor, a server another chat started dies with that chat). Start it the way the verdict says, then rerun with `--dev-url <url>`.
|
|
||||||
- **`no_match`**: the tab is on a route that does not render the element (navigate to the right route, rerun), or the selector is wrong (derive a better one from the source, or add `--text`).
|
|
||||||
- **`config_missing` / `config_invalid`** under `bootError`: follow [live-setup.md](live-setup.md) first, then rerun.
|
|
||||||
- **`event: null`** with `ok: true`: the event was slower than the wait; run `.claude/skills/impeccable/scripts/impeccable live-poll` once to collect it, then continue.
|
|
||||||
|
|
||||||
Done when the output shows `ok: true`, a `sessionId`, and an `event`, reached with at most one server started and one tab opened by you.
|
|
||||||
|
|
||||||
## Step 3: Generate
|
|
||||||
|
|
||||||
The event is a standard `generate` event: the picked element's context, a preflighted scaffold, and `_instructions` naming the action's reference, the planning section, and the exact splice. Handle it exactly per live.md's **Handle generate**, which owns everything from the identity lock to the done reply: read the action's reference and craft-floor.md as it says, plan per section 4 (identity first, then mode, then three different primary axes, then the squint test), declare knobs per section 7, and deliver per section 6 (a complete replacement of the element per variant, the preview CSS plus every variant in one edit at the scaffold's splice). The lane changes nothing about what a variant may be: the moves a live session would make on this element (a promoted tier, a restructured set, a reordered card, a different surface) are open here too. Never screenshot the page; the overlay preview is the review channel until accept.
|
|
||||||
|
|
||||||
**Reply and wait in one call**, with the file you wrote:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
.claude/skills/impeccable/scripts/impeccable live-poll --reply EVENT_ID done --file src/App.jsx --then-poll
|
|
||||||
```
|
|
||||||
|
|
||||||
This replies done (the browser mounts the variants) and then blocks until the user's choice arrives, so run it the way your harness runs a long wait: **Claude Code** in the foreground with your tool's longest timeout (600000 ms), so you are paused until the choice arrives; **Codex** in a yielded foreground exec; **Cursor** in a background terminal with notify on `"type":"(accept|discard|variant_mount_failed|exit)"`. Never pass a short `--timeout=`. While it runs there is nothing else to do: never sleep and never poll its output on a timer; a harness that backgrounds it wakes you when it returns. `{"type":"timeout"}` means the user has not chosen yet: run `live-poll` again and keep waiting. If the edit fails after the browser flipped to GENERATING, `--reply EVENT_ID error "Short reason"` (without `--then-poll`) so the bar resets.
|
|
||||||
|
|
||||||
Then tell the user, in one line, where their variants are: *"Three [bolder] variants are live on [the pricing cards]: cycle with the floating bar's arrows, adjust the Tune knobs, and Accept the keeper."*
|
|
||||||
|
|
||||||
Outside the replace path, read the matching live.md section before acting: `scaffold.previewMode: "svelte-component"` (Svelte previews are edited as components, and their accept is mechanical), `mode: "insert"`, `variant_mount_failed`, `steer`, `manual_edit_apply`, and any `fallback: "agent-driven"` wrap error.
|
|
||||||
|
|
||||||
## Step 4: Accept and close
|
|
||||||
|
|
||||||
The call from Step 3 returns the user's choice. **`discard`**: nothing to do. **`accept`**: `_acceptResult.carbonize: true` is the normal case, and the cleanup is live.md's **Required after accept**, unchanged: move the accepted variant's rules into the stylesheet that already owns the element with real selectors, bake the chosen knob values in, unwrap the element and drop every `data-impeccable-*` attribute, delete the inline `<style>` block and both `impeccable-carbonize` markers, then `.claude/skills/impeccable/scripts/impeccable live-complete --id SESSION_ID` and confirm `phase: "completed"`. (`baked: true` appears only when the accept was run with `--bake`; then the helper already made the variant permanent and no `live-complete` is owed.)
|
|
||||||
|
|
||||||
Close without being asked, the moment the choice is handled:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
.claude/skills/impeccable/scripts/impeccable live-server stop
|
|
||||||
```
|
|
||||||
|
|
||||||
Stopping removes the injected script and reloads the page once: the user sees the accepted design with no overlay chrome, still served by their dev server. **Never kill or restart the dev server**, including one you started in Step 2.
|
|
||||||
|
|
||||||
- **The user asks for more variants before you closed**: skip the close, run Step 2 again for the next element (the helper is reused), and close after the last choice.
|
|
||||||
- **Interrupted or unsure of the state**: `.claude/skills/impeccable/scripts/impeccable live-status`, then `live-resume`; the journal under `.impeccable/live/sessions/` is canonical.
|
|
||||||
|
|
||||||
Done when the helper is stopped and the dev site still answers with the accepted design.
|
|
||||||
@@ -205,11 +205,6 @@ t('items', { count }) // Handles complex plural rules
|
|||||||
- Optimistic updates with rollback
|
- Optimistic updates with rollback
|
||||||
- Conflict resolution
|
- Conflict resolution
|
||||||
|
|
||||||
**Interrupted gestures** (custom sliders, drag surfaces, scrollable control strips):
|
|
||||||
- A second finger or pointer lands mid-drag: the first drag keeps its pointer or ends cleanly, never jumps to the new one
|
|
||||||
- The browser cancels the gesture to scroll (`pointercancel`), capture is lost (`lostpointercapture`), the pointer is released outside the control, or the window loses focus (`blur`) mid-drag: clear the dragging state and release capture
|
|
||||||
- After each of these, the next tap or drag works without a reload
|
|
||||||
|
|
||||||
**Permission states**:
|
**Permission states**:
|
||||||
- No permission to view
|
- No permission to view
|
||||||
- No permission to edit
|
- No permission to edit
|
||||||
@@ -309,7 +304,6 @@ const throttledScroll = throttle(handleScroll, 100);
|
|||||||
- Unit tests for edge cases
|
- Unit tests for edge cases
|
||||||
- Integration tests for error scenarios
|
- Integration tests for error scenarios
|
||||||
- E2E tests for critical paths
|
- E2E tests for critical paths
|
||||||
- A behavioral regression for each confirmed gesture fix, when the project's test runner can drive input
|
|
||||||
- Visual regression tests
|
- Visual regression tests
|
||||||
- Accessibility tests (axe, WAVE)
|
- Accessibility tests (axe, WAVE)
|
||||||
|
|
||||||
@@ -336,10 +330,7 @@ Test thoroughly with edge cases:
|
|||||||
- **Network issues**: Disable internet, throttle connection
|
- **Network issues**: Disable internet, throttle connection
|
||||||
- **Large datasets**: Test with 1000+ items
|
- **Large datasets**: Test with 1000+ items
|
||||||
- **Concurrent actions**: Click submit 10 times rapidly
|
- **Concurrent actions**: Click submit 10 times rapidly
|
||||||
- **Interrupted gestures**: Add a second finger mid-drag, scroll across the control, release outside it, switch windows mid-drag; then drag again
|
|
||||||
- **Errors**: Force API errors, test all error states
|
- **Errors**: Force API errors, test all error states
|
||||||
- **Empty**: Remove all data, test empty states
|
- **Empty**: Remove all data, test empty states
|
||||||
|
|
||||||
For gestures, say what produced the evidence (emulated viewport, synthesized touch, which engine, physical device) and name what stayed untested.
|
|
||||||
|
|
||||||
When edge cases are covered, hand off to `/impeccable polish` for the final pass.
|
When edge cases are covered, hand off to `/impeccable polish` for the final pass.
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ Reason over the signals; there is no score to obey:
|
|||||||
- `critique.latest` is `null` → the project has never been critiqued; for a set-up project with a real surface, offering `/impeccable critique <surface>` is a strong default.
|
- `critique.latest` is `null` → the project has never been critiqued; for a set-up project with a real surface, offering `/impeccable critique <surface>` is a strong default.
|
||||||
- `critique.latest` with a low `score` or non-zero `p0` / `p1` → `polish` (it reads that snapshot as its backlog and closes it when stale or cleared).
|
- `critique.latest` with a low `score` or non-zero `p0` / `p1` → `polish` (it reads that snapshot as its backlog and closes it when stale or cleared).
|
||||||
- `git.changedFiles` pointing at one surface → scope `audit` or `polish` to those files specifically, naming them.
|
- `git.changedFiles` pointing at one surface → scope `audit` or `polish` to those files specifically, naming them.
|
||||||
- `devServer.running` true → `live` is available for in-browser iteration, and `generate` for one-shot variant runs on a named element; if false, don't lead with either. **`live`, `generate`, and the bundled `impeccable detect` are web-only.** If `setup.platform` is `ios`, `android`, or `adaptive`, don't lead with any of them; the browser overlay and the HTML rule engine don't apply to native app code.
|
- `devServer.running` true → `live` is available for in-browser iteration; if false, don't lead with `live`. **`live` and the bundled `impeccable detect` are web-only.** If `setup.platform` is `ios`, `android`, or `adaptive`, don't lead with either; the browser overlay and the HTML rule engine don't apply to native app code.
|
||||||
- Otherwise group by intent (build new / improve what's there / iterate visually), tailored to the current surface and `setup.platform`.
|
- Otherwise group by intent (build new / improve what's there / iterate visually), tailored to the current surface and `setup.platform`.
|
||||||
|
|
||||||
**If `scan.targets` is non-empty and `setup.platform` is not `ios`/`android`/`adaptive`, run `.claude/skills/impeccable/scripts/impeccable detect --json <scan.targets joined by spaces>` once** (the bundled detector over local files: no network, no npx; it reads HTML/CSS, so skip it for native projects). `scan.via` tells you what they are: `git-changes` (the markup/style files in your dirty tree, the most relevant set), `source-dir` (e.g. `src`, `app`), `html`, or `root`. Fold the hits into your picks: many quality / contrast hits → `audit` or `polish`; a specific slop family → the matching command (gradient text or eyebrows → `quieter` / `typeset`, flat or gray palette → `colorize`, and so on). It's a real, current signal that beats guessing. If detect errors or the tree is large and slow, skip it and recommend the user run `audit` themselves; never block the suggestion on it.
|
**If `scan.targets` is non-empty and `setup.platform` is not `ios`/`android`/`adaptive`, run `.claude/skills/impeccable/scripts/impeccable detect --json <scan.targets joined by spaces>` once** (the bundled detector over local files: no network, no npx; it reads HTML/CSS, so skip it for native projects). `scan.via` tells you what they are: `git-changes` (the markup/style files in your dirty tree, the most relevant set), `source-dir` (e.g. `src`, `app`), `html`, or `root`. Fold the hits into your picks: many quality / contrast hits → `audit` or `polish`; a specific slop family → the matching command (gradient text or eyebrows → `quieter` / `typeset`, flat or gray palette → `colorize`, and so on). It's a real, current signal that beats guessing. If detect errors or the tree is large and slow, skip it and recommend the user run `audit` themselves; never block the suggestion on it.
|
||||||
|
|||||||
@@ -19,10 +19,6 @@
|
|||||||
"description": "Interactive live variant mode. Select elements in the browser, pick a design action, and get AI-generated HTML+CSS variants hot-swapped via HMR. Requires a running dev server. Use when you want to visually experiment with design alternatives in real time.",
|
"description": "Interactive live variant mode. Select elements in the browser, pick a design action, and get AI-generated HTML+CSS variants hot-swapped via HMR. Requires a running dev server. Use when you want to visually experiment with design alternatives in real time.",
|
||||||
"argumentHint": ""
|
"argumentHint": ""
|
||||||
},
|
},
|
||||||
"generate": {
|
|
||||||
"description": "Agent-driven live variant generation. Boots live mode, finds the named element on the open page, scrolls the browser to it, and delivers N variants in the requested direction for the user to cycle and accept. Use for requests that name an element and a direction, like 'generate 3 bold variants of the pricing cards', skipping manual element picking.",
|
|
||||||
"argumentHint": "[count] [direction] variants of [element]"
|
|
||||||
},
|
|
||||||
"adapt": {
|
"adapt": {
|
||||||
"description": "Adapt designs to work across different screen sizes, devices, contexts, or platforms. Implements breakpoints, fluid layouts, and touch targets. Use when the user mentions responsive design, mobile layouts, breakpoints, viewport adaptation, or cross-device compatibility.",
|
"description": "Adapt designs to work across different screen sizes, devices, contexts, or platforms. Implements breakpoints, fluid layouts, and touch targets. Use when the user mentions responsive design, mobile layouts, breakpoints, viewport adaptation, or cross-device compatibility.",
|
||||||
"argumentHint": "[target] [context (mobile, tablet, print...)]"
|
"argumentHint": "[target] [context (mobile, tablet, print...)]"
|
||||||
|
|||||||
@@ -87,11 +87,6 @@ fi
|
|||||||
# Last resort: fetch this version's binary for the current platform from the
|
# Last resort: fetch this version's binary for the current platform from the
|
||||||
# public release channel into the user cache. Needs network; sandboxes without
|
# public release channel into the user cache. Needs network; sandboxes without
|
||||||
# egress preinstall the binary on PATH instead.
|
# egress preinstall the binary on PATH instead.
|
||||||
setup_help() {
|
|
||||||
echo "Engine $version setup needs network access and write permission to $cache_root/bin/$version." >&2
|
|
||||||
echo "Run this launcher ($0) with engine-probe in a terminal that has those permissions, then retry the original command." >&2
|
|
||||||
echo "Alternatively, set IMPECCABLE_HOME to a writable cache location, or IMPECCABLE_BIN to a preinstalled engine binary." >&2
|
|
||||||
}
|
|
||||||
fetch_url() {
|
fetch_url() {
|
||||||
if command -v curl >/dev/null 2>&1; then
|
if command -v curl >/dev/null 2>&1; then
|
||||||
curl -fsSL --retry 2 -o "$tmp" "$1" 2>/dev/null
|
curl -fsSL --retry 2 -o "$tmp" "$1" 2>/dev/null
|
||||||
@@ -124,18 +119,7 @@ if [ -n "$version" ] && [ "$os" != unknown ] && [ "$arch" != unknown ]; then
|
|||||||
[ "$os" = windows ] && asset="$asset.exe"
|
[ "$os" = windows ] && asset="$asset.exe"
|
||||||
url="$base/engine-v$version/$asset"
|
url="$base/engine-v$version/$asset"
|
||||||
tmp="$cache_root/bin/$version/.impeccable.part.$$"
|
tmp="$cache_root/bin/$version/.impeccable.part.$$"
|
||||||
if ! mkdir -p "$cache_root/bin/$version" 2>/dev/null; then
|
mkdir -p "$cache_root/bin/$version" 2>/dev/null
|
||||||
echo "impeccable: engine $version is not installed; cannot create cache directory: $cache_root/bin/$version" >&2
|
|
||||||
setup_help
|
|
||||||
exit 127
|
|
||||||
fi
|
|
||||||
# Check the actual staging file, not just directory existence: a cache from
|
|
||||||
# an earlier run can be readable but no longer writable inside a sandbox.
|
|
||||||
if ! (umask 077; : > "$tmp") 2>/dev/null; then
|
|
||||||
echo "impeccable: engine $version is not installed; cannot write to cache directory: $cache_root/bin/$version" >&2
|
|
||||||
setup_help
|
|
||||||
exit 127
|
|
||||||
fi
|
|
||||||
fetched=0
|
fetched=0
|
||||||
if fetch_url "$url"; then
|
if fetch_url "$url"; then
|
||||||
fetched=1
|
fetched=1
|
||||||
@@ -196,9 +180,6 @@ if [ -n "$version" ] && [ "$os" != unknown ] && [ "$arch" != unknown ]; then
|
|||||||
exec "$cached" "$@"
|
exec "$cached" "$@"
|
||||||
fi
|
fi
|
||||||
rm -f "$tmp" 2>/dev/null
|
rm -f "$tmp" 2>/dev/null
|
||||||
echo "impeccable: could not download engine $version from $url; check network access, the release URL, and curl or wget availability." >&2
|
|
||||||
setup_help
|
|
||||||
exit 127
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "impeccable: no engine binary for $os-$arch found (looked in $bin, $cached, PATH)." >&2
|
echo "impeccable: no engine binary for $os-$arch found (looked in $bin, $cached, PATH)." >&2
|
||||||
|
|||||||
@@ -68,27 +68,18 @@ rem another launcher's probe: fail fast and quiet instead.
|
|||||||
if defined IMPECCABLE_LAUNCHER_PROBE exit /b 127
|
if defined IMPECCABLE_LAUNCHER_PROBE exit /b 127
|
||||||
if not defined version goto fail
|
if not defined version goto fail
|
||||||
where curl.exe >nul 2>nul
|
where curl.exe >nul 2>nul
|
||||||
if errorlevel 1 goto curl_missing
|
if errorlevel 1 goto fail
|
||||||
if not defined IMPECCABLE_DOWNLOAD_BASE set "IMPECCABLE_DOWNLOAD_BASE=https://github.com/pbakaus/impeccable/releases/download"
|
if not defined IMPECCABLE_DOWNLOAD_BASE set "IMPECCABLE_DOWNLOAD_BASE=https://github.com/pbakaus/impeccable/releases/download"
|
||||||
if exist "%IMPECCABLE_HOME%\bin\%version%\" goto cache_ready
|
if not exist "%IMPECCABLE_HOME%\bin\%version%" mkdir "%IMPECCABLE_HOME%\bin\%version%" >nul 2>nul
|
||||||
mkdir "%IMPECCABLE_HOME%\bin\%version%" >nul 2>nul
|
|
||||||
if errorlevel 1 goto cache_directory_failed
|
|
||||||
:cache_ready
|
|
||||||
rem Check the staging file too: an existing directory may be read-only.
|
|
||||||
rem Redirection failures do not reliably update ERRORLEVEL in cmd.exe;
|
|
||||||
rem branch on the command's failure directly. Never treat a directory as a
|
|
||||||
rem staging file (later del cleanup would prompt to delete its contents).
|
|
||||||
if exist "%cached%.part\" goto cache_write_failed
|
|
||||||
(type nul >"%cached%.part") 2>nul || goto cache_write_failed
|
|
||||||
set "asset=impeccable-windows-%arch%.exe"
|
set "asset=impeccable-windows-%arch%.exe"
|
||||||
set "url=%IMPECCABLE_DOWNLOAD_BASE%/engine-v%version%/%asset%"
|
set "url=%IMPECCABLE_DOWNLOAD_BASE%/engine-v%version%/%asset%"
|
||||||
curl.exe -fsSL -o "%cached%.part" "%url%" >nul 2>nul
|
curl.exe -fsSL -o "%cached%.part" "%url%" >nul 2>nul
|
||||||
if not errorlevel 1 goto verify
|
if not errorlevel 1 goto verify
|
||||||
if not "%arch%"=="arm64" goto download_failed
|
if not "%arch%"=="arm64" goto fail
|
||||||
set "asset=impeccable-windows-x64.exe"
|
set "asset=impeccable-windows-x64.exe"
|
||||||
set "url=%IMPECCABLE_DOWNLOAD_BASE%/engine-v%version%/%asset%"
|
set "url=%IMPECCABLE_DOWNLOAD_BASE%/engine-v%version%/%asset%"
|
||||||
curl.exe -fsSL -o "%cached%.part" "%url%" >nul 2>nul
|
curl.exe -fsSL -o "%cached%.part" "%url%" >nul 2>nul
|
||||||
if errorlevel 1 goto download_failed
|
if errorlevel 1 goto fail
|
||||||
|
|
||||||
:verify
|
:verify
|
||||||
call :check_download
|
call :check_download
|
||||||
@@ -185,28 +176,6 @@ if not errorlevel 1 set "probe_ok=1"
|
|||||||
del "%probe_tmp%" >nul 2>nul
|
del "%probe_tmp%" >nul 2>nul
|
||||||
exit /b 0
|
exit /b 0
|
||||||
|
|
||||||
:cache_directory_failed
|
|
||||||
echo impeccable: engine %version% is not installed; cannot create cache directory: "%IMPECCABLE_HOME%\bin\%version%" 1>&2
|
|
||||||
goto setup_failed
|
|
||||||
|
|
||||||
:cache_write_failed
|
|
||||||
echo impeccable: engine %version% is not installed; cannot write to cache directory: "%IMPECCABLE_HOME%\bin\%version%" 1>&2
|
|
||||||
goto setup_failed
|
|
||||||
|
|
||||||
:curl_missing
|
|
||||||
echo impeccable: cannot download engine %version%; curl.exe is unavailable. 1>&2
|
|
||||||
goto setup_failed
|
|
||||||
|
|
||||||
:download_failed
|
|
||||||
del "%cached%.part" >nul 2>nul
|
|
||||||
echo impeccable: could not download engine %version% from %url%; check network access and the release URL. 1>&2
|
|
||||||
|
|
||||||
:setup_failed
|
|
||||||
echo Engine %version% setup needs network access and write permission to "%IMPECCABLE_HOME%\bin\%version%". 1>&2
|
|
||||||
echo Run this launcher ("%~f0") with engine-probe in a terminal that has those permissions, then retry the original command. 1>&2
|
|
||||||
echo Alternatively, set IMPECCABLE_HOME to a writable cache location, or IMPECCABLE_BIN to a preinstalled engine binary. 1>&2
|
|
||||||
exit /b 127
|
|
||||||
|
|
||||||
:fail
|
:fail
|
||||||
del "%cached%.part" >nul 2>nul
|
del "%cached%.part" >nul 2>nul
|
||||||
echo impeccable: no engine binary found (looked in %bin%, %cached%, PATH). 1>&2
|
echo impeccable: no engine binary found (looked in %bin%, %cached%, PATH). 1>&2
|
||||||
|
|||||||
@@ -165,14 +165,6 @@
|
|||||||
}
|
}
|
||||||
let parameterGenerationState = 'idle';
|
let parameterGenerationState = 'idle';
|
||||||
let parameterReadyAnnouncedSession = null;
|
let parameterReadyAnnouncedSession = null;
|
||||||
// 'agent' when the generate verb fired this session's Go (the generate
|
|
||||||
// lane declares no knobs, so its bar never shows a pending Tune chip);
|
|
||||||
// null for every Go a user presses.
|
|
||||||
let sessionOrigin = null;
|
|
||||||
// The generate lane picks for the agent and never edits copy in the
|
|
||||||
// browser, so its selection carries no edit-copy badge (set on the
|
|
||||||
// agent-target pick, cleared with the session; a user's pick never sets it).
|
|
||||||
let editBadgeSuppressed = false;
|
|
||||||
let svelteComponentSession = null;
|
let svelteComponentSession = null;
|
||||||
let svelteRuntimePromise = null;
|
let svelteRuntimePromise = null;
|
||||||
let pendingSvelteComponentRetryObserver = null;
|
let pendingSvelteComponentRetryObserver = null;
|
||||||
@@ -991,20 +983,9 @@
|
|||||||
}
|
}
|
||||||
} catch { /* cross-origin */ }
|
} catch { /* cross-origin */ }
|
||||||
}
|
}
|
||||||
// The selector a mechanical bake would anchor lasting rules on, and how
|
|
||||||
// many elements it matches right now: the bake refuses anything but one,
|
|
||||||
// since its rules would restyle every match, not just this element.
|
|
||||||
const cssIdent = (s) => /^[A-Za-z_-][\w-]*$/.test(s);
|
|
||||||
const anchorClasses = [...el.classList].filter(cssIdent);
|
|
||||||
const anchor = el.id && cssIdent(el.id)
|
|
||||||
? '#' + el.id
|
|
||||||
: (anchorClasses.length ? el.tagName.toLowerCase() + '.' + anchorClasses.join('.') : null);
|
|
||||||
let anchorMatches = null;
|
|
||||||
if (anchor) { try { anchorMatches = document.querySelectorAll(anchor).length; } catch { anchorMatches = null; } }
|
|
||||||
return {
|
return {
|
||||||
tagName: el.tagName.toLowerCase(), id: el.id || null,
|
tagName: el.tagName.toLowerCase(), id: el.id || null,
|
||||||
classes: [...el.classList],
|
classes: [...el.classList],
|
||||||
anchor, anchorMatches,
|
|
||||||
textContent: (el.textContent || '').slice(0, 500),
|
textContent: (el.textContent || '').slice(0, 500),
|
||||||
outerHTML: sanitizedContextOuterHTML(el, 10000),
|
outerHTML: sanitizedContextOuterHTML(el, 10000),
|
||||||
computedStyles: {
|
computedStyles: {
|
||||||
@@ -2056,7 +2037,6 @@
|
|||||||
function setLiveState(next) {
|
function setLiveState(next) {
|
||||||
state = next;
|
state = next;
|
||||||
window.__IMPECCABLE_LIVE_STATE__ = next;
|
window.__IMPECCABLE_LIVE_STATE__ = next;
|
||||||
retryDeclinedAgentTargets();
|
|
||||||
syncPageInteractionCursor();
|
syncPageInteractionCursor();
|
||||||
// Whether a queued steer is still behind a generation is a function of this
|
// Whether a queued steer is still behind a generation is a function of this
|
||||||
// state, so the hint has to move with it, not only with the 5s poll.
|
// state, so the hint has to move with it, not only with the 5s poll.
|
||||||
@@ -4034,7 +4014,6 @@
|
|||||||
|
|
||||||
function hidePendingApplyDock() {
|
function hidePendingApplyDock() {
|
||||||
pendingApplyInFlight = false;
|
pendingApplyInFlight = false;
|
||||||
retryDeclinedAgentTargets();
|
|
||||||
clearStoredManualApplyState();
|
clearStoredManualApplyState();
|
||||||
if (pendingIntroAnimation) { pendingIntroAnimation.cancel(); pendingIntroAnimation = null; }
|
if (pendingIntroAnimation) { pendingIntroAnimation.cancel(); pendingIntroAnimation = null; }
|
||||||
if (pendingDockEl) pendingDockEl.style.display = 'none';
|
if (pendingDockEl) pendingDockEl.style.display = 'none';
|
||||||
@@ -4068,7 +4047,6 @@
|
|||||||
function setPendingApplyLoading(loading, count) {
|
function setPendingApplyLoading(loading, count) {
|
||||||
if (!pendingPillEl || !pendingPillLabelEl || !pendingPillCountEl || !pendingTrashBtn) return;
|
if (!pendingPillEl || !pendingPillLabelEl || !pendingPillCountEl || !pendingTrashBtn) return;
|
||||||
pendingApplyInFlight = loading === true;
|
pendingApplyInFlight = loading === true;
|
||||||
if (!pendingApplyInFlight) retryDeclinedAgentTargets();
|
|
||||||
const currentCount = count || parseInt(pendingPillEl.dataset.count || '0', 10) || 0;
|
const currentCount = count || parseInt(pendingPillEl.dataset.count || '0', 10) || 0;
|
||||||
if (pendingApplyInFlight) storeManualApplyState(currentCount);
|
if (pendingApplyInFlight) storeManualApplyState(currentCount);
|
||||||
else clearStoredManualApplyState();
|
else clearStoredManualApplyState();
|
||||||
@@ -4710,7 +4688,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderEditBadge(mode) {
|
function renderEditBadge(mode) {
|
||||||
if (editBadgeSuppressed || sessionOrigin === 'agent') mode = 'hidden';
|
|
||||||
if (mode === 'hidden' || !editBadgeEl) {
|
if (mode === 'hidden' || !editBadgeEl) {
|
||||||
hideConfigureBarTooltip();
|
hideConfigureBarTooltip();
|
||||||
if (editBadgeEl) editBadgeEl.style.display = 'none';
|
if (editBadgeEl) editBadgeEl.style.display = 'none';
|
||||||
@@ -6204,8 +6181,6 @@
|
|||||||
resetSessionFileMeta();
|
resetSessionFileMeta();
|
||||||
currentSessionId = null;
|
currentSessionId = null;
|
||||||
parameterGenerationState = 'idle';
|
parameterGenerationState = 'idle';
|
||||||
sessionOrigin = null;
|
|
||||||
editBadgeSuppressed = false;
|
|
||||||
parameterReadyAnnouncedSession = null;
|
parameterReadyAnnouncedSession = null;
|
||||||
expectedVariants = 0;
|
expectedVariants = 0;
|
||||||
arrivedVariants = 0;
|
arrivedVariants = 0;
|
||||||
@@ -7137,398 +7112,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
//
|
//
|
||||||
// ------------------------------------------------------------------
|
|
||||||
// Agent-initiated targeting (the `generate` command). The agent names an
|
|
||||||
// element by CSS selector over POST /agent-target; the server pushes an
|
|
||||||
// `agent_target` SSE message here. The overlay resolves the selector,
|
|
||||||
// scrolls the element into view, enters the same picked state a user
|
|
||||||
// click produces, and fires the normal Go pipeline, so everything
|
|
||||||
// downstream (generate event, variants, cycling, accept) is unchanged.
|
|
||||||
// The verdict goes back through POST /agent-target-result, which resolves
|
|
||||||
// the agent's held-open CLI call.
|
|
||||||
|
|
||||||
function postAgentTargetResult(targetId, result) {
|
|
||||||
fetch('http://localhost:' + PORT + '/agent-target-result?token=' + TOKEN, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ token: TOKEN, targetId, clientId: AGENT_TARGET_CLIENT_ID, ...result }),
|
|
||||||
}).catch(() => { /* server gone; nothing to report to */ });
|
|
||||||
}
|
|
||||||
|
|
||||||
function describeAgentTargetCandidate(el) {
|
|
||||||
return {
|
|
||||||
tag: el.tagName.toLowerCase(),
|
|
||||||
id: el.id || null,
|
|
||||||
classes: [...el.classList].filter((c) => !c.startsWith('impeccable-')),
|
|
||||||
text: (el.textContent || '').trim().slice(0, 80),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveAgentTargetElement(msg) {
|
|
||||||
let matched;
|
|
||||||
try {
|
|
||||||
matched = [...document.querySelectorAll(msg.selector)];
|
|
||||||
} catch {
|
|
||||||
return { error: { ok: false, error: 'invalid_selector', selector: msg.selector } };
|
|
||||||
}
|
|
||||||
let candidates = matched.filter((el) => pickable(el));
|
|
||||||
if (msg.text) {
|
|
||||||
const needle = String(msg.text).toLowerCase();
|
|
||||||
candidates = candidates.filter((el) => (el.textContent || '').toLowerCase().includes(needle));
|
|
||||||
}
|
|
||||||
if (candidates.length === 0) {
|
|
||||||
return {
|
|
||||||
error: {
|
|
||||||
ok: false,
|
|
||||||
error: 'no_match',
|
|
||||||
selector: msg.selector,
|
|
||||||
matchCount: 0,
|
|
||||||
// How many nodes the raw selector hit before the pickable/text
|
|
||||||
// filters: distinguishes a wrong selector from an unpickable match.
|
|
||||||
rawMatchCount: matched.length,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (Number.isInteger(msg.index)) {
|
|
||||||
const el = candidates[msg.index - 1];
|
|
||||||
if (!el) {
|
|
||||||
return { error: { ok: false, error: 'index_out_of_range', selector: msg.selector, matchCount: candidates.length } };
|
|
||||||
}
|
|
||||||
return { el, matchCount: candidates.length };
|
|
||||||
}
|
|
||||||
if (candidates.length > 1) {
|
|
||||||
return {
|
|
||||||
error: {
|
|
||||||
ok: false,
|
|
||||||
error: 'ambiguous',
|
|
||||||
selector: msg.selector,
|
|
||||||
matchCount: candidates.length,
|
|
||||||
candidates: candidates.slice(0, 8).map(describeAgentTargetCandidate),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return { el: candidates[0], matchCount: 1 };
|
|
||||||
}
|
|
||||||
|
|
||||||
function scrollAgentTargetIntoView(el, done) {
|
|
||||||
const rect = el.getBoundingClientRect();
|
|
||||||
if (rect.top >= 0 && rect.bottom <= window.innerHeight) { done(); return; }
|
|
||||||
let settled = false;
|
|
||||||
let fallback = null;
|
|
||||||
const finish = () => {
|
|
||||||
if (settled) return;
|
|
||||||
settled = true;
|
|
||||||
removeEventListener('scrollend', finish, true);
|
|
||||||
if (fallback) clearTimeout(fallback);
|
|
||||||
done();
|
|
||||||
};
|
|
||||||
// scrollend where supported; a timer covers engines without it and the
|
|
||||||
// no-movement case (element already at its final resting position).
|
|
||||||
addEventListener('scrollend', finish, true);
|
|
||||||
fallback = setTimeout(finish, 1200);
|
|
||||||
el.scrollIntoView({ block: 'center', behavior: 'smooth' });
|
|
||||||
}
|
|
||||||
|
|
||||||
// One id per page load: the server keys claims and roll-call reports on
|
|
||||||
// it, and only the tab that holds the lease can renew it.
|
|
||||||
const AGENT_TARGET_CLIENT_ID = id8();
|
|
||||||
|
|
||||||
// The agent target an agent-initiated Go is serving: set by
|
|
||||||
// actOnAgentTarget around its handleGo call, read once by handleGo.
|
|
||||||
let agentTargetForGo = null;
|
|
||||||
|
|
||||||
// The helper's word on its global bar. The generate lane asks the helper
|
|
||||||
// to keep it out of the way (`impeccable live --no-live-bar`, or an agent
|
|
||||||
// target carrying hideLiveBar), and the helper tells every connected tab
|
|
||||||
// at once (`live_bar`) and every later connection on `connected`, so the
|
|
||||||
// bar stays hidden in every tab, through reloads, the accept, and the
|
|
||||||
// bake, until the helper stops and takes the overlay with it. The variant
|
|
||||||
// controls still show.
|
|
||||||
let liveBarHiddenByHelper = false;
|
|
||||||
|
|
||||||
function applyLiveBarPreference(hidden) {
|
|
||||||
liveBarHiddenByHelper = hidden === true;
|
|
||||||
setLiveBarHidden(liveBarHiddenByHelper);
|
|
||||||
}
|
|
||||||
|
|
||||||
// A plain live session must never notice this code: hiding remembers the
|
|
||||||
// bar's own display value and restoring puts exactly that back, and a
|
|
||||||
// restore on a bar that is not hidden is a no-op, so the `connected`
|
|
||||||
// frame every session receives changes nothing unless the lane asked.
|
|
||||||
function setLiveBarHidden(hidden) {
|
|
||||||
if (!globalBarEl) return;
|
|
||||||
if (hidden) {
|
|
||||||
if (globalBarEl.style.display !== 'none') {
|
|
||||||
globalBarEl.dataset.liveBarDisplay = globalBarEl.style.display || 'flex';
|
|
||||||
globalBarEl.style.display = 'none';
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (globalBarEl.style.display === 'none') {
|
|
||||||
globalBarEl.style.display = globalBarEl.dataset.liveBarDisplay || 'flex';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function claimAgentTarget(targetId, report) {
|
|
||||||
return fetch('http://localhost:' + PORT + '/agent-target-claim?token=' + TOKEN, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ token: TOKEN, targetId, clientId: AGENT_TARGET_CLIENT_ID, ...report }),
|
|
||||||
}).then((res) => res.json())
|
|
||||||
.then((j) => ({ granted: !!j && j.granted === true, pending: !!j && j.pending === true }))
|
|
||||||
.catch(() => ({ granted: false, pending: false }));
|
|
||||||
}
|
|
||||||
|
|
||||||
// `exceptTargetId` is the target this call is about: a tab acting on it
|
|
||||||
// is not busy for itself, but it is busy for every other target, or two
|
|
||||||
// held requests could both be claimed here and the second Go would
|
|
||||||
// overwrite the session the first one minted.
|
|
||||||
function agentTargetBusyReason(exceptTargetId) {
|
|
||||||
if (pendingApplyInFlight) return 'manual_apply_in_flight';
|
|
||||||
if (state !== 'IDLE' && state !== 'PICKING' && state !== 'CONFIGURING') return 'session_active';
|
|
||||||
for (const [targetId, status] of agentTargetsSeen) {
|
|
||||||
if (status === 'acting' && targetId !== exceptTargetId) return 'agent_target_in_flight';
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Targets this tab declined as busy. A busy report is only this tab's word
|
|
||||||
// at that moment: the moment it is free again (setLiveState), it claims
|
|
||||||
// each of these as eligible, and the server drops the stale report, so a
|
|
||||||
// busy verdict is never built on a tab that has since gone idle. The
|
|
||||||
// server denies claims for resolved targets, so retries are harmless.
|
|
||||||
const busyDeclinedTargets = new Map();
|
|
||||||
|
|
||||||
function declineAgentTargetBusy(msg, busy) {
|
|
||||||
busyDeclinedTargets.set(msg.targetId, msg);
|
|
||||||
noteAgentTarget(msg.targetId, 'declined');
|
|
||||||
claimAgentTarget(msg.targetId, { eligible: false, state, reason: busy });
|
|
||||||
}
|
|
||||||
|
|
||||||
// A torn-down overlay, or one whose helper connection is gone, cannot
|
|
||||||
// serve a target and must not even claim one: it would hold the lease for
|
|
||||||
// a request it will never act on.
|
|
||||||
function agentTargetOverlayGone() {
|
|
||||||
return !evtSource;
|
|
||||||
}
|
|
||||||
|
|
||||||
// A denied claimant retries at this cadence, a little over the lease, so
|
|
||||||
// the first retry after a dead holder's lease lapses is granted.
|
|
||||||
const AGENT_TARGET_RESCUE_RETRY_MS = 3500;
|
|
||||||
|
|
||||||
// Claim the lease and act as the holder. A denied claim means another tab
|
|
||||||
// holds the lease. That holder can die before posting its result (reload,
|
|
||||||
// crash, even after renewing), and its lease lapses after ~3s, so this tab
|
|
||||||
// keeps retrying for as long as the server still holds the request: the
|
|
||||||
// answer's `pending` is the server's word that the request is alive, and
|
|
||||||
// it turns false the moment the request resolved or timed out, so no tab
|
|
||||||
// retries a request nobody awaits. A tab that turned busy meanwhile joins
|
|
||||||
// the roll call instead of taking a lease it cannot use. The first claim
|
|
||||||
// and the busy-to-idle re-claim share this.
|
|
||||||
function claimAndActOnAgentTarget(msg) {
|
|
||||||
if (agentTargetOverlayGone()) return;
|
|
||||||
const busy = agentTargetBusyReason(msg.targetId);
|
|
||||||
if (busy) { declineAgentTargetBusy(msg, busy); return; }
|
|
||||||
if (declineAgentTargetUnresolvable(msg)) return;
|
|
||||||
claimAgentTarget(msg.targetId, { eligible: true }).then((claim) => {
|
|
||||||
if (claim.granted) { noteAgentTarget(msg.targetId, 'acting'); actOnAgentTarget(msg); return; }
|
|
||||||
noteAgentTarget(msg.targetId, 'denied');
|
|
||||||
if (!claim.pending) return;
|
|
||||||
setTimeout(() => claimAndActOnAgentTarget(msg), AGENT_TARGET_RESCUE_RETRY_MS);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function retryDeclinedAgentTargets() {
|
|
||||||
if (busyDeclinedTargets.size === 0 || agentTargetBusyReason()) return;
|
|
||||||
for (const [targetId, msg] of busyDeclinedTargets) {
|
|
||||||
busyDeclinedTargets.delete(targetId);
|
|
||||||
claimAndActOnAgentTarget(msg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// This page's participation in each target it heard: 'acting' once a
|
|
||||||
// claim was granted, 'done' once it replied (or stood down from a lapsed
|
|
||||||
// lease), else the word it last gave. The server replays pending targets
|
|
||||||
// to every connection that opens. After a reconnect that overlapped the
|
|
||||||
// old connection the server still holds this page's word; after one that
|
|
||||||
// did not, it dropped the word on the close, so a replayed target is
|
|
||||||
// handled again: a busy or unresolvable page re-declines (idempotent), an
|
|
||||||
// idle page claims.
|
|
||||||
const agentTargetsSeen = new Map();
|
|
||||||
function noteAgentTarget(targetId, status) {
|
|
||||||
agentTargetsSeen.set(targetId, status);
|
|
||||||
if (agentTargetsSeen.size > 100) agentTargetsSeen.delete(agentTargetsSeen.keys().next().value);
|
|
||||||
}
|
|
||||||
|
|
||||||
// A target this page took a lease on is off-limits for a replay: while
|
|
||||||
// acting (a second claim or Go), and once done, because its result may
|
|
||||||
// still be on the wire and this tab is GENERATING by then, so handling
|
|
||||||
// the replay would decline busy, hand the lease back mid-resolution, and
|
|
||||||
// let another tab fire a second Go.
|
|
||||||
function agentTargetTaken(targetId) {
|
|
||||||
const status = agentTargetsSeen.get(targetId);
|
|
||||||
return status === 'acting' || status === 'done';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only a page that can resolve the target claims it. A tab whose page
|
|
||||||
// lacks the element declines with its resolution verdict instead, so a
|
|
||||||
// first-wins claim never lets the wrong page answer for a target that
|
|
||||||
// another page has. The server prefers a busy report (a tab that could
|
|
||||||
// serve later) over these, and returns the resolution verdict only when
|
|
||||||
// no connected page can serve.
|
|
||||||
//
|
|
||||||
// An element can be momentarily absent (a route still rendering, an HMR
|
|
||||||
// commit mid-swap), so a failed resolution is not this page's final word:
|
|
||||||
// it is re-checked a few times over about two seconds, claiming the
|
|
||||||
// moment the element mounts, and only the last miss is reported. The
|
|
||||||
// server's timeout still bounds the whole exchange.
|
|
||||||
// The page reports the miss at once (so the other overlays' words can
|
|
||||||
// complete the roll call) and keeps re-checking at this cadence for as
|
|
||||||
// long as the server says the request is pending: the server holds an
|
|
||||||
// all-no_match roll call open for a short grace precisely so a late mount
|
|
||||||
// can still be claimed, drops the stale report on an eligible claim, and
|
|
||||||
// ends the watch by answering pending:false once the request resolved or
|
|
||||||
// timed out.
|
|
||||||
const AGENT_TARGET_RESOLVE_WATCH_MS = 500;
|
|
||||||
|
|
||||||
function declineAgentTargetUnresolvable(msg) {
|
|
||||||
const probe = resolveAgentTargetElement(msg);
|
|
||||||
if (!probe.error) return false;
|
|
||||||
reportAgentTargetUnresolvable(msg, probe.error);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function reportAgentTargetUnresolvable(msg, error) {
|
|
||||||
noteAgentTarget(msg.targetId, 'declined');
|
|
||||||
claimAgentTarget(msg.targetId, { eligible: false, state, reason: 'no_match', result: error }).then((answer) => {
|
|
||||||
if (!answer.pending) return;
|
|
||||||
setTimeout(() => watchAgentTargetResolution(msg, error), AGENT_TARGET_RESOLVE_WATCH_MS);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function watchAgentTargetResolution(msg, lastError) {
|
|
||||||
if (agentTargetOverlayGone() || agentTargetTaken(msg.targetId)) return;
|
|
||||||
const busy = agentTargetBusyReason(msg.targetId);
|
|
||||||
if (busy) { declineAgentTargetBusy(msg, busy); return; }
|
|
||||||
const probe = resolveAgentTargetElement(msg);
|
|
||||||
if (!probe.error) { claimAndActOnAgentTarget(msg); return; }
|
|
||||||
// Still unresolvable: re-report (idempotent); the answer says whether
|
|
||||||
// the server is still holding the request open.
|
|
||||||
reportAgentTargetUnresolvable(msg, probe.error || lastError);
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleAgentTarget(msg) {
|
|
||||||
if (!msg || typeof msg.targetId !== 'string') return;
|
|
||||||
if (agentTargetTaken(msg.targetId)) return;
|
|
||||||
noteAgentTarget(msg.targetId, 'heard');
|
|
||||||
const busy = agentTargetBusyReason(msg.targetId);
|
|
||||||
if (busy) {
|
|
||||||
// Roll call: a busy tab reports itself and never acts. The server
|
|
||||||
// answers `busy` the moment every connected overlay has reported, so
|
|
||||||
// an idle tab elsewhere is never raced by a timer.
|
|
||||||
declineAgentTargetBusy(msg, busy);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (declineAgentTargetUnresolvable(msg)) return;
|
|
||||||
// Eligible tabs race for the server's lease and only the holder acts. A
|
|
||||||
// hidden tab yields a short head start so a visible one wins when both
|
|
||||||
// exist, and still serves the request on its own: the user finds the
|
|
||||||
// selection waiting when they return to it.
|
|
||||||
setTimeout(() => claimAndActOnAgentTarget(msg), document.hidden ? 150 : 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
function actOnAgentTarget(msg) {
|
|
||||||
if (agentTargetOverlayGone()) return;
|
|
||||||
// Every exit ends this tab's acting state, so a later target is not
|
|
||||||
// refused for a Go that already happened or never will.
|
|
||||||
const reply = (result) => { noteAgentTarget(msg.targetId, 'done'); postAgentTargetResult(msg.targetId, result); };
|
|
||||||
const busy = agentTargetBusyReason(msg.targetId);
|
|
||||||
if (busy) {
|
|
||||||
// Turned busy between claim and act: report it, which also hands the
|
|
||||||
// lease back so the roll call can complete or a rescuer can claim.
|
|
||||||
declineAgentTargetBusy(msg, busy);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const resolved = resolveAgentTargetElement(msg);
|
|
||||||
if (resolved.error) {
|
|
||||||
// The element went away between claim and act. A result would end the
|
|
||||||
// request for every tab; a decline hands the lease back so another
|
|
||||||
// page or a remount can still serve it.
|
|
||||||
reportAgentTargetUnresolvable(msg, resolved.error);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const el = resolved.el;
|
|
||||||
if (msg.dryRun) {
|
|
||||||
reply({
|
|
||||||
ok: true,
|
|
||||||
dryRun: true,
|
|
||||||
matchCount: resolved.matchCount,
|
|
||||||
element: describeAgentTargetCandidate(el),
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
scrollAgentTargetIntoView(el, () => {
|
|
||||||
// Torn down during the scroll settle: do not renew. The lease lapses
|
|
||||||
// for a rescuer instead of Go minting a session on a dismantled
|
|
||||||
// overlay.
|
|
||||||
if (agentTargetOverlayGone()) return;
|
|
||||||
// Renew the lease right before the irreversible part: a tab whose
|
|
||||||
// lease lapsed while it scrolled (a rescuer took over) stops here, so
|
|
||||||
// one request never gets two Go presses.
|
|
||||||
claimAgentTarget(msg.targetId, { eligible: true }).then((renewal) => {
|
|
||||||
if (!renewal.granted) { noteAgentTarget(msg.targetId, 'done'); return; }
|
|
||||||
// An insert placement left mid-configure gives way, exactly as a
|
|
||||||
// click outside it does in handleClick.
|
|
||||||
if (state === 'CONFIGURING' && configureKind === 'insert') cancelInsertConfigure();
|
|
||||||
// Mirror of the user-click pick entry in handleClick, minus the
|
|
||||||
// pick-mode gate (the agent's intent replaces the toggle); the entry
|
|
||||||
// goes through beginNewLiveConfiguration like every other pick so
|
|
||||||
// deferred recovery sees a fresh interaction revision.
|
|
||||||
selectedElement = el;
|
|
||||||
beginNewLiveConfiguration();
|
|
||||||
showHighlight(selectedElement);
|
|
||||||
clearAnnotations();
|
|
||||||
showAnnotOverlay(selectedElement);
|
|
||||||
showBar('configure');
|
|
||||||
editBadgeSuppressed = true;
|
|
||||||
renderEditBadge('hidden');
|
|
||||||
startScrollTracking();
|
|
||||||
maybePrefetchPage();
|
|
||||||
maybeWarnConditionalAncestor(selectedElement);
|
|
||||||
// Preset what the agent asked for, then fire the same Go a user press
|
|
||||||
// fires. handleGo reads exactly these inputs.
|
|
||||||
selectedAction = msg.action;
|
|
||||||
selectedCount = msg.count;
|
|
||||||
// updateBarContent rebuilds the configure row and replaces the input
|
|
||||||
// element, so the prompt must be written into the input it creates,
|
|
||||||
// never before (the action-chip click handler does the same dance).
|
|
||||||
updateBarContent('configure');
|
|
||||||
const input = uiGetById(PREFIX + '-input');
|
|
||||||
if (input) input.value = msg.prompt || '';
|
|
||||||
// The target rides on the generate event too: the helper resolves
|
|
||||||
// the request from whichever lands first, so a page that dies
|
|
||||||
// between Go and its result cannot leave the request pending for a
|
|
||||||
// second Go elsewhere.
|
|
||||||
const candidate = describeAgentTargetCandidate(el);
|
|
||||||
agentTargetForGo = { targetId: msg.targetId, matchCount: resolved.matchCount, action: msg.action, count: msg.count, element: candidate };
|
|
||||||
handleGo();
|
|
||||||
agentTargetForGo = null;
|
|
||||||
if (state === 'GENERATING' && currentSessionId) {
|
|
||||||
reply({
|
|
||||||
ok: true,
|
|
||||||
matchCount: resolved.matchCount,
|
|
||||||
sessionId: currentSessionId,
|
|
||||||
action: msg.action,
|
|
||||||
count: msg.count,
|
|
||||||
element: candidate,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
reply({ ok: false, error: 'go_failed', state });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// SSE (server→browser) + fetch POST (browser→server)
|
// SSE (server→browser) + fetch POST (browser→server)
|
||||||
// Zero-dependency replacement for WebSocket.
|
// Zero-dependency replacement for WebSocket.
|
||||||
//
|
//
|
||||||
@@ -7538,7 +7121,7 @@
|
|||||||
const SSE_MAX_RETRIES = 20; // generous: heartbeats keep the connection alive, so retries mean real trouble
|
const SSE_MAX_RETRIES = 20; // generous: heartbeats keep the connection alive, so retries mean real trouble
|
||||||
|
|
||||||
function connectSSE() {
|
function connectSSE() {
|
||||||
evtSource = new EventSource('http://localhost:' + PORT + '/events?token=' + TOKEN + '&clientId=' + AGENT_TARGET_CLIENT_ID);
|
evtSource = new EventSource('http://localhost:' + PORT + '/events?token=' + TOKEN);
|
||||||
|
|
||||||
evtSource.onopen = () => {
|
evtSource.onopen = () => {
|
||||||
sseRetries = 0; // reset on successful (re)connect
|
sseRetries = 0; // reset on successful (re)connect
|
||||||
@@ -7549,11 +7132,8 @@
|
|||||||
let msg; try { msg = JSON.parse(e.data); } catch { return; }
|
let msg; try { msg = JSON.parse(e.data); } catch { return; }
|
||||||
switch (msg.type) {
|
switch (msg.type) {
|
||||||
case 'connected':
|
case 'connected':
|
||||||
applyLiveBarPreference(msg.hideLiveBar === true);
|
|
||||||
hasProjectContext = !!msg.hasProjectContext;
|
hasProjectContext = !!msg.hasProjectContext;
|
||||||
// The generate lane runs without PRODUCT.md by design and never
|
if (!hasProjectContext) showToast(`No PRODUCT.md found. Variants will be brand-agnostic. Run ${IMPECCABLE_COMMAND} init to generate one.`, 7000);
|
||||||
// sends the user to init, so its quiet chrome skips this notice.
|
|
||||||
if (!hasProjectContext && !liveBarHiddenByHelper) showToast(`No PRODUCT.md found. Variants will be brand-agnostic. Run ${IMPECCABLE_COMMAND} init to generate one.`, 7000);
|
|
||||||
console.log('[impeccable] Live mode connected.');
|
console.log('[impeccable] Live mode connected.');
|
||||||
syncAgentPollingUi(!!msg.agentPolling);
|
syncAgentPollingUi(!!msg.agentPolling);
|
||||||
startAgentStatusPoll();
|
startAgentStatusPoll();
|
||||||
@@ -7563,15 +7143,9 @@
|
|||||||
syncPageInteractionCursor();
|
syncPageInteractionCursor();
|
||||||
syncPageChatFocus('sse-connected');
|
syncPageChatFocus('sse-connected');
|
||||||
break;
|
break;
|
||||||
case 'live_bar':
|
|
||||||
applyLiveBarPreference(msg.hidden === true);
|
|
||||||
break;
|
|
||||||
case 'agent_polling':
|
case 'agent_polling':
|
||||||
syncAgentPollingUi(!!msg.connected);
|
syncAgentPollingUi(!!msg.connected);
|
||||||
break;
|
break;
|
||||||
case 'agent_target':
|
|
||||||
handleAgentTarget(msg);
|
|
||||||
break;
|
|
||||||
case 'agent_phase':
|
case 'agent_phase':
|
||||||
if (msg.id === currentSessionId && (state === 'GENERATING' || state === 'CYCLING')) {
|
if (msg.id === currentSessionId && (state === 'GENERATING' || state === 'CYCLING')) {
|
||||||
// Advance the visible phase monotonically. A behind/resumed
|
// Advance the visible phase monotonically. A behind/resumed
|
||||||
@@ -7634,11 +7208,6 @@
|
|||||||
disableInlineEdit();
|
disableInlineEdit();
|
||||||
refreshParamsPanel();
|
refreshParamsPanel();
|
||||||
}
|
}
|
||||||
// The done reply is the agent's last word on this generation:
|
|
||||||
// with every variant mounted and no knobs declared, the Tune
|
|
||||||
// chip must stop spinning. A reload between the mount and this
|
|
||||||
// reply restored the pending state from the cache.
|
|
||||||
completeParameterGenerationIfReady();
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
// Source fallback when HMR did not land variants in this tab.
|
// Source fallback when HMR did not land variants in this tab.
|
||||||
@@ -7802,15 +7371,6 @@
|
|||||||
}).then(async res => {
|
}).then(async res => {
|
||||||
if (res.ok) return res;
|
if (res.ok) return res;
|
||||||
const body = await res.json().catch(() => ({}));
|
const body = await res.json().catch(() => ({}));
|
||||||
// The helper refused to open a session for an agent target it has
|
|
||||||
// already answered (another page served it after this page's lease
|
|
||||||
// lapsed mid-capture, or the request timed out): drop the local
|
|
||||||
// session and hand the surface back.
|
|
||||||
if (body.error === 'agent_target_already_served' && msg.type === 'generate'
|
|
||||||
&& msg.id && msg.id === currentSessionId) {
|
|
||||||
abandonSupersededGo(msg.id);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
// The server refused to journal progress for a session it has never
|
// The server refused to journal progress for a session it has never
|
||||||
// seen: this browser is carrying state from another project or a
|
// seen: this browser is carrying state from another project or a
|
||||||
// wiped store (two apps sharing a localhost port). Continuing to
|
// wiped store (two apps sharing a localhost port). Continuing to
|
||||||
@@ -7832,14 +7392,6 @@
|
|||||||
return sessionCreationGate.then(doSend);
|
return sessionCreationGate.then(doSend);
|
||||||
}
|
}
|
||||||
|
|
||||||
function abandonSupersededGo(sessionId) {
|
|
||||||
if (sessionId !== currentSessionId) return;
|
|
||||||
console.warn('[impeccable] The helper already answered this agent target; clearing session ' + sessionId + '.');
|
|
||||||
markSessionHandled();
|
|
||||||
cleanup({ instantChrome: true });
|
|
||||||
showToast('The helper already answered this request, so this session was cleared. Pick an element to start fresh.', 6000);
|
|
||||||
}
|
|
||||||
|
|
||||||
let abandonedForeignSessionId = null;
|
let abandonedForeignSessionId = null;
|
||||||
function abandonForeignSession(sessionId) {
|
function abandonForeignSession(sessionId) {
|
||||||
if (abandonedForeignSessionId === sessionId || sessionId !== currentSessionId) return;
|
if (abandonedForeignSessionId === sessionId || sessionId !== currentSessionId) return;
|
||||||
@@ -8244,7 +7796,6 @@
|
|||||||
visibleVariant = 0;
|
visibleVariant = 0;
|
||||||
generationPhase = 'queued';
|
generationPhase = 'queued';
|
||||||
parameterGenerationState = 'pending';
|
parameterGenerationState = 'pending';
|
||||||
sessionOrigin = agentTargetForGo ? 'agent' : null;
|
|
||||||
parameterReadyAnnouncedSession = null;
|
parameterReadyAnnouncedSession = null;
|
||||||
resetSessionFileMeta();
|
resetSessionFileMeta();
|
||||||
|
|
||||||
@@ -8270,24 +7821,6 @@
|
|||||||
};
|
};
|
||||||
if (snapshot.comments.length > 0) basePayload.comments = snapshot.comments;
|
if (snapshot.comments.length > 0) basePayload.comments = snapshot.comments;
|
||||||
if (snapshot.strokes.length > 0) basePayload.strokes = snapshot.strokes;
|
if (snapshot.strokes.length > 0) basePayload.strokes = snapshot.strokes;
|
||||||
if (agentTargetForGo) {
|
|
||||||
// An agent-initiated Go names the target it serves (see
|
|
||||||
// actOnAgentTarget): the helper resolves that request from this event
|
|
||||||
// as well as from the overlay's own result post.
|
|
||||||
basePayload.agentTarget = {
|
|
||||||
targetId: agentTargetForGo.targetId,
|
|
||||||
clientId: AGENT_TARGET_CLIENT_ID,
|
|
||||||
result: {
|
|
||||||
ok: true,
|
|
||||||
matchCount: agentTargetForGo.matchCount,
|
|
||||||
sessionId: currentSessionId,
|
|
||||||
action: agentTargetForGo.action,
|
|
||||||
count: agentTargetForGo.count,
|
|
||||||
element: agentTargetForGo.element,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
agentTargetForGo = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Hide the interactive overlay so it doesn't linger during generation.
|
// Hide the interactive overlay so it doesn't linger during generation.
|
||||||
hideAnnotOverlay();
|
hideAnnotOverlay();
|
||||||
@@ -8348,7 +7881,6 @@
|
|||||||
visibleVariant = 0;
|
visibleVariant = 0;
|
||||||
generationPhase = 'queued';
|
generationPhase = 'queued';
|
||||||
parameterGenerationState = 'pending';
|
parameterGenerationState = 'pending';
|
||||||
sessionOrigin = agentTargetForGo ? 'agent' : null;
|
|
||||||
parameterReadyAnnouncedSession = null;
|
parameterReadyAnnouncedSession = null;
|
||||||
resetSessionFileMeta();
|
resetSessionFileMeta();
|
||||||
selectedElement = placeholderElement;
|
selectedElement = placeholderElement;
|
||||||
@@ -9395,8 +8927,6 @@ void main() {
|
|||||||
pagePickSkipClick = false;
|
pagePickSkipClick = false;
|
||||||
currentSessionId = null;
|
currentSessionId = null;
|
||||||
parameterGenerationState = 'idle';
|
parameterGenerationState = 'idle';
|
||||||
sessionOrigin = null;
|
|
||||||
editBadgeSuppressed = false;
|
|
||||||
parameterReadyAnnouncedSession = null;
|
parameterReadyAnnouncedSession = null;
|
||||||
selectedAction = 'impeccable';
|
selectedAction = 'impeccable';
|
||||||
pendingAcceptedSession = null;
|
pendingAcceptedSession = null;
|
||||||
@@ -9488,7 +9018,6 @@ void main() {
|
|||||||
paramsCurrentValues = { ...saved.paramValues };
|
paramsCurrentValues = { ...saved.paramValues };
|
||||||
}
|
}
|
||||||
if (saved.parameterState) parameterGenerationState = saved.parameterState;
|
if (saved.parameterState) parameterGenerationState = saved.parameterState;
|
||||||
sessionOrigin = saved.origin === 'agent' ? 'agent' : null;
|
|
||||||
if (saved.generationPhase) generationPhase = saved.generationPhase;
|
if (saved.generationPhase) generationPhase = saved.generationPhase;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -9576,12 +9105,7 @@ void main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function restoreSessionWithoutWrapper(reason, activeSessions) {
|
function restoreSessionWithoutWrapper(reason, activeSessions) {
|
||||||
// The session cache is per origin, so a tab on another page of the same
|
const cached = loadSession();
|
||||||
// app sees this page's session too. Only the page that saved it may
|
|
||||||
// resume it: the server-adoption branch below already applies the same
|
|
||||||
// check, and a tab on another page has nothing to render for it.
|
|
||||||
const cachedRaw = loadSession();
|
|
||||||
const cached = cachedRaw?.id && !pageMatchesCurrent(cachedRaw.pageUrl) ? null : cachedRaw;
|
|
||||||
// localStorage is a cache, not a gate. A cleared tab, a second browser
|
// localStorage is a cache, not a gate. A cleared tab, a second browser
|
||||||
// profile, or a teardown that dropped local state all leave the durable
|
// profile, or a teardown that dropped local state all leave the durable
|
||||||
// server session as the only record of work in progress; adopt it instead
|
// server session as the only record of work in progress; adopt it instead
|
||||||
@@ -9694,7 +9218,6 @@ void main() {
|
|||||||
pageUrl: location.pathname,
|
pageUrl: location.pathname,
|
||||||
paramValues: { ...paramsCurrentValues },
|
paramValues: { ...paramsCurrentValues },
|
||||||
parameterState: parameterGenerationState,
|
parameterState: parameterGenerationState,
|
||||||
origin: sessionOrigin || undefined,
|
|
||||||
insertPlaceholder: insertPlaceholderSnapshot || undefined,
|
insertPlaceholder: insertPlaceholderSnapshot || undefined,
|
||||||
pickedAnchor: pickedAnchorSnapshot || undefined,
|
pickedAnchor: pickedAnchorSnapshot || undefined,
|
||||||
pickedAnchorViewportTop: Number.isFinite(pickedAnchorViewportTop) ? pickedAnchorViewportTop : undefined,
|
pickedAnchorViewportTop: Number.isFinite(pickedAnchorViewportTop) ? pickedAnchorViewportTop : undefined,
|
||||||
@@ -9820,8 +9343,6 @@ void main() {
|
|||||||
pagePickSkipClick = false;
|
pagePickSkipClick = false;
|
||||||
currentSessionId = null;
|
currentSessionId = null;
|
||||||
parameterGenerationState = 'idle';
|
parameterGenerationState = 'idle';
|
||||||
sessionOrigin = null;
|
|
||||||
editBadgeSuppressed = false;
|
|
||||||
parameterReadyAnnouncedSession = null;
|
parameterReadyAnnouncedSession = null;
|
||||||
selectedAction = 'impeccable';
|
selectedAction = 'impeccable';
|
||||||
renderEditBadge('hidden');
|
renderEditBadge('hidden');
|
||||||
@@ -10080,14 +9601,6 @@ void main() {
|
|||||||
|
|
||||||
const resumedState = arrivedVariants > 0 ? 'CYCLING' : 'GENERATING';
|
const resumedState = arrivedVariants > 0 ? 'CYCLING' : 'GENERATING';
|
||||||
|
|
||||||
// A reload between the variants mounting and the agent's done reply
|
|
||||||
// restores a pending Tune state from the cache; the helper knows whether
|
|
||||||
// that generation already finished.
|
|
||||||
if (arrivedVariants >= expectedVariants && expectedVariants > 0
|
|
||||||
&& (parameterGenerationState === 'pending' || parameterGenerationState === 'loading')) {
|
|
||||||
settleParameterStateFromHelper(sessionId);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find the visible variant's content element for highlight positioning.
|
// Find the visible variant's content element for highlight positioning.
|
||||||
const isInsert = wrapper.dataset.impeccableMode === 'insert';
|
const isInsert = wrapper.dataset.impeccableMode === 'insert';
|
||||||
const visEl = visibleVariant > 0 ? pickVariantContent(wrapper, visibleVariant) : null;
|
const visEl = visibleVariant > 0 ? pickVariantContent(wrapper, visibleVariant) : null;
|
||||||
@@ -11552,21 +11065,6 @@ void main() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// After a resume the cache may say the Tune knobs are still coming while
|
|
||||||
// the agent already replied done before the reload. The helper's session
|
|
||||||
// record settles it; otherwise the done reply on SSE does.
|
|
||||||
function settleParameterStateFromHelper(sessionId) {
|
|
||||||
fetch('http://localhost:' + PORT + '/status?token=' + TOKEN, { cache: 'no-store' })
|
|
||||||
.then((res) => (res.ok ? res.json() : null))
|
|
||||||
.then((data) => {
|
|
||||||
if (!data || sessionId !== currentSessionId) return;
|
|
||||||
const session = (data.activeSessions || []).find((s) => s && s.id === sessionId);
|
|
||||||
if (!session) return;
|
|
||||||
if (session.generationCompletedAt || session.generationPhase === 'completed') completeParameterGenerationIfReady();
|
|
||||||
})
|
|
||||||
.catch(() => { /* the done reply on SSE settles it otherwise */ });
|
|
||||||
}
|
|
||||||
|
|
||||||
function fetchAgentPollingStatus() {
|
function fetchAgentPollingStatus() {
|
||||||
fetch('http://localhost:' + PORT + '/status?token=' + TOKEN, { cache: 'no-store' })
|
fetch('http://localhost:' + PORT + '/status?token=' + TOKEN, { cache: 'no-store' })
|
||||||
.then((res) => (res.ok ? res.json() : null))
|
.then((res) => (res.ok ? res.json() : null))
|
||||||
@@ -11606,15 +11104,11 @@ void main() {
|
|||||||
uiAppendStyle(s);
|
uiAppendStyle(s);
|
||||||
}
|
}
|
||||||
|
|
||||||
// The generate lane's helper says so in the served script itself, so a
|
|
||||||
// lane session never draws the bar at all; every other session mounts
|
|
||||||
// it exactly as before.
|
|
||||||
const barHiddenFromStart = window.__IMPECCABLE_LIVE_BAR_HIDDEN__ === true;
|
|
||||||
globalBarEl = el('div', {
|
globalBarEl = el('div', {
|
||||||
position: 'fixed', bottom: '14px', left: '50%',
|
position: 'fixed', bottom: '14px', left: '50%',
|
||||||
transform: 'translateX(-50%) translateY(20px)',
|
transform: 'translateX(-50%) translateY(20px)',
|
||||||
zIndex: Z.bar + 5,
|
zIndex: Z.bar + 5,
|
||||||
display: barHiddenFromStart ? 'none' : 'flex', alignItems: 'stretch',
|
display: 'flex', alignItems: 'stretch',
|
||||||
gap: '0',
|
gap: '0',
|
||||||
width: 'max-content',
|
width: 'max-content',
|
||||||
background: P.surface,
|
background: P.surface,
|
||||||
@@ -11630,10 +11124,6 @@ void main() {
|
|||||||
});
|
});
|
||||||
globalBarEl.id = PREFIX + '-global-bar';
|
globalBarEl.id = PREFIX + '-global-bar';
|
||||||
globalBarEl.dataset.theme = theme;
|
globalBarEl.dataset.theme = theme;
|
||||||
if (barHiddenFromStart) {
|
|
||||||
liveBarHiddenByHelper = true;
|
|
||||||
globalBarEl.dataset.liveBarDisplay = 'flex';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Brand mark - kinpaku Impeccable icon (site header / favicon paths).
|
// Brand mark - kinpaku Impeccable icon (site header / favicon paths).
|
||||||
const brand = el('span', {
|
const brand = el('span', {
|
||||||
@@ -12029,9 +11519,6 @@ void main() {
|
|||||||
// Listen for detection results AND ready signal
|
// Listen for detection results AND ready signal
|
||||||
window.addEventListener('message', onDetectMessage);
|
window.addEventListener('message', onDetectMessage);
|
||||||
updateGlobalBarState();
|
updateGlobalBarState();
|
||||||
// The helper may already have said the bar stays hidden (a connect
|
|
||||||
// that raced the bar build, or a reload mid-lane): re-apply it here.
|
|
||||||
if (liveBarHiddenByHelper) setLiveBarHidden(true);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateGlobalBarState() {
|
function updateGlobalBarState() {
|
||||||
@@ -12228,13 +11715,6 @@ void main() {
|
|||||||
|
|
||||||
/** Full teardown: remove all UI, disconnect SSE, clean up. */
|
/** Full teardown: remove all UI, disconnect SSE, clean up. */
|
||||||
function teardown() {
|
function teardown() {
|
||||||
// Declined targets die with the overlay: the IDLE transition below must
|
|
||||||
// not re-claim a lease this page can no longer act on. So does the
|
|
||||||
// target ledger: an 'acting' entry from a Go that never happened must
|
|
||||||
// not refuse every target the next connection hears.
|
|
||||||
busyDeclinedTargets.clear();
|
|
||||||
agentTargetsSeen.clear();
|
|
||||||
liveBarHiddenByHelper = false;
|
|
||||||
stopAgentStatusPoll();
|
stopAgentStatusPoll();
|
||||||
hideAgentPollTooltip();
|
hideAgentPollTooltip();
|
||||||
if (agentPollTooltipEl) {
|
if (agentPollTooltipEl) {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
name: impeccable
|
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.
|
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: 4.3.1
|
version: 4.3.0
|
||||||
license: Apache 2.0
|
license: Apache 2.0
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -63,8 +63,7 @@ Choose the mode from the requested surface, not the product, and persist it only
|
|||||||
| `clarify [target]` | Fix | Improve UX copy, labels, and error messages | [reference/clarify.md](reference/clarify.md) |
|
| `clarify [target]` | Fix | Improve UX copy, labels, and error messages | [reference/clarify.md](reference/clarify.md) |
|
||||||
| `adapt [target]` | Fix | Adapt for different devices and screen sizes | [reference/adapt.md](reference/adapt.md) · native: [reference/adapt.native.md](reference/adapt.native.md) |
|
| `adapt [target]` | Fix | Adapt for different devices and screen sizes | [reference/adapt.md](reference/adapt.md) · native: [reference/adapt.native.md](reference/adapt.native.md) |
|
||||||
| `optimize [target]` | Fix | Diagnose and fix UI performance | [reference/optimize.md](reference/optimize.md) |
|
| `optimize [target]` | Fix | Diagnose and fix UI performance | [reference/optimize.md](reference/optimize.md) |
|
||||||
| `live` | Iterate | Visual variant mode: pick elements in the browser, iterate on alternatives | [reference/live.md](reference/live.md) |
|
| `live` | Iterate | Visual variant mode: pick elements in the browser, generate alternatives | [reference/live.md](reference/live.md) |
|
||||||
| `generate [n] [action] [element]` | Iterate | Variants, versions, or alternatives of a named element to choose from in the live browser; no manual picking | [reference/generate.md](reference/generate.md) |
|
|
||||||
|
|
||||||
Routing:
|
Routing:
|
||||||
|
|
||||||
|
|||||||
@@ -188,12 +188,6 @@ Test thoroughly across contexts:
|
|||||||
- **Edge cases**: Very small screens (320px), very large screens (4K)
|
- **Edge cases**: Very small screens (320px), very large screens (4K)
|
||||||
- **Slow connections**: Test on throttled network
|
- **Slow connections**: Test on throttled network
|
||||||
|
|
||||||
**Custom controls** (sliders, drag surfaces, scrollable control strips): a before/after slider can pass every width check above and still refuse to drag on iOS, so exercise each one in scope in the same batched round as the checks above:
|
|
||||||
|
|
||||||
- **Primary gesture**: Tap it and confirm it responds as designed, then drag it with the target input method; the drag must complete, not just start
|
|
||||||
- **Scroll across it**: A swipe along the page's scroll axis across the control scrolls the page or container without activating it; a drag that starts on the control along its axis moves the control, not the page. Neither failure throws an error, so try both
|
|
||||||
- **Evidence**: Say what produced the evidence: an emulated viewport, synthesized touch input through a browser tool, which engine ran it (Chromium is not Safari), or a physical device. Screenshots and resized viewports verify layout, never a gesture. Name what stayed untested and move on; unreachable hardware is a reported gap, not a blocker
|
|
||||||
|
|
||||||
When the adaptation feels native to each context, hand off to `/impeccable polish` for the final pass.
|
When the adaptation feels native to each context, hand off to `/impeccable polish` for the final pass.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -48,12 +48,11 @@ Run comprehensive checks across 5 dimensions. Score each dimension 0-4 using the
|
|||||||
**Check for**:
|
**Check for**:
|
||||||
- **Fixed widths**: Hard-coded widths that break on mobile
|
- **Fixed widths**: Hard-coded widths that break on mobile
|
||||||
- **Touch targets**: Interactive elements < 44x44px
|
- **Touch targets**: Interactive elements < 44x44px
|
||||||
- **Broken touch interaction**: Custom sliders, drag surfaces, and scrollable control strips whose primary gesture fails under touch, that swallow page scroll or lose the drag to it, or that stay stuck after an interrupted gesture. Code tells: mouse-only handlers, no `touch-action` on a pointer-event drag surface, drag state that nothing clears on cancel, lost capture, or blur. Exercise the gesture when a browser tool can synthesize touch (a rendered viewport proves layout, not the gesture), then say what produced the evidence (emulated viewport, synthesized touch, which engine, physical device) and what stayed untested
|
|
||||||
- **Horizontal scroll**: Content overflow on narrow viewports
|
- **Horizontal scroll**: Content overflow on narrow viewports
|
||||||
- **Text scaling**: Layouts that break when text size increases
|
- **Text scaling**: Layouts that break when text size increases
|
||||||
- **Missing breakpoints**: No mobile/tablet variants
|
- **Missing breakpoints**: No mobile/tablet variants
|
||||||
|
|
||||||
**Score 0-4**: 0=Desktop-only (breaks on mobile), 1=Major issues (some breakpoints, many failures), 2=Partial (works on mobile, rough edges), 3=Good (responsive, minor touch target or overflow issues), 4=Excellent (fluid, all viewports, proper touch targets, gestures work under touch)
|
**Score 0-4**: 0=Desktop-only (breaks on mobile), 1=Major issues (some breakpoints, many failures), 2=Partial (works on mobile, rough edges), 3=Good (responsive, minor touch target or overflow issues), 4=Excellent (fluid, all viewports, proper touch targets)
|
||||||
|
|
||||||
### 5. Implementation Integrity (CRITICAL)
|
### 5. Implementation Integrity (CRITICAL)
|
||||||
|
|
||||||
|
|||||||
@@ -1,101 +0,0 @@
|
|||||||
> **Additional context needed**: only the target element, when the request does not name one that resolves uniquely on the page.
|
|
||||||
|
|
||||||
Generate is the fast lane into live mode: the user names an element, a direction, and a count in one sentence, and within a minute they are cycling through variants in their browser. One command boots the helper, hands the element to the overlay in the page your harness already shows (it scrolls to it, selects it, and fires the same Go a click fires) and returns the generate event; one edit writes the variants; one call replies and waits for the user's choice, which the helper bakes into source itself. This file owns the lane's plumbing; from the event onward the design work is [live.md](live.md)'s, unchanged, so read it in full now if you have not this session.
|
|
||||||
|
|
||||||
**Web only.** Live mode's browser overlay has no native equivalent; on `ios` / `android` / `adaptive` projects, decline this command and offer `bolder` or `quieter` on the source instead.
|
|
||||||
|
|
||||||
The plumbing is where the lane saves time: one command starts the session around the page your harness already shows, one call replies and waits, and nothing here is a browser you have to babysit. The design work is not where it saves time. Setup runs as for any command (`impeccable context`, this reference, craft-floor.md before the edit), and the variants are planned, written, and accepted exactly the way a live session plans, writes, and accepts them.
|
|
||||||
|
|
||||||
Three prohibitions cover the known ways this command goes wrong:
|
|
||||||
|
|
||||||
- **Never run init or document, and never ask for PRODUCT.md or DESIGN.md.** When they exist, the start command prints them under `boot` and you use them. When they do not, it says so (`contextMissing`, `contextNote`) and you extract the identity from the event (Step 3). A missing file is never a reason to interview the user inside this command; offer `init` in one line after the session ends.
|
|
||||||
- **Never hand-write a variants wrapper or invent a session id.** Only the browser mints session ids (8 hex characters, at Go). A missing event is fixed by rerunning Step 2, never with a direct source edit.
|
|
||||||
- **Do not act on hook findings while live markers are in the file**, and do not restyle variants to appease them; the accept verifies the file once the variant is permanent.
|
|
||||||
|
|
||||||
## Step 1: Parse the request
|
|
||||||
|
|
||||||
Three parts, all from the user's sentence:
|
|
||||||
|
|
||||||
- **A number in the request**: that is the count. **No number**: 3. The protocol caps count at 8.
|
|
||||||
- **The direction wording** maps onto the live action vocabulary; never invent a new action value:
|
|
||||||
- **bold, bolder, stronger, punchier**: `bolder`
|
|
||||||
- **quiet, calmer, softer, toned down**: `quieter`
|
|
||||||
- **simpler, minimal, stripped**: `distill`
|
|
||||||
- **refined, tightened, polished**: `polish`
|
|
||||||
- **font and type words**: `typeset`
|
|
||||||
- **color words**: `colorize`
|
|
||||||
- **arrangement and spacing words**: `layout`
|
|
||||||
- **device and breakpoint words**: `adapt`
|
|
||||||
- **motion words**: `animate`
|
|
||||||
- **playful words**: `delight`
|
|
||||||
- **rule-breaking words**: `overdrive`
|
|
||||||
- **Wording that carries intent but no vocabulary word** ("make it feel like a bank", "warmer", "more premium"): `impeccable`, with the user's wording passed as the prompt.
|
|
||||||
- **An action fits AND extra intent rides along** ("bolder, but keep it monochrome"): that action, with the rest as the prompt.
|
|
||||||
- **The wording names no direction at all** ("better", "improve", "nicer", "different", "fresh", "new", "redesign", "fix", "some options", "ideas", "alternatives", or just "variants" with nothing else): Ask the user directly to clarify what you cannot infer. Ask one question, offering the vocabulary: *"Which direction should the variants take? bolder, quieter, simpler (distill), polished, typography (typeset), color (colorize), layout, motion (animate), playful (delight), or rule-breaking (overdrive)."* Map the answer with this list; an answer that is still open ("surprise me", "you pick") is `impeccable` with the user's original wording as the prompt, and Step 2 starts on that answer.
|
|
||||||
- **The element description** ("the pricing cards", "the hero heading"): Step 2 resolves it to a selector.
|
|
||||||
|
|
||||||
Done when you hold an action from the vocabulary (asked for, when the request named no direction), a count from 1 to 8, and the element description.
|
|
||||||
|
|
||||||
## Step 2: Reuse the page, then start
|
|
||||||
|
|
||||||
**Reuse** the dev server already running and the tab your harness already shows it in; a second server or a second browser window is the failure this step prevents.
|
|
||||||
|
|
||||||
1. **Find the dev server**, cheapest source first, and stop at the first hit: the user's message, a browser tab already on the app (Claude Code: an origin in `tabs_context`), a server your harness started (Claude Code: `preview_list`), a terminal that printed its URL. Its origin is your `--dev-url`. **No hit**: leave `--dev-url` off and run the start command with no wait; the boot probes for a running server and its verdict names the move. `browser_needed` carries the `devUrl` it found: open it as in 2, then rerun with `--dev-url <devUrl> --wait-for-browser 60000`. `no_dev_server` means nothing serves the app: start the dev script the way the verdict says (Claude Code: `preview_start`; Cursor: a background terminal; Codex: an exec you yield from), wait for its URL, then rerun with `--dev-url <url>`.
|
|
||||||
2. **Open the page that renders the element in your browser, then start.** The route the request names, else the one `--target` serves; `--dev-url` takes only the origin.
|
|
||||||
- **Cursor** (`browser_navigate`) and **Claude Code** (`navigate`, which opens the Browser pane when it is closed and takes the `tabId` from `tabs_context` when a tab is already on that origin): open the URL, then run the start command with `--dev-url <url> --wait-for-browser 60000`. The boot injects the overlay and the page reloads into it while the command waits. Your browser tool is the only opener on these harnesses; the engine ignores `--open` there.
|
|
||||||
- **No browser tool** (Codex, others): run the start command with `--open --wait-for-browser 120000`; it opens the system browser, and the longer wait covers the user finding the tab. **`browser_open_failed` back**: tell the user the `url` in one line and rerun with `--wait-for-browser 120000`.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
.cursor/skills/impeccable/scripts/impeccable live-generate --target src/App.jsx --dev-url http://127.0.0.1:5173/ --selector ".pricing-grid" --action bolder --count 3 --boot --wait-for-browser 60000
|
|
||||||
```
|
|
||||||
|
|
||||||
Run it in the foreground in Cursor and Claude Code (it returns within the wait); on Codex, in an exec you yield from, the way Step 3 runs the poll.
|
|
||||||
|
|
||||||
- `--target`: the file that renders the element when the request or the project makes it obvious; skip it otherwise.
|
|
||||||
- `--dev-url`: the origin from 1; omit it and the boot probes.
|
|
||||||
- `--selector`: a unique class first, then a landmark tag plus class, an id last (every variant mounts a copy of the element, so an id repeats in the DOM). **The request names a repeated component in plural** ("the pricing cards"): target the container that holds the set, so one scoped stylesheet restyles every instance. One read of the source file that renders the element is allowed when the selector is not obvious; `--dry-run` resolves and reports without starting anything when it is not certain.
|
|
||||||
- `--boot`: runs the lane's boot (PRODUCT.md and DESIGN.md loaded again for the helper, missing files tolerated, dev URL found, bottom bar hidden for the helper's lifetime) and reuses a helper that is already running. Its result rides along as `boot`.
|
|
||||||
- Also available: `--prompt`, `--text` (keep only matches whose visible text contains a snippet), `--index` (1-based pick among matches).
|
|
||||||
|
|
||||||
Read the output in this order: `boot` (or `boot.contextMissing` with `boot.contextNote`: the page is the source of truth, per the note), then `event`, the generate event for `sessionId`, with the same `_instructions` a user's Go gets. Every verdict carries `_instructions`, and they win over your recollection of this file; the ones whose move is a decision of yours:
|
|
||||||
|
|
||||||
- **`ambiguous`**: the candidates are listed; target their common container, or rerun with `--text "<visible text>"` or `--index <n>`.
|
|
||||||
- **`dev_server_gone`**: the dev server stopped answering while the command waited for the page (on Cursor, a server another chat started dies with that chat). Start it the way the verdict says, then rerun with `--dev-url <url>`.
|
|
||||||
- **`no_match`**: the tab is on a route that does not render the element (navigate to the right route, rerun), or the selector is wrong (derive a better one from the source, or add `--text`).
|
|
||||||
- **`config_missing` / `config_invalid`** under `bootError`: follow [live-setup.md](live-setup.md) first, then rerun.
|
|
||||||
- **`event: null`** with `ok: true`: the event was slower than the wait; run `.cursor/skills/impeccable/scripts/impeccable live-poll` once to collect it, then continue.
|
|
||||||
|
|
||||||
Done when the output shows `ok: true`, a `sessionId`, and an `event`, reached with at most one server started and one tab opened by you.
|
|
||||||
|
|
||||||
## Step 3: Generate
|
|
||||||
|
|
||||||
The event is a standard `generate` event: the picked element's context, a preflighted scaffold, and `_instructions` naming the action's reference, the planning section, and the exact splice. Handle it exactly per live.md's **Handle generate**, which owns everything from the identity lock to the done reply: read the action's reference and craft-floor.md as it says, plan per section 4 (identity first, then mode, then three different primary axes, then the squint test), declare knobs per section 7, and deliver per section 6 (a complete replacement of the element per variant, the preview CSS plus every variant in one edit at the scaffold's splice). The lane changes nothing about what a variant may be: the moves a live session would make on this element (a promoted tier, a restructured set, a reordered card, a different surface) are open here too. Never screenshot the page; the overlay preview is the review channel until accept.
|
|
||||||
|
|
||||||
**Reply and wait in one call**, with the file you wrote:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
.cursor/skills/impeccable/scripts/impeccable live-poll --reply EVENT_ID done --file src/App.jsx --then-poll
|
|
||||||
```
|
|
||||||
|
|
||||||
This replies done (the browser mounts the variants) and then blocks until the user's choice arrives, so run it the way your harness runs a long wait: **Claude Code** in the foreground with your tool's longest timeout (600000 ms), so you are paused until the choice arrives; **Codex** in a yielded foreground exec; **Cursor** in a background terminal with notify on `"type":"(accept|discard|variant_mount_failed|exit)"`. Never pass a short `--timeout=`. While it runs there is nothing else to do: never sleep and never poll its output on a timer; a harness that backgrounds it wakes you when it returns. `{"type":"timeout"}` means the user has not chosen yet: run `live-poll` again and keep waiting. If the edit fails after the browser flipped to GENERATING, `--reply EVENT_ID error "Short reason"` (without `--then-poll`) so the bar resets.
|
|
||||||
|
|
||||||
Then tell the user, in one line, where their variants are: *"Three [bolder] variants are live on [the pricing cards]: cycle with the floating bar's arrows, adjust the Tune knobs, and Accept the keeper."*
|
|
||||||
|
|
||||||
Outside the replace path, read the matching live.md section before acting: `scaffold.previewMode: "svelte-component"` (Svelte previews are edited as components, and their accept is mechanical), `mode: "insert"`, `variant_mount_failed`, `steer`, `manual_edit_apply`, and any `fallback: "agent-driven"` wrap error.
|
|
||||||
|
|
||||||
## Step 4: Accept and close
|
|
||||||
|
|
||||||
The call from Step 3 returns the user's choice. **`discard`**: nothing to do. **`accept`**: `_acceptResult.carbonize: true` is the normal case, and the cleanup is live.md's **Required after accept**, unchanged: move the accepted variant's rules into the stylesheet that already owns the element with real selectors, bake the chosen knob values in, unwrap the element and drop every `data-impeccable-*` attribute, delete the inline `<style>` block and both `impeccable-carbonize` markers, then `.cursor/skills/impeccable/scripts/impeccable live-complete --id SESSION_ID` and confirm `phase: "completed"`. (`baked: true` appears only when the accept was run with `--bake`; then the helper already made the variant permanent and no `live-complete` is owed.)
|
|
||||||
|
|
||||||
Close without being asked, the moment the choice is handled:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
.cursor/skills/impeccable/scripts/impeccable live-server stop
|
|
||||||
```
|
|
||||||
|
|
||||||
Stopping removes the injected script and reloads the page once: the user sees the accepted design with no overlay chrome, still served by their dev server. **Never kill or restart the dev server**, including one you started in Step 2.
|
|
||||||
|
|
||||||
- **The user asks for more variants before you closed**: skip the close, run Step 2 again for the next element (the helper is reused), and close after the last choice.
|
|
||||||
- **Interrupted or unsure of the state**: `.cursor/skills/impeccable/scripts/impeccable live-status`, then `live-resume`; the journal under `.impeccable/live/sessions/` is canonical.
|
|
||||||
|
|
||||||
Done when the helper is stopped and the dev site still answers with the accepted design.
|
|
||||||
@@ -205,11 +205,6 @@ t('items', { count }) // Handles complex plural rules
|
|||||||
- Optimistic updates with rollback
|
- Optimistic updates with rollback
|
||||||
- Conflict resolution
|
- Conflict resolution
|
||||||
|
|
||||||
**Interrupted gestures** (custom sliders, drag surfaces, scrollable control strips):
|
|
||||||
- A second finger or pointer lands mid-drag: the first drag keeps its pointer or ends cleanly, never jumps to the new one
|
|
||||||
- The browser cancels the gesture to scroll (`pointercancel`), capture is lost (`lostpointercapture`), the pointer is released outside the control, or the window loses focus (`blur`) mid-drag: clear the dragging state and release capture
|
|
||||||
- After each of these, the next tap or drag works without a reload
|
|
||||||
|
|
||||||
**Permission states**:
|
**Permission states**:
|
||||||
- No permission to view
|
- No permission to view
|
||||||
- No permission to edit
|
- No permission to edit
|
||||||
@@ -309,7 +304,6 @@ const throttledScroll = throttle(handleScroll, 100);
|
|||||||
- Unit tests for edge cases
|
- Unit tests for edge cases
|
||||||
- Integration tests for error scenarios
|
- Integration tests for error scenarios
|
||||||
- E2E tests for critical paths
|
- E2E tests for critical paths
|
||||||
- A behavioral regression for each confirmed gesture fix, when the project's test runner can drive input
|
|
||||||
- Visual regression tests
|
- Visual regression tests
|
||||||
- Accessibility tests (axe, WAVE)
|
- Accessibility tests (axe, WAVE)
|
||||||
|
|
||||||
@@ -336,10 +330,7 @@ Test thoroughly with edge cases:
|
|||||||
- **Network issues**: Disable internet, throttle connection
|
- **Network issues**: Disable internet, throttle connection
|
||||||
- **Large datasets**: Test with 1000+ items
|
- **Large datasets**: Test with 1000+ items
|
||||||
- **Concurrent actions**: Click submit 10 times rapidly
|
- **Concurrent actions**: Click submit 10 times rapidly
|
||||||
- **Interrupted gestures**: Add a second finger mid-drag, scroll across the control, release outside it, switch windows mid-drag; then drag again
|
|
||||||
- **Errors**: Force API errors, test all error states
|
- **Errors**: Force API errors, test all error states
|
||||||
- **Empty**: Remove all data, test empty states
|
- **Empty**: Remove all data, test empty states
|
||||||
|
|
||||||
For gestures, say what produced the evidence (emulated viewport, synthesized touch, which engine, physical device) and name what stayed untested.
|
|
||||||
|
|
||||||
When edge cases are covered, hand off to `/impeccable polish` for the final pass.
|
When edge cases are covered, hand off to `/impeccable polish` for the final pass.
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ Reason over the signals; there is no score to obey:
|
|||||||
- `critique.latest` is `null` → the project has never been critiqued; for a set-up project with a real surface, offering `/impeccable critique <surface>` is a strong default.
|
- `critique.latest` is `null` → the project has never been critiqued; for a set-up project with a real surface, offering `/impeccable critique <surface>` is a strong default.
|
||||||
- `critique.latest` with a low `score` or non-zero `p0` / `p1` → `polish` (it reads that snapshot as its backlog and closes it when stale or cleared).
|
- `critique.latest` with a low `score` or non-zero `p0` / `p1` → `polish` (it reads that snapshot as its backlog and closes it when stale or cleared).
|
||||||
- `git.changedFiles` pointing at one surface → scope `audit` or `polish` to those files specifically, naming them.
|
- `git.changedFiles` pointing at one surface → scope `audit` or `polish` to those files specifically, naming them.
|
||||||
- `devServer.running` true → `live` is available for in-browser iteration, and `generate` for one-shot variant runs on a named element; if false, don't lead with either. **`live`, `generate`, and the bundled `impeccable detect` are web-only.** If `setup.platform` is `ios`, `android`, or `adaptive`, don't lead with any of them; the browser overlay and the HTML rule engine don't apply to native app code.
|
- `devServer.running` true → `live` is available for in-browser iteration; if false, don't lead with `live`. **`live` and the bundled `impeccable detect` are web-only.** If `setup.platform` is `ios`, `android`, or `adaptive`, don't lead with either; the browser overlay and the HTML rule engine don't apply to native app code.
|
||||||
- Otherwise group by intent (build new / improve what's there / iterate visually), tailored to the current surface and `setup.platform`.
|
- Otherwise group by intent (build new / improve what's there / iterate visually), tailored to the current surface and `setup.platform`.
|
||||||
|
|
||||||
**If `scan.targets` is non-empty and `setup.platform` is not `ios`/`android`/`adaptive`, run `.cursor/skills/impeccable/scripts/impeccable detect --json <scan.targets joined by spaces>` once** (the bundled detector over local files: no network, no npx; it reads HTML/CSS, so skip it for native projects). `scan.via` tells you what they are: `git-changes` (the markup/style files in your dirty tree, the most relevant set), `source-dir` (e.g. `src`, `app`), `html`, or `root`. Fold the hits into your picks: many quality / contrast hits → `audit` or `polish`; a specific slop family → the matching command (gradient text or eyebrows → `quieter` / `typeset`, flat or gray palette → `colorize`, and so on). It's a real, current signal that beats guessing. If detect errors or the tree is large and slow, skip it and recommend the user run `audit` themselves; never block the suggestion on it.
|
**If `scan.targets` is non-empty and `setup.platform` is not `ios`/`android`/`adaptive`, run `.cursor/skills/impeccable/scripts/impeccable detect --json <scan.targets joined by spaces>` once** (the bundled detector over local files: no network, no npx; it reads HTML/CSS, so skip it for native projects). `scan.via` tells you what they are: `git-changes` (the markup/style files in your dirty tree, the most relevant set), `source-dir` (e.g. `src`, `app`), `html`, or `root`. Fold the hits into your picks: many quality / contrast hits → `audit` or `polish`; a specific slop family → the matching command (gradient text or eyebrows → `quieter` / `typeset`, flat or gray palette → `colorize`, and so on). It's a real, current signal that beats guessing. If detect errors or the tree is large and slow, skip it and recommend the user run `audit` themselves; never block the suggestion on it.
|
||||||
|
|||||||
@@ -19,10 +19,6 @@
|
|||||||
"description": "Interactive live variant mode. Select elements in the browser, pick a design action, and get AI-generated HTML+CSS variants hot-swapped via HMR. Requires a running dev server. Use when you want to visually experiment with design alternatives in real time.",
|
"description": "Interactive live variant mode. Select elements in the browser, pick a design action, and get AI-generated HTML+CSS variants hot-swapped via HMR. Requires a running dev server. Use when you want to visually experiment with design alternatives in real time.",
|
||||||
"argumentHint": ""
|
"argumentHint": ""
|
||||||
},
|
},
|
||||||
"generate": {
|
|
||||||
"description": "Agent-driven live variant generation. Boots live mode, finds the named element on the open page, scrolls the browser to it, and delivers N variants in the requested direction for the user to cycle and accept. Use for requests that name an element and a direction, like 'generate 3 bold variants of the pricing cards', skipping manual element picking.",
|
|
||||||
"argumentHint": "[count] [direction] variants of [element]"
|
|
||||||
},
|
|
||||||
"adapt": {
|
"adapt": {
|
||||||
"description": "Adapt designs to work across different screen sizes, devices, contexts, or platforms. Implements breakpoints, fluid layouts, and touch targets. Use when the user mentions responsive design, mobile layouts, breakpoints, viewport adaptation, or cross-device compatibility.",
|
"description": "Adapt designs to work across different screen sizes, devices, contexts, or platforms. Implements breakpoints, fluid layouts, and touch targets. Use when the user mentions responsive design, mobile layouts, breakpoints, viewport adaptation, or cross-device compatibility.",
|
||||||
"argumentHint": "[target] [context (mobile, tablet, print...)]"
|
"argumentHint": "[target] [context (mobile, tablet, print...)]"
|
||||||
|
|||||||
@@ -87,11 +87,6 @@ fi
|
|||||||
# Last resort: fetch this version's binary for the current platform from the
|
# Last resort: fetch this version's binary for the current platform from the
|
||||||
# public release channel into the user cache. Needs network; sandboxes without
|
# public release channel into the user cache. Needs network; sandboxes without
|
||||||
# egress preinstall the binary on PATH instead.
|
# egress preinstall the binary on PATH instead.
|
||||||
setup_help() {
|
|
||||||
echo "Engine $version setup needs network access and write permission to $cache_root/bin/$version." >&2
|
|
||||||
echo "Run this launcher ($0) with engine-probe in a terminal that has those permissions, then retry the original command." >&2
|
|
||||||
echo "Alternatively, set IMPECCABLE_HOME to a writable cache location, or IMPECCABLE_BIN to a preinstalled engine binary." >&2
|
|
||||||
}
|
|
||||||
fetch_url() {
|
fetch_url() {
|
||||||
if command -v curl >/dev/null 2>&1; then
|
if command -v curl >/dev/null 2>&1; then
|
||||||
curl -fsSL --retry 2 -o "$tmp" "$1" 2>/dev/null
|
curl -fsSL --retry 2 -o "$tmp" "$1" 2>/dev/null
|
||||||
@@ -124,18 +119,7 @@ if [ -n "$version" ] && [ "$os" != unknown ] && [ "$arch" != unknown ]; then
|
|||||||
[ "$os" = windows ] && asset="$asset.exe"
|
[ "$os" = windows ] && asset="$asset.exe"
|
||||||
url="$base/engine-v$version/$asset"
|
url="$base/engine-v$version/$asset"
|
||||||
tmp="$cache_root/bin/$version/.impeccable.part.$$"
|
tmp="$cache_root/bin/$version/.impeccable.part.$$"
|
||||||
if ! mkdir -p "$cache_root/bin/$version" 2>/dev/null; then
|
mkdir -p "$cache_root/bin/$version" 2>/dev/null
|
||||||
echo "impeccable: engine $version is not installed; cannot create cache directory: $cache_root/bin/$version" >&2
|
|
||||||
setup_help
|
|
||||||
exit 127
|
|
||||||
fi
|
|
||||||
# Check the actual staging file, not just directory existence: a cache from
|
|
||||||
# an earlier run can be readable but no longer writable inside a sandbox.
|
|
||||||
if ! (umask 077; : > "$tmp") 2>/dev/null; then
|
|
||||||
echo "impeccable: engine $version is not installed; cannot write to cache directory: $cache_root/bin/$version" >&2
|
|
||||||
setup_help
|
|
||||||
exit 127
|
|
||||||
fi
|
|
||||||
fetched=0
|
fetched=0
|
||||||
if fetch_url "$url"; then
|
if fetch_url "$url"; then
|
||||||
fetched=1
|
fetched=1
|
||||||
@@ -196,9 +180,6 @@ if [ -n "$version" ] && [ "$os" != unknown ] && [ "$arch" != unknown ]; then
|
|||||||
exec "$cached" "$@"
|
exec "$cached" "$@"
|
||||||
fi
|
fi
|
||||||
rm -f "$tmp" 2>/dev/null
|
rm -f "$tmp" 2>/dev/null
|
||||||
echo "impeccable: could not download engine $version from $url; check network access, the release URL, and curl or wget availability." >&2
|
|
||||||
setup_help
|
|
||||||
exit 127
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "impeccable: no engine binary for $os-$arch found (looked in $bin, $cached, PATH)." >&2
|
echo "impeccable: no engine binary for $os-$arch found (looked in $bin, $cached, PATH)." >&2
|
||||||
|
|||||||
@@ -68,27 +68,18 @@ rem another launcher's probe: fail fast and quiet instead.
|
|||||||
if defined IMPECCABLE_LAUNCHER_PROBE exit /b 127
|
if defined IMPECCABLE_LAUNCHER_PROBE exit /b 127
|
||||||
if not defined version goto fail
|
if not defined version goto fail
|
||||||
where curl.exe >nul 2>nul
|
where curl.exe >nul 2>nul
|
||||||
if errorlevel 1 goto curl_missing
|
if errorlevel 1 goto fail
|
||||||
if not defined IMPECCABLE_DOWNLOAD_BASE set "IMPECCABLE_DOWNLOAD_BASE=https://github.com/pbakaus/impeccable/releases/download"
|
if not defined IMPECCABLE_DOWNLOAD_BASE set "IMPECCABLE_DOWNLOAD_BASE=https://github.com/pbakaus/impeccable/releases/download"
|
||||||
if exist "%IMPECCABLE_HOME%\bin\%version%\" goto cache_ready
|
if not exist "%IMPECCABLE_HOME%\bin\%version%" mkdir "%IMPECCABLE_HOME%\bin\%version%" >nul 2>nul
|
||||||
mkdir "%IMPECCABLE_HOME%\bin\%version%" >nul 2>nul
|
|
||||||
if errorlevel 1 goto cache_directory_failed
|
|
||||||
:cache_ready
|
|
||||||
rem Check the staging file too: an existing directory may be read-only.
|
|
||||||
rem Redirection failures do not reliably update ERRORLEVEL in cmd.exe;
|
|
||||||
rem branch on the command's failure directly. Never treat a directory as a
|
|
||||||
rem staging file (later del cleanup would prompt to delete its contents).
|
|
||||||
if exist "%cached%.part\" goto cache_write_failed
|
|
||||||
(type nul >"%cached%.part") 2>nul || goto cache_write_failed
|
|
||||||
set "asset=impeccable-windows-%arch%.exe"
|
set "asset=impeccable-windows-%arch%.exe"
|
||||||
set "url=%IMPECCABLE_DOWNLOAD_BASE%/engine-v%version%/%asset%"
|
set "url=%IMPECCABLE_DOWNLOAD_BASE%/engine-v%version%/%asset%"
|
||||||
curl.exe -fsSL -o "%cached%.part" "%url%" >nul 2>nul
|
curl.exe -fsSL -o "%cached%.part" "%url%" >nul 2>nul
|
||||||
if not errorlevel 1 goto verify
|
if not errorlevel 1 goto verify
|
||||||
if not "%arch%"=="arm64" goto download_failed
|
if not "%arch%"=="arm64" goto fail
|
||||||
set "asset=impeccable-windows-x64.exe"
|
set "asset=impeccable-windows-x64.exe"
|
||||||
set "url=%IMPECCABLE_DOWNLOAD_BASE%/engine-v%version%/%asset%"
|
set "url=%IMPECCABLE_DOWNLOAD_BASE%/engine-v%version%/%asset%"
|
||||||
curl.exe -fsSL -o "%cached%.part" "%url%" >nul 2>nul
|
curl.exe -fsSL -o "%cached%.part" "%url%" >nul 2>nul
|
||||||
if errorlevel 1 goto download_failed
|
if errorlevel 1 goto fail
|
||||||
|
|
||||||
:verify
|
:verify
|
||||||
call :check_download
|
call :check_download
|
||||||
@@ -185,28 +176,6 @@ if not errorlevel 1 set "probe_ok=1"
|
|||||||
del "%probe_tmp%" >nul 2>nul
|
del "%probe_tmp%" >nul 2>nul
|
||||||
exit /b 0
|
exit /b 0
|
||||||
|
|
||||||
:cache_directory_failed
|
|
||||||
echo impeccable: engine %version% is not installed; cannot create cache directory: "%IMPECCABLE_HOME%\bin\%version%" 1>&2
|
|
||||||
goto setup_failed
|
|
||||||
|
|
||||||
:cache_write_failed
|
|
||||||
echo impeccable: engine %version% is not installed; cannot write to cache directory: "%IMPECCABLE_HOME%\bin\%version%" 1>&2
|
|
||||||
goto setup_failed
|
|
||||||
|
|
||||||
:curl_missing
|
|
||||||
echo impeccable: cannot download engine %version%; curl.exe is unavailable. 1>&2
|
|
||||||
goto setup_failed
|
|
||||||
|
|
||||||
:download_failed
|
|
||||||
del "%cached%.part" >nul 2>nul
|
|
||||||
echo impeccable: could not download engine %version% from %url%; check network access and the release URL. 1>&2
|
|
||||||
|
|
||||||
:setup_failed
|
|
||||||
echo Engine %version% setup needs network access and write permission to "%IMPECCABLE_HOME%\bin\%version%". 1>&2
|
|
||||||
echo Run this launcher ("%~f0") with engine-probe in a terminal that has those permissions, then retry the original command. 1>&2
|
|
||||||
echo Alternatively, set IMPECCABLE_HOME to a writable cache location, or IMPECCABLE_BIN to a preinstalled engine binary. 1>&2
|
|
||||||
exit /b 127
|
|
||||||
|
|
||||||
:fail
|
:fail
|
||||||
del "%cached%.part" >nul 2>nul
|
del "%cached%.part" >nul 2>nul
|
||||||
echo impeccable: no engine binary found (looked in %bin%, %cached%, PATH). 1>&2
|
echo impeccable: no engine binary found (looked in %bin%, %cached%, PATH). 1>&2
|
||||||
|
|||||||
@@ -165,14 +165,6 @@
|
|||||||
}
|
}
|
||||||
let parameterGenerationState = 'idle';
|
let parameterGenerationState = 'idle';
|
||||||
let parameterReadyAnnouncedSession = null;
|
let parameterReadyAnnouncedSession = null;
|
||||||
// 'agent' when the generate verb fired this session's Go (the generate
|
|
||||||
// lane declares no knobs, so its bar never shows a pending Tune chip);
|
|
||||||
// null for every Go a user presses.
|
|
||||||
let sessionOrigin = null;
|
|
||||||
// The generate lane picks for the agent and never edits copy in the
|
|
||||||
// browser, so its selection carries no edit-copy badge (set on the
|
|
||||||
// agent-target pick, cleared with the session; a user's pick never sets it).
|
|
||||||
let editBadgeSuppressed = false;
|
|
||||||
let svelteComponentSession = null;
|
let svelteComponentSession = null;
|
||||||
let svelteRuntimePromise = null;
|
let svelteRuntimePromise = null;
|
||||||
let pendingSvelteComponentRetryObserver = null;
|
let pendingSvelteComponentRetryObserver = null;
|
||||||
@@ -991,20 +983,9 @@
|
|||||||
}
|
}
|
||||||
} catch { /* cross-origin */ }
|
} catch { /* cross-origin */ }
|
||||||
}
|
}
|
||||||
// The selector a mechanical bake would anchor lasting rules on, and how
|
|
||||||
// many elements it matches right now: the bake refuses anything but one,
|
|
||||||
// since its rules would restyle every match, not just this element.
|
|
||||||
const cssIdent = (s) => /^[A-Za-z_-][\w-]*$/.test(s);
|
|
||||||
const anchorClasses = [...el.classList].filter(cssIdent);
|
|
||||||
const anchor = el.id && cssIdent(el.id)
|
|
||||||
? '#' + el.id
|
|
||||||
: (anchorClasses.length ? el.tagName.toLowerCase() + '.' + anchorClasses.join('.') : null);
|
|
||||||
let anchorMatches = null;
|
|
||||||
if (anchor) { try { anchorMatches = document.querySelectorAll(anchor).length; } catch { anchorMatches = null; } }
|
|
||||||
return {
|
return {
|
||||||
tagName: el.tagName.toLowerCase(), id: el.id || null,
|
tagName: el.tagName.toLowerCase(), id: el.id || null,
|
||||||
classes: [...el.classList],
|
classes: [...el.classList],
|
||||||
anchor, anchorMatches,
|
|
||||||
textContent: (el.textContent || '').slice(0, 500),
|
textContent: (el.textContent || '').slice(0, 500),
|
||||||
outerHTML: sanitizedContextOuterHTML(el, 10000),
|
outerHTML: sanitizedContextOuterHTML(el, 10000),
|
||||||
computedStyles: {
|
computedStyles: {
|
||||||
@@ -2056,7 +2037,6 @@
|
|||||||
function setLiveState(next) {
|
function setLiveState(next) {
|
||||||
state = next;
|
state = next;
|
||||||
window.__IMPECCABLE_LIVE_STATE__ = next;
|
window.__IMPECCABLE_LIVE_STATE__ = next;
|
||||||
retryDeclinedAgentTargets();
|
|
||||||
syncPageInteractionCursor();
|
syncPageInteractionCursor();
|
||||||
// Whether a queued steer is still behind a generation is a function of this
|
// Whether a queued steer is still behind a generation is a function of this
|
||||||
// state, so the hint has to move with it, not only with the 5s poll.
|
// state, so the hint has to move with it, not only with the 5s poll.
|
||||||
@@ -4034,7 +4014,6 @@
|
|||||||
|
|
||||||
function hidePendingApplyDock() {
|
function hidePendingApplyDock() {
|
||||||
pendingApplyInFlight = false;
|
pendingApplyInFlight = false;
|
||||||
retryDeclinedAgentTargets();
|
|
||||||
clearStoredManualApplyState();
|
clearStoredManualApplyState();
|
||||||
if (pendingIntroAnimation) { pendingIntroAnimation.cancel(); pendingIntroAnimation = null; }
|
if (pendingIntroAnimation) { pendingIntroAnimation.cancel(); pendingIntroAnimation = null; }
|
||||||
if (pendingDockEl) pendingDockEl.style.display = 'none';
|
if (pendingDockEl) pendingDockEl.style.display = 'none';
|
||||||
@@ -4068,7 +4047,6 @@
|
|||||||
function setPendingApplyLoading(loading, count) {
|
function setPendingApplyLoading(loading, count) {
|
||||||
if (!pendingPillEl || !pendingPillLabelEl || !pendingPillCountEl || !pendingTrashBtn) return;
|
if (!pendingPillEl || !pendingPillLabelEl || !pendingPillCountEl || !pendingTrashBtn) return;
|
||||||
pendingApplyInFlight = loading === true;
|
pendingApplyInFlight = loading === true;
|
||||||
if (!pendingApplyInFlight) retryDeclinedAgentTargets();
|
|
||||||
const currentCount = count || parseInt(pendingPillEl.dataset.count || '0', 10) || 0;
|
const currentCount = count || parseInt(pendingPillEl.dataset.count || '0', 10) || 0;
|
||||||
if (pendingApplyInFlight) storeManualApplyState(currentCount);
|
if (pendingApplyInFlight) storeManualApplyState(currentCount);
|
||||||
else clearStoredManualApplyState();
|
else clearStoredManualApplyState();
|
||||||
@@ -4710,7 +4688,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderEditBadge(mode) {
|
function renderEditBadge(mode) {
|
||||||
if (editBadgeSuppressed || sessionOrigin === 'agent') mode = 'hidden';
|
|
||||||
if (mode === 'hidden' || !editBadgeEl) {
|
if (mode === 'hidden' || !editBadgeEl) {
|
||||||
hideConfigureBarTooltip();
|
hideConfigureBarTooltip();
|
||||||
if (editBadgeEl) editBadgeEl.style.display = 'none';
|
if (editBadgeEl) editBadgeEl.style.display = 'none';
|
||||||
@@ -6204,8 +6181,6 @@
|
|||||||
resetSessionFileMeta();
|
resetSessionFileMeta();
|
||||||
currentSessionId = null;
|
currentSessionId = null;
|
||||||
parameterGenerationState = 'idle';
|
parameterGenerationState = 'idle';
|
||||||
sessionOrigin = null;
|
|
||||||
editBadgeSuppressed = false;
|
|
||||||
parameterReadyAnnouncedSession = null;
|
parameterReadyAnnouncedSession = null;
|
||||||
expectedVariants = 0;
|
expectedVariants = 0;
|
||||||
arrivedVariants = 0;
|
arrivedVariants = 0;
|
||||||
@@ -7137,398 +7112,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
//
|
//
|
||||||
// ------------------------------------------------------------------
|
|
||||||
// Agent-initiated targeting (the `generate` command). The agent names an
|
|
||||||
// element by CSS selector over POST /agent-target; the server pushes an
|
|
||||||
// `agent_target` SSE message here. The overlay resolves the selector,
|
|
||||||
// scrolls the element into view, enters the same picked state a user
|
|
||||||
// click produces, and fires the normal Go pipeline, so everything
|
|
||||||
// downstream (generate event, variants, cycling, accept) is unchanged.
|
|
||||||
// The verdict goes back through POST /agent-target-result, which resolves
|
|
||||||
// the agent's held-open CLI call.
|
|
||||||
|
|
||||||
function postAgentTargetResult(targetId, result) {
|
|
||||||
fetch('http://localhost:' + PORT + '/agent-target-result?token=' + TOKEN, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ token: TOKEN, targetId, clientId: AGENT_TARGET_CLIENT_ID, ...result }),
|
|
||||||
}).catch(() => { /* server gone; nothing to report to */ });
|
|
||||||
}
|
|
||||||
|
|
||||||
function describeAgentTargetCandidate(el) {
|
|
||||||
return {
|
|
||||||
tag: el.tagName.toLowerCase(),
|
|
||||||
id: el.id || null,
|
|
||||||
classes: [...el.classList].filter((c) => !c.startsWith('impeccable-')),
|
|
||||||
text: (el.textContent || '').trim().slice(0, 80),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveAgentTargetElement(msg) {
|
|
||||||
let matched;
|
|
||||||
try {
|
|
||||||
matched = [...document.querySelectorAll(msg.selector)];
|
|
||||||
} catch {
|
|
||||||
return { error: { ok: false, error: 'invalid_selector', selector: msg.selector } };
|
|
||||||
}
|
|
||||||
let candidates = matched.filter((el) => pickable(el));
|
|
||||||
if (msg.text) {
|
|
||||||
const needle = String(msg.text).toLowerCase();
|
|
||||||
candidates = candidates.filter((el) => (el.textContent || '').toLowerCase().includes(needle));
|
|
||||||
}
|
|
||||||
if (candidates.length === 0) {
|
|
||||||
return {
|
|
||||||
error: {
|
|
||||||
ok: false,
|
|
||||||
error: 'no_match',
|
|
||||||
selector: msg.selector,
|
|
||||||
matchCount: 0,
|
|
||||||
// How many nodes the raw selector hit before the pickable/text
|
|
||||||
// filters: distinguishes a wrong selector from an unpickable match.
|
|
||||||
rawMatchCount: matched.length,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (Number.isInteger(msg.index)) {
|
|
||||||
const el = candidates[msg.index - 1];
|
|
||||||
if (!el) {
|
|
||||||
return { error: { ok: false, error: 'index_out_of_range', selector: msg.selector, matchCount: candidates.length } };
|
|
||||||
}
|
|
||||||
return { el, matchCount: candidates.length };
|
|
||||||
}
|
|
||||||
if (candidates.length > 1) {
|
|
||||||
return {
|
|
||||||
error: {
|
|
||||||
ok: false,
|
|
||||||
error: 'ambiguous',
|
|
||||||
selector: msg.selector,
|
|
||||||
matchCount: candidates.length,
|
|
||||||
candidates: candidates.slice(0, 8).map(describeAgentTargetCandidate),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return { el: candidates[0], matchCount: 1 };
|
|
||||||
}
|
|
||||||
|
|
||||||
function scrollAgentTargetIntoView(el, done) {
|
|
||||||
const rect = el.getBoundingClientRect();
|
|
||||||
if (rect.top >= 0 && rect.bottom <= window.innerHeight) { done(); return; }
|
|
||||||
let settled = false;
|
|
||||||
let fallback = null;
|
|
||||||
const finish = () => {
|
|
||||||
if (settled) return;
|
|
||||||
settled = true;
|
|
||||||
removeEventListener('scrollend', finish, true);
|
|
||||||
if (fallback) clearTimeout(fallback);
|
|
||||||
done();
|
|
||||||
};
|
|
||||||
// scrollend where supported; a timer covers engines without it and the
|
|
||||||
// no-movement case (element already at its final resting position).
|
|
||||||
addEventListener('scrollend', finish, true);
|
|
||||||
fallback = setTimeout(finish, 1200);
|
|
||||||
el.scrollIntoView({ block: 'center', behavior: 'smooth' });
|
|
||||||
}
|
|
||||||
|
|
||||||
// One id per page load: the server keys claims and roll-call reports on
|
|
||||||
// it, and only the tab that holds the lease can renew it.
|
|
||||||
const AGENT_TARGET_CLIENT_ID = id8();
|
|
||||||
|
|
||||||
// The agent target an agent-initiated Go is serving: set by
|
|
||||||
// actOnAgentTarget around its handleGo call, read once by handleGo.
|
|
||||||
let agentTargetForGo = null;
|
|
||||||
|
|
||||||
// The helper's word on its global bar. The generate lane asks the helper
|
|
||||||
// to keep it out of the way (`impeccable live --no-live-bar`, or an agent
|
|
||||||
// target carrying hideLiveBar), and the helper tells every connected tab
|
|
||||||
// at once (`live_bar`) and every later connection on `connected`, so the
|
|
||||||
// bar stays hidden in every tab, through reloads, the accept, and the
|
|
||||||
// bake, until the helper stops and takes the overlay with it. The variant
|
|
||||||
// controls still show.
|
|
||||||
let liveBarHiddenByHelper = false;
|
|
||||||
|
|
||||||
function applyLiveBarPreference(hidden) {
|
|
||||||
liveBarHiddenByHelper = hidden === true;
|
|
||||||
setLiveBarHidden(liveBarHiddenByHelper);
|
|
||||||
}
|
|
||||||
|
|
||||||
// A plain live session must never notice this code: hiding remembers the
|
|
||||||
// bar's own display value and restoring puts exactly that back, and a
|
|
||||||
// restore on a bar that is not hidden is a no-op, so the `connected`
|
|
||||||
// frame every session receives changes nothing unless the lane asked.
|
|
||||||
function setLiveBarHidden(hidden) {
|
|
||||||
if (!globalBarEl) return;
|
|
||||||
if (hidden) {
|
|
||||||
if (globalBarEl.style.display !== 'none') {
|
|
||||||
globalBarEl.dataset.liveBarDisplay = globalBarEl.style.display || 'flex';
|
|
||||||
globalBarEl.style.display = 'none';
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (globalBarEl.style.display === 'none') {
|
|
||||||
globalBarEl.style.display = globalBarEl.dataset.liveBarDisplay || 'flex';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function claimAgentTarget(targetId, report) {
|
|
||||||
return fetch('http://localhost:' + PORT + '/agent-target-claim?token=' + TOKEN, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ token: TOKEN, targetId, clientId: AGENT_TARGET_CLIENT_ID, ...report }),
|
|
||||||
}).then((res) => res.json())
|
|
||||||
.then((j) => ({ granted: !!j && j.granted === true, pending: !!j && j.pending === true }))
|
|
||||||
.catch(() => ({ granted: false, pending: false }));
|
|
||||||
}
|
|
||||||
|
|
||||||
// `exceptTargetId` is the target this call is about: a tab acting on it
|
|
||||||
// is not busy for itself, but it is busy for every other target, or two
|
|
||||||
// held requests could both be claimed here and the second Go would
|
|
||||||
// overwrite the session the first one minted.
|
|
||||||
function agentTargetBusyReason(exceptTargetId) {
|
|
||||||
if (pendingApplyInFlight) return 'manual_apply_in_flight';
|
|
||||||
if (state !== 'IDLE' && state !== 'PICKING' && state !== 'CONFIGURING') return 'session_active';
|
|
||||||
for (const [targetId, status] of agentTargetsSeen) {
|
|
||||||
if (status === 'acting' && targetId !== exceptTargetId) return 'agent_target_in_flight';
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Targets this tab declined as busy. A busy report is only this tab's word
|
|
||||||
// at that moment: the moment it is free again (setLiveState), it claims
|
|
||||||
// each of these as eligible, and the server drops the stale report, so a
|
|
||||||
// busy verdict is never built on a tab that has since gone idle. The
|
|
||||||
// server denies claims for resolved targets, so retries are harmless.
|
|
||||||
const busyDeclinedTargets = new Map();
|
|
||||||
|
|
||||||
function declineAgentTargetBusy(msg, busy) {
|
|
||||||
busyDeclinedTargets.set(msg.targetId, msg);
|
|
||||||
noteAgentTarget(msg.targetId, 'declined');
|
|
||||||
claimAgentTarget(msg.targetId, { eligible: false, state, reason: busy });
|
|
||||||
}
|
|
||||||
|
|
||||||
// A torn-down overlay, or one whose helper connection is gone, cannot
|
|
||||||
// serve a target and must not even claim one: it would hold the lease for
|
|
||||||
// a request it will never act on.
|
|
||||||
function agentTargetOverlayGone() {
|
|
||||||
return !evtSource;
|
|
||||||
}
|
|
||||||
|
|
||||||
// A denied claimant retries at this cadence, a little over the lease, so
|
|
||||||
// the first retry after a dead holder's lease lapses is granted.
|
|
||||||
const AGENT_TARGET_RESCUE_RETRY_MS = 3500;
|
|
||||||
|
|
||||||
// Claim the lease and act as the holder. A denied claim means another tab
|
|
||||||
// holds the lease. That holder can die before posting its result (reload,
|
|
||||||
// crash, even after renewing), and its lease lapses after ~3s, so this tab
|
|
||||||
// keeps retrying for as long as the server still holds the request: the
|
|
||||||
// answer's `pending` is the server's word that the request is alive, and
|
|
||||||
// it turns false the moment the request resolved or timed out, so no tab
|
|
||||||
// retries a request nobody awaits. A tab that turned busy meanwhile joins
|
|
||||||
// the roll call instead of taking a lease it cannot use. The first claim
|
|
||||||
// and the busy-to-idle re-claim share this.
|
|
||||||
function claimAndActOnAgentTarget(msg) {
|
|
||||||
if (agentTargetOverlayGone()) return;
|
|
||||||
const busy = agentTargetBusyReason(msg.targetId);
|
|
||||||
if (busy) { declineAgentTargetBusy(msg, busy); return; }
|
|
||||||
if (declineAgentTargetUnresolvable(msg)) return;
|
|
||||||
claimAgentTarget(msg.targetId, { eligible: true }).then((claim) => {
|
|
||||||
if (claim.granted) { noteAgentTarget(msg.targetId, 'acting'); actOnAgentTarget(msg); return; }
|
|
||||||
noteAgentTarget(msg.targetId, 'denied');
|
|
||||||
if (!claim.pending) return;
|
|
||||||
setTimeout(() => claimAndActOnAgentTarget(msg), AGENT_TARGET_RESCUE_RETRY_MS);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function retryDeclinedAgentTargets() {
|
|
||||||
if (busyDeclinedTargets.size === 0 || agentTargetBusyReason()) return;
|
|
||||||
for (const [targetId, msg] of busyDeclinedTargets) {
|
|
||||||
busyDeclinedTargets.delete(targetId);
|
|
||||||
claimAndActOnAgentTarget(msg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// This page's participation in each target it heard: 'acting' once a
|
|
||||||
// claim was granted, 'done' once it replied (or stood down from a lapsed
|
|
||||||
// lease), else the word it last gave. The server replays pending targets
|
|
||||||
// to every connection that opens. After a reconnect that overlapped the
|
|
||||||
// old connection the server still holds this page's word; after one that
|
|
||||||
// did not, it dropped the word on the close, so a replayed target is
|
|
||||||
// handled again: a busy or unresolvable page re-declines (idempotent), an
|
|
||||||
// idle page claims.
|
|
||||||
const agentTargetsSeen = new Map();
|
|
||||||
function noteAgentTarget(targetId, status) {
|
|
||||||
agentTargetsSeen.set(targetId, status);
|
|
||||||
if (agentTargetsSeen.size > 100) agentTargetsSeen.delete(agentTargetsSeen.keys().next().value);
|
|
||||||
}
|
|
||||||
|
|
||||||
// A target this page took a lease on is off-limits for a replay: while
|
|
||||||
// acting (a second claim or Go), and once done, because its result may
|
|
||||||
// still be on the wire and this tab is GENERATING by then, so handling
|
|
||||||
// the replay would decline busy, hand the lease back mid-resolution, and
|
|
||||||
// let another tab fire a second Go.
|
|
||||||
function agentTargetTaken(targetId) {
|
|
||||||
const status = agentTargetsSeen.get(targetId);
|
|
||||||
return status === 'acting' || status === 'done';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only a page that can resolve the target claims it. A tab whose page
|
|
||||||
// lacks the element declines with its resolution verdict instead, so a
|
|
||||||
// first-wins claim never lets the wrong page answer for a target that
|
|
||||||
// another page has. The server prefers a busy report (a tab that could
|
|
||||||
// serve later) over these, and returns the resolution verdict only when
|
|
||||||
// no connected page can serve.
|
|
||||||
//
|
|
||||||
// An element can be momentarily absent (a route still rendering, an HMR
|
|
||||||
// commit mid-swap), so a failed resolution is not this page's final word:
|
|
||||||
// it is re-checked a few times over about two seconds, claiming the
|
|
||||||
// moment the element mounts, and only the last miss is reported. The
|
|
||||||
// server's timeout still bounds the whole exchange.
|
|
||||||
// The page reports the miss at once (so the other overlays' words can
|
|
||||||
// complete the roll call) and keeps re-checking at this cadence for as
|
|
||||||
// long as the server says the request is pending: the server holds an
|
|
||||||
// all-no_match roll call open for a short grace precisely so a late mount
|
|
||||||
// can still be claimed, drops the stale report on an eligible claim, and
|
|
||||||
// ends the watch by answering pending:false once the request resolved or
|
|
||||||
// timed out.
|
|
||||||
const AGENT_TARGET_RESOLVE_WATCH_MS = 500;
|
|
||||||
|
|
||||||
function declineAgentTargetUnresolvable(msg) {
|
|
||||||
const probe = resolveAgentTargetElement(msg);
|
|
||||||
if (!probe.error) return false;
|
|
||||||
reportAgentTargetUnresolvable(msg, probe.error);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function reportAgentTargetUnresolvable(msg, error) {
|
|
||||||
noteAgentTarget(msg.targetId, 'declined');
|
|
||||||
claimAgentTarget(msg.targetId, { eligible: false, state, reason: 'no_match', result: error }).then((answer) => {
|
|
||||||
if (!answer.pending) return;
|
|
||||||
setTimeout(() => watchAgentTargetResolution(msg, error), AGENT_TARGET_RESOLVE_WATCH_MS);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function watchAgentTargetResolution(msg, lastError) {
|
|
||||||
if (agentTargetOverlayGone() || agentTargetTaken(msg.targetId)) return;
|
|
||||||
const busy = agentTargetBusyReason(msg.targetId);
|
|
||||||
if (busy) { declineAgentTargetBusy(msg, busy); return; }
|
|
||||||
const probe = resolveAgentTargetElement(msg);
|
|
||||||
if (!probe.error) { claimAndActOnAgentTarget(msg); return; }
|
|
||||||
// Still unresolvable: re-report (idempotent); the answer says whether
|
|
||||||
// the server is still holding the request open.
|
|
||||||
reportAgentTargetUnresolvable(msg, probe.error || lastError);
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleAgentTarget(msg) {
|
|
||||||
if (!msg || typeof msg.targetId !== 'string') return;
|
|
||||||
if (agentTargetTaken(msg.targetId)) return;
|
|
||||||
noteAgentTarget(msg.targetId, 'heard');
|
|
||||||
const busy = agentTargetBusyReason(msg.targetId);
|
|
||||||
if (busy) {
|
|
||||||
// Roll call: a busy tab reports itself and never acts. The server
|
|
||||||
// answers `busy` the moment every connected overlay has reported, so
|
|
||||||
// an idle tab elsewhere is never raced by a timer.
|
|
||||||
declineAgentTargetBusy(msg, busy);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (declineAgentTargetUnresolvable(msg)) return;
|
|
||||||
// Eligible tabs race for the server's lease and only the holder acts. A
|
|
||||||
// hidden tab yields a short head start so a visible one wins when both
|
|
||||||
// exist, and still serves the request on its own: the user finds the
|
|
||||||
// selection waiting when they return to it.
|
|
||||||
setTimeout(() => claimAndActOnAgentTarget(msg), document.hidden ? 150 : 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
function actOnAgentTarget(msg) {
|
|
||||||
if (agentTargetOverlayGone()) return;
|
|
||||||
// Every exit ends this tab's acting state, so a later target is not
|
|
||||||
// refused for a Go that already happened or never will.
|
|
||||||
const reply = (result) => { noteAgentTarget(msg.targetId, 'done'); postAgentTargetResult(msg.targetId, result); };
|
|
||||||
const busy = agentTargetBusyReason(msg.targetId);
|
|
||||||
if (busy) {
|
|
||||||
// Turned busy between claim and act: report it, which also hands the
|
|
||||||
// lease back so the roll call can complete or a rescuer can claim.
|
|
||||||
declineAgentTargetBusy(msg, busy);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const resolved = resolveAgentTargetElement(msg);
|
|
||||||
if (resolved.error) {
|
|
||||||
// The element went away between claim and act. A result would end the
|
|
||||||
// request for every tab; a decline hands the lease back so another
|
|
||||||
// page or a remount can still serve it.
|
|
||||||
reportAgentTargetUnresolvable(msg, resolved.error);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const el = resolved.el;
|
|
||||||
if (msg.dryRun) {
|
|
||||||
reply({
|
|
||||||
ok: true,
|
|
||||||
dryRun: true,
|
|
||||||
matchCount: resolved.matchCount,
|
|
||||||
element: describeAgentTargetCandidate(el),
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
scrollAgentTargetIntoView(el, () => {
|
|
||||||
// Torn down during the scroll settle: do not renew. The lease lapses
|
|
||||||
// for a rescuer instead of Go minting a session on a dismantled
|
|
||||||
// overlay.
|
|
||||||
if (agentTargetOverlayGone()) return;
|
|
||||||
// Renew the lease right before the irreversible part: a tab whose
|
|
||||||
// lease lapsed while it scrolled (a rescuer took over) stops here, so
|
|
||||||
// one request never gets two Go presses.
|
|
||||||
claimAgentTarget(msg.targetId, { eligible: true }).then((renewal) => {
|
|
||||||
if (!renewal.granted) { noteAgentTarget(msg.targetId, 'done'); return; }
|
|
||||||
// An insert placement left mid-configure gives way, exactly as a
|
|
||||||
// click outside it does in handleClick.
|
|
||||||
if (state === 'CONFIGURING' && configureKind === 'insert') cancelInsertConfigure();
|
|
||||||
// Mirror of the user-click pick entry in handleClick, minus the
|
|
||||||
// pick-mode gate (the agent's intent replaces the toggle); the entry
|
|
||||||
// goes through beginNewLiveConfiguration like every other pick so
|
|
||||||
// deferred recovery sees a fresh interaction revision.
|
|
||||||
selectedElement = el;
|
|
||||||
beginNewLiveConfiguration();
|
|
||||||
showHighlight(selectedElement);
|
|
||||||
clearAnnotations();
|
|
||||||
showAnnotOverlay(selectedElement);
|
|
||||||
showBar('configure');
|
|
||||||
editBadgeSuppressed = true;
|
|
||||||
renderEditBadge('hidden');
|
|
||||||
startScrollTracking();
|
|
||||||
maybePrefetchPage();
|
|
||||||
maybeWarnConditionalAncestor(selectedElement);
|
|
||||||
// Preset what the agent asked for, then fire the same Go a user press
|
|
||||||
// fires. handleGo reads exactly these inputs.
|
|
||||||
selectedAction = msg.action;
|
|
||||||
selectedCount = msg.count;
|
|
||||||
// updateBarContent rebuilds the configure row and replaces the input
|
|
||||||
// element, so the prompt must be written into the input it creates,
|
|
||||||
// never before (the action-chip click handler does the same dance).
|
|
||||||
updateBarContent('configure');
|
|
||||||
const input = uiGetById(PREFIX + '-input');
|
|
||||||
if (input) input.value = msg.prompt || '';
|
|
||||||
// The target rides on the generate event too: the helper resolves
|
|
||||||
// the request from whichever lands first, so a page that dies
|
|
||||||
// between Go and its result cannot leave the request pending for a
|
|
||||||
// second Go elsewhere.
|
|
||||||
const candidate = describeAgentTargetCandidate(el);
|
|
||||||
agentTargetForGo = { targetId: msg.targetId, matchCount: resolved.matchCount, action: msg.action, count: msg.count, element: candidate };
|
|
||||||
handleGo();
|
|
||||||
agentTargetForGo = null;
|
|
||||||
if (state === 'GENERATING' && currentSessionId) {
|
|
||||||
reply({
|
|
||||||
ok: true,
|
|
||||||
matchCount: resolved.matchCount,
|
|
||||||
sessionId: currentSessionId,
|
|
||||||
action: msg.action,
|
|
||||||
count: msg.count,
|
|
||||||
element: candidate,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
reply({ ok: false, error: 'go_failed', state });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// SSE (server→browser) + fetch POST (browser→server)
|
// SSE (server→browser) + fetch POST (browser→server)
|
||||||
// Zero-dependency replacement for WebSocket.
|
// Zero-dependency replacement for WebSocket.
|
||||||
//
|
//
|
||||||
@@ -7538,7 +7121,7 @@
|
|||||||
const SSE_MAX_RETRIES = 20; // generous: heartbeats keep the connection alive, so retries mean real trouble
|
const SSE_MAX_RETRIES = 20; // generous: heartbeats keep the connection alive, so retries mean real trouble
|
||||||
|
|
||||||
function connectSSE() {
|
function connectSSE() {
|
||||||
evtSource = new EventSource('http://localhost:' + PORT + '/events?token=' + TOKEN + '&clientId=' + AGENT_TARGET_CLIENT_ID);
|
evtSource = new EventSource('http://localhost:' + PORT + '/events?token=' + TOKEN);
|
||||||
|
|
||||||
evtSource.onopen = () => {
|
evtSource.onopen = () => {
|
||||||
sseRetries = 0; // reset on successful (re)connect
|
sseRetries = 0; // reset on successful (re)connect
|
||||||
@@ -7549,11 +7132,8 @@
|
|||||||
let msg; try { msg = JSON.parse(e.data); } catch { return; }
|
let msg; try { msg = JSON.parse(e.data); } catch { return; }
|
||||||
switch (msg.type) {
|
switch (msg.type) {
|
||||||
case 'connected':
|
case 'connected':
|
||||||
applyLiveBarPreference(msg.hideLiveBar === true);
|
|
||||||
hasProjectContext = !!msg.hasProjectContext;
|
hasProjectContext = !!msg.hasProjectContext;
|
||||||
// The generate lane runs without PRODUCT.md by design and never
|
if (!hasProjectContext) showToast(`No PRODUCT.md found. Variants will be brand-agnostic. Run ${IMPECCABLE_COMMAND} init to generate one.`, 7000);
|
||||||
// sends the user to init, so its quiet chrome skips this notice.
|
|
||||||
if (!hasProjectContext && !liveBarHiddenByHelper) showToast(`No PRODUCT.md found. Variants will be brand-agnostic. Run ${IMPECCABLE_COMMAND} init to generate one.`, 7000);
|
|
||||||
console.log('[impeccable] Live mode connected.');
|
console.log('[impeccable] Live mode connected.');
|
||||||
syncAgentPollingUi(!!msg.agentPolling);
|
syncAgentPollingUi(!!msg.agentPolling);
|
||||||
startAgentStatusPoll();
|
startAgentStatusPoll();
|
||||||
@@ -7563,15 +7143,9 @@
|
|||||||
syncPageInteractionCursor();
|
syncPageInteractionCursor();
|
||||||
syncPageChatFocus('sse-connected');
|
syncPageChatFocus('sse-connected');
|
||||||
break;
|
break;
|
||||||
case 'live_bar':
|
|
||||||
applyLiveBarPreference(msg.hidden === true);
|
|
||||||
break;
|
|
||||||
case 'agent_polling':
|
case 'agent_polling':
|
||||||
syncAgentPollingUi(!!msg.connected);
|
syncAgentPollingUi(!!msg.connected);
|
||||||
break;
|
break;
|
||||||
case 'agent_target':
|
|
||||||
handleAgentTarget(msg);
|
|
||||||
break;
|
|
||||||
case 'agent_phase':
|
case 'agent_phase':
|
||||||
if (msg.id === currentSessionId && (state === 'GENERATING' || state === 'CYCLING')) {
|
if (msg.id === currentSessionId && (state === 'GENERATING' || state === 'CYCLING')) {
|
||||||
// Advance the visible phase monotonically. A behind/resumed
|
// Advance the visible phase monotonically. A behind/resumed
|
||||||
@@ -7634,11 +7208,6 @@
|
|||||||
disableInlineEdit();
|
disableInlineEdit();
|
||||||
refreshParamsPanel();
|
refreshParamsPanel();
|
||||||
}
|
}
|
||||||
// The done reply is the agent's last word on this generation:
|
|
||||||
// with every variant mounted and no knobs declared, the Tune
|
|
||||||
// chip must stop spinning. A reload between the mount and this
|
|
||||||
// reply restored the pending state from the cache.
|
|
||||||
completeParameterGenerationIfReady();
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
// Source fallback when HMR did not land variants in this tab.
|
// Source fallback when HMR did not land variants in this tab.
|
||||||
@@ -7802,15 +7371,6 @@
|
|||||||
}).then(async res => {
|
}).then(async res => {
|
||||||
if (res.ok) return res;
|
if (res.ok) return res;
|
||||||
const body = await res.json().catch(() => ({}));
|
const body = await res.json().catch(() => ({}));
|
||||||
// The helper refused to open a session for an agent target it has
|
|
||||||
// already answered (another page served it after this page's lease
|
|
||||||
// lapsed mid-capture, or the request timed out): drop the local
|
|
||||||
// session and hand the surface back.
|
|
||||||
if (body.error === 'agent_target_already_served' && msg.type === 'generate'
|
|
||||||
&& msg.id && msg.id === currentSessionId) {
|
|
||||||
abandonSupersededGo(msg.id);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
// The server refused to journal progress for a session it has never
|
// The server refused to journal progress for a session it has never
|
||||||
// seen: this browser is carrying state from another project or a
|
// seen: this browser is carrying state from another project or a
|
||||||
// wiped store (two apps sharing a localhost port). Continuing to
|
// wiped store (two apps sharing a localhost port). Continuing to
|
||||||
@@ -7832,14 +7392,6 @@
|
|||||||
return sessionCreationGate.then(doSend);
|
return sessionCreationGate.then(doSend);
|
||||||
}
|
}
|
||||||
|
|
||||||
function abandonSupersededGo(sessionId) {
|
|
||||||
if (sessionId !== currentSessionId) return;
|
|
||||||
console.warn('[impeccable] The helper already answered this agent target; clearing session ' + sessionId + '.');
|
|
||||||
markSessionHandled();
|
|
||||||
cleanup({ instantChrome: true });
|
|
||||||
showToast('The helper already answered this request, so this session was cleared. Pick an element to start fresh.', 6000);
|
|
||||||
}
|
|
||||||
|
|
||||||
let abandonedForeignSessionId = null;
|
let abandonedForeignSessionId = null;
|
||||||
function abandonForeignSession(sessionId) {
|
function abandonForeignSession(sessionId) {
|
||||||
if (abandonedForeignSessionId === sessionId || sessionId !== currentSessionId) return;
|
if (abandonedForeignSessionId === sessionId || sessionId !== currentSessionId) return;
|
||||||
@@ -8244,7 +7796,6 @@
|
|||||||
visibleVariant = 0;
|
visibleVariant = 0;
|
||||||
generationPhase = 'queued';
|
generationPhase = 'queued';
|
||||||
parameterGenerationState = 'pending';
|
parameterGenerationState = 'pending';
|
||||||
sessionOrigin = agentTargetForGo ? 'agent' : null;
|
|
||||||
parameterReadyAnnouncedSession = null;
|
parameterReadyAnnouncedSession = null;
|
||||||
resetSessionFileMeta();
|
resetSessionFileMeta();
|
||||||
|
|
||||||
@@ -8270,24 +7821,6 @@
|
|||||||
};
|
};
|
||||||
if (snapshot.comments.length > 0) basePayload.comments = snapshot.comments;
|
if (snapshot.comments.length > 0) basePayload.comments = snapshot.comments;
|
||||||
if (snapshot.strokes.length > 0) basePayload.strokes = snapshot.strokes;
|
if (snapshot.strokes.length > 0) basePayload.strokes = snapshot.strokes;
|
||||||
if (agentTargetForGo) {
|
|
||||||
// An agent-initiated Go names the target it serves (see
|
|
||||||
// actOnAgentTarget): the helper resolves that request from this event
|
|
||||||
// as well as from the overlay's own result post.
|
|
||||||
basePayload.agentTarget = {
|
|
||||||
targetId: agentTargetForGo.targetId,
|
|
||||||
clientId: AGENT_TARGET_CLIENT_ID,
|
|
||||||
result: {
|
|
||||||
ok: true,
|
|
||||||
matchCount: agentTargetForGo.matchCount,
|
|
||||||
sessionId: currentSessionId,
|
|
||||||
action: agentTargetForGo.action,
|
|
||||||
count: agentTargetForGo.count,
|
|
||||||
element: agentTargetForGo.element,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
agentTargetForGo = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Hide the interactive overlay so it doesn't linger during generation.
|
// Hide the interactive overlay so it doesn't linger during generation.
|
||||||
hideAnnotOverlay();
|
hideAnnotOverlay();
|
||||||
@@ -8348,7 +7881,6 @@
|
|||||||
visibleVariant = 0;
|
visibleVariant = 0;
|
||||||
generationPhase = 'queued';
|
generationPhase = 'queued';
|
||||||
parameterGenerationState = 'pending';
|
parameterGenerationState = 'pending';
|
||||||
sessionOrigin = agentTargetForGo ? 'agent' : null;
|
|
||||||
parameterReadyAnnouncedSession = null;
|
parameterReadyAnnouncedSession = null;
|
||||||
resetSessionFileMeta();
|
resetSessionFileMeta();
|
||||||
selectedElement = placeholderElement;
|
selectedElement = placeholderElement;
|
||||||
@@ -9395,8 +8927,6 @@ void main() {
|
|||||||
pagePickSkipClick = false;
|
pagePickSkipClick = false;
|
||||||
currentSessionId = null;
|
currentSessionId = null;
|
||||||
parameterGenerationState = 'idle';
|
parameterGenerationState = 'idle';
|
||||||
sessionOrigin = null;
|
|
||||||
editBadgeSuppressed = false;
|
|
||||||
parameterReadyAnnouncedSession = null;
|
parameterReadyAnnouncedSession = null;
|
||||||
selectedAction = 'impeccable';
|
selectedAction = 'impeccable';
|
||||||
pendingAcceptedSession = null;
|
pendingAcceptedSession = null;
|
||||||
@@ -9488,7 +9018,6 @@ void main() {
|
|||||||
paramsCurrentValues = { ...saved.paramValues };
|
paramsCurrentValues = { ...saved.paramValues };
|
||||||
}
|
}
|
||||||
if (saved.parameterState) parameterGenerationState = saved.parameterState;
|
if (saved.parameterState) parameterGenerationState = saved.parameterState;
|
||||||
sessionOrigin = saved.origin === 'agent' ? 'agent' : null;
|
|
||||||
if (saved.generationPhase) generationPhase = saved.generationPhase;
|
if (saved.generationPhase) generationPhase = saved.generationPhase;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -9576,12 +9105,7 @@ void main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function restoreSessionWithoutWrapper(reason, activeSessions) {
|
function restoreSessionWithoutWrapper(reason, activeSessions) {
|
||||||
// The session cache is per origin, so a tab on another page of the same
|
const cached = loadSession();
|
||||||
// app sees this page's session too. Only the page that saved it may
|
|
||||||
// resume it: the server-adoption branch below already applies the same
|
|
||||||
// check, and a tab on another page has nothing to render for it.
|
|
||||||
const cachedRaw = loadSession();
|
|
||||||
const cached = cachedRaw?.id && !pageMatchesCurrent(cachedRaw.pageUrl) ? null : cachedRaw;
|
|
||||||
// localStorage is a cache, not a gate. A cleared tab, a second browser
|
// localStorage is a cache, not a gate. A cleared tab, a second browser
|
||||||
// profile, or a teardown that dropped local state all leave the durable
|
// profile, or a teardown that dropped local state all leave the durable
|
||||||
// server session as the only record of work in progress; adopt it instead
|
// server session as the only record of work in progress; adopt it instead
|
||||||
@@ -9694,7 +9218,6 @@ void main() {
|
|||||||
pageUrl: location.pathname,
|
pageUrl: location.pathname,
|
||||||
paramValues: { ...paramsCurrentValues },
|
paramValues: { ...paramsCurrentValues },
|
||||||
parameterState: parameterGenerationState,
|
parameterState: parameterGenerationState,
|
||||||
origin: sessionOrigin || undefined,
|
|
||||||
insertPlaceholder: insertPlaceholderSnapshot || undefined,
|
insertPlaceholder: insertPlaceholderSnapshot || undefined,
|
||||||
pickedAnchor: pickedAnchorSnapshot || undefined,
|
pickedAnchor: pickedAnchorSnapshot || undefined,
|
||||||
pickedAnchorViewportTop: Number.isFinite(pickedAnchorViewportTop) ? pickedAnchorViewportTop : undefined,
|
pickedAnchorViewportTop: Number.isFinite(pickedAnchorViewportTop) ? pickedAnchorViewportTop : undefined,
|
||||||
@@ -9820,8 +9343,6 @@ void main() {
|
|||||||
pagePickSkipClick = false;
|
pagePickSkipClick = false;
|
||||||
currentSessionId = null;
|
currentSessionId = null;
|
||||||
parameterGenerationState = 'idle';
|
parameterGenerationState = 'idle';
|
||||||
sessionOrigin = null;
|
|
||||||
editBadgeSuppressed = false;
|
|
||||||
parameterReadyAnnouncedSession = null;
|
parameterReadyAnnouncedSession = null;
|
||||||
selectedAction = 'impeccable';
|
selectedAction = 'impeccable';
|
||||||
renderEditBadge('hidden');
|
renderEditBadge('hidden');
|
||||||
@@ -10080,14 +9601,6 @@ void main() {
|
|||||||
|
|
||||||
const resumedState = arrivedVariants > 0 ? 'CYCLING' : 'GENERATING';
|
const resumedState = arrivedVariants > 0 ? 'CYCLING' : 'GENERATING';
|
||||||
|
|
||||||
// A reload between the variants mounting and the agent's done reply
|
|
||||||
// restores a pending Tune state from the cache; the helper knows whether
|
|
||||||
// that generation already finished.
|
|
||||||
if (arrivedVariants >= expectedVariants && expectedVariants > 0
|
|
||||||
&& (parameterGenerationState === 'pending' || parameterGenerationState === 'loading')) {
|
|
||||||
settleParameterStateFromHelper(sessionId);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find the visible variant's content element for highlight positioning.
|
// Find the visible variant's content element for highlight positioning.
|
||||||
const isInsert = wrapper.dataset.impeccableMode === 'insert';
|
const isInsert = wrapper.dataset.impeccableMode === 'insert';
|
||||||
const visEl = visibleVariant > 0 ? pickVariantContent(wrapper, visibleVariant) : null;
|
const visEl = visibleVariant > 0 ? pickVariantContent(wrapper, visibleVariant) : null;
|
||||||
@@ -11552,21 +11065,6 @@ void main() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// After a resume the cache may say the Tune knobs are still coming while
|
|
||||||
// the agent already replied done before the reload. The helper's session
|
|
||||||
// record settles it; otherwise the done reply on SSE does.
|
|
||||||
function settleParameterStateFromHelper(sessionId) {
|
|
||||||
fetch('http://localhost:' + PORT + '/status?token=' + TOKEN, { cache: 'no-store' })
|
|
||||||
.then((res) => (res.ok ? res.json() : null))
|
|
||||||
.then((data) => {
|
|
||||||
if (!data || sessionId !== currentSessionId) return;
|
|
||||||
const session = (data.activeSessions || []).find((s) => s && s.id === sessionId);
|
|
||||||
if (!session) return;
|
|
||||||
if (session.generationCompletedAt || session.generationPhase === 'completed') completeParameterGenerationIfReady();
|
|
||||||
})
|
|
||||||
.catch(() => { /* the done reply on SSE settles it otherwise */ });
|
|
||||||
}
|
|
||||||
|
|
||||||
function fetchAgentPollingStatus() {
|
function fetchAgentPollingStatus() {
|
||||||
fetch('http://localhost:' + PORT + '/status?token=' + TOKEN, { cache: 'no-store' })
|
fetch('http://localhost:' + PORT + '/status?token=' + TOKEN, { cache: 'no-store' })
|
||||||
.then((res) => (res.ok ? res.json() : null))
|
.then((res) => (res.ok ? res.json() : null))
|
||||||
@@ -11606,15 +11104,11 @@ void main() {
|
|||||||
uiAppendStyle(s);
|
uiAppendStyle(s);
|
||||||
}
|
}
|
||||||
|
|
||||||
// The generate lane's helper says so in the served script itself, so a
|
|
||||||
// lane session never draws the bar at all; every other session mounts
|
|
||||||
// it exactly as before.
|
|
||||||
const barHiddenFromStart = window.__IMPECCABLE_LIVE_BAR_HIDDEN__ === true;
|
|
||||||
globalBarEl = el('div', {
|
globalBarEl = el('div', {
|
||||||
position: 'fixed', bottom: '14px', left: '50%',
|
position: 'fixed', bottom: '14px', left: '50%',
|
||||||
transform: 'translateX(-50%) translateY(20px)',
|
transform: 'translateX(-50%) translateY(20px)',
|
||||||
zIndex: Z.bar + 5,
|
zIndex: Z.bar + 5,
|
||||||
display: barHiddenFromStart ? 'none' : 'flex', alignItems: 'stretch',
|
display: 'flex', alignItems: 'stretch',
|
||||||
gap: '0',
|
gap: '0',
|
||||||
width: 'max-content',
|
width: 'max-content',
|
||||||
background: P.surface,
|
background: P.surface,
|
||||||
@@ -11630,10 +11124,6 @@ void main() {
|
|||||||
});
|
});
|
||||||
globalBarEl.id = PREFIX + '-global-bar';
|
globalBarEl.id = PREFIX + '-global-bar';
|
||||||
globalBarEl.dataset.theme = theme;
|
globalBarEl.dataset.theme = theme;
|
||||||
if (barHiddenFromStart) {
|
|
||||||
liveBarHiddenByHelper = true;
|
|
||||||
globalBarEl.dataset.liveBarDisplay = 'flex';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Brand mark - kinpaku Impeccable icon (site header / favicon paths).
|
// Brand mark - kinpaku Impeccable icon (site header / favicon paths).
|
||||||
const brand = el('span', {
|
const brand = el('span', {
|
||||||
@@ -12029,9 +11519,6 @@ void main() {
|
|||||||
// Listen for detection results AND ready signal
|
// Listen for detection results AND ready signal
|
||||||
window.addEventListener('message', onDetectMessage);
|
window.addEventListener('message', onDetectMessage);
|
||||||
updateGlobalBarState();
|
updateGlobalBarState();
|
||||||
// The helper may already have said the bar stays hidden (a connect
|
|
||||||
// that raced the bar build, or a reload mid-lane): re-apply it here.
|
|
||||||
if (liveBarHiddenByHelper) setLiveBarHidden(true);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateGlobalBarState() {
|
function updateGlobalBarState() {
|
||||||
@@ -12228,13 +11715,6 @@ void main() {
|
|||||||
|
|
||||||
/** Full teardown: remove all UI, disconnect SSE, clean up. */
|
/** Full teardown: remove all UI, disconnect SSE, clean up. */
|
||||||
function teardown() {
|
function teardown() {
|
||||||
// Declined targets die with the overlay: the IDLE transition below must
|
|
||||||
// not re-claim a lease this page can no longer act on. So does the
|
|
||||||
// target ledger: an 'acting' entry from a Go that never happened must
|
|
||||||
// not refuse every target the next connection hears.
|
|
||||||
busyDeclinedTargets.clear();
|
|
||||||
agentTargetsSeen.clear();
|
|
||||||
liveBarHiddenByHelper = false;
|
|
||||||
stopAgentStatusPoll();
|
stopAgentStatusPoll();
|
||||||
hideAgentPollTooltip();
|
hideAgentPollTooltip();
|
||||||
if (agentPollTooltipEl) {
|
if (agentPollTooltipEl) {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
name: impeccable
|
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.
|
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: 4.3.1
|
version: 4.3.0
|
||||||
user-invocable: true
|
user-invocable: true
|
||||||
license: Apache 2.0
|
license: Apache 2.0
|
||||||
---
|
---
|
||||||
@@ -64,8 +64,7 @@ Choose the mode from the requested surface, not the product, and persist it only
|
|||||||
| `clarify [target]` | Fix | Improve UX copy, labels, and error messages | [reference/clarify.md](reference/clarify.md) |
|
| `clarify [target]` | Fix | Improve UX copy, labels, and error messages | [reference/clarify.md](reference/clarify.md) |
|
||||||
| `adapt [target]` | Fix | Adapt for different devices and screen sizes | [reference/adapt.md](reference/adapt.md) · native: [reference/adapt.native.md](reference/adapt.native.md) |
|
| `adapt [target]` | Fix | Adapt for different devices and screen sizes | [reference/adapt.md](reference/adapt.md) · native: [reference/adapt.native.md](reference/adapt.native.md) |
|
||||||
| `optimize [target]` | Fix | Diagnose and fix UI performance | [reference/optimize.md](reference/optimize.md) |
|
| `optimize [target]` | Fix | Diagnose and fix UI performance | [reference/optimize.md](reference/optimize.md) |
|
||||||
| `live` | Iterate | Visual variant mode: pick elements in the browser, iterate on alternatives | [reference/live.md](reference/live.md) |
|
| `live` | Iterate | Visual variant mode: pick elements in the browser, generate alternatives | [reference/live.md](reference/live.md) |
|
||||||
| `generate [n] [action] [element]` | Iterate | Variants, versions, or alternatives of a named element to choose from in the live browser; no manual picking | [reference/generate.md](reference/generate.md) |
|
|
||||||
|
|
||||||
Routing:
|
Routing:
|
||||||
|
|
||||||
|
|||||||
@@ -188,12 +188,6 @@ Test thoroughly across contexts:
|
|||||||
- **Edge cases**: Very small screens (320px), very large screens (4K)
|
- **Edge cases**: Very small screens (320px), very large screens (4K)
|
||||||
- **Slow connections**: Test on throttled network
|
- **Slow connections**: Test on throttled network
|
||||||
|
|
||||||
**Custom controls** (sliders, drag surfaces, scrollable control strips): a before/after slider can pass every width check above and still refuse to drag on iOS, so exercise each one in scope in the same batched round as the checks above:
|
|
||||||
|
|
||||||
- **Primary gesture**: Tap it and confirm it responds as designed, then drag it with the target input method; the drag must complete, not just start
|
|
||||||
- **Scroll across it**: A swipe along the page's scroll axis across the control scrolls the page or container without activating it; a drag that starts on the control along its axis moves the control, not the page. Neither failure throws an error, so try both
|
|
||||||
- **Evidence**: Say what produced the evidence: an emulated viewport, synthesized touch input through a browser tool, which engine ran it (Chromium is not Safari), or a physical device. Screenshots and resized viewports verify layout, never a gesture. Name what stayed untested and move on; unreachable hardware is a reported gap, not a blocker
|
|
||||||
|
|
||||||
When the adaptation feels native to each context, hand off to `/impeccable polish` for the final pass.
|
When the adaptation feels native to each context, hand off to `/impeccable polish` for the final pass.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -48,12 +48,11 @@ Run comprehensive checks across 5 dimensions. Score each dimension 0-4 using the
|
|||||||
**Check for**:
|
**Check for**:
|
||||||
- **Fixed widths**: Hard-coded widths that break on mobile
|
- **Fixed widths**: Hard-coded widths that break on mobile
|
||||||
- **Touch targets**: Interactive elements < 44x44px
|
- **Touch targets**: Interactive elements < 44x44px
|
||||||
- **Broken touch interaction**: Custom sliders, drag surfaces, and scrollable control strips whose primary gesture fails under touch, that swallow page scroll or lose the drag to it, or that stay stuck after an interrupted gesture. Code tells: mouse-only handlers, no `touch-action` on a pointer-event drag surface, drag state that nothing clears on cancel, lost capture, or blur. Exercise the gesture when a browser tool can synthesize touch (a rendered viewport proves layout, not the gesture), then say what produced the evidence (emulated viewport, synthesized touch, which engine, physical device) and what stayed untested
|
|
||||||
- **Horizontal scroll**: Content overflow on narrow viewports
|
- **Horizontal scroll**: Content overflow on narrow viewports
|
||||||
- **Text scaling**: Layouts that break when text size increases
|
- **Text scaling**: Layouts that break when text size increases
|
||||||
- **Missing breakpoints**: No mobile/tablet variants
|
- **Missing breakpoints**: No mobile/tablet variants
|
||||||
|
|
||||||
**Score 0-4**: 0=Desktop-only (breaks on mobile), 1=Major issues (some breakpoints, many failures), 2=Partial (works on mobile, rough edges), 3=Good (responsive, minor touch target or overflow issues), 4=Excellent (fluid, all viewports, proper touch targets, gestures work under touch)
|
**Score 0-4**: 0=Desktop-only (breaks on mobile), 1=Major issues (some breakpoints, many failures), 2=Partial (works on mobile, rough edges), 3=Good (responsive, minor touch target or overflow issues), 4=Excellent (fluid, all viewports, proper touch targets)
|
||||||
|
|
||||||
### 5. Implementation Integrity (CRITICAL)
|
### 5. Implementation Integrity (CRITICAL)
|
||||||
|
|
||||||
|
|||||||
@@ -1,101 +0,0 @@
|
|||||||
> **Additional context needed**: only the target element, when the request does not name one that resolves uniquely on the page.
|
|
||||||
|
|
||||||
Generate is the fast lane into live mode: the user names an element, a direction, and a count in one sentence, and within a minute they are cycling through variants in their browser. One command boots the helper, hands the element to the overlay in the page your harness already shows (it scrolls to it, selects it, and fires the same Go a click fires) and returns the generate event; one edit writes the variants; one call replies and waits for the user's choice, which the helper bakes into source itself. This file owns the lane's plumbing; from the event onward the design work is [live.md](live.md)'s, unchanged, so read it in full now if you have not this session.
|
|
||||||
|
|
||||||
**Web only.** Live mode's browser overlay has no native equivalent; on `ios` / `android` / `adaptive` projects, decline this command and offer `bolder` or `quieter` on the source instead.
|
|
||||||
|
|
||||||
The plumbing is where the lane saves time: one command starts the session around the page your harness already shows, one call replies and waits, and nothing here is a browser you have to babysit. The design work is not where it saves time. Setup runs as for any command (`impeccable context`, this reference, craft-floor.md before the edit), and the variants are planned, written, and accepted exactly the way a live session plans, writes, and accepts them.
|
|
||||||
|
|
||||||
Three prohibitions cover the known ways this command goes wrong:
|
|
||||||
|
|
||||||
- **Never run init or document, and never ask for PRODUCT.md or DESIGN.md.** When they exist, the start command prints them under `boot` and you use them. When they do not, it says so (`contextMissing`, `contextNote`) and you extract the identity from the event (Step 3). A missing file is never a reason to interview the user inside this command; offer `init` in one line after the session ends.
|
|
||||||
- **Never hand-write a variants wrapper or invent a session id.** Only the browser mints session ids (8 hex characters, at Go). A missing event is fixed by rerunning Step 2, never with a direct source edit.
|
|
||||||
- **Do not act on hook findings while live markers are in the file**, and do not restyle variants to appease them; the accept verifies the file once the variant is permanent.
|
|
||||||
|
|
||||||
## Step 1: Parse the request
|
|
||||||
|
|
||||||
Three parts, all from the user's sentence:
|
|
||||||
|
|
||||||
- **A number in the request**: that is the count. **No number**: 3. The protocol caps count at 8.
|
|
||||||
- **The direction wording** maps onto the live action vocabulary; never invent a new action value:
|
|
||||||
- **bold, bolder, stronger, punchier**: `bolder`
|
|
||||||
- **quiet, calmer, softer, toned down**: `quieter`
|
|
||||||
- **simpler, minimal, stripped**: `distill`
|
|
||||||
- **refined, tightened, polished**: `polish`
|
|
||||||
- **font and type words**: `typeset`
|
|
||||||
- **color words**: `colorize`
|
|
||||||
- **arrangement and spacing words**: `layout`
|
|
||||||
- **device and breakpoint words**: `adapt`
|
|
||||||
- **motion words**: `animate`
|
|
||||||
- **playful words**: `delight`
|
|
||||||
- **rule-breaking words**: `overdrive`
|
|
||||||
- **Wording that carries intent but no vocabulary word** ("make it feel like a bank", "warmer", "more premium"): `impeccable`, with the user's wording passed as the prompt.
|
|
||||||
- **An action fits AND extra intent rides along** ("bolder, but keep it monochrome"): that action, with the rest as the prompt.
|
|
||||||
- **The wording names no direction at all** ("better", "improve", "nicer", "different", "fresh", "new", "redesign", "fix", "some options", "ideas", "alternatives", or just "variants" with nothing else): STOP and call the ask_user_question tool to clarify. Ask one question, offering the vocabulary: *"Which direction should the variants take? bolder, quieter, simpler (distill), polished, typography (typeset), color (colorize), layout, motion (animate), playful (delight), or rule-breaking (overdrive)."* Map the answer with this list; an answer that is still open ("surprise me", "you pick") is `impeccable` with the user's original wording as the prompt, and Step 2 starts on that answer.
|
|
||||||
- **The element description** ("the pricing cards", "the hero heading"): Step 2 resolves it to a selector.
|
|
||||||
|
|
||||||
Done when you hold an action from the vocabulary (asked for, when the request named no direction), a count from 1 to 8, and the element description.
|
|
||||||
|
|
||||||
## Step 2: Reuse the page, then start
|
|
||||||
|
|
||||||
**Reuse** the dev server already running and the tab your harness already shows it in; a second server or a second browser window is the failure this step prevents.
|
|
||||||
|
|
||||||
1. **Find the dev server**, cheapest source first, and stop at the first hit: the user's message, a browser tab already on the app (Claude Code: an origin in `tabs_context`), a server your harness started (Claude Code: `preview_list`), a terminal that printed its URL. Its origin is your `--dev-url`. **No hit**: leave `--dev-url` off and run the start command with no wait; the boot probes for a running server and its verdict names the move. `browser_needed` carries the `devUrl` it found: open it as in 2, then rerun with `--dev-url <devUrl> --wait-for-browser 60000`. `no_dev_server` means nothing serves the app: start the dev script the way the verdict says (Claude Code: `preview_start`; Cursor: a background terminal; Codex: an exec you yield from), wait for its URL, then rerun with `--dev-url <url>`.
|
|
||||||
2. **Open the page that renders the element in your browser, then start.** The route the request names, else the one `--target` serves; `--dev-url` takes only the origin.
|
|
||||||
- **Cursor** (`browser_navigate`) and **Claude Code** (`navigate`, which opens the Browser pane when it is closed and takes the `tabId` from `tabs_context` when a tab is already on that origin): open the URL, then run the start command with `--dev-url <url> --wait-for-browser 60000`. The boot injects the overlay and the page reloads into it while the command waits. Your browser tool is the only opener on these harnesses; the engine ignores `--open` there.
|
|
||||||
- **No browser tool** (Codex, others): run the start command with `--open --wait-for-browser 120000`; it opens the system browser, and the longer wait covers the user finding the tab. **`browser_open_failed` back**: tell the user the `url` in one line and rerun with `--wait-for-browser 120000`.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
.dsh/skills/impeccable/scripts/impeccable live-generate --target src/App.jsx --dev-url http://127.0.0.1:5173/ --selector ".pricing-grid" --action bolder --count 3 --boot --wait-for-browser 60000
|
|
||||||
```
|
|
||||||
|
|
||||||
Run it in the foreground in Cursor and Claude Code (it returns within the wait); on Codex, in an exec you yield from, the way Step 3 runs the poll.
|
|
||||||
|
|
||||||
- `--target`: the file that renders the element when the request or the project makes it obvious; skip it otherwise.
|
|
||||||
- `--dev-url`: the origin from 1; omit it and the boot probes.
|
|
||||||
- `--selector`: a unique class first, then a landmark tag plus class, an id last (every variant mounts a copy of the element, so an id repeats in the DOM). **The request names a repeated component in plural** ("the pricing cards"): target the container that holds the set, so one scoped stylesheet restyles every instance. One read of the source file that renders the element is allowed when the selector is not obvious; `--dry-run` resolves and reports without starting anything when it is not certain.
|
|
||||||
- `--boot`: runs the lane's boot (PRODUCT.md and DESIGN.md loaded again for the helper, missing files tolerated, dev URL found, bottom bar hidden for the helper's lifetime) and reuses a helper that is already running. Its result rides along as `boot`.
|
|
||||||
- Also available: `--prompt`, `--text` (keep only matches whose visible text contains a snippet), `--index` (1-based pick among matches).
|
|
||||||
|
|
||||||
Read the output in this order: `boot` (or `boot.contextMissing` with `boot.contextNote`: the page is the source of truth, per the note), then `event`, the generate event for `sessionId`, with the same `_instructions` a user's Go gets. Every verdict carries `_instructions`, and they win over your recollection of this file; the ones whose move is a decision of yours:
|
|
||||||
|
|
||||||
- **`ambiguous`**: the candidates are listed; target their common container, or rerun with `--text "<visible text>"` or `--index <n>`.
|
|
||||||
- **`dev_server_gone`**: the dev server stopped answering while the command waited for the page (on Cursor, a server another chat started dies with that chat). Start it the way the verdict says, then rerun with `--dev-url <url>`.
|
|
||||||
- **`no_match`**: the tab is on a route that does not render the element (navigate to the right route, rerun), or the selector is wrong (derive a better one from the source, or add `--text`).
|
|
||||||
- **`config_missing` / `config_invalid`** under `bootError`: follow [live-setup.md](live-setup.md) first, then rerun.
|
|
||||||
- **`event: null`** with `ok: true`: the event was slower than the wait; run `.dsh/skills/impeccable/scripts/impeccable live-poll` once to collect it, then continue.
|
|
||||||
|
|
||||||
Done when the output shows `ok: true`, a `sessionId`, and an `event`, reached with at most one server started and one tab opened by you.
|
|
||||||
|
|
||||||
## Step 3: Generate
|
|
||||||
|
|
||||||
The event is a standard `generate` event: the picked element's context, a preflighted scaffold, and `_instructions` naming the action's reference, the planning section, and the exact splice. Handle it exactly per live.md's **Handle generate**, which owns everything from the identity lock to the done reply: read the action's reference and craft-floor.md as it says, plan per section 4 (identity first, then mode, then three different primary axes, then the squint test), declare knobs per section 7, and deliver per section 6 (a complete replacement of the element per variant, the preview CSS plus every variant in one edit at the scaffold's splice). The lane changes nothing about what a variant may be: the moves a live session would make on this element (a promoted tier, a restructured set, a reordered card, a different surface) are open here too. Never screenshot the page; the overlay preview is the review channel until accept.
|
|
||||||
|
|
||||||
**Reply and wait in one call**, with the file you wrote:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
.dsh/skills/impeccable/scripts/impeccable live-poll --reply EVENT_ID done --file src/App.jsx --then-poll
|
|
||||||
```
|
|
||||||
|
|
||||||
This replies done (the browser mounts the variants) and then blocks until the user's choice arrives, so run it the way your harness runs a long wait: **Claude Code** in the foreground with your tool's longest timeout (600000 ms), so you are paused until the choice arrives; **Codex** in a yielded foreground exec; **Cursor** in a background terminal with notify on `"type":"(accept|discard|variant_mount_failed|exit)"`. Never pass a short `--timeout=`. While it runs there is nothing else to do: never sleep and never poll its output on a timer; a harness that backgrounds it wakes you when it returns. `{"type":"timeout"}` means the user has not chosen yet: run `live-poll` again and keep waiting. If the edit fails after the browser flipped to GENERATING, `--reply EVENT_ID error "Short reason"` (without `--then-poll`) so the bar resets.
|
|
||||||
|
|
||||||
Then tell the user, in one line, where their variants are: *"Three [bolder] variants are live on [the pricing cards]: cycle with the floating bar's arrows, adjust the Tune knobs, and Accept the keeper."*
|
|
||||||
|
|
||||||
Outside the replace path, read the matching live.md section before acting: `scaffold.previewMode: "svelte-component"` (Svelte previews are edited as components, and their accept is mechanical), `mode: "insert"`, `variant_mount_failed`, `steer`, `manual_edit_apply`, and any `fallback: "agent-driven"` wrap error.
|
|
||||||
|
|
||||||
## Step 4: Accept and close
|
|
||||||
|
|
||||||
The call from Step 3 returns the user's choice. **`discard`**: nothing to do. **`accept`**: `_acceptResult.carbonize: true` is the normal case, and the cleanup is live.md's **Required after accept**, unchanged: move the accepted variant's rules into the stylesheet that already owns the element with real selectors, bake the chosen knob values in, unwrap the element and drop every `data-impeccable-*` attribute, delete the inline `<style>` block and both `impeccable-carbonize` markers, then `.dsh/skills/impeccable/scripts/impeccable live-complete --id SESSION_ID` and confirm `phase: "completed"`. (`baked: true` appears only when the accept was run with `--bake`; then the helper already made the variant permanent and no `live-complete` is owed.)
|
|
||||||
|
|
||||||
Close without being asked, the moment the choice is handled:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
.dsh/skills/impeccable/scripts/impeccable live-server stop
|
|
||||||
```
|
|
||||||
|
|
||||||
Stopping removes the injected script and reloads the page once: the user sees the accepted design with no overlay chrome, still served by their dev server. **Never kill or restart the dev server**, including one you started in Step 2.
|
|
||||||
|
|
||||||
- **The user asks for more variants before you closed**: skip the close, run Step 2 again for the next element (the helper is reused), and close after the last choice.
|
|
||||||
- **Interrupted or unsure of the state**: `.dsh/skills/impeccable/scripts/impeccable live-status`, then `live-resume`; the journal under `.impeccable/live/sessions/` is canonical.
|
|
||||||
|
|
||||||
Done when the helper is stopped and the dev site still answers with the accepted design.
|
|
||||||
@@ -205,11 +205,6 @@ t('items', { count }) // Handles complex plural rules
|
|||||||
- Optimistic updates with rollback
|
- Optimistic updates with rollback
|
||||||
- Conflict resolution
|
- Conflict resolution
|
||||||
|
|
||||||
**Interrupted gestures** (custom sliders, drag surfaces, scrollable control strips):
|
|
||||||
- A second finger or pointer lands mid-drag: the first drag keeps its pointer or ends cleanly, never jumps to the new one
|
|
||||||
- The browser cancels the gesture to scroll (`pointercancel`), capture is lost (`lostpointercapture`), the pointer is released outside the control, or the window loses focus (`blur`) mid-drag: clear the dragging state and release capture
|
|
||||||
- After each of these, the next tap or drag works without a reload
|
|
||||||
|
|
||||||
**Permission states**:
|
**Permission states**:
|
||||||
- No permission to view
|
- No permission to view
|
||||||
- No permission to edit
|
- No permission to edit
|
||||||
@@ -309,7 +304,6 @@ const throttledScroll = throttle(handleScroll, 100);
|
|||||||
- Unit tests for edge cases
|
- Unit tests for edge cases
|
||||||
- Integration tests for error scenarios
|
- Integration tests for error scenarios
|
||||||
- E2E tests for critical paths
|
- E2E tests for critical paths
|
||||||
- A behavioral regression for each confirmed gesture fix, when the project's test runner can drive input
|
|
||||||
- Visual regression tests
|
- Visual regression tests
|
||||||
- Accessibility tests (axe, WAVE)
|
- Accessibility tests (axe, WAVE)
|
||||||
|
|
||||||
@@ -336,10 +330,7 @@ Test thoroughly with edge cases:
|
|||||||
- **Network issues**: Disable internet, throttle connection
|
- **Network issues**: Disable internet, throttle connection
|
||||||
- **Large datasets**: Test with 1000+ items
|
- **Large datasets**: Test with 1000+ items
|
||||||
- **Concurrent actions**: Click submit 10 times rapidly
|
- **Concurrent actions**: Click submit 10 times rapidly
|
||||||
- **Interrupted gestures**: Add a second finger mid-drag, scroll across the control, release outside it, switch windows mid-drag; then drag again
|
|
||||||
- **Errors**: Force API errors, test all error states
|
- **Errors**: Force API errors, test all error states
|
||||||
- **Empty**: Remove all data, test empty states
|
- **Empty**: Remove all data, test empty states
|
||||||
|
|
||||||
For gestures, say what produced the evidence (emulated viewport, synthesized touch, which engine, physical device) and name what stayed untested.
|
|
||||||
|
|
||||||
When edge cases are covered, hand off to `/impeccable polish` for the final pass.
|
When edge cases are covered, hand off to `/impeccable polish` for the final pass.
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ Reason over the signals; there is no score to obey:
|
|||||||
- `critique.latest` is `null` → the project has never been critiqued; for a set-up project with a real surface, offering `/impeccable critique <surface>` is a strong default.
|
- `critique.latest` is `null` → the project has never been critiqued; for a set-up project with a real surface, offering `/impeccable critique <surface>` is a strong default.
|
||||||
- `critique.latest` with a low `score` or non-zero `p0` / `p1` → `polish` (it reads that snapshot as its backlog and closes it when stale or cleared).
|
- `critique.latest` with a low `score` or non-zero `p0` / `p1` → `polish` (it reads that snapshot as its backlog and closes it when stale or cleared).
|
||||||
- `git.changedFiles` pointing at one surface → scope `audit` or `polish` to those files specifically, naming them.
|
- `git.changedFiles` pointing at one surface → scope `audit` or `polish` to those files specifically, naming them.
|
||||||
- `devServer.running` true → `live` is available for in-browser iteration, and `generate` for one-shot variant runs on a named element; if false, don't lead with either. **`live`, `generate`, and the bundled `impeccable detect` are web-only.** If `setup.platform` is `ios`, `android`, or `adaptive`, don't lead with any of them; the browser overlay and the HTML rule engine don't apply to native app code.
|
- `devServer.running` true → `live` is available for in-browser iteration; if false, don't lead with `live`. **`live` and the bundled `impeccable detect` are web-only.** If `setup.platform` is `ios`, `android`, or `adaptive`, don't lead with either; the browser overlay and the HTML rule engine don't apply to native app code.
|
||||||
- Otherwise group by intent (build new / improve what's there / iterate visually), tailored to the current surface and `setup.platform`.
|
- Otherwise group by intent (build new / improve what's there / iterate visually), tailored to the current surface and `setup.platform`.
|
||||||
|
|
||||||
**If `scan.targets` is non-empty and `setup.platform` is not `ios`/`android`/`adaptive`, run `.dsh/skills/impeccable/scripts/impeccable detect --json <scan.targets joined by spaces>` once** (the bundled detector over local files: no network, no npx; it reads HTML/CSS, so skip it for native projects). `scan.via` tells you what they are: `git-changes` (the markup/style files in your dirty tree, the most relevant set), `source-dir` (e.g. `src`, `app`), `html`, or `root`. Fold the hits into your picks: many quality / contrast hits → `audit` or `polish`; a specific slop family → the matching command (gradient text or eyebrows → `quieter` / `typeset`, flat or gray palette → `colorize`, and so on). It's a real, current signal that beats guessing. If detect errors or the tree is large and slow, skip it and recommend the user run `audit` themselves; never block the suggestion on it.
|
**If `scan.targets` is non-empty and `setup.platform` is not `ios`/`android`/`adaptive`, run `.dsh/skills/impeccable/scripts/impeccable detect --json <scan.targets joined by spaces>` once** (the bundled detector over local files: no network, no npx; it reads HTML/CSS, so skip it for native projects). `scan.via` tells you what they are: `git-changes` (the markup/style files in your dirty tree, the most relevant set), `source-dir` (e.g. `src`, `app`), `html`, or `root`. Fold the hits into your picks: many quality / contrast hits → `audit` or `polish`; a specific slop family → the matching command (gradient text or eyebrows → `quieter` / `typeset`, flat or gray palette → `colorize`, and so on). It's a real, current signal that beats guessing. If detect errors or the tree is large and slow, skip it and recommend the user run `audit` themselves; never block the suggestion on it.
|
||||||
|
|||||||
@@ -19,10 +19,6 @@
|
|||||||
"description": "Interactive live variant mode. Select elements in the browser, pick a design action, and get AI-generated HTML+CSS variants hot-swapped via HMR. Requires a running dev server. Use when you want to visually experiment with design alternatives in real time.",
|
"description": "Interactive live variant mode. Select elements in the browser, pick a design action, and get AI-generated HTML+CSS variants hot-swapped via HMR. Requires a running dev server. Use when you want to visually experiment with design alternatives in real time.",
|
||||||
"argumentHint": ""
|
"argumentHint": ""
|
||||||
},
|
},
|
||||||
"generate": {
|
|
||||||
"description": "Agent-driven live variant generation. Boots live mode, finds the named element on the open page, scrolls the browser to it, and delivers N variants in the requested direction for the user to cycle and accept. Use for requests that name an element and a direction, like 'generate 3 bold variants of the pricing cards', skipping manual element picking.",
|
|
||||||
"argumentHint": "[count] [direction] variants of [element]"
|
|
||||||
},
|
|
||||||
"adapt": {
|
"adapt": {
|
||||||
"description": "Adapt designs to work across different screen sizes, devices, contexts, or platforms. Implements breakpoints, fluid layouts, and touch targets. Use when the user mentions responsive design, mobile layouts, breakpoints, viewport adaptation, or cross-device compatibility.",
|
"description": "Adapt designs to work across different screen sizes, devices, contexts, or platforms. Implements breakpoints, fluid layouts, and touch targets. Use when the user mentions responsive design, mobile layouts, breakpoints, viewport adaptation, or cross-device compatibility.",
|
||||||
"argumentHint": "[target] [context (mobile, tablet, print...)]"
|
"argumentHint": "[target] [context (mobile, tablet, print...)]"
|
||||||
|
|||||||
@@ -87,11 +87,6 @@ fi
|
|||||||
# Last resort: fetch this version's binary for the current platform from the
|
# Last resort: fetch this version's binary for the current platform from the
|
||||||
# public release channel into the user cache. Needs network; sandboxes without
|
# public release channel into the user cache. Needs network; sandboxes without
|
||||||
# egress preinstall the binary on PATH instead.
|
# egress preinstall the binary on PATH instead.
|
||||||
setup_help() {
|
|
||||||
echo "Engine $version setup needs network access and write permission to $cache_root/bin/$version." >&2
|
|
||||||
echo "Run this launcher ($0) with engine-probe in a terminal that has those permissions, then retry the original command." >&2
|
|
||||||
echo "Alternatively, set IMPECCABLE_HOME to a writable cache location, or IMPECCABLE_BIN to a preinstalled engine binary." >&2
|
|
||||||
}
|
|
||||||
fetch_url() {
|
fetch_url() {
|
||||||
if command -v curl >/dev/null 2>&1; then
|
if command -v curl >/dev/null 2>&1; then
|
||||||
curl -fsSL --retry 2 -o "$tmp" "$1" 2>/dev/null
|
curl -fsSL --retry 2 -o "$tmp" "$1" 2>/dev/null
|
||||||
@@ -124,18 +119,7 @@ if [ -n "$version" ] && [ "$os" != unknown ] && [ "$arch" != unknown ]; then
|
|||||||
[ "$os" = windows ] && asset="$asset.exe"
|
[ "$os" = windows ] && asset="$asset.exe"
|
||||||
url="$base/engine-v$version/$asset"
|
url="$base/engine-v$version/$asset"
|
||||||
tmp="$cache_root/bin/$version/.impeccable.part.$$"
|
tmp="$cache_root/bin/$version/.impeccable.part.$$"
|
||||||
if ! mkdir -p "$cache_root/bin/$version" 2>/dev/null; then
|
mkdir -p "$cache_root/bin/$version" 2>/dev/null
|
||||||
echo "impeccable: engine $version is not installed; cannot create cache directory: $cache_root/bin/$version" >&2
|
|
||||||
setup_help
|
|
||||||
exit 127
|
|
||||||
fi
|
|
||||||
# Check the actual staging file, not just directory existence: a cache from
|
|
||||||
# an earlier run can be readable but no longer writable inside a sandbox.
|
|
||||||
if ! (umask 077; : > "$tmp") 2>/dev/null; then
|
|
||||||
echo "impeccable: engine $version is not installed; cannot write to cache directory: $cache_root/bin/$version" >&2
|
|
||||||
setup_help
|
|
||||||
exit 127
|
|
||||||
fi
|
|
||||||
fetched=0
|
fetched=0
|
||||||
if fetch_url "$url"; then
|
if fetch_url "$url"; then
|
||||||
fetched=1
|
fetched=1
|
||||||
@@ -196,9 +180,6 @@ if [ -n "$version" ] && [ "$os" != unknown ] && [ "$arch" != unknown ]; then
|
|||||||
exec "$cached" "$@"
|
exec "$cached" "$@"
|
||||||
fi
|
fi
|
||||||
rm -f "$tmp" 2>/dev/null
|
rm -f "$tmp" 2>/dev/null
|
||||||
echo "impeccable: could not download engine $version from $url; check network access, the release URL, and curl or wget availability." >&2
|
|
||||||
setup_help
|
|
||||||
exit 127
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "impeccable: no engine binary for $os-$arch found (looked in $bin, $cached, PATH)." >&2
|
echo "impeccable: no engine binary for $os-$arch found (looked in $bin, $cached, PATH)." >&2
|
||||||
|
|||||||
@@ -68,27 +68,18 @@ rem another launcher's probe: fail fast and quiet instead.
|
|||||||
if defined IMPECCABLE_LAUNCHER_PROBE exit /b 127
|
if defined IMPECCABLE_LAUNCHER_PROBE exit /b 127
|
||||||
if not defined version goto fail
|
if not defined version goto fail
|
||||||
where curl.exe >nul 2>nul
|
where curl.exe >nul 2>nul
|
||||||
if errorlevel 1 goto curl_missing
|
if errorlevel 1 goto fail
|
||||||
if not defined IMPECCABLE_DOWNLOAD_BASE set "IMPECCABLE_DOWNLOAD_BASE=https://github.com/pbakaus/impeccable/releases/download"
|
if not defined IMPECCABLE_DOWNLOAD_BASE set "IMPECCABLE_DOWNLOAD_BASE=https://github.com/pbakaus/impeccable/releases/download"
|
||||||
if exist "%IMPECCABLE_HOME%\bin\%version%\" goto cache_ready
|
if not exist "%IMPECCABLE_HOME%\bin\%version%" mkdir "%IMPECCABLE_HOME%\bin\%version%" >nul 2>nul
|
||||||
mkdir "%IMPECCABLE_HOME%\bin\%version%" >nul 2>nul
|
|
||||||
if errorlevel 1 goto cache_directory_failed
|
|
||||||
:cache_ready
|
|
||||||
rem Check the staging file too: an existing directory may be read-only.
|
|
||||||
rem Redirection failures do not reliably update ERRORLEVEL in cmd.exe;
|
|
||||||
rem branch on the command's failure directly. Never treat a directory as a
|
|
||||||
rem staging file (later del cleanup would prompt to delete its contents).
|
|
||||||
if exist "%cached%.part\" goto cache_write_failed
|
|
||||||
(type nul >"%cached%.part") 2>nul || goto cache_write_failed
|
|
||||||
set "asset=impeccable-windows-%arch%.exe"
|
set "asset=impeccable-windows-%arch%.exe"
|
||||||
set "url=%IMPECCABLE_DOWNLOAD_BASE%/engine-v%version%/%asset%"
|
set "url=%IMPECCABLE_DOWNLOAD_BASE%/engine-v%version%/%asset%"
|
||||||
curl.exe -fsSL -o "%cached%.part" "%url%" >nul 2>nul
|
curl.exe -fsSL -o "%cached%.part" "%url%" >nul 2>nul
|
||||||
if not errorlevel 1 goto verify
|
if not errorlevel 1 goto verify
|
||||||
if not "%arch%"=="arm64" goto download_failed
|
if not "%arch%"=="arm64" goto fail
|
||||||
set "asset=impeccable-windows-x64.exe"
|
set "asset=impeccable-windows-x64.exe"
|
||||||
set "url=%IMPECCABLE_DOWNLOAD_BASE%/engine-v%version%/%asset%"
|
set "url=%IMPECCABLE_DOWNLOAD_BASE%/engine-v%version%/%asset%"
|
||||||
curl.exe -fsSL -o "%cached%.part" "%url%" >nul 2>nul
|
curl.exe -fsSL -o "%cached%.part" "%url%" >nul 2>nul
|
||||||
if errorlevel 1 goto download_failed
|
if errorlevel 1 goto fail
|
||||||
|
|
||||||
:verify
|
:verify
|
||||||
call :check_download
|
call :check_download
|
||||||
@@ -185,28 +176,6 @@ if not errorlevel 1 set "probe_ok=1"
|
|||||||
del "%probe_tmp%" >nul 2>nul
|
del "%probe_tmp%" >nul 2>nul
|
||||||
exit /b 0
|
exit /b 0
|
||||||
|
|
||||||
:cache_directory_failed
|
|
||||||
echo impeccable: engine %version% is not installed; cannot create cache directory: "%IMPECCABLE_HOME%\bin\%version%" 1>&2
|
|
||||||
goto setup_failed
|
|
||||||
|
|
||||||
:cache_write_failed
|
|
||||||
echo impeccable: engine %version% is not installed; cannot write to cache directory: "%IMPECCABLE_HOME%\bin\%version%" 1>&2
|
|
||||||
goto setup_failed
|
|
||||||
|
|
||||||
:curl_missing
|
|
||||||
echo impeccable: cannot download engine %version%; curl.exe is unavailable. 1>&2
|
|
||||||
goto setup_failed
|
|
||||||
|
|
||||||
:download_failed
|
|
||||||
del "%cached%.part" >nul 2>nul
|
|
||||||
echo impeccable: could not download engine %version% from %url%; check network access and the release URL. 1>&2
|
|
||||||
|
|
||||||
:setup_failed
|
|
||||||
echo Engine %version% setup needs network access and write permission to "%IMPECCABLE_HOME%\bin\%version%". 1>&2
|
|
||||||
echo Run this launcher ("%~f0") with engine-probe in a terminal that has those permissions, then retry the original command. 1>&2
|
|
||||||
echo Alternatively, set IMPECCABLE_HOME to a writable cache location, or IMPECCABLE_BIN to a preinstalled engine binary. 1>&2
|
|
||||||
exit /b 127
|
|
||||||
|
|
||||||
:fail
|
:fail
|
||||||
del "%cached%.part" >nul 2>nul
|
del "%cached%.part" >nul 2>nul
|
||||||
echo impeccable: no engine binary found (looked in %bin%, %cached%, PATH). 1>&2
|
echo impeccable: no engine binary found (looked in %bin%, %cached%, PATH). 1>&2
|
||||||
|
|||||||
@@ -165,14 +165,6 @@
|
|||||||
}
|
}
|
||||||
let parameterGenerationState = 'idle';
|
let parameterGenerationState = 'idle';
|
||||||
let parameterReadyAnnouncedSession = null;
|
let parameterReadyAnnouncedSession = null;
|
||||||
// 'agent' when the generate verb fired this session's Go (the generate
|
|
||||||
// lane declares no knobs, so its bar never shows a pending Tune chip);
|
|
||||||
// null for every Go a user presses.
|
|
||||||
let sessionOrigin = null;
|
|
||||||
// The generate lane picks for the agent and never edits copy in the
|
|
||||||
// browser, so its selection carries no edit-copy badge (set on the
|
|
||||||
// agent-target pick, cleared with the session; a user's pick never sets it).
|
|
||||||
let editBadgeSuppressed = false;
|
|
||||||
let svelteComponentSession = null;
|
let svelteComponentSession = null;
|
||||||
let svelteRuntimePromise = null;
|
let svelteRuntimePromise = null;
|
||||||
let pendingSvelteComponentRetryObserver = null;
|
let pendingSvelteComponentRetryObserver = null;
|
||||||
@@ -991,20 +983,9 @@
|
|||||||
}
|
}
|
||||||
} catch { /* cross-origin */ }
|
} catch { /* cross-origin */ }
|
||||||
}
|
}
|
||||||
// The selector a mechanical bake would anchor lasting rules on, and how
|
|
||||||
// many elements it matches right now: the bake refuses anything but one,
|
|
||||||
// since its rules would restyle every match, not just this element.
|
|
||||||
const cssIdent = (s) => /^[A-Za-z_-][\w-]*$/.test(s);
|
|
||||||
const anchorClasses = [...el.classList].filter(cssIdent);
|
|
||||||
const anchor = el.id && cssIdent(el.id)
|
|
||||||
? '#' + el.id
|
|
||||||
: (anchorClasses.length ? el.tagName.toLowerCase() + '.' + anchorClasses.join('.') : null);
|
|
||||||
let anchorMatches = null;
|
|
||||||
if (anchor) { try { anchorMatches = document.querySelectorAll(anchor).length; } catch { anchorMatches = null; } }
|
|
||||||
return {
|
return {
|
||||||
tagName: el.tagName.toLowerCase(), id: el.id || null,
|
tagName: el.tagName.toLowerCase(), id: el.id || null,
|
||||||
classes: [...el.classList],
|
classes: [...el.classList],
|
||||||
anchor, anchorMatches,
|
|
||||||
textContent: (el.textContent || '').slice(0, 500),
|
textContent: (el.textContent || '').slice(0, 500),
|
||||||
outerHTML: sanitizedContextOuterHTML(el, 10000),
|
outerHTML: sanitizedContextOuterHTML(el, 10000),
|
||||||
computedStyles: {
|
computedStyles: {
|
||||||
@@ -2056,7 +2037,6 @@
|
|||||||
function setLiveState(next) {
|
function setLiveState(next) {
|
||||||
state = next;
|
state = next;
|
||||||
window.__IMPECCABLE_LIVE_STATE__ = next;
|
window.__IMPECCABLE_LIVE_STATE__ = next;
|
||||||
retryDeclinedAgentTargets();
|
|
||||||
syncPageInteractionCursor();
|
syncPageInteractionCursor();
|
||||||
// Whether a queued steer is still behind a generation is a function of this
|
// Whether a queued steer is still behind a generation is a function of this
|
||||||
// state, so the hint has to move with it, not only with the 5s poll.
|
// state, so the hint has to move with it, not only with the 5s poll.
|
||||||
@@ -4034,7 +4014,6 @@
|
|||||||
|
|
||||||
function hidePendingApplyDock() {
|
function hidePendingApplyDock() {
|
||||||
pendingApplyInFlight = false;
|
pendingApplyInFlight = false;
|
||||||
retryDeclinedAgentTargets();
|
|
||||||
clearStoredManualApplyState();
|
clearStoredManualApplyState();
|
||||||
if (pendingIntroAnimation) { pendingIntroAnimation.cancel(); pendingIntroAnimation = null; }
|
if (pendingIntroAnimation) { pendingIntroAnimation.cancel(); pendingIntroAnimation = null; }
|
||||||
if (pendingDockEl) pendingDockEl.style.display = 'none';
|
if (pendingDockEl) pendingDockEl.style.display = 'none';
|
||||||
@@ -4068,7 +4047,6 @@
|
|||||||
function setPendingApplyLoading(loading, count) {
|
function setPendingApplyLoading(loading, count) {
|
||||||
if (!pendingPillEl || !pendingPillLabelEl || !pendingPillCountEl || !pendingTrashBtn) return;
|
if (!pendingPillEl || !pendingPillLabelEl || !pendingPillCountEl || !pendingTrashBtn) return;
|
||||||
pendingApplyInFlight = loading === true;
|
pendingApplyInFlight = loading === true;
|
||||||
if (!pendingApplyInFlight) retryDeclinedAgentTargets();
|
|
||||||
const currentCount = count || parseInt(pendingPillEl.dataset.count || '0', 10) || 0;
|
const currentCount = count || parseInt(pendingPillEl.dataset.count || '0', 10) || 0;
|
||||||
if (pendingApplyInFlight) storeManualApplyState(currentCount);
|
if (pendingApplyInFlight) storeManualApplyState(currentCount);
|
||||||
else clearStoredManualApplyState();
|
else clearStoredManualApplyState();
|
||||||
@@ -4710,7 +4688,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderEditBadge(mode) {
|
function renderEditBadge(mode) {
|
||||||
if (editBadgeSuppressed || sessionOrigin === 'agent') mode = 'hidden';
|
|
||||||
if (mode === 'hidden' || !editBadgeEl) {
|
if (mode === 'hidden' || !editBadgeEl) {
|
||||||
hideConfigureBarTooltip();
|
hideConfigureBarTooltip();
|
||||||
if (editBadgeEl) editBadgeEl.style.display = 'none';
|
if (editBadgeEl) editBadgeEl.style.display = 'none';
|
||||||
@@ -6204,8 +6181,6 @@
|
|||||||
resetSessionFileMeta();
|
resetSessionFileMeta();
|
||||||
currentSessionId = null;
|
currentSessionId = null;
|
||||||
parameterGenerationState = 'idle';
|
parameterGenerationState = 'idle';
|
||||||
sessionOrigin = null;
|
|
||||||
editBadgeSuppressed = false;
|
|
||||||
parameterReadyAnnouncedSession = null;
|
parameterReadyAnnouncedSession = null;
|
||||||
expectedVariants = 0;
|
expectedVariants = 0;
|
||||||
arrivedVariants = 0;
|
arrivedVariants = 0;
|
||||||
@@ -7137,398 +7112,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
//
|
//
|
||||||
// ------------------------------------------------------------------
|
|
||||||
// Agent-initiated targeting (the `generate` command). The agent names an
|
|
||||||
// element by CSS selector over POST /agent-target; the server pushes an
|
|
||||||
// `agent_target` SSE message here. The overlay resolves the selector,
|
|
||||||
// scrolls the element into view, enters the same picked state a user
|
|
||||||
// click produces, and fires the normal Go pipeline, so everything
|
|
||||||
// downstream (generate event, variants, cycling, accept) is unchanged.
|
|
||||||
// The verdict goes back through POST /agent-target-result, which resolves
|
|
||||||
// the agent's held-open CLI call.
|
|
||||||
|
|
||||||
function postAgentTargetResult(targetId, result) {
|
|
||||||
fetch('http://localhost:' + PORT + '/agent-target-result?token=' + TOKEN, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ token: TOKEN, targetId, clientId: AGENT_TARGET_CLIENT_ID, ...result }),
|
|
||||||
}).catch(() => { /* server gone; nothing to report to */ });
|
|
||||||
}
|
|
||||||
|
|
||||||
function describeAgentTargetCandidate(el) {
|
|
||||||
return {
|
|
||||||
tag: el.tagName.toLowerCase(),
|
|
||||||
id: el.id || null,
|
|
||||||
classes: [...el.classList].filter((c) => !c.startsWith('impeccable-')),
|
|
||||||
text: (el.textContent || '').trim().slice(0, 80),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveAgentTargetElement(msg) {
|
|
||||||
let matched;
|
|
||||||
try {
|
|
||||||
matched = [...document.querySelectorAll(msg.selector)];
|
|
||||||
} catch {
|
|
||||||
return { error: { ok: false, error: 'invalid_selector', selector: msg.selector } };
|
|
||||||
}
|
|
||||||
let candidates = matched.filter((el) => pickable(el));
|
|
||||||
if (msg.text) {
|
|
||||||
const needle = String(msg.text).toLowerCase();
|
|
||||||
candidates = candidates.filter((el) => (el.textContent || '').toLowerCase().includes(needle));
|
|
||||||
}
|
|
||||||
if (candidates.length === 0) {
|
|
||||||
return {
|
|
||||||
error: {
|
|
||||||
ok: false,
|
|
||||||
error: 'no_match',
|
|
||||||
selector: msg.selector,
|
|
||||||
matchCount: 0,
|
|
||||||
// How many nodes the raw selector hit before the pickable/text
|
|
||||||
// filters: distinguishes a wrong selector from an unpickable match.
|
|
||||||
rawMatchCount: matched.length,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (Number.isInteger(msg.index)) {
|
|
||||||
const el = candidates[msg.index - 1];
|
|
||||||
if (!el) {
|
|
||||||
return { error: { ok: false, error: 'index_out_of_range', selector: msg.selector, matchCount: candidates.length } };
|
|
||||||
}
|
|
||||||
return { el, matchCount: candidates.length };
|
|
||||||
}
|
|
||||||
if (candidates.length > 1) {
|
|
||||||
return {
|
|
||||||
error: {
|
|
||||||
ok: false,
|
|
||||||
error: 'ambiguous',
|
|
||||||
selector: msg.selector,
|
|
||||||
matchCount: candidates.length,
|
|
||||||
candidates: candidates.slice(0, 8).map(describeAgentTargetCandidate),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return { el: candidates[0], matchCount: 1 };
|
|
||||||
}
|
|
||||||
|
|
||||||
function scrollAgentTargetIntoView(el, done) {
|
|
||||||
const rect = el.getBoundingClientRect();
|
|
||||||
if (rect.top >= 0 && rect.bottom <= window.innerHeight) { done(); return; }
|
|
||||||
let settled = false;
|
|
||||||
let fallback = null;
|
|
||||||
const finish = () => {
|
|
||||||
if (settled) return;
|
|
||||||
settled = true;
|
|
||||||
removeEventListener('scrollend', finish, true);
|
|
||||||
if (fallback) clearTimeout(fallback);
|
|
||||||
done();
|
|
||||||
};
|
|
||||||
// scrollend where supported; a timer covers engines without it and the
|
|
||||||
// no-movement case (element already at its final resting position).
|
|
||||||
addEventListener('scrollend', finish, true);
|
|
||||||
fallback = setTimeout(finish, 1200);
|
|
||||||
el.scrollIntoView({ block: 'center', behavior: 'smooth' });
|
|
||||||
}
|
|
||||||
|
|
||||||
// One id per page load: the server keys claims and roll-call reports on
|
|
||||||
// it, and only the tab that holds the lease can renew it.
|
|
||||||
const AGENT_TARGET_CLIENT_ID = id8();
|
|
||||||
|
|
||||||
// The agent target an agent-initiated Go is serving: set by
|
|
||||||
// actOnAgentTarget around its handleGo call, read once by handleGo.
|
|
||||||
let agentTargetForGo = null;
|
|
||||||
|
|
||||||
// The helper's word on its global bar. The generate lane asks the helper
|
|
||||||
// to keep it out of the way (`impeccable live --no-live-bar`, or an agent
|
|
||||||
// target carrying hideLiveBar), and the helper tells every connected tab
|
|
||||||
// at once (`live_bar`) and every later connection on `connected`, so the
|
|
||||||
// bar stays hidden in every tab, through reloads, the accept, and the
|
|
||||||
// bake, until the helper stops and takes the overlay with it. The variant
|
|
||||||
// controls still show.
|
|
||||||
let liveBarHiddenByHelper = false;
|
|
||||||
|
|
||||||
function applyLiveBarPreference(hidden) {
|
|
||||||
liveBarHiddenByHelper = hidden === true;
|
|
||||||
setLiveBarHidden(liveBarHiddenByHelper);
|
|
||||||
}
|
|
||||||
|
|
||||||
// A plain live session must never notice this code: hiding remembers the
|
|
||||||
// bar's own display value and restoring puts exactly that back, and a
|
|
||||||
// restore on a bar that is not hidden is a no-op, so the `connected`
|
|
||||||
// frame every session receives changes nothing unless the lane asked.
|
|
||||||
function setLiveBarHidden(hidden) {
|
|
||||||
if (!globalBarEl) return;
|
|
||||||
if (hidden) {
|
|
||||||
if (globalBarEl.style.display !== 'none') {
|
|
||||||
globalBarEl.dataset.liveBarDisplay = globalBarEl.style.display || 'flex';
|
|
||||||
globalBarEl.style.display = 'none';
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (globalBarEl.style.display === 'none') {
|
|
||||||
globalBarEl.style.display = globalBarEl.dataset.liveBarDisplay || 'flex';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function claimAgentTarget(targetId, report) {
|
|
||||||
return fetch('http://localhost:' + PORT + '/agent-target-claim?token=' + TOKEN, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ token: TOKEN, targetId, clientId: AGENT_TARGET_CLIENT_ID, ...report }),
|
|
||||||
}).then((res) => res.json())
|
|
||||||
.then((j) => ({ granted: !!j && j.granted === true, pending: !!j && j.pending === true }))
|
|
||||||
.catch(() => ({ granted: false, pending: false }));
|
|
||||||
}
|
|
||||||
|
|
||||||
// `exceptTargetId` is the target this call is about: a tab acting on it
|
|
||||||
// is not busy for itself, but it is busy for every other target, or two
|
|
||||||
// held requests could both be claimed here and the second Go would
|
|
||||||
// overwrite the session the first one minted.
|
|
||||||
function agentTargetBusyReason(exceptTargetId) {
|
|
||||||
if (pendingApplyInFlight) return 'manual_apply_in_flight';
|
|
||||||
if (state !== 'IDLE' && state !== 'PICKING' && state !== 'CONFIGURING') return 'session_active';
|
|
||||||
for (const [targetId, status] of agentTargetsSeen) {
|
|
||||||
if (status === 'acting' && targetId !== exceptTargetId) return 'agent_target_in_flight';
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Targets this tab declined as busy. A busy report is only this tab's word
|
|
||||||
// at that moment: the moment it is free again (setLiveState), it claims
|
|
||||||
// each of these as eligible, and the server drops the stale report, so a
|
|
||||||
// busy verdict is never built on a tab that has since gone idle. The
|
|
||||||
// server denies claims for resolved targets, so retries are harmless.
|
|
||||||
const busyDeclinedTargets = new Map();
|
|
||||||
|
|
||||||
function declineAgentTargetBusy(msg, busy) {
|
|
||||||
busyDeclinedTargets.set(msg.targetId, msg);
|
|
||||||
noteAgentTarget(msg.targetId, 'declined');
|
|
||||||
claimAgentTarget(msg.targetId, { eligible: false, state, reason: busy });
|
|
||||||
}
|
|
||||||
|
|
||||||
// A torn-down overlay, or one whose helper connection is gone, cannot
|
|
||||||
// serve a target and must not even claim one: it would hold the lease for
|
|
||||||
// a request it will never act on.
|
|
||||||
function agentTargetOverlayGone() {
|
|
||||||
return !evtSource;
|
|
||||||
}
|
|
||||||
|
|
||||||
// A denied claimant retries at this cadence, a little over the lease, so
|
|
||||||
// the first retry after a dead holder's lease lapses is granted.
|
|
||||||
const AGENT_TARGET_RESCUE_RETRY_MS = 3500;
|
|
||||||
|
|
||||||
// Claim the lease and act as the holder. A denied claim means another tab
|
|
||||||
// holds the lease. That holder can die before posting its result (reload,
|
|
||||||
// crash, even after renewing), and its lease lapses after ~3s, so this tab
|
|
||||||
// keeps retrying for as long as the server still holds the request: the
|
|
||||||
// answer's `pending` is the server's word that the request is alive, and
|
|
||||||
// it turns false the moment the request resolved or timed out, so no tab
|
|
||||||
// retries a request nobody awaits. A tab that turned busy meanwhile joins
|
|
||||||
// the roll call instead of taking a lease it cannot use. The first claim
|
|
||||||
// and the busy-to-idle re-claim share this.
|
|
||||||
function claimAndActOnAgentTarget(msg) {
|
|
||||||
if (agentTargetOverlayGone()) return;
|
|
||||||
const busy = agentTargetBusyReason(msg.targetId);
|
|
||||||
if (busy) { declineAgentTargetBusy(msg, busy); return; }
|
|
||||||
if (declineAgentTargetUnresolvable(msg)) return;
|
|
||||||
claimAgentTarget(msg.targetId, { eligible: true }).then((claim) => {
|
|
||||||
if (claim.granted) { noteAgentTarget(msg.targetId, 'acting'); actOnAgentTarget(msg); return; }
|
|
||||||
noteAgentTarget(msg.targetId, 'denied');
|
|
||||||
if (!claim.pending) return;
|
|
||||||
setTimeout(() => claimAndActOnAgentTarget(msg), AGENT_TARGET_RESCUE_RETRY_MS);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function retryDeclinedAgentTargets() {
|
|
||||||
if (busyDeclinedTargets.size === 0 || agentTargetBusyReason()) return;
|
|
||||||
for (const [targetId, msg] of busyDeclinedTargets) {
|
|
||||||
busyDeclinedTargets.delete(targetId);
|
|
||||||
claimAndActOnAgentTarget(msg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// This page's participation in each target it heard: 'acting' once a
|
|
||||||
// claim was granted, 'done' once it replied (or stood down from a lapsed
|
|
||||||
// lease), else the word it last gave. The server replays pending targets
|
|
||||||
// to every connection that opens. After a reconnect that overlapped the
|
|
||||||
// old connection the server still holds this page's word; after one that
|
|
||||||
// did not, it dropped the word on the close, so a replayed target is
|
|
||||||
// handled again: a busy or unresolvable page re-declines (idempotent), an
|
|
||||||
// idle page claims.
|
|
||||||
const agentTargetsSeen = new Map();
|
|
||||||
function noteAgentTarget(targetId, status) {
|
|
||||||
agentTargetsSeen.set(targetId, status);
|
|
||||||
if (agentTargetsSeen.size > 100) agentTargetsSeen.delete(agentTargetsSeen.keys().next().value);
|
|
||||||
}
|
|
||||||
|
|
||||||
// A target this page took a lease on is off-limits for a replay: while
|
|
||||||
// acting (a second claim or Go), and once done, because its result may
|
|
||||||
// still be on the wire and this tab is GENERATING by then, so handling
|
|
||||||
// the replay would decline busy, hand the lease back mid-resolution, and
|
|
||||||
// let another tab fire a second Go.
|
|
||||||
function agentTargetTaken(targetId) {
|
|
||||||
const status = agentTargetsSeen.get(targetId);
|
|
||||||
return status === 'acting' || status === 'done';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only a page that can resolve the target claims it. A tab whose page
|
|
||||||
// lacks the element declines with its resolution verdict instead, so a
|
|
||||||
// first-wins claim never lets the wrong page answer for a target that
|
|
||||||
// another page has. The server prefers a busy report (a tab that could
|
|
||||||
// serve later) over these, and returns the resolution verdict only when
|
|
||||||
// no connected page can serve.
|
|
||||||
//
|
|
||||||
// An element can be momentarily absent (a route still rendering, an HMR
|
|
||||||
// commit mid-swap), so a failed resolution is not this page's final word:
|
|
||||||
// it is re-checked a few times over about two seconds, claiming the
|
|
||||||
// moment the element mounts, and only the last miss is reported. The
|
|
||||||
// server's timeout still bounds the whole exchange.
|
|
||||||
// The page reports the miss at once (so the other overlays' words can
|
|
||||||
// complete the roll call) and keeps re-checking at this cadence for as
|
|
||||||
// long as the server says the request is pending: the server holds an
|
|
||||||
// all-no_match roll call open for a short grace precisely so a late mount
|
|
||||||
// can still be claimed, drops the stale report on an eligible claim, and
|
|
||||||
// ends the watch by answering pending:false once the request resolved or
|
|
||||||
// timed out.
|
|
||||||
const AGENT_TARGET_RESOLVE_WATCH_MS = 500;
|
|
||||||
|
|
||||||
function declineAgentTargetUnresolvable(msg) {
|
|
||||||
const probe = resolveAgentTargetElement(msg);
|
|
||||||
if (!probe.error) return false;
|
|
||||||
reportAgentTargetUnresolvable(msg, probe.error);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function reportAgentTargetUnresolvable(msg, error) {
|
|
||||||
noteAgentTarget(msg.targetId, 'declined');
|
|
||||||
claimAgentTarget(msg.targetId, { eligible: false, state, reason: 'no_match', result: error }).then((answer) => {
|
|
||||||
if (!answer.pending) return;
|
|
||||||
setTimeout(() => watchAgentTargetResolution(msg, error), AGENT_TARGET_RESOLVE_WATCH_MS);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function watchAgentTargetResolution(msg, lastError) {
|
|
||||||
if (agentTargetOverlayGone() || agentTargetTaken(msg.targetId)) return;
|
|
||||||
const busy = agentTargetBusyReason(msg.targetId);
|
|
||||||
if (busy) { declineAgentTargetBusy(msg, busy); return; }
|
|
||||||
const probe = resolveAgentTargetElement(msg);
|
|
||||||
if (!probe.error) { claimAndActOnAgentTarget(msg); return; }
|
|
||||||
// Still unresolvable: re-report (idempotent); the answer says whether
|
|
||||||
// the server is still holding the request open.
|
|
||||||
reportAgentTargetUnresolvable(msg, probe.error || lastError);
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleAgentTarget(msg) {
|
|
||||||
if (!msg || typeof msg.targetId !== 'string') return;
|
|
||||||
if (agentTargetTaken(msg.targetId)) return;
|
|
||||||
noteAgentTarget(msg.targetId, 'heard');
|
|
||||||
const busy = agentTargetBusyReason(msg.targetId);
|
|
||||||
if (busy) {
|
|
||||||
// Roll call: a busy tab reports itself and never acts. The server
|
|
||||||
// answers `busy` the moment every connected overlay has reported, so
|
|
||||||
// an idle tab elsewhere is never raced by a timer.
|
|
||||||
declineAgentTargetBusy(msg, busy);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (declineAgentTargetUnresolvable(msg)) return;
|
|
||||||
// Eligible tabs race for the server's lease and only the holder acts. A
|
|
||||||
// hidden tab yields a short head start so a visible one wins when both
|
|
||||||
// exist, and still serves the request on its own: the user finds the
|
|
||||||
// selection waiting when they return to it.
|
|
||||||
setTimeout(() => claimAndActOnAgentTarget(msg), document.hidden ? 150 : 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
function actOnAgentTarget(msg) {
|
|
||||||
if (agentTargetOverlayGone()) return;
|
|
||||||
// Every exit ends this tab's acting state, so a later target is not
|
|
||||||
// refused for a Go that already happened or never will.
|
|
||||||
const reply = (result) => { noteAgentTarget(msg.targetId, 'done'); postAgentTargetResult(msg.targetId, result); };
|
|
||||||
const busy = agentTargetBusyReason(msg.targetId);
|
|
||||||
if (busy) {
|
|
||||||
// Turned busy between claim and act: report it, which also hands the
|
|
||||||
// lease back so the roll call can complete or a rescuer can claim.
|
|
||||||
declineAgentTargetBusy(msg, busy);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const resolved = resolveAgentTargetElement(msg);
|
|
||||||
if (resolved.error) {
|
|
||||||
// The element went away between claim and act. A result would end the
|
|
||||||
// request for every tab; a decline hands the lease back so another
|
|
||||||
// page or a remount can still serve it.
|
|
||||||
reportAgentTargetUnresolvable(msg, resolved.error);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const el = resolved.el;
|
|
||||||
if (msg.dryRun) {
|
|
||||||
reply({
|
|
||||||
ok: true,
|
|
||||||
dryRun: true,
|
|
||||||
matchCount: resolved.matchCount,
|
|
||||||
element: describeAgentTargetCandidate(el),
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
scrollAgentTargetIntoView(el, () => {
|
|
||||||
// Torn down during the scroll settle: do not renew. The lease lapses
|
|
||||||
// for a rescuer instead of Go minting a session on a dismantled
|
|
||||||
// overlay.
|
|
||||||
if (agentTargetOverlayGone()) return;
|
|
||||||
// Renew the lease right before the irreversible part: a tab whose
|
|
||||||
// lease lapsed while it scrolled (a rescuer took over) stops here, so
|
|
||||||
// one request never gets two Go presses.
|
|
||||||
claimAgentTarget(msg.targetId, { eligible: true }).then((renewal) => {
|
|
||||||
if (!renewal.granted) { noteAgentTarget(msg.targetId, 'done'); return; }
|
|
||||||
// An insert placement left mid-configure gives way, exactly as a
|
|
||||||
// click outside it does in handleClick.
|
|
||||||
if (state === 'CONFIGURING' && configureKind === 'insert') cancelInsertConfigure();
|
|
||||||
// Mirror of the user-click pick entry in handleClick, minus the
|
|
||||||
// pick-mode gate (the agent's intent replaces the toggle); the entry
|
|
||||||
// goes through beginNewLiveConfiguration like every other pick so
|
|
||||||
// deferred recovery sees a fresh interaction revision.
|
|
||||||
selectedElement = el;
|
|
||||||
beginNewLiveConfiguration();
|
|
||||||
showHighlight(selectedElement);
|
|
||||||
clearAnnotations();
|
|
||||||
showAnnotOverlay(selectedElement);
|
|
||||||
showBar('configure');
|
|
||||||
editBadgeSuppressed = true;
|
|
||||||
renderEditBadge('hidden');
|
|
||||||
startScrollTracking();
|
|
||||||
maybePrefetchPage();
|
|
||||||
maybeWarnConditionalAncestor(selectedElement);
|
|
||||||
// Preset what the agent asked for, then fire the same Go a user press
|
|
||||||
// fires. handleGo reads exactly these inputs.
|
|
||||||
selectedAction = msg.action;
|
|
||||||
selectedCount = msg.count;
|
|
||||||
// updateBarContent rebuilds the configure row and replaces the input
|
|
||||||
// element, so the prompt must be written into the input it creates,
|
|
||||||
// never before (the action-chip click handler does the same dance).
|
|
||||||
updateBarContent('configure');
|
|
||||||
const input = uiGetById(PREFIX + '-input');
|
|
||||||
if (input) input.value = msg.prompt || '';
|
|
||||||
// The target rides on the generate event too: the helper resolves
|
|
||||||
// the request from whichever lands first, so a page that dies
|
|
||||||
// between Go and its result cannot leave the request pending for a
|
|
||||||
// second Go elsewhere.
|
|
||||||
const candidate = describeAgentTargetCandidate(el);
|
|
||||||
agentTargetForGo = { targetId: msg.targetId, matchCount: resolved.matchCount, action: msg.action, count: msg.count, element: candidate };
|
|
||||||
handleGo();
|
|
||||||
agentTargetForGo = null;
|
|
||||||
if (state === 'GENERATING' && currentSessionId) {
|
|
||||||
reply({
|
|
||||||
ok: true,
|
|
||||||
matchCount: resolved.matchCount,
|
|
||||||
sessionId: currentSessionId,
|
|
||||||
action: msg.action,
|
|
||||||
count: msg.count,
|
|
||||||
element: candidate,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
reply({ ok: false, error: 'go_failed', state });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// SSE (server→browser) + fetch POST (browser→server)
|
// SSE (server→browser) + fetch POST (browser→server)
|
||||||
// Zero-dependency replacement for WebSocket.
|
// Zero-dependency replacement for WebSocket.
|
||||||
//
|
//
|
||||||
@@ -7538,7 +7121,7 @@
|
|||||||
const SSE_MAX_RETRIES = 20; // generous: heartbeats keep the connection alive, so retries mean real trouble
|
const SSE_MAX_RETRIES = 20; // generous: heartbeats keep the connection alive, so retries mean real trouble
|
||||||
|
|
||||||
function connectSSE() {
|
function connectSSE() {
|
||||||
evtSource = new EventSource('http://localhost:' + PORT + '/events?token=' + TOKEN + '&clientId=' + AGENT_TARGET_CLIENT_ID);
|
evtSource = new EventSource('http://localhost:' + PORT + '/events?token=' + TOKEN);
|
||||||
|
|
||||||
evtSource.onopen = () => {
|
evtSource.onopen = () => {
|
||||||
sseRetries = 0; // reset on successful (re)connect
|
sseRetries = 0; // reset on successful (re)connect
|
||||||
@@ -7549,11 +7132,8 @@
|
|||||||
let msg; try { msg = JSON.parse(e.data); } catch { return; }
|
let msg; try { msg = JSON.parse(e.data); } catch { return; }
|
||||||
switch (msg.type) {
|
switch (msg.type) {
|
||||||
case 'connected':
|
case 'connected':
|
||||||
applyLiveBarPreference(msg.hideLiveBar === true);
|
|
||||||
hasProjectContext = !!msg.hasProjectContext;
|
hasProjectContext = !!msg.hasProjectContext;
|
||||||
// The generate lane runs without PRODUCT.md by design and never
|
if (!hasProjectContext) showToast(`No PRODUCT.md found. Variants will be brand-agnostic. Run ${IMPECCABLE_COMMAND} init to generate one.`, 7000);
|
||||||
// sends the user to init, so its quiet chrome skips this notice.
|
|
||||||
if (!hasProjectContext && !liveBarHiddenByHelper) showToast(`No PRODUCT.md found. Variants will be brand-agnostic. Run ${IMPECCABLE_COMMAND} init to generate one.`, 7000);
|
|
||||||
console.log('[impeccable] Live mode connected.');
|
console.log('[impeccable] Live mode connected.');
|
||||||
syncAgentPollingUi(!!msg.agentPolling);
|
syncAgentPollingUi(!!msg.agentPolling);
|
||||||
startAgentStatusPoll();
|
startAgentStatusPoll();
|
||||||
@@ -7563,15 +7143,9 @@
|
|||||||
syncPageInteractionCursor();
|
syncPageInteractionCursor();
|
||||||
syncPageChatFocus('sse-connected');
|
syncPageChatFocus('sse-connected');
|
||||||
break;
|
break;
|
||||||
case 'live_bar':
|
|
||||||
applyLiveBarPreference(msg.hidden === true);
|
|
||||||
break;
|
|
||||||
case 'agent_polling':
|
case 'agent_polling':
|
||||||
syncAgentPollingUi(!!msg.connected);
|
syncAgentPollingUi(!!msg.connected);
|
||||||
break;
|
break;
|
||||||
case 'agent_target':
|
|
||||||
handleAgentTarget(msg);
|
|
||||||
break;
|
|
||||||
case 'agent_phase':
|
case 'agent_phase':
|
||||||
if (msg.id === currentSessionId && (state === 'GENERATING' || state === 'CYCLING')) {
|
if (msg.id === currentSessionId && (state === 'GENERATING' || state === 'CYCLING')) {
|
||||||
// Advance the visible phase monotonically. A behind/resumed
|
// Advance the visible phase monotonically. A behind/resumed
|
||||||
@@ -7634,11 +7208,6 @@
|
|||||||
disableInlineEdit();
|
disableInlineEdit();
|
||||||
refreshParamsPanel();
|
refreshParamsPanel();
|
||||||
}
|
}
|
||||||
// The done reply is the agent's last word on this generation:
|
|
||||||
// with every variant mounted and no knobs declared, the Tune
|
|
||||||
// chip must stop spinning. A reload between the mount and this
|
|
||||||
// reply restored the pending state from the cache.
|
|
||||||
completeParameterGenerationIfReady();
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
// Source fallback when HMR did not land variants in this tab.
|
// Source fallback when HMR did not land variants in this tab.
|
||||||
@@ -7802,15 +7371,6 @@
|
|||||||
}).then(async res => {
|
}).then(async res => {
|
||||||
if (res.ok) return res;
|
if (res.ok) return res;
|
||||||
const body = await res.json().catch(() => ({}));
|
const body = await res.json().catch(() => ({}));
|
||||||
// The helper refused to open a session for an agent target it has
|
|
||||||
// already answered (another page served it after this page's lease
|
|
||||||
// lapsed mid-capture, or the request timed out): drop the local
|
|
||||||
// session and hand the surface back.
|
|
||||||
if (body.error === 'agent_target_already_served' && msg.type === 'generate'
|
|
||||||
&& msg.id && msg.id === currentSessionId) {
|
|
||||||
abandonSupersededGo(msg.id);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
// The server refused to journal progress for a session it has never
|
// The server refused to journal progress for a session it has never
|
||||||
// seen: this browser is carrying state from another project or a
|
// seen: this browser is carrying state from another project or a
|
||||||
// wiped store (two apps sharing a localhost port). Continuing to
|
// wiped store (two apps sharing a localhost port). Continuing to
|
||||||
@@ -7832,14 +7392,6 @@
|
|||||||
return sessionCreationGate.then(doSend);
|
return sessionCreationGate.then(doSend);
|
||||||
}
|
}
|
||||||
|
|
||||||
function abandonSupersededGo(sessionId) {
|
|
||||||
if (sessionId !== currentSessionId) return;
|
|
||||||
console.warn('[impeccable] The helper already answered this agent target; clearing session ' + sessionId + '.');
|
|
||||||
markSessionHandled();
|
|
||||||
cleanup({ instantChrome: true });
|
|
||||||
showToast('The helper already answered this request, so this session was cleared. Pick an element to start fresh.', 6000);
|
|
||||||
}
|
|
||||||
|
|
||||||
let abandonedForeignSessionId = null;
|
let abandonedForeignSessionId = null;
|
||||||
function abandonForeignSession(sessionId) {
|
function abandonForeignSession(sessionId) {
|
||||||
if (abandonedForeignSessionId === sessionId || sessionId !== currentSessionId) return;
|
if (abandonedForeignSessionId === sessionId || sessionId !== currentSessionId) return;
|
||||||
@@ -8244,7 +7796,6 @@
|
|||||||
visibleVariant = 0;
|
visibleVariant = 0;
|
||||||
generationPhase = 'queued';
|
generationPhase = 'queued';
|
||||||
parameterGenerationState = 'pending';
|
parameterGenerationState = 'pending';
|
||||||
sessionOrigin = agentTargetForGo ? 'agent' : null;
|
|
||||||
parameterReadyAnnouncedSession = null;
|
parameterReadyAnnouncedSession = null;
|
||||||
resetSessionFileMeta();
|
resetSessionFileMeta();
|
||||||
|
|
||||||
@@ -8270,24 +7821,6 @@
|
|||||||
};
|
};
|
||||||
if (snapshot.comments.length > 0) basePayload.comments = snapshot.comments;
|
if (snapshot.comments.length > 0) basePayload.comments = snapshot.comments;
|
||||||
if (snapshot.strokes.length > 0) basePayload.strokes = snapshot.strokes;
|
if (snapshot.strokes.length > 0) basePayload.strokes = snapshot.strokes;
|
||||||
if (agentTargetForGo) {
|
|
||||||
// An agent-initiated Go names the target it serves (see
|
|
||||||
// actOnAgentTarget): the helper resolves that request from this event
|
|
||||||
// as well as from the overlay's own result post.
|
|
||||||
basePayload.agentTarget = {
|
|
||||||
targetId: agentTargetForGo.targetId,
|
|
||||||
clientId: AGENT_TARGET_CLIENT_ID,
|
|
||||||
result: {
|
|
||||||
ok: true,
|
|
||||||
matchCount: agentTargetForGo.matchCount,
|
|
||||||
sessionId: currentSessionId,
|
|
||||||
action: agentTargetForGo.action,
|
|
||||||
count: agentTargetForGo.count,
|
|
||||||
element: agentTargetForGo.element,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
agentTargetForGo = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Hide the interactive overlay so it doesn't linger during generation.
|
// Hide the interactive overlay so it doesn't linger during generation.
|
||||||
hideAnnotOverlay();
|
hideAnnotOverlay();
|
||||||
@@ -8348,7 +7881,6 @@
|
|||||||
visibleVariant = 0;
|
visibleVariant = 0;
|
||||||
generationPhase = 'queued';
|
generationPhase = 'queued';
|
||||||
parameterGenerationState = 'pending';
|
parameterGenerationState = 'pending';
|
||||||
sessionOrigin = agentTargetForGo ? 'agent' : null;
|
|
||||||
parameterReadyAnnouncedSession = null;
|
parameterReadyAnnouncedSession = null;
|
||||||
resetSessionFileMeta();
|
resetSessionFileMeta();
|
||||||
selectedElement = placeholderElement;
|
selectedElement = placeholderElement;
|
||||||
@@ -9395,8 +8927,6 @@ void main() {
|
|||||||
pagePickSkipClick = false;
|
pagePickSkipClick = false;
|
||||||
currentSessionId = null;
|
currentSessionId = null;
|
||||||
parameterGenerationState = 'idle';
|
parameterGenerationState = 'idle';
|
||||||
sessionOrigin = null;
|
|
||||||
editBadgeSuppressed = false;
|
|
||||||
parameterReadyAnnouncedSession = null;
|
parameterReadyAnnouncedSession = null;
|
||||||
selectedAction = 'impeccable';
|
selectedAction = 'impeccable';
|
||||||
pendingAcceptedSession = null;
|
pendingAcceptedSession = null;
|
||||||
@@ -9488,7 +9018,6 @@ void main() {
|
|||||||
paramsCurrentValues = { ...saved.paramValues };
|
paramsCurrentValues = { ...saved.paramValues };
|
||||||
}
|
}
|
||||||
if (saved.parameterState) parameterGenerationState = saved.parameterState;
|
if (saved.parameterState) parameterGenerationState = saved.parameterState;
|
||||||
sessionOrigin = saved.origin === 'agent' ? 'agent' : null;
|
|
||||||
if (saved.generationPhase) generationPhase = saved.generationPhase;
|
if (saved.generationPhase) generationPhase = saved.generationPhase;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -9576,12 +9105,7 @@ void main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function restoreSessionWithoutWrapper(reason, activeSessions) {
|
function restoreSessionWithoutWrapper(reason, activeSessions) {
|
||||||
// The session cache is per origin, so a tab on another page of the same
|
const cached = loadSession();
|
||||||
// app sees this page's session too. Only the page that saved it may
|
|
||||||
// resume it: the server-adoption branch below already applies the same
|
|
||||||
// check, and a tab on another page has nothing to render for it.
|
|
||||||
const cachedRaw = loadSession();
|
|
||||||
const cached = cachedRaw?.id && !pageMatchesCurrent(cachedRaw.pageUrl) ? null : cachedRaw;
|
|
||||||
// localStorage is a cache, not a gate. A cleared tab, a second browser
|
// localStorage is a cache, not a gate. A cleared tab, a second browser
|
||||||
// profile, or a teardown that dropped local state all leave the durable
|
// profile, or a teardown that dropped local state all leave the durable
|
||||||
// server session as the only record of work in progress; adopt it instead
|
// server session as the only record of work in progress; adopt it instead
|
||||||
@@ -9694,7 +9218,6 @@ void main() {
|
|||||||
pageUrl: location.pathname,
|
pageUrl: location.pathname,
|
||||||
paramValues: { ...paramsCurrentValues },
|
paramValues: { ...paramsCurrentValues },
|
||||||
parameterState: parameterGenerationState,
|
parameterState: parameterGenerationState,
|
||||||
origin: sessionOrigin || undefined,
|
|
||||||
insertPlaceholder: insertPlaceholderSnapshot || undefined,
|
insertPlaceholder: insertPlaceholderSnapshot || undefined,
|
||||||
pickedAnchor: pickedAnchorSnapshot || undefined,
|
pickedAnchor: pickedAnchorSnapshot || undefined,
|
||||||
pickedAnchorViewportTop: Number.isFinite(pickedAnchorViewportTop) ? pickedAnchorViewportTop : undefined,
|
pickedAnchorViewportTop: Number.isFinite(pickedAnchorViewportTop) ? pickedAnchorViewportTop : undefined,
|
||||||
@@ -9820,8 +9343,6 @@ void main() {
|
|||||||
pagePickSkipClick = false;
|
pagePickSkipClick = false;
|
||||||
currentSessionId = null;
|
currentSessionId = null;
|
||||||
parameterGenerationState = 'idle';
|
parameterGenerationState = 'idle';
|
||||||
sessionOrigin = null;
|
|
||||||
editBadgeSuppressed = false;
|
|
||||||
parameterReadyAnnouncedSession = null;
|
parameterReadyAnnouncedSession = null;
|
||||||
selectedAction = 'impeccable';
|
selectedAction = 'impeccable';
|
||||||
renderEditBadge('hidden');
|
renderEditBadge('hidden');
|
||||||
@@ -10080,14 +9601,6 @@ void main() {
|
|||||||
|
|
||||||
const resumedState = arrivedVariants > 0 ? 'CYCLING' : 'GENERATING';
|
const resumedState = arrivedVariants > 0 ? 'CYCLING' : 'GENERATING';
|
||||||
|
|
||||||
// A reload between the variants mounting and the agent's done reply
|
|
||||||
// restores a pending Tune state from the cache; the helper knows whether
|
|
||||||
// that generation already finished.
|
|
||||||
if (arrivedVariants >= expectedVariants && expectedVariants > 0
|
|
||||||
&& (parameterGenerationState === 'pending' || parameterGenerationState === 'loading')) {
|
|
||||||
settleParameterStateFromHelper(sessionId);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find the visible variant's content element for highlight positioning.
|
// Find the visible variant's content element for highlight positioning.
|
||||||
const isInsert = wrapper.dataset.impeccableMode === 'insert';
|
const isInsert = wrapper.dataset.impeccableMode === 'insert';
|
||||||
const visEl = visibleVariant > 0 ? pickVariantContent(wrapper, visibleVariant) : null;
|
const visEl = visibleVariant > 0 ? pickVariantContent(wrapper, visibleVariant) : null;
|
||||||
@@ -11552,21 +11065,6 @@ void main() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// After a resume the cache may say the Tune knobs are still coming while
|
|
||||||
// the agent already replied done before the reload. The helper's session
|
|
||||||
// record settles it; otherwise the done reply on SSE does.
|
|
||||||
function settleParameterStateFromHelper(sessionId) {
|
|
||||||
fetch('http://localhost:' + PORT + '/status?token=' + TOKEN, { cache: 'no-store' })
|
|
||||||
.then((res) => (res.ok ? res.json() : null))
|
|
||||||
.then((data) => {
|
|
||||||
if (!data || sessionId !== currentSessionId) return;
|
|
||||||
const session = (data.activeSessions || []).find((s) => s && s.id === sessionId);
|
|
||||||
if (!session) return;
|
|
||||||
if (session.generationCompletedAt || session.generationPhase === 'completed') completeParameterGenerationIfReady();
|
|
||||||
})
|
|
||||||
.catch(() => { /* the done reply on SSE settles it otherwise */ });
|
|
||||||
}
|
|
||||||
|
|
||||||
function fetchAgentPollingStatus() {
|
function fetchAgentPollingStatus() {
|
||||||
fetch('http://localhost:' + PORT + '/status?token=' + TOKEN, { cache: 'no-store' })
|
fetch('http://localhost:' + PORT + '/status?token=' + TOKEN, { cache: 'no-store' })
|
||||||
.then((res) => (res.ok ? res.json() : null))
|
.then((res) => (res.ok ? res.json() : null))
|
||||||
@@ -11606,15 +11104,11 @@ void main() {
|
|||||||
uiAppendStyle(s);
|
uiAppendStyle(s);
|
||||||
}
|
}
|
||||||
|
|
||||||
// The generate lane's helper says so in the served script itself, so a
|
|
||||||
// lane session never draws the bar at all; every other session mounts
|
|
||||||
// it exactly as before.
|
|
||||||
const barHiddenFromStart = window.__IMPECCABLE_LIVE_BAR_HIDDEN__ === true;
|
|
||||||
globalBarEl = el('div', {
|
globalBarEl = el('div', {
|
||||||
position: 'fixed', bottom: '14px', left: '50%',
|
position: 'fixed', bottom: '14px', left: '50%',
|
||||||
transform: 'translateX(-50%) translateY(20px)',
|
transform: 'translateX(-50%) translateY(20px)',
|
||||||
zIndex: Z.bar + 5,
|
zIndex: Z.bar + 5,
|
||||||
display: barHiddenFromStart ? 'none' : 'flex', alignItems: 'stretch',
|
display: 'flex', alignItems: 'stretch',
|
||||||
gap: '0',
|
gap: '0',
|
||||||
width: 'max-content',
|
width: 'max-content',
|
||||||
background: P.surface,
|
background: P.surface,
|
||||||
@@ -11630,10 +11124,6 @@ void main() {
|
|||||||
});
|
});
|
||||||
globalBarEl.id = PREFIX + '-global-bar';
|
globalBarEl.id = PREFIX + '-global-bar';
|
||||||
globalBarEl.dataset.theme = theme;
|
globalBarEl.dataset.theme = theme;
|
||||||
if (barHiddenFromStart) {
|
|
||||||
liveBarHiddenByHelper = true;
|
|
||||||
globalBarEl.dataset.liveBarDisplay = 'flex';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Brand mark - kinpaku Impeccable icon (site header / favicon paths).
|
// Brand mark - kinpaku Impeccable icon (site header / favicon paths).
|
||||||
const brand = el('span', {
|
const brand = el('span', {
|
||||||
@@ -12029,9 +11519,6 @@ void main() {
|
|||||||
// Listen for detection results AND ready signal
|
// Listen for detection results AND ready signal
|
||||||
window.addEventListener('message', onDetectMessage);
|
window.addEventListener('message', onDetectMessage);
|
||||||
updateGlobalBarState();
|
updateGlobalBarState();
|
||||||
// The helper may already have said the bar stays hidden (a connect
|
|
||||||
// that raced the bar build, or a reload mid-lane): re-apply it here.
|
|
||||||
if (liveBarHiddenByHelper) setLiveBarHidden(true);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateGlobalBarState() {
|
function updateGlobalBarState() {
|
||||||
@@ -12228,13 +11715,6 @@ void main() {
|
|||||||
|
|
||||||
/** Full teardown: remove all UI, disconnect SSE, clean up. */
|
/** Full teardown: remove all UI, disconnect SSE, clean up. */
|
||||||
function teardown() {
|
function teardown() {
|
||||||
// Declined targets die with the overlay: the IDLE transition below must
|
|
||||||
// not re-claim a lease this page can no longer act on. So does the
|
|
||||||
// target ledger: an 'acting' entry from a Go that never happened must
|
|
||||||
// not refuse every target the next connection hears.
|
|
||||||
busyDeclinedTargets.clear();
|
|
||||||
agentTargetsSeen.clear();
|
|
||||||
liveBarHiddenByHelper = false;
|
|
||||||
stopAgentStatusPoll();
|
stopAgentStatusPoll();
|
||||||
hideAgentPollTooltip();
|
hideAgentPollTooltip();
|
||||||
if (agentPollTooltipEl) {
|
if (agentPollTooltipEl) {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
name: impeccable
|
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.
|
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: 4.3.1
|
version: 4.3.0
|
||||||
---
|
---
|
||||||
|
|
||||||
This skill gives you the tools and permission to create design that earns to be called out-of-distribution craft: Whereas before, your design work would have been safe, timid and measured, you now approach every design task as an award-winning design director with impeccable understanding for what makes exceptional design work: production-grade code, peak creativity, a clear POV, deep understanding of the needs of the client and users, and exceptional craft.
|
This skill gives you the tools and permission to create design that earns to be called out-of-distribution craft: Whereas before, your design work would have been safe, timid and measured, you now approach every design task as an award-winning design director with impeccable understanding for what makes exceptional design work: production-grade code, peak creativity, a clear POV, deep understanding of the needs of the client and users, and exceptional craft.
|
||||||
@@ -62,8 +62,7 @@ Choose the mode from the requested surface, not the product, and persist it only
|
|||||||
| `clarify [target]` | Fix | Improve UX copy, labels, and error messages | [reference/clarify.md](reference/clarify.md) |
|
| `clarify [target]` | Fix | Improve UX copy, labels, and error messages | [reference/clarify.md](reference/clarify.md) |
|
||||||
| `adapt [target]` | Fix | Adapt for different devices and screen sizes | [reference/adapt.md](reference/adapt.md) · native: [reference/adapt.native.md](reference/adapt.native.md) |
|
| `adapt [target]` | Fix | Adapt for different devices and screen sizes | [reference/adapt.md](reference/adapt.md) · native: [reference/adapt.native.md](reference/adapt.native.md) |
|
||||||
| `optimize [target]` | Fix | Diagnose and fix UI performance | [reference/optimize.md](reference/optimize.md) |
|
| `optimize [target]` | Fix | Diagnose and fix UI performance | [reference/optimize.md](reference/optimize.md) |
|
||||||
| `live` | Iterate | Visual variant mode: pick elements in the browser, iterate on alternatives | [reference/live.md](reference/live.md) |
|
| `live` | Iterate | Visual variant mode: pick elements in the browser, generate alternatives | [reference/live.md](reference/live.md) |
|
||||||
| `generate [n] [action] [element]` | Iterate | Variants, versions, or alternatives of a named element to choose from in the live browser; no manual picking | [reference/generate.md](reference/generate.md) |
|
|
||||||
|
|
||||||
Routing:
|
Routing:
|
||||||
|
|
||||||
|
|||||||
@@ -188,12 +188,6 @@ Test thoroughly across contexts:
|
|||||||
- **Edge cases**: Very small screens (320px), very large screens (4K)
|
- **Edge cases**: Very small screens (320px), very large screens (4K)
|
||||||
- **Slow connections**: Test on throttled network
|
- **Slow connections**: Test on throttled network
|
||||||
|
|
||||||
**Custom controls** (sliders, drag surfaces, scrollable control strips): a before/after slider can pass every width check above and still refuse to drag on iOS, so exercise each one in scope in the same batched round as the checks above:
|
|
||||||
|
|
||||||
- **Primary gesture**: Tap it and confirm it responds as designed, then drag it with the target input method; the drag must complete, not just start
|
|
||||||
- **Scroll across it**: A swipe along the page's scroll axis across the control scrolls the page or container without activating it; a drag that starts on the control along its axis moves the control, not the page. Neither failure throws an error, so try both
|
|
||||||
- **Evidence**: Say what produced the evidence: an emulated viewport, synthesized touch input through a browser tool, which engine ran it (Chromium is not Safari), or a physical device. Screenshots and resized viewports verify layout, never a gesture. Name what stayed untested and move on; unreachable hardware is a reported gap, not a blocker
|
|
||||||
|
|
||||||
When the adaptation feels native to each context, hand off to `/impeccable polish` for the final pass.
|
When the adaptation feels native to each context, hand off to `/impeccable polish` for the final pass.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -48,12 +48,11 @@ Run comprehensive checks across 5 dimensions. Score each dimension 0-4 using the
|
|||||||
**Check for**:
|
**Check for**:
|
||||||
- **Fixed widths**: Hard-coded widths that break on mobile
|
- **Fixed widths**: Hard-coded widths that break on mobile
|
||||||
- **Touch targets**: Interactive elements < 44x44px
|
- **Touch targets**: Interactive elements < 44x44px
|
||||||
- **Broken touch interaction**: Custom sliders, drag surfaces, and scrollable control strips whose primary gesture fails under touch, that swallow page scroll or lose the drag to it, or that stay stuck after an interrupted gesture. Code tells: mouse-only handlers, no `touch-action` on a pointer-event drag surface, drag state that nothing clears on cancel, lost capture, or blur. Exercise the gesture when a browser tool can synthesize touch (a rendered viewport proves layout, not the gesture), then say what produced the evidence (emulated viewport, synthesized touch, which engine, physical device) and what stayed untested
|
|
||||||
- **Horizontal scroll**: Content overflow on narrow viewports
|
- **Horizontal scroll**: Content overflow on narrow viewports
|
||||||
- **Text scaling**: Layouts that break when text size increases
|
- **Text scaling**: Layouts that break when text size increases
|
||||||
- **Missing breakpoints**: No mobile/tablet variants
|
- **Missing breakpoints**: No mobile/tablet variants
|
||||||
|
|
||||||
**Score 0-4**: 0=Desktop-only (breaks on mobile), 1=Major issues (some breakpoints, many failures), 2=Partial (works on mobile, rough edges), 3=Good (responsive, minor touch target or overflow issues), 4=Excellent (fluid, all viewports, proper touch targets, gestures work under touch)
|
**Score 0-4**: 0=Desktop-only (breaks on mobile), 1=Major issues (some breakpoints, many failures), 2=Partial (works on mobile, rough edges), 3=Good (responsive, minor touch target or overflow issues), 4=Excellent (fluid, all viewports, proper touch targets)
|
||||||
|
|
||||||
### 5. Implementation Integrity (CRITICAL)
|
### 5. Implementation Integrity (CRITICAL)
|
||||||
|
|
||||||
|
|||||||
@@ -1,101 +0,0 @@
|
|||||||
> **Additional context needed**: only the target element, when the request does not name one that resolves uniquely on the page.
|
|
||||||
|
|
||||||
Generate is the fast lane into live mode: the user names an element, a direction, and a count in one sentence, and within a minute they are cycling through variants in their browser. One command boots the helper, hands the element to the overlay in the page your harness already shows (it scrolls to it, selects it, and fires the same Go a click fires) and returns the generate event; one edit writes the variants; one call replies and waits for the user's choice, which the helper bakes into source itself. This file owns the lane's plumbing; from the event onward the design work is [live.md](live.md)'s, unchanged, so read it in full now if you have not this session.
|
|
||||||
|
|
||||||
**Web only.** Live mode's browser overlay has no native equivalent; on `ios` / `android` / `adaptive` projects, decline this command and offer `bolder` or `quieter` on the source instead.
|
|
||||||
|
|
||||||
The plumbing is where the lane saves time: one command starts the session around the page your harness already shows, one call replies and waits, and nothing here is a browser you have to babysit. The design work is not where it saves time. Setup runs as for any command (`impeccable context`, this reference, craft-floor.md before the edit), and the variants are planned, written, and accepted exactly the way a live session plans, writes, and accepts them.
|
|
||||||
|
|
||||||
Three prohibitions cover the known ways this command goes wrong:
|
|
||||||
|
|
||||||
- **Never run init or document, and never ask for PRODUCT.md or DESIGN.md.** When they exist, the start command prints them under `boot` and you use them. When they do not, it says so (`contextMissing`, `contextNote`) and you extract the identity from the event (Step 3). A missing file is never a reason to interview the user inside this command; offer `init` in one line after the session ends.
|
|
||||||
- **Never hand-write a variants wrapper or invent a session id.** Only the browser mints session ids (8 hex characters, at Go). A missing event is fixed by rerunning Step 2, never with a direct source edit.
|
|
||||||
- **Do not act on hook findings while live markers are in the file**, and do not restyle variants to appease them; the accept verifies the file once the variant is permanent.
|
|
||||||
|
|
||||||
## Step 1: Parse the request
|
|
||||||
|
|
||||||
Three parts, all from the user's sentence:
|
|
||||||
|
|
||||||
- **A number in the request**: that is the count. **No number**: 3. The protocol caps count at 8.
|
|
||||||
- **The direction wording** maps onto the live action vocabulary; never invent a new action value:
|
|
||||||
- **bold, bolder, stronger, punchier**: `bolder`
|
|
||||||
- **quiet, calmer, softer, toned down**: `quieter`
|
|
||||||
- **simpler, minimal, stripped**: `distill`
|
|
||||||
- **refined, tightened, polished**: `polish`
|
|
||||||
- **font and type words**: `typeset`
|
|
||||||
- **color words**: `colorize`
|
|
||||||
- **arrangement and spacing words**: `layout`
|
|
||||||
- **device and breakpoint words**: `adapt`
|
|
||||||
- **motion words**: `animate`
|
|
||||||
- **playful words**: `delight`
|
|
||||||
- **rule-breaking words**: `overdrive`
|
|
||||||
- **Wording that carries intent but no vocabulary word** ("make it feel like a bank", "warmer", "more premium"): `impeccable`, with the user's wording passed as the prompt.
|
|
||||||
- **An action fits AND extra intent rides along** ("bolder, but keep it monochrome"): that action, with the rest as the prompt.
|
|
||||||
- **The wording names no direction at all** ("better", "improve", "nicer", "different", "fresh", "new", "redesign", "fix", "some options", "ideas", "alternatives", or just "variants" with nothing else): Ask the user directly to clarify what you cannot infer. Ask one question, offering the vocabulary: *"Which direction should the variants take? bolder, quieter, simpler (distill), polished, typography (typeset), color (colorize), layout, motion (animate), playful (delight), or rule-breaking (overdrive)."* Map the answer with this list; an answer that is still open ("surprise me", "you pick") is `impeccable` with the user's original wording as the prompt, and Step 2 starts on that answer.
|
|
||||||
- **The element description** ("the pricing cards", "the hero heading"): Step 2 resolves it to a selector.
|
|
||||||
|
|
||||||
Done when you hold an action from the vocabulary (asked for, when the request named no direction), a count from 1 to 8, and the element description.
|
|
||||||
|
|
||||||
## Step 2: Reuse the page, then start
|
|
||||||
|
|
||||||
**Reuse** the dev server already running and the tab your harness already shows it in; a second server or a second browser window is the failure this step prevents.
|
|
||||||
|
|
||||||
1. **Find the dev server**, cheapest source first, and stop at the first hit: the user's message, a browser tab already on the app (Claude Code: an origin in `tabs_context`), a server your harness started (Claude Code: `preview_list`), a terminal that printed its URL. Its origin is your `--dev-url`. **No hit**: leave `--dev-url` off and run the start command with no wait; the boot probes for a running server and its verdict names the move. `browser_needed` carries the `devUrl` it found: open it as in 2, then rerun with `--dev-url <devUrl> --wait-for-browser 60000`. `no_dev_server` means nothing serves the app: start the dev script the way the verdict says (Claude Code: `preview_start`; Cursor: a background terminal; Codex: an exec you yield from), wait for its URL, then rerun with `--dev-url <url>`.
|
|
||||||
2. **Open the page that renders the element in your browser, then start.** The route the request names, else the one `--target` serves; `--dev-url` takes only the origin.
|
|
||||||
- **Cursor** (`browser_navigate`) and **Claude Code** (`navigate`, which opens the Browser pane when it is closed and takes the `tabId` from `tabs_context` when a tab is already on that origin): open the URL, then run the start command with `--dev-url <url> --wait-for-browser 60000`. The boot injects the overlay and the page reloads into it while the command waits. Your browser tool is the only opener on these harnesses; the engine ignores `--open` there.
|
|
||||||
- **No browser tool** (Codex, others): run the start command with `--open --wait-for-browser 120000`; it opens the system browser, and the longer wait covers the user finding the tab. **`browser_open_failed` back**: tell the user the `url` in one line and rerun with `--wait-for-browser 120000`.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
.gemini/skills/impeccable/scripts/impeccable live-generate --target src/App.jsx --dev-url http://127.0.0.1:5173/ --selector ".pricing-grid" --action bolder --count 3 --boot --wait-for-browser 60000
|
|
||||||
```
|
|
||||||
|
|
||||||
Run it in the foreground in Cursor and Claude Code (it returns within the wait); on Codex, in an exec you yield from, the way Step 3 runs the poll.
|
|
||||||
|
|
||||||
- `--target`: the file that renders the element when the request or the project makes it obvious; skip it otherwise.
|
|
||||||
- `--dev-url`: the origin from 1; omit it and the boot probes.
|
|
||||||
- `--selector`: a unique class first, then a landmark tag plus class, an id last (every variant mounts a copy of the element, so an id repeats in the DOM). **The request names a repeated component in plural** ("the pricing cards"): target the container that holds the set, so one scoped stylesheet restyles every instance. One read of the source file that renders the element is allowed when the selector is not obvious; `--dry-run` resolves and reports without starting anything when it is not certain.
|
|
||||||
- `--boot`: runs the lane's boot (PRODUCT.md and DESIGN.md loaded again for the helper, missing files tolerated, dev URL found, bottom bar hidden for the helper's lifetime) and reuses a helper that is already running. Its result rides along as `boot`.
|
|
||||||
- Also available: `--prompt`, `--text` (keep only matches whose visible text contains a snippet), `--index` (1-based pick among matches).
|
|
||||||
|
|
||||||
Read the output in this order: `boot` (or `boot.contextMissing` with `boot.contextNote`: the page is the source of truth, per the note), then `event`, the generate event for `sessionId`, with the same `_instructions` a user's Go gets. Every verdict carries `_instructions`, and they win over your recollection of this file; the ones whose move is a decision of yours:
|
|
||||||
|
|
||||||
- **`ambiguous`**: the candidates are listed; target their common container, or rerun with `--text "<visible text>"` or `--index <n>`.
|
|
||||||
- **`dev_server_gone`**: the dev server stopped answering while the command waited for the page (on Cursor, a server another chat started dies with that chat). Start it the way the verdict says, then rerun with `--dev-url <url>`.
|
|
||||||
- **`no_match`**: the tab is on a route that does not render the element (navigate to the right route, rerun), or the selector is wrong (derive a better one from the source, or add `--text`).
|
|
||||||
- **`config_missing` / `config_invalid`** under `bootError`: follow [live-setup.md](live-setup.md) first, then rerun.
|
|
||||||
- **`event: null`** with `ok: true`: the event was slower than the wait; run `.gemini/skills/impeccable/scripts/impeccable live-poll` once to collect it, then continue.
|
|
||||||
|
|
||||||
Done when the output shows `ok: true`, a `sessionId`, and an `event`, reached with at most one server started and one tab opened by you.
|
|
||||||
|
|
||||||
## Step 3: Generate
|
|
||||||
|
|
||||||
The event is a standard `generate` event: the picked element's context, a preflighted scaffold, and `_instructions` naming the action's reference, the planning section, and the exact splice. Handle it exactly per live.md's **Handle generate**, which owns everything from the identity lock to the done reply: read the action's reference and craft-floor.md as it says, plan per section 4 (identity first, then mode, then three different primary axes, then the squint test), declare knobs per section 7, and deliver per section 6 (a complete replacement of the element per variant, the preview CSS plus every variant in one edit at the scaffold's splice). The lane changes nothing about what a variant may be: the moves a live session would make on this element (a promoted tier, a restructured set, a reordered card, a different surface) are open here too. Never screenshot the page; the overlay preview is the review channel until accept.
|
|
||||||
|
|
||||||
**Reply and wait in one call**, with the file you wrote:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
.gemini/skills/impeccable/scripts/impeccable live-poll --reply EVENT_ID done --file src/App.jsx --then-poll
|
|
||||||
```
|
|
||||||
|
|
||||||
This replies done (the browser mounts the variants) and then blocks until the user's choice arrives, so run it the way your harness runs a long wait: **Claude Code** in the foreground with your tool's longest timeout (600000 ms), so you are paused until the choice arrives; **Codex** in a yielded foreground exec; **Cursor** in a background terminal with notify on `"type":"(accept|discard|variant_mount_failed|exit)"`. Never pass a short `--timeout=`. While it runs there is nothing else to do: never sleep and never poll its output on a timer; a harness that backgrounds it wakes you when it returns. `{"type":"timeout"}` means the user has not chosen yet: run `live-poll` again and keep waiting. If the edit fails after the browser flipped to GENERATING, `--reply EVENT_ID error "Short reason"` (without `--then-poll`) so the bar resets.
|
|
||||||
|
|
||||||
Then tell the user, in one line, where their variants are: *"Three [bolder] variants are live on [the pricing cards]: cycle with the floating bar's arrows, adjust the Tune knobs, and Accept the keeper."*
|
|
||||||
|
|
||||||
Outside the replace path, read the matching live.md section before acting: `scaffold.previewMode: "svelte-component"` (Svelte previews are edited as components, and their accept is mechanical), `mode: "insert"`, `variant_mount_failed`, `steer`, `manual_edit_apply`, and any `fallback: "agent-driven"` wrap error.
|
|
||||||
|
|
||||||
## Step 4: Accept and close
|
|
||||||
|
|
||||||
The call from Step 3 returns the user's choice. **`discard`**: nothing to do. **`accept`**: `_acceptResult.carbonize: true` is the normal case, and the cleanup is live.md's **Required after accept**, unchanged: move the accepted variant's rules into the stylesheet that already owns the element with real selectors, bake the chosen knob values in, unwrap the element and drop every `data-impeccable-*` attribute, delete the inline `<style>` block and both `impeccable-carbonize` markers, then `.gemini/skills/impeccable/scripts/impeccable live-complete --id SESSION_ID` and confirm `phase: "completed"`. (`baked: true` appears only when the accept was run with `--bake`; then the helper already made the variant permanent and no `live-complete` is owed.)
|
|
||||||
|
|
||||||
Close without being asked, the moment the choice is handled:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
.gemini/skills/impeccable/scripts/impeccable live-server stop
|
|
||||||
```
|
|
||||||
|
|
||||||
Stopping removes the injected script and reloads the page once: the user sees the accepted design with no overlay chrome, still served by their dev server. **Never kill or restart the dev server**, including one you started in Step 2.
|
|
||||||
|
|
||||||
- **The user asks for more variants before you closed**: skip the close, run Step 2 again for the next element (the helper is reused), and close after the last choice.
|
|
||||||
- **Interrupted or unsure of the state**: `.gemini/skills/impeccable/scripts/impeccable live-status`, then `live-resume`; the journal under `.impeccable/live/sessions/` is canonical.
|
|
||||||
|
|
||||||
Done when the helper is stopped and the dev site still answers with the accepted design.
|
|
||||||
@@ -205,11 +205,6 @@ t('items', { count }) // Handles complex plural rules
|
|||||||
- Optimistic updates with rollback
|
- Optimistic updates with rollback
|
||||||
- Conflict resolution
|
- Conflict resolution
|
||||||
|
|
||||||
**Interrupted gestures** (custom sliders, drag surfaces, scrollable control strips):
|
|
||||||
- A second finger or pointer lands mid-drag: the first drag keeps its pointer or ends cleanly, never jumps to the new one
|
|
||||||
- The browser cancels the gesture to scroll (`pointercancel`), capture is lost (`lostpointercapture`), the pointer is released outside the control, or the window loses focus (`blur`) mid-drag: clear the dragging state and release capture
|
|
||||||
- After each of these, the next tap or drag works without a reload
|
|
||||||
|
|
||||||
**Permission states**:
|
**Permission states**:
|
||||||
- No permission to view
|
- No permission to view
|
||||||
- No permission to edit
|
- No permission to edit
|
||||||
@@ -309,7 +304,6 @@ const throttledScroll = throttle(handleScroll, 100);
|
|||||||
- Unit tests for edge cases
|
- Unit tests for edge cases
|
||||||
- Integration tests for error scenarios
|
- Integration tests for error scenarios
|
||||||
- E2E tests for critical paths
|
- E2E tests for critical paths
|
||||||
- A behavioral regression for each confirmed gesture fix, when the project's test runner can drive input
|
|
||||||
- Visual regression tests
|
- Visual regression tests
|
||||||
- Accessibility tests (axe, WAVE)
|
- Accessibility tests (axe, WAVE)
|
||||||
|
|
||||||
@@ -336,10 +330,7 @@ Test thoroughly with edge cases:
|
|||||||
- **Network issues**: Disable internet, throttle connection
|
- **Network issues**: Disable internet, throttle connection
|
||||||
- **Large datasets**: Test with 1000+ items
|
- **Large datasets**: Test with 1000+ items
|
||||||
- **Concurrent actions**: Click submit 10 times rapidly
|
- **Concurrent actions**: Click submit 10 times rapidly
|
||||||
- **Interrupted gestures**: Add a second finger mid-drag, scroll across the control, release outside it, switch windows mid-drag; then drag again
|
|
||||||
- **Errors**: Force API errors, test all error states
|
- **Errors**: Force API errors, test all error states
|
||||||
- **Empty**: Remove all data, test empty states
|
- **Empty**: Remove all data, test empty states
|
||||||
|
|
||||||
For gestures, say what produced the evidence (emulated viewport, synthesized touch, which engine, physical device) and name what stayed untested.
|
|
||||||
|
|
||||||
When edge cases are covered, hand off to `/impeccable polish` for the final pass.
|
When edge cases are covered, hand off to `/impeccable polish` for the final pass.
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ Reason over the signals; there is no score to obey:
|
|||||||
- `critique.latest` is `null` → the project has never been critiqued; for a set-up project with a real surface, offering `/impeccable critique <surface>` is a strong default.
|
- `critique.latest` is `null` → the project has never been critiqued; for a set-up project with a real surface, offering `/impeccable critique <surface>` is a strong default.
|
||||||
- `critique.latest` with a low `score` or non-zero `p0` / `p1` → `polish` (it reads that snapshot as its backlog and closes it when stale or cleared).
|
- `critique.latest` with a low `score` or non-zero `p0` / `p1` → `polish` (it reads that snapshot as its backlog and closes it when stale or cleared).
|
||||||
- `git.changedFiles` pointing at one surface → scope `audit` or `polish` to those files specifically, naming them.
|
- `git.changedFiles` pointing at one surface → scope `audit` or `polish` to those files specifically, naming them.
|
||||||
- `devServer.running` true → `live` is available for in-browser iteration, and `generate` for one-shot variant runs on a named element; if false, don't lead with either. **`live`, `generate`, and the bundled `impeccable detect` are web-only.** If `setup.platform` is `ios`, `android`, or `adaptive`, don't lead with any of them; the browser overlay and the HTML rule engine don't apply to native app code.
|
- `devServer.running` true → `live` is available for in-browser iteration; if false, don't lead with `live`. **`live` and the bundled `impeccable detect` are web-only.** If `setup.platform` is `ios`, `android`, or `adaptive`, don't lead with either; the browser overlay and the HTML rule engine don't apply to native app code.
|
||||||
- Otherwise group by intent (build new / improve what's there / iterate visually), tailored to the current surface and `setup.platform`.
|
- Otherwise group by intent (build new / improve what's there / iterate visually), tailored to the current surface and `setup.platform`.
|
||||||
|
|
||||||
**If `scan.targets` is non-empty and `setup.platform` is not `ios`/`android`/`adaptive`, run `.gemini/skills/impeccable/scripts/impeccable detect --json <scan.targets joined by spaces>` once** (the bundled detector over local files: no network, no npx; it reads HTML/CSS, so skip it for native projects). `scan.via` tells you what they are: `git-changes` (the markup/style files in your dirty tree, the most relevant set), `source-dir` (e.g. `src`, `app`), `html`, or `root`. Fold the hits into your picks: many quality / contrast hits → `audit` or `polish`; a specific slop family → the matching command (gradient text or eyebrows → `quieter` / `typeset`, flat or gray palette → `colorize`, and so on). It's a real, current signal that beats guessing. If detect errors or the tree is large and slow, skip it and recommend the user run `audit` themselves; never block the suggestion on it.
|
**If `scan.targets` is non-empty and `setup.platform` is not `ios`/`android`/`adaptive`, run `.gemini/skills/impeccable/scripts/impeccable detect --json <scan.targets joined by spaces>` once** (the bundled detector over local files: no network, no npx; it reads HTML/CSS, so skip it for native projects). `scan.via` tells you what they are: `git-changes` (the markup/style files in your dirty tree, the most relevant set), `source-dir` (e.g. `src`, `app`), `html`, or `root`. Fold the hits into your picks: many quality / contrast hits → `audit` or `polish`; a specific slop family → the matching command (gradient text or eyebrows → `quieter` / `typeset`, flat or gray palette → `colorize`, and so on). It's a real, current signal that beats guessing. If detect errors or the tree is large and slow, skip it and recommend the user run `audit` themselves; never block the suggestion on it.
|
||||||
|
|||||||
@@ -19,10 +19,6 @@
|
|||||||
"description": "Interactive live variant mode. Select elements in the browser, pick a design action, and get AI-generated HTML+CSS variants hot-swapped via HMR. Requires a running dev server. Use when you want to visually experiment with design alternatives in real time.",
|
"description": "Interactive live variant mode. Select elements in the browser, pick a design action, and get AI-generated HTML+CSS variants hot-swapped via HMR. Requires a running dev server. Use when you want to visually experiment with design alternatives in real time.",
|
||||||
"argumentHint": ""
|
"argumentHint": ""
|
||||||
},
|
},
|
||||||
"generate": {
|
|
||||||
"description": "Agent-driven live variant generation. Boots live mode, finds the named element on the open page, scrolls the browser to it, and delivers N variants in the requested direction for the user to cycle and accept. Use for requests that name an element and a direction, like 'generate 3 bold variants of the pricing cards', skipping manual element picking.",
|
|
||||||
"argumentHint": "[count] [direction] variants of [element]"
|
|
||||||
},
|
|
||||||
"adapt": {
|
"adapt": {
|
||||||
"description": "Adapt designs to work across different screen sizes, devices, contexts, or platforms. Implements breakpoints, fluid layouts, and touch targets. Use when the user mentions responsive design, mobile layouts, breakpoints, viewport adaptation, or cross-device compatibility.",
|
"description": "Adapt designs to work across different screen sizes, devices, contexts, or platforms. Implements breakpoints, fluid layouts, and touch targets. Use when the user mentions responsive design, mobile layouts, breakpoints, viewport adaptation, or cross-device compatibility.",
|
||||||
"argumentHint": "[target] [context (mobile, tablet, print...)]"
|
"argumentHint": "[target] [context (mobile, tablet, print...)]"
|
||||||
|
|||||||
@@ -87,11 +87,6 @@ fi
|
|||||||
# Last resort: fetch this version's binary for the current platform from the
|
# Last resort: fetch this version's binary for the current platform from the
|
||||||
# public release channel into the user cache. Needs network; sandboxes without
|
# public release channel into the user cache. Needs network; sandboxes without
|
||||||
# egress preinstall the binary on PATH instead.
|
# egress preinstall the binary on PATH instead.
|
||||||
setup_help() {
|
|
||||||
echo "Engine $version setup needs network access and write permission to $cache_root/bin/$version." >&2
|
|
||||||
echo "Run this launcher ($0) with engine-probe in a terminal that has those permissions, then retry the original command." >&2
|
|
||||||
echo "Alternatively, set IMPECCABLE_HOME to a writable cache location, or IMPECCABLE_BIN to a preinstalled engine binary." >&2
|
|
||||||
}
|
|
||||||
fetch_url() {
|
fetch_url() {
|
||||||
if command -v curl >/dev/null 2>&1; then
|
if command -v curl >/dev/null 2>&1; then
|
||||||
curl -fsSL --retry 2 -o "$tmp" "$1" 2>/dev/null
|
curl -fsSL --retry 2 -o "$tmp" "$1" 2>/dev/null
|
||||||
@@ -124,18 +119,7 @@ if [ -n "$version" ] && [ "$os" != unknown ] && [ "$arch" != unknown ]; then
|
|||||||
[ "$os" = windows ] && asset="$asset.exe"
|
[ "$os" = windows ] && asset="$asset.exe"
|
||||||
url="$base/engine-v$version/$asset"
|
url="$base/engine-v$version/$asset"
|
||||||
tmp="$cache_root/bin/$version/.impeccable.part.$$"
|
tmp="$cache_root/bin/$version/.impeccable.part.$$"
|
||||||
if ! mkdir -p "$cache_root/bin/$version" 2>/dev/null; then
|
mkdir -p "$cache_root/bin/$version" 2>/dev/null
|
||||||
echo "impeccable: engine $version is not installed; cannot create cache directory: $cache_root/bin/$version" >&2
|
|
||||||
setup_help
|
|
||||||
exit 127
|
|
||||||
fi
|
|
||||||
# Check the actual staging file, not just directory existence: a cache from
|
|
||||||
# an earlier run can be readable but no longer writable inside a sandbox.
|
|
||||||
if ! (umask 077; : > "$tmp") 2>/dev/null; then
|
|
||||||
echo "impeccable: engine $version is not installed; cannot write to cache directory: $cache_root/bin/$version" >&2
|
|
||||||
setup_help
|
|
||||||
exit 127
|
|
||||||
fi
|
|
||||||
fetched=0
|
fetched=0
|
||||||
if fetch_url "$url"; then
|
if fetch_url "$url"; then
|
||||||
fetched=1
|
fetched=1
|
||||||
@@ -196,9 +180,6 @@ if [ -n "$version" ] && [ "$os" != unknown ] && [ "$arch" != unknown ]; then
|
|||||||
exec "$cached" "$@"
|
exec "$cached" "$@"
|
||||||
fi
|
fi
|
||||||
rm -f "$tmp" 2>/dev/null
|
rm -f "$tmp" 2>/dev/null
|
||||||
echo "impeccable: could not download engine $version from $url; check network access, the release URL, and curl or wget availability." >&2
|
|
||||||
setup_help
|
|
||||||
exit 127
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "impeccable: no engine binary for $os-$arch found (looked in $bin, $cached, PATH)." >&2
|
echo "impeccable: no engine binary for $os-$arch found (looked in $bin, $cached, PATH)." >&2
|
||||||
|
|||||||
@@ -68,27 +68,18 @@ rem another launcher's probe: fail fast and quiet instead.
|
|||||||
if defined IMPECCABLE_LAUNCHER_PROBE exit /b 127
|
if defined IMPECCABLE_LAUNCHER_PROBE exit /b 127
|
||||||
if not defined version goto fail
|
if not defined version goto fail
|
||||||
where curl.exe >nul 2>nul
|
where curl.exe >nul 2>nul
|
||||||
if errorlevel 1 goto curl_missing
|
if errorlevel 1 goto fail
|
||||||
if not defined IMPECCABLE_DOWNLOAD_BASE set "IMPECCABLE_DOWNLOAD_BASE=https://github.com/pbakaus/impeccable/releases/download"
|
if not defined IMPECCABLE_DOWNLOAD_BASE set "IMPECCABLE_DOWNLOAD_BASE=https://github.com/pbakaus/impeccable/releases/download"
|
||||||
if exist "%IMPECCABLE_HOME%\bin\%version%\" goto cache_ready
|
if not exist "%IMPECCABLE_HOME%\bin\%version%" mkdir "%IMPECCABLE_HOME%\bin\%version%" >nul 2>nul
|
||||||
mkdir "%IMPECCABLE_HOME%\bin\%version%" >nul 2>nul
|
|
||||||
if errorlevel 1 goto cache_directory_failed
|
|
||||||
:cache_ready
|
|
||||||
rem Check the staging file too: an existing directory may be read-only.
|
|
||||||
rem Redirection failures do not reliably update ERRORLEVEL in cmd.exe;
|
|
||||||
rem branch on the command's failure directly. Never treat a directory as a
|
|
||||||
rem staging file (later del cleanup would prompt to delete its contents).
|
|
||||||
if exist "%cached%.part\" goto cache_write_failed
|
|
||||||
(type nul >"%cached%.part") 2>nul || goto cache_write_failed
|
|
||||||
set "asset=impeccable-windows-%arch%.exe"
|
set "asset=impeccable-windows-%arch%.exe"
|
||||||
set "url=%IMPECCABLE_DOWNLOAD_BASE%/engine-v%version%/%asset%"
|
set "url=%IMPECCABLE_DOWNLOAD_BASE%/engine-v%version%/%asset%"
|
||||||
curl.exe -fsSL -o "%cached%.part" "%url%" >nul 2>nul
|
curl.exe -fsSL -o "%cached%.part" "%url%" >nul 2>nul
|
||||||
if not errorlevel 1 goto verify
|
if not errorlevel 1 goto verify
|
||||||
if not "%arch%"=="arm64" goto download_failed
|
if not "%arch%"=="arm64" goto fail
|
||||||
set "asset=impeccable-windows-x64.exe"
|
set "asset=impeccable-windows-x64.exe"
|
||||||
set "url=%IMPECCABLE_DOWNLOAD_BASE%/engine-v%version%/%asset%"
|
set "url=%IMPECCABLE_DOWNLOAD_BASE%/engine-v%version%/%asset%"
|
||||||
curl.exe -fsSL -o "%cached%.part" "%url%" >nul 2>nul
|
curl.exe -fsSL -o "%cached%.part" "%url%" >nul 2>nul
|
||||||
if errorlevel 1 goto download_failed
|
if errorlevel 1 goto fail
|
||||||
|
|
||||||
:verify
|
:verify
|
||||||
call :check_download
|
call :check_download
|
||||||
@@ -185,28 +176,6 @@ if not errorlevel 1 set "probe_ok=1"
|
|||||||
del "%probe_tmp%" >nul 2>nul
|
del "%probe_tmp%" >nul 2>nul
|
||||||
exit /b 0
|
exit /b 0
|
||||||
|
|
||||||
:cache_directory_failed
|
|
||||||
echo impeccable: engine %version% is not installed; cannot create cache directory: "%IMPECCABLE_HOME%\bin\%version%" 1>&2
|
|
||||||
goto setup_failed
|
|
||||||
|
|
||||||
:cache_write_failed
|
|
||||||
echo impeccable: engine %version% is not installed; cannot write to cache directory: "%IMPECCABLE_HOME%\bin\%version%" 1>&2
|
|
||||||
goto setup_failed
|
|
||||||
|
|
||||||
:curl_missing
|
|
||||||
echo impeccable: cannot download engine %version%; curl.exe is unavailable. 1>&2
|
|
||||||
goto setup_failed
|
|
||||||
|
|
||||||
:download_failed
|
|
||||||
del "%cached%.part" >nul 2>nul
|
|
||||||
echo impeccable: could not download engine %version% from %url%; check network access and the release URL. 1>&2
|
|
||||||
|
|
||||||
:setup_failed
|
|
||||||
echo Engine %version% setup needs network access and write permission to "%IMPECCABLE_HOME%\bin\%version%". 1>&2
|
|
||||||
echo Run this launcher ("%~f0") with engine-probe in a terminal that has those permissions, then retry the original command. 1>&2
|
|
||||||
echo Alternatively, set IMPECCABLE_HOME to a writable cache location, or IMPECCABLE_BIN to a preinstalled engine binary. 1>&2
|
|
||||||
exit /b 127
|
|
||||||
|
|
||||||
:fail
|
:fail
|
||||||
del "%cached%.part" >nul 2>nul
|
del "%cached%.part" >nul 2>nul
|
||||||
echo impeccable: no engine binary found (looked in %bin%, %cached%, PATH). 1>&2
|
echo impeccable: no engine binary found (looked in %bin%, %cached%, PATH). 1>&2
|
||||||
|
|||||||
@@ -165,14 +165,6 @@
|
|||||||
}
|
}
|
||||||
let parameterGenerationState = 'idle';
|
let parameterGenerationState = 'idle';
|
||||||
let parameterReadyAnnouncedSession = null;
|
let parameterReadyAnnouncedSession = null;
|
||||||
// 'agent' when the generate verb fired this session's Go (the generate
|
|
||||||
// lane declares no knobs, so its bar never shows a pending Tune chip);
|
|
||||||
// null for every Go a user presses.
|
|
||||||
let sessionOrigin = null;
|
|
||||||
// The generate lane picks for the agent and never edits copy in the
|
|
||||||
// browser, so its selection carries no edit-copy badge (set on the
|
|
||||||
// agent-target pick, cleared with the session; a user's pick never sets it).
|
|
||||||
let editBadgeSuppressed = false;
|
|
||||||
let svelteComponentSession = null;
|
let svelteComponentSession = null;
|
||||||
let svelteRuntimePromise = null;
|
let svelteRuntimePromise = null;
|
||||||
let pendingSvelteComponentRetryObserver = null;
|
let pendingSvelteComponentRetryObserver = null;
|
||||||
@@ -991,20 +983,9 @@
|
|||||||
}
|
}
|
||||||
} catch { /* cross-origin */ }
|
} catch { /* cross-origin */ }
|
||||||
}
|
}
|
||||||
// The selector a mechanical bake would anchor lasting rules on, and how
|
|
||||||
// many elements it matches right now: the bake refuses anything but one,
|
|
||||||
// since its rules would restyle every match, not just this element.
|
|
||||||
const cssIdent = (s) => /^[A-Za-z_-][\w-]*$/.test(s);
|
|
||||||
const anchorClasses = [...el.classList].filter(cssIdent);
|
|
||||||
const anchor = el.id && cssIdent(el.id)
|
|
||||||
? '#' + el.id
|
|
||||||
: (anchorClasses.length ? el.tagName.toLowerCase() + '.' + anchorClasses.join('.') : null);
|
|
||||||
let anchorMatches = null;
|
|
||||||
if (anchor) { try { anchorMatches = document.querySelectorAll(anchor).length; } catch { anchorMatches = null; } }
|
|
||||||
return {
|
return {
|
||||||
tagName: el.tagName.toLowerCase(), id: el.id || null,
|
tagName: el.tagName.toLowerCase(), id: el.id || null,
|
||||||
classes: [...el.classList],
|
classes: [...el.classList],
|
||||||
anchor, anchorMatches,
|
|
||||||
textContent: (el.textContent || '').slice(0, 500),
|
textContent: (el.textContent || '').slice(0, 500),
|
||||||
outerHTML: sanitizedContextOuterHTML(el, 10000),
|
outerHTML: sanitizedContextOuterHTML(el, 10000),
|
||||||
computedStyles: {
|
computedStyles: {
|
||||||
@@ -2056,7 +2037,6 @@
|
|||||||
function setLiveState(next) {
|
function setLiveState(next) {
|
||||||
state = next;
|
state = next;
|
||||||
window.__IMPECCABLE_LIVE_STATE__ = next;
|
window.__IMPECCABLE_LIVE_STATE__ = next;
|
||||||
retryDeclinedAgentTargets();
|
|
||||||
syncPageInteractionCursor();
|
syncPageInteractionCursor();
|
||||||
// Whether a queued steer is still behind a generation is a function of this
|
// Whether a queued steer is still behind a generation is a function of this
|
||||||
// state, so the hint has to move with it, not only with the 5s poll.
|
// state, so the hint has to move with it, not only with the 5s poll.
|
||||||
@@ -4034,7 +4014,6 @@
|
|||||||
|
|
||||||
function hidePendingApplyDock() {
|
function hidePendingApplyDock() {
|
||||||
pendingApplyInFlight = false;
|
pendingApplyInFlight = false;
|
||||||
retryDeclinedAgentTargets();
|
|
||||||
clearStoredManualApplyState();
|
clearStoredManualApplyState();
|
||||||
if (pendingIntroAnimation) { pendingIntroAnimation.cancel(); pendingIntroAnimation = null; }
|
if (pendingIntroAnimation) { pendingIntroAnimation.cancel(); pendingIntroAnimation = null; }
|
||||||
if (pendingDockEl) pendingDockEl.style.display = 'none';
|
if (pendingDockEl) pendingDockEl.style.display = 'none';
|
||||||
@@ -4068,7 +4047,6 @@
|
|||||||
function setPendingApplyLoading(loading, count) {
|
function setPendingApplyLoading(loading, count) {
|
||||||
if (!pendingPillEl || !pendingPillLabelEl || !pendingPillCountEl || !pendingTrashBtn) return;
|
if (!pendingPillEl || !pendingPillLabelEl || !pendingPillCountEl || !pendingTrashBtn) return;
|
||||||
pendingApplyInFlight = loading === true;
|
pendingApplyInFlight = loading === true;
|
||||||
if (!pendingApplyInFlight) retryDeclinedAgentTargets();
|
|
||||||
const currentCount = count || parseInt(pendingPillEl.dataset.count || '0', 10) || 0;
|
const currentCount = count || parseInt(pendingPillEl.dataset.count || '0', 10) || 0;
|
||||||
if (pendingApplyInFlight) storeManualApplyState(currentCount);
|
if (pendingApplyInFlight) storeManualApplyState(currentCount);
|
||||||
else clearStoredManualApplyState();
|
else clearStoredManualApplyState();
|
||||||
@@ -4710,7 +4688,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderEditBadge(mode) {
|
function renderEditBadge(mode) {
|
||||||
if (editBadgeSuppressed || sessionOrigin === 'agent') mode = 'hidden';
|
|
||||||
if (mode === 'hidden' || !editBadgeEl) {
|
if (mode === 'hidden' || !editBadgeEl) {
|
||||||
hideConfigureBarTooltip();
|
hideConfigureBarTooltip();
|
||||||
if (editBadgeEl) editBadgeEl.style.display = 'none';
|
if (editBadgeEl) editBadgeEl.style.display = 'none';
|
||||||
@@ -6204,8 +6181,6 @@
|
|||||||
resetSessionFileMeta();
|
resetSessionFileMeta();
|
||||||
currentSessionId = null;
|
currentSessionId = null;
|
||||||
parameterGenerationState = 'idle';
|
parameterGenerationState = 'idle';
|
||||||
sessionOrigin = null;
|
|
||||||
editBadgeSuppressed = false;
|
|
||||||
parameterReadyAnnouncedSession = null;
|
parameterReadyAnnouncedSession = null;
|
||||||
expectedVariants = 0;
|
expectedVariants = 0;
|
||||||
arrivedVariants = 0;
|
arrivedVariants = 0;
|
||||||
@@ -7137,398 +7112,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
//
|
//
|
||||||
// ------------------------------------------------------------------
|
|
||||||
// Agent-initiated targeting (the `generate` command). The agent names an
|
|
||||||
// element by CSS selector over POST /agent-target; the server pushes an
|
|
||||||
// `agent_target` SSE message here. The overlay resolves the selector,
|
|
||||||
// scrolls the element into view, enters the same picked state a user
|
|
||||||
// click produces, and fires the normal Go pipeline, so everything
|
|
||||||
// downstream (generate event, variants, cycling, accept) is unchanged.
|
|
||||||
// The verdict goes back through POST /agent-target-result, which resolves
|
|
||||||
// the agent's held-open CLI call.
|
|
||||||
|
|
||||||
function postAgentTargetResult(targetId, result) {
|
|
||||||
fetch('http://localhost:' + PORT + '/agent-target-result?token=' + TOKEN, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ token: TOKEN, targetId, clientId: AGENT_TARGET_CLIENT_ID, ...result }),
|
|
||||||
}).catch(() => { /* server gone; nothing to report to */ });
|
|
||||||
}
|
|
||||||
|
|
||||||
function describeAgentTargetCandidate(el) {
|
|
||||||
return {
|
|
||||||
tag: el.tagName.toLowerCase(),
|
|
||||||
id: el.id || null,
|
|
||||||
classes: [...el.classList].filter((c) => !c.startsWith('impeccable-')),
|
|
||||||
text: (el.textContent || '').trim().slice(0, 80),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveAgentTargetElement(msg) {
|
|
||||||
let matched;
|
|
||||||
try {
|
|
||||||
matched = [...document.querySelectorAll(msg.selector)];
|
|
||||||
} catch {
|
|
||||||
return { error: { ok: false, error: 'invalid_selector', selector: msg.selector } };
|
|
||||||
}
|
|
||||||
let candidates = matched.filter((el) => pickable(el));
|
|
||||||
if (msg.text) {
|
|
||||||
const needle = String(msg.text).toLowerCase();
|
|
||||||
candidates = candidates.filter((el) => (el.textContent || '').toLowerCase().includes(needle));
|
|
||||||
}
|
|
||||||
if (candidates.length === 0) {
|
|
||||||
return {
|
|
||||||
error: {
|
|
||||||
ok: false,
|
|
||||||
error: 'no_match',
|
|
||||||
selector: msg.selector,
|
|
||||||
matchCount: 0,
|
|
||||||
// How many nodes the raw selector hit before the pickable/text
|
|
||||||
// filters: distinguishes a wrong selector from an unpickable match.
|
|
||||||
rawMatchCount: matched.length,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (Number.isInteger(msg.index)) {
|
|
||||||
const el = candidates[msg.index - 1];
|
|
||||||
if (!el) {
|
|
||||||
return { error: { ok: false, error: 'index_out_of_range', selector: msg.selector, matchCount: candidates.length } };
|
|
||||||
}
|
|
||||||
return { el, matchCount: candidates.length };
|
|
||||||
}
|
|
||||||
if (candidates.length > 1) {
|
|
||||||
return {
|
|
||||||
error: {
|
|
||||||
ok: false,
|
|
||||||
error: 'ambiguous',
|
|
||||||
selector: msg.selector,
|
|
||||||
matchCount: candidates.length,
|
|
||||||
candidates: candidates.slice(0, 8).map(describeAgentTargetCandidate),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return { el: candidates[0], matchCount: 1 };
|
|
||||||
}
|
|
||||||
|
|
||||||
function scrollAgentTargetIntoView(el, done) {
|
|
||||||
const rect = el.getBoundingClientRect();
|
|
||||||
if (rect.top >= 0 && rect.bottom <= window.innerHeight) { done(); return; }
|
|
||||||
let settled = false;
|
|
||||||
let fallback = null;
|
|
||||||
const finish = () => {
|
|
||||||
if (settled) return;
|
|
||||||
settled = true;
|
|
||||||
removeEventListener('scrollend', finish, true);
|
|
||||||
if (fallback) clearTimeout(fallback);
|
|
||||||
done();
|
|
||||||
};
|
|
||||||
// scrollend where supported; a timer covers engines without it and the
|
|
||||||
// no-movement case (element already at its final resting position).
|
|
||||||
addEventListener('scrollend', finish, true);
|
|
||||||
fallback = setTimeout(finish, 1200);
|
|
||||||
el.scrollIntoView({ block: 'center', behavior: 'smooth' });
|
|
||||||
}
|
|
||||||
|
|
||||||
// One id per page load: the server keys claims and roll-call reports on
|
|
||||||
// it, and only the tab that holds the lease can renew it.
|
|
||||||
const AGENT_TARGET_CLIENT_ID = id8();
|
|
||||||
|
|
||||||
// The agent target an agent-initiated Go is serving: set by
|
|
||||||
// actOnAgentTarget around its handleGo call, read once by handleGo.
|
|
||||||
let agentTargetForGo = null;
|
|
||||||
|
|
||||||
// The helper's word on its global bar. The generate lane asks the helper
|
|
||||||
// to keep it out of the way (`impeccable live --no-live-bar`, or an agent
|
|
||||||
// target carrying hideLiveBar), and the helper tells every connected tab
|
|
||||||
// at once (`live_bar`) and every later connection on `connected`, so the
|
|
||||||
// bar stays hidden in every tab, through reloads, the accept, and the
|
|
||||||
// bake, until the helper stops and takes the overlay with it. The variant
|
|
||||||
// controls still show.
|
|
||||||
let liveBarHiddenByHelper = false;
|
|
||||||
|
|
||||||
function applyLiveBarPreference(hidden) {
|
|
||||||
liveBarHiddenByHelper = hidden === true;
|
|
||||||
setLiveBarHidden(liveBarHiddenByHelper);
|
|
||||||
}
|
|
||||||
|
|
||||||
// A plain live session must never notice this code: hiding remembers the
|
|
||||||
// bar's own display value and restoring puts exactly that back, and a
|
|
||||||
// restore on a bar that is not hidden is a no-op, so the `connected`
|
|
||||||
// frame every session receives changes nothing unless the lane asked.
|
|
||||||
function setLiveBarHidden(hidden) {
|
|
||||||
if (!globalBarEl) return;
|
|
||||||
if (hidden) {
|
|
||||||
if (globalBarEl.style.display !== 'none') {
|
|
||||||
globalBarEl.dataset.liveBarDisplay = globalBarEl.style.display || 'flex';
|
|
||||||
globalBarEl.style.display = 'none';
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (globalBarEl.style.display === 'none') {
|
|
||||||
globalBarEl.style.display = globalBarEl.dataset.liveBarDisplay || 'flex';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function claimAgentTarget(targetId, report) {
|
|
||||||
return fetch('http://localhost:' + PORT + '/agent-target-claim?token=' + TOKEN, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ token: TOKEN, targetId, clientId: AGENT_TARGET_CLIENT_ID, ...report }),
|
|
||||||
}).then((res) => res.json())
|
|
||||||
.then((j) => ({ granted: !!j && j.granted === true, pending: !!j && j.pending === true }))
|
|
||||||
.catch(() => ({ granted: false, pending: false }));
|
|
||||||
}
|
|
||||||
|
|
||||||
// `exceptTargetId` is the target this call is about: a tab acting on it
|
|
||||||
// is not busy for itself, but it is busy for every other target, or two
|
|
||||||
// held requests could both be claimed here and the second Go would
|
|
||||||
// overwrite the session the first one minted.
|
|
||||||
function agentTargetBusyReason(exceptTargetId) {
|
|
||||||
if (pendingApplyInFlight) return 'manual_apply_in_flight';
|
|
||||||
if (state !== 'IDLE' && state !== 'PICKING' && state !== 'CONFIGURING') return 'session_active';
|
|
||||||
for (const [targetId, status] of agentTargetsSeen) {
|
|
||||||
if (status === 'acting' && targetId !== exceptTargetId) return 'agent_target_in_flight';
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Targets this tab declined as busy. A busy report is only this tab's word
|
|
||||||
// at that moment: the moment it is free again (setLiveState), it claims
|
|
||||||
// each of these as eligible, and the server drops the stale report, so a
|
|
||||||
// busy verdict is never built on a tab that has since gone idle. The
|
|
||||||
// server denies claims for resolved targets, so retries are harmless.
|
|
||||||
const busyDeclinedTargets = new Map();
|
|
||||||
|
|
||||||
function declineAgentTargetBusy(msg, busy) {
|
|
||||||
busyDeclinedTargets.set(msg.targetId, msg);
|
|
||||||
noteAgentTarget(msg.targetId, 'declined');
|
|
||||||
claimAgentTarget(msg.targetId, { eligible: false, state, reason: busy });
|
|
||||||
}
|
|
||||||
|
|
||||||
// A torn-down overlay, or one whose helper connection is gone, cannot
|
|
||||||
// serve a target and must not even claim one: it would hold the lease for
|
|
||||||
// a request it will never act on.
|
|
||||||
function agentTargetOverlayGone() {
|
|
||||||
return !evtSource;
|
|
||||||
}
|
|
||||||
|
|
||||||
// A denied claimant retries at this cadence, a little over the lease, so
|
|
||||||
// the first retry after a dead holder's lease lapses is granted.
|
|
||||||
const AGENT_TARGET_RESCUE_RETRY_MS = 3500;
|
|
||||||
|
|
||||||
// Claim the lease and act as the holder. A denied claim means another tab
|
|
||||||
// holds the lease. That holder can die before posting its result (reload,
|
|
||||||
// crash, even after renewing), and its lease lapses after ~3s, so this tab
|
|
||||||
// keeps retrying for as long as the server still holds the request: the
|
|
||||||
// answer's `pending` is the server's word that the request is alive, and
|
|
||||||
// it turns false the moment the request resolved or timed out, so no tab
|
|
||||||
// retries a request nobody awaits. A tab that turned busy meanwhile joins
|
|
||||||
// the roll call instead of taking a lease it cannot use. The first claim
|
|
||||||
// and the busy-to-idle re-claim share this.
|
|
||||||
function claimAndActOnAgentTarget(msg) {
|
|
||||||
if (agentTargetOverlayGone()) return;
|
|
||||||
const busy = agentTargetBusyReason(msg.targetId);
|
|
||||||
if (busy) { declineAgentTargetBusy(msg, busy); return; }
|
|
||||||
if (declineAgentTargetUnresolvable(msg)) return;
|
|
||||||
claimAgentTarget(msg.targetId, { eligible: true }).then((claim) => {
|
|
||||||
if (claim.granted) { noteAgentTarget(msg.targetId, 'acting'); actOnAgentTarget(msg); return; }
|
|
||||||
noteAgentTarget(msg.targetId, 'denied');
|
|
||||||
if (!claim.pending) return;
|
|
||||||
setTimeout(() => claimAndActOnAgentTarget(msg), AGENT_TARGET_RESCUE_RETRY_MS);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function retryDeclinedAgentTargets() {
|
|
||||||
if (busyDeclinedTargets.size === 0 || agentTargetBusyReason()) return;
|
|
||||||
for (const [targetId, msg] of busyDeclinedTargets) {
|
|
||||||
busyDeclinedTargets.delete(targetId);
|
|
||||||
claimAndActOnAgentTarget(msg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// This page's participation in each target it heard: 'acting' once a
|
|
||||||
// claim was granted, 'done' once it replied (or stood down from a lapsed
|
|
||||||
// lease), else the word it last gave. The server replays pending targets
|
|
||||||
// to every connection that opens. After a reconnect that overlapped the
|
|
||||||
// old connection the server still holds this page's word; after one that
|
|
||||||
// did not, it dropped the word on the close, so a replayed target is
|
|
||||||
// handled again: a busy or unresolvable page re-declines (idempotent), an
|
|
||||||
// idle page claims.
|
|
||||||
const agentTargetsSeen = new Map();
|
|
||||||
function noteAgentTarget(targetId, status) {
|
|
||||||
agentTargetsSeen.set(targetId, status);
|
|
||||||
if (agentTargetsSeen.size > 100) agentTargetsSeen.delete(agentTargetsSeen.keys().next().value);
|
|
||||||
}
|
|
||||||
|
|
||||||
// A target this page took a lease on is off-limits for a replay: while
|
|
||||||
// acting (a second claim or Go), and once done, because its result may
|
|
||||||
// still be on the wire and this tab is GENERATING by then, so handling
|
|
||||||
// the replay would decline busy, hand the lease back mid-resolution, and
|
|
||||||
// let another tab fire a second Go.
|
|
||||||
function agentTargetTaken(targetId) {
|
|
||||||
const status = agentTargetsSeen.get(targetId);
|
|
||||||
return status === 'acting' || status === 'done';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only a page that can resolve the target claims it. A tab whose page
|
|
||||||
// lacks the element declines with its resolution verdict instead, so a
|
|
||||||
// first-wins claim never lets the wrong page answer for a target that
|
|
||||||
// another page has. The server prefers a busy report (a tab that could
|
|
||||||
// serve later) over these, and returns the resolution verdict only when
|
|
||||||
// no connected page can serve.
|
|
||||||
//
|
|
||||||
// An element can be momentarily absent (a route still rendering, an HMR
|
|
||||||
// commit mid-swap), so a failed resolution is not this page's final word:
|
|
||||||
// it is re-checked a few times over about two seconds, claiming the
|
|
||||||
// moment the element mounts, and only the last miss is reported. The
|
|
||||||
// server's timeout still bounds the whole exchange.
|
|
||||||
// The page reports the miss at once (so the other overlays' words can
|
|
||||||
// complete the roll call) and keeps re-checking at this cadence for as
|
|
||||||
// long as the server says the request is pending: the server holds an
|
|
||||||
// all-no_match roll call open for a short grace precisely so a late mount
|
|
||||||
// can still be claimed, drops the stale report on an eligible claim, and
|
|
||||||
// ends the watch by answering pending:false once the request resolved or
|
|
||||||
// timed out.
|
|
||||||
const AGENT_TARGET_RESOLVE_WATCH_MS = 500;
|
|
||||||
|
|
||||||
function declineAgentTargetUnresolvable(msg) {
|
|
||||||
const probe = resolveAgentTargetElement(msg);
|
|
||||||
if (!probe.error) return false;
|
|
||||||
reportAgentTargetUnresolvable(msg, probe.error);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function reportAgentTargetUnresolvable(msg, error) {
|
|
||||||
noteAgentTarget(msg.targetId, 'declined');
|
|
||||||
claimAgentTarget(msg.targetId, { eligible: false, state, reason: 'no_match', result: error }).then((answer) => {
|
|
||||||
if (!answer.pending) return;
|
|
||||||
setTimeout(() => watchAgentTargetResolution(msg, error), AGENT_TARGET_RESOLVE_WATCH_MS);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function watchAgentTargetResolution(msg, lastError) {
|
|
||||||
if (agentTargetOverlayGone() || agentTargetTaken(msg.targetId)) return;
|
|
||||||
const busy = agentTargetBusyReason(msg.targetId);
|
|
||||||
if (busy) { declineAgentTargetBusy(msg, busy); return; }
|
|
||||||
const probe = resolveAgentTargetElement(msg);
|
|
||||||
if (!probe.error) { claimAndActOnAgentTarget(msg); return; }
|
|
||||||
// Still unresolvable: re-report (idempotent); the answer says whether
|
|
||||||
// the server is still holding the request open.
|
|
||||||
reportAgentTargetUnresolvable(msg, probe.error || lastError);
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleAgentTarget(msg) {
|
|
||||||
if (!msg || typeof msg.targetId !== 'string') return;
|
|
||||||
if (agentTargetTaken(msg.targetId)) return;
|
|
||||||
noteAgentTarget(msg.targetId, 'heard');
|
|
||||||
const busy = agentTargetBusyReason(msg.targetId);
|
|
||||||
if (busy) {
|
|
||||||
// Roll call: a busy tab reports itself and never acts. The server
|
|
||||||
// answers `busy` the moment every connected overlay has reported, so
|
|
||||||
// an idle tab elsewhere is never raced by a timer.
|
|
||||||
declineAgentTargetBusy(msg, busy);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (declineAgentTargetUnresolvable(msg)) return;
|
|
||||||
// Eligible tabs race for the server's lease and only the holder acts. A
|
|
||||||
// hidden tab yields a short head start so a visible one wins when both
|
|
||||||
// exist, and still serves the request on its own: the user finds the
|
|
||||||
// selection waiting when they return to it.
|
|
||||||
setTimeout(() => claimAndActOnAgentTarget(msg), document.hidden ? 150 : 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
function actOnAgentTarget(msg) {
|
|
||||||
if (agentTargetOverlayGone()) return;
|
|
||||||
// Every exit ends this tab's acting state, so a later target is not
|
|
||||||
// refused for a Go that already happened or never will.
|
|
||||||
const reply = (result) => { noteAgentTarget(msg.targetId, 'done'); postAgentTargetResult(msg.targetId, result); };
|
|
||||||
const busy = agentTargetBusyReason(msg.targetId);
|
|
||||||
if (busy) {
|
|
||||||
// Turned busy between claim and act: report it, which also hands the
|
|
||||||
// lease back so the roll call can complete or a rescuer can claim.
|
|
||||||
declineAgentTargetBusy(msg, busy);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const resolved = resolveAgentTargetElement(msg);
|
|
||||||
if (resolved.error) {
|
|
||||||
// The element went away between claim and act. A result would end the
|
|
||||||
// request for every tab; a decline hands the lease back so another
|
|
||||||
// page or a remount can still serve it.
|
|
||||||
reportAgentTargetUnresolvable(msg, resolved.error);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const el = resolved.el;
|
|
||||||
if (msg.dryRun) {
|
|
||||||
reply({
|
|
||||||
ok: true,
|
|
||||||
dryRun: true,
|
|
||||||
matchCount: resolved.matchCount,
|
|
||||||
element: describeAgentTargetCandidate(el),
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
scrollAgentTargetIntoView(el, () => {
|
|
||||||
// Torn down during the scroll settle: do not renew. The lease lapses
|
|
||||||
// for a rescuer instead of Go minting a session on a dismantled
|
|
||||||
// overlay.
|
|
||||||
if (agentTargetOverlayGone()) return;
|
|
||||||
// Renew the lease right before the irreversible part: a tab whose
|
|
||||||
// lease lapsed while it scrolled (a rescuer took over) stops here, so
|
|
||||||
// one request never gets two Go presses.
|
|
||||||
claimAgentTarget(msg.targetId, { eligible: true }).then((renewal) => {
|
|
||||||
if (!renewal.granted) { noteAgentTarget(msg.targetId, 'done'); return; }
|
|
||||||
// An insert placement left mid-configure gives way, exactly as a
|
|
||||||
// click outside it does in handleClick.
|
|
||||||
if (state === 'CONFIGURING' && configureKind === 'insert') cancelInsertConfigure();
|
|
||||||
// Mirror of the user-click pick entry in handleClick, minus the
|
|
||||||
// pick-mode gate (the agent's intent replaces the toggle); the entry
|
|
||||||
// goes through beginNewLiveConfiguration like every other pick so
|
|
||||||
// deferred recovery sees a fresh interaction revision.
|
|
||||||
selectedElement = el;
|
|
||||||
beginNewLiveConfiguration();
|
|
||||||
showHighlight(selectedElement);
|
|
||||||
clearAnnotations();
|
|
||||||
showAnnotOverlay(selectedElement);
|
|
||||||
showBar('configure');
|
|
||||||
editBadgeSuppressed = true;
|
|
||||||
renderEditBadge('hidden');
|
|
||||||
startScrollTracking();
|
|
||||||
maybePrefetchPage();
|
|
||||||
maybeWarnConditionalAncestor(selectedElement);
|
|
||||||
// Preset what the agent asked for, then fire the same Go a user press
|
|
||||||
// fires. handleGo reads exactly these inputs.
|
|
||||||
selectedAction = msg.action;
|
|
||||||
selectedCount = msg.count;
|
|
||||||
// updateBarContent rebuilds the configure row and replaces the input
|
|
||||||
// element, so the prompt must be written into the input it creates,
|
|
||||||
// never before (the action-chip click handler does the same dance).
|
|
||||||
updateBarContent('configure');
|
|
||||||
const input = uiGetById(PREFIX + '-input');
|
|
||||||
if (input) input.value = msg.prompt || '';
|
|
||||||
// The target rides on the generate event too: the helper resolves
|
|
||||||
// the request from whichever lands first, so a page that dies
|
|
||||||
// between Go and its result cannot leave the request pending for a
|
|
||||||
// second Go elsewhere.
|
|
||||||
const candidate = describeAgentTargetCandidate(el);
|
|
||||||
agentTargetForGo = { targetId: msg.targetId, matchCount: resolved.matchCount, action: msg.action, count: msg.count, element: candidate };
|
|
||||||
handleGo();
|
|
||||||
agentTargetForGo = null;
|
|
||||||
if (state === 'GENERATING' && currentSessionId) {
|
|
||||||
reply({
|
|
||||||
ok: true,
|
|
||||||
matchCount: resolved.matchCount,
|
|
||||||
sessionId: currentSessionId,
|
|
||||||
action: msg.action,
|
|
||||||
count: msg.count,
|
|
||||||
element: candidate,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
reply({ ok: false, error: 'go_failed', state });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// SSE (server→browser) + fetch POST (browser→server)
|
// SSE (server→browser) + fetch POST (browser→server)
|
||||||
// Zero-dependency replacement for WebSocket.
|
// Zero-dependency replacement for WebSocket.
|
||||||
//
|
//
|
||||||
@@ -7538,7 +7121,7 @@
|
|||||||
const SSE_MAX_RETRIES = 20; // generous: heartbeats keep the connection alive, so retries mean real trouble
|
const SSE_MAX_RETRIES = 20; // generous: heartbeats keep the connection alive, so retries mean real trouble
|
||||||
|
|
||||||
function connectSSE() {
|
function connectSSE() {
|
||||||
evtSource = new EventSource('http://localhost:' + PORT + '/events?token=' + TOKEN + '&clientId=' + AGENT_TARGET_CLIENT_ID);
|
evtSource = new EventSource('http://localhost:' + PORT + '/events?token=' + TOKEN);
|
||||||
|
|
||||||
evtSource.onopen = () => {
|
evtSource.onopen = () => {
|
||||||
sseRetries = 0; // reset on successful (re)connect
|
sseRetries = 0; // reset on successful (re)connect
|
||||||
@@ -7549,11 +7132,8 @@
|
|||||||
let msg; try { msg = JSON.parse(e.data); } catch { return; }
|
let msg; try { msg = JSON.parse(e.data); } catch { return; }
|
||||||
switch (msg.type) {
|
switch (msg.type) {
|
||||||
case 'connected':
|
case 'connected':
|
||||||
applyLiveBarPreference(msg.hideLiveBar === true);
|
|
||||||
hasProjectContext = !!msg.hasProjectContext;
|
hasProjectContext = !!msg.hasProjectContext;
|
||||||
// The generate lane runs without PRODUCT.md by design and never
|
if (!hasProjectContext) showToast(`No PRODUCT.md found. Variants will be brand-agnostic. Run ${IMPECCABLE_COMMAND} init to generate one.`, 7000);
|
||||||
// sends the user to init, so its quiet chrome skips this notice.
|
|
||||||
if (!hasProjectContext && !liveBarHiddenByHelper) showToast(`No PRODUCT.md found. Variants will be brand-agnostic. Run ${IMPECCABLE_COMMAND} init to generate one.`, 7000);
|
|
||||||
console.log('[impeccable] Live mode connected.');
|
console.log('[impeccable] Live mode connected.');
|
||||||
syncAgentPollingUi(!!msg.agentPolling);
|
syncAgentPollingUi(!!msg.agentPolling);
|
||||||
startAgentStatusPoll();
|
startAgentStatusPoll();
|
||||||
@@ -7563,15 +7143,9 @@
|
|||||||
syncPageInteractionCursor();
|
syncPageInteractionCursor();
|
||||||
syncPageChatFocus('sse-connected');
|
syncPageChatFocus('sse-connected');
|
||||||
break;
|
break;
|
||||||
case 'live_bar':
|
|
||||||
applyLiveBarPreference(msg.hidden === true);
|
|
||||||
break;
|
|
||||||
case 'agent_polling':
|
case 'agent_polling':
|
||||||
syncAgentPollingUi(!!msg.connected);
|
syncAgentPollingUi(!!msg.connected);
|
||||||
break;
|
break;
|
||||||
case 'agent_target':
|
|
||||||
handleAgentTarget(msg);
|
|
||||||
break;
|
|
||||||
case 'agent_phase':
|
case 'agent_phase':
|
||||||
if (msg.id === currentSessionId && (state === 'GENERATING' || state === 'CYCLING')) {
|
if (msg.id === currentSessionId && (state === 'GENERATING' || state === 'CYCLING')) {
|
||||||
// Advance the visible phase monotonically. A behind/resumed
|
// Advance the visible phase monotonically. A behind/resumed
|
||||||
@@ -7634,11 +7208,6 @@
|
|||||||
disableInlineEdit();
|
disableInlineEdit();
|
||||||
refreshParamsPanel();
|
refreshParamsPanel();
|
||||||
}
|
}
|
||||||
// The done reply is the agent's last word on this generation:
|
|
||||||
// with every variant mounted and no knobs declared, the Tune
|
|
||||||
// chip must stop spinning. A reload between the mount and this
|
|
||||||
// reply restored the pending state from the cache.
|
|
||||||
completeParameterGenerationIfReady();
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
// Source fallback when HMR did not land variants in this tab.
|
// Source fallback when HMR did not land variants in this tab.
|
||||||
@@ -7802,15 +7371,6 @@
|
|||||||
}).then(async res => {
|
}).then(async res => {
|
||||||
if (res.ok) return res;
|
if (res.ok) return res;
|
||||||
const body = await res.json().catch(() => ({}));
|
const body = await res.json().catch(() => ({}));
|
||||||
// The helper refused to open a session for an agent target it has
|
|
||||||
// already answered (another page served it after this page's lease
|
|
||||||
// lapsed mid-capture, or the request timed out): drop the local
|
|
||||||
// session and hand the surface back.
|
|
||||||
if (body.error === 'agent_target_already_served' && msg.type === 'generate'
|
|
||||||
&& msg.id && msg.id === currentSessionId) {
|
|
||||||
abandonSupersededGo(msg.id);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
// The server refused to journal progress for a session it has never
|
// The server refused to journal progress for a session it has never
|
||||||
// seen: this browser is carrying state from another project or a
|
// seen: this browser is carrying state from another project or a
|
||||||
// wiped store (two apps sharing a localhost port). Continuing to
|
// wiped store (two apps sharing a localhost port). Continuing to
|
||||||
@@ -7832,14 +7392,6 @@
|
|||||||
return sessionCreationGate.then(doSend);
|
return sessionCreationGate.then(doSend);
|
||||||
}
|
}
|
||||||
|
|
||||||
function abandonSupersededGo(sessionId) {
|
|
||||||
if (sessionId !== currentSessionId) return;
|
|
||||||
console.warn('[impeccable] The helper already answered this agent target; clearing session ' + sessionId + '.');
|
|
||||||
markSessionHandled();
|
|
||||||
cleanup({ instantChrome: true });
|
|
||||||
showToast('The helper already answered this request, so this session was cleared. Pick an element to start fresh.', 6000);
|
|
||||||
}
|
|
||||||
|
|
||||||
let abandonedForeignSessionId = null;
|
let abandonedForeignSessionId = null;
|
||||||
function abandonForeignSession(sessionId) {
|
function abandonForeignSession(sessionId) {
|
||||||
if (abandonedForeignSessionId === sessionId || sessionId !== currentSessionId) return;
|
if (abandonedForeignSessionId === sessionId || sessionId !== currentSessionId) return;
|
||||||
@@ -8244,7 +7796,6 @@
|
|||||||
visibleVariant = 0;
|
visibleVariant = 0;
|
||||||
generationPhase = 'queued';
|
generationPhase = 'queued';
|
||||||
parameterGenerationState = 'pending';
|
parameterGenerationState = 'pending';
|
||||||
sessionOrigin = agentTargetForGo ? 'agent' : null;
|
|
||||||
parameterReadyAnnouncedSession = null;
|
parameterReadyAnnouncedSession = null;
|
||||||
resetSessionFileMeta();
|
resetSessionFileMeta();
|
||||||
|
|
||||||
@@ -8270,24 +7821,6 @@
|
|||||||
};
|
};
|
||||||
if (snapshot.comments.length > 0) basePayload.comments = snapshot.comments;
|
if (snapshot.comments.length > 0) basePayload.comments = snapshot.comments;
|
||||||
if (snapshot.strokes.length > 0) basePayload.strokes = snapshot.strokes;
|
if (snapshot.strokes.length > 0) basePayload.strokes = snapshot.strokes;
|
||||||
if (agentTargetForGo) {
|
|
||||||
// An agent-initiated Go names the target it serves (see
|
|
||||||
// actOnAgentTarget): the helper resolves that request from this event
|
|
||||||
// as well as from the overlay's own result post.
|
|
||||||
basePayload.agentTarget = {
|
|
||||||
targetId: agentTargetForGo.targetId,
|
|
||||||
clientId: AGENT_TARGET_CLIENT_ID,
|
|
||||||
result: {
|
|
||||||
ok: true,
|
|
||||||
matchCount: agentTargetForGo.matchCount,
|
|
||||||
sessionId: currentSessionId,
|
|
||||||
action: agentTargetForGo.action,
|
|
||||||
count: agentTargetForGo.count,
|
|
||||||
element: agentTargetForGo.element,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
agentTargetForGo = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Hide the interactive overlay so it doesn't linger during generation.
|
// Hide the interactive overlay so it doesn't linger during generation.
|
||||||
hideAnnotOverlay();
|
hideAnnotOverlay();
|
||||||
@@ -8348,7 +7881,6 @@
|
|||||||
visibleVariant = 0;
|
visibleVariant = 0;
|
||||||
generationPhase = 'queued';
|
generationPhase = 'queued';
|
||||||
parameterGenerationState = 'pending';
|
parameterGenerationState = 'pending';
|
||||||
sessionOrigin = agentTargetForGo ? 'agent' : null;
|
|
||||||
parameterReadyAnnouncedSession = null;
|
parameterReadyAnnouncedSession = null;
|
||||||
resetSessionFileMeta();
|
resetSessionFileMeta();
|
||||||
selectedElement = placeholderElement;
|
selectedElement = placeholderElement;
|
||||||
@@ -9395,8 +8927,6 @@ void main() {
|
|||||||
pagePickSkipClick = false;
|
pagePickSkipClick = false;
|
||||||
currentSessionId = null;
|
currentSessionId = null;
|
||||||
parameterGenerationState = 'idle';
|
parameterGenerationState = 'idle';
|
||||||
sessionOrigin = null;
|
|
||||||
editBadgeSuppressed = false;
|
|
||||||
parameterReadyAnnouncedSession = null;
|
parameterReadyAnnouncedSession = null;
|
||||||
selectedAction = 'impeccable';
|
selectedAction = 'impeccable';
|
||||||
pendingAcceptedSession = null;
|
pendingAcceptedSession = null;
|
||||||
@@ -9488,7 +9018,6 @@ void main() {
|
|||||||
paramsCurrentValues = { ...saved.paramValues };
|
paramsCurrentValues = { ...saved.paramValues };
|
||||||
}
|
}
|
||||||
if (saved.parameterState) parameterGenerationState = saved.parameterState;
|
if (saved.parameterState) parameterGenerationState = saved.parameterState;
|
||||||
sessionOrigin = saved.origin === 'agent' ? 'agent' : null;
|
|
||||||
if (saved.generationPhase) generationPhase = saved.generationPhase;
|
if (saved.generationPhase) generationPhase = saved.generationPhase;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -9576,12 +9105,7 @@ void main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function restoreSessionWithoutWrapper(reason, activeSessions) {
|
function restoreSessionWithoutWrapper(reason, activeSessions) {
|
||||||
// The session cache is per origin, so a tab on another page of the same
|
const cached = loadSession();
|
||||||
// app sees this page's session too. Only the page that saved it may
|
|
||||||
// resume it: the server-adoption branch below already applies the same
|
|
||||||
// check, and a tab on another page has nothing to render for it.
|
|
||||||
const cachedRaw = loadSession();
|
|
||||||
const cached = cachedRaw?.id && !pageMatchesCurrent(cachedRaw.pageUrl) ? null : cachedRaw;
|
|
||||||
// localStorage is a cache, not a gate. A cleared tab, a second browser
|
// localStorage is a cache, not a gate. A cleared tab, a second browser
|
||||||
// profile, or a teardown that dropped local state all leave the durable
|
// profile, or a teardown that dropped local state all leave the durable
|
||||||
// server session as the only record of work in progress; adopt it instead
|
// server session as the only record of work in progress; adopt it instead
|
||||||
@@ -9694,7 +9218,6 @@ void main() {
|
|||||||
pageUrl: location.pathname,
|
pageUrl: location.pathname,
|
||||||
paramValues: { ...paramsCurrentValues },
|
paramValues: { ...paramsCurrentValues },
|
||||||
parameterState: parameterGenerationState,
|
parameterState: parameterGenerationState,
|
||||||
origin: sessionOrigin || undefined,
|
|
||||||
insertPlaceholder: insertPlaceholderSnapshot || undefined,
|
insertPlaceholder: insertPlaceholderSnapshot || undefined,
|
||||||
pickedAnchor: pickedAnchorSnapshot || undefined,
|
pickedAnchor: pickedAnchorSnapshot || undefined,
|
||||||
pickedAnchorViewportTop: Number.isFinite(pickedAnchorViewportTop) ? pickedAnchorViewportTop : undefined,
|
pickedAnchorViewportTop: Number.isFinite(pickedAnchorViewportTop) ? pickedAnchorViewportTop : undefined,
|
||||||
@@ -9820,8 +9343,6 @@ void main() {
|
|||||||
pagePickSkipClick = false;
|
pagePickSkipClick = false;
|
||||||
currentSessionId = null;
|
currentSessionId = null;
|
||||||
parameterGenerationState = 'idle';
|
parameterGenerationState = 'idle';
|
||||||
sessionOrigin = null;
|
|
||||||
editBadgeSuppressed = false;
|
|
||||||
parameterReadyAnnouncedSession = null;
|
parameterReadyAnnouncedSession = null;
|
||||||
selectedAction = 'impeccable';
|
selectedAction = 'impeccable';
|
||||||
renderEditBadge('hidden');
|
renderEditBadge('hidden');
|
||||||
@@ -10080,14 +9601,6 @@ void main() {
|
|||||||
|
|
||||||
const resumedState = arrivedVariants > 0 ? 'CYCLING' : 'GENERATING';
|
const resumedState = arrivedVariants > 0 ? 'CYCLING' : 'GENERATING';
|
||||||
|
|
||||||
// A reload between the variants mounting and the agent's done reply
|
|
||||||
// restores a pending Tune state from the cache; the helper knows whether
|
|
||||||
// that generation already finished.
|
|
||||||
if (arrivedVariants >= expectedVariants && expectedVariants > 0
|
|
||||||
&& (parameterGenerationState === 'pending' || parameterGenerationState === 'loading')) {
|
|
||||||
settleParameterStateFromHelper(sessionId);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find the visible variant's content element for highlight positioning.
|
// Find the visible variant's content element for highlight positioning.
|
||||||
const isInsert = wrapper.dataset.impeccableMode === 'insert';
|
const isInsert = wrapper.dataset.impeccableMode === 'insert';
|
||||||
const visEl = visibleVariant > 0 ? pickVariantContent(wrapper, visibleVariant) : null;
|
const visEl = visibleVariant > 0 ? pickVariantContent(wrapper, visibleVariant) : null;
|
||||||
@@ -11552,21 +11065,6 @@ void main() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// After a resume the cache may say the Tune knobs are still coming while
|
|
||||||
// the agent already replied done before the reload. The helper's session
|
|
||||||
// record settles it; otherwise the done reply on SSE does.
|
|
||||||
function settleParameterStateFromHelper(sessionId) {
|
|
||||||
fetch('http://localhost:' + PORT + '/status?token=' + TOKEN, { cache: 'no-store' })
|
|
||||||
.then((res) => (res.ok ? res.json() : null))
|
|
||||||
.then((data) => {
|
|
||||||
if (!data || sessionId !== currentSessionId) return;
|
|
||||||
const session = (data.activeSessions || []).find((s) => s && s.id === sessionId);
|
|
||||||
if (!session) return;
|
|
||||||
if (session.generationCompletedAt || session.generationPhase === 'completed') completeParameterGenerationIfReady();
|
|
||||||
})
|
|
||||||
.catch(() => { /* the done reply on SSE settles it otherwise */ });
|
|
||||||
}
|
|
||||||
|
|
||||||
function fetchAgentPollingStatus() {
|
function fetchAgentPollingStatus() {
|
||||||
fetch('http://localhost:' + PORT + '/status?token=' + TOKEN, { cache: 'no-store' })
|
fetch('http://localhost:' + PORT + '/status?token=' + TOKEN, { cache: 'no-store' })
|
||||||
.then((res) => (res.ok ? res.json() : null))
|
.then((res) => (res.ok ? res.json() : null))
|
||||||
@@ -11606,15 +11104,11 @@ void main() {
|
|||||||
uiAppendStyle(s);
|
uiAppendStyle(s);
|
||||||
}
|
}
|
||||||
|
|
||||||
// The generate lane's helper says so in the served script itself, so a
|
|
||||||
// lane session never draws the bar at all; every other session mounts
|
|
||||||
// it exactly as before.
|
|
||||||
const barHiddenFromStart = window.__IMPECCABLE_LIVE_BAR_HIDDEN__ === true;
|
|
||||||
globalBarEl = el('div', {
|
globalBarEl = el('div', {
|
||||||
position: 'fixed', bottom: '14px', left: '50%',
|
position: 'fixed', bottom: '14px', left: '50%',
|
||||||
transform: 'translateX(-50%) translateY(20px)',
|
transform: 'translateX(-50%) translateY(20px)',
|
||||||
zIndex: Z.bar + 5,
|
zIndex: Z.bar + 5,
|
||||||
display: barHiddenFromStart ? 'none' : 'flex', alignItems: 'stretch',
|
display: 'flex', alignItems: 'stretch',
|
||||||
gap: '0',
|
gap: '0',
|
||||||
width: 'max-content',
|
width: 'max-content',
|
||||||
background: P.surface,
|
background: P.surface,
|
||||||
@@ -11630,10 +11124,6 @@ void main() {
|
|||||||
});
|
});
|
||||||
globalBarEl.id = PREFIX + '-global-bar';
|
globalBarEl.id = PREFIX + '-global-bar';
|
||||||
globalBarEl.dataset.theme = theme;
|
globalBarEl.dataset.theme = theme;
|
||||||
if (barHiddenFromStart) {
|
|
||||||
liveBarHiddenByHelper = true;
|
|
||||||
globalBarEl.dataset.liveBarDisplay = 'flex';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Brand mark - kinpaku Impeccable icon (site header / favicon paths).
|
// Brand mark - kinpaku Impeccable icon (site header / favicon paths).
|
||||||
const brand = el('span', {
|
const brand = el('span', {
|
||||||
@@ -12029,9 +11519,6 @@ void main() {
|
|||||||
// Listen for detection results AND ready signal
|
// Listen for detection results AND ready signal
|
||||||
window.addEventListener('message', onDetectMessage);
|
window.addEventListener('message', onDetectMessage);
|
||||||
updateGlobalBarState();
|
updateGlobalBarState();
|
||||||
// The helper may already have said the bar stays hidden (a connect
|
|
||||||
// that raced the bar build, or a reload mid-lane): re-apply it here.
|
|
||||||
if (liveBarHiddenByHelper) setLiveBarHidden(true);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateGlobalBarState() {
|
function updateGlobalBarState() {
|
||||||
@@ -12228,13 +11715,6 @@ void main() {
|
|||||||
|
|
||||||
/** Full teardown: remove all UI, disconnect SSE, clean up. */
|
/** Full teardown: remove all UI, disconnect SSE, clean up. */
|
||||||
function teardown() {
|
function teardown() {
|
||||||
// Declined targets die with the overlay: the IDLE transition below must
|
|
||||||
// not re-claim a lease this page can no longer act on. So does the
|
|
||||||
// target ledger: an 'acting' entry from a Go that never happened must
|
|
||||||
// not refuse every target the next connection hears.
|
|
||||||
busyDeclinedTargets.clear();
|
|
||||||
agentTargetsSeen.clear();
|
|
||||||
liveBarHiddenByHelper = false;
|
|
||||||
stopAgentStatusPoll();
|
stopAgentStatusPoll();
|
||||||
hideAgentPollTooltip();
|
hideAgentPollTooltip();
|
||||||
if (agentPollTooltipEl) {
|
if (agentPollTooltipEl) {
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
---
|
---
|
||||||
name: impeccable
|
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.
|
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: 4.3.1
|
version: 4.3.0
|
||||||
user-invocable: true
|
user-invocable: true
|
||||||
argument-hint: "[shape · audit|critique · animate|bolder|colorize|delight|layout|overdrive|quieter|typeset · adapt|clarify|distill · harden|onboard|optimize|polish · init|document|extract|live|generate] [target]"
|
argument-hint: "[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
|
license: Apache 2.0
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -65,8 +65,7 @@ Choose the mode from the requested surface, not the product, and persist it only
|
|||||||
| `clarify [target]` | Fix | Improve UX copy, labels, and error messages | [reference/clarify.md](reference/clarify.md) |
|
| `clarify [target]` | Fix | Improve UX copy, labels, and error messages | [reference/clarify.md](reference/clarify.md) |
|
||||||
| `adapt [target]` | Fix | Adapt for different devices and screen sizes | [reference/adapt.md](reference/adapt.md) · native: [reference/adapt.native.md](reference/adapt.native.md) |
|
| `adapt [target]` | Fix | Adapt for different devices and screen sizes | [reference/adapt.md](reference/adapt.md) · native: [reference/adapt.native.md](reference/adapt.native.md) |
|
||||||
| `optimize [target]` | Fix | Diagnose and fix UI performance | [reference/optimize.md](reference/optimize.md) |
|
| `optimize [target]` | Fix | Diagnose and fix UI performance | [reference/optimize.md](reference/optimize.md) |
|
||||||
| `live` | Iterate | Visual variant mode: pick elements in the browser, iterate on alternatives | [reference/live.md](reference/live.md) |
|
| `live` | Iterate | Visual variant mode: pick elements in the browser, generate alternatives | [reference/live.md](reference/live.md) |
|
||||||
| `generate [n] [action] [element]` | Iterate | Variants, versions, or alternatives of a named element to choose from in the live browser; no manual picking | [reference/generate.md](reference/generate.md) |
|
|
||||||
|
|
||||||
Routing:
|
Routing:
|
||||||
|
|
||||||
|
|||||||
@@ -188,12 +188,6 @@ Test thoroughly across contexts:
|
|||||||
- **Edge cases**: Very small screens (320px), very large screens (4K)
|
- **Edge cases**: Very small screens (320px), very large screens (4K)
|
||||||
- **Slow connections**: Test on throttled network
|
- **Slow connections**: Test on throttled network
|
||||||
|
|
||||||
**Custom controls** (sliders, drag surfaces, scrollable control strips): a before/after slider can pass every width check above and still refuse to drag on iOS, so exercise each one in scope in the same batched round as the checks above:
|
|
||||||
|
|
||||||
- **Primary gesture**: Tap it and confirm it responds as designed, then drag it with the target input method; the drag must complete, not just start
|
|
||||||
- **Scroll across it**: A swipe along the page's scroll axis across the control scrolls the page or container without activating it; a drag that starts on the control along its axis moves the control, not the page. Neither failure throws an error, so try both
|
|
||||||
- **Evidence**: Say what produced the evidence: an emulated viewport, synthesized touch input through a browser tool, which engine ran it (Chromium is not Safari), or a physical device. Screenshots and resized viewports verify layout, never a gesture. Name what stayed untested and move on; unreachable hardware is a reported gap, not a blocker
|
|
||||||
|
|
||||||
When the adaptation feels native to each context, hand off to `/impeccable polish` for the final pass.
|
When the adaptation feels native to each context, hand off to `/impeccable polish` for the final pass.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -48,12 +48,11 @@ Run comprehensive checks across 5 dimensions. Score each dimension 0-4 using the
|
|||||||
**Check for**:
|
**Check for**:
|
||||||
- **Fixed widths**: Hard-coded widths that break on mobile
|
- **Fixed widths**: Hard-coded widths that break on mobile
|
||||||
- **Touch targets**: Interactive elements < 44x44px
|
- **Touch targets**: Interactive elements < 44x44px
|
||||||
- **Broken touch interaction**: Custom sliders, drag surfaces, and scrollable control strips whose primary gesture fails under touch, that swallow page scroll or lose the drag to it, or that stay stuck after an interrupted gesture. Code tells: mouse-only handlers, no `touch-action` on a pointer-event drag surface, drag state that nothing clears on cancel, lost capture, or blur. Exercise the gesture when a browser tool can synthesize touch (a rendered viewport proves layout, not the gesture), then say what produced the evidence (emulated viewport, synthesized touch, which engine, physical device) and what stayed untested
|
|
||||||
- **Horizontal scroll**: Content overflow on narrow viewports
|
- **Horizontal scroll**: Content overflow on narrow viewports
|
||||||
- **Text scaling**: Layouts that break when text size increases
|
- **Text scaling**: Layouts that break when text size increases
|
||||||
- **Missing breakpoints**: No mobile/tablet variants
|
- **Missing breakpoints**: No mobile/tablet variants
|
||||||
|
|
||||||
**Score 0-4**: 0=Desktop-only (breaks on mobile), 1=Major issues (some breakpoints, many failures), 2=Partial (works on mobile, rough edges), 3=Good (responsive, minor touch target or overflow issues), 4=Excellent (fluid, all viewports, proper touch targets, gestures work under touch)
|
**Score 0-4**: 0=Desktop-only (breaks on mobile), 1=Major issues (some breakpoints, many failures), 2=Partial (works on mobile, rough edges), 3=Good (responsive, minor touch target or overflow issues), 4=Excellent (fluid, all viewports, proper touch targets)
|
||||||
|
|
||||||
### 5. Implementation Integrity (CRITICAL)
|
### 5. Implementation Integrity (CRITICAL)
|
||||||
|
|
||||||
|
|||||||
@@ -1,101 +0,0 @@
|
|||||||
> **Additional context needed**: only the target element, when the request does not name one that resolves uniquely on the page.
|
|
||||||
|
|
||||||
Generate is the fast lane into live mode: the user names an element, a direction, and a count in one sentence, and within a minute they are cycling through variants in their browser. One command boots the helper, hands the element to the overlay in the page your harness already shows (it scrolls to it, selects it, and fires the same Go a click fires) and returns the generate event; one edit writes the variants; one call replies and waits for the user's choice, which the helper bakes into source itself. This file owns the lane's plumbing; from the event onward the design work is [live.md](live.md)'s, unchanged, so read it in full now if you have not this session.
|
|
||||||
|
|
||||||
**Web only.** Live mode's browser overlay has no native equivalent; on `ios` / `android` / `adaptive` projects, decline this command and offer `bolder` or `quieter` on the source instead.
|
|
||||||
|
|
||||||
The plumbing is where the lane saves time: one command starts the session around the page your harness already shows, one call replies and waits, and nothing here is a browser you have to babysit. The design work is not where it saves time. Setup runs as for any command (`impeccable context`, this reference, craft-floor.md before the edit), and the variants are planned, written, and accepted exactly the way a live session plans, writes, and accepts them.
|
|
||||||
|
|
||||||
Three prohibitions cover the known ways this command goes wrong:
|
|
||||||
|
|
||||||
- **Never run init or document, and never ask for PRODUCT.md or DESIGN.md.** When they exist, the start command prints them under `boot` and you use them. When they do not, it says so (`contextMissing`, `contextNote`) and you extract the identity from the event (Step 3). A missing file is never a reason to interview the user inside this command; offer `init` in one line after the session ends.
|
|
||||||
- **Never hand-write a variants wrapper or invent a session id.** Only the browser mints session ids (8 hex characters, at Go). A missing event is fixed by rerunning Step 2, never with a direct source edit.
|
|
||||||
- **Do not act on hook findings while live markers are in the file**, and do not restyle variants to appease them; the accept verifies the file once the variant is permanent.
|
|
||||||
|
|
||||||
## Step 1: Parse the request
|
|
||||||
|
|
||||||
Three parts, all from the user's sentence:
|
|
||||||
|
|
||||||
- **A number in the request**: that is the count. **No number**: 3. The protocol caps count at 8.
|
|
||||||
- **The direction wording** maps onto the live action vocabulary; never invent a new action value:
|
|
||||||
- **bold, bolder, stronger, punchier**: `bolder`
|
|
||||||
- **quiet, calmer, softer, toned down**: `quieter`
|
|
||||||
- **simpler, minimal, stripped**: `distill`
|
|
||||||
- **refined, tightened, polished**: `polish`
|
|
||||||
- **font and type words**: `typeset`
|
|
||||||
- **color words**: `colorize`
|
|
||||||
- **arrangement and spacing words**: `layout`
|
|
||||||
- **device and breakpoint words**: `adapt`
|
|
||||||
- **motion words**: `animate`
|
|
||||||
- **playful words**: `delight`
|
|
||||||
- **rule-breaking words**: `overdrive`
|
|
||||||
- **Wording that carries intent but no vocabulary word** ("make it feel like a bank", "warmer", "more premium"): `impeccable`, with the user's wording passed as the prompt.
|
|
||||||
- **An action fits AND extra intent rides along** ("bolder, but keep it monochrome"): that action, with the rest as the prompt.
|
|
||||||
- **The wording names no direction at all** ("better", "improve", "nicer", "different", "fresh", "new", "redesign", "fix", "some options", "ideas", "alternatives", or just "variants" with nothing else): Ask the user directly to clarify what you cannot infer. Ask one question, offering the vocabulary: *"Which direction should the variants take? bolder, quieter, simpler (distill), polished, typography (typeset), color (colorize), layout, motion (animate), playful (delight), or rule-breaking (overdrive)."* Map the answer with this list; an answer that is still open ("surprise me", "you pick") is `impeccable` with the user's original wording as the prompt, and Step 2 starts on that answer.
|
|
||||||
- **The element description** ("the pricing cards", "the hero heading"): Step 2 resolves it to a selector.
|
|
||||||
|
|
||||||
Done when you hold an action from the vocabulary (asked for, when the request named no direction), a count from 1 to 8, and the element description.
|
|
||||||
|
|
||||||
## Step 2: Reuse the page, then start
|
|
||||||
|
|
||||||
**Reuse** the dev server already running and the tab your harness already shows it in; a second server or a second browser window is the failure this step prevents.
|
|
||||||
|
|
||||||
1. **Find the dev server**, cheapest source first, and stop at the first hit: the user's message, a browser tab already on the app (Claude Code: an origin in `tabs_context`), a server your harness started (Claude Code: `preview_list`), a terminal that printed its URL. Its origin is your `--dev-url`. **No hit**: leave `--dev-url` off and run the start command with no wait; the boot probes for a running server and its verdict names the move. `browser_needed` carries the `devUrl` it found: open it as in 2, then rerun with `--dev-url <devUrl> --wait-for-browser 60000`. `no_dev_server` means nothing serves the app: start the dev script the way the verdict says (Claude Code: `preview_start`; Cursor: a background terminal; Codex: an exec you yield from), wait for its URL, then rerun with `--dev-url <url>`.
|
|
||||||
2. **Open the page that renders the element in your browser, then start.** The route the request names, else the one `--target` serves; `--dev-url` takes only the origin.
|
|
||||||
- **Cursor** (`browser_navigate`) and **Claude Code** (`navigate`, which opens the Browser pane when it is closed and takes the `tabId` from `tabs_context` when a tab is already on that origin): open the URL, then run the start command with `--dev-url <url> --wait-for-browser 60000`. The boot injects the overlay and the page reloads into it while the command waits. Your browser tool is the only opener on these harnesses; the engine ignores `--open` there.
|
|
||||||
- **No browser tool** (Codex, others): run the start command with `--open --wait-for-browser 120000`; it opens the system browser, and the longer wait covers the user finding the tab. **`browser_open_failed` back**: tell the user the `url` in one line and rerun with `--wait-for-browser 120000`.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
.github/skills/impeccable/scripts/impeccable live-generate --target src/App.jsx --dev-url http://127.0.0.1:5173/ --selector ".pricing-grid" --action bolder --count 3 --boot --wait-for-browser 60000
|
|
||||||
```
|
|
||||||
|
|
||||||
Run it in the foreground in Cursor and Claude Code (it returns within the wait); on Codex, in an exec you yield from, the way Step 3 runs the poll.
|
|
||||||
|
|
||||||
- `--target`: the file that renders the element when the request or the project makes it obvious; skip it otherwise.
|
|
||||||
- `--dev-url`: the origin from 1; omit it and the boot probes.
|
|
||||||
- `--selector`: a unique class first, then a landmark tag plus class, an id last (every variant mounts a copy of the element, so an id repeats in the DOM). **The request names a repeated component in plural** ("the pricing cards"): target the container that holds the set, so one scoped stylesheet restyles every instance. One read of the source file that renders the element is allowed when the selector is not obvious; `--dry-run` resolves and reports without starting anything when it is not certain.
|
|
||||||
- `--boot`: runs the lane's boot (PRODUCT.md and DESIGN.md loaded again for the helper, missing files tolerated, dev URL found, bottom bar hidden for the helper's lifetime) and reuses a helper that is already running. Its result rides along as `boot`.
|
|
||||||
- Also available: `--prompt`, `--text` (keep only matches whose visible text contains a snippet), `--index` (1-based pick among matches).
|
|
||||||
|
|
||||||
Read the output in this order: `boot` (or `boot.contextMissing` with `boot.contextNote`: the page is the source of truth, per the note), then `event`, the generate event for `sessionId`, with the same `_instructions` a user's Go gets. Every verdict carries `_instructions`, and they win over your recollection of this file; the ones whose move is a decision of yours:
|
|
||||||
|
|
||||||
- **`ambiguous`**: the candidates are listed; target their common container, or rerun with `--text "<visible text>"` or `--index <n>`.
|
|
||||||
- **`dev_server_gone`**: the dev server stopped answering while the command waited for the page (on Cursor, a server another chat started dies with that chat). Start it the way the verdict says, then rerun with `--dev-url <url>`.
|
|
||||||
- **`no_match`**: the tab is on a route that does not render the element (navigate to the right route, rerun), or the selector is wrong (derive a better one from the source, or add `--text`).
|
|
||||||
- **`config_missing` / `config_invalid`** under `bootError`: follow [live-setup.md](live-setup.md) first, then rerun.
|
|
||||||
- **`event: null`** with `ok: true`: the event was slower than the wait; run `.github/skills/impeccable/scripts/impeccable live-poll` once to collect it, then continue.
|
|
||||||
|
|
||||||
Done when the output shows `ok: true`, a `sessionId`, and an `event`, reached with at most one server started and one tab opened by you.
|
|
||||||
|
|
||||||
## Step 3: Generate
|
|
||||||
|
|
||||||
The event is a standard `generate` event: the picked element's context, a preflighted scaffold, and `_instructions` naming the action's reference, the planning section, and the exact splice. Handle it exactly per live.md's **Handle generate**, which owns everything from the identity lock to the done reply: read the action's reference and craft-floor.md as it says, plan per section 4 (identity first, then mode, then three different primary axes, then the squint test), declare knobs per section 7, and deliver per section 6 (a complete replacement of the element per variant, the preview CSS plus every variant in one edit at the scaffold's splice). The lane changes nothing about what a variant may be: the moves a live session would make on this element (a promoted tier, a restructured set, a reordered card, a different surface) are open here too. Never screenshot the page; the overlay preview is the review channel until accept.
|
|
||||||
|
|
||||||
**Reply and wait in one call**, with the file you wrote:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
.github/skills/impeccable/scripts/impeccable live-poll --reply EVENT_ID done --file src/App.jsx --then-poll
|
|
||||||
```
|
|
||||||
|
|
||||||
This replies done (the browser mounts the variants) and then blocks until the user's choice arrives, so run it the way your harness runs a long wait: **Claude Code** in the foreground with your tool's longest timeout (600000 ms), so you are paused until the choice arrives; **Codex** in a yielded foreground exec; **Cursor** in a background terminal with notify on `"type":"(accept|discard|variant_mount_failed|exit)"`. Never pass a short `--timeout=`. While it runs there is nothing else to do: never sleep and never poll its output on a timer; a harness that backgrounds it wakes you when it returns. `{"type":"timeout"}` means the user has not chosen yet: run `live-poll` again and keep waiting. If the edit fails after the browser flipped to GENERATING, `--reply EVENT_ID error "Short reason"` (without `--then-poll`) so the bar resets.
|
|
||||||
|
|
||||||
Then tell the user, in one line, where their variants are: *"Three [bolder] variants are live on [the pricing cards]: cycle with the floating bar's arrows, adjust the Tune knobs, and Accept the keeper."*
|
|
||||||
|
|
||||||
Outside the replace path, read the matching live.md section before acting: `scaffold.previewMode: "svelte-component"` (Svelte previews are edited as components, and their accept is mechanical), `mode: "insert"`, `variant_mount_failed`, `steer`, `manual_edit_apply`, and any `fallback: "agent-driven"` wrap error.
|
|
||||||
|
|
||||||
## Step 4: Accept and close
|
|
||||||
|
|
||||||
The call from Step 3 returns the user's choice. **`discard`**: nothing to do. **`accept`**: `_acceptResult.carbonize: true` is the normal case, and the cleanup is live.md's **Required after accept**, unchanged: move the accepted variant's rules into the stylesheet that already owns the element with real selectors, bake the chosen knob values in, unwrap the element and drop every `data-impeccable-*` attribute, delete the inline `<style>` block and both `impeccable-carbonize` markers, then `.github/skills/impeccable/scripts/impeccable live-complete --id SESSION_ID` and confirm `phase: "completed"`. (`baked: true` appears only when the accept was run with `--bake`; then the helper already made the variant permanent and no `live-complete` is owed.)
|
|
||||||
|
|
||||||
Close without being asked, the moment the choice is handled:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
.github/skills/impeccable/scripts/impeccable live-server stop
|
|
||||||
```
|
|
||||||
|
|
||||||
Stopping removes the injected script and reloads the page once: the user sees the accepted design with no overlay chrome, still served by their dev server. **Never kill or restart the dev server**, including one you started in Step 2.
|
|
||||||
|
|
||||||
- **The user asks for more variants before you closed**: skip the close, run Step 2 again for the next element (the helper is reused), and close after the last choice.
|
|
||||||
- **Interrupted or unsure of the state**: `.github/skills/impeccable/scripts/impeccable live-status`, then `live-resume`; the journal under `.impeccable/live/sessions/` is canonical.
|
|
||||||
|
|
||||||
Done when the helper is stopped and the dev site still answers with the accepted design.
|
|
||||||
@@ -205,11 +205,6 @@ t('items', { count }) // Handles complex plural rules
|
|||||||
- Optimistic updates with rollback
|
- Optimistic updates with rollback
|
||||||
- Conflict resolution
|
- Conflict resolution
|
||||||
|
|
||||||
**Interrupted gestures** (custom sliders, drag surfaces, scrollable control strips):
|
|
||||||
- A second finger or pointer lands mid-drag: the first drag keeps its pointer or ends cleanly, never jumps to the new one
|
|
||||||
- The browser cancels the gesture to scroll (`pointercancel`), capture is lost (`lostpointercapture`), the pointer is released outside the control, or the window loses focus (`blur`) mid-drag: clear the dragging state and release capture
|
|
||||||
- After each of these, the next tap or drag works without a reload
|
|
||||||
|
|
||||||
**Permission states**:
|
**Permission states**:
|
||||||
- No permission to view
|
- No permission to view
|
||||||
- No permission to edit
|
- No permission to edit
|
||||||
@@ -309,7 +304,6 @@ const throttledScroll = throttle(handleScroll, 100);
|
|||||||
- Unit tests for edge cases
|
- Unit tests for edge cases
|
||||||
- Integration tests for error scenarios
|
- Integration tests for error scenarios
|
||||||
- E2E tests for critical paths
|
- E2E tests for critical paths
|
||||||
- A behavioral regression for each confirmed gesture fix, when the project's test runner can drive input
|
|
||||||
- Visual regression tests
|
- Visual regression tests
|
||||||
- Accessibility tests (axe, WAVE)
|
- Accessibility tests (axe, WAVE)
|
||||||
|
|
||||||
@@ -336,10 +330,7 @@ Test thoroughly with edge cases:
|
|||||||
- **Network issues**: Disable internet, throttle connection
|
- **Network issues**: Disable internet, throttle connection
|
||||||
- **Large datasets**: Test with 1000+ items
|
- **Large datasets**: Test with 1000+ items
|
||||||
- **Concurrent actions**: Click submit 10 times rapidly
|
- **Concurrent actions**: Click submit 10 times rapidly
|
||||||
- **Interrupted gestures**: Add a second finger mid-drag, scroll across the control, release outside it, switch windows mid-drag; then drag again
|
|
||||||
- **Errors**: Force API errors, test all error states
|
- **Errors**: Force API errors, test all error states
|
||||||
- **Empty**: Remove all data, test empty states
|
- **Empty**: Remove all data, test empty states
|
||||||
|
|
||||||
For gestures, say what produced the evidence (emulated viewport, synthesized touch, which engine, physical device) and name what stayed untested.
|
|
||||||
|
|
||||||
When edge cases are covered, hand off to `/impeccable polish` for the final pass.
|
When edge cases are covered, hand off to `/impeccable polish` for the final pass.
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ Reason over the signals; there is no score to obey:
|
|||||||
- `critique.latest` is `null` → the project has never been critiqued; for a set-up project with a real surface, offering `/impeccable critique <surface>` is a strong default.
|
- `critique.latest` is `null` → the project has never been critiqued; for a set-up project with a real surface, offering `/impeccable critique <surface>` is a strong default.
|
||||||
- `critique.latest` with a low `score` or non-zero `p0` / `p1` → `polish` (it reads that snapshot as its backlog and closes it when stale or cleared).
|
- `critique.latest` with a low `score` or non-zero `p0` / `p1` → `polish` (it reads that snapshot as its backlog and closes it when stale or cleared).
|
||||||
- `git.changedFiles` pointing at one surface → scope `audit` or `polish` to those files specifically, naming them.
|
- `git.changedFiles` pointing at one surface → scope `audit` or `polish` to those files specifically, naming them.
|
||||||
- `devServer.running` true → `live` is available for in-browser iteration, and `generate` for one-shot variant runs on a named element; if false, don't lead with either. **`live`, `generate`, and the bundled `impeccable detect` are web-only.** If `setup.platform` is `ios`, `android`, or `adaptive`, don't lead with any of them; the browser overlay and the HTML rule engine don't apply to native app code.
|
- `devServer.running` true → `live` is available for in-browser iteration; if false, don't lead with `live`. **`live` and the bundled `impeccable detect` are web-only.** If `setup.platform` is `ios`, `android`, or `adaptive`, don't lead with either; the browser overlay and the HTML rule engine don't apply to native app code.
|
||||||
- Otherwise group by intent (build new / improve what's there / iterate visually), tailored to the current surface and `setup.platform`.
|
- Otherwise group by intent (build new / improve what's there / iterate visually), tailored to the current surface and `setup.platform`.
|
||||||
|
|
||||||
**If `scan.targets` is non-empty and `setup.platform` is not `ios`/`android`/`adaptive`, run `.github/skills/impeccable/scripts/impeccable detect --json <scan.targets joined by spaces>` once** (the bundled detector over local files: no network, no npx; it reads HTML/CSS, so skip it for native projects). `scan.via` tells you what they are: `git-changes` (the markup/style files in your dirty tree, the most relevant set), `source-dir` (e.g. `src`, `app`), `html`, or `root`. Fold the hits into your picks: many quality / contrast hits → `audit` or `polish`; a specific slop family → the matching command (gradient text or eyebrows → `quieter` / `typeset`, flat or gray palette → `colorize`, and so on). It's a real, current signal that beats guessing. If detect errors or the tree is large and slow, skip it and recommend the user run `audit` themselves; never block the suggestion on it.
|
**If `scan.targets` is non-empty and `setup.platform` is not `ios`/`android`/`adaptive`, run `.github/skills/impeccable/scripts/impeccable detect --json <scan.targets joined by spaces>` once** (the bundled detector over local files: no network, no npx; it reads HTML/CSS, so skip it for native projects). `scan.via` tells you what they are: `git-changes` (the markup/style files in your dirty tree, the most relevant set), `source-dir` (e.g. `src`, `app`), `html`, or `root`. Fold the hits into your picks: many quality / contrast hits → `audit` or `polish`; a specific slop family → the matching command (gradient text or eyebrows → `quieter` / `typeset`, flat or gray palette → `colorize`, and so on). It's a real, current signal that beats guessing. If detect errors or the tree is large and slow, skip it and recommend the user run `audit` themselves; never block the suggestion on it.
|
||||||
|
|||||||
@@ -19,10 +19,6 @@
|
|||||||
"description": "Interactive live variant mode. Select elements in the browser, pick a design action, and get AI-generated HTML+CSS variants hot-swapped via HMR. Requires a running dev server. Use when you want to visually experiment with design alternatives in real time.",
|
"description": "Interactive live variant mode. Select elements in the browser, pick a design action, and get AI-generated HTML+CSS variants hot-swapped via HMR. Requires a running dev server. Use when you want to visually experiment with design alternatives in real time.",
|
||||||
"argumentHint": ""
|
"argumentHint": ""
|
||||||
},
|
},
|
||||||
"generate": {
|
|
||||||
"description": "Agent-driven live variant generation. Boots live mode, finds the named element on the open page, scrolls the browser to it, and delivers N variants in the requested direction for the user to cycle and accept. Use for requests that name an element and a direction, like 'generate 3 bold variants of the pricing cards', skipping manual element picking.",
|
|
||||||
"argumentHint": "[count] [direction] variants of [element]"
|
|
||||||
},
|
|
||||||
"adapt": {
|
"adapt": {
|
||||||
"description": "Adapt designs to work across different screen sizes, devices, contexts, or platforms. Implements breakpoints, fluid layouts, and touch targets. Use when the user mentions responsive design, mobile layouts, breakpoints, viewport adaptation, or cross-device compatibility.",
|
"description": "Adapt designs to work across different screen sizes, devices, contexts, or platforms. Implements breakpoints, fluid layouts, and touch targets. Use when the user mentions responsive design, mobile layouts, breakpoints, viewport adaptation, or cross-device compatibility.",
|
||||||
"argumentHint": "[target] [context (mobile, tablet, print...)]"
|
"argumentHint": "[target] [context (mobile, tablet, print...)]"
|
||||||
|
|||||||
@@ -87,11 +87,6 @@ fi
|
|||||||
# Last resort: fetch this version's binary for the current platform from the
|
# Last resort: fetch this version's binary for the current platform from the
|
||||||
# public release channel into the user cache. Needs network; sandboxes without
|
# public release channel into the user cache. Needs network; sandboxes without
|
||||||
# egress preinstall the binary on PATH instead.
|
# egress preinstall the binary on PATH instead.
|
||||||
setup_help() {
|
|
||||||
echo "Engine $version setup needs network access and write permission to $cache_root/bin/$version." >&2
|
|
||||||
echo "Run this launcher ($0) with engine-probe in a terminal that has those permissions, then retry the original command." >&2
|
|
||||||
echo "Alternatively, set IMPECCABLE_HOME to a writable cache location, or IMPECCABLE_BIN to a preinstalled engine binary." >&2
|
|
||||||
}
|
|
||||||
fetch_url() {
|
fetch_url() {
|
||||||
if command -v curl >/dev/null 2>&1; then
|
if command -v curl >/dev/null 2>&1; then
|
||||||
curl -fsSL --retry 2 -o "$tmp" "$1" 2>/dev/null
|
curl -fsSL --retry 2 -o "$tmp" "$1" 2>/dev/null
|
||||||
@@ -124,18 +119,7 @@ if [ -n "$version" ] && [ "$os" != unknown ] && [ "$arch" != unknown ]; then
|
|||||||
[ "$os" = windows ] && asset="$asset.exe"
|
[ "$os" = windows ] && asset="$asset.exe"
|
||||||
url="$base/engine-v$version/$asset"
|
url="$base/engine-v$version/$asset"
|
||||||
tmp="$cache_root/bin/$version/.impeccable.part.$$"
|
tmp="$cache_root/bin/$version/.impeccable.part.$$"
|
||||||
if ! mkdir -p "$cache_root/bin/$version" 2>/dev/null; then
|
mkdir -p "$cache_root/bin/$version" 2>/dev/null
|
||||||
echo "impeccable: engine $version is not installed; cannot create cache directory: $cache_root/bin/$version" >&2
|
|
||||||
setup_help
|
|
||||||
exit 127
|
|
||||||
fi
|
|
||||||
# Check the actual staging file, not just directory existence: a cache from
|
|
||||||
# an earlier run can be readable but no longer writable inside a sandbox.
|
|
||||||
if ! (umask 077; : > "$tmp") 2>/dev/null; then
|
|
||||||
echo "impeccable: engine $version is not installed; cannot write to cache directory: $cache_root/bin/$version" >&2
|
|
||||||
setup_help
|
|
||||||
exit 127
|
|
||||||
fi
|
|
||||||
fetched=0
|
fetched=0
|
||||||
if fetch_url "$url"; then
|
if fetch_url "$url"; then
|
||||||
fetched=1
|
fetched=1
|
||||||
@@ -196,9 +180,6 @@ if [ -n "$version" ] && [ "$os" != unknown ] && [ "$arch" != unknown ]; then
|
|||||||
exec "$cached" "$@"
|
exec "$cached" "$@"
|
||||||
fi
|
fi
|
||||||
rm -f "$tmp" 2>/dev/null
|
rm -f "$tmp" 2>/dev/null
|
||||||
echo "impeccable: could not download engine $version from $url; check network access, the release URL, and curl or wget availability." >&2
|
|
||||||
setup_help
|
|
||||||
exit 127
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "impeccable: no engine binary for $os-$arch found (looked in $bin, $cached, PATH)." >&2
|
echo "impeccable: no engine binary for $os-$arch found (looked in $bin, $cached, PATH)." >&2
|
||||||
|
|||||||
@@ -68,27 +68,18 @@ rem another launcher's probe: fail fast and quiet instead.
|
|||||||
if defined IMPECCABLE_LAUNCHER_PROBE exit /b 127
|
if defined IMPECCABLE_LAUNCHER_PROBE exit /b 127
|
||||||
if not defined version goto fail
|
if not defined version goto fail
|
||||||
where curl.exe >nul 2>nul
|
where curl.exe >nul 2>nul
|
||||||
if errorlevel 1 goto curl_missing
|
if errorlevel 1 goto fail
|
||||||
if not defined IMPECCABLE_DOWNLOAD_BASE set "IMPECCABLE_DOWNLOAD_BASE=https://github.com/pbakaus/impeccable/releases/download"
|
if not defined IMPECCABLE_DOWNLOAD_BASE set "IMPECCABLE_DOWNLOAD_BASE=https://github.com/pbakaus/impeccable/releases/download"
|
||||||
if exist "%IMPECCABLE_HOME%\bin\%version%\" goto cache_ready
|
if not exist "%IMPECCABLE_HOME%\bin\%version%" mkdir "%IMPECCABLE_HOME%\bin\%version%" >nul 2>nul
|
||||||
mkdir "%IMPECCABLE_HOME%\bin\%version%" >nul 2>nul
|
|
||||||
if errorlevel 1 goto cache_directory_failed
|
|
||||||
:cache_ready
|
|
||||||
rem Check the staging file too: an existing directory may be read-only.
|
|
||||||
rem Redirection failures do not reliably update ERRORLEVEL in cmd.exe;
|
|
||||||
rem branch on the command's failure directly. Never treat a directory as a
|
|
||||||
rem staging file (later del cleanup would prompt to delete its contents).
|
|
||||||
if exist "%cached%.part\" goto cache_write_failed
|
|
||||||
(type nul >"%cached%.part") 2>nul || goto cache_write_failed
|
|
||||||
set "asset=impeccable-windows-%arch%.exe"
|
set "asset=impeccable-windows-%arch%.exe"
|
||||||
set "url=%IMPECCABLE_DOWNLOAD_BASE%/engine-v%version%/%asset%"
|
set "url=%IMPECCABLE_DOWNLOAD_BASE%/engine-v%version%/%asset%"
|
||||||
curl.exe -fsSL -o "%cached%.part" "%url%" >nul 2>nul
|
curl.exe -fsSL -o "%cached%.part" "%url%" >nul 2>nul
|
||||||
if not errorlevel 1 goto verify
|
if not errorlevel 1 goto verify
|
||||||
if not "%arch%"=="arm64" goto download_failed
|
if not "%arch%"=="arm64" goto fail
|
||||||
set "asset=impeccable-windows-x64.exe"
|
set "asset=impeccable-windows-x64.exe"
|
||||||
set "url=%IMPECCABLE_DOWNLOAD_BASE%/engine-v%version%/%asset%"
|
set "url=%IMPECCABLE_DOWNLOAD_BASE%/engine-v%version%/%asset%"
|
||||||
curl.exe -fsSL -o "%cached%.part" "%url%" >nul 2>nul
|
curl.exe -fsSL -o "%cached%.part" "%url%" >nul 2>nul
|
||||||
if errorlevel 1 goto download_failed
|
if errorlevel 1 goto fail
|
||||||
|
|
||||||
:verify
|
:verify
|
||||||
call :check_download
|
call :check_download
|
||||||
@@ -185,28 +176,6 @@ if not errorlevel 1 set "probe_ok=1"
|
|||||||
del "%probe_tmp%" >nul 2>nul
|
del "%probe_tmp%" >nul 2>nul
|
||||||
exit /b 0
|
exit /b 0
|
||||||
|
|
||||||
:cache_directory_failed
|
|
||||||
echo impeccable: engine %version% is not installed; cannot create cache directory: "%IMPECCABLE_HOME%\bin\%version%" 1>&2
|
|
||||||
goto setup_failed
|
|
||||||
|
|
||||||
:cache_write_failed
|
|
||||||
echo impeccable: engine %version% is not installed; cannot write to cache directory: "%IMPECCABLE_HOME%\bin\%version%" 1>&2
|
|
||||||
goto setup_failed
|
|
||||||
|
|
||||||
:curl_missing
|
|
||||||
echo impeccable: cannot download engine %version%; curl.exe is unavailable. 1>&2
|
|
||||||
goto setup_failed
|
|
||||||
|
|
||||||
:download_failed
|
|
||||||
del "%cached%.part" >nul 2>nul
|
|
||||||
echo impeccable: could not download engine %version% from %url%; check network access and the release URL. 1>&2
|
|
||||||
|
|
||||||
:setup_failed
|
|
||||||
echo Engine %version% setup needs network access and write permission to "%IMPECCABLE_HOME%\bin\%version%". 1>&2
|
|
||||||
echo Run this launcher ("%~f0") with engine-probe in a terminal that has those permissions, then retry the original command. 1>&2
|
|
||||||
echo Alternatively, set IMPECCABLE_HOME to a writable cache location, or IMPECCABLE_BIN to a preinstalled engine binary. 1>&2
|
|
||||||
exit /b 127
|
|
||||||
|
|
||||||
:fail
|
:fail
|
||||||
del "%cached%.part" >nul 2>nul
|
del "%cached%.part" >nul 2>nul
|
||||||
echo impeccable: no engine binary found (looked in %bin%, %cached%, PATH). 1>&2
|
echo impeccable: no engine binary found (looked in %bin%, %cached%, PATH). 1>&2
|
||||||
|
|||||||
@@ -165,14 +165,6 @@
|
|||||||
}
|
}
|
||||||
let parameterGenerationState = 'idle';
|
let parameterGenerationState = 'idle';
|
||||||
let parameterReadyAnnouncedSession = null;
|
let parameterReadyAnnouncedSession = null;
|
||||||
// 'agent' when the generate verb fired this session's Go (the generate
|
|
||||||
// lane declares no knobs, so its bar never shows a pending Tune chip);
|
|
||||||
// null for every Go a user presses.
|
|
||||||
let sessionOrigin = null;
|
|
||||||
// The generate lane picks for the agent and never edits copy in the
|
|
||||||
// browser, so its selection carries no edit-copy badge (set on the
|
|
||||||
// agent-target pick, cleared with the session; a user's pick never sets it).
|
|
||||||
let editBadgeSuppressed = false;
|
|
||||||
let svelteComponentSession = null;
|
let svelteComponentSession = null;
|
||||||
let svelteRuntimePromise = null;
|
let svelteRuntimePromise = null;
|
||||||
let pendingSvelteComponentRetryObserver = null;
|
let pendingSvelteComponentRetryObserver = null;
|
||||||
@@ -991,20 +983,9 @@
|
|||||||
}
|
}
|
||||||
} catch { /* cross-origin */ }
|
} catch { /* cross-origin */ }
|
||||||
}
|
}
|
||||||
// The selector a mechanical bake would anchor lasting rules on, and how
|
|
||||||
// many elements it matches right now: the bake refuses anything but one,
|
|
||||||
// since its rules would restyle every match, not just this element.
|
|
||||||
const cssIdent = (s) => /^[A-Za-z_-][\w-]*$/.test(s);
|
|
||||||
const anchorClasses = [...el.classList].filter(cssIdent);
|
|
||||||
const anchor = el.id && cssIdent(el.id)
|
|
||||||
? '#' + el.id
|
|
||||||
: (anchorClasses.length ? el.tagName.toLowerCase() + '.' + anchorClasses.join('.') : null);
|
|
||||||
let anchorMatches = null;
|
|
||||||
if (anchor) { try { anchorMatches = document.querySelectorAll(anchor).length; } catch { anchorMatches = null; } }
|
|
||||||
return {
|
return {
|
||||||
tagName: el.tagName.toLowerCase(), id: el.id || null,
|
tagName: el.tagName.toLowerCase(), id: el.id || null,
|
||||||
classes: [...el.classList],
|
classes: [...el.classList],
|
||||||
anchor, anchorMatches,
|
|
||||||
textContent: (el.textContent || '').slice(0, 500),
|
textContent: (el.textContent || '').slice(0, 500),
|
||||||
outerHTML: sanitizedContextOuterHTML(el, 10000),
|
outerHTML: sanitizedContextOuterHTML(el, 10000),
|
||||||
computedStyles: {
|
computedStyles: {
|
||||||
@@ -2056,7 +2037,6 @@
|
|||||||
function setLiveState(next) {
|
function setLiveState(next) {
|
||||||
state = next;
|
state = next;
|
||||||
window.__IMPECCABLE_LIVE_STATE__ = next;
|
window.__IMPECCABLE_LIVE_STATE__ = next;
|
||||||
retryDeclinedAgentTargets();
|
|
||||||
syncPageInteractionCursor();
|
syncPageInteractionCursor();
|
||||||
// Whether a queued steer is still behind a generation is a function of this
|
// Whether a queued steer is still behind a generation is a function of this
|
||||||
// state, so the hint has to move with it, not only with the 5s poll.
|
// state, so the hint has to move with it, not only with the 5s poll.
|
||||||
@@ -4034,7 +4014,6 @@
|
|||||||
|
|
||||||
function hidePendingApplyDock() {
|
function hidePendingApplyDock() {
|
||||||
pendingApplyInFlight = false;
|
pendingApplyInFlight = false;
|
||||||
retryDeclinedAgentTargets();
|
|
||||||
clearStoredManualApplyState();
|
clearStoredManualApplyState();
|
||||||
if (pendingIntroAnimation) { pendingIntroAnimation.cancel(); pendingIntroAnimation = null; }
|
if (pendingIntroAnimation) { pendingIntroAnimation.cancel(); pendingIntroAnimation = null; }
|
||||||
if (pendingDockEl) pendingDockEl.style.display = 'none';
|
if (pendingDockEl) pendingDockEl.style.display = 'none';
|
||||||
@@ -4068,7 +4047,6 @@
|
|||||||
function setPendingApplyLoading(loading, count) {
|
function setPendingApplyLoading(loading, count) {
|
||||||
if (!pendingPillEl || !pendingPillLabelEl || !pendingPillCountEl || !pendingTrashBtn) return;
|
if (!pendingPillEl || !pendingPillLabelEl || !pendingPillCountEl || !pendingTrashBtn) return;
|
||||||
pendingApplyInFlight = loading === true;
|
pendingApplyInFlight = loading === true;
|
||||||
if (!pendingApplyInFlight) retryDeclinedAgentTargets();
|
|
||||||
const currentCount = count || parseInt(pendingPillEl.dataset.count || '0', 10) || 0;
|
const currentCount = count || parseInt(pendingPillEl.dataset.count || '0', 10) || 0;
|
||||||
if (pendingApplyInFlight) storeManualApplyState(currentCount);
|
if (pendingApplyInFlight) storeManualApplyState(currentCount);
|
||||||
else clearStoredManualApplyState();
|
else clearStoredManualApplyState();
|
||||||
@@ -4710,7 +4688,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderEditBadge(mode) {
|
function renderEditBadge(mode) {
|
||||||
if (editBadgeSuppressed || sessionOrigin === 'agent') mode = 'hidden';
|
|
||||||
if (mode === 'hidden' || !editBadgeEl) {
|
if (mode === 'hidden' || !editBadgeEl) {
|
||||||
hideConfigureBarTooltip();
|
hideConfigureBarTooltip();
|
||||||
if (editBadgeEl) editBadgeEl.style.display = 'none';
|
if (editBadgeEl) editBadgeEl.style.display = 'none';
|
||||||
@@ -6204,8 +6181,6 @@
|
|||||||
resetSessionFileMeta();
|
resetSessionFileMeta();
|
||||||
currentSessionId = null;
|
currentSessionId = null;
|
||||||
parameterGenerationState = 'idle';
|
parameterGenerationState = 'idle';
|
||||||
sessionOrigin = null;
|
|
||||||
editBadgeSuppressed = false;
|
|
||||||
parameterReadyAnnouncedSession = null;
|
parameterReadyAnnouncedSession = null;
|
||||||
expectedVariants = 0;
|
expectedVariants = 0;
|
||||||
arrivedVariants = 0;
|
arrivedVariants = 0;
|
||||||
@@ -7137,398 +7112,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
//
|
//
|
||||||
// ------------------------------------------------------------------
|
|
||||||
// Agent-initiated targeting (the `generate` command). The agent names an
|
|
||||||
// element by CSS selector over POST /agent-target; the server pushes an
|
|
||||||
// `agent_target` SSE message here. The overlay resolves the selector,
|
|
||||||
// scrolls the element into view, enters the same picked state a user
|
|
||||||
// click produces, and fires the normal Go pipeline, so everything
|
|
||||||
// downstream (generate event, variants, cycling, accept) is unchanged.
|
|
||||||
// The verdict goes back through POST /agent-target-result, which resolves
|
|
||||||
// the agent's held-open CLI call.
|
|
||||||
|
|
||||||
function postAgentTargetResult(targetId, result) {
|
|
||||||
fetch('http://localhost:' + PORT + '/agent-target-result?token=' + TOKEN, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ token: TOKEN, targetId, clientId: AGENT_TARGET_CLIENT_ID, ...result }),
|
|
||||||
}).catch(() => { /* server gone; nothing to report to */ });
|
|
||||||
}
|
|
||||||
|
|
||||||
function describeAgentTargetCandidate(el) {
|
|
||||||
return {
|
|
||||||
tag: el.tagName.toLowerCase(),
|
|
||||||
id: el.id || null,
|
|
||||||
classes: [...el.classList].filter((c) => !c.startsWith('impeccable-')),
|
|
||||||
text: (el.textContent || '').trim().slice(0, 80),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveAgentTargetElement(msg) {
|
|
||||||
let matched;
|
|
||||||
try {
|
|
||||||
matched = [...document.querySelectorAll(msg.selector)];
|
|
||||||
} catch {
|
|
||||||
return { error: { ok: false, error: 'invalid_selector', selector: msg.selector } };
|
|
||||||
}
|
|
||||||
let candidates = matched.filter((el) => pickable(el));
|
|
||||||
if (msg.text) {
|
|
||||||
const needle = String(msg.text).toLowerCase();
|
|
||||||
candidates = candidates.filter((el) => (el.textContent || '').toLowerCase().includes(needle));
|
|
||||||
}
|
|
||||||
if (candidates.length === 0) {
|
|
||||||
return {
|
|
||||||
error: {
|
|
||||||
ok: false,
|
|
||||||
error: 'no_match',
|
|
||||||
selector: msg.selector,
|
|
||||||
matchCount: 0,
|
|
||||||
// How many nodes the raw selector hit before the pickable/text
|
|
||||||
// filters: distinguishes a wrong selector from an unpickable match.
|
|
||||||
rawMatchCount: matched.length,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (Number.isInteger(msg.index)) {
|
|
||||||
const el = candidates[msg.index - 1];
|
|
||||||
if (!el) {
|
|
||||||
return { error: { ok: false, error: 'index_out_of_range', selector: msg.selector, matchCount: candidates.length } };
|
|
||||||
}
|
|
||||||
return { el, matchCount: candidates.length };
|
|
||||||
}
|
|
||||||
if (candidates.length > 1) {
|
|
||||||
return {
|
|
||||||
error: {
|
|
||||||
ok: false,
|
|
||||||
error: 'ambiguous',
|
|
||||||
selector: msg.selector,
|
|
||||||
matchCount: candidates.length,
|
|
||||||
candidates: candidates.slice(0, 8).map(describeAgentTargetCandidate),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return { el: candidates[0], matchCount: 1 };
|
|
||||||
}
|
|
||||||
|
|
||||||
function scrollAgentTargetIntoView(el, done) {
|
|
||||||
const rect = el.getBoundingClientRect();
|
|
||||||
if (rect.top >= 0 && rect.bottom <= window.innerHeight) { done(); return; }
|
|
||||||
let settled = false;
|
|
||||||
let fallback = null;
|
|
||||||
const finish = () => {
|
|
||||||
if (settled) return;
|
|
||||||
settled = true;
|
|
||||||
removeEventListener('scrollend', finish, true);
|
|
||||||
if (fallback) clearTimeout(fallback);
|
|
||||||
done();
|
|
||||||
};
|
|
||||||
// scrollend where supported; a timer covers engines without it and the
|
|
||||||
// no-movement case (element already at its final resting position).
|
|
||||||
addEventListener('scrollend', finish, true);
|
|
||||||
fallback = setTimeout(finish, 1200);
|
|
||||||
el.scrollIntoView({ block: 'center', behavior: 'smooth' });
|
|
||||||
}
|
|
||||||
|
|
||||||
// One id per page load: the server keys claims and roll-call reports on
|
|
||||||
// it, and only the tab that holds the lease can renew it.
|
|
||||||
const AGENT_TARGET_CLIENT_ID = id8();
|
|
||||||
|
|
||||||
// The agent target an agent-initiated Go is serving: set by
|
|
||||||
// actOnAgentTarget around its handleGo call, read once by handleGo.
|
|
||||||
let agentTargetForGo = null;
|
|
||||||
|
|
||||||
// The helper's word on its global bar. The generate lane asks the helper
|
|
||||||
// to keep it out of the way (`impeccable live --no-live-bar`, or an agent
|
|
||||||
// target carrying hideLiveBar), and the helper tells every connected tab
|
|
||||||
// at once (`live_bar`) and every later connection on `connected`, so the
|
|
||||||
// bar stays hidden in every tab, through reloads, the accept, and the
|
|
||||||
// bake, until the helper stops and takes the overlay with it. The variant
|
|
||||||
// controls still show.
|
|
||||||
let liveBarHiddenByHelper = false;
|
|
||||||
|
|
||||||
function applyLiveBarPreference(hidden) {
|
|
||||||
liveBarHiddenByHelper = hidden === true;
|
|
||||||
setLiveBarHidden(liveBarHiddenByHelper);
|
|
||||||
}
|
|
||||||
|
|
||||||
// A plain live session must never notice this code: hiding remembers the
|
|
||||||
// bar's own display value and restoring puts exactly that back, and a
|
|
||||||
// restore on a bar that is not hidden is a no-op, so the `connected`
|
|
||||||
// frame every session receives changes nothing unless the lane asked.
|
|
||||||
function setLiveBarHidden(hidden) {
|
|
||||||
if (!globalBarEl) return;
|
|
||||||
if (hidden) {
|
|
||||||
if (globalBarEl.style.display !== 'none') {
|
|
||||||
globalBarEl.dataset.liveBarDisplay = globalBarEl.style.display || 'flex';
|
|
||||||
globalBarEl.style.display = 'none';
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (globalBarEl.style.display === 'none') {
|
|
||||||
globalBarEl.style.display = globalBarEl.dataset.liveBarDisplay || 'flex';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function claimAgentTarget(targetId, report) {
|
|
||||||
return fetch('http://localhost:' + PORT + '/agent-target-claim?token=' + TOKEN, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ token: TOKEN, targetId, clientId: AGENT_TARGET_CLIENT_ID, ...report }),
|
|
||||||
}).then((res) => res.json())
|
|
||||||
.then((j) => ({ granted: !!j && j.granted === true, pending: !!j && j.pending === true }))
|
|
||||||
.catch(() => ({ granted: false, pending: false }));
|
|
||||||
}
|
|
||||||
|
|
||||||
// `exceptTargetId` is the target this call is about: a tab acting on it
|
|
||||||
// is not busy for itself, but it is busy for every other target, or two
|
|
||||||
// held requests could both be claimed here and the second Go would
|
|
||||||
// overwrite the session the first one minted.
|
|
||||||
function agentTargetBusyReason(exceptTargetId) {
|
|
||||||
if (pendingApplyInFlight) return 'manual_apply_in_flight';
|
|
||||||
if (state !== 'IDLE' && state !== 'PICKING' && state !== 'CONFIGURING') return 'session_active';
|
|
||||||
for (const [targetId, status] of agentTargetsSeen) {
|
|
||||||
if (status === 'acting' && targetId !== exceptTargetId) return 'agent_target_in_flight';
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Targets this tab declined as busy. A busy report is only this tab's word
|
|
||||||
// at that moment: the moment it is free again (setLiveState), it claims
|
|
||||||
// each of these as eligible, and the server drops the stale report, so a
|
|
||||||
// busy verdict is never built on a tab that has since gone idle. The
|
|
||||||
// server denies claims for resolved targets, so retries are harmless.
|
|
||||||
const busyDeclinedTargets = new Map();
|
|
||||||
|
|
||||||
function declineAgentTargetBusy(msg, busy) {
|
|
||||||
busyDeclinedTargets.set(msg.targetId, msg);
|
|
||||||
noteAgentTarget(msg.targetId, 'declined');
|
|
||||||
claimAgentTarget(msg.targetId, { eligible: false, state, reason: busy });
|
|
||||||
}
|
|
||||||
|
|
||||||
// A torn-down overlay, or one whose helper connection is gone, cannot
|
|
||||||
// serve a target and must not even claim one: it would hold the lease for
|
|
||||||
// a request it will never act on.
|
|
||||||
function agentTargetOverlayGone() {
|
|
||||||
return !evtSource;
|
|
||||||
}
|
|
||||||
|
|
||||||
// A denied claimant retries at this cadence, a little over the lease, so
|
|
||||||
// the first retry after a dead holder's lease lapses is granted.
|
|
||||||
const AGENT_TARGET_RESCUE_RETRY_MS = 3500;
|
|
||||||
|
|
||||||
// Claim the lease and act as the holder. A denied claim means another tab
|
|
||||||
// holds the lease. That holder can die before posting its result (reload,
|
|
||||||
// crash, even after renewing), and its lease lapses after ~3s, so this tab
|
|
||||||
// keeps retrying for as long as the server still holds the request: the
|
|
||||||
// answer's `pending` is the server's word that the request is alive, and
|
|
||||||
// it turns false the moment the request resolved or timed out, so no tab
|
|
||||||
// retries a request nobody awaits. A tab that turned busy meanwhile joins
|
|
||||||
// the roll call instead of taking a lease it cannot use. The first claim
|
|
||||||
// and the busy-to-idle re-claim share this.
|
|
||||||
function claimAndActOnAgentTarget(msg) {
|
|
||||||
if (agentTargetOverlayGone()) return;
|
|
||||||
const busy = agentTargetBusyReason(msg.targetId);
|
|
||||||
if (busy) { declineAgentTargetBusy(msg, busy); return; }
|
|
||||||
if (declineAgentTargetUnresolvable(msg)) return;
|
|
||||||
claimAgentTarget(msg.targetId, { eligible: true }).then((claim) => {
|
|
||||||
if (claim.granted) { noteAgentTarget(msg.targetId, 'acting'); actOnAgentTarget(msg); return; }
|
|
||||||
noteAgentTarget(msg.targetId, 'denied');
|
|
||||||
if (!claim.pending) return;
|
|
||||||
setTimeout(() => claimAndActOnAgentTarget(msg), AGENT_TARGET_RESCUE_RETRY_MS);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function retryDeclinedAgentTargets() {
|
|
||||||
if (busyDeclinedTargets.size === 0 || agentTargetBusyReason()) return;
|
|
||||||
for (const [targetId, msg] of busyDeclinedTargets) {
|
|
||||||
busyDeclinedTargets.delete(targetId);
|
|
||||||
claimAndActOnAgentTarget(msg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// This page's participation in each target it heard: 'acting' once a
|
|
||||||
// claim was granted, 'done' once it replied (or stood down from a lapsed
|
|
||||||
// lease), else the word it last gave. The server replays pending targets
|
|
||||||
// to every connection that opens. After a reconnect that overlapped the
|
|
||||||
// old connection the server still holds this page's word; after one that
|
|
||||||
// did not, it dropped the word on the close, so a replayed target is
|
|
||||||
// handled again: a busy or unresolvable page re-declines (idempotent), an
|
|
||||||
// idle page claims.
|
|
||||||
const agentTargetsSeen = new Map();
|
|
||||||
function noteAgentTarget(targetId, status) {
|
|
||||||
agentTargetsSeen.set(targetId, status);
|
|
||||||
if (agentTargetsSeen.size > 100) agentTargetsSeen.delete(agentTargetsSeen.keys().next().value);
|
|
||||||
}
|
|
||||||
|
|
||||||
// A target this page took a lease on is off-limits for a replay: while
|
|
||||||
// acting (a second claim or Go), and once done, because its result may
|
|
||||||
// still be on the wire and this tab is GENERATING by then, so handling
|
|
||||||
// the replay would decline busy, hand the lease back mid-resolution, and
|
|
||||||
// let another tab fire a second Go.
|
|
||||||
function agentTargetTaken(targetId) {
|
|
||||||
const status = agentTargetsSeen.get(targetId);
|
|
||||||
return status === 'acting' || status === 'done';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only a page that can resolve the target claims it. A tab whose page
|
|
||||||
// lacks the element declines with its resolution verdict instead, so a
|
|
||||||
// first-wins claim never lets the wrong page answer for a target that
|
|
||||||
// another page has. The server prefers a busy report (a tab that could
|
|
||||||
// serve later) over these, and returns the resolution verdict only when
|
|
||||||
// no connected page can serve.
|
|
||||||
//
|
|
||||||
// An element can be momentarily absent (a route still rendering, an HMR
|
|
||||||
// commit mid-swap), so a failed resolution is not this page's final word:
|
|
||||||
// it is re-checked a few times over about two seconds, claiming the
|
|
||||||
// moment the element mounts, and only the last miss is reported. The
|
|
||||||
// server's timeout still bounds the whole exchange.
|
|
||||||
// The page reports the miss at once (so the other overlays' words can
|
|
||||||
// complete the roll call) and keeps re-checking at this cadence for as
|
|
||||||
// long as the server says the request is pending: the server holds an
|
|
||||||
// all-no_match roll call open for a short grace precisely so a late mount
|
|
||||||
// can still be claimed, drops the stale report on an eligible claim, and
|
|
||||||
// ends the watch by answering pending:false once the request resolved or
|
|
||||||
// timed out.
|
|
||||||
const AGENT_TARGET_RESOLVE_WATCH_MS = 500;
|
|
||||||
|
|
||||||
function declineAgentTargetUnresolvable(msg) {
|
|
||||||
const probe = resolveAgentTargetElement(msg);
|
|
||||||
if (!probe.error) return false;
|
|
||||||
reportAgentTargetUnresolvable(msg, probe.error);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function reportAgentTargetUnresolvable(msg, error) {
|
|
||||||
noteAgentTarget(msg.targetId, 'declined');
|
|
||||||
claimAgentTarget(msg.targetId, { eligible: false, state, reason: 'no_match', result: error }).then((answer) => {
|
|
||||||
if (!answer.pending) return;
|
|
||||||
setTimeout(() => watchAgentTargetResolution(msg, error), AGENT_TARGET_RESOLVE_WATCH_MS);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function watchAgentTargetResolution(msg, lastError) {
|
|
||||||
if (agentTargetOverlayGone() || agentTargetTaken(msg.targetId)) return;
|
|
||||||
const busy = agentTargetBusyReason(msg.targetId);
|
|
||||||
if (busy) { declineAgentTargetBusy(msg, busy); return; }
|
|
||||||
const probe = resolveAgentTargetElement(msg);
|
|
||||||
if (!probe.error) { claimAndActOnAgentTarget(msg); return; }
|
|
||||||
// Still unresolvable: re-report (idempotent); the answer says whether
|
|
||||||
// the server is still holding the request open.
|
|
||||||
reportAgentTargetUnresolvable(msg, probe.error || lastError);
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleAgentTarget(msg) {
|
|
||||||
if (!msg || typeof msg.targetId !== 'string') return;
|
|
||||||
if (agentTargetTaken(msg.targetId)) return;
|
|
||||||
noteAgentTarget(msg.targetId, 'heard');
|
|
||||||
const busy = agentTargetBusyReason(msg.targetId);
|
|
||||||
if (busy) {
|
|
||||||
// Roll call: a busy tab reports itself and never acts. The server
|
|
||||||
// answers `busy` the moment every connected overlay has reported, so
|
|
||||||
// an idle tab elsewhere is never raced by a timer.
|
|
||||||
declineAgentTargetBusy(msg, busy);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (declineAgentTargetUnresolvable(msg)) return;
|
|
||||||
// Eligible tabs race for the server's lease and only the holder acts. A
|
|
||||||
// hidden tab yields a short head start so a visible one wins when both
|
|
||||||
// exist, and still serves the request on its own: the user finds the
|
|
||||||
// selection waiting when they return to it.
|
|
||||||
setTimeout(() => claimAndActOnAgentTarget(msg), document.hidden ? 150 : 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
function actOnAgentTarget(msg) {
|
|
||||||
if (agentTargetOverlayGone()) return;
|
|
||||||
// Every exit ends this tab's acting state, so a later target is not
|
|
||||||
// refused for a Go that already happened or never will.
|
|
||||||
const reply = (result) => { noteAgentTarget(msg.targetId, 'done'); postAgentTargetResult(msg.targetId, result); };
|
|
||||||
const busy = agentTargetBusyReason(msg.targetId);
|
|
||||||
if (busy) {
|
|
||||||
// Turned busy between claim and act: report it, which also hands the
|
|
||||||
// lease back so the roll call can complete or a rescuer can claim.
|
|
||||||
declineAgentTargetBusy(msg, busy);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const resolved = resolveAgentTargetElement(msg);
|
|
||||||
if (resolved.error) {
|
|
||||||
// The element went away between claim and act. A result would end the
|
|
||||||
// request for every tab; a decline hands the lease back so another
|
|
||||||
// page or a remount can still serve it.
|
|
||||||
reportAgentTargetUnresolvable(msg, resolved.error);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const el = resolved.el;
|
|
||||||
if (msg.dryRun) {
|
|
||||||
reply({
|
|
||||||
ok: true,
|
|
||||||
dryRun: true,
|
|
||||||
matchCount: resolved.matchCount,
|
|
||||||
element: describeAgentTargetCandidate(el),
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
scrollAgentTargetIntoView(el, () => {
|
|
||||||
// Torn down during the scroll settle: do not renew. The lease lapses
|
|
||||||
// for a rescuer instead of Go minting a session on a dismantled
|
|
||||||
// overlay.
|
|
||||||
if (agentTargetOverlayGone()) return;
|
|
||||||
// Renew the lease right before the irreversible part: a tab whose
|
|
||||||
// lease lapsed while it scrolled (a rescuer took over) stops here, so
|
|
||||||
// one request never gets two Go presses.
|
|
||||||
claimAgentTarget(msg.targetId, { eligible: true }).then((renewal) => {
|
|
||||||
if (!renewal.granted) { noteAgentTarget(msg.targetId, 'done'); return; }
|
|
||||||
// An insert placement left mid-configure gives way, exactly as a
|
|
||||||
// click outside it does in handleClick.
|
|
||||||
if (state === 'CONFIGURING' && configureKind === 'insert') cancelInsertConfigure();
|
|
||||||
// Mirror of the user-click pick entry in handleClick, minus the
|
|
||||||
// pick-mode gate (the agent's intent replaces the toggle); the entry
|
|
||||||
// goes through beginNewLiveConfiguration like every other pick so
|
|
||||||
// deferred recovery sees a fresh interaction revision.
|
|
||||||
selectedElement = el;
|
|
||||||
beginNewLiveConfiguration();
|
|
||||||
showHighlight(selectedElement);
|
|
||||||
clearAnnotations();
|
|
||||||
showAnnotOverlay(selectedElement);
|
|
||||||
showBar('configure');
|
|
||||||
editBadgeSuppressed = true;
|
|
||||||
renderEditBadge('hidden');
|
|
||||||
startScrollTracking();
|
|
||||||
maybePrefetchPage();
|
|
||||||
maybeWarnConditionalAncestor(selectedElement);
|
|
||||||
// Preset what the agent asked for, then fire the same Go a user press
|
|
||||||
// fires. handleGo reads exactly these inputs.
|
|
||||||
selectedAction = msg.action;
|
|
||||||
selectedCount = msg.count;
|
|
||||||
// updateBarContent rebuilds the configure row and replaces the input
|
|
||||||
// element, so the prompt must be written into the input it creates,
|
|
||||||
// never before (the action-chip click handler does the same dance).
|
|
||||||
updateBarContent('configure');
|
|
||||||
const input = uiGetById(PREFIX + '-input');
|
|
||||||
if (input) input.value = msg.prompt || '';
|
|
||||||
// The target rides on the generate event too: the helper resolves
|
|
||||||
// the request from whichever lands first, so a page that dies
|
|
||||||
// between Go and its result cannot leave the request pending for a
|
|
||||||
// second Go elsewhere.
|
|
||||||
const candidate = describeAgentTargetCandidate(el);
|
|
||||||
agentTargetForGo = { targetId: msg.targetId, matchCount: resolved.matchCount, action: msg.action, count: msg.count, element: candidate };
|
|
||||||
handleGo();
|
|
||||||
agentTargetForGo = null;
|
|
||||||
if (state === 'GENERATING' && currentSessionId) {
|
|
||||||
reply({
|
|
||||||
ok: true,
|
|
||||||
matchCount: resolved.matchCount,
|
|
||||||
sessionId: currentSessionId,
|
|
||||||
action: msg.action,
|
|
||||||
count: msg.count,
|
|
||||||
element: candidate,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
reply({ ok: false, error: 'go_failed', state });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// SSE (server→browser) + fetch POST (browser→server)
|
// SSE (server→browser) + fetch POST (browser→server)
|
||||||
// Zero-dependency replacement for WebSocket.
|
// Zero-dependency replacement for WebSocket.
|
||||||
//
|
//
|
||||||
@@ -7538,7 +7121,7 @@
|
|||||||
const SSE_MAX_RETRIES = 20; // generous: heartbeats keep the connection alive, so retries mean real trouble
|
const SSE_MAX_RETRIES = 20; // generous: heartbeats keep the connection alive, so retries mean real trouble
|
||||||
|
|
||||||
function connectSSE() {
|
function connectSSE() {
|
||||||
evtSource = new EventSource('http://localhost:' + PORT + '/events?token=' + TOKEN + '&clientId=' + AGENT_TARGET_CLIENT_ID);
|
evtSource = new EventSource('http://localhost:' + PORT + '/events?token=' + TOKEN);
|
||||||
|
|
||||||
evtSource.onopen = () => {
|
evtSource.onopen = () => {
|
||||||
sseRetries = 0; // reset on successful (re)connect
|
sseRetries = 0; // reset on successful (re)connect
|
||||||
@@ -7549,11 +7132,8 @@
|
|||||||
let msg; try { msg = JSON.parse(e.data); } catch { return; }
|
let msg; try { msg = JSON.parse(e.data); } catch { return; }
|
||||||
switch (msg.type) {
|
switch (msg.type) {
|
||||||
case 'connected':
|
case 'connected':
|
||||||
applyLiveBarPreference(msg.hideLiveBar === true);
|
|
||||||
hasProjectContext = !!msg.hasProjectContext;
|
hasProjectContext = !!msg.hasProjectContext;
|
||||||
// The generate lane runs without PRODUCT.md by design and never
|
if (!hasProjectContext) showToast(`No PRODUCT.md found. Variants will be brand-agnostic. Run ${IMPECCABLE_COMMAND} init to generate one.`, 7000);
|
||||||
// sends the user to init, so its quiet chrome skips this notice.
|
|
||||||
if (!hasProjectContext && !liveBarHiddenByHelper) showToast(`No PRODUCT.md found. Variants will be brand-agnostic. Run ${IMPECCABLE_COMMAND} init to generate one.`, 7000);
|
|
||||||
console.log('[impeccable] Live mode connected.');
|
console.log('[impeccable] Live mode connected.');
|
||||||
syncAgentPollingUi(!!msg.agentPolling);
|
syncAgentPollingUi(!!msg.agentPolling);
|
||||||
startAgentStatusPoll();
|
startAgentStatusPoll();
|
||||||
@@ -7563,15 +7143,9 @@
|
|||||||
syncPageInteractionCursor();
|
syncPageInteractionCursor();
|
||||||
syncPageChatFocus('sse-connected');
|
syncPageChatFocus('sse-connected');
|
||||||
break;
|
break;
|
||||||
case 'live_bar':
|
|
||||||
applyLiveBarPreference(msg.hidden === true);
|
|
||||||
break;
|
|
||||||
case 'agent_polling':
|
case 'agent_polling':
|
||||||
syncAgentPollingUi(!!msg.connected);
|
syncAgentPollingUi(!!msg.connected);
|
||||||
break;
|
break;
|
||||||
case 'agent_target':
|
|
||||||
handleAgentTarget(msg);
|
|
||||||
break;
|
|
||||||
case 'agent_phase':
|
case 'agent_phase':
|
||||||
if (msg.id === currentSessionId && (state === 'GENERATING' || state === 'CYCLING')) {
|
if (msg.id === currentSessionId && (state === 'GENERATING' || state === 'CYCLING')) {
|
||||||
// Advance the visible phase monotonically. A behind/resumed
|
// Advance the visible phase monotonically. A behind/resumed
|
||||||
@@ -7634,11 +7208,6 @@
|
|||||||
disableInlineEdit();
|
disableInlineEdit();
|
||||||
refreshParamsPanel();
|
refreshParamsPanel();
|
||||||
}
|
}
|
||||||
// The done reply is the agent's last word on this generation:
|
|
||||||
// with every variant mounted and no knobs declared, the Tune
|
|
||||||
// chip must stop spinning. A reload between the mount and this
|
|
||||||
// reply restored the pending state from the cache.
|
|
||||||
completeParameterGenerationIfReady();
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
// Source fallback when HMR did not land variants in this tab.
|
// Source fallback when HMR did not land variants in this tab.
|
||||||
@@ -7802,15 +7371,6 @@
|
|||||||
}).then(async res => {
|
}).then(async res => {
|
||||||
if (res.ok) return res;
|
if (res.ok) return res;
|
||||||
const body = await res.json().catch(() => ({}));
|
const body = await res.json().catch(() => ({}));
|
||||||
// The helper refused to open a session for an agent target it has
|
|
||||||
// already answered (another page served it after this page's lease
|
|
||||||
// lapsed mid-capture, or the request timed out): drop the local
|
|
||||||
// session and hand the surface back.
|
|
||||||
if (body.error === 'agent_target_already_served' && msg.type === 'generate'
|
|
||||||
&& msg.id && msg.id === currentSessionId) {
|
|
||||||
abandonSupersededGo(msg.id);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
// The server refused to journal progress for a session it has never
|
// The server refused to journal progress for a session it has never
|
||||||
// seen: this browser is carrying state from another project or a
|
// seen: this browser is carrying state from another project or a
|
||||||
// wiped store (two apps sharing a localhost port). Continuing to
|
// wiped store (two apps sharing a localhost port). Continuing to
|
||||||
@@ -7832,14 +7392,6 @@
|
|||||||
return sessionCreationGate.then(doSend);
|
return sessionCreationGate.then(doSend);
|
||||||
}
|
}
|
||||||
|
|
||||||
function abandonSupersededGo(sessionId) {
|
|
||||||
if (sessionId !== currentSessionId) return;
|
|
||||||
console.warn('[impeccable] The helper already answered this agent target; clearing session ' + sessionId + '.');
|
|
||||||
markSessionHandled();
|
|
||||||
cleanup({ instantChrome: true });
|
|
||||||
showToast('The helper already answered this request, so this session was cleared. Pick an element to start fresh.', 6000);
|
|
||||||
}
|
|
||||||
|
|
||||||
let abandonedForeignSessionId = null;
|
let abandonedForeignSessionId = null;
|
||||||
function abandonForeignSession(sessionId) {
|
function abandonForeignSession(sessionId) {
|
||||||
if (abandonedForeignSessionId === sessionId || sessionId !== currentSessionId) return;
|
if (abandonedForeignSessionId === sessionId || sessionId !== currentSessionId) return;
|
||||||
@@ -8244,7 +7796,6 @@
|
|||||||
visibleVariant = 0;
|
visibleVariant = 0;
|
||||||
generationPhase = 'queued';
|
generationPhase = 'queued';
|
||||||
parameterGenerationState = 'pending';
|
parameterGenerationState = 'pending';
|
||||||
sessionOrigin = agentTargetForGo ? 'agent' : null;
|
|
||||||
parameterReadyAnnouncedSession = null;
|
parameterReadyAnnouncedSession = null;
|
||||||
resetSessionFileMeta();
|
resetSessionFileMeta();
|
||||||
|
|
||||||
@@ -8270,24 +7821,6 @@
|
|||||||
};
|
};
|
||||||
if (snapshot.comments.length > 0) basePayload.comments = snapshot.comments;
|
if (snapshot.comments.length > 0) basePayload.comments = snapshot.comments;
|
||||||
if (snapshot.strokes.length > 0) basePayload.strokes = snapshot.strokes;
|
if (snapshot.strokes.length > 0) basePayload.strokes = snapshot.strokes;
|
||||||
if (agentTargetForGo) {
|
|
||||||
// An agent-initiated Go names the target it serves (see
|
|
||||||
// actOnAgentTarget): the helper resolves that request from this event
|
|
||||||
// as well as from the overlay's own result post.
|
|
||||||
basePayload.agentTarget = {
|
|
||||||
targetId: agentTargetForGo.targetId,
|
|
||||||
clientId: AGENT_TARGET_CLIENT_ID,
|
|
||||||
result: {
|
|
||||||
ok: true,
|
|
||||||
matchCount: agentTargetForGo.matchCount,
|
|
||||||
sessionId: currentSessionId,
|
|
||||||
action: agentTargetForGo.action,
|
|
||||||
count: agentTargetForGo.count,
|
|
||||||
element: agentTargetForGo.element,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
agentTargetForGo = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Hide the interactive overlay so it doesn't linger during generation.
|
// Hide the interactive overlay so it doesn't linger during generation.
|
||||||
hideAnnotOverlay();
|
hideAnnotOverlay();
|
||||||
@@ -8348,7 +7881,6 @@
|
|||||||
visibleVariant = 0;
|
visibleVariant = 0;
|
||||||
generationPhase = 'queued';
|
generationPhase = 'queued';
|
||||||
parameterGenerationState = 'pending';
|
parameterGenerationState = 'pending';
|
||||||
sessionOrigin = agentTargetForGo ? 'agent' : null;
|
|
||||||
parameterReadyAnnouncedSession = null;
|
parameterReadyAnnouncedSession = null;
|
||||||
resetSessionFileMeta();
|
resetSessionFileMeta();
|
||||||
selectedElement = placeholderElement;
|
selectedElement = placeholderElement;
|
||||||
@@ -9395,8 +8927,6 @@ void main() {
|
|||||||
pagePickSkipClick = false;
|
pagePickSkipClick = false;
|
||||||
currentSessionId = null;
|
currentSessionId = null;
|
||||||
parameterGenerationState = 'idle';
|
parameterGenerationState = 'idle';
|
||||||
sessionOrigin = null;
|
|
||||||
editBadgeSuppressed = false;
|
|
||||||
parameterReadyAnnouncedSession = null;
|
parameterReadyAnnouncedSession = null;
|
||||||
selectedAction = 'impeccable';
|
selectedAction = 'impeccable';
|
||||||
pendingAcceptedSession = null;
|
pendingAcceptedSession = null;
|
||||||
@@ -9488,7 +9018,6 @@ void main() {
|
|||||||
paramsCurrentValues = { ...saved.paramValues };
|
paramsCurrentValues = { ...saved.paramValues };
|
||||||
}
|
}
|
||||||
if (saved.parameterState) parameterGenerationState = saved.parameterState;
|
if (saved.parameterState) parameterGenerationState = saved.parameterState;
|
||||||
sessionOrigin = saved.origin === 'agent' ? 'agent' : null;
|
|
||||||
if (saved.generationPhase) generationPhase = saved.generationPhase;
|
if (saved.generationPhase) generationPhase = saved.generationPhase;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -9576,12 +9105,7 @@ void main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function restoreSessionWithoutWrapper(reason, activeSessions) {
|
function restoreSessionWithoutWrapper(reason, activeSessions) {
|
||||||
// The session cache is per origin, so a tab on another page of the same
|
const cached = loadSession();
|
||||||
// app sees this page's session too. Only the page that saved it may
|
|
||||||
// resume it: the server-adoption branch below already applies the same
|
|
||||||
// check, and a tab on another page has nothing to render for it.
|
|
||||||
const cachedRaw = loadSession();
|
|
||||||
const cached = cachedRaw?.id && !pageMatchesCurrent(cachedRaw.pageUrl) ? null : cachedRaw;
|
|
||||||
// localStorage is a cache, not a gate. A cleared tab, a second browser
|
// localStorage is a cache, not a gate. A cleared tab, a second browser
|
||||||
// profile, or a teardown that dropped local state all leave the durable
|
// profile, or a teardown that dropped local state all leave the durable
|
||||||
// server session as the only record of work in progress; adopt it instead
|
// server session as the only record of work in progress; adopt it instead
|
||||||
@@ -9694,7 +9218,6 @@ void main() {
|
|||||||
pageUrl: location.pathname,
|
pageUrl: location.pathname,
|
||||||
paramValues: { ...paramsCurrentValues },
|
paramValues: { ...paramsCurrentValues },
|
||||||
parameterState: parameterGenerationState,
|
parameterState: parameterGenerationState,
|
||||||
origin: sessionOrigin || undefined,
|
|
||||||
insertPlaceholder: insertPlaceholderSnapshot || undefined,
|
insertPlaceholder: insertPlaceholderSnapshot || undefined,
|
||||||
pickedAnchor: pickedAnchorSnapshot || undefined,
|
pickedAnchor: pickedAnchorSnapshot || undefined,
|
||||||
pickedAnchorViewportTop: Number.isFinite(pickedAnchorViewportTop) ? pickedAnchorViewportTop : undefined,
|
pickedAnchorViewportTop: Number.isFinite(pickedAnchorViewportTop) ? pickedAnchorViewportTop : undefined,
|
||||||
@@ -9820,8 +9343,6 @@ void main() {
|
|||||||
pagePickSkipClick = false;
|
pagePickSkipClick = false;
|
||||||
currentSessionId = null;
|
currentSessionId = null;
|
||||||
parameterGenerationState = 'idle';
|
parameterGenerationState = 'idle';
|
||||||
sessionOrigin = null;
|
|
||||||
editBadgeSuppressed = false;
|
|
||||||
parameterReadyAnnouncedSession = null;
|
parameterReadyAnnouncedSession = null;
|
||||||
selectedAction = 'impeccable';
|
selectedAction = 'impeccable';
|
||||||
renderEditBadge('hidden');
|
renderEditBadge('hidden');
|
||||||
@@ -10080,14 +9601,6 @@ void main() {
|
|||||||
|
|
||||||
const resumedState = arrivedVariants > 0 ? 'CYCLING' : 'GENERATING';
|
const resumedState = arrivedVariants > 0 ? 'CYCLING' : 'GENERATING';
|
||||||
|
|
||||||
// A reload between the variants mounting and the agent's done reply
|
|
||||||
// restores a pending Tune state from the cache; the helper knows whether
|
|
||||||
// that generation already finished.
|
|
||||||
if (arrivedVariants >= expectedVariants && expectedVariants > 0
|
|
||||||
&& (parameterGenerationState === 'pending' || parameterGenerationState === 'loading')) {
|
|
||||||
settleParameterStateFromHelper(sessionId);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find the visible variant's content element for highlight positioning.
|
// Find the visible variant's content element for highlight positioning.
|
||||||
const isInsert = wrapper.dataset.impeccableMode === 'insert';
|
const isInsert = wrapper.dataset.impeccableMode === 'insert';
|
||||||
const visEl = visibleVariant > 0 ? pickVariantContent(wrapper, visibleVariant) : null;
|
const visEl = visibleVariant > 0 ? pickVariantContent(wrapper, visibleVariant) : null;
|
||||||
@@ -11552,21 +11065,6 @@ void main() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// After a resume the cache may say the Tune knobs are still coming while
|
|
||||||
// the agent already replied done before the reload. The helper's session
|
|
||||||
// record settles it; otherwise the done reply on SSE does.
|
|
||||||
function settleParameterStateFromHelper(sessionId) {
|
|
||||||
fetch('http://localhost:' + PORT + '/status?token=' + TOKEN, { cache: 'no-store' })
|
|
||||||
.then((res) => (res.ok ? res.json() : null))
|
|
||||||
.then((data) => {
|
|
||||||
if (!data || sessionId !== currentSessionId) return;
|
|
||||||
const session = (data.activeSessions || []).find((s) => s && s.id === sessionId);
|
|
||||||
if (!session) return;
|
|
||||||
if (session.generationCompletedAt || session.generationPhase === 'completed') completeParameterGenerationIfReady();
|
|
||||||
})
|
|
||||||
.catch(() => { /* the done reply on SSE settles it otherwise */ });
|
|
||||||
}
|
|
||||||
|
|
||||||
function fetchAgentPollingStatus() {
|
function fetchAgentPollingStatus() {
|
||||||
fetch('http://localhost:' + PORT + '/status?token=' + TOKEN, { cache: 'no-store' })
|
fetch('http://localhost:' + PORT + '/status?token=' + TOKEN, { cache: 'no-store' })
|
||||||
.then((res) => (res.ok ? res.json() : null))
|
.then((res) => (res.ok ? res.json() : null))
|
||||||
@@ -11606,15 +11104,11 @@ void main() {
|
|||||||
uiAppendStyle(s);
|
uiAppendStyle(s);
|
||||||
}
|
}
|
||||||
|
|
||||||
// The generate lane's helper says so in the served script itself, so a
|
|
||||||
// lane session never draws the bar at all; every other session mounts
|
|
||||||
// it exactly as before.
|
|
||||||
const barHiddenFromStart = window.__IMPECCABLE_LIVE_BAR_HIDDEN__ === true;
|
|
||||||
globalBarEl = el('div', {
|
globalBarEl = el('div', {
|
||||||
position: 'fixed', bottom: '14px', left: '50%',
|
position: 'fixed', bottom: '14px', left: '50%',
|
||||||
transform: 'translateX(-50%) translateY(20px)',
|
transform: 'translateX(-50%) translateY(20px)',
|
||||||
zIndex: Z.bar + 5,
|
zIndex: Z.bar + 5,
|
||||||
display: barHiddenFromStart ? 'none' : 'flex', alignItems: 'stretch',
|
display: 'flex', alignItems: 'stretch',
|
||||||
gap: '0',
|
gap: '0',
|
||||||
width: 'max-content',
|
width: 'max-content',
|
||||||
background: P.surface,
|
background: P.surface,
|
||||||
@@ -11630,10 +11124,6 @@ void main() {
|
|||||||
});
|
});
|
||||||
globalBarEl.id = PREFIX + '-global-bar';
|
globalBarEl.id = PREFIX + '-global-bar';
|
||||||
globalBarEl.dataset.theme = theme;
|
globalBarEl.dataset.theme = theme;
|
||||||
if (barHiddenFromStart) {
|
|
||||||
liveBarHiddenByHelper = true;
|
|
||||||
globalBarEl.dataset.liveBarDisplay = 'flex';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Brand mark - kinpaku Impeccable icon (site header / favicon paths).
|
// Brand mark - kinpaku Impeccable icon (site header / favicon paths).
|
||||||
const brand = el('span', {
|
const brand = el('span', {
|
||||||
@@ -12029,9 +11519,6 @@ void main() {
|
|||||||
// Listen for detection results AND ready signal
|
// Listen for detection results AND ready signal
|
||||||
window.addEventListener('message', onDetectMessage);
|
window.addEventListener('message', onDetectMessage);
|
||||||
updateGlobalBarState();
|
updateGlobalBarState();
|
||||||
// The helper may already have said the bar stays hidden (a connect
|
|
||||||
// that raced the bar build, or a reload mid-lane): re-apply it here.
|
|
||||||
if (liveBarHiddenByHelper) setLiveBarHidden(true);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateGlobalBarState() {
|
function updateGlobalBarState() {
|
||||||
@@ -12228,13 +11715,6 @@ void main() {
|
|||||||
|
|
||||||
/** Full teardown: remove all UI, disconnect SSE, clean up. */
|
/** Full teardown: remove all UI, disconnect SSE, clean up. */
|
||||||
function teardown() {
|
function teardown() {
|
||||||
// Declined targets die with the overlay: the IDLE transition below must
|
|
||||||
// not re-claim a lease this page can no longer act on. So does the
|
|
||||||
// target ledger: an 'acting' entry from a Go that never happened must
|
|
||||||
// not refuse every target the next connection hears.
|
|
||||||
busyDeclinedTargets.clear();
|
|
||||||
agentTargetsSeen.clear();
|
|
||||||
liveBarHiddenByHelper = false;
|
|
||||||
stopAgentStatusPoll();
|
stopAgentStatusPoll();
|
||||||
hideAgentPollTooltip();
|
hideAgentPollTooltip();
|
||||||
if (agentPollTooltipEl) {
|
if (agentPollTooltipEl) {
|
||||||
|
|||||||
@@ -346,7 +346,7 @@ jobs:
|
|||||||
- group: svelte
|
- group: svelte
|
||||||
fixtures: vite8-sveltekit-stateful
|
fixtures: vite8-sveltekit-stateful
|
||||||
- group: react
|
- group: react
|
||||||
fixtures: vite8-react-css-modules,vite8-react-insert,vite8-react-plain,vite8-react-pricing-cards
|
fixtures: vite8-react-css-modules,vite8-react-insert,vite8-react-plain
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
@@ -397,7 +397,7 @@ jobs:
|
|||||||
run: bun run test:live-e2e
|
run: bun run test:live-e2e
|
||||||
env:
|
env:
|
||||||
IMPECCABLE_E2E_ONLY: ${{ matrix.fixtures }}
|
IMPECCABLE_E2E_ONLY: ${{ matrix.fixtures }}
|
||||||
IMPECCABLE_E2E_SCENARIOS: core,agent-target
|
IMPECCABLE_E2E_SCENARIOS: core
|
||||||
IMPECCABLE_E2E_TEST_TIMEOUT_MS: 180000
|
IMPECCABLE_E2E_TEST_TIMEOUT_MS: 180000
|
||||||
IMPECCABLE_E2E_INSTALL_TIMEOUT_MS: 120000
|
IMPECCABLE_E2E_INSTALL_TIMEOUT_MS: 120000
|
||||||
IMPECCABLE_E2E_DEV_READY_TIMEOUT_MS: 60000
|
IMPECCABLE_E2E_DEV_READY_TIMEOUT_MS: 60000
|
||||||
@@ -428,7 +428,7 @@ jobs:
|
|||||||
- group: react-a
|
- group: react-a
|
||||||
fixtures: vite8-https,vite8-react-base-path,vite8-react-csp-meta,vite8-react-css-modules,vite8-react-emotion
|
fixtures: vite8-https,vite8-react-base-path,vite8-react-csp-meta,vite8-react-css-modules,vite8-react-emotion
|
||||||
- group: react-b
|
- group: react-b
|
||||||
fixtures: vite8-react-insert,vite8-react-mapped-list,vite8-react-modal,vite8-react-plain,vite8-react-pricing-cards
|
fixtures: vite8-react-insert,vite8-react-mapped-list,vite8-react-modal,vite8-react-plain
|
||||||
- group: stateful
|
- group: stateful
|
||||||
fixtures: vite8-react-radix-dialog,vite8-react-router-spa,vite8-react-styled-components,vite8-react-tabs
|
fixtures: vite8-react-radix-dialog,vite8-react-router-spa,vite8-react-styled-components,vite8-react-tabs
|
||||||
- group: styling
|
- group: styling
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ jobs:
|
|||||||
name: unsigned-windows-x64
|
name: unsigned-windows-x64
|
||||||
path: unsigned
|
path: unsigned
|
||||||
- name: Azure login (OIDC)
|
- name: Azure login (OIDC)
|
||||||
uses: azure/login@a641126d1b8aa4d1fa005f4f92df94a3a4c4c906 # v3
|
uses: azure/login@7ddb5af1ef8758cf1353cf3b42f940aee27ba21c # v3
|
||||||
with:
|
with:
|
||||||
client-id: ${{ vars.AZURE_CLIENT_ID }}
|
client-id: ${{ vars.AZURE_CLIENT_ID }}
|
||||||
tenant-id: ${{ vars.AZURE_TENANT_ID }}
|
tenant-id: ${{ vars.AZURE_TENANT_ID }}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
---
|
---
|
||||||
name: impeccable
|
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.
|
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: 4.3.1
|
version: 4.3.0
|
||||||
user-invocable: true
|
user-invocable: true
|
||||||
argument-hint: "[shape · audit|critique · animate|bolder|colorize|delight|layout|overdrive|quieter|typeset · adapt|clarify|distill · harden|onboard|optimize|polish · init|document|extract|live|generate] [target]"
|
argument-hint: "[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
|
license: Apache 2.0
|
||||||
allowed-tools:
|
allowed-tools:
|
||||||
- Bash(npx impeccable *)
|
- Bash(npx impeccable *)
|
||||||
@@ -68,8 +68,7 @@ Choose the mode from the requested surface, not the product, and persist it only
|
|||||||
| `clarify [target]` | Fix | Improve UX copy, labels, and error messages | [reference/clarify.md](reference/clarify.md) |
|
| `clarify [target]` | Fix | Improve UX copy, labels, and error messages | [reference/clarify.md](reference/clarify.md) |
|
||||||
| `adapt [target]` | Fix | Adapt for different devices and screen sizes | [reference/adapt.md](reference/adapt.md) · native: [reference/adapt.native.md](reference/adapt.native.md) |
|
| `adapt [target]` | Fix | Adapt for different devices and screen sizes | [reference/adapt.md](reference/adapt.md) · native: [reference/adapt.native.md](reference/adapt.native.md) |
|
||||||
| `optimize [target]` | Fix | Diagnose and fix UI performance | [reference/optimize.md](reference/optimize.md) |
|
| `optimize [target]` | Fix | Diagnose and fix UI performance | [reference/optimize.md](reference/optimize.md) |
|
||||||
| `live` | Iterate | Visual variant mode: pick elements in the browser, iterate on alternatives | [reference/live.md](reference/live.md) |
|
| `live` | Iterate | Visual variant mode: pick elements in the browser, generate alternatives | [reference/live.md](reference/live.md) |
|
||||||
| `generate [n] [action] [element]` | Iterate | Variants, versions, or alternatives of a named element to choose from in the live browser; no manual picking | [reference/generate.md](reference/generate.md) |
|
|
||||||
|
|
||||||
Routing:
|
Routing:
|
||||||
|
|
||||||
|
|||||||
@@ -188,12 +188,6 @@ Test thoroughly across contexts:
|
|||||||
- **Edge cases**: Very small screens (320px), very large screens (4K)
|
- **Edge cases**: Very small screens (320px), very large screens (4K)
|
||||||
- **Slow connections**: Test on throttled network
|
- **Slow connections**: Test on throttled network
|
||||||
|
|
||||||
**Custom controls** (sliders, drag surfaces, scrollable control strips): a before/after slider can pass every width check above and still refuse to drag on iOS, so exercise each one in scope in the same batched round as the checks above:
|
|
||||||
|
|
||||||
- **Primary gesture**: Tap it and confirm it responds as designed, then drag it with the target input method; the drag must complete, not just start
|
|
||||||
- **Scroll across it**: A swipe along the page's scroll axis across the control scrolls the page or container without activating it; a drag that starts on the control along its axis moves the control, not the page. Neither failure throws an error, so try both
|
|
||||||
- **Evidence**: Say what produced the evidence: an emulated viewport, synthesized touch input through a browser tool, which engine ran it (Chromium is not Safari), or a physical device. Screenshots and resized viewports verify layout, never a gesture. Name what stayed untested and move on; unreachable hardware is a reported gap, not a blocker
|
|
||||||
|
|
||||||
When the adaptation feels native to each context, hand off to `/impeccable polish` for the final pass.
|
When the adaptation feels native to each context, hand off to `/impeccable polish` for the final pass.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -48,12 +48,11 @@ Run comprehensive checks across 5 dimensions. Score each dimension 0-4 using the
|
|||||||
**Check for**:
|
**Check for**:
|
||||||
- **Fixed widths**: Hard-coded widths that break on mobile
|
- **Fixed widths**: Hard-coded widths that break on mobile
|
||||||
- **Touch targets**: Interactive elements < 44x44px
|
- **Touch targets**: Interactive elements < 44x44px
|
||||||
- **Broken touch interaction**: Custom sliders, drag surfaces, and scrollable control strips whose primary gesture fails under touch, that swallow page scroll or lose the drag to it, or that stay stuck after an interrupted gesture. Code tells: mouse-only handlers, no `touch-action` on a pointer-event drag surface, drag state that nothing clears on cancel, lost capture, or blur. Exercise the gesture when a browser tool can synthesize touch (a rendered viewport proves layout, not the gesture), then say what produced the evidence (emulated viewport, synthesized touch, which engine, physical device) and what stayed untested
|
|
||||||
- **Horizontal scroll**: Content overflow on narrow viewports
|
- **Horizontal scroll**: Content overflow on narrow viewports
|
||||||
- **Text scaling**: Layouts that break when text size increases
|
- **Text scaling**: Layouts that break when text size increases
|
||||||
- **Missing breakpoints**: No mobile/tablet variants
|
- **Missing breakpoints**: No mobile/tablet variants
|
||||||
|
|
||||||
**Score 0-4**: 0=Desktop-only (breaks on mobile), 1=Major issues (some breakpoints, many failures), 2=Partial (works on mobile, rough edges), 3=Good (responsive, minor touch target or overflow issues), 4=Excellent (fluid, all viewports, proper touch targets, gestures work under touch)
|
**Score 0-4**: 0=Desktop-only (breaks on mobile), 1=Major issues (some breakpoints, many failures), 2=Partial (works on mobile, rough edges), 3=Good (responsive, minor touch target or overflow issues), 4=Excellent (fluid, all viewports, proper touch targets)
|
||||||
|
|
||||||
### 5. Implementation Integrity (CRITICAL)
|
### 5. Implementation Integrity (CRITICAL)
|
||||||
|
|
||||||
|
|||||||
@@ -1,101 +0,0 @@
|
|||||||
> **Additional context needed**: only the target element, when the request does not name one that resolves uniquely on the page.
|
|
||||||
|
|
||||||
Generate is the fast lane into live mode: the user names an element, a direction, and a count in one sentence, and within a minute they are cycling through variants in their browser. One command boots the helper, hands the element to the overlay in the page your harness already shows (it scrolls to it, selects it, and fires the same Go a click fires) and returns the generate event; one edit writes the variants; one call replies and waits for the user's choice, which the helper bakes into source itself. This file owns the lane's plumbing; from the event onward the design work is [live.md](live.md)'s, unchanged, so read it in full now if you have not this session.
|
|
||||||
|
|
||||||
**Web only.** Live mode's browser overlay has no native equivalent; on `ios` / `android` / `adaptive` projects, decline this command and offer `bolder` or `quieter` on the source instead.
|
|
||||||
|
|
||||||
The plumbing is where the lane saves time: one command starts the session around the page your harness already shows, one call replies and waits, and nothing here is a browser you have to babysit. The design work is not where it saves time. Setup runs as for any command (`impeccable context`, this reference, craft-floor.md before the edit), and the variants are planned, written, and accepted exactly the way a live session plans, writes, and accepts them.
|
|
||||||
|
|
||||||
Three prohibitions cover the known ways this command goes wrong:
|
|
||||||
|
|
||||||
- **Never run init or document, and never ask for PRODUCT.md or DESIGN.md.** When they exist, the start command prints them under `boot` and you use them. When they do not, it says so (`contextMissing`, `contextNote`) and you extract the identity from the event (Step 3). A missing file is never a reason to interview the user inside this command; offer `init` in one line after the session ends.
|
|
||||||
- **Never hand-write a variants wrapper or invent a session id.** Only the browser mints session ids (8 hex characters, at Go). A missing event is fixed by rerunning Step 2, never with a direct source edit.
|
|
||||||
- **Do not act on hook findings while live markers are in the file**, and do not restyle variants to appease them; the accept verifies the file once the variant is permanent.
|
|
||||||
|
|
||||||
## Step 1: Parse the request
|
|
||||||
|
|
||||||
Three parts, all from the user's sentence:
|
|
||||||
|
|
||||||
- **A number in the request**: that is the count. **No number**: 3. The protocol caps count at 8.
|
|
||||||
- **The direction wording** maps onto the live action vocabulary; never invent a new action value:
|
|
||||||
- **bold, bolder, stronger, punchier**: `bolder`
|
|
||||||
- **quiet, calmer, softer, toned down**: `quieter`
|
|
||||||
- **simpler, minimal, stripped**: `distill`
|
|
||||||
- **refined, tightened, polished**: `polish`
|
|
||||||
- **font and type words**: `typeset`
|
|
||||||
- **color words**: `colorize`
|
|
||||||
- **arrangement and spacing words**: `layout`
|
|
||||||
- **device and breakpoint words**: `adapt`
|
|
||||||
- **motion words**: `animate`
|
|
||||||
- **playful words**: `delight`
|
|
||||||
- **rule-breaking words**: `overdrive`
|
|
||||||
- **Wording that carries intent but no vocabulary word** ("make it feel like a bank", "warmer", "more premium"): `impeccable`, with the user's wording passed as the prompt.
|
|
||||||
- **An action fits AND extra intent rides along** ("bolder, but keep it monochrome"): that action, with the rest as the prompt.
|
|
||||||
- **The wording names no direction at all** ("better", "improve", "nicer", "different", "fresh", "new", "redesign", "fix", "some options", "ideas", "alternatives", or just "variants" with nothing else): STOP and call the AskUserQuestion tool to clarify. Ask one question, offering the vocabulary: *"Which direction should the variants take? bolder, quieter, simpler (distill), polished, typography (typeset), color (colorize), layout, motion (animate), playful (delight), or rule-breaking (overdrive)."* Map the answer with this list; an answer that is still open ("surprise me", "you pick") is `impeccable` with the user's original wording as the prompt, and Step 2 starts on that answer.
|
|
||||||
- **The element description** ("the pricing cards", "the hero heading"): Step 2 resolves it to a selector.
|
|
||||||
|
|
||||||
Done when you hold an action from the vocabulary (asked for, when the request named no direction), a count from 1 to 8, and the element description.
|
|
||||||
|
|
||||||
## Step 2: Reuse the page, then start
|
|
||||||
|
|
||||||
**Reuse** the dev server already running and the tab your harness already shows it in; a second server or a second browser window is the failure this step prevents.
|
|
||||||
|
|
||||||
1. **Find the dev server**, cheapest source first, and stop at the first hit: the user's message, a browser tab already on the app (Claude Code: an origin in `tabs_context`), a server your harness started (Claude Code: `preview_list`), a terminal that printed its URL. Its origin is your `--dev-url`. **No hit**: leave `--dev-url` off and run the start command with no wait; the boot probes for a running server and its verdict names the move. `browser_needed` carries the `devUrl` it found: open it as in 2, then rerun with `--dev-url <devUrl> --wait-for-browser 60000`. `no_dev_server` means nothing serves the app: start the dev script the way the verdict says (Claude Code: `preview_start`; Cursor: a background terminal; Codex: an exec you yield from), wait for its URL, then rerun with `--dev-url <url>`.
|
|
||||||
2. **Open the page that renders the element in your browser, then start.** The route the request names, else the one `--target` serves; `--dev-url` takes only the origin.
|
|
||||||
- **Cursor** (`browser_navigate`) and **Claude Code** (`navigate`, which opens the Browser pane when it is closed and takes the `tabId` from `tabs_context` when a tab is already on that origin): open the URL, then run the start command with `--dev-url <url> --wait-for-browser 60000`. The boot injects the overlay and the page reloads into it while the command waits. Your browser tool is the only opener on these harnesses; the engine ignores `--open` there.
|
|
||||||
- **No browser tool** (Codex, others): run the start command with `--open --wait-for-browser 120000`; it opens the system browser, and the longer wait covers the user finding the tab. **`browser_open_failed` back**: tell the user the `url` in one line and rerun with `--wait-for-browser 120000`.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
.grok/skills/impeccable/scripts/impeccable live-generate --target src/App.jsx --dev-url http://127.0.0.1:5173/ --selector ".pricing-grid" --action bolder --count 3 --boot --wait-for-browser 60000
|
|
||||||
```
|
|
||||||
|
|
||||||
Run it in the foreground in Cursor and Claude Code (it returns within the wait); on Codex, in an exec you yield from, the way Step 3 runs the poll.
|
|
||||||
|
|
||||||
- `--target`: the file that renders the element when the request or the project makes it obvious; skip it otherwise.
|
|
||||||
- `--dev-url`: the origin from 1; omit it and the boot probes.
|
|
||||||
- `--selector`: a unique class first, then a landmark tag plus class, an id last (every variant mounts a copy of the element, so an id repeats in the DOM). **The request names a repeated component in plural** ("the pricing cards"): target the container that holds the set, so one scoped stylesheet restyles every instance. One read of the source file that renders the element is allowed when the selector is not obvious; `--dry-run` resolves and reports without starting anything when it is not certain.
|
|
||||||
- `--boot`: runs the lane's boot (PRODUCT.md and DESIGN.md loaded again for the helper, missing files tolerated, dev URL found, bottom bar hidden for the helper's lifetime) and reuses a helper that is already running. Its result rides along as `boot`.
|
|
||||||
- Also available: `--prompt`, `--text` (keep only matches whose visible text contains a snippet), `--index` (1-based pick among matches).
|
|
||||||
|
|
||||||
Read the output in this order: `boot` (or `boot.contextMissing` with `boot.contextNote`: the page is the source of truth, per the note), then `event`, the generate event for `sessionId`, with the same `_instructions` a user's Go gets. Every verdict carries `_instructions`, and they win over your recollection of this file; the ones whose move is a decision of yours:
|
|
||||||
|
|
||||||
- **`ambiguous`**: the candidates are listed; target their common container, or rerun with `--text "<visible text>"` or `--index <n>`.
|
|
||||||
- **`dev_server_gone`**: the dev server stopped answering while the command waited for the page (on Cursor, a server another chat started dies with that chat). Start it the way the verdict says, then rerun with `--dev-url <url>`.
|
|
||||||
- **`no_match`**: the tab is on a route that does not render the element (navigate to the right route, rerun), or the selector is wrong (derive a better one from the source, or add `--text`).
|
|
||||||
- **`config_missing` / `config_invalid`** under `bootError`: follow [live-setup.md](live-setup.md) first, then rerun.
|
|
||||||
- **`event: null`** with `ok: true`: the event was slower than the wait; run `.grok/skills/impeccable/scripts/impeccable live-poll` once to collect it, then continue.
|
|
||||||
|
|
||||||
Done when the output shows `ok: true`, a `sessionId`, and an `event`, reached with at most one server started and one tab opened by you.
|
|
||||||
|
|
||||||
## Step 3: Generate
|
|
||||||
|
|
||||||
The event is a standard `generate` event: the picked element's context, a preflighted scaffold, and `_instructions` naming the action's reference, the planning section, and the exact splice. Handle it exactly per live.md's **Handle generate**, which owns everything from the identity lock to the done reply: read the action's reference and craft-floor.md as it says, plan per section 4 (identity first, then mode, then three different primary axes, then the squint test), declare knobs per section 7, and deliver per section 6 (a complete replacement of the element per variant, the preview CSS plus every variant in one edit at the scaffold's splice). The lane changes nothing about what a variant may be: the moves a live session would make on this element (a promoted tier, a restructured set, a reordered card, a different surface) are open here too. Never screenshot the page; the overlay preview is the review channel until accept.
|
|
||||||
|
|
||||||
**Reply and wait in one call**, with the file you wrote:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
.grok/skills/impeccable/scripts/impeccable live-poll --reply EVENT_ID done --file src/App.jsx --then-poll
|
|
||||||
```
|
|
||||||
|
|
||||||
This replies done (the browser mounts the variants) and then blocks until the user's choice arrives, so run it the way your harness runs a long wait: **Claude Code** in the foreground with your tool's longest timeout (600000 ms), so you are paused until the choice arrives; **Codex** in a yielded foreground exec; **Cursor** in a background terminal with notify on `"type":"(accept|discard|variant_mount_failed|exit)"`. Never pass a short `--timeout=`. While it runs there is nothing else to do: never sleep and never poll its output on a timer; a harness that backgrounds it wakes you when it returns. `{"type":"timeout"}` means the user has not chosen yet: run `live-poll` again and keep waiting. If the edit fails after the browser flipped to GENERATING, `--reply EVENT_ID error "Short reason"` (without `--then-poll`) so the bar resets.
|
|
||||||
|
|
||||||
Then tell the user, in one line, where their variants are: *"Three [bolder] variants are live on [the pricing cards]: cycle with the floating bar's arrows, adjust the Tune knobs, and Accept the keeper."*
|
|
||||||
|
|
||||||
Outside the replace path, read the matching live.md section before acting: `scaffold.previewMode: "svelte-component"` (Svelte previews are edited as components, and their accept is mechanical), `mode: "insert"`, `variant_mount_failed`, `steer`, `manual_edit_apply`, and any `fallback: "agent-driven"` wrap error.
|
|
||||||
|
|
||||||
## Step 4: Accept and close
|
|
||||||
|
|
||||||
The call from Step 3 returns the user's choice. **`discard`**: nothing to do. **`accept`**: `_acceptResult.carbonize: true` is the normal case, and the cleanup is live.md's **Required after accept**, unchanged: move the accepted variant's rules into the stylesheet that already owns the element with real selectors, bake the chosen knob values in, unwrap the element and drop every `data-impeccable-*` attribute, delete the inline `<style>` block and both `impeccable-carbonize` markers, then `.grok/skills/impeccable/scripts/impeccable live-complete --id SESSION_ID` and confirm `phase: "completed"`. (`baked: true` appears only when the accept was run with `--bake`; then the helper already made the variant permanent and no `live-complete` is owed.)
|
|
||||||
|
|
||||||
Close without being asked, the moment the choice is handled:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
.grok/skills/impeccable/scripts/impeccable live-server stop
|
|
||||||
```
|
|
||||||
|
|
||||||
Stopping removes the injected script and reloads the page once: the user sees the accepted design with no overlay chrome, still served by their dev server. **Never kill or restart the dev server**, including one you started in Step 2.
|
|
||||||
|
|
||||||
- **The user asks for more variants before you closed**: skip the close, run Step 2 again for the next element (the helper is reused), and close after the last choice.
|
|
||||||
- **Interrupted or unsure of the state**: `.grok/skills/impeccable/scripts/impeccable live-status`, then `live-resume`; the journal under `.impeccable/live/sessions/` is canonical.
|
|
||||||
|
|
||||||
Done when the helper is stopped and the dev site still answers with the accepted design.
|
|
||||||
@@ -205,11 +205,6 @@ t('items', { count }) // Handles complex plural rules
|
|||||||
- Optimistic updates with rollback
|
- Optimistic updates with rollback
|
||||||
- Conflict resolution
|
- Conflict resolution
|
||||||
|
|
||||||
**Interrupted gestures** (custom sliders, drag surfaces, scrollable control strips):
|
|
||||||
- A second finger or pointer lands mid-drag: the first drag keeps its pointer or ends cleanly, never jumps to the new one
|
|
||||||
- The browser cancels the gesture to scroll (`pointercancel`), capture is lost (`lostpointercapture`), the pointer is released outside the control, or the window loses focus (`blur`) mid-drag: clear the dragging state and release capture
|
|
||||||
- After each of these, the next tap or drag works without a reload
|
|
||||||
|
|
||||||
**Permission states**:
|
**Permission states**:
|
||||||
- No permission to view
|
- No permission to view
|
||||||
- No permission to edit
|
- No permission to edit
|
||||||
@@ -309,7 +304,6 @@ const throttledScroll = throttle(handleScroll, 100);
|
|||||||
- Unit tests for edge cases
|
- Unit tests for edge cases
|
||||||
- Integration tests for error scenarios
|
- Integration tests for error scenarios
|
||||||
- E2E tests for critical paths
|
- E2E tests for critical paths
|
||||||
- A behavioral regression for each confirmed gesture fix, when the project's test runner can drive input
|
|
||||||
- Visual regression tests
|
- Visual regression tests
|
||||||
- Accessibility tests (axe, WAVE)
|
- Accessibility tests (axe, WAVE)
|
||||||
|
|
||||||
@@ -336,10 +330,7 @@ Test thoroughly with edge cases:
|
|||||||
- **Network issues**: Disable internet, throttle connection
|
- **Network issues**: Disable internet, throttle connection
|
||||||
- **Large datasets**: Test with 1000+ items
|
- **Large datasets**: Test with 1000+ items
|
||||||
- **Concurrent actions**: Click submit 10 times rapidly
|
- **Concurrent actions**: Click submit 10 times rapidly
|
||||||
- **Interrupted gestures**: Add a second finger mid-drag, scroll across the control, release outside it, switch windows mid-drag; then drag again
|
|
||||||
- **Errors**: Force API errors, test all error states
|
- **Errors**: Force API errors, test all error states
|
||||||
- **Empty**: Remove all data, test empty states
|
- **Empty**: Remove all data, test empty states
|
||||||
|
|
||||||
For gestures, say what produced the evidence (emulated viewport, synthesized touch, which engine, physical device) and name what stayed untested.
|
|
||||||
|
|
||||||
When edge cases are covered, hand off to `/impeccable polish` for the final pass.
|
When edge cases are covered, hand off to `/impeccable polish` for the final pass.
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ Reason over the signals; there is no score to obey:
|
|||||||
- `critique.latest` is `null` → the project has never been critiqued; for a set-up project with a real surface, offering `/impeccable critique <surface>` is a strong default.
|
- `critique.latest` is `null` → the project has never been critiqued; for a set-up project with a real surface, offering `/impeccable critique <surface>` is a strong default.
|
||||||
- `critique.latest` with a low `score` or non-zero `p0` / `p1` → `polish` (it reads that snapshot as its backlog and closes it when stale or cleared).
|
- `critique.latest` with a low `score` or non-zero `p0` / `p1` → `polish` (it reads that snapshot as its backlog and closes it when stale or cleared).
|
||||||
- `git.changedFiles` pointing at one surface → scope `audit` or `polish` to those files specifically, naming them.
|
- `git.changedFiles` pointing at one surface → scope `audit` or `polish` to those files specifically, naming them.
|
||||||
- `devServer.running` true → `live` is available for in-browser iteration, and `generate` for one-shot variant runs on a named element; if false, don't lead with either. **`live`, `generate`, and the bundled `impeccable detect` are web-only.** If `setup.platform` is `ios`, `android`, or `adaptive`, don't lead with any of them; the browser overlay and the HTML rule engine don't apply to native app code.
|
- `devServer.running` true → `live` is available for in-browser iteration; if false, don't lead with `live`. **`live` and the bundled `impeccable detect` are web-only.** If `setup.platform` is `ios`, `android`, or `adaptive`, don't lead with either; the browser overlay and the HTML rule engine don't apply to native app code.
|
||||||
- Otherwise group by intent (build new / improve what's there / iterate visually), tailored to the current surface and `setup.platform`.
|
- Otherwise group by intent (build new / improve what's there / iterate visually), tailored to the current surface and `setup.platform`.
|
||||||
|
|
||||||
**If `scan.targets` is non-empty and `setup.platform` is not `ios`/`android`/`adaptive`, run `.grok/skills/impeccable/scripts/impeccable detect --json <scan.targets joined by spaces>` once** (the bundled detector over local files: no network, no npx; it reads HTML/CSS, so skip it for native projects). `scan.via` tells you what they are: `git-changes` (the markup/style files in your dirty tree, the most relevant set), `source-dir` (e.g. `src`, `app`), `html`, or `root`. Fold the hits into your picks: many quality / contrast hits → `audit` or `polish`; a specific slop family → the matching command (gradient text or eyebrows → `quieter` / `typeset`, flat or gray palette → `colorize`, and so on). It's a real, current signal that beats guessing. If detect errors or the tree is large and slow, skip it and recommend the user run `audit` themselves; never block the suggestion on it.
|
**If `scan.targets` is non-empty and `setup.platform` is not `ios`/`android`/`adaptive`, run `.grok/skills/impeccable/scripts/impeccable detect --json <scan.targets joined by spaces>` once** (the bundled detector over local files: no network, no npx; it reads HTML/CSS, so skip it for native projects). `scan.via` tells you what they are: `git-changes` (the markup/style files in your dirty tree, the most relevant set), `source-dir` (e.g. `src`, `app`), `html`, or `root`. Fold the hits into your picks: many quality / contrast hits → `audit` or `polish`; a specific slop family → the matching command (gradient text or eyebrows → `quieter` / `typeset`, flat or gray palette → `colorize`, and so on). It's a real, current signal that beats guessing. If detect errors or the tree is large and slow, skip it and recommend the user run `audit` themselves; never block the suggestion on it.
|
||||||
|
|||||||
@@ -19,10 +19,6 @@
|
|||||||
"description": "Interactive live variant mode. Select elements in the browser, pick a design action, and get AI-generated HTML+CSS variants hot-swapped via HMR. Requires a running dev server. Use when you want to visually experiment with design alternatives in real time.",
|
"description": "Interactive live variant mode. Select elements in the browser, pick a design action, and get AI-generated HTML+CSS variants hot-swapped via HMR. Requires a running dev server. Use when you want to visually experiment with design alternatives in real time.",
|
||||||
"argumentHint": ""
|
"argumentHint": ""
|
||||||
},
|
},
|
||||||
"generate": {
|
|
||||||
"description": "Agent-driven live variant generation. Boots live mode, finds the named element on the open page, scrolls the browser to it, and delivers N variants in the requested direction for the user to cycle and accept. Use for requests that name an element and a direction, like 'generate 3 bold variants of the pricing cards', skipping manual element picking.",
|
|
||||||
"argumentHint": "[count] [direction] variants of [element]"
|
|
||||||
},
|
|
||||||
"adapt": {
|
"adapt": {
|
||||||
"description": "Adapt designs to work across different screen sizes, devices, contexts, or platforms. Implements breakpoints, fluid layouts, and touch targets. Use when the user mentions responsive design, mobile layouts, breakpoints, viewport adaptation, or cross-device compatibility.",
|
"description": "Adapt designs to work across different screen sizes, devices, contexts, or platforms. Implements breakpoints, fluid layouts, and touch targets. Use when the user mentions responsive design, mobile layouts, breakpoints, viewport adaptation, or cross-device compatibility.",
|
||||||
"argumentHint": "[target] [context (mobile, tablet, print...)]"
|
"argumentHint": "[target] [context (mobile, tablet, print...)]"
|
||||||
|
|||||||
@@ -87,11 +87,6 @@ fi
|
|||||||
# Last resort: fetch this version's binary for the current platform from the
|
# Last resort: fetch this version's binary for the current platform from the
|
||||||
# public release channel into the user cache. Needs network; sandboxes without
|
# public release channel into the user cache. Needs network; sandboxes without
|
||||||
# egress preinstall the binary on PATH instead.
|
# egress preinstall the binary on PATH instead.
|
||||||
setup_help() {
|
|
||||||
echo "Engine $version setup needs network access and write permission to $cache_root/bin/$version." >&2
|
|
||||||
echo "Run this launcher ($0) with engine-probe in a terminal that has those permissions, then retry the original command." >&2
|
|
||||||
echo "Alternatively, set IMPECCABLE_HOME to a writable cache location, or IMPECCABLE_BIN to a preinstalled engine binary." >&2
|
|
||||||
}
|
|
||||||
fetch_url() {
|
fetch_url() {
|
||||||
if command -v curl >/dev/null 2>&1; then
|
if command -v curl >/dev/null 2>&1; then
|
||||||
curl -fsSL --retry 2 -o "$tmp" "$1" 2>/dev/null
|
curl -fsSL --retry 2 -o "$tmp" "$1" 2>/dev/null
|
||||||
@@ -124,18 +119,7 @@ if [ -n "$version" ] && [ "$os" != unknown ] && [ "$arch" != unknown ]; then
|
|||||||
[ "$os" = windows ] && asset="$asset.exe"
|
[ "$os" = windows ] && asset="$asset.exe"
|
||||||
url="$base/engine-v$version/$asset"
|
url="$base/engine-v$version/$asset"
|
||||||
tmp="$cache_root/bin/$version/.impeccable.part.$$"
|
tmp="$cache_root/bin/$version/.impeccable.part.$$"
|
||||||
if ! mkdir -p "$cache_root/bin/$version" 2>/dev/null; then
|
mkdir -p "$cache_root/bin/$version" 2>/dev/null
|
||||||
echo "impeccable: engine $version is not installed; cannot create cache directory: $cache_root/bin/$version" >&2
|
|
||||||
setup_help
|
|
||||||
exit 127
|
|
||||||
fi
|
|
||||||
# Check the actual staging file, not just directory existence: a cache from
|
|
||||||
# an earlier run can be readable but no longer writable inside a sandbox.
|
|
||||||
if ! (umask 077; : > "$tmp") 2>/dev/null; then
|
|
||||||
echo "impeccable: engine $version is not installed; cannot write to cache directory: $cache_root/bin/$version" >&2
|
|
||||||
setup_help
|
|
||||||
exit 127
|
|
||||||
fi
|
|
||||||
fetched=0
|
fetched=0
|
||||||
if fetch_url "$url"; then
|
if fetch_url "$url"; then
|
||||||
fetched=1
|
fetched=1
|
||||||
@@ -196,9 +180,6 @@ if [ -n "$version" ] && [ "$os" != unknown ] && [ "$arch" != unknown ]; then
|
|||||||
exec "$cached" "$@"
|
exec "$cached" "$@"
|
||||||
fi
|
fi
|
||||||
rm -f "$tmp" 2>/dev/null
|
rm -f "$tmp" 2>/dev/null
|
||||||
echo "impeccable: could not download engine $version from $url; check network access, the release URL, and curl or wget availability." >&2
|
|
||||||
setup_help
|
|
||||||
exit 127
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "impeccable: no engine binary for $os-$arch found (looked in $bin, $cached, PATH)." >&2
|
echo "impeccable: no engine binary for $os-$arch found (looked in $bin, $cached, PATH)." >&2
|
||||||
|
|||||||
@@ -68,27 +68,18 @@ rem another launcher's probe: fail fast and quiet instead.
|
|||||||
if defined IMPECCABLE_LAUNCHER_PROBE exit /b 127
|
if defined IMPECCABLE_LAUNCHER_PROBE exit /b 127
|
||||||
if not defined version goto fail
|
if not defined version goto fail
|
||||||
where curl.exe >nul 2>nul
|
where curl.exe >nul 2>nul
|
||||||
if errorlevel 1 goto curl_missing
|
if errorlevel 1 goto fail
|
||||||
if not defined IMPECCABLE_DOWNLOAD_BASE set "IMPECCABLE_DOWNLOAD_BASE=https://github.com/pbakaus/impeccable/releases/download"
|
if not defined IMPECCABLE_DOWNLOAD_BASE set "IMPECCABLE_DOWNLOAD_BASE=https://github.com/pbakaus/impeccable/releases/download"
|
||||||
if exist "%IMPECCABLE_HOME%\bin\%version%\" goto cache_ready
|
if not exist "%IMPECCABLE_HOME%\bin\%version%" mkdir "%IMPECCABLE_HOME%\bin\%version%" >nul 2>nul
|
||||||
mkdir "%IMPECCABLE_HOME%\bin\%version%" >nul 2>nul
|
|
||||||
if errorlevel 1 goto cache_directory_failed
|
|
||||||
:cache_ready
|
|
||||||
rem Check the staging file too: an existing directory may be read-only.
|
|
||||||
rem Redirection failures do not reliably update ERRORLEVEL in cmd.exe;
|
|
||||||
rem branch on the command's failure directly. Never treat a directory as a
|
|
||||||
rem staging file (later del cleanup would prompt to delete its contents).
|
|
||||||
if exist "%cached%.part\" goto cache_write_failed
|
|
||||||
(type nul >"%cached%.part") 2>nul || goto cache_write_failed
|
|
||||||
set "asset=impeccable-windows-%arch%.exe"
|
set "asset=impeccable-windows-%arch%.exe"
|
||||||
set "url=%IMPECCABLE_DOWNLOAD_BASE%/engine-v%version%/%asset%"
|
set "url=%IMPECCABLE_DOWNLOAD_BASE%/engine-v%version%/%asset%"
|
||||||
curl.exe -fsSL -o "%cached%.part" "%url%" >nul 2>nul
|
curl.exe -fsSL -o "%cached%.part" "%url%" >nul 2>nul
|
||||||
if not errorlevel 1 goto verify
|
if not errorlevel 1 goto verify
|
||||||
if not "%arch%"=="arm64" goto download_failed
|
if not "%arch%"=="arm64" goto fail
|
||||||
set "asset=impeccable-windows-x64.exe"
|
set "asset=impeccable-windows-x64.exe"
|
||||||
set "url=%IMPECCABLE_DOWNLOAD_BASE%/engine-v%version%/%asset%"
|
set "url=%IMPECCABLE_DOWNLOAD_BASE%/engine-v%version%/%asset%"
|
||||||
curl.exe -fsSL -o "%cached%.part" "%url%" >nul 2>nul
|
curl.exe -fsSL -o "%cached%.part" "%url%" >nul 2>nul
|
||||||
if errorlevel 1 goto download_failed
|
if errorlevel 1 goto fail
|
||||||
|
|
||||||
:verify
|
:verify
|
||||||
call :check_download
|
call :check_download
|
||||||
@@ -185,28 +176,6 @@ if not errorlevel 1 set "probe_ok=1"
|
|||||||
del "%probe_tmp%" >nul 2>nul
|
del "%probe_tmp%" >nul 2>nul
|
||||||
exit /b 0
|
exit /b 0
|
||||||
|
|
||||||
:cache_directory_failed
|
|
||||||
echo impeccable: engine %version% is not installed; cannot create cache directory: "%IMPECCABLE_HOME%\bin\%version%" 1>&2
|
|
||||||
goto setup_failed
|
|
||||||
|
|
||||||
:cache_write_failed
|
|
||||||
echo impeccable: engine %version% is not installed; cannot write to cache directory: "%IMPECCABLE_HOME%\bin\%version%" 1>&2
|
|
||||||
goto setup_failed
|
|
||||||
|
|
||||||
:curl_missing
|
|
||||||
echo impeccable: cannot download engine %version%; curl.exe is unavailable. 1>&2
|
|
||||||
goto setup_failed
|
|
||||||
|
|
||||||
:download_failed
|
|
||||||
del "%cached%.part" >nul 2>nul
|
|
||||||
echo impeccable: could not download engine %version% from %url%; check network access and the release URL. 1>&2
|
|
||||||
|
|
||||||
:setup_failed
|
|
||||||
echo Engine %version% setup needs network access and write permission to "%IMPECCABLE_HOME%\bin\%version%". 1>&2
|
|
||||||
echo Run this launcher ("%~f0") with engine-probe in a terminal that has those permissions, then retry the original command. 1>&2
|
|
||||||
echo Alternatively, set IMPECCABLE_HOME to a writable cache location, or IMPECCABLE_BIN to a preinstalled engine binary. 1>&2
|
|
||||||
exit /b 127
|
|
||||||
|
|
||||||
:fail
|
:fail
|
||||||
del "%cached%.part" >nul 2>nul
|
del "%cached%.part" >nul 2>nul
|
||||||
echo impeccable: no engine binary found (looked in %bin%, %cached%, PATH). 1>&2
|
echo impeccable: no engine binary found (looked in %bin%, %cached%, PATH). 1>&2
|
||||||
|
|||||||
@@ -165,14 +165,6 @@
|
|||||||
}
|
}
|
||||||
let parameterGenerationState = 'idle';
|
let parameterGenerationState = 'idle';
|
||||||
let parameterReadyAnnouncedSession = null;
|
let parameterReadyAnnouncedSession = null;
|
||||||
// 'agent' when the generate verb fired this session's Go (the generate
|
|
||||||
// lane declares no knobs, so its bar never shows a pending Tune chip);
|
|
||||||
// null for every Go a user presses.
|
|
||||||
let sessionOrigin = null;
|
|
||||||
// The generate lane picks for the agent and never edits copy in the
|
|
||||||
// browser, so its selection carries no edit-copy badge (set on the
|
|
||||||
// agent-target pick, cleared with the session; a user's pick never sets it).
|
|
||||||
let editBadgeSuppressed = false;
|
|
||||||
let svelteComponentSession = null;
|
let svelteComponentSession = null;
|
||||||
let svelteRuntimePromise = null;
|
let svelteRuntimePromise = null;
|
||||||
let pendingSvelteComponentRetryObserver = null;
|
let pendingSvelteComponentRetryObserver = null;
|
||||||
@@ -991,20 +983,9 @@
|
|||||||
}
|
}
|
||||||
} catch { /* cross-origin */ }
|
} catch { /* cross-origin */ }
|
||||||
}
|
}
|
||||||
// The selector a mechanical bake would anchor lasting rules on, and how
|
|
||||||
// many elements it matches right now: the bake refuses anything but one,
|
|
||||||
// since its rules would restyle every match, not just this element.
|
|
||||||
const cssIdent = (s) => /^[A-Za-z_-][\w-]*$/.test(s);
|
|
||||||
const anchorClasses = [...el.classList].filter(cssIdent);
|
|
||||||
const anchor = el.id && cssIdent(el.id)
|
|
||||||
? '#' + el.id
|
|
||||||
: (anchorClasses.length ? el.tagName.toLowerCase() + '.' + anchorClasses.join('.') : null);
|
|
||||||
let anchorMatches = null;
|
|
||||||
if (anchor) { try { anchorMatches = document.querySelectorAll(anchor).length; } catch { anchorMatches = null; } }
|
|
||||||
return {
|
return {
|
||||||
tagName: el.tagName.toLowerCase(), id: el.id || null,
|
tagName: el.tagName.toLowerCase(), id: el.id || null,
|
||||||
classes: [...el.classList],
|
classes: [...el.classList],
|
||||||
anchor, anchorMatches,
|
|
||||||
textContent: (el.textContent || '').slice(0, 500),
|
textContent: (el.textContent || '').slice(0, 500),
|
||||||
outerHTML: sanitizedContextOuterHTML(el, 10000),
|
outerHTML: sanitizedContextOuterHTML(el, 10000),
|
||||||
computedStyles: {
|
computedStyles: {
|
||||||
@@ -2056,7 +2037,6 @@
|
|||||||
function setLiveState(next) {
|
function setLiveState(next) {
|
||||||
state = next;
|
state = next;
|
||||||
window.__IMPECCABLE_LIVE_STATE__ = next;
|
window.__IMPECCABLE_LIVE_STATE__ = next;
|
||||||
retryDeclinedAgentTargets();
|
|
||||||
syncPageInteractionCursor();
|
syncPageInteractionCursor();
|
||||||
// Whether a queued steer is still behind a generation is a function of this
|
// Whether a queued steer is still behind a generation is a function of this
|
||||||
// state, so the hint has to move with it, not only with the 5s poll.
|
// state, so the hint has to move with it, not only with the 5s poll.
|
||||||
@@ -4034,7 +4014,6 @@
|
|||||||
|
|
||||||
function hidePendingApplyDock() {
|
function hidePendingApplyDock() {
|
||||||
pendingApplyInFlight = false;
|
pendingApplyInFlight = false;
|
||||||
retryDeclinedAgentTargets();
|
|
||||||
clearStoredManualApplyState();
|
clearStoredManualApplyState();
|
||||||
if (pendingIntroAnimation) { pendingIntroAnimation.cancel(); pendingIntroAnimation = null; }
|
if (pendingIntroAnimation) { pendingIntroAnimation.cancel(); pendingIntroAnimation = null; }
|
||||||
if (pendingDockEl) pendingDockEl.style.display = 'none';
|
if (pendingDockEl) pendingDockEl.style.display = 'none';
|
||||||
@@ -4068,7 +4047,6 @@
|
|||||||
function setPendingApplyLoading(loading, count) {
|
function setPendingApplyLoading(loading, count) {
|
||||||
if (!pendingPillEl || !pendingPillLabelEl || !pendingPillCountEl || !pendingTrashBtn) return;
|
if (!pendingPillEl || !pendingPillLabelEl || !pendingPillCountEl || !pendingTrashBtn) return;
|
||||||
pendingApplyInFlight = loading === true;
|
pendingApplyInFlight = loading === true;
|
||||||
if (!pendingApplyInFlight) retryDeclinedAgentTargets();
|
|
||||||
const currentCount = count || parseInt(pendingPillEl.dataset.count || '0', 10) || 0;
|
const currentCount = count || parseInt(pendingPillEl.dataset.count || '0', 10) || 0;
|
||||||
if (pendingApplyInFlight) storeManualApplyState(currentCount);
|
if (pendingApplyInFlight) storeManualApplyState(currentCount);
|
||||||
else clearStoredManualApplyState();
|
else clearStoredManualApplyState();
|
||||||
@@ -4710,7 +4688,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderEditBadge(mode) {
|
function renderEditBadge(mode) {
|
||||||
if (editBadgeSuppressed || sessionOrigin === 'agent') mode = 'hidden';
|
|
||||||
if (mode === 'hidden' || !editBadgeEl) {
|
if (mode === 'hidden' || !editBadgeEl) {
|
||||||
hideConfigureBarTooltip();
|
hideConfigureBarTooltip();
|
||||||
if (editBadgeEl) editBadgeEl.style.display = 'none';
|
if (editBadgeEl) editBadgeEl.style.display = 'none';
|
||||||
@@ -6204,8 +6181,6 @@
|
|||||||
resetSessionFileMeta();
|
resetSessionFileMeta();
|
||||||
currentSessionId = null;
|
currentSessionId = null;
|
||||||
parameterGenerationState = 'idle';
|
parameterGenerationState = 'idle';
|
||||||
sessionOrigin = null;
|
|
||||||
editBadgeSuppressed = false;
|
|
||||||
parameterReadyAnnouncedSession = null;
|
parameterReadyAnnouncedSession = null;
|
||||||
expectedVariants = 0;
|
expectedVariants = 0;
|
||||||
arrivedVariants = 0;
|
arrivedVariants = 0;
|
||||||
@@ -7137,398 +7112,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
//
|
//
|
||||||
// ------------------------------------------------------------------
|
|
||||||
// Agent-initiated targeting (the `generate` command). The agent names an
|
|
||||||
// element by CSS selector over POST /agent-target; the server pushes an
|
|
||||||
// `agent_target` SSE message here. The overlay resolves the selector,
|
|
||||||
// scrolls the element into view, enters the same picked state a user
|
|
||||||
// click produces, and fires the normal Go pipeline, so everything
|
|
||||||
// downstream (generate event, variants, cycling, accept) is unchanged.
|
|
||||||
// The verdict goes back through POST /agent-target-result, which resolves
|
|
||||||
// the agent's held-open CLI call.
|
|
||||||
|
|
||||||
function postAgentTargetResult(targetId, result) {
|
|
||||||
fetch('http://localhost:' + PORT + '/agent-target-result?token=' + TOKEN, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ token: TOKEN, targetId, clientId: AGENT_TARGET_CLIENT_ID, ...result }),
|
|
||||||
}).catch(() => { /* server gone; nothing to report to */ });
|
|
||||||
}
|
|
||||||
|
|
||||||
function describeAgentTargetCandidate(el) {
|
|
||||||
return {
|
|
||||||
tag: el.tagName.toLowerCase(),
|
|
||||||
id: el.id || null,
|
|
||||||
classes: [...el.classList].filter((c) => !c.startsWith('impeccable-')),
|
|
||||||
text: (el.textContent || '').trim().slice(0, 80),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveAgentTargetElement(msg) {
|
|
||||||
let matched;
|
|
||||||
try {
|
|
||||||
matched = [...document.querySelectorAll(msg.selector)];
|
|
||||||
} catch {
|
|
||||||
return { error: { ok: false, error: 'invalid_selector', selector: msg.selector } };
|
|
||||||
}
|
|
||||||
let candidates = matched.filter((el) => pickable(el));
|
|
||||||
if (msg.text) {
|
|
||||||
const needle = String(msg.text).toLowerCase();
|
|
||||||
candidates = candidates.filter((el) => (el.textContent || '').toLowerCase().includes(needle));
|
|
||||||
}
|
|
||||||
if (candidates.length === 0) {
|
|
||||||
return {
|
|
||||||
error: {
|
|
||||||
ok: false,
|
|
||||||
error: 'no_match',
|
|
||||||
selector: msg.selector,
|
|
||||||
matchCount: 0,
|
|
||||||
// How many nodes the raw selector hit before the pickable/text
|
|
||||||
// filters: distinguishes a wrong selector from an unpickable match.
|
|
||||||
rawMatchCount: matched.length,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (Number.isInteger(msg.index)) {
|
|
||||||
const el = candidates[msg.index - 1];
|
|
||||||
if (!el) {
|
|
||||||
return { error: { ok: false, error: 'index_out_of_range', selector: msg.selector, matchCount: candidates.length } };
|
|
||||||
}
|
|
||||||
return { el, matchCount: candidates.length };
|
|
||||||
}
|
|
||||||
if (candidates.length > 1) {
|
|
||||||
return {
|
|
||||||
error: {
|
|
||||||
ok: false,
|
|
||||||
error: 'ambiguous',
|
|
||||||
selector: msg.selector,
|
|
||||||
matchCount: candidates.length,
|
|
||||||
candidates: candidates.slice(0, 8).map(describeAgentTargetCandidate),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return { el: candidates[0], matchCount: 1 };
|
|
||||||
}
|
|
||||||
|
|
||||||
function scrollAgentTargetIntoView(el, done) {
|
|
||||||
const rect = el.getBoundingClientRect();
|
|
||||||
if (rect.top >= 0 && rect.bottom <= window.innerHeight) { done(); return; }
|
|
||||||
let settled = false;
|
|
||||||
let fallback = null;
|
|
||||||
const finish = () => {
|
|
||||||
if (settled) return;
|
|
||||||
settled = true;
|
|
||||||
removeEventListener('scrollend', finish, true);
|
|
||||||
if (fallback) clearTimeout(fallback);
|
|
||||||
done();
|
|
||||||
};
|
|
||||||
// scrollend where supported; a timer covers engines without it and the
|
|
||||||
// no-movement case (element already at its final resting position).
|
|
||||||
addEventListener('scrollend', finish, true);
|
|
||||||
fallback = setTimeout(finish, 1200);
|
|
||||||
el.scrollIntoView({ block: 'center', behavior: 'smooth' });
|
|
||||||
}
|
|
||||||
|
|
||||||
// One id per page load: the server keys claims and roll-call reports on
|
|
||||||
// it, and only the tab that holds the lease can renew it.
|
|
||||||
const AGENT_TARGET_CLIENT_ID = id8();
|
|
||||||
|
|
||||||
// The agent target an agent-initiated Go is serving: set by
|
|
||||||
// actOnAgentTarget around its handleGo call, read once by handleGo.
|
|
||||||
let agentTargetForGo = null;
|
|
||||||
|
|
||||||
// The helper's word on its global bar. The generate lane asks the helper
|
|
||||||
// to keep it out of the way (`impeccable live --no-live-bar`, or an agent
|
|
||||||
// target carrying hideLiveBar), and the helper tells every connected tab
|
|
||||||
// at once (`live_bar`) and every later connection on `connected`, so the
|
|
||||||
// bar stays hidden in every tab, through reloads, the accept, and the
|
|
||||||
// bake, until the helper stops and takes the overlay with it. The variant
|
|
||||||
// controls still show.
|
|
||||||
let liveBarHiddenByHelper = false;
|
|
||||||
|
|
||||||
function applyLiveBarPreference(hidden) {
|
|
||||||
liveBarHiddenByHelper = hidden === true;
|
|
||||||
setLiveBarHidden(liveBarHiddenByHelper);
|
|
||||||
}
|
|
||||||
|
|
||||||
// A plain live session must never notice this code: hiding remembers the
|
|
||||||
// bar's own display value and restoring puts exactly that back, and a
|
|
||||||
// restore on a bar that is not hidden is a no-op, so the `connected`
|
|
||||||
// frame every session receives changes nothing unless the lane asked.
|
|
||||||
function setLiveBarHidden(hidden) {
|
|
||||||
if (!globalBarEl) return;
|
|
||||||
if (hidden) {
|
|
||||||
if (globalBarEl.style.display !== 'none') {
|
|
||||||
globalBarEl.dataset.liveBarDisplay = globalBarEl.style.display || 'flex';
|
|
||||||
globalBarEl.style.display = 'none';
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (globalBarEl.style.display === 'none') {
|
|
||||||
globalBarEl.style.display = globalBarEl.dataset.liveBarDisplay || 'flex';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function claimAgentTarget(targetId, report) {
|
|
||||||
return fetch('http://localhost:' + PORT + '/agent-target-claim?token=' + TOKEN, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ token: TOKEN, targetId, clientId: AGENT_TARGET_CLIENT_ID, ...report }),
|
|
||||||
}).then((res) => res.json())
|
|
||||||
.then((j) => ({ granted: !!j && j.granted === true, pending: !!j && j.pending === true }))
|
|
||||||
.catch(() => ({ granted: false, pending: false }));
|
|
||||||
}
|
|
||||||
|
|
||||||
// `exceptTargetId` is the target this call is about: a tab acting on it
|
|
||||||
// is not busy for itself, but it is busy for every other target, or two
|
|
||||||
// held requests could both be claimed here and the second Go would
|
|
||||||
// overwrite the session the first one minted.
|
|
||||||
function agentTargetBusyReason(exceptTargetId) {
|
|
||||||
if (pendingApplyInFlight) return 'manual_apply_in_flight';
|
|
||||||
if (state !== 'IDLE' && state !== 'PICKING' && state !== 'CONFIGURING') return 'session_active';
|
|
||||||
for (const [targetId, status] of agentTargetsSeen) {
|
|
||||||
if (status === 'acting' && targetId !== exceptTargetId) return 'agent_target_in_flight';
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Targets this tab declined as busy. A busy report is only this tab's word
|
|
||||||
// at that moment: the moment it is free again (setLiveState), it claims
|
|
||||||
// each of these as eligible, and the server drops the stale report, so a
|
|
||||||
// busy verdict is never built on a tab that has since gone idle. The
|
|
||||||
// server denies claims for resolved targets, so retries are harmless.
|
|
||||||
const busyDeclinedTargets = new Map();
|
|
||||||
|
|
||||||
function declineAgentTargetBusy(msg, busy) {
|
|
||||||
busyDeclinedTargets.set(msg.targetId, msg);
|
|
||||||
noteAgentTarget(msg.targetId, 'declined');
|
|
||||||
claimAgentTarget(msg.targetId, { eligible: false, state, reason: busy });
|
|
||||||
}
|
|
||||||
|
|
||||||
// A torn-down overlay, or one whose helper connection is gone, cannot
|
|
||||||
// serve a target and must not even claim one: it would hold the lease for
|
|
||||||
// a request it will never act on.
|
|
||||||
function agentTargetOverlayGone() {
|
|
||||||
return !evtSource;
|
|
||||||
}
|
|
||||||
|
|
||||||
// A denied claimant retries at this cadence, a little over the lease, so
|
|
||||||
// the first retry after a dead holder's lease lapses is granted.
|
|
||||||
const AGENT_TARGET_RESCUE_RETRY_MS = 3500;
|
|
||||||
|
|
||||||
// Claim the lease and act as the holder. A denied claim means another tab
|
|
||||||
// holds the lease. That holder can die before posting its result (reload,
|
|
||||||
// crash, even after renewing), and its lease lapses after ~3s, so this tab
|
|
||||||
// keeps retrying for as long as the server still holds the request: the
|
|
||||||
// answer's `pending` is the server's word that the request is alive, and
|
|
||||||
// it turns false the moment the request resolved or timed out, so no tab
|
|
||||||
// retries a request nobody awaits. A tab that turned busy meanwhile joins
|
|
||||||
// the roll call instead of taking a lease it cannot use. The first claim
|
|
||||||
// and the busy-to-idle re-claim share this.
|
|
||||||
function claimAndActOnAgentTarget(msg) {
|
|
||||||
if (agentTargetOverlayGone()) return;
|
|
||||||
const busy = agentTargetBusyReason(msg.targetId);
|
|
||||||
if (busy) { declineAgentTargetBusy(msg, busy); return; }
|
|
||||||
if (declineAgentTargetUnresolvable(msg)) return;
|
|
||||||
claimAgentTarget(msg.targetId, { eligible: true }).then((claim) => {
|
|
||||||
if (claim.granted) { noteAgentTarget(msg.targetId, 'acting'); actOnAgentTarget(msg); return; }
|
|
||||||
noteAgentTarget(msg.targetId, 'denied');
|
|
||||||
if (!claim.pending) return;
|
|
||||||
setTimeout(() => claimAndActOnAgentTarget(msg), AGENT_TARGET_RESCUE_RETRY_MS);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function retryDeclinedAgentTargets() {
|
|
||||||
if (busyDeclinedTargets.size === 0 || agentTargetBusyReason()) return;
|
|
||||||
for (const [targetId, msg] of busyDeclinedTargets) {
|
|
||||||
busyDeclinedTargets.delete(targetId);
|
|
||||||
claimAndActOnAgentTarget(msg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// This page's participation in each target it heard: 'acting' once a
|
|
||||||
// claim was granted, 'done' once it replied (or stood down from a lapsed
|
|
||||||
// lease), else the word it last gave. The server replays pending targets
|
|
||||||
// to every connection that opens. After a reconnect that overlapped the
|
|
||||||
// old connection the server still holds this page's word; after one that
|
|
||||||
// did not, it dropped the word on the close, so a replayed target is
|
|
||||||
// handled again: a busy or unresolvable page re-declines (idempotent), an
|
|
||||||
// idle page claims.
|
|
||||||
const agentTargetsSeen = new Map();
|
|
||||||
function noteAgentTarget(targetId, status) {
|
|
||||||
agentTargetsSeen.set(targetId, status);
|
|
||||||
if (agentTargetsSeen.size > 100) agentTargetsSeen.delete(agentTargetsSeen.keys().next().value);
|
|
||||||
}
|
|
||||||
|
|
||||||
// A target this page took a lease on is off-limits for a replay: while
|
|
||||||
// acting (a second claim or Go), and once done, because its result may
|
|
||||||
// still be on the wire and this tab is GENERATING by then, so handling
|
|
||||||
// the replay would decline busy, hand the lease back mid-resolution, and
|
|
||||||
// let another tab fire a second Go.
|
|
||||||
function agentTargetTaken(targetId) {
|
|
||||||
const status = agentTargetsSeen.get(targetId);
|
|
||||||
return status === 'acting' || status === 'done';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only a page that can resolve the target claims it. A tab whose page
|
|
||||||
// lacks the element declines with its resolution verdict instead, so a
|
|
||||||
// first-wins claim never lets the wrong page answer for a target that
|
|
||||||
// another page has. The server prefers a busy report (a tab that could
|
|
||||||
// serve later) over these, and returns the resolution verdict only when
|
|
||||||
// no connected page can serve.
|
|
||||||
//
|
|
||||||
// An element can be momentarily absent (a route still rendering, an HMR
|
|
||||||
// commit mid-swap), so a failed resolution is not this page's final word:
|
|
||||||
// it is re-checked a few times over about two seconds, claiming the
|
|
||||||
// moment the element mounts, and only the last miss is reported. The
|
|
||||||
// server's timeout still bounds the whole exchange.
|
|
||||||
// The page reports the miss at once (so the other overlays' words can
|
|
||||||
// complete the roll call) and keeps re-checking at this cadence for as
|
|
||||||
// long as the server says the request is pending: the server holds an
|
|
||||||
// all-no_match roll call open for a short grace precisely so a late mount
|
|
||||||
// can still be claimed, drops the stale report on an eligible claim, and
|
|
||||||
// ends the watch by answering pending:false once the request resolved or
|
|
||||||
// timed out.
|
|
||||||
const AGENT_TARGET_RESOLVE_WATCH_MS = 500;
|
|
||||||
|
|
||||||
function declineAgentTargetUnresolvable(msg) {
|
|
||||||
const probe = resolveAgentTargetElement(msg);
|
|
||||||
if (!probe.error) return false;
|
|
||||||
reportAgentTargetUnresolvable(msg, probe.error);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function reportAgentTargetUnresolvable(msg, error) {
|
|
||||||
noteAgentTarget(msg.targetId, 'declined');
|
|
||||||
claimAgentTarget(msg.targetId, { eligible: false, state, reason: 'no_match', result: error }).then((answer) => {
|
|
||||||
if (!answer.pending) return;
|
|
||||||
setTimeout(() => watchAgentTargetResolution(msg, error), AGENT_TARGET_RESOLVE_WATCH_MS);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function watchAgentTargetResolution(msg, lastError) {
|
|
||||||
if (agentTargetOverlayGone() || agentTargetTaken(msg.targetId)) return;
|
|
||||||
const busy = agentTargetBusyReason(msg.targetId);
|
|
||||||
if (busy) { declineAgentTargetBusy(msg, busy); return; }
|
|
||||||
const probe = resolveAgentTargetElement(msg);
|
|
||||||
if (!probe.error) { claimAndActOnAgentTarget(msg); return; }
|
|
||||||
// Still unresolvable: re-report (idempotent); the answer says whether
|
|
||||||
// the server is still holding the request open.
|
|
||||||
reportAgentTargetUnresolvable(msg, probe.error || lastError);
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleAgentTarget(msg) {
|
|
||||||
if (!msg || typeof msg.targetId !== 'string') return;
|
|
||||||
if (agentTargetTaken(msg.targetId)) return;
|
|
||||||
noteAgentTarget(msg.targetId, 'heard');
|
|
||||||
const busy = agentTargetBusyReason(msg.targetId);
|
|
||||||
if (busy) {
|
|
||||||
// Roll call: a busy tab reports itself and never acts. The server
|
|
||||||
// answers `busy` the moment every connected overlay has reported, so
|
|
||||||
// an idle tab elsewhere is never raced by a timer.
|
|
||||||
declineAgentTargetBusy(msg, busy);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (declineAgentTargetUnresolvable(msg)) return;
|
|
||||||
// Eligible tabs race for the server's lease and only the holder acts. A
|
|
||||||
// hidden tab yields a short head start so a visible one wins when both
|
|
||||||
// exist, and still serves the request on its own: the user finds the
|
|
||||||
// selection waiting when they return to it.
|
|
||||||
setTimeout(() => claimAndActOnAgentTarget(msg), document.hidden ? 150 : 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
function actOnAgentTarget(msg) {
|
|
||||||
if (agentTargetOverlayGone()) return;
|
|
||||||
// Every exit ends this tab's acting state, so a later target is not
|
|
||||||
// refused for a Go that already happened or never will.
|
|
||||||
const reply = (result) => { noteAgentTarget(msg.targetId, 'done'); postAgentTargetResult(msg.targetId, result); };
|
|
||||||
const busy = agentTargetBusyReason(msg.targetId);
|
|
||||||
if (busy) {
|
|
||||||
// Turned busy between claim and act: report it, which also hands the
|
|
||||||
// lease back so the roll call can complete or a rescuer can claim.
|
|
||||||
declineAgentTargetBusy(msg, busy);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const resolved = resolveAgentTargetElement(msg);
|
|
||||||
if (resolved.error) {
|
|
||||||
// The element went away between claim and act. A result would end the
|
|
||||||
// request for every tab; a decline hands the lease back so another
|
|
||||||
// page or a remount can still serve it.
|
|
||||||
reportAgentTargetUnresolvable(msg, resolved.error);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const el = resolved.el;
|
|
||||||
if (msg.dryRun) {
|
|
||||||
reply({
|
|
||||||
ok: true,
|
|
||||||
dryRun: true,
|
|
||||||
matchCount: resolved.matchCount,
|
|
||||||
element: describeAgentTargetCandidate(el),
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
scrollAgentTargetIntoView(el, () => {
|
|
||||||
// Torn down during the scroll settle: do not renew. The lease lapses
|
|
||||||
// for a rescuer instead of Go minting a session on a dismantled
|
|
||||||
// overlay.
|
|
||||||
if (agentTargetOverlayGone()) return;
|
|
||||||
// Renew the lease right before the irreversible part: a tab whose
|
|
||||||
// lease lapsed while it scrolled (a rescuer took over) stops here, so
|
|
||||||
// one request never gets two Go presses.
|
|
||||||
claimAgentTarget(msg.targetId, { eligible: true }).then((renewal) => {
|
|
||||||
if (!renewal.granted) { noteAgentTarget(msg.targetId, 'done'); return; }
|
|
||||||
// An insert placement left mid-configure gives way, exactly as a
|
|
||||||
// click outside it does in handleClick.
|
|
||||||
if (state === 'CONFIGURING' && configureKind === 'insert') cancelInsertConfigure();
|
|
||||||
// Mirror of the user-click pick entry in handleClick, minus the
|
|
||||||
// pick-mode gate (the agent's intent replaces the toggle); the entry
|
|
||||||
// goes through beginNewLiveConfiguration like every other pick so
|
|
||||||
// deferred recovery sees a fresh interaction revision.
|
|
||||||
selectedElement = el;
|
|
||||||
beginNewLiveConfiguration();
|
|
||||||
showHighlight(selectedElement);
|
|
||||||
clearAnnotations();
|
|
||||||
showAnnotOverlay(selectedElement);
|
|
||||||
showBar('configure');
|
|
||||||
editBadgeSuppressed = true;
|
|
||||||
renderEditBadge('hidden');
|
|
||||||
startScrollTracking();
|
|
||||||
maybePrefetchPage();
|
|
||||||
maybeWarnConditionalAncestor(selectedElement);
|
|
||||||
// Preset what the agent asked for, then fire the same Go a user press
|
|
||||||
// fires. handleGo reads exactly these inputs.
|
|
||||||
selectedAction = msg.action;
|
|
||||||
selectedCount = msg.count;
|
|
||||||
// updateBarContent rebuilds the configure row and replaces the input
|
|
||||||
// element, so the prompt must be written into the input it creates,
|
|
||||||
// never before (the action-chip click handler does the same dance).
|
|
||||||
updateBarContent('configure');
|
|
||||||
const input = uiGetById(PREFIX + '-input');
|
|
||||||
if (input) input.value = msg.prompt || '';
|
|
||||||
// The target rides on the generate event too: the helper resolves
|
|
||||||
// the request from whichever lands first, so a page that dies
|
|
||||||
// between Go and its result cannot leave the request pending for a
|
|
||||||
// second Go elsewhere.
|
|
||||||
const candidate = describeAgentTargetCandidate(el);
|
|
||||||
agentTargetForGo = { targetId: msg.targetId, matchCount: resolved.matchCount, action: msg.action, count: msg.count, element: candidate };
|
|
||||||
handleGo();
|
|
||||||
agentTargetForGo = null;
|
|
||||||
if (state === 'GENERATING' && currentSessionId) {
|
|
||||||
reply({
|
|
||||||
ok: true,
|
|
||||||
matchCount: resolved.matchCount,
|
|
||||||
sessionId: currentSessionId,
|
|
||||||
action: msg.action,
|
|
||||||
count: msg.count,
|
|
||||||
element: candidate,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
reply({ ok: false, error: 'go_failed', state });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// SSE (server→browser) + fetch POST (browser→server)
|
// SSE (server→browser) + fetch POST (browser→server)
|
||||||
// Zero-dependency replacement for WebSocket.
|
// Zero-dependency replacement for WebSocket.
|
||||||
//
|
//
|
||||||
@@ -7538,7 +7121,7 @@
|
|||||||
const SSE_MAX_RETRIES = 20; // generous: heartbeats keep the connection alive, so retries mean real trouble
|
const SSE_MAX_RETRIES = 20; // generous: heartbeats keep the connection alive, so retries mean real trouble
|
||||||
|
|
||||||
function connectSSE() {
|
function connectSSE() {
|
||||||
evtSource = new EventSource('http://localhost:' + PORT + '/events?token=' + TOKEN + '&clientId=' + AGENT_TARGET_CLIENT_ID);
|
evtSource = new EventSource('http://localhost:' + PORT + '/events?token=' + TOKEN);
|
||||||
|
|
||||||
evtSource.onopen = () => {
|
evtSource.onopen = () => {
|
||||||
sseRetries = 0; // reset on successful (re)connect
|
sseRetries = 0; // reset on successful (re)connect
|
||||||
@@ -7549,11 +7132,8 @@
|
|||||||
let msg; try { msg = JSON.parse(e.data); } catch { return; }
|
let msg; try { msg = JSON.parse(e.data); } catch { return; }
|
||||||
switch (msg.type) {
|
switch (msg.type) {
|
||||||
case 'connected':
|
case 'connected':
|
||||||
applyLiveBarPreference(msg.hideLiveBar === true);
|
|
||||||
hasProjectContext = !!msg.hasProjectContext;
|
hasProjectContext = !!msg.hasProjectContext;
|
||||||
// The generate lane runs without PRODUCT.md by design and never
|
if (!hasProjectContext) showToast(`No PRODUCT.md found. Variants will be brand-agnostic. Run ${IMPECCABLE_COMMAND} init to generate one.`, 7000);
|
||||||
// sends the user to init, so its quiet chrome skips this notice.
|
|
||||||
if (!hasProjectContext && !liveBarHiddenByHelper) showToast(`No PRODUCT.md found. Variants will be brand-agnostic. Run ${IMPECCABLE_COMMAND} init to generate one.`, 7000);
|
|
||||||
console.log('[impeccable] Live mode connected.');
|
console.log('[impeccable] Live mode connected.');
|
||||||
syncAgentPollingUi(!!msg.agentPolling);
|
syncAgentPollingUi(!!msg.agentPolling);
|
||||||
startAgentStatusPoll();
|
startAgentStatusPoll();
|
||||||
@@ -7563,15 +7143,9 @@
|
|||||||
syncPageInteractionCursor();
|
syncPageInteractionCursor();
|
||||||
syncPageChatFocus('sse-connected');
|
syncPageChatFocus('sse-connected');
|
||||||
break;
|
break;
|
||||||
case 'live_bar':
|
|
||||||
applyLiveBarPreference(msg.hidden === true);
|
|
||||||
break;
|
|
||||||
case 'agent_polling':
|
case 'agent_polling':
|
||||||
syncAgentPollingUi(!!msg.connected);
|
syncAgentPollingUi(!!msg.connected);
|
||||||
break;
|
break;
|
||||||
case 'agent_target':
|
|
||||||
handleAgentTarget(msg);
|
|
||||||
break;
|
|
||||||
case 'agent_phase':
|
case 'agent_phase':
|
||||||
if (msg.id === currentSessionId && (state === 'GENERATING' || state === 'CYCLING')) {
|
if (msg.id === currentSessionId && (state === 'GENERATING' || state === 'CYCLING')) {
|
||||||
// Advance the visible phase monotonically. A behind/resumed
|
// Advance the visible phase monotonically. A behind/resumed
|
||||||
@@ -7634,11 +7208,6 @@
|
|||||||
disableInlineEdit();
|
disableInlineEdit();
|
||||||
refreshParamsPanel();
|
refreshParamsPanel();
|
||||||
}
|
}
|
||||||
// The done reply is the agent's last word on this generation:
|
|
||||||
// with every variant mounted and no knobs declared, the Tune
|
|
||||||
// chip must stop spinning. A reload between the mount and this
|
|
||||||
// reply restored the pending state from the cache.
|
|
||||||
completeParameterGenerationIfReady();
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
// Source fallback when HMR did not land variants in this tab.
|
// Source fallback when HMR did not land variants in this tab.
|
||||||
@@ -7802,15 +7371,6 @@
|
|||||||
}).then(async res => {
|
}).then(async res => {
|
||||||
if (res.ok) return res;
|
if (res.ok) return res;
|
||||||
const body = await res.json().catch(() => ({}));
|
const body = await res.json().catch(() => ({}));
|
||||||
// The helper refused to open a session for an agent target it has
|
|
||||||
// already answered (another page served it after this page's lease
|
|
||||||
// lapsed mid-capture, or the request timed out): drop the local
|
|
||||||
// session and hand the surface back.
|
|
||||||
if (body.error === 'agent_target_already_served' && msg.type === 'generate'
|
|
||||||
&& msg.id && msg.id === currentSessionId) {
|
|
||||||
abandonSupersededGo(msg.id);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
// The server refused to journal progress for a session it has never
|
// The server refused to journal progress for a session it has never
|
||||||
// seen: this browser is carrying state from another project or a
|
// seen: this browser is carrying state from another project or a
|
||||||
// wiped store (two apps sharing a localhost port). Continuing to
|
// wiped store (two apps sharing a localhost port). Continuing to
|
||||||
@@ -7832,14 +7392,6 @@
|
|||||||
return sessionCreationGate.then(doSend);
|
return sessionCreationGate.then(doSend);
|
||||||
}
|
}
|
||||||
|
|
||||||
function abandonSupersededGo(sessionId) {
|
|
||||||
if (sessionId !== currentSessionId) return;
|
|
||||||
console.warn('[impeccable] The helper already answered this agent target; clearing session ' + sessionId + '.');
|
|
||||||
markSessionHandled();
|
|
||||||
cleanup({ instantChrome: true });
|
|
||||||
showToast('The helper already answered this request, so this session was cleared. Pick an element to start fresh.', 6000);
|
|
||||||
}
|
|
||||||
|
|
||||||
let abandonedForeignSessionId = null;
|
let abandonedForeignSessionId = null;
|
||||||
function abandonForeignSession(sessionId) {
|
function abandonForeignSession(sessionId) {
|
||||||
if (abandonedForeignSessionId === sessionId || sessionId !== currentSessionId) return;
|
if (abandonedForeignSessionId === sessionId || sessionId !== currentSessionId) return;
|
||||||
@@ -8244,7 +7796,6 @@
|
|||||||
visibleVariant = 0;
|
visibleVariant = 0;
|
||||||
generationPhase = 'queued';
|
generationPhase = 'queued';
|
||||||
parameterGenerationState = 'pending';
|
parameterGenerationState = 'pending';
|
||||||
sessionOrigin = agentTargetForGo ? 'agent' : null;
|
|
||||||
parameterReadyAnnouncedSession = null;
|
parameterReadyAnnouncedSession = null;
|
||||||
resetSessionFileMeta();
|
resetSessionFileMeta();
|
||||||
|
|
||||||
@@ -8270,24 +7821,6 @@
|
|||||||
};
|
};
|
||||||
if (snapshot.comments.length > 0) basePayload.comments = snapshot.comments;
|
if (snapshot.comments.length > 0) basePayload.comments = snapshot.comments;
|
||||||
if (snapshot.strokes.length > 0) basePayload.strokes = snapshot.strokes;
|
if (snapshot.strokes.length > 0) basePayload.strokes = snapshot.strokes;
|
||||||
if (agentTargetForGo) {
|
|
||||||
// An agent-initiated Go names the target it serves (see
|
|
||||||
// actOnAgentTarget): the helper resolves that request from this event
|
|
||||||
// as well as from the overlay's own result post.
|
|
||||||
basePayload.agentTarget = {
|
|
||||||
targetId: agentTargetForGo.targetId,
|
|
||||||
clientId: AGENT_TARGET_CLIENT_ID,
|
|
||||||
result: {
|
|
||||||
ok: true,
|
|
||||||
matchCount: agentTargetForGo.matchCount,
|
|
||||||
sessionId: currentSessionId,
|
|
||||||
action: agentTargetForGo.action,
|
|
||||||
count: agentTargetForGo.count,
|
|
||||||
element: agentTargetForGo.element,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
agentTargetForGo = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Hide the interactive overlay so it doesn't linger during generation.
|
// Hide the interactive overlay so it doesn't linger during generation.
|
||||||
hideAnnotOverlay();
|
hideAnnotOverlay();
|
||||||
@@ -8348,7 +7881,6 @@
|
|||||||
visibleVariant = 0;
|
visibleVariant = 0;
|
||||||
generationPhase = 'queued';
|
generationPhase = 'queued';
|
||||||
parameterGenerationState = 'pending';
|
parameterGenerationState = 'pending';
|
||||||
sessionOrigin = agentTargetForGo ? 'agent' : null;
|
|
||||||
parameterReadyAnnouncedSession = null;
|
parameterReadyAnnouncedSession = null;
|
||||||
resetSessionFileMeta();
|
resetSessionFileMeta();
|
||||||
selectedElement = placeholderElement;
|
selectedElement = placeholderElement;
|
||||||
@@ -9395,8 +8927,6 @@ void main() {
|
|||||||
pagePickSkipClick = false;
|
pagePickSkipClick = false;
|
||||||
currentSessionId = null;
|
currentSessionId = null;
|
||||||
parameterGenerationState = 'idle';
|
parameterGenerationState = 'idle';
|
||||||
sessionOrigin = null;
|
|
||||||
editBadgeSuppressed = false;
|
|
||||||
parameterReadyAnnouncedSession = null;
|
parameterReadyAnnouncedSession = null;
|
||||||
selectedAction = 'impeccable';
|
selectedAction = 'impeccable';
|
||||||
pendingAcceptedSession = null;
|
pendingAcceptedSession = null;
|
||||||
@@ -9488,7 +9018,6 @@ void main() {
|
|||||||
paramsCurrentValues = { ...saved.paramValues };
|
paramsCurrentValues = { ...saved.paramValues };
|
||||||
}
|
}
|
||||||
if (saved.parameterState) parameterGenerationState = saved.parameterState;
|
if (saved.parameterState) parameterGenerationState = saved.parameterState;
|
||||||
sessionOrigin = saved.origin === 'agent' ? 'agent' : null;
|
|
||||||
if (saved.generationPhase) generationPhase = saved.generationPhase;
|
if (saved.generationPhase) generationPhase = saved.generationPhase;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -9576,12 +9105,7 @@ void main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function restoreSessionWithoutWrapper(reason, activeSessions) {
|
function restoreSessionWithoutWrapper(reason, activeSessions) {
|
||||||
// The session cache is per origin, so a tab on another page of the same
|
const cached = loadSession();
|
||||||
// app sees this page's session too. Only the page that saved it may
|
|
||||||
// resume it: the server-adoption branch below already applies the same
|
|
||||||
// check, and a tab on another page has nothing to render for it.
|
|
||||||
const cachedRaw = loadSession();
|
|
||||||
const cached = cachedRaw?.id && !pageMatchesCurrent(cachedRaw.pageUrl) ? null : cachedRaw;
|
|
||||||
// localStorage is a cache, not a gate. A cleared tab, a second browser
|
// localStorage is a cache, not a gate. A cleared tab, a second browser
|
||||||
// profile, or a teardown that dropped local state all leave the durable
|
// profile, or a teardown that dropped local state all leave the durable
|
||||||
// server session as the only record of work in progress; adopt it instead
|
// server session as the only record of work in progress; adopt it instead
|
||||||
@@ -9694,7 +9218,6 @@ void main() {
|
|||||||
pageUrl: location.pathname,
|
pageUrl: location.pathname,
|
||||||
paramValues: { ...paramsCurrentValues },
|
paramValues: { ...paramsCurrentValues },
|
||||||
parameterState: parameterGenerationState,
|
parameterState: parameterGenerationState,
|
||||||
origin: sessionOrigin || undefined,
|
|
||||||
insertPlaceholder: insertPlaceholderSnapshot || undefined,
|
insertPlaceholder: insertPlaceholderSnapshot || undefined,
|
||||||
pickedAnchor: pickedAnchorSnapshot || undefined,
|
pickedAnchor: pickedAnchorSnapshot || undefined,
|
||||||
pickedAnchorViewportTop: Number.isFinite(pickedAnchorViewportTop) ? pickedAnchorViewportTop : undefined,
|
pickedAnchorViewportTop: Number.isFinite(pickedAnchorViewportTop) ? pickedAnchorViewportTop : undefined,
|
||||||
@@ -9820,8 +9343,6 @@ void main() {
|
|||||||
pagePickSkipClick = false;
|
pagePickSkipClick = false;
|
||||||
currentSessionId = null;
|
currentSessionId = null;
|
||||||
parameterGenerationState = 'idle';
|
parameterGenerationState = 'idle';
|
||||||
sessionOrigin = null;
|
|
||||||
editBadgeSuppressed = false;
|
|
||||||
parameterReadyAnnouncedSession = null;
|
parameterReadyAnnouncedSession = null;
|
||||||
selectedAction = 'impeccable';
|
selectedAction = 'impeccable';
|
||||||
renderEditBadge('hidden');
|
renderEditBadge('hidden');
|
||||||
@@ -10080,14 +9601,6 @@ void main() {
|
|||||||
|
|
||||||
const resumedState = arrivedVariants > 0 ? 'CYCLING' : 'GENERATING';
|
const resumedState = arrivedVariants > 0 ? 'CYCLING' : 'GENERATING';
|
||||||
|
|
||||||
// A reload between the variants mounting and the agent's done reply
|
|
||||||
// restores a pending Tune state from the cache; the helper knows whether
|
|
||||||
// that generation already finished.
|
|
||||||
if (arrivedVariants >= expectedVariants && expectedVariants > 0
|
|
||||||
&& (parameterGenerationState === 'pending' || parameterGenerationState === 'loading')) {
|
|
||||||
settleParameterStateFromHelper(sessionId);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find the visible variant's content element for highlight positioning.
|
// Find the visible variant's content element for highlight positioning.
|
||||||
const isInsert = wrapper.dataset.impeccableMode === 'insert';
|
const isInsert = wrapper.dataset.impeccableMode === 'insert';
|
||||||
const visEl = visibleVariant > 0 ? pickVariantContent(wrapper, visibleVariant) : null;
|
const visEl = visibleVariant > 0 ? pickVariantContent(wrapper, visibleVariant) : null;
|
||||||
@@ -11552,21 +11065,6 @@ void main() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// After a resume the cache may say the Tune knobs are still coming while
|
|
||||||
// the agent already replied done before the reload. The helper's session
|
|
||||||
// record settles it; otherwise the done reply on SSE does.
|
|
||||||
function settleParameterStateFromHelper(sessionId) {
|
|
||||||
fetch('http://localhost:' + PORT + '/status?token=' + TOKEN, { cache: 'no-store' })
|
|
||||||
.then((res) => (res.ok ? res.json() : null))
|
|
||||||
.then((data) => {
|
|
||||||
if (!data || sessionId !== currentSessionId) return;
|
|
||||||
const session = (data.activeSessions || []).find((s) => s && s.id === sessionId);
|
|
||||||
if (!session) return;
|
|
||||||
if (session.generationCompletedAt || session.generationPhase === 'completed') completeParameterGenerationIfReady();
|
|
||||||
})
|
|
||||||
.catch(() => { /* the done reply on SSE settles it otherwise */ });
|
|
||||||
}
|
|
||||||
|
|
||||||
function fetchAgentPollingStatus() {
|
function fetchAgentPollingStatus() {
|
||||||
fetch('http://localhost:' + PORT + '/status?token=' + TOKEN, { cache: 'no-store' })
|
fetch('http://localhost:' + PORT + '/status?token=' + TOKEN, { cache: 'no-store' })
|
||||||
.then((res) => (res.ok ? res.json() : null))
|
.then((res) => (res.ok ? res.json() : null))
|
||||||
@@ -11606,15 +11104,11 @@ void main() {
|
|||||||
uiAppendStyle(s);
|
uiAppendStyle(s);
|
||||||
}
|
}
|
||||||
|
|
||||||
// The generate lane's helper says so in the served script itself, so a
|
|
||||||
// lane session never draws the bar at all; every other session mounts
|
|
||||||
// it exactly as before.
|
|
||||||
const barHiddenFromStart = window.__IMPECCABLE_LIVE_BAR_HIDDEN__ === true;
|
|
||||||
globalBarEl = el('div', {
|
globalBarEl = el('div', {
|
||||||
position: 'fixed', bottom: '14px', left: '50%',
|
position: 'fixed', bottom: '14px', left: '50%',
|
||||||
transform: 'translateX(-50%) translateY(20px)',
|
transform: 'translateX(-50%) translateY(20px)',
|
||||||
zIndex: Z.bar + 5,
|
zIndex: Z.bar + 5,
|
||||||
display: barHiddenFromStart ? 'none' : 'flex', alignItems: 'stretch',
|
display: 'flex', alignItems: 'stretch',
|
||||||
gap: '0',
|
gap: '0',
|
||||||
width: 'max-content',
|
width: 'max-content',
|
||||||
background: P.surface,
|
background: P.surface,
|
||||||
@@ -11630,10 +11124,6 @@ void main() {
|
|||||||
});
|
});
|
||||||
globalBarEl.id = PREFIX + '-global-bar';
|
globalBarEl.id = PREFIX + '-global-bar';
|
||||||
globalBarEl.dataset.theme = theme;
|
globalBarEl.dataset.theme = theme;
|
||||||
if (barHiddenFromStart) {
|
|
||||||
liveBarHiddenByHelper = true;
|
|
||||||
globalBarEl.dataset.liveBarDisplay = 'flex';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Brand mark - kinpaku Impeccable icon (site header / favicon paths).
|
// Brand mark - kinpaku Impeccable icon (site header / favicon paths).
|
||||||
const brand = el('span', {
|
const brand = el('span', {
|
||||||
@@ -12029,9 +11519,6 @@ void main() {
|
|||||||
// Listen for detection results AND ready signal
|
// Listen for detection results AND ready signal
|
||||||
window.addEventListener('message', onDetectMessage);
|
window.addEventListener('message', onDetectMessage);
|
||||||
updateGlobalBarState();
|
updateGlobalBarState();
|
||||||
// The helper may already have said the bar stays hidden (a connect
|
|
||||||
// that raced the bar build, or a reload mid-lane): re-apply it here.
|
|
||||||
if (liveBarHiddenByHelper) setLiveBarHidden(true);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateGlobalBarState() {
|
function updateGlobalBarState() {
|
||||||
@@ -12228,13 +11715,6 @@ void main() {
|
|||||||
|
|
||||||
/** Full teardown: remove all UI, disconnect SSE, clean up. */
|
/** Full teardown: remove all UI, disconnect SSE, clean up. */
|
||||||
function teardown() {
|
function teardown() {
|
||||||
// Declined targets die with the overlay: the IDLE transition below must
|
|
||||||
// not re-claim a lease this page can no longer act on. So does the
|
|
||||||
// target ledger: an 'acting' entry from a Go that never happened must
|
|
||||||
// not refuse every target the next connection hears.
|
|
||||||
busyDeclinedTargets.clear();
|
|
||||||
agentTargetsSeen.clear();
|
|
||||||
liveBarHiddenByHelper = false;
|
|
||||||
stopAgentStatusPoll();
|
stopAgentStatusPoll();
|
||||||
hideAgentPollTooltip();
|
hideAgentPollTooltip();
|
||||||
if (agentPollTooltipEl) {
|
if (agentPollTooltipEl) {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
name: impeccable
|
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.
|
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: 4.3.1
|
version: 4.3.0
|
||||||
license: Apache 2.0
|
license: Apache 2.0
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -63,8 +63,7 @@ Choose the mode from the requested surface, not the product, and persist it only
|
|||||||
| `clarify [target]` | Fix | Improve UX copy, labels, and error messages | [reference/clarify.md](reference/clarify.md) |
|
| `clarify [target]` | Fix | Improve UX copy, labels, and error messages | [reference/clarify.md](reference/clarify.md) |
|
||||||
| `adapt [target]` | Fix | Adapt for different devices and screen sizes | [reference/adapt.md](reference/adapt.md) · native: [reference/adapt.native.md](reference/adapt.native.md) |
|
| `adapt [target]` | Fix | Adapt for different devices and screen sizes | [reference/adapt.md](reference/adapt.md) · native: [reference/adapt.native.md](reference/adapt.native.md) |
|
||||||
| `optimize [target]` | Fix | Diagnose and fix UI performance | [reference/optimize.md](reference/optimize.md) |
|
| `optimize [target]` | Fix | Diagnose and fix UI performance | [reference/optimize.md](reference/optimize.md) |
|
||||||
| `live` | Iterate | Visual variant mode: pick elements in the browser, iterate on alternatives | [reference/live.md](reference/live.md) |
|
| `live` | Iterate | Visual variant mode: pick elements in the browser, generate alternatives | [reference/live.md](reference/live.md) |
|
||||||
| `generate [n] [action] [element]` | Iterate | Variants, versions, or alternatives of a named element to choose from in the live browser; no manual picking | [reference/generate.md](reference/generate.md) |
|
|
||||||
|
|
||||||
Routing:
|
Routing:
|
||||||
|
|
||||||
|
|||||||
@@ -188,12 +188,6 @@ Test thoroughly across contexts:
|
|||||||
- **Edge cases**: Very small screens (320px), very large screens (4K)
|
- **Edge cases**: Very small screens (320px), very large screens (4K)
|
||||||
- **Slow connections**: Test on throttled network
|
- **Slow connections**: Test on throttled network
|
||||||
|
|
||||||
**Custom controls** (sliders, drag surfaces, scrollable control strips): a before/after slider can pass every width check above and still refuse to drag on iOS, so exercise each one in scope in the same batched round as the checks above:
|
|
||||||
|
|
||||||
- **Primary gesture**: Tap it and confirm it responds as designed, then drag it with the target input method; the drag must complete, not just start
|
|
||||||
- **Scroll across it**: A swipe along the page's scroll axis across the control scrolls the page or container without activating it; a drag that starts on the control along its axis moves the control, not the page. Neither failure throws an error, so try both
|
|
||||||
- **Evidence**: Say what produced the evidence: an emulated viewport, synthesized touch input through a browser tool, which engine ran it (Chromium is not Safari), or a physical device. Screenshots and resized viewports verify layout, never a gesture. Name what stayed untested and move on; unreachable hardware is a reported gap, not a blocker
|
|
||||||
|
|
||||||
When the adaptation feels native to each context, hand off to `/impeccable polish` for the final pass.
|
When the adaptation feels native to each context, hand off to `/impeccable polish` for the final pass.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -48,12 +48,11 @@ Run comprehensive checks across 5 dimensions. Score each dimension 0-4 using the
|
|||||||
**Check for**:
|
**Check for**:
|
||||||
- **Fixed widths**: Hard-coded widths that break on mobile
|
- **Fixed widths**: Hard-coded widths that break on mobile
|
||||||
- **Touch targets**: Interactive elements < 44x44px
|
- **Touch targets**: Interactive elements < 44x44px
|
||||||
- **Broken touch interaction**: Custom sliders, drag surfaces, and scrollable control strips whose primary gesture fails under touch, that swallow page scroll or lose the drag to it, or that stay stuck after an interrupted gesture. Code tells: mouse-only handlers, no `touch-action` on a pointer-event drag surface, drag state that nothing clears on cancel, lost capture, or blur. Exercise the gesture when a browser tool can synthesize touch (a rendered viewport proves layout, not the gesture), then say what produced the evidence (emulated viewport, synthesized touch, which engine, physical device) and what stayed untested
|
|
||||||
- **Horizontal scroll**: Content overflow on narrow viewports
|
- **Horizontal scroll**: Content overflow on narrow viewports
|
||||||
- **Text scaling**: Layouts that break when text size increases
|
- **Text scaling**: Layouts that break when text size increases
|
||||||
- **Missing breakpoints**: No mobile/tablet variants
|
- **Missing breakpoints**: No mobile/tablet variants
|
||||||
|
|
||||||
**Score 0-4**: 0=Desktop-only (breaks on mobile), 1=Major issues (some breakpoints, many failures), 2=Partial (works on mobile, rough edges), 3=Good (responsive, minor touch target or overflow issues), 4=Excellent (fluid, all viewports, proper touch targets, gestures work under touch)
|
**Score 0-4**: 0=Desktop-only (breaks on mobile), 1=Major issues (some breakpoints, many failures), 2=Partial (works on mobile, rough edges), 3=Good (responsive, minor touch target or overflow issues), 4=Excellent (fluid, all viewports, proper touch targets)
|
||||||
|
|
||||||
### 5. Implementation Integrity (CRITICAL)
|
### 5. Implementation Integrity (CRITICAL)
|
||||||
|
|
||||||
|
|||||||
@@ -1,101 +0,0 @@
|
|||||||
> **Additional context needed**: only the target element, when the request does not name one that resolves uniquely on the page.
|
|
||||||
|
|
||||||
Generate is the fast lane into live mode: the user names an element, a direction, and a count in one sentence, and within a minute they are cycling through variants in their browser. One command boots the helper, hands the element to the overlay in the page your harness already shows (it scrolls to it, selects it, and fires the same Go a click fires) and returns the generate event; one edit writes the variants; one call replies and waits for the user's choice, which the helper bakes into source itself. This file owns the lane's plumbing; from the event onward the design work is [live.md](live.md)'s, unchanged, so read it in full now if you have not this session.
|
|
||||||
|
|
||||||
**Web only.** Live mode's browser overlay has no native equivalent; on `ios` / `android` / `adaptive` projects, decline this command and offer `bolder` or `quieter` on the source instead.
|
|
||||||
|
|
||||||
The plumbing is where the lane saves time: one command starts the session around the page your harness already shows, one call replies and waits, and nothing here is a browser you have to babysit. The design work is not where it saves time. Setup runs as for any command (`impeccable context`, this reference, craft-floor.md before the edit), and the variants are planned, written, and accepted exactly the way a live session plans, writes, and accepts them.
|
|
||||||
|
|
||||||
Three prohibitions cover the known ways this command goes wrong:
|
|
||||||
|
|
||||||
- **Never run init or document, and never ask for PRODUCT.md or DESIGN.md.** When they exist, the start command prints them under `boot` and you use them. When they do not, it says so (`contextMissing`, `contextNote`) and you extract the identity from the event (Step 3). A missing file is never a reason to interview the user inside this command; offer `init` in one line after the session ends.
|
|
||||||
- **Never hand-write a variants wrapper or invent a session id.** Only the browser mints session ids (8 hex characters, at Go). A missing event is fixed by rerunning Step 2, never with a direct source edit.
|
|
||||||
- **Do not act on hook findings while live markers are in the file**, and do not restyle variants to appease them; the accept verifies the file once the variant is permanent.
|
|
||||||
|
|
||||||
## Step 1: Parse the request
|
|
||||||
|
|
||||||
Three parts, all from the user's sentence:
|
|
||||||
|
|
||||||
- **A number in the request**: that is the count. **No number**: 3. The protocol caps count at 8.
|
|
||||||
- **The direction wording** maps onto the live action vocabulary; never invent a new action value:
|
|
||||||
- **bold, bolder, stronger, punchier**: `bolder`
|
|
||||||
- **quiet, calmer, softer, toned down**: `quieter`
|
|
||||||
- **simpler, minimal, stripped**: `distill`
|
|
||||||
- **refined, tightened, polished**: `polish`
|
|
||||||
- **font and type words**: `typeset`
|
|
||||||
- **color words**: `colorize`
|
|
||||||
- **arrangement and spacing words**: `layout`
|
|
||||||
- **device and breakpoint words**: `adapt`
|
|
||||||
- **motion words**: `animate`
|
|
||||||
- **playful words**: `delight`
|
|
||||||
- **rule-breaking words**: `overdrive`
|
|
||||||
- **Wording that carries intent but no vocabulary word** ("make it feel like a bank", "warmer", "more premium"): `impeccable`, with the user's wording passed as the prompt.
|
|
||||||
- **An action fits AND extra intent rides along** ("bolder, but keep it monochrome"): that action, with the rest as the prompt.
|
|
||||||
- **The wording names no direction at all** ("better", "improve", "nicer", "different", "fresh", "new", "redesign", "fix", "some options", "ideas", "alternatives", or just "variants" with nothing else): Ask the user directly to clarify what you cannot infer. Ask one question, offering the vocabulary: *"Which direction should the variants take? bolder, quieter, simpler (distill), polished, typography (typeset), color (colorize), layout, motion (animate), playful (delight), or rule-breaking (overdrive)."* Map the answer with this list; an answer that is still open ("surprise me", "you pick") is `impeccable` with the user's original wording as the prompt, and Step 2 starts on that answer.
|
|
||||||
- **The element description** ("the pricing cards", "the hero heading"): Step 2 resolves it to a selector.
|
|
||||||
|
|
||||||
Done when you hold an action from the vocabulary (asked for, when the request named no direction), a count from 1 to 8, and the element description.
|
|
||||||
|
|
||||||
## Step 2: Reuse the page, then start
|
|
||||||
|
|
||||||
**Reuse** the dev server already running and the tab your harness already shows it in; a second server or a second browser window is the failure this step prevents.
|
|
||||||
|
|
||||||
1. **Find the dev server**, cheapest source first, and stop at the first hit: the user's message, a browser tab already on the app (Claude Code: an origin in `tabs_context`), a server your harness started (Claude Code: `preview_list`), a terminal that printed its URL. Its origin is your `--dev-url`. **No hit**: leave `--dev-url` off and run the start command with no wait; the boot probes for a running server and its verdict names the move. `browser_needed` carries the `devUrl` it found: open it as in 2, then rerun with `--dev-url <devUrl> --wait-for-browser 60000`. `no_dev_server` means nothing serves the app: start the dev script the way the verdict says (Claude Code: `preview_start`; Cursor: a background terminal; Codex: an exec you yield from), wait for its URL, then rerun with `--dev-url <url>`.
|
|
||||||
2. **Open the page that renders the element in your browser, then start.** The route the request names, else the one `--target` serves; `--dev-url` takes only the origin.
|
|
||||||
- **Cursor** (`browser_navigate`) and **Claude Code** (`navigate`, which opens the Browser pane when it is closed and takes the `tabId` from `tabs_context` when a tab is already on that origin): open the URL, then run the start command with `--dev-url <url> --wait-for-browser 60000`. The boot injects the overlay and the page reloads into it while the command waits. Your browser tool is the only opener on these harnesses; the engine ignores `--open` there.
|
|
||||||
- **No browser tool** (Codex, others): run the start command with `--open --wait-for-browser 120000`; it opens the system browser, and the longer wait covers the user finding the tab. **`browser_open_failed` back**: tell the user the `url` in one line and rerun with `--wait-for-browser 120000`.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
.hermes/skills/impeccable/scripts/impeccable live-generate --target src/App.jsx --dev-url http://127.0.0.1:5173/ --selector ".pricing-grid" --action bolder --count 3 --boot --wait-for-browser 60000
|
|
||||||
```
|
|
||||||
|
|
||||||
Run it in the foreground in Cursor and Claude Code (it returns within the wait); on Codex, in an exec you yield from, the way Step 3 runs the poll.
|
|
||||||
|
|
||||||
- `--target`: the file that renders the element when the request or the project makes it obvious; skip it otherwise.
|
|
||||||
- `--dev-url`: the origin from 1; omit it and the boot probes.
|
|
||||||
- `--selector`: a unique class first, then a landmark tag plus class, an id last (every variant mounts a copy of the element, so an id repeats in the DOM). **The request names a repeated component in plural** ("the pricing cards"): target the container that holds the set, so one scoped stylesheet restyles every instance. One read of the source file that renders the element is allowed when the selector is not obvious; `--dry-run` resolves and reports without starting anything when it is not certain.
|
|
||||||
- `--boot`: runs the lane's boot (PRODUCT.md and DESIGN.md loaded again for the helper, missing files tolerated, dev URL found, bottom bar hidden for the helper's lifetime) and reuses a helper that is already running. Its result rides along as `boot`.
|
|
||||||
- Also available: `--prompt`, `--text` (keep only matches whose visible text contains a snippet), `--index` (1-based pick among matches).
|
|
||||||
|
|
||||||
Read the output in this order: `boot` (or `boot.contextMissing` with `boot.contextNote`: the page is the source of truth, per the note), then `event`, the generate event for `sessionId`, with the same `_instructions` a user's Go gets. Every verdict carries `_instructions`, and they win over your recollection of this file; the ones whose move is a decision of yours:
|
|
||||||
|
|
||||||
- **`ambiguous`**: the candidates are listed; target their common container, or rerun with `--text "<visible text>"` or `--index <n>`.
|
|
||||||
- **`dev_server_gone`**: the dev server stopped answering while the command waited for the page (on Cursor, a server another chat started dies with that chat). Start it the way the verdict says, then rerun with `--dev-url <url>`.
|
|
||||||
- **`no_match`**: the tab is on a route that does not render the element (navigate to the right route, rerun), or the selector is wrong (derive a better one from the source, or add `--text`).
|
|
||||||
- **`config_missing` / `config_invalid`** under `bootError`: follow [live-setup.md](live-setup.md) first, then rerun.
|
|
||||||
- **`event: null`** with `ok: true`: the event was slower than the wait; run `.hermes/skills/impeccable/scripts/impeccable live-poll` once to collect it, then continue.
|
|
||||||
|
|
||||||
Done when the output shows `ok: true`, a `sessionId`, and an `event`, reached with at most one server started and one tab opened by you.
|
|
||||||
|
|
||||||
## Step 3: Generate
|
|
||||||
|
|
||||||
The event is a standard `generate` event: the picked element's context, a preflighted scaffold, and `_instructions` naming the action's reference, the planning section, and the exact splice. Handle it exactly per live.md's **Handle generate**, which owns everything from the identity lock to the done reply: read the action's reference and craft-floor.md as it says, plan per section 4 (identity first, then mode, then three different primary axes, then the squint test), declare knobs per section 7, and deliver per section 6 (a complete replacement of the element per variant, the preview CSS plus every variant in one edit at the scaffold's splice). The lane changes nothing about what a variant may be: the moves a live session would make on this element (a promoted tier, a restructured set, a reordered card, a different surface) are open here too. Never screenshot the page; the overlay preview is the review channel until accept.
|
|
||||||
|
|
||||||
**Reply and wait in one call**, with the file you wrote:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
.hermes/skills/impeccable/scripts/impeccable live-poll --reply EVENT_ID done --file src/App.jsx --then-poll
|
|
||||||
```
|
|
||||||
|
|
||||||
This replies done (the browser mounts the variants) and then blocks until the user's choice arrives, so run it the way your harness runs a long wait: **Claude Code** in the foreground with your tool's longest timeout (600000 ms), so you are paused until the choice arrives; **Codex** in a yielded foreground exec; **Cursor** in a background terminal with notify on `"type":"(accept|discard|variant_mount_failed|exit)"`. Never pass a short `--timeout=`. While it runs there is nothing else to do: never sleep and never poll its output on a timer; a harness that backgrounds it wakes you when it returns. `{"type":"timeout"}` means the user has not chosen yet: run `live-poll` again and keep waiting. If the edit fails after the browser flipped to GENERATING, `--reply EVENT_ID error "Short reason"` (without `--then-poll`) so the bar resets.
|
|
||||||
|
|
||||||
Then tell the user, in one line, where their variants are: *"Three [bolder] variants are live on [the pricing cards]: cycle with the floating bar's arrows, adjust the Tune knobs, and Accept the keeper."*
|
|
||||||
|
|
||||||
Outside the replace path, read the matching live.md section before acting: `scaffold.previewMode: "svelte-component"` (Svelte previews are edited as components, and their accept is mechanical), `mode: "insert"`, `variant_mount_failed`, `steer`, `manual_edit_apply`, and any `fallback: "agent-driven"` wrap error.
|
|
||||||
|
|
||||||
## Step 4: Accept and close
|
|
||||||
|
|
||||||
The call from Step 3 returns the user's choice. **`discard`**: nothing to do. **`accept`**: `_acceptResult.carbonize: true` is the normal case, and the cleanup is live.md's **Required after accept**, unchanged: move the accepted variant's rules into the stylesheet that already owns the element with real selectors, bake the chosen knob values in, unwrap the element and drop every `data-impeccable-*` attribute, delete the inline `<style>` block and both `impeccable-carbonize` markers, then `.hermes/skills/impeccable/scripts/impeccable live-complete --id SESSION_ID` and confirm `phase: "completed"`. (`baked: true` appears only when the accept was run with `--bake`; then the helper already made the variant permanent and no `live-complete` is owed.)
|
|
||||||
|
|
||||||
Close without being asked, the moment the choice is handled:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
.hermes/skills/impeccable/scripts/impeccable live-server stop
|
|
||||||
```
|
|
||||||
|
|
||||||
Stopping removes the injected script and reloads the page once: the user sees the accepted design with no overlay chrome, still served by their dev server. **Never kill or restart the dev server**, including one you started in Step 2.
|
|
||||||
|
|
||||||
- **The user asks for more variants before you closed**: skip the close, run Step 2 again for the next element (the helper is reused), and close after the last choice.
|
|
||||||
- **Interrupted or unsure of the state**: `.hermes/skills/impeccable/scripts/impeccable live-status`, then `live-resume`; the journal under `.impeccable/live/sessions/` is canonical.
|
|
||||||
|
|
||||||
Done when the helper is stopped and the dev site still answers with the accepted design.
|
|
||||||
@@ -205,11 +205,6 @@ t('items', { count }) // Handles complex plural rules
|
|||||||
- Optimistic updates with rollback
|
- Optimistic updates with rollback
|
||||||
- Conflict resolution
|
- Conflict resolution
|
||||||
|
|
||||||
**Interrupted gestures** (custom sliders, drag surfaces, scrollable control strips):
|
|
||||||
- A second finger or pointer lands mid-drag: the first drag keeps its pointer or ends cleanly, never jumps to the new one
|
|
||||||
- The browser cancels the gesture to scroll (`pointercancel`), capture is lost (`lostpointercapture`), the pointer is released outside the control, or the window loses focus (`blur`) mid-drag: clear the dragging state and release capture
|
|
||||||
- After each of these, the next tap or drag works without a reload
|
|
||||||
|
|
||||||
**Permission states**:
|
**Permission states**:
|
||||||
- No permission to view
|
- No permission to view
|
||||||
- No permission to edit
|
- No permission to edit
|
||||||
@@ -309,7 +304,6 @@ const throttledScroll = throttle(handleScroll, 100);
|
|||||||
- Unit tests for edge cases
|
- Unit tests for edge cases
|
||||||
- Integration tests for error scenarios
|
- Integration tests for error scenarios
|
||||||
- E2E tests for critical paths
|
- E2E tests for critical paths
|
||||||
- A behavioral regression for each confirmed gesture fix, when the project's test runner can drive input
|
|
||||||
- Visual regression tests
|
- Visual regression tests
|
||||||
- Accessibility tests (axe, WAVE)
|
- Accessibility tests (axe, WAVE)
|
||||||
|
|
||||||
@@ -336,10 +330,7 @@ Test thoroughly with edge cases:
|
|||||||
- **Network issues**: Disable internet, throttle connection
|
- **Network issues**: Disable internet, throttle connection
|
||||||
- **Large datasets**: Test with 1000+ items
|
- **Large datasets**: Test with 1000+ items
|
||||||
- **Concurrent actions**: Click submit 10 times rapidly
|
- **Concurrent actions**: Click submit 10 times rapidly
|
||||||
- **Interrupted gestures**: Add a second finger mid-drag, scroll across the control, release outside it, switch windows mid-drag; then drag again
|
|
||||||
- **Errors**: Force API errors, test all error states
|
- **Errors**: Force API errors, test all error states
|
||||||
- **Empty**: Remove all data, test empty states
|
- **Empty**: Remove all data, test empty states
|
||||||
|
|
||||||
For gestures, say what produced the evidence (emulated viewport, synthesized touch, which engine, physical device) and name what stayed untested.
|
|
||||||
|
|
||||||
When edge cases are covered, hand off to `/impeccable polish` for the final pass.
|
When edge cases are covered, hand off to `/impeccable polish` for the final pass.
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ Reason over the signals; there is no score to obey:
|
|||||||
- `critique.latest` is `null` → the project has never been critiqued; for a set-up project with a real surface, offering `/impeccable critique <surface>` is a strong default.
|
- `critique.latest` is `null` → the project has never been critiqued; for a set-up project with a real surface, offering `/impeccable critique <surface>` is a strong default.
|
||||||
- `critique.latest` with a low `score` or non-zero `p0` / `p1` → `polish` (it reads that snapshot as its backlog and closes it when stale or cleared).
|
- `critique.latest` with a low `score` or non-zero `p0` / `p1` → `polish` (it reads that snapshot as its backlog and closes it when stale or cleared).
|
||||||
- `git.changedFiles` pointing at one surface → scope `audit` or `polish` to those files specifically, naming them.
|
- `git.changedFiles` pointing at one surface → scope `audit` or `polish` to those files specifically, naming them.
|
||||||
- `devServer.running` true → `live` is available for in-browser iteration, and `generate` for one-shot variant runs on a named element; if false, don't lead with either. **`live`, `generate`, and the bundled `impeccable detect` are web-only.** If `setup.platform` is `ios`, `android`, or `adaptive`, don't lead with any of them; the browser overlay and the HTML rule engine don't apply to native app code.
|
- `devServer.running` true → `live` is available for in-browser iteration; if false, don't lead with `live`. **`live` and the bundled `impeccable detect` are web-only.** If `setup.platform` is `ios`, `android`, or `adaptive`, don't lead with either; the browser overlay and the HTML rule engine don't apply to native app code.
|
||||||
- Otherwise group by intent (build new / improve what's there / iterate visually), tailored to the current surface and `setup.platform`.
|
- Otherwise group by intent (build new / improve what's there / iterate visually), tailored to the current surface and `setup.platform`.
|
||||||
|
|
||||||
**If `scan.targets` is non-empty and `setup.platform` is not `ios`/`android`/`adaptive`, run `.hermes/skills/impeccable/scripts/impeccable detect --json <scan.targets joined by spaces>` once** (the bundled detector over local files: no network, no npx; it reads HTML/CSS, so skip it for native projects). `scan.via` tells you what they are: `git-changes` (the markup/style files in your dirty tree, the most relevant set), `source-dir` (e.g. `src`, `app`), `html`, or `root`. Fold the hits into your picks: many quality / contrast hits → `audit` or `polish`; a specific slop family → the matching command (gradient text or eyebrows → `quieter` / `typeset`, flat or gray palette → `colorize`, and so on). It's a real, current signal that beats guessing. If detect errors or the tree is large and slow, skip it and recommend the user run `audit` themselves; never block the suggestion on it.
|
**If `scan.targets` is non-empty and `setup.platform` is not `ios`/`android`/`adaptive`, run `.hermes/skills/impeccable/scripts/impeccable detect --json <scan.targets joined by spaces>` once** (the bundled detector over local files: no network, no npx; it reads HTML/CSS, so skip it for native projects). `scan.via` tells you what they are: `git-changes` (the markup/style files in your dirty tree, the most relevant set), `source-dir` (e.g. `src`, `app`), `html`, or `root`. Fold the hits into your picks: many quality / contrast hits → `audit` or `polish`; a specific slop family → the matching command (gradient text or eyebrows → `quieter` / `typeset`, flat or gray palette → `colorize`, and so on). It's a real, current signal that beats guessing. If detect errors or the tree is large and slow, skip it and recommend the user run `audit` themselves; never block the suggestion on it.
|
||||||
|
|||||||
@@ -19,10 +19,6 @@
|
|||||||
"description": "Interactive live variant mode. Select elements in the browser, pick a design action, and get AI-generated HTML+CSS variants hot-swapped via HMR. Requires a running dev server. Use when you want to visually experiment with design alternatives in real time.",
|
"description": "Interactive live variant mode. Select elements in the browser, pick a design action, and get AI-generated HTML+CSS variants hot-swapped via HMR. Requires a running dev server. Use when you want to visually experiment with design alternatives in real time.",
|
||||||
"argumentHint": ""
|
"argumentHint": ""
|
||||||
},
|
},
|
||||||
"generate": {
|
|
||||||
"description": "Agent-driven live variant generation. Boots live mode, finds the named element on the open page, scrolls the browser to it, and delivers N variants in the requested direction for the user to cycle and accept. Use for requests that name an element and a direction, like 'generate 3 bold variants of the pricing cards', skipping manual element picking.",
|
|
||||||
"argumentHint": "[count] [direction] variants of [element]"
|
|
||||||
},
|
|
||||||
"adapt": {
|
"adapt": {
|
||||||
"description": "Adapt designs to work across different screen sizes, devices, contexts, or platforms. Implements breakpoints, fluid layouts, and touch targets. Use when the user mentions responsive design, mobile layouts, breakpoints, viewport adaptation, or cross-device compatibility.",
|
"description": "Adapt designs to work across different screen sizes, devices, contexts, or platforms. Implements breakpoints, fluid layouts, and touch targets. Use when the user mentions responsive design, mobile layouts, breakpoints, viewport adaptation, or cross-device compatibility.",
|
||||||
"argumentHint": "[target] [context (mobile, tablet, print...)]"
|
"argumentHint": "[target] [context (mobile, tablet, print...)]"
|
||||||
|
|||||||
@@ -87,11 +87,6 @@ fi
|
|||||||
# Last resort: fetch this version's binary for the current platform from the
|
# Last resort: fetch this version's binary for the current platform from the
|
||||||
# public release channel into the user cache. Needs network; sandboxes without
|
# public release channel into the user cache. Needs network; sandboxes without
|
||||||
# egress preinstall the binary on PATH instead.
|
# egress preinstall the binary on PATH instead.
|
||||||
setup_help() {
|
|
||||||
echo "Engine $version setup needs network access and write permission to $cache_root/bin/$version." >&2
|
|
||||||
echo "Run this launcher ($0) with engine-probe in a terminal that has those permissions, then retry the original command." >&2
|
|
||||||
echo "Alternatively, set IMPECCABLE_HOME to a writable cache location, or IMPECCABLE_BIN to a preinstalled engine binary." >&2
|
|
||||||
}
|
|
||||||
fetch_url() {
|
fetch_url() {
|
||||||
if command -v curl >/dev/null 2>&1; then
|
if command -v curl >/dev/null 2>&1; then
|
||||||
curl -fsSL --retry 2 -o "$tmp" "$1" 2>/dev/null
|
curl -fsSL --retry 2 -o "$tmp" "$1" 2>/dev/null
|
||||||
@@ -124,18 +119,7 @@ if [ -n "$version" ] && [ "$os" != unknown ] && [ "$arch" != unknown ]; then
|
|||||||
[ "$os" = windows ] && asset="$asset.exe"
|
[ "$os" = windows ] && asset="$asset.exe"
|
||||||
url="$base/engine-v$version/$asset"
|
url="$base/engine-v$version/$asset"
|
||||||
tmp="$cache_root/bin/$version/.impeccable.part.$$"
|
tmp="$cache_root/bin/$version/.impeccable.part.$$"
|
||||||
if ! mkdir -p "$cache_root/bin/$version" 2>/dev/null; then
|
mkdir -p "$cache_root/bin/$version" 2>/dev/null
|
||||||
echo "impeccable: engine $version is not installed; cannot create cache directory: $cache_root/bin/$version" >&2
|
|
||||||
setup_help
|
|
||||||
exit 127
|
|
||||||
fi
|
|
||||||
# Check the actual staging file, not just directory existence: a cache from
|
|
||||||
# an earlier run can be readable but no longer writable inside a sandbox.
|
|
||||||
if ! (umask 077; : > "$tmp") 2>/dev/null; then
|
|
||||||
echo "impeccable: engine $version is not installed; cannot write to cache directory: $cache_root/bin/$version" >&2
|
|
||||||
setup_help
|
|
||||||
exit 127
|
|
||||||
fi
|
|
||||||
fetched=0
|
fetched=0
|
||||||
if fetch_url "$url"; then
|
if fetch_url "$url"; then
|
||||||
fetched=1
|
fetched=1
|
||||||
@@ -196,9 +180,6 @@ if [ -n "$version" ] && [ "$os" != unknown ] && [ "$arch" != unknown ]; then
|
|||||||
exec "$cached" "$@"
|
exec "$cached" "$@"
|
||||||
fi
|
fi
|
||||||
rm -f "$tmp" 2>/dev/null
|
rm -f "$tmp" 2>/dev/null
|
||||||
echo "impeccable: could not download engine $version from $url; check network access, the release URL, and curl or wget availability." >&2
|
|
||||||
setup_help
|
|
||||||
exit 127
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "impeccable: no engine binary for $os-$arch found (looked in $bin, $cached, PATH)." >&2
|
echo "impeccable: no engine binary for $os-$arch found (looked in $bin, $cached, PATH)." >&2
|
||||||
|
|||||||
@@ -68,27 +68,18 @@ rem another launcher's probe: fail fast and quiet instead.
|
|||||||
if defined IMPECCABLE_LAUNCHER_PROBE exit /b 127
|
if defined IMPECCABLE_LAUNCHER_PROBE exit /b 127
|
||||||
if not defined version goto fail
|
if not defined version goto fail
|
||||||
where curl.exe >nul 2>nul
|
where curl.exe >nul 2>nul
|
||||||
if errorlevel 1 goto curl_missing
|
if errorlevel 1 goto fail
|
||||||
if not defined IMPECCABLE_DOWNLOAD_BASE set "IMPECCABLE_DOWNLOAD_BASE=https://github.com/pbakaus/impeccable/releases/download"
|
if not defined IMPECCABLE_DOWNLOAD_BASE set "IMPECCABLE_DOWNLOAD_BASE=https://github.com/pbakaus/impeccable/releases/download"
|
||||||
if exist "%IMPECCABLE_HOME%\bin\%version%\" goto cache_ready
|
if not exist "%IMPECCABLE_HOME%\bin\%version%" mkdir "%IMPECCABLE_HOME%\bin\%version%" >nul 2>nul
|
||||||
mkdir "%IMPECCABLE_HOME%\bin\%version%" >nul 2>nul
|
|
||||||
if errorlevel 1 goto cache_directory_failed
|
|
||||||
:cache_ready
|
|
||||||
rem Check the staging file too: an existing directory may be read-only.
|
|
||||||
rem Redirection failures do not reliably update ERRORLEVEL in cmd.exe;
|
|
||||||
rem branch on the command's failure directly. Never treat a directory as a
|
|
||||||
rem staging file (later del cleanup would prompt to delete its contents).
|
|
||||||
if exist "%cached%.part\" goto cache_write_failed
|
|
||||||
(type nul >"%cached%.part") 2>nul || goto cache_write_failed
|
|
||||||
set "asset=impeccable-windows-%arch%.exe"
|
set "asset=impeccable-windows-%arch%.exe"
|
||||||
set "url=%IMPECCABLE_DOWNLOAD_BASE%/engine-v%version%/%asset%"
|
set "url=%IMPECCABLE_DOWNLOAD_BASE%/engine-v%version%/%asset%"
|
||||||
curl.exe -fsSL -o "%cached%.part" "%url%" >nul 2>nul
|
curl.exe -fsSL -o "%cached%.part" "%url%" >nul 2>nul
|
||||||
if not errorlevel 1 goto verify
|
if not errorlevel 1 goto verify
|
||||||
if not "%arch%"=="arm64" goto download_failed
|
if not "%arch%"=="arm64" goto fail
|
||||||
set "asset=impeccable-windows-x64.exe"
|
set "asset=impeccable-windows-x64.exe"
|
||||||
set "url=%IMPECCABLE_DOWNLOAD_BASE%/engine-v%version%/%asset%"
|
set "url=%IMPECCABLE_DOWNLOAD_BASE%/engine-v%version%/%asset%"
|
||||||
curl.exe -fsSL -o "%cached%.part" "%url%" >nul 2>nul
|
curl.exe -fsSL -o "%cached%.part" "%url%" >nul 2>nul
|
||||||
if errorlevel 1 goto download_failed
|
if errorlevel 1 goto fail
|
||||||
|
|
||||||
:verify
|
:verify
|
||||||
call :check_download
|
call :check_download
|
||||||
@@ -185,28 +176,6 @@ if not errorlevel 1 set "probe_ok=1"
|
|||||||
del "%probe_tmp%" >nul 2>nul
|
del "%probe_tmp%" >nul 2>nul
|
||||||
exit /b 0
|
exit /b 0
|
||||||
|
|
||||||
:cache_directory_failed
|
|
||||||
echo impeccable: engine %version% is not installed; cannot create cache directory: "%IMPECCABLE_HOME%\bin\%version%" 1>&2
|
|
||||||
goto setup_failed
|
|
||||||
|
|
||||||
:cache_write_failed
|
|
||||||
echo impeccable: engine %version% is not installed; cannot write to cache directory: "%IMPECCABLE_HOME%\bin\%version%" 1>&2
|
|
||||||
goto setup_failed
|
|
||||||
|
|
||||||
:curl_missing
|
|
||||||
echo impeccable: cannot download engine %version%; curl.exe is unavailable. 1>&2
|
|
||||||
goto setup_failed
|
|
||||||
|
|
||||||
:download_failed
|
|
||||||
del "%cached%.part" >nul 2>nul
|
|
||||||
echo impeccable: could not download engine %version% from %url%; check network access and the release URL. 1>&2
|
|
||||||
|
|
||||||
:setup_failed
|
|
||||||
echo Engine %version% setup needs network access and write permission to "%IMPECCABLE_HOME%\bin\%version%". 1>&2
|
|
||||||
echo Run this launcher ("%~f0") with engine-probe in a terminal that has those permissions, then retry the original command. 1>&2
|
|
||||||
echo Alternatively, set IMPECCABLE_HOME to a writable cache location, or IMPECCABLE_BIN to a preinstalled engine binary. 1>&2
|
|
||||||
exit /b 127
|
|
||||||
|
|
||||||
:fail
|
:fail
|
||||||
del "%cached%.part" >nul 2>nul
|
del "%cached%.part" >nul 2>nul
|
||||||
echo impeccable: no engine binary found (looked in %bin%, %cached%, PATH). 1>&2
|
echo impeccable: no engine binary found (looked in %bin%, %cached%, PATH). 1>&2
|
||||||
|
|||||||
@@ -165,14 +165,6 @@
|
|||||||
}
|
}
|
||||||
let parameterGenerationState = 'idle';
|
let parameterGenerationState = 'idle';
|
||||||
let parameterReadyAnnouncedSession = null;
|
let parameterReadyAnnouncedSession = null;
|
||||||
// 'agent' when the generate verb fired this session's Go (the generate
|
|
||||||
// lane declares no knobs, so its bar never shows a pending Tune chip);
|
|
||||||
// null for every Go a user presses.
|
|
||||||
let sessionOrigin = null;
|
|
||||||
// The generate lane picks for the agent and never edits copy in the
|
|
||||||
// browser, so its selection carries no edit-copy badge (set on the
|
|
||||||
// agent-target pick, cleared with the session; a user's pick never sets it).
|
|
||||||
let editBadgeSuppressed = false;
|
|
||||||
let svelteComponentSession = null;
|
let svelteComponentSession = null;
|
||||||
let svelteRuntimePromise = null;
|
let svelteRuntimePromise = null;
|
||||||
let pendingSvelteComponentRetryObserver = null;
|
let pendingSvelteComponentRetryObserver = null;
|
||||||
@@ -991,20 +983,9 @@
|
|||||||
}
|
}
|
||||||
} catch { /* cross-origin */ }
|
} catch { /* cross-origin */ }
|
||||||
}
|
}
|
||||||
// The selector a mechanical bake would anchor lasting rules on, and how
|
|
||||||
// many elements it matches right now: the bake refuses anything but one,
|
|
||||||
// since its rules would restyle every match, not just this element.
|
|
||||||
const cssIdent = (s) => /^[A-Za-z_-][\w-]*$/.test(s);
|
|
||||||
const anchorClasses = [...el.classList].filter(cssIdent);
|
|
||||||
const anchor = el.id && cssIdent(el.id)
|
|
||||||
? '#' + el.id
|
|
||||||
: (anchorClasses.length ? el.tagName.toLowerCase() + '.' + anchorClasses.join('.') : null);
|
|
||||||
let anchorMatches = null;
|
|
||||||
if (anchor) { try { anchorMatches = document.querySelectorAll(anchor).length; } catch { anchorMatches = null; } }
|
|
||||||
return {
|
return {
|
||||||
tagName: el.tagName.toLowerCase(), id: el.id || null,
|
tagName: el.tagName.toLowerCase(), id: el.id || null,
|
||||||
classes: [...el.classList],
|
classes: [...el.classList],
|
||||||
anchor, anchorMatches,
|
|
||||||
textContent: (el.textContent || '').slice(0, 500),
|
textContent: (el.textContent || '').slice(0, 500),
|
||||||
outerHTML: sanitizedContextOuterHTML(el, 10000),
|
outerHTML: sanitizedContextOuterHTML(el, 10000),
|
||||||
computedStyles: {
|
computedStyles: {
|
||||||
@@ -2056,7 +2037,6 @@
|
|||||||
function setLiveState(next) {
|
function setLiveState(next) {
|
||||||
state = next;
|
state = next;
|
||||||
window.__IMPECCABLE_LIVE_STATE__ = next;
|
window.__IMPECCABLE_LIVE_STATE__ = next;
|
||||||
retryDeclinedAgentTargets();
|
|
||||||
syncPageInteractionCursor();
|
syncPageInteractionCursor();
|
||||||
// Whether a queued steer is still behind a generation is a function of this
|
// Whether a queued steer is still behind a generation is a function of this
|
||||||
// state, so the hint has to move with it, not only with the 5s poll.
|
// state, so the hint has to move with it, not only with the 5s poll.
|
||||||
@@ -4034,7 +4014,6 @@
|
|||||||
|
|
||||||
function hidePendingApplyDock() {
|
function hidePendingApplyDock() {
|
||||||
pendingApplyInFlight = false;
|
pendingApplyInFlight = false;
|
||||||
retryDeclinedAgentTargets();
|
|
||||||
clearStoredManualApplyState();
|
clearStoredManualApplyState();
|
||||||
if (pendingIntroAnimation) { pendingIntroAnimation.cancel(); pendingIntroAnimation = null; }
|
if (pendingIntroAnimation) { pendingIntroAnimation.cancel(); pendingIntroAnimation = null; }
|
||||||
if (pendingDockEl) pendingDockEl.style.display = 'none';
|
if (pendingDockEl) pendingDockEl.style.display = 'none';
|
||||||
@@ -4068,7 +4047,6 @@
|
|||||||
function setPendingApplyLoading(loading, count) {
|
function setPendingApplyLoading(loading, count) {
|
||||||
if (!pendingPillEl || !pendingPillLabelEl || !pendingPillCountEl || !pendingTrashBtn) return;
|
if (!pendingPillEl || !pendingPillLabelEl || !pendingPillCountEl || !pendingTrashBtn) return;
|
||||||
pendingApplyInFlight = loading === true;
|
pendingApplyInFlight = loading === true;
|
||||||
if (!pendingApplyInFlight) retryDeclinedAgentTargets();
|
|
||||||
const currentCount = count || parseInt(pendingPillEl.dataset.count || '0', 10) || 0;
|
const currentCount = count || parseInt(pendingPillEl.dataset.count || '0', 10) || 0;
|
||||||
if (pendingApplyInFlight) storeManualApplyState(currentCount);
|
if (pendingApplyInFlight) storeManualApplyState(currentCount);
|
||||||
else clearStoredManualApplyState();
|
else clearStoredManualApplyState();
|
||||||
@@ -4710,7 +4688,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderEditBadge(mode) {
|
function renderEditBadge(mode) {
|
||||||
if (editBadgeSuppressed || sessionOrigin === 'agent') mode = 'hidden';
|
|
||||||
if (mode === 'hidden' || !editBadgeEl) {
|
if (mode === 'hidden' || !editBadgeEl) {
|
||||||
hideConfigureBarTooltip();
|
hideConfigureBarTooltip();
|
||||||
if (editBadgeEl) editBadgeEl.style.display = 'none';
|
if (editBadgeEl) editBadgeEl.style.display = 'none';
|
||||||
@@ -6204,8 +6181,6 @@
|
|||||||
resetSessionFileMeta();
|
resetSessionFileMeta();
|
||||||
currentSessionId = null;
|
currentSessionId = null;
|
||||||
parameterGenerationState = 'idle';
|
parameterGenerationState = 'idle';
|
||||||
sessionOrigin = null;
|
|
||||||
editBadgeSuppressed = false;
|
|
||||||
parameterReadyAnnouncedSession = null;
|
parameterReadyAnnouncedSession = null;
|
||||||
expectedVariants = 0;
|
expectedVariants = 0;
|
||||||
arrivedVariants = 0;
|
arrivedVariants = 0;
|
||||||
@@ -7137,398 +7112,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
//
|
//
|
||||||
// ------------------------------------------------------------------
|
|
||||||
// Agent-initiated targeting (the `generate` command). The agent names an
|
|
||||||
// element by CSS selector over POST /agent-target; the server pushes an
|
|
||||||
// `agent_target` SSE message here. The overlay resolves the selector,
|
|
||||||
// scrolls the element into view, enters the same picked state a user
|
|
||||||
// click produces, and fires the normal Go pipeline, so everything
|
|
||||||
// downstream (generate event, variants, cycling, accept) is unchanged.
|
|
||||||
// The verdict goes back through POST /agent-target-result, which resolves
|
|
||||||
// the agent's held-open CLI call.
|
|
||||||
|
|
||||||
function postAgentTargetResult(targetId, result) {
|
|
||||||
fetch('http://localhost:' + PORT + '/agent-target-result?token=' + TOKEN, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ token: TOKEN, targetId, clientId: AGENT_TARGET_CLIENT_ID, ...result }),
|
|
||||||
}).catch(() => { /* server gone; nothing to report to */ });
|
|
||||||
}
|
|
||||||
|
|
||||||
function describeAgentTargetCandidate(el) {
|
|
||||||
return {
|
|
||||||
tag: el.tagName.toLowerCase(),
|
|
||||||
id: el.id || null,
|
|
||||||
classes: [...el.classList].filter((c) => !c.startsWith('impeccable-')),
|
|
||||||
text: (el.textContent || '').trim().slice(0, 80),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveAgentTargetElement(msg) {
|
|
||||||
let matched;
|
|
||||||
try {
|
|
||||||
matched = [...document.querySelectorAll(msg.selector)];
|
|
||||||
} catch {
|
|
||||||
return { error: { ok: false, error: 'invalid_selector', selector: msg.selector } };
|
|
||||||
}
|
|
||||||
let candidates = matched.filter((el) => pickable(el));
|
|
||||||
if (msg.text) {
|
|
||||||
const needle = String(msg.text).toLowerCase();
|
|
||||||
candidates = candidates.filter((el) => (el.textContent || '').toLowerCase().includes(needle));
|
|
||||||
}
|
|
||||||
if (candidates.length === 0) {
|
|
||||||
return {
|
|
||||||
error: {
|
|
||||||
ok: false,
|
|
||||||
error: 'no_match',
|
|
||||||
selector: msg.selector,
|
|
||||||
matchCount: 0,
|
|
||||||
// How many nodes the raw selector hit before the pickable/text
|
|
||||||
// filters: distinguishes a wrong selector from an unpickable match.
|
|
||||||
rawMatchCount: matched.length,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (Number.isInteger(msg.index)) {
|
|
||||||
const el = candidates[msg.index - 1];
|
|
||||||
if (!el) {
|
|
||||||
return { error: { ok: false, error: 'index_out_of_range', selector: msg.selector, matchCount: candidates.length } };
|
|
||||||
}
|
|
||||||
return { el, matchCount: candidates.length };
|
|
||||||
}
|
|
||||||
if (candidates.length > 1) {
|
|
||||||
return {
|
|
||||||
error: {
|
|
||||||
ok: false,
|
|
||||||
error: 'ambiguous',
|
|
||||||
selector: msg.selector,
|
|
||||||
matchCount: candidates.length,
|
|
||||||
candidates: candidates.slice(0, 8).map(describeAgentTargetCandidate),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return { el: candidates[0], matchCount: 1 };
|
|
||||||
}
|
|
||||||
|
|
||||||
function scrollAgentTargetIntoView(el, done) {
|
|
||||||
const rect = el.getBoundingClientRect();
|
|
||||||
if (rect.top >= 0 && rect.bottom <= window.innerHeight) { done(); return; }
|
|
||||||
let settled = false;
|
|
||||||
let fallback = null;
|
|
||||||
const finish = () => {
|
|
||||||
if (settled) return;
|
|
||||||
settled = true;
|
|
||||||
removeEventListener('scrollend', finish, true);
|
|
||||||
if (fallback) clearTimeout(fallback);
|
|
||||||
done();
|
|
||||||
};
|
|
||||||
// scrollend where supported; a timer covers engines without it and the
|
|
||||||
// no-movement case (element already at its final resting position).
|
|
||||||
addEventListener('scrollend', finish, true);
|
|
||||||
fallback = setTimeout(finish, 1200);
|
|
||||||
el.scrollIntoView({ block: 'center', behavior: 'smooth' });
|
|
||||||
}
|
|
||||||
|
|
||||||
// One id per page load: the server keys claims and roll-call reports on
|
|
||||||
// it, and only the tab that holds the lease can renew it.
|
|
||||||
const AGENT_TARGET_CLIENT_ID = id8();
|
|
||||||
|
|
||||||
// The agent target an agent-initiated Go is serving: set by
|
|
||||||
// actOnAgentTarget around its handleGo call, read once by handleGo.
|
|
||||||
let agentTargetForGo = null;
|
|
||||||
|
|
||||||
// The helper's word on its global bar. The generate lane asks the helper
|
|
||||||
// to keep it out of the way (`impeccable live --no-live-bar`, or an agent
|
|
||||||
// target carrying hideLiveBar), and the helper tells every connected tab
|
|
||||||
// at once (`live_bar`) and every later connection on `connected`, so the
|
|
||||||
// bar stays hidden in every tab, through reloads, the accept, and the
|
|
||||||
// bake, until the helper stops and takes the overlay with it. The variant
|
|
||||||
// controls still show.
|
|
||||||
let liveBarHiddenByHelper = false;
|
|
||||||
|
|
||||||
function applyLiveBarPreference(hidden) {
|
|
||||||
liveBarHiddenByHelper = hidden === true;
|
|
||||||
setLiveBarHidden(liveBarHiddenByHelper);
|
|
||||||
}
|
|
||||||
|
|
||||||
// A plain live session must never notice this code: hiding remembers the
|
|
||||||
// bar's own display value and restoring puts exactly that back, and a
|
|
||||||
// restore on a bar that is not hidden is a no-op, so the `connected`
|
|
||||||
// frame every session receives changes nothing unless the lane asked.
|
|
||||||
function setLiveBarHidden(hidden) {
|
|
||||||
if (!globalBarEl) return;
|
|
||||||
if (hidden) {
|
|
||||||
if (globalBarEl.style.display !== 'none') {
|
|
||||||
globalBarEl.dataset.liveBarDisplay = globalBarEl.style.display || 'flex';
|
|
||||||
globalBarEl.style.display = 'none';
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (globalBarEl.style.display === 'none') {
|
|
||||||
globalBarEl.style.display = globalBarEl.dataset.liveBarDisplay || 'flex';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function claimAgentTarget(targetId, report) {
|
|
||||||
return fetch('http://localhost:' + PORT + '/agent-target-claim?token=' + TOKEN, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ token: TOKEN, targetId, clientId: AGENT_TARGET_CLIENT_ID, ...report }),
|
|
||||||
}).then((res) => res.json())
|
|
||||||
.then((j) => ({ granted: !!j && j.granted === true, pending: !!j && j.pending === true }))
|
|
||||||
.catch(() => ({ granted: false, pending: false }));
|
|
||||||
}
|
|
||||||
|
|
||||||
// `exceptTargetId` is the target this call is about: a tab acting on it
|
|
||||||
// is not busy for itself, but it is busy for every other target, or two
|
|
||||||
// held requests could both be claimed here and the second Go would
|
|
||||||
// overwrite the session the first one minted.
|
|
||||||
function agentTargetBusyReason(exceptTargetId) {
|
|
||||||
if (pendingApplyInFlight) return 'manual_apply_in_flight';
|
|
||||||
if (state !== 'IDLE' && state !== 'PICKING' && state !== 'CONFIGURING') return 'session_active';
|
|
||||||
for (const [targetId, status] of agentTargetsSeen) {
|
|
||||||
if (status === 'acting' && targetId !== exceptTargetId) return 'agent_target_in_flight';
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Targets this tab declined as busy. A busy report is only this tab's word
|
|
||||||
// at that moment: the moment it is free again (setLiveState), it claims
|
|
||||||
// each of these as eligible, and the server drops the stale report, so a
|
|
||||||
// busy verdict is never built on a tab that has since gone idle. The
|
|
||||||
// server denies claims for resolved targets, so retries are harmless.
|
|
||||||
const busyDeclinedTargets = new Map();
|
|
||||||
|
|
||||||
function declineAgentTargetBusy(msg, busy) {
|
|
||||||
busyDeclinedTargets.set(msg.targetId, msg);
|
|
||||||
noteAgentTarget(msg.targetId, 'declined');
|
|
||||||
claimAgentTarget(msg.targetId, { eligible: false, state, reason: busy });
|
|
||||||
}
|
|
||||||
|
|
||||||
// A torn-down overlay, or one whose helper connection is gone, cannot
|
|
||||||
// serve a target and must not even claim one: it would hold the lease for
|
|
||||||
// a request it will never act on.
|
|
||||||
function agentTargetOverlayGone() {
|
|
||||||
return !evtSource;
|
|
||||||
}
|
|
||||||
|
|
||||||
// A denied claimant retries at this cadence, a little over the lease, so
|
|
||||||
// the first retry after a dead holder's lease lapses is granted.
|
|
||||||
const AGENT_TARGET_RESCUE_RETRY_MS = 3500;
|
|
||||||
|
|
||||||
// Claim the lease and act as the holder. A denied claim means another tab
|
|
||||||
// holds the lease. That holder can die before posting its result (reload,
|
|
||||||
// crash, even after renewing), and its lease lapses after ~3s, so this tab
|
|
||||||
// keeps retrying for as long as the server still holds the request: the
|
|
||||||
// answer's `pending` is the server's word that the request is alive, and
|
|
||||||
// it turns false the moment the request resolved or timed out, so no tab
|
|
||||||
// retries a request nobody awaits. A tab that turned busy meanwhile joins
|
|
||||||
// the roll call instead of taking a lease it cannot use. The first claim
|
|
||||||
// and the busy-to-idle re-claim share this.
|
|
||||||
function claimAndActOnAgentTarget(msg) {
|
|
||||||
if (agentTargetOverlayGone()) return;
|
|
||||||
const busy = agentTargetBusyReason(msg.targetId);
|
|
||||||
if (busy) { declineAgentTargetBusy(msg, busy); return; }
|
|
||||||
if (declineAgentTargetUnresolvable(msg)) return;
|
|
||||||
claimAgentTarget(msg.targetId, { eligible: true }).then((claim) => {
|
|
||||||
if (claim.granted) { noteAgentTarget(msg.targetId, 'acting'); actOnAgentTarget(msg); return; }
|
|
||||||
noteAgentTarget(msg.targetId, 'denied');
|
|
||||||
if (!claim.pending) return;
|
|
||||||
setTimeout(() => claimAndActOnAgentTarget(msg), AGENT_TARGET_RESCUE_RETRY_MS);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function retryDeclinedAgentTargets() {
|
|
||||||
if (busyDeclinedTargets.size === 0 || agentTargetBusyReason()) return;
|
|
||||||
for (const [targetId, msg] of busyDeclinedTargets) {
|
|
||||||
busyDeclinedTargets.delete(targetId);
|
|
||||||
claimAndActOnAgentTarget(msg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// This page's participation in each target it heard: 'acting' once a
|
|
||||||
// claim was granted, 'done' once it replied (or stood down from a lapsed
|
|
||||||
// lease), else the word it last gave. The server replays pending targets
|
|
||||||
// to every connection that opens. After a reconnect that overlapped the
|
|
||||||
// old connection the server still holds this page's word; after one that
|
|
||||||
// did not, it dropped the word on the close, so a replayed target is
|
|
||||||
// handled again: a busy or unresolvable page re-declines (idempotent), an
|
|
||||||
// idle page claims.
|
|
||||||
const agentTargetsSeen = new Map();
|
|
||||||
function noteAgentTarget(targetId, status) {
|
|
||||||
agentTargetsSeen.set(targetId, status);
|
|
||||||
if (agentTargetsSeen.size > 100) agentTargetsSeen.delete(agentTargetsSeen.keys().next().value);
|
|
||||||
}
|
|
||||||
|
|
||||||
// A target this page took a lease on is off-limits for a replay: while
|
|
||||||
// acting (a second claim or Go), and once done, because its result may
|
|
||||||
// still be on the wire and this tab is GENERATING by then, so handling
|
|
||||||
// the replay would decline busy, hand the lease back mid-resolution, and
|
|
||||||
// let another tab fire a second Go.
|
|
||||||
function agentTargetTaken(targetId) {
|
|
||||||
const status = agentTargetsSeen.get(targetId);
|
|
||||||
return status === 'acting' || status === 'done';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only a page that can resolve the target claims it. A tab whose page
|
|
||||||
// lacks the element declines with its resolution verdict instead, so a
|
|
||||||
// first-wins claim never lets the wrong page answer for a target that
|
|
||||||
// another page has. The server prefers a busy report (a tab that could
|
|
||||||
// serve later) over these, and returns the resolution verdict only when
|
|
||||||
// no connected page can serve.
|
|
||||||
//
|
|
||||||
// An element can be momentarily absent (a route still rendering, an HMR
|
|
||||||
// commit mid-swap), so a failed resolution is not this page's final word:
|
|
||||||
// it is re-checked a few times over about two seconds, claiming the
|
|
||||||
// moment the element mounts, and only the last miss is reported. The
|
|
||||||
// server's timeout still bounds the whole exchange.
|
|
||||||
// The page reports the miss at once (so the other overlays' words can
|
|
||||||
// complete the roll call) and keeps re-checking at this cadence for as
|
|
||||||
// long as the server says the request is pending: the server holds an
|
|
||||||
// all-no_match roll call open for a short grace precisely so a late mount
|
|
||||||
// can still be claimed, drops the stale report on an eligible claim, and
|
|
||||||
// ends the watch by answering pending:false once the request resolved or
|
|
||||||
// timed out.
|
|
||||||
const AGENT_TARGET_RESOLVE_WATCH_MS = 500;
|
|
||||||
|
|
||||||
function declineAgentTargetUnresolvable(msg) {
|
|
||||||
const probe = resolveAgentTargetElement(msg);
|
|
||||||
if (!probe.error) return false;
|
|
||||||
reportAgentTargetUnresolvable(msg, probe.error);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function reportAgentTargetUnresolvable(msg, error) {
|
|
||||||
noteAgentTarget(msg.targetId, 'declined');
|
|
||||||
claimAgentTarget(msg.targetId, { eligible: false, state, reason: 'no_match', result: error }).then((answer) => {
|
|
||||||
if (!answer.pending) return;
|
|
||||||
setTimeout(() => watchAgentTargetResolution(msg, error), AGENT_TARGET_RESOLVE_WATCH_MS);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function watchAgentTargetResolution(msg, lastError) {
|
|
||||||
if (agentTargetOverlayGone() || agentTargetTaken(msg.targetId)) return;
|
|
||||||
const busy = agentTargetBusyReason(msg.targetId);
|
|
||||||
if (busy) { declineAgentTargetBusy(msg, busy); return; }
|
|
||||||
const probe = resolveAgentTargetElement(msg);
|
|
||||||
if (!probe.error) { claimAndActOnAgentTarget(msg); return; }
|
|
||||||
// Still unresolvable: re-report (idempotent); the answer says whether
|
|
||||||
// the server is still holding the request open.
|
|
||||||
reportAgentTargetUnresolvable(msg, probe.error || lastError);
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleAgentTarget(msg) {
|
|
||||||
if (!msg || typeof msg.targetId !== 'string') return;
|
|
||||||
if (agentTargetTaken(msg.targetId)) return;
|
|
||||||
noteAgentTarget(msg.targetId, 'heard');
|
|
||||||
const busy = agentTargetBusyReason(msg.targetId);
|
|
||||||
if (busy) {
|
|
||||||
// Roll call: a busy tab reports itself and never acts. The server
|
|
||||||
// answers `busy` the moment every connected overlay has reported, so
|
|
||||||
// an idle tab elsewhere is never raced by a timer.
|
|
||||||
declineAgentTargetBusy(msg, busy);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (declineAgentTargetUnresolvable(msg)) return;
|
|
||||||
// Eligible tabs race for the server's lease and only the holder acts. A
|
|
||||||
// hidden tab yields a short head start so a visible one wins when both
|
|
||||||
// exist, and still serves the request on its own: the user finds the
|
|
||||||
// selection waiting when they return to it.
|
|
||||||
setTimeout(() => claimAndActOnAgentTarget(msg), document.hidden ? 150 : 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
function actOnAgentTarget(msg) {
|
|
||||||
if (agentTargetOverlayGone()) return;
|
|
||||||
// Every exit ends this tab's acting state, so a later target is not
|
|
||||||
// refused for a Go that already happened or never will.
|
|
||||||
const reply = (result) => { noteAgentTarget(msg.targetId, 'done'); postAgentTargetResult(msg.targetId, result); };
|
|
||||||
const busy = agentTargetBusyReason(msg.targetId);
|
|
||||||
if (busy) {
|
|
||||||
// Turned busy between claim and act: report it, which also hands the
|
|
||||||
// lease back so the roll call can complete or a rescuer can claim.
|
|
||||||
declineAgentTargetBusy(msg, busy);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const resolved = resolveAgentTargetElement(msg);
|
|
||||||
if (resolved.error) {
|
|
||||||
// The element went away between claim and act. A result would end the
|
|
||||||
// request for every tab; a decline hands the lease back so another
|
|
||||||
// page or a remount can still serve it.
|
|
||||||
reportAgentTargetUnresolvable(msg, resolved.error);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const el = resolved.el;
|
|
||||||
if (msg.dryRun) {
|
|
||||||
reply({
|
|
||||||
ok: true,
|
|
||||||
dryRun: true,
|
|
||||||
matchCount: resolved.matchCount,
|
|
||||||
element: describeAgentTargetCandidate(el),
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
scrollAgentTargetIntoView(el, () => {
|
|
||||||
// Torn down during the scroll settle: do not renew. The lease lapses
|
|
||||||
// for a rescuer instead of Go minting a session on a dismantled
|
|
||||||
// overlay.
|
|
||||||
if (agentTargetOverlayGone()) return;
|
|
||||||
// Renew the lease right before the irreversible part: a tab whose
|
|
||||||
// lease lapsed while it scrolled (a rescuer took over) stops here, so
|
|
||||||
// one request never gets two Go presses.
|
|
||||||
claimAgentTarget(msg.targetId, { eligible: true }).then((renewal) => {
|
|
||||||
if (!renewal.granted) { noteAgentTarget(msg.targetId, 'done'); return; }
|
|
||||||
// An insert placement left mid-configure gives way, exactly as a
|
|
||||||
// click outside it does in handleClick.
|
|
||||||
if (state === 'CONFIGURING' && configureKind === 'insert') cancelInsertConfigure();
|
|
||||||
// Mirror of the user-click pick entry in handleClick, minus the
|
|
||||||
// pick-mode gate (the agent's intent replaces the toggle); the entry
|
|
||||||
// goes through beginNewLiveConfiguration like every other pick so
|
|
||||||
// deferred recovery sees a fresh interaction revision.
|
|
||||||
selectedElement = el;
|
|
||||||
beginNewLiveConfiguration();
|
|
||||||
showHighlight(selectedElement);
|
|
||||||
clearAnnotations();
|
|
||||||
showAnnotOverlay(selectedElement);
|
|
||||||
showBar('configure');
|
|
||||||
editBadgeSuppressed = true;
|
|
||||||
renderEditBadge('hidden');
|
|
||||||
startScrollTracking();
|
|
||||||
maybePrefetchPage();
|
|
||||||
maybeWarnConditionalAncestor(selectedElement);
|
|
||||||
// Preset what the agent asked for, then fire the same Go a user press
|
|
||||||
// fires. handleGo reads exactly these inputs.
|
|
||||||
selectedAction = msg.action;
|
|
||||||
selectedCount = msg.count;
|
|
||||||
// updateBarContent rebuilds the configure row and replaces the input
|
|
||||||
// element, so the prompt must be written into the input it creates,
|
|
||||||
// never before (the action-chip click handler does the same dance).
|
|
||||||
updateBarContent('configure');
|
|
||||||
const input = uiGetById(PREFIX + '-input');
|
|
||||||
if (input) input.value = msg.prompt || '';
|
|
||||||
// The target rides on the generate event too: the helper resolves
|
|
||||||
// the request from whichever lands first, so a page that dies
|
|
||||||
// between Go and its result cannot leave the request pending for a
|
|
||||||
// second Go elsewhere.
|
|
||||||
const candidate = describeAgentTargetCandidate(el);
|
|
||||||
agentTargetForGo = { targetId: msg.targetId, matchCount: resolved.matchCount, action: msg.action, count: msg.count, element: candidate };
|
|
||||||
handleGo();
|
|
||||||
agentTargetForGo = null;
|
|
||||||
if (state === 'GENERATING' && currentSessionId) {
|
|
||||||
reply({
|
|
||||||
ok: true,
|
|
||||||
matchCount: resolved.matchCount,
|
|
||||||
sessionId: currentSessionId,
|
|
||||||
action: msg.action,
|
|
||||||
count: msg.count,
|
|
||||||
element: candidate,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
reply({ ok: false, error: 'go_failed', state });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// SSE (server→browser) + fetch POST (browser→server)
|
// SSE (server→browser) + fetch POST (browser→server)
|
||||||
// Zero-dependency replacement for WebSocket.
|
// Zero-dependency replacement for WebSocket.
|
||||||
//
|
//
|
||||||
@@ -7538,7 +7121,7 @@
|
|||||||
const SSE_MAX_RETRIES = 20; // generous: heartbeats keep the connection alive, so retries mean real trouble
|
const SSE_MAX_RETRIES = 20; // generous: heartbeats keep the connection alive, so retries mean real trouble
|
||||||
|
|
||||||
function connectSSE() {
|
function connectSSE() {
|
||||||
evtSource = new EventSource('http://localhost:' + PORT + '/events?token=' + TOKEN + '&clientId=' + AGENT_TARGET_CLIENT_ID);
|
evtSource = new EventSource('http://localhost:' + PORT + '/events?token=' + TOKEN);
|
||||||
|
|
||||||
evtSource.onopen = () => {
|
evtSource.onopen = () => {
|
||||||
sseRetries = 0; // reset on successful (re)connect
|
sseRetries = 0; // reset on successful (re)connect
|
||||||
@@ -7549,11 +7132,8 @@
|
|||||||
let msg; try { msg = JSON.parse(e.data); } catch { return; }
|
let msg; try { msg = JSON.parse(e.data); } catch { return; }
|
||||||
switch (msg.type) {
|
switch (msg.type) {
|
||||||
case 'connected':
|
case 'connected':
|
||||||
applyLiveBarPreference(msg.hideLiveBar === true);
|
|
||||||
hasProjectContext = !!msg.hasProjectContext;
|
hasProjectContext = !!msg.hasProjectContext;
|
||||||
// The generate lane runs without PRODUCT.md by design and never
|
if (!hasProjectContext) showToast(`No PRODUCT.md found. Variants will be brand-agnostic. Run ${IMPECCABLE_COMMAND} init to generate one.`, 7000);
|
||||||
// sends the user to init, so its quiet chrome skips this notice.
|
|
||||||
if (!hasProjectContext && !liveBarHiddenByHelper) showToast(`No PRODUCT.md found. Variants will be brand-agnostic. Run ${IMPECCABLE_COMMAND} init to generate one.`, 7000);
|
|
||||||
console.log('[impeccable] Live mode connected.');
|
console.log('[impeccable] Live mode connected.');
|
||||||
syncAgentPollingUi(!!msg.agentPolling);
|
syncAgentPollingUi(!!msg.agentPolling);
|
||||||
startAgentStatusPoll();
|
startAgentStatusPoll();
|
||||||
@@ -7563,15 +7143,9 @@
|
|||||||
syncPageInteractionCursor();
|
syncPageInteractionCursor();
|
||||||
syncPageChatFocus('sse-connected');
|
syncPageChatFocus('sse-connected');
|
||||||
break;
|
break;
|
||||||
case 'live_bar':
|
|
||||||
applyLiveBarPreference(msg.hidden === true);
|
|
||||||
break;
|
|
||||||
case 'agent_polling':
|
case 'agent_polling':
|
||||||
syncAgentPollingUi(!!msg.connected);
|
syncAgentPollingUi(!!msg.connected);
|
||||||
break;
|
break;
|
||||||
case 'agent_target':
|
|
||||||
handleAgentTarget(msg);
|
|
||||||
break;
|
|
||||||
case 'agent_phase':
|
case 'agent_phase':
|
||||||
if (msg.id === currentSessionId && (state === 'GENERATING' || state === 'CYCLING')) {
|
if (msg.id === currentSessionId && (state === 'GENERATING' || state === 'CYCLING')) {
|
||||||
// Advance the visible phase monotonically. A behind/resumed
|
// Advance the visible phase monotonically. A behind/resumed
|
||||||
@@ -7634,11 +7208,6 @@
|
|||||||
disableInlineEdit();
|
disableInlineEdit();
|
||||||
refreshParamsPanel();
|
refreshParamsPanel();
|
||||||
}
|
}
|
||||||
// The done reply is the agent's last word on this generation:
|
|
||||||
// with every variant mounted and no knobs declared, the Tune
|
|
||||||
// chip must stop spinning. A reload between the mount and this
|
|
||||||
// reply restored the pending state from the cache.
|
|
||||||
completeParameterGenerationIfReady();
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
// Source fallback when HMR did not land variants in this tab.
|
// Source fallback when HMR did not land variants in this tab.
|
||||||
@@ -7802,15 +7371,6 @@
|
|||||||
}).then(async res => {
|
}).then(async res => {
|
||||||
if (res.ok) return res;
|
if (res.ok) return res;
|
||||||
const body = await res.json().catch(() => ({}));
|
const body = await res.json().catch(() => ({}));
|
||||||
// The helper refused to open a session for an agent target it has
|
|
||||||
// already answered (another page served it after this page's lease
|
|
||||||
// lapsed mid-capture, or the request timed out): drop the local
|
|
||||||
// session and hand the surface back.
|
|
||||||
if (body.error === 'agent_target_already_served' && msg.type === 'generate'
|
|
||||||
&& msg.id && msg.id === currentSessionId) {
|
|
||||||
abandonSupersededGo(msg.id);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
// The server refused to journal progress for a session it has never
|
// The server refused to journal progress for a session it has never
|
||||||
// seen: this browser is carrying state from another project or a
|
// seen: this browser is carrying state from another project or a
|
||||||
// wiped store (two apps sharing a localhost port). Continuing to
|
// wiped store (two apps sharing a localhost port). Continuing to
|
||||||
@@ -7832,14 +7392,6 @@
|
|||||||
return sessionCreationGate.then(doSend);
|
return sessionCreationGate.then(doSend);
|
||||||
}
|
}
|
||||||
|
|
||||||
function abandonSupersededGo(sessionId) {
|
|
||||||
if (sessionId !== currentSessionId) return;
|
|
||||||
console.warn('[impeccable] The helper already answered this agent target; clearing session ' + sessionId + '.');
|
|
||||||
markSessionHandled();
|
|
||||||
cleanup({ instantChrome: true });
|
|
||||||
showToast('The helper already answered this request, so this session was cleared. Pick an element to start fresh.', 6000);
|
|
||||||
}
|
|
||||||
|
|
||||||
let abandonedForeignSessionId = null;
|
let abandonedForeignSessionId = null;
|
||||||
function abandonForeignSession(sessionId) {
|
function abandonForeignSession(sessionId) {
|
||||||
if (abandonedForeignSessionId === sessionId || sessionId !== currentSessionId) return;
|
if (abandonedForeignSessionId === sessionId || sessionId !== currentSessionId) return;
|
||||||
@@ -8244,7 +7796,6 @@
|
|||||||
visibleVariant = 0;
|
visibleVariant = 0;
|
||||||
generationPhase = 'queued';
|
generationPhase = 'queued';
|
||||||
parameterGenerationState = 'pending';
|
parameterGenerationState = 'pending';
|
||||||
sessionOrigin = agentTargetForGo ? 'agent' : null;
|
|
||||||
parameterReadyAnnouncedSession = null;
|
parameterReadyAnnouncedSession = null;
|
||||||
resetSessionFileMeta();
|
resetSessionFileMeta();
|
||||||
|
|
||||||
@@ -8270,24 +7821,6 @@
|
|||||||
};
|
};
|
||||||
if (snapshot.comments.length > 0) basePayload.comments = snapshot.comments;
|
if (snapshot.comments.length > 0) basePayload.comments = snapshot.comments;
|
||||||
if (snapshot.strokes.length > 0) basePayload.strokes = snapshot.strokes;
|
if (snapshot.strokes.length > 0) basePayload.strokes = snapshot.strokes;
|
||||||
if (agentTargetForGo) {
|
|
||||||
// An agent-initiated Go names the target it serves (see
|
|
||||||
// actOnAgentTarget): the helper resolves that request from this event
|
|
||||||
// as well as from the overlay's own result post.
|
|
||||||
basePayload.agentTarget = {
|
|
||||||
targetId: agentTargetForGo.targetId,
|
|
||||||
clientId: AGENT_TARGET_CLIENT_ID,
|
|
||||||
result: {
|
|
||||||
ok: true,
|
|
||||||
matchCount: agentTargetForGo.matchCount,
|
|
||||||
sessionId: currentSessionId,
|
|
||||||
action: agentTargetForGo.action,
|
|
||||||
count: agentTargetForGo.count,
|
|
||||||
element: agentTargetForGo.element,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
agentTargetForGo = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Hide the interactive overlay so it doesn't linger during generation.
|
// Hide the interactive overlay so it doesn't linger during generation.
|
||||||
hideAnnotOverlay();
|
hideAnnotOverlay();
|
||||||
@@ -8348,7 +7881,6 @@
|
|||||||
visibleVariant = 0;
|
visibleVariant = 0;
|
||||||
generationPhase = 'queued';
|
generationPhase = 'queued';
|
||||||
parameterGenerationState = 'pending';
|
parameterGenerationState = 'pending';
|
||||||
sessionOrigin = agentTargetForGo ? 'agent' : null;
|
|
||||||
parameterReadyAnnouncedSession = null;
|
parameterReadyAnnouncedSession = null;
|
||||||
resetSessionFileMeta();
|
resetSessionFileMeta();
|
||||||
selectedElement = placeholderElement;
|
selectedElement = placeholderElement;
|
||||||
@@ -9395,8 +8927,6 @@ void main() {
|
|||||||
pagePickSkipClick = false;
|
pagePickSkipClick = false;
|
||||||
currentSessionId = null;
|
currentSessionId = null;
|
||||||
parameterGenerationState = 'idle';
|
parameterGenerationState = 'idle';
|
||||||
sessionOrigin = null;
|
|
||||||
editBadgeSuppressed = false;
|
|
||||||
parameterReadyAnnouncedSession = null;
|
parameterReadyAnnouncedSession = null;
|
||||||
selectedAction = 'impeccable';
|
selectedAction = 'impeccable';
|
||||||
pendingAcceptedSession = null;
|
pendingAcceptedSession = null;
|
||||||
@@ -9488,7 +9018,6 @@ void main() {
|
|||||||
paramsCurrentValues = { ...saved.paramValues };
|
paramsCurrentValues = { ...saved.paramValues };
|
||||||
}
|
}
|
||||||
if (saved.parameterState) parameterGenerationState = saved.parameterState;
|
if (saved.parameterState) parameterGenerationState = saved.parameterState;
|
||||||
sessionOrigin = saved.origin === 'agent' ? 'agent' : null;
|
|
||||||
if (saved.generationPhase) generationPhase = saved.generationPhase;
|
if (saved.generationPhase) generationPhase = saved.generationPhase;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -9576,12 +9105,7 @@ void main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function restoreSessionWithoutWrapper(reason, activeSessions) {
|
function restoreSessionWithoutWrapper(reason, activeSessions) {
|
||||||
// The session cache is per origin, so a tab on another page of the same
|
const cached = loadSession();
|
||||||
// app sees this page's session too. Only the page that saved it may
|
|
||||||
// resume it: the server-adoption branch below already applies the same
|
|
||||||
// check, and a tab on another page has nothing to render for it.
|
|
||||||
const cachedRaw = loadSession();
|
|
||||||
const cached = cachedRaw?.id && !pageMatchesCurrent(cachedRaw.pageUrl) ? null : cachedRaw;
|
|
||||||
// localStorage is a cache, not a gate. A cleared tab, a second browser
|
// localStorage is a cache, not a gate. A cleared tab, a second browser
|
||||||
// profile, or a teardown that dropped local state all leave the durable
|
// profile, or a teardown that dropped local state all leave the durable
|
||||||
// server session as the only record of work in progress; adopt it instead
|
// server session as the only record of work in progress; adopt it instead
|
||||||
@@ -9694,7 +9218,6 @@ void main() {
|
|||||||
pageUrl: location.pathname,
|
pageUrl: location.pathname,
|
||||||
paramValues: { ...paramsCurrentValues },
|
paramValues: { ...paramsCurrentValues },
|
||||||
parameterState: parameterGenerationState,
|
parameterState: parameterGenerationState,
|
||||||
origin: sessionOrigin || undefined,
|
|
||||||
insertPlaceholder: insertPlaceholderSnapshot || undefined,
|
insertPlaceholder: insertPlaceholderSnapshot || undefined,
|
||||||
pickedAnchor: pickedAnchorSnapshot || undefined,
|
pickedAnchor: pickedAnchorSnapshot || undefined,
|
||||||
pickedAnchorViewportTop: Number.isFinite(pickedAnchorViewportTop) ? pickedAnchorViewportTop : undefined,
|
pickedAnchorViewportTop: Number.isFinite(pickedAnchorViewportTop) ? pickedAnchorViewportTop : undefined,
|
||||||
@@ -9820,8 +9343,6 @@ void main() {
|
|||||||
pagePickSkipClick = false;
|
pagePickSkipClick = false;
|
||||||
currentSessionId = null;
|
currentSessionId = null;
|
||||||
parameterGenerationState = 'idle';
|
parameterGenerationState = 'idle';
|
||||||
sessionOrigin = null;
|
|
||||||
editBadgeSuppressed = false;
|
|
||||||
parameterReadyAnnouncedSession = null;
|
parameterReadyAnnouncedSession = null;
|
||||||
selectedAction = 'impeccable';
|
selectedAction = 'impeccable';
|
||||||
renderEditBadge('hidden');
|
renderEditBadge('hidden');
|
||||||
@@ -10080,14 +9601,6 @@ void main() {
|
|||||||
|
|
||||||
const resumedState = arrivedVariants > 0 ? 'CYCLING' : 'GENERATING';
|
const resumedState = arrivedVariants > 0 ? 'CYCLING' : 'GENERATING';
|
||||||
|
|
||||||
// A reload between the variants mounting and the agent's done reply
|
|
||||||
// restores a pending Tune state from the cache; the helper knows whether
|
|
||||||
// that generation already finished.
|
|
||||||
if (arrivedVariants >= expectedVariants && expectedVariants > 0
|
|
||||||
&& (parameterGenerationState === 'pending' || parameterGenerationState === 'loading')) {
|
|
||||||
settleParameterStateFromHelper(sessionId);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find the visible variant's content element for highlight positioning.
|
// Find the visible variant's content element for highlight positioning.
|
||||||
const isInsert = wrapper.dataset.impeccableMode === 'insert';
|
const isInsert = wrapper.dataset.impeccableMode === 'insert';
|
||||||
const visEl = visibleVariant > 0 ? pickVariantContent(wrapper, visibleVariant) : null;
|
const visEl = visibleVariant > 0 ? pickVariantContent(wrapper, visibleVariant) : null;
|
||||||
@@ -11552,21 +11065,6 @@ void main() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// After a resume the cache may say the Tune knobs are still coming while
|
|
||||||
// the agent already replied done before the reload. The helper's session
|
|
||||||
// record settles it; otherwise the done reply on SSE does.
|
|
||||||
function settleParameterStateFromHelper(sessionId) {
|
|
||||||
fetch('http://localhost:' + PORT + '/status?token=' + TOKEN, { cache: 'no-store' })
|
|
||||||
.then((res) => (res.ok ? res.json() : null))
|
|
||||||
.then((data) => {
|
|
||||||
if (!data || sessionId !== currentSessionId) return;
|
|
||||||
const session = (data.activeSessions || []).find((s) => s && s.id === sessionId);
|
|
||||||
if (!session) return;
|
|
||||||
if (session.generationCompletedAt || session.generationPhase === 'completed') completeParameterGenerationIfReady();
|
|
||||||
})
|
|
||||||
.catch(() => { /* the done reply on SSE settles it otherwise */ });
|
|
||||||
}
|
|
||||||
|
|
||||||
function fetchAgentPollingStatus() {
|
function fetchAgentPollingStatus() {
|
||||||
fetch('http://localhost:' + PORT + '/status?token=' + TOKEN, { cache: 'no-store' })
|
fetch('http://localhost:' + PORT + '/status?token=' + TOKEN, { cache: 'no-store' })
|
||||||
.then((res) => (res.ok ? res.json() : null))
|
.then((res) => (res.ok ? res.json() : null))
|
||||||
@@ -11606,15 +11104,11 @@ void main() {
|
|||||||
uiAppendStyle(s);
|
uiAppendStyle(s);
|
||||||
}
|
}
|
||||||
|
|
||||||
// The generate lane's helper says so in the served script itself, so a
|
|
||||||
// lane session never draws the bar at all; every other session mounts
|
|
||||||
// it exactly as before.
|
|
||||||
const barHiddenFromStart = window.__IMPECCABLE_LIVE_BAR_HIDDEN__ === true;
|
|
||||||
globalBarEl = el('div', {
|
globalBarEl = el('div', {
|
||||||
position: 'fixed', bottom: '14px', left: '50%',
|
position: 'fixed', bottom: '14px', left: '50%',
|
||||||
transform: 'translateX(-50%) translateY(20px)',
|
transform: 'translateX(-50%) translateY(20px)',
|
||||||
zIndex: Z.bar + 5,
|
zIndex: Z.bar + 5,
|
||||||
display: barHiddenFromStart ? 'none' : 'flex', alignItems: 'stretch',
|
display: 'flex', alignItems: 'stretch',
|
||||||
gap: '0',
|
gap: '0',
|
||||||
width: 'max-content',
|
width: 'max-content',
|
||||||
background: P.surface,
|
background: P.surface,
|
||||||
@@ -11630,10 +11124,6 @@ void main() {
|
|||||||
});
|
});
|
||||||
globalBarEl.id = PREFIX + '-global-bar';
|
globalBarEl.id = PREFIX + '-global-bar';
|
||||||
globalBarEl.dataset.theme = theme;
|
globalBarEl.dataset.theme = theme;
|
||||||
if (barHiddenFromStart) {
|
|
||||||
liveBarHiddenByHelper = true;
|
|
||||||
globalBarEl.dataset.liveBarDisplay = 'flex';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Brand mark - kinpaku Impeccable icon (site header / favicon paths).
|
// Brand mark - kinpaku Impeccable icon (site header / favicon paths).
|
||||||
const brand = el('span', {
|
const brand = el('span', {
|
||||||
@@ -12029,9 +11519,6 @@ void main() {
|
|||||||
// Listen for detection results AND ready signal
|
// Listen for detection results AND ready signal
|
||||||
window.addEventListener('message', onDetectMessage);
|
window.addEventListener('message', onDetectMessage);
|
||||||
updateGlobalBarState();
|
updateGlobalBarState();
|
||||||
// The helper may already have said the bar stays hidden (a connect
|
|
||||||
// that raced the bar build, or a reload mid-lane): re-apply it here.
|
|
||||||
if (liveBarHiddenByHelper) setLiveBarHidden(true);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateGlobalBarState() {
|
function updateGlobalBarState() {
|
||||||
@@ -12228,13 +11715,6 @@ void main() {
|
|||||||
|
|
||||||
/** Full teardown: remove all UI, disconnect SSE, clean up. */
|
/** Full teardown: remove all UI, disconnect SSE, clean up. */
|
||||||
function teardown() {
|
function teardown() {
|
||||||
// Declined targets die with the overlay: the IDLE transition below must
|
|
||||||
// not re-claim a lease this page can no longer act on. So does the
|
|
||||||
// target ledger: an 'acting' entry from a Go that never happened must
|
|
||||||
// not refuse every target the next connection hears.
|
|
||||||
busyDeclinedTargets.clear();
|
|
||||||
agentTargetsSeen.clear();
|
|
||||||
liveBarHiddenByHelper = false;
|
|
||||||
stopAgentStatusPoll();
|
stopAgentStatusPoll();
|
||||||
hideAgentPollTooltip();
|
hideAgentPollTooltip();
|
||||||
if (agentPollTooltipEl) {
|
if (agentPollTooltipEl) {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
name: impeccable
|
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.
|
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: 4.3.1
|
version: 4.3.0
|
||||||
license: Apache 2.0
|
license: Apache 2.0
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -63,8 +63,7 @@ Choose the mode from the requested surface, not the product, and persist it only
|
|||||||
| `clarify [target]` | Fix | Improve UX copy, labels, and error messages | [reference/clarify.md](reference/clarify.md) |
|
| `clarify [target]` | Fix | Improve UX copy, labels, and error messages | [reference/clarify.md](reference/clarify.md) |
|
||||||
| `adapt [target]` | Fix | Adapt for different devices and screen sizes | [reference/adapt.md](reference/adapt.md) · native: [reference/adapt.native.md](reference/adapt.native.md) |
|
| `adapt [target]` | Fix | Adapt for different devices and screen sizes | [reference/adapt.md](reference/adapt.md) · native: [reference/adapt.native.md](reference/adapt.native.md) |
|
||||||
| `optimize [target]` | Fix | Diagnose and fix UI performance | [reference/optimize.md](reference/optimize.md) |
|
| `optimize [target]` | Fix | Diagnose and fix UI performance | [reference/optimize.md](reference/optimize.md) |
|
||||||
| `live` | Iterate | Visual variant mode: pick elements in the browser, iterate on alternatives | [reference/live.md](reference/live.md) |
|
| `live` | Iterate | Visual variant mode: pick elements in the browser, generate alternatives | [reference/live.md](reference/live.md) |
|
||||||
| `generate [n] [action] [element]` | Iterate | Variants, versions, or alternatives of a named element to choose from in the live browser; no manual picking | [reference/generate.md](reference/generate.md) |
|
|
||||||
|
|
||||||
Routing:
|
Routing:
|
||||||
|
|
||||||
|
|||||||
@@ -188,12 +188,6 @@ Test thoroughly across contexts:
|
|||||||
- **Edge cases**: Very small screens (320px), very large screens (4K)
|
- **Edge cases**: Very small screens (320px), very large screens (4K)
|
||||||
- **Slow connections**: Test on throttled network
|
- **Slow connections**: Test on throttled network
|
||||||
|
|
||||||
**Custom controls** (sliders, drag surfaces, scrollable control strips): a before/after slider can pass every width check above and still refuse to drag on iOS, so exercise each one in scope in the same batched round as the checks above:
|
|
||||||
|
|
||||||
- **Primary gesture**: Tap it and confirm it responds as designed, then drag it with the target input method; the drag must complete, not just start
|
|
||||||
- **Scroll across it**: A swipe along the page's scroll axis across the control scrolls the page or container without activating it; a drag that starts on the control along its axis moves the control, not the page. Neither failure throws an error, so try both
|
|
||||||
- **Evidence**: Say what produced the evidence: an emulated viewport, synthesized touch input through a browser tool, which engine ran it (Chromium is not Safari), or a physical device. Screenshots and resized viewports verify layout, never a gesture. Name what stayed untested and move on; unreachable hardware is a reported gap, not a blocker
|
|
||||||
|
|
||||||
When the adaptation feels native to each context, hand off to `/impeccable polish` for the final pass.
|
When the adaptation feels native to each context, hand off to `/impeccable polish` for the final pass.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -48,12 +48,11 @@ Run comprehensive checks across 5 dimensions. Score each dimension 0-4 using the
|
|||||||
**Check for**:
|
**Check for**:
|
||||||
- **Fixed widths**: Hard-coded widths that break on mobile
|
- **Fixed widths**: Hard-coded widths that break on mobile
|
||||||
- **Touch targets**: Interactive elements < 44x44px
|
- **Touch targets**: Interactive elements < 44x44px
|
||||||
- **Broken touch interaction**: Custom sliders, drag surfaces, and scrollable control strips whose primary gesture fails under touch, that swallow page scroll or lose the drag to it, or that stay stuck after an interrupted gesture. Code tells: mouse-only handlers, no `touch-action` on a pointer-event drag surface, drag state that nothing clears on cancel, lost capture, or blur. Exercise the gesture when a browser tool can synthesize touch (a rendered viewport proves layout, not the gesture), then say what produced the evidence (emulated viewport, synthesized touch, which engine, physical device) and what stayed untested
|
|
||||||
- **Horizontal scroll**: Content overflow on narrow viewports
|
- **Horizontal scroll**: Content overflow on narrow viewports
|
||||||
- **Text scaling**: Layouts that break when text size increases
|
- **Text scaling**: Layouts that break when text size increases
|
||||||
- **Missing breakpoints**: No mobile/tablet variants
|
- **Missing breakpoints**: No mobile/tablet variants
|
||||||
|
|
||||||
**Score 0-4**: 0=Desktop-only (breaks on mobile), 1=Major issues (some breakpoints, many failures), 2=Partial (works on mobile, rough edges), 3=Good (responsive, minor touch target or overflow issues), 4=Excellent (fluid, all viewports, proper touch targets, gestures work under touch)
|
**Score 0-4**: 0=Desktop-only (breaks on mobile), 1=Major issues (some breakpoints, many failures), 2=Partial (works on mobile, rough edges), 3=Good (responsive, minor touch target or overflow issues), 4=Excellent (fluid, all viewports, proper touch targets)
|
||||||
|
|
||||||
### 5. Implementation Integrity (CRITICAL)
|
### 5. Implementation Integrity (CRITICAL)
|
||||||
|
|
||||||
|
|||||||
@@ -1,101 +0,0 @@
|
|||||||
> **Additional context needed**: only the target element, when the request does not name one that resolves uniquely on the page.
|
|
||||||
|
|
||||||
Generate is the fast lane into live mode: the user names an element, a direction, and a count in one sentence, and within a minute they are cycling through variants in their browser. One command boots the helper, hands the element to the overlay in the page your harness already shows (it scrolls to it, selects it, and fires the same Go a click fires) and returns the generate event; one edit writes the variants; one call replies and waits for the user's choice, which the helper bakes into source itself. This file owns the lane's plumbing; from the event onward the design work is [live.md](live.md)'s, unchanged, so read it in full now if you have not this session.
|
|
||||||
|
|
||||||
**Web only.** Live mode's browser overlay has no native equivalent; on `ios` / `android` / `adaptive` projects, decline this command and offer `bolder` or `quieter` on the source instead.
|
|
||||||
|
|
||||||
The plumbing is where the lane saves time: one command starts the session around the page your harness already shows, one call replies and waits, and nothing here is a browser you have to babysit. The design work is not where it saves time. Setup runs as for any command (`impeccable context`, this reference, craft-floor.md before the edit), and the variants are planned, written, and accepted exactly the way a live session plans, writes, and accepts them.
|
|
||||||
|
|
||||||
Three prohibitions cover the known ways this command goes wrong:
|
|
||||||
|
|
||||||
- **Never run init or document, and never ask for PRODUCT.md or DESIGN.md.** When they exist, the start command prints them under `boot` and you use them. When they do not, it says so (`contextMissing`, `contextNote`) and you extract the identity from the event (Step 3). A missing file is never a reason to interview the user inside this command; offer `init` in one line after the session ends.
|
|
||||||
- **Never hand-write a variants wrapper or invent a session id.** Only the browser mints session ids (8 hex characters, at Go). A missing event is fixed by rerunning Step 2, never with a direct source edit.
|
|
||||||
- **Do not act on hook findings while live markers are in the file**, and do not restyle variants to appease them; the accept verifies the file once the variant is permanent.
|
|
||||||
|
|
||||||
## Step 1: Parse the request
|
|
||||||
|
|
||||||
Three parts, all from the user's sentence:
|
|
||||||
|
|
||||||
- **A number in the request**: that is the count. **No number**: 3. The protocol caps count at 8.
|
|
||||||
- **The direction wording** maps onto the live action vocabulary; never invent a new action value:
|
|
||||||
- **bold, bolder, stronger, punchier**: `bolder`
|
|
||||||
- **quiet, calmer, softer, toned down**: `quieter`
|
|
||||||
- **simpler, minimal, stripped**: `distill`
|
|
||||||
- **refined, tightened, polished**: `polish`
|
|
||||||
- **font and type words**: `typeset`
|
|
||||||
- **color words**: `colorize`
|
|
||||||
- **arrangement and spacing words**: `layout`
|
|
||||||
- **device and breakpoint words**: `adapt`
|
|
||||||
- **motion words**: `animate`
|
|
||||||
- **playful words**: `delight`
|
|
||||||
- **rule-breaking words**: `overdrive`
|
|
||||||
- **Wording that carries intent but no vocabulary word** ("make it feel like a bank", "warmer", "more premium"): `impeccable`, with the user's wording passed as the prompt.
|
|
||||||
- **An action fits AND extra intent rides along** ("bolder, but keep it monochrome"): that action, with the rest as the prompt.
|
|
||||||
- **The wording names no direction at all** ("better", "improve", "nicer", "different", "fresh", "new", "redesign", "fix", "some options", "ideas", "alternatives", or just "variants" with nothing else): Ask the user directly to clarify what you cannot infer. Ask one question, offering the vocabulary: *"Which direction should the variants take? bolder, quieter, simpler (distill), polished, typography (typeset), color (colorize), layout, motion (animate), playful (delight), or rule-breaking (overdrive)."* Map the answer with this list; an answer that is still open ("surprise me", "you pick") is `impeccable` with the user's original wording as the prompt, and Step 2 starts on that answer.
|
|
||||||
- **The element description** ("the pricing cards", "the hero heading"): Step 2 resolves it to a selector.
|
|
||||||
|
|
||||||
Done when you hold an action from the vocabulary (asked for, when the request named no direction), a count from 1 to 8, and the element description.
|
|
||||||
|
|
||||||
## Step 2: Reuse the page, then start
|
|
||||||
|
|
||||||
**Reuse** the dev server already running and the tab your harness already shows it in; a second server or a second browser window is the failure this step prevents.
|
|
||||||
|
|
||||||
1. **Find the dev server**, cheapest source first, and stop at the first hit: the user's message, a browser tab already on the app (Claude Code: an origin in `tabs_context`), a server your harness started (Claude Code: `preview_list`), a terminal that printed its URL. Its origin is your `--dev-url`. **No hit**: leave `--dev-url` off and run the start command with no wait; the boot probes for a running server and its verdict names the move. `browser_needed` carries the `devUrl` it found: open it as in 2, then rerun with `--dev-url <devUrl> --wait-for-browser 60000`. `no_dev_server` means nothing serves the app: start the dev script the way the verdict says (Claude Code: `preview_start`; Cursor: a background terminal; Codex: an exec you yield from), wait for its URL, then rerun with `--dev-url <url>`.
|
|
||||||
2. **Open the page that renders the element in your browser, then start.** The route the request names, else the one `--target` serves; `--dev-url` takes only the origin.
|
|
||||||
- **Cursor** (`browser_navigate`) and **Claude Code** (`navigate`, which opens the Browser pane when it is closed and takes the `tabId` from `tabs_context` when a tab is already on that origin): open the URL, then run the start command with `--dev-url <url> --wait-for-browser 60000`. The boot injects the overlay and the page reloads into it while the command waits. Your browser tool is the only opener on these harnesses; the engine ignores `--open` there.
|
|
||||||
- **No browser tool** (Codex, others): run the start command with `--open --wait-for-browser 120000`; it opens the system browser, and the longer wait covers the user finding the tab. **`browser_open_failed` back**: tell the user the `url` in one line and rerun with `--wait-for-browser 120000`.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
.kiro/skills/impeccable/scripts/impeccable live-generate --target src/App.jsx --dev-url http://127.0.0.1:5173/ --selector ".pricing-grid" --action bolder --count 3 --boot --wait-for-browser 60000
|
|
||||||
```
|
|
||||||
|
|
||||||
Run it in the foreground in Cursor and Claude Code (it returns within the wait); on Codex, in an exec you yield from, the way Step 3 runs the poll.
|
|
||||||
|
|
||||||
- `--target`: the file that renders the element when the request or the project makes it obvious; skip it otherwise.
|
|
||||||
- `--dev-url`: the origin from 1; omit it and the boot probes.
|
|
||||||
- `--selector`: a unique class first, then a landmark tag plus class, an id last (every variant mounts a copy of the element, so an id repeats in the DOM). **The request names a repeated component in plural** ("the pricing cards"): target the container that holds the set, so one scoped stylesheet restyles every instance. One read of the source file that renders the element is allowed when the selector is not obvious; `--dry-run` resolves and reports without starting anything when it is not certain.
|
|
||||||
- `--boot`: runs the lane's boot (PRODUCT.md and DESIGN.md loaded again for the helper, missing files tolerated, dev URL found, bottom bar hidden for the helper's lifetime) and reuses a helper that is already running. Its result rides along as `boot`.
|
|
||||||
- Also available: `--prompt`, `--text` (keep only matches whose visible text contains a snippet), `--index` (1-based pick among matches).
|
|
||||||
|
|
||||||
Read the output in this order: `boot` (or `boot.contextMissing` with `boot.contextNote`: the page is the source of truth, per the note), then `event`, the generate event for `sessionId`, with the same `_instructions` a user's Go gets. Every verdict carries `_instructions`, and they win over your recollection of this file; the ones whose move is a decision of yours:
|
|
||||||
|
|
||||||
- **`ambiguous`**: the candidates are listed; target their common container, or rerun with `--text "<visible text>"` or `--index <n>`.
|
|
||||||
- **`dev_server_gone`**: the dev server stopped answering while the command waited for the page (on Cursor, a server another chat started dies with that chat). Start it the way the verdict says, then rerun with `--dev-url <url>`.
|
|
||||||
- **`no_match`**: the tab is on a route that does not render the element (navigate to the right route, rerun), or the selector is wrong (derive a better one from the source, or add `--text`).
|
|
||||||
- **`config_missing` / `config_invalid`** under `bootError`: follow [live-setup.md](live-setup.md) first, then rerun.
|
|
||||||
- **`event: null`** with `ok: true`: the event was slower than the wait; run `.kiro/skills/impeccable/scripts/impeccable live-poll` once to collect it, then continue.
|
|
||||||
|
|
||||||
Done when the output shows `ok: true`, a `sessionId`, and an `event`, reached with at most one server started and one tab opened by you.
|
|
||||||
|
|
||||||
## Step 3: Generate
|
|
||||||
|
|
||||||
The event is a standard `generate` event: the picked element's context, a preflighted scaffold, and `_instructions` naming the action's reference, the planning section, and the exact splice. Handle it exactly per live.md's **Handle generate**, which owns everything from the identity lock to the done reply: read the action's reference and craft-floor.md as it says, plan per section 4 (identity first, then mode, then three different primary axes, then the squint test), declare knobs per section 7, and deliver per section 6 (a complete replacement of the element per variant, the preview CSS plus every variant in one edit at the scaffold's splice). The lane changes nothing about what a variant may be: the moves a live session would make on this element (a promoted tier, a restructured set, a reordered card, a different surface) are open here too. Never screenshot the page; the overlay preview is the review channel until accept.
|
|
||||||
|
|
||||||
**Reply and wait in one call**, with the file you wrote:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
.kiro/skills/impeccable/scripts/impeccable live-poll --reply EVENT_ID done --file src/App.jsx --then-poll
|
|
||||||
```
|
|
||||||
|
|
||||||
This replies done (the browser mounts the variants) and then blocks until the user's choice arrives, so run it the way your harness runs a long wait: **Claude Code** in the foreground with your tool's longest timeout (600000 ms), so you are paused until the choice arrives; **Codex** in a yielded foreground exec; **Cursor** in a background terminal with notify on `"type":"(accept|discard|variant_mount_failed|exit)"`. Never pass a short `--timeout=`. While it runs there is nothing else to do: never sleep and never poll its output on a timer; a harness that backgrounds it wakes you when it returns. `{"type":"timeout"}` means the user has not chosen yet: run `live-poll` again and keep waiting. If the edit fails after the browser flipped to GENERATING, `--reply EVENT_ID error "Short reason"` (without `--then-poll`) so the bar resets.
|
|
||||||
|
|
||||||
Then tell the user, in one line, where their variants are: *"Three [bolder] variants are live on [the pricing cards]: cycle with the floating bar's arrows, adjust the Tune knobs, and Accept the keeper."*
|
|
||||||
|
|
||||||
Outside the replace path, read the matching live.md section before acting: `scaffold.previewMode: "svelte-component"` (Svelte previews are edited as components, and their accept is mechanical), `mode: "insert"`, `variant_mount_failed`, `steer`, `manual_edit_apply`, and any `fallback: "agent-driven"` wrap error.
|
|
||||||
|
|
||||||
## Step 4: Accept and close
|
|
||||||
|
|
||||||
The call from Step 3 returns the user's choice. **`discard`**: nothing to do. **`accept`**: `_acceptResult.carbonize: true` is the normal case, and the cleanup is live.md's **Required after accept**, unchanged: move the accepted variant's rules into the stylesheet that already owns the element with real selectors, bake the chosen knob values in, unwrap the element and drop every `data-impeccable-*` attribute, delete the inline `<style>` block and both `impeccable-carbonize` markers, then `.kiro/skills/impeccable/scripts/impeccable live-complete --id SESSION_ID` and confirm `phase: "completed"`. (`baked: true` appears only when the accept was run with `--bake`; then the helper already made the variant permanent and no `live-complete` is owed.)
|
|
||||||
|
|
||||||
Close without being asked, the moment the choice is handled:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
.kiro/skills/impeccable/scripts/impeccable live-server stop
|
|
||||||
```
|
|
||||||
|
|
||||||
Stopping removes the injected script and reloads the page once: the user sees the accepted design with no overlay chrome, still served by their dev server. **Never kill or restart the dev server**, including one you started in Step 2.
|
|
||||||
|
|
||||||
- **The user asks for more variants before you closed**: skip the close, run Step 2 again for the next element (the helper is reused), and close after the last choice.
|
|
||||||
- **Interrupted or unsure of the state**: `.kiro/skills/impeccable/scripts/impeccable live-status`, then `live-resume`; the journal under `.impeccable/live/sessions/` is canonical.
|
|
||||||
|
|
||||||
Done when the helper is stopped and the dev site still answers with the accepted design.
|
|
||||||
@@ -205,11 +205,6 @@ t('items', { count }) // Handles complex plural rules
|
|||||||
- Optimistic updates with rollback
|
- Optimistic updates with rollback
|
||||||
- Conflict resolution
|
- Conflict resolution
|
||||||
|
|
||||||
**Interrupted gestures** (custom sliders, drag surfaces, scrollable control strips):
|
|
||||||
- A second finger or pointer lands mid-drag: the first drag keeps its pointer or ends cleanly, never jumps to the new one
|
|
||||||
- The browser cancels the gesture to scroll (`pointercancel`), capture is lost (`lostpointercapture`), the pointer is released outside the control, or the window loses focus (`blur`) mid-drag: clear the dragging state and release capture
|
|
||||||
- After each of these, the next tap or drag works without a reload
|
|
||||||
|
|
||||||
**Permission states**:
|
**Permission states**:
|
||||||
- No permission to view
|
- No permission to view
|
||||||
- No permission to edit
|
- No permission to edit
|
||||||
@@ -309,7 +304,6 @@ const throttledScroll = throttle(handleScroll, 100);
|
|||||||
- Unit tests for edge cases
|
- Unit tests for edge cases
|
||||||
- Integration tests for error scenarios
|
- Integration tests for error scenarios
|
||||||
- E2E tests for critical paths
|
- E2E tests for critical paths
|
||||||
- A behavioral regression for each confirmed gesture fix, when the project's test runner can drive input
|
|
||||||
- Visual regression tests
|
- Visual regression tests
|
||||||
- Accessibility tests (axe, WAVE)
|
- Accessibility tests (axe, WAVE)
|
||||||
|
|
||||||
@@ -336,10 +330,7 @@ Test thoroughly with edge cases:
|
|||||||
- **Network issues**: Disable internet, throttle connection
|
- **Network issues**: Disable internet, throttle connection
|
||||||
- **Large datasets**: Test with 1000+ items
|
- **Large datasets**: Test with 1000+ items
|
||||||
- **Concurrent actions**: Click submit 10 times rapidly
|
- **Concurrent actions**: Click submit 10 times rapidly
|
||||||
- **Interrupted gestures**: Add a second finger mid-drag, scroll across the control, release outside it, switch windows mid-drag; then drag again
|
|
||||||
- **Errors**: Force API errors, test all error states
|
- **Errors**: Force API errors, test all error states
|
||||||
- **Empty**: Remove all data, test empty states
|
- **Empty**: Remove all data, test empty states
|
||||||
|
|
||||||
For gestures, say what produced the evidence (emulated viewport, synthesized touch, which engine, physical device) and name what stayed untested.
|
|
||||||
|
|
||||||
When edge cases are covered, hand off to `/impeccable polish` for the final pass.
|
When edge cases are covered, hand off to `/impeccable polish` for the final pass.
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ Reason over the signals; there is no score to obey:
|
|||||||
- `critique.latest` is `null` → the project has never been critiqued; for a set-up project with a real surface, offering `/impeccable critique <surface>` is a strong default.
|
- `critique.latest` is `null` → the project has never been critiqued; for a set-up project with a real surface, offering `/impeccable critique <surface>` is a strong default.
|
||||||
- `critique.latest` with a low `score` or non-zero `p0` / `p1` → `polish` (it reads that snapshot as its backlog and closes it when stale or cleared).
|
- `critique.latest` with a low `score` or non-zero `p0` / `p1` → `polish` (it reads that snapshot as its backlog and closes it when stale or cleared).
|
||||||
- `git.changedFiles` pointing at one surface → scope `audit` or `polish` to those files specifically, naming them.
|
- `git.changedFiles` pointing at one surface → scope `audit` or `polish` to those files specifically, naming them.
|
||||||
- `devServer.running` true → `live` is available for in-browser iteration, and `generate` for one-shot variant runs on a named element; if false, don't lead with either. **`live`, `generate`, and the bundled `impeccable detect` are web-only.** If `setup.platform` is `ios`, `android`, or `adaptive`, don't lead with any of them; the browser overlay and the HTML rule engine don't apply to native app code.
|
- `devServer.running` true → `live` is available for in-browser iteration; if false, don't lead with `live`. **`live` and the bundled `impeccable detect` are web-only.** If `setup.platform` is `ios`, `android`, or `adaptive`, don't lead with either; the browser overlay and the HTML rule engine don't apply to native app code.
|
||||||
- Otherwise group by intent (build new / improve what's there / iterate visually), tailored to the current surface and `setup.platform`.
|
- Otherwise group by intent (build new / improve what's there / iterate visually), tailored to the current surface and `setup.platform`.
|
||||||
|
|
||||||
**If `scan.targets` is non-empty and `setup.platform` is not `ios`/`android`/`adaptive`, run `.kiro/skills/impeccable/scripts/impeccable detect --json <scan.targets joined by spaces>` once** (the bundled detector over local files: no network, no npx; it reads HTML/CSS, so skip it for native projects). `scan.via` tells you what they are: `git-changes` (the markup/style files in your dirty tree, the most relevant set), `source-dir` (e.g. `src`, `app`), `html`, or `root`. Fold the hits into your picks: many quality / contrast hits → `audit` or `polish`; a specific slop family → the matching command (gradient text or eyebrows → `quieter` / `typeset`, flat or gray palette → `colorize`, and so on). It's a real, current signal that beats guessing. If detect errors or the tree is large and slow, skip it and recommend the user run `audit` themselves; never block the suggestion on it.
|
**If `scan.targets` is non-empty and `setup.platform` is not `ios`/`android`/`adaptive`, run `.kiro/skills/impeccable/scripts/impeccable detect --json <scan.targets joined by spaces>` once** (the bundled detector over local files: no network, no npx; it reads HTML/CSS, so skip it for native projects). `scan.via` tells you what they are: `git-changes` (the markup/style files in your dirty tree, the most relevant set), `source-dir` (e.g. `src`, `app`), `html`, or `root`. Fold the hits into your picks: many quality / contrast hits → `audit` or `polish`; a specific slop family → the matching command (gradient text or eyebrows → `quieter` / `typeset`, flat or gray palette → `colorize`, and so on). It's a real, current signal that beats guessing. If detect errors or the tree is large and slow, skip it and recommend the user run `audit` themselves; never block the suggestion on it.
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user