Compare commits

...
Author SHA1 Message Date
Paul BakausandClaude Fable 5.1 b50098a477 Address review findings on the component ignore
Four gaps the review bots found in the first commit:

- **The hook dropped the waivers when the design system was off.** Both
  `design_system_options` paths returned `HookScanOptions::default()` when
  `designSystem.enabled` is false, which left `ignore_selectors` empty, so an
  opted-out component kept re-firing on every edit in exactly the projects
  that have no DESIGN.md. Component ignores are not design-system state; they
  travel either way now.
- **The in-page and extension scans never saw the key.** `BrowserConfig`
  reads `ignoreSelectors`, but the two JS adapters that build that config
  (`browser-bundle/50-scan.js` `collectConfigJson`, `60-offscreen.js`
  `configJson`) listed their keys explicitly and dropped it, so the documented
  `window.__IMPECCABLE_CONFIG__.ignoreSelectors` path did nothing. Both
  forward it now, and the bundle is regenerated.
- **Visual-contrast findings skipped the stamp.** The URL engine's visual pass
  produces its findings outside `collect_browser_findings`, so a
  `low-contrast` hit on an opted-out component was reported rather than
  waived. Each candidate carries its own selector, so the pass now resolves
  that element against the same post-reveal snapshot and stamps what the
  config waives. Page-level results (`content-hidden-at-rest`, `script-error`)
  stay unstamped: they name no element.
- **One bad entry could discard the whole page config.** `ignoreSelectors`
  used strict deserialization, so a hand-edited `{}` or `null` in the array
  failed the parse of `BrowserConfig`, which the wasm entry points answer with
  `unwrap_or_default()` — losing the design system and every other setting.
  It now filters bad entries the way `disabledValues` does.

Also: a `files` glob no longer applies to a URL scan. Globs name repo paths,
and `index.html` reaching `https://example.com/index.html` would scope an
ignore to a page the entry never named. URL scans take the unscoped entries
only, which is what the docs already promised.

Assisted-by: Claude Code
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LQBUunp8QttxZqihybNmtL
2026-09-11 12:42:24 -07:00
Paul BakausandClaude Fable 5.1 1d214f1e48 Add detector.ignoreSelectors: one component-level opt-out instead of an attribute per instance
An author who wants a rule off for one component has had two choices: put
`data-impeccable-ignore` on every instance, or silence the rule (or the
file) for the whole project. On impeccable-site #34 that meant eleven
attributes on eleven copies of the same 10px label for `undersized-ui-text`,
25 opt-out attributes in all. The count is the problem: the markup carries
noise, and the reviewer never sees how much was waived.

`detector.ignoreSelectors` is the declared twin of that attribute. One entry,
`{ rule, selector }`, waives the rule for every element the selector matches
and for that element's subtree, the same waiver the attribute grants the
element carrying it:

    impeccable ignores add-selector undersized-ui-text ".ks-tag" \
      --reason "10px mono index labels, confirmed"

The waiver is never silent. The engines stamp a waived finding with
`ignoredBy: "<selector>"` instead of dropping it; the config layer drops and
counts, and every scan prints one line per entry on stderr, in `--json` runs
too, so stdout stays the findings array:

    3 undersized-ui-text hits ignored by detector.ignoreSelectors on .ks-tag.

Where it applies: the browser engine (`BrowserConfig.ignoreSelectors`, also
readable from `window.__IMPECCABLE_CONFIG__`), the static HTML engine
(`DetectHtmlOptions.ignore_selectors`, and the `ignoreSelectors` option of
the wasm `detect_html_source_json` export), the detect CLI, and the design
hook. The text engine has no DOM and ignores the key. Entries can be scoped
with `files` globs like `ignoreValues`; `--no-config` disables them; `doctor`
validates their rule ids alongside `ignoreRules`. Nothing changes for a
project without the key: the engines stamp nothing, the CLI prints nothing,
the config writer does not add an empty `ignoreSelectors`, and the
per-instance attribute keeps working exactly as before.

Coverage: `crates/html/tests/selector_ignores.rs` (component, subtree,
wrong-rule, `*`, attribute parity), driver tests over the fake DOM,
`crates/detect` config tests (normalize, merge, per-target narrowing, the
tally), and oracle cases `detect-selector-ignore-*` / `ignores-selector-*`
over a new workspace. The eight re-recorded context/doctor goldens differ
only in the recognized-detector-keys sentence, which now lists the new key.

