mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-12 06:06:37 +03:00
Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b50098a477 | ||
|
|
1d214f1e48 | ||
|
|
cb56ed6c19 | ||
|
|
3e1f67c52c | ||
|
|
0c09f4c7e2 | ||
|
|
7a7579858c | ||
|
|
67d018fe05 | ||
|
|
3bdb9ff06c |
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -461,13 +461,16 @@ npx impeccable detect --no-config src/ # raw scan, ignoring project config
|
||||
npx impeccable ignores list # show detector ignores
|
||||
npx impeccable ignores add-file "src/legacy/**"
|
||||
npx impeccable ignores add-value overused-font Inter --reason "Brand font"
|
||||
npx impeccable ignores add-selector undersized-ui-text ".ks-tag" --reason "10px mono label, by design"
|
||||
```
|
||||
|
||||
The detector catches 61 deterministic issues across AI slop (side-tab borders, purple gradients, bounce easing, dark glows) and general design quality (line length, cramped padding, small touch targets, skipped headings, and more).
|
||||
|
||||
Human-readable findings are diagnostics written to stderr, so redirect them with `2> findings.txt`. Use `--json` for machine-readable results on stdout. Exit `0` means the scan completed without primary findings, exit `2` means it completed with primary findings, and exit `1` means at least one requested target could not be scanned; operational failure takes precedence for a partial multi-target scan. URL scans inspect the rendered DOM, computed layout, and accessible linked stylesheets; browser security still prevents reading cross-origin CSS without CORS. A clean detector run is evidence, not proof of visual or accessibility quality: it does not replace inspecting the rendered experience across relevant viewports.
|
||||
|
||||
By default, `detect` respects the same `.impeccable/config.json` and `.impeccable/config.local.json` detector config as the design hook: `detector.ignoreRules`, `detector.ignoreFiles`, `detector.ignoreValues`, and `detector.designSystem.enabled`. Hook lifecycle settings such as `hook.enabled` only affect automatic hook execution.
|
||||
By default, `detect` respects the same `.impeccable/config.json` and `.impeccable/config.local.json` detector config as the design hook: `detector.ignoreRules`, `detector.ignoreFiles`, `detector.ignoreValues`, `detector.ignoreSelectors`, and `detector.designSystem.enabled`. Hook lifecycle settings such as `hook.enabled` only affect automatic hook execution.
|
||||
|
||||
`detector.ignoreSelectors` is the component-level opt-out. One entry, written by `ignores add-selector <rule> "<selector>"`, waives that rule for every element the CSS selector matches and for that element's subtree, so a component with eleven instances takes one line of config instead of eleven `data-impeccable-ignore` attributes in the markup. What it suppressed is never silent: each scan prints a line per entry on stderr, `3 undersized-ui-text hits ignored by detector.ignoreSelectors on .ks-tag.`, in JSON mode too, so a reviewer sees the count next to the findings.
|
||||
|
||||
For a waiver that should travel with one file instead of the repo config, add an inline comment in the file: `<!-- impeccable-disable overused-font: exported brand doc -->`. The marker works in any comment syntax, scopes to the whole file (or one line with `impeccable-disable-line` / `impeccable-disable-next-line`), and is bypassed by `--no-inline-ignores` or `--no-config`.
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -73,6 +73,11 @@ if (IS_BROWSER && !__impeccable) {
|
||||
// applies them where the findings are assembled, because the overlay
|
||||
// draws its markers from the collected findings.
|
||||
disabledValues: Array.isArray(config.disabledValues) ? config.disabledValues : [],
|
||||
// detector.ignoreSelectors: the project's component-level opt-outs,
|
||||
// [{ rule, selector }]. The core waives a finding on any element the
|
||||
// selector matches, and on its subtree, the way the
|
||||
// data-impeccable-ignore attribute waives the element carrying it.
|
||||
ignoreSelectors: Array.isArray(config.ignoreSelectors) ? config.ignoreSelectors : [],
|
||||
designSystem: config.designSystem == null ? null : config.designSystem,
|
||||
lineLengthMax: config.lineLengthMax == null ? null : config.lineLengthMax,
|
||||
skipScan: config.skipScan === true,
|
||||
|
||||
@@ -111,6 +111,11 @@
|
||||
extensionMode: true,
|
||||
disabledRules: Array.isArray(config.disabledRules) ? config.disabledRules : [],
|
||||
disabledValues: Array.isArray(config.disabledValues) ? config.disabledValues : [],
|
||||
// detector.ignoreSelectors: the project's component-level opt-outs,
|
||||
// [{ rule, selector }]. The core waives a finding on any element the
|
||||
// selector matches, and on its subtree, the way the
|
||||
// data-impeccable-ignore attribute waives the element carrying it.
|
||||
ignoreSelectors: Array.isArray(config.ignoreSelectors) ? config.ignoreSelectors : [],
|
||||
designSystem: config.designSystem == null ? null : config.designSystem,
|
||||
lineLengthMax: config.lineLengthMax == null ? null : config.lineLengthMax,
|
||||
skipScan: config.skipScan === true,
|
||||
|
||||
@@ -233,6 +233,9 @@ struct RawResult {
|
||||
snippet: String,
|
||||
ignore_value: String,
|
||||
severity: String,
|
||||
/// The `detector.ignoreSelectors` selector that waived this finding, when
|
||||
/// one did. Empty for everything else.
|
||||
ignored_by: String,
|
||||
}
|
||||
|
||||
fn cdp_err(e: CdpError) -> EngineError {
|
||||
@@ -384,6 +387,12 @@ fn detect_url_impl(
|
||||
item.extras
|
||||
.insert("ignoreValue".into(), Value::String(r.ignore_value));
|
||||
}
|
||||
if !r.ignored_by.is_empty() {
|
||||
item.extras.insert(
|
||||
impeccable_core::findings::IGNORED_BY_KEY.into(),
|
||||
Value::String(r.ignored_by),
|
||||
);
|
||||
}
|
||||
if !r.severity.is_empty() && r.severity != item.severity {
|
||||
item.severity = r.severity;
|
||||
}
|
||||
@@ -459,6 +468,7 @@ fn scan_page_inner(
|
||||
let config = snapshot_engine::browser_config(
|
||||
serialize_design_system_for_browser(options.design_system.as_deref()),
|
||||
options.rule_pack,
|
||||
options.ignore_selectors.clone(),
|
||||
);
|
||||
|
||||
// Deterministic pass: capture the page and run the rule core natively over
|
||||
@@ -486,6 +496,7 @@ fn scan_page_inner(
|
||||
id: js_str(f.get("type")),
|
||||
snippet: js_str(f.get("detail")),
|
||||
ignore_value: js_str_or_empty(f.get("ignoreValue")),
|
||||
ignored_by: js_str_or_empty(f.get("ignoredBy")),
|
||||
severity: js_str_or_empty(f.get("severity")),
|
||||
});
|
||||
}
|
||||
@@ -515,6 +526,7 @@ fn scan_page_inner(
|
||||
id: f.id,
|
||||
snippet: f.snippet,
|
||||
ignore_value: String::new(),
|
||||
ignored_by: String::new(),
|
||||
severity: String::new(),
|
||||
})
|
||||
.collect(),
|
||||
@@ -527,6 +539,7 @@ fn scan_page_inner(
|
||||
id: "script-error".to_string(),
|
||||
snippet: message,
|
||||
ignore_value: String::new(),
|
||||
ignored_by: String::new(),
|
||||
severity: String::new(),
|
||||
});
|
||||
}
|
||||
@@ -535,7 +548,24 @@ fn scan_page_inner(
|
||||
snapshot_engine::analyze_visual_contrast(page, &base, 12.0, true)
|
||||
})
|
||||
.map_err(cdp_err)?;
|
||||
let visual = run_visual_contrast_fallback(page, &analyses, &serialized_groups, viewport, profile, url)?;
|
||||
// The visual pass produces findings outside `collect_browser_findings`,
|
||||
// so the component-level opt-outs are applied here against the same
|
||||
// post-reveal snapshot, keyed on each candidate's own selector.
|
||||
let waive = |selector: &str, rule: &str| -> String {
|
||||
use impeccable_core::browser::Dom as _;
|
||||
if config.ignore_selectors.is_empty() || selector.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
let Ok(Some(el)) = base.query_one(None, selector) else {
|
||||
return String::new();
|
||||
};
|
||||
impeccable_core::selector_ignores::waiving_selector(&config.ignore_selectors, rule, |sel| {
|
||||
matches!(base.closest(el, sel), Ok(Some(_)))
|
||||
})
|
||||
.unwrap_or_default()
|
||||
.to_string()
|
||||
};
|
||||
let visual = run_visual_contrast_fallback(page, &analyses, &serialized_groups, viewport, profile, url, &waive)?;
|
||||
results.extend(visual);
|
||||
Ok(results)
|
||||
}
|
||||
@@ -566,6 +596,7 @@ fn reveal_sweep(page: &mut Page<'_>) -> Result<(), CdpError> {
|
||||
/// target)`: the JS post-processing of the analytic/canvas analyses
|
||||
/// (`analyzeVisualContrast`, computed natively in [`snapshot_engine`]) plus the
|
||||
/// screenshot pixel fallback for candidates the analyses left unresolved.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn run_visual_contrast_fallback(
|
||||
page: &mut Page<'_>,
|
||||
browser_analyses: &[Value],
|
||||
@@ -573,6 +604,9 @@ fn run_visual_contrast_fallback(
|
||||
viewport: Viewport,
|
||||
profile: Option<&DetectorProfile>,
|
||||
target: &str,
|
||||
// `(candidate selector, rule id) -> the detector.ignoreSelectors selector
|
||||
// that waives it, or empty`.
|
||||
waive: &dyn Fn(&str, &str) -> String,
|
||||
) -> Result<Vec<RawResult>, EngineError> {
|
||||
let existing_low_contrast: Vec<String> = serialized_groups
|
||||
.iter()
|
||||
@@ -598,12 +632,18 @@ fn run_visual_contrast_fallback(
|
||||
.iter()
|
||||
.any(|s| Some(s.as_str()) == r.get("selector").and_then(Value::as_str))
|
||||
})
|
||||
.filter_map(|r| r.get("finding"))
|
||||
.map(|f| RawResult {
|
||||
id: js_str(f.get("id")),
|
||||
snippet: js_str(f.get("snippet")),
|
||||
ignore_value: String::new(),
|
||||
severity: String::new(),
|
||||
.map(|r| {
|
||||
let selector = r.get("selector").and_then(Value::as_str).unwrap_or("");
|
||||
let f = r.get("finding").expect("filtered on a truthy finding");
|
||||
let id = js_str(f.get("id"));
|
||||
let ignored_by = waive(selector, &id);
|
||||
RawResult {
|
||||
id,
|
||||
snippet: js_str(f.get("snippet")),
|
||||
ignore_value: String::new(),
|
||||
ignored_by,
|
||||
severity: String::new(),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
@@ -635,6 +675,11 @@ fn run_visual_contrast_fallback(
|
||||
})
|
||||
.collect();
|
||||
for candidate in filtered {
|
||||
let candidate_selector = candidate
|
||||
.get("selector")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
let result = step_findings(profile, "visual-contrast", "pixel-diff", target, || {
|
||||
let f = screenshot_contrast::capture_visual_contrast_candidate(
|
||||
page,
|
||||
@@ -644,10 +689,12 @@ fn run_visual_contrast_fallback(
|
||||
.map_err(cdp_err)?;
|
||||
Ok::<_, EngineError>(
|
||||
f.map(|f| {
|
||||
let ignored_by = waive(&candidate_selector, f.id);
|
||||
vec![RawResult {
|
||||
id: f.id.to_string(),
|
||||
snippet: f.snippet,
|
||||
ignore_value: String::new(),
|
||||
ignored_by,
|
||||
severity: String::new(),
|
||||
}]
|
||||
})
|
||||
|
||||
@@ -186,11 +186,13 @@ pub fn resolve_needs<T>(
|
||||
pub fn browser_config(
|
||||
design_system: Value,
|
||||
rule_pack: Option<&'static dyn impeccable_core::rule_pack::RulePack>,
|
||||
ignore_selectors: Vec<impeccable_core::selector_ignores::SelectorIgnore>,
|
||||
) -> BrowserConfig {
|
||||
BrowserConfig {
|
||||
extension_mode: false,
|
||||
disabled_rules: Vec::new(),
|
||||
disabled_values: Vec::new(),
|
||||
ignore_selectors,
|
||||
skip_scan: false,
|
||||
design_system: if design_system.is_null() {
|
||||
None
|
||||
|
||||
@@ -39,7 +39,14 @@ const KNOWN_CONFIG_KEYS: [&str; 8] =
|
||||
["hook", "detector", "updateCheck", "stalenessCheck", "projectRoots", "buildPath", "$schema", "version"];
|
||||
const BUILD_PATH_VALUES: [&str; 2] = ["comp", "code"];
|
||||
const DIRECTION_WORK_PATHS: [&str; 2] = [".impeccable/surfaces", ".impeccable/mocks/decision"];
|
||||
const KNOWN_DETECTOR_KEYS: [&str; 5] = ["ignoreRules", "ignoreFiles", "ignoreValues", "designSystem", "extensions"];
|
||||
const KNOWN_DETECTOR_KEYS: [&str; 6] = [
|
||||
"ignoreRules",
|
||||
"ignoreFiles",
|
||||
"ignoreValues",
|
||||
"ignoreSelectors",
|
||||
"designSystem",
|
||||
"extensions",
|
||||
];
|
||||
|
||||
struct NativeEvidence {
|
||||
platform: &'static str,
|
||||
|
||||
@@ -141,12 +141,31 @@ pub fn check_detector_ignores(project_root: &str, known_rule_ids: Option<&[Strin
|
||||
continue;
|
||||
}
|
||||
let rel = to_relative(Some(&fp), project_root).unwrap();
|
||||
if let (Some(known), Some(rules)) = (known_rule_ids, detector.get("ignoreRules").and_then(|v| v.as_array())) {
|
||||
let unknown: Vec<String> = rules
|
||||
.iter()
|
||||
.map(|r| js_trim(&js_string_or_empty(r)).to_lowercase())
|
||||
.filter(|r| !r.is_empty() && r != "*" && !known.contains(r))
|
||||
.collect();
|
||||
if let Some(known) = known_rule_ids {
|
||||
let rules = detector
|
||||
.get("ignoreRules")
|
||||
.and_then(|v| v.as_array())
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
// A component ignore names a rule too, and a typo there is the
|
||||
// same dead entry: it waives nothing and nobody hears about it.
|
||||
let selector_rules: Vec<Value> = detector
|
||||
.get("ignoreSelectors")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|list| {
|
||||
list.iter()
|
||||
.filter_map(|e| e.get("rule").cloned())
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let mut unknown: Vec<String> = Vec::new();
|
||||
for r in rules.iter().chain(selector_rules.iter()) {
|
||||
let id = js_trim(&js_string_or_empty(r)).to_lowercase();
|
||||
if id.is_empty() || id == "*" || known.contains(&id) || unknown.contains(&id) {
|
||||
continue;
|
||||
}
|
||||
unknown.push(id);
|
||||
}
|
||||
if !unknown.is_empty() {
|
||||
out.push(finding(
|
||||
"detector-ignore-rules-unknown",
|
||||
|
||||
@@ -8,6 +8,7 @@ use super::dom::{tag_lower, Dom, ElId, Rect};
|
||||
use super::element_checks::check_element_borders_dom;
|
||||
use super::{BrowserConfig, BrowserFinding, DisabledValue, FindingGroup};
|
||||
use crate::js_ext_a::JsMap;
|
||||
use impeccable_foundation::selector_ignores::{waiving_selector, SelectorIgnore};
|
||||
use serde::Serialize;
|
||||
|
||||
/// The collect result type is shared.
|
||||
@@ -63,6 +64,41 @@ pub fn add_browser_findings(
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply the project's component-level opt-outs (`detector.ignoreSelectors`)
|
||||
/// to a collected group list.
|
||||
///
|
||||
/// The semantics are the attribute's: an entry waives its rule for every
|
||||
/// element the selector matches and for that element's subtree, which is what
|
||||
/// `element.closest(selector)` answers. Findings are stamped rather than
|
||||
/// dropped, so the layer that owns the ignore list can report "N hits ignored
|
||||
/// by config on `.ks-tag`" instead of quietly reporting nothing.
|
||||
pub fn stamp_selector_ignores(
|
||||
dom: &dyn Dom,
|
||||
groups: &mut [FindingGroup],
|
||||
entries: &[SelectorIgnore],
|
||||
) {
|
||||
if entries.is_empty() {
|
||||
return;
|
||||
}
|
||||
for group in groups.iter_mut() {
|
||||
// Handle 0 is JS null (a missing document.body): nothing to match.
|
||||
if group.el == 0 {
|
||||
continue;
|
||||
}
|
||||
for f in group.findings.iter_mut() {
|
||||
if f.ignored_by.is_some() {
|
||||
continue;
|
||||
}
|
||||
let el = group.el;
|
||||
if let Some(selector) = waiving_selector(entries, &f.type_, |sel| {
|
||||
matches!(dom.closest(el, sel), Ok(Some(_)))
|
||||
}) {
|
||||
f.ignored_by = Some(selector.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Design system (index.mjs) ──────────────────────────────────────────────
|
||||
|
||||
/// The `seen` sets `collectBrowserFindings` threads through the element loop
|
||||
@@ -363,6 +399,7 @@ pub fn check_element_design_system_dom(
|
||||
detail,
|
||||
severity: None,
|
||||
ignore_value: Some(value),
|
||||
ignored_by: None,
|
||||
};
|
||||
|
||||
if ds.has_fonts && browser_has_direct_text(dom, el) {
|
||||
@@ -515,6 +552,7 @@ pub fn check_browser_design_system_sources(
|
||||
),
|
||||
severity: None,
|
||||
ignore_value: Some(display),
|
||||
ignored_by: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -833,6 +871,12 @@ pub fn serialize_findings(dom: &dyn Dom, groups: &[FindingGroup]) -> serde_json:
|
||||
"description".into(),
|
||||
Value::String(ap.map(|a| a.description).unwrap_or("").to_string()),
|
||||
);
|
||||
// Only present when a detector.ignoreSelectors entry waived
|
||||
// this finding, so a scan without the feature serializes
|
||||
// exactly what it always did.
|
||||
if let Some(selector) = f.ignored_by.as_ref() {
|
||||
m.insert("ignoredBy".into(), Value::String(selector.clone()));
|
||||
}
|
||||
Value::Object(m)
|
||||
})
|
||||
.collect();
|
||||
@@ -1464,6 +1508,7 @@ pub fn collect_browser_findings(dom: &dyn Dom, config: &BrowserConfig) -> Collec
|
||||
page_level.retain(|f| !browser_value_ignored(f, &disabled_values));
|
||||
}
|
||||
|
||||
stamp_selector_ignores(dom, &mut groups, &config.ignore_selectors);
|
||||
CollectResult { groups, page_level }
|
||||
}
|
||||
|
||||
@@ -1871,6 +1916,100 @@ mod tests {
|
||||
let pass = json!({ "status": "pass", "selector": "#t", "finding": null });
|
||||
assert_eq!(visual_contrast_result_el(&d, &pass), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_selector_ignores_stamp_the_component_and_its_subtree() {
|
||||
// The #34 shape: one component, many instances, one rule.
|
||||
let mut d = FakeDom::new();
|
||||
let (_h, body) = d.with_page();
|
||||
let mut tags = Vec::new();
|
||||
for _ in 0..3 {
|
||||
let tag = d.add(Some(body), "span");
|
||||
d.add_selector(tag, ".ks-tag");
|
||||
tags.push(tag);
|
||||
}
|
||||
let inner = d.add(Some(tags[0]), "b");
|
||||
let other = d.add(Some(body), "a");
|
||||
d.add_selector(other, ".cta");
|
||||
|
||||
let mut groups: Vec<FindingGroup> = tags
|
||||
.iter()
|
||||
.chain([&inner, &other])
|
||||
.map(|el| FindingGroup {
|
||||
el: *el,
|
||||
findings: vec![
|
||||
BrowserFinding::new("undersized-ui-text", "10px functional text"),
|
||||
BrowserFinding::new("wide-tracking", "letter-spacing: 0.08em"),
|
||||
],
|
||||
})
|
||||
.collect();
|
||||
|
||||
let entries = vec![SelectorIgnore::new("undersized-ui-text", ".ks-tag")];
|
||||
stamp_selector_ignores(&d, &mut groups, &entries);
|
||||
|
||||
// Three instances plus the descendant: waived, and each carries the
|
||||
// selector that waived it rather than vanishing.
|
||||
for g in groups.iter().take(4) {
|
||||
assert_eq!(g.findings[0].ignored_by.as_deref(), Some(".ks-tag"));
|
||||
// Only the named rule is waived.
|
||||
assert_eq!(g.findings[1].ignored_by, None);
|
||||
}
|
||||
// An element outside the component keeps both findings clean.
|
||||
assert_eq!(groups[4].findings[0].ignored_by, None);
|
||||
assert_eq!(groups[4].findings[1].ignored_by, None);
|
||||
|
||||
// Serialization carries the stamp, and only when there is one.
|
||||
let json = serialize_findings(&d, &groups);
|
||||
let first = &json[0]["findings"][0];
|
||||
assert_eq!(first["ignoredBy"], json!(".ks-tag"));
|
||||
assert_eq!(json[0]["findings"][1].get("ignoredBy"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_selector_ignores_are_off_without_entries() {
|
||||
let mut d = FakeDom::new();
|
||||
let (_h, body) = d.with_page();
|
||||
let tag = d.add(Some(body), "span");
|
||||
d.add_selector(tag, ".ks-tag");
|
||||
let mut groups = vec![FindingGroup {
|
||||
el: tag,
|
||||
findings: vec![BrowserFinding::new("undersized-ui-text", "10px")],
|
||||
}];
|
||||
stamp_selector_ignores(&d, &mut groups, &[]);
|
||||
assert_eq!(groups[0].findings[0].ignored_by, None);
|
||||
// A `*` entry waives every rule on the component, as the attribute does.
|
||||
stamp_selector_ignores(&d, &mut groups, &[SelectorIgnore::new("*", ".ks-tag")]);
|
||||
assert_eq!(groups[0].findings[0].ignored_by.as_deref(), Some(".ks-tag"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn browser_config_reads_ignore_selectors_from_the_page_config() {
|
||||
let cfg: BrowserConfig = serde_json::from_str(
|
||||
r#"{"ignoreSelectors":[{"rule":"Undersized-UI-Text","selector":".ks-tag"}]}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(cfg.ignore_selectors.len(), 1);
|
||||
// The parser normalizes, so a page config written by hand still
|
||||
// matches: the rule folds case, the selector keeps it.
|
||||
assert_eq!(cfg.ignore_selectors[0].rule, "undersized-ui-text");
|
||||
assert_eq!(cfg.ignore_selectors[0].selector, ".ks-tag");
|
||||
let bare: BrowserConfig = serde_json::from_str("{}").unwrap();
|
||||
assert!(bare.ignore_selectors.is_empty());
|
||||
// A hand-edited entry of the wrong shape drops itself, never the whole
|
||||
// config: `unwrap_or_default()` at the wasm boundary would otherwise
|
||||
// lose the design system with it.
|
||||
let junk: BrowserConfig = serde_json::from_str(
|
||||
r#"{"lineLengthMax":90,"ignoreSelectors":[{},null,"nope",{"rule":"side-tab"},{"rule":"side-tab","selector":".x"}]}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(junk.ignore_selectors.len(), 1);
|
||||
assert_eq!(junk.line_max(), 90.0);
|
||||
let not_a_list: BrowserConfig =
|
||||
serde_json::from_str(r#"{"ignoreSelectors":"nope"}"#).unwrap();
|
||||
assert!(not_a_list.ignore_selectors.is_empty());
|
||||
// A config without the key serializes without it.
|
||||
assert!(!serde_json::to_string(&bare).unwrap().contains("ignoreSelectors"));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -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 ───────────────────────────────
|
||||
@@ -1279,6 +1309,7 @@ pub fn check_element_blinking_cursor_dom(dom: &dyn Dom, el: ElId) -> Vec<Browser
|
||||
None
|
||||
},
|
||||
ignore_value: None,
|
||||
ignored_by: None,
|
||||
}]
|
||||
}
|
||||
|
||||
@@ -1378,6 +1409,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
@@ -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"));
|
||||
|
||||
@@ -17,7 +17,7 @@ pub mod checks;
|
||||
|
||||
pub use impeccable_foundation::{
|
||||
color, constants, fdlibm_trig, findings, fonts, inline_ignores, js, js_ext_a, js_ext_b, page,
|
||||
registry, rule_pack,
|
||||
registry, rule_pack, selector_ignores,
|
||||
};
|
||||
|
||||
#[cfg(any(test, feature = "vectors"))]
|
||||
|
||||
@@ -9,7 +9,8 @@ use impeccable_core::registry::{filter_by_scopes, rule_scopes};
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::config::{
|
||||
filter_detection_findings, read_detection_config, should_ignore_detection_file, DetectionConfig,
|
||||
filter_detection_findings_reported, read_detection_config, selector_ignores_for_target,
|
||||
selector_ignores_for_url, should_ignore_detection_file, DetectionConfig, IgnoredBySelector,
|
||||
};
|
||||
use crate::design_system::{load_design_system_for_target, DesignSystemCache};
|
||||
use crate::detect_text::{detect_text, TextOptions};
|
||||
@@ -57,7 +58,16 @@ Exit status:
|
||||
Project config:
|
||||
Respects .impeccable/config.json and .impeccable/config.local.json detector
|
||||
settings: detector.ignoreRules, detector.ignoreFiles, detector.ignoreValues,
|
||||
and detector.designSystem.enabled.
|
||||
detector.ignoreSelectors, and detector.designSystem.enabled.
|
||||
|
||||
Component ignores:
|
||||
detector.ignoreSelectors waives one rule for every element a CSS selector
|
||||
matches, and for that element's subtree: one entry for a component instead
|
||||
of a data-impeccable-ignore attribute on each of its instances. Write one
|
||||
with `impeccable ignores add-selector <rule> \"<selector>\"`. Every scan
|
||||
prints what it suppressed on stderr, in --json runs too, so the exception
|
||||
stays visible:
|
||||
3 undersized-ui-text hits ignored by detector.ignoreSelectors on .ks-tag.
|
||||
|
||||
Inline ignores:
|
||||
In-file comments waive a finding where it lives and travel with the file:
|
||||
@@ -207,6 +217,33 @@ fn format_advisory_section(advisory: &[&Finding], stderr_tty: bool) -> String {
|
||||
lines.join("\n")
|
||||
}
|
||||
|
||||
/// The component-level opt-outs that fired on this run, one line each.
|
||||
///
|
||||
/// A `detector.ignoreSelectors` entry waives every instance of a component at
|
||||
/// once, so the only way a reviewer learns it is there is for the scan to say
|
||||
/// what it suppressed. Empty when the project has no such entry, or when the
|
||||
/// entries it has matched nothing, so a scan is unchanged until the feature
|
||||
/// is used.
|
||||
pub fn format_ignored_by_selector(report: &[IgnoredBySelector], stderr_tty: bool) -> String {
|
||||
if report.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
let mut lines = Vec::with_capacity(report.len());
|
||||
for r in report {
|
||||
lines.push(dim(
|
||||
&format!(
|
||||
"{} {} hit{} ignored by detector.ignoreSelectors on {}.",
|
||||
r.count,
|
||||
r.rule,
|
||||
if r.count == 1 { "" } else { "s" },
|
||||
r.selector
|
||||
),
|
||||
stderr_tty,
|
||||
));
|
||||
}
|
||||
lines.join("\n")
|
||||
}
|
||||
|
||||
/// JS: main.mjs#formatFindings
|
||||
pub fn format_findings(findings: &[Finding], json_mode: bool, stderr_tty: bool) -> String {
|
||||
if json_mode {
|
||||
@@ -251,6 +288,26 @@ impl<'a> Ctx<'a> {
|
||||
}
|
||||
|
||||
fn scan_options_for(&mut self, local_path: Option<&str>) -> ScanOptions {
|
||||
let mut options = self.design_system_options_for(local_path);
|
||||
// Component-level opt-outs are per target: an entry with `files`
|
||||
// governs the targets its globs match, an entry without governs all
|
||||
// of them. `--no-config` leaves the list empty, so nothing is waived.
|
||||
options.ignore_selectors =
|
||||
selector_ignores_for_target(&self.config, local_path.unwrap_or_default());
|
||||
options
|
||||
}
|
||||
|
||||
/// The URL scan's options: no local design system to resolve, and only
|
||||
/// the unscoped component ignores, since a `files` glob names repo paths
|
||||
/// rather than URLs.
|
||||
fn url_scan_options(&self) -> ScanOptions {
|
||||
ScanOptions {
|
||||
ignore_selectors: selector_ignores_for_url(&self.config),
|
||||
..self.base.clone()
|
||||
}
|
||||
}
|
||||
|
||||
fn design_system_options_for(&mut self, local_path: Option<&str>) -> ScanOptions {
|
||||
let (Some(local_path), true) = (local_path, self.design_system_enabled) else {
|
||||
return self.base.clone();
|
||||
};
|
||||
@@ -507,6 +564,8 @@ fn detect_cli(args_in: &[String], io: &mut Io, engines: &Engines) -> Result<i32,
|
||||
design_system: None,
|
||||
viewport,
|
||||
profile: None,
|
||||
// Filled in per target: an ignoreSelectors entry can be scoped to files.
|
||||
ignore_selectors: Vec::new(),
|
||||
// The `impeccable` binary installs no rule pack; a library caller that
|
||||
// does sets this before handing the options to an engine.
|
||||
rule_pack: None,
|
||||
@@ -587,7 +646,10 @@ fn detect_cli(args_in: &[String], io: &mut Io, engines: &Engines) -> Result<i32,
|
||||
result?;
|
||||
}
|
||||
|
||||
all = filter_detection_findings(all, &ctx.config);
|
||||
// Findings the engines stamped with a component-level opt-out leave the
|
||||
// reportable set here and come back as a count.
|
||||
let ignored_by_selector;
|
||||
(all, ignored_by_selector) = filter_detection_findings_reported(all, &ctx.config);
|
||||
let scope_refs: Vec<&str> = scopes.iter().map(|s| s.as_str()).collect();
|
||||
all = filter_by_scopes(all, &scope_refs, |f: &Finding| f.antipattern.as_str());
|
||||
if no_advisory {
|
||||
@@ -605,13 +667,22 @@ fn detect_cli(args_in: &[String], io: &mut Io, engines: &Engines) -> Result<i32,
|
||||
} else {
|
||||
0
|
||||
};
|
||||
// The ignored-by-config tally goes to stderr in every mode: in --json,
|
||||
// stdout stays the findings array a consumer parses.
|
||||
let ignored_note = format_ignored_by_selector(&ignored_by_selector, stderr_tty);
|
||||
if !all.is_empty() {
|
||||
if json_mode {
|
||||
let text = format_findings(&all, true, stderr_tty);
|
||||
ctx.io.out(&format!("{text}\n"));
|
||||
if !ignored_note.is_empty() {
|
||||
ctx.io.err(&format!("{ignored_note}\n"));
|
||||
}
|
||||
} else if quiet_mode {
|
||||
ctx.io
|
||||
.err(&format!("{}\n", format_finding_summary(primary_len)));
|
||||
if !ignored_note.is_empty() {
|
||||
ctx.io.err(&format!("{ignored_note}\n"));
|
||||
}
|
||||
if advisory_len > 0 {
|
||||
let note = dim(
|
||||
&format!(
|
||||
@@ -625,12 +696,18 @@ fn detect_cli(args_in: &[String], io: &mut Io, engines: &Engines) -> Result<i32,
|
||||
} else {
|
||||
let text = format_findings(&all, false, stderr_tty);
|
||||
ctx.io.err(&format!("{text}\n"));
|
||||
if !ignored_note.is_empty() {
|
||||
ctx.io.err(&format!("\n{ignored_note}\n"));
|
||||
}
|
||||
}
|
||||
return Ok(exit_code);
|
||||
}
|
||||
if json_mode {
|
||||
ctx.io.out("[]\n");
|
||||
}
|
||||
if !ignored_note.is_empty() {
|
||||
ctx.io.err(&format!("{ignored_note}\n"));
|
||||
}
|
||||
Ok(exit_code)
|
||||
}
|
||||
|
||||
@@ -676,7 +753,7 @@ fn scan_targets(
|
||||
let local = file_url_to_local_path(target);
|
||||
ctx.scan_options_for(local.as_deref())
|
||||
} else {
|
||||
ctx.base.clone()
|
||||
ctx.url_scan_options()
|
||||
};
|
||||
let result = match (shared, ctx.engines.url) {
|
||||
(Some(s), _) => s.detect_url(target, &url_options),
|
||||
|
||||
+359
-2
@@ -5,6 +5,7 @@
|
||||
|
||||
use impeccable_core::findings::Finding;
|
||||
use impeccable_core::js::{self, math_round, number_to_string, parse_float, parse_int};
|
||||
use impeccable_core::selector_ignores::SelectorIgnore;
|
||||
use once_cell::sync::Lazy;
|
||||
use regex::Regex;
|
||||
use serde_json::{Map, Value};
|
||||
@@ -42,6 +43,7 @@ const DETECTOR_CONFIG_KEYS: &[&str] = &[
|
||||
"ignoreRules",
|
||||
"ignoreFiles",
|
||||
"ignoreValues",
|
||||
"ignoreSelectors",
|
||||
"designSystem",
|
||||
"advisoryRules",
|
||||
];
|
||||
@@ -78,6 +80,44 @@ impl IgnoreValueEntry {
|
||||
}
|
||||
}
|
||||
|
||||
/// One normalized `ignoreSelectors` entry: a component-level opt-out.
|
||||
///
|
||||
/// `{ rule, selector }` waives one rule for every element the selector
|
||||
/// matches and for that element's subtree, which is the same waiver
|
||||
/// `data-impeccable-ignore="<rule>"` grants the element that carries it. The
|
||||
/// point is the count: eleven instances of one component take one entry here
|
||||
/// instead of eleven attributes in the markup, and the engine reports what
|
||||
/// the entry suppressed rather than staying silent about it.
|
||||
#[derive(Debug, Clone, PartialEq, Default)]
|
||||
pub struct IgnoreSelectorEntry {
|
||||
pub rule: String,
|
||||
pub selector: String,
|
||||
pub files: Option<Vec<String>>,
|
||||
pub created_at: Option<String>,
|
||||
pub reason: Option<String>,
|
||||
}
|
||||
|
||||
impl IgnoreSelectorEntry {
|
||||
pub fn to_json(&self) -> Value {
|
||||
let mut m = Map::new();
|
||||
m.insert("rule".into(), Value::String(self.rule.clone()));
|
||||
m.insert("selector".into(), Value::String(self.selector.clone()));
|
||||
if let Some(files) = &self.files {
|
||||
m.insert(
|
||||
"files".into(),
|
||||
Value::Array(files.iter().map(|f| Value::String(f.clone())).collect()),
|
||||
);
|
||||
}
|
||||
if let Some(c) = &self.created_at {
|
||||
m.insert("createdAt".into(), Value::String(c.clone()));
|
||||
}
|
||||
if let Some(r) = &self.reason {
|
||||
m.insert("reason".into(), Value::String(r.clone()));
|
||||
}
|
||||
Value::Object(m)
|
||||
}
|
||||
}
|
||||
|
||||
/// The detector config object (`readDetectionConfig` / `readRawDetectionConfig`
|
||||
/// result). `design_system` is `Some` when the JS object carries a
|
||||
/// `designSystem` key.
|
||||
@@ -86,6 +126,7 @@ pub struct DetectionConfig {
|
||||
pub ignore_rules: Vec<String>,
|
||||
pub ignore_files: Vec<String>,
|
||||
pub ignore_values: Vec<IgnoreValueEntry>,
|
||||
pub ignore_selectors: Vec<IgnoreSelectorEntry>,
|
||||
pub design_system_enabled: Option<bool>,
|
||||
pub advisory_rules: Option<String>,
|
||||
}
|
||||
@@ -131,6 +172,9 @@ fn apply_detection_config_source(config: &mut DetectionConfig, raw: Option<&Map<
|
||||
if let Some(Value::Array(values)) = raw.get("ignoreValues") {
|
||||
config.ignore_values = merge_ignore_values(&config.ignore_values, values);
|
||||
}
|
||||
if let Some(Value::Array(selectors)) = raw.get("ignoreSelectors") {
|
||||
config.ignore_selectors = merge_ignore_selectors(&config.ignore_selectors, selectors);
|
||||
}
|
||||
}
|
||||
|
||||
fn unique_strings(values: Vec<String>) -> Vec<String> {
|
||||
@@ -190,6 +234,22 @@ pub fn write_detection_config(
|
||||
for (k, v) in normalize_detection_config_for_write(detector_config) {
|
||||
next_detector.insert(k, v);
|
||||
}
|
||||
// `ignoreSelectors` is written only by a project that uses it, so a config
|
||||
// that never opted into component-level ignores does not grow an empty
|
||||
// key on the next `ignores add-rule`.
|
||||
if !detector_config.ignore_selectors.is_empty()
|
||||
|| next_detector.contains_key("ignoreSelectors")
|
||||
{
|
||||
next_detector.insert(
|
||||
"ignoreSelectors".into(),
|
||||
Value::Array(
|
||||
normalize_ignore_selector_entries_typed(&detector_config.ignore_selectors)
|
||||
.iter()
|
||||
.map(IgnoreSelectorEntry::to_json)
|
||||
.collect(),
|
||||
),
|
||||
);
|
||||
}
|
||||
let mut next = existing.clone();
|
||||
next.insert("detector".into(), Value::Object(next_detector));
|
||||
match next_hook {
|
||||
@@ -643,6 +703,187 @@ fn merge_ignore_values(existing: &[IgnoreValueEntry], incoming: &[Value]) -> Vec
|
||||
map.into_iter().map(|(_, e)| e).collect()
|
||||
}
|
||||
|
||||
/// `normalizeIgnoreValueEntries`' twin for `ignoreSelectors`. The rule is
|
||||
/// lowercased like every other rule id; the selector keeps its case (CSS
|
||||
/// class names are case-sensitive) and only loses surrounding whitespace.
|
||||
/// An entry missing either half is dropped: a selector ignore with no
|
||||
/// selector would be `ignoreRules`, and one with no rule would be
|
||||
/// `ignoreFiles` by another name.
|
||||
pub fn normalize_ignore_selector_entries(entries: &[Value]) -> Vec<IgnoreSelectorEntry> {
|
||||
let mut out = Vec::new();
|
||||
for entry in entries {
|
||||
let Value::Object(entry) = entry else {
|
||||
continue;
|
||||
};
|
||||
let rule = normalize_ignore_rule(
|
||||
&entry
|
||||
.get("rule")
|
||||
.map(js_string_or_empty)
|
||||
.unwrap_or_default(),
|
||||
);
|
||||
let selector = js::trim(
|
||||
&entry
|
||||
.get("selector")
|
||||
.map(js_string_or_empty)
|
||||
.unwrap_or_default(),
|
||||
)
|
||||
.to_string();
|
||||
if rule.is_empty() || selector.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let mut files: Vec<String> = Vec::new();
|
||||
if let Some(Value::String(f)) = entry.get("file") {
|
||||
if !js::trim(f).is_empty() {
|
||||
files.push(js::trim(f).to_string());
|
||||
}
|
||||
}
|
||||
if let Some(Value::Array(list)) = entry.get("files") {
|
||||
for f in list {
|
||||
if let Value::String(f) = f {
|
||||
if !js::trim(f).is_empty() {
|
||||
files.push(js::trim(f).to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let files = unique_strings(files);
|
||||
let mut normalized = IgnoreSelectorEntry {
|
||||
rule,
|
||||
selector,
|
||||
files: if files.is_empty() { None } else { Some(files) },
|
||||
created_at: None,
|
||||
reason: None,
|
||||
};
|
||||
if let Some(Value::String(c)) = entry.get("createdAt") {
|
||||
if !js::trim(c).is_empty() {
|
||||
normalized.created_at = Some(js::trim(c).to_string());
|
||||
}
|
||||
}
|
||||
if let Some(Value::String(r)) = entry.get("reason") {
|
||||
if !js::trim(r).is_empty() {
|
||||
normalized.reason = Some(js::trim(r).to_string());
|
||||
}
|
||||
}
|
||||
out.push(normalized);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The same normalization over already-typed entries (idempotent on write).
|
||||
pub fn normalize_ignore_selector_entries_typed(
|
||||
entries: &[IgnoreSelectorEntry],
|
||||
) -> Vec<IgnoreSelectorEntry> {
|
||||
let raw: Vec<Value> = entries.iter().map(IgnoreSelectorEntry::to_json).collect();
|
||||
normalize_ignore_selector_entries(&raw)
|
||||
}
|
||||
|
||||
fn selector_entry_key(entry: &IgnoreSelectorEntry) -> String {
|
||||
format!(
|
||||
"{}\0{}\0{}",
|
||||
entry.rule,
|
||||
entry.selector,
|
||||
ignore_value_files_key(entry.files.as_ref())
|
||||
)
|
||||
}
|
||||
|
||||
/// Merge raw `ignoreSelectors` JSON into an existing list, later entries
|
||||
/// replacing earlier ones with the same rule + selector + files key. Shared
|
||||
/// with the hook's own config reader.
|
||||
pub fn merge_ignore_selectors(
|
||||
existing: &[IgnoreSelectorEntry],
|
||||
incoming: &[Value],
|
||||
) -> Vec<IgnoreSelectorEntry> {
|
||||
let mut map: Vec<(String, IgnoreSelectorEntry)> = Vec::new();
|
||||
let mut set = |entry: IgnoreSelectorEntry| {
|
||||
let key = selector_entry_key(&entry);
|
||||
if let Some(slot) = map.iter_mut().find(|(k, _)| *k == key) {
|
||||
slot.1 = entry;
|
||||
} else {
|
||||
map.push((key, entry));
|
||||
}
|
||||
};
|
||||
for entry in normalize_ignore_selector_entries_typed(existing) {
|
||||
set(entry);
|
||||
}
|
||||
for entry in normalize_ignore_selector_entries(incoming) {
|
||||
set(entry);
|
||||
}
|
||||
map.into_iter().map(|(_, e)| e).collect()
|
||||
}
|
||||
|
||||
/// The entries that govern one local scan target, as the engines take them.
|
||||
///
|
||||
/// An entry with no `files` covers every target. An entry with `files` covers
|
||||
/// the paths its globs match, tested the way a scoped `ignoreValues` entry is
|
||||
/// (raw path, then each `/`-suffix of it).
|
||||
pub fn selector_ignores_for_target(
|
||||
config: &DetectionConfig,
|
||||
target: &str,
|
||||
) -> Vec<SelectorIgnore> {
|
||||
selector_ignores_filtered(config, |files| path_matches_scoped_globs(target, files))
|
||||
}
|
||||
|
||||
/// The entries that govern a URL scan: the unscoped ones only.
|
||||
///
|
||||
/// `files` globs describe repo paths, and a URL is not one. Matching them
|
||||
/// against the URL would let a glob like `index.html` reach
|
||||
/// `https://example.com/index.html` by accident, scoping an ignore to a page
|
||||
/// the entry never named.
|
||||
pub fn selector_ignores_for_url(config: &DetectionConfig) -> Vec<SelectorIgnore> {
|
||||
selector_ignores_filtered(config, |_| false)
|
||||
}
|
||||
|
||||
fn selector_ignores_filtered(
|
||||
config: &DetectionConfig,
|
||||
covers: impl Fn(&[String]) -> bool,
|
||||
) -> Vec<SelectorIgnore> {
|
||||
normalize_ignore_selector_entries_typed(&config.ignore_selectors)
|
||||
.into_iter()
|
||||
.filter(|e| match &e.files {
|
||||
Some(files) if !files.is_empty() => covers(files),
|
||||
_ => true,
|
||||
})
|
||||
.map(|e| SelectorIgnore::new(&e.rule, &e.selector))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// One `(rule, selector)` pair and how many findings it waived on this run.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct IgnoredBySelector {
|
||||
pub rule: String,
|
||||
pub selector: String,
|
||||
pub count: usize,
|
||||
}
|
||||
|
||||
/// Split the findings the engines stamped with `ignoredBy` out of the
|
||||
/// reportable set, counted by rule and selector in first-seen order. This is
|
||||
/// what turns a component-level opt-out into a number a reviewer can read
|
||||
/// instead of silence.
|
||||
pub fn partition_selector_ignored(findings: Vec<Finding>) -> (Vec<Finding>, Vec<IgnoredBySelector>) {
|
||||
let mut kept = Vec::with_capacity(findings.len());
|
||||
let mut report: Vec<IgnoredBySelector> = Vec::new();
|
||||
for f in findings {
|
||||
match impeccable_core::findings::ignored_by(&f) {
|
||||
Some(selector) => {
|
||||
let rule = normalize_ignore_rule(&f.antipattern);
|
||||
match report
|
||||
.iter_mut()
|
||||
.find(|r| r.rule == rule && r.selector == selector)
|
||||
{
|
||||
Some(slot) => slot.count += 1,
|
||||
None => report.push(IgnoredBySelector {
|
||||
rule,
|
||||
selector: selector.to_string(),
|
||||
count: 1,
|
||||
}),
|
||||
}
|
||||
}
|
||||
None => kept.push(f),
|
||||
}
|
||||
}
|
||||
(kept, report)
|
||||
}
|
||||
|
||||
fn escape_glob_char(c: char) -> bool {
|
||||
matches!(
|
||||
c,
|
||||
@@ -766,8 +1007,24 @@ pub fn should_ignore_detection_file(file_path: &str, root: &str, config: &Detect
|
||||
false
|
||||
}
|
||||
|
||||
/// JS: impeccable-config.mjs#filterDetectionFindings
|
||||
/// JS: impeccable-config.mjs#filterDetectionFindings, plus the
|
||||
/// component-level opt-outs the engines stamped. Callers that want the count
|
||||
/// of what a selector ignore suppressed use
|
||||
/// [`filter_detection_findings_reported`].
|
||||
pub fn filter_detection_findings(findings: Vec<Finding>, config: &DetectionConfig) -> Vec<Finding> {
|
||||
filter_detection_findings_reported(findings, config).0
|
||||
}
|
||||
|
||||
/// `filterDetectionFindings` with the selector-ignore tally alongside it.
|
||||
pub fn filter_detection_findings_reported(
|
||||
findings: Vec<Finding>,
|
||||
config: &DetectionConfig,
|
||||
) -> (Vec<Finding>, Vec<IgnoredBySelector>) {
|
||||
let (findings, report) = partition_selector_ignored(findings);
|
||||
(filter_by_rules_and_values(findings, config), report)
|
||||
}
|
||||
|
||||
fn filter_by_rules_and_values(findings: Vec<Finding>, config: &DetectionConfig) -> Vec<Finding> {
|
||||
if findings.is_empty() {
|
||||
return vec![];
|
||||
}
|
||||
@@ -813,7 +1070,13 @@ fn is_ignored_finding_value(finding: &Finding, ignore_values: &[IgnoreValueEntry
|
||||
}
|
||||
|
||||
fn finding_matches_scoped_ignore_file(finding: &Finding, globs: &[String]) -> bool {
|
||||
let file_path = js::trim(&finding.file);
|
||||
path_matches_scoped_globs(&finding.file, globs)
|
||||
}
|
||||
|
||||
/// JS `findingMatchesScopedIgnoreFile`'s path test: the raw path, then every
|
||||
/// `/`-suffix of it, so `src/a.css` is matched by `a.css` too.
|
||||
fn path_matches_scoped_globs(path: &str, globs: &[String]) -> bool {
|
||||
let file_path = js::trim(path);
|
||||
if file_path.is_empty() {
|
||||
return false;
|
||||
}
|
||||
@@ -1121,4 +1384,98 @@ mod tests {
|
||||
assert_eq!(decode_uri_component("Open%20Sans"), "Open Sans");
|
||||
assert_eq!(decode_uri_component("bad%zz"), "bad%zz");
|
||||
}
|
||||
|
||||
fn config_with_selectors(raw: &str) -> DetectionConfig {
|
||||
let mut config = DetectionConfig::with_defaults();
|
||||
let parsed: Value = serde_json::from_str(raw).unwrap();
|
||||
apply_detection_config_source(&mut config, parsed.as_object());
|
||||
config
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignore_selectors_parse_normalize_and_merge() {
|
||||
let config = config_with_selectors(
|
||||
r#"{"ignoreSelectors":[
|
||||
{"rule":"Undersized-UI-Text","selector":" .ks-tag ","reason":"by design"},
|
||||
{"rule":"","selector":".x"},
|
||||
{"rule":"side-tab"},
|
||||
"nope",
|
||||
{"rule":"undersized-ui-text","selector":".ks-tag","reason":"second word wins"},
|
||||
{"rule":"glow-effect","selector":".demo","files":["src/demo/**"," "]}
|
||||
]}"#,
|
||||
);
|
||||
// Half-entries and junk are dropped, and the same rule+selector+files
|
||||
// key is one entry the later value replaces.
|
||||
assert_eq!(config.ignore_selectors.len(), 2);
|
||||
let first = &config.ignore_selectors[0];
|
||||
assert_eq!(first.rule, "undersized-ui-text");
|
||||
assert_eq!(first.selector, ".ks-tag");
|
||||
assert_eq!(first.reason.as_deref(), Some("second word wins"));
|
||||
assert_eq!(
|
||||
config.ignore_selectors[1].files.as_deref(),
|
||||
Some(["src/demo/**".to_string()].as_slice())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selector_ignores_are_narrowed_per_target() {
|
||||
let config = config_with_selectors(
|
||||
r#"{"ignoreSelectors":[
|
||||
{"rule":"undersized-ui-text","selector":".ks-tag"},
|
||||
{"rule":"glow-effect","selector":".demo","files":["src/demo/**"]}
|
||||
]}"#,
|
||||
);
|
||||
let everywhere = selector_ignores_for_target(&config, "src/pages/index.astro");
|
||||
assert_eq!(everywhere.len(), 1);
|
||||
assert_eq!(everywhere[0].selector, ".ks-tag");
|
||||
let scoped = selector_ignores_for_target(&config, "src/demo/playground.astro");
|
||||
assert_eq!(scoped.len(), 2);
|
||||
// A URL scan is covered by the unscoped entries only: a `files` glob
|
||||
// describes repo paths, and must not reach a URL path that happens to
|
||||
// end the same way.
|
||||
assert_eq!(selector_ignores_for_url(&config).len(), 1);
|
||||
let url_globs = config_with_selectors(
|
||||
r#"{"ignoreSelectors":[
|
||||
{"rule":"glow-effect","selector":".demo","files":["index.html"]}
|
||||
]}"#,
|
||||
);
|
||||
assert!(selector_ignores_for_url(&url_globs).is_empty());
|
||||
assert_eq!(
|
||||
selector_ignores_for_target(&url_globs, "src/index.html").len(),
|
||||
1
|
||||
);
|
||||
// `--no-config` leaves the list empty, so nothing is waived.
|
||||
assert!(selector_ignores_for_target(&DetectionConfig::raw(), "a.html").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stamped_findings_leave_the_reportable_set_as_a_count() {
|
||||
let stamp = |rule: &str, selector: Option<&str>| {
|
||||
impeccable_core::findings::stamp_ignored_by(
|
||||
impeccable_core::findings::finding(rule, "a.html", "snip", 0.0),
|
||||
selector,
|
||||
)
|
||||
};
|
||||
let findings = vec![
|
||||
stamp("undersized-ui-text", Some(".ks-tag")),
|
||||
stamp("undersized-ui-text", Some(".ks-tag")),
|
||||
stamp("side-tab", Some(".ks-tag")),
|
||||
stamp("undersized-ui-text", None),
|
||||
];
|
||||
let (kept, report) =
|
||||
filter_detection_findings_reported(findings, &DetectionConfig::with_defaults());
|
||||
assert_eq!(kept.len(), 1);
|
||||
assert_eq!(report.len(), 2);
|
||||
assert_eq!(report[0].rule, "undersized-ui-text");
|
||||
assert_eq!(report[0].selector, ".ks-tag");
|
||||
assert_eq!(report[0].count, 2);
|
||||
assert_eq!(report[1].count, 1);
|
||||
// Nothing stamped, nothing reported.
|
||||
let (kept, report) = filter_detection_findings_reported(
|
||||
vec![stamp("side-tab", None)],
|
||||
&DetectionConfig::with_defaults(),
|
||||
);
|
||||
assert_eq!(kept.len(), 1);
|
||||
assert!(report.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ use std::rc::Rc;
|
||||
|
||||
use impeccable_core::findings::Finding;
|
||||
use impeccable_core::rule_pack::RulePack;
|
||||
use impeccable_core::selector_ignores::SelectorIgnore;
|
||||
|
||||
use crate::design_system::DesignSystem;
|
||||
use crate::profiler::DetectorProfile;
|
||||
@@ -23,6 +24,10 @@ pub struct ScanOptions {
|
||||
pub viewport: Option<(u32, u32)>,
|
||||
/// JS `options.profile` (library callers only; no CLI flag).
|
||||
pub profile: Option<Rc<DetectorProfile>>,
|
||||
/// The project's component-level opt-outs for this target
|
||||
/// (`detector.ignoreSelectors`, narrowed to the entries whose `files`
|
||||
/// globs cover it). Empty under `--no-config`.
|
||||
pub ignore_selectors: Vec<SelectorIgnore>,
|
||||
/// The installed rule pack (`impeccable_core::rule_pack`), passed through
|
||||
/// to the text engine and on to the HTML engine. `None` in the `impeccable`
|
||||
/// binary, which ships the built-in rules only.
|
||||
|
||||
+204
-14
@@ -8,7 +8,7 @@ use impeccable_core::js;
|
||||
use crate::config::{
|
||||
get_config_path, get_local_config_path, normalize_ignore_value, read_detection_config,
|
||||
read_raw_detection_config, synthetic_ignore_value, write_detection_config, DetectionConfig,
|
||||
IgnoreValueEntry,
|
||||
IgnoreSelectorEntry, IgnoreValueEntry,
|
||||
};
|
||||
use crate::jsp;
|
||||
|
||||
@@ -21,9 +21,11 @@ Actions:
|
||||
add-rule <rule> [--all-values] Ignore a rule
|
||||
add-file <glob> Ignore files by glob
|
||||
add-value <rule> <value> Ignore one rule/value pair
|
||||
add-selector <rule> <selector> Ignore one rule on a component, everywhere
|
||||
remove-rule <rule> Remove a rule ignore
|
||||
remove-file <glob> Remove a file ignore
|
||||
remove-value <rule> <value> Remove a rule/value ignore
|
||||
remove-selector <rule> <selector> Remove a component ignore
|
||||
clear Clear detector ignores in the selected scope
|
||||
|
||||
Scope:
|
||||
@@ -32,14 +34,22 @@ Scope:
|
||||
--all For remove/clear, apply to shared and local
|
||||
|
||||
Value options:
|
||||
--file <glob> Scope add-value/remove-value to a file glob
|
||||
--reason <text> Store or update a reason on add-value
|
||||
--file <glob> Scope add-value/add-selector to a file glob
|
||||
--reason <text> Store or update a reason on add-value/add-selector
|
||||
|
||||
Component ignores (add-selector) waive one rule for every element a CSS
|
||||
selector matches, and for that element's subtree. One entry replaces the
|
||||
same data-impeccable-ignore attribute repeated on every instance of a
|
||||
component, and the scan reports how many hits it suppressed instead of
|
||||
going quiet.
|
||||
|
||||
Examples:
|
||||
impeccable ignores add-file \"src/legacy/**\"
|
||||
impeccable ignores add-value overused-font Inter --reason \"Brand font\"
|
||||
impeccable ignores add-value design-system-color \"*\" --file \"src/demo.css\"
|
||||
impeccable ignores add-selector undersized-ui-text \".ks-tag\" --reason \"10px mono label, by design\"
|
||||
impeccable ignores remove-value overused-font Inter
|
||||
impeccable ignores remove-selector undersized-ui-text \".ks-tag\"
|
||||
";
|
||||
|
||||
fn action_for(arg: &str) -> Option<&'static str> {
|
||||
@@ -48,9 +58,11 @@ fn action_for(arg: &str) -> Option<&'static str> {
|
||||
"add-rule" | "ignore-rule" => "add-rule",
|
||||
"add-file" | "ignore-file" => "add-file",
|
||||
"add-value" | "ignore-value" | "update-value" => "add-value",
|
||||
"add-selector" | "ignore-selector" | "update-selector" => "add-selector",
|
||||
"remove-rule" | "rm-rule" => "remove-rule",
|
||||
"remove-file" | "rm-file" => "remove-file",
|
||||
"remove-value" | "rm-value" => "remove-value",
|
||||
"remove-selector" | "rm-selector" => "remove-selector",
|
||||
"clear" => "clear",
|
||||
_ => return None,
|
||||
})
|
||||
@@ -191,6 +203,27 @@ fn format_values(values: &[IgnoreValueEntry]) -> String {
|
||||
.join(", ")
|
||||
}
|
||||
|
||||
fn format_selectors(entries: &[IgnoreSelectorEntry]) -> String {
|
||||
if entries.is_empty() {
|
||||
return "(none)".to_string();
|
||||
}
|
||||
entries
|
||||
.iter()
|
||||
.map(|e| {
|
||||
let file_suffix = match &e.files {
|
||||
Some(f) if !f.is_empty() => format!(" [{}]", f.join(", ")),
|
||||
_ => String::new(),
|
||||
};
|
||||
let reason_suffix = match &e.reason {
|
||||
Some(r) if !r.is_empty() => format!(" - {r}"),
|
||||
_ => String::new(),
|
||||
};
|
||||
format!("{} on {}{file_suffix}{reason_suffix}", e.rule, e.selector)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
}
|
||||
|
||||
fn format_config(label: &str, config: &DetectionConfig) -> String {
|
||||
let none_or = |v: &[String]| {
|
||||
if v.is_empty() {
|
||||
@@ -199,21 +232,29 @@ fn format_config(label: &str, config: &DetectionConfig) -> String {
|
||||
v.join(", ")
|
||||
}
|
||||
};
|
||||
[
|
||||
let mut lines = vec![
|
||||
format!("{label}:"),
|
||||
format!(" ignoreRules: {}", none_or(&config.ignore_rules)),
|
||||
format!(" ignoreFiles: {}", none_or(&config.ignore_files)),
|
||||
format!(" ignoreValues: {}", format_values(&config.ignore_values)),
|
||||
format!(
|
||||
" designSystem: {}",
|
||||
if config.design_system_enabled == Some(false) {
|
||||
"disabled"
|
||||
} else {
|
||||
"enabled"
|
||||
}
|
||||
),
|
||||
]
|
||||
.join("\n")
|
||||
];
|
||||
// Listed only where the project uses component ignores, so the familiar
|
||||
// four-line block is unchanged for everyone else.
|
||||
if !config.ignore_selectors.is_empty() {
|
||||
lines.push(format!(
|
||||
" ignoreSelectors: {}",
|
||||
format_selectors(&config.ignore_selectors)
|
||||
));
|
||||
}
|
||||
lines.push(format!(
|
||||
" designSystem: {}",
|
||||
if config.design_system_enabled == Some(false) {
|
||||
"disabled"
|
||||
} else {
|
||||
"enabled"
|
||||
}
|
||||
));
|
||||
lines.join("\n")
|
||||
}
|
||||
|
||||
fn rel_or_abs(cwd: &str, target: &str) -> String {
|
||||
@@ -426,6 +467,140 @@ fn add_value(cwd: &str, args: &[String]) -> R<String> {
|
||||
))
|
||||
}
|
||||
|
||||
struct SelectorArgs {
|
||||
rule: String,
|
||||
selector: String,
|
||||
files: Vec<String>,
|
||||
reason: String,
|
||||
}
|
||||
|
||||
/// `add-selector <rule> <selector...> [--file <glob>]... [--reason <text...>]`.
|
||||
/// The selector keeps its case and its internal spacing (`.card .ks-tag` is a
|
||||
/// descendant selector, not two arguments), so positionals after the rule are
|
||||
/// joined rather than normalized the way an ignore value is.
|
||||
fn parse_selector_args(args: &[String]) -> R<SelectorArgs> {
|
||||
let mut positionals: Vec<String> = Vec::new();
|
||||
let mut files: Vec<String> = Vec::new();
|
||||
let mut reason = String::new();
|
||||
let mut i = 0;
|
||||
while i < args.len() {
|
||||
let arg = args[i].as_str();
|
||||
if arg == "--reason" {
|
||||
let mut chunks = Vec::new();
|
||||
while i + 1 < args.len() && !args[i + 1].starts_with("--") {
|
||||
i += 1;
|
||||
chunks.push(args[i].clone());
|
||||
}
|
||||
reason = js::trim(&chunks.join(" ")).to_string();
|
||||
} else if let Some(v) = arg.strip_prefix("--reason=") {
|
||||
reason = js::trim(v).to_string();
|
||||
} else if arg == "--file" || arg == "--files" {
|
||||
if i + 1 >= args.len() {
|
||||
return Err(format!("{arg} requires a glob"));
|
||||
}
|
||||
i += 1;
|
||||
files.push(require_glob(&args[i], arg)?);
|
||||
} else if let Some(v) = arg.strip_prefix("--file=") {
|
||||
files.push(require_glob(v, "--file")?);
|
||||
} else if let Some(v) = arg.strip_prefix("--files=") {
|
||||
files.push(require_glob(v, "--files")?);
|
||||
} else if arg.starts_with("--") {
|
||||
return Err(format!("Unknown add-selector flag: {arg}"));
|
||||
} else {
|
||||
positionals.push(arg.to_string());
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
let rule = js::to_lower_case(js::trim(
|
||||
positionals.first().map(String::as_str).unwrap_or(""),
|
||||
));
|
||||
let selector = js::trim(&positionals.get(1..).unwrap_or(&[]).join(" ")).to_string();
|
||||
if rule.is_empty() || selector.is_empty() {
|
||||
return Err(
|
||||
"Pass a rule id and a CSS selector, e.g. impeccable ignores add-selector undersized-ui-text \".ks-tag\""
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
if selector == "*" {
|
||||
return Err("A `*` selector waives the rule everywhere. Use add-rule for that, or name the component's selector.".to_string());
|
||||
}
|
||||
let mut scoped: Vec<String> = Vec::new();
|
||||
for f in files.into_iter().filter(|f| !f.is_empty()) {
|
||||
if !scoped.contains(&f) {
|
||||
scoped.push(f);
|
||||
}
|
||||
}
|
||||
scoped.sort();
|
||||
Ok(SelectorArgs {
|
||||
rule,
|
||||
selector,
|
||||
files: scoped,
|
||||
reason,
|
||||
})
|
||||
}
|
||||
|
||||
fn selector_key(rule: &str, selector: &str, files: &[String]) -> String {
|
||||
let mut sorted = files.to_vec();
|
||||
sorted.sort();
|
||||
format!(
|
||||
"{}\0{}\0{}",
|
||||
js::to_lower_case(js::trim(rule)),
|
||||
js::trim(selector),
|
||||
sorted.join("\u{1f}")
|
||||
)
|
||||
}
|
||||
|
||||
fn selector_entry_key(e: &IgnoreSelectorEntry) -> String {
|
||||
selector_key(
|
||||
&e.rule,
|
||||
&e.selector,
|
||||
e.files.as_deref().unwrap_or_default(),
|
||||
)
|
||||
}
|
||||
|
||||
fn add_selector(cwd: &str, args: &[String]) -> R<String> {
|
||||
let scope = parse_scope(args, false)?;
|
||||
let parsed = parse_selector_args(&scope.rest)?;
|
||||
let mut config = read_raw_detection_config(cwd, scope.local);
|
||||
let key = selector_key(&parsed.rule, &parsed.selector, &parsed.files);
|
||||
if let Some(existing) = config
|
||||
.ignore_selectors
|
||||
.iter_mut()
|
||||
.find(|e| selector_entry_key(e) == key)
|
||||
{
|
||||
if !parsed.reason.is_empty() {
|
||||
existing.reason = Some(parsed.reason.clone());
|
||||
}
|
||||
if !parsed.files.is_empty() {
|
||||
existing.files = Some(parsed.files.clone());
|
||||
}
|
||||
} else {
|
||||
config.ignore_selectors.push(IgnoreSelectorEntry {
|
||||
rule: parsed.rule.clone(),
|
||||
selector: parsed.selector.clone(),
|
||||
files: if parsed.files.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(parsed.files.clone())
|
||||
},
|
||||
created_at: Some(iso_now()),
|
||||
reason: if parsed.reason.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(parsed.reason.clone())
|
||||
},
|
||||
});
|
||||
}
|
||||
let target = write_scope(cwd, &config, scope.local)?;
|
||||
Ok(format!(
|
||||
"Added {} on {} to {} detector ignoreSelectors ({}).",
|
||||
parsed.rule,
|
||||
parsed.selector,
|
||||
if scope.local { "local" } else { "shared" },
|
||||
rel_or_abs(cwd, &target)
|
||||
))
|
||||
}
|
||||
|
||||
fn remove_from_scopes(
|
||||
cwd: &str,
|
||||
args: &[String],
|
||||
@@ -493,6 +668,18 @@ fn remove_value(cwd: &str, args: &[String]) -> R<String> {
|
||||
})
|
||||
}
|
||||
|
||||
fn remove_selector(cwd: &str, args: &[String]) -> R<String> {
|
||||
remove_from_scopes(cwd, args, |config, rest| {
|
||||
let parsed = parse_selector_args(rest)?;
|
||||
let key = selector_key(&parsed.rule, &parsed.selector, &parsed.files);
|
||||
let before = config.ignore_selectors.len();
|
||||
config
|
||||
.ignore_selectors
|
||||
.retain(|e| selector_entry_key(e) != key);
|
||||
Ok(before - config.ignore_selectors.len())
|
||||
})
|
||||
}
|
||||
|
||||
fn clear(cwd: &str, args: &[String]) -> R<String> {
|
||||
let scope = parse_scope(args, true)?;
|
||||
if !scope.rest.is_empty() {
|
||||
@@ -508,6 +695,7 @@ fn clear(cwd: &str, args: &[String]) -> R<String> {
|
||||
config.ignore_rules.clear();
|
||||
config.ignore_files.clear();
|
||||
config.ignore_values.clear();
|
||||
config.ignore_selectors.clear();
|
||||
write_scope(cwd, &config, is_local)?;
|
||||
}
|
||||
Ok(format!(
|
||||
@@ -547,9 +735,11 @@ pub fn run(args: &[String], io: &mut Io) -> i32 {
|
||||
"add-rule" => add_rule(&cwd, &rest),
|
||||
"add-file" => add_file(&cwd, &rest),
|
||||
"add-value" => add_value(&cwd, &rest),
|
||||
"add-selector" => add_selector(&cwd, &rest),
|
||||
"remove-rule" => remove_rule(&cwd, &rest),
|
||||
"remove-file" => remove_file(&cwd, &rest),
|
||||
"remove-value" => remove_value(&cwd, &rest),
|
||||
"remove-selector" => remove_selector(&cwd, &rest),
|
||||
_ => clear(&cwd, &rest),
|
||||
};
|
||||
match out {
|
||||
|
||||
@@ -39,6 +39,17 @@ pub struct BrowserFinding {
|
||||
skip_serializing_if = "Option::is_none"
|
||||
)]
|
||||
pub ignore_value: Option<String>,
|
||||
/// The `detector.ignoreSelectors` selector that waived this finding, when
|
||||
/// one did. A stamped finding is still reported by the engine: the config
|
||||
/// layer drops it and counts it, so a component-level opt-out shows up as
|
||||
/// a number rather than as silence. `None` for everything else, and
|
||||
/// skipped in serialization, so output without the feature is unchanged.
|
||||
#[serde(
|
||||
default,
|
||||
rename = "ignoredBy",
|
||||
skip_serializing_if = "Option::is_none"
|
||||
)]
|
||||
pub ignored_by: Option<String>,
|
||||
}
|
||||
|
||||
impl BrowserFinding {
|
||||
@@ -48,6 +59,7 @@ impl BrowserFinding {
|
||||
detail: detail.into(),
|
||||
severity: None,
|
||||
ignore_value: None,
|
||||
ignored_by: None,
|
||||
}
|
||||
}
|
||||
/// `{ type: f.id, detail: f.snippet }` from a Section 3 hit.
|
||||
@@ -127,6 +139,35 @@ where
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// The same tolerance for `ignoreSelectors`: an entry that is not an object
|
||||
/// with both halves is dropped, rather than failing the parse of the whole
|
||||
/// config (which the wasm entry points answer with `unwrap_or_default()`,
|
||||
/// silently losing the design system and every other setting with it).
|
||||
fn de_ignore_selectors<'de, D>(
|
||||
de: D,
|
||||
) -> Result<Vec<crate::selector_ignores::SelectorIgnore>, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
let raw = serde_json::Value::deserialize(de)?;
|
||||
let Some(items) = raw.as_array() else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
Ok(items
|
||||
.iter()
|
||||
.filter_map(|entry| {
|
||||
let obj = entry.as_object()?;
|
||||
let text = |key: &str| match obj.get(key) {
|
||||
Some(serde_json::Value::String(s)) => s.clone(),
|
||||
_ => String::new(),
|
||||
};
|
||||
let parsed =
|
||||
crate::selector_ignores::SelectorIgnore::new(text("rule"), text("selector"));
|
||||
parsed.is_valid().then_some(parsed)
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// What the bundle passes into `collectBrowserFindings`: extension mode and
|
||||
/// the relevant slice of `window.__IMPECCABLE_CONFIG__`.
|
||||
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
|
||||
@@ -145,6 +186,20 @@ pub struct BrowserConfig {
|
||||
/// overlay. Serialized as `disabledValues`.
|
||||
#[serde(default, deserialize_with = "de_disabled_values")]
|
||||
pub disabled_values: Vec<DisabledValue>,
|
||||
/// `window.__IMPECCABLE_CONFIG__?.ignoreSelectors`: the project's
|
||||
/// component-level opt-outs, `[{ rule, selector }]`. Every finding on an
|
||||
/// element the selector matches (or on a descendant of one) is stamped
|
||||
/// with that selector instead of being reported clean, the same waiver
|
||||
/// `data-impeccable-ignore` grants the element that carries it. Honored
|
||||
/// in every mode: unlike `disabledRules`, this list is the project's own
|
||||
/// config rather than a browser-extension preference. Empty by default,
|
||||
/// and skipped in serialization so a config without it is byte-identical.
|
||||
#[serde(
|
||||
default,
|
||||
deserialize_with = "de_ignore_selectors",
|
||||
skip_serializing_if = "Vec::is_empty"
|
||||
)]
|
||||
pub ignore_selectors: Vec<crate::selector_ignores::SelectorIgnore>,
|
||||
/// `window.__IMPECCABLE_CONFIG__?.skipScan === true` (only honored in
|
||||
/// extension mode): the page is waived wholesale by detector.ignoreFiles,
|
||||
/// so every scan stage answers empty.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -84,6 +84,31 @@ pub fn finding(id: &str, file_path: &str, snippet: &str, line: f64) -> Finding {
|
||||
.unwrap_or_else(|| panic!("finding(): unknown antipattern id {id:?}"))
|
||||
}
|
||||
|
||||
/// The extras key an engine stamps on a finding a component-level opt-out
|
||||
/// (`detector.ignoreSelectors`) waived, carrying the selector that waived it.
|
||||
/// The finding still travels; the config layer drops and counts it.
|
||||
pub const IGNORED_BY_KEY: &str = "ignoredBy";
|
||||
|
||||
/// Stamp `ignoredBy` when a selector waived this finding. `None` leaves the
|
||||
/// finding untouched, so nothing changes for a project without the config.
|
||||
pub fn stamp_ignored_by(mut finding: Finding, selector: Option<&str>) -> Finding {
|
||||
if let Some(selector) = selector.filter(|s| !s.is_empty()) {
|
||||
finding.extras.insert(
|
||||
IGNORED_BY_KEY.to_string(),
|
||||
Value::String(selector.to_string()),
|
||||
);
|
||||
}
|
||||
finding
|
||||
}
|
||||
|
||||
/// The selector that waived this finding, when one did.
|
||||
pub fn ignored_by(finding: &Finding) -> Option<&str> {
|
||||
match finding.extras.get(IGNORED_BY_KEY) {
|
||||
Some(Value::String(s)) if !s.is_empty() => Some(s.as_str()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -107,4 +132,15 @@ mod tests {
|
||||
assert_eq!(finding("script-error", "f", "s", 0.0).severity, "error");
|
||||
assert!(try_finding("nope", "f", "s", 0.0).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignored_by_stamp_round_trips_and_stays_off_by_default() {
|
||||
let plain = finding("side-tab", "a.html", "s", 0.0);
|
||||
assert_eq!(ignored_by(&stamp_ignored_by(plain.clone(), None)), None);
|
||||
assert_eq!(ignored_by(&stamp_ignored_by(plain.clone(), Some(""))), None);
|
||||
let stamped = stamp_ignored_by(plain, Some(".ks-tag"));
|
||||
assert_eq!(ignored_by(&stamped), Some(".ks-tag"));
|
||||
let json = serde_json::to_string(&stamped).unwrap();
|
||||
assert!(json.ends_with(r#""snippet":"s","ignoredBy":".ks-tag"}"#), "{json}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ pub mod page;
|
||||
pub mod registry;
|
||||
pub mod rule_pack;
|
||||
pub mod rules;
|
||||
pub mod selector_ignores;
|
||||
|
||||
#[cfg(any(test, feature = "vectors"))]
|
||||
pub mod vectors;
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
//! Component-level opt-outs: one `{ rule, selector }` pair waives a rule for
|
||||
//! every element the selector matches, and for that element's subtree.
|
||||
//!
|
||||
//! This is the declared twin of the `data-impeccable-ignore` attribute. The
|
||||
//! attribute waives the element that carries it; a selector ignore waives
|
||||
//! every instance of a component from one line of project config, so an
|
||||
//! author with eleven copies of the same 10px label writes one entry instead
|
||||
//! of eleven attributes.
|
||||
//!
|
||||
//! The engines do not drop what a selector ignore covers. They stamp the
|
||||
//! finding with the selector that waived it (`Finding.ignoredBy` /
|
||||
//! `BrowserFinding.ignoredBy`), and the config layer that owns the ignore
|
||||
//! list drops and counts them, so "silenced" stays countable.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// One `detector.ignoreSelectors` entry, reduced to what an engine needs.
|
||||
/// `rule` is a lowercased rule id or `*` (every rule); `selector` is a CSS
|
||||
/// selector matched against the finding's element and its ancestors.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||
pub struct SelectorIgnore {
|
||||
pub rule: String,
|
||||
pub selector: String,
|
||||
}
|
||||
|
||||
impl SelectorIgnore {
|
||||
/// Normalizing constructor: the rule is trimmed and lowercased the way
|
||||
/// `data-impeccable-ignore` tokens are, the selector keeps its case
|
||||
/// (`.ksTag` and `.kstag` are different classes) and only loses
|
||||
/// surrounding whitespace.
|
||||
pub fn new(rule: impl AsRef<str>, selector: impl AsRef<str>) -> Self {
|
||||
SelectorIgnore {
|
||||
rule: crate::js::to_lower_case(crate::js::trim(rule.as_ref())),
|
||||
selector: crate::js::trim(selector.as_ref()).to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Usable only with both halves present. An entry with an empty selector
|
||||
/// would waive everything, which is what `ignoreRules` is for.
|
||||
pub fn is_valid(&self) -> bool {
|
||||
!self.rule.is_empty() && !self.selector.is_empty()
|
||||
}
|
||||
|
||||
/// `*` covers every rule, exactly as it does in the attribute.
|
||||
pub fn covers_rule(&self, rule_id: &str) -> bool {
|
||||
if !self.is_valid() {
|
||||
return false;
|
||||
}
|
||||
self.rule == "*" || self.rule == crate::js::to_lower_case(crate::js::trim(rule_id))
|
||||
}
|
||||
}
|
||||
|
||||
/// The first entry that waives `rule_id` for an element, where `closest`
|
||||
/// answers the DOM's `element.closest(selector) !== null` (self or ancestor).
|
||||
/// Returns the selector that waived it, which is what the finding carries.
|
||||
pub fn waiving_selector<'a>(
|
||||
entries: &'a [SelectorIgnore],
|
||||
rule_id: &str,
|
||||
mut closest: impl FnMut(&str) -> bool,
|
||||
) -> Option<&'a str> {
|
||||
entries
|
||||
.iter()
|
||||
.find(|e| e.covers_rule(rule_id) && closest(&e.selector))
|
||||
.map(|e| e.selector.as_str())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn normalizes_rule_and_selector() {
|
||||
let e = SelectorIgnore::new(" Undersized-UI-Text ", " .ks-tag ");
|
||||
assert_eq!(e.rule, "undersized-ui-text");
|
||||
assert_eq!(e.selector, ".ks-tag");
|
||||
assert!(e.is_valid());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn half_an_entry_is_not_an_entry() {
|
||||
assert!(!SelectorIgnore::new("", ".ks-tag").is_valid());
|
||||
assert!(!SelectorIgnore::new("side-tab", " ").is_valid());
|
||||
assert!(!SelectorIgnore::new("", ".ks-tag").covers_rule("side-tab"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn star_covers_every_rule() {
|
||||
let e = SelectorIgnore::new("*", ".demo");
|
||||
assert!(e.covers_rule("side-tab"));
|
||||
assert!(e.covers_rule("UNDERSIZED-UI-TEXT"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn waiving_selector_picks_the_first_match() {
|
||||
let entries = vec![
|
||||
SelectorIgnore::new("side-tab", ".nope"),
|
||||
SelectorIgnore::new("undersized-ui-text", ".ks-tag"),
|
||||
SelectorIgnore::new("undersized-ui-text", ".also"),
|
||||
];
|
||||
let hit = waiving_selector(&entries, "undersized-ui-text", |s| s != ".nope");
|
||||
assert_eq!(hit, Some(".ks-tag"));
|
||||
assert_eq!(waiving_selector(&entries, "glow-effect", |_| true), None);
|
||||
assert_eq!(
|
||||
waiving_selector(&entries, "undersized-ui-text", |_| false),
|
||||
None
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -13,8 +13,9 @@ use impeccable_core::findings::Finding;
|
||||
use impeccable_core::js;
|
||||
use impeccable_detect::config::{
|
||||
extract_finding_ignore_value, filter_detection_findings, matches_any_glob,
|
||||
normalize_ignore_rule, normalize_ignore_value, normalize_ignore_value_entries, DetectionConfig,
|
||||
IgnoreValueEntry,
|
||||
merge_ignore_selectors, normalize_ignore_rule, normalize_ignore_value,
|
||||
normalize_ignore_value_entries, selector_ignores_for_target, DetectionConfig,
|
||||
IgnoreSelectorEntry, IgnoreValueEntry,
|
||||
};
|
||||
use impeccable_detect::design_system::{load_design_system_for_cwd, resolve_design_md_path, DesignSystem};
|
||||
use impeccable_detect::detect_text::{detect_text, TextOptions};
|
||||
@@ -373,6 +374,8 @@ pub struct HookConfig {
|
||||
pub ignore_rules: Vec<String>,
|
||||
pub ignore_files: Vec<String>,
|
||||
pub ignore_values: Vec<IgnoreValueEntry>,
|
||||
/// `detector.ignoreSelectors`: the project's component-level opt-outs.
|
||||
pub ignore_selectors: Vec<IgnoreSelectorEntry>,
|
||||
pub extensions: Vec<ExtensionEntry>,
|
||||
pub per_edit_rules: String,
|
||||
pub advisory_rules: String,
|
||||
@@ -389,6 +392,7 @@ impl Default for HookConfig {
|
||||
ignore_rules: vec![],
|
||||
ignore_files: vec![],
|
||||
ignore_values: vec![],
|
||||
ignore_selectors: vec![],
|
||||
extensions: vec![],
|
||||
per_edit_rules: "immediate".to_string(),
|
||||
advisory_rules: "exclude".to_string(),
|
||||
@@ -482,6 +486,9 @@ fn apply_detector_config_source(config: &mut HookConfig, raw: Option<&Map<String
|
||||
if let Some(Value::Array(list)) = raw.get("ignoreValues") {
|
||||
config.ignore_values = merge_ignore_values(&config.ignore_values, list);
|
||||
}
|
||||
if let Some(Value::Array(list)) = raw.get("ignoreSelectors") {
|
||||
config.ignore_selectors = merge_ignore_selectors(&config.ignore_selectors, list);
|
||||
}
|
||||
if let Some(Value::Array(list)) = raw.get("extensions") {
|
||||
config.extensions = merge_extensions(&config.extensions, list);
|
||||
}
|
||||
@@ -989,6 +996,7 @@ pub fn filter_findings(findings: Vec<Finding>, config: &HookConfig) -> Vec<Findi
|
||||
ignore_rules: config.ignore_rules.clone(),
|
||||
ignore_files: vec![],
|
||||
ignore_values: config.ignore_values.clone(),
|
||||
ignore_selectors: config.ignore_selectors.clone(),
|
||||
design_system_enabled: None,
|
||||
advisory_rules: None,
|
||||
};
|
||||
@@ -1615,6 +1623,9 @@ pub fn should_emit_ack_for_file(file_path: &str, config: &HookConfig) -> bool {
|
||||
#[derive(Default, Clone)]
|
||||
pub struct HookScanOptions {
|
||||
pub design_system: Option<Rc<DesignSystem>>,
|
||||
/// The project's component-level opt-outs, narrowed per file when the
|
||||
/// options are handed to an engine.
|
||||
pub ignore_selectors: Vec<IgnoreSelectorEntry>,
|
||||
}
|
||||
|
||||
impl HookScanOptions {
|
||||
@@ -1624,12 +1635,19 @@ impl HookScanOptions {
|
||||
.map(|d| d.md_newer_than_json)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
pub fn to_scan_options(&self) -> ScanOptions {
|
||||
pub fn to_scan_options(&self, target: &str) -> ScanOptions {
|
||||
ScanOptions {
|
||||
inline_ignores: true,
|
||||
design_system: self.design_system.clone(),
|
||||
viewport: None,
|
||||
profile: None,
|
||||
ignore_selectors: selector_ignores_for_target(
|
||||
&DetectionConfig {
|
||||
ignore_selectors: self.ignore_selectors.clone(),
|
||||
..DetectionConfig::raw()
|
||||
},
|
||||
target,
|
||||
),
|
||||
rule_pack: None,
|
||||
}
|
||||
}
|
||||
@@ -1637,11 +1655,19 @@ impl HookScanOptions {
|
||||
|
||||
/// JS: designSystemOptions(config, detector, projectCwd)
|
||||
pub fn design_system_options(config: &HookConfig, project_cwd: &str) -> HookScanOptions {
|
||||
// Component ignores are not design-system state: a project with
|
||||
// `designSystem.enabled: false` still opted its components out, and the
|
||||
// hook would otherwise re-report them on every edit.
|
||||
let ignore_selectors = config.ignore_selectors.clone();
|
||||
if !config.design_system_enabled {
|
||||
return HookScanOptions::default();
|
||||
return HookScanOptions {
|
||||
design_system: None,
|
||||
ignore_selectors,
|
||||
};
|
||||
}
|
||||
HookScanOptions {
|
||||
design_system: load_design_system_for_cwd(project_cwd).map(Rc::new),
|
||||
ignore_selectors,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1653,7 +1679,8 @@ pub fn design_system_options_for_file(
|
||||
file_path: &str,
|
||||
) -> HookScanOptions {
|
||||
if !config.design_system_enabled {
|
||||
return HookScanOptions::default();
|
||||
// Same as above: the waivers travel even when no design system does.
|
||||
return design_system_options(config, project_cwd);
|
||||
}
|
||||
let project = impeccable_context::context::resolve_project(
|
||||
project_cwd,
|
||||
@@ -1695,7 +1722,7 @@ pub fn detector_detect_html(
|
||||
) -> Result<Vec<Finding>, String> {
|
||||
let mut sink = std::io::sink();
|
||||
rt.html
|
||||
.detect_html(file_path, &scan.to_scan_options(), &mut sink)
|
||||
.detect_html(file_path, &scan.to_scan_options(file_path), &mut sink)
|
||||
.map_err(|e| e.message)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -28,7 +28,8 @@ use crate::profile::{self, Meta, ProfileSink};
|
||||
use crate::quality::{check_element_quality, check_page_quality_from_doc, pf0};
|
||||
use impeccable_core::checks::html_patterns::{check_html_patterns, HtmlPatternCorpora};
|
||||
use impeccable_core::checks::rules::RuleHit;
|
||||
use impeccable_core::findings::{try_finding, Finding};
|
||||
use impeccable_core::findings::{stamp_ignored_by, try_finding, Finding};
|
||||
use impeccable_core::selector_ignores::{waiving_selector, SelectorIgnore};
|
||||
use impeccable_core::inline_ignores::apply_inline_ignores;
|
||||
use impeccable_core::page::is_full_page;
|
||||
use once_cell::sync::Lazy;
|
||||
@@ -80,6 +81,13 @@ pub struct DetectHtmlOptions<'a> {
|
||||
/// Sink for the JS `process.stderr.write` notices (unreadable linked
|
||||
/// stylesheets); `None` drops them.
|
||||
pub warn: Option<&'a dyn Fn(&str)>,
|
||||
/// The project's component-level opt-outs (`detector.ignoreSelectors`),
|
||||
/// already narrowed to the entries whose `files` globs cover this file.
|
||||
/// An element finding whose element (or an ancestor of it) matches one of
|
||||
/// these selectors is stamped `ignoredBy: "<selector>"` rather than
|
||||
/// dropped, so the config layer can count what it suppresses. Empty by
|
||||
/// default, which is the behavior every existing caller gets.
|
||||
pub ignore_selectors: &'a [SelectorIgnore],
|
||||
/// A rule pack's static-document hook: rules over the parsed page.
|
||||
pub static_rule_pack: Option<&'static dyn StaticRulePack>,
|
||||
/// The same pack's engine-wide text hook. An HTML file gets **one** pack
|
||||
@@ -228,8 +236,14 @@ pub fn detect_html_source(
|
||||
if scoped_ignore_active(el, &h.id) {
|
||||
continue;
|
||||
}
|
||||
// A component-level opt-out waives the same way the attribute
|
||||
// does (self or ancestor), but the finding is stamped rather
|
||||
// than dropped so the config layer can count it.
|
||||
let waived = waiving_selector(options.ignore_selectors, &h.id, |sel| {
|
||||
el.closest(sel).is_some()
|
||||
});
|
||||
if let Some(f) = mk(&h.id, &h.snippet) {
|
||||
findings.push(f);
|
||||
findings.push(stamp_ignored_by(f, waived));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -316,6 +330,7 @@ pub fn detect_html_source(
|
||||
},
|
||||
);
|
||||
for f in pattern_hits {
|
||||
let mut pattern_waived: Option<String> = None;
|
||||
if let Some(selector) = f.selector.as_deref() {
|
||||
let stripped = PSEUDO_STRIP_RE.replace_all(selector, "");
|
||||
let stripped = impeccable_core::js::trim(&stripped);
|
||||
@@ -329,6 +344,24 @@ pub fn detect_html_source(
|
||||
{
|
||||
continue;
|
||||
}
|
||||
// A selector-backed pattern finding is waived by config
|
||||
// only when every element it names is covered, the same
|
||||
// all-or-nothing rule the attribute pass above applies.
|
||||
if !matches.is_empty() {
|
||||
let mut covering: Option<&str> = None;
|
||||
for el in &matches {
|
||||
match waiving_selector(options.ignore_selectors, &f.id, |sel| {
|
||||
el.closest(sel).is_some()
|
||||
}) {
|
||||
Some(sel) => covering = covering.or(Some(sel)),
|
||||
None => {
|
||||
covering = None;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
pattern_waived = covering.map(str::to_string);
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(mut item) = mk(&f.id, &f.snippet) {
|
||||
@@ -336,7 +369,7 @@ pub fn detect_html_source(
|
||||
item.severity = sev.clone();
|
||||
}
|
||||
impeccable_core::findings::derive_advisory_flag(&mut item);
|
||||
findings.push(item);
|
||||
findings.push(stamp_ignored_by(item, pattern_waived.as_deref()));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -80,6 +80,7 @@ impl HtmlEngine for StaticHtmlEngine {
|
||||
warn: Some(&warn),
|
||||
static_rule_pack: self.static_rule_pack,
|
||||
rule_pack: options.rule_pack,
|
||||
ignore_selectors: &options.ignore_selectors,
|
||||
};
|
||||
detect_html(Path::new(path), &html_options).map_err(|e| {
|
||||
EngineError::new(match e {
|
||||
|
||||
@@ -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:?}"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
//! Component-level opt-outs in the static engine: one `{ rule, selector }`
|
||||
//! entry stands in for the same `data-impeccable-ignore` attribute repeated
|
||||
//! on every instance of a component, and the waived findings come back
|
||||
//! stamped so a caller can count them.
|
||||
|
||||
use impeccable_core::findings::{ignored_by, Finding};
|
||||
use impeccable_core::selector_ignores::SelectorIgnore;
|
||||
use impeccable_html::{detect_html_source, DetectHtmlOptions};
|
||||
use std::path::Path;
|
||||
|
||||
/// Three instances of one 10px mono label, the shape that earned eleven
|
||||
/// attributes on impeccable-site #34.
|
||||
const PAGE: &str = r#"<!doctype html>
|
||||
<html><head><style>
|
||||
.ks-tag { font-family: ui-monospace, monospace; font-size: 10px; }
|
||||
.free-label { font-size: 10px; }
|
||||
body { font-family: system-ui; font-size: 16px; }
|
||||
</style></head>
|
||||
<body>
|
||||
<h1>Worlds</h1>
|
||||
<p>Body copy long enough to read like a real paragraph on a real page somewhere.</p>
|
||||
<span class="ks-tag">01 - Explore directions</span>
|
||||
<span class="ks-tag">02 - See one built</span>
|
||||
<span class="ks-tag">03 - Third label</span>
|
||||
<span class="free-label">04 - Not part of the component</span>
|
||||
</body></html>
|
||||
"#;
|
||||
|
||||
fn scan(html: &str, entries: &[SelectorIgnore]) -> Vec<Finding> {
|
||||
let opts = DetectHtmlOptions {
|
||||
ignore_selectors: entries,
|
||||
..DetectHtmlOptions::default()
|
||||
};
|
||||
detect_html_source(html, Path::new("/nonexistent/dir/page.html"), &opts)
|
||||
}
|
||||
|
||||
fn undersized(findings: &[Finding]) -> Vec<&Finding> {
|
||||
findings
|
||||
.iter()
|
||||
.filter(|f| f.antipattern == "undersized-ui-text")
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_entry_covers_every_instance_of_the_component() {
|
||||
let before = scan(PAGE, &[]);
|
||||
let hits = undersized(&before);
|
||||
assert_eq!(hits.len(), 4, "fixture should flag all four labels");
|
||||
assert!(hits.iter().all(|f| ignored_by(f).is_none()));
|
||||
|
||||
let after = scan(
|
||||
PAGE,
|
||||
&[SelectorIgnore::new("undersized-ui-text", ".ks-tag")],
|
||||
);
|
||||
let hits = undersized(&after);
|
||||
assert_eq!(hits.len(), 4, "waived findings are stamped, not dropped");
|
||||
let waived: Vec<&&Finding> = hits
|
||||
.iter()
|
||||
.filter(|f| ignored_by(f) == Some(".ks-tag"))
|
||||
.collect();
|
||||
assert_eq!(waived.len(), 3, "the three component instances are waived");
|
||||
// The label outside the component is untouched, and so is every other rule.
|
||||
let free: Vec<&&Finding> = hits.iter().filter(|f| ignored_by(f).is_none()).collect();
|
||||
assert_eq!(free.len(), 1);
|
||||
assert!(free[0].snippet.contains("Not part of the component"));
|
||||
assert!(after
|
||||
.iter()
|
||||
.filter(|f| f.antipattern != "undersized-ui-text")
|
||||
.all(|f| ignored_by(f).is_none()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_entry_for_another_rule_waives_nothing() {
|
||||
let after = scan(PAGE, &[SelectorIgnore::new("side-tab", ".ks-tag")]);
|
||||
assert!(undersized(&after).iter().all(|f| ignored_by(f).is_none()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_star_entry_waives_every_rule_on_the_component() {
|
||||
let after = scan(PAGE, &[SelectorIgnore::new("*", ".ks-tag")]);
|
||||
let waived = undersized(&after)
|
||||
.iter()
|
||||
.filter(|f| ignored_by(f).is_some())
|
||||
.count();
|
||||
assert_eq!(waived, 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_entry_covers_the_components_subtree() {
|
||||
let html = r#"<!doctype html><html><head><style>
|
||||
.ks-tag { font-size: 10px; }
|
||||
</style></head><body><h1>Title</h1>
|
||||
<p>Body copy long enough to read like a real paragraph on a real page somewhere.</p>
|
||||
<span class="ks-tag">outer <b>inner label text</b></span>
|
||||
</body></html>"#;
|
||||
let after = scan(html, &[SelectorIgnore::new("undersized-ui-text", ".ks-tag")]);
|
||||
assert!(
|
||||
undersized(&after)
|
||||
.iter()
|
||||
.all(|f| ignored_by(f) == Some(".ks-tag")),
|
||||
"a finding on a descendant is waived with its component"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_per_instance_attribute_still_works() {
|
||||
let html = PAGE.replace(
|
||||
r#"<span class="ks-tag">01"#,
|
||||
r#"<span class="ks-tag" data-impeccable-ignore="undersized-ui-text">01"#,
|
||||
);
|
||||
// Attribute-waived findings never reach the caller at all, which is the
|
||||
// behavior that shipped; the config entry is the countable alternative.
|
||||
let after = scan(&html, &[]);
|
||||
assert_eq!(undersized(&after).len(), 3);
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -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."));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,13 +7,22 @@
|
||||
//! ```json
|
||||
//! {
|
||||
//! "inlineIgnores": true,
|
||||
//! "designSystem": { "frontmatter": { ... }, "sidecar": { ... } }
|
||||
//! "designSystem": { "frontmatter": { ... }, "sidecar": { ... } },
|
||||
//! "ignoreSelectors": [{ "rule": "undersized-ui-text", "selector": ".ks-tag" }]
|
||||
//! }
|
||||
//! ```
|
||||
//!
|
||||
//! - `inlineIgnores` (default `true`): apply the `impeccable-disable` waivers
|
||||
//! found in the source, exactly as the CLI does. `false` reports waived
|
||||
//! findings too.
|
||||
//! - `ignoreSelectors`: the project's component-level opt-outs, the
|
||||
//! `detector.ignoreSelectors` entries whose `files` globs cover this file
|
||||
//! (the host narrows them; the engine matches selectors only). A finding on
|
||||
//! an element the selector matches, or on a descendant of one, comes back
|
||||
//! carrying `ignoredBy: "<selector>"` instead of being dropped, so a host
|
||||
//! can report how many hits an author's opt-out silenced. Applies to the
|
||||
//! HTML engine, where elements exist; the text engine has no DOM to match
|
||||
//! against and ignores the key.
|
||||
//! - `designSystem`: the DESIGN.md inputs, not a pre-normalized object (the
|
||||
//! JS API's `options.designSystem` carried `Set`s and `Map`s, which JSON
|
||||
//! cannot). `frontmatter` is the parsed DESIGN.md frontmatter, `sidecar`
|
||||
@@ -42,6 +51,7 @@ use std::path::Path;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use impeccable_detect::design_system::{normalize_design_system, DesignSystem};
|
||||
use impeccable_core::selector_ignores::SelectorIgnore;
|
||||
use impeccable_detect::detect_text::{detect_text, TextOptions};
|
||||
use impeccable_html::{detect_html_source, DesignSystemHook, DetectHtmlOptions, StaticRulePack};
|
||||
use serde_json::Value;
|
||||
@@ -65,6 +75,7 @@ pub fn installed_static_rule_pack() -> Option<&'static dyn StaticRulePack> {
|
||||
struct Options {
|
||||
inline_ignores: bool,
|
||||
design_system: Option<DesignSystem>,
|
||||
ignore_selectors: Vec<SelectorIgnore>,
|
||||
}
|
||||
|
||||
fn parse_options(options_json: &str) -> Options {
|
||||
@@ -87,9 +98,24 @@ fn parse_options(options_json: &str) -> Options {
|
||||
false,
|
||||
))
|
||||
});
|
||||
let ignore_selectors = parsed
|
||||
.get("ignoreSelectors")
|
||||
.and_then(Value::as_array)
|
||||
.map(|list| {
|
||||
list.iter()
|
||||
.filter_map(|e| {
|
||||
let rule = e.get("rule").and_then(Value::as_str)?;
|
||||
let selector = e.get("selector").and_then(Value::as_str)?;
|
||||
let entry = SelectorIgnore::new(rule, selector);
|
||||
entry.is_valid().then_some(entry)
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
Options {
|
||||
inline_ignores,
|
||||
design_system,
|
||||
ignore_selectors,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -142,6 +168,7 @@ pub fn detect_html_source_json(html: &str, file_path: &str, options_json: &str)
|
||||
warn: None,
|
||||
static_rule_pack: installed_static_rule_pack(),
|
||||
rule_pack: crate::installed_rule_pack(),
|
||||
ignore_selectors: &options.ignore_selectors,
|
||||
},
|
||||
);
|
||||
findings_json(&findings)
|
||||
|
||||
@@ -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.
|
||||
|
||||
+13
-7
@@ -184,7 +184,7 @@ Examples:
|
||||
Then `files = walkDir(resolved).filter(f => !shouldIgnoreDetectionFile(f, cwd, config))`. If `files.length > 50 && stdin.isTTY && !json && !quiet`: `stderr> \nFound ${n} files (${htmlCount} HTML) in ${target}.\nScanning may take a while${htmlCount > 10 ? ' (static HTML/CSS processes each HTML file individually)' : ''}.\nTarget a specific subdirectory to narrow scope.\n` then readline prompt `Continue? [Y/n] ` on stderr; empty or `/^y(es)?$/i` continues; otherwise `stderr> Aborted.\n`, `exit 0`. Then `buildImportGraph(files)` → reverse map; each file scanned with its own options; findings from a file that is imported get `f.importedBy = [basename(importer), ...]` (Set iteration order).
|
||||
- **File**: skipped if `shouldIgnoreDetectionFile`; else `detectLocalFile`.
|
||||
- `detectLocalFile(fp, opts)`: extension (lowercased) in `HTML_EXTENSIONS = {'.html','.htm'}` → `detectHtml(fp, opts)`; else `detectText(readFileSync(fp,'utf-8'), fp, opts)`.
|
||||
4. Post-filter: `filterDetectionFindings(all, config)` (ignoreRules/ignoreValues), then `filterByScopes(all, scopes)` (keeps findings whose rule declares any requested scope; empty scopes = no filter), then `--no-advisory` drop.
|
||||
4. Post-filter: `filterDetectionFindings(all, config)` (component ignores, then ignoreRules/ignoreValues), then `filterByScopes(all, scopes)` (keeps findings whose rule declares any requested scope; empty scopes = no filter), then `--no-advisory` drop.
|
||||
5. Partition `{primary, advisory}` by `f.advisory === true || f.severity === 'advisory'`.
|
||||
|
||||
Any target that cannot be scanned sets `hadOperationalFailure` (#711): a URL
|
||||
@@ -201,7 +201,8 @@ target.
|
||||
- quiet: `stderr> ${primary.length} anti-pattern${n===1?'':'s'} found.\n`; if advisory: `stderr> dim(`${adv} advisory note${adv===1?'':'s'} (not counted).`) + '\n'`.
|
||||
- text: `stderr> formatFindings(all,false) + '\n'`.
|
||||
- `exit(hadOperationalFailure ? 1 : (primary.length > 0 ? 2 : 0))`.
|
||||
- no findings: json → `stdout> []\n`; text/quiet → nothing. `exit(hadOperationalFailure ? 1 : 0)`.
|
||||
- **Component-ignore tally** (`detector.ignoreSelectors`, see the config section): when entries waived anything, one `dim` line per `{rule, selector}` goes to **stderr in every mode**, so `--json` stdout stays the findings array a consumer parses: `stderr> ${count} ${rule} hit${count===1?'':'s'} ignored by detector.ignoreSelectors on ${selector}.\n`. In text mode it follows the findings after a blank line; in quiet mode it precedes the advisory note; with no findings at all it is still printed. Nothing is emitted when the project has no such entry or the entries matched nothing, so unchanged projects see unchanged output.
|
||||
- no findings: json → `stdout> []\n`; text/quiet → nothing but the tally above. `exit(hadOperationalFailure ? 1 : 0)`.
|
||||
- Exit 1 takes precedence over exit 2: findings from the targets that did scan
|
||||
do not turn a partial scan into a complete one (#711).
|
||||
- Any other exit: `1` for arg errors above; uncaught exceptions propagate to `cli.js` catch (`exit 1`).
|
||||
@@ -244,7 +245,7 @@ Example (non-TTY):
|
||||
{ antipattern: id, name: ap.name, description: ap.description, severity: ap.severity || 'warning', category: ap.category || null, file: filePath, line, snippet }
|
||||
// plus, only when the effective severity is 'advisory': advisory: true
|
||||
```
|
||||
Optional keys added later by engines (appended after the above): `ignoreValue` (design-system rules; browser findings with a value), `importedBy` (dir scans), `severity` may be overwritten by per-finding promotion (browser & html-patterns, e.g. pulsing dot in a header). Design-system findings are `{...finding(...), ...extras}` where extras = `{ ignoreValue }`. Static-HTML and browser findings have `line: 0`; regex findings have 1-based lines. `severity` values in registry: `'warning'` (default), `'advisory'` (many generated-UI tells and design-system-color/radius/font-size, numbered-section-labels, blinking-cursor, shape-assembled-illustration), `'error'` (`script-error`, `content-hidden-at-rest`). `severity` is the canonical advisory field (#709): `deriveAdvisoryFlag` stamps `advisory: true` when and only when the effective severity is `'advisory'`, so a per-finding promotion or demotion carries the flag with it, and every `severity:'advisory'` rule is partitioned out of the failure count and the exit code. `isAdvisory` accepts either `finding.advisory === true` or `finding.severity === 'advisory'`.
|
||||
Optional keys added later by engines (appended after the above): `ignoreValue` (design-system rules; browser findings with a value), `ignoredBy` (the `detector.ignoreSelectors` selector that waived the finding; the CLI drops and counts these, so they appear only in engine-level output such as the wasm exports), `importedBy` (dir scans), `severity` may be overwritten by per-finding promotion (browser & html-patterns, e.g. pulsing dot in a header). Design-system findings are `{...finding(...), ...extras}` where extras = `{ ignoreValue }`. Static-HTML and browser findings have `line: 0`; regex findings have 1-based lines. `severity` values in registry: `'warning'` (default), `'advisory'` (many generated-UI tells and design-system-color/radius/font-size, numbered-section-labels, blinking-cursor, shape-assembled-illustration), `'error'` (`script-error`, `content-hidden-at-rest`). `severity` is the canonical advisory field (#709): `deriveAdvisoryFlag` stamps `advisory: true` when and only when the effective severity is `'advisory'`, so a per-finding promotion or demotion carries the flag with it, and every `severity:'advisory'` rule is partitioned out of the failure count and the exit code. `isAdvisory` accepts either `finding.advisory === true` or `finding.severity === 'advisory'`.
|
||||
|
||||
**Categories**: `category` is `'slop'` (AI tells) or `'quality'`. Category has **no effect on output**, ordering, or exit codes; it is only carried in the finding and used by `getRulesForCategory`. Registry (59 ids, in order): side-tab, border-accent-on-rounded, overused-font, flat-type-hierarchy, gradient-text, ai-color-palette, cream-palette, nested-cards, monotonous-spacing, bounce-easing, pulsing-dot, blinking-cursor, shape-assembled-illustration, dark-glow, radial-halo, radial-spotlight-glow, marquee, icon-tile-stack, italic-serif-display, hero-eyebrow-chip, kicker-above-heading, numbered-section-labels, em-dash-overuse, marketing-buzzword, aphoristic-cadence, oversized-h1, extreme-negative-tracking, broken-image, script-error, content-hidden-at-rest, edge-flush-cards, text-occlusion, first-viewport-column-overflow, gray-on-color, low-contrast, layout-transition, line-length, cramped-padding, body-text-viewport-edge, tight-leading, skipped-heading, heading-rhythm, justified-text, tiny-text, undersized-ui-text, all-caps-body, wide-tracking, text-overflow, repeated-container-text, clipped-overflow-container, design-system-font, design-system-color, design-system-radius, design-system-font-size, gpt-thin-border-wide-shadow, repeating-stripes-gradient, codex-grid-background, theater-slop-phrase, image-hover-transform. Scopes: `type` = overused-font, flat-type-hierarchy, italic-serif-display, hero-eyebrow-chip, kicker-above-heading, numbered-section-labels, oversized-h1, extreme-negative-tracking, line-length, tight-leading, skipped-heading, heading-rhythm, justified-text, tiny-text, undersized-ui-text, all-caps-body, wide-tracking, design-system-font, design-system-font-size; `layout` = nested-cards, monotonous-spacing, icon-tile-stack, content-hidden-at-rest, edge-flush-cards, text-occlusion, first-viewport-column-overflow, line-length, cramped-padding, body-text-viewport-edge, heading-rhythm, text-overflow, clipped-overflow-container. `RULE_ENGINE_SUPPORT = { regex: Set['source','page-analyzer'], 'static-html': Set['element','page'], browser: Set['element','page','layout'], visual: Set['visual-contrast'] }`.
|
||||
|
||||
@@ -269,7 +270,8 @@ Optional keys added later by engines (appended after the above): `ignoreValue` (
|
||||
- Applied inside `detectText` and `detectHtml` at the end unless `options.inlineIgnores === false` (set by `--no-config` or `--no-inline-ignores`). Not applied to URL scans.
|
||||
- Fast path: skip unless `/impeccable-disable/i` occurs.
|
||||
- **DOM-scoped ignore** (`rules/checks.mjs scopedIgnoreActive`): attribute `data-impeccable-ignore="rule-a rule-b"` (split on `/[\s,]+/`, lowercased; empty value or `*` = all) on an element waives matching findings for it and its subtree in browser, extension, and static engines. In `detectHtml`'s html-patterns pass, selector-backed findings are dropped when every element matched by the (pseudo-stripped) selector is under a waiver; unmatched selectors keep the finding.
|
||||
- Tests: `tests/inline-ignores.test.mjs`; fixture `scoped-ignore.html`.
|
||||
- **Component-scoped ignore** (`detector.ignoreSelectors`): the declared twin of that attribute, for a component whose instances would otherwise each carry one. An entry `{ rule, selector }` waives `rule` for every element matching `selector` and for that element's subtree (`element.closest(selector) !== null`), in the browser engine (`BrowserConfig.ignoreSelectors`, also readable from `window.__IMPECCABLE_CONFIG__`) and the static HTML engine (`DetectHtmlOptions.ignore_selectors`). Rule `*` covers every rule. The engines **stamp** rather than drop: a waived finding comes back carrying `ignoredBy: "<selector>"` (`BrowserFinding.ignoredBy`, serialized on the group finding; `Finding.ignoredBy` in the extras), and the config layer drops it and counts it, so the suppression is a number rather than silence. The text engine has no DOM and never stamps.
|
||||
- Tests: `tests/inline-ignores.test.mjs`; fixture `scoped-ignore.html`. Component ignores: `crates/html/tests/selector_ignores.rs`, `crates/core/src/browser/driver.rs` tests, oracle `detect-selector-ignore-*`.
|
||||
|
||||
#### Config file (`cli/lib/impeccable-config.mjs`) — `.impeccable/config.json` + `.impeccable/config.local.json`
|
||||
|
||||
@@ -278,24 +280,27 @@ Optional keys added later by engines (appended after the above): `ignoreValue` (
|
||||
```json
|
||||
{ "detector": { "ignoreRules": ["side-tab"], "ignoreFiles": ["src/legacy/**"],
|
||||
"ignoreValues": [{ "rule": "overused-font", "value": "inter", "files": ["src/a.css"], "createdAt": "ISO", "reason": "..." }],
|
||||
"ignoreSelectors": [{ "rule": "undersized-ui-text", "selector": ".ks-tag", "files": ["src/a.astro"], "createdAt": "ISO", "reason": "..." }],
|
||||
"designSystem": { "enabled": true }, "advisoryRules": "include"|"exclude" },
|
||||
"hook": { "consent": "accepted"|"declined", ... }, "updateCheck": true }
|
||||
```
|
||||
- `readDetectionConfig(root)`: start `{ignoreRules:[],ignoreFiles:[],ignoreValues:[],designSystem:{enabled:true}}`; for shared then local: apply legacy `raw.hook.*` section then `raw.detector.*`. Arrays are unioned (`uniqueStrings`, String-coerced); ignoreValues merged by key `rule\0value\0sortedFiles.join('\x1f')` (later wins); `designSystem.enabled` false only when literally `false`; `advisoryRules` copied only if `'include'|'exclude'`. Invalid JSON / non-object files are ignored silently. **No validation errors are ever raised by the CLI**; the only validation of ignore lists lives in `skill/scripts/lib/staleness-deep.mjs checkDetectorIgnores` (doctor): unknown `ignoreRules` ids vs live `ANTIPATTERNS` → finding `detector-ignore-rules-unknown` (severity `mention`); non-glob `ignoreFiles` entries that don't exist → `detector-ignore-files-missing`.
|
||||
- `normalizeIgnoreValue(v)`: trim, strip one leading/trailing quote, `+`→space, collapse whitespace, lowercase. Rules lowercased/trimmed.
|
||||
- `normalizeIgnoreValueEntries`: keeps `{rule, value, [files], [createdAt], [reason]}` in **that key order**; `file` (string) and `files` merged, trimmed, deduped.
|
||||
- `ignoreSelectors` (component ignores): merged by key `rule\0selector\0sortedFiles.join('\x1f')` (later wins), normalized to `{rule, selector, [files], [createdAt], [reason]}` in **that key order**. The rule is lowercased/trimmed like every rule id; the **selector keeps its case** (CSS class names are case-sensitive) and only loses surrounding whitespace. An entry missing either half is dropped. `selectorIgnoresForTarget(config, target)` narrows the list per local scan target: an entry without `files` covers every target, one with `files` covers the paths its globs match (raw path then each `/`-suffix). A URL scan uses `selectorIgnoresForUrl(config)`, the unscoped entries only: a `files` glob names repo paths, so it must not reach a URL that happens to end the same way. `--no-config` leaves the list empty. The key is **written only by a project that uses it**, so an existing config does not grow an empty `ignoreSelectors` on the next `ignores add-rule`. `doctor`'s `detector-ignore-rules-unknown` validates the `rule` of each entry alongside `ignoreRules`.
|
||||
- Glob → regex: `**` → `.*` (swallowing a following `/`), `*` → `[^/]*`, `?` → `[^/]`, `{a,b}` → `(?:a|b)`, regex specials escaped; anchored `^...$`. `matchesAnyGlob` tests the `/`-normalized path and its basename.
|
||||
- `shouldIgnoreDetectionFile(filePath, root, config)`: raw path, absolute path, and root-relative path (if inside root) tested against `ignoreFiles`.
|
||||
- `filterDetectionFindings`: drop when `ignoreRules` has the rule, or an `ignoreValues` entry matches: same rule; entry.value `*` (wildcard) OR extracted value equals (with color-key equality for `design-system-color`: rgb/hex/hsl parsed to `r,g,b,round(a*255)`); if entry has `files`, `finding.file` (or any `/`-suffix of it) must glob-match; a wildcard with no files never matches (unscoped `*` disallowed).
|
||||
- `filterDetectionFindings`: first drop every finding the engines stamped with `ignoredBy` (the component ignores above), counted by `{rule, selector, count}` in first-seen order; `filterDetectionFindingsReported` returns that tally beside the kept findings. Then drop when `ignoreRules` has the rule, or an `ignoreValues` entry matches: same rule; entry.value `*` (wildcard) OR extracted value equals (with color-key equality for `design-system-color`: rgb/hex/hsl parsed to `r,g,b,round(a*255)`); if entry has `files`, `finding.file` (or any `/`-suffix of it) must glob-match; a wildcard with no files never matches (unscoped `*` disallowed).
|
||||
- `extractFindingIgnoreValue`: only for `overused-font, bounce-easing, design-system-font, design-system-color, design-system-radius, design-system-font-size`; source `finding.ignoreValue || finding.value`, else parse `detail`/`snippet`: bounce → `animate-bounce`, `cubic-bezier(...)`, or animation token matching `/bounce|elastic|wobble|jiggle|spring/i`; fonts → `Primary font:`, `Google Fonts:`, `font-family:` value, or `family=` URL param (decoded).
|
||||
|
||||
#### `impeccable ignores` (`cli/bin/commands/ignores.mjs`)
|
||||
|
||||
- Actions/aliases: `status|ls|list`→list (default when no action), `add-rule|ignore-rule`, `add-file|ignore-file`, `add-value|ignore-value|update-value`, `remove-rule|rm-rule`, `remove-file|rm-file`, `remove-value|rm-value`, `clear`. `--help`/`-h` prints usage (stdout). Unknown → throws `Unknown ignores action: ${a}. Run "impeccable ignores --help".` (exit 1 via cli.js).
|
||||
- Actions/aliases: `status|ls|list`→list (default when no action), `add-rule|ignore-rule`, `add-file|ignore-file`, `add-value|ignore-value|update-value`, `add-selector|ignore-selector|update-selector`, `remove-rule|rm-rule`, `remove-file|rm-file`, `remove-value|rm-value`, `remove-selector|rm-selector`, `clear`. `--help`/`-h` prints usage (stdout). Unknown → throws `Unknown ignores action: ${a}. Run "impeccable ignores --help".` (exit 1 via cli.js).
|
||||
- Scope flags: `--shared` (default), `--local`, `--all` (remove/clear only); more than one → error `Pass only one scope flag: --shared, --local, or --all` (or `--shared or --local`).
|
||||
- `add-rule <rule> [--all-values] [--reason ...]`: `overused-font` without `--all-values` → error "overused-font is value-specific by default. Use add-value overused-font <font>, or add-rule overused-font --all-values for broad suppression." Output: `Added ${rule} to ${local?'local':'shared'} detector ignoreRules (${relpath}).`
|
||||
- `add-file <glob>` → `Added ${glob} to ... detector ignoreFiles (...)`.
|
||||
- `add-value <rule> <value...> [--file <glob>]... [--reason <text...>]`: value = normalized join of positionals after rule; `--file`/`--files`/`--file=`/`--files=` (empty or flag-like → error); unknown `--x` → `Unknown add-value flag: --x`; `*` value requires `--file`; existing entry (same key) updates reason/files, else pushes `{rule,value,[files],createdAt: ISO now,[reason]}`. Output `Added ${rule}=${value} to ... detector ignoreValues (...)`.
|
||||
- `add-selector <rule> <selector...> [--file <glob>]... [--reason <text...>]`: the selector is the positionals after the rule joined with a space and trimmed, case preserved (`.card .ks-tag` is one descendant selector). Missing rule or selector → `Pass a rule id and a CSS selector, e.g. impeccable ignores add-selector undersized-ui-text ".ks-tag"`; selector `*` → `A \`*\` selector waives the rule everywhere. Use add-rule for that, or name the component's selector.`; unknown `--x` → `Unknown add-selector flag: --x`. An existing entry (same rule + selector + files) updates reason/files, else pushes `{rule,selector,[files],createdAt: ISO now,[reason]}`. Output `Added ${rule} on ${selector} to ... detector ignoreSelectors (...)`.
|
||||
- `remove-*` → `Removed ${n} from shared (path), ${n} from local (path).` or `No matching detector ignore found.` `clear` → `Cleared detector ignores in ${'shared and local config'|'local config'|'shared config'}.`
|
||||
- `list` output:
|
||||
```
|
||||
@@ -307,6 +312,7 @@ Optional keys added later by engines (appended after the above): `ignoreValue` (
|
||||
ignoreRules: (none)
|
||||
ignoreFiles: ...
|
||||
ignoreValues: rule=value [glob1, glob2] - reason, ...
|
||||
ignoreSelectors: rule on selector [glob1, glob2] - reason, ... (omitted when empty)
|
||||
designSystem: enabled|disabled
|
||||
|
||||
Shared:
|
||||
@@ -1740,7 +1746,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`.
|
||||
|
||||
+10
-2
@@ -204,11 +204,19 @@ over the file-scanning engines, JSON in and JSON out:
|
||||
- `detect_text_json(content, file_path, options_json)`
|
||||
- `detect_html_source_json(html, file_path, options_json)`
|
||||
|
||||
Both take `{ inlineIgnores?: boolean, designSystem?: { frontmatter?, sidecar? } }`
|
||||
Both take
|
||||
`{ inlineIgnores?: boolean, designSystem?: { frontmatter?, sidecar? }, ignoreSelectors?: [{ rule, selector }] }`
|
||||
and return the findings array `impeccable detect --json` prints, same keys and
|
||||
same order. `designSystem` carries the DESIGN.md inputs rather than a
|
||||
normalized object, because the JS API's normalized form used `Set`s and
|
||||
`Map`s that JSON cannot hold. Unparseable options fall back to the defaults.
|
||||
`Map`s that JSON cannot hold. `ignoreSelectors` is the project's
|
||||
component-level opt-out (`detector.ignoreSelectors`), already narrowed by the
|
||||
host to the entries whose `files` globs cover this file: a finding on an
|
||||
element the selector matches, or on a descendant of one, comes back carrying
|
||||
`ignoredBy: "<selector>"` rather than being dropped, so the host can count
|
||||
what an author's opt-out silenced and say so. Only the HTML engine matches
|
||||
selectors; the text engine has no DOM and ignores the key. Unparseable
|
||||
options fall back to the defaults.
|
||||
`antipatterns_json()` lists the built-ins followed by any pack's rows, and
|
||||
`immediate_tier_rules_json()` returns the design hook's immediate tier (the
|
||||
rule ids worth fixing at the edit site). That list lives in
|
||||
|
||||
@@ -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 `{{command_prefix}}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 `{{command_prefix}}impeccable polish` for the final pass.
|
||||
|
||||
@@ -12,7 +12,7 @@ This command toggles the hook **per project** by editing `.impeccable/config.jso
|
||||
|
||||
Declare server-side template extensions under **`detector.extensions`** when the project uses Blade, Twig, ERB, or Handlebars files; the hook skips them otherwise because they sit outside the built-in extension list. One entry per extension, `{ "ext": ".blade.php", "engine": "html" }`. `engine` picks the analyzer (`html` for markup templates, `text` for JS/TS/CSS-like files) and defaults to `html`. Match against the end of the filename, so double extensions like `.blade.php` and `.html.erb` work. Config only adds extensions; the built-in list always applies.
|
||||
|
||||
Manual `npx impeccable detect` scans use the same project filter config by default: `detector.ignoreRules`, `detector.ignoreFiles`, `detector.ignoreValues`, and `detector.designSystem.enabled`. `hook.enabled` only controls automatic hook execution, not manual CLI scans. Use `npx impeccable detect --no-config ...` for a raw detector run that ignores project config/context. Use `npx impeccable ignores ...` for direct CLI CRUD on the same detector ignores.
|
||||
Manual `npx impeccable detect` scans use the same project filter config by default: `detector.ignoreRules`, `detector.ignoreFiles`, `detector.ignoreValues`, `detector.ignoreSelectors`, and `detector.designSystem.enabled`. `hook.enabled` only controls automatic hook execution, not manual CLI scans. Use `npx impeccable detect --no-config ...` for a raw detector run that ignores project config/context. Use `npx impeccable ignores ...` for direct CLI CRUD on the same detector ignores.
|
||||
|
||||
Supported harnesses: Claude Code (`.claude/settings.local.json` in the project, which is gitignored so the hook stays machine-local; a hook you move into the shared `settings.json` is honored in place too), Codex (`.codex/hooks.json` in the project), Cursor (`.cursor/hooks.json` in the project), Grok Build (`.grok/hooks/impeccable.json` in the project; requires `/hooks-trust` or `--trust`), and GitHub Copilot (`.github/hooks/impeccable.json` in the project, a team-shared committed file that both the Copilot CLI and the cloud agent read). For the Copilot CLI, repo-level hooks fire once `.github/hooks/impeccable.json` is committed to the repository's default branch.
|
||||
|
||||
@@ -63,9 +63,11 @@ Prefer the narrowest exception:
|
||||
- If the finding line shows an `ignore-value <rule> <value>` pair, pass it to `impeccable hooks ignore-value` with your `--reason`. This writes shared `.impeccable/config.json` by default.
|
||||
- For value-specific findings such as `overused-font` and `bounce-easing`, use `ignore-value` for the specific value. Do not use `ignore-rule overused-font` for a specific font.
|
||||
- If the finding has no value-specific command, such as `side-tab`, scope that one rule to the file: `ignore-value <id> "*" --file <path>`. Run `npx impeccable detect <path>` first to see what actually fires there.
|
||||
- If the same rule fires on every instance of one component, waive the component once instead of per instance: `npx impeccable ignores add-selector <rule> "<css-selector>" --reason "..."`. It writes `detector.ignoreSelectors` in the same `.impeccable/config.json`, waives that rule for every element the selector matches and for its subtree, and every scan then reports how many hits it suppressed, so the exception stays visible. Eleven copies of the same 10px label want one entry here, not eleven `data-impeccable-ignore` attributes. This is a component-wide suppression: ask the user first, as you would for `ignore-file`. There is no `hooks ignore-selector`; use the `ignores` command.
|
||||
- Reach for `ignore-file <path>` only when the whole file is out of scope for design review: a fixture, a generated artifact, a deliberate slop demo. It silences every rule for that file permanently, including rules that have not been written yet. A real UI surface with one noisy rule wants the file-scoped value ignore above.
|
||||
- Use `ignore-rule <id>` only when the user asks to suppress that whole rule across the project. For broad overused-font suppression, use `ignore-rule overused-font --all-values` only when the user asks to ignore overused fonts generally.
|
||||
- Prefer config ignores (the commands above) by default; they keep suppressions in one reviewable place. Reach for an inline comment only when the waiver must travel with a single file that leaves the repo (a generated/exported standalone document, an emailed HTML file). The supported marker is `impeccable-disable <rule>` (whole file) or `impeccable-disable-line` / `impeccable-disable-next-line` (one line), in any comment syntax, with an optional reason after `:` or `--`. The detector honors it by default; `--no-inline-ignores` or `--no-config` bypasses it.
|
||||
- The DOM equivalent, `data-impeccable-ignore="<rule>"` on an element, waives that element and its subtree. It belongs on a one-off: a single demo block, one deliberately ugly sample. Repeating it across every instance of a component is the sign you wanted `ignores add-selector` instead: the attribute hides the count from whoever reviews the change, the config entry reports it.
|
||||
|
||||
Example value-specific exception:
|
||||
|
||||
@@ -92,6 +94,12 @@ for everything else:
|
||||
{{scripts_path}}/impeccable hooks ignore-value design-system-font-size "*" --file "src/overlay/widget.js" --reason "Injected widget builds its own type scale; DESIGN.md's ramp describes the site"
|
||||
```
|
||||
|
||||
Example component exception, for one rule across every instance of a component:
|
||||
|
||||
```bash
|
||||
npx impeccable ignores add-selector undersized-ui-text ".ks-tag" --reason "User confirmed: 10px mono index labels, decorative counters beside the heading"
|
||||
```
|
||||
|
||||
Example whole-file exception, for a file that is out of scope entirely:
|
||||
|
||||
```bash
|
||||
|
||||
@@ -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>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user