StaticElement.closest() handed the raw selector string to css-select's
is() on every ancestor step, recompiling the same selector N times for
an element N levels deep. StaticDocument now caches one compiled
matcher per selector (failed compiles cached as rethrowers so bad
selectors still return null). Findings are byte-identical across the
fixture corpus; scan time drops to ~62% on the fixtures and ~7x faster
on deep-DOM pages.
Prepared with AI assistance (Cursor agent), directed by @abdulwahabone.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Simplify local detector dispatch
Centralize HTML-versus-text file routing for stdin, directory, and direct-file scans. Add CLI characterization coverage for both stdin paths.
Prepared with AI assistance under maintainer pbakaus's standing scheduled-refactor authorization.
* Strengthen detector dispatch characterization
Put the HTML-only finding in a linked stylesheet so the text engine cannot satisfy the static-engine assertion.
Prepared with AI assistance under maintainer pbakaus's standing scheduled-refactor authorization.
* Stop assuming white when a background cannot be read
Dark themes came back from a scan buried in low-contrast findings that
all claimed the light text sat on #ffffff. Two live runs against
impeccable.style produced 102 and 95 of them.
Two causes, both fixed here.
Parsing. Browsers keep the authored color space in getComputedStyle
output: oklch() stayed oklch, but color-mix results come back as
color(srgb 1.04 0.72 -0.21), wide-gamut authors get color(display-p3
...), and lch()/lab() survive verbatim. The parser read none of those, so
those surfaces registered as unset. parseGradientColors was worse: it
matched only rgba() and #hex, so a ground painted as
linear-gradient(oklch(...), oklch(...)) counted as a gradient with no
stops at all.
Guessing. When the ancestor walk ran out of readable color it returned
white, and on a body-level gradient it returned white without even
looking. Light copy on a lacquer-black page then measured 1.3:1 against a
canvas the visitor never sees.
resolveBackgroundInfo now separates three outcomes: a resolved surface, a
gradient the caller should fall back to stops for, and an unreadable
layer. The last one makes both color adapters skip their contrast checks
entirely. White survives in exactly one case, the one that earns it:
every layer up to the document root was genuinely transparent.
Color conversions moved to cli/engine/shared/color.mjs and gained lab,
lch, and color() for srgb, srgb-linear, and display-p3. Spaces outside
that set return null, which now routes to abstention rather than to a
color nobody painted. Every conversion is pinned against what Chrome
itself paints for the same string.
Rescanning impeccable.style: 102 low-contrast findings down to 30, none
of them on an invented white ground.
Assisted-by: Claude Code
* fix: address PR review bot findings on background resolution
- Treat a url() image layer stacked above a gradient as an occluding,
unreadable surface: resolveBackgroundInfo now returns unresolved so the
gradient-stop fallback never measures stops the image hides
(greptile-apps finding, reproduced in Chrome).
- Route the glow and AI-palette DOM adapters through resolveBackgroundInfo
so an unresolved surface makes them abstain instead of hunting gradient
ancestors past an unreadable layer (Cursor Bugbot finding).
- Resolve background-color keywords jsdom hands through verbatim:
inherit now reads as no-paint (the ancestor walk IS its resolution) and
currentcolor substitutes the element's own computed text color instead
of forcing an abstention (Copilot finding).
- Regression coverage in the dark-theme fixture for all three, asserted in
both the jsdom and real-Chrome suites; browser detector regenerated.
AI-assisted: prepared with Claude Code at the maintainer's direction.
Co-Authored-By: Claude <noreply@anthropic.com>
* fix: keep zero-offset glow findings when the surface is unreadable
The browser glow adapter abstained from the whole element when
resolveBackgroundInfo reported an unreadable surface, which also dropped
zero-offset chromatic halo findings that do not depend on the background
at all. It now skips only the gradient hunt past the unreadable layer and
scores the halo tell against a null surface, matching what the static
loop already did. Fixture cases pin both sides: the halo over a url()
image ancestor flags in both engines, and an offset chromatic shadow on
the same unknown surface stays abstained.
Also hardens the currentcolor background substitution with the
parseColorResolved fallback used by the text-color path, and adds fixture
coverage proving tokenized currentcolor surfaces already resolve through
the static cascade (flag when knowable, abstain when the token is
undefined).
Addresses Cursor Bugbot review findings on PR #541.
AI-assisted-by: Claude Code
Co-Authored-By: Claude <noreply@anthropic.com>
* fix: abstain on translucent gradients over images, drop phantom color-mix stops
Two follow-up review findings on the merge with main.
A gradient leading a url() layer was treated as a resolvable surface even
when its stops are translucent, so the glow and AI-palette hunts averaged
wash stops (a 20% black wash reads as pure black) while the real surface
blends with image pixels the engine cannot read. resolveBackgroundInfo now
marks gradient-over-image unresolved unless every readable stop of the
leading gradient is opaque, in which case the gradient provably covers the
image and remains the scorable surface.
parseGradientColorsModern predated this branch's parseGradientColors
rewrite: its second regex pass re-extracted color tokens nested inside
color-mix() stops that the shared parser already captures whole via
balanced-paren tokens, appending ingredient colors that are never painted.
The worst-case stop ratio then invented low-contrast findings against a
color nobody sees. The helper is removed; all callers use the shared
parser, which covers the modern syntaxes it existed for.
Fixture coverage pins both: the translucent-wash-over-image glow abstains
in both engines, an opaque gradient over an image still flags in the
browser, and the color-mix wash case stays clean in the static engine.
Each new assertion was verified to fail against the previous engine.
Addresses Greptile and Cursor Bugbot review findings on PR #541.
AI-assisted-by: Claude Code
Co-Authored-By: Claude <noreply@anthropic.com>
---------
Co-authored-by: Claude <noreply@anthropic.com>
* Add data-impeccable-ignore scoped waivers; fix occlusion and image-backed contrast FPs
Three changes that let a page hosting deliberate anti-pattern exhibits
scan clean without losing coverage, prepared with AI assistance (Claude
Code) on maintainer instruction:
- data-impeccable-ignore="rule-a rule-b" (or "*" / bare) on any element
suppresses matching findings for its whole subtree, in the browser
overlay, the extension, and the static engine. The DOM twin of the
line-based impeccable-disable comments (which a live DOM cannot
apply) and the generalization of data-impeccable-allow-kickers.
Applied at the addBrowserFindings choke point, at the static element
walk, and for regex findings that carry a live selector.
- text-occlusion: an occluder whose effective opacity multiplies out to
~0 paints nothing. An opacity-0 range scrubber stretched over a
before/after comparison produced 16 "100% covered by an opaque
element" findings on one page because elementFromPoint returns it and
its UA background-color read as opaque paint. Invisible-at-rest
elements are also no longer probed as victims.
- Analytic contrast now skips what it cannot measure: a url() image
layer anywhere in the background stack ends the gradient-stops walk
(dark ink on a bright gold-leaf image measured 2.6:1 against the wash
composited over the wrong base), and elements that are invisible at
rest (visibility hidden, effective opacity ~0 — hidden scene decks)
are skipped by the color checks in both engines. The static cascade
now tracks opacity to support this.
Covered by a new scoped-ignore fixture (exact rule, star, comma list,
nested depth, wrong-rule control) tested in both engines, a scrubber
pass case in the occlusion fixture, and image-backed / photo-panel /
hidden-scene pass cases in the gradient-ground fixture. Full suite
passes; browser and extension bundles regenerated.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* CSS-scan findings carry their enclosing selector; browser pass resolves them
Page-level CSS-text findings (marquee, dark-glow, radial-halo,
repeating-stripes, codex-grid, ai-color-palette, image-hover-transform,
pseudo/inset side-tab stripes) now attach the selector of the rule that
matched, via a best-effort enclosingCssSelector() helper or the
selector already in scope. The browser pass resolves that selector
against the live DOM: pseudo segments are stripped, a selector that
renders nowhere on the page drops the finding (the CSS ships there but
the pattern never paints — the live DOM is ground truth in a browser
scan), and matches under a data-impeccable-ignore ancestor are waived.
Static scans are unchanged: partial documents keep the text-level
findings. Applied with AI assistance (Claude Code).
Covered in the scoped-ignore fixture: a live marquee under a marquee
waiver is suppressed, and dead two-axis grid CSS matching no element is
dropped.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Attribute selectors on gradient-text and bounce-easing page emitters too
Same mechanism as the previous commit, extended to the three page-level
motion/text emitters that were still selector-less. Applied with AI
assistance (Claude Code).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* text-overflow: skip SVG content; scrollWidth lies there
Chrome reports arbitrary non-zero scrollWidth/clientWidth on SVG
elements (a <text> gave 78/48 while its rendered length sat inside its
box), so the box-metric delta is noise. SVG clips to its own viewport
anyway. Pass case added to the quality fixture. Applied with AI
assistance (Claude Code).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Overlay samples image-backed text at the pixel level by default
Visual contrast gains a third mode. Explicit true still runs the full
sampled pass, explicit false still disables everything (the mode the
test suites use), and unset — the default overlay run — now samples
ONLY image-backed text: the one class the analytic walk deliberately
skips, because a url() layer's pixels are unknowable without looking.
The cost is bounded and the method is precise: at most a 3x3 grid of
sample points per candidate (degrading to 3 or 1 for small rects), the
source image drawn once to a canvas with only those pixels read, and
glyph ink never pollutes the samples because the image is drawn alone.
A cross-origin image without CORS headers reports unresolved rather
than guessing. Applied with AI assistance (Claude Code).
Covered by a new fixture: white text on a near-white same-origin
data-URI image background flags via sampled pixels under default
options; dark ink on the same image passes.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Review fixes: root opacity, keyframe steps, static parity, attributed fixtures
Applied with AI assistance (Claude Code), addressing all seven findings
from the automated reviews:
- effectiveOpacityDOM walks through body and html: a page-fade wrapper
with body/html opacity 0 hides every descendant (Greptile executed a
Chromium repro of the false positive).
- enclosingCssSelector refuses `from`/`to` keyframe steps, which read
as never-matching type selectors and got valid findings wrongly
dropped by the zero-match rule (Bugbot, high). Regression case: an
overshoot bezier inside a `to` step must survive as page-level.
- The static cascade now inherits visibility, so descendants of a
hidden container compute as hidden like the browser path; a declared
visibility:visible still overrides.
- The static engine applies scoped waivers to selector-backed
html-pattern findings, mirroring the browser — but keeps findings
whose selector matches nothing, since static scans see partial
documents.
- The scoped-ignore fixture grows to the mandated matrix: 4 flag cases
(control, other-rule waiver, sibling waiver, misspelled rule id) and
5 waived shapes (exact rule, nested depth, star, comma list, self),
each with a unique border width so every finding attributes to
exactly one case in both engines' tests.
- The image-backed contrast test pins its cases via the sampled
finding's candidate text: the white-on-light specimen must flag and
the dark-ink control must stay clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Review fixes: image-only starvation, selector rejection class, inset stripes
Second review round, applied with AI assistance (Claude Code):
- The image-only filter moves inside the candidate collector, before the
cap: gradient/opacity/filter candidates earlier in DOM order no longer
consume the 12-candidate budget and starve the url()-backed texts the
mode exists to sample (Bugbot, high). The regression fixture packs 14
gradient decoys ahead of the photo panels, and the test now drives the
overlay entry (impeccableDetectAsync, default options) rather than
detectUrl's Node-side full fallback, which is where the image-only
mode actually lives.
- enclosingCssSelector no longer rejects the child combinator or quoted
attribute selectors; only braces and angle brackets disqualify.
- The inset box-shadow side-tab scanner attaches its selector like the
pseudo-element scanner does, so those findings waive and dead-drop
the same way.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* Fix: measure gradient body grounds instead of assuming white (browser mode)
A page whose ground is set via background: linear-gradient(...) on body
leaves backgroundColor transparent, and resolveBackground assumed white
for any body/html-level gradient. In a real browser that assumption is
wrong: the shorthand is always decomposed there, so reaching that branch
means the ground truly is the gradient. On a dark oklch gradient ground
(impeccable.style's lacquer) this turned every light-on-dark text into a
~1.3:1 "on #ffffff" low-contrast finding, ~120 false positives on one
site. Browser mode now returns null so the caller measures against the
actual gradient stops; the white assumption stays for jsdom, where the
undecomposed-shorthand rationale still holds.
Gradient stops also now parse modern color syntax: computed
backgroundImage keeps oklch()/oklab()/hsl()/hwb() stops as authored, and
parseGradientColors only read rgb()/hex, so a token-driven gradient
ground was invisible even once the walk deferred to it. New
parseGradientColorsModern routes those stops through parseAnyColor.
Covered by a Puppeteer fixture (dark oklch body gradient): light text on
the ground must not flag, muted dark-gray ink must, proving the stops
are measured rather than the checks silently skipping.
Prepared with AI assistance (Claude Code), on maintainer instruction.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Composite translucent layers over gradient stops; parse modern glow stops
Review fixes from PR #557's automated reviews, applied with AI
assistance (Claude Code):
- Cursor Bugbot found the new browser-mode early return discarded the
translucent ancestors resolveBackground had collected: text on a
frosted wash over a body gradient was measured against raw stops.
resolveGradientStops now collects translucent layers during its own
walk (through readCascadeBackgroundColor, extracted so both walks
read surfaces identically) and composites every stop under them.
- Copilot flagged the other legacy parseGradientColors call sites. The
glow-context fallback now uses parseGradientColorsModern, since body
gradients reach it more often after this change. The AI-palette rule
and the injected analytic sampler stay on the legacy parser
deliberately: the former is a rule-behavior expansion deserving its
own fixtures, the latter degrades to pixel sampling or a skip.
- Greptile asked for standard fixture structure: the fixture now has
labeled flag/pass cases (3 flag, 5 pass) including the frosted-wash
pair that locks the overlay compositing in both directions and a
legacy hex-stop gradient guarding the original parser path.
The test scopes itself to the DOM path via visualContrast: false, the
suite's established pattern; the screenshot sampler is a separate
subsystem with its own coverage.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Pin gradient-ground flag cases to their snippet signatures
Bugbot follow-up: a count-only assertion let an offsetting miss and
false positive cancel, especially the frosted pair. Each flag case now
asserts its full text-on-background signature, so the frosted case must
measure against the composited wash and the count guard excludes any
pass case flagging in its place. Applied with AI assistance (Claude
Code).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Comments: the static path is the custom engine now, not jsdom
jsdom left the dependency tree when the static-html engine (StaticElement
+ css-cascade.mjs) replaced it, and that engine does decompose the
background shorthand, so the comments this PR added were dated in both
name and rationale. Only comments touched by this PR are renamed; the
~40 legacy jsdom mentions elsewhere in checks.mjs are a separate sweep.
Applied with AI assistance (Claude Code).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Static engine: measure body gradients too, dropping the white assumption
Follow-up to the browser-mode fix: the white assumption for body/html
gradients was a jsdom guard, and jsdom is gone. The static cascade
decomposes the background shorthand (expandStaticDeclaration) and
preserves var() colors for later resolution, so a missing solid under a
body gradient is now as real in static mode as in a browser — and the
static engine had the identical false-positive class (light text on a
dark gradient ground flagged "on #ffffff") while missing the muted-ink
true positives on the same page.
The old catastrophic case cannot recur: opaque stops fully cover any
hidden solid (they are the ground), alpha stops composite over the
resolved base or the white canvas default, and unresolvable stops drop
rather than guess.
Static twin of the browser test added over the same fixture; the full
suite, the url()-ancestor guard, and a source scan of impeccable.style
(0 low-contrast findings) all stay clean. Applied with AI assistance
(Claude Code).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
The value-capture regex stopped only at ;{}, so in single-line JSX
style objects it ran past the closing quote and swallowed later
properties, flagging layout props that were never transitioned. The
capture now stops at the matching closing quote when the value is a
quoted string, falling back to the old bounds for real CSS.
Prepared with AI assistance under maintainer direction.
Co-authored-by: Cursor <cursoragent@cursor.com>
Review finding on #553: an object-literal argument like
${getOffset({ size: 2 })} ended the interpolation match at the inner
closing brace, losing the shadow context. Interpolations now admit one
level of braces (with paired quotes inside); the shared subpattern is
hoisted into compiled constants. Deeper nesting stays fail-safe by
design: a line-scoped regex cannot balance arbitrary braces, and the
miss produces a waivable finding, never a leak.
AI-assisted (Cursor agent), reviewed by maintainer.
Co-authored-by: Cursor <cursoragent@cursor.com>
Review finding on #553: the interpolation subpattern excluded quotes,
so a documented shadow color after ${getShadow('lg')} or a quoted
ternary branch lost its context and fired as drift. Interpolations now
admit complete single/double-quoted strings; the quotes pair up inside
the ${...}, so an unpaired quote or the template's closing backtick
still ends the context and the allowance cannot leak to a later
property.
AI-assisted (Cursor agent), reviewed by maintainer.
Co-authored-by: Cursor <cursoragent@cursor.com>
Review finding on #553: the end-anchored shadow-context tails excluded
`}` (JS) and `{`/`}` (CSS), so a documented shadow color after a ${...}
interpolation in a boxShadow template literal or a CSS-in-JS
box-shadow line lost its allowance and fired as drift. Both tails now
admit complete ${...} interpolations; a bare `}`, quote, or `;` still
ends the context, so the allowance cannot leak past a template's
closing backtick into a later property.
AI-assisted (Cursor agent), reviewed by maintainer.
Co-authored-by: Cursor <cursoragent@cursor.com>
The detector never read the sidecar's extensions.shadows, and the only
workaround (a colors entry for black) allowlisted every black at every
alpha because colorKey() drops alpha. Shadow token colors now live in a
separate allowlist matched on alpha as well as r/g/b, and the allowance
applies only inside box-shadow / text-shadow values, so a documented
shadow black still fires as a page ground.
AI-assisted (Cursor agent), reviewed by maintainer.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Fix Blade directory detection
AI assistance: Codex reproduced the issue, implemented the fix, and ran the validation described in the pull request.
* Fix compound scan suffix matching
AI assistance: Codex addressed review findings and ran the validation described in the pull request.
* Fix: unescape YAML quote escapes in DESIGN.md frontmatter scalars (#428)
parseScalar() stripped a double-quoted scalar's outer quotes without
processing the backslash escapes inside, so a font stack that quotes a
multi-word family the CSS way, e.g.
fontFamily: "\"IBM Plex Sans\", system-ui, sans-serif"
reached allowedFonts as '\"ibm plex sans' and design-system-font flagged
fonts DESIGN.md declares. Also collapses the doubled-quote escape in
single-quoted scalars and keeps a lone quote literal instead of slicing
it to an empty string. Applied to both copies of the parser
(cli/engine/design-system.mjs and skill/scripts/lib/design-parser.mjs).
Co-authored-by: Cursor Agent (AI-assisted change, reviewed and directed
by a maintainer)
* Decode YAML hex and Unicode escapes in double-quoted scalars
Review follow-up: the escape scanner only handled the simple set, so
\xNN, \uNNNN, and \UNNNNNNNN sequences stayed encoded and an escaped
token like "\x23b8422e" never matched #b8422e in CSS. Decode validated
hex escapes in both parser copies; malformed or out-of-range sequences
stay literal. Regression coverage for all three forms.
Co-authored-by: Cursor Agent (AI-assisted change, reviewed and directed
by a maintainer)
* Complete the YAML 1.2 double-quote escape set
Review follow-up: the escape map omitted the escaped space (\ ) and
non-breaking space (\_) forms, so fonts declared with them kept a
literal backslash in allowedFonts and their CSS declarations were
reported as undeclared. Map the full spec 5.7 set (\a \b \v \f \e
\N \L \P included) in both parser copies instead of chasing one escape
at a time. Regression coverage for both named forms.
Co-authored-by: Cursor Agent (AI-assisted change, reviewed and directed
by a maintainer)
* Fix broken-image comment false positives
AI assistance was used to reproduce the issue, implement the fix, and add regression coverage.
* Harden JavaScript comment scanning
AI assistance was used to address automated review feedback, add regression coverage, and run validation.
* Handle comments in template expressions
AI assistance was used to reproduce and fix automated review feedback, add regression coverage, and run validation.
* Preserve JSX around URL and regex syntax
AI assistance was used to reproduce and fix automated review feedback, add regression coverage, and run validation.
* Fix regex keyword property context
AI assistance: Codex identified, implemented, and validated this review follow-up under maintainer authorization.
* Handle JSX slash edge cases
AI assistance: Codex addressed review findings and validated this follow-up under maintainer authorization.
* Ignore CSS-in-JS comments
AI assistance: Codex addressed top-level review findings and validated this follow-up under maintainer authorization.
* Handle remaining slash contexts
Fix JavaScript keyword separation and JSX protocol-relative URL classification so comment stripping preserves only live source. Add focused regressions for the reviewed edge cases.\n\nAI assistance: Codex implemented and validated this change under maintainer authorization.
* Handle generic styled templates
Recognize TypeScript generic arguments consistently in CSS-in-JS extraction and comment sanitization. Add focused regressions for extraction and comment-only styled templates.\n\nAI assistance: Codex implemented and validated this change under maintainer authorization.
* Handle nested styled generics
Teach CSS-in-JS extraction and comment sanitization to scan balanced nested TypeScript generic arguments before template literals. Add regressions for live and commented nested-generic styles.\n\nAI assistance disclosure: Codex implemented and validated this review follow-up under maintainer authorization.
* Handle nested source contexts
Keep regex detection correct after postfix operators, distinguish JSX expression comments from protocol-relative text, and scan nested template literals inside CSS-in-JS interpolations. Add focused regressions for each review finding.\n\nAI assistance disclosure: Codex implemented and validated these review follow-ups under maintainer authorization.
* Complete comment-safe source scanning
Recognize regex literals after for-of, comparisons, and block braces without confusing object-literal division. Route grid-background detection through the offset-preserving comment-neutralized source and add negative and positive controls.\n\nAI assistance disclosure: Codex implemented and validated these review follow-ups under maintainer authorization.
* Handle remaining lexer contexts
Recognize JSX attribute expressions and regex literals inside CSS-in-JS interpolations so comment stripping remains source-safe.\n\nAI-assisted: Codex implemented and validated this change under maintainer authorization.
* Align interpolation regex contexts
Match postfix-update and statement-block regex classification in CSS-in-JS interpolation parsing so templates remain extractable.\n\nAI-assisted: Codex implemented and validated this change under maintainer authorization.
Replace three duplicate matcher loops with one declarative pattern list while preserving import resolution behavior. Add Sass @use and @forward characterization coverage.\n\nAI-assisted change prepared under pbakaus's scheduled architecture-refactor authorization.
One family with weight and size contrast carrying the hierarchy is a
legitimate type system, and in practice the rule mostly punished
minimal pages: it was the loudest cross-rule noise on the fixture
corpus's should-pass columns. Removed from the registry, both engine
paths, the regex page analyzers, and the devtools category map; the
negative assertions stay as resurrection guards, and the text-content
analyzer index base shifts down one with the removal.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul's codex build carried an element literally named class="kicker"
and the detector returned one finding. Two independent blind spots:
- The linked stylesheet was styles.css?v=3, and the href resolved as a
literal path with the query string in it, so the whole sheet was
invisible to every element-level check: 1 finding with the link, 18
with the CSS inlined. Hrefs now strip query and hash before resolving.
- The kicker gate demanded letter-spacing >= max(1px, 0.08 * size). The
wild's most common recipe, 0.08em at 12px, computes to 0.973px and
lost to the absolute floor by a fraction. The floor is now purely
proportional (0.06 * size), with a fixture case pinning the exact
shape that slipped through.
With both fixed, the failed codex build scans at 18 findings including
its numbered section kickers (numbered-section-labels), side-tab
stripe, and grid background.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The static engine's checkHtmlPatterns ran its CSS-property regexes over
the entire source string, so documentation ABOUT css flagged as css:
impeccable.style's changelog line naming background-clip: text inside a
<code> tag tripped gradient-text, the purple hexes in a <pre> sample
read as the AI palette, and a commented-out stripe rule counted as a
live one. The browser path shared the exposure through outerHTML.
The fix is engine-level, not a per-rule patch. The pattern pass now
scans scoped corpora: styleText carries <style> block contents,
style="" attribute values, and the linked stylesheets the static engine
already reads for the cascade; classText carries class attribute values
for the utility-class scans. The static engine builds both from its
parsed document, so escaped code samples never contribute; other
callers fall back to a tag-scoped extraction in
buildHtmlPatternCorpora, and bare CSS input stays its own style text so
direct callers keep working. The pulsing-dot and marquee scanners take
a second markup argument for the parts that really are markup: landmark
ranges, Tailwind class positions, the <marquee> tag itself.
Rendered-text checks (theater phrases) and markup-shaped checks (svg
scenes, img hover classes) keep the full source on purpose. No registry
ids change; this is scoping, not a new rule.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The detector's repeated-section-kickers rule waited for three tracked
labels before calling the pattern; generated pages earn the finding on
the first one. Retire that id and replace it with kicker-above-heading,
which flags any tracked-caps or small-caps label block sitting directly
above an h1-h4 or heading-role element, at full warning severity.
The candidate gate absorbs the false-positive shapes the repetition
count used to paper over: editorial category-and-date meta lines,
breadcrumbs with separators, legal and chapter numbering, application
panel context labels, nav landmarks before page titles, and stat
callouts with the label below the number. Hero-scale h1 eyebrows stay
with hero-eyebrow-chip so one element gets one finding, and the static
cascade now carries font-variant so small-caps kickers register.
The craft floor entry moves from caution to ban in the same breath.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Review bots caught two real gaps in the pseudo-stripe wiring: findings
had no source line (so line-scoped impeccable-disable directives could
not match them), and the scanner read commented-out CSS as live rules.
scanCssTextForPseudoStripe now blanks comment bodies byte-for-byte
(preserving offsets) and returns each rule's selector offset; the three
regex-engine call sites convert that to a real line, including the
whole-file line for component style blocks and CSS-in-JS templates. The
HTML path ignores the new field. Tests now assert every finding's line
against the selector's actual position and cover a commented-out stripe.
Prepared with AI assistance (Claude Code), directed by @pbakaus.
Co-Authored-By: Claude Code <noreply@anthropic.com>
Cursor Bugbot correctly noted classic VuePress keeps theme layouts,
components, and styles under .vuepress/, which the walker scanned before
the hidden-dir rule. Same treatment as .vitepress and .storybook.
Prepared with AI assistance (Claude Code), directed by @pbakaus.
Co-Authored-By: Claude Code <noreply@anthropic.com>
The side-tab silhouette drawn as an absolutely-positioned ::before/
::after bar carries no border token, so the regex engine's line matchers
never saw it in .css/.scss files, component style blocks, or CSS-in-JS
templates — while the identical construction on a full HTML page was
flagged via checkHtmlPatterns (issue #394). Wire the existing
scanCssTextForPseudoStripe scanner into all three regex-engine paths.
New fixtures (pseudo-stripe.css, pseudo-stripe.vue) pin four flag shapes
(inset shorthand, longhand pins, bottom edge, height:100%) and six pass
shapes (neutral divider, wide panel, static, hairline, hover-conditional
underline, non-full-height badge), attributed per case via data-case
selectors in the finding snippet.
Prepared with AI assistance (Claude Code), directed by @pbakaus.
Co-Authored-By: Claude Code <noreply@anthropic.com>
Greptile's review correctly flagged a regression in the blanket
hidden-dir skip: .vitepress/theme/*.vue and .storybook/ preview files are
real UI source that the walker scanned before this branch. Both the
walker and the scan-target filter now carry a two-entry allowlist
(HIDDEN_SOURCE_DIRS) for those conventional locations; every other
hidden dir keeps being skipped.
Prepared with AI assistance (Claude Code), directed by @pbakaus.
Co-Authored-By: Claude Code <noreply@anthropic.com>
When impeccable (or any agent tool) is installed into a project's
.claude/.cursor/.codex tree, a root scan descended into the vendored skill
code and reported the detector's own example strings as findings, and
context-signals returned installed-skill files as scan candidates whenever
the harness tree appeared in the branch diff (issue #303).
Rather than growing SKIP_DIRS by a denylist of harness names that drifts
as new tools appear, the walker now skips every hidden directory during
recursion — which already covered .git/.next/.nuxt/.svelte-kit/.turbo/
.vercel, and covers all present and future harness installs plus
.impeccable itself. SKIP_DIRS shrinks to the four non-hidden entries.
An explicitly passed hidden target still scans: only child entries are
name-checked, never the root the walker is given.
scanTargets() applies the same rule to git-changed files (directory
segments only, so root dotfiles keep their existing behavior), and falls
through to source-dir targeting when the only dirty files are vendored.
Prepared with AI assistance (Claude Code), directed by @pbakaus.
Co-Authored-By: Claude Code <noreply@anthropic.com>
The static-html engine never emitted side-tab for `border-left: 4px solid
purple` (or any named color outside a hardcoded 9-name list) in .html
files: extractStaticColor's regex dropped the color token from border
shorthands, the side defaulted to neutral black, and checkBorders skipped
it. The same declaration in a .css file was flagged by the regex engine,
so the two engines disagreed while both exited cleanly (issue #359).
Build the extraction alternation from the same CSS_NAMED_COLORS table
parseAnyColor resolves against (longest-first, whole-token), so the set of
names the extractor recognizes and the set the parser can resolve cannot
drift apart again. STATIC_NAMED_COLORS shrinks to the one keyword
parseAnyColor deliberately refuses (`transparent` as zero-alpha), since
parseAnyColor already covers every real named color in the table.
New two-column fixture (named-color-borders.html) covers the issue
reproducers: purple shorthand + radius, rebeccapurple (substring-safe
matching), crimson top stripe, bare 3px teal, var() resolving to a named
color, and an inline style attribute — with neutral named colors
(dimgray, gainsboro, black), thin, and uniform borders as pass cases.
Prepared with AI assistance (Claude Code), directed by @pbakaus.
Co-Authored-By: Claude Code <noreply@anthropic.com>
Flags the decorative low-opacity chromatic radial-gradient "spotlight"
washed behind a hero or section and fading to transparent, an AI-slop
reflex the saturated radial-halo gate lets slip (e.g. rgba(80,111,255,
0.26) -> transparent on a mobile hero).
Gates: a non-repeating radial-gradient whose last stop is transparent,
whose visible stops are all low-opacity (alpha < 0.45) with at most two
of them, at least one chromatic (channel spread >= 24 exempts neutral
vignettes), on a decorative-scale surface (width >= 240, height >= 160,
exempting badges/avatars/small lights). The alpha band is disjoint from
radial-halo (>= 0.7), so the two never double-report.
Wired into both element loops (static-html + injected browser) with the
pure checkRadialSpotlight shared by both adapters. TDD fixture with 5
flag / 9 pass shapes. Browser-path sweep over the eval corpus: 29 hits
on 11 pages, 0 false positives. Count 59 -> 60.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Fixes three detector bugs that surfaced on real-world (Shopify) URL scans:
#407 — DOM named-property shadowing crash. On a <form> with a named control
like <input name="id"> (every Shopify product form), HTMLFormElement's
[LegacyOverrideBuiltIns] behavior makes `form.id` return the input element, not
the id string, so `elId.startsWith(...)` throws and aborts the whole scan. Read
the id via getAttribute whenever `el.id` is not a string, at all three sites:
checkQuality (checks.mjs) and collectBrowserFindings + generateSelector
(browser/injected/index.mjs). Regenerated the browser bundle.
#408 — tiny-text / undersized-ui-text flagged non-rendered elements. On sites
that set html{font-size:62.5%} the root computes to 10px, so <script>/<style>/
<title>/<noscript> and display:none / visibility:hidden blocks — whose JS/CSS/
JSON-LD text clears the hasDirectText gate — produced dozens of phantom "10px
body text" findings. Added isNonRenderedText() (tag list + head descendants +
display/visibility) and gated both text-size floors on it.
#409 — contrast rules misjudged gradients. Case A: background-clip:text paints
its glyphs with the element's own gradient, not a backdrop, so measuring the
never-painted `color` against those stops is a guaranteed false positive; skip
the backdrop-contrast checks when bgClip is 'text' (the gradient-text pattern
flag still fires). Case B: a translucent gradient stop (e.g. a 9%-alpha accent
glow) was treated as an opaque accent; composite alpha stops over the resolved
surface beneath the gradient in resolveGradientStops(), dropping the stop rather
than guessing when that surface is unresolvable.
Fixtures + tests: shadowed-form-id.html (browser, #407), nonrendered-text.html
(#408), and gradient-clipped + alpha-glow cases added to color.html (#409).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Bump the npm package and regenerate the browser detector bundle with
the advisory tier, entity-aware em-dash counting, and the
undersized-ui-text rule.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The existing `tiny-text` rule owns long body copy and deliberately exempts
the UI furniture layer (nav, footer, links, buttons, labels, uppercase
micro-labels). That left a real gap: a build shipped its entire furniture
layer (nav links, category names, timecodes, meta rows) at 8px because the
chosen pixel font only steps in 8px increments, and the design hook waved it
through as merely "not on the DESIGN.md ramp" -- which the model resolved by
adding 8px to the ramp. Being on the ramp launders the token, not the
legibility problem.
New `undersized-ui-text` quality rule closes that laundering path:
- Flags interactive and short content-bearing text (links, buttons, nav
items, labels, table cells, meta rows, timecodes) below an 11px floor. The
floor holds inside a footer; only non-interactive legal smallprint gets the
softer 10px floor.
- Ignores the design system entirely, so a value ON the ramp is still
flagged.
- Uppercase letterspaced micro-labels stay in scope (still functional).
- Exempts sup/sub, visually-hidden (sr-only) text, and code/terminal
contexts. em/rem/%-sized text that computes at or above the floor never
fires.
- Complements tiny-text without double-flagging: long non-furniture body
copy stays with tiny-text.
Implemented as a single check in checkQuality (rules/checks.mjs), so both the
static-html (jsdom) and browser adapters pick it up through the unified
per-element path -- no dual wiring. Registered in registry/antipatterns.mjs.
TDD: fixture tests/fixtures/antipatterns/undersized-ui-text.html (7 flag / 7
pass shapes), failing test first, then implement. Full fixtures suite 64/64.
Deferred (blocked by an active release-gate eval reading build/_data/dist):
regenerate the browser bundle (bun run build:browser ->
cli/engine/detect-antipatterns-browser.js) and the extension detector
(bun run build:extension -> extension/detector/detect.js + antipatterns.json)
so the standalone browser/extension artifacts carry the new rule.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Em-dashes are used legitimately by humans, so em-dash-overuse fired far too
often. Reclassify it as the first advisory-tier rule: detected, but never a
failure.
Engine
- Add `advisory: true` to the rule metadata schema (em-dash-overuse is the
first). findings.mjs stamps `advisory: true` on advisory findings so every
consumer can partition without a registry lookup. Rule count stays 58.
- Raise the firing threshold from a flat 5 dashes to two gates: an absolute
floor of 8 and a density of about one dash per 500 characters of body text.
A long article that uses a few em-dashes no longer trips; a short,
dash-per-clause page still does. Entity decoding (mdash, numeric, hex) is
unchanged. Thresholds live in shared/constants.mjs so every engine agrees.
Browser parity
- The browser bundle carried a registry entry but no logic, so the overlay and
extension could never flag it. Add checkEmDashOveruse / checkEmDashOveruseDOM
in rules/checks.mjs (reads rendered text, no entity decoding needed), wire it
into the injected page-level pass, and carry the advisory flag through
serializeFindings so the overlay/extension can render it with the mildest
affordance.
CLI
- Advisory findings print under a separate dimmed "Advisory" section, are
excluded from the failure count, and never change the exit code (an
advisory-only scan exits 0). JSON keeps them with `"advisory": true`.
`--no-advisory` suppresses them entirely.
Hook
- Advisory rules are skipped by default in both the per-edit and Stop deep-pass
hooks, so the hook never nags about them. Opt in with
`.impeccable/config.json` -> `detector.advisoryRules: "include"`.
Tests
- Fixture + threshold + browser-adapter coverage; advisory-skip default and
opt-in for the hook; formatFindings partitioning. The em-dash-overuse stand
for a deferred copy rule in the tier tests is swapped to marketing-buzzword.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The detect CLI loaded DESIGN.md once from process.cwd() and applied it to
every scan target. Scanning another project's files from inside a different
repo therefore judged them against the wrong project's design system
(cross-project contamination observed during eval work: running detect from
impeccable-evals against a generated artifact elsewhere applied the evals
repo's DESIGN.md).
DESIGN.md now resolves by walking up from each scan target's own location to
its design root: a directory carrying a DESIGN.md is the root; a directory
carrying a project marker (.git / package.json / .impeccable) without a
DESIGN.md is a boundary that stops the walk with no design system, so a
sibling project never inherits a parent's or cwd's rules. A target with no
design root above it falls back to no design system rather than cwd's.
Resolution is memoized per root, so a multi-file scan reads each DESIGN.md
once, and targets spanning projects each get their own. file:// URLs resolve
from their path; remote http(s) URLs get no design system.
Adds tests/detect-cli-design-contamination.test.mjs, which spawns the real
CLI to prove B's file is not judged by A's DESIGN.md, that a project still
governs its own file, that a mixed-project scan resolves per target, and that
a marker-less bare file gets no design system.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
On Windows, `impeccable detect <url>` flashed a persistent black window during
scans. The scan uses puppeteer's bundled Chrome, which runs from an untrusted
user-cache path; Windows blocks its GPU process, so it crash-loops and flashes a
compositor surface on every retry. It is not a real application window (not in
Alt+Tab, not clickable, invisible to window enumeration) and not malware.
Prefer the system-installed Chrome via channel:'chrome' on Windows, which runs
from a trusted location with a healthy GPU: no crash loop, no window. Fall back
to the bundled browser when Chrome is not installed. Scoped to Windows only, so
mac and linux keep the pinned bundled build for consistent measurement. Both
render on hardware GPU, so contrast measurement is unaffected.
Also routes both launch sites through one helper and fixes a pre-existing bug
where detectUrl hardcoded headless:true instead of honoring options.headless.
Tests: new tests/detect-url-launch.test.mjs covers the launch choice per
platform (Windows prefers channel:'chrome' and falls back to bundled;
non-Windows never attempts it), wired into the detector suite. Verified on
Windows 11 / Chrome 150: zero GPU crashes, window gone, findings unchanged.
This change was prepared with AI assistance.
The em-dash-overuse text analyzer ran stripHtmlToText over raw markup,
which drops tags but leaves character entities intact. A model that wrote
—, —, or — rendered a real em-dash the counter never
saw, so 12 entity-escaped dashes on a live page slipped through.
Decode the em-dash entities (named, zero-padded decimal, upper/lower hex)
to the literal glyph before counting. En-dash entities stay untouched: the
rule counts em-dashes, and the literal en-dash was never counted either.
The gap lived only in the regex / static-HTML path (detectText and
detect-html's runTextContentAnalyzers, both over raw HTML). The browser
adapter never ran this analyzer, so build:browser and build:extension
produce no diff.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
main still carries the site, so every `site/` path resolves to deleted.
`tests/docs-integrity.test.js` goes with it (it imports the site's demo
renderer), and `package.json` keeps main's `@anthropic-ai/sdk` bump while
dropping `@google/genai` and `@paper-design/shaders`, which nothing in the
product layer imports.
Real code merges:
- hook-lib: main's #391 cache fix (sync the remembered set to the live
scan so fixed findings stop being named and a reintroduced one fires
again) now runs on the immediate tier rather than the whole filtered
set. Remembering a deferred finding the per-edit pass never reported
would let the Stop deep pass dedupe it away. main's `maxFileBytes`
ceiling, `cleanAcked` once-per-file ack, and template-extensions
re-export all land alongside the tiering work.
- live-browser: main's `hasParams` gate on the Tune badge, keeping this
branch's `C.ink` badge text so it stays legible on kinpaku gold.
- detect-text: both the block-level codex-grid-background scan and main's
inset-stripe CSS check.
- test-suites: union of both trigger sets and file lists, minus the
site-only entries (`shiki-theme`, `docs-integrity`).
- Two hook tests moved off deferred-tier rules (`overused-font`,
`side-tab`) onto immediate-tier ones. They assert cache bookkeeping,
which the per-edit pass only reaches for the immediate tier.
Also drops the site waivers from `.impeccable/config.json` and stops
`build:browser` recreating a stray `site/` tree just to write a bundle
the other repo builds itself.
Co-Authored-By: Claude <noreply@anthropic.com>
* Give DESIGN.md a real type ramp so the design hook stops crying wolf
The design hook fired on nearly every CSS file we touched. The cause was
DESIGN.md's typography block: it declared seven named roles rather than a
scale, and two of those roles used clamp(), which the extractor skipped
outright. That left an allowlist of five sizes standing against the 86
distinct font sizes actually in use, so design-system-font-size flagged
roughly 500 declarations. Editing any .astro page made it worse, because
the companion-stylesheet scan re-reported the whole backlog.
Extractor (cli/engine/design-system.mjs):
- Read a typography.scale map as the enumerated ramp.
- Read both clamp() endpoints as allowed sizes. These stay additive on
purpose: clamp endpoints alone cannot switch the rule on, because a fully
fluid system enumerates no discrete ramp and inferring one from its
endpoints would flag every intermediate size. The existing abstention
test still passes, and three new tests cover the added behavior.
DESIGN.md:
- Document a 19-step ramp, 8px through 72px at a 16px root.
- Snap the five discrete role sizes onto ramp steps.
This also fixes real drift. DESIGN.md claims to mirror kinpaku-tokens.css
verbatim, but wordmark was 1.15rem in the CSS against 1.3rem documented,
with tracking at 0.42em against 0.15em. Both are re-synced.
Standardization, 64 declarations:
- Six near-identical steps between 13.7px and 15.4px collapse onto 14 and 15.
- .foundation-card-label, .designing-lane-mock-title and
.designing-iterate-name each existed at two different sizes in two files.
Now unified.
- The wordmark rendered at four sizes (20.8, 18.4, 17, 16.8px). Now 18px,
plus one deliberate smaller nav variant.
Exemptions, for designs that are foreign on purpose: the antipattern-example
fixtures, the neo-mirai case-study build, the periodic-table cell
annotations in framework-viz.js (5 to 7px diagram geometry sitting at 2 to
3px offsets), and the .why-slop-* before-state card's Inter and gradient
text.
Verified by computed style across ten rendered pages: every element lands on
a ramp step except clamp() values mid-interpolation, which is what fluid
means. Full test suite and build validators pass.
Generated provider output is deliberately left out; the sync workflow owns it.
Prepared with AI assistance (Claude Code).
Co-Authored-By: Claude <noreply@anthropic.com>
* Validate clamp() endpoints in usage, not just when reading DESIGN.md
Reading clamp endpoints as documented steps without also checking them in
source left an asymmetry: `isAllowedFontSizeRaw` returned true for anything
failing the px/rem literal test, so `clamp(99rem, 1vw, 200rem)` passed. That
is how `.ptable-symbol` at `clamp(1.45rem, 1.8vw, 1.8rem)` stayed invisible
until someone measured computed styles, which is not a check the hook can run.
Fluid values are now judged on their min and max. The viewport term
interpolates between them and is never a fixed step, so it is left alone.
Endpoints that cannot be resolved, such as var() or calc() or em, abstain
rather than guess. Findings name the offending endpoint and use it as the
ignore-value, because the whole clamp string is not actionable on its own.
Turning the check on surfaced 22 fluid declarations that had never been
looked at. Three used hero sizes above the ramp's 72px cap (80, 83.2 and
88px) alongside the display role's documented 89.6px max, so the top of the
ramp was genuinely incomplete. Added the 80 and 88 steps, which gives the
display end consistent 8px increments instead of 48/56/64/72 plus an orphan
at 89.6, and fixes two declarations outright.
The other 20 are snapped by a stated rule: nearest step, ties toward the
smaller step, endpoints already matching a documented fluid role left as-is,
and where nearest-step would make a breakpoint override meet or exceed its
base, the next smaller step so the override still reduces. That last case
applies once, to .designing-page-title.
Also narrows the framework-viz.js waiver. The periodic-table cell
annotations now carry two `impeccable-disable-line` comments naming the
reason, instead of a config entry wildcarding the whole file for the rule.
Inline waivers travel with the code and cannot silence future drift
elsewhere in that file.
Verified at 420px, 900px and 1600px across seven pages. The pinned ends are
fully on-ramp; the only off-ramp values at 900px are the vw term
mid-interpolation, which is what fluid means.
Prepared with AI assistance (Claude Code).
Co-Authored-By: Claude <noreply@anthropic.com>
* Address review: wordmark tracking picked the wrong side, stale ramp count
Two review findings, both fair.
Wordmark tracking (greptile, bugbot). This PR moved DESIGN.md's wordmark
letterSpacing from 0.15em to 0.42em on the grounds that DESIGN.md claims to
mirror kinpaku-tokens.css and the token read 0.42em. That was the wrong side
to trust. `--ks-type-wordmark-track` has exactly one consumer,
design-system.css:570, which is the specimen page. Every production lockup
(.ks-wordmark, .kinpaku-chrome .site-header-brand-name, .footer-logo)
hardcodes 0.15em, so 0.15em is what every visitor actually sees and what
DESIGN.md already documented correctly before this PR touched it.
Reverted the doc to 0.15em and moved the token to 0.15em as well, so the
specimen now renders the same lockup as production instead of a wider one
nothing else uses. Verified by computed style: header and specimen both
report 18px with 2.7px tracking. No production visual change.
Stale ramp count (copilot). The sidecar described an "18-step ramp, 8px
through 72px". It went stale twice inside this PR, once when the 8 step was
added and again when 80 and 88 were added for the hero display sizes. It is
21 steps, 8px through 88px.
Prepared with AI assistance (Claude Code).
Co-Authored-By: Claude <noreply@anthropic.com>
* Strip !important from the font-size ignore value
Follow-on from the waiver wiring in the hook branch. The ignoreValue is what a
`hooks ignore-value` waiver has to match, and `font-size: 1.4rem !important`
emitted `1.4rem !important` while a plain declaration emitted `1.4rem`. Once
font-size is a direct-value rule, that means the same size needs two different
waivers depending on whether it carries a priority marker.
font-family already strips the marker before matching, and there is a test for
that. font-size now does the same. The snippet still shows the declaration as
authored.
Prepared with AI assistance (Claude Code).
Co-Authored-By: Claude <noreply@anthropic.com>
* Have the wordmark rules consume their tokens instead of copying the values
Follow-up to the tracking fix, and the residual half of what the reviewers
were pointing at. `.ks-wordmark` and the kinpaku chrome lockup each repeated
`1.125rem` and `0.15em` literally rather than reading
`--ks-type-wordmark-size` and `--ks-type-wordmark-track`. That duplication is
exactly how the token drifted to 0.42em while every production lockup stayed
at 0.15em and nobody noticed, which is the confusion that started this thread.
The values already agree, so this is a no-op visually and is verified as such:
computed styles across the home, design-system, docs and changelog pages all
still report 18px with 2.7px tracking. What changes is that there is now one
place to edit, so the next tracking change cannot silently apply to the
specimen page alone.
Prepared with AI assistance (Claude Code).
Co-Authored-By: Claude <noreply@anthropic.com>
---------
Co-authored-by: Claude <noreply@anthropic.com>
* Detect single-edge stripes painted with an inset box-shadow
The side-tab rule caught bordered stripes but not the inset box-shadow spelling of
the same anti-pattern, which is how it usually reaches an Astro/CSS source file.
Adds a structural CSS scan for `box-shadow: inset` layers whose shape is a 3-12px
stripe on exactly one edge with no blur or spread, reusing the existing `side-tab`
rule id, so the rule count is unchanged.
Scoped narrowly, because a stripe is correct design in some places. It skips
selection and focus indicators (the rule's one documented exception), interactive
and semantic elements, narrow artwork, and neutral colors: `inset 4px 0 0 #000` is
a hairline, not an AI tell. Chromatic intent is read from the color literal or from
a `var(--token)` name.
Grammar rather than one spelling, learned the hard way — three of the four
false-negative shapes below were found only after the first pass shipped:
- `inset` is order-independent, so `4px 0 0 red inset` is the same stripe. Only a
standalone keyword is stripped, so `var(--inset-accent)` is not mangled.
- box-shadow takes <length>{2,4}: `inset 4px 0 red` omits blur and spread, which
default to 0. That is exactly the stripe shape.
- Authored CSS spells neutrals as `#000` / `black`, and shared/color.mjs only
parses the computed function forms a browser emits, deliberately reporting
anything else as chromatic. Routing authored colors through it flagged plain
black hairlines, so hex and named neutrals are handled before deferring.
- Comment bodies are blanked before matching, preserving byte offsets so line
numbers stay right, and the selector's line is taken from its first
non-whitespace character rather than the greedy match start.
Fixture covers 8 flag shapes and 13 pass shapes, including a literal-color column
that the original had none of, which is why the neutral bug survived review.
Prepared with AI assistance under maintainer direction.
Co-Authored-By: Claude <noreply@anthropic.com>
* Parse box-shadow layers by grammar, not by one spelling
Three review-bot findings, two of them the same mistake I had already made
twice in this rule.
Color-first layers were missed (greptile). `box-shadow` orders `inset`,
the lengths, and the color freely, so `red 4px 0 inset` and
`var(--brand-accent) 4px 0 0 inset` paint the stripe the length-first
regex was looking for and were skipped. That is the third valid spelling
this rule has missed after trailing `inset` and the two-length form, all
from encoding one spelling instead of the grammar. Stop patching
spellings: tokenize the layer, pick out `inset` and the 2-4 lengths in any
order, and treat the single remaining token as the color. Tokenizing is
paren-aware because `rgb(0 0 0)` is one color value whose channels would
otherwise read as lengths.
Neutral `rgb()` with space-separated channels was flagged (cursor).
shared/color.mjs parses only the comma form that getComputedStyle emits,
so an authored `rgb(0 0 0)` fell through it and reported chromatic — the
exemption isNeutralAuthoredColor exists for, missed. Parse both separators
before delegating. Left shared/color.mjs alone: it reads computed styles,
where the comma form is all a browser produces.
Line numbers were derived by re-slicing the whole prefix per rule, O(n^2)
on a large stylesheet (Copilot). Matches arrive in source order, so carry
a monotonic cursor: one pass total.
Fixtures cover both flag shapes and the neutral pass shape; all three fail
against the previous parse ("expected Color First Edge to flag", and
Space Rgb Neutral Edge appearing in the old flag list).
Assisted-by: Claude Code
* Fix the !important regression my tokenizer introduced, plus two cascade bugs
Three findings from Cursor on the grammar rewrite. The first is mine, from
the commit that claimed to end this bug class.
`!important` stopped flagging. Tokenizing split it into its own token, so
the color count came out at two and the layer was skipped — a shape the
regex it replaced handled correctly. `!important` qualifies the
declaration, not the shadow value, so strip it before reading layers.
Style-block findings reported one line low. block.startLine is the first
line after the <style> tag, but block.content begins at the character right
after that tag, so content's own line 1 sits on the tag's line. Passing
startLine - 1 to a 1-based line lookup counted that line twice. It is
startLine - 2. runRegexMatchers is unaffected and stays at startLine - 1
because it indexes its split lines from zero — verified by a fixture where
bounce-easing and side-tab share one block and now both report correctly.
Repeated declarations read the first, not the last. The cascade paints the
last, so `box-shadow: inset 4px 0 red; box-shadow: none` was flagged
though it paints nothing, and the reverse order was missed. Same for a
width override deciding the narrow-artwork skip.
Fixtures cover !important, both cascade orders, and the line-accuracy
shapes (multi-line block, single-line block, plain .css); they fail against
the previous commit.
Assisted-by: Claude Code
---------
Co-authored-by: Claude <noreply@anthropic.com>
Remove provider gating, share grid-background detection across source and rendered scan paths, and update the detector catalog and tests.\n\nAI-assisted: prepared by Codex at Paul's request.
Scan Astro style blocks for inset-shadow stripes, recognize semantically chromatic external tokens without flagging neutral unknowns, and make the polling generator run advisory detector checks before publication. Sync the affected detector bundles and add a paired regression fixture.\n\nAI-assisted: Codex analyzed the failed Live task, implemented the detector and generator changes, and ran the validation suites under maintainer direction.