Compare commits

..
Author SHA1 Message Date
cb56ed6c19 Fix: detect placeholder contrast (#790) (#799)
* Fix: detect placeholder contrast (#790)

`detect` never read `::placeholder` color, so pale placeholders passed. Score them with the same WCAG math as body text, without host class/clip heuristics.

Prepared with AI assistance.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Fix: match descendant ::placeholder hosts (#790)

`.form ::placeholder` kept the ancestor as the host. Reuse the hover combinator star-fill so the color lands on the inputs inside.

Prepared with AI assistance.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Fix: placeholder-shown and gradient alpha (#790)

Browser scans skip when :placeholder-shown is false, so a live filled field does not keep the HTML value attribute's empty state. Translucent placeholders flatten over each gradient stop before scoring.

Prepared with AI assistance.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Fix: trailing combinator only for ::placeholder hosts (#790)

`star_empty_compounds` turned `.label + ::placeholder` into `.label *+*`. Fill only a trailing empty compound so adjacent-sibling hosts still match.

Prepared with AI assistance.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-10 13:49:59 -07:00
github-actions[bot] 3e1f67c52c Sync generated provider output 2026-09-10 20:46:13 +00:00
0c09f4c7e2 Fix: verify touch gestures in adapt, audit, and harden (#805) (#807)
* Fix: verify touch gestures in adapt, audit, and harden (#805)

The verification sections of adapt.md, audit.md, and harden.md listed
environments and layout properties but never had the agent exercise a
control's primary gesture, so an emulated viewport plus screenshots
could pass as touch testing. adapt now exercises the primary gesture
and the scroll-across trade and reports what produced the evidence,
audit checks broken touch interaction with code-level tells, harden
covers interrupted gestures and recovery, and a reference-contract
test pins the three sections.

Prepared with AI assistance (Claude Code), directed by @abdulwahabone.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* Pin the scroll, exercise, and cleanup sentences in the reference test (#805)

Greptile flagged that the contract test pinned the new labels but not
adapt's scroll-across trade, audit's instruction to exercise the
gesture, or harden's drag-state and capture cleanup.

Prepared with AI assistance (Claude Code), directed by @abdulwahabone.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-10 13:45:37 -07:00
7a7579858c test: scenario 19 documentation case when the context launcher is denied (#791)
* Add degraded Setup path: must-read pack when the context launcher is refused

When the host denies the impeccable context launcher (issue #789, measured
in #744), the Setup fallback now names the degraded path and its
unconditional must-read pack: the routed command's reference and
craft-floor.md before any UI edit, and document.md before writing DESIGN.md.
init.md gains the degraded Step 1 behavior, docs/CLI-CONTRACT.md documents
the degraded contract, and scenario 19 gains a denied-launcher documentation
case asserting document.md and source reads precede the DESIGN.md write.

No version bump, no changelog entry, no generated harness sync.

AI was used for assistance.
Includes AI_PR_NOTICE.txt per the repository's contribution policy: this
change was prepared without maintainer approval on issue #789, so no PR is
opened by the agent.

Co-authored-by: Matt Van Horn <mvanhorn@users.noreply.github.com>

* Drop restated degraded-setup prose; keep the scenario 19 documentation case

The launcher-unavailable path already lives on main. This removes the
notice file and the restated SKILL, init, and CLI-contract text, and keeps
the denied-launcher documentation coverage. The notice must now land before
the first tool call after the denial, not only before the eventual write.

AI was used for assistance.

Co-authored-by: Matt Van Horn <mvanhorn@users.noreply.github.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
2026-09-10 13:44:25 -07:00
67d018fe05 Fix: print JSON on live-poll --reply success (#800)
Successful --reply was exit 0 with empty stdout, so agents could not tell delivery from a hang. Prepared with AI assistance.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-10 08:47:39 +05:00
3bdb9ff06c Fix: drop stale carbonize diagnostic on complete (#801)
Complete and discarded snapshots no longer keep carbonize_cleanup_required after cleanup is done.

AI assistance: Cursor Grok 4.6.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-10 08:47:16 +05:00
101 changed files with 1425 additions and 1241 deletions
@@ -188,6 +188,12 @@ Test thoroughly across contexts:
- **Edge cases**: Very small screens (320px), very large screens (4K)
- **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.
---
+2 -1
View File
@@ -48,11 +48,12 @@ Run comprehensive checks across 5 dimensions. Score each dimension 0-4 using the
**Check for**:
- **Fixed widths**: Hard-coded widths that break on mobile
- **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
- **Text scaling**: Layouts that break when text size increases
- **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)
**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)
### 5. Implementation Integrity (CRITICAL)
@@ -205,6 +205,11 @@ t('items', { count }) // Handles complex plural rules
- Optimistic updates with rollback
- 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**:
- No permission to view
- No permission to edit
@@ -304,6 +309,7 @@ const throttledScroll = throttle(handleScroll, 100);
- Unit tests for edge cases
- Integration tests for error scenarios
- 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
- Accessibility tests (axe, WAVE)
@@ -330,7 +336,10 @@ Test thoroughly with edge cases:
- **Network issues**: Disable internet, throttle connection
- **Large datasets**: Test with 1000+ items
- **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
- **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.
@@ -188,6 +188,12 @@ Test thoroughly across contexts:
- **Edge cases**: Very small screens (320px), very large screens (4K)
- **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.
---
+2 -1
View File
@@ -48,11 +48,12 @@ Run comprehensive checks across 5 dimensions. Score each dimension 0-4 using the
**Check for**:
- **Fixed widths**: Hard-coded widths that break on mobile
- **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
- **Text scaling**: Layouts that break when text size increases
- **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)
**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)
### 5. Implementation Integrity (CRITICAL)
@@ -205,6 +205,11 @@ t('items', { count }) // Handles complex plural rules
- Optimistic updates with rollback
- 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**:
- No permission to view
- No permission to edit
@@ -304,6 +309,7 @@ const throttledScroll = throttle(handleScroll, 100);
- Unit tests for edge cases
- Integration tests for error scenarios
- 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
- Accessibility tests (axe, WAVE)
@@ -330,7 +336,10 @@ Test thoroughly with edge cases:
- **Network issues**: Disable internet, throttle connection
- **Large datasets**: Test with 1000+ items
- **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
- **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.
@@ -188,6 +188,12 @@ Test thoroughly across contexts:
- **Edge cases**: Very small screens (320px), very large screens (4K)
- **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.
---
+2 -1
View File
@@ -48,11 +48,12 @@ Run comprehensive checks across 5 dimensions. Score each dimension 0-4 using the
**Check for**:
- **Fixed widths**: Hard-coded widths that break on mobile
- **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
- **Text scaling**: Layouts that break when text size increases
- **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)
**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)
### 5. Implementation Integrity (CRITICAL)
@@ -205,6 +205,11 @@ t('items', { count }) // Handles complex plural rules
- Optimistic updates with rollback
- 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**:
- No permission to view
- No permission to edit
@@ -304,6 +309,7 @@ const throttledScroll = throttle(handleScroll, 100);
- Unit tests for edge cases
- Integration tests for error scenarios
- 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
- Accessibility tests (axe, WAVE)
@@ -330,7 +336,10 @@ Test thoroughly with edge cases:
- **Network issues**: Disable internet, throttle connection
- **Large datasets**: Test with 1000+ items
- **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
- **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.
@@ -188,6 +188,12 @@ Test thoroughly across contexts:
- **Edge cases**: Very small screens (320px), very large screens (4K)
- **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.
---
+2 -1
View File
@@ -48,11 +48,12 @@ Run comprehensive checks across 5 dimensions. Score each dimension 0-4 using the
**Check for**:
- **Fixed widths**: Hard-coded widths that break on mobile
- **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
- **Text scaling**: Layouts that break when text size increases
- **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)
**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)
### 5. Implementation Integrity (CRITICAL)
@@ -205,6 +205,11 @@ t('items', { count }) // Handles complex plural rules
- Optimistic updates with rollback
- 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**:
- No permission to view
- No permission to edit
@@ -304,6 +309,7 @@ const throttledScroll = throttle(handleScroll, 100);
- Unit tests for edge cases
- Integration tests for error scenarios
- 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
- Accessibility tests (axe, WAVE)
@@ -330,7 +336,10 @@ Test thoroughly with edge cases:
- **Network issues**: Disable internet, throttle connection
- **Large datasets**: Test with 1000+ items
- **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
- **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.
@@ -188,6 +188,12 @@ Test thoroughly across contexts:
- **Edge cases**: Very small screens (320px), very large screens (4K)
- **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.
---
+2 -1
View File
@@ -48,11 +48,12 @@ Run comprehensive checks across 5 dimensions. Score each dimension 0-4 using the
**Check for**:
- **Fixed widths**: Hard-coded widths that break on mobile
- **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
- **Text scaling**: Layouts that break when text size increases
- **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)
**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)
### 5. Implementation Integrity (CRITICAL)
@@ -205,6 +205,11 @@ t('items', { count }) // Handles complex plural rules
- Optimistic updates with rollback
- 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**:
- No permission to view
- No permission to edit
@@ -304,6 +309,7 @@ const throttledScroll = throttle(handleScroll, 100);
- Unit tests for edge cases
- Integration tests for error scenarios
- 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
- Accessibility tests (axe, WAVE)
@@ -330,7 +336,10 @@ Test thoroughly with edge cases:
- **Network issues**: Disable internet, throttle connection
- **Large datasets**: Test with 1000+ items
- **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
- **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.
@@ -188,6 +188,12 @@ Test thoroughly across contexts:
- **Edge cases**: Very small screens (320px), very large screens (4K)
- **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.
---
+2 -1
View File
@@ -48,11 +48,12 @@ Run comprehensive checks across 5 dimensions. Score each dimension 0-4 using the
**Check for**:
- **Fixed widths**: Hard-coded widths that break on mobile
- **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
- **Text scaling**: Layouts that break when text size increases
- **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)
**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)
### 5. Implementation Integrity (CRITICAL)
@@ -205,6 +205,11 @@ t('items', { count }) // Handles complex plural rules
- Optimistic updates with rollback
- 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**:
- No permission to view
- No permission to edit
@@ -304,6 +309,7 @@ const throttledScroll = throttle(handleScroll, 100);
- Unit tests for edge cases
- Integration tests for error scenarios
- 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
- Accessibility tests (axe, WAVE)
@@ -330,7 +336,10 @@ Test thoroughly with edge cases:
- **Network issues**: Disable internet, throttle connection
- **Large datasets**: Test with 1000+ items
- **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
- **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.
@@ -188,6 +188,12 @@ Test thoroughly across contexts:
- **Edge cases**: Very small screens (320px), very large screens (4K)
- **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.
---
+2 -1
View File
@@ -48,11 +48,12 @@ Run comprehensive checks across 5 dimensions. Score each dimension 0-4 using the
**Check for**:
- **Fixed widths**: Hard-coded widths that break on mobile
- **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
- **Text scaling**: Layouts that break when text size increases
- **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)
**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)
### 5. Implementation Integrity (CRITICAL)
@@ -205,6 +205,11 @@ t('items', { count }) // Handles complex plural rules
- Optimistic updates with rollback
- 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**:
- No permission to view
- No permission to edit
@@ -304,6 +309,7 @@ const throttledScroll = throttle(handleScroll, 100);
- Unit tests for edge cases
- Integration tests for error scenarios
- 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
- Accessibility tests (axe, WAVE)
@@ -330,7 +336,10 @@ Test thoroughly with edge cases:
- **Network issues**: Disable internet, throttle connection
- **Large datasets**: Test with 1000+ items
- **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
- **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.
@@ -188,6 +188,12 @@ Test thoroughly across contexts:
- **Edge cases**: Very small screens (320px), very large screens (4K)
- **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.
---
+2 -1
View File
@@ -48,11 +48,12 @@ Run comprehensive checks across 5 dimensions. Score each dimension 0-4 using the
**Check for**:
- **Fixed widths**: Hard-coded widths that break on mobile
- **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
- **Text scaling**: Layouts that break when text size increases
- **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)
**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)
### 5. Implementation Integrity (CRITICAL)
@@ -205,6 +205,11 @@ t('items', { count }) // Handles complex plural rules
- Optimistic updates with rollback
- 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**:
- No permission to view
- No permission to edit
@@ -304,6 +309,7 @@ const throttledScroll = throttle(handleScroll, 100);
- Unit tests for edge cases
- Integration tests for error scenarios
- 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
- Accessibility tests (axe, WAVE)
@@ -330,7 +336,10 @@ Test thoroughly with edge cases:
- **Network issues**: Disable internet, throttle connection
- **Large datasets**: Test with 1000+ items
- **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
- **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.
@@ -188,6 +188,12 @@ Test thoroughly across contexts:
- **Edge cases**: Very small screens (320px), very large screens (4K)
- **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.
---
+2 -1
View File
@@ -48,11 +48,12 @@ Run comprehensive checks across 5 dimensions. Score each dimension 0-4 using the
**Check for**:
- **Fixed widths**: Hard-coded widths that break on mobile
- **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
- **Text scaling**: Layouts that break when text size increases
- **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)
**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)
### 5. Implementation Integrity (CRITICAL)
@@ -205,6 +205,11 @@ t('items', { count }) // Handles complex plural rules
- Optimistic updates with rollback
- 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**:
- No permission to view
- No permission to edit
@@ -304,6 +309,7 @@ const throttledScroll = throttle(handleScroll, 100);
- Unit tests for edge cases
- Integration tests for error scenarios
- 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
- Accessibility tests (axe, WAVE)
@@ -330,7 +336,10 @@ Test thoroughly with edge cases:
- **Network issues**: Disable internet, throttle connection
- **Large datasets**: Test with 1000+ items
- **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
- **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.
@@ -188,6 +188,12 @@ Test thoroughly across contexts:
- **Edge cases**: Very small screens (320px), very large screens (4K)
- **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.
---
+2 -1
View File
@@ -48,11 +48,12 @@ Run comprehensive checks across 5 dimensions. Score each dimension 0-4 using the
**Check for**:
- **Fixed widths**: Hard-coded widths that break on mobile
- **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
- **Text scaling**: Layouts that break when text size increases
- **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)
**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)
### 5. Implementation Integrity (CRITICAL)
@@ -205,6 +205,11 @@ t('items', { count }) // Handles complex plural rules
- Optimistic updates with rollback
- 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**:
- No permission to view
- No permission to edit
@@ -304,6 +309,7 @@ const throttledScroll = throttle(handleScroll, 100);
- Unit tests for edge cases
- Integration tests for error scenarios
- 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
- Accessibility tests (axe, WAVE)
@@ -330,7 +336,10 @@ Test thoroughly with edge cases:
- **Network issues**: Disable internet, throttle connection
- **Large datasets**: Test with 1000+ items
- **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
- **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.
@@ -188,6 +188,12 @@ Test thoroughly across contexts:
- **Edge cases**: Very small screens (320px), very large screens (4K)
- **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.
---
@@ -48,11 +48,12 @@ Run comprehensive checks across 5 dimensions. Score each dimension 0-4 using the
**Check for**:
- **Fixed widths**: Hard-coded widths that break on mobile
- **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
- **Text scaling**: Layouts that break when text size increases
- **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)
**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)
### 5. Implementation Integrity (CRITICAL)
@@ -205,6 +205,11 @@ t('items', { count }) // Handles complex plural rules
- Optimistic updates with rollback
- 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**:
- No permission to view
- No permission to edit
@@ -304,6 +309,7 @@ const throttledScroll = throttle(handleScroll, 100);
- Unit tests for edge cases
- Integration tests for error scenarios
- 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
- Accessibility tests (axe, WAVE)
@@ -330,7 +336,10 @@ Test thoroughly with edge cases:
- **Network issues**: Disable internet, throttle connection
- **Large datasets**: Test with 1000+ items
- **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
- **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.
+6
View File
@@ -188,6 +188,12 @@ Test thoroughly across contexts:
- **Edge cases**: Very small screens (320px), very large screens (4K)
- **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.
---
+2 -1
View File
@@ -48,11 +48,12 @@ Run comprehensive checks across 5 dimensions. Score each dimension 0-4 using the
**Check for**:
- **Fixed widths**: Hard-coded widths that break on mobile
- **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
- **Text scaling**: Layouts that break when text size increases
- **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)
**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)
### 5. Implementation Integrity (CRITICAL)
@@ -205,6 +205,11 @@ t('items', { count }) // Handles complex plural rules
- Optimistic updates with rollback
- 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**:
- No permission to view
- No permission to edit
@@ -304,6 +309,7 @@ const throttledScroll = throttle(handleScroll, 100);
- Unit tests for edge cases
- Integration tests for error scenarios
- 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
- Accessibility tests (axe, WAVE)
@@ -330,7 +336,10 @@ Test thoroughly with edge cases:
- **Network issues**: Disable internet, throttle connection
- **Large datasets**: Test with 1000+ items
- **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
- **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.
@@ -188,6 +188,12 @@ Test thoroughly across contexts:
- **Edge cases**: Very small screens (320px), very large screens (4K)
- **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.
---
+2 -1
View File
@@ -48,11 +48,12 @@ Run comprehensive checks across 5 dimensions. Score each dimension 0-4 using the
**Check for**:
- **Fixed widths**: Hard-coded widths that break on mobile
- **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
- **Text scaling**: Layouts that break when text size increases
- **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)
**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)
### 5. Implementation Integrity (CRITICAL)
@@ -205,6 +205,11 @@ t('items', { count }) // Handles complex plural rules
- Optimistic updates with rollback
- 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**:
- No permission to view
- No permission to edit
@@ -304,6 +309,7 @@ const throttledScroll = throttle(handleScroll, 100);
- Unit tests for edge cases
- Integration tests for error scenarios
- 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
- Accessibility tests (axe, WAVE)
@@ -330,7 +336,10 @@ Test thoroughly with edge cases:
- **Network issues**: Disable internet, throttle connection
- **Large datasets**: Test with 1000+ items
- **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
- **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.
@@ -188,6 +188,12 @@ Test thoroughly across contexts:
- **Edge cases**: Very small screens (320px), very large screens (4K)
- **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.
---
@@ -48,11 +48,12 @@ Run comprehensive checks across 5 dimensions. Score each dimension 0-4 using the
**Check for**:
- **Fixed widths**: Hard-coded widths that break on mobile
- **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
- **Text scaling**: Layouts that break when text size increases
- **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)
**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)
### 5. Implementation Integrity (CRITICAL)
@@ -205,6 +205,11 @@ t('items', { count }) // Handles complex plural rules
- Optimistic updates with rollback
- 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**:
- No permission to view
- No permission to edit
@@ -304,6 +309,7 @@ const throttledScroll = throttle(handleScroll, 100);
- Unit tests for edge cases
- Integration tests for error scenarios
- 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
- Accessibility tests (axe, WAVE)
@@ -330,7 +336,10 @@ Test thoroughly with edge cases:
- **Network issues**: Disable internet, throttle connection
- **Large datasets**: Test with 1000+ items
- **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
- **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.
@@ -188,6 +188,12 @@ Test thoroughly across contexts:
- **Edge cases**: Very small screens (320px), very large screens (4K)
- **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.
---
@@ -48,11 +48,12 @@ Run comprehensive checks across 5 dimensions. Score each dimension 0-4 using the
**Check for**:
- **Fixed widths**: Hard-coded widths that break on mobile
- **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
- **Text scaling**: Layouts that break when text size increases
- **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)
**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)
### 5. Implementation Integrity (CRITICAL)
@@ -205,6 +205,11 @@ t('items', { count }) // Handles complex plural rules
- Optimistic updates with rollback
- 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**:
- No permission to view
- No permission to edit
@@ -304,6 +309,7 @@ const throttledScroll = throttle(handleScroll, 100);
- Unit tests for edge cases
- Integration tests for error scenarios
- 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
- Accessibility tests (axe, WAVE)
@@ -330,7 +336,10 @@ Test thoroughly with edge cases:
- **Network issues**: Disable internet, throttle connection
- **Large datasets**: Test with 1000+ items
- **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
- **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.
@@ -188,6 +188,12 @@ Test thoroughly across contexts:
- **Edge cases**: Very small screens (320px), very large screens (4K)
- **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.
---
+2 -1
View File
@@ -48,11 +48,12 @@ Run comprehensive checks across 5 dimensions. Score each dimension 0-4 using the
**Check for**:
- **Fixed widths**: Hard-coded widths that break on mobile
- **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
- **Text scaling**: Layouts that break when text size increases
- **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)
**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)
### 5. Implementation Integrity (CRITICAL)
@@ -205,6 +205,11 @@ t('items', { count }) // Handles complex plural rules
- Optimistic updates with rollback
- 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**:
- No permission to view
- No permission to edit
@@ -304,6 +309,7 @@ const throttledScroll = throttle(handleScroll, 100);
- Unit tests for edge cases
- Integration tests for error scenarios
- 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
- Accessibility tests (axe, WAVE)
@@ -330,7 +336,10 @@ Test thoroughly with edge cases:
- **Network issues**: Disable internet, throttle connection
- **Large datasets**: Test with 1000+ items
- **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
- **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.
@@ -188,6 +188,12 @@ Test thoroughly across contexts:
- **Edge cases**: Very small screens (320px), very large screens (4K)
- **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.
---
+2 -1
View File
@@ -48,11 +48,12 @@ Run comprehensive checks across 5 dimensions. Score each dimension 0-4 using the
**Check for**:
- **Fixed widths**: Hard-coded widths that break on mobile
- **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
- **Text scaling**: Layouts that break when text size increases
- **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)
**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)
### 5. Implementation Integrity (CRITICAL)
@@ -205,6 +205,11 @@ t('items', { count }) // Handles complex plural rules
- Optimistic updates with rollback
- 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**:
- No permission to view
- No permission to edit
@@ -304,6 +309,7 @@ const throttledScroll = throttle(handleScroll, 100);
- Unit tests for edge cases
- Integration tests for error scenarios
- 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
- Accessibility tests (axe, WAVE)
@@ -330,7 +336,10 @@ Test thoroughly with edge cases:
- **Network issues**: Disable internet, throttle connection
- **Large datasets**: Test with 1000+ items
- **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
- **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.
@@ -188,6 +188,12 @@ Test thoroughly across contexts:
- **Edge cases**: Very small screens (320px), very large screens (4K)
- **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.
---
+2 -1
View File
@@ -48,11 +48,12 @@ Run comprehensive checks across 5 dimensions. Score each dimension 0-4 using the
**Check for**:
- **Fixed widths**: Hard-coded widths that break on mobile
- **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
- **Text scaling**: Layouts that break when text size increases
- **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)
**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)
### 5. Implementation Integrity (CRITICAL)
@@ -205,6 +205,11 @@ t('items', { count }) // Handles complex plural rules
- Optimistic updates with rollback
- 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**:
- No permission to view
- No permission to edit
@@ -304,6 +309,7 @@ const throttledScroll = throttle(handleScroll, 100);
- Unit tests for edge cases
- Integration tests for error scenarios
- 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
- Accessibility tests (axe, WAVE)
@@ -330,7 +336,10 @@ Test thoroughly with edge cases:
- **Network issues**: Disable internet, throttle connection
- **Large datasets**: Test with 1000+ items
- **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
- **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.
Generated
-1
View File
@@ -595,7 +595,6 @@ dependencies = [
"serde",
"serde_json",
"sha1",
"sha2",
]
[[package]]
+6 -1
View File
@@ -585,6 +585,7 @@ const __impeccableSnapshot = {
for (let id = 1; id < elements.length; id++) {
const el = elements[id];
const rec = { t: el.tagName };
const tag = rec.t;
const nsUri = el.namespaceURI || '';
const ns = __SNAP_NS[nsUri];
if (ns === undefined) { rec.n = 3; rec.nu = nsUri; } else if (ns !== 0) { rec.n = ns; }
@@ -615,6 +616,11 @@ const __impeccableSnapshot = {
if (content == null || content === '' || content === 'none') continue;
rec[key] = __SNAP_PSEUDO_PROPS.map(p => intern(ps[p]));
}
if ((tag === 'INPUT' || tag === 'TEXTAREA') && el.getAttribute('placeholder')) {
let ps;
try { ps = getComputedStyle(el, '::placeholder'); } catch { ps = null; }
if (ps) rec.ph = intern(ps.color);
}
if (typeof el.getBoundingClientRect === 'function') rec.r = __snapRect4(el.getBoundingClientRect());
rec.m = [
__snapNum(el.clientWidth), __snapNum(el.clientHeight), __snapNum(el.clientLeft),
@@ -632,7 +638,6 @@ const __impeccableSnapshot = {
if (typeof el.className !== 'string') rec.k = true;
const st = states.get(id);
if (st) rec.st = st;
const tag = rec.t;
if (tag === 'IMG' || tag === 'VIDEO' || tag === 'CANVAS' || tag === 'PICTURE') {
rec.md = {
nw: el.naturalWidth || 0, nh: el.naturalHeight || 0,
-1
View File
@@ -20,7 +20,6 @@ serde_json = { workspace = true, features = ["preserve_order", "float_roundtrip"
regex = { workspace = true }
once_cell = { workspace = true }
sha1 = "0.10"
sha2 = "0.10"
[dev-dependencies]
serde_json = { workspace = true, features = ["preserve_order", "float_roundtrip"] }
+102 -373
View File
@@ -18,7 +18,7 @@ use impeccable_comp::raster::{self as r, Image};
use regex::Regex;
use serde_json::{json, Map, Value};
use crate::comp_diff::{align_build, best_shift, build_report, compare, write_artifacts, write_region_artifacts, CompareResult, Score};
use crate::comp_diff::{align_build, best_shift, build_report, compare, write_artifacts, Score};
use crate::comp_spec::{load_spec, plate_reference, BUILD_DIR, SPEC_PATH};
use crate::font_match::choice_stamped;
use crate::util::{self, arg, flag, round, to_fixed};
@@ -163,7 +163,6 @@ struct Gate {
worst_crops: Vec<Value>,
advisories: Vec<String>,
region_verdicts: Map<String, Value>,
region_reasons: Map<String, Value>,
// comps: approved comp path
approved: Option<String>,
// plates: per-plate rows
@@ -192,7 +191,6 @@ impl Gate {
worst_crops: vec![],
advisories: vec![],
region_verdicts: Map::new(),
region_reasons: Map::new(),
approved: None,
plates: None,
error: false,
@@ -203,7 +201,6 @@ impl Gate {
let mut m = Map::new();
m.insert("ok".into(), json!(self.ok));
m.insert("reasons".into(), json!(self.reasons));
if !self.region_reasons.is_empty() { m.insert("regionReasons".into(), json!(self.region_reasons)); }
if let Some(s) = &self.summary {
m.insert("summary".into(), json!(s));
}
@@ -480,15 +477,10 @@ fn gate_plates(io: &Io) -> Gate {
g.plates = Some(vec![]);
return g;
}
let Some(comp) = spec.get("comp").and_then(Value::as_str).and_then(|c| load_raster(io, c).ok()) else {
let mut gate = Gate::fail(vec!["cannot validate plates: the spec's comp is missing or unreadable".into()]);
gate.plates = Some(vec![]);
return gate;
};
let comp = spec.get("comp").and_then(Value::as_str).and_then(|c| load_raster(io, c).ok());
let mut reasons: Vec<String> = Vec::new();
let mut plates: Vec<Value> = Vec::new();
for rr in &raster_regions {
let reasons_before = reasons.len();
let id = rr.get("id").and_then(Value::as_str).unwrap_or("").to_string();
let file = rr.get("plate").and_then(Value::as_str).map(String::from);
let Some(file) = file.clone().filter(|f| abs(io, f).exists()) else {
@@ -516,9 +508,8 @@ fn gate_plates(io: &Io) -> Gate {
img.image.width, px_w as i64, round(min_w) as i64
));
}
let score_val;
{
let comp = &comp;
let mut score_val: Option<f64> = None;
if let Some(comp) = &comp {
let refimg = plate_reference(comp, &spec, rr);
// composite transparent plates over the region's sampled ground
let mut build = img.image.clone();
@@ -559,10 +550,7 @@ fn gate_plates(io: &Io) -> Gate {
}
}
plates.push(json!({
"id": id, "file": file, "status": if reasons.len() == reasons_before { "ok" } else { "invalid" },
"assetHash": sha256_file(io, &file),
"regionHash": sha256_bytes(util::json_pretty(rr).as_bytes()),
"compHash": spec.get("comp").and_then(Value::as_str).and_then(|p| sha256_file(io, p)),
"id": id, "file": file, "status": "ok",
"size": format!("{}x{}", img.image.width, img.image.height),
"score": score_val.map(util::num).unwrap_or(Value::Null)
}));
@@ -574,50 +562,6 @@ fn gate_plates(io: &Io) -> Gate {
g
}
fn sha256_bytes(bytes: &[u8]) -> String {
use sha2::{Digest, Sha256};
format!("{:x}", Sha256::digest(bytes))
}
fn sha256_file(io: &Io, file: &str) -> Option<String> {
std::fs::read(abs(io, file)).ok().map(|bytes| sha256_bytes(&bytes))
}
fn save_plate_receipts(state: &mut Value, gate: &Gate) {
if let Some(plates) = &gate.plates {
let receipts: Map<String, Value> = plates.iter().filter_map(|p| {
Some((p.get("id")?.as_str()?.to_string(), p.clone()))
}).collect();
state["plates"] = Value::Object(receipts);
}
}
fn plate_receipt_current(io: &Io, state: &Value, spec: &Value, region: &Value) -> bool {
let Some(id) = region.get("id").and_then(Value::as_str) else { return false; };
let Some(receipt) = state.get("plates").and_then(|p| p.get(id)) else { return false; };
let Some(file) = region.get("plate").and_then(Value::as_str) else { return false; };
let Some(comp) = spec.get("comp").and_then(Value::as_str) else { return false; };
receipt.get("status").and_then(Value::as_str) == Some("ok")
&& receipt.get("score").and_then(Value::as_f64).map(|s| s.is_finite()).unwrap_or(false)
&& receipt.get("file").and_then(Value::as_str) == Some(file)
&& sha256_file(io, file).as_deref().is_some_and(|h| receipt.get("assetHash").and_then(Value::as_str) == Some(h))
&& sha256_file(io, comp).as_deref().is_some_and(|h| receipt.get("compHash").and_then(Value::as_str) == Some(h))
&& receipt.get("regionHash").and_then(Value::as_str) == Some(sha256_bytes(util::json_pretty(region).as_bytes()).as_str())
}
fn revalidate_plates(io: &Io, state: &mut Value, spec: Option<&Value>) -> Option<Gate> {
let spec = spec?;
let stale = spec_regions(spec).iter()
.filter(|r| r.get("medium").and_then(Value::as_str) == Some("raster"))
.any(|r| !plate_receipt_current(io, state, spec, r));
if stale {
let gate = gate_plates(io);
save_plate_receipts(state, &gate);
if !gate.ok { return Some(gate); }
}
None
}
fn hex_rgba(hex: &str) -> Option<[u8; 4]> {
let re = regex_hex();
let caps = re.captures(hex)?;
@@ -975,7 +919,6 @@ struct HeroReadings {
chrome: Vec<String>,
plates: Vec<String>,
invented: Value,
region_ids: std::collections::HashMap<String, Vec<String>>,
}
fn hero_readings(io: &Io, state: &Value, spec: Option<&Value>, build_path: &str) -> Option<HeroReadings> {
@@ -993,9 +936,7 @@ fn hero_readings(io: &Io, state: &Value, spec: Option<&Value>, build_path: &str)
let mut text: Vec<String> = Vec::new();
let mut chrome: Vec<String> = Vec::new();
let mut plates: Vec<String> = Vec::new();
let mut region_ids = std::collections::HashMap::<String, Vec<String>>::new();
for rr in spec_regions(spec) {
let starts = (text.len(), chrome.len(), plates.len());
let px = rr.get("px");
if px.is_none() {
continue;
@@ -1038,12 +979,9 @@ fn hero_readings(io: &Io, state: &Value, spec: Option<&Value>, build_path: &str)
));
}
}
for message in text[starts.0..].iter().chain(chrome[starts.1..].iter()).chain(plates[starts.2..].iter()) {
region_ids.entry(message.clone()).or_default().push(region.id.clone());
}
}
let invented = invented_ink(&comp, &aligned);
Some(HeroReadings { text, chrome, plates, invented, region_ids })
Some(HeroReadings { text, chrome, plates, invented })
}
// ---- hero gate -------------------------------------------------------------
@@ -1063,11 +1001,11 @@ fn rscore_opt(r: &Value, k: &str) -> Option<f64> {
}
/// Run comp-diff in-process (JS spawned comp-diff.mjs --json), returning its report.
fn hero_diff(io: &Io, comp_path: &str, build_path: &str, spec: Option<&Value>, out_dir: &str) -> Result<(Value, CompareResult), String> {
fn hero_diff(io: &Io, comp_path: &str, build_path: &str, spec: Option<&Value>, out_dir: &str) -> Result<Value, String> {
let comp = load_raster(io, comp_path)?;
let build = load_raster(io, build_path)?;
let res = compare(&comp, &build, spec, "top", "hero", None);
let files = write_artifacts(&res, &comp, &abs(io, out_dir)).map_err(|e| format!("cannot persist comparison artifacts: {e}"))?;
let files = write_artifacts(&res, &comp, &abs(io, out_dir));
let meta = json!({
"label": "hero",
"comp": comp_path,
@@ -1077,148 +1015,18 @@ fn hero_diff(io: &Io, comp_path: &str, build_path: &str, spec: Option<&Value>, o
"buildSize": format!("{}x{}", build.width, build.height),
});
let report = build_report(&res, Some(&files), &meta);
Ok((report, res))
let _ = std::fs::write(abs(io, &format!("{out_dir}/report.json")), util::json_pretty(&report));
Ok(report)
}
#[allow(clippy::too_many_arguments)]
fn gate_hero(io: &Io, state: &mut Value, build_path: &str, min: f64, out_dir: &str, artifact: Option<&str>, organic_scan: OrganicScan) -> Gate {
let pending = Gate::fail(vec!["hero comparison has not completed".into()]);
if let Err(e) = unavailable_report(io, out_dir, &pending, "hero") {
return Gate::fail(vec![format!("cannot persist hero gate evidence: {e}")]);
}
let mut gate = gate_hero_inner(io, state, build_path, min, out_dir, artifact, organic_scan);
if gate.report.is_none() {
if let Err(e) = unavailable_report(io, out_dir, &gate, "hero") {
gate.ok = false;
gate.reasons.push(format!("cannot persist hero gate evidence: {e}"));
}
}
gate
}
fn atomic_report(path: &Path, report: &Value) -> Result<(), String> {
static NEXT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
let temp = path.with_extension(format!("tmp-{}-{}", std::process::id(), NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed)));
let result = (|| {
if let Some(parent) = path.parent() { std::fs::create_dir_all(parent)?; }
std::fs::write(&temp, util::json_pretty(report))?;
std::fs::rename(&temp, path)
})();
if result.is_err() { let _ = std::fs::remove_file(temp); }
result.map_err(|e: std::io::Error| e.to_string())
}
fn unavailable_report(io: &Io, out_dir: &str, gate: &Gate, phase: &str) -> Result<(), String> {
let mut report = json!({ "interpretation": format!("{phase}-gate"), "measurementsAvailable": false,
"regions": [], "gate": { "ok": false, "reasons": gate.reasons,
"advisories": gate.advisories, "unscopedReasons": gate.reasons } });
// Invalidate the report before touching images; also clean partial writes on failure.
// Attempt cleanup even when the report itself cannot be replaced.
let report_write = atomic_report(&abs(io, &format!("{out_dir}/report.json")), &report);
let cleanup = clear_comparison_artifacts(&abs(io, out_dir));
if let Err(error) = &cleanup {
// A read-only regions directory may prevent unlinking its children even
// when its parent permits moving the directory. Preserve those bytes as
// explicitly invalid evidence instead of leaving them at current paths.
report["artifactCleanup"] = json!({"status":"failed", "error":error,
"invalidArtifacts":["raw-report.json", "side-by-side.png", "heatmap.png", "regions/"]});
match quarantine_comparison_artifacts(&abs(io, out_dir)) {
Ok(record) => report["artifactCleanup"]["quarantine"] = record,
Err(e) => report["artifactCleanup"]["quarantineError"] = json!(e),
}
// If the filesystem also refuses quarantine, the report explicitly marks
// every potentially remaining artifact invalid. The gate still fails.
atomic_report(&abs(io, &format!("{out_dir}/report.json")), &report)?;
}
report_write.and(cleanup)
}
fn quarantine_comparison_artifacts(out_dir: &Path) -> Result<Value, String> {
static NEXT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
let quarantine = loop {
let candidate = out_dir.join(format!("invalid-comparison-{}-{}", std::process::id(), NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed)));
match std::fs::create_dir(&candidate) {
Ok(()) => break candidate,
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue,
Err(e) => return Err(e.to_string()),
}
};
let mut errors = Vec::new();
let mut artifacts = Map::new();
let prefix = quarantine.file_name().unwrap().to_string_lossy();
for name in ["raw-report.json", "side-by-side.png", "heatmap.png", "regions"] {
let path = out_dir.join(name);
// Keep the same parent: moving a read-only directory into another parent
// can require write permission on that directory (to change its `..`).
let target = out_dir.join(format!("{prefix}-{name}"));
let move_artifact = (|| -> std::io::Result<()> {
match std::fs::symlink_metadata(&target) {
Ok(_) => return Err(std::io::Error::new(std::io::ErrorKind::AlreadyExists, "quarantine target exists")),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => (),
Err(e) => return Err(e),
}
std::fs::rename(&path, &target)
})();
match move_artifact {
Ok(()) => { artifacts.insert(name.into(), json!(target.to_string_lossy().replace('\\', "/"))); },
Err(e) if e.kind() == std::io::ErrorKind::NotFound => (),
Err(e) => errors.push(format!("{name}: {e}")),
}
}
let record = json!({"invalid":true, "artifacts":artifacts, "errors":errors});
atomic_report(&quarantine.join("manifest.json"), &record)?;
Ok(record)
}
fn clear_comparison_artifacts(out_dir: &Path) -> Result<(), String> {
// Only remove generated files. Never recursively delete a caller's output directory
// or follow a regions symlink into another directory. Unexpected directories at
// file paths remain obstructions: the subsequent writer must still fail closed.
fn remove_file(path: &Path) -> std::io::Result<()> {
match std::fs::symlink_metadata(path) {
Ok(meta) if !meta.is_dir() => std::fs::remove_file(path),
Ok(_) => Ok(()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(e),
}
}
let mut errors = Vec::new();
for name in ["raw-report.json", "side-by-side.png", "heatmap.png"] {
if let Err(e) = remove_file(&out_dir.join(name)) { errors.push(format!("{name}: {e}")); }
}
fn clear_regions(path: &Path, errors: &mut Vec<String>) -> std::io::Result<()> {
let meta = match std::fs::symlink_metadata(path) {
Ok(meta) => meta,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
Err(e) => return Err(e),
};
if meta.file_type().is_symlink() { return std::fs::remove_file(path); }
if meta.is_dir() {
for entry in std::fs::read_dir(path)? {
let child = entry?.path();
if let Err(e) = clear_regions(&child, errors) { errors.push(format!("{}: {e}", child.display())); }
}
} else if path.extension().and_then(|ext| ext.to_str()) == Some("png") {
std::fs::remove_file(path)?;
}
Ok(())
}
if let Err(e) = clear_regions(&out_dir.join("regions"), &mut errors) { errors.push(format!("regions: {e}")); }
if errors.is_empty() { Ok(()) } else { Err(format!("cannot clear comparison artifacts: {}", errors.join("; "))) }
}
#[allow(clippy::too_many_arguments)]
fn gate_hero_inner(io: &Io, state: &mut Value, build_path: &str, min: f64, out_dir: &str, artifact: Option<&str>, organic_scan: OrganicScan) -> Gate {
let s = self_cmd(io);
if !abs(io, build_path).exists() {
let bp = state.get("breakpoint").and_then(Value::as_str).map(String::from).unwrap_or_else(|| "comp size".into());
return Gate::fail(vec![format!("no hero capture at {build_path}: screenshot the first viewport at the comp's own dimensions ({bp}) into that path")]);
}
let spec_gate = gate_spec(io, state);
if !spec_gate.ok { return spec_gate; }
let spec_for_refs = load_spec(&abs(io, SPEC_PATH));
if let Some(failure) = revalidate_plates(io, state, spec_for_refs.as_ref()) { return failure; }
// resolve the page
let mut page_file: Option<String> = artifact.map(String::from).or_else(|| state.get("artifact").and_then(Value::as_str).map(String::from));
if page_file.as_ref().map(|p| !abs(io, p).exists()).unwrap_or(true) {
@@ -1254,14 +1062,13 @@ fn gate_hero_inner(io: &Io, state: &mut Value, build_path: &str, min: f64, out_d
);
}
let comp_path = state.get("comp").and_then(Value::as_str).unwrap_or("").to_string();
let (mut report, mut measured) = match hero_diff(io, &comp_path, build_path, spec_for_refs.as_ref(), out_dir) {
let report = match hero_diff(io, &comp_path, build_path, spec_for_refs.as_ref(), out_dir) {
Ok(r) => r,
Err(e) => return Gate::fail(vec![format!("comp-diff failed: {e}")]),
};
let mut regions: Vec<Value> = report.get("regions").and_then(Value::as_array).cloned().unwrap_or_default();
let mut reasons: Vec<String> = Vec::new();
let mut advisories: Vec<String> = Vec::new();
let mut region_reasons = Map::new();
let overall = report.get("overall").and_then(Value::as_f64).unwrap_or(0.0);
let sc = |k: &str| report.pointer(&format!("/scores/{k}")).and_then(Value::as_f64).unwrap_or(0.0);
// capture-frame check
@@ -1338,19 +1145,21 @@ fn gate_hero_inner(io: &Io, state: &mut Value, build_path: &str, min: f64, out_d
});
if ink_present {
r["verdict"] = json!("drift");
r["verdictReason"] = json!("texture with overlapping code-drawn ink present");
} else {
missing_ids.push(id);
}
}
// passed-plate placement notes
let passed_plate = |id: &str| -> bool {
spec_regions_v.iter().find(|r| r.get("id").and_then(Value::as_str) == Some(id))
.map(|r| spec_for_refs.as_ref().is_some_and(|spec| plate_receipt_current(io, state, spec, r))
&& state.get("plates").and_then(|p| p.get(id)).and_then(|p| p.get("score")).and_then(Value::as_f64).is_some_and(|s| s >= PLATE_MIN))
state
.pointer(&format!("/plates/{id}"))
.map(|p| {
p.get("status").and_then(Value::as_str) == Some("ok")
&& p.get("score").map(|s| s.is_null() || s.as_f64().map(|v| v >= PLATE_MIN).unwrap_or(false)).unwrap_or(true)
})
.unwrap_or(false)
};
let mut placement_notes: Vec<(String, String)> = Vec::new();
let mut placement_notes: Vec<String> = Vec::new();
for r in regions.iter_mut() {
let kind = r.get("kind").and_then(Value::as_str).unwrap_or("").to_string();
let id = r.get("id").and_then(Value::as_str).unwrap_or("").to_string();
@@ -1371,7 +1180,6 @@ fn gate_hero_inner(io: &Io, state: &mut Value, build_path: &str, min: f64, out_d
}
r["verdict"] = json!("drift");
r["placed"] = json!(true);
r["verdictReason"] = json!("current plate passed asset validation and rendered presence check; placement remains reviewable");
let ic = r.pointer("/inkBox/comp").cloned().unwrap_or(Value::Null);
let ib = r.pointer("/inkBox/build").cloned().unwrap_or(Value::Null);
if !ic.is_null() && !ib.is_null() {
@@ -1380,28 +1188,28 @@ fn gate_hero_inner(io: &Io, state: &mut Value, build_path: &str, min: f64, out_d
let (bw_, bh_, bx, by) = (cf(&ib, "w"), cf(&ib, "h"), cf(&ib, "x"), cf(&ib, "y"));
let off = (bw_ - cw_).abs() > cw_ * 0.2 || (bh_ - ch_).abs() > ch_ * 0.2 || (bx - cx).abs() > cw_ * 0.15 || (by - cy).abs() > ch_ * 0.15;
if off {
placement_notes.push((id.to_string(), format!(
placement_notes.push(format!(
"plate {id} is placed but not at the comp's box: its ink spans {}x{}px at ({},{}) in the comp region and {}x{}px at ({},{}) in the build; size and position the <img> to the spec box (object-fit: cover), not to the surrounding layout",
cw_ as i64, ch_ as i64, cx as i64, cy as i64, bw_ as i64, bh_ as i64, bx as i64, by as i64
)));
));
}
}
}
for id in &missing_ids {
if let Some(r) = regions.iter().find(|r| r.get("id").and_then(Value::as_str) == Some(id.as_str())) {
if r.get("verdict").and_then(Value::as_str) == Some("missing") {
push_region_blocker(&mut reasons, &mut region_reasons, id, format!(
reasons.push(format!(
"region {id} is missing (detail {}%, structure {}%): the comp shows material the build does not",
pct0(rscore(r, "detail")), pct0(rscore(r, "structure"))
));
}
}
}
for (id, n) in placement_notes {
for n in placement_notes {
if above_bar {
advisories.push(format!("(advisory, above the {}% bar) {n}", pct0(min)));
} else {
push_region_blocker(&mut reasons, &mut region_reasons, &id, n);
reasons.push(n);
}
}
// contradicted
@@ -1421,7 +1229,7 @@ fn gate_hero_inner(io: &Io, state: &mut Value, build_path: &str, min: f64, out_d
} else {
format!("the plate here does not read as the comp region; regenerate it with the crop as reference ({s} generate-image --ref <crop.png> --prompt-file <prompt.txt> --out <plate.png> for {id}) and place it at its box")
};
push_region_blocker(&mut reasons, &mut region_reasons, id, format!(
reasons.push(format!(
"region {id} ({kind}) is contradicted (structure {}%, detail added {}%): {tail}",
pct0(rscore(r, "structure")), pct0(rscore(r, "detailAdded"))
));
@@ -1431,7 +1239,7 @@ fn gate_hero_inner(io: &Io, state: &mut Value, build_path: &str, min: f64, out_d
continue;
}
let id = r.get("id").and_then(Value::as_str).unwrap_or("");
push_region_blocker(&mut reasons, &mut region_reasons, id, format!(
reasons.push(format!(
"control {id} drifts to {}% (structure {}%, color {}%): its chrome differs from the comp's; open {} and match the border, fill, radius, chevron or arrow, and label size",
pct0(rscore(r, "overall")), pct0(rscore(r, "structure")), pct0(rscore(r, "color")),
format!("{out_dir}/regions/{id}.png")
@@ -1471,23 +1279,19 @@ fn gate_hero_inner(io: &Io, state: &mut Value, build_path: &str, min: f64, out_d
if above_bar {
advisories.push(format!("(advisory, above the {}% bar) {msg}", pct0(min)));
} else {
push_region_blocker(&mut reasons, &mut region_reasons, r.get("id").and_then(Value::as_str).unwrap_or(""), msg);
reasons.push(msg);
}
}
}
let other_contradicted: Vec<&Value> = contradicted.iter().filter(|r| !direction_contradicted.iter().any(|d| d.get("id") == r.get("id"))).collect();
let allow = 1usize.max(regions.len() / 3);
if other_contradicted.len() > allow {
let message = format!(
reasons.push(format!(
"{} of {} regions contradicted: {}",
other_contradicted.len(),
regions.len(),
other_contradicted.iter().filter_map(|r| r.get("id").and_then(Value::as_str)).collect::<Vec<_>>().join(", ")
);
for r in &other_contradicted {
if let Some(id) = r.get("id").and_then(Value::as_str) { record_region_reason(&mut region_reasons, id, &message); }
}
reasons.push(message);
));
}
// organic clip + svg illustrations
let artifact_file = page_file.clone();
@@ -1496,7 +1300,7 @@ fn gate_hero_inner(io: &Io, state: &mut Value, build_path: &str, min: f64, out_d
for o in organic_clip_regions(io, af, spec, organic_scan) {
let id = o.get("id").and_then(Value::as_str).unwrap_or("");
let snip = o.get("snippet").and_then(Value::as_str).unwrap_or("");
push_region_blocker(&mut reasons, &mut region_reasons, id, format!("artifact draws an organic clip-path ({snip}) inside raster region {id}'s box; that region ships as its plate, never as a polygon"));
reasons.push(format!("artifact draws an organic clip-path ({snip}) inside raster region {id}'s box; that region ships as its plate, never as a polygon"));
}
let svgs = std::fs::read_to_string(abs(io, af)).map(|h| svg_illustrations(&h)).unwrap_or_default();
for v in svgs.iter().take(6) {
@@ -1519,6 +1323,7 @@ fn gate_hero_inner(io: &Io, state: &mut Value, build_path: &str, min: f64, out_d
if let Some(readings) = readings {
use once_cell::sync::Lazy;
static FOLD: Lazy<regex::Regex> = Lazy::new(|| Regex::new(r"(?i)^text ([a-z0-9]+(?:-[a-z0-9]+)*?)(?:-(?:\d+|[a-z]))?: (cap height|\d+ lines? in the build|the face renders|ink is|its first line|it starts|line pitch)").unwrap());
static IDM: Lazy<regex::Regex> = Lazy::new(|| Regex::new(r"^text ([^:]+):").unwrap());
static CAP: Lazy<regex::Regex> = Lazy::new(|| Regex::new(r"cap height").unwrap());
static LINES: Lazy<regex::Regex> = Lazy::new(|| Regex::new(r"lines? in the build").unwrap());
static HEAV: Lazy<regex::Regex> = Lazy::new(|| Regex::new(r"heavier|lighter").unwrap());
@@ -1527,8 +1332,7 @@ fn gate_hero_inner(io: &Io, state: &mut Value, build_path: &str, min: f64, out_d
let order = |f: &str| -> u8 {
if CAP.is_match(f) { 0 } else if LINES.is_match(f) { 1 } else if HEAV.is_match(f) { 2 } else if INKIS.is_match(f) { 3 } else { 4 }
};
// Keep region provenance when sibling text findings are folded.
let mut reading_ids = readings.region_ids.clone();
// fold sibling text findings
let mut folded: Vec<(String, String, Vec<String>)> = Vec::new(); // (key, first, ids)
for f in &readings.text {
let key = if let Some(m) = FOLD.captures(f) {
@@ -1536,21 +1340,24 @@ fn gate_hero_inner(io: &Io, state: &mut Value, build_path: &str, min: f64, out_d
} else {
f.clone()
};
let ids = readings.region_ids.get(f).cloned().unwrap_or_default();
let idm = IDM.captures(f).map(|m| m[1].to_string());
if let Some(entry) = folded.iter_mut().find(|(k, _, _)| *k == key) {
entry.2.extend(ids);
if let Some(id) = idm {
entry.2.push(id);
}
} else {
let ids = idm.map(|id| vec![id]).unwrap_or_default();
folded.push((key, f.clone(), ids));
}
}
let mut text: Vec<String> = folded
.into_iter()
.map(|(_, first, ids)| {
let message = if ids.len() > 1 {
if ids.len() > 1 {
format!("{first} (also {})", ids[1..].join(", "))
} else { first };
reading_ids.insert(message.clone(), ids);
message
} else {
first
}
})
.collect();
text.sort_by_key(|a| order(a));
@@ -1590,15 +1397,19 @@ fn gate_hero_inner(io: &Io, state: &mut Value, build_path: &str, min: f64, out_d
advisories.push(format!(" {f}"));
}
} else {
if !kept.is_empty() {
let of = if fresh.len() > kept.len() { format!("{} of {}, the rest after these", kept.len(), fresh.len()) } else { format!("{}", kept.len()) };
reasons.push(format!("READINGS, each one CSS edit ({of}):"));
}
for f in &kept {
push_reading_blocker(&mut reasons, &mut region_reasons, &reading_ids, f);
reasons.push(f.clone());
}
}
for f in &advisory_stale {
advisories.push(format!("(advisory, unchanged for 3+ attempts) {f}"));
}
for f in &readings.plates {
push_reading_blocker(&mut reasons, &mut region_reasons, &reading_ids, f);
reasons.push(f.clone());
}
let cells = readings.invented.get("cells").and_then(Value::as_array).cloned().unwrap_or_default();
let fraction = readings.invented.get("fraction").and_then(Value::as_f64).unwrap_or(0.0);
@@ -1614,13 +1425,13 @@ fn gate_hero_inner(io: &Io, state: &mut Value, build_path: &str, min: f64, out_d
}
}
// worst regions
let worst_sorted = repair_regions(&regions, &region_reasons);
let mut worst_sorted = regions.clone();
worst_sorted.sort_by(|a, b| rscore(a, "overall").partial_cmp(&rscore(b, "overall")).unwrap());
let worst_top: Vec<&Value> = worst_sorted.iter().take(3).collect();
let region_dir = format!("{out_dir}/regions");
let mut g = Gate::blank();
g.ok = reasons.is_empty();
g.reasons = reasons;
g.region_reasons = region_reasons;
g.summary = Some(format!("hero {}% ({})", pct0(overall), report.get("verdict").and_then(Value::as_str).unwrap_or("")));
g.score = Some(overall);
g.verdict = report.get("verdict").and_then(Value::as_str).map(String::from);
@@ -1643,90 +1454,18 @@ fn gate_hero_inner(io: &Io, state: &mut Value, build_path: &str, min: f64, out_d
.iter()
.filter_map(|r| Some((r.get("id")?.as_str()?.to_string(), json!(r.get("verdict")?.as_str()?))))
.collect();
publish_gate_evidence(io, out_dir, &mut report, &mut measured, &regions, &mut g, "hero");
g
}
#[allow(clippy::too_many_arguments)]
fn publish_gate_evidence(io: &Io, out_dir: &str, report: &mut Value, measured: &mut CompareResult, regions: &[Value], g: &mut Gate, phase: &str) {
let raw_path = format!("{out_dir}/raw-report.json");
let raw = report.clone();
g.report = Some(format!("{out_dir}/report.json"));
apply_gate_evidence(report, measured, regions, g);
report["interpretation"] = json!(format!("{phase}-gate"));
report["rawReport"] = json!(raw_path);
let evidence_write = (|| {
atomic_report(&abs(io, &raw_path), &raw)?;
write_region_artifacts(measured, &abs(io, out_dir), report.get("regions").and_then(Value::as_array).map(Vec::as_slice))?;
atomic_report(&abs(io, &format!("{out_dir}/report.json")), report)
})();
if let Err(e) = evidence_write {
g.ok = false;
g.reasons.push(format!("cannot persist {phase} gate evidence: {e}"));
g.report = None;
g.side_by_side = None;
g.worst_crops.clear();
}
}
fn push_region_blocker(reasons: &mut Vec<String>, regions: &mut Map<String, Value>, id: &str, message: String) {
record_region_reason(regions, id, &message);
reasons.push(message);
}
fn record_region_reason(regions: &mut Map<String, Value>, id: &str, message: &str) {
regions.entry(id.to_string()).or_insert_with(|| json!([])).as_array_mut().unwrap().push(json!(message));
}
fn push_reading_blocker(reasons: &mut Vec<String>, regions: &mut Map<String, Value>, ids: &std::collections::HashMap<String, Vec<String>>, message: &str) {
for id in ids.get(message).into_iter().flatten() { record_region_reason(regions, id, message); }
reasons.push(message.to_string());
}
fn repair_regions(regions: &[Value], blockers: &Map<String, Value>) -> Vec<Value> {
let mut ordered: Vec<Value> = regions.iter().filter(|region| {
region.get("id").and_then(Value::as_str).is_some_and(|id|
blockers.get(id).and_then(Value::as_array).is_some_and(|reasons| !reasons.is_empty()))
}).cloned().collect();
ordered.sort_by(|a, b| rscore(a, "overall").total_cmp(&rscore(b, "overall")));
ordered
}
/// Raw scores never change during interpretation. Only gate verdicts and their
/// basis are published alongside them; the original report is retained separately.
fn apply_gate_evidence(report: &mut Value, measured: &mut CompareResult, regions: &[Value], gate: &Gate) {
let unscoped: Vec<&String> = gate.reasons.iter().filter(|reason| {
!gate.region_reasons.values().any(|v| v.as_array().is_some_and(|a| a.iter().any(|m| m.as_str() == Some(reason.as_str()))))
}).collect();
let mut effective = regions.to_vec();
for region in &mut effective {
let id = region.get("id").and_then(Value::as_str).unwrap_or("").to_string();
let blockers = gate.region_reasons.get(&id).cloned().unwrap_or_else(|| json!([]));
region["blocking"] = if !blockers.as_array().unwrap().is_empty() { json!(true) } else if unscoped.is_empty() { json!(false) } else { Value::Null };
region["blockingReasons"] = blockers;
if let Some(raw) = measured.regions.iter_mut().find(|r| r.id == id) {
region["rawVerdict"] = json!(raw.verdict);
if let Some(verdict) = region.get("verdict").and_then(Value::as_str) {
raw.verdict = verdict.into();
}
}
}
report["regions"] = json!(effective);
report["interpretation"] = json!("hero-gate");
report["measurementsAvailable"] = json!(true);
report["gate"] = json!({ "ok": gate.ok, "reasons": gate.reasons, "advisories": gate.advisories, "unscopedReasons": unscoped });
}
/// JS: heroLoopVerdict(state, gate, artifactPath).
fn hero_loop_verdict(state: &mut Value, gate: &Gate, artifact_path: &str, io: &Io) -> Option<String> {
let s = self_cmd(io);
let hero = state.pointer_mut("/phases/hero")?.as_object_mut()?;
let mut history: Vec<Value> = hero.get("history").and_then(Value::as_array).cloned().unwrap_or_default();
let entry = json!({
"at": now(),
"score": gate.score.map(util::num).unwrap_or(Value::Null),
"worstIds": gate.worst_ids,
"blockingReasons": gate.reasons,
"regionVerdicts": Value::Object(gate.region_verdicts.clone()),
"artifactHash": hash_file(io, artifact_path).map(Value::from).unwrap_or(Value::Null),
});
@@ -1738,11 +1477,16 @@ fn hero_loop_verdict(state: &mut Value, gate: &Gate, artifact_path: &str, io: &I
return None;
}
let last3 = &history[history.len() - 3..];
let current = json!(gate.reasons);
let stuck = !gate.ok && !gate.reasons.is_empty()
&& last3.iter().all(|h| h.get("blockingReasons") == Some(&current));
if stuck {
return Some("The same hero gate checks remain unresolved after three attempts. The blocking reasons below still apply.".into());
let first_worst = last3[0].pointer("/worstIds/0").and_then(Value::as_str);
let stuck = first_worst.is_some() && last3.iter().all(|h| h.pointer("/worstIds/0").and_then(Value::as_str) == first_worst);
let scores: Vec<f64> = last3.iter().map(|h| h.get("score").and_then(Value::as_f64).unwrap_or(0.0)).collect();
let no_progress = scores.iter().cloned().fold(f64::MIN, f64::max) - scores.iter().cloned().fold(f64::MAX, f64::min) < 0.03;
if stuck && no_progress {
let w = first_worst.unwrap();
return Some(format!(
"region {w} has been the worst region for three attempts and the score moved less than 3 points: value edits are not reaching it. Open {} and rebuild that region from the comp crop (place its plate, or produce one with {s} generate-image --ref <crop.png> --prompt-file <prompt.txt> --out <plate.png>, or re-derive its structure from the spec box), then recapture.",
format!(".impeccable/review/diff/hero/regions/{w}.png")
));
}
None
}
@@ -1756,22 +1500,7 @@ fn hash_file(io: &Io, file: &str) -> Option<String> {
Some(d.iter().map(|b| format!("{b:02x}")).collect::<String>()[..12].to_string())
}
fn gate_responsive(io: &Io, state: &mut Value, min: f64, out_dir: &str) -> Gate {
let pending = Gate::fail(vec!["responsive comparison has not completed".into()]);
if let Err(e) = unavailable_report(io, out_dir, &pending, "responsive") {
return Gate::fail(vec![format!("cannot persist responsive gate evidence: {e}")]);
}
let mut gate = gate_responsive_inner(io, state, min, out_dir);
if gate.report.is_none() {
if let Err(e) = unavailable_report(io, out_dir, &gate, "responsive") {
gate.ok = false;
gate.reasons.push(format!("cannot persist responsive gate evidence: {e}"));
}
}
gate
}
fn gate_responsive_inner(io: &Io, state: &mut Value, min: f64, out_dir: &str) -> Gate {
fn gate_responsive(io: &Io, state: &Value, min: f64, out_dir: &str) -> Gate {
let desktop = ".impeccable/review/desktop.png";
let mobile = ".impeccable/review/mobile.png";
let mut reasons = Vec::new();
@@ -1784,25 +1513,21 @@ fn gate_responsive_inner(io: &Io, state: &mut Value, min: f64, out_dir: &str) ->
if !reasons.is_empty() {
return Gate::fail(reasons);
}
let spec_gate = gate_spec(io, state);
if !spec_gate.ok { return spec_gate; }
let spec = load_spec(&abs(io, SPEC_PATH));
if let Some(failure) = revalidate_plates(io, state, spec.as_ref()) { return failure; }
let comp_path = state.get("comp").and_then(Value::as_str).unwrap_or("");
let (mut report, mut measured) = match hero_diff_labeled(io, comp_path, desktop, spec.as_ref(), out_dir, "desktop") {
let report = match hero_diff_labeled(io, comp_path, desktop, spec.as_ref(), out_dir, "desktop") {
Ok(r) => r,
Err(e) => return Gate::fail(vec![format!("comp-diff failed on {desktop}: {e}")]),
};
let mut regions: Vec<Value> = report.get("regions").and_then(Value::as_array).cloned().unwrap_or_default();
let missing: Vec<Value> = regions
let regions: Vec<Value> = report.get("regions").and_then(Value::as_array).cloned().unwrap_or_default();
let missing: Vec<&Value> = regions
.iter()
.filter(|r| {
if r.get("verdict").and_then(Value::as_str) != Some("missing") || r.get("kind").and_then(Value::as_str) == Some("texture") {
return false;
}
let id = r.get("id").and_then(Value::as_str).unwrap_or("");
let passed = spec.as_ref().is_some_and(|spec| spec_regions(spec).iter().any(|region|
region.get("id").and_then(Value::as_str) == Some(id) && plate_receipt_current(io, state, spec, region)));
let passed = state.pointer(&format!("/plates/{id}/status")).and_then(Value::as_str) == Some("ok");
let kind = r.get("kind").and_then(Value::as_str).unwrap_or("");
if (kind == "plate" || kind == "image") && passed {
let present = rscore_opt(r, "detailRaw").map(|v| v >= 0.3).unwrap_or(rscore(r, "detail") >= 0.3);
@@ -1812,17 +1537,8 @@ fn gate_responsive_inner(io: &Io, state: &mut Value, min: f64, out_dir: &str) ->
}
true
})
.cloned().collect();
let contradicted_direction: Vec<Value> = regions.iter().filter(|r| r.get("verdict").and_then(Value::as_str) == Some("contradicted") && r.get("kind").and_then(Value::as_str) == Some("text")).cloned().collect();
for region in &mut regions {
if region.get("verdict").and_then(Value::as_str) == Some("missing")
&& matches!(region.get("kind").and_then(Value::as_str), Some("plate" | "image"))
&& !missing.iter().any(|r| r.get("id") == region.get("id")) {
region["verdict"] = json!("drift");
region["verdictReason"] = json!("current plate passed asset validation and responsive rendered presence check");
}
}
let mut region_reasons = Map::new();
.collect();
let contradicted_direction: Vec<&Value> = regions.iter().filter(|r| r.get("verdict").and_then(Value::as_str) == Some("contradicted") && r.get("kind").and_then(Value::as_str) == Some("text")).collect();
let overall = report.get("overall").and_then(Value::as_f64).unwrap_or(0.0);
let mut reasons = Vec::new();
if overall < min {
@@ -1835,11 +1551,10 @@ fn gate_responsive_inner(io: &Io, state: &mut Value, min: f64, out_dir: &str) ->
));
}
for r in &missing {
let id = r.get("id").and_then(Value::as_str).unwrap_or("");
push_region_blocker(&mut reasons, &mut region_reasons, id, format!("at desktop width, region {id} is missing"));
reasons.push(format!("at desktop width, region {} is missing", r.get("id").and_then(Value::as_str).unwrap_or("")));
}
for r in &contradicted_direction {
push_region_blocker(&mut reasons, &mut region_reasons, r.get("id").and_then(Value::as_str).unwrap_or(""), format!(
reasons.push(format!(
"at desktop width, region {} ({}) is contradicted (structure {}%)",
r.get("id").and_then(Value::as_str).unwrap_or(""),
r.get("kind").and_then(Value::as_str).unwrap_or(""),
@@ -1850,16 +1565,14 @@ fn gate_responsive_inner(io: &Io, state: &mut Value, min: f64, out_dir: &str) ->
g.summary = Some(format!("desktop {}% ({})", pct0(overall), report.get("verdict").and_then(Value::as_str).unwrap_or("")));
g.score = Some(overall);
g.side_by_side = report.pointer("/files/sideBySide").and_then(Value::as_str).map(String::from);
g.region_reasons = region_reasons;
publish_gate_evidence(io, out_dir, &mut report, &mut measured, &regions, &mut g, "responsive");
g
}
fn hero_diff_labeled(io: &Io, comp_path: &str, build_path: &str, spec: Option<&Value>, out_dir: &str, label: &str) -> Result<(Value, CompareResult), String> {
fn hero_diff_labeled(io: &Io, comp_path: &str, build_path: &str, spec: Option<&Value>, out_dir: &str, label: &str) -> Result<Value, String> {
let comp = load_raster(io, comp_path)?;
let build = load_raster(io, build_path)?;
let res = compare(&comp, &build, spec, "top", label, None);
let files = write_artifacts(&res, &comp, &abs(io, out_dir)).map_err(|e| format!("cannot persist comparison artifacts: {e}"))?;
let files = write_artifacts(&res, &comp, &abs(io, out_dir));
let meta = json!({
"label": label, "comp": comp_path, "build": build_path,
"spec": if spec.is_some() { Value::String(SPEC_PATH.into()) } else { Value::Null },
@@ -1867,7 +1580,8 @@ fn hero_diff_labeled(io: &Io, comp_path: &str, build_path: &str, spec: Option<&V
"buildSize": format!("{}x{}", build.width, build.height),
});
let report = build_report(&res, Some(&files), &meta);
Ok((report, res))
let _ = std::fs::write(abs(io, &format!("{out_dir}/report.json")), util::json_pretty(&report));
Ok(report)
}
// ---- transitions -----------------------------------------------------------
@@ -1900,14 +1614,25 @@ fn force_allowed(reason: Option<&str>) -> bool {
if reason.trim().chars().count() < 20 {
return false;
}
// Match a direct attribution together with its quotation. A user mention
// elsewhere in the reason cannot authorize a different speaker's words.
static QUOTE: Lazy<Regex> = Lazy::new(|| Regex::new(r#"(?i)(?:^|[.!?]\s+)(?:the\s+)?(?:user|paul)(?:\s+(?:said|says|wrote|replied|answered|confirmed|asked)\s*[:,]?|\s*:)\s*(?:"([^"]+)"|“([^”]+)”|'([^']+)'|([^]+))"#).unwrap());
static DOWNGRADE: Lazy<Regex> = Lazy::new(|| Regex::new(r"(?i)^\s*(please\s+)?(ignore|waive|relax|skip|drop|disregard) (the )?(approved )?(comp|mockup|fidelity|plate|region)\b|^\s*(the )?(comp|mockup|fidelity|plate|region)\b[^.!?;\n]{0,40}\b(is optional|is not required|does not need to match|doesn't need to match|need not match|can differ|can be skipped)\b").unwrap());
QUOTE.captures_iter(reason.trim()).any(|capture| {
(1..=4).filter_map(|i| capture.get(i)).any(|q| DOWNGRADE.is_match(q.as_str()))
})
static ERRORED: Lazy<Regex> = Lazy::new(|| Regex::new(r"(?i)gate \w+ errored").unwrap());
if ERRORED.is_match(reason) {
return true;
}
static NAMES_USER: Lazy<Regex> = Lazy::new(|| Regex::new(r"(?i)\buser\b|\bthey (said|asked|told|chose|picked)\b|\bpaul\b").unwrap());
static ABOUT_COMP: Lazy<Regex> = Lazy::new(|| Regex::new(r"(?i)\b(comp|mock|mockup|composition|fidelity|plate|region)\b").unwrap());
static TRANS1: Lazy<Regex> = Lazy::new(|| Regex::new(r"(?i)truthful|semantic|pixel-level|prioriti[sz]e (facts|semantics|accessibility)").unwrap());
static TRANS2: Lazy<Regex> = Lazy::new(|| Regex::new(r"(?i)(drop|skip|remove|without|not needed|don't need|do not need|ignore) (the )?(comp|plate|region|fidelity)").unwrap());
static DOWNGRADES: Lazy<Regex> = Lazy::new(|| Regex::new(r"(?i)\b(don't|do not|doesn't|does not|no longer|not) (need|have to|want|care|require|match|follow|hold)|\b(drop|skip|remove|ignore|waive|relax|override|approve|approved|accept|accepted|fine|okay|ok|good enough|ship it|move on|proceed|go ahead|instead of|rather than)\b").unwrap());
static REPORTED: Lazy<Regex> = Lazy::new(|| Regex::new(r#"(?i)["'\u{201c}\u{2018}].{6,}["'\u{201d}\u{2019}]|\b(user|they|paul) (said|says|asked|asks|told|wrote|replied|answered|chose|picked|approved|confirmed)\b"#).unwrap());
static BRIEF1: Lazy<Regex> = Lazy::new(|| Regex::new(r"(?i)\b(should feel|feel like|not a .* page|extension of)\b").unwrap());
static BRIEF2: Lazy<Regex> = Lazy::new(|| Regex::new(r"(?i)\b(comp|mock|fidelity|gate|plate)\b.*\b(approved|accept|fine|ok|okay|skip|drop|waive|relax|override|move on|proceed)\b").unwrap());
let names_user = NAMES_USER.is_match(reason);
let about_comp = ABOUT_COMP.is_match(reason);
let is_translation_dodge = TRANS1.is_match(reason) && !TRANS2.is_match(reason);
let downgrades = DOWNGRADES.is_match(reason);
let reported = REPORTED.is_match(reason);
let brief_quote_only = BRIEF1.is_match(reason) && !BRIEF2.is_match(reason);
names_user && about_comp && downgrades && reported && !is_translation_dodge && !brief_quote_only
}
struct AdvanceResult {
@@ -1951,7 +1676,16 @@ fn advance(io: &Io, state: &mut Value, force: bool, reason: Option<&str>, opts:
if let Some(p) = state.pointer_mut(&format!("/phases/{phase}")).and_then(|p| p.as_object_mut()) {
p.insert("gate".into(), gate.record_json(&now()));
}
if phase == "plates" { save_plate_receipts(state, &gate); }
if phase == "plates" {
if let Some(plates) = &gate.plates {
let mut m = Map::new();
for pl in plates {
let id = pl.get("id").and_then(Value::as_str).unwrap_or("").to_string();
m.insert(id, json!({ "status": pl.get("status").cloned().unwrap_or(Value::Null), "score": pl.get("score").cloned().unwrap_or(Value::Null), "size": pl.get("size").cloned().unwrap_or(Value::Null) }));
}
state.as_object_mut().unwrap().insert("plates".into(), Value::Object(m));
}
}
if !gate.ok && force && !force_allowed(reason) {
if let Some(p) = state.pointer_mut(&format!("/phases/{phase}")).and_then(|p| p.as_object_mut()) {
p.insert("status".into(), json!("open"));
@@ -2084,8 +1818,7 @@ mod transparency_guidance_tests {
fn missing_plate_guidance_uses_the_configured_launcher() {
let dir = std::env::temp_dir().join(format!("impeccable-plate-launcher-{}", std::process::id()));
std::fs::create_dir_all(dir.join(BUILD_DIR)).unwrap();
std::fs::write(dir.join(SPEC_PATH), json!({"comp":"comp.png","regions": [{"id": "art", "medium": "raster", "plate": "missing.png"}]}).to_string()).unwrap();
std::fs::write(dir.join("comp.png"), png_io::encode_png(&r::create_image(8,8,[255,255,255,255]), &[]).unwrap()).unwrap();
std::fs::write(dir.join(SPEC_PATH), json!({"regions": [{"id": "art", "medium": "raster", "plate": "missing.png"}]}).to_string()).unwrap();
let env = [("IMPECCABLE_SELF".into(), "/custom/impeccable".into())].into();
let (io, _) = Io::captured("", dir.clone(), env);
let reasons = gate_plates(&io).reasons.join("\n");
@@ -2421,7 +2154,3 @@ pub fn run(argv: &[String], io: &mut Io, organic_scan: OrganicScan) -> i32 {
}
}
}
#[cfg(test)]
#[path = "build_phase/integrity_tests.rs"]
mod integrity_tests;
@@ -1,521 +0,0 @@
use super::*;
#[test]
fn delegation_is_not_authority_to_override_comp() {
for reason in [
"The user said 'Use your judgment to fill in missing product details from my original request.' Proceeding past the comp fidelity gate.",
"The user answered 'Please proceed with the implementation.' I accept the comp differences.",
"The user said \"Do not ignore the comp fidelity requirement.\"",
"gate hero errored after the screenshot tool failed repeatedly",
"The user says the page should feel like a bookshop, so relax the comp gate",
] {
assert!(!force_allowed(Some(reason)), "{reason}");
}
assert!(force_allowed(Some(
"The user said \"Ignore the comp fidelity requirement; ship this version.\""
)));
}
#[test]
fn stall_feedback_does_not_rebuild_a_nonblocking_plate() {
let (io, _) = Io::captured("", std::env::temp_dir(), Default::default());
let mut state = json!({"phases":{"hero":{"history":[]}}});
let mut gate = Gate::fail(vec![
"control meaning-card drifts to 60%: match the comp".into()
]);
gate.score = Some(0.7524);
gate.worst_ids = vec!["accepted-fox".into()];
for _ in 0..3 {
if let Some(message) = hero_loop_verdict(&mut state, &gate, "missing.html", &io) {
assert!(!message.contains("accepted-fox"), "{message}");
assert!(!message.contains("generate-image"), "{message}");
}
assert!(!gate.ok);
assert_eq!(gate.reasons.len(), 1);
}
}
struct Workspace {
path: PathBuf,
}
impl Workspace {
fn new() -> Self {
static NEXT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
let path = std::env::temp_dir().join(format!(
"comp-integrity-{}-{}",
std::process::id(),
NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
));
std::fs::create_dir_all(&path).unwrap();
Self { path }
}
fn io(&self) -> Io {
Io::captured("", self.path.clone(), Default::default()).0
}
fn write(&self, file: &str, bytes: &[u8]) {
let p = self.path.join(file);
std::fs::create_dir_all(p.parent().unwrap()).unwrap();
std::fs::write(p, bytes).unwrap();
}
}
impl Drop for Workspace {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.path);
}
}
#[test]
fn plate_approval_is_bound_to_current_asset_region_and_comp() {
let ws = Workspace::new();
ws.write("art.png", b"accepted asset bytes");
ws.write("comp.png", b"approved comp bytes");
let io = ws.io();
let region =
json!({"id":"art", "kind":"plate", "plate":"art.png", "box":{"x":0,"y":0,"w":1,"h":1}});
let spec = json!({"comp":"comp.png", "regions":[region.clone()]});
let receipt = json!({"id":"art", "status":"ok", "score":0.81, "file":"art.png",
"assetHash":sha256_file(&io,"art.png"), "compHash":sha256_file(&io,"comp.png"),
"regionHash":sha256_bytes(util::json_pretty(&region).as_bytes())});
let mut state = json!({"plates":{"art":receipt}});
assert!(plate_receipt_current(&io, &state, &spec, &region));
ws.write("art.png", b"replacement");
assert!(!plate_receipt_current(&io, &state, &spec, &region));
ws.write("art.png", b"accepted asset bytes");
let mut smaller = region.clone();
smaller["box"]["w"] = json!(0.1);
assert!(!plate_receipt_current(&io, &state, &spec, &smaller));
ws.write("comp.png", b"different comp");
assert!(!plate_receipt_current(&io, &state, &spec, &region));
ws.write("comp.png", b"approved comp bytes");
state["plates"]["art"]["status"] = json!("invalid");
assert!(!plate_receipt_current(&io, &state, &spec, &region));
state["plates"]["art"] = json!({"status":"ok", "score":1.0});
assert!(
!plate_receipt_current(&io, &state, &spec, &region),
"legacy scores must be revalidated"
);
std::fs::remove_file(ws.path.join("art.png")).unwrap();
assert!(!plate_receipt_current(&io, &state, &spec, &region));
}
#[test]
fn copied_comp_does_not_earn_an_ok_plate_receipt_or_advance() {
let ws = Workspace::new();
let mut comp = r::create_image(64, 64, [230, 220, 200, 255]);
for y in 12..52 {
for x in 12..52 {
let p = (y * 64 + x) * 4;
comp.data[p..p + 4].copy_from_slice(&[90, 40, 20, 255]);
}
}
ws.write("comp.png", &png_io::encode_png(&comp, &[]).unwrap());
ws.write(
"art.png",
&png_io::encode_png(&r::resize(&comp, 128.0, 128.0), &[]).unwrap(),
);
let spec = json!({"comp":"comp.png", "regions":[{"id":"art","kind":"plate","medium":"raster","plate":"art.png",
"box":{"x":0,"y":0,"w":1,"h":1},"px":{"x":0,"y":0,"w":64,"h":64},"detail":{"energy":20}}]});
ws.write(SPEC_PATH, util::json_pretty(&spec).as_bytes());
let io = ws.io();
let gate = gate_plates(&io);
assert!(!gate.ok);
assert!(
gate.reasons.iter().any(|r| r.contains("comp crop")),
"{:?}",
gate.reasons
);
assert_eq!(gate.plates.as_ref().unwrap()[0]["status"], "invalid");
let mut state =
json!({"phase":"plates","comp":"comp.png","phases":{"plates":{"attempts":0},"hero":{}}});
let opts = GateOpts {
build_path: None,
min: None,
artifact: None,
};
for _ in 0..5 {
let result = advance(&io, &mut state, false, None, &opts, &no_organic_scan);
assert!(!result.ok);
assert_eq!(state["phase"], "plates");
assert_eq!(state["plates"]["art"]["status"], "invalid");
}
}
#[test]
fn gate_report_keeps_raw_measurements_and_does_not_turn_drift_into_a_pass() {
let comp = r::create_image(64, 64, [230, 220, 200, 255]);
let spec = json!({"regions":[{"id":"fox", "kind":"plate", "x":0,"y":0,"w":1,"h":1}]});
let mut measured = compare(&comp, &comp, Some(&spec), "top", "hero", None);
measured.regions[0].verdict = "missing".into();
let mut report = build_report(&measured, None, &json!({}));
let original = report.clone();
let mut regions = report["regions"].as_array().unwrap().clone();
regions[0]["verdict"] = json!("drift");
regions[0]["placed"] = json!(true);
let gate = Gate::fail(vec!["control meaning-card drifts to 60%".into()]);
apply_gate_evidence(&mut report, &mut measured, &regions, &gate);
assert_eq!(report["regions"][0]["rawVerdict"], "missing");
assert_eq!(report["regions"][0]["verdict"], "drift");
assert_eq!(
measured.regions[0].verdict, "drift",
"the image writer uses the same effective verdict"
);
assert_eq!(
report["regions"][0]["score"],
original["regions"][0]["score"]
);
assert_eq!(report["gate"]["ok"], false);
assert_eq!(
report["gate"]["reasons"][0],
"control meaning-card drifts to 60%"
);
assert_eq!(original["regions"][0]["verdict"], "missing");
}
#[test]
fn accepted_file_hidden_in_render_still_blocks_hero() {
let ws = Workspace::new();
let mut comp = r::create_image(64, 64, [230, 220, 200, 255]);
for y in 8..56 {
for x in 8..56 {
let p = (y * 64 + x) * 4;
comp.data[p..p + 4].copy_from_slice(&[40, 40, 40, 255]);
}
}
ws.write("comp.png", &png_io::encode_png(&comp, &[]).unwrap());
ws.write("art.png", &png_io::encode_png(&comp, &[]).unwrap());
ws.write(
"blank.png",
&png_io::encode_png(&r::create_image(64, 64, [230, 220, 200, 255]), &[]).unwrap(),
);
ws.write(
"index.html",
b"<img src=\"art.png\" style=\"display:none\">",
);
let region = json!({"id":"art","kind":"plate","medium":"raster","plate":"art.png", "box":{"x":0,"y":0,"w":1,"h":1},"px":{"x":0,"y":0,"w":64,"h":64}});
let spec = json!({"comp":"comp.png","regions":[region.clone()]});
ws.write(SPEC_PATH, util::json_pretty(&spec).as_bytes());
let io = ws.io();
// Model an already accepted current asset; rendered presence is still required.
let receipt = json!({"status":"ok","score":0.9,"file":"art.png","assetHash":sha256_file(&io,"art.png"),"compHash":sha256_file(&io,"comp.png"),"regionHash":sha256_bytes(util::json_pretty(&region).as_bytes())});
let mut state =
json!({"comp":"comp.png","plates":{"art":receipt},"phases":{"hero":{"attempts":0}}});
for _ in 0..4 {
let g = gate_hero(
&io,
&mut state,
"blank.png",
HERO_MIN,
"diff",
Some("index.html"),
&no_organic_scan,
);
assert!(!g.ok);
assert!(
g.reasons.iter().any(|r| r.contains("missing")),
"{:?}",
g.reasons
);
let report: Value =
serde_json::from_slice(&std::fs::read(ws.path.join("diff/report.json")).unwrap())
.unwrap();
assert_eq!(report["gate"]["ok"], false);
assert_eq!(report["regions"][0]["blocking"], true);
assert_eq!(report["regions"][0]["verdict"], "missing");
assert!(ws.path.join("diff/raw-report.json").exists());
}
}
#[test]
fn shrinking_or_retyping_regions_does_not_disable_spec_checks() {
let bytes = std::fs::read(
Path::new(env!("CARGO_MANIFEST_DIR")).join("../comp/tests/fixtures/comp.png"),
)
.unwrap();
let comp = png_io::decode_png(&bytes).unwrap().image;
let tiny = json!({"regions":[{"id":"only","kind":"chrome","note":"a small control", "box":{"x":0,"y":0,"w":0.02,"h":0.02}}]});
assert!(crate::comp_spec::measure_regions(&comp, &tiny, "comp.png").is_err());
let retyped = json!({"regions":[{"id":"art","kind":"chrome","note":"a painted illustration", "box":{"x":0,"y":0,"w":0.1,"h":0.1}}]});
assert!(
crate::comp_spec::measure_regions(&comp, &retyped, "comp.png")
.unwrap_err()
.contains("painted material")
);
}
#[test]
fn preflight_failure_replaces_stale_success_report() {
let ws = Workspace::new();
ws.write(
"diff/report.json",
br#"{"gate":{"ok":true},"regions":[{"id":"old"}]}"#,
);
let mut state = json!({"comp":"missing.png"});
let gate = gate_hero(
&ws.io(),
&mut state,
"missing-build.png",
HERO_MIN,
"diff",
None,
&no_organic_scan,
);
assert!(!gate.ok);
let report: Value =
serde_json::from_slice(&std::fs::read(ws.path.join("diff/report.json")).unwrap()).unwrap();
assert_eq!(report["gate"]["ok"], false);
assert_eq!(report["measurementsAvailable"], false);
assert_eq!(report["regions"], json!([]));
assert_eq!(report["gate"]["reasons"], json!(gate.reasons));
}
#[test]
fn unrelated_user_mention_cannot_authorize_another_speakers_quote() {
for reason in [
"The user requested dark mode. The designer said \"ignore the comp fidelity requirement.\"",
"The user asked to proceed. I will \"ignore the comp fidelity requirement\"",
"The designer said the user said \"ignore the comp fidelity requirement\"",
"The user said \"Keep the comp.\" The designer said \"Ignore the comp.\"",
] {
assert!(!force_allowed(Some(reason)), "{reason}");
}
for reason in [
"The user said \"Ignore the comp fidelity requirement.\"",
"User: Please waive the comp requirement.",
"Paul wrote: “The comp is optional.”",
] {
assert!(force_allowed(Some(reason)), "{reason}");
}
}
fn simple_hero_workspace() -> (Workspace, Value) {
let ws = Workspace::new();
let comp = r::create_image(100, 100, [150, 70, 30, 255]);
ws.write("comp.png", &png_io::encode_png(&comp, &[]).unwrap());
ws.write("index.html", b"<main><button>Continue</button></main>");
ws.write(
SPEC_PATH,
util::json_pretty(&json!({"comp":"comp.png","regions":[{
"id":"button","kind":"control","medium":"code","box":{"x":0,"y":0,"w":1,"h":1},
"px":{"x":0,"y":0,"w":100,"h":100}}]}))
.as_bytes(),
);
(ws, json!({"comp":"comp.png","phases":{"hero":{}}}))
}
#[test]
fn failed_evidence_writes_cannot_publish_success() {
for blocked_file in ["regions/button.png", "raw-report.json"] {
let (ws, mut state) = simple_hero_workspace();
let g = gate_hero(
&ws.io(),
&mut state,
"comp.png",
HERO_MIN,
"diff",
Some("index.html"),
&no_organic_scan,
);
assert!(g.ok, "fixture: {:?}", g.reasons);
let blocked = ws.path.join("diff").join(blocked_file);
std::fs::remove_file(&blocked).unwrap();
std::fs::create_dir(&blocked).unwrap();
let g = gate_hero(
&ws.io(),
&mut state,
"comp.png",
HERO_MIN,
"diff",
Some("index.html"),
&no_organic_scan,
);
assert!(!g.ok, "write failure must block: {blocked_file}");
assert_no_current_measurements(&ws);
let report: Value =
serde_json::from_slice(&std::fs::read(ws.path.join("diff/report.json")).unwrap())
.unwrap();
assert_eq!(report["gate"]["ok"], false);
assert_eq!(report["measurementsAvailable"], false);
assert!(report["gate"]["reasons"]
.as_array()
.unwrap()
.iter()
.any(|r| r.as_str().unwrap().contains("persist")));
}
}
#[test]
fn missing_comp_cannot_approve_plates() {
let ws = Workspace::new();
let art = r::create_image(100, 100, [140, 60, 20, 255]);
ws.write("art.png", &png_io::encode_png(&art, &[]).unwrap());
ws.write(SPEC_PATH, util::json_pretty(&json!({"comp":"missing.png","regions":[{
"id":"art","kind":"plate","medium":"raster","plate":"art.png","px":{"x":0,"y":0,"w":10,"h":10}}]})).as_bytes());
let g = gate_plates(&ws.io());
assert!(!g.ok);
assert!(g.reasons.iter().any(|r| r.contains("comp")));
}
#[test]
fn responsive_revalidates_legacy_or_changed_plate_receipts() {
let (ws, mut state) = simple_hero_workspace();
let bytes = std::fs::read(ws.path.join("comp.png")).unwrap();
ws.write(".impeccable/review/desktop.png", &bytes);
ws.write(".impeccable/review/mobile.png", &bytes);
let region = json!({"id":"art","kind":"plate","medium":"raster","plate":"removed.png",
"box":{"x":0,"y":0,"w":1,"h":1},"px":{"x":0,"y":0,"w":100,"h":100}});
ws.write(
SPEC_PATH,
util::json_pretty(&json!({"comp":"comp.png","regions":[region]})).as_bytes(),
);
state["plates"] = json!({"art":{"status":"ok","score":0.9}});
let g = gate_responsive(&ws.io(), &mut state, RESPONSIVE_MIN, "diff");
assert!(!g.ok, "a missing asset cannot inherit legacy approval");
assert!(
g.reasons.iter().any(|r| r.contains("plate missing")),
"{:?}",
g.reasons
);
}
#[test]
fn repair_crops_follow_blockers_not_the_lowest_raw_score() {
let regions = vec![
json!({"id":"advisory-art","score":{"overall":0.3}}),
json!({"id":"blocking-control","score":{"overall":0.6}}),
];
let mut blockers = Map::new();
record_region_reason(&mut blockers, "blocking-control", "control still differs");
let repairs = repair_regions(&regions, &blockers);
assert_eq!(repairs.len(), 1);
assert_eq!(repairs[0]["id"], "blocking-control");
assert!(
repair_regions(&regions, &Map::new()).is_empty(),
"global blockers do not justify guessing which asset to regenerate"
);
}
#[test]
fn folded_readings_keep_all_region_ids_without_becoming_unscoped() {
let mut reasons = vec![];
let mut bindings = Map::new();
let message = "text title-1: cap height differs (also title-2)";
let ids = [(
message.to_string(),
vec!["title-1".into(), "title-2".into()],
)]
.into();
push_reading_blocker(&mut reasons, &mut bindings, &ids, message);
assert_eq!(reasons, vec![message]);
for id in ["title-1", "title-2"] {
assert_eq!(bindings[id], json!([message]));
}
}
#[test]
fn responsive_failures_replace_previous_success_evidence() {
for failure in ["missing-comp", "crop-write"] {
let (ws, mut state) = simple_hero_workspace();
let image = std::fs::read(ws.path.join("comp.png")).unwrap();
ws.write(".impeccable/review/desktop.png", &image);
ws.write(".impeccable/review/mobile.png", &image);
let good = gate_responsive(&ws.io(), &mut state, RESPONSIVE_MIN, "diff");
assert!(good.ok, "{:?}", good.reasons);
let report: Value =
serde_json::from_slice(&std::fs::read(ws.path.join("diff/report.json")).unwrap())
.unwrap();
assert_eq!(report["gate"]["ok"], true);
assert_eq!(report["interpretation"], "responsive-gate");
assert_eq!(report["regions"][0]["blocking"], false);
if failure == "missing-comp" {
std::fs::remove_file(ws.path.join("comp.png")).unwrap();
} else {
std::fs::remove_file(ws.path.join("diff/regions/button.png")).unwrap();
std::fs::create_dir(ws.path.join("diff/regions/button.png")).unwrap();
}
let bad = gate_responsive(&ws.io(), &mut state, RESPONSIVE_MIN, "diff");
assert!(!bad.ok);
assert_no_current_measurements(&ws);
let report: Value =
serde_json::from_slice(&std::fs::read(ws.path.join("diff/report.json")).unwrap())
.unwrap();
assert_eq!(report["gate"]["ok"], false, "{failure}");
assert_eq!(report["measurementsAvailable"], false);
assert_eq!(report["interpretation"], "responsive-gate");
assert_eq!(report["gate"]["reasons"], json!(bad.reasons));
}
}
fn assert_no_current_measurements(ws: &Workspace) {
for file in ["raw-report.json", "side-by-side.png", "heatmap.png", "regions/button.png", "regions/retired.png", "regions/nested/retired.png"] {
assert!(!ws.path.join("diff").join(file).is_file(), "stale evidence: {file}");
}
}
#[test]
fn failed_preflight_clears_complete_evidence_for_both_gates() {
for responsive in [false, true] {
let (ws, mut state) = simple_hero_workspace();
let image = std::fs::read(ws.path.join("comp.png")).unwrap();
ws.write(".impeccable/review/desktop.png", &image);
ws.write(".impeccable/review/mobile.png", &image);
let run = |state: &mut Value| if responsive {
gate_responsive(&ws.io(), state, RESPONSIVE_MIN, "diff")
} else {
gate_hero(&ws.io(), state, "comp.png", HERO_MIN, "diff", Some("index.html"), &no_organic_scan)
};
assert!(run(&mut state).ok);
ws.write("diff/regions/retired.png", &image);
ws.write("diff/regions/nested/retired.png", &image);
ws.write("diff/notes.txt", b"keep unrelated files");
std::fs::remove_file(ws.path.join("comp.png")).unwrap();
assert!(!run(&mut state).ok);
assert_no_current_measurements(&ws);
assert_eq!(std::fs::read(ws.path.join("diff/notes.txt")).unwrap(), b"keep unrelated files");
}
}
#[test]
fn successful_repeat_removes_retired_region_crops() {
let (ws, mut state) = simple_hero_workspace();
ws.write("diff/regions/retired.png", b"old crop");
let gate = gate_hero(&ws.io(), &mut state, "comp.png", HERO_MIN, "diff", Some("index.html"), &no_organic_scan);
assert!(gate.ok, "{:?}", gate.reasons);
assert!(!ws.path.join("diff/regions/retired.png").exists());
assert!(ws.path.join("diff/regions/button.png").is_file());
}
#[cfg(unix)]
#[test]
fn artifact_cleanup_does_not_follow_region_directory_symlinks() {
let (ws, mut state) = simple_hero_workspace();
ws.write("elsewhere/keep.png", b"unrelated image");
std::fs::create_dir_all(ws.path.join("diff")).unwrap();
std::os::unix::fs::symlink(ws.path.join("elsewhere"), ws.path.join("diff/regions")).unwrap();
let gate = gate_hero(&ws.io(), &mut state, "comp.png", HERO_MIN, "diff", Some("index.html"), &no_organic_scan);
assert!(gate.ok, "{:?}", gate.reasons);
assert_eq!(std::fs::read(ws.path.join("elsewhere/keep.png")).unwrap(), b"unrelated image");
assert!(!ws.path.join("elsewhere/button.png").exists());
assert!(ws.path.join("diff/regions/button.png").is_file());
}
#[cfg(unix)]
#[test]
fn artifact_cleanup_failure_blocks_the_gate() {
use std::os::unix::fs::PermissionsExt;
let (ws, mut state) = simple_hero_workspace();
ws.write("diff/regions/retired.png", b"stale crop");
let dir = ws.path.join("diff/regions");
std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o555)).unwrap();
let gate = gate_hero(&ws.io(), &mut state, "comp.png", HERO_MIN, "diff", Some("index.html"), &no_organic_scan);
if dir.exists() { std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o755)).unwrap(); }
assert!(!gate.ok);
assert!(gate.reasons.iter().any(|r| r.contains("cannot clear comparison artifacts")), "{:?}", gate.reasons);
let report: Value = serde_json::from_slice(&std::fs::read(ws.path.join("diff/report.json")).unwrap()).unwrap();
assert_eq!(report["measurementsAvailable"], false);
assert_eq!(report["gate"]["ok"], false);
assert_no_current_measurements(&ws);
let quarantine = ws.path.join(report["artifactCleanup"]["quarantine"]["artifacts"]["regions"].as_str().expect("cleanup failure must identify quarantined evidence"));
assert!(quarantine.join("retired.png").is_file());
assert_eq!(report["artifactCleanup"]["quarantine"]["errors"], json!([]));
std::fs::set_permissions(quarantine, std::fs::Permissions::from_mode(0o755)).unwrap();
}
+29 -73
View File
@@ -284,13 +284,20 @@ pub fn resolve_regions(comp: &Image, spec: Option<&Value>) -> Vec<RegionBox> {
/// JS: regionCrop(img, r).
fn region_crop(img: &Image, rr: &RegionBox) -> Image {
// Sampling support must not add neighbouring elements to a declared region.
let bounds = r::clamp_rect(img, rr.x * img.width as f64, rr.y * img.height as f64,
rr.w * img.width as f64, rr.h * img.height as f64);
// A subpixel box still samples an actual pixel, including at the far edge.
r::crop(img, bounds.x.min(img.width.saturating_sub(1)) as f64,
bounds.y.min(img.height.saturating_sub(1)) as f64,
bounds.w.max(1) as f64, bounds.h.max(1) as f64)
let min_px = 48f64;
let mut x = rr.x * img.width as f64;
let mut y = rr.y * img.height as f64;
let mut w = rr.w * img.width as f64;
let mut h = rr.h * img.height as f64;
if h < min_px {
y -= (min_px - h) / 2.0;
h = min_px;
}
if w < min_px {
x -= (min_px - w) / 2.0;
w = min_px;
}
r::crop(img, x, y, w, h)
}
fn ink_box_json(b: &Option<InkBox>) -> Value {
@@ -500,7 +507,7 @@ fn render_heatmap(comp: &Image, build: &Image) -> Image {
}
/// JS: renderRegionPair(compCrop, buildCrop, id, score).
fn render_region_pair(comp_crop: &Image, build_crop: &Image, id: &str, score: &Score, verdict: &str, gate_region: Option<&Value>) -> Image {
fn render_region_pair(comp_crop: &Image, build_crop: &Image, id: &str, score: &Score) -> Image {
let gap = 16f64;
let pad = 12f64;
let max_w = 700f64;
@@ -513,16 +520,11 @@ fn render_region_pair(comp_crop: &Image, build_crop: &Image, id: &str, score: &S
);
r::blit(&mut out, &a, pad, pad + 30.0);
r::blit(&mut out, &b, pad + a.width as f64 + gap, pad + 30.0);
let v = verdict;
let gate_label = gate_region.map(|region| match region.get("blocking").and_then(Value::as_bool) {
Some(true) => " / BLOCKING",
Some(false) => " / NONBLOCKING",
None => " / CHECK GATE",
}).unwrap_or("");
let v = verdict_for(score, None);
r::draw_label(&mut out, &format!("{} COMP", id.to_uppercase()), pad, pad, [255.0, 255.0, 255.0, 255.0], [0.0, 0.0, 0.0, 220.0], 2.0, 4.0);
r::draw_label(
&mut out,
&format!("BUILD {} {}%{gate_label}", v.to_uppercase(), to_fixed(score.overall * 100.0, 0)),
&format!("BUILD {} {}%", v.to_uppercase(), to_fixed(score.overall * 100.0, 0)),
pad + a.width as f64 + gap,
pad,
[255.0, 255.0, 255.0, 255.0],
@@ -542,32 +544,24 @@ fn write_png(path: &Path, img: &Image) -> Result<(), String> {
}
/// JS: writeArtifacts(result, comp, outDir).
pub fn write_artifacts(result: &CompareResult, comp: &Image, out_dir: &Path) -> Result<Value, String> {
std::fs::create_dir_all(out_dir.join("regions")).map_err(|e| e.to_string())?;
pub fn write_artifacts(result: &CompareResult, comp: &Image, out_dir: &Path) -> Value {
let _ = std::fs::create_dir_all(out_dir.join("regions"));
let side = render_side_by_side(comp, &result.aligned, &result.label, &result.whole);
let side_path = out_dir.join("side-by-side.png");
write_png(&side_path, &side)?;
let _ = write_png(&side_path, &side);
let heat_path = out_dir.join("heatmap.png");
write_png(&heat_path, &render_heatmap(comp, &result.aligned))?;
let region_files = write_region_artifacts(result, out_dir, None)?;
Ok(json!({
"sideBySide": path_str(&side_path),
"heatmap": path_str(&heat_path),
"regionFiles": region_files,
}))
}
/// Refresh labels after gate interpretation, without rerunning measurements.
pub fn write_region_artifacts(result: &CompareResult, out_dir: &Path, gate_regions: Option<&[Value]>) -> Result<Vec<Value>, String> {
let _ = write_png(&heat_path, &render_heatmap(comp, &result.aligned));
let mut region_files: Vec<Value> = Vec::new();
for rg in &result.regions {
let file = out_dir.join("regions").join(format!("{}.png", rg.id));
let gate_region = gate_regions.and_then(|regions| regions.iter().find(|r| r.get("id").and_then(Value::as_str) == Some(rg.id.as_str())));
write_png(&file, &render_region_pair(&rg.a, &rg.b, &rg.id, &rg.score, &rg.verdict, gate_region))
.map_err(|e| format!("{}: {e}", file.display()))?;
let _ = write_png(&file, &render_region_pair(&rg.a, &rg.b, &rg.id, &rg.score));
region_files.push(json!(path_str(&file)));
}
Ok(region_files)
json!({
"sideBySide": path_str(&side_path),
"heatmap": path_str(&heat_path),
"regionFiles": region_files,
})
}
fn path_str(p: &Path) -> String {
@@ -745,10 +739,7 @@ pub fn run(argv: &[String], io: &mut Io) -> i32 {
let files = if flag(argv, "no-files") {
None
} else {
match write_artifacts(&result, &comp, &resolve(io, &out_dir)) {
Ok(files) => Some(files),
Err(e) => { io.err(&format!("comp-diff: cannot persist comparison artifacts: {e}\n")); return 1; }
}
Some(write_artifacts(&result, &comp, &resolve(io, &out_dir)))
};
let meta = json!({
"label": label,
@@ -761,9 +752,7 @@ pub fn run(argv: &[String], io: &mut Io) -> i32 {
let report = build_report(&result, files.as_ref(), &meta);
if files.is_some() {
let rp = resolve(io, &out_dir).join("report.json");
if let Err(e) = std::fs::write(&rp, util::json_pretty(&report)) {
io.err(&format!("comp-diff: cannot persist report: {e}\n")); return 1;
}
let _ = std::fs::write(&rp, util::json_pretty(&report));
}
if flag(argv, "json") {
io.out(&format!("{}\n", util::json_pretty(&report)));
@@ -784,36 +773,3 @@ pub fn run(argv: &[String], io: &mut Io) -> i32 {
}
0
}
#[cfg(test)]
mod region_isolation_regression {
use super::*;
#[test]
fn subpixel_regions_sample_real_pixels() {
let a = r::create_image(10, 10, [220, 10, 20, 255]);
let b = r::create_image(10, 10, [10, 30, 210, 255]);
for (x, y) in [(0.2, 0.2), (0.99, 0.99)] {
let rr = RegionBox { id: "tiny".into(), x, y, w:0.001, h:0.001, kind:None };
let ac = region_crop(&a, &rr);
let bc = region_crop(&b, &rr);
assert_eq!(&ac.data[..4], &[220,10,20,255]);
assert_eq!(&bc.data[..4], &[10,30,210,255]);
assert_ne!(ac.data, bc.data);
}
}
#[test]
fn small_region_does_not_sample_neighbours() {
let a = r::create_image(256, 128, [240, 220, 190, 255]);
let mut b = a.clone();
for y in 40..53 { for x in 60..180 {
let p = (y * 256 + x) * 4;
b.data[p..p+4].copy_from_slice(&[20,20,20,255]);
}}
let rr = RegionBox { id: "toolbar".into(), x:60.0/256.0, y:53.0/128.0,
w:120.0/256.0, h:22.0/128.0, kind:Some("chrome".into()) };
let ac = region_crop(&a, &rr);
let bc = region_crop(&b, &rr);
assert_eq!(ac.height, 22, "crop must honor the declared box");
assert_eq!(ac.data, bc.data, "neighbours cannot change the target pixels");
}
}
+90 -5
View File
@@ -20,8 +20,9 @@ use crate::checks::measures::{
};
use crate::checks::rules::{
check_borders, check_colors, check_glow, check_hero_eyebrow, check_icon_tile,
check_italic_serif, check_motion, is_emoji_only_text, BorderOpts, ColorOpts, GlowOpts,
HeroEyebrowOpts, IconTileOpts, ItalicSerifOpts, MotionOpts, RuleHit, Sides, HEADING_TAGS,
check_italic_serif, check_motion, check_placeholder_colors, is_emoji_only_text, BorderOpts,
ColorOpts, GlowOpts, HeroEyebrowOpts, IconTileOpts, ItalicSerifOpts, MotionOpts, RuleHit,
Sides, HEADING_TAGS,
};
use crate::checks::text_rules::{
CURSOR_FIRST_VIEWPORT_PX, CURSOR_GLYPH_RE, POSITIONED_CHILD_INTERACTIVE_SELECTOR,
@@ -459,8 +460,8 @@ pub fn check_element_colors_dom(dom: &dyn Dom, el: ElId) -> Vec<RuleHit> {
} else {
resolve_gradient_stops(dom, el)
};
check_colors(&ColorOpts {
tag,
let color_opts = ColorOpts {
tag: tag.clone(),
text_color: parse_rgb_or_any(&dom.style(el, "color")),
bg_color: own_bg,
effective_bg: if surface_unresolved {
@@ -477,7 +478,36 @@ pub fn check_element_colors_dom(dom: &dyn Dom, el: ElId) -> Vec<RuleHit> {
bg_image: Some(dom.style(el, "backgroundImage")),
class_list: Some(class_attr(dom, el)),
detector_is_browser: true,
})
};
let mut findings = check_colors(&color_opts);
if tag == "input" || tag == "textarea" {
let placeholder = dom.attr(el, "placeholder").unwrap_or_default();
let placeholder = js::trim(&placeholder);
if !placeholder.is_empty() {
let skip = if tag == "input" {
let t = js::to_lower_case(&dom.attr(el, "type").unwrap_or_else(|| "text".into()));
matches!(
t.as_str(),
"hidden" | "checkbox" | "radio" | "file" | "submit" | "button" | "image"
| "reset" | "range" | "color"
)
} else {
false
} || !matches_or_false(dom, el, ":placeholder-shown");
if !skip {
if let Some(ph_raw) = dom.pseudo_style(el, "::placeholder", "color") {
if let Some(ph_color) = parse_rgb_or_any(&ph_raw) {
findings.extend(check_placeholder_colors(
&color_opts,
placeholder,
ph_color,
));
}
}
}
}
}
findings
}
// ── icon tile / italic serif / hero eyebrow ───────────────────────────────
@@ -1378,6 +1408,61 @@ mod tests {
assert!(check_element_pseudo_stripe_dom(&d, card).is_empty());
}
#[test]
fn placeholder_low_contrast_flags() {
let (mut d, body) = page();
let input = d.add(Some(body), "input");
visible(&mut d, input);
d.set_attr(input, "placeholder", "Pale Placeholder On White Field");
d.set_rect(input, 0.0, 0.0, 200.0, 40.0);
d.set_styles(
input,
&[
("backgroundColor", "rgb(255, 255, 255)"),
("color", "rgb(0, 0, 0)"),
("fontSize", "16px"),
("fontWeight", "400"),
("webkitBackgroundClip", "border-box"),
],
);
d.set_pseudo_style(input, "::placeholder", "color", "rgb(187, 187, 187)");
d.add_selector(input, ":placeholder-shown");
let hits = check_element_colors_dom(&d, input);
assert!(
hits.iter().any(|h| {
h.id == "low-contrast"
&& h.snippet.contains("placeholder \"Pale Placeholder On White Field\"")
}),
"{hits:?}"
);
}
#[test]
fn placeholder_skips_when_not_shown() {
let (mut d, body) = page();
let input = d.add(Some(body), "input");
visible(&mut d, input);
d.set_attr(input, "placeholder", "Pale Placeholder On White Field");
d.set_attr(input, "value", "");
d.set_rect(input, 0.0, 0.0, 200.0, 40.0);
d.set_styles(
input,
&[
("backgroundColor", "rgb(255, 255, 255)"),
("color", "rgb(0, 0, 0)"),
("fontSize", "16px"),
("fontWeight", "400"),
("webkitBackgroundClip", "border-box"),
],
);
d.set_pseudo_style(input, "::placeholder", "color", "rgb(187, 187, 187)");
let hits = check_element_colors_dom(&d, input);
assert!(
hits.iter().all(|h| h.id != "low-contrast"),
"live filled field must not score a hidden placeholder, {hits:?}"
);
}
#[test]
fn colors_low_contrast_on_resolved_surface_and_pseudo_surface() {
let (mut d, body) = page();
+163 -70
View File
@@ -4,7 +4,8 @@
//! `undefined` / `null` distinctions the source relies on.
use crate::color::{
color_to_hex, contrast_ratio, get_hue, has_chroma, is_neutral_color, relative_luminance, Rgba,
color_to_hex, composite_color_over, contrast_ratio, get_hue, has_chroma, is_neutral_color,
relative_luminance, Rgba,
};
use crate::constants::{
BORDER_SAFE_TAGS, GENERIC_FONTS, KNOWN_SERIF_FONTS, SAFE_TAGS, WCAG_LARGE_BOLD_TEXT_PX,
@@ -155,75 +156,10 @@ pub fn check_colors(opts: &ColorOpts) -> Vec<RuleHit> {
if opts.has_direct_text && opts.text_color.is_some() && !opts.is_emoji_only {
let text_color = opts.text_color.unwrap();
let is_gradient_clipped_text = bg_clip == "text";
let bgs: Option<Vec<Rgba>> = if is_gradient_clipped_text {
None
} else if let Some(bg) = opts.effective_bg {
Some(vec![bg])
} else {
match &opts.effective_bg_stops {
Some(stops) if !stops.is_empty() => Some(stops.clone()),
_ => None,
}
};
if let Some(bgs) = bgs {
let text_lum = relative_luminance(&text_color);
let is_gray =
!has_chroma(Some(&text_color), Some(20.0)) && text_lum > 0.05 && text_lum < 0.85;
if is_gray && bgs.iter().all(|b| has_chroma(Some(b), Some(40.0))) {
let bg_label = match opts.effective_bg {
Some(bg) => color_to_hex(Some(&bg)),
None => format!(
"gradient({})",
bgs.iter()
.map(|b| color_to_hex(Some(b)))
.collect::<Vec<_>>()
.join(", ")
),
};
findings.push(RuleHit::new(
"gray-on-color",
format!(
"text {} on bg {}",
color_to_hex(Some(&text_color)),
bg_label
),
));
}
let ratios: Vec<f64> = bgs.iter().map(|b| contrast_ratio(&text_color, b)).collect();
let mut worst_idx = 0usize;
for i in 1..ratios.len() {
if ratios[i] < ratios[worst_idx] {
worst_idx = i;
}
}
let ratio = ratios[worst_idx];
let is_large_text = opts.font_size >= WCAG_LARGE_TEXT_PX
|| (opts.font_size >= WCAG_LARGE_BOLD_TEXT_PX && opts.font_weight >= 700.0);
let threshold = if is_large_text { 3.0 } else { 4.5 };
if ratio < threshold {
let is_alpha_fallback_fp = !opts.detector_is_browser
&& opts.effective_bg.is_none()
&& text_color.a.map_or(false, |a| a < 1.0);
if !is_alpha_fallback_fp {
let ratio_label = if to_fixed(ratio, 1) == to_fixed(threshold, 1) {
to_fixed(ratio, 2)
} else {
to_fixed(ratio, 1)
};
findings.push(RuleHit::new(
"low-contrast",
format!(
"{}:1 (need {}:1) — text {} on {}",
ratio_label,
number_to_string(threshold),
color_to_hex(Some(&text_color)),
color_to_hex(Some(&bgs[worst_idx]))
),
));
}
}
// Gradient-clipped text paints the gradient, not `color`, so there
// is no background to score it against.
if bg_clip != "text" {
findings.extend(contrast_findings(opts, &text_color));
}
if has_chroma(Some(&text_color), Some(50.0)) {
@@ -281,6 +217,119 @@ pub fn check_colors(opts: &ColorOpts) -> Vec<RuleHit> {
findings
}
/// The contrast scoring `check_colors` and `check_placeholder_colors`
/// share: gray-on-color, then WCAG AA against the worst background. The
/// backgrounds are the composited `effective_bg`, or the gradient stops when
/// no opaque surface resolved; with neither there is nothing to score.
fn contrast_findings(opts: &ColorOpts, text_color: &Rgba) -> Vec<RuleHit> {
let bgs: Vec<Rgba> = if let Some(bg) = opts.effective_bg {
vec![bg]
} else {
match &opts.effective_bg_stops {
Some(stops) if !stops.is_empty() => stops.clone(),
_ => return Vec::new(),
}
};
let mut findings = Vec::new();
let text_lum = relative_luminance(text_color);
let is_gray = !has_chroma(Some(text_color), Some(20.0)) && text_lum > 0.05 && text_lum < 0.85;
if is_gray && bgs.iter().all(|b| has_chroma(Some(b), Some(40.0))) {
let bg_label = match opts.effective_bg {
Some(bg) => color_to_hex(Some(&bg)),
None => format!(
"gradient({})",
bgs.iter()
.map(|b| color_to_hex(Some(b)))
.collect::<Vec<_>>()
.join(", ")
),
};
findings.push(RuleHit::new(
"gray-on-color",
format!("text {} on bg {}", color_to_hex(Some(text_color)), bg_label),
));
}
let ratios: Vec<f64> = bgs.iter().map(|b| contrast_ratio(text_color, b)).collect();
let mut worst_idx = 0usize;
for i in 1..ratios.len() {
if ratios[i] < ratios[worst_idx] {
worst_idx = i;
}
}
let ratio = ratios[worst_idx];
let is_large_text = opts.font_size >= WCAG_LARGE_TEXT_PX
|| (opts.font_size >= WCAG_LARGE_BOLD_TEXT_PX && opts.font_weight >= 700.0);
let threshold = if is_large_text { 3.0 } else { 4.5 };
if ratio < threshold {
let is_alpha_fallback_fp = !opts.detector_is_browser
&& opts.effective_bg.is_none()
&& text_color.a.map_or(false, |a| a < 1.0);
if !is_alpha_fallback_fp {
let ratio_label = if to_fixed(ratio, 1) == to_fixed(threshold, 1) {
to_fixed(ratio, 2)
} else {
to_fixed(ratio, 1)
};
findings.push(RuleHit::new(
"low-contrast",
format!(
"{}:1 (need {}:1) — text {} on {}",
ratio_label,
number_to_string(threshold),
color_to_hex(Some(text_color)),
color_to_hex(Some(&bgs[worst_idx]))
),
));
}
}
findings
}
/// Placeholder text contrast, sibling of `check_hover_contrast`. Skips the
/// SAFE_TAGS gate and the host heuristics in `check_colors` (class list,
/// clip, gradient) because the host is an empty control; only the
/// placeholder glyphs are scored. A translucent placeholder is flattened
/// over the composited background first, including each gradient stop when
/// no opaque surface resolved. Snippets carry the placeholder string so
/// fixture tests can key on it.
pub fn check_placeholder_colors(
opts: &ColorOpts,
placeholder_text: &str,
mut text_color: Rgba,
) -> Vec<RuleHit> {
let mut flat: Option<ColorOpts> = None;
if text_color.a.map_or(false, |a| a < 1.0) {
if let Some(bg) = opts.effective_bg {
text_color = composite_color_over(&text_color, &bg);
} else if let Some(stops) = opts.effective_bg_stops.as_ref().filter(|s| !s.is_empty()) {
let mut worst_i = 0usize;
let mut worst_ratio = f64::MAX;
let mut worst_fg = text_color;
for (i, stop) in stops.iter().enumerate() {
let fg = composite_color_over(&text_color, stop);
let r = contrast_ratio(&fg, stop);
if r < worst_ratio {
worst_ratio = r;
worst_i = i;
worst_fg = fg;
}
}
text_color = worst_fg;
let mut o = opts.clone();
o.effective_bg = Some(stops[worst_i]);
o.effective_bg_stops = None;
flat = Some(o);
}
}
let opts = flat.as_ref().unwrap_or(opts);
let mut findings = contrast_findings(opts, &text_color);
for h in &mut findings {
h.snippet = format!("placeholder \"{}\" {}", placeholder_text, h.snippet);
}
findings
}
/// JS: checks.mjs#checkHoverContrast
pub fn check_hover_contrast(opts: &HoverContrastOpts) -> Vec<RuleHit> {
if !opts.has_direct_text || opts.is_emoji_only || opts.text_color.is_none() || opts.bg.is_none()
@@ -1044,6 +1093,50 @@ mod tests {
);
}
#[test]
fn placeholder_colors_ignore_host_class_heuristics() {
let opts = ColorOpts {
tag: "input".to_string(),
effective_bg: Some(Rgba::new(255.0, 255.0, 255.0, 1.0)),
font_size: 24.0,
font_weight: 400.0,
class_list: Some("text-slate-300 bg-red-500".to_string()),
bg_clip: Some("text".to_string()),
bg_image: Some("linear-gradient(red, blue)".to_string()),
..Default::default()
};
let ink = check_placeholder_colors(&opts, "Name", Rgba::new(26.0, 26.0, 26.0, 1.0));
assert!(ink.is_empty(), "{ink:?}");
let pale = check_placeholder_colors(&opts, "Name", Rgba::new(187.0, 187.0, 187.0, 1.0));
assert_eq!(pale.len(), 1);
assert_eq!(pale[0].id, "low-contrast");
assert!(pale[0].snippet.contains("placeholder \"Name\""), "{pale:?}");
// No resolved surface and no gradient stops: nothing to score.
let unresolved = ColorOpts {
effective_bg: None,
effective_bg_stops: None,
..opts.clone()
};
let none = check_placeholder_colors(&unresolved, "Name", Rgba::new(187.0, 187.0, 187.0, 1.0));
assert!(none.is_empty(), "{none:?}");
// Translucent black over a light gradient: flatten per stop, then score.
let gradient = ColorOpts {
effective_bg: None,
effective_bg_stops: Some(vec![
Rgba::new(255.0, 255.0, 255.0, 1.0),
Rgba::new(240.0, 240.0, 240.0, 1.0),
]),
..opts.clone()
};
let wash = check_placeholder_colors(
&gradient,
"Name",
Rgba::new(0.0, 0.0, 0.0, 0.2),
);
assert_eq!(wash.len(), 1, "{wash:?}");
assert_eq!(wash[0].id, "low-contrast");
}
#[test]
fn heading_tags_and_card_like() {
assert!(is_heading_tag("h4"));
+33 -2
View File
@@ -12,8 +12,9 @@
//! element tree in document order (child nodes with their text, so
//! `textContent` and the direct text nodes come out byte-equal), attributes,
//! the computed-style properties the rules read (`STYLE_PROPS`, interned
//! values), `::before` / `::after` styles where `content` is set, bounding
//! rects, the client/scroll/offset metrics, `checkVisibility`, direct-text
//! values), `::before` / `::after` styles where `content` is set,
//! `::placeholder` `color` on text controls, bounding rects, the
//! client/scroll/offset metrics, `checkVisibility`, direct-text
//! rects, viewport and scroll, hostname, quirks mode, `body.innerText`, the
//! `@keyframes` rules, the document HTML for the regex pass, and the media
//! intrinsics the visual-contrast path needs.
@@ -253,6 +254,10 @@ pub struct SnapNode {
pub before: Option<Vec<u32>>,
#[serde(rename = "f", default)]
pub after: Option<Vec<u32>>,
/// Interned `getComputedStyle(el, '::placeholder').color` when the
/// element has a non-empty `placeholder` attribute.
#[serde(rename = "ph", default)]
pub placeholder_color: Option<u32>,
/// `getBoundingClientRect` as `[x, y, width, height]`; `None` when the
/// element has no such method.
#[serde(rename = "r", default)]
@@ -824,6 +829,11 @@ impl Dom for SnapshotDom {
}
fn pseudo_style(&self, el: ElId, pseudo: &str, prop: &str) -> Option<String> {
let n = self.snap.node(el);
if pseudo == "::placeholder" && prop == "color" {
return n
.placeholder_color
.and_then(|idx| self.snap.strings.get(idx as usize).cloned());
}
let vals = match pseudo {
"::before" | ":before" => n.before.as_ref(),
"::after" | ":after" => n.after.as_ref(),
@@ -999,6 +1009,27 @@ mod tests {
assert!(d.offset_width(6).is_nan());
}
#[test]
fn placeholder_color_is_readable_as_pseudo_style() {
let json = r#"{
"v": 1, "hostname": "example.test", "innerWidth": 1280, "innerHeight": 800,
"styleProps": ["display", "color"], "pseudoProps": ["content"],
"strings": ["block", "rgb(0, 0, 0)", "rgb(187, 187, 187)"],
"documentElement": 1, "body": 2,
"els": [
{"t":"HTML","c":[2],"s":[0,1],"r":[0,0,1280,800]},
{"t":"BODY","p":1,"c":[3],"s":[0,1]},
{"t":"INPUT","p":2,"c":[],"a":[["placeholder","Jane"]],"s":[0,1],"ph":2}
]
}"#;
let d = snap(json);
assert_eq!(
d.pseudo_style(3, "::placeholder", "color").as_deref(),
Some("rgb(187, 187, 187)")
);
assert_eq!(d.pseudo_style(2, "::placeholder", "color"), None);
}
#[test]
fn selectors_over_snapshot() {
let d = snap(SMALL);
+41 -5
View File
@@ -20,9 +20,9 @@ use impeccable_core::checks::measures::{
use impeccable_core::checks::rules::{
check_borders, check_colors, check_glow, check_hero_eyebrow, check_hover_contrast,
check_icon_tile, check_italic_serif, check_kicker_above_heading, check_motion,
is_emoji_only_text, is_heading_tag, resolve_hero_heading_size_px, BorderOpts, ColorOpts,
GlowOpts, HeroEyebrowOpts, HoverContrastOpts, IconTileOpts, ItalicSerifOpts, KickerCandidate,
MotionOpts, RuleHit, Sides,
check_placeholder_colors, is_emoji_only_text, is_heading_tag, resolve_hero_heading_size_px,
BorderOpts, ColorOpts, GlowOpts, HeroEyebrowOpts, HoverContrastOpts, IconTileOpts,
ItalicSerifOpts, KickerCandidate, MotionOpts, RuleHit, Sides,
};
use impeccable_core::checks::text_rules::{
check_numbered_section_labels, is_kicker_candidate, is_numbered_section_label_candidate,
@@ -539,7 +539,7 @@ pub fn check_element_colors(
sv(style, "backgroundClip")
}
};
check_colors(&ColorOpts {
let color_opts = ColorOpts {
tag: tag.to_string(),
text_color,
bg_color: own_bg,
@@ -557,7 +557,43 @@ pub fn check_element_colors(
bg_image: Some(sv(style, "backgroundImage").to_string()),
class_list: Some(el.class_name().to_string()),
detector_is_browser: false,
})
};
let mut findings = check_colors(&color_opts);
if tag == "input" || tag == "textarea" {
let placeholder = el.get_attribute("placeholder").unwrap_or("").trim();
if !placeholder.is_empty() {
let skip = if tag == "input" {
let t = js::to_lower_case(el.get_attribute("type").unwrap_or("text"));
matches!(
t.as_str(),
"hidden" | "checkbox" | "radio" | "file" | "submit" | "button" | "image"
| "reset" | "range" | "color"
) || el
.get_attribute("value")
.is_some_and(|v| !js::trim(v).is_empty())
} else {
!js::trim(&direct_text).is_empty()
};
if !skip {
if let Some(ph_style) = el.doc.get_placeholder_style(el.id()) {
let ph_color = custom_props
.and_then(|m| {
measures::parse_color_resolved(sv_opt(ph_style, "color"), Some(m))
})
.or_else(|| parse_rgb(sv_opt(ph_style, "color")))
.or_else(|| parse_any_color(sv_opt(ph_style, "color")));
if let Some(ph_color) = ph_color {
findings.extend(check_placeholder_colors(
&color_opts,
placeholder,
ph_color,
));
}
}
}
}
}
findings
}
/// JS: checks.mjs#checkElementHoverContrast(el, style, tag, window)
+62 -2
View File
@@ -139,6 +139,31 @@ static PSEUDO_RULE_RE: Lazy<Regex> = Lazy::new(|| {
))
.expect("PSEUDO_RULE_RE")
});
static PLACEHOLDER_RULE_RE: Lazy<Regex> = Lazy::new(|| {
Regex::new(r"(?i)^(.*)(?:::placeholder|::?-webkit-input-placeholder|::?-moz-placeholder)$")
.expect("PLACEHOLDER_RULE_RE")
});
fn placeholder_host_selector(selector: &str) -> Option<String> {
let pm = PLACEHOLDER_RULE_RE.captures(selector)?;
let captured = pm.get(1).map(|m| m.as_str()).unwrap_or("");
let trimmed_end = captured.trim_end_matches(|c: char| js::is_js_whitespace(c));
if trimmed_end.is_empty() {
return Some("*".to_string());
}
// Only fill a trailing empty compound. `star_empty_compounds` would
// rewrite `.label + ::placeholder` to `.label *+*`.
let last = trimmed_end.chars().last().unwrap();
if captured.len() != trimmed_end.len() || last == '>' || last == '+' || last == '~' {
if last == '>' || last == '+' || last == '~' {
Some(format!("{}*", trimmed_end))
} else {
Some(format!("{} *", trimmed_end))
}
} else {
Some(trimmed_end.to_string())
}
}
static COLOR_TOKEN_RE: Lazy<Regex> = Lazy::new(|| {
Regex::new(r"(?i)(?:rgba?|hsla?|oklch|oklab|lab|lch|hwb|color-mix)\([^)]*(?:\([^)]*\))?[^)]*\)|#[0-9a-f]{3,8}(?-u:\b)")
.expect("COLOR_TOKEN_RE")
@@ -252,6 +277,7 @@ pub fn build_static_style_map(
) {
let mut specified: SpecifiedStore<NodeId> = SpecifiedStore::new();
let mut hover_specified: SpecifiedStore<NodeId> = SpecifiedStore::new();
let mut placeholder_specified: SpecifiedStore<NodeId> = SpecifiedStore::new();
let root_custom_props = collect_css_custom_props(css_text);
let rules = profile::step(
profile,
@@ -264,7 +290,12 @@ pub fn build_static_style_map(
Meta::new("selector-match", "css-selectors", file_path),
|| {
for rule in &rules {
if !rule.is_hover {
let placeholder_host = if rule.is_hover {
None
} else {
placeholder_host_selector(&rule.selector)
};
if !rule.is_hover && placeholder_host.is_none() {
if let Some(pm) = PSEUDO_RULE_RE.captures(&rule.selector) {
let base = pm.get(1).map(|m| m.as_str()).unwrap_or("").to_string();
mark_pseudo_rule(doc, rule, &base, &root_custom_props);
@@ -273,6 +304,8 @@ pub fn build_static_style_map(
}
let match_selector: Option<&str> = if rule.is_hover {
rule.match_selector.as_deref()
} else if let Some(ref host) = placeholder_host {
Some(host.as_str())
} else {
Some(rule.selector.as_str())
};
@@ -296,11 +329,18 @@ pub fn build_static_style_map(
};
let store = if rule.is_hover {
&mut hover_specified
} else if placeholder_host.is_some() {
&mut placeholder_specified
} else {
&mut specified
};
for node in matched {
for decl in &rule.declarations {
if placeholder_host.is_some()
&& js::to_lower_case(&decl.prop) != "color"
{
continue;
}
let meta = DeclMeta {
important: decl.important,
specificity: rule.specificity,
@@ -341,7 +381,7 @@ pub fn build_static_style_map(
profile,
Meta::new("cascade", "compute-styles", file_path),
|| {
compute_styles(doc, &specified, &hover_specified);
compute_styles(doc, &specified, &hover_specified, &placeholder_specified);
},
);
}
@@ -353,6 +393,7 @@ fn compute_styles(
doc: &mut StaticDocument,
specified: &SpecifiedStore<NodeId>,
hover_specified: &SpecifiedStore<NodeId>,
placeholder_specified: &SpecifiedStore<NodeId>,
) {
let mut computed: HashMap<NodeId, Rc<StyleValues>> = HashMap::new();
let mut customs: HashMap<NodeId, Rc<CustomProps>> = HashMap::new();
@@ -368,6 +409,7 @@ fn compute_styles(
.map(|e| (e.id(), None))
.collect();
let mut hover_out: Vec<(NodeId, StyleValues)> = Vec::new();
let mut placeholder_out: Vec<(NodeId, StyleValues)> = Vec::new();
while let Some((node, parent)) = stack.pop() {
let parent_style: Option<Rc<StyleValues>> = parent.and_then(|p| computed.get(&p).cloned());
@@ -439,6 +481,21 @@ fn compute_styles(
}
}
if let Some(ph_map) = placeholder_specified.get(&node) {
if let Some(color_decl) = ph_map.get("color") {
let next = normalize_static_css_value(
"color",
&color_decl.value,
&custom_props,
Some(&values),
Some(&values),
);
let mut ph_style = StyleValues::default();
ph_style.insert("color".to_string(), next);
placeholder_out.push((node, ph_style));
}
}
let style_rc = Rc::new(values);
computed.insert(node, style_rc);
customs.insert(node, Rc::new(custom_props));
@@ -460,6 +517,9 @@ fn compute_styles(
for (node, style) in hover_out {
doc.set_hover_style(node, style);
}
for (node, style) in placeholder_out {
doc.set_placeholder_style(node, style);
}
}
/// `STATIC_DEFAULT_STYLE[prop]` lookup re-exported for the adapters.
+8
View File
@@ -55,6 +55,7 @@ pub struct StaticDocument {
pub html: Html,
styles: HashMap<NodeId, StyleValues>,
hover_styles: HashMap<NodeId, StyleValues>,
placeholder_styles: HashMap<NodeId, StyleValues>,
accent_dash: HashSet<NodeId>,
pseudo_surface: HashMap<NodeId, Rgba>,
selector_cache: RefCell<HashMap<String, Result<Selector, SelectorError>>>,
@@ -174,6 +175,7 @@ impl StaticDocument {
html,
styles: HashMap::new(),
hover_styles: HashMap::new(),
placeholder_styles: HashMap::new(),
accent_dash: HashSet::new(),
pseudo_surface: HashMap::new(),
selector_cache: RefCell::new(HashMap::new()),
@@ -319,6 +321,12 @@ impl StaticDocument {
pub fn get_hover_style(&self, node: NodeId) -> Option<&StyleValues> {
self.hover_styles.get(&node)
}
pub fn set_placeholder_style(&mut self, node: NodeId, style: StyleValues) {
self.placeholder_styles.insert(node, style);
}
pub fn get_placeholder_style(&self, node: NodeId) -> Option<&StyleValues> {
self.placeholder_styles.get(&node)
}
pub fn set_accent_dash_pseudo(&mut self, node: NodeId) {
self.accent_dash.insert(node);
}
+122
View File
@@ -0,0 +1,122 @@
//! Integration tests for `::placeholder` contrast detection (#790).
use impeccable_html::{detect_html_source, DetectHtmlOptions};
use std::path::Path;
const ISSUER_REPRO: &str = r#"<!DOCTYPE html>
<html><head><style>
input::placeholder { color: #bbbbbb; }
input { background: white; font-size: 16px; width: 200px; height: 40px; border: 1px solid #ccc; padding: 8px; box-sizing: border-box; }
</style></head>
<body><input placeholder="Search"></body></html>
"#;
fn scan(html: &str) -> Vec<impeccable_core::findings::Finding> {
detect_html_source(html, Path::new("/tmp/placeholder.html"), &DetectHtmlOptions::default())
}
fn repo_root() -> std::path::PathBuf {
std::env::var("IMPECCABLE_PUBLIC_REPO")
.map(std::path::PathBuf::from)
.unwrap_or_else(|_| Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."))
}
#[test]
fn issuer_repro_flags_pale_placeholder() {
let findings = scan(ISSUER_REPRO);
assert!(
findings.iter().any(|f| f.antipattern == "low-contrast"),
"expected low-contrast finding, got {findings:?}"
);
}
#[test]
fn bare_placeholder_selector_flags() {
let html = r#"<!DOCTYPE html>
<html><head><style>
::placeholder { color: #bbbbbb; }
input { background: white; font-size: 16px; width: 200px; height: 40px; }
</style></head>
<body><input placeholder="Search"></body></html>
"#;
let findings = scan(html);
assert!(
findings.iter().any(|f| f.antipattern == "low-contrast"),
"expected low-contrast for bare ::placeholder, got {findings:?}"
);
}
#[test]
fn descendant_placeholder_selector_flags() {
let html = r#"<!DOCTYPE html>
<html><head><style>
.form ::placeholder { color: #bbbbbb; }
input { background: white; font-size: 16px; width: 200px; height: 40px; }
</style></head>
<body><div class="form"><input placeholder="Search"></div></body></html>
"#;
let findings = scan(html);
assert!(
findings.iter().any(|f| f.antipattern == "low-contrast"),
"expected low-contrast for descendant ::placeholder, got {findings:?}"
);
}
#[test]
fn sibling_placeholder_selector_flags() {
let html = r#"<!DOCTYPE html>
<html><head><style>
.label + ::placeholder { color: #bbbbbb; }
input { background: white; font-size: 16px; width: 200px; height: 40px; }
</style></head>
<body><label class="label">Name</label><input placeholder="Search"></body></html>
"#;
let findings = scan(html);
assert!(
findings.iter().any(|f| f.antipattern == "low-contrast"),
"expected low-contrast for sibling ::placeholder, got {findings:?}"
);
}
#[test]
fn fixture_flag_and_pass_cases() {
let fixture = repo_root().join("tests/fixtures/antipatterns/placeholder-contrast.html");
assert!(
fixture.is_file(),
"missing fixture at {}",
fixture.display()
);
let html = std::fs::read_to_string(&fixture).unwrap();
let findings = detect_html_source(&html, &fixture, &DetectHtmlOptions::default());
let ids: Vec<&str> = findings.iter().map(|f| f.antipattern.as_str()).collect();
let snippets: Vec<&str> = findings.iter().map(|f| f.snippet.as_str()).collect();
for needle in [
"Pale Placeholder On White Field",
"Pale Placeholder On White Textarea",
"Translucent Placeholder On Light Field",
"Pale Placeholder On Frosted Panel",
] {
assert!(
snippets.iter().any(|s| s.contains(needle)),
"expected flag for placeholder {needle:?}, findings={findings:?}"
);
}
for needle in [
"Ink Placeholder On White Field",
"Light Placeholder On Dark Field",
"Filled Field Hides Placeholder",
"Unstyled Placeholder Uses UA Color",
] {
assert!(
!snippets.iter().any(|s| s.contains(needle)),
"pass case {needle:?} should not flag, findings={findings:?}"
);
}
assert!(
ids.iter().filter(|id| **id == "low-contrast").count() >= 4,
"expected at least four low-contrast hits, got {findings:?}"
);
}
File diff suppressed because one or more lines are too long
+55 -1
View File
@@ -596,6 +596,18 @@ fn write_carbonize_banner(event: &Map<String, Value>, io: &mut Io) {
}
}
fn reply_ack_json(reply: &Reply) -> Value {
let mut m = Map::new();
m.insert("ok".into(), json!(true));
m.insert("id".into(), json!(reply.id));
m.insert("status".into(), json!(reply.ty));
if let Some(f) = &reply.file {
m.insert("file".into(), json!(f));
}
m.insert("_instructions".into(), json!("Poll again now."));
Value::Object(m)
}
/// JS: printPollEvent(event) — a wire-supplied `_instructions` must never
/// win over the locally generated one (#488).
fn print_poll_event(event: &mut Value, io: &mut Io) {
@@ -718,7 +730,13 @@ pub fn run(args: &[String], io: &mut Io) -> i32 {
}
};
return match post_reply(&base, &token, &reply) {
Ok(()) => 0,
Ok(()) => {
println(
io,
&serde_json::to_string(&reply_ack_json(&reply)).unwrap_or_default(),
);
0
}
Err(PollError::ConnRefused) => {
io.err(&format!(
"Live server not running. Start one with: {}\n",
@@ -839,4 +857,40 @@ mod tests {
}));
assert!(parsed.get("_instructions").is_none(), "{}", parsed);
}
#[test]
fn reply_ack_json_includes_file_when_present() {
let reply = Reply {
id: "ab12cd34".into(),
ty: "done".into(),
message: None,
file: Some("index.html".into()),
data: None,
source_event_type: None,
};
let parsed = reply_ack_json(&reply);
assert_eq!(parsed["ok"], json!(true));
assert_eq!(parsed["id"], json!("ab12cd34"));
assert_eq!(parsed["status"], json!("done"));
assert_eq!(parsed["file"], json!("index.html"));
assert_eq!(parsed["_instructions"], json!("Poll again now."));
}
#[test]
fn reply_ack_json_omits_file_when_absent() {
let reply = Reply {
id: "ab12cd34".into(),
ty: "steer_done".into(),
message: None,
file: None,
data: None,
source_event_type: None,
};
let parsed = reply_ack_json(&reply);
assert_eq!(parsed["ok"], json!(true));
assert_eq!(parsed["id"], json!("ab12cd34"));
assert_eq!(parsed["status"], json!("steer_done"));
assert!(parsed.get("file").is_none(), "{}", parsed);
assert_eq!(parsed["_instructions"], json!("Poll again now."));
}
}
+72
View File
@@ -418,6 +418,16 @@ fn push_diag(next: &mut Map<String, Value>, d: Value) {
next.insert("diagnostics".to_string(), Value::Array(arr));
}
fn drop_diag(next: &mut Map<String, Value>, error: &str) {
let mut arr = next
.get("diagnostics")
.and_then(|v| v.as_array())
.cloned()
.unwrap_or_default();
arr.retain(|d| d.get("error").and_then(|e| e.as_str()) != Some(error));
next.insert("diagnostics".to_string(), Value::Array(arr));
}
/// JS: applyEvent(snapshot, entry)
pub fn apply_event(snapshot: &Map<String, Value>, entry: &Value) -> Map<String, Value> {
let event: Map<String, Value> = match entry.get("event") {
@@ -864,6 +874,7 @@ pub fn apply_event(snapshot: &Map<String, Value>, entry: &Value) -> Map<String,
set!("phase", json!("discarded"));
set!("pendingEventSeq", Value::Null);
set!("pendingEvent", Value::Null);
drop_diag(&mut next, "carbonize_cleanup_required");
}
"complete" => {
set!("phase", json!("completed"));
@@ -876,6 +887,7 @@ pub fn apply_event(snapshot: &Map<String, Value>, entry: &Value) -> Map<String,
set_if!("previewMode", ev("previewMode"));
set!("pendingEventSeq", Value::Null);
set!("pendingEvent", Value::Null);
drop_diag(&mut next, "carbonize_cleanup_required");
}
"agent_error" => {
if canceled && ev("sourceEventType").and_then(|v| v.as_str()) == Some("generate") {
@@ -925,3 +937,63 @@ fn write_snapshot(path: &str, snapshot: &Map<String, Value>, journal_bytes: i64,
pub fn get_str<'a>(m: &'a Map<String, Value>, k: &str) -> Option<&'a str> {
get(m, k).and_then(|v| v.as_str())
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn journal_entry(seq: i64, event: Value) -> Value {
json!({ "seq": seq, "ts": "2026-01-01T00:00:00.000Z", "event": event })
}
fn has_diag(snapshot: &Map<String, Value>, error: &str) -> bool {
snapshot
.get("diagnostics")
.and_then(|v| v.as_array())
.map(|a| {
a.iter()
.any(|d| d.get("error").and_then(|e| e.as_str()) == Some(error))
})
.unwrap_or(false)
}
fn replay(id: &str, events: &[Value]) -> Map<String, Value> {
let mut snap = base_snapshot(id);
for entry in events {
snap = apply_event(&snap, entry);
}
snap
}
fn accept_carbonize_done(id: &str, terminal: &str) -> Map<String, Value> {
replay(
id,
&[
journal_entry(
1,
json!({ "id": id, "type": "accept", "variantId": 2 }),
),
journal_entry(
2,
json!({ "id": id, "type": "agent_done", "carbonize": true, "file": "index.html" }),
),
journal_entry(3, json!({ "id": id, "type": terminal })),
],
)
}
#[test]
fn complete_drops_carbonize_cleanup_required() {
let snap = accept_carbonize_done("ab12cd34", "complete");
assert_eq!(snap.get("phase").and_then(|p| p.as_str()), Some("completed"));
assert!(!has_diag(&snap, "carbonize_cleanup_required"));
}
#[test]
fn discarded_drops_carbonize_cleanup_required() {
let snap = accept_carbonize_done("ab12cd34", "discarded");
assert_eq!(snap.get("phase").and_then(|p| p.as_str()), Some("discarded"));
assert!(!has_diag(&snap, "carbonize_cleanup_required"));
}
}
@@ -188,6 +188,12 @@ Test thoroughly across contexts:
- **Edge cases**: Very small screens (320px), very large screens (4K)
- **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.
---
@@ -48,11 +48,12 @@ Run comprehensive checks across 5 dimensions. Score each dimension 0-4 using the
**Check for**:
- **Fixed widths**: Hard-coded widths that break on mobile
- **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
- **Text scaling**: Layouts that break when text size increases
- **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)
**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)
### 5. Implementation Integrity (CRITICAL)
@@ -205,6 +205,11 @@ t('items', { count }) // Handles complex plural rules
- Optimistic updates with rollback
- 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**:
- No permission to view
- No permission to edit
@@ -304,6 +309,7 @@ const throttledScroll = throttle(handleScroll, 100);
- Unit tests for edge cases
- Integration tests for error scenarios
- 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
- Accessibility tests (axe, WAVE)
@@ -330,7 +336,10 @@ Test thoroughly with edge cases:
- **Network issues**: Disable internet, throttle connection
- **Large datasets**: Test with 1000+ items
- **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
- **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.
+1 -1
View File
@@ -1740,7 +1740,7 @@ Conventions: every script's "run directly" guard is `process.argv[1]` ending wit
#### `live-poll.mjs` -> `impeccable poll`
- Invoked from live.md poll loop; `--reply` forms quoted in `_instructions` (see instructions.mjs strings in 6.3/below).
- Args: `--stream`, `--timeout=MS` (one-shot total, default 600000), `--types=A,B`, `--ack-timeout=MS` (stream, default 600000), `--reply <id> <status> [--file PATH] [--data JSON] [message]`, `--help`. `--reply` errors (stderr, exit 1): `Usage: node "<abs>/live-poll.mjs" --reply <id> <status> [--file path] [--data '<json>'] [message]` + `Missing event id after --reply.` / `The value after --reply must be the event id, not the status "done". Use --reply EVENT_ID done.` / `Missing reply status after event id "X".`; `--data must be valid JSON: <err>`.
- Args: `--stream`, `--timeout=MS` (one-shot total, default 600000), `--types=A,B`, `--ack-timeout=MS` (stream, default 600000), `--reply <id> <status> [--file PATH] [--data JSON] [message]`, `--help`. `--reply` success (stdout, exit 0): one compact JSON line `{ok:true,id,status,file? (only when --file was passed),_instructions:'Poll again now.'}`. `--reply` errors (stderr, exit 1): `Usage: node "<abs>/live-poll.mjs" --reply <id> <status> [--file path] [--data '<json>'] [message]` + `Missing event id after --reply.` / `The value after --reply must be the event id, not the status "done". Use --reply EVENT_ID done.` / `Missing reply status after event id "X".`; `--data must be valid JSON: <err>`.
- Needs `server.json`; else stderr `No running live server found. Start one with: node "<abs>/live.mjs"` exit 1.
- One-shot: loops `GET /poll?token&timeout=<slice ≤270000>&leaseMs=600000[&types]` until an event or total deadline; prints one JSON line (`console.log(JSON.stringify(event))`) with `_instructions` added by `instructionsForEvent` (unless already present). For `accept`/`discard`: spawns `node live-accept.mjs --id ID (--discard | --variant N) [--page-url U] [--param-values JSON]` (30 s), sets `event._acceptResult` (parse failure/throw → `{handled:false, mode:'error', error}`), then POSTs completion `{id, type: completionType, sourceEventType: event.type, message: _acceptResult.error, file: _acceptResult.file, data: {carbonize:true}?}` where completionType = discard: `discarded` if handled else `error`; accept: `agent_done` if handled&carbonize, `complete` if handled, `error` if mode error or (svelte-component unhandled), else `agent_done`; sets `event._completionAck = {ok:true, type}` (+ `final:false, requiresComplete:true, nextCommand:'live-complete.mjs --id <id>', message:'Carbonize cleanup must be verified, then the session must be completed explicitly before polling again.'` for carbonize) or `{ok:false, error}`. Stderr banners: manual_edit_apply → 4-line banner starting `Manual Apply action required: edit source, then reply with \`live-poll.mjs --reply <id> done --data '<json>'\`.`; carbonize → `⚠ Carbonize cleanup REQUIRED before next poll. After cleanup, run live-complete.mjs --id <id>. See reference/live.md "Required after accept".`
- Stream: stderr `[impeccable-poll] stream mode: one JSON object per line on stdout; use --reply while this process stays running`; after each reply-needing event waits (poll `/status` every 400 ms) until the id leaves `pendingEvents` (else `Timed out waiting for --reply on event <id>` exit 1); returns on `exit`.
-83
View File
@@ -1,83 +0,0 @@
# Comp gate evidence and integrity
Comp fidelity still has two independent requirements: the numeric score and the
absence of blocking findings. A high overall score, a repeated attempt, or an
existing plate file does not satisfy a missing-region check.
## Measurements and decisions
`comp-diff` measures the declared region bounds. It does not enlarge a narrow
region to include neighbouring elements. Subpixel regions sample at least one
real source pixel, including at image edges. Coverage and region-kind checks remain
the responsibility of `comp-spec`; this change does not authorize omitting
regions or shrinking their bounds to exclude required work.
A standalone `comp-diff` report contains raw verdicts. The hero gate can interpret
those measurements using current plate validation and rendered presence. For
hero and responsive evidence:
- `raw-report.json` retains uninterpreted metrics and verdicts.
- `report.json` retains the same scores, adds `rawVerdict` to each region, and
publishes the effective `verdict` used by the gate and paired image label.
- `gate.ok` and `gate.reasons` describe whether advancement is allowed and all
unresolved blockers. `drift` alone does not mean a region is nonblocking.
- Paired images label their gate status as `BLOCKING`, `NONBLOCKING`, or
`CHECK GATE`, using the same report fields.
- Region-specific blockers are recorded as `blockingReasons`. `blocking: null`
means an unscoped blocking finding prevents attributing a clean bill of health
to that region. Unscoped findings remain in `gate.unscopedReasons`; they are
not hidden or waived.
When capture, spec, or plate validation fails before measurement, the current
report has `measurementsAvailable: false`, no region results, and the failed
gate reasons. It does not present the preceding capture as current evidence.
Repair crops include only regions with current blockers; shared readings retain
all affected region IDs. Frame-wide blockers keep their whole-frame evidence.
Both hero and responsive reports are marked incomplete before preflight and
image writes, then committed atomically only after the evidence files succeed.
Each attempt clears the previous raw report, whole-frame PNGs, and region PNGs;
failed attempts also clear any partial evidence they wrote. Removed regions cannot
leave old crops in a successful comparison. Cleanup is limited to generated files,
does not follow region-directory symlinks, and blocks the gate if it fails.
When removal fails, the remaining generated set is renamed beside its current paths under a unique
`invalid-comparison-*` prefix. `artifactCleanup` and a quarantine manifest record
the moved paths. Keeping the same parent avoids requiring write access to the
read-only directory itself.
If filesystem permissions also prevent quarantine, that field records the error
and explicitly lists the invalid artifact paths; no evidence is certified and the
gate remains closed.
Write failures block the gate
and, when the report location is writable, publish an unavailable-evidence report.
Stall feedback follows repeated blocking reasons. It never chooses an asset to
regenerate solely because that asset has the lowest raw score. The feedback is
additional context; it does not clear a finding or advance the phase.
## Plate validation
Successful plate receipts include SHA-256 fingerprints of the asset bytes,
measured region, and comp. Hero and responsive gates revalidate receipts. A changed or deleted file, a changed region, or a
changed comp invalidates the receipt. Legacy score-only receipts are revalidated.
An invalid plate cannot receive an `ok` receipt merely because its PNG decoded.
Rendered presence is still checked after asset validation, so a file hidden in
the page does not count as placed.
## Overrides
A `--force --reason` must contain a direct quoted downgrade of comp authority
immediately attributed to the user. An unrelated mention of the user does not
authorize a quote from another speaker. Generic delegation, the builder's surrounding claim that
it may proceed, or a gate exception does not establish that authorization.
The quote parser is deliberately conservative. It cannot authenticate a quote:
the calling harness must retain the actual user answer and assess provenance.
Neither local receipts nor caller-written state are a security boundary against
an agent with arbitrary write access to all files and engine code.
## Validation
The comp-verbs regressions cover narrow-region isolation, changed/deleted plate
receipts, comp-crop reuse, hidden rendered assets, repeated failed attempts,
ambiguous overrides, spec coverage and kind refusals, and raw/effective report
agreement. They do not certify semantic equivalence of arbitrary artwork or
justify relaxing a fidelity threshold.
@@ -188,6 +188,12 @@ Test thoroughly across contexts:
- **Edge cases**: Very small screens (320px), very large screens (4K)
- **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.
---
+2 -1
View File
@@ -48,11 +48,12 @@ Run comprehensive checks across 5 dimensions. Score each dimension 0-4 using the
**Check for**:
- **Fixed widths**: Hard-coded widths that break on mobile
- **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
- **Text scaling**: Layouts that break when text size increases
- **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)
**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)
### 5. Implementation Integrity (CRITICAL)
@@ -205,6 +205,11 @@ t('items', { count }) // Handles complex plural rules
- Optimistic updates with rollback
- 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**:
- No permission to view
- No permission to edit
@@ -304,6 +309,7 @@ const throttledScroll = throttle(handleScroll, 100);
- Unit tests for edge cases
- Integration tests for error scenarios
- 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
- Accessibility tests (axe, WAVE)
@@ -330,7 +336,10 @@ Test thoroughly with edge cases:
- **Network issues**: Disable internet, throttle connection
- **Large datasets**: Test with 1000+ items
- **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
- **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.
+6
View File
@@ -188,6 +188,12 @@ Test thoroughly across contexts:
- **Edge cases**: Very small screens (320px), very large screens (4K)
- **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 `{{command_prefix}}impeccable polish` for the final pass.
---
+2 -1
View File
@@ -48,11 +48,12 @@ Run comprehensive checks across 5 dimensions. Score each dimension 0-4 using the
**Check for**:
- **Fixed widths**: Hard-coded widths that break on mobile
- **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
- **Text scaling**: Layouts that break when text size increases
- **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)
**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)
### 5. Implementation Integrity (CRITICAL)
+9
View File
@@ -205,6 +205,11 @@ t('items', { count }) // Handles complex plural rules
- Optimistic updates with rollback
- 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**:
- No permission to view
- No permission to edit
@@ -304,6 +309,7 @@ const throttledScroll = throttle(handleScroll, 100);
- Unit tests for edge cases
- Integration tests for error scenarios
- 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
- Accessibility tests (axe, WAVE)
@@ -330,7 +336,10 @@ Test thoroughly with edge cases:
- **Network issues**: Disable internet, throttle connection
- **Large datasets**: Test with 1000+ items
- **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
- **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 `{{command_prefix}}impeccable polish` for the final pass.
+1 -1
View File
@@ -109,7 +109,7 @@ The comp-led path is a frontier-tier job: it asks the builder to hold a measured
1. **spec.** Measure the comp: `impeccable comp-spec --comp <comp> --grid` writes a coordinate grid over the comp; open it, name every salient region by grid span in a regions file (text and control regions snap to the largest ink mass inside their span, so a headline named B1:E4 measures as the headline and not the column beside it; `snap: false` keeps the span, and an explicit `box` is taken as drawn) (kind `plate` / `image` / `texture` for anything painted: every illustration, photograph, figure, product object, and material texture; `text` / `control` / `chrome` for what code draws; every region carries a `note` saying what the comp shows there, which the plate prompt and the gate messages read), and run `impeccable comp-spec --comp <comp> --regions <file>`. The spec carries each region's box, sampled palette, and medium; `impeccable comp-spec --print` is the build's reference from here on. Type is measured, not guessed: `impeccable font-match --measure <text region>` reads the comp's cap height, width class, and weight off the pixels, and `impeccable font-match --rank <region> --text "..."` takes its candidates from a fingerprint index of the Google Fonts catalog (the nearest faces to the crop's shape) plus any names you pass with `--candidates`, renders them at that cap height with the region's words, and ranks them by fingerprint distance (its `USE` line is the CSS; its proof sheet shows the comp over the top three); with no browser resolvable it records the catalog's nearest face and says the size is estimated, which is still the choice to build on. Do not install a browser to rank, and never write a `chosen` face into the spec by hand: the gate accepts only what font-match wrote. The spec gate refuses to close until the lead text region is measured and ranked. A region note that describes painted material (a diagram, drawing, photograph, texture) under a code kind is refused at the spec: reclassify it as a plate, or reword the note if code really draws it. The script refuses a regions file that leaves comp ink unnamed (callouts, a parts table, a notes block): what is never named can never be missing, so everything the comp shows gets a region. It also refuses a `text` / `control` / `chrome` region larger than a quarter of the comp: that is a column, not an element, and a column scored as one region hides the plates, tables, and notes inside it. Name each element inside it (`container: true` only when it truly is one undivided element). Anything drawn is a plate: an inline SVG past an icon's budget (a diagram, notation, leader lines with arrows, a "quick approximation" of the artwork) is refused at the hero; icon-sized SVG (under 64px, a few paths) is fine, and a chart the page draws from data at runtime is a chart, not an illustration. Callout lines and arrows that annotate a drawing belong to that drawing's plate, with only their labels set as text. A crop of the comp is never a plate (the plates gate refuses a file that is a resample of the comp region: the comp's grain, its neighbours' edges, and its resolution would ship as the artwork); the crop is the reference the plate is generated from. A plate region's box has to hold its whole artwork with a margin: the spec measures the artwork's contact with the box edges and refuses a box that cuts through it (`bleed: true` only when the page really crops it there), because a plate placed with `object-fit: cover` on such a box shows the artwork minus the side the box lost. Anything not in the spec does not exist on the page: no borders, rules, containers, or chrome the comp does not show. Only three concessions exist: fonts (the closest obtainable face), icon glyphs (close enough, exact if the user chose an icon library; this covers the pictogram only, never a control's chrome, so a chevron, an arrow, a dropdown's border and fill, a button's shape are the comp's), and genuine defects in the comp such as spelling errors. <!-- rule:skill-comp-spec -->
2. **plates.** Every raster region ships as a plate: an illustration, photo, or figure regenerated at asset resolution from its comp crop, UI text removed, at its `plate` path (isolated ink, figures, or objects use native transparent PNG so they sit on the page's own ground; photos and textures stay opaque); a texture (paper, cloth, grain) is a clean patch of the comp region mirror-tiled to size, generated only when no clean patch exists. `impeccable comp-spec --crop <id>` writes the reference; save `impeccable comp-spec --plate-prompt <id> --background transparent` to a prompt file for a cutout, or use `--background opaque` otherwise. Prefer the harness-native image tool with that crop and prompt, then `impeccable embed-prompt <plate> --prompt-file <prompt.txt>`. The API fallback is `impeccable generate-image --ref <crop.png> --prompt-file <prompt.txt> --out <plate.png> --size <WxH> --quality high --background transparent` (use `--background opaque` for full-frame assets); create the output directory first. Verify actual alpha, white foregrounds, fine edges, and clear holes on light and dark grounds; do not chroma-key native output. The plates gate scores the assets against the comp; also inspect placement and scale visually. With parallel subagents, spawn the shipped asset producer (`impeccable-asset-producer`; `impeccable_asset_producer` in codex; `/impeccable-asset-producer` in Cursor; on GitHub Copilot say "Use the impeccable-asset-producer agent") with the spec path and let it produce them all; without subagents, produce them here. A crop of the comp is a reference, never a shipping pixel. The gate checks every plate exists, is at least 1.5x the region's size, and reads as the region. Page code waits for this gate: a page written before its plates exist is a page that draws its material in CSS. A single-file deliverable changes nothing here: the plate is produced the same way and inlined as a data URI. `--force` exists for one case only, the user downgrading the comp's authority in words you quote in `--reason`; the script refuses every other reason. <!-- rule:skill-plates-before-page -->
3. **hero.** `impeccable build-phase scaffold` first: it writes the measured layout as CSS custom properties (`.impeccable/build/scaffold/layout.css`: `--r-<id>-x/y/w/h` in % of the comp, plus cap height, font-size, family, and weight where measured) and a reference page (`hero-reference.html`) with every region at its box and every plate placed. Bind the numbers to your own semantic structure, an element per region; the reference is a check on positions, never the page, and overlapping boxes are overlapping boxes. Then build only the first viewport, at the comp's own dimensions, the comp's words copied verbatim (the user approved that comp with those words; rewording is a stated decision after the hero passes, never a silent one inside it), every text region sized from its measured cap height and set in its ranked face, plates first: place every plate at its spec box (`object-fit: cover`, an `<img>`, a background image, or an inlined data URI named for it) before any text or control, capture into `.impeccable/review/hero-repro.png`, run `impeccable build-phase record hero` once so you see the plate regions read as match before any text exists, then lay the semantic layer over the plates from the spec's palette and boxes and advance. The gate first refuses while any plate is unreferenced by the source, then runs `impeccable comp-diff`, writes `.impeccable/review/diff/hero/` (side-by-side, heatmap, one paired crop per region, `report.json`; `raw-report.json` preserves the uninterpreted measurements). The report and crop labels use the gate's verdicts; `gate.reasons` lists the remaining blockers even when a region is called drift. An accepted plate is revalidated if its file, measured region, or comp changes. The gate passes at 72% overall with no hard veto outstanding (a missing region, a contradicted plate or text block, an SVG illustration, a clipped plate, invented ink block at any score); above the bar, the numeric readings become advisories printed with the pass, and the polish pass before responsive is where they get fixed: the gate also reads each text region's cap height, line count, weight, ink colour, and position against the comp, each chrome strip's height off its rule, and the frame for ink where the comp is calm (a kicker, an extra nav item, a divider), and says each miss as a number ("cap height 78px in the build, 103px in the comp"); those numbers are the edit. When it fails, open the region crops it lists, in order, before editing: a region scored `missing` needs its material, `contradicted` needs its structure re-derived from the spec box, `drift` is where size and spacing edits belong; repeated attempts do not clear unresolved blockers. This is where the run's ambition is won or lost, and a retry here costs minutes where a rebuild verdict at the finish costs the run. <!-- rule:skill-hero-gate -->
3. **hero.** `impeccable build-phase scaffold` first: it writes the measured layout as CSS custom properties (`.impeccable/build/scaffold/layout.css`: `--r-<id>-x/y/w/h` in % of the comp, plus cap height, font-size, family, and weight where measured) and a reference page (`hero-reference.html`) with every region at its box and every plate placed. Bind the numbers to your own semantic structure, an element per region; the reference is a check on positions, never the page, and overlapping boxes are overlapping boxes. Then build only the first viewport, at the comp's own dimensions, the comp's words copied verbatim (the user approved that comp with those words; rewording is a stated decision after the hero passes, never a silent one inside it), every text region sized from its measured cap height and set in its ranked face, plates first: place every plate at its spec box (`object-fit: cover`, an `<img>`, a background image, or an inlined data URI named for it) before any text or control, capture into `.impeccable/review/hero-repro.png`, run `impeccable build-phase record hero` once so you see the plate regions read as match before any text exists, then lay the semantic layer over the plates from the spec's palette and boxes and advance. The gate first refuses while any plate is unreferenced by the source, then runs `impeccable comp-diff`, writes `.impeccable/review/diff/hero/` (side-by-side, heatmap, one paired crop per region, `report.json`), and passes at 72% overall with no hard veto outstanding (a missing region, a contradicted plate or text block, an SVG illustration, a clipped plate, invented ink block at any score); above the bar, the numeric readings become advisories printed with the pass, and the polish pass before responsive is where they get fixed: the gate also reads each text region's cap height, line count, weight, ink colour, and position against the comp, each chrome strip's height off its rule, and the frame for ink where the comp is calm (a kicker, an extra nav item, a divider), and says each miss as a number ("cap height 78px in the build, 103px in the comp"); those numbers are the edit. When it fails, open the region crops it lists, in order, before editing: a region scored `missing` needs its material, `contradicted` needs its structure re-derived from the spec box, `drift` is where size and spacing edits belong; the gate refuses a third attempt that only nudges values on the same region. This is where the run's ambition is won or lost, and a retry here costs minutes where a rebuild verdict at the finish costs the run. <!-- rule:skill-hero-gate -->
4. **sections.** Build the rest of the surface inside the spec's system: the same corner language, line weights, and palette, and nothing the comp never shows. Where the comp does not cover a region, it inherits the recorded system.
5. **motion.** The signature interaction, reveals, and motion, orchestrated once rather than scattered.
6. **responsive.** The other viewports, and the first viewport at common desktop widths (1280 to 1600), not only at the comp's exact size: fluid columns, no fixed-pixel grid that wraps a hundred pixels narrower. Capture `desktop.png` (1440 wide, full page) and `mobile.png` (390 wide) into `.impeccable/review/`; the gate diffs the desktop capture against the comp and refuses a first viewport that only held at the comp's width. A comp'd surface that is mobile-first was comped portrait; the plates were produced for that frame.
+92
View File
@@ -0,0 +1,92 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Placeholder Contrast — Should Flag vs Should Pass</title>
<style>
body { font-family: system-ui, sans-serif; background: #fafafa; padding: 24px; margin: 0; color: #1a1a1a; }
.grid { display: grid; grid-template-columns: 1fr 1fr; gap: 32px; max-width: 1200px; margin: 0 auto; }
.col h2 { font-size: 14px; text-transform: uppercase; letter-spacing: 0.05em; margin: 0 0 16px; color: #475569; }
.col h3 { font-size: 11px; text-transform: uppercase; letter-spacing: 0.05em; margin: 24px 0 8px; color: #64748b; }
.field {
width: 280px;
height: 40px;
padding: 8px 12px;
font-size: 16px;
border: 1px solid #cbd5e1;
border-radius: 6px;
box-sizing: border-box;
display: block;
margin-bottom: 8px;
}
.field-white { background: #ffffff; }
.field-light { background: #f5f5f5; }
.field-dark { background: #1a1a1a; border-color: #333; }
.flag-pale-white::placeholder { color: #bbbbbb; }
.flag-pale-textarea::placeholder { color: #bbbbbb; }
.flag-translucent-light::placeholder { color: rgba(255, 255, 255, 0.4); }
.dark-wrap { background: #0f0f11; padding: 20px; width: 320px; }
.frosted-panel {
background: rgba(255, 255, 255, 0.15);
padding: 12px;
}
.frosted-panel .field {
background: rgba(255, 255, 255, 0.2);
border-color: rgba(255, 255, 255, 0.25);
}
.frosted-panel .field::placeholder { color: #bbbbbb; }
.pass-ink-white::placeholder { color: #1a1a1a; }
.pass-light-dark::placeholder { color: #e8e8e8; }
.pass-filled-pale::placeholder { color: #bbbbbb; }
.pass-unstyled { /* no ::placeholder rule — UA color only */ }
</style>
</head>
<body>
<div class="grid">
<div class="col" data-col="flag">
<h2>Should flag</h2>
<h3>Pale placeholder on white input</h3>
<input class="field field-white flag-pale-white" type="text" placeholder="Pale Placeholder On White Field">
<h3>Pale placeholder on white textarea</h3>
<textarea class="field field-white flag-pale-textarea" rows="2" placeholder="Pale Placeholder On White Textarea"></textarea>
<h3>Translucent placeholder on light field</h3>
<input class="field field-light flag-translucent-light" type="text" placeholder="Translucent Placeholder On Light Field">
<h3>Pale placeholder on frosted panel</h3>
<div class="dark-wrap">
<div class="frosted-panel">
<input class="field" type="text" placeholder="Pale Placeholder On Frosted Panel">
</div>
</div>
</div>
<div class="col" data-col="pass">
<h2>Should pass</h2>
<h3>Ink placeholder on white field</h3>
<input class="field field-white pass-ink-white" type="text" placeholder="Ink Placeholder On White Field">
<h3>Light placeholder on dark field</h3>
<input class="field field-dark pass-light-dark" type="text" placeholder="Light Placeholder On Dark Field">
<h3>Input with no placeholder attribute</h3>
<input class="field field-white" type="text" value="">
<h3>Filled field hides placeholder</h3>
<input class="field field-white pass-filled-pale" type="text" value="Already filled" placeholder="Filled Field Hides Placeholder">
<h3>Unstyled placeholder uses UA color</h3>
<input class="field field-white pass-unstyled" type="text" placeholder="Unstyled Placeholder Uses UA Color">
<h3>Empty placeholder attribute</h3>
<input class="field field-white flag-pale-white" type="text" placeholder="">
</div>
</div>
</body>
</html>
-12
View File
@@ -164,15 +164,3 @@ installed. The binary's `CLI_VERSION` moves from `3.6.0` to `4.0.0` with the
CLI 4.0.0 release; it is what the binary prints when run directly.
- `cli-version`.
## Recorded 2026-09-11: comp regions no longer include neighbouring pixels
The `comp-diff-no-spec` golden now measures the automatic bands at their actual
bounds rather than enlarging bands under 48px. Reviewed changes are confined to
regional scores and ink boxes: the second band's overall is 0.6755 (was 0.6951),
the fourth is 1.0 (was 0.9468), and narrow-band ink boxes use the corrected crop
coordinates. Whole-frame scores, verdicts, region definitions, exit status, and
stderr are unchanged. The golden was updated to enforce these exact results;
this is not an open-ended accepted delta. Frozen function call vectors remain
unchanged. The Rust narrow-region regression independently checks that changing
only neighbouring pixels leaves the measured crop identical.
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,6 +1,6 @@
{
"stdout": "",
"stderr": "415 anti-patterns found.\n17 advisory notes (not counted).\n",
"stderr": "419 anti-patterns found.\n17 advisory notes (not counted).\n",
"exit": 2,
"signal": null,
"files": {}
File diff suppressed because one or more lines are too long
@@ -0,0 +1,7 @@
{
"stdout": "[\n {\n \"antipattern\": \"low-contrast\",\n \"name\": \"Low contrast text\",\n \"description\": \"Text does not meet WCAG AA contrast requirements (4.5:1 for body, 3:1 for large text). Increase the contrast between text and background.\",\n \"severity\": \"warning\",\n \"category\": \"quality\",\n \"file\": \"<REPO>/tests/fixtures/antipatterns/placeholder-contrast.html\",\n \"line\": 0,\n \"snippet\": \"placeholder \\\"Pale Placeholder On White Field\\\" 1.9:1 (need 4.5:1) — text #bbbbbb on #ffffff\"\n },\n {\n \"antipattern\": \"low-contrast\",\n \"name\": \"Low contrast text\",\n \"description\": \"Text does not meet WCAG AA contrast requirements (4.5:1 for body, 3:1 for large text). Increase the contrast between text and background.\",\n \"severity\": \"warning\",\n \"category\": \"quality\",\n \"file\": \"<REPO>/tests/fixtures/antipatterns/placeholder-contrast.html\",\n \"line\": 0,\n \"snippet\": \"placeholder \\\"Pale Placeholder On White Textarea\\\" 1.9:1 (need 4.5:1) — text #bbbbbb on #ffffff\"\n },\n {\n \"antipattern\": \"low-contrast\",\n \"name\": \"Low contrast text\",\n \"description\": \"Text does not meet WCAG AA contrast requirements (4.5:1 for body, 3:1 for large text). Increase the contrast between text and background.\",\n \"severity\": \"warning\",\n \"category\": \"quality\",\n \"file\": \"<REPO>/tests/fixtures/antipatterns/placeholder-contrast.html\",\n \"line\": 0,\n \"snippet\": \"placeholder \\\"Translucent Placeholder On Light Field\\\" 1.0:1 (need 4.5:1) — text #f9f9f9 on #f5f5f5\"\n },\n {\n \"antipattern\": \"low-contrast\",\n \"name\": \"Low contrast text\",\n \"description\": \"Text does not meet WCAG AA contrast requirements (4.5:1 for body, 3:1 for large text). Increase the contrast between text and background.\",\n \"severity\": \"warning\",\n \"category\": \"quality\",\n \"file\": \"<REPO>/tests/fixtures/antipatterns/placeholder-contrast.html\",\n \"line\": 0,\n \"snippet\": \"placeholder \\\"Pale Placeholder On Frosted Panel\\\" 3.5:1 (need 4.5:1) — text #bbbbbb on #5c5c5d\"\n }\n]\n",
"stderr": "",
"exit": 2,
"signal": null,
"files": {}
}
@@ -0,0 +1,7 @@
{
"stdout": "",
"stderr": "\n<REPO>/tests/fixtures/antipatterns/placeholder-contrast.html\n [low-contrast] placeholder \"Pale Placeholder On White Field\" 1.9:1 (need 4.5:1) — text #bbbbbb on #ffffff\n → Text does not meet WCAG AA contrast requirements (4.5:1 for body, 3:1 for large text). Increase the contrast between text and background.\n [low-contrast] placeholder \"Pale Placeholder On White Textarea\" 1.9:1 (need 4.5:1) — text #bbbbbb on #ffffff\n → Text does not meet WCAG AA contrast requirements (4.5:1 for body, 3:1 for large text). Increase the contrast between text and background.\n [low-contrast] placeholder \"Translucent Placeholder On Light Field\" 1.0:1 (need 4.5:1) — text #f9f9f9 on #f5f5f5\n → Text does not meet WCAG AA contrast requirements (4.5:1 for body, 3:1 for large text). Increase the contrast between text and background.\n [low-contrast] placeholder \"Pale Placeholder On Frosted Panel\" 3.5:1 (need 4.5:1) — text #bbbbbb on #5c5c5d\n → Text does not meet WCAG AA contrast requirements (4.5:1 for body, 3:1 for large text). Increase the contrast between text and background.\n\n4 anti-patterns found.\n",
"exit": 2,
"signal": null,
"files": {}
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -1,11 +1,11 @@
{
"stdout": "{\n \"ok\": true,\n \"id\": \"ab12cd34\",\n \"phase\": \"completed\",\n \"snapshot\": {\n \"id\": \"ab12cd34\",\n \"phase\": \"completed\",\n \"pageUrl\": \"/\",\n \"sourceFile\": \"index.html\",\n \"previewFile\": null,\n \"previewMode\": null,\n \"expectedVariants\": 3,\n \"arrivedVariants\": 3,\n \"visibleVariant\": 2,\n \"paramValues\": {\n \"face\": \"serif\"\n },\n \"pendingEventSeq\": null,\n \"pendingEvent\": null,\n \"deliveryLease\": null,\n \"checkpointRevision\": 0,\n \"browserCheckpointRevision\": 0,\n \"publicationCheckpointRevision\": 0,\n \"activeOwner\": null,\n \"sourceMarkers\": {},\n \"fallbackMode\": null,\n \"generationPhase\": null,\n \"generationCompletedAt\": 1785578580000,\n \"generationTimings\": {},\n \"variantPlan\": null,\n \"generationCanceled\": true,\n \"generationCanceledAt\": 1785578520000,\n \"cancelReason\": \"accept\",\n \"annotationArtifacts\": [],\n \"mountedVariants\": [],\n \"mountFailures\": [],\n \"renderState\": \"pending\",\n \"diagnostics\": [\n {\n \"error\": \"carbonize_cleanup_required\",\n \"file\": \"index.html\",\n \"message\": \"Accepted variant still has carbonize markers that must be folded into source CSS.\"\n }\n ],\n \"updatedAt\": \"<ISO>\"\n }\n}\n",
"stdout": "{\n \"ok\": true,\n \"id\": \"ab12cd34\",\n \"phase\": \"completed\",\n \"snapshot\": {\n \"id\": \"ab12cd34\",\n \"phase\": \"completed\",\n \"pageUrl\": \"/\",\n \"sourceFile\": \"index.html\",\n \"previewFile\": null,\n \"previewMode\": null,\n \"expectedVariants\": 3,\n \"arrivedVariants\": 3,\n \"visibleVariant\": 2,\n \"paramValues\": {\n \"face\": \"serif\"\n },\n \"pendingEventSeq\": null,\n \"pendingEvent\": null,\n \"deliveryLease\": null,\n \"checkpointRevision\": 0,\n \"browserCheckpointRevision\": 0,\n \"publicationCheckpointRevision\": 0,\n \"activeOwner\": null,\n \"sourceMarkers\": {},\n \"fallbackMode\": null,\n \"generationPhase\": null,\n \"generationCompletedAt\": 1785578580000,\n \"generationTimings\": {},\n \"variantPlan\": null,\n \"generationCanceled\": true,\n \"generationCanceledAt\": 1785578520000,\n \"cancelReason\": \"accept\",\n \"annotationArtifacts\": [],\n \"mountedVariants\": [],\n \"mountFailures\": [],\n \"renderState\": \"pending\",\n \"diagnostics\": [],\n \"updatedAt\": \"<ISO>\"\n }\n}\n",
"stderr": "",
"exit": 0,
"signal": null,
"files": {
".impeccable/live/config.json": "{\n \"files\": [\"index.html\", \"public/**/*.html\"],\n \"insertBefore\": \"</body>\",\n \"commentSyntax\": \"html\"\n}\n",
".impeccable/live/sessions/ab12cd34.jsonl": "{\"seq\":1,\"id\":\"ab12cd34\",\"type\":\"generate\",\"ts\":\"<ISO>\",\"event\":{\"id\":\"ab12cd34\",\"type\":\"generate\",\"action\":\"bolder\",\"count\":3,\"pageUrl\":\"/\",\"element\":{\"tagName\":\"h1\",\"id\":\"hero\",\"classes\":[\"hero-title\"],\"textContent\":\"Oracle Fixture\",\"outerHTML\":\"<h1 id=\\\"hero\\\" class=\\\"hero-title\\\">Oracle Fixture</h1>\"},\"clientSentAt\":1754042400000}}\n{\"seq\":2,\"id\":\"ab12cd34\",\"type\":\"agent_done\",\"ts\":\"<ISO>\",\"event\":{\"id\":\"ab12cd34\",\"type\":\"agent_done\",\"file\":\"index.html\",\"sourceEventType\":\"generate\",\"carbonize\":false,\"arrivedVariants\":3}}\n{\"seq\":3,\"id\":\"ab12cd34\",\"type\":\"accept\",\"ts\":\"<ISO>\",\"event\":{\"id\":\"ab12cd34\",\"type\":\"accept\",\"variantId\":\"2\",\"pageUrl\":\"/\",\"paramValues\":{\"face\":\"serif\"}}}\n{\"seq\":4,\"id\":\"ab12cd34\",\"type\":\"agent_done\",\"ts\":\"<ISO>\",\"event\":{\"id\":\"ab12cd34\",\"type\":\"agent_done\",\"file\":\"index.html\",\"sourceEventType\":\"accept\",\"carbonize\":true}}\n{\"seq\":5,\"id\":\"ab12cd34\",\"type\":\"complete\",\"ts\":\"<ISO>\",\"event\":{\"type\":\"complete\",\"id\":\"ab12cd34\"}}\n",
".impeccable/live/sessions/ab12cd34.snapshot.json": "{\n \"id\": \"ab12cd34\",\n \"phase\": \"completed\",\n \"pageUrl\": \"/\",\n \"sourceFile\": \"index.html\",\n \"previewFile\": null,\n \"previewMode\": null,\n \"expectedVariants\": 3,\n \"arrivedVariants\": 3,\n \"visibleVariant\": 2,\n \"paramValues\": {\n \"face\": \"serif\"\n },\n \"pendingEventSeq\": null,\n \"pendingEvent\": null,\n \"deliveryLease\": null,\n \"checkpointRevision\": 0,\n \"browserCheckpointRevision\": 0,\n \"publicationCheckpointRevision\": 0,\n \"activeOwner\": null,\n \"sourceMarkers\": {},\n \"fallbackMode\": null,\n \"generationPhase\": null,\n \"generationCompletedAt\": 1785578580000,\n \"generationTimings\": {},\n \"variantPlan\": null,\n \"generationCanceled\": true,\n \"generationCanceledAt\": 1785578520000,\n \"cancelReason\": \"accept\",\n \"annotationArtifacts\": [],\n \"mountedVariants\": [],\n \"mountFailures\": [],\n \"renderState\": \"pending\",\n \"diagnostics\": [\n {\n \"error\": \"carbonize_cleanup_required\",\n \"file\": \"index.html\",\n \"message\": \"Accepted variant still has carbonize markers that must be folded into source CSS.\"\n }\n ],\n \"updatedAt\": \"<ISO>\",\n \"__journalBytes\": 1053,\n \"__nextSeq\": 6\n}\n"
".impeccable/live/sessions/ab12cd34.snapshot.json": "{\n \"id\": \"ab12cd34\",\n \"phase\": \"completed\",\n \"pageUrl\": \"/\",\n \"sourceFile\": \"index.html\",\n \"previewFile\": null,\n \"previewMode\": null,\n \"expectedVariants\": 3,\n \"arrivedVariants\": 3,\n \"visibleVariant\": 2,\n \"paramValues\": {\n \"face\": \"serif\"\n },\n \"pendingEventSeq\": null,\n \"pendingEvent\": null,\n \"deliveryLease\": null,\n \"checkpointRevision\": 0,\n \"browserCheckpointRevision\": 0,\n \"publicationCheckpointRevision\": 0,\n \"activeOwner\": null,\n \"sourceMarkers\": {},\n \"fallbackMode\": null,\n \"generationPhase\": null,\n \"generationCompletedAt\": 1785578580000,\n \"generationTimings\": {},\n \"variantPlan\": null,\n \"generationCanceled\": true,\n \"generationCanceledAt\": 1785578520000,\n \"cancelReason\": \"accept\",\n \"annotationArtifacts\": [],\n \"mountedVariants\": [],\n \"mountFailures\": [],\n \"renderState\": \"pending\",\n \"diagnostics\": [],\n \"updatedAt\": \"<ISO>\",\n \"__journalBytes\": 1053,\n \"__nextSeq\": 6\n}\n"
}
}
@@ -1,11 +1,11 @@
{
"stdout": "{\n \"ok\": true,\n \"id\": \"ab12cd34\",\n \"phase\": \"completed\",\n \"snapshot\": {\n \"id\": \"ab12cd34\",\n \"phase\": \"completed\",\n \"pageUrl\": \"/\",\n \"sourceFile\": \"index.html\",\n \"previewFile\": null,\n \"previewMode\": null,\n \"expectedVariants\": 3,\n \"arrivedVariants\": 3,\n \"visibleVariant\": 2,\n \"paramValues\": {\n \"face\": \"serif\"\n },\n \"pendingEventSeq\": null,\n \"pendingEvent\": null,\n \"deliveryLease\": null,\n \"checkpointRevision\": 0,\n \"browserCheckpointRevision\": 0,\n \"publicationCheckpointRevision\": 0,\n \"activeOwner\": null,\n \"sourceMarkers\": {},\n \"fallbackMode\": null,\n \"generationPhase\": null,\n \"generationCompletedAt\": 1785578580000,\n \"generationTimings\": {},\n \"variantPlan\": null,\n \"generationCanceled\": true,\n \"generationCanceledAt\": 1785578520000,\n \"cancelReason\": \"accept\",\n \"annotationArtifacts\": [],\n \"mountedVariants\": [],\n \"mountFailures\": [],\n \"renderState\": \"pending\",\n \"diagnostics\": [\n {\n \"error\": \"carbonize_cleanup_required\",\n \"file\": \"index.html\",\n \"message\": \"Accepted variant still has carbonize markers that must be folded into source CSS.\"\n }\n ],\n \"updatedAt\": \"<ISO>\"\n }\n}\n",
"stdout": "{\n \"ok\": true,\n \"id\": \"ab12cd34\",\n \"phase\": \"completed\",\n \"snapshot\": {\n \"id\": \"ab12cd34\",\n \"phase\": \"completed\",\n \"pageUrl\": \"/\",\n \"sourceFile\": \"index.html\",\n \"previewFile\": null,\n \"previewMode\": null,\n \"expectedVariants\": 3,\n \"arrivedVariants\": 3,\n \"visibleVariant\": 2,\n \"paramValues\": {\n \"face\": \"serif\"\n },\n \"pendingEventSeq\": null,\n \"pendingEvent\": null,\n \"deliveryLease\": null,\n \"checkpointRevision\": 0,\n \"browserCheckpointRevision\": 0,\n \"publicationCheckpointRevision\": 0,\n \"activeOwner\": null,\n \"sourceMarkers\": {},\n \"fallbackMode\": null,\n \"generationPhase\": null,\n \"generationCompletedAt\": 1785578580000,\n \"generationTimings\": {},\n \"variantPlan\": null,\n \"generationCanceled\": true,\n \"generationCanceledAt\": 1785578520000,\n \"cancelReason\": \"accept\",\n \"annotationArtifacts\": [],\n \"mountedVariants\": [],\n \"mountFailures\": [],\n \"renderState\": \"pending\",\n \"diagnostics\": [],\n \"updatedAt\": \"<ISO>\"\n }\n}\n",
"stderr": "",
"exit": 0,
"signal": null,
"files": {
".impeccable/live/config.json": "{\n \"files\": [\"index.html\", \"public/**/*.html\"],\n \"insertBefore\": \"</body>\",\n \"commentSyntax\": \"html\"\n}\n",
".impeccable/live/sessions/ab12cd34.jsonl": "{\"seq\":1,\"id\":\"ab12cd34\",\"type\":\"generate\",\"ts\":\"<ISO>\",\"event\":{\"id\":\"ab12cd34\",\"type\":\"generate\",\"action\":\"bolder\",\"count\":3,\"pageUrl\":\"/\",\"element\":{\"tagName\":\"h1\",\"id\":\"hero\",\"classes\":[\"hero-title\"],\"textContent\":\"Oracle Fixture\",\"outerHTML\":\"<h1 id=\\\"hero\\\" class=\\\"hero-title\\\">Oracle Fixture</h1>\"},\"clientSentAt\":1754042400000}}\n{\"seq\":2,\"id\":\"ab12cd34\",\"type\":\"agent_done\",\"ts\":\"<ISO>\",\"event\":{\"id\":\"ab12cd34\",\"type\":\"agent_done\",\"file\":\"index.html\",\"sourceEventType\":\"generate\",\"carbonize\":false,\"arrivedVariants\":3}}\n{\"seq\":3,\"id\":\"ab12cd34\",\"type\":\"accept\",\"ts\":\"<ISO>\",\"event\":{\"id\":\"ab12cd34\",\"type\":\"accept\",\"variantId\":\"2\",\"pageUrl\":\"/\",\"paramValues\":{\"face\":\"serif\"}}}\n{\"seq\":4,\"id\":\"ab12cd34\",\"type\":\"agent_done\",\"ts\":\"<ISO>\",\"event\":{\"id\":\"ab12cd34\",\"type\":\"agent_done\",\"file\":\"index.html\",\"sourceEventType\":\"accept\",\"carbonize\":true}}\n{\"seq\":5,\"id\":\"ab12cd34\",\"type\":\"complete\",\"ts\":\"<ISO>\",\"event\":{\"type\":\"complete\",\"id\":\"ab12cd34\"}}\n",
".impeccable/live/sessions/ab12cd34.snapshot.json": "{\n \"id\": \"ab12cd34\",\n \"phase\": \"completed\",\n \"pageUrl\": \"/\",\n \"sourceFile\": \"index.html\",\n \"previewFile\": null,\n \"previewMode\": null,\n \"expectedVariants\": 3,\n \"arrivedVariants\": 3,\n \"visibleVariant\": 2,\n \"paramValues\": {\n \"face\": \"serif\"\n },\n \"pendingEventSeq\": null,\n \"pendingEvent\": null,\n \"deliveryLease\": null,\n \"checkpointRevision\": 0,\n \"browserCheckpointRevision\": 0,\n \"publicationCheckpointRevision\": 0,\n \"activeOwner\": null,\n \"sourceMarkers\": {},\n \"fallbackMode\": null,\n \"generationPhase\": null,\n \"generationCompletedAt\": 1785578580000,\n \"generationTimings\": {},\n \"variantPlan\": null,\n \"generationCanceled\": true,\n \"generationCanceledAt\": 1785578520000,\n \"cancelReason\": \"accept\",\n \"annotationArtifacts\": [],\n \"mountedVariants\": [],\n \"mountFailures\": [],\n \"renderState\": \"pending\",\n \"diagnostics\": [\n {\n \"error\": \"carbonize_cleanup_required\",\n \"file\": \"index.html\",\n \"message\": \"Accepted variant still has carbonize markers that must be folded into source CSS.\"\n }\n ],\n \"updatedAt\": \"<ISO>\",\n \"__journalBytes\": 1053,\n \"__nextSeq\": 6\n}\n"
".impeccable/live/sessions/ab12cd34.snapshot.json": "{\n \"id\": \"ab12cd34\",\n \"phase\": \"completed\",\n \"pageUrl\": \"/\",\n \"sourceFile\": \"index.html\",\n \"previewFile\": null,\n \"previewMode\": null,\n \"expectedVariants\": 3,\n \"arrivedVariants\": 3,\n \"visibleVariant\": 2,\n \"paramValues\": {\n \"face\": \"serif\"\n },\n \"pendingEventSeq\": null,\n \"pendingEvent\": null,\n \"deliveryLease\": null,\n \"checkpointRevision\": 0,\n \"browserCheckpointRevision\": 0,\n \"publicationCheckpointRevision\": 0,\n \"activeOwner\": null,\n \"sourceMarkers\": {},\n \"fallbackMode\": null,\n \"generationPhase\": null,\n \"generationCompletedAt\": 1785578580000,\n \"generationTimings\": {},\n \"variantPlan\": null,\n \"generationCanceled\": true,\n \"generationCanceledAt\": 1785578520000,\n \"cancelReason\": \"accept\",\n \"annotationArtifacts\": [],\n \"mountedVariants\": [],\n \"mountFailures\": [],\n \"renderState\": \"pending\",\n \"diagnostics\": [],\n \"updatedAt\": \"<ISO>\",\n \"__journalBytes\": 1053,\n \"__nextSeq\": 6\n}\n"
}
}
+3 -3
View File
@@ -1,13 +1,13 @@
{
"steps": [
{
"stdout": "{\n \"ok\": true,\n \"id\": \"ab12cd34\",\n \"phase\": \"completed\",\n \"snapshot\": {\n \"id\": \"ab12cd34\",\n \"phase\": \"completed\",\n \"pageUrl\": \"/\",\n \"sourceFile\": \"index.html\",\n \"previewFile\": null,\n \"previewMode\": null,\n \"expectedVariants\": 3,\n \"arrivedVariants\": 3,\n \"visibleVariant\": 2,\n \"paramValues\": {\n \"face\": \"serif\"\n },\n \"pendingEventSeq\": null,\n \"pendingEvent\": null,\n \"deliveryLease\": null,\n \"checkpointRevision\": 0,\n \"browserCheckpointRevision\": 0,\n \"publicationCheckpointRevision\": 0,\n \"activeOwner\": null,\n \"sourceMarkers\": {},\n \"fallbackMode\": null,\n \"generationPhase\": null,\n \"generationCompletedAt\": 1785578580000,\n \"generationTimings\": {},\n \"variantPlan\": null,\n \"generationCanceled\": true,\n \"generationCanceledAt\": 1785578520000,\n \"cancelReason\": \"accept\",\n \"annotationArtifacts\": [],\n \"mountedVariants\": [],\n \"mountFailures\": [],\n \"renderState\": \"pending\",\n \"diagnostics\": [\n {\n \"error\": \"carbonize_cleanup_required\",\n \"file\": \"index.html\",\n \"message\": \"Accepted variant still has carbonize markers that must be folded into source CSS.\"\n }\n ],\n \"updatedAt\": \"<ISO>\"\n }\n}\n",
"stdout": "{\n \"ok\": true,\n \"id\": \"ab12cd34\",\n \"phase\": \"completed\",\n \"snapshot\": {\n \"id\": \"ab12cd34\",\n \"phase\": \"completed\",\n \"pageUrl\": \"/\",\n \"sourceFile\": \"index.html\",\n \"previewFile\": null,\n \"previewMode\": null,\n \"expectedVariants\": 3,\n \"arrivedVariants\": 3,\n \"visibleVariant\": 2,\n \"paramValues\": {\n \"face\": \"serif\"\n },\n \"pendingEventSeq\": null,\n \"pendingEvent\": null,\n \"deliveryLease\": null,\n \"checkpointRevision\": 0,\n \"browserCheckpointRevision\": 0,\n \"publicationCheckpointRevision\": 0,\n \"activeOwner\": null,\n \"sourceMarkers\": {},\n \"fallbackMode\": null,\n \"generationPhase\": null,\n \"generationCompletedAt\": 1785578580000,\n \"generationTimings\": {},\n \"variantPlan\": null,\n \"generationCanceled\": true,\n \"generationCanceledAt\": 1785578520000,\n \"cancelReason\": \"accept\",\n \"annotationArtifacts\": [],\n \"mountedVariants\": [],\n \"mountFailures\": [],\n \"renderState\": \"pending\",\n \"diagnostics\": [],\n \"updatedAt\": \"<ISO>\"\n }\n}\n",
"stderr": "",
"exit": 0,
"signal": null
},
{
"stdout": "{\n \"ok\": true,\n \"id\": \"ab12cd34\",\n \"phase\": \"completed\",\n \"snapshot\": {\n \"id\": \"ab12cd34\",\n \"phase\": \"completed\",\n \"pageUrl\": \"/\",\n \"sourceFile\": \"index.html\",\n \"previewFile\": null,\n \"previewMode\": null,\n \"expectedVariants\": 3,\n \"arrivedVariants\": 3,\n \"visibleVariant\": 2,\n \"paramValues\": {\n \"face\": \"serif\"\n },\n \"pendingEventSeq\": null,\n \"pendingEvent\": null,\n \"deliveryLease\": null,\n \"checkpointRevision\": 0,\n \"browserCheckpointRevision\": 0,\n \"publicationCheckpointRevision\": 0,\n \"activeOwner\": null,\n \"sourceMarkers\": {},\n \"fallbackMode\": null,\n \"generationPhase\": null,\n \"generationCompletedAt\": 1785578580000,\n \"generationTimings\": {},\n \"variantPlan\": null,\n \"generationCanceled\": true,\n \"generationCanceledAt\": 1785578520000,\n \"cancelReason\": \"accept\",\n \"annotationArtifacts\": [],\n \"mountedVariants\": [],\n \"mountFailures\": [],\n \"renderState\": \"pending\",\n \"diagnostics\": [\n {\n \"error\": \"carbonize_cleanup_required\",\n \"file\": \"index.html\",\n \"message\": \"Accepted variant still has carbonize markers that must be folded into source CSS.\"\n }\n ],\n \"updatedAt\": \"<ISO>\"\n }\n}\n",
"stdout": "{\n \"ok\": true,\n \"id\": \"ab12cd34\",\n \"phase\": \"completed\",\n \"snapshot\": {\n \"id\": \"ab12cd34\",\n \"phase\": \"completed\",\n \"pageUrl\": \"/\",\n \"sourceFile\": \"index.html\",\n \"previewFile\": null,\n \"previewMode\": null,\n \"expectedVariants\": 3,\n \"arrivedVariants\": 3,\n \"visibleVariant\": 2,\n \"paramValues\": {\n \"face\": \"serif\"\n },\n \"pendingEventSeq\": null,\n \"pendingEvent\": null,\n \"deliveryLease\": null,\n \"checkpointRevision\": 0,\n \"browserCheckpointRevision\": 0,\n \"publicationCheckpointRevision\": 0,\n \"activeOwner\": null,\n \"sourceMarkers\": {},\n \"fallbackMode\": null,\n \"generationPhase\": null,\n \"generationCompletedAt\": 1785578580000,\n \"generationTimings\": {},\n \"variantPlan\": null,\n \"generationCanceled\": true,\n \"generationCanceledAt\": 1785578520000,\n \"cancelReason\": \"accept\",\n \"annotationArtifacts\": [],\n \"mountedVariants\": [],\n \"mountFailures\": [],\n \"renderState\": \"pending\",\n \"diagnostics\": [],\n \"updatedAt\": \"<ISO>\"\n }\n}\n",
"stderr": "",
"exit": 0,
"signal": null
@@ -16,6 +16,6 @@
"files": {
".impeccable/live/config.json": "{\n \"files\": [\"index.html\", \"public/**/*.html\"],\n \"insertBefore\": \"</body>\",\n \"commentSyntax\": \"html\"\n}\n",
".impeccable/live/sessions/ab12cd34.jsonl": "{\"seq\":1,\"id\":\"ab12cd34\",\"type\":\"generate\",\"ts\":\"<ISO>\",\"event\":{\"id\":\"ab12cd34\",\"type\":\"generate\",\"action\":\"bolder\",\"count\":3,\"pageUrl\":\"/\",\"element\":{\"tagName\":\"h1\",\"id\":\"hero\",\"classes\":[\"hero-title\"],\"textContent\":\"Oracle Fixture\",\"outerHTML\":\"<h1 id=\\\"hero\\\" class=\\\"hero-title\\\">Oracle Fixture</h1>\"},\"clientSentAt\":1754042400000}}\n{\"seq\":2,\"id\":\"ab12cd34\",\"type\":\"agent_done\",\"ts\":\"<ISO>\",\"event\":{\"id\":\"ab12cd34\",\"type\":\"agent_done\",\"file\":\"index.html\",\"sourceEventType\":\"generate\",\"carbonize\":false,\"arrivedVariants\":3}}\n{\"seq\":3,\"id\":\"ab12cd34\",\"type\":\"accept\",\"ts\":\"<ISO>\",\"event\":{\"id\":\"ab12cd34\",\"type\":\"accept\",\"variantId\":\"2\",\"pageUrl\":\"/\",\"paramValues\":{\"face\":\"serif\"}}}\n{\"seq\":4,\"id\":\"ab12cd34\",\"type\":\"agent_done\",\"ts\":\"<ISO>\",\"event\":{\"id\":\"ab12cd34\",\"type\":\"agent_done\",\"file\":\"index.html\",\"sourceEventType\":\"accept\",\"carbonize\":true}}\n{\"seq\":5,\"id\":\"ab12cd34\",\"type\":\"complete\",\"ts\":\"<ISO>\",\"event\":{\"type\":\"complete\",\"id\":\"ab12cd34\"}}\n{\"seq\":6,\"id\":\"ab12cd34\",\"type\":\"complete\",\"ts\":\"<ISO>\",\"event\":{\"type\":\"complete\",\"id\":\"ab12cd34\"}}\n",
".impeccable/live/sessions/ab12cd34.snapshot.json": "{\n \"id\": \"ab12cd34\",\n \"phase\": \"completed\",\n \"pageUrl\": \"/\",\n \"sourceFile\": \"index.html\",\n \"previewFile\": null,\n \"previewMode\": null,\n \"expectedVariants\": 3,\n \"arrivedVariants\": 3,\n \"visibleVariant\": 2,\n \"paramValues\": {\n \"face\": \"serif\"\n },\n \"pendingEventSeq\": null,\n \"pendingEvent\": null,\n \"deliveryLease\": null,\n \"checkpointRevision\": 0,\n \"browserCheckpointRevision\": 0,\n \"publicationCheckpointRevision\": 0,\n \"activeOwner\": null,\n \"sourceMarkers\": {},\n \"fallbackMode\": null,\n \"generationPhase\": null,\n \"generationCompletedAt\": 1785578580000,\n \"generationTimings\": {},\n \"variantPlan\": null,\n \"generationCanceled\": true,\n \"generationCanceledAt\": 1785578520000,\n \"cancelReason\": \"accept\",\n \"annotationArtifacts\": [],\n \"mountedVariants\": [],\n \"mountFailures\": [],\n \"renderState\": \"pending\",\n \"diagnostics\": [\n {\n \"error\": \"carbonize_cleanup_required\",\n \"file\": \"index.html\",\n \"message\": \"Accepted variant still has carbonize markers that must be folded into source CSS.\"\n }\n ],\n \"updatedAt\": \"<ISO>\",\n \"__journalBytes\": 1173,\n \"__nextSeq\": 7\n}\n"
".impeccable/live/sessions/ab12cd34.snapshot.json": "{\n \"id\": \"ab12cd34\",\n \"phase\": \"completed\",\n \"pageUrl\": \"/\",\n \"sourceFile\": \"index.html\",\n \"previewFile\": null,\n \"previewMode\": null,\n \"expectedVariants\": 3,\n \"arrivedVariants\": 3,\n \"visibleVariant\": 2,\n \"paramValues\": {\n \"face\": \"serif\"\n },\n \"pendingEventSeq\": null,\n \"pendingEvent\": null,\n \"deliveryLease\": null,\n \"checkpointRevision\": 0,\n \"browserCheckpointRevision\": 0,\n \"publicationCheckpointRevision\": 0,\n \"activeOwner\": null,\n \"sourceMarkers\": {},\n \"fallbackMode\": null,\n \"generationPhase\": null,\n \"generationCompletedAt\": 1785578580000,\n \"generationTimings\": {},\n \"variantPlan\": null,\n \"generationCanceled\": true,\n \"generationCanceledAt\": 1785578520000,\n \"cancelReason\": \"accept\",\n \"annotationArtifacts\": [],\n \"mountedVariants\": [],\n \"mountFailures\": [],\n \"renderState\": \"pending\",\n \"diagnostics\": [],\n \"updatedAt\": \"<ISO>\",\n \"__journalBytes\": 1173,\n \"__nextSeq\": 7\n}\n"
}
}
@@ -26,7 +26,7 @@
"signal": null
},
{
"stdout": "",
"stdout": "{\"ok\":true,\"id\":\"ab12cd34\",\"status\":\"done\",\"file\":\"index.html\",\"_instructions\":\"Poll again now.\"}\n",
"stderr": "",
"exit": 0,
"signal": null
@@ -44,7 +44,7 @@
"signal": null
},
{
"stdout": "",
"stdout": "{\"ok\":true,\"id\":\"ab12cd34\",\"status\":\"steer_done\",\"_instructions\":\"Poll again now.\"}\n",
"stderr": "",
"exit": 0,
"signal": null
+19 -1
View File
@@ -4,7 +4,7 @@ import fs from 'node:fs';
import path from 'node:path';
import { MockLanguageModelV3 } from 'ai/test';
import { prepareWorkspace, cleanupWorkspace, makeTools, runTurn, fileLoaded, SKILL_BODY } from './skill-behavior/harness.mjs';
import { assertPlanningFallbackWarning, assertNewWorkLifecycle, assertWorkflowAdvice, assertCommandComparison, missingReferences } from './skill-behavior/assertions.mjs';
import { assertLauncherDenialWarningBeforeNextTool, assertPlanningFallbackWarning, assertNewWorkLifecycle, assertWorkflowAdvice, assertCommandComparison, missingReferences } from './skill-behavior/assertions.mjs';
import { CASE_STUDY_ANSWER } from './skill-behavior/fixtures.mjs';
import { sourceHash as hashSources } from './skill-workflow/source-hash.mjs';
import { assertCompleted, assertFreshCaptures, assertNoChangeDocumentation, assertDocumentationArtifacts } from './skill-workflow/assertions.mjs';
@@ -186,6 +186,24 @@ it('headless behavior shells disable unattended decision pages and omit provider
}
});
it('documentation fallback requires an assistant warning before the first tool call after denial', () => {
const call = { role: 'assistant', content: [{ type: 'tool-call', toolCallId: 'context', toolName: 'bash', input: { command: '.claude/skills/impeccable/scripts/impeccable context' } }] };
const denial = { role: 'tool', content: [{ type: 'tool-result', toolCallId: 'context', toolName: 'bash', output: { type: 'text', value: 'Error: Bash permission denied by the host. This command was not executed.' } }] };
const warning = { role: 'assistant', content: 'Context loading did not run because the launcher was denied.' };
const read = { role: 'assistant', content: [{ type: 'tool-call', toolCallId: 'read', toolName: 'read', input: { path: 'reference/document.md' } }] };
const write = { role: 'assistant', content: [{ type: 'tool-call', toolCallId: 'write', toolName: 'write', input: { path: 'DESIGN.md' } }] };
assert.doesNotThrow(() => assertLauncherDenialWarningBeforeNextTool([call, denial, warning, read, write]));
assert.doesNotThrow(() => assertLauncherDenialWarningBeforeNextTool([call, denial, { role: 'assistant', content: [{ type: 'text', text: warning.content }, ...read.content] }, write]));
for (const messages of [
[call, denial, read, warning, write], // Reads first, warns only before the write.
[call, denial, read, write, warning], // Final-only disclosure.
[warning, call, denial, read], // Not a response to the actual denial.
[call, denial, warning], // Warning with no follow-up tool call.
]) {
assert.throws(() => assertLauncherDenialWarningBeforeNextTool(messages), assert.AssertionError);
}
});
it('planning fallback requires an assistant warning between the denial and context reads', () => {
const call = { role: 'assistant', content: [{ type: 'tool-call', toolCallId: 'context', toolName: 'bash', input: { command: '.claude/skills/impeccable/scripts/impeccable context' } }] };
const denial = { role: 'tool', content: [{ type: 'tool-result', toolCallId: 'context', toolName: 'bash', output: { type: 'text', value: 'Error: Bash permission denied by the host. This command was not executed.' } }] };
+15 -1
View File
@@ -321,7 +321,7 @@ results remain the completed measurements.
| 16 | existing surface, with and without PRODUCT.md; asks where to start | completes relevant advice without edits, interviews, critique archives, menu scans, or explicit invented refinement prerequisites; reference coverage is diagnostic |
| 17 | existing surface; asks whether critique is required before polish | completes read-only advice distinguishing assessment from implementation and explaining critique is optional; reference coverage is diagnostic |
| 18 | existing surface; explicitly requests polish followed by a next-command recommendation | loads `polish.md` rather than substituting workflow advice for the requested work |
| 19 | tiny spacing edit with PRODUCT.md + DESIGN.md; Bash denied, a real-loader success control, and a denied-launcher planning-only case | edits require successful playbook/craft-floor reads and a pre-edit denial warning; planning stays read-only and skips craft-floor |
| 19 | tiny spacing edit with PRODUCT.md + DESIGN.md; Bash denied, a real-loader success control, a denied-launcher planning-only case, and a denied-launcher documentation case (PRODUCT.md + index.html, no DESIGN.md) | edits require successful playbook/craft-floor reads and a pre-edit denial warning; planning stays read-only and skips craft-floor; documentation requires successful document.md and source reads before any DESIGN.md write, with the denial disclosed before the first tool call after the denied launcher |
## Setup launcher-failure branch (2026-09-06, PR #750)
@@ -405,6 +405,20 @@ IMPECCABLE_SKILL_BEHAVIOR_MODELS=claude-sonnet-5,gpt-5.6-terra,gemini-3.7-flash,
node --test --test-name-pattern='scenario 19:' tests/skill-behavior/scenarios.test.mjs
```
### Degraded documentation case (#789)
A fourth scenario 19 case covers the documentation branch: launcher denied,
PRODUCT.md plus an incumbent `index.html`, prompt `/impeccable document`.
It requires an actual denied context attempt, successful `document.md` and
source reads before the DESIGN.md write, the denial disclosed before the
first tool call after the denied launcher, and an untouched PRODUCT.md.
Scenario 19 on this branch, all four cases, one run each on 2026-09-09:
4/4 on claude-sonnet-5, 4/4 on gpt-5.6-terra, 4/4 on gemini-3.7-flash. The
documentation case also passes with main's unchanged `SKILL.src.md` and
`init.md` plus this test file, so the coverage is the test, not a skill-text
change.
## Workflow-advice baseline (2026-09-05, PR #737)
The four cases in scenarios 16-18 are new; prior scenario results do not
+24 -3
View File
@@ -61,17 +61,25 @@ export function assertNewWorkLifecycle(trace, { target, redesign = false }) {
export const LAUNCHER_FAILURE_WARNING = /(?:context|launcher|bash)[^.!?\n]{0,160}(?:denied|refused|unavailable|blocked|could(?:n't| not)|cannot|can't|did(?:n't| not)|fail|unable)|(?:denied|refused|unavailable|blocked|could(?:n't| not)|cannot|can't|unable)[^.!?\n]{0,160}(?:context|launcher|bash)/i;
export function assertPlanningFallbackWarning(responseMessages) {
const blocks = responseMessages.flatMap((message) =>
function responseBlocks(responseMessages) {
return responseMessages.flatMap((message) =>
(typeof message.content === 'string' ? [{ type: 'text', text: message.content }] : message.content)
.map((block) => ({ ...block, role: message.role })),
);
}
function contextLauncherDenialIndex(blocks) {
const contextCalls = new Set(blocks.filter((block) => block.role === 'assistant'
&& block.type === 'tool-call' && block.toolName === 'bash'
&& /impeccable\s+context\b/.test(block.input?.command ?? '')).map((block) => block.toolCallId));
const denialIndex = blocks.findIndex((block) => block.role === 'tool'
return blocks.findIndex((block) => block.role === 'tool'
&& block.type === 'tool-result' && contextCalls.has(block.toolCallId)
&& block.output?.type === 'text' && /Bash permission denied by the host/.test(block.output.value));
}
export function assertPlanningFallbackWarning(responseMessages) {
const blocks = responseBlocks(responseMessages);
const denialIndex = contextLauncherDenialIndex(blocks);
assert.ok(denialIndex >= 0, 'must observe the context launcher denial in the response sequence');
const warningIndex = blocks.findIndex((block, index) => index > denialIndex
&& block.role === 'assistant' && block.type === 'text' && LAUNCHER_FAILURE_WARNING.test(block.text));
@@ -81,3 +89,16 @@ export function assertPlanningFallbackWarning(responseMessages) {
assert.ok(warningIndex > denialIndex && contextReadIndex > warningIndex,
'planning fallback must warn after denial and before reading project context, not only in the final response');
}
export function assertLauncherDenialWarningBeforeNextTool(responseMessages) {
const blocks = responseBlocks(responseMessages);
const denialIndex = contextLauncherDenialIndex(blocks);
assert.ok(denialIndex >= 0, 'must observe the context launcher denial in the response sequence');
const warningIndex = blocks.findIndex((block, index) => index > denialIndex
&& block.role === 'assistant' && block.type === 'text' && LAUNCHER_FAILURE_WARNING.test(block.text));
const nextToolIndex = blocks.findIndex((block, index) => index > denialIndex
&& block.role === 'assistant' && block.type === 'tool-call');
assert.ok(nextToolIndex >= 0, 'must continue with a tool call after the denied launcher');
assert.ok(warningIndex > denialIndex && nextToolIndex > warningIndex,
'must disclose the failed context launcher before the first tool call after the denial, not only before the eventual write');
}
+30 -1
View File
@@ -28,7 +28,7 @@ import {
ENGINE_MISSING_MESSAGE,
} from './harness.mjs';
import { detectProvider, getModel, hasKey, resolveModelList, PROVIDERS } from './providers.mjs';
import { assertPlanningFallbackWarning, LAUNCHER_FAILURE_WARNING, assertAdviceOnly, assertWorkflowAdvice, assertCommandComparison, missingReferences } from './assertions.mjs';
import { assertLauncherDenialWarningBeforeNextTool, assertPlanningFallbackWarning, LAUNCHER_FAILURE_WARNING, assertAdviceOnly, assertWorkflowAdvice, assertCommandComparison, missingReferences } from './assertions.mjs';
import { assertCompleted } from '../skill-workflow/assertions.mjs';
import {
PRODUCT_MD_SAMPLE,
@@ -708,6 +708,35 @@ for (const modelId of resolveModelList()) {
});
}
it('scenario 19: denied launcher requires document.md before writing DESIGN.md', async () => {
const workspace = prepareWorkspace({ files: {
'PRODUCT.md': PRODUCT_MD_SAMPLE,
'index.html': MINIMAL_LANDING_HTML,
} });
try {
const { trace, stepTexts, finishReason, responseMessages } = await runTurn({
workspace,
model,
userPrompt: '/impeccable document. Record the incumbent design system from index.html into DESIGN.md.',
maxSteps: 14,
denyBash: true,
});
logTrace('S19', 'denied-launcher-document', modelId, trace, { finishReason, text: stepTexts.join('\n') });
assert.notEqual(finishReason, 'length', 'a truncated response is not a completed documentation pass');
assert.ok(trace.toolCalls.some((call) => call.name === 'bash' && call.denied && /impeccable\s+context\b/.test(call.input.command)), 'must encounter an actual denied context attempt');
const designWriteIndex = trace.toolCalls.findIndex((call) => call.mutatedPaths.some((p) => /(?:^|\/)DESIGN\.md$/.test(p)));
assert.ok(designWriteIndex >= 0, 'must still produce DESIGN.md, not stop at the refusal');
const documentReadIndex = trace.toolCalls.findIndex((call) => call.name === 'read' && call.succeeded && /(?:^|\/)reference\/document\.md$/.test(call.input.path));
assert.ok(documentReadIndex >= 0 && documentReadIndex < designWriteIndex, 'reference/document.md must actually be read before DESIGN.md is written');
const sourceReadIndex = trace.toolCalls.findIndex((call) => call.name === 'read' && call.succeeded && call.input.path.endsWith('index.html'));
assert.ok(sourceReadIndex >= 0 && sourceReadIndex < designWriteIndex, 'the incumbent source must be read before DESIGN.md is written');
assertLauncherDenialWarningBeforeNextTool(responseMessages);
assert.ok(!trace.toolCalls.some((call) => call.mutatedPaths.some((p) => /(?:^|\/)PRODUCT\.md$/.test(p))), 'must not rewrite PRODUCT.md');
} finally {
cleanupWorkspace(workspace);
}
});
it('scenario 19: denied launcher keeps planning-only work read-only without craft-floor', async () => {
const workspace = prepareWorkspace({ files: {
'PRODUCT.md': PRODUCT_MD_SAMPLE,

Some files were not shown because too many files have changed in this diff Show More