mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-11 21:57:14 +03:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cb56ed6c19 | ||
|
|
3e1f67c52c | ||
|
|
0c09f4c7e2 | ||
|
|
7a7579858c |
@@ -188,6 +188,12 @@ Test thoroughly across contexts:
|
||||
- **Edge cases**: Very small screens (320px), very large screens (4K)
|
||||
- **Slow connections**: Test on throttled network
|
||||
|
||||
**Custom controls** (sliders, drag surfaces, scrollable control strips): a before/after slider can pass every width check above and still refuse to drag on iOS, so exercise each one in scope in the same batched round as the checks above:
|
||||
|
||||
- **Primary gesture**: Tap it and confirm it responds as designed, then drag it with the target input method; the drag must complete, not just start
|
||||
- **Scroll across it**: A swipe along the page's scroll axis across the control scrolls the page or container without activating it; a drag that starts on the control along its axis moves the control, not the page. Neither failure throws an error, so try both
|
||||
- **Evidence**: Say what produced the evidence: an emulated viewport, synthesized touch input through a browser tool, which engine ran it (Chromium is not Safari), or a physical device. Screenshots and resized viewports verify layout, never a gesture. Name what stayed untested and move on; unreachable hardware is a reported gap, not a blocker
|
||||
|
||||
When the adaptation feels native to each context, hand off to `/impeccable polish` for the final pass.
|
||||
|
||||
---
|
||||
|
||||
@@ -48,11 +48,12 @@ Run comprehensive checks across 5 dimensions. Score each dimension 0-4 using the
|
||||
**Check for**:
|
||||
- **Fixed widths**: Hard-coded widths that break on mobile
|
||||
- **Touch targets**: Interactive elements < 44x44px
|
||||
- **Broken touch interaction**: Custom sliders, drag surfaces, and scrollable control strips whose primary gesture fails under touch, that swallow page scroll or lose the drag to it, or that stay stuck after an interrupted gesture. Code tells: mouse-only handlers, no `touch-action` on a pointer-event drag surface, drag state that nothing clears on cancel, lost capture, or blur. Exercise the gesture when a browser tool can synthesize touch (a rendered viewport proves layout, not the gesture), then say what produced the evidence (emulated viewport, synthesized touch, which engine, physical device) and what stayed untested
|
||||
- **Horizontal scroll**: Content overflow on narrow viewports
|
||||
- **Text scaling**: Layouts that break when text size increases
|
||||
- **Missing breakpoints**: No mobile/tablet variants
|
||||
|
||||
**Score 0-4**: 0=Desktop-only (breaks on mobile), 1=Major issues (some breakpoints, many failures), 2=Partial (works on mobile, rough edges), 3=Good (responsive, minor touch target or overflow issues), 4=Excellent (fluid, all viewports, proper touch targets)
|
||||
**Score 0-4**: 0=Desktop-only (breaks on mobile), 1=Major issues (some breakpoints, many failures), 2=Partial (works on mobile, rough edges), 3=Good (responsive, minor touch target or overflow issues), 4=Excellent (fluid, all viewports, proper touch targets, gestures work under touch)
|
||||
|
||||
### 5. Implementation Integrity (CRITICAL)
|
||||
|
||||
|
||||
@@ -205,6 +205,11 @@ t('items', { count }) // Handles complex plural rules
|
||||
- Optimistic updates with rollback
|
||||
- Conflict resolution
|
||||
|
||||
**Interrupted gestures** (custom sliders, drag surfaces, scrollable control strips):
|
||||
- A second finger or pointer lands mid-drag: the first drag keeps its pointer or ends cleanly, never jumps to the new one
|
||||
- The browser cancels the gesture to scroll (`pointercancel`), capture is lost (`lostpointercapture`), the pointer is released outside the control, or the window loses focus (`blur`) mid-drag: clear the dragging state and release capture
|
||||
- After each of these, the next tap or drag works without a reload
|
||||
|
||||
**Permission states**:
|
||||
- No permission to view
|
||||
- No permission to edit
|
||||
@@ -304,6 +309,7 @@ const throttledScroll = throttle(handleScroll, 100);
|
||||
- Unit tests for edge cases
|
||||
- Integration tests for error scenarios
|
||||
- E2E tests for critical paths
|
||||
- A behavioral regression for each confirmed gesture fix, when the project's test runner can drive input
|
||||
- Visual regression tests
|
||||
- Accessibility tests (axe, WAVE)
|
||||
|
||||
@@ -330,7 +336,10 @@ Test thoroughly with edge cases:
|
||||
- **Network issues**: Disable internet, throttle connection
|
||||
- **Large datasets**: Test with 1000+ items
|
||||
- **Concurrent actions**: Click submit 10 times rapidly
|
||||
- **Interrupted gestures**: Add a second finger mid-drag, scroll across the control, release outside it, switch windows mid-drag; then drag again
|
||||
- **Errors**: Force API errors, test all error states
|
||||
- **Empty**: Remove all data, test empty states
|
||||
|
||||
For gestures, say what produced the evidence (emulated viewport, synthesized touch, which engine, physical device) and name what stayed untested.
|
||||
|
||||
When edge cases are covered, hand off to `/impeccable polish` for the final pass.
|
||||
|
||||
@@ -188,6 +188,12 @@ Test thoroughly across contexts:
|
||||
- **Edge cases**: Very small screens (320px), very large screens (4K)
|
||||
- **Slow connections**: Test on throttled network
|
||||
|
||||
**Custom controls** (sliders, drag surfaces, scrollable control strips): a before/after slider can pass every width check above and still refuse to drag on iOS, so exercise each one in scope in the same batched round as the checks above:
|
||||
|
||||
- **Primary gesture**: Tap it and confirm it responds as designed, then drag it with the target input method; the drag must complete, not just start
|
||||
- **Scroll across it**: A swipe along the page's scroll axis across the control scrolls the page or container without activating it; a drag that starts on the control along its axis moves the control, not the page. Neither failure throws an error, so try both
|
||||
- **Evidence**: Say what produced the evidence: an emulated viewport, synthesized touch input through a browser tool, which engine ran it (Chromium is not Safari), or a physical device. Screenshots and resized viewports verify layout, never a gesture. Name what stayed untested and move on; unreachable hardware is a reported gap, not a blocker
|
||||
|
||||
When the adaptation feels native to each context, hand off to `$impeccable polish` for the final pass.
|
||||
|
||||
---
|
||||
|
||||
@@ -48,11 +48,12 @@ Run comprehensive checks across 5 dimensions. Score each dimension 0-4 using the
|
||||
**Check for**:
|
||||
- **Fixed widths**: Hard-coded widths that break on mobile
|
||||
- **Touch targets**: Interactive elements < 44x44px
|
||||
- **Broken touch interaction**: Custom sliders, drag surfaces, and scrollable control strips whose primary gesture fails under touch, that swallow page scroll or lose the drag to it, or that stay stuck after an interrupted gesture. Code tells: mouse-only handlers, no `touch-action` on a pointer-event drag surface, drag state that nothing clears on cancel, lost capture, or blur. Exercise the gesture when a browser tool can synthesize touch (a rendered viewport proves layout, not the gesture), then say what produced the evidence (emulated viewport, synthesized touch, which engine, physical device) and what stayed untested
|
||||
- **Horizontal scroll**: Content overflow on narrow viewports
|
||||
- **Text scaling**: Layouts that break when text size increases
|
||||
- **Missing breakpoints**: No mobile/tablet variants
|
||||
|
||||
**Score 0-4**: 0=Desktop-only (breaks on mobile), 1=Major issues (some breakpoints, many failures), 2=Partial (works on mobile, rough edges), 3=Good (responsive, minor touch target or overflow issues), 4=Excellent (fluid, all viewports, proper touch targets)
|
||||
**Score 0-4**: 0=Desktop-only (breaks on mobile), 1=Major issues (some breakpoints, many failures), 2=Partial (works on mobile, rough edges), 3=Good (responsive, minor touch target or overflow issues), 4=Excellent (fluid, all viewports, proper touch targets, gestures work under touch)
|
||||
|
||||
### 5. Implementation Integrity (CRITICAL)
|
||||
|
||||
|
||||
@@ -205,6 +205,11 @@ t('items', { count }) // Handles complex plural rules
|
||||
- Optimistic updates with rollback
|
||||
- Conflict resolution
|
||||
|
||||
**Interrupted gestures** (custom sliders, drag surfaces, scrollable control strips):
|
||||
- A second finger or pointer lands mid-drag: the first drag keeps its pointer or ends cleanly, never jumps to the new one
|
||||
- The browser cancels the gesture to scroll (`pointercancel`), capture is lost (`lostpointercapture`), the pointer is released outside the control, or the window loses focus (`blur`) mid-drag: clear the dragging state and release capture
|
||||
- After each of these, the next tap or drag works without a reload
|
||||
|
||||
**Permission states**:
|
||||
- No permission to view
|
||||
- No permission to edit
|
||||
@@ -304,6 +309,7 @@ const throttledScroll = throttle(handleScroll, 100);
|
||||
- Unit tests for edge cases
|
||||
- Integration tests for error scenarios
|
||||
- E2E tests for critical paths
|
||||
- A behavioral regression for each confirmed gesture fix, when the project's test runner can drive input
|
||||
- Visual regression tests
|
||||
- Accessibility tests (axe, WAVE)
|
||||
|
||||
@@ -330,7 +336,10 @@ Test thoroughly with edge cases:
|
||||
- **Network issues**: Disable internet, throttle connection
|
||||
- **Large datasets**: Test with 1000+ items
|
||||
- **Concurrent actions**: Click submit 10 times rapidly
|
||||
- **Interrupted gestures**: Add a second finger mid-drag, scroll across the control, release outside it, switch windows mid-drag; then drag again
|
||||
- **Errors**: Force API errors, test all error states
|
||||
- **Empty**: Remove all data, test empty states
|
||||
|
||||
For gestures, say what produced the evidence (emulated viewport, synthesized touch, which engine, physical device) and name what stayed untested.
|
||||
|
||||
When edge cases are covered, hand off to `$impeccable polish` for the final pass.
|
||||
|
||||
@@ -188,6 +188,12 @@ Test thoroughly across contexts:
|
||||
- **Edge cases**: Very small screens (320px), very large screens (4K)
|
||||
- **Slow connections**: Test on throttled network
|
||||
|
||||
**Custom controls** (sliders, drag surfaces, scrollable control strips): a before/after slider can pass every width check above and still refuse to drag on iOS, so exercise each one in scope in the same batched round as the checks above:
|
||||
|
||||
- **Primary gesture**: Tap it and confirm it responds as designed, then drag it with the target input method; the drag must complete, not just start
|
||||
- **Scroll across it**: A swipe along the page's scroll axis across the control scrolls the page or container without activating it; a drag that starts on the control along its axis moves the control, not the page. Neither failure throws an error, so try both
|
||||
- **Evidence**: Say what produced the evidence: an emulated viewport, synthesized touch input through a browser tool, which engine ran it (Chromium is not Safari), or a physical device. Screenshots and resized viewports verify layout, never a gesture. Name what stayed untested and move on; unreachable hardware is a reported gap, not a blocker
|
||||
|
||||
When the adaptation feels native to each context, hand off to `/impeccable polish` for the final pass.
|
||||
|
||||
---
|
||||
|
||||
@@ -48,11 +48,12 @@ Run comprehensive checks across 5 dimensions. Score each dimension 0-4 using the
|
||||
**Check for**:
|
||||
- **Fixed widths**: Hard-coded widths that break on mobile
|
||||
- **Touch targets**: Interactive elements < 44x44px
|
||||
- **Broken touch interaction**: Custom sliders, drag surfaces, and scrollable control strips whose primary gesture fails under touch, that swallow page scroll or lose the drag to it, or that stay stuck after an interrupted gesture. Code tells: mouse-only handlers, no `touch-action` on a pointer-event drag surface, drag state that nothing clears on cancel, lost capture, or blur. Exercise the gesture when a browser tool can synthesize touch (a rendered viewport proves layout, not the gesture), then say what produced the evidence (emulated viewport, synthesized touch, which engine, physical device) and what stayed untested
|
||||
- **Horizontal scroll**: Content overflow on narrow viewports
|
||||
- **Text scaling**: Layouts that break when text size increases
|
||||
- **Missing breakpoints**: No mobile/tablet variants
|
||||
|
||||
**Score 0-4**: 0=Desktop-only (breaks on mobile), 1=Major issues (some breakpoints, many failures), 2=Partial (works on mobile, rough edges), 3=Good (responsive, minor touch target or overflow issues), 4=Excellent (fluid, all viewports, proper touch targets)
|
||||
**Score 0-4**: 0=Desktop-only (breaks on mobile), 1=Major issues (some breakpoints, many failures), 2=Partial (works on mobile, rough edges), 3=Good (responsive, minor touch target or overflow issues), 4=Excellent (fluid, all viewports, proper touch targets, gestures work under touch)
|
||||
|
||||
### 5. Implementation Integrity (CRITICAL)
|
||||
|
||||
|
||||
@@ -205,6 +205,11 @@ t('items', { count }) // Handles complex plural rules
|
||||
- Optimistic updates with rollback
|
||||
- Conflict resolution
|
||||
|
||||
**Interrupted gestures** (custom sliders, drag surfaces, scrollable control strips):
|
||||
- A second finger or pointer lands mid-drag: the first drag keeps its pointer or ends cleanly, never jumps to the new one
|
||||
- The browser cancels the gesture to scroll (`pointercancel`), capture is lost (`lostpointercapture`), the pointer is released outside the control, or the window loses focus (`blur`) mid-drag: clear the dragging state and release capture
|
||||
- After each of these, the next tap or drag works without a reload
|
||||
|
||||
**Permission states**:
|
||||
- No permission to view
|
||||
- No permission to edit
|
||||
@@ -304,6 +309,7 @@ const throttledScroll = throttle(handleScroll, 100);
|
||||
- Unit tests for edge cases
|
||||
- Integration tests for error scenarios
|
||||
- E2E tests for critical paths
|
||||
- A behavioral regression for each confirmed gesture fix, when the project's test runner can drive input
|
||||
- Visual regression tests
|
||||
- Accessibility tests (axe, WAVE)
|
||||
|
||||
@@ -330,7 +336,10 @@ Test thoroughly with edge cases:
|
||||
- **Network issues**: Disable internet, throttle connection
|
||||
- **Large datasets**: Test with 1000+ items
|
||||
- **Concurrent actions**: Click submit 10 times rapidly
|
||||
- **Interrupted gestures**: Add a second finger mid-drag, scroll across the control, release outside it, switch windows mid-drag; then drag again
|
||||
- **Errors**: Force API errors, test all error states
|
||||
- **Empty**: Remove all data, test empty states
|
||||
|
||||
For gestures, say what produced the evidence (emulated viewport, synthesized touch, which engine, physical device) and name what stayed untested.
|
||||
|
||||
When edge cases are covered, hand off to `/impeccable polish` for the final pass.
|
||||
|
||||
@@ -188,6 +188,12 @@ Test thoroughly across contexts:
|
||||
- **Edge cases**: Very small screens (320px), very large screens (4K)
|
||||
- **Slow connections**: Test on throttled network
|
||||
|
||||
**Custom controls** (sliders, drag surfaces, scrollable control strips): a before/after slider can pass every width check above and still refuse to drag on iOS, so exercise each one in scope in the same batched round as the checks above:
|
||||
|
||||
- **Primary gesture**: Tap it and confirm it responds as designed, then drag it with the target input method; the drag must complete, not just start
|
||||
- **Scroll across it**: A swipe along the page's scroll axis across the control scrolls the page or container without activating it; a drag that starts on the control along its axis moves the control, not the page. Neither failure throws an error, so try both
|
||||
- **Evidence**: Say what produced the evidence: an emulated viewport, synthesized touch input through a browser tool, which engine ran it (Chromium is not Safari), or a physical device. Screenshots and resized viewports verify layout, never a gesture. Name what stayed untested and move on; unreachable hardware is a reported gap, not a blocker
|
||||
|
||||
When the adaptation feels native to each context, hand off to `/impeccable polish` for the final pass.
|
||||
|
||||
---
|
||||
|
||||
@@ -48,11 +48,12 @@ Run comprehensive checks across 5 dimensions. Score each dimension 0-4 using the
|
||||
**Check for**:
|
||||
- **Fixed widths**: Hard-coded widths that break on mobile
|
||||
- **Touch targets**: Interactive elements < 44x44px
|
||||
- **Broken touch interaction**: Custom sliders, drag surfaces, and scrollable control strips whose primary gesture fails under touch, that swallow page scroll or lose the drag to it, or that stay stuck after an interrupted gesture. Code tells: mouse-only handlers, no `touch-action` on a pointer-event drag surface, drag state that nothing clears on cancel, lost capture, or blur. Exercise the gesture when a browser tool can synthesize touch (a rendered viewport proves layout, not the gesture), then say what produced the evidence (emulated viewport, synthesized touch, which engine, physical device) and what stayed untested
|
||||
- **Horizontal scroll**: Content overflow on narrow viewports
|
||||
- **Text scaling**: Layouts that break when text size increases
|
||||
- **Missing breakpoints**: No mobile/tablet variants
|
||||
|
||||
**Score 0-4**: 0=Desktop-only (breaks on mobile), 1=Major issues (some breakpoints, many failures), 2=Partial (works on mobile, rough edges), 3=Good (responsive, minor touch target or overflow issues), 4=Excellent (fluid, all viewports, proper touch targets)
|
||||
**Score 0-4**: 0=Desktop-only (breaks on mobile), 1=Major issues (some breakpoints, many failures), 2=Partial (works on mobile, rough edges), 3=Good (responsive, minor touch target or overflow issues), 4=Excellent (fluid, all viewports, proper touch targets, gestures work under touch)
|
||||
|
||||
### 5. Implementation Integrity (CRITICAL)
|
||||
|
||||
|
||||
@@ -205,6 +205,11 @@ t('items', { count }) // Handles complex plural rules
|
||||
- Optimistic updates with rollback
|
||||
- Conflict resolution
|
||||
|
||||
**Interrupted gestures** (custom sliders, drag surfaces, scrollable control strips):
|
||||
- A second finger or pointer lands mid-drag: the first drag keeps its pointer or ends cleanly, never jumps to the new one
|
||||
- The browser cancels the gesture to scroll (`pointercancel`), capture is lost (`lostpointercapture`), the pointer is released outside the control, or the window loses focus (`blur`) mid-drag: clear the dragging state and release capture
|
||||
- After each of these, the next tap or drag works without a reload
|
||||
|
||||
**Permission states**:
|
||||
- No permission to view
|
||||
- No permission to edit
|
||||
@@ -304,6 +309,7 @@ const throttledScroll = throttle(handleScroll, 100);
|
||||
- Unit tests for edge cases
|
||||
- Integration tests for error scenarios
|
||||
- E2E tests for critical paths
|
||||
- A behavioral regression for each confirmed gesture fix, when the project's test runner can drive input
|
||||
- Visual regression tests
|
||||
- Accessibility tests (axe, WAVE)
|
||||
|
||||
@@ -330,7 +336,10 @@ Test thoroughly with edge cases:
|
||||
- **Network issues**: Disable internet, throttle connection
|
||||
- **Large datasets**: Test with 1000+ items
|
||||
- **Concurrent actions**: Click submit 10 times rapidly
|
||||
- **Interrupted gestures**: Add a second finger mid-drag, scroll across the control, release outside it, switch windows mid-drag; then drag again
|
||||
- **Errors**: Force API errors, test all error states
|
||||
- **Empty**: Remove all data, test empty states
|
||||
|
||||
For gestures, say what produced the evidence (emulated viewport, synthesized touch, which engine, physical device) and name what stayed untested.
|
||||
|
||||
When edge cases are covered, hand off to `/impeccable polish` for the final pass.
|
||||
|
||||
@@ -188,6 +188,12 @@ Test thoroughly across contexts:
|
||||
- **Edge cases**: Very small screens (320px), very large screens (4K)
|
||||
- **Slow connections**: Test on throttled network
|
||||
|
||||
**Custom controls** (sliders, drag surfaces, scrollable control strips): a before/after slider can pass every width check above and still refuse to drag on iOS, so exercise each one in scope in the same batched round as the checks above:
|
||||
|
||||
- **Primary gesture**: Tap it and confirm it responds as designed, then drag it with the target input method; the drag must complete, not just start
|
||||
- **Scroll across it**: A swipe along the page's scroll axis across the control scrolls the page or container without activating it; a drag that starts on the control along its axis moves the control, not the page. Neither failure throws an error, so try both
|
||||
- **Evidence**: Say what produced the evidence: an emulated viewport, synthesized touch input through a browser tool, which engine ran it (Chromium is not Safari), or a physical device. Screenshots and resized viewports verify layout, never a gesture. Name what stayed untested and move on; unreachable hardware is a reported gap, not a blocker
|
||||
|
||||
When the adaptation feels native to each context, hand off to `/impeccable polish` for the final pass.
|
||||
|
||||
---
|
||||
|
||||
@@ -48,11 +48,12 @@ Run comprehensive checks across 5 dimensions. Score each dimension 0-4 using the
|
||||
**Check for**:
|
||||
- **Fixed widths**: Hard-coded widths that break on mobile
|
||||
- **Touch targets**: Interactive elements < 44x44px
|
||||
- **Broken touch interaction**: Custom sliders, drag surfaces, and scrollable control strips whose primary gesture fails under touch, that swallow page scroll or lose the drag to it, or that stay stuck after an interrupted gesture. Code tells: mouse-only handlers, no `touch-action` on a pointer-event drag surface, drag state that nothing clears on cancel, lost capture, or blur. Exercise the gesture when a browser tool can synthesize touch (a rendered viewport proves layout, not the gesture), then say what produced the evidence (emulated viewport, synthesized touch, which engine, physical device) and what stayed untested
|
||||
- **Horizontal scroll**: Content overflow on narrow viewports
|
||||
- **Text scaling**: Layouts that break when text size increases
|
||||
- **Missing breakpoints**: No mobile/tablet variants
|
||||
|
||||
**Score 0-4**: 0=Desktop-only (breaks on mobile), 1=Major issues (some breakpoints, many failures), 2=Partial (works on mobile, rough edges), 3=Good (responsive, minor touch target or overflow issues), 4=Excellent (fluid, all viewports, proper touch targets)
|
||||
**Score 0-4**: 0=Desktop-only (breaks on mobile), 1=Major issues (some breakpoints, many failures), 2=Partial (works on mobile, rough edges), 3=Good (responsive, minor touch target or overflow issues), 4=Excellent (fluid, all viewports, proper touch targets, gestures work under touch)
|
||||
|
||||
### 5. Implementation Integrity (CRITICAL)
|
||||
|
||||
|
||||
@@ -205,6 +205,11 @@ t('items', { count }) // Handles complex plural rules
|
||||
- Optimistic updates with rollback
|
||||
- Conflict resolution
|
||||
|
||||
**Interrupted gestures** (custom sliders, drag surfaces, scrollable control strips):
|
||||
- A second finger or pointer lands mid-drag: the first drag keeps its pointer or ends cleanly, never jumps to the new one
|
||||
- The browser cancels the gesture to scroll (`pointercancel`), capture is lost (`lostpointercapture`), the pointer is released outside the control, or the window loses focus (`blur`) mid-drag: clear the dragging state and release capture
|
||||
- After each of these, the next tap or drag works without a reload
|
||||
|
||||
**Permission states**:
|
||||
- No permission to view
|
||||
- No permission to edit
|
||||
@@ -304,6 +309,7 @@ const throttledScroll = throttle(handleScroll, 100);
|
||||
- Unit tests for edge cases
|
||||
- Integration tests for error scenarios
|
||||
- E2E tests for critical paths
|
||||
- A behavioral regression for each confirmed gesture fix, when the project's test runner can drive input
|
||||
- Visual regression tests
|
||||
- Accessibility tests (axe, WAVE)
|
||||
|
||||
@@ -330,7 +336,10 @@ Test thoroughly with edge cases:
|
||||
- **Network issues**: Disable internet, throttle connection
|
||||
- **Large datasets**: Test with 1000+ items
|
||||
- **Concurrent actions**: Click submit 10 times rapidly
|
||||
- **Interrupted gestures**: Add a second finger mid-drag, scroll across the control, release outside it, switch windows mid-drag; then drag again
|
||||
- **Errors**: Force API errors, test all error states
|
||||
- **Empty**: Remove all data, test empty states
|
||||
|
||||
For gestures, say what produced the evidence (emulated viewport, synthesized touch, which engine, physical device) and name what stayed untested.
|
||||
|
||||
When edge cases are covered, hand off to `/impeccable polish` for the final pass.
|
||||
|
||||
@@ -188,6 +188,12 @@ Test thoroughly across contexts:
|
||||
- **Edge cases**: Very small screens (320px), very large screens (4K)
|
||||
- **Slow connections**: Test on throttled network
|
||||
|
||||
**Custom controls** (sliders, drag surfaces, scrollable control strips): a before/after slider can pass every width check above and still refuse to drag on iOS, so exercise each one in scope in the same batched round as the checks above:
|
||||
|
||||
- **Primary gesture**: Tap it and confirm it responds as designed, then drag it with the target input method; the drag must complete, not just start
|
||||
- **Scroll across it**: A swipe along the page's scroll axis across the control scrolls the page or container without activating it; a drag that starts on the control along its axis moves the control, not the page. Neither failure throws an error, so try both
|
||||
- **Evidence**: Say what produced the evidence: an emulated viewport, synthesized touch input through a browser tool, which engine ran it (Chromium is not Safari), or a physical device. Screenshots and resized viewports verify layout, never a gesture. Name what stayed untested and move on; unreachable hardware is a reported gap, not a blocker
|
||||
|
||||
When the adaptation feels native to each context, hand off to `/impeccable polish` for the final pass.
|
||||
|
||||
---
|
||||
|
||||
@@ -48,11 +48,12 @@ Run comprehensive checks across 5 dimensions. Score each dimension 0-4 using the
|
||||
**Check for**:
|
||||
- **Fixed widths**: Hard-coded widths that break on mobile
|
||||
- **Touch targets**: Interactive elements < 44x44px
|
||||
- **Broken touch interaction**: Custom sliders, drag surfaces, and scrollable control strips whose primary gesture fails under touch, that swallow page scroll or lose the drag to it, or that stay stuck after an interrupted gesture. Code tells: mouse-only handlers, no `touch-action` on a pointer-event drag surface, drag state that nothing clears on cancel, lost capture, or blur. Exercise the gesture when a browser tool can synthesize touch (a rendered viewport proves layout, not the gesture), then say what produced the evidence (emulated viewport, synthesized touch, which engine, physical device) and what stayed untested
|
||||
- **Horizontal scroll**: Content overflow on narrow viewports
|
||||
- **Text scaling**: Layouts that break when text size increases
|
||||
- **Missing breakpoints**: No mobile/tablet variants
|
||||
|
||||
**Score 0-4**: 0=Desktop-only (breaks on mobile), 1=Major issues (some breakpoints, many failures), 2=Partial (works on mobile, rough edges), 3=Good (responsive, minor touch target or overflow issues), 4=Excellent (fluid, all viewports, proper touch targets)
|
||||
**Score 0-4**: 0=Desktop-only (breaks on mobile), 1=Major issues (some breakpoints, many failures), 2=Partial (works on mobile, rough edges), 3=Good (responsive, minor touch target or overflow issues), 4=Excellent (fluid, all viewports, proper touch targets, gestures work under touch)
|
||||
|
||||
### 5. Implementation Integrity (CRITICAL)
|
||||
|
||||
|
||||
@@ -205,6 +205,11 @@ t('items', { count }) // Handles complex plural rules
|
||||
- Optimistic updates with rollback
|
||||
- Conflict resolution
|
||||
|
||||
**Interrupted gestures** (custom sliders, drag surfaces, scrollable control strips):
|
||||
- A second finger or pointer lands mid-drag: the first drag keeps its pointer or ends cleanly, never jumps to the new one
|
||||
- The browser cancels the gesture to scroll (`pointercancel`), capture is lost (`lostpointercapture`), the pointer is released outside the control, or the window loses focus (`blur`) mid-drag: clear the dragging state and release capture
|
||||
- After each of these, the next tap or drag works without a reload
|
||||
|
||||
**Permission states**:
|
||||
- No permission to view
|
||||
- No permission to edit
|
||||
@@ -304,6 +309,7 @@ const throttledScroll = throttle(handleScroll, 100);
|
||||
- Unit tests for edge cases
|
||||
- Integration tests for error scenarios
|
||||
- E2E tests for critical paths
|
||||
- A behavioral regression for each confirmed gesture fix, when the project's test runner can drive input
|
||||
- Visual regression tests
|
||||
- Accessibility tests (axe, WAVE)
|
||||
|
||||
@@ -330,7 +336,10 @@ Test thoroughly with edge cases:
|
||||
- **Network issues**: Disable internet, throttle connection
|
||||
- **Large datasets**: Test with 1000+ items
|
||||
- **Concurrent actions**: Click submit 10 times rapidly
|
||||
- **Interrupted gestures**: Add a second finger mid-drag, scroll across the control, release outside it, switch windows mid-drag; then drag again
|
||||
- **Errors**: Force API errors, test all error states
|
||||
- **Empty**: Remove all data, test empty states
|
||||
|
||||
For gestures, say what produced the evidence (emulated viewport, synthesized touch, which engine, physical device) and name what stayed untested.
|
||||
|
||||
When edge cases are covered, hand off to `/impeccable polish` for the final pass.
|
||||
|
||||
@@ -188,6 +188,12 @@ Test thoroughly across contexts:
|
||||
- **Edge cases**: Very small screens (320px), very large screens (4K)
|
||||
- **Slow connections**: Test on throttled network
|
||||
|
||||
**Custom controls** (sliders, drag surfaces, scrollable control strips): a before/after slider can pass every width check above and still refuse to drag on iOS, so exercise each one in scope in the same batched round as the checks above:
|
||||
|
||||
- **Primary gesture**: Tap it and confirm it responds as designed, then drag it with the target input method; the drag must complete, not just start
|
||||
- **Scroll across it**: A swipe along the page's scroll axis across the control scrolls the page or container without activating it; a drag that starts on the control along its axis moves the control, not the page. Neither failure throws an error, so try both
|
||||
- **Evidence**: Say what produced the evidence: an emulated viewport, synthesized touch input through a browser tool, which engine ran it (Chromium is not Safari), or a physical device. Screenshots and resized viewports verify layout, never a gesture. Name what stayed untested and move on; unreachable hardware is a reported gap, not a blocker
|
||||
|
||||
When the adaptation feels native to each context, hand off to `/impeccable polish` for the final pass.
|
||||
|
||||
---
|
||||
|
||||
@@ -48,11 +48,12 @@ Run comprehensive checks across 5 dimensions. Score each dimension 0-4 using the
|
||||
**Check for**:
|
||||
- **Fixed widths**: Hard-coded widths that break on mobile
|
||||
- **Touch targets**: Interactive elements < 44x44px
|
||||
- **Broken touch interaction**: Custom sliders, drag surfaces, and scrollable control strips whose primary gesture fails under touch, that swallow page scroll or lose the drag to it, or that stay stuck after an interrupted gesture. Code tells: mouse-only handlers, no `touch-action` on a pointer-event drag surface, drag state that nothing clears on cancel, lost capture, or blur. Exercise the gesture when a browser tool can synthesize touch (a rendered viewport proves layout, not the gesture), then say what produced the evidence (emulated viewport, synthesized touch, which engine, physical device) and what stayed untested
|
||||
- **Horizontal scroll**: Content overflow on narrow viewports
|
||||
- **Text scaling**: Layouts that break when text size increases
|
||||
- **Missing breakpoints**: No mobile/tablet variants
|
||||
|
||||
**Score 0-4**: 0=Desktop-only (breaks on mobile), 1=Major issues (some breakpoints, many failures), 2=Partial (works on mobile, rough edges), 3=Good (responsive, minor touch target or overflow issues), 4=Excellent (fluid, all viewports, proper touch targets)
|
||||
**Score 0-4**: 0=Desktop-only (breaks on mobile), 1=Major issues (some breakpoints, many failures), 2=Partial (works on mobile, rough edges), 3=Good (responsive, minor touch target or overflow issues), 4=Excellent (fluid, all viewports, proper touch targets, gestures work under touch)
|
||||
|
||||
### 5. Implementation Integrity (CRITICAL)
|
||||
|
||||
|
||||
@@ -205,6 +205,11 @@ t('items', { count }) // Handles complex plural rules
|
||||
- Optimistic updates with rollback
|
||||
- Conflict resolution
|
||||
|
||||
**Interrupted gestures** (custom sliders, drag surfaces, scrollable control strips):
|
||||
- A second finger or pointer lands mid-drag: the first drag keeps its pointer or ends cleanly, never jumps to the new one
|
||||
- The browser cancels the gesture to scroll (`pointercancel`), capture is lost (`lostpointercapture`), the pointer is released outside the control, or the window loses focus (`blur`) mid-drag: clear the dragging state and release capture
|
||||
- After each of these, the next tap or drag works without a reload
|
||||
|
||||
**Permission states**:
|
||||
- No permission to view
|
||||
- No permission to edit
|
||||
@@ -304,6 +309,7 @@ const throttledScroll = throttle(handleScroll, 100);
|
||||
- Unit tests for edge cases
|
||||
- Integration tests for error scenarios
|
||||
- E2E tests for critical paths
|
||||
- A behavioral regression for each confirmed gesture fix, when the project's test runner can drive input
|
||||
- Visual regression tests
|
||||
- Accessibility tests (axe, WAVE)
|
||||
|
||||
@@ -330,7 +336,10 @@ Test thoroughly with edge cases:
|
||||
- **Network issues**: Disable internet, throttle connection
|
||||
- **Large datasets**: Test with 1000+ items
|
||||
- **Concurrent actions**: Click submit 10 times rapidly
|
||||
- **Interrupted gestures**: Add a second finger mid-drag, scroll across the control, release outside it, switch windows mid-drag; then drag again
|
||||
- **Errors**: Force API errors, test all error states
|
||||
- **Empty**: Remove all data, test empty states
|
||||
|
||||
For gestures, say what produced the evidence (emulated viewport, synthesized touch, which engine, physical device) and name what stayed untested.
|
||||
|
||||
When edge cases are covered, hand off to `/impeccable polish` for the final pass.
|
||||
|
||||
@@ -188,6 +188,12 @@ Test thoroughly across contexts:
|
||||
- **Edge cases**: Very small screens (320px), very large screens (4K)
|
||||
- **Slow connections**: Test on throttled network
|
||||
|
||||
**Custom controls** (sliders, drag surfaces, scrollable control strips): a before/after slider can pass every width check above and still refuse to drag on iOS, so exercise each one in scope in the same batched round as the checks above:
|
||||
|
||||
- **Primary gesture**: Tap it and confirm it responds as designed, then drag it with the target input method; the drag must complete, not just start
|
||||
- **Scroll across it**: A swipe along the page's scroll axis across the control scrolls the page or container without activating it; a drag that starts on the control along its axis moves the control, not the page. Neither failure throws an error, so try both
|
||||
- **Evidence**: Say what produced the evidence: an emulated viewport, synthesized touch input through a browser tool, which engine ran it (Chromium is not Safari), or a physical device. Screenshots and resized viewports verify layout, never a gesture. Name what stayed untested and move on; unreachable hardware is a reported gap, not a blocker
|
||||
|
||||
When the adaptation feels native to each context, hand off to `/impeccable polish` for the final pass.
|
||||
|
||||
---
|
||||
|
||||
@@ -48,11 +48,12 @@ Run comprehensive checks across 5 dimensions. Score each dimension 0-4 using the
|
||||
**Check for**:
|
||||
- **Fixed widths**: Hard-coded widths that break on mobile
|
||||
- **Touch targets**: Interactive elements < 44x44px
|
||||
- **Broken touch interaction**: Custom sliders, drag surfaces, and scrollable control strips whose primary gesture fails under touch, that swallow page scroll or lose the drag to it, or that stay stuck after an interrupted gesture. Code tells: mouse-only handlers, no `touch-action` on a pointer-event drag surface, drag state that nothing clears on cancel, lost capture, or blur. Exercise the gesture when a browser tool can synthesize touch (a rendered viewport proves layout, not the gesture), then say what produced the evidence (emulated viewport, synthesized touch, which engine, physical device) and what stayed untested
|
||||
- **Horizontal scroll**: Content overflow on narrow viewports
|
||||
- **Text scaling**: Layouts that break when text size increases
|
||||
- **Missing breakpoints**: No mobile/tablet variants
|
||||
|
||||
**Score 0-4**: 0=Desktop-only (breaks on mobile), 1=Major issues (some breakpoints, many failures), 2=Partial (works on mobile, rough edges), 3=Good (responsive, minor touch target or overflow issues), 4=Excellent (fluid, all viewports, proper touch targets)
|
||||
**Score 0-4**: 0=Desktop-only (breaks on mobile), 1=Major issues (some breakpoints, many failures), 2=Partial (works on mobile, rough edges), 3=Good (responsive, minor touch target or overflow issues), 4=Excellent (fluid, all viewports, proper touch targets, gestures work under touch)
|
||||
|
||||
### 5. Implementation Integrity (CRITICAL)
|
||||
|
||||
|
||||
@@ -205,6 +205,11 @@ t('items', { count }) // Handles complex plural rules
|
||||
- Optimistic updates with rollback
|
||||
- Conflict resolution
|
||||
|
||||
**Interrupted gestures** (custom sliders, drag surfaces, scrollable control strips):
|
||||
- A second finger or pointer lands mid-drag: the first drag keeps its pointer or ends cleanly, never jumps to the new one
|
||||
- The browser cancels the gesture to scroll (`pointercancel`), capture is lost (`lostpointercapture`), the pointer is released outside the control, or the window loses focus (`blur`) mid-drag: clear the dragging state and release capture
|
||||
- After each of these, the next tap or drag works without a reload
|
||||
|
||||
**Permission states**:
|
||||
- No permission to view
|
||||
- No permission to edit
|
||||
@@ -304,6 +309,7 @@ const throttledScroll = throttle(handleScroll, 100);
|
||||
- Unit tests for edge cases
|
||||
- Integration tests for error scenarios
|
||||
- E2E tests for critical paths
|
||||
- A behavioral regression for each confirmed gesture fix, when the project's test runner can drive input
|
||||
- Visual regression tests
|
||||
- Accessibility tests (axe, WAVE)
|
||||
|
||||
@@ -330,7 +336,10 @@ Test thoroughly with edge cases:
|
||||
- **Network issues**: Disable internet, throttle connection
|
||||
- **Large datasets**: Test with 1000+ items
|
||||
- **Concurrent actions**: Click submit 10 times rapidly
|
||||
- **Interrupted gestures**: Add a second finger mid-drag, scroll across the control, release outside it, switch windows mid-drag; then drag again
|
||||
- **Errors**: Force API errors, test all error states
|
||||
- **Empty**: Remove all data, test empty states
|
||||
|
||||
For gestures, say what produced the evidence (emulated viewport, synthesized touch, which engine, physical device) and name what stayed untested.
|
||||
|
||||
When edge cases are covered, hand off to `/impeccable polish` for the final pass.
|
||||
|
||||
@@ -188,6 +188,12 @@ Test thoroughly across contexts:
|
||||
- **Edge cases**: Very small screens (320px), very large screens (4K)
|
||||
- **Slow connections**: Test on throttled network
|
||||
|
||||
**Custom controls** (sliders, drag surfaces, scrollable control strips): a before/after slider can pass every width check above and still refuse to drag on iOS, so exercise each one in scope in the same batched round as the checks above:
|
||||
|
||||
- **Primary gesture**: Tap it and confirm it responds as designed, then drag it with the target input method; the drag must complete, not just start
|
||||
- **Scroll across it**: A swipe along the page's scroll axis across the control scrolls the page or container without activating it; a drag that starts on the control along its axis moves the control, not the page. Neither failure throws an error, so try both
|
||||
- **Evidence**: Say what produced the evidence: an emulated viewport, synthesized touch input through a browser tool, which engine ran it (Chromium is not Safari), or a physical device. Screenshots and resized viewports verify layout, never a gesture. Name what stayed untested and move on; unreachable hardware is a reported gap, not a blocker
|
||||
|
||||
When the adaptation feels native to each context, hand off to `/impeccable polish` for the final pass.
|
||||
|
||||
---
|
||||
|
||||
@@ -48,11 +48,12 @@ Run comprehensive checks across 5 dimensions. Score each dimension 0-4 using the
|
||||
**Check for**:
|
||||
- **Fixed widths**: Hard-coded widths that break on mobile
|
||||
- **Touch targets**: Interactive elements < 44x44px
|
||||
- **Broken touch interaction**: Custom sliders, drag surfaces, and scrollable control strips whose primary gesture fails under touch, that swallow page scroll or lose the drag to it, or that stay stuck after an interrupted gesture. Code tells: mouse-only handlers, no `touch-action` on a pointer-event drag surface, drag state that nothing clears on cancel, lost capture, or blur. Exercise the gesture when a browser tool can synthesize touch (a rendered viewport proves layout, not the gesture), then say what produced the evidence (emulated viewport, synthesized touch, which engine, physical device) and what stayed untested
|
||||
- **Horizontal scroll**: Content overflow on narrow viewports
|
||||
- **Text scaling**: Layouts that break when text size increases
|
||||
- **Missing breakpoints**: No mobile/tablet variants
|
||||
|
||||
**Score 0-4**: 0=Desktop-only (breaks on mobile), 1=Major issues (some breakpoints, many failures), 2=Partial (works on mobile, rough edges), 3=Good (responsive, minor touch target or overflow issues), 4=Excellent (fluid, all viewports, proper touch targets)
|
||||
**Score 0-4**: 0=Desktop-only (breaks on mobile), 1=Major issues (some breakpoints, many failures), 2=Partial (works on mobile, rough edges), 3=Good (responsive, minor touch target or overflow issues), 4=Excellent (fluid, all viewports, proper touch targets, gestures work under touch)
|
||||
|
||||
### 5. Implementation Integrity (CRITICAL)
|
||||
|
||||
|
||||
@@ -205,6 +205,11 @@ t('items', { count }) // Handles complex plural rules
|
||||
- Optimistic updates with rollback
|
||||
- Conflict resolution
|
||||
|
||||
**Interrupted gestures** (custom sliders, drag surfaces, scrollable control strips):
|
||||
- A second finger or pointer lands mid-drag: the first drag keeps its pointer or ends cleanly, never jumps to the new one
|
||||
- The browser cancels the gesture to scroll (`pointercancel`), capture is lost (`lostpointercapture`), the pointer is released outside the control, or the window loses focus (`blur`) mid-drag: clear the dragging state and release capture
|
||||
- After each of these, the next tap or drag works without a reload
|
||||
|
||||
**Permission states**:
|
||||
- No permission to view
|
||||
- No permission to edit
|
||||
@@ -304,6 +309,7 @@ const throttledScroll = throttle(handleScroll, 100);
|
||||
- Unit tests for edge cases
|
||||
- Integration tests for error scenarios
|
||||
- E2E tests for critical paths
|
||||
- A behavioral regression for each confirmed gesture fix, when the project's test runner can drive input
|
||||
- Visual regression tests
|
||||
- Accessibility tests (axe, WAVE)
|
||||
|
||||
@@ -330,7 +336,10 @@ Test thoroughly with edge cases:
|
||||
- **Network issues**: Disable internet, throttle connection
|
||||
- **Large datasets**: Test with 1000+ items
|
||||
- **Concurrent actions**: Click submit 10 times rapidly
|
||||
- **Interrupted gestures**: Add a second finger mid-drag, scroll across the control, release outside it, switch windows mid-drag; then drag again
|
||||
- **Errors**: Force API errors, test all error states
|
||||
- **Empty**: Remove all data, test empty states
|
||||
|
||||
For gestures, say what produced the evidence (emulated viewport, synthesized touch, which engine, physical device) and name what stayed untested.
|
||||
|
||||
When edge cases are covered, hand off to `/impeccable polish` for the final pass.
|
||||
|
||||
@@ -188,6 +188,12 @@ Test thoroughly across contexts:
|
||||
- **Edge cases**: Very small screens (320px), very large screens (4K)
|
||||
- **Slow connections**: Test on throttled network
|
||||
|
||||
**Custom controls** (sliders, drag surfaces, scrollable control strips): a before/after slider can pass every width check above and still refuse to drag on iOS, so exercise each one in scope in the same batched round as the checks above:
|
||||
|
||||
- **Primary gesture**: Tap it and confirm it responds as designed, then drag it with the target input method; the drag must complete, not just start
|
||||
- **Scroll across it**: A swipe along the page's scroll axis across the control scrolls the page or container without activating it; a drag that starts on the control along its axis moves the control, not the page. Neither failure throws an error, so try both
|
||||
- **Evidence**: Say what produced the evidence: an emulated viewport, synthesized touch input through a browser tool, which engine ran it (Chromium is not Safari), or a physical device. Screenshots and resized viewports verify layout, never a gesture. Name what stayed untested and move on; unreachable hardware is a reported gap, not a blocker
|
||||
|
||||
When the adaptation feels native to each context, hand off to `/impeccable polish` for the final pass.
|
||||
|
||||
---
|
||||
|
||||
@@ -48,11 +48,12 @@ Run comprehensive checks across 5 dimensions. Score each dimension 0-4 using the
|
||||
**Check for**:
|
||||
- **Fixed widths**: Hard-coded widths that break on mobile
|
||||
- **Touch targets**: Interactive elements < 44x44px
|
||||
- **Broken touch interaction**: Custom sliders, drag surfaces, and scrollable control strips whose primary gesture fails under touch, that swallow page scroll or lose the drag to it, or that stay stuck after an interrupted gesture. Code tells: mouse-only handlers, no `touch-action` on a pointer-event drag surface, drag state that nothing clears on cancel, lost capture, or blur. Exercise the gesture when a browser tool can synthesize touch (a rendered viewport proves layout, not the gesture), then say what produced the evidence (emulated viewport, synthesized touch, which engine, physical device) and what stayed untested
|
||||
- **Horizontal scroll**: Content overflow on narrow viewports
|
||||
- **Text scaling**: Layouts that break when text size increases
|
||||
- **Missing breakpoints**: No mobile/tablet variants
|
||||
|
||||
**Score 0-4**: 0=Desktop-only (breaks on mobile), 1=Major issues (some breakpoints, many failures), 2=Partial (works on mobile, rough edges), 3=Good (responsive, minor touch target or overflow issues), 4=Excellent (fluid, all viewports, proper touch targets)
|
||||
**Score 0-4**: 0=Desktop-only (breaks on mobile), 1=Major issues (some breakpoints, many failures), 2=Partial (works on mobile, rough edges), 3=Good (responsive, minor touch target or overflow issues), 4=Excellent (fluid, all viewports, proper touch targets, gestures work under touch)
|
||||
|
||||
### 5. Implementation Integrity (CRITICAL)
|
||||
|
||||
|
||||
@@ -205,6 +205,11 @@ t('items', { count }) // Handles complex plural rules
|
||||
- Optimistic updates with rollback
|
||||
- Conflict resolution
|
||||
|
||||
**Interrupted gestures** (custom sliders, drag surfaces, scrollable control strips):
|
||||
- A second finger or pointer lands mid-drag: the first drag keeps its pointer or ends cleanly, never jumps to the new one
|
||||
- The browser cancels the gesture to scroll (`pointercancel`), capture is lost (`lostpointercapture`), the pointer is released outside the control, or the window loses focus (`blur`) mid-drag: clear the dragging state and release capture
|
||||
- After each of these, the next tap or drag works without a reload
|
||||
|
||||
**Permission states**:
|
||||
- No permission to view
|
||||
- No permission to edit
|
||||
@@ -304,6 +309,7 @@ const throttledScroll = throttle(handleScroll, 100);
|
||||
- Unit tests for edge cases
|
||||
- Integration tests for error scenarios
|
||||
- E2E tests for critical paths
|
||||
- A behavioral regression for each confirmed gesture fix, when the project's test runner can drive input
|
||||
- Visual regression tests
|
||||
- Accessibility tests (axe, WAVE)
|
||||
|
||||
@@ -330,7 +336,10 @@ Test thoroughly with edge cases:
|
||||
- **Network issues**: Disable internet, throttle connection
|
||||
- **Large datasets**: Test with 1000+ items
|
||||
- **Concurrent actions**: Click submit 10 times rapidly
|
||||
- **Interrupted gestures**: Add a second finger mid-drag, scroll across the control, release outside it, switch windows mid-drag; then drag again
|
||||
- **Errors**: Force API errors, test all error states
|
||||
- **Empty**: Remove all data, test empty states
|
||||
|
||||
For gestures, say what produced the evidence (emulated viewport, synthesized touch, which engine, physical device) and name what stayed untested.
|
||||
|
||||
When edge cases are covered, hand off to `/impeccable polish` for the final pass.
|
||||
|
||||
@@ -188,6 +188,12 @@ Test thoroughly across contexts:
|
||||
- **Edge cases**: Very small screens (320px), very large screens (4K)
|
||||
- **Slow connections**: Test on throttled network
|
||||
|
||||
**Custom controls** (sliders, drag surfaces, scrollable control strips): a before/after slider can pass every width check above and still refuse to drag on iOS, so exercise each one in scope in the same batched round as the checks above:
|
||||
|
||||
- **Primary gesture**: Tap it and confirm it responds as designed, then drag it with the target input method; the drag must complete, not just start
|
||||
- **Scroll across it**: A swipe along the page's scroll axis across the control scrolls the page or container without activating it; a drag that starts on the control along its axis moves the control, not the page. Neither failure throws an error, so try both
|
||||
- **Evidence**: Say what produced the evidence: an emulated viewport, synthesized touch input through a browser tool, which engine ran it (Chromium is not Safari), or a physical device. Screenshots and resized viewports verify layout, never a gesture. Name what stayed untested and move on; unreachable hardware is a reported gap, not a blocker
|
||||
|
||||
When the adaptation feels native to each context, hand off to `/impeccable polish` for the final pass.
|
||||
|
||||
---
|
||||
|
||||
@@ -48,11 +48,12 @@ Run comprehensive checks across 5 dimensions. Score each dimension 0-4 using the
|
||||
**Check for**:
|
||||
- **Fixed widths**: Hard-coded widths that break on mobile
|
||||
- **Touch targets**: Interactive elements < 44x44px
|
||||
- **Broken touch interaction**: Custom sliders, drag surfaces, and scrollable control strips whose primary gesture fails under touch, that swallow page scroll or lose the drag to it, or that stay stuck after an interrupted gesture. Code tells: mouse-only handlers, no `touch-action` on a pointer-event drag surface, drag state that nothing clears on cancel, lost capture, or blur. Exercise the gesture when a browser tool can synthesize touch (a rendered viewport proves layout, not the gesture), then say what produced the evidence (emulated viewport, synthesized touch, which engine, physical device) and what stayed untested
|
||||
- **Horizontal scroll**: Content overflow on narrow viewports
|
||||
- **Text scaling**: Layouts that break when text size increases
|
||||
- **Missing breakpoints**: No mobile/tablet variants
|
||||
|
||||
**Score 0-4**: 0=Desktop-only (breaks on mobile), 1=Major issues (some breakpoints, many failures), 2=Partial (works on mobile, rough edges), 3=Good (responsive, minor touch target or overflow issues), 4=Excellent (fluid, all viewports, proper touch targets)
|
||||
**Score 0-4**: 0=Desktop-only (breaks on mobile), 1=Major issues (some breakpoints, many failures), 2=Partial (works on mobile, rough edges), 3=Good (responsive, minor touch target or overflow issues), 4=Excellent (fluid, all viewports, proper touch targets, gestures work under touch)
|
||||
|
||||
### 5. Implementation Integrity (CRITICAL)
|
||||
|
||||
|
||||
@@ -205,6 +205,11 @@ t('items', { count }) // Handles complex plural rules
|
||||
- Optimistic updates with rollback
|
||||
- Conflict resolution
|
||||
|
||||
**Interrupted gestures** (custom sliders, drag surfaces, scrollable control strips):
|
||||
- A second finger or pointer lands mid-drag: the first drag keeps its pointer or ends cleanly, never jumps to the new one
|
||||
- The browser cancels the gesture to scroll (`pointercancel`), capture is lost (`lostpointercapture`), the pointer is released outside the control, or the window loses focus (`blur`) mid-drag: clear the dragging state and release capture
|
||||
- After each of these, the next tap or drag works without a reload
|
||||
|
||||
**Permission states**:
|
||||
- No permission to view
|
||||
- No permission to edit
|
||||
@@ -304,6 +309,7 @@ const throttledScroll = throttle(handleScroll, 100);
|
||||
- Unit tests for edge cases
|
||||
- Integration tests for error scenarios
|
||||
- E2E tests for critical paths
|
||||
- A behavioral regression for each confirmed gesture fix, when the project's test runner can drive input
|
||||
- Visual regression tests
|
||||
- Accessibility tests (axe, WAVE)
|
||||
|
||||
@@ -330,7 +336,10 @@ Test thoroughly with edge cases:
|
||||
- **Network issues**: Disable internet, throttle connection
|
||||
- **Large datasets**: Test with 1000+ items
|
||||
- **Concurrent actions**: Click submit 10 times rapidly
|
||||
- **Interrupted gestures**: Add a second finger mid-drag, scroll across the control, release outside it, switch windows mid-drag; then drag again
|
||||
- **Errors**: Force API errors, test all error states
|
||||
- **Empty**: Remove all data, test empty states
|
||||
|
||||
For gestures, say what produced the evidence (emulated viewport, synthesized touch, which engine, physical device) and name what stayed untested.
|
||||
|
||||
When edge cases are covered, hand off to `/impeccable polish` for the final pass.
|
||||
|
||||
@@ -188,6 +188,12 @@ Test thoroughly across contexts:
|
||||
- **Edge cases**: Very small screens (320px), very large screens (4K)
|
||||
- **Slow connections**: Test on throttled network
|
||||
|
||||
**Custom controls** (sliders, drag surfaces, scrollable control strips): a before/after slider can pass every width check above and still refuse to drag on iOS, so exercise each one in scope in the same batched round as the checks above:
|
||||
|
||||
- **Primary gesture**: Tap it and confirm it responds as designed, then drag it with the target input method; the drag must complete, not just start
|
||||
- **Scroll across it**: A swipe along the page's scroll axis across the control scrolls the page or container without activating it; a drag that starts on the control along its axis moves the control, not the page. Neither failure throws an error, so try both
|
||||
- **Evidence**: Say what produced the evidence: an emulated viewport, synthesized touch input through a browser tool, which engine ran it (Chromium is not Safari), or a physical device. Screenshots and resized viewports verify layout, never a gesture. Name what stayed untested and move on; unreachable hardware is a reported gap, not a blocker
|
||||
|
||||
When the adaptation feels native to each context, hand off to `/impeccable polish` for the final pass.
|
||||
|
||||
---
|
||||
|
||||
@@ -48,11 +48,12 @@ Run comprehensive checks across 5 dimensions. Score each dimension 0-4 using the
|
||||
**Check for**:
|
||||
- **Fixed widths**: Hard-coded widths that break on mobile
|
||||
- **Touch targets**: Interactive elements < 44x44px
|
||||
- **Broken touch interaction**: Custom sliders, drag surfaces, and scrollable control strips whose primary gesture fails under touch, that swallow page scroll or lose the drag to it, or that stay stuck after an interrupted gesture. Code tells: mouse-only handlers, no `touch-action` on a pointer-event drag surface, drag state that nothing clears on cancel, lost capture, or blur. Exercise the gesture when a browser tool can synthesize touch (a rendered viewport proves layout, not the gesture), then say what produced the evidence (emulated viewport, synthesized touch, which engine, physical device) and what stayed untested
|
||||
- **Horizontal scroll**: Content overflow on narrow viewports
|
||||
- **Text scaling**: Layouts that break when text size increases
|
||||
- **Missing breakpoints**: No mobile/tablet variants
|
||||
|
||||
**Score 0-4**: 0=Desktop-only (breaks on mobile), 1=Major issues (some breakpoints, many failures), 2=Partial (works on mobile, rough edges), 3=Good (responsive, minor touch target or overflow issues), 4=Excellent (fluid, all viewports, proper touch targets)
|
||||
**Score 0-4**: 0=Desktop-only (breaks on mobile), 1=Major issues (some breakpoints, many failures), 2=Partial (works on mobile, rough edges), 3=Good (responsive, minor touch target or overflow issues), 4=Excellent (fluid, all viewports, proper touch targets, gestures work under touch)
|
||||
|
||||
### 5. Implementation Integrity (CRITICAL)
|
||||
|
||||
|
||||
@@ -205,6 +205,11 @@ t('items', { count }) // Handles complex plural rules
|
||||
- Optimistic updates with rollback
|
||||
- Conflict resolution
|
||||
|
||||
**Interrupted gestures** (custom sliders, drag surfaces, scrollable control strips):
|
||||
- A second finger or pointer lands mid-drag: the first drag keeps its pointer or ends cleanly, never jumps to the new one
|
||||
- The browser cancels the gesture to scroll (`pointercancel`), capture is lost (`lostpointercapture`), the pointer is released outside the control, or the window loses focus (`blur`) mid-drag: clear the dragging state and release capture
|
||||
- After each of these, the next tap or drag works without a reload
|
||||
|
||||
**Permission states**:
|
||||
- No permission to view
|
||||
- No permission to edit
|
||||
@@ -304,6 +309,7 @@ const throttledScroll = throttle(handleScroll, 100);
|
||||
- Unit tests for edge cases
|
||||
- Integration tests for error scenarios
|
||||
- E2E tests for critical paths
|
||||
- A behavioral regression for each confirmed gesture fix, when the project's test runner can drive input
|
||||
- Visual regression tests
|
||||
- Accessibility tests (axe, WAVE)
|
||||
|
||||
@@ -330,7 +336,10 @@ Test thoroughly with edge cases:
|
||||
- **Network issues**: Disable internet, throttle connection
|
||||
- **Large datasets**: Test with 1000+ items
|
||||
- **Concurrent actions**: Click submit 10 times rapidly
|
||||
- **Interrupted gestures**: Add a second finger mid-drag, scroll across the control, release outside it, switch windows mid-drag; then drag again
|
||||
- **Errors**: Force API errors, test all error states
|
||||
- **Empty**: Remove all data, test empty states
|
||||
|
||||
For gestures, say what produced the evidence (emulated viewport, synthesized touch, which engine, physical device) and name what stayed untested.
|
||||
|
||||
When edge cases are covered, hand off to `/impeccable polish` for the final pass.
|
||||
|
||||
@@ -188,6 +188,12 @@ Test thoroughly across contexts:
|
||||
- **Edge cases**: Very small screens (320px), very large screens (4K)
|
||||
- **Slow connections**: Test on throttled network
|
||||
|
||||
**Custom controls** (sliders, drag surfaces, scrollable control strips): a before/after slider can pass every width check above and still refuse to drag on iOS, so exercise each one in scope in the same batched round as the checks above:
|
||||
|
||||
- **Primary gesture**: Tap it and confirm it responds as designed, then drag it with the target input method; the drag must complete, not just start
|
||||
- **Scroll across it**: A swipe along the page's scroll axis across the control scrolls the page or container without activating it; a drag that starts on the control along its axis moves the control, not the page. Neither failure throws an error, so try both
|
||||
- **Evidence**: Say what produced the evidence: an emulated viewport, synthesized touch input through a browser tool, which engine ran it (Chromium is not Safari), or a physical device. Screenshots and resized viewports verify layout, never a gesture. Name what stayed untested and move on; unreachable hardware is a reported gap, not a blocker
|
||||
|
||||
When the adaptation feels native to each context, hand off to `/impeccable polish` for the final pass.
|
||||
|
||||
---
|
||||
|
||||
@@ -48,11 +48,12 @@ Run comprehensive checks across 5 dimensions. Score each dimension 0-4 using the
|
||||
**Check for**:
|
||||
- **Fixed widths**: Hard-coded widths that break on mobile
|
||||
- **Touch targets**: Interactive elements < 44x44px
|
||||
- **Broken touch interaction**: Custom sliders, drag surfaces, and scrollable control strips whose primary gesture fails under touch, that swallow page scroll or lose the drag to it, or that stay stuck after an interrupted gesture. Code tells: mouse-only handlers, no `touch-action` on a pointer-event drag surface, drag state that nothing clears on cancel, lost capture, or blur. Exercise the gesture when a browser tool can synthesize touch (a rendered viewport proves layout, not the gesture), then say what produced the evidence (emulated viewport, synthesized touch, which engine, physical device) and what stayed untested
|
||||
- **Horizontal scroll**: Content overflow on narrow viewports
|
||||
- **Text scaling**: Layouts that break when text size increases
|
||||
- **Missing breakpoints**: No mobile/tablet variants
|
||||
|
||||
**Score 0-4**: 0=Desktop-only (breaks on mobile), 1=Major issues (some breakpoints, many failures), 2=Partial (works on mobile, rough edges), 3=Good (responsive, minor touch target or overflow issues), 4=Excellent (fluid, all viewports, proper touch targets)
|
||||
**Score 0-4**: 0=Desktop-only (breaks on mobile), 1=Major issues (some breakpoints, many failures), 2=Partial (works on mobile, rough edges), 3=Good (responsive, minor touch target or overflow issues), 4=Excellent (fluid, all viewports, proper touch targets, gestures work under touch)
|
||||
|
||||
### 5. Implementation Integrity (CRITICAL)
|
||||
|
||||
|
||||
@@ -205,6 +205,11 @@ t('items', { count }) // Handles complex plural rules
|
||||
- Optimistic updates with rollback
|
||||
- Conflict resolution
|
||||
|
||||
**Interrupted gestures** (custom sliders, drag surfaces, scrollable control strips):
|
||||
- A second finger or pointer lands mid-drag: the first drag keeps its pointer or ends cleanly, never jumps to the new one
|
||||
- The browser cancels the gesture to scroll (`pointercancel`), capture is lost (`lostpointercapture`), the pointer is released outside the control, or the window loses focus (`blur`) mid-drag: clear the dragging state and release capture
|
||||
- After each of these, the next tap or drag works without a reload
|
||||
|
||||
**Permission states**:
|
||||
- No permission to view
|
||||
- No permission to edit
|
||||
@@ -304,6 +309,7 @@ const throttledScroll = throttle(handleScroll, 100);
|
||||
- Unit tests for edge cases
|
||||
- Integration tests for error scenarios
|
||||
- E2E tests for critical paths
|
||||
- A behavioral regression for each confirmed gesture fix, when the project's test runner can drive input
|
||||
- Visual regression tests
|
||||
- Accessibility tests (axe, WAVE)
|
||||
|
||||
@@ -330,7 +336,10 @@ Test thoroughly with edge cases:
|
||||
- **Network issues**: Disable internet, throttle connection
|
||||
- **Large datasets**: Test with 1000+ items
|
||||
- **Concurrent actions**: Click submit 10 times rapidly
|
||||
- **Interrupted gestures**: Add a second finger mid-drag, scroll across the control, release outside it, switch windows mid-drag; then drag again
|
||||
- **Errors**: Force API errors, test all error states
|
||||
- **Empty**: Remove all data, test empty states
|
||||
|
||||
For gestures, say what produced the evidence (emulated viewport, synthesized touch, which engine, physical device) and name what stayed untested.
|
||||
|
||||
When edge cases are covered, hand off to `/impeccable polish` for the final pass.
|
||||
|
||||
@@ -188,6 +188,12 @@ Test thoroughly across contexts:
|
||||
- **Edge cases**: Very small screens (320px), very large screens (4K)
|
||||
- **Slow connections**: Test on throttled network
|
||||
|
||||
**Custom controls** (sliders, drag surfaces, scrollable control strips): a before/after slider can pass every width check above and still refuse to drag on iOS, so exercise each one in scope in the same batched round as the checks above:
|
||||
|
||||
- **Primary gesture**: Tap it and confirm it responds as designed, then drag it with the target input method; the drag must complete, not just start
|
||||
- **Scroll across it**: A swipe along the page's scroll axis across the control scrolls the page or container without activating it; a drag that starts on the control along its axis moves the control, not the page. Neither failure throws an error, so try both
|
||||
- **Evidence**: Say what produced the evidence: an emulated viewport, synthesized touch input through a browser tool, which engine ran it (Chromium is not Safari), or a physical device. Screenshots and resized viewports verify layout, never a gesture. Name what stayed untested and move on; unreachable hardware is a reported gap, not a blocker
|
||||
|
||||
When the adaptation feels native to each context, hand off to `/impeccable polish` for the final pass.
|
||||
|
||||
---
|
||||
|
||||
@@ -48,11 +48,12 @@ Run comprehensive checks across 5 dimensions. Score each dimension 0-4 using the
|
||||
**Check for**:
|
||||
- **Fixed widths**: Hard-coded widths that break on mobile
|
||||
- **Touch targets**: Interactive elements < 44x44px
|
||||
- **Broken touch interaction**: Custom sliders, drag surfaces, and scrollable control strips whose primary gesture fails under touch, that swallow page scroll or lose the drag to it, or that stay stuck after an interrupted gesture. Code tells: mouse-only handlers, no `touch-action` on a pointer-event drag surface, drag state that nothing clears on cancel, lost capture, or blur. Exercise the gesture when a browser tool can synthesize touch (a rendered viewport proves layout, not the gesture), then say what produced the evidence (emulated viewport, synthesized touch, which engine, physical device) and what stayed untested
|
||||
- **Horizontal scroll**: Content overflow on narrow viewports
|
||||
- **Text scaling**: Layouts that break when text size increases
|
||||
- **Missing breakpoints**: No mobile/tablet variants
|
||||
|
||||
**Score 0-4**: 0=Desktop-only (breaks on mobile), 1=Major issues (some breakpoints, many failures), 2=Partial (works on mobile, rough edges), 3=Good (responsive, minor touch target or overflow issues), 4=Excellent (fluid, all viewports, proper touch targets)
|
||||
**Score 0-4**: 0=Desktop-only (breaks on mobile), 1=Major issues (some breakpoints, many failures), 2=Partial (works on mobile, rough edges), 3=Good (responsive, minor touch target or overflow issues), 4=Excellent (fluid, all viewports, proper touch targets, gestures work under touch)
|
||||
|
||||
### 5. Implementation Integrity (CRITICAL)
|
||||
|
||||
|
||||
@@ -205,6 +205,11 @@ t('items', { count }) // Handles complex plural rules
|
||||
- Optimistic updates with rollback
|
||||
- Conflict resolution
|
||||
|
||||
**Interrupted gestures** (custom sliders, drag surfaces, scrollable control strips):
|
||||
- A second finger or pointer lands mid-drag: the first drag keeps its pointer or ends cleanly, never jumps to the new one
|
||||
- The browser cancels the gesture to scroll (`pointercancel`), capture is lost (`lostpointercapture`), the pointer is released outside the control, or the window loses focus (`blur`) mid-drag: clear the dragging state and release capture
|
||||
- After each of these, the next tap or drag works without a reload
|
||||
|
||||
**Permission states**:
|
||||
- No permission to view
|
||||
- No permission to edit
|
||||
@@ -304,6 +309,7 @@ const throttledScroll = throttle(handleScroll, 100);
|
||||
- Unit tests for edge cases
|
||||
- Integration tests for error scenarios
|
||||
- E2E tests for critical paths
|
||||
- A behavioral regression for each confirmed gesture fix, when the project's test runner can drive input
|
||||
- Visual regression tests
|
||||
- Accessibility tests (axe, WAVE)
|
||||
|
||||
@@ -330,7 +336,10 @@ Test thoroughly with edge cases:
|
||||
- **Network issues**: Disable internet, throttle connection
|
||||
- **Large datasets**: Test with 1000+ items
|
||||
- **Concurrent actions**: Click submit 10 times rapidly
|
||||
- **Interrupted gestures**: Add a second finger mid-drag, scroll across the control, release outside it, switch windows mid-drag; then drag again
|
||||
- **Errors**: Force API errors, test all error states
|
||||
- **Empty**: Remove all data, test empty states
|
||||
|
||||
For gestures, say what produced the evidence (emulated viewport, synthesized touch, which engine, physical device) and name what stayed untested.
|
||||
|
||||
When edge cases are covered, hand off to `/impeccable polish` for the final pass.
|
||||
|
||||
@@ -188,6 +188,12 @@ Test thoroughly across contexts:
|
||||
- **Edge cases**: Very small screens (320px), very large screens (4K)
|
||||
- **Slow connections**: Test on throttled network
|
||||
|
||||
**Custom controls** (sliders, drag surfaces, scrollable control strips): a before/after slider can pass every width check above and still refuse to drag on iOS, so exercise each one in scope in the same batched round as the checks above:
|
||||
|
||||
- **Primary gesture**: Tap it and confirm it responds as designed, then drag it with the target input method; the drag must complete, not just start
|
||||
- **Scroll across it**: A swipe along the page's scroll axis across the control scrolls the page or container without activating it; a drag that starts on the control along its axis moves the control, not the page. Neither failure throws an error, so try both
|
||||
- **Evidence**: Say what produced the evidence: an emulated viewport, synthesized touch input through a browser tool, which engine ran it (Chromium is not Safari), or a physical device. Screenshots and resized viewports verify layout, never a gesture. Name what stayed untested and move on; unreachable hardware is a reported gap, not a blocker
|
||||
|
||||
When the adaptation feels native to each context, hand off to `/impeccable polish` for the final pass.
|
||||
|
||||
---
|
||||
|
||||
@@ -48,11 +48,12 @@ Run comprehensive checks across 5 dimensions. Score each dimension 0-4 using the
|
||||
**Check for**:
|
||||
- **Fixed widths**: Hard-coded widths that break on mobile
|
||||
- **Touch targets**: Interactive elements < 44x44px
|
||||
- **Broken touch interaction**: Custom sliders, drag surfaces, and scrollable control strips whose primary gesture fails under touch, that swallow page scroll or lose the drag to it, or that stay stuck after an interrupted gesture. Code tells: mouse-only handlers, no `touch-action` on a pointer-event drag surface, drag state that nothing clears on cancel, lost capture, or blur. Exercise the gesture when a browser tool can synthesize touch (a rendered viewport proves layout, not the gesture), then say what produced the evidence (emulated viewport, synthesized touch, which engine, physical device) and what stayed untested
|
||||
- **Horizontal scroll**: Content overflow on narrow viewports
|
||||
- **Text scaling**: Layouts that break when text size increases
|
||||
- **Missing breakpoints**: No mobile/tablet variants
|
||||
|
||||
**Score 0-4**: 0=Desktop-only (breaks on mobile), 1=Major issues (some breakpoints, many failures), 2=Partial (works on mobile, rough edges), 3=Good (responsive, minor touch target or overflow issues), 4=Excellent (fluid, all viewports, proper touch targets)
|
||||
**Score 0-4**: 0=Desktop-only (breaks on mobile), 1=Major issues (some breakpoints, many failures), 2=Partial (works on mobile, rough edges), 3=Good (responsive, minor touch target or overflow issues), 4=Excellent (fluid, all viewports, proper touch targets, gestures work under touch)
|
||||
|
||||
### 5. Implementation Integrity (CRITICAL)
|
||||
|
||||
|
||||
@@ -205,6 +205,11 @@ t('items', { count }) // Handles complex plural rules
|
||||
- Optimistic updates with rollback
|
||||
- Conflict resolution
|
||||
|
||||
**Interrupted gestures** (custom sliders, drag surfaces, scrollable control strips):
|
||||
- A second finger or pointer lands mid-drag: the first drag keeps its pointer or ends cleanly, never jumps to the new one
|
||||
- The browser cancels the gesture to scroll (`pointercancel`), capture is lost (`lostpointercapture`), the pointer is released outside the control, or the window loses focus (`blur`) mid-drag: clear the dragging state and release capture
|
||||
- After each of these, the next tap or drag works without a reload
|
||||
|
||||
**Permission states**:
|
||||
- No permission to view
|
||||
- No permission to edit
|
||||
@@ -304,6 +309,7 @@ const throttledScroll = throttle(handleScroll, 100);
|
||||
- Unit tests for edge cases
|
||||
- Integration tests for error scenarios
|
||||
- E2E tests for critical paths
|
||||
- A behavioral regression for each confirmed gesture fix, when the project's test runner can drive input
|
||||
- Visual regression tests
|
||||
- Accessibility tests (axe, WAVE)
|
||||
|
||||
@@ -330,7 +336,10 @@ Test thoroughly with edge cases:
|
||||
- **Network issues**: Disable internet, throttle connection
|
||||
- **Large datasets**: Test with 1000+ items
|
||||
- **Concurrent actions**: Click submit 10 times rapidly
|
||||
- **Interrupted gestures**: Add a second finger mid-drag, scroll across the control, release outside it, switch windows mid-drag; then drag again
|
||||
- **Errors**: Force API errors, test all error states
|
||||
- **Empty**: Remove all data, test empty states
|
||||
|
||||
For gestures, say what produced the evidence (emulated viewport, synthesized touch, which engine, physical device) and name what stayed untested.
|
||||
|
||||
When edge cases are covered, hand off to `/impeccable polish` for the final pass.
|
||||
|
||||
@@ -188,6 +188,12 @@ Test thoroughly across contexts:
|
||||
- **Edge cases**: Very small screens (320px), very large screens (4K)
|
||||
- **Slow connections**: Test on throttled network
|
||||
|
||||
**Custom controls** (sliders, drag surfaces, scrollable control strips): a before/after slider can pass every width check above and still refuse to drag on iOS, so exercise each one in scope in the same batched round as the checks above:
|
||||
|
||||
- **Primary gesture**: Tap it and confirm it responds as designed, then drag it with the target input method; the drag must complete, not just start
|
||||
- **Scroll across it**: A swipe along the page's scroll axis across the control scrolls the page or container without activating it; a drag that starts on the control along its axis moves the control, not the page. Neither failure throws an error, so try both
|
||||
- **Evidence**: Say what produced the evidence: an emulated viewport, synthesized touch input through a browser tool, which engine ran it (Chromium is not Safari), or a physical device. Screenshots and resized viewports verify layout, never a gesture. Name what stayed untested and move on; unreachable hardware is a reported gap, not a blocker
|
||||
|
||||
When the adaptation feels native to each context, hand off to `/impeccable polish` for the final pass.
|
||||
|
||||
---
|
||||
|
||||
@@ -48,11 +48,12 @@ Run comprehensive checks across 5 dimensions. Score each dimension 0-4 using the
|
||||
**Check for**:
|
||||
- **Fixed widths**: Hard-coded widths that break on mobile
|
||||
- **Touch targets**: Interactive elements < 44x44px
|
||||
- **Broken touch interaction**: Custom sliders, drag surfaces, and scrollable control strips whose primary gesture fails under touch, that swallow page scroll or lose the drag to it, or that stay stuck after an interrupted gesture. Code tells: mouse-only handlers, no `touch-action` on a pointer-event drag surface, drag state that nothing clears on cancel, lost capture, or blur. Exercise the gesture when a browser tool can synthesize touch (a rendered viewport proves layout, not the gesture), then say what produced the evidence (emulated viewport, synthesized touch, which engine, physical device) and what stayed untested
|
||||
- **Horizontal scroll**: Content overflow on narrow viewports
|
||||
- **Text scaling**: Layouts that break when text size increases
|
||||
- **Missing breakpoints**: No mobile/tablet variants
|
||||
|
||||
**Score 0-4**: 0=Desktop-only (breaks on mobile), 1=Major issues (some breakpoints, many failures), 2=Partial (works on mobile, rough edges), 3=Good (responsive, minor touch target or overflow issues), 4=Excellent (fluid, all viewports, proper touch targets)
|
||||
**Score 0-4**: 0=Desktop-only (breaks on mobile), 1=Major issues (some breakpoints, many failures), 2=Partial (works on mobile, rough edges), 3=Good (responsive, minor touch target or overflow issues), 4=Excellent (fluid, all viewports, proper touch targets, gestures work under touch)
|
||||
|
||||
### 5. Implementation Integrity (CRITICAL)
|
||||
|
||||
|
||||
@@ -205,6 +205,11 @@ t('items', { count }) // Handles complex plural rules
|
||||
- Optimistic updates with rollback
|
||||
- Conflict resolution
|
||||
|
||||
**Interrupted gestures** (custom sliders, drag surfaces, scrollable control strips):
|
||||
- A second finger or pointer lands mid-drag: the first drag keeps its pointer or ends cleanly, never jumps to the new one
|
||||
- The browser cancels the gesture to scroll (`pointercancel`), capture is lost (`lostpointercapture`), the pointer is released outside the control, or the window loses focus (`blur`) mid-drag: clear the dragging state and release capture
|
||||
- After each of these, the next tap or drag works without a reload
|
||||
|
||||
**Permission states**:
|
||||
- No permission to view
|
||||
- No permission to edit
|
||||
@@ -304,6 +309,7 @@ const throttledScroll = throttle(handleScroll, 100);
|
||||
- Unit tests for edge cases
|
||||
- Integration tests for error scenarios
|
||||
- E2E tests for critical paths
|
||||
- A behavioral regression for each confirmed gesture fix, when the project's test runner can drive input
|
||||
- Visual regression tests
|
||||
- Accessibility tests (axe, WAVE)
|
||||
|
||||
@@ -330,7 +336,10 @@ Test thoroughly with edge cases:
|
||||
- **Network issues**: Disable internet, throttle connection
|
||||
- **Large datasets**: Test with 1000+ items
|
||||
- **Concurrent actions**: Click submit 10 times rapidly
|
||||
- **Interrupted gestures**: Add a second finger mid-drag, scroll across the control, release outside it, switch windows mid-drag; then drag again
|
||||
- **Errors**: Force API errors, test all error states
|
||||
- **Empty**: Remove all data, test empty states
|
||||
|
||||
For gestures, say what produced the evidence (emulated viewport, synthesized touch, which engine, physical device) and name what stayed untested.
|
||||
|
||||
When edge cases are covered, hand off to `/impeccable polish` for the final pass.
|
||||
|
||||
@@ -188,6 +188,12 @@ Test thoroughly across contexts:
|
||||
- **Edge cases**: Very small screens (320px), very large screens (4K)
|
||||
- **Slow connections**: Test on throttled network
|
||||
|
||||
**Custom controls** (sliders, drag surfaces, scrollable control strips): a before/after slider can pass every width check above and still refuse to drag on iOS, so exercise each one in scope in the same batched round as the checks above:
|
||||
|
||||
- **Primary gesture**: Tap it and confirm it responds as designed, then drag it with the target input method; the drag must complete, not just start
|
||||
- **Scroll across it**: A swipe along the page's scroll axis across the control scrolls the page or container without activating it; a drag that starts on the control along its axis moves the control, not the page. Neither failure throws an error, so try both
|
||||
- **Evidence**: Say what produced the evidence: an emulated viewport, synthesized touch input through a browser tool, which engine ran it (Chromium is not Safari), or a physical device. Screenshots and resized viewports verify layout, never a gesture. Name what stayed untested and move on; unreachable hardware is a reported gap, not a blocker
|
||||
|
||||
When the adaptation feels native to each context, hand off to `/impeccable polish` for the final pass.
|
||||
|
||||
---
|
||||
|
||||
@@ -48,11 +48,12 @@ Run comprehensive checks across 5 dimensions. Score each dimension 0-4 using the
|
||||
**Check for**:
|
||||
- **Fixed widths**: Hard-coded widths that break on mobile
|
||||
- **Touch targets**: Interactive elements < 44x44px
|
||||
- **Broken touch interaction**: Custom sliders, drag surfaces, and scrollable control strips whose primary gesture fails under touch, that swallow page scroll or lose the drag to it, or that stay stuck after an interrupted gesture. Code tells: mouse-only handlers, no `touch-action` on a pointer-event drag surface, drag state that nothing clears on cancel, lost capture, or blur. Exercise the gesture when a browser tool can synthesize touch (a rendered viewport proves layout, not the gesture), then say what produced the evidence (emulated viewport, synthesized touch, which engine, physical device) and what stayed untested
|
||||
- **Horizontal scroll**: Content overflow on narrow viewports
|
||||
- **Text scaling**: Layouts that break when text size increases
|
||||
- **Missing breakpoints**: No mobile/tablet variants
|
||||
|
||||
**Score 0-4**: 0=Desktop-only (breaks on mobile), 1=Major issues (some breakpoints, many failures), 2=Partial (works on mobile, rough edges), 3=Good (responsive, minor touch target or overflow issues), 4=Excellent (fluid, all viewports, proper touch targets)
|
||||
**Score 0-4**: 0=Desktop-only (breaks on mobile), 1=Major issues (some breakpoints, many failures), 2=Partial (works on mobile, rough edges), 3=Good (responsive, minor touch target or overflow issues), 4=Excellent (fluid, all viewports, proper touch targets, gestures work under touch)
|
||||
|
||||
### 5. Implementation Integrity (CRITICAL)
|
||||
|
||||
|
||||
@@ -205,6 +205,11 @@ t('items', { count }) // Handles complex plural rules
|
||||
- Optimistic updates with rollback
|
||||
- Conflict resolution
|
||||
|
||||
**Interrupted gestures** (custom sliders, drag surfaces, scrollable control strips):
|
||||
- A second finger or pointer lands mid-drag: the first drag keeps its pointer or ends cleanly, never jumps to the new one
|
||||
- The browser cancels the gesture to scroll (`pointercancel`), capture is lost (`lostpointercapture`), the pointer is released outside the control, or the window loses focus (`blur`) mid-drag: clear the dragging state and release capture
|
||||
- After each of these, the next tap or drag works without a reload
|
||||
|
||||
**Permission states**:
|
||||
- No permission to view
|
||||
- No permission to edit
|
||||
@@ -304,6 +309,7 @@ const throttledScroll = throttle(handleScroll, 100);
|
||||
- Unit tests for edge cases
|
||||
- Integration tests for error scenarios
|
||||
- E2E tests for critical paths
|
||||
- A behavioral regression for each confirmed gesture fix, when the project's test runner can drive input
|
||||
- Visual regression tests
|
||||
- Accessibility tests (axe, WAVE)
|
||||
|
||||
@@ -330,7 +336,10 @@ Test thoroughly with edge cases:
|
||||
- **Network issues**: Disable internet, throttle connection
|
||||
- **Large datasets**: Test with 1000+ items
|
||||
- **Concurrent actions**: Click submit 10 times rapidly
|
||||
- **Interrupted gestures**: Add a second finger mid-drag, scroll across the control, release outside it, switch windows mid-drag; then drag again
|
||||
- **Errors**: Force API errors, test all error states
|
||||
- **Empty**: Remove all data, test empty states
|
||||
|
||||
For gestures, say what produced the evidence (emulated viewport, synthesized touch, which engine, physical device) and name what stayed untested.
|
||||
|
||||
When edge cases are covered, hand off to `/impeccable polish` for the final pass.
|
||||
|
||||
@@ -188,6 +188,12 @@ Test thoroughly across contexts:
|
||||
- **Edge cases**: Very small screens (320px), very large screens (4K)
|
||||
- **Slow connections**: Test on throttled network
|
||||
|
||||
**Custom controls** (sliders, drag surfaces, scrollable control strips): a before/after slider can pass every width check above and still refuse to drag on iOS, so exercise each one in scope in the same batched round as the checks above:
|
||||
|
||||
- **Primary gesture**: Tap it and confirm it responds as designed, then drag it with the target input method; the drag must complete, not just start
|
||||
- **Scroll across it**: A swipe along the page's scroll axis across the control scrolls the page or container without activating it; a drag that starts on the control along its axis moves the control, not the page. Neither failure throws an error, so try both
|
||||
- **Evidence**: Say what produced the evidence: an emulated viewport, synthesized touch input through a browser tool, which engine ran it (Chromium is not Safari), or a physical device. Screenshots and resized viewports verify layout, never a gesture. Name what stayed untested and move on; unreachable hardware is a reported gap, not a blocker
|
||||
|
||||
When the adaptation feels native to each context, hand off to `/impeccable polish` for the final pass.
|
||||
|
||||
---
|
||||
|
||||
@@ -48,11 +48,12 @@ Run comprehensive checks across 5 dimensions. Score each dimension 0-4 using the
|
||||
**Check for**:
|
||||
- **Fixed widths**: Hard-coded widths that break on mobile
|
||||
- **Touch targets**: Interactive elements < 44x44px
|
||||
- **Broken touch interaction**: Custom sliders, drag surfaces, and scrollable control strips whose primary gesture fails under touch, that swallow page scroll or lose the drag to it, or that stay stuck after an interrupted gesture. Code tells: mouse-only handlers, no `touch-action` on a pointer-event drag surface, drag state that nothing clears on cancel, lost capture, or blur. Exercise the gesture when a browser tool can synthesize touch (a rendered viewport proves layout, not the gesture), then say what produced the evidence (emulated viewport, synthesized touch, which engine, physical device) and what stayed untested
|
||||
- **Horizontal scroll**: Content overflow on narrow viewports
|
||||
- **Text scaling**: Layouts that break when text size increases
|
||||
- **Missing breakpoints**: No mobile/tablet variants
|
||||
|
||||
**Score 0-4**: 0=Desktop-only (breaks on mobile), 1=Major issues (some breakpoints, many failures), 2=Partial (works on mobile, rough edges), 3=Good (responsive, minor touch target or overflow issues), 4=Excellent (fluid, all viewports, proper touch targets)
|
||||
**Score 0-4**: 0=Desktop-only (breaks on mobile), 1=Major issues (some breakpoints, many failures), 2=Partial (works on mobile, rough edges), 3=Good (responsive, minor touch target or overflow issues), 4=Excellent (fluid, all viewports, proper touch targets, gestures work under touch)
|
||||
|
||||
### 5. Implementation Integrity (CRITICAL)
|
||||
|
||||
|
||||
@@ -205,6 +205,11 @@ t('items', { count }) // Handles complex plural rules
|
||||
- Optimistic updates with rollback
|
||||
- Conflict resolution
|
||||
|
||||
**Interrupted gestures** (custom sliders, drag surfaces, scrollable control strips):
|
||||
- A second finger or pointer lands mid-drag: the first drag keeps its pointer or ends cleanly, never jumps to the new one
|
||||
- The browser cancels the gesture to scroll (`pointercancel`), capture is lost (`lostpointercapture`), the pointer is released outside the control, or the window loses focus (`blur`) mid-drag: clear the dragging state and release capture
|
||||
- After each of these, the next tap or drag works without a reload
|
||||
|
||||
**Permission states**:
|
||||
- No permission to view
|
||||
- No permission to edit
|
||||
@@ -304,6 +309,7 @@ const throttledScroll = throttle(handleScroll, 100);
|
||||
- Unit tests for edge cases
|
||||
- Integration tests for error scenarios
|
||||
- E2E tests for critical paths
|
||||
- A behavioral regression for each confirmed gesture fix, when the project's test runner can drive input
|
||||
- Visual regression tests
|
||||
- Accessibility tests (axe, WAVE)
|
||||
|
||||
@@ -330,7 +336,10 @@ Test thoroughly with edge cases:
|
||||
- **Network issues**: Disable internet, throttle connection
|
||||
- **Large datasets**: Test with 1000+ items
|
||||
- **Concurrent actions**: Click submit 10 times rapidly
|
||||
- **Interrupted gestures**: Add a second finger mid-drag, scroll across the control, release outside it, switch windows mid-drag; then drag again
|
||||
- **Errors**: Force API errors, test all error states
|
||||
- **Empty**: Remove all data, test empty states
|
||||
|
||||
For gestures, say what produced the evidence (emulated viewport, synthesized touch, which engine, physical device) and name what stayed untested.
|
||||
|
||||
When edge cases are covered, hand off to `/impeccable polish` for the final pass.
|
||||
|
||||
@@ -585,6 +585,7 @@ const __impeccableSnapshot = {
|
||||
for (let id = 1; id < elements.length; id++) {
|
||||
const el = elements[id];
|
||||
const rec = { t: el.tagName };
|
||||
const tag = rec.t;
|
||||
const nsUri = el.namespaceURI || '';
|
||||
const ns = __SNAP_NS[nsUri];
|
||||
if (ns === undefined) { rec.n = 3; rec.nu = nsUri; } else if (ns !== 0) { rec.n = ns; }
|
||||
@@ -615,6 +616,11 @@ const __impeccableSnapshot = {
|
||||
if (content == null || content === '' || content === 'none') continue;
|
||||
rec[key] = __SNAP_PSEUDO_PROPS.map(p => intern(ps[p]));
|
||||
}
|
||||
if ((tag === 'INPUT' || tag === 'TEXTAREA') && el.getAttribute('placeholder')) {
|
||||
let ps;
|
||||
try { ps = getComputedStyle(el, '::placeholder'); } catch { ps = null; }
|
||||
if (ps) rec.ph = intern(ps.color);
|
||||
}
|
||||
if (typeof el.getBoundingClientRect === 'function') rec.r = __snapRect4(el.getBoundingClientRect());
|
||||
rec.m = [
|
||||
__snapNum(el.clientWidth), __snapNum(el.clientHeight), __snapNum(el.clientLeft),
|
||||
@@ -632,7 +638,6 @@ const __impeccableSnapshot = {
|
||||
if (typeof el.className !== 'string') rec.k = true;
|
||||
const st = states.get(id);
|
||||
if (st) rec.st = st;
|
||||
const tag = rec.t;
|
||||
if (tag === 'IMG' || tag === 'VIDEO' || tag === 'CANVAS' || tag === 'PICTURE') {
|
||||
rec.md = {
|
||||
nw: el.naturalWidth || 0, nh: el.naturalHeight || 0,
|
||||
|
||||
@@ -1350,7 +1350,6 @@ pub fn collect_browser_findings(dom: &dyn Dom, config: &BrowserConfig) -> Collec
|
||||
let mut findings: Vec<BrowserFinding> = Vec::new();
|
||||
findings.extend(hits(ec::check_element_borders_dom(dom, el)));
|
||||
findings.extend(hits(ec::check_element_pseudo_stripe_dom(dom, el)));
|
||||
findings.extend(hits(ec::check_element_stripe_child_dom(dom, el)));
|
||||
findings.extend(hits(ec::check_element_colors_dom(dom, el)));
|
||||
findings.extend(hits(ec::check_element_motion_dom(dom, el)));
|
||||
findings.extend(hits(ec::check_element_glow_dom(dom, el)));
|
||||
|
||||
@@ -20,7 +20,7 @@ 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_stripe_child, is_emoji_only_text, BorderOpts,
|
||||
check_italic_serif, check_motion, check_placeholder_colors, is_emoji_only_text, BorderOpts,
|
||||
ColorOpts, GlowOpts, HeroEyebrowOpts, IconTileOpts, ItalicSerifOpts, MotionOpts, RuleHit,
|
||||
Sides, HEADING_TAGS,
|
||||
};
|
||||
@@ -368,57 +368,6 @@ pub fn check_element_pseudo_stripe_dom(dom: &dyn Dom, el: ElId) -> Vec<RuleHit>
|
||||
findings
|
||||
}
|
||||
|
||||
const STRIPE_CHILD_SKIP: &str = "nav, blockquote, pre, table, button, a, select, progress, meter, [role=\"progressbar\"], [role=\"slider\"], [role=\"scrollbar\"], [role=\"separator\"], [role=\"tablist\"]";
|
||||
|
||||
/// JS: checks.mjs#checkElementStripeChildDOM(el)
|
||||
pub fn check_element_stripe_child_dom(dom: &dyn Dom, el: ElId) -> Vec<RuleHit> {
|
||||
let tag = tag_lower(dom, el);
|
||||
if tag != "div" && tag != "span" {
|
||||
return Vec::new();
|
||||
}
|
||||
let Some(host) = dom.parent(el) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let host_tag = tag_lower(dom, host);
|
||||
if host_tag == "body" || host_tag == "html" {
|
||||
return Vec::new();
|
||||
}
|
||||
if !dom.children(el).is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
if !js::trim(&collapse_ws(&dom.text_content(el))).is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
if closest_or_none(dom, el, STRIPE_CHILD_SKIP).is_some() {
|
||||
return Vec::new();
|
||||
}
|
||||
if !is_rendered_for_browser_rule(dom, el) {
|
||||
return Vec::new();
|
||||
}
|
||||
if is_tab_context_element(dom, el) || is_status_context_element(dom, el) {
|
||||
return Vec::new();
|
||||
}
|
||||
let host_rect = dom.rect(host);
|
||||
if host_rect.width < 40.0 || host_rect.height < 20.0 {
|
||||
return Vec::new();
|
||||
}
|
||||
let child_rect = dom.rect(el);
|
||||
if child_rect.height < host_rect.height - 44.0 || child_rect.height < host_rect.height * 0.5 {
|
||||
return Vec::new();
|
||||
}
|
||||
let hugs = |v: f64| v.is_finite() && v.abs() <= 3.0;
|
||||
let edge = if hugs(child_rect.left - host_rect.left) {
|
||||
Some("left")
|
||||
} else if hugs(host_rect.right - child_rect.right) {
|
||||
Some("right")
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let width = child_rect.width;
|
||||
let bg = parse_rgb_or_any(&dom.style(el, "backgroundColor"));
|
||||
check_stripe_child(&class_selector(dom, el), width, edge, bg)
|
||||
}
|
||||
|
||||
/// JS: checks.mjs#readPseudoSurfaceDOM(el, rect)
|
||||
pub fn read_pseudo_surface_dom(dom: &dyn Dom, el: ElId, rect: &Rect) -> Option<Rgba> {
|
||||
for which in PSEUDOS {
|
||||
@@ -511,8 +460,8 @@ pub fn check_element_colors_dom(dom: &dyn Dom, el: ElId) -> Vec<RuleHit> {
|
||||
} else {
|
||||
resolve_gradient_stops(dom, el)
|
||||
};
|
||||
check_colors(&ColorOpts {
|
||||
tag,
|
||||
let color_opts = ColorOpts {
|
||||
tag: tag.clone(),
|
||||
text_color: parse_rgb_or_any(&dom.style(el, "color")),
|
||||
bg_color: own_bg,
|
||||
effective_bg: if surface_unresolved {
|
||||
@@ -529,7 +478,36 @@ pub fn check_element_colors_dom(dom: &dyn Dom, el: ElId) -> Vec<RuleHit> {
|
||||
bg_image: Some(dom.style(el, "backgroundImage")),
|
||||
class_list: Some(class_attr(dom, el)),
|
||||
detector_is_browser: true,
|
||||
})
|
||||
};
|
||||
let mut findings = check_colors(&color_opts);
|
||||
if tag == "input" || tag == "textarea" {
|
||||
let placeholder = dom.attr(el, "placeholder").unwrap_or_default();
|
||||
let placeholder = js::trim(&placeholder);
|
||||
if !placeholder.is_empty() {
|
||||
let skip = if tag == "input" {
|
||||
let t = js::to_lower_case(&dom.attr(el, "type").unwrap_or_else(|| "text".into()));
|
||||
matches!(
|
||||
t.as_str(),
|
||||
"hidden" | "checkbox" | "radio" | "file" | "submit" | "button" | "image"
|
||||
| "reset" | "range" | "color"
|
||||
)
|
||||
} else {
|
||||
false
|
||||
} || !matches_or_false(dom, el, ":placeholder-shown");
|
||||
if !skip {
|
||||
if let Some(ph_raw) = dom.pseudo_style(el, "::placeholder", "color") {
|
||||
if let Some(ph_color) = parse_rgb_or_any(&ph_raw) {
|
||||
findings.extend(check_placeholder_colors(
|
||||
&color_opts,
|
||||
placeholder,
|
||||
ph_color,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
findings
|
||||
}
|
||||
|
||||
// ── icon tile / italic serif / hero eyebrow ───────────────────────────────
|
||||
@@ -1431,41 +1409,58 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stripe_child_flags_left_edge_and_skips_neutral_text_and_tab_context() {
|
||||
fn placeholder_low_contrast_flags() {
|
||||
let (mut d, body) = page();
|
||||
let host = d.add(Some(body), "div");
|
||||
visible(&mut d, host);
|
||||
d.set_attr(host, "class", "card");
|
||||
d.set_rect(host, 0.0, 0.0, 300.0, 100.0);
|
||||
let stripe = d.add(Some(host), "div");
|
||||
visible(&mut d, stripe);
|
||||
d.set_rect(stripe, 0.0, 0.0, 4.0, 100.0);
|
||||
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(
|
||||
stripe,
|
||||
input,
|
||||
&[
|
||||
("backgroundColor", "rgb(245, 158, 11)"),
|
||||
("width", "4px"),
|
||||
("height", "100px"),
|
||||
("backgroundColor", "rgb(255, 255, 255)"),
|
||||
("color", "rgb(0, 0, 0)"),
|
||||
("fontSize", "16px"),
|
||||
("fontWeight", "400"),
|
||||
("webkitBackgroundClip", "border-box"),
|
||||
],
|
||||
);
|
||||
let hits = check_element_stripe_child_dom(&d, stripe);
|
||||
assert_eq!(hits.len(), 1);
|
||||
assert_eq!(hits[0].id, "side-tab");
|
||||
assert_eq!(hits[0].snippet, "div — 4px stripe child (left)");
|
||||
d.set_styles(stripe, &[("backgroundColor", "rgb(120, 120, 120)")]);
|
||||
assert!(check_element_stripe_child_dom(&d, stripe).is_empty());
|
||||
let stripe_text = d.add(Some(host), "div");
|
||||
visible(&mut d, stripe_text);
|
||||
d.set_rect(stripe_text, 4.0, 0.0, 4.0, 100.0);
|
||||
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(
|
||||
stripe_text,
|
||||
&[("backgroundColor", "rgb(245, 158, 11)")],
|
||||
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:?}"
|
||||
);
|
||||
d.add_text(stripe_text, "x");
|
||||
assert!(check_element_stripe_child_dom(&d, stripe_text).is_empty());
|
||||
d.set_styles(stripe, &[("backgroundColor", "rgb(245, 158, 11)")]);
|
||||
d.set_attr(host, "class", "card is-active");
|
||||
assert!(check_element_stripe_child_dom(&d, stripe).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+163
-103
@@ -4,7 +4,8 @@
|
||||
//! `undefined` / `null` distinctions the source relies on.
|
||||
|
||||
use crate::color::{
|
||||
color_to_hex, contrast_ratio, get_hue, has_chroma, is_neutral_color, relative_luminance, Rgba,
|
||||
color_to_hex, composite_color_over, contrast_ratio, get_hue, has_chroma, is_neutral_color,
|
||||
relative_luminance, Rgba,
|
||||
};
|
||||
use crate::constants::{
|
||||
BORDER_SAFE_TAGS, GENERIC_FONTS, KNOWN_SERIF_FONTS, SAFE_TAGS, WCAG_LARGE_BOLD_TEXT_PX,
|
||||
@@ -88,39 +89,6 @@ pub fn check_borders(
|
||||
findings
|
||||
}
|
||||
|
||||
/// Pure gate for dedicated stripe-child side-tabs (empty narrow chromatic
|
||||
/// `div`/`span` at a card edge).
|
||||
pub fn check_stripe_child(
|
||||
selector: &str,
|
||||
width: f64,
|
||||
edge: Option<&str>,
|
||||
bg: Option<Rgba>,
|
||||
) -> Vec<RuleHit> {
|
||||
let Some(edge) = edge else {
|
||||
return Vec::new();
|
||||
};
|
||||
if !(width >= 2.0 && width <= 12.0) {
|
||||
return Vec::new();
|
||||
}
|
||||
let Some(bg) = bg else {
|
||||
return Vec::new();
|
||||
};
|
||||
if bg.alpha_or_one() <= 0.1 {
|
||||
return Vec::new();
|
||||
}
|
||||
let spread = js::math_max3(bg.r, bg.g, bg.b) - js::math_min3(bg.r, bg.g, bg.b);
|
||||
if spread < 30.0 {
|
||||
return Vec::new();
|
||||
}
|
||||
vec![RuleHit::new(
|
||||
"side-tab",
|
||||
format!(
|
||||
"{selector} — {}px stripe child ({edge})",
|
||||
number_to_string(math_round(width))
|
||||
),
|
||||
)]
|
||||
}
|
||||
|
||||
re!(GRADIENT_CI, ci("gradient"));
|
||||
|
||||
re!(
|
||||
@@ -188,75 +156,10 @@ pub fn check_colors(opts: &ColorOpts) -> Vec<RuleHit> {
|
||||
|
||||
if opts.has_direct_text && opts.text_color.is_some() && !opts.is_emoji_only {
|
||||
let text_color = opts.text_color.unwrap();
|
||||
let is_gradient_clipped_text = bg_clip == "text";
|
||||
let bgs: Option<Vec<Rgba>> = if is_gradient_clipped_text {
|
||||
None
|
||||
} else if let Some(bg) = opts.effective_bg {
|
||||
Some(vec![bg])
|
||||
} else {
|
||||
match &opts.effective_bg_stops {
|
||||
Some(stops) if !stops.is_empty() => Some(stops.clone()),
|
||||
_ => None,
|
||||
}
|
||||
};
|
||||
if let Some(bgs) = bgs {
|
||||
let text_lum = relative_luminance(&text_color);
|
||||
let is_gray =
|
||||
!has_chroma(Some(&text_color), Some(20.0)) && text_lum > 0.05 && text_lum < 0.85;
|
||||
if is_gray && bgs.iter().all(|b| has_chroma(Some(b), Some(40.0))) {
|
||||
let bg_label = match opts.effective_bg {
|
||||
Some(bg) => color_to_hex(Some(&bg)),
|
||||
None => format!(
|
||||
"gradient({})",
|
||||
bgs.iter()
|
||||
.map(|b| color_to_hex(Some(b)))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
),
|
||||
};
|
||||
findings.push(RuleHit::new(
|
||||
"gray-on-color",
|
||||
format!(
|
||||
"text {} on bg {}",
|
||||
color_to_hex(Some(&text_color)),
|
||||
bg_label
|
||||
),
|
||||
));
|
||||
}
|
||||
|
||||
let ratios: Vec<f64> = bgs.iter().map(|b| contrast_ratio(&text_color, b)).collect();
|
||||
let mut worst_idx = 0usize;
|
||||
for i in 1..ratios.len() {
|
||||
if ratios[i] < ratios[worst_idx] {
|
||||
worst_idx = i;
|
||||
}
|
||||
}
|
||||
let ratio = ratios[worst_idx];
|
||||
let is_large_text = opts.font_size >= WCAG_LARGE_TEXT_PX
|
||||
|| (opts.font_size >= WCAG_LARGE_BOLD_TEXT_PX && opts.font_weight >= 700.0);
|
||||
let threshold = if is_large_text { 3.0 } else { 4.5 };
|
||||
if ratio < threshold {
|
||||
let is_alpha_fallback_fp = !opts.detector_is_browser
|
||||
&& opts.effective_bg.is_none()
|
||||
&& text_color.a.map_or(false, |a| a < 1.0);
|
||||
if !is_alpha_fallback_fp {
|
||||
let ratio_label = if to_fixed(ratio, 1) == to_fixed(threshold, 1) {
|
||||
to_fixed(ratio, 2)
|
||||
} else {
|
||||
to_fixed(ratio, 1)
|
||||
};
|
||||
findings.push(RuleHit::new(
|
||||
"low-contrast",
|
||||
format!(
|
||||
"{}:1 (need {}:1) — text {} on {}",
|
||||
ratio_label,
|
||||
number_to_string(threshold),
|
||||
color_to_hex(Some(&text_color)),
|
||||
color_to_hex(Some(&bgs[worst_idx]))
|
||||
),
|
||||
));
|
||||
}
|
||||
}
|
||||
// Gradient-clipped text paints the gradient, not `color`, so there
|
||||
// is no background to score it against.
|
||||
if bg_clip != "text" {
|
||||
findings.extend(contrast_findings(opts, &text_color));
|
||||
}
|
||||
|
||||
if has_chroma(Some(&text_color), Some(50.0)) {
|
||||
@@ -314,6 +217,119 @@ pub fn check_colors(opts: &ColorOpts) -> Vec<RuleHit> {
|
||||
findings
|
||||
}
|
||||
|
||||
/// The contrast scoring `check_colors` and `check_placeholder_colors`
|
||||
/// share: gray-on-color, then WCAG AA against the worst background. The
|
||||
/// backgrounds are the composited `effective_bg`, or the gradient stops when
|
||||
/// no opaque surface resolved; with neither there is nothing to score.
|
||||
fn contrast_findings(opts: &ColorOpts, text_color: &Rgba) -> Vec<RuleHit> {
|
||||
let bgs: Vec<Rgba> = if let Some(bg) = opts.effective_bg {
|
||||
vec![bg]
|
||||
} else {
|
||||
match &opts.effective_bg_stops {
|
||||
Some(stops) if !stops.is_empty() => stops.clone(),
|
||||
_ => return Vec::new(),
|
||||
}
|
||||
};
|
||||
let mut findings = Vec::new();
|
||||
let text_lum = relative_luminance(text_color);
|
||||
let is_gray = !has_chroma(Some(text_color), Some(20.0)) && text_lum > 0.05 && text_lum < 0.85;
|
||||
if is_gray && bgs.iter().all(|b| has_chroma(Some(b), Some(40.0))) {
|
||||
let bg_label = match opts.effective_bg {
|
||||
Some(bg) => color_to_hex(Some(&bg)),
|
||||
None => format!(
|
||||
"gradient({})",
|
||||
bgs.iter()
|
||||
.map(|b| color_to_hex(Some(b)))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
),
|
||||
};
|
||||
findings.push(RuleHit::new(
|
||||
"gray-on-color",
|
||||
format!("text {} on bg {}", color_to_hex(Some(text_color)), bg_label),
|
||||
));
|
||||
}
|
||||
|
||||
let ratios: Vec<f64> = bgs.iter().map(|b| contrast_ratio(text_color, b)).collect();
|
||||
let mut worst_idx = 0usize;
|
||||
for i in 1..ratios.len() {
|
||||
if ratios[i] < ratios[worst_idx] {
|
||||
worst_idx = i;
|
||||
}
|
||||
}
|
||||
let ratio = ratios[worst_idx];
|
||||
let is_large_text = opts.font_size >= WCAG_LARGE_TEXT_PX
|
||||
|| (opts.font_size >= WCAG_LARGE_BOLD_TEXT_PX && opts.font_weight >= 700.0);
|
||||
let threshold = if is_large_text { 3.0 } else { 4.5 };
|
||||
if ratio < threshold {
|
||||
let is_alpha_fallback_fp = !opts.detector_is_browser
|
||||
&& opts.effective_bg.is_none()
|
||||
&& text_color.a.map_or(false, |a| a < 1.0);
|
||||
if !is_alpha_fallback_fp {
|
||||
let ratio_label = if to_fixed(ratio, 1) == to_fixed(threshold, 1) {
|
||||
to_fixed(ratio, 2)
|
||||
} else {
|
||||
to_fixed(ratio, 1)
|
||||
};
|
||||
findings.push(RuleHit::new(
|
||||
"low-contrast",
|
||||
format!(
|
||||
"{}:1 (need {}:1) — text {} on {}",
|
||||
ratio_label,
|
||||
number_to_string(threshold),
|
||||
color_to_hex(Some(text_color)),
|
||||
color_to_hex(Some(&bgs[worst_idx]))
|
||||
),
|
||||
));
|
||||
}
|
||||
}
|
||||
findings
|
||||
}
|
||||
|
||||
/// Placeholder text contrast, sibling of `check_hover_contrast`. Skips the
|
||||
/// SAFE_TAGS gate and the host heuristics in `check_colors` (class list,
|
||||
/// clip, gradient) because the host is an empty control; only the
|
||||
/// placeholder glyphs are scored. A translucent placeholder is flattened
|
||||
/// over the composited background first, including each gradient stop when
|
||||
/// no opaque surface resolved. Snippets carry the placeholder string so
|
||||
/// fixture tests can key on it.
|
||||
pub fn check_placeholder_colors(
|
||||
opts: &ColorOpts,
|
||||
placeholder_text: &str,
|
||||
mut text_color: Rgba,
|
||||
) -> Vec<RuleHit> {
|
||||
let mut flat: Option<ColorOpts> = None;
|
||||
if text_color.a.map_or(false, |a| a < 1.0) {
|
||||
if let Some(bg) = opts.effective_bg {
|
||||
text_color = composite_color_over(&text_color, &bg);
|
||||
} else if let Some(stops) = opts.effective_bg_stops.as_ref().filter(|s| !s.is_empty()) {
|
||||
let mut worst_i = 0usize;
|
||||
let mut worst_ratio = f64::MAX;
|
||||
let mut worst_fg = text_color;
|
||||
for (i, stop) in stops.iter().enumerate() {
|
||||
let fg = composite_color_over(&text_color, stop);
|
||||
let r = contrast_ratio(&fg, stop);
|
||||
if r < worst_ratio {
|
||||
worst_ratio = r;
|
||||
worst_i = i;
|
||||
worst_fg = fg;
|
||||
}
|
||||
}
|
||||
text_color = worst_fg;
|
||||
let mut o = opts.clone();
|
||||
o.effective_bg = Some(stops[worst_i]);
|
||||
o.effective_bg_stops = None;
|
||||
flat = Some(o);
|
||||
}
|
||||
}
|
||||
let opts = flat.as_ref().unwrap_or(opts);
|
||||
let mut findings = contrast_findings(opts, &text_color);
|
||||
for h in &mut findings {
|
||||
h.snippet = format!("placeholder \"{}\" {}", placeholder_text, h.snippet);
|
||||
}
|
||||
findings
|
||||
}
|
||||
|
||||
/// JS: checks.mjs#checkHoverContrast
|
||||
pub fn check_hover_contrast(opts: &HoverContrastOpts) -> Vec<RuleHit> {
|
||||
if !opts.has_direct_text || opts.is_emoji_only || opts.text_color.is_none() || opts.bg.is_none()
|
||||
@@ -1077,6 +1093,50 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn placeholder_colors_ignore_host_class_heuristics() {
|
||||
let opts = ColorOpts {
|
||||
tag: "input".to_string(),
|
||||
effective_bg: Some(Rgba::new(255.0, 255.0, 255.0, 1.0)),
|
||||
font_size: 24.0,
|
||||
font_weight: 400.0,
|
||||
class_list: Some("text-slate-300 bg-red-500".to_string()),
|
||||
bg_clip: Some("text".to_string()),
|
||||
bg_image: Some("linear-gradient(red, blue)".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
let ink = check_placeholder_colors(&opts, "Name", Rgba::new(26.0, 26.0, 26.0, 1.0));
|
||||
assert!(ink.is_empty(), "{ink:?}");
|
||||
let pale = check_placeholder_colors(&opts, "Name", Rgba::new(187.0, 187.0, 187.0, 1.0));
|
||||
assert_eq!(pale.len(), 1);
|
||||
assert_eq!(pale[0].id, "low-contrast");
|
||||
assert!(pale[0].snippet.contains("placeholder \"Name\""), "{pale:?}");
|
||||
// No resolved surface and no gradient stops: nothing to score.
|
||||
let unresolved = ColorOpts {
|
||||
effective_bg: None,
|
||||
effective_bg_stops: None,
|
||||
..opts.clone()
|
||||
};
|
||||
let none = check_placeholder_colors(&unresolved, "Name", Rgba::new(187.0, 187.0, 187.0, 1.0));
|
||||
assert!(none.is_empty(), "{none:?}");
|
||||
// Translucent black over a light gradient: flatten per stop, then score.
|
||||
let gradient = ColorOpts {
|
||||
effective_bg: None,
|
||||
effective_bg_stops: Some(vec![
|
||||
Rgba::new(255.0, 255.0, 255.0, 1.0),
|
||||
Rgba::new(240.0, 240.0, 240.0, 1.0),
|
||||
]),
|
||||
..opts.clone()
|
||||
};
|
||||
let wash = check_placeholder_colors(
|
||||
&gradient,
|
||||
"Name",
|
||||
Rgba::new(0.0, 0.0, 0.0, 0.2),
|
||||
);
|
||||
assert_eq!(wash.len(), 1, "{wash:?}");
|
||||
assert_eq!(wash[0].id, "low-contrast");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn heading_tags_and_card_like() {
|
||||
assert!(is_heading_tag("h4"));
|
||||
|
||||
@@ -146,65 +146,36 @@ where
|
||||
}
|
||||
}
|
||||
|
||||
/// Opening-tag span that contains `index`, if any. `end` is the `>` byte.
|
||||
fn markup_tag_span(line: &str, index: usize) -> Option<(usize, usize)> {
|
||||
let mut i = 0usize;
|
||||
while i < line.len() {
|
||||
let Some(rel) = line[i..].find('<') else {
|
||||
return None;
|
||||
};
|
||||
let tag_start = i + rel;
|
||||
let after = &line[tag_start + 1..];
|
||||
if !after.chars().next().is_some_and(|c| c.is_ascii_alphabetic()) {
|
||||
i = tag_start + 1;
|
||||
continue;
|
||||
}
|
||||
let mut tag_end: Option<usize> = None;
|
||||
scan_js(line, tag_start + 1, |ch, j, _p, _n, depth| {
|
||||
if ch == '>' && depth.brace == 0 {
|
||||
tag_end = Some(j);
|
||||
return true;
|
||||
}
|
||||
false
|
||||
});
|
||||
let Some(end) = tag_end else {
|
||||
return None;
|
||||
};
|
||||
if index >= tag_start && index <= end {
|
||||
return Some((tag_start, end));
|
||||
}
|
||||
i = end + 1;
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// JS: detect-text.mjs#containingMarkupTag (only its `text` is read).
|
||||
fn containing_markup_tag(line: &str) -> impl Fn(usize) -> String + '_ {
|
||||
move |index: usize| {
|
||||
markup_tag_span(line, index)
|
||||
.map(|(start, end)| line[start..end + 1].to_string())
|
||||
.unwrap_or_else(|| line.to_string())
|
||||
let mut i = 0usize;
|
||||
while i < line.len() {
|
||||
let Some(rel) = line[i..].find('<') else { break };
|
||||
let tag_start = i + rel;
|
||||
let after = &line[tag_start + 1..];
|
||||
if !after.chars().next().is_some_and(|c| c.is_ascii_alphabetic()) {
|
||||
i = tag_start + 1;
|
||||
continue;
|
||||
}
|
||||
let mut tag_end: Option<usize> = None;
|
||||
scan_js(line, tag_start + 1, |ch, j, _p, _n, depth| {
|
||||
if ch == '>' && depth.brace == 0 {
|
||||
tag_end = Some(j);
|
||||
return true;
|
||||
}
|
||||
false
|
||||
});
|
||||
let Some(end) = tag_end else { break };
|
||||
if index >= tag_start && index <= end {
|
||||
return line[tag_start..end + 1].to_string();
|
||||
}
|
||||
i = end + 1;
|
||||
}
|
||||
line.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn is_self_closing_tag(tag: &str) -> bool {
|
||||
tag.trim_end_matches('>').trim_end().ends_with('/')
|
||||
}
|
||||
|
||||
/// Text path cannot see the DOM. When this line holds a whole tag, require
|
||||
/// it empty or self-closing. A class list with no `<` is a split JSX tag,
|
||||
/// so emptiness is unknown and the other gates still apply.
|
||||
fn stripe_child_markup_empty(line: &str, index: usize) -> bool {
|
||||
let Some((start, end)) = markup_tag_span(line, index) else {
|
||||
return true;
|
||||
};
|
||||
if is_self_closing_tag(&line[start..end + 1]) {
|
||||
return true;
|
||||
}
|
||||
let rest = line.get(end + 1..).unwrap_or("").trim_start();
|
||||
rest.starts_with("</")
|
||||
}
|
||||
|
||||
struct TernarySplit {
|
||||
common: String,
|
||||
consequent: String,
|
||||
@@ -523,43 +494,6 @@ re!(
|
||||
SIDE_TAB_JS_RE,
|
||||
format!("border(?:Left|Right){WS}*[:=]{WS}*[\"'`]({D}+)px{WS}+solid")
|
||||
);
|
||||
re!(
|
||||
SIDE_TAB_STRIPE_CHILD_TW_RE,
|
||||
r"w-(?:0\.5|1(?:\.5)?|2(?:\.5)?|3|\[(?:[2-9]|1[0-2])px\])"
|
||||
);
|
||||
re!(STRIPE_CHILD_HEIGHT_TOKEN_RE, r"h-(?:px\b|[0-9]|\[)");
|
||||
re!(
|
||||
STRIPE_CHILD_ARIA_RE,
|
||||
r"(?i)aria-(?:current|selected)"
|
||||
);
|
||||
re!(STRIPE_CHILD_ROUNDED_FULL_RE, format!("{B}rounded-full{B}"));
|
||||
re!(
|
||||
STRIPE_CHILD_CUE_RE,
|
||||
format!("{B}(?:shrink-0|rounded-[lres](?:-{W}+)?|left-0|right-0|inset-y-0){B}")
|
||||
);
|
||||
|
||||
/// Hyphen-safe class-token boundary: the byte before `index` must not be `-`
|
||||
/// or an ASCII word character (mirrors JS `(?<![\w-])`; the `regex` crate has
|
||||
/// no lookbehind).
|
||||
fn hyphen_safe_prefix(text: &str, index: usize) -> bool {
|
||||
match text.as_bytes().get(index.wrapping_sub(1)) {
|
||||
Some(b) if index > 0 => !b.is_ascii_alphanumeric() && *b != b'-',
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
|
||||
fn hyphen_safe_suffix(text: &str, end: usize) -> bool {
|
||||
!matches!(
|
||||
text.as_bytes().get(end),
|
||||
Some(b) if b.is_ascii_alphanumeric() || *b == b'-' || *b == b'.' || *b == b'/'
|
||||
)
|
||||
}
|
||||
|
||||
fn scope_has_fixed_height(scope: &str) -> bool {
|
||||
STRIPE_CHILD_HEIGHT_TOKEN_RE.find_iter(scope).any(|m| {
|
||||
hyphen_safe_prefix(scope, m.start())
|
||||
})
|
||||
}
|
||||
re!(BORDER_ACCENT_TW_RE, format!("{B}border-[tb]-({D}+){B}"));
|
||||
re!(
|
||||
BORDER_ACCENT_CSS_RE,
|
||||
@@ -930,29 +864,6 @@ pub static REGEX_MATCHERS: Lazy<Vec<Matcher>> = Lazy::new(|| {
|
||||
test: |m, _| num(m.g(1)) >= 3.0,
|
||||
fmt: |m, _| m.whole().to_string(),
|
||||
},
|
||||
Matcher {
|
||||
id: "side-tab",
|
||||
find_all: |l| all(&SIDE_TAB_STRIPE_CHILD_TW_RE, l),
|
||||
test: |m, line| {
|
||||
if !hyphen_safe_prefix(line, m.index)
|
||||
|| !hyphen_safe_suffix(line, m.index + m.whole().len())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let scope = containing_markup_tag(line)(m.index);
|
||||
find_solid_chromatic_bg(&scope).is_some()
|
||||
&& stripe_child_markup_empty(line, m.index)
|
||||
&& STRIPE_CHILD_CUE_RE.is_match(&scope)
|
||||
&& !scope_has_fixed_height(&scope)
|
||||
&& !STRIPE_CHILD_ROUNDED_FULL_RE.is_match(&scope)
|
||||
&& !STRIPE_CHILD_ARIA_RE.is_match(&scope)
|
||||
},
|
||||
fmt: |m, line| {
|
||||
let scope = containing_markup_tag(line)(m.index);
|
||||
let bg = find_solid_chromatic_bg(&scope).unwrap();
|
||||
format!("{} + {bg} stripe child", m.whole())
|
||||
},
|
||||
},
|
||||
Matcher {
|
||||
id: "border-accent-on-rounded",
|
||||
find_all: |l| all(&BORDER_ACCENT_TW_RE, l),
|
||||
@@ -1522,45 +1433,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stripe_child_tailwind() {
|
||||
let s = |line: &str| run("side-tab", line);
|
||||
assert_eq!(
|
||||
s(r#"<div className="w-1 shrink-0 rounded-l-lg bg-amber-500" />"#),
|
||||
vec!["w-1 + bg-amber-500 stripe child"]
|
||||
);
|
||||
assert_eq!(
|
||||
s(r#"<div class="w-[4px] bg-blue-500 shrink-0"></div>"#),
|
||||
vec!["w-[4px] + bg-blue-500 stripe child"]
|
||||
);
|
||||
assert_eq!(
|
||||
s(r#"<span className="w-0.5 bg-rose-500 shrink-0" />"#),
|
||||
vec!["w-0.5 + bg-rose-500 stripe child"]
|
||||
);
|
||||
assert_eq!(
|
||||
s(r#"<div className="w-1 min-h-0 bg-amber-500 shrink-0" />"#),
|
||||
vec!["w-1 + bg-amber-500 stripe child"]
|
||||
);
|
||||
assert!(s(r#"<div className="w-2 h-2 rounded-full bg-green-500" />"#).is_empty());
|
||||
assert!(s(
|
||||
r#"<div className="flex items-center gap-1.5"><div className="w-3 h-3 rounded bg-amber-500" /><span className="text-slate-400">Vital few</span></div>"#
|
||||
)
|
||||
.is_empty());
|
||||
assert!(s(r#"<div className="w-1 bg-amber-500/10" />"#).is_empty());
|
||||
assert!(s(r#"<a className="w-1 bg-amber-500" aria-current="page"></a>"#).is_empty());
|
||||
assert!(s(
|
||||
r#"<div className="w-1 shrink-0"><span className="bg-amber-500" /></div>"#
|
||||
)
|
||||
.is_empty());
|
||||
assert!(s(r#"<div className="w-1 bg-amber-500">|</div>"#).is_empty());
|
||||
assert!(s(r#"<div className="w-1 bg-amber-500" />"#).is_empty());
|
||||
assert_eq!(
|
||||
s(r#" className="w-1 shrink-0 rounded-l-lg bg-amber-500""#),
|
||||
vec!["w-1 + bg-amber-500 stripe child"]
|
||||
);
|
||||
assert!(s(r#"<div className="w-1 shrink-0 bg-amber-500">"#).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn matchers() {
|
||||
assert_eq!(
|
||||
|
||||
@@ -12,8 +12,9 @@
|
||||
//! element tree in document order (child nodes with their text, so
|
||||
//! `textContent` and the direct text nodes come out byte-equal), attributes,
|
||||
//! the computed-style properties the rules read (`STYLE_PROPS`, interned
|
||||
//! values), `::before` / `::after` styles where `content` is set, bounding
|
||||
//! rects, the client/scroll/offset metrics, `checkVisibility`, direct-text
|
||||
//! values), `::before` / `::after` styles where `content` is set,
|
||||
//! `::placeholder` `color` on text controls, bounding rects, the
|
||||
//! client/scroll/offset metrics, `checkVisibility`, direct-text
|
||||
//! rects, viewport and scroll, hostname, quirks mode, `body.innerText`, the
|
||||
//! `@keyframes` rules, the document HTML for the regex pass, and the media
|
||||
//! intrinsics the visual-contrast path needs.
|
||||
@@ -253,6 +254,10 @@ pub struct SnapNode {
|
||||
pub before: Option<Vec<u32>>,
|
||||
#[serde(rename = "f", default)]
|
||||
pub after: Option<Vec<u32>>,
|
||||
/// Interned `getComputedStyle(el, '::placeholder').color` when the
|
||||
/// element has a non-empty `placeholder` attribute.
|
||||
#[serde(rename = "ph", default)]
|
||||
pub placeholder_color: Option<u32>,
|
||||
/// `getBoundingClientRect` as `[x, y, width, height]`; `None` when the
|
||||
/// element has no such method.
|
||||
#[serde(rename = "r", default)]
|
||||
@@ -824,6 +829,11 @@ impl Dom for SnapshotDom {
|
||||
}
|
||||
fn pseudo_style(&self, el: ElId, pseudo: &str, prop: &str) -> Option<String> {
|
||||
let n = self.snap.node(el);
|
||||
if pseudo == "::placeholder" && prop == "color" {
|
||||
return n
|
||||
.placeholder_color
|
||||
.and_then(|idx| self.snap.strings.get(idx as usize).cloned());
|
||||
}
|
||||
let vals = match pseudo {
|
||||
"::before" | ":before" => n.before.as_ref(),
|
||||
"::after" | ":after" => n.after.as_ref(),
|
||||
@@ -999,6 +1009,27 @@ mod tests {
|
||||
assert!(d.offset_width(6).is_nan());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn placeholder_color_is_readable_as_pseudo_style() {
|
||||
let json = r#"{
|
||||
"v": 1, "hostname": "example.test", "innerWidth": 1280, "innerHeight": 800,
|
||||
"styleProps": ["display", "color"], "pseudoProps": ["content"],
|
||||
"strings": ["block", "rgb(0, 0, 0)", "rgb(187, 187, 187)"],
|
||||
"documentElement": 1, "body": 2,
|
||||
"els": [
|
||||
{"t":"HTML","c":[2],"s":[0,1],"r":[0,0,1280,800]},
|
||||
{"t":"BODY","p":1,"c":[3],"s":[0,1]},
|
||||
{"t":"INPUT","p":2,"c":[],"a":[["placeholder","Jane"]],"s":[0,1],"ph":2}
|
||||
]
|
||||
}"#;
|
||||
let d = snap(json);
|
||||
assert_eq!(
|
||||
d.pseudo_style(3, "::placeholder", "color").as_deref(),
|
||||
Some("rgb(187, 187, 187)")
|
||||
);
|
||||
assert_eq!(d.pseudo_style(2, "::placeholder", "color"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selectors_over_snapshot() {
|
||||
let d = snap(SMALL);
|
||||
|
||||
+40
-117
@@ -9,10 +9,9 @@ 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,
|
||||
};
|
||||
use crate::cascade::{expand_static_box_values, split_css_tokens, StyleValues};
|
||||
use crate::cascade::StyleValues;
|
||||
use crate::dom::{StaticDocument, StaticElement};
|
||||
use crate::quality::{collapse_ws, pf0, resolve_font_size_px};
|
||||
use impeccable_core::checks::css_scan::css_length_to_px;
|
||||
use impeccable_core::checks::measures::{
|
||||
self, border_colors_from_style, border_widths_from_style, check_gpt_thin_border_wide_shadow,
|
||||
check_oversized_h1, check_radial_spotlight, positioned_style_implies_escape, resolve_length_px,
|
||||
@@ -21,7 +20,7 @@ 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_stripe_child, is_emoji_only_text, is_heading_tag, resolve_hero_heading_size_px,
|
||||
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,
|
||||
};
|
||||
@@ -461,118 +460,6 @@ pub fn check_element_borders(
|
||||
)
|
||||
}
|
||||
|
||||
const STRIPE_CHILD_SKIP: &str = "nav, blockquote, pre, table, button, a, select, progress, meter, [role=\"progressbar\"], [role=\"slider\"], [role=\"scrollbar\"], [role=\"separator\"], [role=\"tablist\"]";
|
||||
|
||||
fn static_edge_hugs(value: &str) -> bool {
|
||||
let n = parse_float(value);
|
||||
n.is_finite() && n.abs() <= 2.0
|
||||
}
|
||||
|
||||
fn static_resolved_inset(style: &StyleValues) -> [String; 4] {
|
||||
let mut out = [
|
||||
sv(style, "top").to_string(),
|
||||
sv(style, "right").to_string(),
|
||||
sv(style, "bottom").to_string(),
|
||||
sv(style, "left").to_string(),
|
||||
];
|
||||
let inset = sv(style, "inset");
|
||||
if !inset.is_empty() {
|
||||
let expanded = expand_static_box_values(&split_css_tokens(inset));
|
||||
for (i, val) in expanded.into_iter().enumerate() {
|
||||
if out[i].is_empty() || out[i] == "auto" {
|
||||
out[i] = val;
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// JS: checks.mjs#checkElementStripeChild(el, style)
|
||||
pub fn check_element_stripe_child(el: &StaticElement<'_>, style: &StyleValues) -> Vec<RuleHit> {
|
||||
let tag = el.tag_lower();
|
||||
if tag != "div" && tag != "span" {
|
||||
return Vec::new();
|
||||
}
|
||||
let Some(host) = el.parent_element() else {
|
||||
return Vec::new();
|
||||
};
|
||||
if host.tag_lower() == "body" || host.tag_lower() == "html" {
|
||||
return Vec::new();
|
||||
}
|
||||
if !el.children().is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
if !collapsed_text_content(el).is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
if el.closest(STRIPE_CHILD_SKIP).is_some() {
|
||||
return Vec::new();
|
||||
}
|
||||
if is_tab_context_element(el) || is_status_context_element(el) {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let width = css_length_to_px(sv(style, "width")).unwrap_or_else(|| pf0(sv(style, "width")));
|
||||
let position = js::to_lower_case(sv(style, "position"));
|
||||
let host_style = host.style();
|
||||
let edge = if position == "absolute" || position == "fixed" {
|
||||
let height_raw = sv(style, "height");
|
||||
let inset = static_resolved_inset(style);
|
||||
let height_stretches =
|
||||
height_raw == "100%" || (static_edge_hugs(&inset[0]) && static_edge_hugs(&inset[2]));
|
||||
if !height_stretches {
|
||||
return Vec::new();
|
||||
}
|
||||
if static_edge_hugs(&inset[3]) {
|
||||
Some("left")
|
||||
} else if static_edge_hugs(&inset[1]) {
|
||||
Some("right")
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
let pdisplay = sv(host_style, "display");
|
||||
if !pdisplay.contains("flex") {
|
||||
return Vec::new();
|
||||
}
|
||||
let pdir = sv(host_style, "flexDirection");
|
||||
if pdir.starts_with("column") {
|
||||
return Vec::new();
|
||||
}
|
||||
let align_self = sv(style, "alignSelf");
|
||||
let effective_align = if !align_self.is_empty() && align_self != "auto" {
|
||||
align_self
|
||||
} else {
|
||||
sv(host_style, "alignItems")
|
||||
};
|
||||
let is_stretch = effective_align.is_empty()
|
||||
|| effective_align == "stretch"
|
||||
|| effective_align == "normal";
|
||||
let height_raw = sv(style, "height");
|
||||
let height_stretches =
|
||||
height_raw == "100%" || ((height_raw.is_empty() || height_raw == "auto") && is_stretch);
|
||||
if !height_stretches {
|
||||
return Vec::new();
|
||||
}
|
||||
let siblings = host.children();
|
||||
if siblings.len() < 2 {
|
||||
return Vec::new();
|
||||
}
|
||||
let reverse = pdir.contains("reverse");
|
||||
if siblings.first() == Some(el) {
|
||||
Some(if reverse { "right" } else { "left" })
|
||||
} else if siblings.last() == Some(el) {
|
||||
Some(if reverse { "left" } else { "right" })
|
||||
} else {
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
let bg_raw = sv(style, "backgroundColor");
|
||||
let bg = parse_rgb(Some(&bg_raw)).or_else(|| parse_any_color(Some(&bg_raw)));
|
||||
check_stripe_child(&class_selector(el), width, edge, bg)
|
||||
}
|
||||
|
||||
/// JS: checks.mjs#checkElementColors(el, style, tag, window, customPropMap, hasAnchorInheritRule)
|
||||
pub fn check_element_colors(
|
||||
el: &StaticElement<'_>,
|
||||
@@ -652,7 +539,7 @@ pub fn check_element_colors(
|
||||
sv(style, "backgroundClip")
|
||||
}
|
||||
};
|
||||
check_colors(&ColorOpts {
|
||||
let color_opts = ColorOpts {
|
||||
tag: tag.to_string(),
|
||||
text_color,
|
||||
bg_color: own_bg,
|
||||
@@ -670,7 +557,43 @@ pub fn check_element_colors(
|
||||
bg_image: Some(sv(style, "backgroundImage").to_string()),
|
||||
class_list: Some(el.class_name().to_string()),
|
||||
detector_is_browser: false,
|
||||
})
|
||||
};
|
||||
let mut findings = check_colors(&color_opts);
|
||||
if tag == "input" || tag == "textarea" {
|
||||
let placeholder = el.get_attribute("placeholder").unwrap_or("").trim();
|
||||
if !placeholder.is_empty() {
|
||||
let skip = if tag == "input" {
|
||||
let t = js::to_lower_case(el.get_attribute("type").unwrap_or("text"));
|
||||
matches!(
|
||||
t.as_str(),
|
||||
"hidden" | "checkbox" | "radio" | "file" | "submit" | "button" | "image"
|
||||
| "reset" | "range" | "color"
|
||||
) || el
|
||||
.get_attribute("value")
|
||||
.is_some_and(|v| !js::trim(v).is_empty())
|
||||
} else {
|
||||
!js::trim(&direct_text).is_empty()
|
||||
};
|
||||
if !skip {
|
||||
if let Some(ph_style) = el.doc.get_placeholder_style(el.id()) {
|
||||
let ph_color = custom_props
|
||||
.and_then(|m| {
|
||||
measures::parse_color_resolved(sv_opt(ph_style, "color"), Some(m))
|
||||
})
|
||||
.or_else(|| parse_rgb(sv_opt(ph_style, "color")))
|
||||
.or_else(|| parse_any_color(sv_opt(ph_style, "color")));
|
||||
if let Some(ph_color) = ph_color {
|
||||
findings.extend(check_placeholder_colors(
|
||||
&color_opts,
|
||||
placeholder,
|
||||
ph_color,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
findings
|
||||
}
|
||||
|
||||
/// JS: checks.mjs#checkElementHoverContrast(el, style, tag, window)
|
||||
|
||||
@@ -139,6 +139,31 @@ static PSEUDO_RULE_RE: Lazy<Regex> = Lazy::new(|| {
|
||||
))
|
||||
.expect("PSEUDO_RULE_RE")
|
||||
});
|
||||
static PLACEHOLDER_RULE_RE: Lazy<Regex> = Lazy::new(|| {
|
||||
Regex::new(r"(?i)^(.*)(?:::placeholder|::?-webkit-input-placeholder|::?-moz-placeholder)$")
|
||||
.expect("PLACEHOLDER_RULE_RE")
|
||||
});
|
||||
|
||||
fn placeholder_host_selector(selector: &str) -> Option<String> {
|
||||
let pm = PLACEHOLDER_RULE_RE.captures(selector)?;
|
||||
let captured = pm.get(1).map(|m| m.as_str()).unwrap_or("");
|
||||
let trimmed_end = captured.trim_end_matches(|c: char| js::is_js_whitespace(c));
|
||||
if trimmed_end.is_empty() {
|
||||
return Some("*".to_string());
|
||||
}
|
||||
// Only fill a trailing empty compound. `star_empty_compounds` would
|
||||
// rewrite `.label + ::placeholder` to `.label *+*`.
|
||||
let last = trimmed_end.chars().last().unwrap();
|
||||
if captured.len() != trimmed_end.len() || last == '>' || last == '+' || last == '~' {
|
||||
if last == '>' || last == '+' || last == '~' {
|
||||
Some(format!("{}*", trimmed_end))
|
||||
} else {
|
||||
Some(format!("{} *", trimmed_end))
|
||||
}
|
||||
} else {
|
||||
Some(trimmed_end.to_string())
|
||||
}
|
||||
}
|
||||
static COLOR_TOKEN_RE: Lazy<Regex> = Lazy::new(|| {
|
||||
Regex::new(r"(?i)(?:rgba?|hsla?|oklch|oklab|lab|lch|hwb|color-mix)\([^)]*(?:\([^)]*\))?[^)]*\)|#[0-9a-f]{3,8}(?-u:\b)")
|
||||
.expect("COLOR_TOKEN_RE")
|
||||
@@ -252,6 +277,7 @@ pub fn build_static_style_map(
|
||||
) {
|
||||
let mut specified: SpecifiedStore<NodeId> = SpecifiedStore::new();
|
||||
let mut hover_specified: SpecifiedStore<NodeId> = SpecifiedStore::new();
|
||||
let mut placeholder_specified: SpecifiedStore<NodeId> = SpecifiedStore::new();
|
||||
let root_custom_props = collect_css_custom_props(css_text);
|
||||
let rules = profile::step(
|
||||
profile,
|
||||
@@ -264,7 +290,12 @@ pub fn build_static_style_map(
|
||||
Meta::new("selector-match", "css-selectors", file_path),
|
||||
|| {
|
||||
for rule in &rules {
|
||||
if !rule.is_hover {
|
||||
let placeholder_host = if rule.is_hover {
|
||||
None
|
||||
} else {
|
||||
placeholder_host_selector(&rule.selector)
|
||||
};
|
||||
if !rule.is_hover && placeholder_host.is_none() {
|
||||
if let Some(pm) = PSEUDO_RULE_RE.captures(&rule.selector) {
|
||||
let base = pm.get(1).map(|m| m.as_str()).unwrap_or("").to_string();
|
||||
mark_pseudo_rule(doc, rule, &base, &root_custom_props);
|
||||
@@ -273,6 +304,8 @@ pub fn build_static_style_map(
|
||||
}
|
||||
let match_selector: Option<&str> = if rule.is_hover {
|
||||
rule.match_selector.as_deref()
|
||||
} else if let Some(ref host) = placeholder_host {
|
||||
Some(host.as_str())
|
||||
} else {
|
||||
Some(rule.selector.as_str())
|
||||
};
|
||||
@@ -296,11 +329,18 @@ pub fn build_static_style_map(
|
||||
};
|
||||
let store = if rule.is_hover {
|
||||
&mut hover_specified
|
||||
} else if placeholder_host.is_some() {
|
||||
&mut placeholder_specified
|
||||
} else {
|
||||
&mut specified
|
||||
};
|
||||
for node in matched {
|
||||
for decl in &rule.declarations {
|
||||
if placeholder_host.is_some()
|
||||
&& js::to_lower_case(&decl.prop) != "color"
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let meta = DeclMeta {
|
||||
important: decl.important,
|
||||
specificity: rule.specificity,
|
||||
@@ -341,7 +381,7 @@ pub fn build_static_style_map(
|
||||
profile,
|
||||
Meta::new("cascade", "compute-styles", file_path),
|
||||
|| {
|
||||
compute_styles(doc, &specified, &hover_specified);
|
||||
compute_styles(doc, &specified, &hover_specified, &placeholder_specified);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -353,6 +393,7 @@ fn compute_styles(
|
||||
doc: &mut StaticDocument,
|
||||
specified: &SpecifiedStore<NodeId>,
|
||||
hover_specified: &SpecifiedStore<NodeId>,
|
||||
placeholder_specified: &SpecifiedStore<NodeId>,
|
||||
) {
|
||||
let mut computed: HashMap<NodeId, Rc<StyleValues>> = HashMap::new();
|
||||
let mut customs: HashMap<NodeId, Rc<CustomProps>> = HashMap::new();
|
||||
@@ -368,6 +409,7 @@ fn compute_styles(
|
||||
.map(|e| (e.id(), None))
|
||||
.collect();
|
||||
let mut hover_out: Vec<(NodeId, StyleValues)> = Vec::new();
|
||||
let mut placeholder_out: Vec<(NodeId, StyleValues)> = Vec::new();
|
||||
|
||||
while let Some((node, parent)) = stack.pop() {
|
||||
let parent_style: Option<Rc<StyleValues>> = parent.and_then(|p| computed.get(&p).cloned());
|
||||
@@ -439,6 +481,21 @@ fn compute_styles(
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(ph_map) = placeholder_specified.get(&node) {
|
||||
if let Some(color_decl) = ph_map.get("color") {
|
||||
let next = normalize_static_css_value(
|
||||
"color",
|
||||
&color_decl.value,
|
||||
&custom_props,
|
||||
Some(&values),
|
||||
Some(&values),
|
||||
);
|
||||
let mut ph_style = StyleValues::default();
|
||||
ph_style.insert("color".to_string(), next);
|
||||
placeholder_out.push((node, ph_style));
|
||||
}
|
||||
}
|
||||
|
||||
let style_rc = Rc::new(values);
|
||||
computed.insert(node, style_rc);
|
||||
customs.insert(node, Rc::new(custom_props));
|
||||
@@ -460,6 +517,9 @@ fn compute_styles(
|
||||
for (node, style) in hover_out {
|
||||
doc.set_hover_style(node, style);
|
||||
}
|
||||
for (node, style) in placeholder_out {
|
||||
doc.set_placeholder_style(node, style);
|
||||
}
|
||||
}
|
||||
|
||||
/// `STATIC_DEFAULT_STYLE[prop]` lookup re-exported for the adapters.
|
||||
|
||||
@@ -184,9 +184,6 @@ pub const STATIC_PROP_MAP: &[(&str, &str)] = &[
|
||||
("left", "left"),
|
||||
("inset", "inset"),
|
||||
("display", "display"),
|
||||
("flex-direction", "flexDirection"),
|
||||
("align-items", "alignItems"),
|
||||
("align-self", "alignSelf"),
|
||||
("overflow", "overflow"),
|
||||
("overflow-x", "overflowX"),
|
||||
("overflow-y", "overflowY"),
|
||||
|
||||
@@ -65,8 +65,7 @@
|
||||
//! parse in the same process).
|
||||
|
||||
use super::csstree::{self, Important, Node};
|
||||
use super::shorthand::{expand_static_box_values, expand_static_declaration, Expanded};
|
||||
use super::values::split_css_tokens;
|
||||
use super::shorthand::expand_static_declaration;
|
||||
use impeccable_core::js;
|
||||
use indexmap::IndexMap;
|
||||
use once_cell::sync::Lazy;
|
||||
@@ -178,32 +177,6 @@ impl<K: Hash + Eq> SpecifiedStore<K> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Layout properties the stripe-child static adapter needs that are not in
|
||||
/// the frozen `expandStaticDeclaration` allowlist. Applied here so the
|
||||
/// recorded vectors stay byte-equal.
|
||||
fn extra_specified_expansions(prop: &str, value: &str) -> Vec<Expanded> {
|
||||
let p = js::to_lower_case(prop);
|
||||
let v = js::trim(value);
|
||||
if v.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
match p.as_str() {
|
||||
"flex-direction" => vec![("flexDirection".into(), v.to_string())],
|
||||
"align-items" => vec![("alignItems".into(), v.to_string())],
|
||||
"align-self" => vec![("alignSelf".into(), v.to_string())],
|
||||
"inset" => {
|
||||
let vals = expand_static_box_values(&split_css_tokens(v));
|
||||
vec![
|
||||
("top".into(), vals[0].clone()),
|
||||
("right".into(), vals[1].clone()),
|
||||
("bottom".into(), vals[2].clone()),
|
||||
("left".into(), vals[3].clone()),
|
||||
]
|
||||
}
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// JS: css-cascade.mjs#applyStaticDeclaration(specified, node, prop, value, meta)
|
||||
pub fn apply_static_declaration<K: Hash + Eq>(
|
||||
specified: &mut SpecifiedStore<K>,
|
||||
@@ -213,11 +186,7 @@ pub fn apply_static_declaration<K: Hash + Eq>(
|
||||
meta: &DeclMeta,
|
||||
) {
|
||||
let map = specified.map.entry(node).or_default();
|
||||
let extra = extra_specified_expansions(prop, value);
|
||||
for (expanded_prop, expanded_value) in expand_static_declaration(prop, value)
|
||||
.into_iter()
|
||||
.chain(extra)
|
||||
{
|
||||
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 {
|
||||
|
||||
@@ -55,6 +55,7 @@ pub struct StaticDocument {
|
||||
pub html: Html,
|
||||
styles: HashMap<NodeId, StyleValues>,
|
||||
hover_styles: HashMap<NodeId, StyleValues>,
|
||||
placeholder_styles: HashMap<NodeId, StyleValues>,
|
||||
accent_dash: HashSet<NodeId>,
|
||||
pseudo_surface: HashMap<NodeId, Rgba>,
|
||||
selector_cache: RefCell<HashMap<String, Result<Selector, SelectorError>>>,
|
||||
@@ -174,6 +175,7 @@ impl StaticDocument {
|
||||
html,
|
||||
styles: HashMap::new(),
|
||||
hover_styles: HashMap::new(),
|
||||
placeholder_styles: HashMap::new(),
|
||||
accent_dash: HashSet::new(),
|
||||
pseudo_surface: HashMap::new(),
|
||||
selector_cache: RefCell::new(HashMap::new()),
|
||||
@@ -319,6 +321,12 @@ impl StaticDocument {
|
||||
pub fn get_hover_style(&self, node: NodeId) -> Option<&StyleValues> {
|
||||
self.hover_styles.get(&node)
|
||||
}
|
||||
pub fn set_placeholder_style(&mut self, node: NodeId, style: StyleValues) {
|
||||
self.placeholder_styles.insert(node, style);
|
||||
}
|
||||
pub fn get_placeholder_style(&self, node: NodeId) -> Option<&StyleValues> {
|
||||
self.placeholder_styles.get(&node)
|
||||
}
|
||||
pub fn set_accent_dash_pseudo(&mut self, node: NodeId) {
|
||||
self.accent_dash.insert(node);
|
||||
}
|
||||
|
||||
@@ -14,9 +14,8 @@ use crate::adapters::{
|
||||
check_element_colors, check_element_glow, check_element_gpt_border_shadow,
|
||||
check_element_hero_eyebrow, check_element_hover_contrast, check_element_icon_tile,
|
||||
check_element_italic_serif, check_element_motion, check_element_oversized_h1,
|
||||
check_element_radial_spotlight, check_element_stripe_child,
|
||||
check_kicker_above_heading_from_doc, check_numbered_section_labels_from_doc,
|
||||
scoped_ignore_active,
|
||||
check_element_radial_spotlight, check_kicker_above_heading_from_doc,
|
||||
check_numbered_section_labels_from_doc, scoped_ignore_active,
|
||||
};
|
||||
use crate::background::{resolve_background, resolve_border_radius_px, sv};
|
||||
use crate::cascade::{build_static_style_map, collect_static_css_text};
|
||||
@@ -110,7 +109,6 @@ const STATIC_ELEMENT_RULES: &[(&str, &str)] = &[
|
||||
("dark-glow", "*"),
|
||||
("motion-rules", "*"),
|
||||
("icon-tile-stack", "h1,h2,h3,h4,h5,h6"),
|
||||
("stripe-child", "div,span"),
|
||||
("italic-serif-display", "h1,h2"),
|
||||
("hero-eyebrow-chip", "h1"),
|
||||
("broken-image", "img"),
|
||||
@@ -136,7 +134,6 @@ fn run_rule(rule_id: &str, el: &StaticElement<'_>, tag: &str) -> Vec<RuleHit> {
|
||||
}
|
||||
"motion-rules" => check_element_motion(tag, style),
|
||||
"icon-tile-stack" => check_element_icon_tile(el, tag),
|
||||
"stripe-child" => check_element_stripe_child(el, style),
|
||||
"italic-serif-display" => check_element_italic_serif(el, style, tag),
|
||||
"hero-eyebrow-chip" => check_element_hero_eyebrow(el, style, tag),
|
||||
"broken-image" => check_element_broken_image(el),
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
//! Integration tests for `::placeholder` contrast detection (#790).
|
||||
|
||||
use impeccable_html::{detect_html_source, DetectHtmlOptions};
|
||||
use std::path::Path;
|
||||
|
||||
const ISSUER_REPRO: &str = r#"<!DOCTYPE html>
|
||||
<html><head><style>
|
||||
input::placeholder { color: #bbbbbb; }
|
||||
input { background: white; font-size: 16px; width: 200px; height: 40px; border: 1px solid #ccc; padding: 8px; box-sizing: border-box; }
|
||||
</style></head>
|
||||
<body><input placeholder="Search"></body></html>
|
||||
"#;
|
||||
|
||||
fn scan(html: &str) -> Vec<impeccable_core::findings::Finding> {
|
||||
detect_html_source(html, Path::new("/tmp/placeholder.html"), &DetectHtmlOptions::default())
|
||||
}
|
||||
|
||||
fn repo_root() -> std::path::PathBuf {
|
||||
std::env::var("IMPECCABLE_PUBLIC_REPO")
|
||||
.map(std::path::PathBuf::from)
|
||||
.unwrap_or_else(|_| Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn issuer_repro_flags_pale_placeholder() {
|
||||
let findings = scan(ISSUER_REPRO);
|
||||
assert!(
|
||||
findings.iter().any(|f| f.antipattern == "low-contrast"),
|
||||
"expected low-contrast finding, got {findings:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bare_placeholder_selector_flags() {
|
||||
let html = r#"<!DOCTYPE html>
|
||||
<html><head><style>
|
||||
::placeholder { color: #bbbbbb; }
|
||||
input { background: white; font-size: 16px; width: 200px; height: 40px; }
|
||||
</style></head>
|
||||
<body><input placeholder="Search"></body></html>
|
||||
"#;
|
||||
let findings = scan(html);
|
||||
assert!(
|
||||
findings.iter().any(|f| f.antipattern == "low-contrast"),
|
||||
"expected low-contrast for bare ::placeholder, got {findings:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn descendant_placeholder_selector_flags() {
|
||||
let html = r#"<!DOCTYPE html>
|
||||
<html><head><style>
|
||||
.form ::placeholder { color: #bbbbbb; }
|
||||
input { background: white; font-size: 16px; width: 200px; height: 40px; }
|
||||
</style></head>
|
||||
<body><div class="form"><input placeholder="Search"></div></body></html>
|
||||
"#;
|
||||
let findings = scan(html);
|
||||
assert!(
|
||||
findings.iter().any(|f| f.antipattern == "low-contrast"),
|
||||
"expected low-contrast for descendant ::placeholder, got {findings:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sibling_placeholder_selector_flags() {
|
||||
let html = r#"<!DOCTYPE html>
|
||||
<html><head><style>
|
||||
.label + ::placeholder { color: #bbbbbb; }
|
||||
input { background: white; font-size: 16px; width: 200px; height: 40px; }
|
||||
</style></head>
|
||||
<body><label class="label">Name</label><input placeholder="Search"></body></html>
|
||||
"#;
|
||||
let findings = scan(html);
|
||||
assert!(
|
||||
findings.iter().any(|f| f.antipattern == "low-contrast"),
|
||||
"expected low-contrast for sibling ::placeholder, got {findings:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fixture_flag_and_pass_cases() {
|
||||
let fixture = repo_root().join("tests/fixtures/antipatterns/placeholder-contrast.html");
|
||||
assert!(
|
||||
fixture.is_file(),
|
||||
"missing fixture at {}",
|
||||
fixture.display()
|
||||
);
|
||||
let html = std::fs::read_to_string(&fixture).unwrap();
|
||||
let findings = detect_html_source(&html, &fixture, &DetectHtmlOptions::default());
|
||||
let ids: Vec<&str> = findings.iter().map(|f| f.antipattern.as_str()).collect();
|
||||
let snippets: Vec<&str> = findings.iter().map(|f| f.snippet.as_str()).collect();
|
||||
|
||||
for needle in [
|
||||
"Pale Placeholder On White Field",
|
||||
"Pale Placeholder On White Textarea",
|
||||
"Translucent Placeholder On Light Field",
|
||||
"Pale Placeholder On Frosted Panel",
|
||||
] {
|
||||
assert!(
|
||||
snippets.iter().any(|s| s.contains(needle)),
|
||||
"expected flag for placeholder {needle:?}, findings={findings:?}"
|
||||
);
|
||||
}
|
||||
|
||||
for needle in [
|
||||
"Ink Placeholder On White Field",
|
||||
"Light Placeholder On Dark Field",
|
||||
"Filled Field Hides Placeholder",
|
||||
"Unstyled Placeholder Uses UA Color",
|
||||
] {
|
||||
assert!(
|
||||
!snippets.iter().any(|s| s.contains(needle)),
|
||||
"pass case {needle:?} should not flag, findings={findings:?}"
|
||||
);
|
||||
}
|
||||
|
||||
assert!(
|
||||
ids.iter().filter(|id| **id == "low-contrast").count() >= 4,
|
||||
"expected at least four low-contrast hits, got {findings:?}"
|
||||
);
|
||||
}
|
||||
@@ -1,167 +0,0 @@
|
||||
use impeccable_html::{detect_html_source, DetectHtmlOptions};
|
||||
use std::path::Path;
|
||||
|
||||
fn side_tab_snippets(html: &str) -> Vec<String> {
|
||||
detect_html_source(
|
||||
html,
|
||||
Path::new("/app/stripe.html"),
|
||||
&DetectHtmlOptions::default(),
|
||||
)
|
||||
.into_iter()
|
||||
.filter(|f| f.antipattern == "side-tab")
|
||||
.map(|f| f.snippet)
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flex_row_first_child_flags() {
|
||||
let html = r#"<!DOCTYPE html><html><head><style>
|
||||
.card { display: flex; flex-direction: row; width: 320px; height: 100px; }
|
||||
.stripe { width: 4px; background: #f59e0b; }
|
||||
.body { flex: 1; }
|
||||
</style></head><body>
|
||||
<div class="card"><div class="stripe"></div><div class="body">Content</div></div>
|
||||
</body></html>"#;
|
||||
let hits = side_tab_snippets(html);
|
||||
assert_eq!(hits.len(), 1);
|
||||
assert!(hits[0].contains("stripe child (left)"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn absolute_left_inset_flags() {
|
||||
let html = r#"<!DOCTYPE html><html><head><style>
|
||||
.card { position: relative; width: 320px; height: 100px; }
|
||||
.stripe { position: absolute; inset: 0 auto 0 0; width: 4px; background: #3b82f6; }
|
||||
</style></head><body>
|
||||
<div class="card"><div class="stripe"></div></div>
|
||||
</body></html>"#;
|
||||
let hits = side_tab_snippets(html);
|
||||
assert_eq!(hits.len(), 1);
|
||||
assert!(hits[0].contains("stripe child (left)"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn absolute_top_bottom_flags() {
|
||||
let html = r#"<!DOCTYPE html><html><head><style>
|
||||
.card { position: relative; width: 320px; height: 100px; }
|
||||
.stripe { position: absolute; left: 0; top: 0; bottom: 0; width: 4px; background: #3b82f6; }
|
||||
</style></head><body>
|
||||
<div class="card"><div class="stripe"></div></div>
|
||||
</body></html>"#;
|
||||
let hits = side_tab_snippets(html);
|
||||
assert_eq!(hits.len(), 1);
|
||||
assert!(hits[0].contains("stripe child (left)"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flex_column_does_not_flag() {
|
||||
let html = r#"<!DOCTYPE html><html><head><style>
|
||||
.card { display: flex; flex-direction: column; width: 320px; height: 100px; }
|
||||
.stripe { width: 4px; background: #f59e0b; }
|
||||
</style></head><body>
|
||||
<div class="card"><div class="stripe"></div><div>Body</div></div>
|
||||
</body></html>"#;
|
||||
assert!(side_tab_snippets(html).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn align_items_center_does_not_flag() {
|
||||
let html = r#"<!DOCTYPE html><html><head><style>
|
||||
.card { display: flex; align-items: center; width: 320px; height: 100px; }
|
||||
.stripe { width: 4px; background: #f59e0b; }
|
||||
</style></head><body>
|
||||
<div class="card"><div class="stripe"></div><div>Body</div></div>
|
||||
</body></html>"#;
|
||||
assert!(side_tab_snippets(html).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn align_self_flex_start_does_not_flag() {
|
||||
let html = r#"<!DOCTYPE html><html><head><style>
|
||||
.card { display: flex; width: 320px; height: 100px; }
|
||||
.stripe { width: 4px; align-self: flex-start; background: #f59e0b; }
|
||||
</style></head><body>
|
||||
<div class="card"><div class="stripe"></div><div>Body</div></div>
|
||||
</body></html>"#;
|
||||
assert!(side_tab_snippets(html).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn neutral_and_contentful_and_wide_do_not_flag() {
|
||||
let neutral = r#"<!DOCTYPE html><html><head><style>
|
||||
.card { display: flex; width: 320px; height: 100px; }
|
||||
.stripe { width: 4px; background: #e5e5e5; }
|
||||
</style></head><body>
|
||||
<div class="card"><div class="stripe"></div><div>Body</div></div>
|
||||
</body></html>"#;
|
||||
assert!(side_tab_snippets(neutral).is_empty());
|
||||
|
||||
let text = r#"<!DOCTYPE html><html><head><style>
|
||||
.card { display: flex; width: 320px; height: 100px; }
|
||||
.stripe { width: 4px; background: #f59e0b; }
|
||||
</style></head><body>
|
||||
<div class="card"><div class="stripe">!</div><div>Body</div></div>
|
||||
</body></html>"#;
|
||||
assert!(side_tab_snippets(text).is_empty());
|
||||
|
||||
let wide = r#"<!DOCTYPE html><html><head><style>
|
||||
.card { display: flex; width: 320px; height: 100px; }
|
||||
.stripe { width: 40px; background: #f59e0b; }
|
||||
</style></head><body>
|
||||
<div class="card"><div class="stripe"></div><div>Body</div></div>
|
||||
</body></html>"#;
|
||||
assert!(side_tab_snippets(wide).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rem_width_flags() {
|
||||
let html = r#"<!DOCTYPE html><html><head><style>
|
||||
.card { display: flex; width: 320px; height: 100px; }
|
||||
.stripe { width: 0.25rem; background: #f59e0b; }
|
||||
</style></head><body>
|
||||
<div class="card"><div class="stripe"></div><div>Body</div></div>
|
||||
</body></html>"#;
|
||||
let hits = side_tab_snippets(html);
|
||||
assert_eq!(hits.len(), 1);
|
||||
assert!(hits[0].contains("stripe child (left)"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn height_full_with_align_center_flags() {
|
||||
let html = r#"<!DOCTYPE html><html><head><style>
|
||||
.card { display: flex; align-items: center; width: 320px; height: 100px; }
|
||||
.stripe { width: 4px; height: 100%; background: #f59e0b; }
|
||||
</style></head><body>
|
||||
<div class="card"><div class="stripe"></div><div>Body</div></div>
|
||||
</body></html>"#;
|
||||
let hits = side_tab_snippets(html);
|
||||
assert_eq!(hits.len(), 1);
|
||||
assert!(hits[0].contains("stripe child (left)"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inset_after_left_longhand_flags() {
|
||||
let html = r#"<!DOCTYPE html><html><head><style>
|
||||
.card { position: relative; width: 320px; height: 100px; }
|
||||
.stripe { position: absolute; left: 10px; inset: 0 auto 0 0; width: 4px; background: #3b82f6; }
|
||||
</style></head><body>
|
||||
<div class="card"><div class="stripe"></div></div>
|
||||
</body></html>"#;
|
||||
let hits = side_tab_snippets(html);
|
||||
assert_eq!(hits.len(), 1);
|
||||
assert!(hits[0].contains("stripe child (left)"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn row_reverse_first_child_is_right() {
|
||||
let html = r#"<!DOCTYPE html><html><head><style>
|
||||
.card { display: flex; flex-direction: row-reverse; width: 320px; height: 100px; }
|
||||
.stripe { width: 4px; background: #f59e0b; }
|
||||
.body { flex: 1; }
|
||||
</style></head><body>
|
||||
<div class="card"><div class="stripe"></div><div class="body">Content</div></div>
|
||||
</body></html>"#;
|
||||
let hits = side_tab_snippets(html);
|
||||
assert_eq!(hits.len(), 1);
|
||||
assert!(hits[0].contains("stripe child (right)"));
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -188,6 +188,12 @@ Test thoroughly across contexts:
|
||||
- **Edge cases**: Very small screens (320px), very large screens (4K)
|
||||
- **Slow connections**: Test on throttled network
|
||||
|
||||
**Custom controls** (sliders, drag surfaces, scrollable control strips): a before/after slider can pass every width check above and still refuse to drag on iOS, so exercise each one in scope in the same batched round as the checks above:
|
||||
|
||||
- **Primary gesture**: Tap it and confirm it responds as designed, then drag it with the target input method; the drag must complete, not just start
|
||||
- **Scroll across it**: A swipe along the page's scroll axis across the control scrolls the page or container without activating it; a drag that starts on the control along its axis moves the control, not the page. Neither failure throws an error, so try both
|
||||
- **Evidence**: Say what produced the evidence: an emulated viewport, synthesized touch input through a browser tool, which engine ran it (Chromium is not Safari), or a physical device. Screenshots and resized viewports verify layout, never a gesture. Name what stayed untested and move on; unreachable hardware is a reported gap, not a blocker
|
||||
|
||||
When the adaptation feels native to each context, hand off to `/impeccable polish` for the final pass.
|
||||
|
||||
---
|
||||
|
||||
@@ -48,11 +48,12 @@ Run comprehensive checks across 5 dimensions. Score each dimension 0-4 using the
|
||||
**Check for**:
|
||||
- **Fixed widths**: Hard-coded widths that break on mobile
|
||||
- **Touch targets**: Interactive elements < 44x44px
|
||||
- **Broken touch interaction**: Custom sliders, drag surfaces, and scrollable control strips whose primary gesture fails under touch, that swallow page scroll or lose the drag to it, or that stay stuck after an interrupted gesture. Code tells: mouse-only handlers, no `touch-action` on a pointer-event drag surface, drag state that nothing clears on cancel, lost capture, or blur. Exercise the gesture when a browser tool can synthesize touch (a rendered viewport proves layout, not the gesture), then say what produced the evidence (emulated viewport, synthesized touch, which engine, physical device) and what stayed untested
|
||||
- **Horizontal scroll**: Content overflow on narrow viewports
|
||||
- **Text scaling**: Layouts that break when text size increases
|
||||
- **Missing breakpoints**: No mobile/tablet variants
|
||||
|
||||
**Score 0-4**: 0=Desktop-only (breaks on mobile), 1=Major issues (some breakpoints, many failures), 2=Partial (works on mobile, rough edges), 3=Good (responsive, minor touch target or overflow issues), 4=Excellent (fluid, all viewports, proper touch targets)
|
||||
**Score 0-4**: 0=Desktop-only (breaks on mobile), 1=Major issues (some breakpoints, many failures), 2=Partial (works on mobile, rough edges), 3=Good (responsive, minor touch target or overflow issues), 4=Excellent (fluid, all viewports, proper touch targets, gestures work under touch)
|
||||
|
||||
### 5. Implementation Integrity (CRITICAL)
|
||||
|
||||
|
||||
@@ -205,6 +205,11 @@ t('items', { count }) // Handles complex plural rules
|
||||
- Optimistic updates with rollback
|
||||
- Conflict resolution
|
||||
|
||||
**Interrupted gestures** (custom sliders, drag surfaces, scrollable control strips):
|
||||
- A second finger or pointer lands mid-drag: the first drag keeps its pointer or ends cleanly, never jumps to the new one
|
||||
- The browser cancels the gesture to scroll (`pointercancel`), capture is lost (`lostpointercapture`), the pointer is released outside the control, or the window loses focus (`blur`) mid-drag: clear the dragging state and release capture
|
||||
- After each of these, the next tap or drag works without a reload
|
||||
|
||||
**Permission states**:
|
||||
- No permission to view
|
||||
- No permission to edit
|
||||
@@ -304,6 +309,7 @@ const throttledScroll = throttle(handleScroll, 100);
|
||||
- Unit tests for edge cases
|
||||
- Integration tests for error scenarios
|
||||
- E2E tests for critical paths
|
||||
- A behavioral regression for each confirmed gesture fix, when the project's test runner can drive input
|
||||
- Visual regression tests
|
||||
- Accessibility tests (axe, WAVE)
|
||||
|
||||
@@ -330,7 +336,10 @@ Test thoroughly with edge cases:
|
||||
- **Network issues**: Disable internet, throttle connection
|
||||
- **Large datasets**: Test with 1000+ items
|
||||
- **Concurrent actions**: Click submit 10 times rapidly
|
||||
- **Interrupted gestures**: Add a second finger mid-drag, scroll across the control, release outside it, switch windows mid-drag; then drag again
|
||||
- **Errors**: Force API errors, test all error states
|
||||
- **Empty**: Remove all data, test empty states
|
||||
|
||||
For gestures, say what produced the evidence (emulated viewport, synthesized touch, which engine, physical device) and name what stayed untested.
|
||||
|
||||
When edge cases are covered, hand off to `/impeccable polish` for the final pass.
|
||||
|
||||
@@ -332,7 +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.
|
||||
- `detectText`: regex line matchers (ids: side-tab including Tailwind stripe-child empty `w-*` + chromatic `bg-*` plus a `shrink-0` / edge-round cue, 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.
|
||||
- `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,6 +188,12 @@ Test thoroughly across contexts:
|
||||
- **Edge cases**: Very small screens (320px), very large screens (4K)
|
||||
- **Slow connections**: Test on throttled network
|
||||
|
||||
**Custom controls** (sliders, drag surfaces, scrollable control strips): a before/after slider can pass every width check above and still refuse to drag on iOS, so exercise each one in scope in the same batched round as the checks above:
|
||||
|
||||
- **Primary gesture**: Tap it and confirm it responds as designed, then drag it with the target input method; the drag must complete, not just start
|
||||
- **Scroll across it**: A swipe along the page's scroll axis across the control scrolls the page or container without activating it; a drag that starts on the control along its axis moves the control, not the page. Neither failure throws an error, so try both
|
||||
- **Evidence**: Say what produced the evidence: an emulated viewport, synthesized touch input through a browser tool, which engine ran it (Chromium is not Safari), or a physical device. Screenshots and resized viewports verify layout, never a gesture. Name what stayed untested and move on; unreachable hardware is a reported gap, not a blocker
|
||||
|
||||
When the adaptation feels native to each context, hand off to `/impeccable polish` for the final pass.
|
||||
|
||||
---
|
||||
|
||||
@@ -48,11 +48,12 @@ Run comprehensive checks across 5 dimensions. Score each dimension 0-4 using the
|
||||
**Check for**:
|
||||
- **Fixed widths**: Hard-coded widths that break on mobile
|
||||
- **Touch targets**: Interactive elements < 44x44px
|
||||
- **Broken touch interaction**: Custom sliders, drag surfaces, and scrollable control strips whose primary gesture fails under touch, that swallow page scroll or lose the drag to it, or that stay stuck after an interrupted gesture. Code tells: mouse-only handlers, no `touch-action` on a pointer-event drag surface, drag state that nothing clears on cancel, lost capture, or blur. Exercise the gesture when a browser tool can synthesize touch (a rendered viewport proves layout, not the gesture), then say what produced the evidence (emulated viewport, synthesized touch, which engine, physical device) and what stayed untested
|
||||
- **Horizontal scroll**: Content overflow on narrow viewports
|
||||
- **Text scaling**: Layouts that break when text size increases
|
||||
- **Missing breakpoints**: No mobile/tablet variants
|
||||
|
||||
**Score 0-4**: 0=Desktop-only (breaks on mobile), 1=Major issues (some breakpoints, many failures), 2=Partial (works on mobile, rough edges), 3=Good (responsive, minor touch target or overflow issues), 4=Excellent (fluid, all viewports, proper touch targets)
|
||||
**Score 0-4**: 0=Desktop-only (breaks on mobile), 1=Major issues (some breakpoints, many failures), 2=Partial (works on mobile, rough edges), 3=Good (responsive, minor touch target or overflow issues), 4=Excellent (fluid, all viewports, proper touch targets, gestures work under touch)
|
||||
|
||||
### 5. Implementation Integrity (CRITICAL)
|
||||
|
||||
|
||||
@@ -205,6 +205,11 @@ t('items', { count }) // Handles complex plural rules
|
||||
- Optimistic updates with rollback
|
||||
- Conflict resolution
|
||||
|
||||
**Interrupted gestures** (custom sliders, drag surfaces, scrollable control strips):
|
||||
- A second finger or pointer lands mid-drag: the first drag keeps its pointer or ends cleanly, never jumps to the new one
|
||||
- The browser cancels the gesture to scroll (`pointercancel`), capture is lost (`lostpointercapture`), the pointer is released outside the control, or the window loses focus (`blur`) mid-drag: clear the dragging state and release capture
|
||||
- After each of these, the next tap or drag works without a reload
|
||||
|
||||
**Permission states**:
|
||||
- No permission to view
|
||||
- No permission to edit
|
||||
@@ -304,6 +309,7 @@ const throttledScroll = throttle(handleScroll, 100);
|
||||
- Unit tests for edge cases
|
||||
- Integration tests for error scenarios
|
||||
- E2E tests for critical paths
|
||||
- A behavioral regression for each confirmed gesture fix, when the project's test runner can drive input
|
||||
- Visual regression tests
|
||||
- Accessibility tests (axe, WAVE)
|
||||
|
||||
@@ -330,7 +336,10 @@ Test thoroughly with edge cases:
|
||||
- **Network issues**: Disable internet, throttle connection
|
||||
- **Large datasets**: Test with 1000+ items
|
||||
- **Concurrent actions**: Click submit 10 times rapidly
|
||||
- **Interrupted gestures**: Add a second finger mid-drag, scroll across the control, release outside it, switch windows mid-drag; then drag again
|
||||
- **Errors**: Force API errors, test all error states
|
||||
- **Empty**: Remove all data, test empty states
|
||||
|
||||
For gestures, say what produced the evidence (emulated viewport, synthesized touch, which engine, physical device) and name what stayed untested.
|
||||
|
||||
When edge cases are covered, hand off to `/impeccable polish` for the final pass.
|
||||
|
||||
@@ -188,6 +188,12 @@ Test thoroughly across contexts:
|
||||
- **Edge cases**: Very small screens (320px), very large screens (4K)
|
||||
- **Slow connections**: Test on throttled network
|
||||
|
||||
**Custom controls** (sliders, drag surfaces, scrollable control strips): a before/after slider can pass every width check above and still refuse to drag on iOS, so exercise each one in scope in the same batched round as the checks above:
|
||||
|
||||
- **Primary gesture**: Tap it and confirm it responds as designed, then drag it with the target input method; the drag must complete, not just start
|
||||
- **Scroll across it**: A swipe along the page's scroll axis across the control scrolls the page or container without activating it; a drag that starts on the control along its axis moves the control, not the page. Neither failure throws an error, so try both
|
||||
- **Evidence**: Say what produced the evidence: an emulated viewport, synthesized touch input through a browser tool, which engine ran it (Chromium is not Safari), or a physical device. Screenshots and resized viewports verify layout, never a gesture. Name what stayed untested and move on; unreachable hardware is a reported gap, not a blocker
|
||||
|
||||
When the adaptation feels native to each context, hand off to `{{command_prefix}}impeccable polish` for the final pass.
|
||||
|
||||
---
|
||||
|
||||
@@ -48,11 +48,12 @@ Run comprehensive checks across 5 dimensions. Score each dimension 0-4 using the
|
||||
**Check for**:
|
||||
- **Fixed widths**: Hard-coded widths that break on mobile
|
||||
- **Touch targets**: Interactive elements < 44x44px
|
||||
- **Broken touch interaction**: Custom sliders, drag surfaces, and scrollable control strips whose primary gesture fails under touch, that swallow page scroll or lose the drag to it, or that stay stuck after an interrupted gesture. Code tells: mouse-only handlers, no `touch-action` on a pointer-event drag surface, drag state that nothing clears on cancel, lost capture, or blur. Exercise the gesture when a browser tool can synthesize touch (a rendered viewport proves layout, not the gesture), then say what produced the evidence (emulated viewport, synthesized touch, which engine, physical device) and what stayed untested
|
||||
- **Horizontal scroll**: Content overflow on narrow viewports
|
||||
- **Text scaling**: Layouts that break when text size increases
|
||||
- **Missing breakpoints**: No mobile/tablet variants
|
||||
|
||||
**Score 0-4**: 0=Desktop-only (breaks on mobile), 1=Major issues (some breakpoints, many failures), 2=Partial (works on mobile, rough edges), 3=Good (responsive, minor touch target or overflow issues), 4=Excellent (fluid, all viewports, proper touch targets)
|
||||
**Score 0-4**: 0=Desktop-only (breaks on mobile), 1=Major issues (some breakpoints, many failures), 2=Partial (works on mobile, rough edges), 3=Good (responsive, minor touch target or overflow issues), 4=Excellent (fluid, all viewports, proper touch targets, gestures work under touch)
|
||||
|
||||
### 5. Implementation Integrity (CRITICAL)
|
||||
|
||||
|
||||
@@ -205,6 +205,11 @@ t('items', { count }) // Handles complex plural rules
|
||||
- Optimistic updates with rollback
|
||||
- Conflict resolution
|
||||
|
||||
**Interrupted gestures** (custom sliders, drag surfaces, scrollable control strips):
|
||||
- A second finger or pointer lands mid-drag: the first drag keeps its pointer or ends cleanly, never jumps to the new one
|
||||
- The browser cancels the gesture to scroll (`pointercancel`), capture is lost (`lostpointercapture`), the pointer is released outside the control, or the window loses focus (`blur`) mid-drag: clear the dragging state and release capture
|
||||
- After each of these, the next tap or drag works without a reload
|
||||
|
||||
**Permission states**:
|
||||
- No permission to view
|
||||
- No permission to edit
|
||||
@@ -304,6 +309,7 @@ const throttledScroll = throttle(handleScroll, 100);
|
||||
- Unit tests for edge cases
|
||||
- Integration tests for error scenarios
|
||||
- E2E tests for critical paths
|
||||
- A behavioral regression for each confirmed gesture fix, when the project's test runner can drive input
|
||||
- Visual regression tests
|
||||
- Accessibility tests (axe, WAVE)
|
||||
|
||||
@@ -330,7 +336,10 @@ Test thoroughly with edge cases:
|
||||
- **Network issues**: Disable internet, throttle connection
|
||||
- **Large datasets**: Test with 1000+ items
|
||||
- **Concurrent actions**: Click submit 10 times rapidly
|
||||
- **Interrupted gestures**: Add a second finger mid-drag, scroll across the control, release outside it, switch windows mid-drag; then drag again
|
||||
- **Errors**: Force API errors, test all error states
|
||||
- **Empty**: Remove all data, test empty states
|
||||
|
||||
For gestures, say what produced the evidence (emulated viewport, synthesized touch, which engine, physical device) and name what stayed untested.
|
||||
|
||||
When edge cases are covered, hand off to `{{command_prefix}}impeccable polish` for the final pass.
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Placeholder Contrast — Should Flag vs Should Pass</title>
|
||||
<style>
|
||||
body { font-family: system-ui, sans-serif; background: #fafafa; padding: 24px; margin: 0; color: #1a1a1a; }
|
||||
.grid { display: grid; grid-template-columns: 1fr 1fr; gap: 32px; max-width: 1200px; margin: 0 auto; }
|
||||
.col h2 { font-size: 14px; text-transform: uppercase; letter-spacing: 0.05em; margin: 0 0 16px; color: #475569; }
|
||||
.col h3 { font-size: 11px; text-transform: uppercase; letter-spacing: 0.05em; margin: 24px 0 8px; color: #64748b; }
|
||||
.field {
|
||||
width: 280px;
|
||||
height: 40px;
|
||||
padding: 8px 12px;
|
||||
font-size: 16px;
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 6px;
|
||||
box-sizing: border-box;
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.field-white { background: #ffffff; }
|
||||
.field-light { background: #f5f5f5; }
|
||||
.field-dark { background: #1a1a1a; border-color: #333; }
|
||||
.flag-pale-white::placeholder { color: #bbbbbb; }
|
||||
.flag-pale-textarea::placeholder { color: #bbbbbb; }
|
||||
.flag-translucent-light::placeholder { color: rgba(255, 255, 255, 0.4); }
|
||||
.dark-wrap { background: #0f0f11; padding: 20px; width: 320px; }
|
||||
.frosted-panel {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
padding: 12px;
|
||||
}
|
||||
.frosted-panel .field {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border-color: rgba(255, 255, 255, 0.25);
|
||||
}
|
||||
.frosted-panel .field::placeholder { color: #bbbbbb; }
|
||||
.pass-ink-white::placeholder { color: #1a1a1a; }
|
||||
.pass-light-dark::placeholder { color: #e8e8e8; }
|
||||
.pass-filled-pale::placeholder { color: #bbbbbb; }
|
||||
.pass-unstyled { /* no ::placeholder rule — UA color only */ }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="grid">
|
||||
|
||||
<div class="col" data-col="flag">
|
||||
<h2>Should flag</h2>
|
||||
|
||||
<h3>Pale placeholder on white input</h3>
|
||||
<input class="field field-white flag-pale-white" type="text" placeholder="Pale Placeholder On White Field">
|
||||
|
||||
<h3>Pale placeholder on white textarea</h3>
|
||||
<textarea class="field field-white flag-pale-textarea" rows="2" placeholder="Pale Placeholder On White Textarea"></textarea>
|
||||
|
||||
<h3>Translucent placeholder on light field</h3>
|
||||
<input class="field field-light flag-translucent-light" type="text" placeholder="Translucent Placeholder On Light Field">
|
||||
|
||||
<h3>Pale placeholder on frosted panel</h3>
|
||||
<div class="dark-wrap">
|
||||
<div class="frosted-panel">
|
||||
<input class="field" type="text" placeholder="Pale Placeholder On Frosted Panel">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col" data-col="pass">
|
||||
<h2>Should pass</h2>
|
||||
|
||||
<h3>Ink placeholder on white field</h3>
|
||||
<input class="field field-white pass-ink-white" type="text" placeholder="Ink Placeholder On White Field">
|
||||
|
||||
<h3>Light placeholder on dark field</h3>
|
||||
<input class="field field-dark pass-light-dark" type="text" placeholder="Light Placeholder On Dark Field">
|
||||
|
||||
<h3>Input with no placeholder attribute</h3>
|
||||
<input class="field field-white" type="text" value="">
|
||||
|
||||
<h3>Filled field hides placeholder</h3>
|
||||
<input class="field field-white pass-filled-pale" type="text" value="Already filled" placeholder="Filled Field Hides Placeholder">
|
||||
|
||||
<h3>Unstyled placeholder uses UA color</h3>
|
||||
<input class="field field-white pass-unstyled" type="text" placeholder="Unstyled Placeholder Uses UA Color">
|
||||
|
||||
<h3>Empty placeholder attribute</h3>
|
||||
<input class="field field-white flag-pale-white" type="text" placeholder="">
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
-121
@@ -1,121 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Stripe-Child Side-Tab Fixture</title>
|
||||
<style>
|
||||
body { font-family: system-ui, sans-serif; margin: 0; padding: 24px; }
|
||||
.grid { display: grid; grid-template-columns: 1fr 1fr; gap: 32px; max-width: 960px; margin: 0 auto; }
|
||||
.col h2 { font-size: 14px; text-transform: uppercase; letter-spacing: 0.05em; margin: 0 0 16px; color: #475569; }
|
||||
.case { margin-bottom: 24px; padding: 16px; border: 1px solid #e2e8f0; border-radius: 12px; background: #fff; }
|
||||
|
||||
/* FLAG: flex-row first child stripe */
|
||||
.card-flex-left { display: flex; flex-direction: row; width: 320px; height: 100px; border: 1px solid #e2e8f0; border-radius: 12px; overflow: hidden; }
|
||||
.stripe-left { width: 4px; background: #f59e0b; flex-shrink: 0; }
|
||||
.card-flex-left .body { flex: 1; padding: 16px; }
|
||||
|
||||
/* FLAG: flex-row last child stripe */
|
||||
.card-flex-right { display: flex; flex-direction: row; width: 320px; height: 100px; border: 1px solid #e2e8f0; border-radius: 12px; overflow: hidden; }
|
||||
.card-flex-right .body { flex: 1; padding: 16px; }
|
||||
.stripe-right { width: 4px; background: #3b82f6; flex-shrink: 0; }
|
||||
|
||||
/* FLAG: absolute left stripe */
|
||||
.card-abs { position: relative; width: 320px; height: 100px; border: 1px solid #e2e8f0; border-radius: 12px; }
|
||||
.stripe-abs { position: absolute; left: 0; top: 0; bottom: 0; width: 4px; background: #f59e0b; }
|
||||
|
||||
/* FLAG: absolute inset shorthand */
|
||||
.card-inset { position: relative; width: 320px; height: 100px; border: 1px solid #e2e8f0; border-radius: 12px; }
|
||||
.stripe-inset { position: absolute; inset: 0 auto 0 0; width: 4px; background: #3b82f6; }
|
||||
|
||||
/* PASS: neutral gray stripe */
|
||||
.card-neutral { display: flex; width: 320px; height: 100px; border: 1px solid #e2e8f0; border-radius: 12px; }
|
||||
.stripe-neutral { width: 4px; background: #e5e5e5; }
|
||||
|
||||
/* PASS: black / low-spread fill */
|
||||
.card-black { display: flex; width: 320px; height: 100px; border: 1px solid #e2e8f0; border-radius: 12px; }
|
||||
.stripe-black { width: 4px; background: black; }
|
||||
|
||||
/* PASS: align-items center (short child) */
|
||||
.card-center { display: flex; align-items: center; width: 320px; height: 100px; border: 1px solid #e2e8f0; border-radius: 12px; }
|
||||
.stripe-center { width: 4px; background: #f59e0b; }
|
||||
|
||||
/* PASS: flex-direction column */
|
||||
.card-column { display: flex; flex-direction: column; width: 320px; height: 100px; border: 1px solid #e2e8f0; border-radius: 12px; }
|
||||
.stripe-column { width: 4px; background: #f59e0b; }
|
||||
|
||||
/* PASS: active/selected host context */
|
||||
.card-active { display: flex; width: 320px; height: 100px; border: 1px solid #e2e8f0; border-radius: 12px; }
|
||||
.card-active.is-active .stripe-active { width: 4px; background: #f59e0b; }
|
||||
|
||||
/* PASS: contentful narrow child */
|
||||
.card-text { display: flex; width: 320px; height: 100px; border: 1px solid #e2e8f0; border-radius: 12px; }
|
||||
.stripe-text { width: 4px; background: #f59e0b; }
|
||||
|
||||
/* PASS: progressbar role */
|
||||
.card-progress { display: flex; width: 320px; height: 100px; border: 1px solid #e2e8f0; border-radius: 12px; }
|
||||
.stripe-progress { width: 4px; background: #f59e0b; }
|
||||
|
||||
/* PASS: wide first child */
|
||||
.card-wide { display: flex; width: 320px; height: 100px; border: 1px solid #e2e8f0; border-radius: 12px; }
|
||||
.stripe-wide { width: 40px; background: #f59e0b; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="grid">
|
||||
<div class="col">
|
||||
<h2>Should flag</h2>
|
||||
<div class="case">
|
||||
<h3>Flex row first-child stripe</h3>
|
||||
<div class="card-flex-left"><div class="stripe-left"></div><div class="body">Card body</div></div>
|
||||
</div>
|
||||
<div class="case">
|
||||
<h3>Flex row last-child stripe</h3>
|
||||
<div class="card-flex-right"><div class="body">Card body</div><div class="stripe-right"></div></div>
|
||||
</div>
|
||||
<div class="case">
|
||||
<h3>Absolute left stripe</h3>
|
||||
<div class="card-abs"><div class="stripe-abs"></div><div class="body">Card body</div></div>
|
||||
</div>
|
||||
<div class="case">
|
||||
<h3>Absolute inset stripe</h3>
|
||||
<div class="card-inset"><div class="stripe-inset"></div><div class="body">Card body</div></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col">
|
||||
<h2>Should pass</h2>
|
||||
<div class="case">
|
||||
<h3>Neutral gray stripe</h3>
|
||||
<div class="card-neutral"><div class="stripe-neutral"></div><div class="body">Card body</div></div>
|
||||
</div>
|
||||
<div class="case">
|
||||
<h3>Black stripe fill</h3>
|
||||
<div class="card-black"><div class="stripe-black"></div><div class="body">Card body</div></div>
|
||||
</div>
|
||||
<div class="case">
|
||||
<h3>Align items center short child</h3>
|
||||
<div class="card-center"><div class="stripe-center"></div><div class="body">Card body</div></div>
|
||||
</div>
|
||||
<div class="case">
|
||||
<h3>Flex column layout</h3>
|
||||
<div class="card-column"><div class="stripe-column"></div><div class="body">Card body</div></div>
|
||||
</div>
|
||||
<div class="case">
|
||||
<h3>Active selected host</h3>
|
||||
<div class="card-active is-active"><div class="stripe-active"></div><div>Card body</div></div>
|
||||
</div>
|
||||
<div class="case">
|
||||
<h3>Contentful narrow child</h3>
|
||||
<div class="card-text"><div class="stripe-text">|</div><div class="body">Card body</div></div>
|
||||
</div>
|
||||
<div class="case">
|
||||
<h3>Progressbar context</h3>
|
||||
<div class="card-progress" role="progressbar"><div class="stripe-progress"></div><div class="body">50%</div></div>
|
||||
</div>
|
||||
<div class="case">
|
||||
<h3>Wide forty pixel child</h3>
|
||||
<div class="card-wide"><div class="stripe-wide"></div><div class="body">Card body</div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
-61
@@ -1,61 +0,0 @@
|
||||
// Dedicated stripe-child side-tab fixture (Tailwind / JSX text path)
|
||||
|
||||
export function StripeChildCard() {
|
||||
return (
|
||||
<div className="flex rounded-lg border">
|
||||
<div className="w-1 shrink-0 rounded-l-lg bg-amber-500" />
|
||||
<div className="p-4">Card content</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function StripeChildBracketWidth() {
|
||||
return <div className="w-[4px] bg-blue-500 shrink-0" />;
|
||||
}
|
||||
|
||||
export function StripeChildHalf() {
|
||||
return <span className="w-0.5 bg-rose-500 shrink-0" />;
|
||||
}
|
||||
|
||||
export function StripeChildMinHeightOk() {
|
||||
return <div className="w-1 min-h-0 bg-amber-500 shrink-0" />;
|
||||
}
|
||||
|
||||
// PASS: dot indicator, not a stripe
|
||||
export function DotIndicator() {
|
||||
return <div className="w-2 h-2 rounded-full bg-green-500" />;
|
||||
}
|
||||
|
||||
// PASS: small square with sibling text (gray-on-color sibling line)
|
||||
export function VitalFewLegend() {
|
||||
return (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="w-3 h-3 rounded bg-amber-500" />
|
||||
<span className="text-slate-400">Vital few</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// PASS: chart bar with explicit height
|
||||
export function ChartBar() {
|
||||
return <div className="w-3 h-24 bg-blue-500" />;
|
||||
}
|
||||
|
||||
// PASS: opacity tint
|
||||
export function TintStripe() {
|
||||
return <div className="w-1 bg-amber-500/10 shrink-0" />;
|
||||
}
|
||||
|
||||
// PASS: aria-current on stripe tag
|
||||
export function CurrentNavStripe() {
|
||||
return <a className="w-1 bg-amber-500" aria-current="page" />;
|
||||
}
|
||||
|
||||
// PASS: width and chromatic bg on sibling tags
|
||||
export function SplitSiblingClasses() {
|
||||
return (
|
||||
<div className="w-1 shrink-0">
|
||||
<span className="bg-amber-500" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"stdout": "",
|
||||
"stderr": "432 anti-patterns found.\n17 advisory notes (not counted).\n",
|
||||
"stderr": "419 anti-patterns found.\n17 advisory notes (not counted).\n",
|
||||
"exit": 2,
|
||||
"signal": null,
|
||||
"files": {}
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"stdout": "[\n {\n \"antipattern\": \"low-contrast\",\n \"name\": \"Low contrast text\",\n \"description\": \"Text does not meet WCAG AA contrast requirements (4.5:1 for body, 3:1 for large text). Increase the contrast between text and background.\",\n \"severity\": \"warning\",\n \"category\": \"quality\",\n \"file\": \"<REPO>/tests/fixtures/antipatterns/placeholder-contrast.html\",\n \"line\": 0,\n \"snippet\": \"placeholder \\\"Pale Placeholder On White Field\\\" 1.9:1 (need 4.5:1) — text #bbbbbb on #ffffff\"\n },\n {\n \"antipattern\": \"low-contrast\",\n \"name\": \"Low contrast text\",\n \"description\": \"Text does not meet WCAG AA contrast requirements (4.5:1 for body, 3:1 for large text). Increase the contrast between text and background.\",\n \"severity\": \"warning\",\n \"category\": \"quality\",\n \"file\": \"<REPO>/tests/fixtures/antipatterns/placeholder-contrast.html\",\n \"line\": 0,\n \"snippet\": \"placeholder \\\"Pale Placeholder On White Textarea\\\" 1.9:1 (need 4.5:1) — text #bbbbbb on #ffffff\"\n },\n {\n \"antipattern\": \"low-contrast\",\n \"name\": \"Low contrast text\",\n \"description\": \"Text does not meet WCAG AA contrast requirements (4.5:1 for body, 3:1 for large text). Increase the contrast between text and background.\",\n \"severity\": \"warning\",\n \"category\": \"quality\",\n \"file\": \"<REPO>/tests/fixtures/antipatterns/placeholder-contrast.html\",\n \"line\": 0,\n \"snippet\": \"placeholder \\\"Translucent Placeholder On Light Field\\\" 1.0:1 (need 4.5:1) — text #f9f9f9 on #f5f5f5\"\n },\n {\n \"antipattern\": \"low-contrast\",\n \"name\": \"Low contrast text\",\n \"description\": \"Text does not meet WCAG AA contrast requirements (4.5:1 for body, 3:1 for large text). Increase the contrast between text and background.\",\n \"severity\": \"warning\",\n \"category\": \"quality\",\n \"file\": \"<REPO>/tests/fixtures/antipatterns/placeholder-contrast.html\",\n \"line\": 0,\n \"snippet\": \"placeholder \\\"Pale Placeholder On Frosted Panel\\\" 3.5:1 (need 4.5:1) — text #bbbbbb on #5c5c5d\"\n }\n]\n",
|
||||
"stderr": "",
|
||||
"exit": 2,
|
||||
"signal": null,
|
||||
"files": {}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"stdout": "[\n {\n \"antipattern\": \"side-tab\",\n \"name\": \"Side-tab accent border\",\n \"description\": \"Thick colored border on one side of a card — the most recognizable tell of AI-generated UIs. Use a subtler accent or remove it entirely.\",\n \"severity\": \"warning\",\n \"category\": \"slop\",\n \"file\": \"<REPO>/tests/fixtures/antipatterns/stripe-child.jsx\",\n \"line\": 6,\n \"snippet\": \"w-1 + bg-amber-500 stripe child\"\n },\n {\n \"antipattern\": \"side-tab\",\n \"name\": \"Side-tab accent border\",\n \"description\": \"Thick colored border on one side of a card — the most recognizable tell of AI-generated UIs. Use a subtler accent or remove it entirely.\",\n \"severity\": \"warning\",\n \"category\": \"slop\",\n \"file\": \"<REPO>/tests/fixtures/antipatterns/stripe-child.jsx\",\n \"line\": 13,\n \"snippet\": \"w-[4px] + bg-blue-500 stripe child\"\n },\n {\n \"antipattern\": \"side-tab\",\n \"name\": \"Side-tab accent border\",\n \"description\": \"Thick colored border on one side of a card — the most recognizable tell of AI-generated UIs. Use a subtler accent or remove it entirely.\",\n \"severity\": \"warning\",\n \"category\": \"slop\",\n \"file\": \"<REPO>/tests/fixtures/antipatterns/stripe-child.jsx\",\n \"line\": 17,\n \"snippet\": \"w-0.5 + bg-rose-500 stripe child\"\n },\n {\n \"antipattern\": \"side-tab\",\n \"name\": \"Side-tab accent border\",\n \"description\": \"Thick colored border on one side of a card — the most recognizable tell of AI-generated UIs. Use a subtler accent or remove it entirely.\",\n \"severity\": \"warning\",\n \"category\": \"slop\",\n \"file\": \"<REPO>/tests/fixtures/antipatterns/stripe-child.jsx\",\n \"line\": 21,\n \"snippet\": \"w-1 + bg-amber-500 stripe child\"\n }\n]\n",
|
||||
"stderr": "",
|
||||
"exit": 2,
|
||||
"signal": null,
|
||||
"files": {}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"stdout": "",
|
||||
"stderr": "\n<REPO>/tests/fixtures/antipatterns/placeholder-contrast.html\n [low-contrast] placeholder \"Pale Placeholder On White Field\" 1.9:1 (need 4.5:1) — text #bbbbbb on #ffffff\n → Text does not meet WCAG AA contrast requirements (4.5:1 for body, 3:1 for large text). Increase the contrast between text and background.\n [low-contrast] placeholder \"Pale Placeholder On White Textarea\" 1.9:1 (need 4.5:1) — text #bbbbbb on #ffffff\n → Text does not meet WCAG AA contrast requirements (4.5:1 for body, 3:1 for large text). Increase the contrast between text and background.\n [low-contrast] placeholder \"Translucent Placeholder On Light Field\" 1.0:1 (need 4.5:1) — text #f9f9f9 on #f5f5f5\n → Text does not meet WCAG AA contrast requirements (4.5:1 for body, 3:1 for large text). Increase the contrast between text and background.\n [low-contrast] placeholder \"Pale Placeholder On Frosted Panel\" 3.5:1 (need 4.5:1) — text #bbbbbb on #5c5c5d\n → Text does not meet WCAG AA contrast requirements (4.5:1 for body, 3:1 for large text). Increase the contrast between text and background.\n\n4 anti-patterns found.\n",
|
||||
"exit": 2,
|
||||
"signal": null,
|
||||
"files": {}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"stdout": "",
|
||||
"stderr": "\n<REPO>/tests/fixtures/antipatterns/stripe-child.jsx\n line 6: [side-tab] w-1 + bg-amber-500 stripe child\n → Thick colored border on one side of a card — the most recognizable tell of AI-generated UIs. Use a subtler accent or remove it entirely.\n line 13: [side-tab] w-[4px] + bg-blue-500 stripe child\n → Thick colored border on one side of a card — the most recognizable tell of AI-generated UIs. Use a subtler accent or remove it entirely.\n line 17: [side-tab] w-0.5 + bg-rose-500 stripe child\n → Thick colored border on one side of a card — the most recognizable tell of AI-generated UIs. Use a subtler accent or remove it entirely.\n line 21: [side-tab] w-1 + bg-amber-500 stripe child\n → Thick colored border on one side of a card — the most recognizable tell of AI-generated UIs. Use a subtler accent or remove it entirely.\n\n4 anti-patterns found.\n",
|
||||
"exit": 2,
|
||||
"signal": null,
|
||||
"files": {}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -4,7 +4,7 @@ import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { MockLanguageModelV3 } from 'ai/test';
|
||||
import { prepareWorkspace, cleanupWorkspace, makeTools, runTurn, fileLoaded, SKILL_BODY } from './skill-behavior/harness.mjs';
|
||||
import { assertPlanningFallbackWarning, assertNewWorkLifecycle, assertWorkflowAdvice, assertCommandComparison, missingReferences } from './skill-behavior/assertions.mjs';
|
||||
import { assertLauncherDenialWarningBeforeNextTool, assertPlanningFallbackWarning, assertNewWorkLifecycle, assertWorkflowAdvice, assertCommandComparison, missingReferences } from './skill-behavior/assertions.mjs';
|
||||
import { CASE_STUDY_ANSWER } from './skill-behavior/fixtures.mjs';
|
||||
import { sourceHash as hashSources } from './skill-workflow/source-hash.mjs';
|
||||
import { assertCompleted, assertFreshCaptures, assertNoChangeDocumentation, assertDocumentationArtifacts } from './skill-workflow/assertions.mjs';
|
||||
@@ -186,6 +186,24 @@ it('headless behavior shells disable unattended decision pages and omit provider
|
||||
}
|
||||
});
|
||||
|
||||
it('documentation fallback requires an assistant warning before the first tool call after denial', () => {
|
||||
const call = { role: 'assistant', content: [{ type: 'tool-call', toolCallId: 'context', toolName: 'bash', input: { command: '.claude/skills/impeccable/scripts/impeccable context' } }] };
|
||||
const denial = { role: 'tool', content: [{ type: 'tool-result', toolCallId: 'context', toolName: 'bash', output: { type: 'text', value: 'Error: Bash permission denied by the host. This command was not executed.' } }] };
|
||||
const warning = { role: 'assistant', content: 'Context loading did not run because the launcher was denied.' };
|
||||
const read = { role: 'assistant', content: [{ type: 'tool-call', toolCallId: 'read', toolName: 'read', input: { path: 'reference/document.md' } }] };
|
||||
const write = { role: 'assistant', content: [{ type: 'tool-call', toolCallId: 'write', toolName: 'write', input: { path: 'DESIGN.md' } }] };
|
||||
assert.doesNotThrow(() => assertLauncherDenialWarningBeforeNextTool([call, denial, warning, read, write]));
|
||||
assert.doesNotThrow(() => assertLauncherDenialWarningBeforeNextTool([call, denial, { role: 'assistant', content: [{ type: 'text', text: warning.content }, ...read.content] }, write]));
|
||||
for (const messages of [
|
||||
[call, denial, read, warning, write], // Reads first, warns only before the write.
|
||||
[call, denial, read, write, warning], // Final-only disclosure.
|
||||
[warning, call, denial, read], // Not a response to the actual denial.
|
||||
[call, denial, warning], // Warning with no follow-up tool call.
|
||||
]) {
|
||||
assert.throws(() => assertLauncherDenialWarningBeforeNextTool(messages), assert.AssertionError);
|
||||
}
|
||||
});
|
||||
|
||||
it('planning fallback requires an assistant warning between the denial and context reads', () => {
|
||||
const call = { role: 'assistant', content: [{ type: 'tool-call', toolCallId: 'context', toolName: 'bash', input: { command: '.claude/skills/impeccable/scripts/impeccable context' } }] };
|
||||
const denial = { role: 'tool', content: [{ type: 'tool-result', toolCallId: 'context', toolName: 'bash', output: { type: 'text', value: 'Error: Bash permission denied by the host. This command was not executed.' } }] };
|
||||
|
||||
@@ -321,7 +321,7 @@ results remain the completed measurements.
|
||||
| 16 | existing surface, with and without PRODUCT.md; asks where to start | completes relevant advice without edits, interviews, critique archives, menu scans, or explicit invented refinement prerequisites; reference coverage is diagnostic |
|
||||
| 17 | existing surface; asks whether critique is required before polish | completes read-only advice distinguishing assessment from implementation and explaining critique is optional; reference coverage is diagnostic |
|
||||
| 18 | existing surface; explicitly requests polish followed by a next-command recommendation | loads `polish.md` rather than substituting workflow advice for the requested work |
|
||||
| 19 | tiny spacing edit with PRODUCT.md + DESIGN.md; Bash denied, a real-loader success control, and a denied-launcher planning-only case | edits require successful playbook/craft-floor reads and a pre-edit denial warning; planning stays read-only and skips craft-floor |
|
||||
| 19 | tiny spacing edit with PRODUCT.md + DESIGN.md; Bash denied, a real-loader success control, a denied-launcher planning-only case, and a denied-launcher documentation case (PRODUCT.md + index.html, no DESIGN.md) | edits require successful playbook/craft-floor reads and a pre-edit denial warning; planning stays read-only and skips craft-floor; documentation requires successful document.md and source reads before any DESIGN.md write, with the denial disclosed before the first tool call after the denied launcher |
|
||||
|
||||
## Setup launcher-failure branch (2026-09-06, PR #750)
|
||||
|
||||
@@ -405,6 +405,20 @@ IMPECCABLE_SKILL_BEHAVIOR_MODELS=claude-sonnet-5,gpt-5.6-terra,gemini-3.7-flash,
|
||||
node --test --test-name-pattern='scenario 19:' tests/skill-behavior/scenarios.test.mjs
|
||||
```
|
||||
|
||||
### Degraded documentation case (#789)
|
||||
|
||||
A fourth scenario 19 case covers the documentation branch: launcher denied,
|
||||
PRODUCT.md plus an incumbent `index.html`, prompt `/impeccable document`.
|
||||
It requires an actual denied context attempt, successful `document.md` and
|
||||
source reads before the DESIGN.md write, the denial disclosed before the
|
||||
first tool call after the denied launcher, and an untouched PRODUCT.md.
|
||||
|
||||
Scenario 19 on this branch, all four cases, one run each on 2026-09-09:
|
||||
4/4 on claude-sonnet-5, 4/4 on gpt-5.6-terra, 4/4 on gemini-3.7-flash. The
|
||||
documentation case also passes with main's unchanged `SKILL.src.md` and
|
||||
`init.md` plus this test file, so the coverage is the test, not a skill-text
|
||||
change.
|
||||
|
||||
## Workflow-advice baseline (2026-09-05, PR #737)
|
||||
|
||||
The four cases in scenarios 16-18 are new; prior scenario results do not
|
||||
|
||||
@@ -61,17 +61,25 @@ export function assertNewWorkLifecycle(trace, { target, redesign = false }) {
|
||||
|
||||
export const LAUNCHER_FAILURE_WARNING = /(?:context|launcher|bash)[^.!?\n]{0,160}(?:denied|refused|unavailable|blocked|could(?:n't| not)|cannot|can't|did(?:n't| not)|fail|unable)|(?:denied|refused|unavailable|blocked|could(?:n't| not)|cannot|can't|unable)[^.!?\n]{0,160}(?:context|launcher|bash)/i;
|
||||
|
||||
export function assertPlanningFallbackWarning(responseMessages) {
|
||||
const blocks = responseMessages.flatMap((message) =>
|
||||
function responseBlocks(responseMessages) {
|
||||
return responseMessages.flatMap((message) =>
|
||||
(typeof message.content === 'string' ? [{ type: 'text', text: message.content }] : message.content)
|
||||
.map((block) => ({ ...block, role: message.role })),
|
||||
);
|
||||
}
|
||||
|
||||
function contextLauncherDenialIndex(blocks) {
|
||||
const contextCalls = new Set(blocks.filter((block) => block.role === 'assistant'
|
||||
&& block.type === 'tool-call' && block.toolName === 'bash'
|
||||
&& /impeccable\s+context\b/.test(block.input?.command ?? '')).map((block) => block.toolCallId));
|
||||
const denialIndex = blocks.findIndex((block) => block.role === 'tool'
|
||||
return blocks.findIndex((block) => block.role === 'tool'
|
||||
&& block.type === 'tool-result' && contextCalls.has(block.toolCallId)
|
||||
&& block.output?.type === 'text' && /Bash permission denied by the host/.test(block.output.value));
|
||||
}
|
||||
|
||||
export function assertPlanningFallbackWarning(responseMessages) {
|
||||
const blocks = responseBlocks(responseMessages);
|
||||
const denialIndex = contextLauncherDenialIndex(blocks);
|
||||
assert.ok(denialIndex >= 0, 'must observe the context launcher denial in the response sequence');
|
||||
const warningIndex = blocks.findIndex((block, index) => index > denialIndex
|
||||
&& block.role === 'assistant' && block.type === 'text' && LAUNCHER_FAILURE_WARNING.test(block.text));
|
||||
@@ -81,3 +89,16 @@ export function assertPlanningFallbackWarning(responseMessages) {
|
||||
assert.ok(warningIndex > denialIndex && contextReadIndex > warningIndex,
|
||||
'planning fallback must warn after denial and before reading project context, not only in the final response');
|
||||
}
|
||||
|
||||
export function assertLauncherDenialWarningBeforeNextTool(responseMessages) {
|
||||
const blocks = responseBlocks(responseMessages);
|
||||
const denialIndex = contextLauncherDenialIndex(blocks);
|
||||
assert.ok(denialIndex >= 0, 'must observe the context launcher denial in the response sequence');
|
||||
const warningIndex = blocks.findIndex((block, index) => index > denialIndex
|
||||
&& block.role === 'assistant' && block.type === 'text' && LAUNCHER_FAILURE_WARNING.test(block.text));
|
||||
const nextToolIndex = blocks.findIndex((block, index) => index > denialIndex
|
||||
&& block.role === 'assistant' && block.type === 'tool-call');
|
||||
assert.ok(nextToolIndex >= 0, 'must continue with a tool call after the denied launcher');
|
||||
assert.ok(warningIndex > denialIndex && nextToolIndex > warningIndex,
|
||||
'must disclose the failed context launcher before the first tool call after the denial, not only before the eventual write');
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ import {
|
||||
ENGINE_MISSING_MESSAGE,
|
||||
} from './harness.mjs';
|
||||
import { detectProvider, getModel, hasKey, resolveModelList, PROVIDERS } from './providers.mjs';
|
||||
import { assertPlanningFallbackWarning, LAUNCHER_FAILURE_WARNING, assertAdviceOnly, assertWorkflowAdvice, assertCommandComparison, missingReferences } from './assertions.mjs';
|
||||
import { assertLauncherDenialWarningBeforeNextTool, assertPlanningFallbackWarning, LAUNCHER_FAILURE_WARNING, assertAdviceOnly, assertWorkflowAdvice, assertCommandComparison, missingReferences } from './assertions.mjs';
|
||||
import { assertCompleted } from '../skill-workflow/assertions.mjs';
|
||||
import {
|
||||
PRODUCT_MD_SAMPLE,
|
||||
@@ -708,6 +708,35 @@ for (const modelId of resolveModelList()) {
|
||||
});
|
||||
}
|
||||
|
||||
it('scenario 19: denied launcher requires document.md before writing DESIGN.md', async () => {
|
||||
const workspace = prepareWorkspace({ files: {
|
||||
'PRODUCT.md': PRODUCT_MD_SAMPLE,
|
||||
'index.html': MINIMAL_LANDING_HTML,
|
||||
} });
|
||||
try {
|
||||
const { trace, stepTexts, finishReason, responseMessages } = await runTurn({
|
||||
workspace,
|
||||
model,
|
||||
userPrompt: '/impeccable document. Record the incumbent design system from index.html into DESIGN.md.',
|
||||
maxSteps: 14,
|
||||
denyBash: true,
|
||||
});
|
||||
logTrace('S19', 'denied-launcher-document', modelId, trace, { finishReason, text: stepTexts.join('\n') });
|
||||
assert.notEqual(finishReason, 'length', 'a truncated response is not a completed documentation pass');
|
||||
assert.ok(trace.toolCalls.some((call) => call.name === 'bash' && call.denied && /impeccable\s+context\b/.test(call.input.command)), 'must encounter an actual denied context attempt');
|
||||
const designWriteIndex = trace.toolCalls.findIndex((call) => call.mutatedPaths.some((p) => /(?:^|\/)DESIGN\.md$/.test(p)));
|
||||
assert.ok(designWriteIndex >= 0, 'must still produce DESIGN.md, not stop at the refusal');
|
||||
const documentReadIndex = trace.toolCalls.findIndex((call) => call.name === 'read' && call.succeeded && /(?:^|\/)reference\/document\.md$/.test(call.input.path));
|
||||
assert.ok(documentReadIndex >= 0 && documentReadIndex < designWriteIndex, 'reference/document.md must actually be read before DESIGN.md is written');
|
||||
const sourceReadIndex = trace.toolCalls.findIndex((call) => call.name === 'read' && call.succeeded && call.input.path.endsWith('index.html'));
|
||||
assert.ok(sourceReadIndex >= 0 && sourceReadIndex < designWriteIndex, 'the incumbent source must be read before DESIGN.md is written');
|
||||
assertLauncherDenialWarningBeforeNextTool(responseMessages);
|
||||
assert.ok(!trace.toolCalls.some((call) => call.mutatedPaths.some((p) => /(?:^|\/)PRODUCT\.md$/.test(p))), 'must not rewrite PRODUCT.md');
|
||||
} finally {
|
||||
cleanupWorkspace(workspace);
|
||||
}
|
||||
});
|
||||
|
||||
it('scenario 19: denied launcher keeps planning-only work read-only without craft-floor', async () => {
|
||||
const workspace = prepareWorkspace({ files: {
|
||||
'PRODUCT.md': PRODUCT_MD_SAMPLE,
|
||||
|
||||
@@ -67,4 +67,26 @@ describe('skill reference authoring contracts', () => {
|
||||
assert.match(polish, /if a newer critique landed meanwhile, its backlog stays live/);
|
||||
assert.doesNotMatch(polish, /git status|git log/);
|
||||
});
|
||||
|
||||
it('keeps touch-gesture verification in the adapt, audit, and harden references', () => {
|
||||
const adapt = readFileSync(join(ROOT, 'skill/reference/adapt.md'), 'utf-8').replace(/\r\n?/g, '\n');
|
||||
const audit = readFileSync(join(ROOT, 'skill/reference/audit.md'), 'utf-8').replace(/\r\n?/g, '\n');
|
||||
const harden = readFileSync(join(ROOT, 'skill/reference/harden.md'), 'utf-8').replace(/\r\n?/g, '\n');
|
||||
const verifyAdaptations = adapt.match(/## Verify Adaptations\n([\s\S]*?)\n## /)?.[1] ?? '';
|
||||
const responsive = audit.match(/### 4\. Responsive Design\n([\s\S]*?)\n### 5\./)?.[1] ?? '';
|
||||
const edgeCases = harden.match(/### Edge Cases & Boundary Conditions\n([\s\S]*?)\n### /)?.[1] ?? '';
|
||||
const verifyHardening = harden.match(/## Verify Hardening\n([\s\S]*?)(?:\n## |$)/)?.[1] ?? '';
|
||||
|
||||
assert.match(verifyAdaptations, /\*\*Primary gesture\*\*/);
|
||||
assert.match(verifyAdaptations, /produced the evidence/);
|
||||
assert.match(verifyAdaptations, /verify layout, never a gesture/);
|
||||
assert.match(verifyAdaptations, /reported gap, not a blocker/);
|
||||
assert.match(verifyAdaptations, /\*\*Scroll across it\*\*[\s\S]*without activating it/);
|
||||
assert.match(responsive, /\*\*Broken touch interaction\*\*/);
|
||||
assert.match(responsive, /what stayed untested/);
|
||||
assert.match(responsive, /Exercise the gesture when a browser tool can synthesize touch/);
|
||||
assert.match(edgeCases, /\*\*Interrupted gestures\*\*[\s\S]*works without a reload/);
|
||||
assert.match(edgeCases, /clear the dragging state and release capture/);
|
||||
assert.match(verifyHardening, /\*\*Interrupted gestures\*\*/);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user