Assisted-by: Claude Code
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LQBUunp8QttxZqihybNmtL
2026-09-11 12:22:16 -07:00
cb56ed6c19 Fix: detect placeholder contrast (#790) (#799)
* Fix: detect placeholder contrast (#790)

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

Prepared with AI assistance.

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

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

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

Prepared with AI assistance.

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

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

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

Prepared with AI assistance.

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

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

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

Prepared with AI assistance.

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

---------

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

AI was used for assistance.

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

---------

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

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

AI assistance: Cursor Grok 4.6.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-10 08:47:16 +05:00
140 changed files with 2764 additions and 242 deletions
@@ -188,6 +188,12 @@ Test thoroughly across contexts:
- **Edge cases**: Very small screens (320px), very large screens (4K)
- **Slow connections**: Test on throttled network
**Custom controls** (sliders, drag surfaces, scrollable control strips): a before/after slider can pass every width check above and still refuse to drag on iOS, so exercise each one in scope in the same batched round as the checks above:
- **Primary gesture**: Tap it and confirm it responds as designed, then drag it with the target input method; the drag must complete, not just start
- **Scroll across it**: A swipe along the page's scroll axis across the control scrolls the page or container without activating it; a drag that starts on the control along its axis moves the control, not the page. Neither failure throws an error, so try both
- **Evidence**: Say what produced the evidence: an emulated viewport, synthesized touch input through a browser tool, which engine ran it (Chromium is not Safari), or a physical device. Screenshots and resized viewports verify layout, never a gesture. Name what stayed untested and move on; unreachable hardware is a reported gap, not a blocker
When the adaptation feels native to each context, hand off to `/impeccable polish` for the final pass.
---
+2 -1
View File
@@ -48,11 +48,12 @@ Run comprehensive checks across 5 dimensions. Score each dimension 0-4 using the
**Check for**:
- **Fixed widths**: Hard-coded widths that break on mobile
- **Touch targets**: Interactive elements < 44x44px
- **Broken touch interaction**: Custom sliders, drag surfaces, and scrollable control strips whose primary gesture fails under touch, that swallow page scroll or lose the drag to it, or that stay stuck after an interrupted gesture. Code tells: mouse-only handlers, no `touch-action` on a pointer-event drag surface, drag state that nothing clears on cancel, lost capture, or blur. Exercise the gesture when a browser tool can synthesize touch (a rendered viewport proves layout, not the gesture), then say what produced the evidence (emulated viewport, synthesized touch, which engine, physical device) and what stayed untested
- **Horizontal scroll**: Content overflow on narrow viewports
- **Text scaling**: Layouts that break when text size increases
- **Missing breakpoints**: No mobile/tablet variants
**Score 0-4**: 0=Desktop-only (breaks on mobile), 1=Major issues (some breakpoints, many failures), 2=Partial (works on mobile, rough edges), 3=Good (responsive, minor touch target or overflow issues), 4=Excellent (fluid, all viewports, proper touch targets)
**Score 0-4**: 0=Desktop-only (breaks on mobile), 1=Major issues (some breakpoints, many failures), 2=Partial (works on mobile, rough edges), 3=Good (responsive, minor touch target or overflow issues), 4=Excellent (fluid, all viewports, proper touch targets, gestures work under touch)
### 5. Implementation Integrity (CRITICAL)
@@ -205,6 +205,11 @@ t('items', { count }) // Handles complex plural rules
- Optimistic updates with rollback
- Conflict resolution
**Interrupted gestures** (custom sliders, drag surfaces, scrollable control strips):
- A second finger or pointer lands mid-drag: the first drag keeps its pointer or ends cleanly, never jumps to the new one
- The browser cancels the gesture to scroll (`pointercancel`), capture is lost (`lostpointercapture`), the pointer is released outside the control, or the window loses focus (`blur`) mid-drag: clear the dragging state and release capture
- After each of these, the next tap or drag works without a reload
**Permission states**:
- No permission to view
- No permission to edit
@@ -304,6 +309,7 @@ const throttledScroll = throttle(handleScroll, 100);
- Unit tests for edge cases
- Integration tests for error scenarios
- E2E tests for critical paths
- A behavioral regression for each confirmed gesture fix, when the project's test runner can drive input
- Visual regression tests
- Accessibility tests (axe, WAVE)
@@ -330,7 +336,10 @@ Test thoroughly with edge cases:
- **Network issues**: Disable internet, throttle connection
- **Large datasets**: Test with 1000+ items
- **Concurrent actions**: Click submit 10 times rapidly
- **Interrupted gestures**: Add a second finger mid-drag, scroll across the control, release outside it, switch windows mid-drag; then drag again
- **Errors**: Force API errors, test all error states
- **Empty**: Remove all data, test empty states
For gestures, say what produced the evidence (emulated viewport, synthesized touch, which engine, physical device) and name what stayed untested.
When edge cases are covered, hand off to `/impeccable polish` for the final pass.
@@ -188,6 +188,12 @@ Test thoroughly across contexts:
- **Edge cases**: Very small screens (320px), very large screens (4K)
- **Slow connections**: Test on throttled network
**Custom controls** (sliders, drag surfaces, scrollable control strips): a before/after slider can pass every width check above and still refuse to drag on iOS, so exercise each one in scope in the same batched round as the checks above:
- **Primary gesture**: Tap it and confirm it responds as designed, then drag it with the target input method; the drag must complete, not just start
- **Scroll across it**: A swipe along the page's scroll axis across the control scrolls the page or container without activating it; a drag that starts on the control along its axis moves the control, not the page. Neither failure throws an error, so try both
- **Evidence**: Say what produced the evidence: an emulated viewport, synthesized touch input through a browser tool, which engine ran it (Chromium is not Safari), or a physical device. Screenshots and resized viewports verify layout, never a gesture. Name what stayed untested and move on; unreachable hardware is a reported gap, not a blocker
When the adaptation feels native to each context, hand off to `$impeccable polish` for the final pass.
---
+2 -1
View File
@@ -48,11 +48,12 @@ Run comprehensive checks across 5 dimensions. Score each dimension 0-4 using the
**Check for**:
- **Fixed widths**: Hard-coded widths that break on mobile
- **Touch targets**: Interactive elements < 44x44px
- **Broken touch interaction**: Custom sliders, drag surfaces, and scrollable control strips whose primary gesture fails under touch, that swallow page scroll or lose the drag to it, or that stay stuck after an interrupted gesture. Code tells: mouse-only handlers, no `touch-action` on a pointer-event drag surface, drag state that nothing clears on cancel, lost capture, or blur. Exercise the gesture when a browser tool can synthesize touch (a rendered viewport proves layout, not the gesture), then say what produced the evidence (emulated viewport, synthesized touch, which engine, physical device) and what stayed untested
- **Horizontal scroll**: Content overflow on narrow viewports
- **Text scaling**: Layouts that break when text size increases
- **Missing breakpoints**: No mobile/tablet variants
**Score 0-4**: 0=Desktop-only (breaks on mobile), 1=Major issues (some breakpoints, many failures), 2=Partial (works on mobile, rough edges), 3=Good (responsive, minor touch target or overflow issues), 4=Excellent (fluid, all viewports, proper touch targets)
**Score 0-4**: 0=Desktop-only (breaks on mobile), 1=Major issues (some breakpoints, many failures), 2=Partial (works on mobile, rough edges), 3=Good (responsive, minor touch target or overflow issues), 4=Excellent (fluid, all viewports, proper touch targets, gestures work under touch)
### 5. Implementation Integrity (CRITICAL)
@@ -205,6 +205,11 @@ t('items', { count }) // Handles complex plural rules
- Optimistic updates with rollback
- Conflict resolution
**Interrupted gestures** (custom sliders, drag surfaces, scrollable control strips):
- A second finger or pointer lands mid-drag: the first drag keeps its pointer or ends cleanly, never jumps to the new one
- The browser cancels the gesture to scroll (`pointercancel`), capture is lost (`lostpointercapture`), the pointer is released outside the control, or the window loses focus (`blur`) mid-drag: clear the dragging state and release capture
- After each of these, the next tap or drag works without a reload
**Permission states**:
- No permission to view
- No permission to edit
@@ -304,6 +309,7 @@ const throttledScroll = throttle(handleScroll, 100);
- Unit tests for edge cases
- Integration tests for error scenarios
- E2E tests for critical paths
- A behavioral regression for each confirmed gesture fix, when the project's test runner can drive input
- Visual regression tests
- Accessibility tests (axe, WAVE)
@@ -330,7 +336,10 @@ Test thoroughly with edge cases:
- **Network issues**: Disable internet, throttle connection
- **Large datasets**: Test with 1000+ items
- **Concurrent actions**: Click submit 10 times rapidly
- **Interrupted gestures**: Add a second finger mid-drag, scroll across the control, release outside it, switch windows mid-drag; then drag again
- **Errors**: Force API errors, test all error states
- **Empty**: Remove all data, test empty states
For gestures, say what produced the evidence (emulated viewport, synthesized touch, which engine, physical device) and name what stayed untested.
When edge cases are covered, hand off to `$impeccable polish` for the final pass.
@@ -188,6 +188,12 @@ Test thoroughly across contexts:
- **Edge cases**: Very small screens (320px), very large screens (4K)
- **Slow connections**: Test on throttled network
**Custom controls** (sliders, drag surfaces, scrollable control strips): a before/after slider can pass every width check above and still refuse to drag on iOS, so exercise each one in scope in the same batched round as the checks above:
- **Primary gesture**: Tap it and confirm it responds as designed, then drag it with the target input method; the drag must complete, not just start
- **Scroll across it**: A swipe along the page's scroll axis across the control scrolls the page or container without activating it; a drag that starts on the control along its axis moves the control, not the page. Neither failure throws an error, so try both
- **Evidence**: Say what produced the evidence: an emulated viewport, synthesized touch input through a browser tool, which engine ran it (Chromium is not Safari), or a physical device. Screenshots and resized viewports verify layout, never a gesture. Name what stayed untested and move on; unreachable hardware is a reported gap, not a blocker
When the adaptation feels native to each context, hand off to `/impeccable polish` for the final pass.
---
+2 -1
View File
@@ -48,11 +48,12 @@ Run comprehensive checks across 5 dimensions. Score each dimension 0-4 using the
**Check for**:
- **Fixed widths**: Hard-coded widths that break on mobile
- **Touch targets**: Interactive elements < 44x44px
- **Broken touch interaction**: Custom sliders, drag surfaces, and scrollable control strips whose primary gesture fails under touch, that swallow page scroll or lose the drag to it, or that stay stuck after an interrupted gesture. Code tells: mouse-only handlers, no `touch-action` on a pointer-event drag surface, drag state that nothing clears on cancel, lost capture, or blur. Exercise the gesture when a browser tool can synthesize touch (a rendered viewport proves layout, not the gesture), then say what produced the evidence (emulated viewport, synthesized touch, which engine, physical device) and what stayed untested
- **Horizontal scroll**: Content overflow on narrow viewports
- **Text scaling**: Layouts that break when text size increases
- **Missing breakpoints**: No mobile/tablet variants
**Score 0-4**: 0=Desktop-only (breaks on mobile), 1=Major issues (some breakpoints, many failures), 2=Partial (works on mobile, rough edges), 3=Good (responsive, minor touch target or overflow issues), 4=Excellent (fluid, all viewports, proper touch targets)
**Score 0-4**: 0=Desktop-only (breaks on mobile), 1=Major issues (some breakpoints, many failures), 2=Partial (works on mobile, rough edges), 3=Good (responsive, minor touch target or overflow issues), 4=Excellent (fluid, all viewports, proper touch targets, gestures work under touch)
### 5. Implementation Integrity (CRITICAL)
@@ -205,6 +205,11 @@ t('items', { count }) // Handles complex plural rules
- Optimistic updates with rollback
- Conflict resolution
**Interrupted gestures** (custom sliders, drag surfaces, scrollable control strips):
- A second finger or pointer lands mid-drag: the first drag keeps its pointer or ends cleanly, never jumps to the new one
- The browser cancels the gesture to scroll (`pointercancel`), capture is lost (`lostpointercapture`), the pointer is released outside the control, or the window loses focus (`blur`) mid-drag: clear the dragging state and release capture
- After each of these, the next tap or drag works without a reload
**Permission states**:
- No permission to view
- No permission to edit
@@ -304,6 +309,7 @@ const throttledScroll = throttle(handleScroll, 100);
- Unit tests for edge cases
- Integration tests for error scenarios
- E2E tests for critical paths
- A behavioral regression for each confirmed gesture fix, when the project's test runner can drive input
- Visual regression tests
- Accessibility tests (axe, WAVE)
@@ -330,7 +336,10 @@ Test thoroughly with edge cases:
- **Network issues**: Disable internet, throttle connection
- **Large datasets**: Test with 1000+ items
- **Concurrent actions**: Click submit 10 times rapidly
- **Interrupted gestures**: Add a second finger mid-drag, scroll across the control, release outside it, switch windows mid-drag; then drag again
- **Errors**: Force API errors, test all error states
- **Empty**: Remove all data, test empty states
For gestures, say what produced the evidence (emulated viewport, synthesized touch, which engine, physical device) and name what stayed untested.
When edge cases are covered, hand off to `/impeccable polish` for the final pass.
@@ -188,6 +188,12 @@ Test thoroughly across contexts:
- **Edge cases**: Very small screens (320px), very large screens (4K)
- **Slow connections**: Test on throttled network
**Custom controls** (sliders, drag surfaces, scrollable control strips): a before/after slider can pass every width check above and still refuse to drag on iOS, so exercise each one in scope in the same batched round as the checks above:
- **Primary gesture**: Tap it and confirm it responds as designed, then drag it with the target input method; the drag must complete, not just start
- **Scroll across it**: A swipe along the page's scroll axis across the control scrolls the page or container without activating it; a drag that starts on the control along its axis moves the control, not the page. Neither failure throws an error, so try both
- **Evidence**: Say what produced the evidence: an emulated viewport, synthesized touch input through a browser tool, which engine ran it (Chromium is not Safari), or a physical device. Screenshots and resized viewports verify layout, never a gesture. Name what stayed untested and move on; unreachable hardware is a reported gap, not a blocker
When the adaptation feels native to each context, hand off to `/impeccable polish` for the final pass.
---
+2 -1
View File
@@ -48,11 +48,12 @@ Run comprehensive checks across 5 dimensions. Score each dimension 0-4 using the
**Check for**:
- **Fixed widths**: Hard-coded widths that break on mobile
- **Touch targets**: Interactive elements < 44x44px
- **Broken touch interaction**: Custom sliders, drag surfaces, and scrollable control strips whose primary gesture fails under touch, that swallow page scroll or lose the drag to it, or that stay stuck after an interrupted gesture. Code tells: mouse-only handlers, no `touch-action` on a pointer-event drag surface, drag state that nothing clears on cancel, lost capture, or blur. Exercise the gesture when a browser tool can synthesize touch (a rendered viewport proves layout, not the gesture), then say what produced the evidence (emulated viewport, synthesized touch, which engine, physical device) and what stayed untested
- **Horizontal scroll**: Content overflow on narrow viewports
- **Text scaling**: Layouts that break when text size increases
- **Missing breakpoints**: No mobile/tablet variants
**Score 0-4**: 0=Desktop-only (breaks on mobile), 1=Major issues (some breakpoints, many failures), 2=Partial (works on mobile, rough edges), 3=Good (responsive, minor touch target or overflow issues), 4=Excellent (fluid, all viewports, proper touch targets)
**Score 0-4**: 0=Desktop-only (breaks on mobile), 1=Major issues (some breakpoints, many failures), 2=Partial (works on mobile, rough edges), 3=Good (responsive, minor touch target or overflow issues), 4=Excellent (fluid, all viewports, proper touch targets, gestures work under touch)
### 5. Implementation Integrity (CRITICAL)
@@ -205,6 +205,11 @@ t('items', { count }) // Handles complex plural rules
- Optimistic updates with rollback
- Conflict resolution
**Interrupted gestures** (custom sliders, drag surfaces, scrollable control strips):
- A second finger or pointer lands mid-drag: the first drag keeps its pointer or ends cleanly, never jumps to the new one
- The browser cancels the gesture to scroll (`pointercancel`), capture is lost (`lostpointercapture`), the pointer is released outside the control, or the window loses focus (`blur`) mid-drag: clear the dragging state and release capture
- After each of these, the next tap or drag works without a reload
**Permission states**:
- No permission to view
- No permission to edit
@@ -304,6 +309,7 @@ const throttledScroll = throttle(handleScroll, 100);
- Unit tests for edge cases
- Integration tests for error scenarios
- E2E tests for critical paths
- A behavioral regression for each confirmed gesture fix, when the project's test runner can drive input
- Visual regression tests
- Accessibility tests (axe, WAVE)
@@ -330,7 +336,10 @@ Test thoroughly with edge cases:
- **Network issues**: Disable internet, throttle connection
- **Large datasets**: Test with 1000+ items
- **Concurrent actions**: Click submit 10 times rapidly
- **Interrupted gestures**: Add a second finger mid-drag, scroll across the control, release outside it, switch windows mid-drag; then drag again
- **Errors**: Force API errors, test all error states
- **Empty**: Remove all data, test empty states
For gestures, say what produced the evidence (emulated viewport, synthesized touch, which engine, physical device) and name what stayed untested.
When edge cases are covered, hand off to `/impeccable polish` for the final pass.
@@ -188,6 +188,12 @@ Test thoroughly across contexts:
- **Edge cases**: Very small screens (320px), very large screens (4K)
- **Slow connections**: Test on throttled network
**Custom controls** (sliders, drag surfaces, scrollable control strips): a before/after slider can pass every width check above and still refuse to drag on iOS, so exercise each one in scope in the same batched round as the checks above:
- **Primary gesture**: Tap it and confirm it responds as designed, then drag it with the target input method; the drag must complete, not just start
- **Scroll across it**: A swipe along the page's scroll axis across the control scrolls the page or container without activating it; a drag that starts on the control along its axis moves the control, not the page. Neither failure throws an error, so try both
- **Evidence**: Say what produced the evidence: an emulated viewport, synthesized touch input through a browser tool, which engine ran it (Chromium is not Safari), or a physical device. Screenshots and resized viewports verify layout, never a gesture. Name what stayed untested and move on; unreachable hardware is a reported gap, not a blocker
When the adaptation feels native to each context, hand off to `/impeccable polish` for the final pass.
---
+2 -1
View File
@@ -48,11 +48,12 @@ Run comprehensive checks across 5 dimensions. Score each dimension 0-4 using the
**Check for**:
- **Fixed widths**: Hard-coded widths that break on mobile
- **Touch targets**: Interactive elements < 44x44px
- **Broken touch interaction**: Custom sliders, drag surfaces, and scrollable control strips whose primary gesture fails under touch, that swallow page scroll or lose the drag to it, or that stay stuck after an interrupted gesture. Code tells: mouse-only handlers, no `touch-action` on a pointer-event drag surface, drag state that nothing clears on cancel, lost capture, or blur. Exercise the gesture when a browser tool can synthesize touch (a rendered viewport proves layout, not the gesture), then say what produced the evidence (emulated viewport, synthesized touch, which engine, physical device) and what stayed untested
- **Horizontal scroll**: Content overflow on narrow viewports
- **Text scaling**: Layouts that break when text size increases
- **Missing breakpoints**: No mobile/tablet variants
**Score 0-4**: 0=Desktop-only (breaks on mobile), 1=Major issues (some breakpoints, many failures), 2=Partial (works on mobile, rough edges), 3=Good (responsive, minor touch target or overflow issues), 4=Excellent (fluid, all viewports, proper touch targets)
**Score 0-4**: 0=Desktop-only (breaks on mobile), 1=Major issues (some breakpoints, many failures), 2=Partial (works on mobile, rough edges), 3=Good (responsive, minor touch target or overflow issues), 4=Excellent (fluid, all viewports, proper touch targets, gestures work under touch)
### 5. Implementation Integrity (CRITICAL)
@@ -205,6 +205,11 @@ t('items', { count }) // Handles complex plural rules
- Optimistic updates with rollback
- Conflict resolution
**Interrupted gestures** (custom sliders, drag surfaces, scrollable control strips):
- A second finger or pointer lands mid-drag: the first drag keeps its pointer or ends cleanly, never jumps to the new one
- The browser cancels the gesture to scroll (`pointercancel`), capture is lost (`lostpointercapture`), the pointer is released outside the control, or the window loses focus (`blur`) mid-drag: clear the dragging state and release capture
- After each of these, the next tap or drag works without a reload
**Permission states**:
- No permission to view
- No permission to edit
@@ -304,6 +309,7 @@ const throttledScroll = throttle(handleScroll, 100);
- Unit tests for edge cases
- Integration tests for error scenarios
- E2E tests for critical paths
- A behavioral regression for each confirmed gesture fix, when the project's test runner can drive input
- Visual regression tests
- Accessibility tests (axe, WAVE)
@@ -330,7 +336,10 @@ Test thoroughly with edge cases:
- **Network issues**: Disable internet, throttle connection
- **Large datasets**: Test with 1000+ items
- **Concurrent actions**: Click submit 10 times rapidly
- **Interrupted gestures**: Add a second finger mid-drag, scroll across the control, release outside it, switch windows mid-drag; then drag again
- **Errors**: Force API errors, test all error states
- **Empty**: Remove all data, test empty states
For gestures, say what produced the evidence (emulated viewport, synthesized touch, which engine, physical device) and name what stayed untested.
When edge cases are covered, hand off to `/impeccable polish` for the final pass.
@@ -188,6 +188,12 @@ Test thoroughly across contexts:
- **Edge cases**: Very small screens (320px), very large screens (4K)
- **Slow connections**: Test on throttled network
**Custom controls** (sliders, drag surfaces, scrollable control strips): a before/after slider can pass every width check above and still refuse to drag on iOS, so exercise each one in scope in the same batched round as the checks above:
- **Primary gesture**: Tap it and confirm it responds as designed, then drag it with the target input method; the drag must complete, not just start
- **Scroll across it**: A swipe along the page's scroll axis across the control scrolls the page or container without activating it; a drag that starts on the control along its axis moves the control, not the page. Neither failure throws an error, so try both
- **Evidence**: Say what produced the evidence: an emulated viewport, synthesized touch input through a browser tool, which engine ran it (Chromium is not Safari), or a physical device. Screenshots and resized viewports verify layout, never a gesture. Name what stayed untested and move on; unreachable hardware is a reported gap, not a blocker
When the adaptation feels native to each context, hand off to `/impeccable polish` for the final pass.
---
+2 -1
View File
@@ -48,11 +48,12 @@ Run comprehensive checks across 5 dimensions. Score each dimension 0-4 using the
**Check for**:
- **Fixed widths**: Hard-coded widths that break on mobile
- **Touch targets**: Interactive elements < 44x44px
- **Broken touch interaction**: Custom sliders, drag surfaces, and scrollable control strips whose primary gesture fails under touch, that swallow page scroll or lose the drag to it, or that stay stuck after an interrupted gesture. Code tells: mouse-only handlers, no `touch-action` on a pointer-event drag surface, drag state that nothing clears on cancel, lost capture, or blur. Exercise the gesture when a browser tool can synthesize touch (a rendered viewport proves layout, not the gesture), then say what produced the evidence (emulated viewport, synthesized touch, which engine, physical device) and what stayed untested
- **Horizontal scroll**: Content overflow on narrow viewports
- **Text scaling**: Layouts that break when text size increases
- **Missing breakpoints**: No mobile/tablet variants
**Score 0-4**: 0=Desktop-only (breaks on mobile), 1=Major issues (some breakpoints, many failures), 2=Partial (works on mobile, rough edges), 3=Good (responsive, minor touch target or overflow issues), 4=Excellent (fluid, all viewports, proper touch targets)
**Score 0-4**: 0=Desktop-only (breaks on mobile), 1=Major issues (some breakpoints, many failures), 2=Partial (works on mobile, rough edges), 3=Good (responsive, minor touch target or overflow issues), 4=Excellent (fluid, all viewports, proper touch targets, gestures work under touch)
### 5. Implementation Integrity (CRITICAL)
@@ -205,6 +205,11 @@ t('items', { count }) // Handles complex plural rules
- Optimistic updates with rollback
- Conflict resolution
**Interrupted gestures** (custom sliders, drag surfaces, scrollable control strips):
- A second finger or pointer lands mid-drag: the first drag keeps its pointer or ends cleanly, never jumps to the new one
- The browser cancels the gesture to scroll (`pointercancel`), capture is lost (`lostpointercapture`), the pointer is released outside the control, or the window loses focus (`blur`) mid-drag: clear the dragging state and release capture
- After each of these, the next tap or drag works without a reload
**Permission states**:
- No permission to view
- No permission to edit
@@ -304,6 +309,7 @@ const throttledScroll = throttle(handleScroll, 100);
- Unit tests for edge cases
- Integration tests for error scenarios
- E2E tests for critical paths
- A behavioral regression for each confirmed gesture fix, when the project's test runner can drive input
- Visual regression tests
- Accessibility tests (axe, WAVE)
@@ -330,7 +336,10 @@ Test thoroughly with edge cases:
- **Network issues**: Disable internet, throttle connection
- **Large datasets**: Test with 1000+ items
- **Concurrent actions**: Click submit 10 times rapidly
- **Interrupted gestures**: Add a second finger mid-drag, scroll across the control, release outside it, switch windows mid-drag; then drag again
- **Errors**: Force API errors, test all error states
- **Empty**: Remove all data, test empty states
For gestures, say what produced the evidence (emulated viewport, synthesized touch, which engine, physical device) and name what stayed untested.
When edge cases are covered, hand off to `/impeccable polish` for the final pass.
@@ -188,6 +188,12 @@ Test thoroughly across contexts:
- **Edge cases**: Very small screens (320px), very large screens (4K)
- **Slow connections**: Test on throttled network
**Custom controls** (sliders, drag surfaces, scrollable control strips): a before/after slider can pass every width check above and still refuse to drag on iOS, so exercise each one in scope in the same batched round as the checks above:
- **Primary gesture**: Tap it and confirm it responds as designed, then drag it with the target input method; the drag must complete, not just start
- **Scroll across it**: A swipe along the page's scroll axis across the control scrolls the page or container without activating it; a drag that starts on the control along its axis moves the control, not the page. Neither failure throws an error, so try both
- **Evidence**: Say what produced the evidence: an emulated viewport, synthesized touch input through a browser tool, which engine ran it (Chromium is not Safari), or a physical device. Screenshots and resized viewports verify layout, never a gesture. Name what stayed untested and move on; unreachable hardware is a reported gap, not a blocker
When the adaptation feels native to each context, hand off to `/impeccable polish` for the final pass.
---
+2 -1
View File
@@ -48,11 +48,12 @@ Run comprehensive checks across 5 dimensions. Score each dimension 0-4 using the
**Check for**:
- **Fixed widths**: Hard-coded widths that break on mobile
- **Touch targets**: Interactive elements < 44x44px
- **Broken touch interaction**: Custom sliders, drag surfaces, and scrollable control strips whose primary gesture fails under touch, that swallow page scroll or lose the drag to it, or that stay stuck after an interrupted gesture. Code tells: mouse-only handlers, no `touch-action` on a pointer-event drag surface, drag state that nothing clears on cancel, lost capture, or blur. Exercise the gesture when a browser tool can synthesize touch (a rendered viewport proves layout, not the gesture), then say what produced the evidence (emulated viewport, synthesized touch, which engine, physical device) and what stayed untested
- **Horizontal scroll**: Content overflow on narrow viewports
- **Text scaling**: Layouts that break when text size increases
- **Missing breakpoints**: No mobile/tablet variants
**Score 0-4**: 0=Desktop-only (breaks on mobile), 1=Major issues (some breakpoints, many failures), 2=Partial (works on mobile, rough edges), 3=Good (responsive, minor touch target or overflow issues), 4=Excellent (fluid, all viewports, proper touch targets)
**Score 0-4**: 0=Desktop-only (breaks on mobile), 1=Major issues (some breakpoints, many failures), 2=Partial (works on mobile, rough edges), 3=Good (responsive, minor touch target or overflow issues), 4=Excellent (fluid, all viewports, proper touch targets, gestures work under touch)
### 5. Implementation Integrity (CRITICAL)
@@ -205,6 +205,11 @@ t('items', { count }) // Handles complex plural rules
- Optimistic updates with rollback
- Conflict resolution
**Interrupted gestures** (custom sliders, drag surfaces, scrollable control strips):
- A second finger or pointer lands mid-drag: the first drag keeps its pointer or ends cleanly, never jumps to the new one
- The browser cancels the gesture to scroll (`pointercancel`), capture is lost (`lostpointercapture`), the pointer is released outside the control, or the window loses focus (`blur`) mid-drag: clear the dragging state and release capture
- After each of these, the next tap or drag works without a reload
**Permission states**:
- No permission to view
- No permission to edit
@@ -304,6 +309,7 @@ const throttledScroll = throttle(handleScroll, 100);
- Unit tests for edge cases
- Integration tests for error scenarios
- E2E tests for critical paths
- A behavioral regression for each confirmed gesture fix, when the project's test runner can drive input
- Visual regression tests
- Accessibility tests (axe, WAVE)
@@ -330,7 +336,10 @@ Test thoroughly with edge cases:
- **Network issues**: Disable internet, throttle connection
- **Large datasets**: Test with 1000+ items
- **Concurrent actions**: Click submit 10 times rapidly
- **Interrupted gestures**: Add a second finger mid-drag, scroll across the control, release outside it, switch windows mid-drag; then drag again
- **Errors**: Force API errors, test all error states
- **Empty**: Remove all data, test empty states
For gestures, say what produced the evidence (emulated viewport, synthesized touch, which engine, physical device) and name what stayed untested.
When edge cases are covered, hand off to `/impeccable polish` for the final pass.
@@ -188,6 +188,12 @@ Test thoroughly across contexts:
- **Edge cases**: Very small screens (320px), very large screens (4K)
- **Slow connections**: Test on throttled network
**Custom controls** (sliders, drag surfaces, scrollable control strips): a before/after slider can pass every width check above and still refuse to drag on iOS, so exercise each one in scope in the same batched round as the checks above:
- **Primary gesture**: Tap it and confirm it responds as designed, then drag it with the target input method; the drag must complete, not just start
- **Scroll across it**: A swipe along the page's scroll axis across the control scrolls the page or container without activating it; a drag that starts on the control along its axis moves the control, not the page. Neither failure throws an error, so try both
- **Evidence**: Say what produced the evidence: an emulated viewport, synthesized touch input through a browser tool, which engine ran it (Chromium is not Safari), or a physical device. Screenshots and resized viewports verify layout, never a gesture. Name what stayed untested and move on; unreachable hardware is a reported gap, not a blocker
When the adaptation feels native to each context, hand off to `/impeccable polish` for the final pass.
---
+2 -1
View File
@@ -48,11 +48,12 @@ Run comprehensive checks across 5 dimensions. Score each dimension 0-4 using the
**Check for**:
- **Fixed widths**: Hard-coded widths that break on mobile
- **Touch targets**: Interactive elements < 44x44px
- **Broken touch interaction**: Custom sliders, drag surfaces, and scrollable control strips whose primary gesture fails under touch, that swallow page scroll or lose the drag to it, or that stay stuck after an interrupted gesture. Code tells: mouse-only handlers, no `touch-action` on a pointer-event drag surface, drag state that nothing clears on cancel, lost capture, or blur. Exercise the gesture when a browser tool can synthesize touch (a rendered viewport proves layout, not the gesture), then say what produced the evidence (emulated viewport, synthesized touch, which engine, physical device) and what stayed untested
- **Horizontal scroll**: Content overflow on narrow viewports
- **Text scaling**: Layouts that break when text size increases
- **Missing breakpoints**: No mobile/tablet variants
**Score 0-4**: 0=Desktop-only (breaks on mobile), 1=Major issues (some breakpoints, many failures), 2=Partial (works on mobile, rough edges), 3=Good (responsive, minor touch target or overflow issues), 4=Excellent (fluid, all viewports, proper touch targets)
**Score 0-4**: 0=Desktop-only (breaks on mobile), 1=Major issues (some breakpoints, many failures), 2=Partial (works on mobile, rough edges), 3=Good (responsive, minor touch target or overflow issues), 4=Excellent (fluid, all viewports, proper touch targets, gestures work under touch)
### 5. Implementation Integrity (CRITICAL)
@@ -205,6 +205,11 @@ t('items', { count }) // Handles complex plural rules
- Optimistic updates with rollback
- Conflict resolution
**Interrupted gestures** (custom sliders, drag surfaces, scrollable control strips):
- A second finger or pointer lands mid-drag: the first drag keeps its pointer or ends cleanly, never jumps to the new one
- The browser cancels the gesture to scroll (`pointercancel`), capture is lost (`lostpointercapture`), the pointer is released outside the control, or the window loses focus (`blur`) mid-drag: clear the dragging state and release capture
- After each of these, the next tap or drag works without a reload
**Permission states**:
- No permission to view
- No permission to edit
@@ -304,6 +309,7 @@ const throttledScroll = throttle(handleScroll, 100);
- Unit tests for edge cases
- Integration tests for error scenarios
- E2E tests for critical paths
- A behavioral regression for each confirmed gesture fix, when the project's test runner can drive input
- Visual regression tests
- Accessibility tests (axe, WAVE)
@@ -330,7 +336,10 @@ Test thoroughly with edge cases:
- **Network issues**: Disable internet, throttle connection
- **Large datasets**: Test with 1000+ items
- **Concurrent actions**: Click submit 10 times rapidly
- **Interrupted gestures**: Add a second finger mid-drag, scroll across the control, release outside it, switch windows mid-drag; then drag again
- **Errors**: Force API errors, test all error states
- **Empty**: Remove all data, test empty states
For gestures, say what produced the evidence (emulated viewport, synthesized touch, which engine, physical device) and name what stayed untested.
When edge cases are covered, hand off to `/impeccable polish` for the final pass.
@@ -188,6 +188,12 @@ Test thoroughly across contexts:
- **Edge cases**: Very small screens (320px), very large screens (4K)
- **Slow connections**: Test on throttled network
**Custom controls** (sliders, drag surfaces, scrollable control strips): a before/after slider can pass every width check above and still refuse to drag on iOS, so exercise each one in scope in the same batched round as the checks above:
- **Primary gesture**: Tap it and confirm it responds as designed, then drag it with the target input method; the drag must complete, not just start
- **Scroll across it**: A swipe along the page's scroll axis across the control scrolls the page or container without activating it; a drag that starts on the control along its axis moves the control, not the page. Neither failure throws an error, so try both
- **Evidence**: Say what produced the evidence: an emulated viewport, synthesized touch input through a browser tool, which engine ran it (Chromium is not Safari), or a physical device. Screenshots and resized viewports verify layout, never a gesture. Name what stayed untested and move on; unreachable hardware is a reported gap, not a blocker
When the adaptation feels native to each context, hand off to `/impeccable polish` for the final pass.
---
+2 -1
View File
@@ -48,11 +48,12 @@ Run comprehensive checks across 5 dimensions. Score each dimension 0-4 using the
**Check for**:
- **Fixed widths**: Hard-coded widths that break on mobile
- **Touch targets**: Interactive elements < 44x44px
- **Broken touch interaction**: Custom sliders, drag surfaces, and scrollable control strips whose primary gesture fails under touch, that swallow page scroll or lose the drag to it, or that stay stuck after an interrupted gesture. Code tells: mouse-only handlers, no `touch-action` on a pointer-event drag surface, drag state that nothing clears on cancel, lost capture, or blur. Exercise the gesture when a browser tool can synthesize touch (a rendered viewport proves layout, not the gesture), then say what produced the evidence (emulated viewport, synthesized touch, which engine, physical device) and what stayed untested
- **Horizontal scroll**: Content overflow on narrow viewports
- **Text scaling**: Layouts that break when text size increases
- **Missing breakpoints**: No mobile/tablet variants
**Score 0-4**: 0=Desktop-only (breaks on mobile), 1=Major issues (some breakpoints, many failures), 2=Partial (works on mobile, rough edges), 3=Good (responsive, minor touch target or overflow issues), 4=Excellent (fluid, all viewports, proper touch targets)
**Score 0-4**: 0=Desktop-only (breaks on mobile), 1=Major issues (some breakpoints, many failures), 2=Partial (works on mobile, rough edges), 3=Good (responsive, minor touch target or overflow issues), 4=Excellent (fluid, all viewports, proper touch targets, gestures work under touch)
### 5. Implementation Integrity (CRITICAL)
@@ -205,6 +205,11 @@ t('items', { count }) // Handles complex plural rules
- Optimistic updates with rollback
- Conflict resolution
**Interrupted gestures** (custom sliders, drag surfaces, scrollable control strips):
- A second finger or pointer lands mid-drag: the first drag keeps its pointer or ends cleanly, never jumps to the new one
- The browser cancels the gesture to scroll (`pointercancel`), capture is lost (`lostpointercapture`), the pointer is released outside the control, or the window loses focus (`blur`) mid-drag: clear the dragging state and release capture
- After each of these, the next tap or drag works without a reload
**Permission states**:
- No permission to view
- No permission to edit
@@ -304,6 +309,7 @@ const throttledScroll = throttle(handleScroll, 100);
- Unit tests for edge cases
- Integration tests for error scenarios
- E2E tests for critical paths
- A behavioral regression for each confirmed gesture fix, when the project's test runner can drive input
- Visual regression tests
- Accessibility tests (axe, WAVE)
@@ -330,7 +336,10 @@ Test thoroughly with edge cases:
- **Network issues**: Disable internet, throttle connection
- **Large datasets**: Test with 1000+ items
- **Concurrent actions**: Click submit 10 times rapidly
- **Interrupted gestures**: Add a second finger mid-drag, scroll across the control, release outside it, switch windows mid-drag; then drag again
- **Errors**: Force API errors, test all error states
- **Empty**: Remove all data, test empty states
For gestures, say what produced the evidence (emulated viewport, synthesized touch, which engine, physical device) and name what stayed untested.
When edge cases are covered, hand off to `/impeccable polish` for the final pass.
@@ -188,6 +188,12 @@ Test thoroughly across contexts:
- **Edge cases**: Very small screens (320px), very large screens (4K)
- **Slow connections**: Test on throttled network
**Custom controls** (sliders, drag surfaces, scrollable control strips): a before/after slider can pass every width check above and still refuse to drag on iOS, so exercise each one in scope in the same batched round as the checks above:
- **Primary gesture**: Tap it and confirm it responds as designed, then drag it with the target input method; the drag must complete, not just start
- **Scroll across it**: A swipe along the page's scroll axis across the control scrolls the page or container without activating it; a drag that starts on the control along its axis moves the control, not the page. Neither failure throws an error, so try both
- **Evidence**: Say what produced the evidence: an emulated viewport, synthesized touch input through a browser tool, which engine ran it (Chromium is not Safari), or a physical device. Screenshots and resized viewports verify layout, never a gesture. Name what stayed untested and move on; unreachable hardware is a reported gap, not a blocker
When the adaptation feels native to each context, hand off to `/impeccable polish` for the final pass.
---
+2 -1
View File
@@ -48,11 +48,12 @@ Run comprehensive checks across 5 dimensions. Score each dimension 0-4 using the
**Check for**:
- **Fixed widths**: Hard-coded widths that break on mobile
- **Touch targets**: Interactive elements < 44x44px
- **Broken touch interaction**: Custom sliders, drag surfaces, and scrollable control strips whose primary gesture fails under touch, that swallow page scroll or lose the drag to it, or that stay stuck after an interrupted gesture. Code tells: mouse-only handlers, no `touch-action` on a pointer-event drag surface, drag state that nothing clears on cancel, lost capture, or blur. Exercise the gesture when a browser tool can synthesize touch (a rendered viewport proves layout, not the gesture), then say what produced the evidence (emulated viewport, synthesized touch, which engine, physical device) and what stayed untested
- **Horizontal scroll**: Content overflow on narrow viewports
- **Text scaling**: Layouts that break when text size increases
- **Missing breakpoints**: No mobile/tablet variants
**Score 0-4**: 0=Desktop-only (breaks on mobile), 1=Major issues (some breakpoints, many failures), 2=Partial (works on mobile, rough edges), 3=Good (responsive, minor touch target or overflow issues), 4=Excellent (fluid, all viewports, proper touch targets)
**Score 0-4**: 0=Desktop-only (breaks on mobile), 1=Major issues (some breakpoints, many failures), 2=Partial (works on mobile, rough edges), 3=Good (responsive, minor touch target or overflow issues), 4=Excellent (fluid, all viewports, proper touch targets, gestures work under touch)
### 5. Implementation Integrity (CRITICAL)
@@ -205,6 +205,11 @@ t('items', { count }) // Handles complex plural rules
- Optimistic updates with rollback
- Conflict resolution
**Interrupted gestures** (custom sliders, drag surfaces, scrollable control strips):
- A second finger or pointer lands mid-drag: the first drag keeps its pointer or ends cleanly, never jumps to the new one
- The browser cancels the gesture to scroll (`pointercancel`), capture is lost (`lostpointercapture`), the pointer is released outside the control, or the window loses focus (`blur`) mid-drag: clear the dragging state and release capture
- After each of these, the next tap or drag works without a reload
**Permission states**:
- No permission to view
- No permission to edit
@@ -304,6 +309,7 @@ const throttledScroll = throttle(handleScroll, 100);
- Unit tests for edge cases
- Integration tests for error scenarios
- E2E tests for critical paths
- A behavioral regression for each confirmed gesture fix, when the project's test runner can drive input
- Visual regression tests
- Accessibility tests (axe, WAVE)
@@ -330,7 +336,10 @@ Test thoroughly with edge cases:
- **Network issues**: Disable internet, throttle connection
- **Large datasets**: Test with 1000+ items
- **Concurrent actions**: Click submit 10 times rapidly
- **Interrupted gestures**: Add a second finger mid-drag, scroll across the control, release outside it, switch windows mid-drag; then drag again
- **Errors**: Force API errors, test all error states
- **Empty**: Remove all data, test empty states
For gestures, say what produced the evidence (emulated viewport, synthesized touch, which engine, physical device) and name what stayed untested.
When edge cases are covered, hand off to `/impeccable polish` for the final pass.
@@ -188,6 +188,12 @@ Test thoroughly across contexts:
- **Edge cases**: Very small screens (320px), very large screens (4K)
- **Slow connections**: Test on throttled network
**Custom controls** (sliders, drag surfaces, scrollable control strips): a before/after slider can pass every width check above and still refuse to drag on iOS, so exercise each one in scope in the same batched round as the checks above:
- **Primary gesture**: Tap it and confirm it responds as designed, then drag it with the target input method; the drag must complete, not just start
- **Scroll across it**: A swipe along the page's scroll axis across the control scrolls the page or container without activating it; a drag that starts on the control along its axis moves the control, not the page. Neither failure throws an error, so try both
- **Evidence**: Say what produced the evidence: an emulated viewport, synthesized touch input through a browser tool, which engine ran it (Chromium is not Safari), or a physical device. Screenshots and resized viewports verify layout, never a gesture. Name what stayed untested and move on; unreachable hardware is a reported gap, not a blocker
When the adaptation feels native to each context, hand off to `/impeccable polish` for the final pass.
---
@@ -48,11 +48,12 @@ Run comprehensive checks across 5 dimensions. Score each dimension 0-4 using the
**Check for**:
- **Fixed widths**: Hard-coded widths that break on mobile
- **Touch targets**: Interactive elements < 44x44px
- **Broken touch interaction**: Custom sliders, drag surfaces, and scrollable control strips whose primary gesture fails under touch, that swallow page scroll or lose the drag to it, or that stay stuck after an interrupted gesture. Code tells: mouse-only handlers, no `touch-action` on a pointer-event drag surface, drag state that nothing clears on cancel, lost capture, or blur. Exercise the gesture when a browser tool can synthesize touch (a rendered viewport proves layout, not the gesture), then say what produced the evidence (emulated viewport, synthesized touch, which engine, physical device) and what stayed untested
- **Horizontal scroll**: Content overflow on narrow viewports
- **Text scaling**: Layouts that break when text size increases
- **Missing breakpoints**: No mobile/tablet variants
**Score 0-4**: 0=Desktop-only (breaks on mobile), 1=Major issues (some breakpoints, many failures), 2=Partial (works on mobile, rough edges), 3=Good (responsive, minor touch target or overflow issues), 4=Excellent (fluid, all viewports, proper touch targets)
**Score 0-4**: 0=Desktop-only (breaks on mobile), 1=Major issues (some breakpoints, many failures), 2=Partial (works on mobile, rough edges), 3=Good (responsive, minor touch target or overflow issues), 4=Excellent (fluid, all viewports, proper touch targets, gestures work under touch)
### 5. Implementation Integrity (CRITICAL)
@@ -205,6 +205,11 @@ t('items', { count }) // Handles complex plural rules
- Optimistic updates with rollback
- Conflict resolution
**Interrupted gestures** (custom sliders, drag surfaces, scrollable control strips):
- A second finger or pointer lands mid-drag: the first drag keeps its pointer or ends cleanly, never jumps to the new one
- The browser cancels the gesture to scroll (`pointercancel`), capture is lost (`lostpointercapture`), the pointer is released outside the control, or the window loses focus (`blur`) mid-drag: clear the dragging state and release capture
- After each of these, the next tap or drag works without a reload
**Permission states**:
- No permission to view
- No permission to edit
@@ -304,6 +309,7 @@ const throttledScroll = throttle(handleScroll, 100);
- Unit tests for edge cases
- Integration tests for error scenarios
- E2E tests for critical paths
- A behavioral regression for each confirmed gesture fix, when the project's test runner can drive input
- Visual regression tests
- Accessibility tests (axe, WAVE)
@@ -330,7 +336,10 @@ Test thoroughly with edge cases:
- **Network issues**: Disable internet, throttle connection
- **Large datasets**: Test with 1000+ items
- **Concurrent actions**: Click submit 10 times rapidly
- **Interrupted gestures**: Add a second finger mid-drag, scroll across the control, release outside it, switch windows mid-drag; then drag again
- **Errors**: Force API errors, test all error states
- **Empty**: Remove all data, test empty states
For gestures, say what produced the evidence (emulated viewport, synthesized touch, which engine, physical device) and name what stayed untested.
When edge cases are covered, hand off to `/impeccable polish` for the final pass.
+6
View File
@@ -188,6 +188,12 @@ Test thoroughly across contexts:
- **Edge cases**: Very small screens (320px), very large screens (4K)
- **Slow connections**: Test on throttled network
**Custom controls** (sliders, drag surfaces, scrollable control strips): a before/after slider can pass every width check above and still refuse to drag on iOS, so exercise each one in scope in the same batched round as the checks above:
- **Primary gesture**: Tap it and confirm it responds as designed, then drag it with the target input method; the drag must complete, not just start
- **Scroll across it**: A swipe along the page's scroll axis across the control scrolls the page or container without activating it; a drag that starts on the control along its axis moves the control, not the page. Neither failure throws an error, so try both
- **Evidence**: Say what produced the evidence: an emulated viewport, synthesized touch input through a browser tool, which engine ran it (Chromium is not Safari), or a physical device. Screenshots and resized viewports verify layout, never a gesture. Name what stayed untested and move on; unreachable hardware is a reported gap, not a blocker
When the adaptation feels native to each context, hand off to `/impeccable polish` for the final pass.
---
+2 -1
View File
@@ -48,11 +48,12 @@ Run comprehensive checks across 5 dimensions. Score each dimension 0-4 using the
**Check for**:
- **Fixed widths**: Hard-coded widths that break on mobile
- **Touch targets**: Interactive elements < 44x44px
- **Broken touch interaction**: Custom sliders, drag surfaces, and scrollable control strips whose primary gesture fails under touch, that swallow page scroll or lose the drag to it, or that stay stuck after an interrupted gesture. Code tells: mouse-only handlers, no `touch-action` on a pointer-event drag surface, drag state that nothing clears on cancel, lost capture, or blur. Exercise the gesture when a browser tool can synthesize touch (a rendered viewport proves layout, not the gesture), then say what produced the evidence (emulated viewport, synthesized touch, which engine, physical device) and what stayed untested
- **Horizontal scroll**: Content overflow on narrow viewports
- **Text scaling**: Layouts that break when text size increases
- **Missing breakpoints**: No mobile/tablet variants
**Score 0-4**: 0=Desktop-only (breaks on mobile), 1=Major issues (some breakpoints, many failures), 2=Partial (works on mobile, rough edges), 3=Good (responsive, minor touch target or overflow issues), 4=Excellent (fluid, all viewports, proper touch targets)
**Score 0-4**: 0=Desktop-only (breaks on mobile), 1=Major issues (some breakpoints, many failures), 2=Partial (works on mobile, rough edges), 3=Good (responsive, minor touch target or overflow issues), 4=Excellent (fluid, all viewports, proper touch targets, gestures work under touch)
### 5. Implementation Integrity (CRITICAL)
@@ -205,6 +205,11 @@ t('items', { count }) // Handles complex plural rules
- Optimistic updates with rollback
- Conflict resolution
**Interrupted gestures** (custom sliders, drag surfaces, scrollable control strips):
- A second finger or pointer lands mid-drag: the first drag keeps its pointer or ends cleanly, never jumps to the new one
- The browser cancels the gesture to scroll (`pointercancel`), capture is lost (`lostpointercapture`), the pointer is released outside the control, or the window loses focus (`blur`) mid-drag: clear the dragging state and release capture
- After each of these, the next tap or drag works without a reload
**Permission states**:
- No permission to view
- No permission to edit
@@ -304,6 +309,7 @@ const throttledScroll = throttle(handleScroll, 100);
- Unit tests for edge cases
- Integration tests for error scenarios
- E2E tests for critical paths
- A behavioral regression for each confirmed gesture fix, when the project's test runner can drive input
- Visual regression tests
- Accessibility tests (axe, WAVE)
@@ -330,7 +336,10 @@ Test thoroughly with edge cases:
- **Network issues**: Disable internet, throttle connection
- **Large datasets**: Test with 1000+ items
- **Concurrent actions**: Click submit 10 times rapidly
- **Interrupted gestures**: Add a second finger mid-drag, scroll across the control, release outside it, switch windows mid-drag; then drag again
- **Errors**: Force API errors, test all error states
- **Empty**: Remove all data, test empty states
For gestures, say what produced the evidence (emulated viewport, synthesized touch, which engine, physical device) and name what stayed untested.
When edge cases are covered, hand off to `/impeccable polish` for the final pass.
@@ -188,6 +188,12 @@ Test thoroughly across contexts:
- **Edge cases**: Very small screens (320px), very large screens (4K)
- **Slow connections**: Test on throttled network
**Custom controls** (sliders, drag surfaces, scrollable control strips): a before/after slider can pass every width check above and still refuse to drag on iOS, so exercise each one in scope in the same batched round as the checks above:
- **Primary gesture**: Tap it and confirm it responds as designed, then drag it with the target input method; the drag must complete, not just start
- **Scroll across it**: A swipe along the page's scroll axis across the control scrolls the page or container without activating it; a drag that starts on the control along its axis moves the control, not the page. Neither failure throws an error, so try both
- **Evidence**: Say what produced the evidence: an emulated viewport, synthesized touch input through a browser tool, which engine ran it (Chromium is not Safari), or a physical device. Screenshots and resized viewports verify layout, never a gesture. Name what stayed untested and move on; unreachable hardware is a reported gap, not a blocker
When the adaptation feels native to each context, hand off to `/impeccable polish` for the final pass.
---
+2 -1
View File
@@ -48,11 +48,12 @@ Run comprehensive checks across 5 dimensions. Score each dimension 0-4 using the
**Check for**:
- **Fixed widths**: Hard-coded widths that break on mobile
- **Touch targets**: Interactive elements < 44x44px
- **Broken touch interaction**: Custom sliders, drag surfaces, and scrollable control strips whose primary gesture fails under touch, that swallow page scroll or lose the drag to it, or that stay stuck after an interrupted gesture. Code tells: mouse-only handlers, no `touch-action` on a pointer-event drag surface, drag state that nothing clears on cancel, lost capture, or blur. Exercise the gesture when a browser tool can synthesize touch (a rendered viewport proves layout, not the gesture), then say what produced the evidence (emulated viewport, synthesized touch, which engine, physical device) and what stayed untested
- **Horizontal scroll**: Content overflow on narrow viewports
- **Text scaling**: Layouts that break when text size increases
- **Missing breakpoints**: No mobile/tablet variants
**Score 0-4**: 0=Desktop-only (breaks on mobile), 1=Major issues (some breakpoints, many failures), 2=Partial (works on mobile, rough edges), 3=Good (responsive, minor touch target or overflow issues), 4=Excellent (fluid, all viewports, proper touch targets)
**Score 0-4**: 0=Desktop-only (breaks on mobile), 1=Major issues (some breakpoints, many failures), 2=Partial (works on mobile, rough edges), 3=Good (responsive, minor touch target or overflow issues), 4=Excellent (fluid, all viewports, proper touch targets, gestures work under touch)
### 5. Implementation Integrity (CRITICAL)
@@ -205,6 +205,11 @@ t('items', { count }) // Handles complex plural rules
- Optimistic updates with rollback
- Conflict resolution
**Interrupted gestures** (custom sliders, drag surfaces, scrollable control strips):
- A second finger or pointer lands mid-drag: the first drag keeps its pointer or ends cleanly, never jumps to the new one
- The browser cancels the gesture to scroll (`pointercancel`), capture is lost (`lostpointercapture`), the pointer is released outside the control, or the window loses focus (`blur`) mid-drag: clear the dragging state and release capture
- After each of these, the next tap or drag works without a reload
**Permission states**:
- No permission to view
- No permission to edit
@@ -304,6 +309,7 @@ const throttledScroll = throttle(handleScroll, 100);
- Unit tests for edge cases
- Integration tests for error scenarios
- E2E tests for critical paths
- A behavioral regression for each confirmed gesture fix, when the project's test runner can drive input
- Visual regression tests
- Accessibility tests (axe, WAVE)
@@ -330,7 +336,10 @@ Test thoroughly with edge cases:
- **Network issues**: Disable internet, throttle connection
- **Large datasets**: Test with 1000+ items
- **Concurrent actions**: Click submit 10 times rapidly
- **Interrupted gestures**: Add a second finger mid-drag, scroll across the control, release outside it, switch windows mid-drag; then drag again
- **Errors**: Force API errors, test all error states
- **Empty**: Remove all data, test empty states
For gestures, say what produced the evidence (emulated viewport, synthesized touch, which engine, physical device) and name what stayed untested.
When edge cases are covered, hand off to `/impeccable polish` for the final pass.
@@ -188,6 +188,12 @@ Test thoroughly across contexts:
- **Edge cases**: Very small screens (320px), very large screens (4K)
- **Slow connections**: Test on throttled network
**Custom controls** (sliders, drag surfaces, scrollable control strips): a before/after slider can pass every width check above and still refuse to drag on iOS, so exercise each one in scope in the same batched round as the checks above:
- **Primary gesture**: Tap it and confirm it responds as designed, then drag it with the target input method; the drag must complete, not just start
- **Scroll across it**: A swipe along the page's scroll axis across the control scrolls the page or container without activating it; a drag that starts on the control along its axis moves the control, not the page. Neither failure throws an error, so try both
- **Evidence**: Say what produced the evidence: an emulated viewport, synthesized touch input through a browser tool, which engine ran it (Chromium is not Safari), or a physical device. Screenshots and resized viewports verify layout, never a gesture. Name what stayed untested and move on; unreachable hardware is a reported gap, not a blocker
When the adaptation feels native to each context, hand off to `/impeccable polish` for the final pass.
---
@@ -48,11 +48,12 @@ Run comprehensive checks across 5 dimensions. Score each dimension 0-4 using the
**Check for**:
- **Fixed widths**: Hard-coded widths that break on mobile
- **Touch targets**: Interactive elements < 44x44px
- **Broken touch interaction**: Custom sliders, drag surfaces, and scrollable control strips whose primary gesture fails under touch, that swallow page scroll or lose the drag to it, or that stay stuck after an interrupted gesture. Code tells: mouse-only handlers, no `touch-action` on a pointer-event drag surface, drag state that nothing clears on cancel, lost capture, or blur. Exercise the gesture when a browser tool can synthesize touch (a rendered viewport proves layout, not the gesture), then say what produced the evidence (emulated viewport, synthesized touch, which engine, physical device) and what stayed untested
- **Horizontal scroll**: Content overflow on narrow viewports
- **Text scaling**: Layouts that break when text size increases
- **Missing breakpoints**: No mobile/tablet variants
**Score 0-4**: 0=Desktop-only (breaks on mobile), 1=Major issues (some breakpoints, many failures), 2=Partial (works on mobile, rough edges), 3=Good (responsive, minor touch target or overflow issues), 4=Excellent (fluid, all viewports, proper touch targets)
**Score 0-4**: 0=Desktop-only (breaks on mobile), 1=Major issues (some breakpoints, many failures), 2=Partial (works on mobile, rough edges), 3=Good (responsive, minor touch target or overflow issues), 4=Excellent (fluid, all viewports, proper touch targets, gestures work under touch)
### 5. Implementation Integrity (CRITICAL)
@@ -205,6 +205,11 @@ t('items', { count }) // Handles complex plural rules
- Optimistic updates with rollback
- Conflict resolution
**Interrupted gestures** (custom sliders, drag surfaces, scrollable control strips):
- A second finger or pointer lands mid-drag: the first drag keeps its pointer or ends cleanly, never jumps to the new one
- The browser cancels the gesture to scroll (`pointercancel`), capture is lost (`lostpointercapture`), the pointer is released outside the control, or the window loses focus (`blur`) mid-drag: clear the dragging state and release capture
- After each of these, the next tap or drag works without a reload
**Permission states**:
- No permission to view
- No permission to edit
@@ -304,6 +309,7 @@ const throttledScroll = throttle(handleScroll, 100);
- Unit tests for edge cases
- Integration tests for error scenarios
- E2E tests for critical paths
- A behavioral regression for each confirmed gesture fix, when the project's test runner can drive input
- Visual regression tests
- Accessibility tests (axe, WAVE)
@@ -330,7 +336,10 @@ Test thoroughly with edge cases:
- **Network issues**: Disable internet, throttle connection
- **Large datasets**: Test with 1000+ items
- **Concurrent actions**: Click submit 10 times rapidly
- **Interrupted gestures**: Add a second finger mid-drag, scroll across the control, release outside it, switch windows mid-drag; then drag again
- **Errors**: Force API errors, test all error states
- **Empty**: Remove all data, test empty states
For gestures, say what produced the evidence (emulated viewport, synthesized touch, which engine, physical device) and name what stayed untested.
When edge cases are covered, hand off to `/impeccable polish` for the final pass.
@@ -188,6 +188,12 @@ Test thoroughly across contexts:
- **Edge cases**: Very small screens (320px), very large screens (4K)
- **Slow connections**: Test on throttled network
**Custom controls** (sliders, drag surfaces, scrollable control strips): a before/after slider can pass every width check above and still refuse to drag on iOS, so exercise each one in scope in the same batched round as the checks above:
- **Primary gesture**: Tap it and confirm it responds as designed, then drag it with the target input method; the drag must complete, not just start
- **Scroll across it**: A swipe along the page's scroll axis across the control scrolls the page or container without activating it; a drag that starts on the control along its axis moves the control, not the page. Neither failure throws an error, so try both
- **Evidence**: Say what produced the evidence: an emulated viewport, synthesized touch input through a browser tool, which engine ran it (Chromium is not Safari), or a physical device. Screenshots and resized viewports verify layout, never a gesture. Name what stayed untested and move on; unreachable hardware is a reported gap, not a blocker
When the adaptation feels native to each context, hand off to `/impeccable polish` for the final pass.
---
@@ -48,11 +48,12 @@ Run comprehensive checks across 5 dimensions. Score each dimension 0-4 using the
**Check for**:
- **Fixed widths**: Hard-coded widths that break on mobile
- **Touch targets**: Interactive elements < 44x44px
- **Broken touch interaction**: Custom sliders, drag surfaces, and scrollable control strips whose primary gesture fails under touch, that swallow page scroll or lose the drag to it, or that stay stuck after an interrupted gesture. Code tells: mouse-only handlers, no `touch-action` on a pointer-event drag surface, drag state that nothing clears on cancel, lost capture, or blur. Exercise the gesture when a browser tool can synthesize touch (a rendered viewport proves layout, not the gesture), then say what produced the evidence (emulated viewport, synthesized touch, which engine, physical device) and what stayed untested
- **Horizontal scroll**: Content overflow on narrow viewports
- **Text scaling**: Layouts that break when text size increases
- **Missing breakpoints**: No mobile/tablet variants
**Score 0-4**: 0=Desktop-only (breaks on mobile), 1=Major issues (some breakpoints, many failures), 2=Partial (works on mobile, rough edges), 3=Good (responsive, minor touch target or overflow issues), 4=Excellent (fluid, all viewports, proper touch targets)
**Score 0-4**: 0=Desktop-only (breaks on mobile), 1=Major issues (some breakpoints, many failures), 2=Partial (works on mobile, rough edges), 3=Good (responsive, minor touch target or overflow issues), 4=Excellent (fluid, all viewports, proper touch targets, gestures work under touch)
### 5. Implementation Integrity (CRITICAL)
@@ -205,6 +205,11 @@ t('items', { count }) // Handles complex plural rules
- Optimistic updates with rollback
- Conflict resolution
**Interrupted gestures** (custom sliders, drag surfaces, scrollable control strips):
- A second finger or pointer lands mid-drag: the first drag keeps its pointer or ends cleanly, never jumps to the new one
- The browser cancels the gesture to scroll (`pointercancel`), capture is lost (`lostpointercapture`), the pointer is released outside the control, or the window loses focus (`blur`) mid-drag: clear the dragging state and release capture
- After each of these, the next tap or drag works without a reload
**Permission states**:
- No permission to view
- No permission to edit
@@ -304,6 +309,7 @@ const throttledScroll = throttle(handleScroll, 100);
- Unit tests for edge cases
- Integration tests for error scenarios
- E2E tests for critical paths
- A behavioral regression for each confirmed gesture fix, when the project's test runner can drive input
- Visual regression tests
- Accessibility tests (axe, WAVE)
@@ -330,7 +336,10 @@ Test thoroughly with edge cases:
- **Network issues**: Disable internet, throttle connection
- **Large datasets**: Test with 1000+ items
- **Concurrent actions**: Click submit 10 times rapidly
- **Interrupted gestures**: Add a second finger mid-drag, scroll across the control, release outside it, switch windows mid-drag; then drag again
- **Errors**: Force API errors, test all error states
- **Empty**: Remove all data, test empty states
For gestures, say what produced the evidence (emulated viewport, synthesized touch, which engine, physical device) and name what stayed untested.
When edge cases are covered, hand off to `/impeccable polish` for the final pass.
@@ -188,6 +188,12 @@ Test thoroughly across contexts:
- **Edge cases**: Very small screens (320px), very large screens (4K)
- **Slow connections**: Test on throttled network
**Custom controls** (sliders, drag surfaces, scrollable control strips): a before/after slider can pass every width check above and still refuse to drag on iOS, so exercise each one in scope in the same batched round as the checks above:
- **Primary gesture**: Tap it and confirm it responds as designed, then drag it with the target input method; the drag must complete, not just start
- **Scroll across it**: A swipe along the page's scroll axis across the control scrolls the page or container without activating it; a drag that starts on the control along its axis moves the control, not the page. Neither failure throws an error, so try both
- **Evidence**: Say what produced the evidence: an emulated viewport, synthesized touch input through a browser tool, which engine ran it (Chromium is not Safari), or a physical device. Screenshots and resized viewports verify layout, never a gesture. Name what stayed untested and move on; unreachable hardware is a reported gap, not a blocker
When the adaptation feels native to each context, hand off to `/impeccable polish` for the final pass.
---
+2 -1
View File
@@ -48,11 +48,12 @@ Run comprehensive checks across 5 dimensions. Score each dimension 0-4 using the
**Check for**:
- **Fixed widths**: Hard-coded widths that break on mobile
- **Touch targets**: Interactive elements < 44x44px
- **Broken touch interaction**: Custom sliders, drag surfaces, and scrollable control strips whose primary gesture fails under touch, that swallow page scroll or lose the drag to it, or that stay stuck after an interrupted gesture. Code tells: mouse-only handlers, no `touch-action` on a pointer-event drag surface, drag state that nothing clears on cancel, lost capture, or blur. Exercise the gesture when a browser tool can synthesize touch (a rendered viewport proves layout, not the gesture), then say what produced the evidence (emulated viewport, synthesized touch, which engine, physical device) and what stayed untested
- **Horizontal scroll**: Content overflow on narrow viewports
- **Text scaling**: Layouts that break when text size increases
- **Missing breakpoints**: No mobile/tablet variants
**Score 0-4**: 0=Desktop-only (breaks on mobile), 1=Major issues (some breakpoints, many failures), 2=Partial (works on mobile, rough edges), 3=Good (responsive, minor touch target or overflow issues), 4=Excellent (fluid, all viewports, proper touch targets)
**Score 0-4**: 0=Desktop-only (breaks on mobile), 1=Major issues (some breakpoints, many failures), 2=Partial (works on mobile, rough edges), 3=Good (responsive, minor touch target or overflow issues), 4=Excellent (fluid, all viewports, proper touch targets, gestures work under touch)
### 5. Implementation Integrity (CRITICAL)
@@ -205,6 +205,11 @@ t('items', { count }) // Handles complex plural rules
- Optimistic updates with rollback
- Conflict resolution
**Interrupted gestures** (custom sliders, drag surfaces, scrollable control strips):
- A second finger or pointer lands mid-drag: the first drag keeps its pointer or ends cleanly, never jumps to the new one
- The browser cancels the gesture to scroll (`pointercancel`), capture is lost (`lostpointercapture`), the pointer is released outside the control, or the window loses focus (`blur`) mid-drag: clear the dragging state and release capture
- After each of these, the next tap or drag works without a reload
**Permission states**:
- No permission to view
- No permission to edit
@@ -304,6 +309,7 @@ const throttledScroll = throttle(handleScroll, 100);
- Unit tests for edge cases
- Integration tests for error scenarios
- E2E tests for critical paths
- A behavioral regression for each confirmed gesture fix, when the project's test runner can drive input
- Visual regression tests
- Accessibility tests (axe, WAVE)
@@ -330,7 +336,10 @@ Test thoroughly with edge cases:
- **Network issues**: Disable internet, throttle connection
- **Large datasets**: Test with 1000+ items
- **Concurrent actions**: Click submit 10 times rapidly
- **Interrupted gestures**: Add a second finger mid-drag, scroll across the control, release outside it, switch windows mid-drag; then drag again
- **Errors**: Force API errors, test all error states
- **Empty**: Remove all data, test empty states
For gestures, say what produced the evidence (emulated viewport, synthesized touch, which engine, physical device) and name what stayed untested.
When edge cases are covered, hand off to `/impeccable polish` for the final pass.
@@ -188,6 +188,12 @@ Test thoroughly across contexts:
- **Edge cases**: Very small screens (320px), very large screens (4K)
- **Slow connections**: Test on throttled network
**Custom controls** (sliders, drag surfaces, scrollable control strips): a before/after slider can pass every width check above and still refuse to drag on iOS, so exercise each one in scope in the same batched round as the checks above:
- **Primary gesture**: Tap it and confirm it responds as designed, then drag it with the target input method; the drag must complete, not just start
- **Scroll across it**: A swipe along the page's scroll axis across the control scrolls the page or container without activating it; a drag that starts on the control along its axis moves the control, not the page. Neither failure throws an error, so try both
- **Evidence**: Say what produced the evidence: an emulated viewport, synthesized touch input through a browser tool, which engine ran it (Chromium is not Safari), or a physical device. Screenshots and resized viewports verify layout, never a gesture. Name what stayed untested and move on; unreachable hardware is a reported gap, not a blocker
When the adaptation feels native to each context, hand off to `/impeccable polish` for the final pass.
---
+2 -1
View File
@@ -48,11 +48,12 @@ Run comprehensive checks across 5 dimensions. Score each dimension 0-4 using the
**Check for**:
- **Fixed widths**: Hard-coded widths that break on mobile
- **Touch targets**: Interactive elements < 44x44px
- **Broken touch interaction**: Custom sliders, drag surfaces, and scrollable control strips whose primary gesture fails under touch, that swallow page scroll or lose the drag to it, or that stay stuck after an interrupted gesture. Code tells: mouse-only handlers, no `touch-action` on a pointer-event drag surface, drag state that nothing clears on cancel, lost capture, or blur. Exercise the gesture when a browser tool can synthesize touch (a rendered viewport proves layout, not the gesture), then say what produced the evidence (emulated viewport, synthesized touch, which engine, physical device) and what stayed untested
- **Horizontal scroll**: Content overflow on narrow viewports
- **Text scaling**: Layouts that break when text size increases
- **Missing breakpoints**: No mobile/tablet variants
**Score 0-4**: 0=Desktop-only (breaks on mobile), 1=Major issues (some breakpoints, many failures), 2=Partial (works on mobile, rough edges), 3=Good (responsive, minor touch target or overflow issues), 4=Excellent (fluid, all viewports, proper touch targets)
**Score 0-4**: 0=Desktop-only (breaks on mobile), 1=Major issues (some breakpoints, many failures), 2=Partial (works on mobile, rough edges), 3=Good (responsive, minor touch target or overflow issues), 4=Excellent (fluid, all viewports, proper touch targets, gestures work under touch)
### 5. Implementation Integrity (CRITICAL)
@@ -205,6 +205,11 @@ t('items', { count }) // Handles complex plural rules
- Optimistic updates with rollback
- Conflict resolution
**Interrupted gestures** (custom sliders, drag surfaces, scrollable control strips):
- A second finger or pointer lands mid-drag: the first drag keeps its pointer or ends cleanly, never jumps to the new one
- The browser cancels the gesture to scroll (`pointercancel`), capture is lost (`lostpointercapture`), the pointer is released outside the control, or the window loses focus (`blur`) mid-drag: clear the dragging state and release capture
- After each of these, the next tap or drag works without a reload
**Permission states**:
- No permission to view
- No permission to edit
@@ -304,6 +309,7 @@ const throttledScroll = throttle(handleScroll, 100);
- Unit tests for edge cases
- Integration tests for error scenarios
- E2E tests for critical paths
- A behavioral regression for each confirmed gesture fix, when the project's test runner can drive input
- Visual regression tests
- Accessibility tests (axe, WAVE)
@@ -330,7 +336,10 @@ Test thoroughly with edge cases:
- **Network issues**: Disable internet, throttle connection
- **Large datasets**: Test with 1000+ items
- **Concurrent actions**: Click submit 10 times rapidly
- **Interrupted gestures**: Add a second finger mid-drag, scroll across the control, release outside it, switch windows mid-drag; then drag again
- **Errors**: Force API errors, test all error states
- **Empty**: Remove all data, test empty states
For gestures, say what produced the evidence (emulated viewport, synthesized touch, which engine, physical device) and name what stayed untested.
When edge cases are covered, hand off to `/impeccable polish` for the final pass.
@@ -188,6 +188,12 @@ Test thoroughly across contexts:
- **Edge cases**: Very small screens (320px), very large screens (4K)
- **Slow connections**: Test on throttled network
**Custom controls** (sliders, drag surfaces, scrollable control strips): a before/after slider can pass every width check above and still refuse to drag on iOS, so exercise each one in scope in the same batched round as the checks above:
- **Primary gesture**: Tap it and confirm it responds as designed, then drag it with the target input method; the drag must complete, not just start
- **Scroll across it**: A swipe along the page's scroll axis across the control scrolls the page or container without activating it; a drag that starts on the control along its axis moves the control, not the page. Neither failure throws an error, so try both
- **Evidence**: Say what produced the evidence: an emulated viewport, synthesized touch input through a browser tool, which engine ran it (Chromium is not Safari), or a physical device. Screenshots and resized viewports verify layout, never a gesture. Name what stayed untested and move on; unreachable hardware is a reported gap, not a blocker
When the adaptation feels native to each context, hand off to `/impeccable polish` for the final pass.
---
+2 -1
View File
@@ -48,11 +48,12 @@ Run comprehensive checks across 5 dimensions. Score each dimension 0-4 using the
**Check for**:
- **Fixed widths**: Hard-coded widths that break on mobile
- **Touch targets**: Interactive elements < 44x44px
- **Broken touch interaction**: Custom sliders, drag surfaces, and scrollable control strips whose primary gesture fails under touch, that swallow page scroll or lose the drag to it, or that stay stuck after an interrupted gesture. Code tells: mouse-only handlers, no `touch-action` on a pointer-event drag surface, drag state that nothing clears on cancel, lost capture, or blur. Exercise the gesture when a browser tool can synthesize touch (a rendered viewport proves layout, not the gesture), then say what produced the evidence (emulated viewport, synthesized touch, which engine, physical device) and what stayed untested
- **Horizontal scroll**: Content overflow on narrow viewports
- **Text scaling**: Layouts that break when text size increases
- **Missing breakpoints**: No mobile/tablet variants
**Score 0-4**: 0=Desktop-only (breaks on mobile), 1=Major issues (some breakpoints, many failures), 2=Partial (works on mobile, rough edges), 3=Good (responsive, minor touch target or overflow issues), 4=Excellent (fluid, all viewports, proper touch targets)
**Score 0-4**: 0=Desktop-only (breaks on mobile), 1=Major issues (some breakpoints, many failures), 2=Partial (works on mobile, rough edges), 3=Good (responsive, minor touch target or overflow issues), 4=Excellent (fluid, all viewports, proper touch targets, gestures work under touch)
### 5. Implementation Integrity (CRITICAL)
@@ -205,6 +205,11 @@ t('items', { count }) // Handles complex plural rules
- Optimistic updates with rollback
- Conflict resolution
**Interrupted gestures** (custom sliders, drag surfaces, scrollable control strips):
- A second finger or pointer lands mid-drag: the first drag keeps its pointer or ends cleanly, never jumps to the new one
- The browser cancels the gesture to scroll (`pointercancel`), capture is lost (`lostpointercapture`), the pointer is released outside the control, or the window loses focus (`blur`) mid-drag: clear the dragging state and release capture
- After each of these, the next tap or drag works without a reload
**Permission states**:
- No permission to view
- No permission to edit
@@ -304,6 +309,7 @@ const throttledScroll = throttle(handleScroll, 100);
- Unit tests for edge cases
- Integration tests for error scenarios
- E2E tests for critical paths
- A behavioral regression for each confirmed gesture fix, when the project's test runner can drive input
- Visual regression tests
- Accessibility tests (axe, WAVE)
@@ -330,7 +336,10 @@ Test thoroughly with edge cases:
- **Network issues**: Disable internet, throttle connection
- **Large datasets**: Test with 1000+ items
- **Concurrent actions**: Click submit 10 times rapidly
- **Interrupted gestures**: Add a second finger mid-drag, scroll across the control, release outside it, switch windows mid-drag; then drag again
- **Errors**: Force API errors, test all error states
- **Empty**: Remove all data, test empty states
For gestures, say what produced the evidence (emulated viewport, synthesized touch, which engine, physical device) and name what stayed untested.
When edge cases are covered, hand off to `/impeccable polish` for the final pass.
+4 -1
View File
@@ -461,13 +461,16 @@ npx impeccable detect --no-config src/ # raw scan, ignoring project config
npx impeccable ignores list # show detector ignores
npx impeccable ignores add-file "src/legacy/**"
npx impeccable ignores add-value overused-font Inter --reason "Brand font"
npx impeccable ignores add-selector undersized-ui-text ".ks-tag" --reason "10px mono label, by design"
```
The detector catches 61 deterministic issues across AI slop (side-tab borders, purple gradients, bounce easing, dark glows) and general design quality (line length, cramped padding, small touch targets, skipped headings, and more).
Human-readable findings are diagnostics written to stderr, so redirect them with `2> findings.txt`. Use `--json` for machine-readable results on stdout. Exit `0` means the scan completed without primary findings, exit `2` means it completed with primary findings, and exit `1` means at least one requested target could not be scanned; operational failure takes precedence for a partial multi-target scan. URL scans inspect the rendered DOM, computed layout, and accessible linked stylesheets; browser security still prevents reading cross-origin CSS without CORS. A clean detector run is evidence, not proof of visual or accessibility quality: it does not replace inspecting the rendered experience across relevant viewports.
By default, `detect` respects the same `.impeccable/config.json` and `.impeccable/config.local.json` detector config as the design hook: `detector.ignoreRules`, `detector.ignoreFiles`, `detector.ignoreValues`, and `detector.designSystem.enabled`. Hook lifecycle settings such as `hook.enabled` only affect automatic hook execution.
By default, `detect` respects the same `.impeccable/config.json` and `.impeccable/config.local.json` detector config as the design hook: `detector.ignoreRules`, `detector.ignoreFiles`, `detector.ignoreValues`, `detector.ignoreSelectors`, and `detector.designSystem.enabled`. Hook lifecycle settings such as `hook.enabled` only affect automatic hook execution.
`detector.ignoreSelectors` is the component-level opt-out. One entry, written by `ignores add-selector <rule> "<selector>"`, waives that rule for every element the CSS selector matches and for that element's subtree, so a component with eleven instances takes one line of config instead of eleven `data-impeccable-ignore` attributes in the markup. What it suppressed is never silent: each scan prints a line per entry on stderr, `3 undersized-ui-text hits ignored by detector.ignoreSelectors on .ks-tag.`, in JSON mode too, so a reviewer sees the count next to the findings.
For a waiver that should travel with one file instead of the repo config, add an inline comment in the file: `<!-- impeccable-disable overused-font: exported brand doc -->`. The marker works in any comment syntax, scopes to the whole file (or one line with `impeccable-disable-line` / `impeccable-disable-next-line`), and is bypassed by `--no-inline-ignores` or `--no-config`.
+6 -1
View File
@@ -585,6 +585,7 @@ const __impeccableSnapshot = {
for (let id = 1; id < elements.length; id++) {
const el = elements[id];
const rec = { t: el.tagName };
const tag = rec.t;
const nsUri = el.namespaceURI || '';
const ns = __SNAP_NS[nsUri];
if (ns === undefined) { rec.n = 3; rec.nu = nsUri; } else if (ns !== 0) { rec.n = ns; }
@@ -615,6 +616,11 @@ const __impeccableSnapshot = {
if (content == null || content === '' || content === 'none') continue;
rec[key] = __SNAP_PSEUDO_PROPS.map(p => intern(ps[p]));
}
if ((tag === 'INPUT' || tag === 'TEXTAREA') && el.getAttribute('placeholder')) {
let ps;
try { ps = getComputedStyle(el, '::placeholder'); } catch { ps = null; }
if (ps) rec.ph = intern(ps.color);
}
if (typeof el.getBoundingClientRect === 'function') rec.r = __snapRect4(el.getBoundingClientRect());
rec.m = [
__snapNum(el.clientWidth), __snapNum(el.clientHeight), __snapNum(el.clientLeft),
@@ -632,7 +638,6 @@ const __impeccableSnapshot = {
if (typeof el.className !== 'string') rec.k = true;
const st = states.get(id);
if (st) rec.st = st;
const tag = rec.t;
if (tag === 'IMG' || tag === 'VIDEO' || tag === 'CANVAS' || tag === 'PICTURE') {
rec.md = {
nw: el.naturalWidth || 0, nh: el.naturalHeight || 0,
+5
View File
@@ -73,6 +73,11 @@ if (IS_BROWSER && !__impeccable) {
// applies them where the findings are assembled, because the overlay
// draws its markers from the collected findings.
disabledValues: Array.isArray(config.disabledValues) ? config.disabledValues : [],
// detector.ignoreSelectors: the project's component-level opt-outs,
// [{ rule, selector }]. The core waives a finding on any element the
// selector matches, and on its subtree, the way the
// data-impeccable-ignore attribute waives the element carrying it.
ignoreSelectors: Array.isArray(config.ignoreSelectors) ? config.ignoreSelectors : [],
designSystem: config.designSystem == null ? null : config.designSystem,
lineLengthMax: config.lineLengthMax == null ? null : config.lineLengthMax,
skipScan: config.skipScan === true,
+5
View File
@@ -111,6 +111,11 @@
extensionMode: true,
disabledRules: Array.isArray(config.disabledRules) ? config.disabledRules : [],
disabledValues: Array.isArray(config.disabledValues) ? config.disabledValues : [],
// detector.ignoreSelectors: the project's component-level opt-outs,
// [{ rule, selector }]. The core waives a finding on any element the
// selector matches, and on its subtree, the way the
// data-impeccable-ignore attribute waives the element carrying it.
ignoreSelectors: Array.isArray(config.ignoreSelectors) ? config.ignoreSelectors : [],
designSystem: config.designSystem == null ? null : config.designSystem,
lineLengthMax: config.lineLengthMax == null ? null : config.lineLengthMax,
skipScan: config.skipScan === true,
+54 -7
View File
@@ -233,6 +233,9 @@ struct RawResult {
snippet: String,
ignore_value: String,
severity: String,
/// The `detector.ignoreSelectors` selector that waived this finding, when
/// one did. Empty for everything else.
ignored_by: String,
}
fn cdp_err(e: CdpError) -> EngineError {
@@ -384,6 +387,12 @@ fn detect_url_impl(
item.extras
.insert("ignoreValue".into(), Value::String(r.ignore_value));
}
if !r.ignored_by.is_empty() {
item.extras.insert(
impeccable_core::findings::IGNORED_BY_KEY.into(),
Value::String(r.ignored_by),
);
}
if !r.severity.is_empty() && r.severity != item.severity {
item.severity = r.severity;
}
@@ -459,6 +468,7 @@ fn scan_page_inner(
let config = snapshot_engine::browser_config(
serialize_design_system_for_browser(options.design_system.as_deref()),
options.rule_pack,
options.ignore_selectors.clone(),
);
// Deterministic pass: capture the page and run the rule core natively over
@@ -486,6 +496,7 @@ fn scan_page_inner(
id: js_str(f.get("type")),
snippet: js_str(f.get("detail")),
ignore_value: js_str_or_empty(f.get("ignoreValue")),
ignored_by: js_str_or_empty(f.get("ignoredBy")),
severity: js_str_or_empty(f.get("severity")),
});
}
@@ -515,6 +526,7 @@ fn scan_page_inner(
id: f.id,
snippet: f.snippet,
ignore_value: String::new(),
ignored_by: String::new(),
severity: String::new(),
})
.collect(),
@@ -527,6 +539,7 @@ fn scan_page_inner(
id: "script-error".to_string(),
snippet: message,
ignore_value: String::new(),
ignored_by: String::new(),
severity: String::new(),
});
}
@@ -535,7 +548,24 @@ fn scan_page_inner(
snapshot_engine::analyze_visual_contrast(page, &base, 12.0, true)
})
.map_err(cdp_err)?;
let visual = run_visual_contrast_fallback(page, &analyses, &serialized_groups, viewport, profile, url)?;
// The visual pass produces findings outside `collect_browser_findings`,
// so the component-level opt-outs are applied here against the same
// post-reveal snapshot, keyed on each candidate's own selector.
let waive = |selector: &str, rule: &str| -> String {
use impeccable_core::browser::Dom as _;
if config.ignore_selectors.is_empty() || selector.is_empty() {
return String::new();
}
let Ok(Some(el)) = base.query_one(None, selector) else {
return String::new();
};
impeccable_core::selector_ignores::waiving_selector(&config.ignore_selectors, rule, |sel| {
matches!(base.closest(el, sel), Ok(Some(_)))
})
.unwrap_or_default()
.to_string()
};
let visual = run_visual_contrast_fallback(page, &analyses, &serialized_groups, viewport, profile, url, &waive)?;
results.extend(visual);
Ok(results)
}
@@ -566,6 +596,7 @@ fn reveal_sweep(page: &mut Page<'_>) -> Result<(), CdpError> {
/// target)`: the JS post-processing of the analytic/canvas analyses
/// (`analyzeVisualContrast`, computed natively in [`snapshot_engine`]) plus the
/// screenshot pixel fallback for candidates the analyses left unresolved.
#[allow(clippy::too_many_arguments)]
fn run_visual_contrast_fallback(
page: &mut Page<'_>,
browser_analyses: &[Value],
@@ -573,6 +604,9 @@ fn run_visual_contrast_fallback(
viewport: Viewport,
profile: Option<&DetectorProfile>,
target: &str,
// `(candidate selector, rule id) -> the detector.ignoreSelectors selector
// that waives it, or empty`.
waive: &dyn Fn(&str, &str) -> String,
) -> Result<Vec<RawResult>, EngineError> {
let existing_low_contrast: Vec<String> = serialized_groups
.iter()
@@ -598,12 +632,18 @@ fn run_visual_contrast_fallback(
.iter()
.any(|s| Some(s.as_str()) == r.get("selector").and_then(Value::as_str))
})
.filter_map(|r| r.get("finding"))
.map(|f| RawResult {
id: js_str(f.get("id")),
snippet: js_str(f.get("snippet")),
ignore_value: String::new(),
severity: String::new(),
.map(|r| {
let selector = r.get("selector").and_then(Value::as_str).unwrap_or("");
let f = r.get("finding").expect("filtered on a truthy finding");
let id = js_str(f.get("id"));
let ignored_by = waive(selector, &id);
RawResult {
id,
snippet: js_str(f.get("snippet")),
ignore_value: String::new(),
ignored_by,
severity: String::new(),
}
})
.collect();
@@ -635,6 +675,11 @@ fn run_visual_contrast_fallback(
})
.collect();
for candidate in filtered {
let candidate_selector = candidate
.get("selector")
.and_then(Value::as_str)
.unwrap_or("")
.to_string();
let result = step_findings(profile, "visual-contrast", "pixel-diff", target, || {
let f = screenshot_contrast::capture_visual_contrast_candidate(
page,
@@ -644,10 +689,12 @@ fn run_visual_contrast_fallback(
.map_err(cdp_err)?;
Ok::<_, EngineError>(
f.map(|f| {
let ignored_by = waive(&candidate_selector, f.id);
vec![RawResult {
id: f.id.to_string(),
snippet: f.snippet,
ignore_value: String::new(),
ignored_by,
severity: String::new(),
}]
})
+2
View File
@@ -186,11 +186,13 @@ pub fn resolve_needs<T>(
pub fn browser_config(
design_system: Value,
rule_pack: Option<&'static dyn impeccable_core::rule_pack::RulePack>,
ignore_selectors: Vec<impeccable_core::selector_ignores::SelectorIgnore>,
) -> BrowserConfig {
BrowserConfig {
extension_mode: false,
disabled_rules: Vec::new(),
disabled_values: Vec::new(),
ignore_selectors,
skip_scan: false,
design_system: if design_system.is_null() {
None
+8 -1
View File
@@ -39,7 +39,14 @@ const KNOWN_CONFIG_KEYS: [&str; 8] =
["hook", "detector", "updateCheck", "stalenessCheck", "projectRoots", "buildPath", "$schema", "version"];
const BUILD_PATH_VALUES: [&str; 2] = ["comp", "code"];
const DIRECTION_WORK_PATHS: [&str; 2] = [".impeccable/surfaces", ".impeccable/mocks/decision"];
const KNOWN_DETECTOR_KEYS: [&str; 5] = ["ignoreRules", "ignoreFiles", "ignoreValues", "designSystem", "extensions"];
const KNOWN_DETECTOR_KEYS: [&str; 6] = [
"ignoreRules",
"ignoreFiles",
"ignoreValues",
"ignoreSelectors",
"designSystem",
"extensions",
];
struct NativeEvidence {
platform: &'static str,
+25 -6
View File
@@ -141,12 +141,31 @@ pub fn check_detector_ignores(project_root: &str, known_rule_ids: Option<&[Strin
continue;
}
let rel = to_relative(Some(&fp), project_root).unwrap();
if let (Some(known), Some(rules)) = (known_rule_ids, detector.get("ignoreRules").and_then(|v| v.as_array())) {
let unknown: Vec<String> = rules
.iter()
.map(|r| js_trim(&js_string_or_empty(r)).to_lowercase())
.filter(|r| !r.is_empty() && r != "*" && !known.contains(r))
.collect();
if let Some(known) = known_rule_ids {
let rules = detector
.get("ignoreRules")
.and_then(|v| v.as_array())
.cloned()
.unwrap_or_default();
// A component ignore names a rule too, and a typo there is the
// same dead entry: it waives nothing and nobody hears about it.
let selector_rules: Vec<Value> = detector
.get("ignoreSelectors")
.and_then(|v| v.as_array())
.map(|list| {
list.iter()
.filter_map(|e| e.get("rule").cloned())
.collect()
})
.unwrap_or_default();
let mut unknown: Vec<String> = Vec::new();
for r in rules.iter().chain(selector_rules.iter()) {
let id = js_trim(&js_string_or_empty(r)).to_lowercase();
if id.is_empty() || id == "*" || known.contains(&id) || unknown.contains(&id) {
continue;
}
unknown.push(id);
}
if !unknown.is_empty() {
out.push(finding(
"detector-ignore-rules-unknown",
+139
View File
@@ -8,6 +8,7 @@ use super::dom::{tag_lower, Dom, ElId, Rect};
use super::element_checks::check_element_borders_dom;
use super::{BrowserConfig, BrowserFinding, DisabledValue, FindingGroup};
use crate::js_ext_a::JsMap;
use impeccable_foundation::selector_ignores::{waiving_selector, SelectorIgnore};
use serde::Serialize;
/// The collect result type is shared.
@@ -63,6 +64,41 @@ pub fn add_browser_findings(
}
}
/// Apply the project's component-level opt-outs (`detector.ignoreSelectors`)
/// to a collected group list.
///
/// The semantics are the attribute's: an entry waives its rule for every
/// element the selector matches and for that element's subtree, which is what
/// `element.closest(selector)` answers. Findings are stamped rather than
/// dropped, so the layer that owns the ignore list can report "N hits ignored
/// by config on `.ks-tag`" instead of quietly reporting nothing.
pub fn stamp_selector_ignores(
dom: &dyn Dom,
groups: &mut [FindingGroup],
entries: &[SelectorIgnore],
) {
if entries.is_empty() {
return;
}
for group in groups.iter_mut() {
// Handle 0 is JS null (a missing document.body): nothing to match.
if group.el == 0 {
continue;
}
for f in group.findings.iter_mut() {
if f.ignored_by.is_some() {
continue;
}
let el = group.el;
if let Some(selector) = waiving_selector(entries, &f.type_, |sel| {
matches!(dom.closest(el, sel), Ok(Some(_)))
}) {
f.ignored_by = Some(selector.to_string());
}
}
}
}
// ─── Design system (index.mjs) ──────────────────────────────────────────────
/// The `seen` sets `collectBrowserFindings` threads through the element loop
@@ -363,6 +399,7 @@ pub fn check_element_design_system_dom(
detail,
severity: None,
ignore_value: Some(value),
ignored_by: None,
};
if ds.has_fonts && browser_has_direct_text(dom, el) {
@@ -515,6 +552,7 @@ pub fn check_browser_design_system_sources(
),
severity: None,
ignore_value: Some(display),
ignored_by: None,
});
}
}
@@ -833,6 +871,12 @@ pub fn serialize_findings(dom: &dyn Dom, groups: &[FindingGroup]) -> serde_json:
"description".into(),
Value::String(ap.map(|a| a.description).unwrap_or("").to_string()),
);
// Only present when a detector.ignoreSelectors entry waived
// this finding, so a scan without the feature serializes
// exactly what it always did.
if let Some(selector) = f.ignored_by.as_ref() {
m.insert("ignoredBy".into(), Value::String(selector.clone()));
}
Value::Object(m)
})
.collect();
@@ -1464,6 +1508,7 @@ pub fn collect_browser_findings(dom: &dyn Dom, config: &BrowserConfig) -> Collec
page_level.retain(|f| !browser_value_ignored(f, &disabled_values));
}
stamp_selector_ignores(dom, &mut groups, &config.ignore_selectors);
CollectResult { groups, page_level }
}
@@ -1871,6 +1916,100 @@ mod tests {
let pass = json!({ "status": "pass", "selector": "#t", "finding": null });
assert_eq!(visual_contrast_result_el(&d, &pass), None);
}
#[test]
fn config_selector_ignores_stamp_the_component_and_its_subtree() {
// The #34 shape: one component, many instances, one rule.
let mut d = FakeDom::new();
let (_h, body) = d.with_page();
let mut tags = Vec::new();
for _ in 0..3 {
let tag = d.add(Some(body), "span");
d.add_selector(tag, ".ks-tag");
tags.push(tag);
}
let inner = d.add(Some(tags[0]), "b");
let other = d.add(Some(body), "a");
d.add_selector(other, ".cta");
let mut groups: Vec<FindingGroup> = tags
.iter()
.chain([&inner, &other])
.map(|el| FindingGroup {
el: *el,
findings: vec![
BrowserFinding::new("undersized-ui-text", "10px functional text"),
BrowserFinding::new("wide-tracking", "letter-spacing: 0.08em"),
],
})
.collect();
let entries = vec![SelectorIgnore::new("undersized-ui-text", ".ks-tag")];
stamp_selector_ignores(&d, &mut groups, &entries);
// Three instances plus the descendant: waived, and each carries the
// selector that waived it rather than vanishing.
for g in groups.iter().take(4) {
assert_eq!(g.findings[0].ignored_by.as_deref(), Some(".ks-tag"));
// Only the named rule is waived.
assert_eq!(g.findings[1].ignored_by, None);
}
// An element outside the component keeps both findings clean.
assert_eq!(groups[4].findings[0].ignored_by, None);
assert_eq!(groups[4].findings[1].ignored_by, None);
// Serialization carries the stamp, and only when there is one.
let json = serialize_findings(&d, &groups);
let first = &json[0]["findings"][0];
assert_eq!(first["ignoredBy"], json!(".ks-tag"));
assert_eq!(json[0]["findings"][1].get("ignoredBy"), None);
}
#[test]
fn config_selector_ignores_are_off_without_entries() {
let mut d = FakeDom::new();
let (_h, body) = d.with_page();
let tag = d.add(Some(body), "span");
d.add_selector(tag, ".ks-tag");
let mut groups = vec![FindingGroup {
el: tag,
findings: vec![BrowserFinding::new("undersized-ui-text", "10px")],
}];
stamp_selector_ignores(&d, &mut groups, &[]);
assert_eq!(groups[0].findings[0].ignored_by, None);
// A `*` entry waives every rule on the component, as the attribute does.
stamp_selector_ignores(&d, &mut groups, &[SelectorIgnore::new("*", ".ks-tag")]);
assert_eq!(groups[0].findings[0].ignored_by.as_deref(), Some(".ks-tag"));
}
#[test]
fn browser_config_reads_ignore_selectors_from_the_page_config() {
let cfg: BrowserConfig = serde_json::from_str(
r#"{"ignoreSelectors":[{"rule":"Undersized-UI-Text","selector":".ks-tag"}]}"#,
)
.unwrap();
assert_eq!(cfg.ignore_selectors.len(), 1);
// The parser normalizes, so a page config written by hand still
// matches: the rule folds case, the selector keeps it.
assert_eq!(cfg.ignore_selectors[0].rule, "undersized-ui-text");
assert_eq!(cfg.ignore_selectors[0].selector, ".ks-tag");
let bare: BrowserConfig = serde_json::from_str("{}").unwrap();
assert!(bare.ignore_selectors.is_empty());
// A hand-edited entry of the wrong shape drops itself, never the whole
// config: `unwrap_or_default()` at the wasm boundary would otherwise
// lose the design system with it.
let junk: BrowserConfig = serde_json::from_str(
r#"{"lineLengthMax":90,"ignoreSelectors":[{},null,"nope",{"rule":"side-tab"},{"rule":"side-tab","selector":".x"}]}"#,
)
.unwrap();
assert_eq!(junk.ignore_selectors.len(), 1);
assert_eq!(junk.line_max(), 90.0);
let not_a_list: BrowserConfig =
serde_json::from_str(r#"{"ignoreSelectors":"nope"}"#).unwrap();
assert!(not_a_list.ignore_selectors.is_empty());
// A config without the key serializes without it.
assert!(!serde_json::to_string(&bare).unwrap().contains("ignoreSelectors"));
}
}
#[cfg(test)]
+91 -5
View File
@@ -20,8 +20,9 @@ use crate::checks::measures::{
};
use crate::checks::rules::{
check_borders, check_colors, check_glow, check_hero_eyebrow, check_icon_tile,
check_italic_serif, check_motion, is_emoji_only_text, BorderOpts, ColorOpts, GlowOpts,
HeroEyebrowOpts, IconTileOpts, ItalicSerifOpts, MotionOpts, RuleHit, Sides, HEADING_TAGS,
check_italic_serif, check_motion, check_placeholder_colors, is_emoji_only_text, BorderOpts,
ColorOpts, GlowOpts, HeroEyebrowOpts, IconTileOpts, ItalicSerifOpts, MotionOpts, RuleHit,
Sides, HEADING_TAGS,
};
use crate::checks::text_rules::{
CURSOR_FIRST_VIEWPORT_PX, CURSOR_GLYPH_RE, POSITIONED_CHILD_INTERACTIVE_SELECTOR,
@@ -459,8 +460,8 @@ pub fn check_element_colors_dom(dom: &dyn Dom, el: ElId) -> Vec<RuleHit> {
} else {
resolve_gradient_stops(dom, el)
};
check_colors(&ColorOpts {
tag,
let color_opts = ColorOpts {
tag: tag.clone(),
text_color: parse_rgb_or_any(&dom.style(el, "color")),
bg_color: own_bg,
effective_bg: if surface_unresolved {
@@ -477,7 +478,36 @@ pub fn check_element_colors_dom(dom: &dyn Dom, el: ElId) -> Vec<RuleHit> {
bg_image: Some(dom.style(el, "backgroundImage")),
class_list: Some(class_attr(dom, el)),
detector_is_browser: true,
})
};
let mut findings = check_colors(&color_opts);
if tag == "input" || tag == "textarea" {
let placeholder = dom.attr(el, "placeholder").unwrap_or_default();
let placeholder = js::trim(&placeholder);
if !placeholder.is_empty() {
let skip = if tag == "input" {
let t = js::to_lower_case(&dom.attr(el, "type").unwrap_or_else(|| "text".into()));
matches!(
t.as_str(),
"hidden" | "checkbox" | "radio" | "file" | "submit" | "button" | "image"
| "reset" | "range" | "color"
)
} else {
false
} || !matches_or_false(dom, el, ":placeholder-shown");
if !skip {
if let Some(ph_raw) = dom.pseudo_style(el, "::placeholder", "color") {
if let Some(ph_color) = parse_rgb_or_any(&ph_raw) {
findings.extend(check_placeholder_colors(
&color_opts,
placeholder,
ph_color,
));
}
}
}
}
}
findings
}
// ── icon tile / italic serif / hero eyebrow ───────────────────────────────
@@ -1279,6 +1309,7 @@ pub fn check_element_blinking_cursor_dom(dom: &dyn Dom, el: ElId) -> Vec<Browser
None
},
ignore_value: None,
ignored_by: None,
}]
}
@@ -1378,6 +1409,61 @@ mod tests {
assert!(check_element_pseudo_stripe_dom(&d, card).is_empty());
}
#[test]
fn placeholder_low_contrast_flags() {
let (mut d, body) = page();
let input = d.add(Some(body), "input");
visible(&mut d, input);
d.set_attr(input, "placeholder", "Pale Placeholder On White Field");
d.set_rect(input, 0.0, 0.0, 200.0, 40.0);
d.set_styles(
input,
&[
("backgroundColor", "rgb(255, 255, 255)"),
("color", "rgb(0, 0, 0)"),
("fontSize", "16px"),
("fontWeight", "400"),
("webkitBackgroundClip", "border-box"),
],
);
d.set_pseudo_style(input, "::placeholder", "color", "rgb(187, 187, 187)");
d.add_selector(input, ":placeholder-shown");
let hits = check_element_colors_dom(&d, input);
assert!(
hits.iter().any(|h| {
h.id == "low-contrast"
&& h.snippet.contains("placeholder \"Pale Placeholder On White Field\"")
}),
"{hits:?}"
);
}
#[test]
fn placeholder_skips_when_not_shown() {
let (mut d, body) = page();
let input = d.add(Some(body), "input");
visible(&mut d, input);
d.set_attr(input, "placeholder", "Pale Placeholder On White Field");
d.set_attr(input, "value", "");
d.set_rect(input, 0.0, 0.0, 200.0, 40.0);
d.set_styles(
input,
&[
("backgroundColor", "rgb(255, 255, 255)"),
("color", "rgb(0, 0, 0)"),
("fontSize", "16px"),
("fontWeight", "400"),
("webkitBackgroundClip", "border-box"),
],
);
d.set_pseudo_style(input, "::placeholder", "color", "rgb(187, 187, 187)");
let hits = check_element_colors_dom(&d, input);
assert!(
hits.iter().all(|h| h.id != "low-contrast"),
"live filled field must not score a hidden placeholder, {hits:?}"
);
}
#[test]
fn colors_low_contrast_on_resolved_surface_and_pseudo_surface() {
let (mut d, body) = page();
+163 -70
View File
@@ -4,7 +4,8 @@
//! `undefined` / `null` distinctions the source relies on.
use crate::color::{
color_to_hex, contrast_ratio, get_hue, has_chroma, is_neutral_color, relative_luminance, Rgba,
color_to_hex, composite_color_over, contrast_ratio, get_hue, has_chroma, is_neutral_color,
relative_luminance, Rgba,
};
use crate::constants::{
BORDER_SAFE_TAGS, GENERIC_FONTS, KNOWN_SERIF_FONTS, SAFE_TAGS, WCAG_LARGE_BOLD_TEXT_PX,
@@ -155,75 +156,10 @@ pub fn check_colors(opts: &ColorOpts) -> Vec<RuleHit> {
if opts.has_direct_text && opts.text_color.is_some() && !opts.is_emoji_only {
let text_color = opts.text_color.unwrap();
let is_gradient_clipped_text = bg_clip == "text";
let bgs: Option<Vec<Rgba>> = if is_gradient_clipped_text {
None
} else if let Some(bg) = opts.effective_bg {
Some(vec![bg])
} else {
match &opts.effective_bg_stops {
Some(stops) if !stops.is_empty() => Some(stops.clone()),
_ => None,
}
};
if let Some(bgs) = bgs {
let text_lum = relative_luminance(&text_color);
let is_gray =
!has_chroma(Some(&text_color), Some(20.0)) && text_lum > 0.05 && text_lum < 0.85;
if is_gray && bgs.iter().all(|b| has_chroma(Some(b), Some(40.0))) {
let bg_label = match opts.effective_bg {
Some(bg) => color_to_hex(Some(&bg)),
None => format!(
"gradient({})",
bgs.iter()
.map(|b| color_to_hex(Some(b)))
.collect::<Vec<_>>()
.join(", ")
),
};
findings.push(RuleHit::new(
"gray-on-color",
format!(
"text {} on bg {}",
color_to_hex(Some(&text_color)),
bg_label
),
));
}
let ratios: Vec<f64> = bgs.iter().map(|b| contrast_ratio(&text_color, b)).collect();
let mut worst_idx = 0usize;
for i in 1..ratios.len() {
if ratios[i] < ratios[worst_idx] {
worst_idx = i;
}
}
let ratio = ratios[worst_idx];
let is_large_text = opts.font_size >= WCAG_LARGE_TEXT_PX
|| (opts.font_size >= WCAG_LARGE_BOLD_TEXT_PX && opts.font_weight >= 700.0);
let threshold = if is_large_text { 3.0 } else { 4.5 };
if ratio < threshold {
let is_alpha_fallback_fp = !opts.detector_is_browser
&& opts.effective_bg.is_none()
&& text_color.a.map_or(false, |a| a < 1.0);
if !is_alpha_fallback_fp {
let ratio_label = if to_fixed(ratio, 1) == to_fixed(threshold, 1) {
to_fixed(ratio, 2)
} else {
to_fixed(ratio, 1)
};
findings.push(RuleHit::new(
"low-contrast",
format!(
"{}:1 (need {}:1) — text {} on {}",
ratio_label,
number_to_string(threshold),
color_to_hex(Some(&text_color)),
color_to_hex(Some(&bgs[worst_idx]))
),
));
}
}
// Gradient-clipped text paints the gradient, not `color`, so there
// is no background to score it against.
if bg_clip != "text" {
findings.extend(contrast_findings(opts, &text_color));
}
if has_chroma(Some(&text_color), Some(50.0)) {
@@ -281,6 +217,119 @@ pub fn check_colors(opts: &ColorOpts) -> Vec<RuleHit> {
findings
}
/// The contrast scoring `check_colors` and `check_placeholder_colors`
/// share: gray-on-color, then WCAG AA against the worst background. The
/// backgrounds are the composited `effective_bg`, or the gradient stops when
/// no opaque surface resolved; with neither there is nothing to score.
fn contrast_findings(opts: &ColorOpts, text_color: &Rgba) -> Vec<RuleHit> {
let bgs: Vec<Rgba> = if let Some(bg) = opts.effective_bg {
vec![bg]
} else {
match &opts.effective_bg_stops {
Some(stops) if !stops.is_empty() => stops.clone(),
_ => return Vec::new(),
}
};
let mut findings = Vec::new();
let text_lum = relative_luminance(text_color);
let is_gray = !has_chroma(Some(text_color), Some(20.0)) && text_lum > 0.05 && text_lum < 0.85;
if is_gray && bgs.iter().all(|b| has_chroma(Some(b), Some(40.0))) {
let bg_label = match opts.effective_bg {
Some(bg) => color_to_hex(Some(&bg)),
None => format!(
"gradient({})",
bgs.iter()
.map(|b| color_to_hex(Some(b)))
.collect::<Vec<_>>()
.join(", ")
),
};
findings.push(RuleHit::new(
"gray-on-color",
format!("text {} on bg {}", color_to_hex(Some(text_color)), bg_label),
));
}
let ratios: Vec<f64> = bgs.iter().map(|b| contrast_ratio(text_color, b)).collect();
let mut worst_idx = 0usize;
for i in 1..ratios.len() {
if ratios[i] < ratios[worst_idx] {
worst_idx = i;
}
}
let ratio = ratios[worst_idx];
let is_large_text = opts.font_size >= WCAG_LARGE_TEXT_PX
|| (opts.font_size >= WCAG_LARGE_BOLD_TEXT_PX && opts.font_weight >= 700.0);
let threshold = if is_large_text { 3.0 } else { 4.5 };
if ratio < threshold {
let is_alpha_fallback_fp = !opts.detector_is_browser
&& opts.effective_bg.is_none()
&& text_color.a.map_or(false, |a| a < 1.0);
if !is_alpha_fallback_fp {
let ratio_label = if to_fixed(ratio, 1) == to_fixed(threshold, 1) {
to_fixed(ratio, 2)
} else {
to_fixed(ratio, 1)
};
findings.push(RuleHit::new(
"low-contrast",
format!(
"{}:1 (need {}:1) — text {} on {}",
ratio_label,
number_to_string(threshold),
color_to_hex(Some(text_color)),
color_to_hex(Some(&bgs[worst_idx]))
),
));
}
}
findings
}
/// Placeholder text contrast, sibling of `check_hover_contrast`. Skips the
/// SAFE_TAGS gate and the host heuristics in `check_colors` (class list,
/// clip, gradient) because the host is an empty control; only the
/// placeholder glyphs are scored. A translucent placeholder is flattened
/// over the composited background first, including each gradient stop when
/// no opaque surface resolved. Snippets carry the placeholder string so
/// fixture tests can key on it.
pub fn check_placeholder_colors(
opts: &ColorOpts,
placeholder_text: &str,
mut text_color: Rgba,
) -> Vec<RuleHit> {
let mut flat: Option<ColorOpts> = None;
if text_color.a.map_or(false, |a| a < 1.0) {
if let Some(bg) = opts.effective_bg {
text_color = composite_color_over(&text_color, &bg);
} else if let Some(stops) = opts.effective_bg_stops.as_ref().filter(|s| !s.is_empty()) {
let mut worst_i = 0usize;
let mut worst_ratio = f64::MAX;
let mut worst_fg = text_color;
for (i, stop) in stops.iter().enumerate() {
let fg = composite_color_over(&text_color, stop);
let r = contrast_ratio(&fg, stop);
if r < worst_ratio {
worst_ratio = r;
worst_i = i;
worst_fg = fg;
}
}
text_color = worst_fg;
let mut o = opts.clone();
o.effective_bg = Some(stops[worst_i]);
o.effective_bg_stops = None;
flat = Some(o);
}
}
let opts = flat.as_ref().unwrap_or(opts);
let mut findings = contrast_findings(opts, &text_color);
for h in &mut findings {
h.snippet = format!("placeholder \"{}\" {}", placeholder_text, h.snippet);
}
findings
}
/// JS: checks.mjs#checkHoverContrast
pub fn check_hover_contrast(opts: &HoverContrastOpts) -> Vec<RuleHit> {
if !opts.has_direct_text || opts.is_emoji_only || opts.text_color.is_none() || opts.bg.is_none()
@@ -1044,6 +1093,50 @@ mod tests {
);
}
#[test]
fn placeholder_colors_ignore_host_class_heuristics() {
let opts = ColorOpts {
tag: "input".to_string(),
effective_bg: Some(Rgba::new(255.0, 255.0, 255.0, 1.0)),
font_size: 24.0,
font_weight: 400.0,
class_list: Some("text-slate-300 bg-red-500".to_string()),
bg_clip: Some("text".to_string()),
bg_image: Some("linear-gradient(red, blue)".to_string()),
..Default::default()
};
let ink = check_placeholder_colors(&opts, "Name", Rgba::new(26.0, 26.0, 26.0, 1.0));
assert!(ink.is_empty(), "{ink:?}");
let pale = check_placeholder_colors(&opts, "Name", Rgba::new(187.0, 187.0, 187.0, 1.0));
assert_eq!(pale.len(), 1);
assert_eq!(pale[0].id, "low-contrast");
assert!(pale[0].snippet.contains("placeholder \"Name\""), "{pale:?}");
// No resolved surface and no gradient stops: nothing to score.
let unresolved = ColorOpts {
effective_bg: None,
effective_bg_stops: None,
..opts.clone()
};
let none = check_placeholder_colors(&unresolved, "Name", Rgba::new(187.0, 187.0, 187.0, 1.0));
assert!(none.is_empty(), "{none:?}");
// Translucent black over a light gradient: flatten per stop, then score.
let gradient = ColorOpts {
effective_bg: None,
effective_bg_stops: Some(vec![
Rgba::new(255.0, 255.0, 255.0, 1.0),
Rgba::new(240.0, 240.0, 240.0, 1.0),
]),
..opts.clone()
};
let wash = check_placeholder_colors(
&gradient,
"Name",
Rgba::new(0.0, 0.0, 0.0, 0.2),
);
assert_eq!(wash.len(), 1, "{wash:?}");
assert_eq!(wash[0].id, "low-contrast");
}
#[test]
fn heading_tags_and_card_like() {
assert!(is_heading_tag("h4"));
+1 -1
View File
@@ -17,7 +17,7 @@ pub mod checks;
pub use impeccable_foundation::{
color, constants, fdlibm_trig, findings, fonts, inline_ignores, js, js_ext_a, js_ext_b, page,
registry, rule_pack,
registry, rule_pack, selector_ignores,
};
#[cfg(any(test, feature = "vectors"))]
+81 -4
View File
@@ -9,7 +9,8 @@ use impeccable_core::registry::{filter_by_scopes, rule_scopes};
use serde_json::Value;
use crate::config::{
filter_detection_findings, read_detection_config, should_ignore_detection_file, DetectionConfig,
filter_detection_findings_reported, read_detection_config, selector_ignores_for_target,
selector_ignores_for_url, should_ignore_detection_file, DetectionConfig, IgnoredBySelector,
};
use crate::design_system::{load_design_system_for_target, DesignSystemCache};
use crate::detect_text::{detect_text, TextOptions};
@@ -57,7 +58,16 @@ Exit status:
Project config:
Respects .impeccable/config.json and .impeccable/config.local.json detector
settings: detector.ignoreRules, detector.ignoreFiles, detector.ignoreValues,
and detector.designSystem.enabled.
detector.ignoreSelectors, and detector.designSystem.enabled.
Component ignores:
detector.ignoreSelectors waives one rule for every element a CSS selector
matches, and for that element's subtree: one entry for a component instead
of a data-impeccable-ignore attribute on each of its instances. Write one
with `impeccable ignores add-selector <rule> \"<selector>\"`. Every scan
prints what it suppressed on stderr, in --json runs too, so the exception
stays visible:
3 undersized-ui-text hits ignored by detector.ignoreSelectors on .ks-tag.
Inline ignores:
In-file comments waive a finding where it lives and travel with the file:
@@ -207,6 +217,33 @@ fn format_advisory_section(advisory: &[&Finding], stderr_tty: bool) -> String {
lines.join("\n")
}
/// The component-level opt-outs that fired on this run, one line each.
///
/// A `detector.ignoreSelectors` entry waives every instance of a component at
/// once, so the only way a reviewer learns it is there is for the scan to say
/// what it suppressed. Empty when the project has no such entry, or when the
/// entries it has matched nothing, so a scan is unchanged until the feature
/// is used.
pub fn format_ignored_by_selector(report: &[IgnoredBySelector], stderr_tty: bool) -> String {
if report.is_empty() {
return String::new();
}
let mut lines = Vec::with_capacity(report.len());
for r in report {
lines.push(dim(
&format!(
"{} {} hit{} ignored by detector.ignoreSelectors on {}.",
r.count,
r.rule,
if r.count == 1 { "" } else { "s" },
r.selector
),
stderr_tty,
));
}
lines.join("\n")
}
/// JS: main.mjs#formatFindings
pub fn format_findings(findings: &[Finding], json_mode: bool, stderr_tty: bool) -> String {
if json_mode {
@@ -251,6 +288,26 @@ impl<'a> Ctx<'a> {
}
fn scan_options_for(&mut self, local_path: Option<&str>) -> ScanOptions {
let mut options = self.design_system_options_for(local_path);
// Component-level opt-outs are per target: an entry with `files`
// governs the targets its globs match, an entry without governs all
// of them. `--no-config` leaves the list empty, so nothing is waived.
options.ignore_selectors =
selector_ignores_for_target(&self.config, local_path.unwrap_or_default());
options
}
/// The URL scan's options: no local design system to resolve, and only
/// the unscoped component ignores, since a `files` glob names repo paths
/// rather than URLs.
fn url_scan_options(&self) -> ScanOptions {
ScanOptions {
ignore_selectors: selector_ignores_for_url(&self.config),
..self.base.clone()
}
}
fn design_system_options_for(&mut self, local_path: Option<&str>) -> ScanOptions {
let (Some(local_path), true) = (local_path, self.design_system_enabled) else {
return self.base.clone();
};
@@ -507,6 +564,8 @@ fn detect_cli(args_in: &[String], io: &mut Io, engines: &Engines) -> Result<i32,
design_system: None,
viewport,
profile: None,
// Filled in per target: an ignoreSelectors entry can be scoped to files.
ignore_selectors: Vec::new(),
// The `impeccable` binary installs no rule pack; a library caller that
// does sets this before handing the options to an engine.
rule_pack: None,
@@ -587,7 +646,10 @@ fn detect_cli(args_in: &[String], io: &mut Io, engines: &Engines) -> Result<i32,
result?;
}
all = filter_detection_findings(all, &ctx.config);
// Findings the engines stamped with a component-level opt-out leave the
// reportable set here and come back as a count.
let ignored_by_selector;
(all, ignored_by_selector) = filter_detection_findings_reported(all, &ctx.config);
let scope_refs: Vec<&str> = scopes.iter().map(|s| s.as_str()).collect();
all = filter_by_scopes(all, &scope_refs, |f: &Finding| f.antipattern.as_str());
if no_advisory {
@@ -605,13 +667,22 @@ fn detect_cli(args_in: &[String], io: &mut Io, engines: &Engines) -> Result<i32,
} else {
0
};
// The ignored-by-config tally goes to stderr in every mode: in --json,
// stdout stays the findings array a consumer parses.
let ignored_note = format_ignored_by_selector(&ignored_by_selector, stderr_tty);
if !all.is_empty() {
if json_mode {
let text = format_findings(&all, true, stderr_tty);
ctx.io.out(&format!("{text}\n"));
if !ignored_note.is_empty() {
ctx.io.err(&format!("{ignored_note}\n"));
}
} else if quiet_mode {
ctx.io
.err(&format!("{}\n", format_finding_summary(primary_len)));
if !ignored_note.is_empty() {
ctx.io.err(&format!("{ignored_note}\n"));
}
if advisory_len > 0 {
let note = dim(
&format!(
@@ -625,12 +696,18 @@ fn detect_cli(args_in: &[String], io: &mut Io, engines: &Engines) -> Result<i32,
} else {
let text = format_findings(&all, false, stderr_tty);
ctx.io.err(&format!("{text}\n"));
if !ignored_note.is_empty() {
ctx.io.err(&format!("\n{ignored_note}\n"));
}
}
return Ok(exit_code);
}
if json_mode {
ctx.io.out("[]\n");
}
if !ignored_note.is_empty() {
ctx.io.err(&format!("{ignored_note}\n"));
}
Ok(exit_code)
}
@@ -676,7 +753,7 @@ fn scan_targets(
let local = file_url_to_local_path(target);
ctx.scan_options_for(local.as_deref())
} else {
ctx.base.clone()
ctx.url_scan_options()
};
let result = match (shared, ctx.engines.url) {
(Some(s), _) => s.detect_url(target, &url_options),
+359 -2
View File
@@ -5,6 +5,7 @@
use impeccable_core::findings::Finding;
use impeccable_core::js::{self, math_round, number_to_string, parse_float, parse_int};
use impeccable_core::selector_ignores::SelectorIgnore;
use once_cell::sync::Lazy;
use regex::Regex;
use serde_json::{Map, Value};
@@ -42,6 +43,7 @@ const DETECTOR_CONFIG_KEYS: &[&str] = &[
"ignoreRules",
"ignoreFiles",
"ignoreValues",
"ignoreSelectors",
"designSystem",
"advisoryRules",
];
@@ -78,6 +80,44 @@ impl IgnoreValueEntry {
}
}
/// One normalized `ignoreSelectors` entry: a component-level opt-out.
///
/// `{ rule, selector }` waives one rule for every element the selector
/// matches and for that element's subtree, which is the same waiver
/// `data-impeccable-ignore="<rule>"` grants the element that carries it. The
/// point is the count: eleven instances of one component take one entry here
/// instead of eleven attributes in the markup, and the engine reports what
/// the entry suppressed rather than staying silent about it.
#[derive(Debug, Clone, PartialEq, Default)]
pub struct IgnoreSelectorEntry {
pub rule: String,
pub selector: String,
pub files: Option<Vec<String>>,
pub created_at: Option<String>,
pub reason: Option<String>,
}
impl IgnoreSelectorEntry {
pub fn to_json(&self) -> Value {
let mut m = Map::new();
m.insert("rule".into(), Value::String(self.rule.clone()));
m.insert("selector".into(), Value::String(self.selector.clone()));
if let Some(files) = &self.files {
m.insert(
"files".into(),
Value::Array(files.iter().map(|f| Value::String(f.clone())).collect()),
);
}
if let Some(c) = &self.created_at {
m.insert("createdAt".into(), Value::String(c.clone()));
}
if let Some(r) = &self.reason {
m.insert("reason".into(), Value::String(r.clone()));
}
Value::Object(m)
}
}
/// The detector config object (`readDetectionConfig` / `readRawDetectionConfig`
/// result). `design_system` is `Some` when the JS object carries a
/// `designSystem` key.
@@ -86,6 +126,7 @@ pub struct DetectionConfig {
pub ignore_rules: Vec<String>,
pub ignore_files: Vec<String>,
pub ignore_values: Vec<IgnoreValueEntry>,
pub ignore_selectors: Vec<IgnoreSelectorEntry>,
pub design_system_enabled: Option<bool>,
pub advisory_rules: Option<String>,
}
@@ -131,6 +172,9 @@ fn apply_detection_config_source(config: &mut DetectionConfig, raw: Option<&Map<
if let Some(Value::Array(values)) = raw.get("ignoreValues") {
config.ignore_values = merge_ignore_values(&config.ignore_values, values);
}
if let Some(Value::Array(selectors)) = raw.get("ignoreSelectors") {
config.ignore_selectors = merge_ignore_selectors(&config.ignore_selectors, selectors);
}
}
fn unique_strings(values: Vec<String>) -> Vec<String> {
@@ -190,6 +234,22 @@ pub fn write_detection_config(
for (k, v) in normalize_detection_config_for_write(detector_config) {
next_detector.insert(k, v);
}
// `ignoreSelectors` is written only by a project that uses it, so a config
// that never opted into component-level ignores does not grow an empty
// key on the next `ignores add-rule`.
if !detector_config.ignore_selectors.is_empty()
|| next_detector.contains_key("ignoreSelectors")
{
next_detector.insert(
"ignoreSelectors".into(),
Value::Array(
normalize_ignore_selector_entries_typed(&detector_config.ignore_selectors)
.iter()
.map(IgnoreSelectorEntry::to_json)
.collect(),
),
);
}
let mut next = existing.clone();
next.insert("detector".into(), Value::Object(next_detector));
match next_hook {
@@ -643,6 +703,187 @@ fn merge_ignore_values(existing: &[IgnoreValueEntry], incoming: &[Value]) -> Vec
map.into_iter().map(|(_, e)| e).collect()
}
/// `normalizeIgnoreValueEntries`' twin for `ignoreSelectors`. The rule is
/// lowercased like every other rule id; the selector keeps its case (CSS
/// class names are case-sensitive) and only loses surrounding whitespace.
/// An entry missing either half is dropped: a selector ignore with no
/// selector would be `ignoreRules`, and one with no rule would be
/// `ignoreFiles` by another name.
pub fn normalize_ignore_selector_entries(entries: &[Value]) -> Vec<IgnoreSelectorEntry> {
let mut out = Vec::new();
for entry in entries {
let Value::Object(entry) = entry else {
continue;
};
let rule = normalize_ignore_rule(
&entry
.get("rule")
.map(js_string_or_empty)
.unwrap_or_default(),
);
let selector = js::trim(
&entry
.get("selector")
.map(js_string_or_empty)
.unwrap_or_default(),
)
.to_string();
if rule.is_empty() || selector.is_empty() {
continue;
}
let mut files: Vec<String> = Vec::new();
if let Some(Value::String(f)) = entry.get("file") {
if !js::trim(f).is_empty() {
files.push(js::trim(f).to_string());
}
}
if let Some(Value::Array(list)) = entry.get("files") {
for f in list {
if let Value::String(f) = f {
if !js::trim(f).is_empty() {
files.push(js::trim(f).to_string());
}
}
}
}
let files = unique_strings(files);
let mut normalized = IgnoreSelectorEntry {
rule,
selector,
files: if files.is_empty() { None } else { Some(files) },
created_at: None,
reason: None,
};
if let Some(Value::String(c)) = entry.get("createdAt") {
if !js::trim(c).is_empty() {
normalized.created_at = Some(js::trim(c).to_string());
}
}
if let Some(Value::String(r)) = entry.get("reason") {
if !js::trim(r).is_empty() {
normalized.reason = Some(js::trim(r).to_string());
}
}
out.push(normalized);
}
out
}
/// The same normalization over already-typed entries (idempotent on write).
pub fn normalize_ignore_selector_entries_typed(
entries: &[IgnoreSelectorEntry],
) -> Vec<IgnoreSelectorEntry> {
let raw: Vec<Value> = entries.iter().map(IgnoreSelectorEntry::to_json).collect();
normalize_ignore_selector_entries(&raw)
}
fn selector_entry_key(entry: &IgnoreSelectorEntry) -> String {
format!(
"{}\0{}\0{}",
entry.rule,
entry.selector,
ignore_value_files_key(entry.files.as_ref())
)
}
/// Merge raw `ignoreSelectors` JSON into an existing list, later entries
/// replacing earlier ones with the same rule + selector + files key. Shared
/// with the hook's own config reader.
pub fn merge_ignore_selectors(
existing: &[IgnoreSelectorEntry],
incoming: &[Value],
) -> Vec<IgnoreSelectorEntry> {
let mut map: Vec<(String, IgnoreSelectorEntry)> = Vec::new();
let mut set = |entry: IgnoreSelectorEntry| {
let key = selector_entry_key(&entry);
if let Some(slot) = map.iter_mut().find(|(k, _)| *k == key) {
slot.1 = entry;
} else {
map.push((key, entry));
}
};
for entry in normalize_ignore_selector_entries_typed(existing) {
set(entry);
}
for entry in normalize_ignore_selector_entries(incoming) {
set(entry);
}
map.into_iter().map(|(_, e)| e).collect()
}
/// The entries that govern one local scan target, as the engines take them.
///
/// An entry with no `files` covers every target. An entry with `files` covers
/// the paths its globs match, tested the way a scoped `ignoreValues` entry is
/// (raw path, then each `/`-suffix of it).
pub fn selector_ignores_for_target(
config: &DetectionConfig,
target: &str,
) -> Vec<SelectorIgnore> {
selector_ignores_filtered(config, |files| path_matches_scoped_globs(target, files))
}
/// The entries that govern a URL scan: the unscoped ones only.
///
/// `files` globs describe repo paths, and a URL is not one. Matching them
/// against the URL would let a glob like `index.html` reach
/// `https://example.com/index.html` by accident, scoping an ignore to a page
/// the entry never named.
pub fn selector_ignores_for_url(config: &DetectionConfig) -> Vec<SelectorIgnore> {
selector_ignores_filtered(config, |_| false)
}
fn selector_ignores_filtered(
config: &DetectionConfig,
covers: impl Fn(&[String]) -> bool,
) -> Vec<SelectorIgnore> {
normalize_ignore_selector_entries_typed(&config.ignore_selectors)
.into_iter()
.filter(|e| match &e.files {
Some(files) if !files.is_empty() => covers(files),
_ => true,
})
.map(|e| SelectorIgnore::new(&e.rule, &e.selector))
.collect()
}
/// One `(rule, selector)` pair and how many findings it waived on this run.
#[derive(Debug, Clone, PartialEq)]
pub struct IgnoredBySelector {
pub rule: String,
pub selector: String,
pub count: usize,
}
/// Split the findings the engines stamped with `ignoredBy` out of the
/// reportable set, counted by rule and selector in first-seen order. This is
/// what turns a component-level opt-out into a number a reviewer can read
/// instead of silence.
pub fn partition_selector_ignored(findings: Vec<Finding>) -> (Vec<Finding>, Vec<IgnoredBySelector>) {
let mut kept = Vec::with_capacity(findings.len());
let mut report: Vec<IgnoredBySelector> = Vec::new();
for f in findings {
match impeccable_core::findings::ignored_by(&f) {
Some(selector) => {
let rule = normalize_ignore_rule(&f.antipattern);
match report
.iter_mut()
.find(|r| r.rule == rule && r.selector == selector)
{
Some(slot) => slot.count += 1,
None => report.push(IgnoredBySelector {
rule,
selector: selector.to_string(),
count: 1,
}),
}
}
None => kept.push(f),
}
}
(kept, report)
}
fn escape_glob_char(c: char) -> bool {
matches!(
c,
@@ -766,8 +1007,24 @@ pub fn should_ignore_detection_file(file_path: &str, root: &str, config: &Detect
false
}
/// JS: impeccable-config.mjs#filterDetectionFindings
/// JS: impeccable-config.mjs#filterDetectionFindings, plus the
/// component-level opt-outs the engines stamped. Callers that want the count
/// of what a selector ignore suppressed use
/// [`filter_detection_findings_reported`].
pub fn filter_detection_findings(findings: Vec<Finding>, config: &DetectionConfig) -> Vec<Finding> {
filter_detection_findings_reported(findings, config).0
}
/// `filterDetectionFindings` with the selector-ignore tally alongside it.
pub fn filter_detection_findings_reported(
findings: Vec<Finding>,
config: &DetectionConfig,
) -> (Vec<Finding>, Vec<IgnoredBySelector>) {
let (findings, report) = partition_selector_ignored(findings);
(filter_by_rules_and_values(findings, config), report)
}
fn filter_by_rules_and_values(findings: Vec<Finding>, config: &DetectionConfig) -> Vec<Finding> {
if findings.is_empty() {
return vec![];
}
@@ -813,7 +1070,13 @@ fn is_ignored_finding_value(finding: &Finding, ignore_values: &[IgnoreValueEntry
}
fn finding_matches_scoped_ignore_file(finding: &Finding, globs: &[String]) -> bool {
let file_path = js::trim(&finding.file);
path_matches_scoped_globs(&finding.file, globs)
}
/// JS `findingMatchesScopedIgnoreFile`'s path test: the raw path, then every
/// `/`-suffix of it, so `src/a.css` is matched by `a.css` too.
fn path_matches_scoped_globs(path: &str, globs: &[String]) -> bool {
let file_path = js::trim(path);
if file_path.is_empty() {
return false;
}
@@ -1121,4 +1384,98 @@ mod tests {
assert_eq!(decode_uri_component("Open%20Sans"), "Open Sans");
assert_eq!(decode_uri_component("bad%zz"), "bad%zz");
}
fn config_with_selectors(raw: &str) -> DetectionConfig {
let mut config = DetectionConfig::with_defaults();
let parsed: Value = serde_json::from_str(raw).unwrap();
apply_detection_config_source(&mut config, parsed.as_object());
config
}
#[test]
fn ignore_selectors_parse_normalize_and_merge() {
let config = config_with_selectors(
r#"{"ignoreSelectors":[
{"rule":"Undersized-UI-Text","selector":" .ks-tag ","reason":"by design"},
{"rule":"","selector":".x"},
{"rule":"side-tab"},
"nope",
{"rule":"undersized-ui-text","selector":".ks-tag","reason":"second word wins"},
{"rule":"glow-effect","selector":".demo","files":["src/demo/**"," "]}
]}"#,
);
// Half-entries and junk are dropped, and the same rule+selector+files
// key is one entry the later value replaces.
assert_eq!(config.ignore_selectors.len(), 2);
let first = &config.ignore_selectors[0];
assert_eq!(first.rule, "undersized-ui-text");
assert_eq!(first.selector, ".ks-tag");
assert_eq!(first.reason.as_deref(), Some("second word wins"));
assert_eq!(
config.ignore_selectors[1].files.as_deref(),
Some(["src/demo/**".to_string()].as_slice())
);
}
#[test]
fn selector_ignores_are_narrowed_per_target() {
let config = config_with_selectors(
r#"{"ignoreSelectors":[
{"rule":"undersized-ui-text","selector":".ks-tag"},
{"rule":"glow-effect","selector":".demo","files":["src/demo/**"]}
]}"#,
);
let everywhere = selector_ignores_for_target(&config, "src/pages/index.astro");
assert_eq!(everywhere.len(), 1);
assert_eq!(everywhere[0].selector, ".ks-tag");
let scoped = selector_ignores_for_target(&config, "src/demo/playground.astro");
assert_eq!(scoped.len(), 2);
// A URL scan is covered by the unscoped entries only: a `files` glob
// describes repo paths, and must not reach a URL path that happens to
// end the same way.
assert_eq!(selector_ignores_for_url(&config).len(), 1);
let url_globs = config_with_selectors(
r#"{"ignoreSelectors":[
{"rule":"glow-effect","selector":".demo","files":["index.html"]}
]}"#,
);
assert!(selector_ignores_for_url(&url_globs).is_empty());
assert_eq!(
selector_ignores_for_target(&url_globs, "src/index.html").len(),
1
);
// `--no-config` leaves the list empty, so nothing is waived.
assert!(selector_ignores_for_target(&DetectionConfig::raw(), "a.html").is_empty());
}
#[test]
fn stamped_findings_leave_the_reportable_set_as_a_count() {
let stamp = |rule: &str, selector: Option<&str>| {
impeccable_core::findings::stamp_ignored_by(
impeccable_core::findings::finding(rule, "a.html", "snip", 0.0),
selector,
)
};
let findings = vec![
stamp("undersized-ui-text", Some(".ks-tag")),
stamp("undersized-ui-text", Some(".ks-tag")),
stamp("side-tab", Some(".ks-tag")),
stamp("undersized-ui-text", None),
];
let (kept, report) =
filter_detection_findings_reported(findings, &DetectionConfig::with_defaults());
assert_eq!(kept.len(), 1);
assert_eq!(report.len(), 2);
assert_eq!(report[0].rule, "undersized-ui-text");
assert_eq!(report[0].selector, ".ks-tag");
assert_eq!(report[0].count, 2);
assert_eq!(report[1].count, 1);
// Nothing stamped, nothing reported.
let (kept, report) = filter_detection_findings_reported(
vec![stamp("side-tab", None)],
&DetectionConfig::with_defaults(),
);
assert_eq!(kept.len(), 1);
assert!(report.is_empty());
}
}
+5
View File
@@ -7,6 +7,7 @@ use std::rc::Rc;
use impeccable_core::findings::Finding;
use impeccable_core::rule_pack::RulePack;
use impeccable_core::selector_ignores::SelectorIgnore;
use crate::design_system::DesignSystem;
use crate::profiler::DetectorProfile;
@@ -23,6 +24,10 @@ pub struct ScanOptions {
pub viewport: Option<(u32, u32)>,
/// JS `options.profile` (library callers only; no CLI flag).
pub profile: Option<Rc<DetectorProfile>>,
/// The project's component-level opt-outs for this target
/// (`detector.ignoreSelectors`, narrowed to the entries whose `files`
/// globs cover it). Empty under `--no-config`.
pub ignore_selectors: Vec<SelectorIgnore>,
/// The installed rule pack (`impeccable_core::rule_pack`), passed through
/// to the text engine and on to the HTML engine. `None` in the `impeccable`
/// binary, which ships the built-in rules only.
+204 -14
View File
@@ -8,7 +8,7 @@ use impeccable_core::js;
use crate::config::{
get_config_path, get_local_config_path, normalize_ignore_value, read_detection_config,
read_raw_detection_config, synthetic_ignore_value, write_detection_config, DetectionConfig,
IgnoreValueEntry,
IgnoreSelectorEntry, IgnoreValueEntry,
};
use crate::jsp;
@@ -21,9 +21,11 @@ Actions:
add-rule <rule> [--all-values] Ignore a rule
add-file <glob> Ignore files by glob
add-value <rule> <value> Ignore one rule/value pair
add-selector <rule> <selector> Ignore one rule on a component, everywhere
remove-rule <rule> Remove a rule ignore
remove-file <glob> Remove a file ignore
remove-value <rule> <value> Remove a rule/value ignore
remove-selector <rule> <selector> Remove a component ignore
clear Clear detector ignores in the selected scope
Scope:
@@ -32,14 +34,22 @@ Scope:
--all For remove/clear, apply to shared and local
Value options:
--file <glob> Scope add-value/remove-value to a file glob
--reason <text> Store or update a reason on add-value
--file <glob> Scope add-value/add-selector to a file glob
--reason <text> Store or update a reason on add-value/add-selector
Component ignores (add-selector) waive one rule for every element a CSS
selector matches, and for that element's subtree. One entry replaces the
same data-impeccable-ignore attribute repeated on every instance of a
component, and the scan reports how many hits it suppressed instead of
going quiet.
Examples:
impeccable ignores add-file \"src/legacy/**\"
impeccable ignores add-value overused-font Inter --reason \"Brand font\"
impeccable ignores add-value design-system-color \"*\" --file \"src/demo.css\"
impeccable ignores add-selector undersized-ui-text \".ks-tag\" --reason \"10px mono label, by design\"
impeccable ignores remove-value overused-font Inter
impeccable ignores remove-selector undersized-ui-text \".ks-tag\"
";
fn action_for(arg: &str) -> Option<&'static str> {
@@ -48,9 +58,11 @@ fn action_for(arg: &str) -> Option<&'static str> {
"add-rule" | "ignore-rule" => "add-rule",
"add-file" | "ignore-file" => "add-file",
"add-value" | "ignore-value" | "update-value" => "add-value",
"add-selector" | "ignore-selector" | "update-selector" => "add-selector",
"remove-rule" | "rm-rule" => "remove-rule",
"remove-file" | "rm-file" => "remove-file",
"remove-value" | "rm-value" => "remove-value",
"remove-selector" | "rm-selector" => "remove-selector",
"clear" => "clear",
_ => return None,
})
@@ -191,6 +203,27 @@ fn format_values(values: &[IgnoreValueEntry]) -> String {
.join(", ")
}
fn format_selectors(entries: &[IgnoreSelectorEntry]) -> String {
if entries.is_empty() {
return "(none)".to_string();
}
entries
.iter()
.map(|e| {
let file_suffix = match &e.files {
Some(f) if !f.is_empty() => format!(" [{}]", f.join(", ")),
_ => String::new(),
};
let reason_suffix = match &e.reason {
Some(r) if !r.is_empty() => format!(" - {r}"),
_ => String::new(),
};
format!("{} on {}{file_suffix}{reason_suffix}", e.rule, e.selector)
})
.collect::<Vec<_>>()
.join(", ")
}
fn format_config(label: &str, config: &DetectionConfig) -> String {
let none_or = |v: &[String]| {
if v.is_empty() {
@@ -199,21 +232,29 @@ fn format_config(label: &str, config: &DetectionConfig) -> String {
v.join(", ")
}
};
[
let mut lines = vec![
format!("{label}:"),
format!(" ignoreRules: {}", none_or(&config.ignore_rules)),
format!(" ignoreFiles: {}", none_or(&config.ignore_files)),
format!(" ignoreValues: {}", format_values(&config.ignore_values)),
format!(
" designSystem: {}",
if config.design_system_enabled == Some(false) {
"disabled"
} else {
"enabled"
}
),
]
.join("\n")
];
// Listed only where the project uses component ignores, so the familiar
// four-line block is unchanged for everyone else.
if !config.ignore_selectors.is_empty() {
lines.push(format!(
" ignoreSelectors: {}",
format_selectors(&config.ignore_selectors)
));
}
lines.push(format!(
" designSystem: {}",
if config.design_system_enabled == Some(false) {
"disabled"
} else {
"enabled"
}
));
lines.join("\n")
}
fn rel_or_abs(cwd: &str, target: &str) -> String {
@@ -426,6 +467,140 @@ fn add_value(cwd: &str, args: &[String]) -> R<String> {
))
}
struct SelectorArgs {
rule: String,
selector: String,
files: Vec<String>,
reason: String,
}
/// `add-selector <rule> <selector...> [--file <glob>]... [--reason <text...>]`.
/// The selector keeps its case and its internal spacing (`.card .ks-tag` is a
/// descendant selector, not two arguments), so positionals after the rule are
/// joined rather than normalized the way an ignore value is.
fn parse_selector_args(args: &[String]) -> R<SelectorArgs> {
let mut positionals: Vec<String> = Vec::new();
let mut files: Vec<String> = Vec::new();
let mut reason = String::new();
let mut i = 0;
while i < args.len() {
let arg = args[i].as_str();
if arg == "--reason" {
let mut chunks = Vec::new();
while i + 1 < args.len() && !args[i + 1].starts_with("--") {
i += 1;
chunks.push(args[i].clone());
}
reason = js::trim(&chunks.join(" ")).to_string();
} else if let Some(v) = arg.strip_prefix("--reason=") {
reason = js::trim(v).to_string();
} else if arg == "--file" || arg == "--files" {
if i + 1 >= args.len() {
return Err(format!("{arg} requires a glob"));
}
i += 1;
files.push(require_glob(&args[i], arg)?);
} else if let Some(v) = arg.strip_prefix("--file=") {
files.push(require_glob(v, "--file")?);
} else if let Some(v) = arg.strip_prefix("--files=") {
files.push(require_glob(v, "--files")?);
} else if arg.starts_with("--") {
return Err(format!("Unknown add-selector flag: {arg}"));
} else {
positionals.push(arg.to_string());
}
i += 1;
}
let rule = js::to_lower_case(js::trim(
positionals.first().map(String::as_str).unwrap_or(""),
));
let selector = js::trim(&positionals.get(1..).unwrap_or(&[]).join(" ")).to_string();
if rule.is_empty() || selector.is_empty() {
return Err(
"Pass a rule id and a CSS selector, e.g. impeccable ignores add-selector undersized-ui-text \".ks-tag\""
.to_string(),
);
}
if selector == "*" {
return Err("A `*` selector waives the rule everywhere. Use add-rule for that, or name the component's selector.".to_string());
}
let mut scoped: Vec<String> = Vec::new();
for f in files.into_iter().filter(|f| !f.is_empty()) {
if !scoped.contains(&f) {
scoped.push(f);
}
}
scoped.sort();
Ok(SelectorArgs {
rule,
selector,
files: scoped,
reason,
})
}
fn selector_key(rule: &str, selector: &str, files: &[String]) -> String {
let mut sorted = files.to_vec();
sorted.sort();
format!(
"{}\0{}\0{}",
js::to_lower_case(js::trim(rule)),
js::trim(selector),
sorted.join("\u{1f}")
)
}
fn selector_entry_key(e: &IgnoreSelectorEntry) -> String {
selector_key(
&e.rule,
&e.selector,
e.files.as_deref().unwrap_or_default(),
)
}
fn add_selector(cwd: &str, args: &[String]) -> R<String> {
let scope = parse_scope(args, false)?;
let parsed = parse_selector_args(&scope.rest)?;
let mut config = read_raw_detection_config(cwd, scope.local);
let key = selector_key(&parsed.rule, &parsed.selector, &parsed.files);
if let Some(existing) = config
.ignore_selectors
.iter_mut()
.find(|e| selector_entry_key(e) == key)
{
if !parsed.reason.is_empty() {
existing.reason = Some(parsed.reason.clone());
}
if !parsed.files.is_empty() {
existing.files = Some(parsed.files.clone());
}
} else {
config.ignore_selectors.push(IgnoreSelectorEntry {
rule: parsed.rule.clone(),
selector: parsed.selector.clone(),
files: if parsed.files.is_empty() {
None
} else {
Some(parsed.files.clone())
},
created_at: Some(iso_now()),
reason: if parsed.reason.is_empty() {
None
} else {
Some(parsed.reason.clone())
},
});
}
let target = write_scope(cwd, &config, scope.local)?;
Ok(format!(
"Added {} on {} to {} detector ignoreSelectors ({}).",
parsed.rule,
parsed.selector,
if scope.local { "local" } else { "shared" },
rel_or_abs(cwd, &target)
))
}
fn remove_from_scopes(
cwd: &str,
args: &[String],
@@ -493,6 +668,18 @@ fn remove_value(cwd: &str, args: &[String]) -> R<String> {
})
}
fn remove_selector(cwd: &str, args: &[String]) -> R<String> {
remove_from_scopes(cwd, args, |config, rest| {
let parsed = parse_selector_args(rest)?;
let key = selector_key(&parsed.rule, &parsed.selector, &parsed.files);
let before = config.ignore_selectors.len();
config
.ignore_selectors
.retain(|e| selector_entry_key(e) != key);
Ok(before - config.ignore_selectors.len())
})
}
fn clear(cwd: &str, args: &[String]) -> R<String> {
let scope = parse_scope(args, true)?;
if !scope.rest.is_empty() {
@@ -508,6 +695,7 @@ fn clear(cwd: &str, args: &[String]) -> R<String> {
config.ignore_rules.clear();
config.ignore_files.clear();
config.ignore_values.clear();
config.ignore_selectors.clear();
write_scope(cwd, &config, is_local)?;
}
Ok(format!(
@@ -547,9 +735,11 @@ pub fn run(args: &[String], io: &mut Io) -> i32 {
"add-rule" => add_rule(&cwd, &rest),
"add-file" => add_file(&cwd, &rest),
"add-value" => add_value(&cwd, &rest),
"add-selector" => add_selector(&cwd, &rest),
"remove-rule" => remove_rule(&cwd, &rest),
"remove-file" => remove_file(&cwd, &rest),
"remove-value" => remove_value(&cwd, &rest),
"remove-selector" => remove_selector(&cwd, &rest),
_ => clear(&cwd, &rest),
};
match out {
+55
View File
@@ -39,6 +39,17 @@ pub struct BrowserFinding {
skip_serializing_if = "Option::is_none"
)]
pub ignore_value: Option<String>,
/// The `detector.ignoreSelectors` selector that waived this finding, when
/// one did. A stamped finding is still reported by the engine: the config
/// layer drops it and counts it, so a component-level opt-out shows up as
/// a number rather than as silence. `None` for everything else, and
/// skipped in serialization, so output without the feature is unchanged.
#[serde(
default,
rename = "ignoredBy",
skip_serializing_if = "Option::is_none"
)]
pub ignored_by: Option<String>,
}
impl BrowserFinding {
@@ -48,6 +59,7 @@ impl BrowserFinding {
detail: detail.into(),
severity: None,
ignore_value: None,
ignored_by: None,
}
}
/// `{ type: f.id, detail: f.snippet }` from a Section 3 hit.
@@ -127,6 +139,35 @@ where
.collect())
}
/// The same tolerance for `ignoreSelectors`: an entry that is not an object
/// with both halves is dropped, rather than failing the parse of the whole
/// config (which the wasm entry points answer with `unwrap_or_default()`,
/// silently losing the design system and every other setting with it).
fn de_ignore_selectors<'de, D>(
de: D,
) -> Result<Vec<crate::selector_ignores::SelectorIgnore>, D::Error>
where
D: serde::Deserializer<'de>,
{
let raw = serde_json::Value::deserialize(de)?;
let Some(items) = raw.as_array() else {
return Ok(Vec::new());
};
Ok(items
.iter()
.filter_map(|entry| {
let obj = entry.as_object()?;
let text = |key: &str| match obj.get(key) {
Some(serde_json::Value::String(s)) => s.clone(),
_ => String::new(),
};
let parsed =
crate::selector_ignores::SelectorIgnore::new(text("rule"), text("selector"));
parsed.is_valid().then_some(parsed)
})
.collect())
}
/// What the bundle passes into `collectBrowserFindings`: extension mode and
/// the relevant slice of `window.__IMPECCABLE_CONFIG__`.
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
@@ -145,6 +186,20 @@ pub struct BrowserConfig {
/// overlay. Serialized as `disabledValues`.
#[serde(default, deserialize_with = "de_disabled_values")]
pub disabled_values: Vec<DisabledValue>,
/// `window.__IMPECCABLE_CONFIG__?.ignoreSelectors`: the project's
/// component-level opt-outs, `[{ rule, selector }]`. Every finding on an
/// element the selector matches (or on a descendant of one) is stamped
/// with that selector instead of being reported clean, the same waiver
/// `data-impeccable-ignore` grants the element that carries it. Honored
/// in every mode: unlike `disabledRules`, this list is the project's own
/// config rather than a browser-extension preference. Empty by default,
/// and skipped in serialization so a config without it is byte-identical.
#[serde(
default,
deserialize_with = "de_ignore_selectors",
skip_serializing_if = "Vec::is_empty"
)]
pub ignore_selectors: Vec<crate::selector_ignores::SelectorIgnore>,
/// `window.__IMPECCABLE_CONFIG__?.skipScan === true` (only honored in
/// extension mode): the page is waived wholesale by detector.ignoreFiles,
/// so every scan stage answers empty.
+33 -2
View File
@@ -12,8 +12,9 @@
//! element tree in document order (child nodes with their text, so
//! `textContent` and the direct text nodes come out byte-equal), attributes,
//! the computed-style properties the rules read (`STYLE_PROPS`, interned
//! values), `::before` / `::after` styles where `content` is set, bounding
//! rects, the client/scroll/offset metrics, `checkVisibility`, direct-text
//! values), `::before` / `::after` styles where `content` is set,
//! `::placeholder` `color` on text controls, bounding rects, the
//! client/scroll/offset metrics, `checkVisibility`, direct-text
//! rects, viewport and scroll, hostname, quirks mode, `body.innerText`, the
//! `@keyframes` rules, the document HTML for the regex pass, and the media
//! intrinsics the visual-contrast path needs.
@@ -253,6 +254,10 @@ pub struct SnapNode {
pub before: Option<Vec<u32>>,
#[serde(rename = "f", default)]
pub after: Option<Vec<u32>>,
/// Interned `getComputedStyle(el, '::placeholder').color` when the
/// element has a non-empty `placeholder` attribute.
#[serde(rename = "ph", default)]
pub placeholder_color: Option<u32>,
/// `getBoundingClientRect` as `[x, y, width, height]`; `None` when the
/// element has no such method.
#[serde(rename = "r", default)]
@@ -824,6 +829,11 @@ impl Dom for SnapshotDom {
}
fn pseudo_style(&self, el: ElId, pseudo: &str, prop: &str) -> Option<String> {
let n = self.snap.node(el);
if pseudo == "::placeholder" && prop == "color" {
return n
.placeholder_color
.and_then(|idx| self.snap.strings.get(idx as usize).cloned());
}
let vals = match pseudo {
"::before" | ":before" => n.before.as_ref(),
"::after" | ":after" => n.after.as_ref(),
@@ -999,6 +1009,27 @@ mod tests {
assert!(d.offset_width(6).is_nan());
}
#[test]
fn placeholder_color_is_readable_as_pseudo_style() {
let json = r#"{
"v": 1, "hostname": "example.test", "innerWidth": 1280, "innerHeight": 800,
"styleProps": ["display", "color"], "pseudoProps": ["content"],
"strings": ["block", "rgb(0, 0, 0)", "rgb(187, 187, 187)"],
"documentElement": 1, "body": 2,
"els": [
{"t":"HTML","c":[2],"s":[0,1],"r":[0,0,1280,800]},
{"t":"BODY","p":1,"c":[3],"s":[0,1]},
{"t":"INPUT","p":2,"c":[],"a":[["placeholder","Jane"]],"s":[0,1],"ph":2}
]
}"#;
let d = snap(json);
assert_eq!(
d.pseudo_style(3, "::placeholder", "color").as_deref(),
Some("rgb(187, 187, 187)")
);
assert_eq!(d.pseudo_style(2, "::placeholder", "color"), None);
}
#[test]
fn selectors_over_snapshot() {
let d = snap(SMALL);
+36
View File
@@ -84,6 +84,31 @@ pub fn finding(id: &str, file_path: &str, snippet: &str, line: f64) -> Finding {
.unwrap_or_else(|| panic!("finding(): unknown antipattern id {id:?}"))
}
/// The extras key an engine stamps on a finding a component-level opt-out
/// (`detector.ignoreSelectors`) waived, carrying the selector that waived it.
/// The finding still travels; the config layer drops and counts it.
pub const IGNORED_BY_KEY: &str = "ignoredBy";
/// Stamp `ignoredBy` when a selector waived this finding. `None` leaves the
/// finding untouched, so nothing changes for a project without the config.
pub fn stamp_ignored_by(mut finding: Finding, selector: Option<&str>) -> Finding {
if let Some(selector) = selector.filter(|s| !s.is_empty()) {
finding.extras.insert(
IGNORED_BY_KEY.to_string(),
Value::String(selector.to_string()),
);
}
finding
}
/// The selector that waived this finding, when one did.
pub fn ignored_by(finding: &Finding) -> Option<&str> {
match finding.extras.get(IGNORED_BY_KEY) {
Some(Value::String(s)) if !s.is_empty() => Some(s.as_str()),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -107,4 +132,15 @@ mod tests {
assert_eq!(finding("script-error", "f", "s", 0.0).severity, "error");
assert!(try_finding("nope", "f", "s", 0.0).is_none());
}
#[test]
fn ignored_by_stamp_round_trips_and_stays_off_by_default() {
let plain = finding("side-tab", "a.html", "s", 0.0);
assert_eq!(ignored_by(&stamp_ignored_by(plain.clone(), None)), None);
assert_eq!(ignored_by(&stamp_ignored_by(plain.clone(), Some(""))), None);
let stamped = stamp_ignored_by(plain, Some(".ks-tag"));
assert_eq!(ignored_by(&stamped), Some(".ks-tag"));
let json = serde_json::to_string(&stamped).unwrap();
assert!(json.ends_with(r#""snippet":"s","ignoredBy":".ks-tag"}"#), "{json}");
}
}
+1
View File
@@ -25,6 +25,7 @@ pub mod page;
pub mod registry;
pub mod rule_pack;
pub mod rules;
pub mod selector_ignores;
#[cfg(any(test, feature = "vectors"))]
pub mod vectors;
+108
View File
@@ -0,0 +1,108 @@
//! Component-level opt-outs: one `{ rule, selector }` pair waives a rule for
//! every element the selector matches, and for that element's subtree.
//!
//! This is the declared twin of the `data-impeccable-ignore` attribute. The
//! attribute waives the element that carries it; a selector ignore waives
//! every instance of a component from one line of project config, so an
//! author with eleven copies of the same 10px label writes one entry instead
//! of eleven attributes.
//!
//! The engines do not drop what a selector ignore covers. They stamp the
//! finding with the selector that waived it (`Finding.ignoredBy` /
//! `BrowserFinding.ignoredBy`), and the config layer that owns the ignore
//! list drops and counts them, so "silenced" stays countable.
use serde::{Deserialize, Serialize};
/// One `detector.ignoreSelectors` entry, reduced to what an engine needs.
/// `rule` is a lowercased rule id or `*` (every rule); `selector` is a CSS
/// selector matched against the finding's element and its ancestors.
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
pub struct SelectorIgnore {
pub rule: String,
pub selector: String,
}
impl SelectorIgnore {
/// Normalizing constructor: the rule is trimmed and lowercased the way
/// `data-impeccable-ignore` tokens are, the selector keeps its case
/// (`.ksTag` and `.kstag` are different classes) and only loses
/// surrounding whitespace.
pub fn new(rule: impl AsRef<str>, selector: impl AsRef<str>) -> Self {
SelectorIgnore {
rule: crate::js::to_lower_case(crate::js::trim(rule.as_ref())),
selector: crate::js::trim(selector.as_ref()).to_string(),
}
}
/// Usable only with both halves present. An entry with an empty selector
/// would waive everything, which is what `ignoreRules` is for.
pub fn is_valid(&self) -> bool {
!self.rule.is_empty() && !self.selector.is_empty()
}
/// `*` covers every rule, exactly as it does in the attribute.
pub fn covers_rule(&self, rule_id: &str) -> bool {
if !self.is_valid() {
return false;
}
self.rule == "*" || self.rule == crate::js::to_lower_case(crate::js::trim(rule_id))
}
}
/// The first entry that waives `rule_id` for an element, where `closest`
/// answers the DOM's `element.closest(selector) !== null` (self or ancestor).
/// Returns the selector that waived it, which is what the finding carries.
pub fn waiving_selector<'a>(
entries: &'a [SelectorIgnore],
rule_id: &str,
mut closest: impl FnMut(&str) -> bool,
) -> Option<&'a str> {
entries
.iter()
.find(|e| e.covers_rule(rule_id) && closest(&e.selector))
.map(|e| e.selector.as_str())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn normalizes_rule_and_selector() {
let e = SelectorIgnore::new(" Undersized-UI-Text ", " .ks-tag ");
assert_eq!(e.rule, "undersized-ui-text");
assert_eq!(e.selector, ".ks-tag");
assert!(e.is_valid());
}
#[test]
fn half_an_entry_is_not_an_entry() {
assert!(!SelectorIgnore::new("", ".ks-tag").is_valid());
assert!(!SelectorIgnore::new("side-tab", " ").is_valid());
assert!(!SelectorIgnore::new("", ".ks-tag").covers_rule("side-tab"));
}
#[test]
fn star_covers_every_rule() {
let e = SelectorIgnore::new("*", ".demo");
assert!(e.covers_rule("side-tab"));
assert!(e.covers_rule("UNDERSIZED-UI-TEXT"));
}
#[test]
fn waiving_selector_picks_the_first_match() {
let entries = vec![
SelectorIgnore::new("side-tab", ".nope"),
SelectorIgnore::new("undersized-ui-text", ".ks-tag"),
SelectorIgnore::new("undersized-ui-text", ".also"),
];
let hit = waiving_selector(&entries, "undersized-ui-text", |s| s != ".nope");
assert_eq!(hit, Some(".ks-tag"));
assert_eq!(waiving_selector(&entries, "glow-effect", |_| true), None);
assert_eq!(
waiving_selector(&entries, "undersized-ui-text", |_| false),
None
);
}
}
+33 -6
View File
@@ -13,8 +13,9 @@ use impeccable_core::findings::Finding;
use impeccable_core::js;
use impeccable_detect::config::{
extract_finding_ignore_value, filter_detection_findings, matches_any_glob,
normalize_ignore_rule, normalize_ignore_value, normalize_ignore_value_entries, DetectionConfig,
IgnoreValueEntry,
merge_ignore_selectors, normalize_ignore_rule, normalize_ignore_value,
normalize_ignore_value_entries, selector_ignores_for_target, DetectionConfig,
IgnoreSelectorEntry, IgnoreValueEntry,
};
use impeccable_detect::design_system::{load_design_system_for_cwd, resolve_design_md_path, DesignSystem};
use impeccable_detect::detect_text::{detect_text, TextOptions};
@@ -373,6 +374,8 @@ pub struct HookConfig {
pub ignore_rules: Vec<String>,
pub ignore_files: Vec<String>,
pub ignore_values: Vec<IgnoreValueEntry>,
/// `detector.ignoreSelectors`: the project's component-level opt-outs.
pub ignore_selectors: Vec<IgnoreSelectorEntry>,
pub extensions: Vec<ExtensionEntry>,
pub per_edit_rules: String,
pub advisory_rules: String,
@@ -389,6 +392,7 @@ impl Default for HookConfig {
ignore_rules: vec![],
ignore_files: vec![],
ignore_values: vec![],
ignore_selectors: vec![],
extensions: vec![],
per_edit_rules: "immediate".to_string(),
advisory_rules: "exclude".to_string(),
@@ -482,6 +486,9 @@ fn apply_detector_config_source(config: &mut HookConfig, raw: Option<&Map<String
if let Some(Value::Array(list)) = raw.get("ignoreValues") {
config.ignore_values = merge_ignore_values(&config.ignore_values, list);
}
if let Some(Value::Array(list)) = raw.get("ignoreSelectors") {
config.ignore_selectors = merge_ignore_selectors(&config.ignore_selectors, list);
}
if let Some(Value::Array(list)) = raw.get("extensions") {
config.extensions = merge_extensions(&config.extensions, list);
}
@@ -989,6 +996,7 @@ pub fn filter_findings(findings: Vec<Finding>, config: &HookConfig) -> Vec<Findi
ignore_rules: config.ignore_rules.clone(),
ignore_files: vec![],
ignore_values: config.ignore_values.clone(),
ignore_selectors: config.ignore_selectors.clone(),
design_system_enabled: None,
advisory_rules: None,
};
@@ -1615,6 +1623,9 @@ pub fn should_emit_ack_for_file(file_path: &str, config: &HookConfig) -> bool {
#[derive(Default, Clone)]
pub struct HookScanOptions {
pub design_system: Option<Rc<DesignSystem>>,
/// The project's component-level opt-outs, narrowed per file when the
/// options are handed to an engine.
pub ignore_selectors: Vec<IgnoreSelectorEntry>,
}
impl HookScanOptions {
@@ -1624,12 +1635,19 @@ impl HookScanOptions {
.map(|d| d.md_newer_than_json)
.unwrap_or(false)
}
pub fn to_scan_options(&self) -> ScanOptions {
pub fn to_scan_options(&self, target: &str) -> ScanOptions {
ScanOptions {
inline_ignores: true,
design_system: self.design_system.clone(),
viewport: None,
profile: None,
ignore_selectors: selector_ignores_for_target(
&DetectionConfig {
ignore_selectors: self.ignore_selectors.clone(),
..DetectionConfig::raw()
},
target,
),
rule_pack: None,
}
}
@@ -1637,11 +1655,19 @@ impl HookScanOptions {
/// JS: designSystemOptions(config, detector, projectCwd)
pub fn design_system_options(config: &HookConfig, project_cwd: &str) -> HookScanOptions {
// Component ignores are not design-system state: a project with
// `designSystem.enabled: false` still opted its components out, and the
// hook would otherwise re-report them on every edit.
let ignore_selectors = config.ignore_selectors.clone();
if !config.design_system_enabled {
return HookScanOptions::default();
return HookScanOptions {
design_system: None,
ignore_selectors,
};
}
HookScanOptions {
design_system: load_design_system_for_cwd(project_cwd).map(Rc::new),
ignore_selectors,
}
}
@@ -1653,7 +1679,8 @@ pub fn design_system_options_for_file(
file_path: &str,
) -> HookScanOptions {
if !config.design_system_enabled {
return HookScanOptions::default();
// Same as above: the waivers travel even when no design system does.
return design_system_options(config, project_cwd);
}
let project = impeccable_context::context::resolve_project(
project_cwd,
@@ -1695,7 +1722,7 @@ pub fn detector_detect_html(
) -> Result<Vec<Finding>, String> {
let mut sink = std::io::sink();
rt.html
.detect_html(file_path, &scan.to_scan_options(), &mut sink)
.detect_html(file_path, &scan.to_scan_options(file_path), &mut sink)
.map_err(|e| e.message)
}
+41 -5
View File
@@ -20,9 +20,9 @@ use impeccable_core::checks::measures::{
use impeccable_core::checks::rules::{
check_borders, check_colors, check_glow, check_hero_eyebrow, check_hover_contrast,
check_icon_tile, check_italic_serif, check_kicker_above_heading, check_motion,
is_emoji_only_text, is_heading_tag, resolve_hero_heading_size_px, BorderOpts, ColorOpts,
GlowOpts, HeroEyebrowOpts, HoverContrastOpts, IconTileOpts, ItalicSerifOpts, KickerCandidate,
MotionOpts, RuleHit, Sides,
check_placeholder_colors, is_emoji_only_text, is_heading_tag, resolve_hero_heading_size_px,
BorderOpts, ColorOpts, GlowOpts, HeroEyebrowOpts, HoverContrastOpts, IconTileOpts,
ItalicSerifOpts, KickerCandidate, MotionOpts, RuleHit, Sides,
};
use impeccable_core::checks::text_rules::{
check_numbered_section_labels, is_kicker_candidate, is_numbered_section_label_candidate,
@@ -539,7 +539,7 @@ pub fn check_element_colors(
sv(style, "backgroundClip")
}
};
check_colors(&ColorOpts {
let color_opts = ColorOpts {
tag: tag.to_string(),
text_color,
bg_color: own_bg,
@@ -557,7 +557,43 @@ pub fn check_element_colors(
bg_image: Some(sv(style, "backgroundImage").to_string()),
class_list: Some(el.class_name().to_string()),
detector_is_browser: false,
})
};
let mut findings = check_colors(&color_opts);
if tag == "input" || tag == "textarea" {
let placeholder = el.get_attribute("placeholder").unwrap_or("").trim();
if !placeholder.is_empty() {
let skip = if tag == "input" {
let t = js::to_lower_case(el.get_attribute("type").unwrap_or("text"));
matches!(
t.as_str(),
"hidden" | "checkbox" | "radio" | "file" | "submit" | "button" | "image"
| "reset" | "range" | "color"
) || el
.get_attribute("value")
.is_some_and(|v| !js::trim(v).is_empty())
} else {
!js::trim(&direct_text).is_empty()
};
if !skip {
if let Some(ph_style) = el.doc.get_placeholder_style(el.id()) {
let ph_color = custom_props
.and_then(|m| {
measures::parse_color_resolved(sv_opt(ph_style, "color"), Some(m))
})
.or_else(|| parse_rgb(sv_opt(ph_style, "color")))
.or_else(|| parse_any_color(sv_opt(ph_style, "color")));
if let Some(ph_color) = ph_color {
findings.extend(check_placeholder_colors(
&color_opts,
placeholder,
ph_color,
));
}
}
}
}
}
findings
}
/// JS: checks.mjs#checkElementHoverContrast(el, style, tag, window)
+62 -2
View File
@@ -139,6 +139,31 @@ static PSEUDO_RULE_RE: Lazy<Regex> = Lazy::new(|| {
))
.expect("PSEUDO_RULE_RE")
});
static PLACEHOLDER_RULE_RE: Lazy<Regex> = Lazy::new(|| {
Regex::new(r"(?i)^(.*)(?:::placeholder|::?-webkit-input-placeholder|::?-moz-placeholder)$")
.expect("PLACEHOLDER_RULE_RE")
});
fn placeholder_host_selector(selector: &str) -> Option<String> {
let pm = PLACEHOLDER_RULE_RE.captures(selector)?;
let captured = pm.get(1).map(|m| m.as_str()).unwrap_or("");
let trimmed_end = captured.trim_end_matches(|c: char| js::is_js_whitespace(c));
if trimmed_end.is_empty() {
return Some("*".to_string());
}
// Only fill a trailing empty compound. `star_empty_compounds` would
// rewrite `.label + ::placeholder` to `.label *+*`.
let last = trimmed_end.chars().last().unwrap();
if captured.len() != trimmed_end.len() || last == '>' || last == '+' || last == '~' {
if last == '>' || last == '+' || last == '~' {
Some(format!("{}*", trimmed_end))
} else {
Some(format!("{} *", trimmed_end))
}
} else {
Some(trimmed_end.to_string())
}
}
static COLOR_TOKEN_RE: Lazy<Regex> = Lazy::new(|| {
Regex::new(r"(?i)(?:rgba?|hsla?|oklch|oklab|lab|lch|hwb|color-mix)\([^)]*(?:\([^)]*\))?[^)]*\)|#[0-9a-f]{3,8}(?-u:\b)")
.expect("COLOR_TOKEN_RE")
@@ -252,6 +277,7 @@ pub fn build_static_style_map(
) {
let mut specified: SpecifiedStore<NodeId> = SpecifiedStore::new();
let mut hover_specified: SpecifiedStore<NodeId> = SpecifiedStore::new();
let mut placeholder_specified: SpecifiedStore<NodeId> = SpecifiedStore::new();
let root_custom_props = collect_css_custom_props(css_text);
let rules = profile::step(
profile,
@@ -264,7 +290,12 @@ pub fn build_static_style_map(
Meta::new("selector-match", "css-selectors", file_path),
|| {
for rule in &rules {
if !rule.is_hover {
let placeholder_host = if rule.is_hover {
None
} else {
placeholder_host_selector(&rule.selector)
};
if !rule.is_hover && placeholder_host.is_none() {
if let Some(pm) = PSEUDO_RULE_RE.captures(&rule.selector) {
let base = pm.get(1).map(|m| m.as_str()).unwrap_or("").to_string();
mark_pseudo_rule(doc, rule, &base, &root_custom_props);
@@ -273,6 +304,8 @@ pub fn build_static_style_map(
}
let match_selector: Option<&str> = if rule.is_hover {
rule.match_selector.as_deref()
} else if let Some(ref host) = placeholder_host {
Some(host.as_str())
} else {
Some(rule.selector.as_str())
};
@@ -296,11 +329,18 @@ pub fn build_static_style_map(
};
let store = if rule.is_hover {
&mut hover_specified
} else if placeholder_host.is_some() {
&mut placeholder_specified
} else {
&mut specified
};
for node in matched {
for decl in &rule.declarations {
if placeholder_host.is_some()
&& js::to_lower_case(&decl.prop) != "color"
{
continue;
}
let meta = DeclMeta {
important: decl.important,
specificity: rule.specificity,
@@ -341,7 +381,7 @@ pub fn build_static_style_map(
profile,
Meta::new("cascade", "compute-styles", file_path),
|| {
compute_styles(doc, &specified, &hover_specified);
compute_styles(doc, &specified, &hover_specified, &placeholder_specified);
},
);
}
@@ -353,6 +393,7 @@ fn compute_styles(
doc: &mut StaticDocument,
specified: &SpecifiedStore<NodeId>,
hover_specified: &SpecifiedStore<NodeId>,
placeholder_specified: &SpecifiedStore<NodeId>,
) {
let mut computed: HashMap<NodeId, Rc<StyleValues>> = HashMap::new();
let mut customs: HashMap<NodeId, Rc<CustomProps>> = HashMap::new();
@@ -368,6 +409,7 @@ fn compute_styles(
.map(|e| (e.id(), None))
.collect();
let mut hover_out: Vec<(NodeId, StyleValues)> = Vec::new();
let mut placeholder_out: Vec<(NodeId, StyleValues)> = Vec::new();
while let Some((node, parent)) = stack.pop() {
let parent_style: Option<Rc<StyleValues>> = parent.and_then(|p| computed.get(&p).cloned());
@@ -439,6 +481,21 @@ fn compute_styles(
}
}
if let Some(ph_map) = placeholder_specified.get(&node) {
if let Some(color_decl) = ph_map.get("color") {
let next = normalize_static_css_value(
"color",
&color_decl.value,
&custom_props,
Some(&values),
Some(&values),
);
let mut ph_style = StyleValues::default();
ph_style.insert("color".to_string(), next);
placeholder_out.push((node, ph_style));
}
}
let style_rc = Rc::new(values);
computed.insert(node, style_rc);
customs.insert(node, Rc::new(custom_props));
@@ -460,6 +517,9 @@ fn compute_styles(
for (node, style) in hover_out {
doc.set_hover_style(node, style);
}
for (node, style) in placeholder_out {
doc.set_placeholder_style(node, style);
}
}
/// `STATIC_DEFAULT_STYLE[prop]` lookup re-exported for the adapters.
+8
View File
@@ -55,6 +55,7 @@ pub struct StaticDocument {
pub html: Html,
styles: HashMap<NodeId, StyleValues>,
hover_styles: HashMap<NodeId, StyleValues>,
placeholder_styles: HashMap<NodeId, StyleValues>,
accent_dash: HashSet<NodeId>,
pseudo_surface: HashMap<NodeId, Rgba>,
selector_cache: RefCell<HashMap<String, Result<Selector, SelectorError>>>,
@@ -174,6 +175,7 @@ impl StaticDocument {
html,
styles: HashMap::new(),
hover_styles: HashMap::new(),
placeholder_styles: HashMap::new(),
accent_dash: HashSet::new(),
pseudo_surface: HashMap::new(),
selector_cache: RefCell::new(HashMap::new()),
@@ -319,6 +321,12 @@ impl StaticDocument {
pub fn get_hover_style(&self, node: NodeId) -> Option<&StyleValues> {
self.hover_styles.get(&node)
}
pub fn set_placeholder_style(&mut self, node: NodeId, style: StyleValues) {
self.placeholder_styles.insert(node, style);
}
pub fn get_placeholder_style(&self, node: NodeId) -> Option<&StyleValues> {
self.placeholder_styles.get(&node)
}
pub fn set_accent_dash_pseudo(&mut self, node: NodeId) {
self.accent_dash.insert(node);
}
+36 -3
View File
@@ -28,7 +28,8 @@ use crate::profile::{self, Meta, ProfileSink};
use crate::quality::{check_element_quality, check_page_quality_from_doc, pf0};
use impeccable_core::checks::html_patterns::{check_html_patterns, HtmlPatternCorpora};
use impeccable_core::checks::rules::RuleHit;
use impeccable_core::findings::{try_finding, Finding};
use impeccable_core::findings::{stamp_ignored_by, try_finding, Finding};
use impeccable_core::selector_ignores::{waiving_selector, SelectorIgnore};
use impeccable_core::inline_ignores::apply_inline_ignores;
use impeccable_core::page::is_full_page;
use once_cell::sync::Lazy;
@@ -80,6 +81,13 @@ pub struct DetectHtmlOptions<'a> {
/// Sink for the JS `process.stderr.write` notices (unreadable linked
/// stylesheets); `None` drops them.
pub warn: Option<&'a dyn Fn(&str)>,
/// The project's component-level opt-outs (`detector.ignoreSelectors`),
/// already narrowed to the entries whose `files` globs cover this file.
/// An element finding whose element (or an ancestor of it) matches one of
/// these selectors is stamped `ignoredBy: "<selector>"` rather than
/// dropped, so the config layer can count what it suppresses. Empty by
/// default, which is the behavior every existing caller gets.
pub ignore_selectors: &'a [SelectorIgnore],
/// A rule pack's static-document hook: rules over the parsed page.
pub static_rule_pack: Option<&'static dyn StaticRulePack>,
/// The same pack's engine-wide text hook. An HTML file gets **one** pack
@@ -228,8 +236,14 @@ pub fn detect_html_source(
if scoped_ignore_active(el, &h.id) {
continue;
}
// A component-level opt-out waives the same way the attribute
// does (self or ancestor), but the finding is stamped rather
// than dropped so the config layer can count it.
let waived = waiving_selector(options.ignore_selectors, &h.id, |sel| {
el.closest(sel).is_some()
});
if let Some(f) = mk(&h.id, &h.snippet) {
findings.push(f);
findings.push(stamp_ignored_by(f, waived));
}
}
}
@@ -316,6 +330,7 @@ pub fn detect_html_source(
},
);
for f in pattern_hits {
let mut pattern_waived: Option<String> = None;
if let Some(selector) = f.selector.as_deref() {
let stripped = PSEUDO_STRIP_RE.replace_all(selector, "");
let stripped = impeccable_core::js::trim(&stripped);
@@ -329,6 +344,24 @@ pub fn detect_html_source(
{
continue;
}
// A selector-backed pattern finding is waived by config
// only when every element it names is covered, the same
// all-or-nothing rule the attribute pass above applies.
if !matches.is_empty() {
let mut covering: Option<&str> = None;
for el in &matches {
match waiving_selector(options.ignore_selectors, &f.id, |sel| {
el.closest(sel).is_some()
}) {
Some(sel) => covering = covering.or(Some(sel)),
None => {
covering = None;
break;
}
}
}
pattern_waived = covering.map(str::to_string);
}
}
}
if let Some(mut item) = mk(&f.id, &f.snippet) {
@@ -336,7 +369,7 @@ pub fn detect_html_source(
item.severity = sev.clone();
}
impeccable_core::findings::derive_advisory_flag(&mut item);
findings.push(item);
findings.push(stamp_ignored_by(item, pattern_waived.as_deref()));
}
}
+1
View File
@@ -80,6 +80,7 @@ impl HtmlEngine for StaticHtmlEngine {
warn: Some(&warn),
static_rule_pack: self.static_rule_pack,
rule_pack: options.rule_pack,
ignore_selectors: &options.ignore_selectors,
};
detect_html(Path::new(path), &html_options).map_err(|e| {
EngineError::new(match e {
+122
View File
@@ -0,0 +1,122 @@
//! Integration tests for `::placeholder` contrast detection (#790).
use impeccable_html::{detect_html_source, DetectHtmlOptions};
use std::path::Path;
const ISSUER_REPRO: &str = r#"<!DOCTYPE html>
<html><head><style>
input::placeholder { color: #bbbbbb; }
input { background: white; font-size: 16px; width: 200px; height: 40px; border: 1px solid #ccc; padding: 8px; box-sizing: border-box; }
</style></head>
<body><input placeholder="Search"></body></html>
"#;
fn scan(html: &str) -> Vec<impeccable_core::findings::Finding> {
detect_html_source(html, Path::new("/tmp/placeholder.html"), &DetectHtmlOptions::default())
}
fn repo_root() -> std::path::PathBuf {
std::env::var("IMPECCABLE_PUBLIC_REPO")
.map(std::path::PathBuf::from)
.unwrap_or_else(|_| Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."))
}
#[test]
fn issuer_repro_flags_pale_placeholder() {
let findings = scan(ISSUER_REPRO);
assert!(
findings.iter().any(|f| f.antipattern == "low-contrast"),
"expected low-contrast finding, got {findings:?}"
);
}
#[test]
fn bare_placeholder_selector_flags() {
let html = r#"<!DOCTYPE html>
<html><head><style>
::placeholder { color: #bbbbbb; }
input { background: white; font-size: 16px; width: 200px; height: 40px; }
</style></head>
<body><input placeholder="Search"></body></html>
"#;
let findings = scan(html);
assert!(
findings.iter().any(|f| f.antipattern == "low-contrast"),
"expected low-contrast for bare ::placeholder, got {findings:?}"
);
}
#[test]
fn descendant_placeholder_selector_flags() {
let html = r#"<!DOCTYPE html>
<html><head><style>
.form ::placeholder { color: #bbbbbb; }
input { background: white; font-size: 16px; width: 200px; height: 40px; }
</style></head>
<body><div class="form"><input placeholder="Search"></div></body></html>
"#;
let findings = scan(html);
assert!(
findings.iter().any(|f| f.antipattern == "low-contrast"),
"expected low-contrast for descendant ::placeholder, got {findings:?}"
);
}
#[test]
fn sibling_placeholder_selector_flags() {
let html = r#"<!DOCTYPE html>
<html><head><style>
.label + ::placeholder { color: #bbbbbb; }
input { background: white; font-size: 16px; width: 200px; height: 40px; }
</style></head>
<body><label class="label">Name</label><input placeholder="Search"></body></html>
"#;
let findings = scan(html);
assert!(
findings.iter().any(|f| f.antipattern == "low-contrast"),
"expected low-contrast for sibling ::placeholder, got {findings:?}"
);
}
#[test]
fn fixture_flag_and_pass_cases() {
let fixture = repo_root().join("tests/fixtures/antipatterns/placeholder-contrast.html");
assert!(
fixture.is_file(),
"missing fixture at {}",
fixture.display()
);
let html = std::fs::read_to_string(&fixture).unwrap();
let findings = detect_html_source(&html, &fixture, &DetectHtmlOptions::default());
let ids: Vec<&str> = findings.iter().map(|f| f.antipattern.as_str()).collect();
let snippets: Vec<&str> = findings.iter().map(|f| f.snippet.as_str()).collect();
for needle in [
"Pale Placeholder On White Field",
"Pale Placeholder On White Textarea",
"Translucent Placeholder On Light Field",
"Pale Placeholder On Frosted Panel",
] {
assert!(
snippets.iter().any(|s| s.contains(needle)),
"expected flag for placeholder {needle:?}, findings={findings:?}"
);
}
for needle in [
"Ink Placeholder On White Field",
"Light Placeholder On Dark Field",
"Filled Field Hides Placeholder",
"Unstyled Placeholder Uses UA Color",
] {
assert!(
!snippets.iter().any(|s| s.contains(needle)),
"pass case {needle:?} should not flag, findings={findings:?}"
);
}
assert!(
ids.iter().filter(|id| **id == "low-contrast").count() >= 4,
"expected at least four low-contrast hits, got {findings:?}"
);
}
+115
View File
@@ -0,0 +1,115 @@
//! Component-level opt-outs in the static engine: one `{ rule, selector }`
//! entry stands in for the same `data-impeccable-ignore` attribute repeated
//! on every instance of a component, and the waived findings come back
//! stamped so a caller can count them.
use impeccable_core::findings::{ignored_by, Finding};
use impeccable_core::selector_ignores::SelectorIgnore;
use impeccable_html::{detect_html_source, DetectHtmlOptions};
use std::path::Path;
/// Three instances of one 10px mono label, the shape that earned eleven
/// attributes on impeccable-site #34.
const PAGE: &str = r#"<!doctype html>
<html><head><style>
.ks-tag { font-family: ui-monospace, monospace; font-size: 10px; }
.free-label { font-size: 10px; }
body { font-family: system-ui; font-size: 16px; }
</style></head>
<body>
<h1>Worlds</h1>
<p>Body copy long enough to read like a real paragraph on a real page somewhere.</p>
<span class="ks-tag">01 - Explore directions</span>
<span class="ks-tag">02 - See one built</span>
<span class="ks-tag">03 - Third label</span>
<span class="free-label">04 - Not part of the component</span>
</body></html>
"#;
fn scan(html: &str, entries: &[SelectorIgnore]) -> Vec<Finding> {
let opts = DetectHtmlOptions {
ignore_selectors: entries,
..DetectHtmlOptions::default()
};
detect_html_source(html, Path::new("/nonexistent/dir/page.html"), &opts)
}
fn undersized(findings: &[Finding]) -> Vec<&Finding> {
findings
.iter()
.filter(|f| f.antipattern == "undersized-ui-text")
.collect()
}
#[test]
fn one_entry_covers_every_instance_of_the_component() {
let before = scan(PAGE, &[]);
let hits = undersized(&before);
assert_eq!(hits.len(), 4, "fixture should flag all four labels");
assert!(hits.iter().all(|f| ignored_by(f).is_none()));
let after = scan(
PAGE,
&[SelectorIgnore::new("undersized-ui-text", ".ks-tag")],
);
let hits = undersized(&after);
assert_eq!(hits.len(), 4, "waived findings are stamped, not dropped");
let waived: Vec<&&Finding> = hits
.iter()
.filter(|f| ignored_by(f) == Some(".ks-tag"))
.collect();
assert_eq!(waived.len(), 3, "the three component instances are waived");
// The label outside the component is untouched, and so is every other rule.
let free: Vec<&&Finding> = hits.iter().filter(|f| ignored_by(f).is_none()).collect();
assert_eq!(free.len(), 1);
assert!(free[0].snippet.contains("Not part of the component"));
assert!(after
.iter()
.filter(|f| f.antipattern != "undersized-ui-text")
.all(|f| ignored_by(f).is_none()));
}
#[test]
fn an_entry_for_another_rule_waives_nothing() {
let after = scan(PAGE, &[SelectorIgnore::new("side-tab", ".ks-tag")]);
assert!(undersized(&after).iter().all(|f| ignored_by(f).is_none()));
}
#[test]
fn a_star_entry_waives_every_rule_on_the_component() {
let after = scan(PAGE, &[SelectorIgnore::new("*", ".ks-tag")]);
let waived = undersized(&after)
.iter()
.filter(|f| ignored_by(f).is_some())
.count();
assert_eq!(waived, 3);
}
#[test]
fn the_entry_covers_the_components_subtree() {
let html = r#"<!doctype html><html><head><style>
.ks-tag { font-size: 10px; }
</style></head><body><h1>Title</h1>
<p>Body copy long enough to read like a real paragraph on a real page somewhere.</p>
<span class="ks-tag">outer <b>inner label text</b></span>
</body></html>"#;
let after = scan(html, &[SelectorIgnore::new("undersized-ui-text", ".ks-tag")]);
assert!(
undersized(&after)
.iter()
.all(|f| ignored_by(f) == Some(".ks-tag")),
"a finding on a descendant is waived with its component"
);
}
#[test]
fn the_per_instance_attribute_still_works() {
let html = PAGE.replace(
r#"<span class="ks-tag">01"#,
r#"<span class="ks-tag" data-impeccable-ignore="undersized-ui-text">01"#,
);
// Attribute-waived findings never reach the caller at all, which is the
// behavior that shipped; the config entry is the countable alternative.
let after = scan(&html, &[]);
assert_eq!(undersized(&after).len(), 3);
}
File diff suppressed because one or more lines are too long
+55 -1
View File
@@ -596,6 +596,18 @@ fn write_carbonize_banner(event: &Map<String, Value>, io: &mut Io) {
}
}
fn reply_ack_json(reply: &Reply) -> Value {
let mut m = Map::new();
m.insert("ok".into(), json!(true));
m.insert("id".into(), json!(reply.id));
m.insert("status".into(), json!(reply.ty));
if let Some(f) = &reply.file {
m.insert("file".into(), json!(f));
}
m.insert("_instructions".into(), json!("Poll again now."));
Value::Object(m)
}
/// JS: printPollEvent(event) — a wire-supplied `_instructions` must never
/// win over the locally generated one (#488).
fn print_poll_event(event: &mut Value, io: &mut Io) {
@@ -718,7 +730,13 @@ pub fn run(args: &[String], io: &mut Io) -> i32 {
}
};
return match post_reply(&base, &token, &reply) {
Ok(()) => 0,
Ok(()) => {
println(
io,
&serde_json::to_string(&reply_ack_json(&reply)).unwrap_or_default(),
);
0
}
Err(PollError::ConnRefused) => {
io.err(&format!(
"Live server not running. Start one with: {}\n",
@@ -839,4 +857,40 @@ mod tests {
}));
assert!(parsed.get("_instructions").is_none(), "{}", parsed);
}
#[test]
fn reply_ack_json_includes_file_when_present() {
let reply = Reply {
id: "ab12cd34".into(),
ty: "done".into(),
message: None,
file: Some("index.html".into()),
data: None,
source_event_type: None,
};
let parsed = reply_ack_json(&reply);
assert_eq!(parsed["ok"], json!(true));
assert_eq!(parsed["id"], json!("ab12cd34"));
assert_eq!(parsed["status"], json!("done"));
assert_eq!(parsed["file"], json!("index.html"));
assert_eq!(parsed["_instructions"], json!("Poll again now."));
}
#[test]
fn reply_ack_json_omits_file_when_absent() {
let reply = Reply {
id: "ab12cd34".into(),
ty: "steer_done".into(),
message: None,
file: None,
data: None,
source_event_type: None,
};
let parsed = reply_ack_json(&reply);
assert_eq!(parsed["ok"], json!(true));
assert_eq!(parsed["id"], json!("ab12cd34"));
assert_eq!(parsed["status"], json!("steer_done"));
assert!(parsed.get("file").is_none(), "{}", parsed);
assert_eq!(parsed["_instructions"], json!("Poll again now."));
}
}
+72
View File
@@ -418,6 +418,16 @@ fn push_diag(next: &mut Map<String, Value>, d: Value) {
next.insert("diagnostics".to_string(), Value::Array(arr));
}
fn drop_diag(next: &mut Map<String, Value>, error: &str) {
let mut arr = next
.get("diagnostics")
.and_then(|v| v.as_array())
.cloned()
.unwrap_or_default();
arr.retain(|d| d.get("error").and_then(|e| e.as_str()) != Some(error));
next.insert("diagnostics".to_string(), Value::Array(arr));
}
/// JS: applyEvent(snapshot, entry)
pub fn apply_event(snapshot: &Map<String, Value>, entry: &Value) -> Map<String, Value> {
let event: Map<String, Value> = match entry.get("event") {
@@ -864,6 +874,7 @@ pub fn apply_event(snapshot: &Map<String, Value>, entry: &Value) -> Map<String,
set!("phase", json!("discarded"));
set!("pendingEventSeq", Value::Null);
set!("pendingEvent", Value::Null);
drop_diag(&mut next, "carbonize_cleanup_required");
}
"complete" => {
set!("phase", json!("completed"));
@@ -876,6 +887,7 @@ pub fn apply_event(snapshot: &Map<String, Value>, entry: &Value) -> Map<String,
set_if!("previewMode", ev("previewMode"));
set!("pendingEventSeq", Value::Null);
set!("pendingEvent", Value::Null);
drop_diag(&mut next, "carbonize_cleanup_required");
}
"agent_error" => {
if canceled && ev("sourceEventType").and_then(|v| v.as_str()) == Some("generate") {
@@ -925,3 +937,63 @@ fn write_snapshot(path: &str, snapshot: &Map<String, Value>, journal_bytes: i64,
pub fn get_str<'a>(m: &'a Map<String, Value>, k: &str) -> Option<&'a str> {
get(m, k).and_then(|v| v.as_str())
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn journal_entry(seq: i64, event: Value) -> Value {
json!({ "seq": seq, "ts": "2026-01-01T00:00:00.000Z", "event": event })
}
fn has_diag(snapshot: &Map<String, Value>, error: &str) -> bool {
snapshot
.get("diagnostics")
.and_then(|v| v.as_array())
.map(|a| {
a.iter()
.any(|d| d.get("error").and_then(|e| e.as_str()) == Some(error))
})
.unwrap_or(false)
}
fn replay(id: &str, events: &[Value]) -> Map<String, Value> {
let mut snap = base_snapshot(id);
for entry in events {
snap = apply_event(&snap, entry);
}
snap
}
fn accept_carbonize_done(id: &str, terminal: &str) -> Map<String, Value> {
replay(
id,
&[
journal_entry(
1,
json!({ "id": id, "type": "accept", "variantId": 2 }),
),
journal_entry(
2,
json!({ "id": id, "type": "agent_done", "carbonize": true, "file": "index.html" }),
),
journal_entry(3, json!({ "id": id, "type": terminal })),
],
)
}
#[test]
fn complete_drops_carbonize_cleanup_required() {
let snap = accept_carbonize_done("ab12cd34", "complete");
assert_eq!(snap.get("phase").and_then(|p| p.as_str()), Some("completed"));
assert!(!has_diag(&snap, "carbonize_cleanup_required"));
}
#[test]
fn discarded_drops_carbonize_cleanup_required() {
let snap = accept_carbonize_done("ab12cd34", "discarded");
assert_eq!(snap.get("phase").and_then(|p| p.as_str()), Some("discarded"));
assert!(!has_diag(&snap, "carbonize_cleanup_required"));
}
}
+28 -1
View File
@@ -7,13 +7,22 @@
//! ```json
//! {
//! "inlineIgnores": true,
//! "designSystem": { "frontmatter": { ... }, "sidecar": { ... } }
//! "designSystem": { "frontmatter": { ... }, "sidecar": { ... } },
//! "ignoreSelectors": [{ "rule": "undersized-ui-text", "selector": ".ks-tag" }]
//! }
//! ```
//!
//! - `inlineIgnores` (default `true`): apply the `impeccable-disable` waivers
//! found in the source, exactly as the CLI does. `false` reports waived
//! findings too.
//! - `ignoreSelectors`: the project's component-level opt-outs, the
//! `detector.ignoreSelectors` entries whose `files` globs cover this file
//! (the host narrows them; the engine matches selectors only). A finding on
//! an element the selector matches, or on a descendant of one, comes back
//! carrying `ignoredBy: "<selector>"` instead of being dropped, so a host
//! can report how many hits an author's opt-out silenced. Applies to the
//! HTML engine, where elements exist; the text engine has no DOM to match
//! against and ignores the key.
//! - `designSystem`: the DESIGN.md inputs, not a pre-normalized object (the
//! JS API's `options.designSystem` carried `Set`s and `Map`s, which JSON
//! cannot). `frontmatter` is the parsed DESIGN.md frontmatter, `sidecar`
@@ -42,6 +51,7 @@ use std::path::Path;
use std::sync::OnceLock;
use impeccable_detect::design_system::{normalize_design_system, DesignSystem};
use impeccable_core::selector_ignores::SelectorIgnore;
use impeccable_detect::detect_text::{detect_text, TextOptions};
use impeccable_html::{detect_html_source, DesignSystemHook, DetectHtmlOptions, StaticRulePack};
use serde_json::Value;
@@ -65,6 +75,7 @@ pub fn installed_static_rule_pack() -> Option<&'static dyn StaticRulePack> {
struct Options {
inline_ignores: bool,
design_system: Option<DesignSystem>,
ignore_selectors: Vec<SelectorIgnore>,
}
fn parse_options(options_json: &str) -> Options {
@@ -87,9 +98,24 @@ fn parse_options(options_json: &str) -> Options {
false,
))
});
let ignore_selectors = parsed
.get("ignoreSelectors")
.and_then(Value::as_array)
.map(|list| {
list.iter()
.filter_map(|e| {
let rule = e.get("rule").and_then(Value::as_str)?;
let selector = e.get("selector").and_then(Value::as_str)?;
let entry = SelectorIgnore::new(rule, selector);
entry.is_valid().then_some(entry)
})
.collect()
})
.unwrap_or_default();
Options {
inline_ignores,
design_system,
ignore_selectors,
}
}
@@ -142,6 +168,7 @@ pub fn detect_html_source_json(html: &str, file_path: &str, options_json: &str)
warn: None,
static_rule_pack: installed_static_rule_pack(),
rule_pack: crate::installed_rule_pack(),
ignore_selectors: &options.ignore_selectors,
},
);
findings_json(&findings)
@@ -188,6 +188,12 @@ Test thoroughly across contexts:
- **Edge cases**: Very small screens (320px), very large screens (4K)
- **Slow connections**: Test on throttled network
**Custom controls** (sliders, drag surfaces, scrollable control strips): a before/after slider can pass every width check above and still refuse to drag on iOS, so exercise each one in scope in the same batched round as the checks above:
- **Primary gesture**: Tap it and confirm it responds as designed, then drag it with the target input method; the drag must complete, not just start
- **Scroll across it**: A swipe along the page's scroll axis across the control scrolls the page or container without activating it; a drag that starts on the control along its axis moves the control, not the page. Neither failure throws an error, so try both
- **Evidence**: Say what produced the evidence: an emulated viewport, synthesized touch input through a browser tool, which engine ran it (Chromium is not Safari), or a physical device. Screenshots and resized viewports verify layout, never a gesture. Name what stayed untested and move on; unreachable hardware is a reported gap, not a blocker
When the adaptation feels native to each context, hand off to `/impeccable polish` for the final pass.
---
@@ -48,11 +48,12 @@ Run comprehensive checks across 5 dimensions. Score each dimension 0-4 using the
**Check for**:
- **Fixed widths**: Hard-coded widths that break on mobile
- **Touch targets**: Interactive elements < 44x44px
- **Broken touch interaction**: Custom sliders, drag surfaces, and scrollable control strips whose primary gesture fails under touch, that swallow page scroll or lose the drag to it, or that stay stuck after an interrupted gesture. Code tells: mouse-only handlers, no `touch-action` on a pointer-event drag surface, drag state that nothing clears on cancel, lost capture, or blur. Exercise the gesture when a browser tool can synthesize touch (a rendered viewport proves layout, not the gesture), then say what produced the evidence (emulated viewport, synthesized touch, which engine, physical device) and what stayed untested
- **Horizontal scroll**: Content overflow on narrow viewports
- **Text scaling**: Layouts that break when text size increases
- **Missing breakpoints**: No mobile/tablet variants
**Score 0-4**: 0=Desktop-only (breaks on mobile), 1=Major issues (some breakpoints, many failures), 2=Partial (works on mobile, rough edges), 3=Good (responsive, minor touch target or overflow issues), 4=Excellent (fluid, all viewports, proper touch targets)
**Score 0-4**: 0=Desktop-only (breaks on mobile), 1=Major issues (some breakpoints, many failures), 2=Partial (works on mobile, rough edges), 3=Good (responsive, minor touch target or overflow issues), 4=Excellent (fluid, all viewports, proper touch targets, gestures work under touch)
### 5. Implementation Integrity (CRITICAL)
@@ -205,6 +205,11 @@ t('items', { count }) // Handles complex plural rules
- Optimistic updates with rollback
- Conflict resolution
**Interrupted gestures** (custom sliders, drag surfaces, scrollable control strips):
- A second finger or pointer lands mid-drag: the first drag keeps its pointer or ends cleanly, never jumps to the new one
- The browser cancels the gesture to scroll (`pointercancel`), capture is lost (`lostpointercapture`), the pointer is released outside the control, or the window loses focus (`blur`) mid-drag: clear the dragging state and release capture
- After each of these, the next tap or drag works without a reload
**Permission states**:
- No permission to view
- No permission to edit
@@ -304,6 +309,7 @@ const throttledScroll = throttle(handleScroll, 100);
- Unit tests for edge cases
- Integration tests for error scenarios
- E2E tests for critical paths
- A behavioral regression for each confirmed gesture fix, when the project's test runner can drive input
- Visual regression tests
- Accessibility tests (axe, WAVE)
@@ -330,7 +336,10 @@ Test thoroughly with edge cases:
- **Network issues**: Disable internet, throttle connection
- **Large datasets**: Test with 1000+ items
- **Concurrent actions**: Click submit 10 times rapidly
- **Interrupted gestures**: Add a second finger mid-drag, scroll across the control, release outside it, switch windows mid-drag; then drag again
- **Errors**: Force API errors, test all error states
- **Empty**: Remove all data, test empty states
For gestures, say what produced the evidence (emulated viewport, synthesized touch, which engine, physical device) and name what stayed untested.
When edge cases are covered, hand off to `/impeccable polish` for the final pass.
+13 -7
View File
@@ -184,7 +184,7 @@ Examples:
Then `files = walkDir(resolved).filter(f => !shouldIgnoreDetectionFile(f, cwd, config))`. If `files.length > 50 && stdin.isTTY && !json && !quiet`: `stderr> \nFound ${n} files (${htmlCount} HTML) in ${target}.\nScanning may take a while${htmlCount > 10 ? ' (static HTML/CSS processes each HTML file individually)' : ''}.\nTarget a specific subdirectory to narrow scope.\n` then readline prompt `Continue? [Y/n] ` on stderr; empty or `/^y(es)?$/i` continues; otherwise `stderr> Aborted.\n`, `exit 0`. Then `buildImportGraph(files)` → reverse map; each file scanned with its own options; findings from a file that is imported get `f.importedBy = [basename(importer), ...]` (Set iteration order).
- **File**: skipped if `shouldIgnoreDetectionFile`; else `detectLocalFile`.
- `detectLocalFile(fp, opts)`: extension (lowercased) in `HTML_EXTENSIONS = {'.html','.htm'}``detectHtml(fp, opts)`; else `detectText(readFileSync(fp,'utf-8'), fp, opts)`.
4. Post-filter: `filterDetectionFindings(all, config)` (ignoreRules/ignoreValues), then `filterByScopes(all, scopes)` (keeps findings whose rule declares any requested scope; empty scopes = no filter), then `--no-advisory` drop.
4. Post-filter: `filterDetectionFindings(all, config)` (component ignores, then ignoreRules/ignoreValues), then `filterByScopes(all, scopes)` (keeps findings whose rule declares any requested scope; empty scopes = no filter), then `--no-advisory` drop.
5. Partition `{primary, advisory}` by `f.advisory === true || f.severity === 'advisory'`.
Any target that cannot be scanned sets `hadOperationalFailure` (#711): a URL
@@ -201,7 +201,8 @@ target.
- quiet: `stderr> ${primary.length} anti-pattern${n===1?'':'s'} found.\n`; if advisory: `stderr> dim(`${adv} advisory note${adv===1?'':'s'} (not counted).`) + '\n'`.
- text: `stderr> formatFindings(all,false) + '\n'`.
- `exit(hadOperationalFailure ? 1 : (primary.length > 0 ? 2 : 0))`.
- no findings: json → `stdout> []\n`; text/quiet → nothing. `exit(hadOperationalFailure ? 1 : 0)`.
- **Component-ignore tally** (`detector.ignoreSelectors`, see the config section): when entries waived anything, one `dim` line per `{rule, selector}` goes to **stderr in every mode**, so `--json` stdout stays the findings array a consumer parses: `stderr> ${count} ${rule} hit${count===1?'':'s'} ignored by detector.ignoreSelectors on ${selector}.\n`. In text mode it follows the findings after a blank line; in quiet mode it precedes the advisory note; with no findings at all it is still printed. Nothing is emitted when the project has no such entry or the entries matched nothing, so unchanged projects see unchanged output.
- no findings: json → `stdout> []\n`; text/quiet → nothing but the tally above. `exit(hadOperationalFailure ? 1 : 0)`.
- Exit 1 takes precedence over exit 2: findings from the targets that did scan
do not turn a partial scan into a complete one (#711).
- Any other exit: `1` for arg errors above; uncaught exceptions propagate to `cli.js` catch (`exit 1`).
@@ -244,7 +245,7 @@ Example (non-TTY):
{ antipattern: id, name: ap.name, description: ap.description, severity: ap.severity || 'warning', category: ap.category || null, file: filePath, line, snippet }
// plus, only when the effective severity is 'advisory': advisory: true
```
Optional keys added later by engines (appended after the above): `ignoreValue` (design-system rules; browser findings with a value), `importedBy` (dir scans), `severity` may be overwritten by per-finding promotion (browser & html-patterns, e.g. pulsing dot in a header). Design-system findings are `{...finding(...), ...extras}` where extras = `{ ignoreValue }`. Static-HTML and browser findings have `line: 0`; regex findings have 1-based lines. `severity` values in registry: `'warning'` (default), `'advisory'` (many generated-UI tells and design-system-color/radius/font-size, numbered-section-labels, blinking-cursor, shape-assembled-illustration), `'error'` (`script-error`, `content-hidden-at-rest`). `severity` is the canonical advisory field (#709): `deriveAdvisoryFlag` stamps `advisory: true` when and only when the effective severity is `'advisory'`, so a per-finding promotion or demotion carries the flag with it, and every `severity:'advisory'` rule is partitioned out of the failure count and the exit code. `isAdvisory` accepts either `finding.advisory === true` or `finding.severity === 'advisory'`.
Optional keys added later by engines (appended after the above): `ignoreValue` (design-system rules; browser findings with a value), `ignoredBy` (the `detector.ignoreSelectors` selector that waived the finding; the CLI drops and counts these, so they appear only in engine-level output such as the wasm exports), `importedBy` (dir scans), `severity` may be overwritten by per-finding promotion (browser & html-patterns, e.g. pulsing dot in a header). Design-system findings are `{...finding(...), ...extras}` where extras = `{ ignoreValue }`. Static-HTML and browser findings have `line: 0`; regex findings have 1-based lines. `severity` values in registry: `'warning'` (default), `'advisory'` (many generated-UI tells and design-system-color/radius/font-size, numbered-section-labels, blinking-cursor, shape-assembled-illustration), `'error'` (`script-error`, `content-hidden-at-rest`). `severity` is the canonical advisory field (#709): `deriveAdvisoryFlag` stamps `advisory: true` when and only when the effective severity is `'advisory'`, so a per-finding promotion or demotion carries the flag with it, and every `severity:'advisory'` rule is partitioned out of the failure count and the exit code. `isAdvisory` accepts either `finding.advisory === true` or `finding.severity === 'advisory'`.
**Categories**: `category` is `'slop'` (AI tells) or `'quality'`. Category has **no effect on output**, ordering, or exit codes; it is only carried in the finding and used by `getRulesForCategory`. Registry (59 ids, in order): side-tab, border-accent-on-rounded, overused-font, flat-type-hierarchy, gradient-text, ai-color-palette, cream-palette, nested-cards, monotonous-spacing, bounce-easing, pulsing-dot, blinking-cursor, shape-assembled-illustration, dark-glow, radial-halo, radial-spotlight-glow, marquee, icon-tile-stack, italic-serif-display, hero-eyebrow-chip, kicker-above-heading, numbered-section-labels, em-dash-overuse, marketing-buzzword, aphoristic-cadence, oversized-h1, extreme-negative-tracking, broken-image, script-error, content-hidden-at-rest, edge-flush-cards, text-occlusion, first-viewport-column-overflow, gray-on-color, low-contrast, layout-transition, line-length, cramped-padding, body-text-viewport-edge, tight-leading, skipped-heading, heading-rhythm, justified-text, tiny-text, undersized-ui-text, all-caps-body, wide-tracking, text-overflow, repeated-container-text, clipped-overflow-container, design-system-font, design-system-color, design-system-radius, design-system-font-size, gpt-thin-border-wide-shadow, repeating-stripes-gradient, codex-grid-background, theater-slop-phrase, image-hover-transform. Scopes: `type` = overused-font, flat-type-hierarchy, italic-serif-display, hero-eyebrow-chip, kicker-above-heading, numbered-section-labels, oversized-h1, extreme-negative-tracking, line-length, tight-leading, skipped-heading, heading-rhythm, justified-text, tiny-text, undersized-ui-text, all-caps-body, wide-tracking, design-system-font, design-system-font-size; `layout` = nested-cards, monotonous-spacing, icon-tile-stack, content-hidden-at-rest, edge-flush-cards, text-occlusion, first-viewport-column-overflow, line-length, cramped-padding, body-text-viewport-edge, heading-rhythm, text-overflow, clipped-overflow-container. `RULE_ENGINE_SUPPORT = { regex: Set['source','page-analyzer'], 'static-html': Set['element','page'], browser: Set['element','page','layout'], visual: Set['visual-contrast'] }`.
@@ -269,7 +270,8 @@ Optional keys added later by engines (appended after the above): `ignoreValue` (
- Applied inside `detectText` and `detectHtml` at the end unless `options.inlineIgnores === false` (set by `--no-config` or `--no-inline-ignores`). Not applied to URL scans.
- Fast path: skip unless `/impeccable-disable/i` occurs.
- **DOM-scoped ignore** (`rules/checks.mjs scopedIgnoreActive`): attribute `data-impeccable-ignore="rule-a rule-b"` (split on `/[\s,]+/`, lowercased; empty value or `*` = all) on an element waives matching findings for it and its subtree in browser, extension, and static engines. In `detectHtml`'s html-patterns pass, selector-backed findings are dropped when every element matched by the (pseudo-stripped) selector is under a waiver; unmatched selectors keep the finding.
- Tests: `tests/inline-ignores.test.mjs`; fixture `scoped-ignore.html`.
- **Component-scoped ignore** (`detector.ignoreSelectors`): the declared twin of that attribute, for a component whose instances would otherwise each carry one. An entry `{ rule, selector }` waives `rule` for every element matching `selector` and for that element's subtree (`element.closest(selector) !== null`), in the browser engine (`BrowserConfig.ignoreSelectors`, also readable from `window.__IMPECCABLE_CONFIG__`) and the static HTML engine (`DetectHtmlOptions.ignore_selectors`). Rule `*` covers every rule. The engines **stamp** rather than drop: a waived finding comes back carrying `ignoredBy: "<selector>"` (`BrowserFinding.ignoredBy`, serialized on the group finding; `Finding.ignoredBy` in the extras), and the config layer drops it and counts it, so the suppression is a number rather than silence. The text engine has no DOM and never stamps.
- Tests: `tests/inline-ignores.test.mjs`; fixture `scoped-ignore.html`. Component ignores: `crates/html/tests/selector_ignores.rs`, `crates/core/src/browser/driver.rs` tests, oracle `detect-selector-ignore-*`.
#### Config file (`cli/lib/impeccable-config.mjs`) — `.impeccable/config.json` + `.impeccable/config.local.json`
@@ -278,24 +280,27 @@ Optional keys added later by engines (appended after the above): `ignoreValue` (
```json
{ "detector": { "ignoreRules": ["side-tab"], "ignoreFiles": ["src/legacy/**"],
"ignoreValues": [{ "rule": "overused-font", "value": "inter", "files": ["src/a.css"], "createdAt": "ISO", "reason": "..." }],
"ignoreSelectors": [{ "rule": "undersized-ui-text", "selector": ".ks-tag", "files": ["src/a.astro"], "createdAt": "ISO", "reason": "..." }],
"designSystem": { "enabled": true }, "advisoryRules": "include"|"exclude" },
"hook": { "consent": "accepted"|"declined", ... }, "updateCheck": true }
```
- `readDetectionConfig(root)`: start `{ignoreRules:[],ignoreFiles:[],ignoreValues:[],designSystem:{enabled:true}}`; for shared then local: apply legacy `raw.hook.*` section then `raw.detector.*`. Arrays are unioned (`uniqueStrings`, String-coerced); ignoreValues merged by key `rule\0value\0sortedFiles.join('\x1f')` (later wins); `designSystem.enabled` false only when literally `false`; `advisoryRules` copied only if `'include'|'exclude'`. Invalid JSON / non-object files are ignored silently. **No validation errors are ever raised by the CLI**; the only validation of ignore lists lives in `skill/scripts/lib/staleness-deep.mjs checkDetectorIgnores` (doctor): unknown `ignoreRules` ids vs live `ANTIPATTERNS` → finding `detector-ignore-rules-unknown` (severity `mention`); non-glob `ignoreFiles` entries that don't exist → `detector-ignore-files-missing`.
- `normalizeIgnoreValue(v)`: trim, strip one leading/trailing quote, `+`→space, collapse whitespace, lowercase. Rules lowercased/trimmed.
- `normalizeIgnoreValueEntries`: keeps `{rule, value, [files], [createdAt], [reason]}` in **that key order**; `file` (string) and `files` merged, trimmed, deduped.
- `ignoreSelectors` (component ignores): merged by key `rule\0selector\0sortedFiles.join('\x1f')` (later wins), normalized to `{rule, selector, [files], [createdAt], [reason]}` in **that key order**. The rule is lowercased/trimmed like every rule id; the **selector keeps its case** (CSS class names are case-sensitive) and only loses surrounding whitespace. An entry missing either half is dropped. `selectorIgnoresForTarget(config, target)` narrows the list per local scan target: an entry without `files` covers every target, one with `files` covers the paths its globs match (raw path then each `/`-suffix). A URL scan uses `selectorIgnoresForUrl(config)`, the unscoped entries only: a `files` glob names repo paths, so it must not reach a URL that happens to end the same way. `--no-config` leaves the list empty. The key is **written only by a project that uses it**, so an existing config does not grow an empty `ignoreSelectors` on the next `ignores add-rule`. `doctor`'s `detector-ignore-rules-unknown` validates the `rule` of each entry alongside `ignoreRules`.
- Glob → regex: `**``.*` (swallowing a following `/`), `*``[^/]*`, `?``[^/]`, `{a,b}``(?:a|b)`, regex specials escaped; anchored `^...$`. `matchesAnyGlob` tests the `/`-normalized path and its basename.
- `shouldIgnoreDetectionFile(filePath, root, config)`: raw path, absolute path, and root-relative path (if inside root) tested against `ignoreFiles`.
- `filterDetectionFindings`: drop when `ignoreRules` has the rule, or an `ignoreValues` entry matches: same rule; entry.value `*` (wildcard) OR extracted value equals (with color-key equality for `design-system-color`: rgb/hex/hsl parsed to `r,g,b,round(a*255)`); if entry has `files`, `finding.file` (or any `/`-suffix of it) must glob-match; a wildcard with no files never matches (unscoped `*` disallowed).
- `filterDetectionFindings`: first drop every finding the engines stamped with `ignoredBy` (the component ignores above), counted by `{rule, selector, count}` in first-seen order; `filterDetectionFindingsReported` returns that tally beside the kept findings. Then drop when `ignoreRules` has the rule, or an `ignoreValues` entry matches: same rule; entry.value `*` (wildcard) OR extracted value equals (with color-key equality for `design-system-color`: rgb/hex/hsl parsed to `r,g,b,round(a*255)`); if entry has `files`, `finding.file` (or any `/`-suffix of it) must glob-match; a wildcard with no files never matches (unscoped `*` disallowed).
- `extractFindingIgnoreValue`: only for `overused-font, bounce-easing, design-system-font, design-system-color, design-system-radius, design-system-font-size`; source `finding.ignoreValue || finding.value`, else parse `detail`/`snippet`: bounce → `animate-bounce`, `cubic-bezier(...)`, or animation token matching `/bounce|elastic|wobble|jiggle|spring/i`; fonts → `Primary font:`, `Google Fonts:`, `font-family:` value, or `family=` URL param (decoded).
#### `impeccable ignores` (`cli/bin/commands/ignores.mjs`)
- Actions/aliases: `status|ls|list`→list (default when no action), `add-rule|ignore-rule`, `add-file|ignore-file`, `add-value|ignore-value|update-value`, `remove-rule|rm-rule`, `remove-file|rm-file`, `remove-value|rm-value`, `clear`. `--help`/`-h` prints usage (stdout). Unknown → throws `Unknown ignores action: ${a}. Run "impeccable ignores --help".` (exit 1 via cli.js).
- Actions/aliases: `status|ls|list`→list (default when no action), `add-rule|ignore-rule`, `add-file|ignore-file`, `add-value|ignore-value|update-value`, `add-selector|ignore-selector|update-selector`, `remove-rule|rm-rule`, `remove-file|rm-file`, `remove-value|rm-value`, `remove-selector|rm-selector`, `clear`. `--help`/`-h` prints usage (stdout). Unknown → throws `Unknown ignores action: ${a}. Run "impeccable ignores --help".` (exit 1 via cli.js).
- Scope flags: `--shared` (default), `--local`, `--all` (remove/clear only); more than one → error `Pass only one scope flag: --shared, --local, or --all` (or `--shared or --local`).
- `add-rule <rule> [--all-values] [--reason ...]`: `overused-font` without `--all-values` → error "overused-font is value-specific by default. Use add-value overused-font <font>, or add-rule overused-font --all-values for broad suppression." Output: `Added ${rule} to ${local?'local':'shared'} detector ignoreRules (${relpath}).`
- `add-file <glob>``Added ${glob} to ... detector ignoreFiles (...)`.
- `add-value <rule> <value...> [--file <glob>]... [--reason <text...>]`: value = normalized join of positionals after rule; `--file`/`--files`/`--file=`/`--files=` (empty or flag-like → error); unknown `--x``Unknown add-value flag: --x`; `*` value requires `--file`; existing entry (same key) updates reason/files, else pushes `{rule,value,[files],createdAt: ISO now,[reason]}`. Output `Added ${rule}=${value} to ... detector ignoreValues (...)`.
- `add-selector <rule> <selector...> [--file <glob>]... [--reason <text...>]`: the selector is the positionals after the rule joined with a space and trimmed, case preserved (`.card .ks-tag` is one descendant selector). Missing rule or selector → `Pass a rule id and a CSS selector, e.g. impeccable ignores add-selector undersized-ui-text ".ks-tag"`; selector `*``A \`*\` selector waives the rule everywhere. Use add-rule for that, or name the component's selector.`; unknown `--x` → `Unknown add-selector flag: --x`. An existing entry (same rule + selector + files) updates reason/files, else pushes `{rule,selector,[files],createdAt: ISO now,[reason]}`. Output `Added ${rule} on ${selector} to ... detector ignoreSelectors (...)`.
- `remove-*``Removed ${n} from shared (path), ${n} from local (path).` or `No matching detector ignore found.` `clear``Cleared detector ignores in ${'shared and local config'|'local config'|'shared config'}.`
- `list` output:
```
@@ -307,6 +312,7 @@ Optional keys added later by engines (appended after the above): `ignoreValue` (
ignoreRules: (none)
ignoreFiles: ...
ignoreValues: rule=value [glob1, glob2] - reason, ...
ignoreSelectors: rule on selector [glob1, glob2] - reason, ... (omitted when empty)
designSystem: enabled|disabled
Shared:
@@ -1740,7 +1746,7 @@ Conventions: every script's "run directly" guard is `process.argv[1]` ending wit
#### `live-poll.mjs` -> `impeccable poll`
- Invoked from live.md poll loop; `--reply` forms quoted in `_instructions` (see instructions.mjs strings in 6.3/below).
- Args: `--stream`, `--timeout=MS` (one-shot total, default 600000), `--types=A,B`, `--ack-timeout=MS` (stream, default 600000), `--reply <id> <status> [--file PATH] [--data JSON] [message]`, `--help`. `--reply` errors (stderr, exit 1): `Usage: node "<abs>/live-poll.mjs" --reply <id> <status> [--file path] [--data '<json>'] [message]` + `Missing event id after --reply.` / `The value after --reply must be the event id, not the status "done". Use --reply EVENT_ID done.` / `Missing reply status after event id "X".`; `--data must be valid JSON: <err>`.
- Args: `--stream`, `--timeout=MS` (one-shot total, default 600000), `--types=A,B`, `--ack-timeout=MS` (stream, default 600000), `--reply <id> <status> [--file PATH] [--data JSON] [message]`, `--help`. `--reply` success (stdout, exit 0): one compact JSON line `{ok:true,id,status,file? (only when --file was passed),_instructions:'Poll again now.'}`. `--reply` errors (stderr, exit 1): `Usage: node "<abs>/live-poll.mjs" --reply <id> <status> [--file path] [--data '<json>'] [message]` + `Missing event id after --reply.` / `The value after --reply must be the event id, not the status "done". Use --reply EVENT_ID done.` / `Missing reply status after event id "X".`; `--data must be valid JSON: <err>`.
- Needs `server.json`; else stderr `No running live server found. Start one with: node "<abs>/live.mjs"` exit 1.
- One-shot: loops `GET /poll?token&timeout=<slice ≤270000>&leaseMs=600000[&types]` until an event or total deadline; prints one JSON line (`console.log(JSON.stringify(event))`) with `_instructions` added by `instructionsForEvent` (unless already present). For `accept`/`discard`: spawns `node live-accept.mjs --id ID (--discard | --variant N) [--page-url U] [--param-values JSON]` (30 s), sets `event._acceptResult` (parse failure/throw → `{handled:false, mode:'error', error}`), then POSTs completion `{id, type: completionType, sourceEventType: event.type, message: _acceptResult.error, file: _acceptResult.file, data: {carbonize:true}?}` where completionType = discard: `discarded` if handled else `error`; accept: `agent_done` if handled&carbonize, `complete` if handled, `error` if mode error or (svelte-component unhandled), else `agent_done`; sets `event._completionAck = {ok:true, type}` (+ `final:false, requiresComplete:true, nextCommand:'live-complete.mjs --id <id>', message:'Carbonize cleanup must be verified, then the session must be completed explicitly before polling again.'` for carbonize) or `{ok:false, error}`. Stderr banners: manual_edit_apply → 4-line banner starting `Manual Apply action required: edit source, then reply with \`live-poll.mjs --reply <id> done --data '<json>'\`.`; carbonize → `⚠ Carbonize cleanup REQUIRED before next poll. After cleanup, run live-complete.mjs --id <id>. See reference/live.md "Required after accept".`
- Stream: stderr `[impeccable-poll] stream mode: one JSON object per line on stdout; use --reply while this process stays running`; after each reply-needing event waits (poll `/status` every 400 ms) until the id leaves `pendingEvents` (else `Timed out waiting for --reply on event <id>` exit 1); returns on `exit`.
+10 -2
View File
@@ -204,11 +204,19 @@ over the file-scanning engines, JSON in and JSON out:
- `detect_text_json(content, file_path, options_json)`
- `detect_html_source_json(html, file_path, options_json)`
Both take `{ inlineIgnores?: boolean, designSystem?: { frontmatter?, sidecar? } }`
Both take
`{ inlineIgnores?: boolean, designSystem?: { frontmatter?, sidecar? }, ignoreSelectors?: [{ rule, selector }] }`
and return the findings array `impeccable detect --json` prints, same keys and
same order. `designSystem` carries the DESIGN.md inputs rather than a
normalized object, because the JS API's normalized form used `Set`s and
`Map`s that JSON cannot hold. Unparseable options fall back to the defaults.
`Map`s that JSON cannot hold. `ignoreSelectors` is the project's
component-level opt-out (`detector.ignoreSelectors`), already narrowed by the
host to the entries whose `files` globs cover this file: a finding on an
element the selector matches, or on a descendant of one, comes back carrying
`ignoredBy: "<selector>"` rather than being dropped, so the host can count
what an author's opt-out silenced and say so. Only the HTML engine matches
selectors; the text engine has no DOM and ignores the key. Unparseable
options fall back to the defaults.
`antipatterns_json()` lists the built-ins followed by any pack's rows, and
`immediate_tier_rules_json()` returns the design hook's immediate tier (the
rule ids worth fixing at the edit site). That list lives in
@@ -188,6 +188,12 @@ Test thoroughly across contexts:
- **Edge cases**: Very small screens (320px), very large screens (4K)
- **Slow connections**: Test on throttled network
**Custom controls** (sliders, drag surfaces, scrollable control strips): a before/after slider can pass every width check above and still refuse to drag on iOS, so exercise each one in scope in the same batched round as the checks above:
- **Primary gesture**: Tap it and confirm it responds as designed, then drag it with the target input method; the drag must complete, not just start
- **Scroll across it**: A swipe along the page's scroll axis across the control scrolls the page or container without activating it; a drag that starts on the control along its axis moves the control, not the page. Neither failure throws an error, so try both
- **Evidence**: Say what produced the evidence: an emulated viewport, synthesized touch input through a browser tool, which engine ran it (Chromium is not Safari), or a physical device. Screenshots and resized viewports verify layout, never a gesture. Name what stayed untested and move on; unreachable hardware is a reported gap, not a blocker
When the adaptation feels native to each context, hand off to `/impeccable polish` for the final pass.
---
+2 -1
View File
@@ -48,11 +48,12 @@ Run comprehensive checks across 5 dimensions. Score each dimension 0-4 using the
**Check for**:
- **Fixed widths**: Hard-coded widths that break on mobile
- **Touch targets**: Interactive elements < 44x44px
- **Broken touch interaction**: Custom sliders, drag surfaces, and scrollable control strips whose primary gesture fails under touch, that swallow page scroll or lose the drag to it, or that stay stuck after an interrupted gesture. Code tells: mouse-only handlers, no `touch-action` on a pointer-event drag surface, drag state that nothing clears on cancel, lost capture, or blur. Exercise the gesture when a browser tool can synthesize touch (a rendered viewport proves layout, not the gesture), then say what produced the evidence (emulated viewport, synthesized touch, which engine, physical device) and what stayed untested
- **Horizontal scroll**: Content overflow on narrow viewports
- **Text scaling**: Layouts that break when text size increases
- **Missing breakpoints**: No mobile/tablet variants
**Score 0-4**: 0=Desktop-only (breaks on mobile), 1=Major issues (some breakpoints, many failures), 2=Partial (works on mobile, rough edges), 3=Good (responsive, minor touch target or overflow issues), 4=Excellent (fluid, all viewports, proper touch targets)
**Score 0-4**: 0=Desktop-only (breaks on mobile), 1=Major issues (some breakpoints, many failures), 2=Partial (works on mobile, rough edges), 3=Good (responsive, minor touch target or overflow issues), 4=Excellent (fluid, all viewports, proper touch targets, gestures work under touch)
### 5. Implementation Integrity (CRITICAL)
@@ -205,6 +205,11 @@ t('items', { count }) // Handles complex plural rules
- Optimistic updates with rollback
- Conflict resolution
**Interrupted gestures** (custom sliders, drag surfaces, scrollable control strips):
- A second finger or pointer lands mid-drag: the first drag keeps its pointer or ends cleanly, never jumps to the new one
- The browser cancels the gesture to scroll (`pointercancel`), capture is lost (`lostpointercapture`), the pointer is released outside the control, or the window loses focus (`blur`) mid-drag: clear the dragging state and release capture
- After each of these, the next tap or drag works without a reload
**Permission states**:
- No permission to view
- No permission to edit
@@ -304,6 +309,7 @@ const throttledScroll = throttle(handleScroll, 100);
- Unit tests for edge cases
- Integration tests for error scenarios
- E2E tests for critical paths
- A behavioral regression for each confirmed gesture fix, when the project's test runner can drive input
- Visual regression tests
- Accessibility tests (axe, WAVE)
@@ -330,7 +336,10 @@ Test thoroughly with edge cases:
- **Network issues**: Disable internet, throttle connection
- **Large datasets**: Test with 1000+ items
- **Concurrent actions**: Click submit 10 times rapidly
- **Interrupted gestures**: Add a second finger mid-drag, scroll across the control, release outside it, switch windows mid-drag; then drag again
- **Errors**: Force API errors, test all error states
- **Empty**: Remove all data, test empty states
For gestures, say what produced the evidence (emulated viewport, synthesized touch, which engine, physical device) and name what stayed untested.
When edge cases are covered, hand off to `/impeccable polish` for the final pass.
+6
View File
@@ -188,6 +188,12 @@ Test thoroughly across contexts:
- **Edge cases**: Very small screens (320px), very large screens (4K)
- **Slow connections**: Test on throttled network
**Custom controls** (sliders, drag surfaces, scrollable control strips): a before/after slider can pass every width check above and still refuse to drag on iOS, so exercise each one in scope in the same batched round as the checks above:
- **Primary gesture**: Tap it and confirm it responds as designed, then drag it with the target input method; the drag must complete, not just start
- **Scroll across it**: A swipe along the page's scroll axis across the control scrolls the page or container without activating it; a drag that starts on the control along its axis moves the control, not the page. Neither failure throws an error, so try both
- **Evidence**: Say what produced the evidence: an emulated viewport, synthesized touch input through a browser tool, which engine ran it (Chromium is not Safari), or a physical device. Screenshots and resized viewports verify layout, never a gesture. Name what stayed untested and move on; unreachable hardware is a reported gap, not a blocker
When the adaptation feels native to each context, hand off to `{{command_prefix}}impeccable polish` for the final pass.
---
+2 -1
View File
@@ -48,11 +48,12 @@ Run comprehensive checks across 5 dimensions. Score each dimension 0-4 using the
**Check for**:
- **Fixed widths**: Hard-coded widths that break on mobile
- **Touch targets**: Interactive elements < 44x44px
- **Broken touch interaction**: Custom sliders, drag surfaces, and scrollable control strips whose primary gesture fails under touch, that swallow page scroll or lose the drag to it, or that stay stuck after an interrupted gesture. Code tells: mouse-only handlers, no `touch-action` on a pointer-event drag surface, drag state that nothing clears on cancel, lost capture, or blur. Exercise the gesture when a browser tool can synthesize touch (a rendered viewport proves layout, not the gesture), then say what produced the evidence (emulated viewport, synthesized touch, which engine, physical device) and what stayed untested
- **Horizontal scroll**: Content overflow on narrow viewports
- **Text scaling**: Layouts that break when text size increases
- **Missing breakpoints**: No mobile/tablet variants
**Score 0-4**: 0=Desktop-only (breaks on mobile), 1=Major issues (some breakpoints, many failures), 2=Partial (works on mobile, rough edges), 3=Good (responsive, minor touch target or overflow issues), 4=Excellent (fluid, all viewports, proper touch targets)
**Score 0-4**: 0=Desktop-only (breaks on mobile), 1=Major issues (some breakpoints, many failures), 2=Partial (works on mobile, rough edges), 3=Good (responsive, minor touch target or overflow issues), 4=Excellent (fluid, all viewports, proper touch targets, gestures work under touch)
### 5. Implementation Integrity (CRITICAL)
+9
View File
@@ -205,6 +205,11 @@ t('items', { count }) // Handles complex plural rules
- Optimistic updates with rollback
- Conflict resolution
**Interrupted gestures** (custom sliders, drag surfaces, scrollable control strips):
- A second finger or pointer lands mid-drag: the first drag keeps its pointer or ends cleanly, never jumps to the new one
- The browser cancels the gesture to scroll (`pointercancel`), capture is lost (`lostpointercapture`), the pointer is released outside the control, or the window loses focus (`blur`) mid-drag: clear the dragging state and release capture
- After each of these, the next tap or drag works without a reload
**Permission states**:
- No permission to view
- No permission to edit
@@ -304,6 +309,7 @@ const throttledScroll = throttle(handleScroll, 100);
- Unit tests for edge cases
- Integration tests for error scenarios
- E2E tests for critical paths
- A behavioral regression for each confirmed gesture fix, when the project's test runner can drive input
- Visual regression tests
- Accessibility tests (axe, WAVE)
@@ -330,7 +336,10 @@ Test thoroughly with edge cases:
- **Network issues**: Disable internet, throttle connection
- **Large datasets**: Test with 1000+ items
- **Concurrent actions**: Click submit 10 times rapidly
- **Interrupted gestures**: Add a second finger mid-drag, scroll across the control, release outside it, switch windows mid-drag; then drag again
- **Errors**: Force API errors, test all error states
- **Empty**: Remove all data, test empty states
For gestures, say what produced the evidence (emulated viewport, synthesized touch, which engine, physical device) and name what stayed untested.
When edge cases are covered, hand off to `{{command_prefix}}impeccable polish` for the final pass.
+9 -1
View File
@@ -12,7 +12,7 @@ This command toggles the hook **per project** by editing `.impeccable/config.jso
Declare server-side template extensions under **`detector.extensions`** when the project uses Blade, Twig, ERB, or Handlebars files; the hook skips them otherwise because they sit outside the built-in extension list. One entry per extension, `{ "ext": ".blade.php", "engine": "html" }`. `engine` picks the analyzer (`html` for markup templates, `text` for JS/TS/CSS-like files) and defaults to `html`. Match against the end of the filename, so double extensions like `.blade.php` and `.html.erb` work. Config only adds extensions; the built-in list always applies.
Manual `npx impeccable detect` scans use the same project filter config by default: `detector.ignoreRules`, `detector.ignoreFiles`, `detector.ignoreValues`, and `detector.designSystem.enabled`. `hook.enabled` only controls automatic hook execution, not manual CLI scans. Use `npx impeccable detect --no-config ...` for a raw detector run that ignores project config/context. Use `npx impeccable ignores ...` for direct CLI CRUD on the same detector ignores.
Manual `npx impeccable detect` scans use the same project filter config by default: `detector.ignoreRules`, `detector.ignoreFiles`, `detector.ignoreValues`, `detector.ignoreSelectors`, and `detector.designSystem.enabled`. `hook.enabled` only controls automatic hook execution, not manual CLI scans. Use `npx impeccable detect --no-config ...` for a raw detector run that ignores project config/context. Use `npx impeccable ignores ...` for direct CLI CRUD on the same detector ignores.
Supported harnesses: Claude Code (`.claude/settings.local.json` in the project, which is gitignored so the hook stays machine-local; a hook you move into the shared `settings.json` is honored in place too), Codex (`.codex/hooks.json` in the project), Cursor (`.cursor/hooks.json` in the project), Grok Build (`.grok/hooks/impeccable.json` in the project; requires `/hooks-trust` or `--trust`), and GitHub Copilot (`.github/hooks/impeccable.json` in the project, a team-shared committed file that both the Copilot CLI and the cloud agent read). For the Copilot CLI, repo-level hooks fire once `.github/hooks/impeccable.json` is committed to the repository's default branch.
@@ -63,9 +63,11 @@ Prefer the narrowest exception:
- If the finding line shows an `ignore-value <rule> <value>` pair, pass it to `impeccable hooks ignore-value` with your `--reason`. This writes shared `.impeccable/config.json` by default.
- For value-specific findings such as `overused-font` and `bounce-easing`, use `ignore-value` for the specific value. Do not use `ignore-rule overused-font` for a specific font.
- If the finding has no value-specific command, such as `side-tab`, scope that one rule to the file: `ignore-value <id> "*" --file <path>`. Run `npx impeccable detect <path>` first to see what actually fires there.
- If the same rule fires on every instance of one component, waive the component once instead of per instance: `npx impeccable ignores add-selector <rule> "<css-selector>" --reason "..."`. It writes `detector.ignoreSelectors` in the same `.impeccable/config.json`, waives that rule for every element the selector matches and for its subtree, and every scan then reports how many hits it suppressed, so the exception stays visible. Eleven copies of the same 10px label want one entry here, not eleven `data-impeccable-ignore` attributes. This is a component-wide suppression: ask the user first, as you would for `ignore-file`. There is no `hooks ignore-selector`; use the `ignores` command.
- Reach for `ignore-file <path>` only when the whole file is out of scope for design review: a fixture, a generated artifact, a deliberate slop demo. It silences every rule for that file permanently, including rules that have not been written yet. A real UI surface with one noisy rule wants the file-scoped value ignore above.
- Use `ignore-rule <id>` only when the user asks to suppress that whole rule across the project. For broad overused-font suppression, use `ignore-rule overused-font --all-values` only when the user asks to ignore overused fonts generally.
- Prefer config ignores (the commands above) by default; they keep suppressions in one reviewable place. Reach for an inline comment only when the waiver must travel with a single file that leaves the repo (a generated/exported standalone document, an emailed HTML file). The supported marker is `impeccable-disable <rule>` (whole file) or `impeccable-disable-line` / `impeccable-disable-next-line` (one line), in any comment syntax, with an optional reason after `:` or `--`. The detector honors it by default; `--no-inline-ignores` or `--no-config` bypasses it.
- The DOM equivalent, `data-impeccable-ignore="<rule>"` on an element, waives that element and its subtree. It belongs on a one-off: a single demo block, one deliberately ugly sample. Repeating it across every instance of a component is the sign you wanted `ignores add-selector` instead: the attribute hides the count from whoever reviews the change, the config entry reports it.
Example value-specific exception:
@@ -92,6 +94,12 @@ for everything else:
{{scripts_path}}/impeccable hooks ignore-value design-system-font-size "*" --file "src/overlay/widget.js" --reason "Injected widget builds its own type scale; DESIGN.md's ramp describes the site"
```
Example component exception, for one rule across every instance of a component:
```bash
npx impeccable ignores add-selector undersized-ui-text ".ks-tag" --reason "User confirmed: 10px mono index labels, decorative counters beside the heading"
```
Example whole-file exception, for a file that is out of scope entirely:
```bash
+92
View File
@@ -0,0 +1,92 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Placeholder Contrast — Should Flag vs Should Pass</title>
<style>
body { font-family: system-ui, sans-serif; background: #fafafa; padding: 24px; margin: 0; color: #1a1a1a; }
.grid { display: grid; grid-template-columns: 1fr 1fr; gap: 32px; max-width: 1200px; margin: 0 auto; }
.col h2 { font-size: 14px; text-transform: uppercase; letter-spacing: 0.05em; margin: 0 0 16px; color: #475569; }
.col h3 { font-size: 11px; text-transform: uppercase; letter-spacing: 0.05em; margin: 24px 0 8px; color: #64748b; }
.field {
width: 280px;
height: 40px;
padding: 8px 12px;
font-size: 16px;
border: 1px solid #cbd5e1;
border-radius: 6px;
box-sizing: border-box;
display: block;
margin-bottom: 8px;
}
.field-white { background: #ffffff; }
.field-light { background: #f5f5f5; }
.field-dark { background: #1a1a1a; border-color: #333; }
.flag-pale-white::placeholder { color: #bbbbbb; }
.flag-pale-textarea::placeholder { color: #bbbbbb; }
.flag-translucent-light::placeholder { color: rgba(255, 255, 255, 0.4); }
.dark-wrap { background: #0f0f11; padding: 20px; width: 320px; }
.frosted-panel {
background: rgba(255, 255, 255, 0.15);
padding: 12px;
}
.frosted-panel .field {
background: rgba(255, 255, 255, 0.2);
border-color: rgba(255, 255, 255, 0.25);
}
.frosted-panel .field::placeholder { color: #bbbbbb; }
.pass-ink-white::placeholder { color: #1a1a1a; }
.pass-light-dark::placeholder { color: #e8e8e8; }
.pass-filled-pale::placeholder { color: #bbbbbb; }
.pass-unstyled { /* no ::placeholder rule — UA color only */ }
</style>
</head>
<body>
<div class="grid">
<div class="col" data-col="flag">
<h2>Should flag</h2>
<h3>Pale placeholder on white input</h3>
<input class="field field-white flag-pale-white" type="text" placeholder="Pale Placeholder On White Field">
<h3>Pale placeholder on white textarea</h3>
<textarea class="field field-white flag-pale-textarea" rows="2" placeholder="Pale Placeholder On White Textarea"></textarea>
<h3>Translucent placeholder on light field</h3>
<input class="field field-light flag-translucent-light" type="text" placeholder="Translucent Placeholder On Light Field">
<h3>Pale placeholder on frosted panel</h3>
<div class="dark-wrap">
<div class="frosted-panel">
<input class="field" type="text" placeholder="Pale Placeholder On Frosted Panel">
</div>
</div>
</div>
<div class="col" data-col="pass">
<h2>Should pass</h2>
<h3>Ink placeholder on white field</h3>
<input class="field field-white pass-ink-white" type="text" placeholder="Ink Placeholder On White Field">
<h3>Light placeholder on dark field</h3>
<input class="field field-dark pass-light-dark" type="text" placeholder="Light Placeholder On Dark Field">
<h3>Input with no placeholder attribute</h3>
<input class="field field-white" type="text" value="">
<h3>Filled field hides placeholder</h3>
<input class="field field-white pass-filled-pale" type="text" value="Already filled" placeholder="Filled Field Hides Placeholder">
<h3>Unstyled placeholder uses UA color</h3>
<input class="field field-white pass-unstyled" type="text" placeholder="Unstyled Placeholder Uses UA Color">
<h3>Empty placeholder attribute</h3>
<input class="field field-white flag-pale-white" type="text" placeholder="">
</div>
</div>
</body>
</html>

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