mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-11 21:57:14 +03:00
Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dacf7d4664 | ||
|
|
634b579b63 | ||
|
|
4c5f3974b3 | ||
|
|
2422ec058f | ||
|
|
0f115e5d37 | ||
|
|
66945a79da | ||
|
|
a82233736c |
@@ -188,12 +188,6 @@ 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,12 +48,11 @@ 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, gestures work under touch)
|
||||
**Score 0-4**: 0=Desktop-only (breaks on mobile), 1=Major issues (some breakpoints, many failures), 2=Partial (works on mobile, rough edges), 3=Good (responsive, minor touch target or overflow issues), 4=Excellent (fluid, all viewports, proper touch targets)
|
||||
|
||||
### 5. Implementation Integrity (CRITICAL)
|
||||
|
||||
|
||||
@@ -205,11 +205,6 @@ 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
|
||||
@@ -309,7 +304,6 @@ 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)
|
||||
|
||||
@@ -336,10 +330,7 @@ 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,12 +188,6 @@ 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,12 +48,11 @@ 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, gestures work under touch)
|
||||
**Score 0-4**: 0=Desktop-only (breaks on mobile), 1=Major issues (some breakpoints, many failures), 2=Partial (works on mobile, rough edges), 3=Good (responsive, minor touch target or overflow issues), 4=Excellent (fluid, all viewports, proper touch targets)
|
||||
|
||||
### 5. Implementation Integrity (CRITICAL)
|
||||
|
||||
|
||||
@@ -205,11 +205,6 @@ 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
|
||||
@@ -309,7 +304,6 @@ 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)
|
||||
|
||||
@@ -336,10 +330,7 @@ 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,12 +188,6 @@ 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,12 +48,11 @@ 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, gestures work under touch)
|
||||
**Score 0-4**: 0=Desktop-only (breaks on mobile), 1=Major issues (some breakpoints, many failures), 2=Partial (works on mobile, rough edges), 3=Good (responsive, minor touch target or overflow issues), 4=Excellent (fluid, all viewports, proper touch targets)
|
||||
|
||||
### 5. Implementation Integrity (CRITICAL)
|
||||
|
||||
|
||||
@@ -205,11 +205,6 @@ 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
|
||||
@@ -309,7 +304,6 @@ 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)
|
||||
|
||||
@@ -336,10 +330,7 @@ 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,12 +188,6 @@ 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,12 +48,11 @@ 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, gestures work under touch)
|
||||
**Score 0-4**: 0=Desktop-only (breaks on mobile), 1=Major issues (some breakpoints, many failures), 2=Partial (works on mobile, rough edges), 3=Good (responsive, minor touch target or overflow issues), 4=Excellent (fluid, all viewports, proper touch targets)
|
||||
|
||||
### 5. Implementation Integrity (CRITICAL)
|
||||
|
||||
|
||||
@@ -205,11 +205,6 @@ 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
|
||||
@@ -309,7 +304,6 @@ 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)
|
||||
|
||||
@@ -336,10 +330,7 @@ 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,12 +188,6 @@ 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,12 +48,11 @@ 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, gestures work under touch)
|
||||
**Score 0-4**: 0=Desktop-only (breaks on mobile), 1=Major issues (some breakpoints, many failures), 2=Partial (works on mobile, rough edges), 3=Good (responsive, minor touch target or overflow issues), 4=Excellent (fluid, all viewports, proper touch targets)
|
||||
|
||||
### 5. Implementation Integrity (CRITICAL)
|
||||
|
||||
|
||||
@@ -205,11 +205,6 @@ 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
|
||||
@@ -309,7 +304,6 @@ 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)
|
||||
|
||||
@@ -336,10 +330,7 @@ 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,12 +188,6 @@ 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,12 +48,11 @@ 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, gestures work under touch)
|
||||
**Score 0-4**: 0=Desktop-only (breaks on mobile), 1=Major issues (some breakpoints, many failures), 2=Partial (works on mobile, rough edges), 3=Good (responsive, minor touch target or overflow issues), 4=Excellent (fluid, all viewports, proper touch targets)
|
||||
|
||||
### 5. Implementation Integrity (CRITICAL)
|
||||
|
||||
|
||||
@@ -205,11 +205,6 @@ 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
|
||||
@@ -309,7 +304,6 @@ 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)
|
||||
|
||||
@@ -336,10 +330,7 @@ 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,12 +188,6 @@ 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,12 +48,11 @@ 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, gestures work under touch)
|
||||
**Score 0-4**: 0=Desktop-only (breaks on mobile), 1=Major issues (some breakpoints, many failures), 2=Partial (works on mobile, rough edges), 3=Good (responsive, minor touch target or overflow issues), 4=Excellent (fluid, all viewports, proper touch targets)
|
||||
|
||||
### 5. Implementation Integrity (CRITICAL)
|
||||
|
||||
|
||||
@@ -205,11 +205,6 @@ 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
|
||||
@@ -309,7 +304,6 @@ 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)
|
||||
|
||||
@@ -336,10 +330,7 @@ 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,12 +188,6 @@ 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,12 +48,11 @@ 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, gestures work under touch)
|
||||
**Score 0-4**: 0=Desktop-only (breaks on mobile), 1=Major issues (some breakpoints, many failures), 2=Partial (works on mobile, rough edges), 3=Good (responsive, minor touch target or overflow issues), 4=Excellent (fluid, all viewports, proper touch targets)
|
||||
|
||||
### 5. Implementation Integrity (CRITICAL)
|
||||
|
||||
|
||||
@@ -205,11 +205,6 @@ 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
|
||||
@@ -309,7 +304,6 @@ 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)
|
||||
|
||||
@@ -336,10 +330,7 @@ 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,12 +188,6 @@ 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,12 +48,11 @@ 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, gestures work under touch)
|
||||
**Score 0-4**: 0=Desktop-only (breaks on mobile), 1=Major issues (some breakpoints, many failures), 2=Partial (works on mobile, rough edges), 3=Good (responsive, minor touch target or overflow issues), 4=Excellent (fluid, all viewports, proper touch targets)
|
||||
|
||||
### 5. Implementation Integrity (CRITICAL)
|
||||
|
||||
|
||||
@@ -205,11 +205,6 @@ 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
|
||||
@@ -309,7 +304,6 @@ 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)
|
||||
|
||||
@@ -336,10 +330,7 @@ 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,12 +188,6 @@ 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,12 +48,11 @@ 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, gestures work under touch)
|
||||
**Score 0-4**: 0=Desktop-only (breaks on mobile), 1=Major issues (some breakpoints, many failures), 2=Partial (works on mobile, rough edges), 3=Good (responsive, minor touch target or overflow issues), 4=Excellent (fluid, all viewports, proper touch targets)
|
||||
|
||||
### 5. Implementation Integrity (CRITICAL)
|
||||
|
||||
|
||||
@@ -205,11 +205,6 @@ 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
|
||||
@@ -309,7 +304,6 @@ 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)
|
||||
|
||||
@@ -336,10 +330,7 @@ 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,12 +188,6 @@ 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,12 +48,11 @@ 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, gestures work under touch)
|
||||
**Score 0-4**: 0=Desktop-only (breaks on mobile), 1=Major issues (some breakpoints, many failures), 2=Partial (works on mobile, rough edges), 3=Good (responsive, minor touch target or overflow issues), 4=Excellent (fluid, all viewports, proper touch targets)
|
||||
|
||||
### 5. Implementation Integrity (CRITICAL)
|
||||
|
||||
|
||||
@@ -205,11 +205,6 @@ 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
|
||||
@@ -309,7 +304,6 @@ 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)
|
||||
|
||||
@@ -336,10 +330,7 @@ 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,12 +188,6 @@ 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,12 +48,11 @@ 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, gestures work under touch)
|
||||
**Score 0-4**: 0=Desktop-only (breaks on mobile), 1=Major issues (some breakpoints, many failures), 2=Partial (works on mobile, rough edges), 3=Good (responsive, minor touch target or overflow issues), 4=Excellent (fluid, all viewports, proper touch targets)
|
||||
|
||||
### 5. Implementation Integrity (CRITICAL)
|
||||
|
||||
|
||||
@@ -205,11 +205,6 @@ 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
|
||||
@@ -309,7 +304,6 @@ 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)
|
||||
|
||||
@@ -336,10 +330,7 @@ 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,12 +188,6 @@ 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,12 +48,11 @@ 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, gestures work under touch)
|
||||
**Score 0-4**: 0=Desktop-only (breaks on mobile), 1=Major issues (some breakpoints, many failures), 2=Partial (works on mobile, rough edges), 3=Good (responsive, minor touch target or overflow issues), 4=Excellent (fluid, all viewports, proper touch targets)
|
||||
|
||||
### 5. Implementation Integrity (CRITICAL)
|
||||
|
||||
|
||||
@@ -205,11 +205,6 @@ 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
|
||||
@@ -309,7 +304,6 @@ 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)
|
||||
|
||||
@@ -336,10 +330,7 @@ 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,12 +188,6 @@ 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,12 +48,11 @@ 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, gestures work under touch)
|
||||
**Score 0-4**: 0=Desktop-only (breaks on mobile), 1=Major issues (some breakpoints, many failures), 2=Partial (works on mobile, rough edges), 3=Good (responsive, minor touch target or overflow issues), 4=Excellent (fluid, all viewports, proper touch targets)
|
||||
|
||||
### 5. Implementation Integrity (CRITICAL)
|
||||
|
||||
|
||||
@@ -205,11 +205,6 @@ 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
|
||||
@@ -309,7 +304,6 @@ 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)
|
||||
|
||||
@@ -336,10 +330,7 @@ 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,12 +188,6 @@ 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,12 +48,11 @@ 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, gestures work under touch)
|
||||
**Score 0-4**: 0=Desktop-only (breaks on mobile), 1=Major issues (some breakpoints, many failures), 2=Partial (works on mobile, rough edges), 3=Good (responsive, minor touch target or overflow issues), 4=Excellent (fluid, all viewports, proper touch targets)
|
||||
|
||||
### 5. Implementation Integrity (CRITICAL)
|
||||
|
||||
|
||||
@@ -205,11 +205,6 @@ 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
|
||||
@@ -309,7 +304,6 @@ 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)
|
||||
|
||||
@@ -336,10 +330,7 @@ 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,12 +188,6 @@ 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,12 +48,11 @@ 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, gestures work under touch)
|
||||
**Score 0-4**: 0=Desktop-only (breaks on mobile), 1=Major issues (some breakpoints, many failures), 2=Partial (works on mobile, rough edges), 3=Good (responsive, minor touch target or overflow issues), 4=Excellent (fluid, all viewports, proper touch targets)
|
||||
|
||||
### 5. Implementation Integrity (CRITICAL)
|
||||
|
||||
|
||||
@@ -205,11 +205,6 @@ 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
|
||||
@@ -309,7 +304,6 @@ 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)
|
||||
|
||||
@@ -336,10 +330,7 @@ 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,12 +188,6 @@ 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,12 +48,11 @@ 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, gestures work under touch)
|
||||
**Score 0-4**: 0=Desktop-only (breaks on mobile), 1=Major issues (some breakpoints, many failures), 2=Partial (works on mobile, rough edges), 3=Good (responsive, minor touch target or overflow issues), 4=Excellent (fluid, all viewports, proper touch targets)
|
||||
|
||||
### 5. Implementation Integrity (CRITICAL)
|
||||
|
||||
|
||||
@@ -205,11 +205,6 @@ 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
|
||||
@@ -309,7 +304,6 @@ 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)
|
||||
|
||||
@@ -336,10 +330,7 @@ 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,12 +188,6 @@ 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,12 +48,11 @@ 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, gestures work under touch)
|
||||
**Score 0-4**: 0=Desktop-only (breaks on mobile), 1=Major issues (some breakpoints, many failures), 2=Partial (works on mobile, rough edges), 3=Good (responsive, minor touch target or overflow issues), 4=Excellent (fluid, all viewports, proper touch targets)
|
||||
|
||||
### 5. Implementation Integrity (CRITICAL)
|
||||
|
||||
|
||||
@@ -205,11 +205,6 @@ 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
|
||||
@@ -309,7 +304,6 @@ 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)
|
||||
|
||||
@@ -336,10 +330,7 @@ Test thoroughly with edge cases:
|
||||
- **Network issues**: Disable internet, throttle connection
|
||||
- **Large datasets**: Test with 1000+ items
|
||||
- **Concurrent actions**: Click submit 10 times rapidly
|
||||
- **Interrupted gestures**: Add a second finger mid-drag, scroll across the control, release outside it, switch windows mid-drag; then drag again
|
||||
- **Errors**: Force API errors, test all error states
|
||||
- **Empty**: Remove all data, test empty states
|
||||
|
||||
For gestures, say what produced the evidence (emulated viewport, synthesized touch, which engine, physical device) and name what stayed untested.
|
||||
|
||||
When edge cases are covered, hand off to `/impeccable polish` for the final pass.
|
||||
|
||||
Generated
+3
@@ -509,6 +509,7 @@ dependencies = [
|
||||
"image-webp",
|
||||
"moxcms",
|
||||
"num-traits",
|
||||
"png",
|
||||
"zune-core",
|
||||
"zune-jpeg",
|
||||
]
|
||||
@@ -671,9 +672,11 @@ dependencies = [
|
||||
name = "impeccable-html"
|
||||
version = "0.1.5"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"cssparser",
|
||||
"ego-tree",
|
||||
"html5ever",
|
||||
"image",
|
||||
"impeccable-common",
|
||||
"impeccable-core",
|
||||
"impeccable-detect",
|
||||
|
||||
@@ -465,7 +465,7 @@ npx impeccable ignores add-value overused-font Inter --reason "Brand font"
|
||||
|
||||
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.
|
||||
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. File scans read the pixels of local background images behind text, so white copy on a near-white photo fails a CI gate without a browser; a remote image is never fetched. 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.
|
||||
|
||||
|
||||
@@ -585,7 +585,6 @@ 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; }
|
||||
@@ -616,11 +615,6 @@ 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),
|
||||
@@ -638,6 +632,7 @@ 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,
|
||||
|
||||
@@ -20,9 +20,8 @@ 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, check_placeholder_colors, is_emoji_only_text, BorderOpts,
|
||||
ColorOpts, GlowOpts, HeroEyebrowOpts, IconTileOpts, ItalicSerifOpts, MotionOpts, RuleHit,
|
||||
Sides, HEADING_TAGS,
|
||||
check_italic_serif, check_motion, 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,
|
||||
@@ -460,8 +459,8 @@ pub fn check_element_colors_dom(dom: &dyn Dom, el: ElId) -> Vec<RuleHit> {
|
||||
} else {
|
||||
resolve_gradient_stops(dom, el)
|
||||
};
|
||||
let color_opts = ColorOpts {
|
||||
tag: tag.clone(),
|
||||
check_colors(&ColorOpts {
|
||||
tag,
|
||||
text_color: parse_rgb_or_any(&dom.style(el, "color")),
|
||||
bg_color: own_bg,
|
||||
effective_bg: if surface_unresolved {
|
||||
@@ -478,36 +477,7 @@ 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 ───────────────────────────────
|
||||
@@ -1408,61 +1378,6 @@ 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();
|
||||
|
||||
@@ -29,6 +29,9 @@
|
||||
//! checkNumberedSectionLabels, checkEmDashOveruse, isRepeatedTextContainer.
|
||||
//! Open (selector and tag lists, thresholds, the two text parsers):
|
||||
//! `impeccable_foundation::rules::text`.
|
||||
//! - `sampled_contrast`: the static engine's pixel-sampled verdict for text
|
||||
//! over a `url()` background (#560): grid, decoration gate, wash and
|
||||
//! compositing rules, the percentile verdict. No JS ancestor.
|
||||
//!
|
||||
//! Element/document adapters (`checkElement*`, `*DOM`, `*FromDoc`) are NOT in
|
||||
//! core: the static ones live in the `html` crate against its DOM model, the
|
||||
@@ -43,6 +46,7 @@ pub mod css_scan;
|
||||
pub mod html_patterns;
|
||||
pub mod measures;
|
||||
pub mod rules;
|
||||
pub mod sampled_contrast;
|
||||
pub mod text_rules;
|
||||
|
||||
#[cfg(feature = "vectors")]
|
||||
|
||||
+89
-173
@@ -4,8 +4,7 @@
|
||||
//! `undefined` / `null` distinctions the source relies on.
|
||||
|
||||
use crate::color::{
|
||||
color_to_hex, composite_color_over, contrast_ratio, get_hue, has_chroma, is_neutral_color,
|
||||
relative_luminance, Rgba,
|
||||
color_to_hex, 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,
|
||||
@@ -136,30 +135,104 @@ fn is_heading_123(tag: &str) -> bool {
|
||||
matches!(tag, "h1" | "h2" | "h3")
|
||||
}
|
||||
|
||||
/// The `SAFE_TAGS` skip at the top of `checkColors`: a link, control, or
|
||||
/// quote is measured only when it paints its own surface. Shared with the
|
||||
/// sampled-contrast path so both skip the same elements.
|
||||
pub fn safe_tag_unstyled(opts: &ColorOpts) -> bool {
|
||||
if !set_has(SAFE_TAGS, opts.tag.as_str()) {
|
||||
return false;
|
||||
}
|
||||
let bg_image = opts.bg_image.as_deref().unwrap_or("");
|
||||
let own_bg = opts
|
||||
.bg_color
|
||||
.map_or(false, |c| c.a.map_or(false, |a| a > 0.5));
|
||||
let own_gradient = !bg_image.is_empty() && GRADIENT_CI.is_match(bg_image);
|
||||
let is_styled_control =
|
||||
opts.has_direct_text && (own_bg || own_gradient) && opts.font_size >= 9.0;
|
||||
!is_styled_control
|
||||
}
|
||||
|
||||
/// JS: checks.mjs#checkColors
|
||||
pub fn check_colors(opts: &ColorOpts) -> Vec<RuleHit> {
|
||||
let tag = opts.tag.as_str();
|
||||
let bg_image = opts.bg_image.as_deref().unwrap_or("");
|
||||
let bg_clip = opts.bg_clip.as_deref().unwrap_or("");
|
||||
if set_has(SAFE_TAGS, tag) {
|
||||
let own_bg = opts
|
||||
.bg_color
|
||||
.map_or(false, |c| c.a.map_or(false, |a| a > 0.5));
|
||||
let own_gradient = !bg_image.is_empty() && GRADIENT_CI.is_match(bg_image);
|
||||
let is_styled_control =
|
||||
opts.has_direct_text && (own_bg || own_gradient) && opts.font_size >= 9.0;
|
||||
if !is_styled_control {
|
||||
return Vec::new();
|
||||
}
|
||||
if safe_tag_unstyled(opts) {
|
||||
return Vec::new();
|
||||
}
|
||||
let mut findings = Vec::new();
|
||||
|
||||
if opts.has_direct_text && opts.text_color.is_some() && !opts.is_emoji_only {
|
||||
let text_color = opts.text_color.unwrap();
|
||||
// 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));
|
||||
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]))
|
||||
),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if has_chroma(Some(&text_color), Some(50.0)) {
|
||||
@@ -217,119 +290,6 @@ 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()
|
||||
@@ -1093,50 +1053,6 @@ 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"));
|
||||
|
||||
@@ -0,0 +1,302 @@
|
||||
//! Pixel-sampled contrast for text over a `url()` background image in the
|
||||
//! static engine (#560). Outside a browser there is no CORS and no canvas
|
||||
//! taint, so the engine reads the image itself; what it cannot know is
|
||||
//! layout. The verdict is therefore coarse: a fixed grid over the whole
|
||||
//! image, and a finding only when nearly all of it fails. A photo with a dark
|
||||
//! third under the headline passes; white text on a near-white texture does
|
||||
//! not, wherever the text sits.
|
||||
//!
|
||||
//! This module is the decisions: grid geometry, the decoration gate, the
|
||||
//! wash and compositing rules, the percentile verdict and its label. Reading
|
||||
//! and decoding the image is the html crate's job.
|
||||
|
||||
use crate::checks::rules::{safe_tag_unstyled, ColorOpts, RuleHit};
|
||||
use crate::color::{color_to_hex, composite_color_over, contrast_ratio, Rgba};
|
||||
use crate::constants::{WCAG_LARGE_BOLD_TEXT_PX, WCAG_LARGE_TEXT_PX};
|
||||
use crate::js::{number_to_string, parse_float, to_fixed};
|
||||
|
||||
/// Grid points per axis: 6x6 = 36 samples, sampling rather than scanning.
|
||||
const GRID: usize = 6;
|
||||
/// A `no-repeat` layer painted smaller than this on a non-repeating axis is
|
||||
/// an icon, badge, or rule, not the ground the text sits on.
|
||||
const DECORATION_MAX_PX: f64 = 160.0;
|
||||
/// The share of the grid that has to resolve to a color before a verdict.
|
||||
const MIN_SAMPLE_SHARE: f64 = 0.75;
|
||||
/// The finding fires when this share of the samples fails: the text is
|
||||
/// somewhere on the image, so only a near-uniform failure is a failure.
|
||||
const FAIL_PERCENTILE: f64 = 0.9;
|
||||
|
||||
/// The color rule's own gates, so the sampler never fires where
|
||||
/// `check_colors` would not have measured a resolved background.
|
||||
pub fn applies(opts: &ColorOpts) -> bool {
|
||||
opts.has_direct_text
|
||||
&& opts.text_color.is_some()
|
||||
&& !opts.is_emoji_only
|
||||
&& opts.bg_clip.as_deref() != Some("text")
|
||||
&& !safe_tag_unstyled(opts)
|
||||
}
|
||||
|
||||
/// The sample positions over a `width` x `height` raster: cell centers of
|
||||
/// the `GRID`, row-major.
|
||||
pub fn grid_points(width: usize, height: usize) -> Vec<(usize, usize)> {
|
||||
if width == 0 || height == 0 {
|
||||
return Vec::new();
|
||||
}
|
||||
let at = |i: usize, extent: usize| -> usize {
|
||||
let v = ((i as f64 + 0.5) / GRID as f64 * extent as f64).floor() as usize;
|
||||
v.min(extent - 1)
|
||||
};
|
||||
let mut points = Vec::with_capacity(GRID * GRID);
|
||||
for gy in 0..GRID {
|
||||
for gx in 0..GRID {
|
||||
points.push((at(gx, width), at(gy, height)));
|
||||
}
|
||||
}
|
||||
points
|
||||
}
|
||||
|
||||
/// Whether a layer with this `background-repeat` and `background-size`
|
||||
/// (the layer's own comma-list entries) paints too little to be the ground:
|
||||
/// a non-repeating axis whose painted extent is under
|
||||
/// `DECORATION_MAX_PX`. Repeating, `cover`, `contain`, and percentage sizes
|
||||
/// fill the box and are never decoration.
|
||||
pub fn is_decorative_layer(repeat: &str, size: &str, intrinsic_w: f64, intrinsic_h: f64) -> bool {
|
||||
let repeat = repeat.trim().to_ascii_lowercase();
|
||||
let tokens: Vec<&str> = repeat.split_whitespace().collect();
|
||||
let (repeat_x, repeat_y) = match tokens.as_slice() {
|
||||
["no-repeat"] => (false, false),
|
||||
["repeat-x"] => (true, false),
|
||||
["repeat-y"] => (false, true),
|
||||
[x, y] => (*x != "no-repeat", *y != "no-repeat"),
|
||||
_ => (true, true),
|
||||
};
|
||||
if repeat_x && repeat_y {
|
||||
return false;
|
||||
}
|
||||
let size = size.trim().to_ascii_lowercase();
|
||||
if size == "cover" || size == "contain" || size.contains('%') {
|
||||
return false;
|
||||
}
|
||||
let tokens: Vec<&str> = size.split_whitespace().collect();
|
||||
let px = |t: Option<&&str>| t.filter(|t| t.ends_with("px")).map(|t| parse_float(t));
|
||||
// An `auto` (or absent) axis follows the other at the image's aspect
|
||||
// ratio, as in CSS: `40px`, `40px auto`, and `auto 40px` all scale.
|
||||
let (w, h) = match (px(tokens.first()), px(tokens.get(1))) {
|
||||
(Some(w), Some(h)) => (w, h),
|
||||
(Some(w), None) if intrinsic_w > 0.0 => (w, intrinsic_h * (w / intrinsic_w)),
|
||||
(None, Some(h)) if intrinsic_h > 0.0 => (intrinsic_w * (h / intrinsic_h), h),
|
||||
(Some(w), None) => (w, intrinsic_h),
|
||||
(None, Some(h)) => (intrinsic_w, h),
|
||||
(None, None) => (intrinsic_w, intrinsic_h),
|
||||
};
|
||||
(!repeat_x && w < DECORATION_MAX_PX) || (!repeat_y && h < DECORATION_MAX_PX)
|
||||
}
|
||||
|
||||
/// A translucent gradient whose stops are all one color is a tint over the
|
||||
/// image and composites exactly; a gradient that varies is a scrim placed
|
||||
/// under the text on purpose, and without layout the engine cannot say
|
||||
/// which stop the text sits on. `None` for the varying case.
|
||||
pub fn uniform_wash(stops: &[Rgba]) -> Option<Rgba> {
|
||||
let first = *stops.first()?;
|
||||
let same = |s: &Rgba| {
|
||||
s.r == first.r
|
||||
&& s.g == first.g
|
||||
&& s.b == first.b
|
||||
&& (s.alpha_or_one() - first.alpha_or_one()).abs() < 1e-9
|
||||
};
|
||||
stops.iter().all(same).then_some(first)
|
||||
}
|
||||
|
||||
/// One grid sample's ground color: the pixel, composited over `under` when
|
||||
/// it is translucent, then under every `overlays` layer (top to bottom, as
|
||||
/// the walk collected them). `None` when a translucent pixel has nothing
|
||||
/// known beneath it.
|
||||
pub fn composite_sample(pixel: Rgba, under: Option<Rgba>, overlays: &[Rgba]) -> Option<Rgba> {
|
||||
let mut color = if pixel.alpha_or_one() >= 0.99 {
|
||||
Rgba::new(pixel.r, pixel.g, pixel.b, 1.0)
|
||||
} else {
|
||||
composite_color_over(&pixel, &under?)
|
||||
};
|
||||
for overlay in overlays.iter().rev() {
|
||||
color = composite_color_over(overlay, &color);
|
||||
}
|
||||
Some(color)
|
||||
}
|
||||
|
||||
/// The verdict over the resolved `samples` of a grid of `grid_total` points,
|
||||
/// against the text of `opts`. `ground` names the image in the snippet (its
|
||||
/// file name, or `data:image/png`).
|
||||
pub fn sampled_contrast(
|
||||
opts: &ColorOpts,
|
||||
ground: &str,
|
||||
samples: &[Rgba],
|
||||
grid_total: usize,
|
||||
) -> Option<RuleHit> {
|
||||
if !applies(opts) {
|
||||
return None;
|
||||
}
|
||||
let text = opts.text_color?;
|
||||
let needed = ((grid_total as f64) * MIN_SAMPLE_SHARE).ceil().max(1.0) as usize;
|
||||
if samples.len() < needed {
|
||||
return None;
|
||||
}
|
||||
let mut ratios: Vec<f64> = samples
|
||||
.iter()
|
||||
.map(|bg| {
|
||||
let fg = if text.alpha_or_one() < 1.0 {
|
||||
composite_color_over(&text, bg)
|
||||
} else {
|
||||
text
|
||||
};
|
||||
contrast_ratio(&fg, bg)
|
||||
})
|
||||
.collect();
|
||||
ratios.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
|
||||
let n = ratios.len();
|
||||
let pick = |share: f64| ratios[((share * n as f64).floor() as usize).min(n - 1)];
|
||||
let measured = pick(FAIL_PERCENTILE);
|
||||
let median = pick(0.5);
|
||||
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 measured >= threshold {
|
||||
return None;
|
||||
}
|
||||
let ratio_label = if to_fixed(measured, 1) == to_fixed(threshold, 1) {
|
||||
to_fixed(measured, 2)
|
||||
} else {
|
||||
to_fixed(measured, 1)
|
||||
};
|
||||
Some(RuleHit::new(
|
||||
"low-contrast",
|
||||
format!(
|
||||
"sampled (coarse) {}:1 (need {}:1) — text {} on {}; p90 of {} samples, median {}:1",
|
||||
ratio_label,
|
||||
number_to_string(threshold),
|
||||
color_to_hex(Some(&text)),
|
||||
ground,
|
||||
n,
|
||||
to_fixed(median, 1)
|
||||
),
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn rgba(r: f64, g: f64, b: f64, a: f64) -> Rgba {
|
||||
Rgba::new(r, g, b, a)
|
||||
}
|
||||
|
||||
fn opts(text: Rgba, font_size: f64) -> ColorOpts {
|
||||
ColorOpts {
|
||||
tag: "p".into(),
|
||||
text_color: Some(text),
|
||||
font_size,
|
||||
font_weight: 400.0,
|
||||
has_direct_text: true,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grid_covers_cell_centers_and_clamps() {
|
||||
let points = grid_points(96, 64);
|
||||
assert_eq!(points.len(), 36);
|
||||
assert_eq!(points[0], (8, 5));
|
||||
assert_eq!(points[35], (88, 58));
|
||||
assert_eq!(grid_points(1, 1), vec![(0, 0); 36]);
|
||||
assert!(grid_points(0, 10).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decoration_gate() {
|
||||
assert!(is_decorative_layer("no-repeat", "auto", 24.0, 24.0));
|
||||
assert!(is_decorative_layer("no-repeat", "40px", 1200.0, 800.0));
|
||||
assert!(is_decorative_layer("no-repeat", "40px auto", 1200.0, 800.0));
|
||||
assert!(is_decorative_layer("no-repeat", "auto 40px", 1200.0, 800.0));
|
||||
assert!(!is_decorative_layer(
|
||||
"no-repeat",
|
||||
"auto 800px",
|
||||
1200.0,
|
||||
800.0
|
||||
));
|
||||
assert!(is_decorative_layer("repeat-y", "auto", 8.0, 400.0));
|
||||
assert!(is_decorative_layer("repeat-x", "auto", 1200.0, 80.0));
|
||||
assert!(!is_decorative_layer("no-repeat", "cover", 24.0, 24.0));
|
||||
assert!(!is_decorative_layer("no-repeat", "100% auto", 24.0, 24.0));
|
||||
assert!(!is_decorative_layer("no-repeat", "auto", 1600.0, 900.0));
|
||||
assert!(!is_decorative_layer("repeat", "auto", 4.0, 4.0));
|
||||
assert!(!is_decorative_layer("", "", 16.0, 16.0));
|
||||
assert!(!is_decorative_layer(
|
||||
"repeat no-repeat",
|
||||
"auto",
|
||||
1200.0,
|
||||
300.0
|
||||
));
|
||||
assert!(is_decorative_layer(
|
||||
"repeat no-repeat",
|
||||
"auto",
|
||||
1200.0,
|
||||
30.0
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn washes_and_compositing() {
|
||||
let tint = rgba(0.0, 0.0, 0.0, 0.4);
|
||||
assert_eq!(uniform_wash(&[tint, tint]), Some(tint));
|
||||
assert_eq!(uniform_wash(&[tint, rgba(0.0, 0.0, 0.0, 0.0)]), None);
|
||||
assert_eq!(uniform_wash(&[]), None);
|
||||
let light = rgba(240.0, 240.0, 240.0, 1.0);
|
||||
assert_eq!(composite_sample(light, None, &[]), Some(light));
|
||||
let dimmed = composite_sample(light, None, &[tint]).unwrap();
|
||||
assert_eq!((dimmed.r, dimmed.g, dimmed.b), (144.0, 144.0, 144.0));
|
||||
let translucent = rgba(240.0, 240.0, 240.0, 0.25);
|
||||
assert_eq!(composite_sample(translucent, None, &[]), None);
|
||||
let over_dark =
|
||||
composite_sample(translucent, Some(rgba(20.0, 20.0, 20.0, 1.0)), &[]).unwrap();
|
||||
assert_eq!((over_dark.r, over_dark.a), (75.0, Some(1.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verdict_needs_a_near_uniform_failure() {
|
||||
let white = rgba(253.0, 253.0, 253.0, 1.0);
|
||||
let light = rgba(243.0, 239.0, 230.0, 1.0);
|
||||
let dark = rgba(26.0, 24.0, 22.0, 1.0);
|
||||
let all_light: Vec<Rgba> = vec![light; 36];
|
||||
let hit = sampled_contrast(&opts(white, 16.0), "hero.png", &all_light, 36).unwrap();
|
||||
assert_eq!(hit.id, "low-contrast");
|
||||
assert_eq!(
|
||||
hit.snippet,
|
||||
"sampled (coarse) 1.1:1 (need 4.5:1) — text #fdfdfd on hero.png; p90 of 36 samples, median 1.1:1"
|
||||
);
|
||||
// Half the image is dark: the text may well sit there.
|
||||
let split: Vec<Rgba> = (0..36)
|
||||
.map(|i| if i % 2 == 0 { light } else { dark })
|
||||
.collect();
|
||||
assert!(sampled_contrast(&opts(white, 16.0), "hero.png", &split, 36).is_none());
|
||||
// Three dark samples out of 36 are not enough to save it.
|
||||
let mostly: Vec<Rgba> = (0..36).map(|i| if i < 3 { dark } else { light }).collect();
|
||||
assert!(sampled_contrast(&opts(white, 16.0), "hero.png", &mostly, 36).is_some());
|
||||
// Too few resolved samples: no verdict.
|
||||
assert!(sampled_contrast(&opts(white, 16.0), "hero.png", &all_light[..20], 36).is_none());
|
||||
// Large text lowers the bar, and the snippet says so.
|
||||
let mid = rgba(150.0, 150.0, 150.0, 1.0);
|
||||
let on_mid: Vec<Rgba> = vec![mid; 36];
|
||||
assert!(
|
||||
sampled_contrast(&opts(white, 28.0), "hero.png", &on_mid, 36)
|
||||
.unwrap()
|
||||
.snippet
|
||||
.contains("(need 3:1)")
|
||||
);
|
||||
// Safe tags and gradient-clipped text never sample.
|
||||
let mut anchor = opts(white, 16.0);
|
||||
anchor.tag = "a".into();
|
||||
assert!(sampled_contrast(&anchor, "hero.png", &all_light, 36).is_none());
|
||||
let mut clipped = opts(white, 16.0);
|
||||
clipped.bg_clip = Some("text".into());
|
||||
assert!(sampled_contrast(&clipped, "hero.png", &all_light, 36).is_none());
|
||||
}
|
||||
}
|
||||
@@ -12,9 +12,8 @@
|
||||
//! 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,
|
||||
//! `::placeholder` `color` on text controls, bounding rects, the
|
||||
//! client/scroll/offset metrics, `checkVisibility`, direct-text
|
||||
//! values), `::before` / `::after` styles where `content` is set, 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.
|
||||
@@ -254,10 +253,6 @@ 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)]
|
||||
@@ -829,11 +824,6 @@ 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(),
|
||||
@@ -1009,27 +999,6 @@ 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);
|
||||
|
||||
@@ -26,6 +26,11 @@ html5ever = "0.39"
|
||||
selectors = "0.38"
|
||||
cssparser = "0.37"
|
||||
thiserror = { workspace = true }
|
||||
# Decoding the background images behind text for the sampled-contrast path
|
||||
# (#560): pure-Rust decoders only (the comp crate's set plus `png`), so the
|
||||
# wasm `detect` build keeps working and nothing native is linked.
|
||||
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "gif", "webp"] }
|
||||
base64 = "0.22"
|
||||
|
||||
[dev-dependencies]
|
||||
impeccable-core = { workspace = true, features = ["vectors"] }
|
||||
|
||||
+49
-41
@@ -6,11 +6,13 @@
|
||||
//! and the computed style and hands plain data over.
|
||||
|
||||
use crate::background::{
|
||||
a_ge, a_gt, read_own_background_color, resolve_background, resolve_background_info,
|
||||
resolve_border_radius_px, resolve_gradient_stops, sv, sv_opt, CustomPropMap,
|
||||
a_ge, a_gt, find_image_ground, read_own_background_color, resolve_background,
|
||||
resolve_background_info, resolve_border_radius_px, resolve_gradient_stops, sv, sv_opt,
|
||||
CustomPropMap,
|
||||
};
|
||||
use crate::cascade::StyleValues;
|
||||
use crate::dom::{StaticDocument, StaticElement};
|
||||
use crate::image_sampling::{ground_label, ImageSampler};
|
||||
use crate::quality::{collapse_ws, pf0, resolve_font_size_px};
|
||||
use impeccable_core::checks::measures::{
|
||||
self, border_colors_from_style, border_widths_from_style, check_gpt_thin_border_wide_shadow,
|
||||
@@ -20,17 +22,18 @@ 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,
|
||||
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,
|
||||
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::sampled_contrast;
|
||||
use impeccable_core::checks::text_rules::{
|
||||
check_numbered_section_labels, is_kicker_candidate, is_numbered_section_label_candidate,
|
||||
parse_numbered_label_text, KickerCandidateInput, NumberedLabelCandidate,
|
||||
NumberedLabelCandidateInput, HEADING_TAGS, KICKER_CARD_CONTEXT_SELECTOR, KICKER_SKIP_SELECTOR,
|
||||
POSITIONED_CHILD_INTERACTIVE_SELECTOR,
|
||||
};
|
||||
use impeccable_core::color::{composite_color_over, parse_any_color, parse_rgb};
|
||||
use impeccable_core::color::{composite_color_over, parse_any_color, parse_rgb, Rgba};
|
||||
use impeccable_core::js::{self, parse_float, parse_int};
|
||||
use impeccable_core::js_ext_a::num_truthy;
|
||||
use impeccable_core::js_ext_b::slice_utf16_prefix;
|
||||
@@ -461,11 +464,15 @@ pub fn check_element_borders(
|
||||
}
|
||||
|
||||
/// JS: checks.mjs#checkElementColors(el, style, tag, window, customPropMap, hasAnchorInheritRule)
|
||||
///
|
||||
/// `images` serves the one branch the JS never had: text whose ground is a
|
||||
/// `url()` layer is measured against the image's pixels (#560).
|
||||
pub fn check_element_colors(
|
||||
el: &StaticElement<'_>,
|
||||
style: &StyleValues,
|
||||
tag: &str,
|
||||
custom_props: CustomPropMap<'_>,
|
||||
images: &ImageSampler,
|
||||
) -> Vec<RuleHit> {
|
||||
if sv_opt(style, "visibility") == Some("hidden") {
|
||||
return Vec::new();
|
||||
@@ -539,7 +546,7 @@ pub fn check_element_colors(
|
||||
sv(style, "backgroundClip")
|
||||
}
|
||||
};
|
||||
let color_opts = ColorOpts {
|
||||
let opts = ColorOpts {
|
||||
tag: tag.to_string(),
|
||||
text_color,
|
||||
bg_color: own_bg,
|
||||
@@ -558,44 +565,45 @@ pub fn check_element_colors(
|
||||
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,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut findings = check_colors(&opts);
|
||||
if surface_unresolved {
|
||||
findings.extend(sampled_image_contrast(el, &opts, images));
|
||||
}
|
||||
findings
|
||||
}
|
||||
|
||||
/// The sampled-contrast path (#560): when the analytic walk gave up at a
|
||||
/// `url()` layer, read that image and measure the text against a grid of
|
||||
/// its pixels. Every gate that needs no IO runs first, so a container
|
||||
/// without direct text never touches the file.
|
||||
fn sampled_image_contrast(
|
||||
el: &StaticElement<'_>,
|
||||
opts: &ColorOpts,
|
||||
images: &ImageSampler,
|
||||
) -> Option<RuleHit> {
|
||||
if !sampled_contrast::applies(opts) {
|
||||
return None;
|
||||
}
|
||||
let ground = find_image_ground(el)?;
|
||||
let raster = images.load(&ground.url)?;
|
||||
if sampled_contrast::is_decorative_layer(
|
||||
&ground.repeat,
|
||||
&ground.size,
|
||||
raster.intrinsic_width as f64,
|
||||
raster.intrinsic_height as f64,
|
||||
) {
|
||||
return None;
|
||||
}
|
||||
let points = sampled_contrast::grid_points(raster.width as usize, raster.height as usize);
|
||||
let samples: Vec<Rgba> = points
|
||||
.iter()
|
||||
.filter_map(|&(x, y)| {
|
||||
sampled_contrast::composite_sample(raster.pixel(x, y), ground.under, &ground.overlays)
|
||||
})
|
||||
.collect();
|
||||
sampled_contrast::sampled_contrast(opts, &ground_label(&ground.url), &samples, points.len())
|
||||
}
|
||||
|
||||
/// JS: checks.mjs#checkElementHoverContrast(el, style, tag, window)
|
||||
pub fn check_element_hover_contrast(
|
||||
el: &StaticElement<'_>,
|
||||
|
||||
@@ -4,9 +4,11 @@
|
||||
//! `compositeGradientStops`, `resolveBorderRadiusPx`), static-engine
|
||||
//! branches only (`DETECTOR_IS_BROWSER === false`).
|
||||
|
||||
use crate::cascade::csstree::strings::decode_url;
|
||||
use crate::cascade::StyleValues;
|
||||
use crate::dom::StaticElement;
|
||||
use impeccable_core::checks::measures::{parse_color_resolved, parse_radius_to_px, CustomProps};
|
||||
use impeccable_core::checks::sampled_contrast::uniform_wash;
|
||||
use impeccable_core::color::{
|
||||
composite_color_over, is_no_paint_color_value, parse_any_color, parse_gradient_colors,
|
||||
parse_rgb, split_top_level_commas, Rgba,
|
||||
@@ -331,3 +333,151 @@ pub fn composite_gradient_stops(
|
||||
pub fn resolve_border_radius_px(style: &StyleValues, width_px: f64) -> f64 {
|
||||
parse_radius_to_px(sv_opt(style, "borderRadius"), width_px).unwrap_or(0.0)
|
||||
}
|
||||
|
||||
// ─── the image ground (#560) ────────────────────────────────────────────────
|
||||
|
||||
/// The image a text element sits on when the walk ends at a `url()` layer:
|
||||
/// what the sampled-contrast path reads instead of skipping.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct ImageGround {
|
||||
/// The layer's `url()` argument, unescaped and unquoted.
|
||||
pub url: String,
|
||||
/// The layer's own `background-repeat` and `background-size` entries.
|
||||
pub repeat: String,
|
||||
pub size: String,
|
||||
/// Translucent paint between the text and the image, top to bottom:
|
||||
/// tinted surfaces up the chain and uniform gradient washes.
|
||||
pub overlays: Vec<Rgba>,
|
||||
/// What a translucent pixel composites over, when the walk can say.
|
||||
pub under: Option<Rgba>,
|
||||
}
|
||||
|
||||
/// One entry of a comma-separated background list, with the CSS wraparound
|
||||
/// for a list shorter than the image list.
|
||||
fn layer_entry(value: &str, index: usize) -> String {
|
||||
let parts = split_top_level_commas(value);
|
||||
if parts.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
js::trim(&parts[index % parts.len()]).to_string()
|
||||
}
|
||||
|
||||
/// The `url()` argument of one background layer: `url(x\ y.png)` from the
|
||||
/// css-tree generator, `url("x y.png")` from a style attribute. A shorthand
|
||||
/// layer may carry other tokens around the call (`#000 url(x)center/cover`),
|
||||
/// so only the call itself is decoded.
|
||||
fn layer_url(layer: &str) -> String {
|
||||
let Some(call) = URL_CALL_RE.find(layer) else {
|
||||
return String::new();
|
||||
};
|
||||
let open = call.end();
|
||||
let mut depth = 1usize;
|
||||
let mut quote: Option<char> = None;
|
||||
let mut close = layer.len();
|
||||
for (i, ch) in layer[open..].char_indices() {
|
||||
match quote {
|
||||
Some(q) if ch == q => quote = None,
|
||||
Some(_) => {}
|
||||
None if ch == '"' || ch == '\'' => quote = Some(ch),
|
||||
None if ch == '(' => depth += 1,
|
||||
None if ch == ')' => {
|
||||
depth -= 1;
|
||||
if depth == 0 {
|
||||
close = open + i + 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
let decoded = decode_url(&format!("url({}", &layer[open..close]));
|
||||
let trimmed = js::trim(&decoded);
|
||||
let unquoted = trimmed
|
||||
.strip_prefix('"')
|
||||
.and_then(|s| s.strip_suffix('"'))
|
||||
.or_else(|| {
|
||||
trimmed
|
||||
.strip_prefix('\'')
|
||||
.and_then(|s| s.strip_suffix('\''))
|
||||
})
|
||||
.unwrap_or(trimmed);
|
||||
unquoted.to_string()
|
||||
}
|
||||
|
||||
/// The mirror of [`resolve_background_info`]'s walk for the case it reports
|
||||
/// as unresolved because a `url()` layer is the ground: that layer plus
|
||||
/// everything painted between the text and it. `None` whenever the analytic
|
||||
/// walk would have resolved a color or given up for another reason (an
|
||||
/// opaque gradient, an unparseable color, a varying scrim).
|
||||
pub fn find_image_ground(el: &StaticElement<'_>) -> Option<ImageGround> {
|
||||
let mut current = Some(*el);
|
||||
let mut overlays: Vec<Rgba> = Vec::new();
|
||||
while let Some(cur) = current {
|
||||
let style = cur.style();
|
||||
let mut bg = read_cascade_background_color(&cur, style, None);
|
||||
if (bg.is_none() || bg.as_ref().is_some_and(|c| a_lt(c, 0.1)))
|
||||
&& CURRENTCOLOR_RE.is_match(js::trim(sv(style, "backgroundColor")))
|
||||
{
|
||||
let color = sv_opt(style, "color");
|
||||
bg = parse_rgb(color).or_else(|| parse_color_resolved(color, None));
|
||||
}
|
||||
if bg.as_ref().is_some_and(|c| a_ge(c, 0.99)) {
|
||||
return None;
|
||||
}
|
||||
if bg.is_none() && !is_no_paint_color_value(sv_opt(style, "backgroundColor")) {
|
||||
return None;
|
||||
}
|
||||
let bg_image = sv(style, "backgroundImage");
|
||||
let layers: Vec<String> = if bg_image.is_empty() || bg_image == "none" {
|
||||
Vec::new()
|
||||
} else {
|
||||
split_top_level_commas(bg_image)
|
||||
};
|
||||
for (index, layer) in layers.iter().enumerate() {
|
||||
let layer = js::trim(layer);
|
||||
// A shorthand layer may put color, position, size, or repeat
|
||||
// tokens before the image call; the call is what matters.
|
||||
let is_gradient = GRADIENT_CALL_RE.is_match(layer);
|
||||
if !is_gradient && URL_CALL_RE.is_match(layer) {
|
||||
// A layer beneath the image is unknown paint; otherwise the
|
||||
// element's own color over its parent's ground, white at the
|
||||
// root like the analytic walk.
|
||||
let under = if index + 1 < layers.len() {
|
||||
None
|
||||
} else {
|
||||
let parent_ground = match cur.parent_element() {
|
||||
Some(p) => resolve_background(&p, None),
|
||||
None => Some(Rgba::new(255.0, 255.0, 255.0, 1.0)),
|
||||
};
|
||||
match bg {
|
||||
Some(c) if a_gt(&c, 0.1) => {
|
||||
parent_ground.map(|p| composite_color_over(&c, &p))
|
||||
}
|
||||
_ => parent_ground,
|
||||
}
|
||||
};
|
||||
return Some(ImageGround {
|
||||
url: layer_url(layer),
|
||||
repeat: layer_entry(sv(style, "backgroundRepeat"), index),
|
||||
size: layer_entry(sv(style, "backgroundSize"), index),
|
||||
overlays,
|
||||
under,
|
||||
});
|
||||
}
|
||||
if is_gradient {
|
||||
let stops = parse_gradient_colors(Some(layer));
|
||||
if stops.is_empty() || stops.iter().all(|s| s.alpha_or_one() >= 0.99) {
|
||||
return None;
|
||||
}
|
||||
overlays.push(uniform_wash(&stops)?);
|
||||
continue;
|
||||
}
|
||||
return None;
|
||||
}
|
||||
if let Some(c) = bg.filter(|c| a_gt(c, 0.1)) {
|
||||
overlays.push(c);
|
||||
}
|
||||
current = cur.parent_element();
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
@@ -10,10 +10,11 @@
|
||||
|
||||
use super::checks_shim::CustomProps;
|
||||
use super::{
|
||||
apply_static_declaration, collect_static_css_rules, compare_static_priority,
|
||||
is_static_inherited_prop, make_default_style, normalize_static_css_value,
|
||||
parse_static_style_attribute, static_default_style, CssRule, DeclMeta, SpecifiedDecl,
|
||||
SpecifiedStore, StyleValues, STATIC_DEFAULT_STYLE,
|
||||
apply_static_declaration, apply_static_longhand, background_longhands,
|
||||
collect_static_css_rules, compare_static_priority, is_static_inherited_prop,
|
||||
make_default_style, normalize_static_css_value, parse_static_style_attribute,
|
||||
static_default_style, CssRule, DeclMeta, SpecifiedDecl, SpecifiedStore, StyleValues,
|
||||
STATIC_DEFAULT_STYLE,
|
||||
};
|
||||
use crate::dom::StaticDocument;
|
||||
use crate::profile::{self, Meta, ProfileSink};
|
||||
@@ -39,7 +40,7 @@ static REMOTE_HREF_RE: Lazy<Regex> =
|
||||
/// Cache-busting (styles.css?v=3) and root-relative (/static/app.css) hrefs
|
||||
/// must not resolve as OS-absolute paths; otherwise the whole stylesheet is
|
||||
/// invisible to every element-level check.
|
||||
fn resolve_linked_css_path(file_dir: &str, href: &str) -> String {
|
||||
pub(crate) fn resolve_linked_css_path(file_dir: &str, href: &str) -> String {
|
||||
let stripped = href.split(['?', '#']).next().unwrap_or("");
|
||||
let root_relative = stripped.starts_with('/') && !stripped.starts_with("//");
|
||||
if !root_relative {
|
||||
@@ -117,7 +118,15 @@ pub fn collect_static_css_text(
|
||||
|| std::fs::read(&css_path),
|
||||
);
|
||||
match read {
|
||||
Ok(bytes) => style_texts.push(String::from_utf8_lossy(&bytes).into_owned()),
|
||||
Ok(bytes) => {
|
||||
let text = String::from_utf8_lossy(&bytes);
|
||||
let sheet_dir = jsp::dirname(&css_path);
|
||||
style_texts.push(if sheet_dir == file_dir_str {
|
||||
text.into_owned()
|
||||
} else {
|
||||
rewrite_sheet_urls(&text, &sheet_dir, &file_dir_str)
|
||||
});
|
||||
}
|
||||
Err(_) => {
|
||||
if warned_missing_stylesheets.insert(css_path.clone()) {
|
||||
if let Some(warn) = warn {
|
||||
@@ -132,6 +141,162 @@ pub fn collect_static_css_text(
|
||||
style_texts.join("\n")
|
||||
}
|
||||
|
||||
// A quoted url cannot span a line and an unquoted one cannot hold
|
||||
// whitespace, quotes, parens, braces, or semicolons (CSS syntax), so a
|
||||
// stray `url(` can never pair with a `)` in a later rule.
|
||||
static CSS_URL_RE: Lazy<Regex> = Lazy::new(|| {
|
||||
// The leading group keeps `url(` from matching inside a longer
|
||||
// identifier such as `myurl(`; the closure emits it unchanged.
|
||||
Regex::new(
|
||||
r#"(?i)(^|[^A-Za-z0-9_-])url\(\s*(?:"((?:[^"\\\n\r]|\\.)*)"|'((?:[^'\\\n\r]|\\.)*)'|([^)"'(\s{};]*))\s*\)"#,
|
||||
)
|
||||
.expect("CSS_URL_RE")
|
||||
});
|
||||
static URL_SCHEME_RE: Lazy<Regex> =
|
||||
Lazy::new(|| Regex::new(r"^[A-Za-z][A-Za-z0-9+.-]*:").expect("URL_SCHEME_RE"));
|
||||
|
||||
/// A relative `url()` in a stylesheet is relative to the sheet, and the
|
||||
/// cascade sees one concatenated text, so a sheet inlined from another
|
||||
/// directory has its relative urls rewritten to page-relative form here.
|
||||
/// This is what lets the sampled-contrast path (#560) resolve the image the
|
||||
/// winning declaration named. Root-relative, remote, `data:`, fragment, and
|
||||
/// escaped urls are left as they are, and comments are copied through
|
||||
/// untouched, so nothing inside one can reach the rules after it.
|
||||
pub fn rewrite_sheet_urls(css: &str, sheet_dir: &str, page_dir: &str) -> String {
|
||||
let mut out = String::with_capacity(css.len());
|
||||
let mut cursor = 0usize;
|
||||
for (start, end) in comment_spans(css) {
|
||||
out.push_str(&rewrite_code_urls(&css[cursor..start], sheet_dir, page_dir));
|
||||
out.push_str(&css[start..end]);
|
||||
cursor = end;
|
||||
}
|
||||
out.push_str(&rewrite_code_urls(&css[cursor..], sheet_dir, page_dir));
|
||||
out
|
||||
}
|
||||
|
||||
/// The byte spans of every `/* */` comment in `css`, found the way the CSS
|
||||
/// tokenizer finds them: a `/*` inside a quoted string or an unquoted
|
||||
/// `url()` is content, not a comment opener, and an unclosed comment runs to
|
||||
/// the end of the sheet. Every span boundary sits on an ASCII byte, so the
|
||||
/// spans are valid `str` indices.
|
||||
/// A byte that can continue a CSS identifier, so `myurl(` is a custom
|
||||
/// function and not the `url(` token.
|
||||
fn is_ident_byte(b: u8) -> bool {
|
||||
b.is_ascii_alphanumeric() || b == b'-' || b == b'_' || b >= 0x80
|
||||
}
|
||||
|
||||
fn comment_spans(css: &str) -> Vec<(usize, usize)> {
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
enum State {
|
||||
Code,
|
||||
Str(u8),
|
||||
Url,
|
||||
}
|
||||
let bytes = css.as_bytes();
|
||||
let mut spans = Vec::new();
|
||||
let mut state = State::Code;
|
||||
let mut i = 0usize;
|
||||
while i < bytes.len() {
|
||||
let b = bytes[i];
|
||||
match state {
|
||||
State::Code => {
|
||||
if b == b'/' && bytes.get(i + 1) == Some(&b'*') {
|
||||
let end = css[i + 2..]
|
||||
.find("*/")
|
||||
.map(|at| i + 2 + at + 2)
|
||||
.unwrap_or(bytes.len());
|
||||
spans.push((i, end));
|
||||
i = end;
|
||||
continue;
|
||||
}
|
||||
if b == b'"' || b == b'\'' {
|
||||
state = State::Str(b);
|
||||
} else if b.eq_ignore_ascii_case(&b'u')
|
||||
&& css[i..]
|
||||
.get(..4)
|
||||
.is_some_and(|s| s.eq_ignore_ascii_case("url("))
|
||||
&& !(i > 0 && is_ident_byte(bytes[i - 1]))
|
||||
{
|
||||
i += 4;
|
||||
while i < bytes.len() && bytes[i].is_ascii_whitespace() {
|
||||
i += 1;
|
||||
}
|
||||
// A quoted argument is a string like any other; an
|
||||
// unquoted one runs to the closing paren.
|
||||
if i < bytes.len() && bytes[i] != b'"' && bytes[i] != b'\'' {
|
||||
state = State::Url;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
State::Str(quote) => {
|
||||
if b == b'\\' {
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
// A raw newline ends a string in CSS (a bad-string token).
|
||||
if b == quote || b == b'\n' || b == b'\r' {
|
||||
state = State::Code;
|
||||
}
|
||||
}
|
||||
State::Url => {
|
||||
if b == b'\\' {
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
if b == b')' {
|
||||
state = State::Code;
|
||||
}
|
||||
}
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
spans
|
||||
}
|
||||
|
||||
fn rewrite_code_urls(css: &str, sheet_dir: &str, page_dir: &str) -> String {
|
||||
CSS_URL_RE
|
||||
.replace_all(css, |caps: ®ex::Captures| {
|
||||
let whole = caps.get(0).map(|m| m.as_str()).unwrap_or("");
|
||||
let before = caps.get(1).map(|m| m.as_str()).unwrap_or("");
|
||||
let (target, quote) = match (caps.get(2), caps.get(3), caps.get(4)) {
|
||||
(Some(m), _, _) => (m.as_str(), "\""),
|
||||
(_, Some(m), _) => (m.as_str(), "'"),
|
||||
(_, _, Some(m)) => (js::trim(m.as_str()), ""),
|
||||
_ => return whole.to_string(),
|
||||
};
|
||||
let lower = js::to_lower_case(target);
|
||||
if target.is_empty()
|
||||
|| target.contains('\\')
|
||||
|| target.starts_with('#')
|
||||
|| target.starts_with('/')
|
||||
|| lower.starts_with("data:")
|
||||
|| URL_SCHEME_RE.is_match(target)
|
||||
{
|
||||
return whole.to_string();
|
||||
}
|
||||
let cut = target.find(['?', '#']).unwrap_or(target.len());
|
||||
let (path, suffix) = target.split_at(cut);
|
||||
// Pure POSIX string math on both directories: a CSS url is
|
||||
// POSIX on every OS, and the win32 helpers would render a drive
|
||||
// path (`D:\a\...`) differently from the url beside it.
|
||||
let sheet = jsp::to_posix(sheet_dir);
|
||||
let page = jsp::to_posix(page_dir);
|
||||
let absolute = jsp::posix::resolve("/", &[&sheet, path]);
|
||||
let relative = jsp::posix::relative("/", &page, &absolute);
|
||||
if relative.is_empty() {
|
||||
return whole.to_string();
|
||||
}
|
||||
let needs_quotes = quote.is_empty()
|
||||
&& relative
|
||||
.chars()
|
||||
.any(|c| c.is_whitespace() || matches!(c, '(' | ')' | '"' | '\''));
|
||||
let quote = if needs_quotes { "\"" } else { quote };
|
||||
format!("{before}url({quote}{relative}{suffix}{quote})")
|
||||
})
|
||||
.into_owned()
|
||||
}
|
||||
|
||||
static PSEUDO_RULE_RE: Lazy<Regex> = Lazy::new(|| {
|
||||
Regex::new(&format!(
|
||||
r"(?i)^(.+?){ws}*::?(?:before|after)$",
|
||||
@@ -139,31 +304,6 @@ 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")
|
||||
@@ -277,7 +417,6 @@ 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,
|
||||
@@ -290,12 +429,7 @@ pub fn build_static_style_map(
|
||||
Meta::new("selector-match", "css-selectors", file_path),
|
||||
|| {
|
||||
for rule in &rules {
|
||||
let placeholder_host = if rule.is_hover {
|
||||
None
|
||||
} else {
|
||||
placeholder_host_selector(&rule.selector)
|
||||
};
|
||||
if !rule.is_hover && placeholder_host.is_none() {
|
||||
if !rule.is_hover {
|
||||
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);
|
||||
@@ -304,8 +438,6 @@ 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())
|
||||
};
|
||||
@@ -329,18 +461,11 @@ 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,
|
||||
@@ -348,6 +473,9 @@ pub fn build_static_style_map(
|
||||
inline: false,
|
||||
};
|
||||
apply_static_declaration(store, node, &decl.prop, &decl.value, &meta);
|
||||
for (prop, value) in background_longhands(&decl.prop, &decl.value) {
|
||||
apply_static_longhand(store, node, &prop, &value, &meta);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -371,6 +499,9 @@ pub fn build_static_style_map(
|
||||
inline: true,
|
||||
};
|
||||
apply_static_declaration(&mut specified, node, &decl.prop, &decl.value, &meta);
|
||||
for (prop, value) in background_longhands(&decl.prop, &decl.value) {
|
||||
apply_static_longhand(&mut specified, node, &prop, &value, &meta);
|
||||
}
|
||||
}
|
||||
inline_order += 1000;
|
||||
}
|
||||
@@ -381,7 +512,7 @@ pub fn build_static_style_map(
|
||||
profile,
|
||||
Meta::new("cascade", "compute-styles", file_path),
|
||||
|| {
|
||||
compute_styles(doc, &specified, &hover_specified, &placeholder_specified);
|
||||
compute_styles(doc, &specified, &hover_specified);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -393,7 +524,6 @@ 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();
|
||||
@@ -409,7 +539,6 @@ 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());
|
||||
@@ -481,21 +610,6 @@ 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));
|
||||
@@ -517,9 +631,6 @@ 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.
|
||||
|
||||
@@ -187,15 +187,39 @@ pub fn apply_static_declaration<K: Hash + Eq>(
|
||||
) {
|
||||
let map = specified.map.entry(node).or_default();
|
||||
for (expanded_prop, expanded_value) in expand_static_declaration(prop, value) {
|
||||
let existing = map.get(&expanded_prop).map(|d| &d.meta);
|
||||
if compare_static_priority(existing, meta) {
|
||||
let next = SpecifiedDecl {
|
||||
meta: meta.clone(),
|
||||
prop: expanded_prop.clone(),
|
||||
value: expanded_value,
|
||||
};
|
||||
map.insert(expanded_prop, next);
|
||||
}
|
||||
apply_expanded(map, &expanded_prop, &expanded_value, meta);
|
||||
}
|
||||
}
|
||||
|
||||
/// Stores one already-expanded longhand for `node` under the cascade's
|
||||
/// priority rule, the way `apply_static_declaration` stores each pair the
|
||||
/// shorthand expansion yields. For the longhands that ride beside that
|
||||
/// expansion rather than inside it (`background_longhands`).
|
||||
pub fn apply_static_longhand<K: Hash + Eq>(
|
||||
specified: &mut SpecifiedStore<K>,
|
||||
node: K,
|
||||
prop: &str,
|
||||
value: &str,
|
||||
meta: &DeclMeta,
|
||||
) {
|
||||
let map = specified.map.entry(node).or_default();
|
||||
apply_expanded(map, prop, value, meta);
|
||||
}
|
||||
|
||||
fn apply_expanded(
|
||||
map: &mut IndexMap<String, SpecifiedDecl>,
|
||||
prop: &str,
|
||||
value: &str,
|
||||
meta: &DeclMeta,
|
||||
) {
|
||||
let existing = map.get(prop).map(|d| &d.meta);
|
||||
if compare_static_priority(existing, meta) {
|
||||
let next = SpecifiedDecl {
|
||||
meta: meta.clone(),
|
||||
prop: prop.to_string(),
|
||||
value: value.to_string(),
|
||||
};
|
||||
map.insert(prop.to_string(), next);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -193,6 +193,101 @@ pub fn parse_static_animation(value: &str) -> StaticAnimation {
|
||||
}
|
||||
}
|
||||
|
||||
static BG_REPEAT_RE: Lazy<Regex> = Lazy::new(|| {
|
||||
Regex::new(r"(?i)^(?:repeat|no-repeat|repeat-x|repeat-y|space|round)$").expect("BG_REPEAT_RE")
|
||||
});
|
||||
static BG_SIZE_RE: Lazy<Regex> =
|
||||
Lazy::new(|| Regex::new(r"(?i)^(?:auto|cover|contain|-?[0-9.]+[a-z%]*)$").expect("BG_SIZE_RE"));
|
||||
|
||||
/// The per-layer `background-repeat` and `background-size` lists of a
|
||||
/// `background` shorthand (`url(a) center / cover no-repeat, url(b)` ->
|
||||
/// `no-repeat, repeat` and `cover, auto`). The css-tree generator may glue
|
||||
/// the first keyword to the image (`url(a)center/cover`), so a token that
|
||||
/// holds a function call is read up to its closing paren and the rest kept.
|
||||
fn parse_static_background_layers(value: &str) -> (String, String) {
|
||||
let mut repeats: Vec<String> = Vec::new();
|
||||
let mut sizes: Vec<String> = Vec::new();
|
||||
for layer in split_css_list(value) {
|
||||
let mut repeat: Vec<String> = Vec::new();
|
||||
let mut size: Vec<String> = Vec::new();
|
||||
let mut in_size = false;
|
||||
for raw in split_css_tokens(&layer) {
|
||||
let token = match raw.rfind(')') {
|
||||
Some(end) if raw.contains('(') => raw[end + 1..].to_string(),
|
||||
_ => raw,
|
||||
};
|
||||
if token.is_empty() {
|
||||
in_size = false;
|
||||
continue;
|
||||
}
|
||||
// `center/cover`, `center / cover`, or `center/ cover`: the size
|
||||
// is what follows the slash, one or two values.
|
||||
if let Some((_, after)) = token.split_once('/') {
|
||||
in_size = true;
|
||||
size.clear();
|
||||
if !after.is_empty() && BG_SIZE_RE.is_match(after) {
|
||||
size.push(js::to_lower_case(after));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if in_size && size.len() < 2 && BG_SIZE_RE.is_match(&token) {
|
||||
size.push(js::to_lower_case(&token));
|
||||
continue;
|
||||
}
|
||||
in_size = false;
|
||||
if repeat.len() < 2 && BG_REPEAT_RE.is_match(&token) {
|
||||
repeat.push(js::to_lower_case(&token));
|
||||
}
|
||||
}
|
||||
repeats.push(if repeat.is_empty() {
|
||||
"repeat".into()
|
||||
} else {
|
||||
repeat.join(" ")
|
||||
});
|
||||
sizes.push(if size.is_empty() {
|
||||
"auto".into()
|
||||
} else {
|
||||
size.join(" ")
|
||||
});
|
||||
}
|
||||
(repeats.join(", "), sizes.join(", "))
|
||||
}
|
||||
|
||||
/// The `backgroundRepeat` / `backgroundSize` pairs a declaration sets. Not
|
||||
/// part of `expand_static_declaration`, whose output the recorded vectors
|
||||
/// pin; the cascade stores these beside it (`apply_static_longhand`) so the
|
||||
/// sampled-contrast path (#560) can tell a tiled or cover image from a
|
||||
/// no-repeat icon. Every `background` shorthand resets both to what it
|
||||
/// names, the defaults when it names nothing, as in CSS; a CSS-wide keyword
|
||||
/// passes through, and a bare `var()` value is left alone the way the
|
||||
/// expansion leaves it.
|
||||
pub fn background_longhands(prop: &str, value: &str) -> Vec<Expanded> {
|
||||
let v = js::trim(value);
|
||||
match js::to_lower_case(prop).as_str() {
|
||||
"background" if VAR_ANYWHERE_RE.is_match(v) && !BG_IMAGE_RE.is_match(v) => Vec::new(),
|
||||
"background" if CSS_WIDE_KEYWORD_RE.is_match(v) => {
|
||||
let keyword = js::to_lower_case(v);
|
||||
vec![
|
||||
("backgroundRepeat".into(), keyword.clone()),
|
||||
("backgroundSize".into(), keyword),
|
||||
]
|
||||
}
|
||||
"background" => {
|
||||
let (repeat, size) = parse_static_background_layers(v);
|
||||
vec![
|
||||
("backgroundRepeat".into(), repeat),
|
||||
("backgroundSize".into(), size),
|
||||
]
|
||||
}
|
||||
"background-repeat" if !v.is_empty() => vec![("backgroundRepeat".into(), v.to_string())],
|
||||
"background-size" if !v.is_empty() => vec![("backgroundSize".into(), v.to_string())],
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
static CSS_WIDE_KEYWORD_RE: Lazy<Regex> = Lazy::new(|| {
|
||||
Regex::new(r"(?i)^(?:inherit|initial|unset|revert|revert-layer)$").expect("CSS_WIDE_KEYWORD_RE")
|
||||
});
|
||||
static BG_IMAGE_RE: Lazy<Regex> =
|
||||
Lazy::new(|| Regex::new(r"(?i)gradient|url\(").expect("BG_IMAGE_RE"));
|
||||
static BG_IMAGE_SPLIT_RE: Lazy<Regex> = Lazy::new(|| {
|
||||
|
||||
@@ -55,7 +55,6 @@ 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>>>,
|
||||
@@ -175,7 +174,6 @@ 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()),
|
||||
@@ -321,12 +319,6 @@ 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);
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ use crate::adapters::{
|
||||
use crate::background::{resolve_background, resolve_border_radius_px, sv};
|
||||
use crate::cascade::{build_static_style_map, collect_static_css_text};
|
||||
use crate::dom::{StaticDocument, StaticElement};
|
||||
use crate::image_sampling::ImageSampler;
|
||||
use crate::page::{
|
||||
check_cream_palette, check_page_layout, check_repeated_container_text_from_doc,
|
||||
check_static_page_typography,
|
||||
@@ -119,14 +120,19 @@ const STATIC_ELEMENT_RULES: &[(&str, &str)] = &[
|
||||
("radial-spotlight-glow", "*"),
|
||||
];
|
||||
|
||||
fn run_rule(rule_id: &str, el: &StaticElement<'_>, tag: &str) -> Vec<RuleHit> {
|
||||
fn run_rule(
|
||||
rule_id: &str,
|
||||
el: &StaticElement<'_>,
|
||||
tag: &str,
|
||||
images: &ImageSampler,
|
||||
) -> Vec<RuleHit> {
|
||||
let style = el.style();
|
||||
match rule_id {
|
||||
"border-rules" => {
|
||||
let radius = resolve_border_radius_px(style, pf0(sv(style, "width")));
|
||||
check_element_borders(tag, style, radius, el)
|
||||
}
|
||||
"color-rules" => check_element_colors(el, style, tag, None),
|
||||
"color-rules" => check_element_colors(el, style, tag, None, images),
|
||||
"hover-color-rules" => check_element_hover_contrast(el, style, tag),
|
||||
"dark-glow" => {
|
||||
let base = el.parent_element().unwrap_or(*el);
|
||||
@@ -210,6 +216,7 @@ pub fn detect_html_source(
|
||||
let css_text = collect_static_css_text(&doc, &file_dir, profile, fp, options.warn);
|
||||
build_static_style_map(&mut doc, css_text.as_str(), profile, fp);
|
||||
let doc = doc;
|
||||
let images = ImageSampler::new(&file_dir.to_string_lossy());
|
||||
|
||||
let mut findings: Vec<Finding> = Vec::new();
|
||||
let mk = |id: &str, snippet: &str| try_finding(id, fp, snippet, 0.0);
|
||||
@@ -222,7 +229,7 @@ pub fn detect_html_source(
|
||||
profile,
|
||||
Meta::new("element", rule_id, fp),
|
||||
|h: &RuleHit| h.id.as_str(),
|
||||
|| run_rule(rule_id, el, &tag),
|
||||
|| run_rule(rule_id, el, &tag, &images),
|
||||
);
|
||||
for h in hits {
|
||||
if scoped_ignore_active(el, &h.id) {
|
||||
|
||||
@@ -0,0 +1,285 @@
|
||||
//! The pixels behind image-backed text for the static engine (#560). A
|
||||
//! `url()` resolves to bytes from a local file relative to the page (linked
|
||||
//! stylesheets have their urls rewritten to page-relative form when they are
|
||||
//! inlined, see `rewrite_sheet_urls`) or from a base64 data URI, never from
|
||||
//! the network; the pure-Rust decoders turn them into a raster no larger than
|
||||
//! the browser overlay's 640px canvas; and the raster is cached for the rest
|
||||
//! of the process, so a directory scan decodes each hero once. Anything
|
||||
//! unreadable, remote, oversized, or undecodable is `None`, and the caller
|
||||
//! keeps today's skip. The same byte budget bounds a file on disk and a data
|
||||
//! URI's payload, checked before anything is copied or decoded.
|
||||
|
||||
use crate::cascade::resolve_linked_css_path;
|
||||
use base64::Engine;
|
||||
use impeccable_common::jsp;
|
||||
use impeccable_core::color::Rgba;
|
||||
use std::borrow::Cow;
|
||||
use std::cell::RefCell;
|
||||
use std::collections::HashMap;
|
||||
use std::hash::{Hash, Hasher};
|
||||
use std::io::Cursor;
|
||||
use std::rc::Rc;
|
||||
|
||||
/// The browser overlay draws to a canvas no larger than this on a side.
|
||||
const MAX_RASTER_SIDE: u32 = 640;
|
||||
/// Files above this are not read: a hero is a few megabytes, and the hook
|
||||
/// runs on every edit.
|
||||
const MAX_FILE_BYTES: u64 = 24 * 1024 * 1024;
|
||||
/// The base64 payload length that decodes to [`MAX_FILE_BYTES`], so a data
|
||||
/// URI is refused before its payload is copied or decoded.
|
||||
const MAX_DATA_URI_CHARS: usize = (MAX_FILE_BYTES as usize / 3) * 4 + 4;
|
||||
const MAX_IMAGE_SIDE: u32 = 8192;
|
||||
const MAX_DECODE_BYTES: u64 = 128 * 1024 * 1024;
|
||||
/// Decoded rasters kept per process; the map is cleared when full.
|
||||
const CACHE_ENTRIES: usize = 24;
|
||||
|
||||
/// A decoded image, RGBA8, at most [`MAX_RASTER_SIDE`] on a side.
|
||||
pub struct Raster {
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
/// The size before the downscale: what `background-size: auto` paints.
|
||||
pub intrinsic_width: u32,
|
||||
pub intrinsic_height: u32,
|
||||
rgba: Vec<u8>,
|
||||
}
|
||||
|
||||
impl Raster {
|
||||
pub fn pixel(&self, x: usize, y: usize) -> Rgba {
|
||||
let i = (y.min(self.height as usize - 1) * self.width as usize
|
||||
+ x.min(self.width as usize - 1))
|
||||
* 4;
|
||||
let p = &self.rgba[i..i + 4];
|
||||
Rgba::new(p[0] as f64, p[1] as f64, p[2] as f64, p[3] as f64 / 255.0)
|
||||
}
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
static RASTERS: RefCell<HashMap<String, Option<Rc<Raster>>>> = RefCell::new(HashMap::new());
|
||||
}
|
||||
|
||||
/// Resolves and decodes the `url()` grounds of one document, relative to
|
||||
/// the document's directory. A url from a linked stylesheet reaches the
|
||||
/// cascade already rewritten to page-relative form.
|
||||
pub struct ImageSampler {
|
||||
base: String,
|
||||
}
|
||||
|
||||
impl ImageSampler {
|
||||
pub fn new(html_dir: &str) -> Self {
|
||||
ImageSampler {
|
||||
base: html_dir.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The raster behind a `url()` argument, or `None` when it cannot be
|
||||
/// read here: a remote URL, a missing or oversized file, a format the
|
||||
/// decoders do not cover (SVG), or an `svg`/text data URI.
|
||||
pub fn load(&self, url: &str) -> Option<Rc<Raster>> {
|
||||
let url = url.trim();
|
||||
if url.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let key = if is_data_uri(url) {
|
||||
// Refused before anything is allocated: a project file can carry
|
||||
// any size of data URI, and the hook scans on every edit.
|
||||
if url.len() > MAX_DATA_URI_CHARS + 256 {
|
||||
return None;
|
||||
}
|
||||
data_uri_key(url)
|
||||
} else {
|
||||
self.resolve_file(url)?
|
||||
};
|
||||
if let Some(hit) = RASTERS.with(|c| c.borrow().get(&key).cloned()) {
|
||||
return hit;
|
||||
}
|
||||
let bytes = if is_data_uri(url) {
|
||||
data_uri_bytes(url)
|
||||
} else {
|
||||
read_bounded(&key)
|
||||
};
|
||||
let raster = bytes.and_then(|b| decode(&b)).map(Rc::new);
|
||||
RASTERS.with(|c| {
|
||||
let mut cache = c.borrow_mut();
|
||||
if cache.len() >= CACHE_ENTRIES {
|
||||
cache.clear();
|
||||
}
|
||||
cache.insert(key, raster.clone());
|
||||
});
|
||||
raster
|
||||
}
|
||||
|
||||
fn resolve_file(&self, url: &str) -> Option<String> {
|
||||
if url.starts_with("//") || url.contains("://") {
|
||||
return None;
|
||||
}
|
||||
let path = resolve_linked_css_path(&self.base, url);
|
||||
std::fs::metadata(&path)
|
||||
.map(|m| m.is_file())
|
||||
.unwrap_or(false)
|
||||
.then_some(path)
|
||||
}
|
||||
}
|
||||
|
||||
fn is_data_uri(url: &str) -> bool {
|
||||
// `get`, not a byte slice: a url may open with a multibyte character.
|
||||
url.get(..5)
|
||||
.is_some_and(|prefix| prefix.eq_ignore_ascii_case("data:"))
|
||||
}
|
||||
|
||||
/// The cache key of a data URI: its length and a hash, so the cache never
|
||||
/// holds a copy of the URI itself.
|
||||
fn data_uri_key(url: &str) -> String {
|
||||
let mut hasher = std::collections::hash_map::DefaultHasher::new();
|
||||
url.hash(&mut hasher);
|
||||
format!("data:{}:{:016x}", url.len(), hasher.finish())
|
||||
}
|
||||
|
||||
/// The name a finding gives the image: the file name, or `data:<mime>`.
|
||||
pub fn ground_label(url: &str) -> String {
|
||||
let url = url.trim();
|
||||
if is_data_uri(url) {
|
||||
let header = url[5..].split(',').next().unwrap_or("");
|
||||
let mime = header
|
||||
.split(';')
|
||||
.next()
|
||||
.unwrap_or("")
|
||||
.trim()
|
||||
.to_ascii_lowercase();
|
||||
return if mime.is_empty() {
|
||||
"data:image".into()
|
||||
} else {
|
||||
format!("data:{mime}")
|
||||
};
|
||||
}
|
||||
let stripped = url.split(['?', '#']).next().unwrap_or("");
|
||||
jsp::basename(stripped)
|
||||
}
|
||||
|
||||
fn data_uri_bytes(url: &str) -> Option<Vec<u8>> {
|
||||
let (header, payload) = url[5..].split_once(',')?;
|
||||
if !header
|
||||
.split(';')
|
||||
.any(|p| p.trim().eq_ignore_ascii_case("base64"))
|
||||
{
|
||||
return None;
|
||||
}
|
||||
if payload.len() > MAX_DATA_URI_CHARS {
|
||||
return None;
|
||||
}
|
||||
let compact: Cow<str> = if payload.chars().any(char::is_whitespace) {
|
||||
Cow::Owned(payload.chars().filter(|c| !c.is_whitespace()).collect())
|
||||
} else {
|
||||
Cow::Borrowed(payload)
|
||||
};
|
||||
base64::engine::general_purpose::STANDARD
|
||||
.decode(compact.as_ref())
|
||||
.or_else(|_| base64::engine::general_purpose::STANDARD_NO_PAD.decode(compact.as_ref()))
|
||||
.ok()
|
||||
}
|
||||
|
||||
fn read_bounded(path: &str) -> Option<Vec<u8>> {
|
||||
let meta = std::fs::metadata(path).ok()?;
|
||||
if !meta.is_file() || meta.len() > MAX_FILE_BYTES {
|
||||
return None;
|
||||
}
|
||||
std::fs::read(path).ok()
|
||||
}
|
||||
|
||||
fn decode(bytes: &[u8]) -> Option<Raster> {
|
||||
let mut reader = image::ImageReader::new(Cursor::new(bytes))
|
||||
.with_guessed_format()
|
||||
.ok()?;
|
||||
let mut limits = image::Limits::default();
|
||||
limits.max_image_width = Some(MAX_IMAGE_SIDE);
|
||||
limits.max_image_height = Some(MAX_IMAGE_SIDE);
|
||||
limits.max_alloc = Some(MAX_DECODE_BYTES);
|
||||
reader.limits(limits);
|
||||
let decoded = reader.decode().ok()?;
|
||||
let (intrinsic_width, intrinsic_height) = (decoded.width(), decoded.height());
|
||||
if intrinsic_width == 0 || intrinsic_height == 0 {
|
||||
return None;
|
||||
}
|
||||
// `thumbnail` also upscales, so only images past the budget go through it.
|
||||
let scaled = if intrinsic_width.max(intrinsic_height) > MAX_RASTER_SIDE {
|
||||
decoded.thumbnail(MAX_RASTER_SIDE, MAX_RASTER_SIDE)
|
||||
} else {
|
||||
decoded
|
||||
};
|
||||
let rgba = scaled.to_rgba8();
|
||||
Some(Raster {
|
||||
width: rgba.width(),
|
||||
height: rgba.height(),
|
||||
intrinsic_width,
|
||||
intrinsic_height,
|
||||
rgba: rgba.into_raw(),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn png_bytes(width: u32, height: u32, rgba: [u8; 4]) -> Vec<u8> {
|
||||
let img = image::RgbaImage::from_pixel(width, height, image::Rgba(rgba));
|
||||
let mut out = Cursor::new(Vec::new());
|
||||
image::DynamicImage::ImageRgba8(img)
|
||||
.write_to(&mut out, image::ImageFormat::Png)
|
||||
.unwrap();
|
||||
out.into_inner()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn data_uri_decodes_and_labels() {
|
||||
let bytes = png_bytes(8, 8, [240, 236, 228, 255]);
|
||||
let uri = format!(
|
||||
"data:image/png;base64,{}",
|
||||
base64::engine::general_purpose::STANDARD.encode(&bytes)
|
||||
);
|
||||
let sampler = ImageSampler::new("/nonexistent");
|
||||
let raster = sampler.load(&uri).expect("png data uri decodes");
|
||||
assert_eq!((raster.width, raster.height), (8, 8));
|
||||
assert_eq!(raster.pixel(3, 3).r, 240.0);
|
||||
assert_eq!(ground_label(&uri), "data:image/png");
|
||||
assert!(sampler.load("data:image/svg+xml;utf8,<svg/>").is_none());
|
||||
assert!(sampler.load("https://example.com/hero.jpg").is_none());
|
||||
assert!(sampler.load("//cdn.example.com/hero.jpg").is_none());
|
||||
assert_eq!(ground_label("img/hero.jpg?v=3"), "hero.jpg");
|
||||
// A url that opens with a multibyte character is a local path, not
|
||||
// a panic.
|
||||
assert!(!is_data_uri("dat\u{20ac}"));
|
||||
assert!(sampler.load("abcd\u{20ac}.png").is_none());
|
||||
assert_eq!(ground_label("abcd\u{20ac}.png"), "abcd\u{20ac}.png");
|
||||
// A payload past the byte budget is refused before it is decoded.
|
||||
let huge = format!(
|
||||
"data:image/png;base64,{}",
|
||||
"A".repeat(MAX_DATA_URI_CHARS + 1)
|
||||
);
|
||||
assert!(sampler.load(&huge).is_none());
|
||||
assert_ne!(data_uri_key(&uri), data_uri_key(&huge));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn files_resolve_against_the_page_dir_and_downscale() {
|
||||
let dir = std::env::temp_dir().join(format!("impeccable-sampler-{}", std::process::id()));
|
||||
std::fs::create_dir_all(dir.join("img")).unwrap();
|
||||
std::fs::write(
|
||||
dir.join("img").join("wide.png"),
|
||||
png_bytes(1280, 320, [20, 20, 20, 255]),
|
||||
)
|
||||
.unwrap();
|
||||
let sampler = ImageSampler::new(&dir.to_string_lossy());
|
||||
assert!(sampler.load("wide.png").is_none());
|
||||
let raster = sampler
|
||||
.load("img/wide.png")
|
||||
.expect("resolves against the page dir");
|
||||
assert_eq!((raster.width, raster.height), (640, 160));
|
||||
assert_eq!(
|
||||
(raster.intrinsic_width, raster.intrinsic_height),
|
||||
(1280, 320)
|
||||
);
|
||||
assert_eq!(raster.pixel(639, 159).g, 20.0);
|
||||
// The second load is the cached raster.
|
||||
assert!(Rc::ptr_eq(&raster, &sampler.load("img/wide.png").unwrap()));
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ pub mod background;
|
||||
pub mod cascade;
|
||||
pub mod dom;
|
||||
pub mod engine;
|
||||
pub mod image_sampling;
|
||||
pub mod page;
|
||||
pub mod profile;
|
||||
pub mod quality;
|
||||
|
||||
@@ -209,3 +209,209 @@ fn checks_shim_helpers() {
|
||||
assert_eq!(resolve_length_px("50%", 10.0), Some(5.0));
|
||||
assert_eq!(resolve_length_px("1.5", 10.0), Some(15.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn background_longhands_ride_beside_the_expansion() {
|
||||
use impeccable_html::cascade::rules::apply_static_longhand;
|
||||
use impeccable_html::cascade::shorthand::background_longhands;
|
||||
|
||||
// The expansion itself is pinned by the recorded vectors and stays
|
||||
// image-and-color only; repeat and size come from the side channel.
|
||||
assert_eq!(
|
||||
background_longhands(
|
||||
"background",
|
||||
"url(a.png) center / cover no-repeat, url(b.png)"
|
||||
),
|
||||
vec![
|
||||
(
|
||||
"backgroundRepeat".to_string(),
|
||||
"no-repeat, repeat".to_string()
|
||||
),
|
||||
("backgroundSize".to_string(), "cover, auto".to_string()),
|
||||
]
|
||||
);
|
||||
// The css-tree generator glues the first keyword to the call.
|
||||
assert_eq!(
|
||||
background_longhands("background", "url(a.png)center/cover no-repeat"),
|
||||
vec![
|
||||
("backgroundRepeat".to_string(), "no-repeat".to_string()),
|
||||
("backgroundSize".to_string(), "cover".to_string()),
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
background_longhands("background", "#fff url(a.png) repeat-x"),
|
||||
vec![
|
||||
("backgroundRepeat".to_string(), "repeat-x".to_string()),
|
||||
("backgroundSize".to_string(), "auto".to_string()),
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
background_longhands("Background-Size", "100% 32px"),
|
||||
vec![("backgroundSize".to_string(), "100% 32px".to_string())]
|
||||
);
|
||||
// A color-only shorthand still resets both longhands, as in CSS.
|
||||
assert_eq!(
|
||||
background_longhands("background", "#fff"),
|
||||
vec![
|
||||
("backgroundRepeat".to_string(), "repeat".to_string()),
|
||||
("backgroundSize".to_string(), "auto".to_string()),
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
background_longhands("background", "Inherit"),
|
||||
vec![
|
||||
("backgroundRepeat".to_string(), "inherit".to_string()),
|
||||
("backgroundSize".to_string(), "inherit".to_string()),
|
||||
]
|
||||
);
|
||||
assert!(background_longhands("background", "var(--surface)").is_empty());
|
||||
assert!(background_longhands("color", "red").is_empty());
|
||||
|
||||
// A later shorthand with an image resets an earlier longhand, and a
|
||||
// later longhand overrides a shorthand, under the cascade's priority.
|
||||
let mut specified: SpecifiedStore<&str> = SpecifiedStore::new();
|
||||
let node = "n1";
|
||||
let apply = |specified: &mut SpecifiedStore<&str>, prop: &str, value: &str, m: DeclMeta| {
|
||||
apply_static_declaration(specified, node, prop, value, &m);
|
||||
for (p, v) in background_longhands(prop, value) {
|
||||
apply_static_longhand(specified, node, &p, &v, &m);
|
||||
}
|
||||
};
|
||||
apply(
|
||||
&mut specified,
|
||||
"background-repeat",
|
||||
"no-repeat",
|
||||
meta(false, [0, 1, 0], 0, false),
|
||||
);
|
||||
apply(
|
||||
&mut specified,
|
||||
"background",
|
||||
"url(hero.jpg) center / cover",
|
||||
meta(false, [0, 1, 0], 1, false),
|
||||
);
|
||||
apply(
|
||||
&mut specified,
|
||||
"background-size",
|
||||
"contain",
|
||||
meta(false, [0, 1, 0], 2, false),
|
||||
);
|
||||
let map = specified.get(&node).expect("node entry");
|
||||
assert_eq!(
|
||||
map.get("backgroundRepeat").map(|d| d.value.as_str()),
|
||||
Some("repeat")
|
||||
);
|
||||
assert_eq!(
|
||||
map.get("backgroundSize").map(|d| d.value.as_str()),
|
||||
Some("contain")
|
||||
);
|
||||
assert_eq!(
|
||||
map.get("backgroundImage").map(|d| d.value.as_str()),
|
||||
Some("url(hero.jpg) center / cover")
|
||||
);
|
||||
// `background: #fff` after a longhand resets it, so a later
|
||||
// `background-image` is classified with the defaults, not a stale value.
|
||||
apply(
|
||||
&mut specified,
|
||||
"background-repeat",
|
||||
"no-repeat",
|
||||
meta(false, [0, 1, 0], 3, false),
|
||||
);
|
||||
apply(
|
||||
&mut specified,
|
||||
"background",
|
||||
"#fff",
|
||||
meta(false, [0, 1, 0], 4, false),
|
||||
);
|
||||
let map = specified.get(&node).expect("node entry");
|
||||
assert_eq!(
|
||||
map.get("backgroundRepeat").map(|d| d.value.as_str()),
|
||||
Some("repeat")
|
||||
);
|
||||
assert_eq!(
|
||||
map.get("backgroundSize").map(|d| d.value.as_str()),
|
||||
Some("auto")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn linked_sheet_urls_are_rewritten_page_relative() {
|
||||
use impeccable_html::cascade::build::rewrite_sheet_urls;
|
||||
|
||||
let css = concat!(
|
||||
".a { background: url(light.png) }\n",
|
||||
".b { background: url(\"../img/hero.jpg?v=3\") no-repeat }\n",
|
||||
".c { background-image: url('./x.webp'), url(/root.png), url(data:image/png;base64,AAAA) }\n",
|
||||
".d { background: url(https://cdn.example.com/a.png) }\n",
|
||||
".e { mask: url(#clip) }\n",
|
||||
".f { background: URL( a b.png ) }\n",
|
||||
);
|
||||
let out = rewrite_sheet_urls(css, "/site/css", "/site");
|
||||
assert!(
|
||||
out.contains(".a { background: url(css/light.png) }"),
|
||||
"{out}"
|
||||
);
|
||||
assert!(
|
||||
out.contains(".b { background: url(\"img/hero.jpg?v=3\") no-repeat }"),
|
||||
"{out}"
|
||||
);
|
||||
assert!(
|
||||
out.contains("url('css/x.webp'), url(/root.png), url(data:image/png;base64,AAAA)"),
|
||||
"{out}"
|
||||
);
|
||||
assert!(out.contains("url(https://cdn.example.com/a.png)"), "{out}");
|
||||
assert!(out.contains("url(#clip)"), "{out}");
|
||||
// An unquoted url with a space is not a url token in CSS; it stays put.
|
||||
assert!(out.contains("URL( a b.png )"), "{out}");
|
||||
// A sheet beside the page keeps every path where it was (the engine
|
||||
// does not even call the rewrite for that case).
|
||||
let same = rewrite_sheet_urls(css, "/site", "/site");
|
||||
assert!(same.contains(".a { background: url(light.png) }"), "{same}");
|
||||
assert!(same.contains("url(\"../img/hero.jpg?v=3\")"), "{same}");
|
||||
// A sheet above the page walks back up.
|
||||
let up = rewrite_sheet_urls(".a { background: url(light.png) }", "/site", "/site/pages");
|
||||
assert_eq!(up, ".a { background: url(../light.png) }");
|
||||
// A stray `url(` in a comment, or an unclosed quote, never swallows the
|
||||
// rules after it: comments pass through untouched and a url form ends
|
||||
// where CSS says it ends.
|
||||
let hazards = concat!(
|
||||
"/* see url( for details */ .g { color: red }\n",
|
||||
".h { background: url(a.png) }\n",
|
||||
".i { background: url(\"oops }\n",
|
||||
".j { background: url(b.png) }\n",
|
||||
"/* url(unterminated.png",
|
||||
);
|
||||
let out = rewrite_sheet_urls(hazards, "/site/css", "/site");
|
||||
assert!(
|
||||
out.contains("/* see url( for details */ .g { color: red }"),
|
||||
"{out}"
|
||||
);
|
||||
assert!(out.contains(".h { background: url(css/a.png) }"), "{out}");
|
||||
assert!(out.contains(".i { background: url(\"oops }"), "{out}");
|
||||
assert!(out.contains(".j { background: url(css/b.png) }"), "{out}");
|
||||
assert!(out.ends_with("/* url(unterminated.png"), "{out}");
|
||||
// A `/*` inside a string or an unquoted url is content, not a comment,
|
||||
// so the rules after it are still rewritten.
|
||||
let strings = concat!(
|
||||
".k { content: \"a/*b\"; background: url(k.png) }\n",
|
||||
".l { background: url(l/*.png) }\n",
|
||||
".m { content: 'c/*d'; } /* real url( */ .n { background: url(n.png) }\n",
|
||||
);
|
||||
let out = rewrite_sheet_urls(strings, "/site/css", "/site");
|
||||
assert!(
|
||||
out.contains(".k { content: \"a/*b\"; background: url(css/k.png) }"),
|
||||
"{out}"
|
||||
);
|
||||
assert!(out.contains(".l { background: url(css/l/*.png) }"), "{out}");
|
||||
assert!(
|
||||
out.contains("/* real url( */ .n { background: url(css/n.png) }"),
|
||||
"{out}"
|
||||
);
|
||||
// `myurl(` is a custom function, not the url token: its comment stays a
|
||||
// comment and its argument is not rewritten.
|
||||
let custom = ".q { mask: myurl(a /* url(x.png) */); background: url(q.png) }";
|
||||
let out = rewrite_sheet_urls(custom, "/site/css", "/site");
|
||||
assert_eq!(
|
||||
out,
|
||||
".q { mask: myurl(a /* url(x.png) */); background: url(css/q.png) }"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,122 +0,0 @@
|
||||
//! Integration tests for `::placeholder` contrast detection (#790).
|
||||
|
||||
use impeccable_html::{detect_html_source, DetectHtmlOptions};
|
||||
use std::path::Path;
|
||||
|
||||
const ISSUER_REPRO: &str = r#"<!DOCTYPE html>
|
||||
<html><head><style>
|
||||
input::placeholder { color: #bbbbbb; }
|
||||
input { background: white; font-size: 16px; width: 200px; height: 40px; border: 1px solid #ccc; padding: 8px; box-sizing: border-box; }
|
||||
</style></head>
|
||||
<body><input placeholder="Search"></body></html>
|
||||
"#;
|
||||
|
||||
fn scan(html: &str) -> Vec<impeccable_core::findings::Finding> {
|
||||
detect_html_source(html, Path::new("/tmp/placeholder.html"), &DetectHtmlOptions::default())
|
||||
}
|
||||
|
||||
fn repo_root() -> std::path::PathBuf {
|
||||
std::env::var("IMPECCABLE_PUBLIC_REPO")
|
||||
.map(std::path::PathBuf::from)
|
||||
.unwrap_or_else(|_| Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn issuer_repro_flags_pale_placeholder() {
|
||||
let findings = scan(ISSUER_REPRO);
|
||||
assert!(
|
||||
findings.iter().any(|f| f.antipattern == "low-contrast"),
|
||||
"expected low-contrast finding, got {findings:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bare_placeholder_selector_flags() {
|
||||
let html = r#"<!DOCTYPE html>
|
||||
<html><head><style>
|
||||
::placeholder { color: #bbbbbb; }
|
||||
input { background: white; font-size: 16px; width: 200px; height: 40px; }
|
||||
</style></head>
|
||||
<body><input placeholder="Search"></body></html>
|
||||
"#;
|
||||
let findings = scan(html);
|
||||
assert!(
|
||||
findings.iter().any(|f| f.antipattern == "low-contrast"),
|
||||
"expected low-contrast for bare ::placeholder, got {findings:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn descendant_placeholder_selector_flags() {
|
||||
let html = r#"<!DOCTYPE html>
|
||||
<html><head><style>
|
||||
.form ::placeholder { color: #bbbbbb; }
|
||||
input { background: white; font-size: 16px; width: 200px; height: 40px; }
|
||||
</style></head>
|
||||
<body><div class="form"><input placeholder="Search"></div></body></html>
|
||||
"#;
|
||||
let findings = scan(html);
|
||||
assert!(
|
||||
findings.iter().any(|f| f.antipattern == "low-contrast"),
|
||||
"expected low-contrast for descendant ::placeholder, got {findings:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sibling_placeholder_selector_flags() {
|
||||
let html = r#"<!DOCTYPE html>
|
||||
<html><head><style>
|
||||
.label + ::placeholder { color: #bbbbbb; }
|
||||
input { background: white; font-size: 16px; width: 200px; height: 40px; }
|
||||
</style></head>
|
||||
<body><label class="label">Name</label><input placeholder="Search"></body></html>
|
||||
"#;
|
||||
let findings = scan(html);
|
||||
assert!(
|
||||
findings.iter().any(|f| f.antipattern == "low-contrast"),
|
||||
"expected low-contrast for sibling ::placeholder, got {findings:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fixture_flag_and_pass_cases() {
|
||||
let fixture = repo_root().join("tests/fixtures/antipatterns/placeholder-contrast.html");
|
||||
assert!(
|
||||
fixture.is_file(),
|
||||
"missing fixture at {}",
|
||||
fixture.display()
|
||||
);
|
||||
let html = std::fs::read_to_string(&fixture).unwrap();
|
||||
let findings = detect_html_source(&html, &fixture, &DetectHtmlOptions::default());
|
||||
let ids: Vec<&str> = findings.iter().map(|f| f.antipattern.as_str()).collect();
|
||||
let snippets: Vec<&str> = findings.iter().map(|f| f.snippet.as_str()).collect();
|
||||
|
||||
for needle in [
|
||||
"Pale Placeholder On White Field",
|
||||
"Pale Placeholder On White Textarea",
|
||||
"Translucent Placeholder On Light Field",
|
||||
"Pale Placeholder On Frosted Panel",
|
||||
] {
|
||||
assert!(
|
||||
snippets.iter().any(|s| s.contains(needle)),
|
||||
"expected flag for placeholder {needle:?}, findings={findings:?}"
|
||||
);
|
||||
}
|
||||
|
||||
for needle in [
|
||||
"Ink Placeholder On White Field",
|
||||
"Light Placeholder On Dark Field",
|
||||
"Filled Field Hides Placeholder",
|
||||
"Unstyled Placeholder Uses UA Color",
|
||||
] {
|
||||
assert!(
|
||||
!snippets.iter().any(|s| s.contains(needle)),
|
||||
"pass case {needle:?} should not flag, findings={findings:?}"
|
||||
);
|
||||
}
|
||||
|
||||
assert!(
|
||||
ids.iter().filter(|id| **id == "low-contrast").count() >= 4,
|
||||
"expected at least four low-contrast hits, got {findings:?}"
|
||||
);
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -188,12 +188,6 @@ 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,12 +48,11 @@ 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, gestures work under touch)
|
||||
**Score 0-4**: 0=Desktop-only (breaks on mobile), 1=Major issues (some breakpoints, many failures), 2=Partial (works on mobile, rough edges), 3=Good (responsive, minor touch target or overflow issues), 4=Excellent (fluid, all viewports, proper touch targets)
|
||||
|
||||
### 5. Implementation Integrity (CRITICAL)
|
||||
|
||||
|
||||
@@ -205,11 +205,6 @@ 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
|
||||
@@ -309,7 +304,6 @@ 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)
|
||||
|
||||
@@ -336,10 +330,7 @@ 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.
|
||||
|
||||
@@ -332,6 +332,7 @@ Optional keys added later by engines (appended after the above): `ignoreValue` (
|
||||
#### Static and regex engines (only what affects the contract)
|
||||
|
||||
- `detectHtml`: reads file, imports `htmlparser2`, `css-select`, `css-tree`, `domutils`; on import failure prints once to stderr `impeccable detect: DEGRADED - HTML parser modules unavailable (htmlparser2, css-select, css-tree, domutils).\nFalling back to regex matching. Custom properties, selector matching and computed contrast are NOT evaluated; findings are an undercount, not a clean bill of health.\n` and falls back to `detectText`. Inlines `<link rel=stylesheet href>` that are local (not `/^(https?:)?\/\//i`), query/hash stripped. Runs element rules, design-system rules (`checkSourceDesignSystem` + `collectStaticDesignSystemFindings`, merged), then page rules only when `isFullPage(html)` (`/<!doctype\s|<html[\s>]|<head[\s>]/i` after stripping comments), plus text-content analyzers; ends with inline-ignore filtering.
|
||||
- **Sampled contrast (#560, engine only, no JS ancestor)**: when `resolveBackgroundInfo` ends at a `url()` layer (the case the JS skipped), the static engine reads that image and measures the text against its pixels. The url resolves to a local file relative to the page (a linked stylesheet from another directory has its relative urls rewritten to page-relative form when it is inlined, so a url resolves against the sheet that declared it; root-relative paths walk up to the project root the way linked stylesheets do) or a base64 `data:` URI; a remote URL, a missing or unreadable file, a file over 24 MiB, a data URI whose payload decodes to more than that, an image over 8192px on a side, and SVG keep the skip. Decoders: PNG, JPEG, GIF, WebP. The image is scaled to at most 640px on a side and read on a fixed 6x6 grid. Each sample composites a translucent pixel over the element's own color and its parent's resolved ground (white at the root; unknown when another layer sits beneath the image), then under every translucent surface and every uniform gradient wash between the text and the image; a gradient whose stops differ is a scrim placed on purpose and keeps the skip, as does an opaque gradient or an unparseable color anywhere in the chain. A layer whose `background-repeat` leaves an axis unrepeated and whose painted extent on that axis (`background-size` in px, else the intrinsic size) is under 160px is decoration and keeps the skip; `cover`, `contain`, and percentage sizes always paint. The cascade carries `backgroundRepeat` / `backgroundSize` for this from the longhands and from the `background` shorthand (per layer, defaults `repeat` / `auto`). A verdict needs 27 of the 36 samples; the finding fires when the 90th-percentile ratio is under the WCAG threshold (same large-text rule as `checkColors`, text alpha blended over the sample): `{id:'low-contrast', snippet:`sampled (coarse) ${p90}:1 (need ${threshold}:1) — text ${hex} on ${file name | data:<mime>}; p90 of ${n} samples, median ${median}:1`}` (ratio label to 2 decimals when its 1-decimal form equals the threshold, as in `checkColors`). It is emitted in the `color-rules` pass right after `checkColors`'s hits for that element, obeys the same `SAFE_TAGS` gate, `data-impeccable-ignore`, and inline ignores, and never produces `gray-on-color`. Fixture: `sampled-image-contrast.html` (+ `sampled-images/`).
|
||||
- `detectText`: regex line matchers (ids: side-tab, border-accent-on-rounded, overused-font, gradient-text, ai-color-palette, gray-on-color, bounce-easing, layout-transition, broken-image), inset-stripe/pseudo-stripe CSS scans, `codex-grid-background`, `<style>` blocks (Astro/Vue/Svelte), CSS-in-JS templates, design-system source checks; dedupe (same antipattern+snippet within 2 lines); page analyzers only when `isFullPage` and ext ∈ `{'.html','.htm','.astro','.vue','.svelte'}` or no ext (`<stdin>`): flat-type-hierarchy, monotonous-spacing, em-dash-overuse, marketing-buzzword, aphoristic-cadence, dark-glow (+ radial-halo, marquee); inline ignores last.
|
||||
|
||||
#### Profiler (`cli/engine/profile/profiler.mjs`)
|
||||
|
||||
@@ -188,12 +188,6 @@ 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,12 +48,11 @@ 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, gestures work under touch)
|
||||
**Score 0-4**: 0=Desktop-only (breaks on mobile), 1=Major issues (some breakpoints, many failures), 2=Partial (works on mobile, rough edges), 3=Good (responsive, minor touch target or overflow issues), 4=Excellent (fluid, all viewports, proper touch targets)
|
||||
|
||||
### 5. Implementation Integrity (CRITICAL)
|
||||
|
||||
|
||||
@@ -205,11 +205,6 @@ 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
|
||||
@@ -309,7 +304,6 @@ 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)
|
||||
|
||||
@@ -336,10 +330,7 @@ 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,12 +188,6 @@ 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,12 +48,11 @@ 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, gestures work under touch)
|
||||
**Score 0-4**: 0=Desktop-only (breaks on mobile), 1=Major issues (some breakpoints, many failures), 2=Partial (works on mobile, rough edges), 3=Good (responsive, minor touch target or overflow issues), 4=Excellent (fluid, all viewports, proper touch targets)
|
||||
|
||||
### 5. Implementation Integrity (CRITICAL)
|
||||
|
||||
|
||||
@@ -205,11 +205,6 @@ 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
|
||||
@@ -309,7 +304,6 @@ 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)
|
||||
|
||||
@@ -336,10 +330,7 @@ Test thoroughly with edge cases:
|
||||
- **Network issues**: Disable internet, throttle connection
|
||||
- **Large datasets**: Test with 1000+ items
|
||||
- **Concurrent actions**: Click submit 10 times rapidly
|
||||
- **Interrupted gestures**: Add a second finger mid-drag, scroll across the control, release outside it, switch windows mid-drag; then drag again
|
||||
- **Errors**: Force API errors, test all error states
|
||||
- **Empty**: Remove all data, test empty states
|
||||
|
||||
For gestures, say what produced the evidence (emulated viewport, synthesized touch, which engine, physical device) and name what stayed untested.
|
||||
|
||||
When edge cases are covered, hand off to `{{command_prefix}}impeccable polish` for the final pass.
|
||||
|
||||
@@ -1,92 +0,0 @@
|
||||
<!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>
|
||||
@@ -0,0 +1,116 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Fixture: pixel-sampled contrast on image-backed text (static engine)</title>
|
||||
<link rel="stylesheet" href="sampled-images/site.css">
|
||||
<style>
|
||||
body { margin: 0; padding: 40px; background: #ffffff; font-family: Georgia, serif; color: #262421; }
|
||||
/* Every panel is a url() ground the analytic walk cannot measure. The
|
||||
static engine reads the image's pixels instead (#560): a fixed grid over
|
||||
the whole image, and a finding only when nearly all of it fails. Each
|
||||
case carries UNIQUE copy so a finding attributes to exactly one case.
|
||||
Images live in sampled-images/: light.png and light.webp are a
|
||||
near-white texture, dark.jpg a charcoal one, split.png half light and
|
||||
half dark, cutout.png the light texture at 25% alpha, icon.png a 24px
|
||||
glyph. */
|
||||
h1 { font-size: 28px; margin: 0 0 24px; }
|
||||
.panel { width: 420px; padding: 24px; margin: 0 0 24px; }
|
||||
.panel p { margin: 0; font-size: 16px; line-height: 1.6; }
|
||||
.panel h2 { margin: 0; font-size: 28px; line-height: 1.3; }
|
||||
.light { background: url(sampled-images/light.png); }
|
||||
.light-webp { background: url("sampled-images/light.webp") center / cover; }
|
||||
.tokens-first { background: center / cover no-repeat url(sampled-images/light.png); }
|
||||
.dark-jpg { background: url(sampled-images/dark.jpg) center / cover no-repeat; }
|
||||
.split { background: url(sampled-images/split.png) repeat; }
|
||||
.inline-data { background: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAICAYAAADED76LAAAAaUlEQVR42k2PSQrAMAwD/f9ndt+TNl2OLmMQ5CCELUtR7CuLP+cUuPPo19F73tqY09q4nXsXC0SYA8T3mgNxgMiypMGZccLA5Kij6+eMDhKIZEZQsmkBA47UKxLkFKsHjCE6IOBQSv2bH8D48hUetch/AAAAAElFTkSuQmCC); }
|
||||
/* Uniform tints composite exactly; a wash that varies is a scrim placed
|
||||
under the text on purpose, and without layout the engine cannot say
|
||||
which stop the text sits on. */
|
||||
.wash-weak { background: linear-gradient(rgba(0, 0, 0, 0.3), rgba(0, 0, 0, 0.3)), url(sampled-images/light.png); }
|
||||
.wash-strong { background: linear-gradient(rgba(0, 0, 0, 0.6), rgba(0, 0, 0, 0.6)), url(sampled-images/light.png); }
|
||||
.fade-scrim { background: linear-gradient(rgba(0, 0, 0, 0.8), rgba(0, 0, 0, 0)), url(sampled-images/light.png); }
|
||||
/* A translucent image composites over what is beneath it: here a dark
|
||||
section, so the light texture at 25% reads as a dark ground. */
|
||||
.dark-section { width: 468px; padding: 0; background: #17150f; }
|
||||
.cutout { background: url(sampled-images/cutout.png); margin: 0; }
|
||||
.missing { background: url(sampled-images/nope.png); }
|
||||
.remote { background: url(https://example.com/hero.jpg) center / cover; }
|
||||
.svg-data { background: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='100' height='100'><rect width='100' height='100' fill='%23f5f2ea'/></svg>") center / cover; }
|
||||
/* A no-repeat icon painted at 24px is decoration, not the ground. */
|
||||
.icon-link { background: url(sampled-images/icon.png) no-repeat left center; padding: 8px 8px 8px 32px; margin: 0 0 24px; width: 380px; font-size: 16px; line-height: 1.6; }
|
||||
.white { color: #fdfdfd; }
|
||||
.ink { color: #262421; }
|
||||
.smoke { color: #3a3632; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Sampled contrast fixture</h1>
|
||||
|
||||
<!-- ── Should flag ──────────────────────────────────────────────── -->
|
||||
<div class="panel light">
|
||||
<p class="white">White copy on a near-white texture fails everywhere the grid lands.</p>
|
||||
</div>
|
||||
<div class="panel dark-jpg">
|
||||
<p class="smoke">Smoke gray copy on a charcoal photo fails wherever it sits.</p>
|
||||
</div>
|
||||
<div class="panel light-webp">
|
||||
<p class="white">The same texture as a WebP decodes through the same path.</p>
|
||||
</div>
|
||||
<div class="panel inline-data">
|
||||
<p class="white">A base64 PNG data URI is read without touching the disk.</p>
|
||||
</div>
|
||||
<div class="panel wash-weak">
|
||||
<p class="white">A thirty percent tint is too weak to carry white copy over the texture.</p>
|
||||
</div>
|
||||
<div class="dark-section">
|
||||
<div class="panel cutout">
|
||||
<p class="ink">Ink on the translucent texture composites over the dark section and fails.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel from-sheet">
|
||||
<p class="white">The ground of this panel is declared in the linked stylesheet.</p>
|
||||
</div>
|
||||
<div class="panel light">
|
||||
<h2 class="white">Large white heading on the texture</h2>
|
||||
</div>
|
||||
<div class="panel tokens-first">
|
||||
<p class="white">Shorthand tokens before the image still name the ground.</p>
|
||||
</div>
|
||||
|
||||
<!-- ── Should pass ──────────────────────────────────────────────── -->
|
||||
<div class="panel light">
|
||||
<p class="ink">Ink on the near-white texture reads fine and must not flag.</p>
|
||||
</div>
|
||||
<div class="panel dark-jpg">
|
||||
<p class="white">White copy on the charcoal photo reads fine and must not flag.</p>
|
||||
</div>
|
||||
<div class="panel split">
|
||||
<p class="white">Half of this image is dark, so the copy may well sit there.</p>
|
||||
</div>
|
||||
<div class="panel wash-strong">
|
||||
<p class="white">A sixty percent tint carries white copy over the texture.</p>
|
||||
</div>
|
||||
<div class="panel fade-scrim">
|
||||
<p class="white">A scrim that fades out is placed on purpose; the engine keeps the skip.</p>
|
||||
</div>
|
||||
<div class="dark-section">
|
||||
<div class="panel cutout">
|
||||
<p class="white">White copy on the translucent texture over the dark section passes.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel missing">
|
||||
<p class="white">An image that is not on disk keeps the skip.</p>
|
||||
</div>
|
||||
<div class="panel remote">
|
||||
<p class="white">A remote image is never fetched during a file scan.</p>
|
||||
</div>
|
||||
<div class="panel svg-data">
|
||||
<p class="white">An SVG has no raster to sample, so the skip stands.</p>
|
||||
</div>
|
||||
<p class="icon-link">A twenty four pixel icon beside the copy is decoration, not the ground.</p>
|
||||
<div class="panel light" data-impeccable-ignore="low-contrast">
|
||||
<p class="white">A waived panel keeps its sampled finding out of the report.</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 174 B |
Binary file not shown.
|
After Width: | Height: | Size: 2.6 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 128 B |
Binary file not shown.
|
After Width: | Height: | Size: 4.4 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.0 KiB |
@@ -0,0 +1,3 @@
|
||||
/* Linked from ../sampled-image-contrast.html. A url() inside a stylesheet is
|
||||
relative to the sheet, so light.png resolves here, not next to the page. */
|
||||
.from-sheet { background: url(light.png); }
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 202 B |
@@ -164,3 +164,15 @@ installed. The binary's `CLI_VERSION` moves from `3.6.0` to `4.0.0` with the
|
||||
CLI 4.0.0 release; it is what the binary prints when run directly.
|
||||
|
||||
- `cli-version`.
|
||||
|
||||
## Recorded 2026-09-10: sampled contrast on image-backed text (#560)
|
||||
|
||||
The static engine now reads the pixels of a local `url()` background behind
|
||||
text instead of skipping the contrast check (docs/CLI-CONTRACT.md, "Sampled
|
||||
contrast"). The JS never had this path, so the new fixture
|
||||
`sampled-image-contrast.html` (with `sampled-images/`) has no JS golden; its
|
||||
cases were recorded from the engine, and the directory sweeps below moved
|
||||
only by that fixture's nine `low-contrast` findings, verified entry by entry.
|
||||
|
||||
- `detect-fixture-json-sampled-image-contrast-html`, `detect-fixture-text-sampled-image-contrast-html`, `detect-fixture-json-sampled-images`, `detect-fixture-text-sampled-images`: new cases.
|
||||
- `detect-dir-json-all-fixtures`, `detect-dir-text-all-fixtures`, `detect-dir-quiet-all-fixtures`, `detect-no-advisory-json`, `detect-no-advisory-text`: the directory sweep picks up the new fixture.
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"stdout": "",
|
||||
"stderr": "419 anti-patterns found.\n17 advisory notes (not counted).\n",
|
||||
"stderr": "424 anti-patterns found.\n17 advisory notes (not counted).\n",
|
||||
"exit": 2,
|
||||
"signal": null,
|
||||
"files": {}
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"stdout": "[\n {\n \"antipattern\": \"low-contrast\",\n \"name\": \"Low contrast text\",\n \"description\": \"Text does not meet WCAG AA contrast requirements (4.5:1 for body, 3:1 for large text). Increase the contrast between text and background.\",\n \"severity\": \"warning\",\n \"category\": \"quality\",\n \"file\": \"<REPO>/tests/fixtures/antipatterns/placeholder-contrast.html\",\n \"line\": 0,\n \"snippet\": \"placeholder \\\"Pale Placeholder On White Field\\\" 1.9:1 (need 4.5:1) — text #bbbbbb on #ffffff\"\n },\n {\n \"antipattern\": \"low-contrast\",\n \"name\": \"Low contrast text\",\n \"description\": \"Text does not meet WCAG AA contrast requirements (4.5:1 for body, 3:1 for large text). Increase the contrast between text and background.\",\n \"severity\": \"warning\",\n \"category\": \"quality\",\n \"file\": \"<REPO>/tests/fixtures/antipatterns/placeholder-contrast.html\",\n \"line\": 0,\n \"snippet\": \"placeholder \\\"Pale Placeholder On White Textarea\\\" 1.9:1 (need 4.5:1) — text #bbbbbb on #ffffff\"\n },\n {\n \"antipattern\": \"low-contrast\",\n \"name\": \"Low contrast text\",\n \"description\": \"Text does not meet WCAG AA contrast requirements (4.5:1 for body, 3:1 for large text). Increase the contrast between text and background.\",\n \"severity\": \"warning\",\n \"category\": \"quality\",\n \"file\": \"<REPO>/tests/fixtures/antipatterns/placeholder-contrast.html\",\n \"line\": 0,\n \"snippet\": \"placeholder \\\"Translucent Placeholder On Light Field\\\" 1.0:1 (need 4.5:1) — text #f9f9f9 on #f5f5f5\"\n },\n {\n \"antipattern\": \"low-contrast\",\n \"name\": \"Low contrast text\",\n \"description\": \"Text does not meet WCAG AA contrast requirements (4.5:1 for body, 3:1 for large text). Increase the contrast between text and background.\",\n \"severity\": \"warning\",\n \"category\": \"quality\",\n \"file\": \"<REPO>/tests/fixtures/antipatterns/placeholder-contrast.html\",\n \"line\": 0,\n \"snippet\": \"placeholder \\\"Pale Placeholder On Frosted Panel\\\" 3.5:1 (need 4.5:1) — text #bbbbbb on #5c5c5d\"\n }\n]\n",
|
||||
"stderr": "",
|
||||
"exit": 2,
|
||||
"signal": null,
|
||||
"files": {}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"stdout": "[\n {\n \"antipattern\": \"low-contrast\",\n \"name\": \"Low contrast text\",\n \"description\": \"Text does not meet WCAG AA contrast requirements (4.5:1 for body, 3:1 for large text). Increase the contrast between text and background.\",\n \"severity\": \"warning\",\n \"category\": \"quality\",\n \"file\": \"<REPO>/tests/fixtures/antipatterns/sampled-image-contrast.html\",\n \"line\": 0,\n \"snippet\": \"sampled (coarse) 1.2:1 (need 4.5:1) — text #fdfdfd on light.png; p90 of 36 samples, median 1.1:1\"\n },\n {\n \"antipattern\": \"low-contrast\",\n \"name\": \"Low contrast text\",\n \"description\": \"Text does not meet WCAG AA contrast requirements (4.5:1 for body, 3:1 for large text). Increase the contrast between text and background.\",\n \"severity\": \"warning\",\n \"category\": \"quality\",\n \"file\": \"<REPO>/tests/fixtures/antipatterns/sampled-image-contrast.html\",\n \"line\": 0,\n \"snippet\": \"sampled (coarse) 1.3:1 (need 4.5:1) — text #3a3632 on dark.jpg; p90 of 36 samples, median 1.2:1\"\n },\n {\n \"antipattern\": \"low-contrast\",\n \"name\": \"Low contrast text\",\n \"description\": \"Text does not meet WCAG AA contrast requirements (4.5:1 for body, 3:1 for large text). Increase the contrast between text and background.\",\n \"severity\": \"warning\",\n \"category\": \"quality\",\n \"file\": \"<REPO>/tests/fixtures/antipatterns/sampled-image-contrast.html\",\n \"line\": 0,\n \"snippet\": \"sampled (coarse) 1.2:1 (need 4.5:1) — text #fdfdfd on light.webp; p90 of 36 samples, median 1.2:1\"\n },\n {\n \"antipattern\": \"low-contrast\",\n \"name\": \"Low contrast text\",\n \"description\": \"Text does not meet WCAG AA contrast requirements (4.5:1 for body, 3:1 for large text). Increase the contrast between text and background.\",\n \"severity\": \"warning\",\n \"category\": \"quality\",\n \"file\": \"<REPO>/tests/fixtures/antipatterns/sampled-image-contrast.html\",\n \"line\": 0,\n \"snippet\": \"sampled (coarse) 1.1:1 (need 4.5:1) — text #fdfdfd on data:image/png; p90 of 36 samples, median 1.1:1\"\n },\n {\n \"antipattern\": \"low-contrast\",\n \"name\": \"Low contrast text\",\n \"description\": \"Text does not meet WCAG AA contrast requirements (4.5:1 for body, 3:1 for large text). Increase the contrast between text and background.\",\n \"severity\": \"warning\",\n \"category\": \"quality\",\n \"file\": \"<REPO>/tests/fixtures/antipatterns/sampled-image-contrast.html\",\n \"line\": 0,\n \"snippet\": \"sampled (coarse) 2.5:1 (need 4.5:1) — text #fdfdfd on light.png; p90 of 36 samples, median 2.4:1\"\n },\n {\n \"antipattern\": \"low-contrast\",\n \"name\": \"Low contrast text\",\n \"description\": \"Text does not meet WCAG AA contrast requirements (4.5:1 for body, 3:1 for large text). Increase the contrast between text and background.\",\n \"severity\": \"warning\",\n \"category\": \"quality\",\n \"file\": \"<REPO>/tests/fixtures/antipatterns/sampled-image-contrast.html\",\n \"line\": 0,\n \"snippet\": \"sampled (coarse) 1.8:1 (need 4.5:1) — text #262421 on cutout.png; p90 of 36 samples, median 1.8:1\"\n },\n {\n \"antipattern\": \"low-contrast\",\n \"name\": \"Low contrast text\",\n \"description\": \"Text does not meet WCAG AA contrast requirements (4.5:1 for body, 3:1 for large text). Increase the contrast between text and background.\",\n \"severity\": \"warning\",\n \"category\": \"quality\",\n \"file\": \"<REPO>/tests/fixtures/antipatterns/sampled-image-contrast.html\",\n \"line\": 0,\n \"snippet\": \"sampled (coarse) 1.2:1 (need 4.5:1) — text #fdfdfd on light.png; p90 of 36 samples, median 1.1:1\"\n },\n {\n \"antipattern\": \"low-contrast\",\n \"name\": \"Low contrast text\",\n \"description\": \"Text does not meet WCAG AA contrast requirements (4.5:1 for body, 3:1 for large text). Increase the contrast between text and background.\",\n \"severity\": \"warning\",\n \"category\": \"quality\",\n \"file\": \"<REPO>/tests/fixtures/antipatterns/sampled-image-contrast.html\",\n \"line\": 0,\n \"snippet\": \"sampled (coarse) 1.2:1 (need 3:1) — text #fdfdfd on light.png; p90 of 36 samples, median 1.1:1\"\n },\n {\n \"antipattern\": \"low-contrast\",\n \"name\": \"Low contrast text\",\n \"description\": \"Text does not meet WCAG AA contrast requirements (4.5:1 for body, 3:1 for large text). Increase the contrast between text and background.\",\n \"severity\": \"warning\",\n \"category\": \"quality\",\n \"file\": \"<REPO>/tests/fixtures/antipatterns/sampled-image-contrast.html\",\n \"line\": 0,\n \"snippet\": \"sampled (coarse) 1.2:1 (need 4.5:1) — text #fdfdfd on light.png; p90 of 36 samples, median 1.1:1\"\n }\n]\n",
|
||||
"stderr": "",
|
||||
"exit": 2,
|
||||
"signal": null,
|
||||
"files": {}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user