mirror of
https://github.com/nexu-io/open-design.git
synced 2026-06-01 03:14:35 +07:00
* feat(craft): add brand-agnostic craft references and refero-derived lint rules Introduce `craft/` as a third top-level content axis alongside `skills/` and `design-systems/`, holding universal (brand-agnostic) craft rules that apply on top of any DESIGN.md. Skills opt in via a new `od.craft.requires` front-matter array; the daemon resolves the slug list and injects the matching files between DESIGN.md and the skill body in the system prompt. Initial vendor (MIT, adapted from referodesign/refero_skill): typography craft, color craft, anti-ai-slop. Pilot wired on saas-landing. Extend the existing lint-artifact pass with two refero-derived rules: - P0 ai-default-indigo — solid #6366f1 / #4f46e5 / #4338ca / #8b5cf6 as accent (not just gradients) is the most-reported AI tell. - P1 all-caps-no-tracking — `text-transform: uppercase` rules without ≥0.06em letter-spacing. The craft loader silently drops missing files so a skill can forward-reference future sections (e.g. `motion`) without breaking. * fix(daemon): skip :root token blocks in ai-default-indigo lint The ai-default-indigo P0 check scanned the whole HTML for the raw hex, so brands that intentionally encode indigo as `--accent: #6366f1` in :root and consume it via var(--accent) downstream were flagged as AI-default — a false positive that forced the agent to "fix" valid output. Strip :root token-definition blocks (including attribute-selector theme variants) before scanning, mirroring the existing pattern used by the raw-hex P1 check. Hex still flagged when it appears in component rules or inline styles. * docs(craft): address PR #225 P3 review feedback - craft/README.md: explain why missing craft sections are silently dropped (forward-compatibility) instead of surfacing a warning. - craft/typography.md: ground the 0.06em ALL CAPS tracking floor in Bringhurst-derived typographic practice rather than presenting the threshold as unattributed. - craft/color.md: cover the edge case where a brand's DESIGN.md intentionally encodes indigo as --accent — `var(--accent)` uses remain unflagged because the linter only inspects hardcoded hex. - docs/skills-protocol.md: link the "missing files dropped silently" note back to craft/README.md for the canonical slug list and the rationale behind the choice. * fix(craft): address PR #225 P0 review feedback - tools/pack: copy `craft/` into the packaged resource root alongside `skills`, `design-systems`, and `frames`, so the `od.craft.requires` integration isn't a silent no-op when the daemon resolves `${OD_RESOURCE_ROOT}/craft` in packaged builds. - packages/contracts: add `craftRequires?: string[]` to `SkillSummary` (and therefore `SkillDetail`) so the field that `listSkills()` already returns and `/api/skills(/:id)` already serializes via `...rest` is part of the documented web/daemon contract instead of leaking through as an untyped property. - apps/daemon/lint-artifact: expand the indigo token-strip pass to cover selector lists containing `:root` (e.g. `:root, [data-theme="light"]`) and any rule whose body is custom-property-only (e.g. a `[data-theme="dark"] { --accent: ... }` theme variant). Real component rules with a hardcoded indigo are still preserved so the P0 finding still fires; tests cover the new selector-list and theme-variant cases. * fix(craft): address PR #225 follow-up review feedback - lint-artifact: scope the indigo token-strip to <style> blocks so the rule-shaped regex no longer captures leading `<style>` text into the selector (which broke `:root` recognition for token blocks that mix `color-scheme`/etc. with `--accent`). Run the strip on the extracted CSS instead, with a regression covering `:root { color-scheme: light; --accent: #6366f1 }`. - lint-artifact: tighten the custom-property-only exemption to global theme-scope selectors (`:root`, `html`, `body`, bare attribute selectors like `[data-theme="dark"]`). Component-local rules such as `.cta { --cta-bg: #6366f1 }` are no longer exempted, so an agent cannot launder default indigo through a local var. Regression test added. - craft/anti-ai-slop.md: stop claiming every rule below is enforced by the linter; only several are. The unenforced rules (standard Hero→Features→Pricing→FAQ→CTA flow, decorative blob/wave SVG backgrounds, perfect symmetry) are now flagged inline as "(guidance, not auto-checked)" so the contract with the lint surface stays honest. * fix(daemon): tighten lint-artifact iteration and :root token gating - all-caps-no-tracking: iterate every <style> block. The previous check called `exec` once on a non-global regex, so an artifact whose offending uppercase rule sat in a second <style> block (e.g. a reset block followed by a components block) slipped past. Switch to `matchAll` and break across both loops once a violation is found. Regression test covers a second-block uppercase rule. - ai-default-indigo: stop unconditionally exempting any selector list containing `:root`. The exemption now requires both conditions to hold: every selector in the list is global theme scope AND the body is token-shaped (CSS custom properties or the `color-scheme` keyword). So `:root { background: #6366f1 }` and `:root, .cta { --cta-bg: #6366f1 }` no longer launder a hardcoded indigo through the strip pass. Regression tests cover both bypass shapes. * fix(daemon): scope theme-attr exemption and strip CSS comments in token blocks Address PR #225 review feedback on `ai-default-indigo`: - The bare-attribute branch of `selectorListIsGlobalThemeScope` accepted any `[attr=...]` selector, so a custom-property-only rule on a component/state attribute (e.g. `[data-variant="primary"]`, `[aria-current="page"]`) was treated as a global theme block and stripped before the indigo scan — exactly the component-local indigo laundering this lint is meant to catch. Restrict the exemption to a small allowlist of known theme switches: `data-theme`, `data-color-scheme`, `data-mode`. - `stripTokenBlocksFromCss` split rule bodies on `;` and matched each fragment from the start, so a token block whose body contained a normal CSS comment such as `:root { /* brand accent */ --accent: #6366f1; }` produced a fragment beginning with the comment, failed `isTokenShapedDeclaration`, and the rule was left in scope of the indigo scan — a false P0 on a legitimate token definition. Strip CSS comments before splitting/classifying declarations. Add regression coverage: arbitrary component/state attribute selectors still trip `ai-default-indigo`; `data-color-scheme` theme variants stay exempted; `:root` token blocks with leading, trailing, and between-declaration CSS comments are recognized. * fix(daemon): strip CSS comments and recognize tokens nested in at-rules The all-caps-no-tracking scan ran against raw `<style>` content, so a commented-out rule like `/* .eyebrow { text-transform: uppercase; } */` matched `upperRe` and emitted a P1 for CSS the browser ignores. Strip CSS comments from the style body before structural matching. `stripTokenBlocksFromCss` only matched flat `selector { body }` rules, so a media-query-wrapped token block like `@media (prefers-color-scheme: dark) { :root { --accent: #6366f1 } }` had its outer `@media` rule treated as the selector/body pair and the inner `:root` token block was never stripped, producing a P0 false positive on legitimate responsive theme CSS. Tighten the body alternation to `[^{}]*` so the regex matches innermost rules and recognizes the inner `:root` block directly while preserving the outer at-rule wrapper. * fix(daemon): align ai-default-indigo list with documented cardinal sins The lint's AI_DEFAULT_INDIGO subset omitted #3730a3 and #a855f7, which craft/anti-ai-slop.md lists as P0-blocked solid accents. An artifact could hard-code one of those documented colors as a button fill and slip past the indigo scan unless it happened to be inside a gradient. Bring the lint set to the exact list documented in the craft doc, and tighten the doc's wording from "etc." to an explicit enumeration that points at AI_DEFAULT_INDIGO so the prompt contract and daemon behavior stay in sync. Add regression tests pinning each newly-included hex. * fix(daemon): tighten theme-scope selector and scan inline ALL CAPS The theme-scope exemption used to accept any attribute on `:root`, `html`, or `body` (e.g. `:root[data-variant="primary"]`), letting an agent launder default indigo through a component/state attribute and slip past the `ai-default-indigo` lint. The prefixed branches now require the attribute name to be one of GLOBAL_THEME_ATTRIBUTES, matching the bare-attribute branch. The `all-caps-no-tracking` rule only iterated `<style>` blocks, so inline declarations like `<span style="text-transform: uppercase">` produced no finding even though craft/typography.md treats the ≥0.06em tracking floor as having no exceptions. Added a second scan over `style="..."` attributes that runs the same letter-spacing check and dedupes against the existing `<style>`-block finding so the agent gets a single corrective signal per artifact. * fix(daemon): align uppercase tracking px floor with the 0.06em rule The previous absolute fallback (>=1.5px) was stricter than the craft rule it enforces. `font-size: 12px; letter-spacing: 1px` is 0.083em — above the 0.06em floor — but 1.5px would reject it and trigger an unnecessary correction loop on compliant small-label CSS. Extract `hasAdequateUppercaseTracking`: read `font-size` from the same rule body and compare px tracking against `fontSize * 0.06`; fall back to a conservative >=1px floor when font-size is inherited (covers the default 16px body where 1px ≈ 0.0625em). Apply the helper to both the <style>-block scan and the inline-style scan, and add 12–14px label tests in both branches. * fix(daemon): treat rem letter-spacing as absolute, not per-element em `rem` was previously folded into the same branch as `em` and accepted at the 0.06 threshold. But `rem` is relative to the root font-size (16px default), not the element's own font-size, so on a 48px heading `letter-spacing: 0.06rem` resolves to 0.96px — about 0.02em of the element, well below the 0.06em rule the lint enforces. Convert rem to absolute px through the 16px root assumption and reuse the same px-vs-element-font-size resolution: same-rule `font-size: <n>px` gives an exact `n * 0.06` floor; otherwise the conservative >=1px fallback applies. Add regression tests for 48px headings with 0.06rem tracking (must flag) plus the 16px-element and rem-floor matches that must keep passing, in both <style>-block and inline-style branches. * fix(daemon): resolve var() refs in uppercase tracking lint `hasAdequateUppercaseTracking` only matched literal numeric values, so a tokenized rule like `letter-spacing: var(--caps-tracking)` — exactly the pattern the craft prompt steers artifacts toward — was falsely reported as `all-caps-no-tracking`. Extract `--name: value` declarations from global theme scopes (`:root`, `html`, theme-attribute selectors) once per artifact, then expand simple `var(--name)` (and `var(--name, fallback)`) references in the inspected rule body before applying the existing 0.06em / px-floor / rem-conversion logic. References without a matching token and no fallback stay in place, preserving the conservative "missing tracking" finding. * fix(daemon): resolve rem and var() font-size in uppercase tracking lint Previously the px-vs-element-font-size resolution only matched `font-size: <n>px`. Any rem-based or tokenized display size fell through to the lenient `>= 1px` body-text fallback, so an artifact emitting `.display { font-size: 3rem; text-transform: uppercase; letter-spacing: 1px; }` (a ~48px heading with a 2.88px floor) slipped past the lint that this helper exists to enforce. Resolve `rem` font-size via the same root-font assumption already used for tracking, and treat any explicitly declared but unresolvable unit (`em`, `%`, `calc(...)`, an unresolved `var(...)`) conservatively — refuse the lenient fallback so the rule must use either an `em` letter-spacing or a verifiable px/rem font-size. `var()` font-size declarations resolve through the existing `resolveCssVars` pass before the size scan runs, so the same fix catches the tokenized-display-size pattern (`--display-size: 3rem`). * fix(daemon): parse declarations to ignore custom-prop names in uppercase tracking lint The hasAdequateUppercaseTracking and resolveFontSizePx helpers used substring regexes against the rule body, so a token-name declaration such as `--letter-spacing: 0.08em` or `--display-font-size: 48px` could satisfy the `letter-spacing` / `font-size` checks even though it has no rendered effect — letting actual ALL-CAPS-without-tracking rules slip past the P1 lint. Parse the declaration list, compare exact property names, and skip declarations whose property starts with `--`. Adds regression tests covering token-name letter-spacing (style-block + inline) and a token-name font-size masking the bail-out branch. * fix(daemon): scope indigo token exemption to --accent only Previously stripTokenBlocksFromCss removed every custom-property-only global theme block before the ai-default-indigo scan, which let a laundered indigo token like `:root { --primary: #6366f1 }` consumed via `var(--primary)` slip past the lint. The craft contract is that the only escape hatch is encoding indigo as the design system's `--accent` token; any other token name is still the LLM-default color hidden behind an arbitrary name. Narrow the strip pass so a non-`--accent` token whose value carries an AI-default indigo hex keeps the rule in scope, and add regression tests for `--primary` / `--button-bg` global tokens feeding a CTA, including the at-rule and theme-attribute variants. * fix(daemon): model CSS cascade in tracking lint and detect blue→cyan trust gradients Address PR #225 review feedback (3 comments): - `letter-spacing` / `font-size` selection now picks the LAST matching declaration in the rule body, modeling CSS source-order cascade. `.eyebrow { letter-spacing: 0.08em; letter-spacing: 0.02em }` renders the noncompliant 0.02em the browser actually shows; the previous first-match behaviour silently passed it. - `extractCssTokens` now records every distinct value seen for a token across global theme scopes, and `hasAdequateUppercaseTracking` enumerates each combination so a default-theme value below the floor cannot be rescued by a scoped override that happened to be parsed later (`:root { --caps-tracking: 0.02em }` + `[data-theme="dark"] { --caps-tracking: 0.08em }` now fires). - New `trust-gradient` P0 rule pairs blue/sky tokens against cyan tokens in `linear-gradient(...)` bodies so `blue→cyan` two-stop trust gradients (documented as a cardinal sin in `craft/anti-ai-slop.md`) are actually enforced — both the hex form (`linear-gradient(90deg, #3b82f6, #06b6d4)`) and the keyword form (`linear-gradient(90deg, blue, cyan)`). Adds 11 regression tests covering each path (cascade override in <style> and inline form, font-size cascade shifting the floor, both orderings of the conflicting-token cascade, the don't-over-fire case when every theme value clears the floor, hex / keyword / sky variants of the trust gradient, and the don't-double-fire case when purple-gradient already caught a mixed gradient). * fix(daemon): apply per-scope cascade in extractCssTokens When the same CSS custom property is declared more than once inside a single rule body (e.g. `:root { --caps-tracking: 0.02em; --caps-tracking: 0.08em }`), CSS source-order cascade collapses to the last value; the earlier declaration never reaches any element. `extractCssTokens` was treating intra-scope duplicates as simultaneous theme alternatives, so `hasAdequateUppercaseTracking` enumerated the stale 0.02em and emitted a spurious all-caps-no-tracking finding. Collapse duplicate token declarations within a rule body to the last value before merging into the cross-scope distinct-value map. Cross-scope overrides (separate `:root` and `[data-theme]` rules) remain preserved as distinct values so the conservative theme-cascade check still fires when ANY applicable theme renders below the floor. * fix(daemon): scope tracking lint to innermost rules and per-theme tokens Restrict the upperRe body alternation to [^{}]* so the regex matches innermost CSS rules and skips at-rule wrappers — an outer @media or @supports could otherwise capture as a single rule whose selector was the at-rule and whose body began with the inner selector token, masking the same-rule font-size and letting noncompliant tracking on large headings slip through the lenient inherited-size fallback. Replace the by-name-distinct-values token map with per-scope token records and a buildResolvedThemes pass that materializes one effective map per theme. Paired token declarations now stay paired during evaluation, so theme variants like :root + [data-theme=dark] no longer generate cross-theme cartesian pairings (e.g. default-size + dark-track) that emit false positives on legitimate light/dark themes. --------- Co-authored-by: looper <looper@open-claude.dev>
1226 lines
52 KiB
TypeScript
1226 lines
52 KiB
TypeScript
// @ts-nocheck
|
|
import { describe, expect, it } from 'vitest';
|
|
|
|
import { lintArtifact } from '../src/lint-artifact.js';
|
|
|
|
describe('ai-default-indigo', () => {
|
|
it('flags solid #6366f1 used as accent', () => {
|
|
const html = `
|
|
<style>
|
|
.cta { background: #6366f1; color: white; }
|
|
</style>
|
|
<button class="cta">Get started</button>
|
|
`;
|
|
const findings = lintArtifact(html);
|
|
const hit = findings.find((f) => f.id === 'ai-default-indigo');
|
|
expect(hit).toBeDefined();
|
|
expect(hit.severity).toBe('P0');
|
|
});
|
|
|
|
it('flags solid #4f46e5 (indigo-600) too', () => {
|
|
const html = `<div style="background: #4f46e5">Hi</div>`;
|
|
const findings = lintArtifact(html);
|
|
expect(findings.find((f) => f.id === 'ai-default-indigo')).toBeDefined();
|
|
});
|
|
|
|
// Regression: the AI_DEFAULT_INDIGO list used to omit `#3730a3` and
|
|
// `#a855f7` even though `craft/anti-ai-slop.md` documents both as
|
|
// P0-blocked solid accents. An artifact could hard-code one of these
|
|
// as a button fill and slip past the lint. The list now matches the
|
|
// craft doc exactly; these regression tests pin the contract.
|
|
it.each([
|
|
['#3730a3', 'tailwind indigo-800'],
|
|
['#a855f7', 'tailwind purple-500'],
|
|
['#7c3aed', 'tailwind violet-600'],
|
|
])('flags solid %s (%s) as a documented cardinal-sin accent', (hex) => {
|
|
const html = `<div style="background: ${hex}">Hi</div>`;
|
|
const findings = lintArtifact(html);
|
|
const hit = findings.find((f) => f.id === 'ai-default-indigo');
|
|
expect(hit).toBeDefined();
|
|
expect(hit.severity).toBe('P0');
|
|
});
|
|
|
|
it('does not double-fire when purple-gradient already caught the same color', () => {
|
|
const html = `<div style="background: linear-gradient(90deg, #6366f1, #ec4899)">Hi</div>`;
|
|
const findings = lintArtifact(html);
|
|
expect(findings.find((f) => f.id === 'purple-gradient')).toBeDefined();
|
|
expect(findings.find((f) => f.id === 'ai-default-indigo')).toBeUndefined();
|
|
});
|
|
|
|
it('does not flag artifacts that use var(--accent) only', () => {
|
|
const html = `
|
|
<style>
|
|
:root { --accent: #2f6feb; }
|
|
.cta { background: var(--accent); color: white; }
|
|
</style>
|
|
<button class="cta">Get started</button>
|
|
`;
|
|
const findings = lintArtifact(html);
|
|
expect(findings.find((f) => f.id === 'ai-default-indigo')).toBeUndefined();
|
|
});
|
|
|
|
it('does not flag indigo declared as a token in :root and consumed via var(--accent)', () => {
|
|
// Brand whose accent is intentionally indigo: defines #6366f1 once
|
|
// in :root and uses var(--accent) downstream. This is the design
|
|
// system speaking, not the model defaulting — must not fire P0.
|
|
const html = `
|
|
<style>
|
|
:root { --accent: #6366f1; --bg: #ffffff; }
|
|
.cta { background: var(--accent); color: white; }
|
|
</style>
|
|
<button class="cta">Get started</button>
|
|
`;
|
|
const findings = lintArtifact(html);
|
|
expect(findings.find((f) => f.id === 'ai-default-indigo')).toBeUndefined();
|
|
});
|
|
|
|
it('still flags indigo when it appears outside :root even if also defined as a token', () => {
|
|
// If the artifact both defines the accent AND hard-codes the same
|
|
// hex in a component rule, the component rule is still raw indigo
|
|
// — fire as before.
|
|
const html = `
|
|
<style>
|
|
:root { --accent: #6366f1; }
|
|
.cta { background: #6366f1; color: white; }
|
|
</style>
|
|
`;
|
|
const findings = lintArtifact(html);
|
|
expect(findings.find((f) => f.id === 'ai-default-indigo')).toBeDefined();
|
|
});
|
|
|
|
it('does not flag indigo in :root with attribute selector (theme variants)', () => {
|
|
const html = `
|
|
<style>
|
|
:root[data-theme="dark"] { --accent: #6366f1; }
|
|
.cta { background: var(--accent); color: white; }
|
|
</style>
|
|
`;
|
|
const findings = lintArtifact(html);
|
|
expect(findings.find((f) => f.id === 'ai-default-indigo')).toBeUndefined();
|
|
});
|
|
|
|
it('does not flag indigo declared in a selector list containing :root', () => {
|
|
// Theme CSS often pairs `:root` with an attribute selector via a
|
|
// selector list so the same tokens apply to both default and
|
|
// light-themed roots. Whichever side comes first, the block is a
|
|
// token definition and must not fire P0.
|
|
const html = `
|
|
<style>
|
|
:root, [data-theme="light"] { --accent: #6366f1; --bg: #ffffff; }
|
|
.cta { background: var(--accent); color: white; }
|
|
</style>
|
|
`;
|
|
const findings = lintArtifact(html);
|
|
expect(findings.find((f) => f.id === 'ai-default-indigo')).toBeUndefined();
|
|
});
|
|
|
|
it('does not flag indigo declared in a selector list with :root second', () => {
|
|
const html = `
|
|
<style>
|
|
[data-theme="light"], :root { --accent: #6366f1; }
|
|
.cta { background: var(--accent); color: white; }
|
|
</style>
|
|
`;
|
|
const findings = lintArtifact(html);
|
|
expect(findings.find((f) => f.id === 'ai-default-indigo')).toBeUndefined();
|
|
});
|
|
|
|
it('does not flag indigo declared in a custom-property-only theme block without :root', () => {
|
|
// Theme-variant blocks that omit `:root` entirely (e.g. only
|
|
// `[data-theme="dark"]`) are still token definitions when their
|
|
// body is custom-property-only; treat them the same way.
|
|
const html = `
|
|
<style>
|
|
[data-theme="dark"] { --accent: #6366f1; --bg: #0b0b10; }
|
|
.cta { background: var(--accent); color: white; }
|
|
</style>
|
|
`;
|
|
const findings = lintArtifact(html);
|
|
expect(findings.find((f) => f.id === 'ai-default-indigo')).toBeUndefined();
|
|
});
|
|
|
|
it('does not flag a :root token block that also declares non-custom properties like color-scheme', () => {
|
|
// Regression: the strip pass used to run its rule-shaped regex
|
|
// against the full HTML string, so the first selector capture
|
|
// included the leading `<style>` text and the `:root` test
|
|
// failed. A common token block such as
|
|
// `:root { color-scheme: light; --accent: #6366f1; }` should be
|
|
// recognized as a token definition even when the body mixes
|
|
// CSS variables with non-custom declarations.
|
|
const html = `<style>:root { color-scheme: light; --accent: #6366f1; }</style>
|
|
<button class="cta" style="background: var(--accent)">Get started</button>`;
|
|
const findings = lintArtifact(html);
|
|
expect(findings.find((f) => f.id === 'ai-default-indigo')).toBeUndefined();
|
|
});
|
|
|
|
it('still flags indigo laundered through a component-local custom property', () => {
|
|
// Regression: the custom-property-only exemption used to apply
|
|
// to *any* selector, so an agent could hide #6366f1 in a local
|
|
// var (e.g. `.cta { --cta-bg: #6366f1 }`) and the linter would
|
|
// strip the rule and miss the P0. The exemption is now scoped
|
|
// to global theme selectors (:root, html, [data-theme=...], …).
|
|
const html = `
|
|
<style>
|
|
.cta { --cta-bg: #6366f1; }
|
|
.cta { background: var(--cta-bg); color: white; }
|
|
</style>
|
|
<button class="cta">Get started</button>
|
|
`;
|
|
const findings = lintArtifact(html);
|
|
expect(findings.find((f) => f.id === 'ai-default-indigo')).toBeDefined();
|
|
});
|
|
|
|
it('still flags a non-token :root declaration containing #6366f1', () => {
|
|
// Regression: the `:root` exemption used to be unconditional, so
|
|
// a rule whose body wasn't actually a token definition (e.g.
|
|
// `:root { background: #6366f1 }`) was stripped before the indigo
|
|
// scan and the P0 silently disappeared. The exemption now requires
|
|
// a token-shaped body, so a non-token `:root` declaration keeps
|
|
// its hex in scope and the lint still fires.
|
|
const html = `
|
|
<style>
|
|
:root { background: #6366f1; }
|
|
</style>
|
|
`;
|
|
const findings = lintArtifact(html);
|
|
expect(findings.find((f) => f.id === 'ai-default-indigo')).toBeDefined();
|
|
});
|
|
|
|
it('still flags indigo when :root sits in a list with a component selector', () => {
|
|
// Regression: `:root, .cta { --cta-bg: #6366f1 }` used to be
|
|
// exempted because the selector list contained `:root`, even
|
|
// though `.cta` is a component selector. The exemption now
|
|
// requires every selector in the list to be a global theme
|
|
// scope, so this mixed list is preserved and the P0 still fires.
|
|
const html = `
|
|
<style>
|
|
:root, .cta { --cta-bg: #6366f1; }
|
|
</style>
|
|
`;
|
|
const findings = lintArtifact(html);
|
|
expect(findings.find((f) => f.id === 'ai-default-indigo')).toBeDefined();
|
|
});
|
|
|
|
it('still flags indigo on a bare component-attribute selector', () => {
|
|
// Regression: the bare-attribute branch of the global-theme-scope
|
|
// test used to accept ANY attribute selector (e.g.
|
|
// `[data-variant="primary"]`), so a custom-property-only rule on
|
|
// a component/state attribute was treated as a global token block
|
|
// and the indigo lint silently disappeared. The exemption now
|
|
// requires the attribute name to be one of the known global-theme
|
|
// switches (`data-theme`, `data-color-scheme`, `data-mode`).
|
|
const html = `
|
|
<style>
|
|
[data-variant="primary"] { --button-bg: #6366f1; }
|
|
</style>
|
|
`;
|
|
const findings = lintArtifact(html);
|
|
expect(findings.find((f) => f.id === 'ai-default-indigo')).toBeDefined();
|
|
});
|
|
|
|
it('still flags indigo on a bare aria-state attribute selector', () => {
|
|
const html = `
|
|
<style>
|
|
[aria-current="page"] { --nav-accent: #6366f1; }
|
|
</style>
|
|
`;
|
|
const findings = lintArtifact(html);
|
|
expect(findings.find((f) => f.id === 'ai-default-indigo')).toBeDefined();
|
|
});
|
|
|
|
it('still flags indigo on a :root prefixed with a component-attribute selector', () => {
|
|
// Regression: `:root[data-variant="primary"]` used to be exempted
|
|
// because the regex only checked the tag prefix and not the
|
|
// attribute name. A component/state attribute attached to `:root`
|
|
// is exactly the laundering pattern this lint must catch — the
|
|
// exemption now requires the attribute (when present) to name a
|
|
// known global-theme switch.
|
|
const html = `
|
|
<style>
|
|
:root[data-variant="primary"] { --button-bg: #6366f1; }
|
|
</style>
|
|
`;
|
|
const findings = lintArtifact(html);
|
|
expect(findings.find((f) => f.id === 'ai-default-indigo')).toBeDefined();
|
|
});
|
|
|
|
it('still flags indigo on an html prefixed with an aria-state attribute selector', () => {
|
|
const html = `
|
|
<style>
|
|
html[aria-current="page"] { --nav-accent: #6366f1; }
|
|
</style>
|
|
`;
|
|
const findings = lintArtifact(html);
|
|
expect(findings.find((f) => f.id === 'ai-default-indigo')).toBeDefined();
|
|
});
|
|
|
|
it('still flags indigo on a body prefixed with a component-attribute selector', () => {
|
|
const html = `
|
|
<style>
|
|
body[data-variant="primary"] { --button-bg: #6366f1; }
|
|
</style>
|
|
`;
|
|
const findings = lintArtifact(html);
|
|
expect(findings.find((f) => f.id === 'ai-default-indigo')).toBeDefined();
|
|
});
|
|
|
|
it('still exempts indigo on :root prefixed with the canonical data-theme switch', () => {
|
|
// Sanity check: the prefixed-attribute change must keep exempting
|
|
// legitimate theme-switch selectors (`:root[data-theme="dark"]`),
|
|
// even though the prefixed-form regex changed shape.
|
|
const html = `
|
|
<style>
|
|
:root[data-theme="dark"] { --accent: #6366f1; --bg: #0b0b10; }
|
|
.cta { background: var(--accent); color: white; }
|
|
</style>
|
|
`;
|
|
const findings = lintArtifact(html);
|
|
expect(findings.find((f) => f.id === 'ai-default-indigo')).toBeUndefined();
|
|
});
|
|
|
|
it('still exempts indigo on html and body prefixed with data-theme', () => {
|
|
const html = `
|
|
<style>
|
|
html[data-theme="dark"] { --accent: #6366f1; }
|
|
body[data-mode="compact"] { --bg: #0b0b10; }
|
|
</style>
|
|
`;
|
|
const findings = lintArtifact(html);
|
|
expect(findings.find((f) => f.id === 'ai-default-indigo')).toBeUndefined();
|
|
});
|
|
|
|
it('still exempts indigo on a bare data-color-scheme theme block', () => {
|
|
// The bare-attribute exemption still covers the canonical
|
|
// global-theme switches; a token block keyed off
|
|
// `[data-color-scheme="dark"]` is a theme variant, not a
|
|
// component-local rule, and must not fire.
|
|
const html = `
|
|
<style>
|
|
[data-color-scheme="dark"] { --accent: #6366f1; --bg: #0b0b10; }
|
|
.cta { background: var(--accent); color: white; }
|
|
</style>
|
|
`;
|
|
const findings = lintArtifact(html);
|
|
expect(findings.find((f) => f.id === 'ai-default-indigo')).toBeUndefined();
|
|
});
|
|
|
|
it('does not flag a :root token block whose body contains CSS comments', () => {
|
|
// Regression: `stripTokenBlocksFromCss` used to split the body on
|
|
// `;` and run `isTokenShapedDeclaration` from the start of each
|
|
// fragment. A common token block such as
|
|
// `:root { /* brand accent */ --accent: #6366f1; }` produced a
|
|
// declaration fragment beginning with the comment, failed the
|
|
// token-shape test, and the rule was left in scope of the
|
|
// indigo scan — a false P0 on a legitimate token definition.
|
|
const html = `
|
|
<style>
|
|
:root { /* brand accent */ --accent: #6366f1; --bg: #ffffff; }
|
|
.cta { background: var(--accent); color: white; }
|
|
</style>
|
|
`;
|
|
const findings = lintArtifact(html);
|
|
expect(findings.find((f) => f.id === 'ai-default-indigo')).toBeUndefined();
|
|
});
|
|
|
|
it('does not flag a :root token block with a trailing CSS comment', () => {
|
|
const html = `
|
|
<style>
|
|
:root { --accent: #6366f1; /* brand accent */ }
|
|
</style>
|
|
<button style="background: var(--accent)">Get started</button>
|
|
`;
|
|
const findings = lintArtifact(html);
|
|
expect(findings.find((f) => f.id === 'ai-default-indigo')).toBeUndefined();
|
|
});
|
|
|
|
it('does not flag a :root token block with a comment between declarations', () => {
|
|
const html = `
|
|
<style>
|
|
:root {
|
|
--bg: #ffffff;
|
|
/* brand accent — keep in sync with DESIGN.md */
|
|
--accent: #6366f1;
|
|
}
|
|
</style>
|
|
`;
|
|
const findings = lintArtifact(html);
|
|
expect(findings.find((f) => f.id === 'ai-default-indigo')).toBeUndefined();
|
|
});
|
|
|
|
it('does not flag indigo declared in a :root token block nested inside @media', () => {
|
|
// Regression: `stripTokenBlocksFromCss` only matched flat
|
|
// `selector { body }` rules, so a media-query-wrapped token block
|
|
// like `@media (prefers-color-scheme: dark) { :root { --accent: #6366f1 } }`
|
|
// had its outer `@media` rule treated as the selector/body pair and
|
|
// the inner `:root` token block was never stripped — producing a
|
|
// P0 false positive on legitimate responsive theme CSS.
|
|
const html = `
|
|
<style>
|
|
@media (prefers-color-scheme: dark) {
|
|
:root { --accent: #6366f1; --bg: #0b0b10; }
|
|
}
|
|
.cta { background: var(--accent); color: white; }
|
|
</style>
|
|
`;
|
|
const findings = lintArtifact(html);
|
|
expect(findings.find((f) => f.id === 'ai-default-indigo')).toBeUndefined();
|
|
});
|
|
|
|
it('does not flag indigo declared in a :root token block nested inside @supports', () => {
|
|
const html = `
|
|
<style>
|
|
@supports (color: oklch(0 0 0)) {
|
|
:root { --accent: #6366f1; }
|
|
}
|
|
</style>
|
|
`;
|
|
const findings = lintArtifact(html);
|
|
expect(findings.find((f) => f.id === 'ai-default-indigo')).toBeUndefined();
|
|
});
|
|
|
|
it('still flags indigo declared on a non-accent global token feeding a CTA', () => {
|
|
// Regression: the strip pass used to remove every custom-property-only
|
|
// global theme block, even when the indigo hid behind a non-`--accent`
|
|
// token like `--primary` or `--button-bg`. The craft contract's escape
|
|
// hatch is `--accent` specifically — encoding indigo as any other
|
|
// token name still launders the LLM-default color, so the rule must
|
|
// stay in scope of the indigo scan.
|
|
const html = `
|
|
<style>
|
|
:root { --primary: #6366f1; }
|
|
.cta { background: var(--primary); color: white; }
|
|
</style>
|
|
<button class="cta">Get started</button>
|
|
`;
|
|
const findings = lintArtifact(html);
|
|
expect(findings.find((f) => f.id === 'ai-default-indigo')).toBeDefined();
|
|
});
|
|
|
|
it('still flags indigo declared on a --button-bg global token alongside other tokens', () => {
|
|
// A laundered indigo token mixed with legitimate tokens in the same
|
|
// :root block must not be stripped — the non-`--accent` indigo
|
|
// declaration keeps the whole rule in scope so the literal hex is
|
|
// visible to the indigo scan.
|
|
const html = `
|
|
<style>
|
|
:root { --bg: #ffffff; --button-bg: #4f46e5; }
|
|
.cta { background: var(--button-bg); color: white; }
|
|
</style>
|
|
`;
|
|
const findings = lintArtifact(html);
|
|
expect(findings.find((f) => f.id === 'ai-default-indigo')).toBeDefined();
|
|
});
|
|
|
|
it('still flags indigo on a non-accent token inside an @media-wrapped :root block', () => {
|
|
// The at-rule unwrapping must not bypass the non-accent check:
|
|
// a media-query-wrapped :root that declares indigo on `--primary`
|
|
// is still laundering the LLM default through an arbitrary name.
|
|
const html = `
|
|
<style>
|
|
@media (prefers-color-scheme: dark) {
|
|
:root { --primary: #6366f1; --bg: #0b0b10; }
|
|
}
|
|
</style>
|
|
`;
|
|
const findings = lintArtifact(html);
|
|
expect(findings.find((f) => f.id === 'ai-default-indigo')).toBeDefined();
|
|
});
|
|
|
|
it('still flags indigo on a non-accent token declared via a theme-attribute selector', () => {
|
|
const html = `
|
|
<style>
|
|
[data-theme="dark"] { --primary: #6366f1; }
|
|
</style>
|
|
`;
|
|
const findings = lintArtifact(html);
|
|
expect(findings.find((f) => f.id === 'ai-default-indigo')).toBeDefined();
|
|
});
|
|
|
|
it('still exempts a :root token block that mixes --accent indigo with non-indigo tokens', () => {
|
|
// The non-accent check should fire only on indigo-bearing tokens;
|
|
// legitimate sibling tokens whose values are unrelated colors must
|
|
// not be misread as laundering.
|
|
const html = `
|
|
<style>
|
|
:root { --accent: #6366f1; --primary: #ff7700; --bg: #ffffff; }
|
|
.cta { background: var(--accent); color: white; }
|
|
</style>
|
|
`;
|
|
const findings = lintArtifact(html);
|
|
expect(findings.find((f) => f.id === 'ai-default-indigo')).toBeUndefined();
|
|
});
|
|
|
|
it('still flags indigo on a component rule nested inside @media', () => {
|
|
// The exemption only applies to global token blocks. A component
|
|
// rule that hard-codes the indigo hex inside an at-rule wrapper
|
|
// is still raw indigo and must fire.
|
|
const html = `
|
|
<style>
|
|
@media (prefers-color-scheme: dark) {
|
|
.cta { background: #6366f1; }
|
|
}
|
|
</style>
|
|
`;
|
|
const findings = lintArtifact(html);
|
|
expect(findings.find((f) => f.id === 'ai-default-indigo')).toBeDefined();
|
|
});
|
|
|
|
});
|
|
|
|
describe('all-caps-no-tracking', () => {
|
|
it('flags uppercase rule with no letter-spacing at all', () => {
|
|
const html = `
|
|
<style>
|
|
.eyebrow { text-transform: uppercase; font-size: 12px; }
|
|
</style>
|
|
<span class="eyebrow">New</span>
|
|
`;
|
|
const findings = lintArtifact(html);
|
|
const hit = findings.find((f) => f.id === 'all-caps-no-tracking');
|
|
expect(hit).toBeDefined();
|
|
expect(hit.severity).toBe('P1');
|
|
});
|
|
|
|
it('flags uppercase rule with too-small letter-spacing', () => {
|
|
const html = `
|
|
<style>
|
|
.eyebrow { text-transform: uppercase; letter-spacing: 0.02em; }
|
|
</style>
|
|
`;
|
|
const findings = lintArtifact(html);
|
|
expect(findings.find((f) => f.id === 'all-caps-no-tracking')).toBeDefined();
|
|
});
|
|
|
|
it('passes uppercase rule with adequate letter-spacing in em', () => {
|
|
const html = `
|
|
<style>
|
|
.eyebrow { text-transform: uppercase; letter-spacing: 0.08em; }
|
|
</style>
|
|
`;
|
|
const findings = lintArtifact(html);
|
|
expect(findings.find((f) => f.id === 'all-caps-no-tracking')).toBeUndefined();
|
|
});
|
|
|
|
it('passes uppercase rule with adequate letter-spacing in px', () => {
|
|
const html = `
|
|
<style>
|
|
.eyebrow { text-transform: uppercase; letter-spacing: 2px; }
|
|
</style>
|
|
`;
|
|
const findings = lintArtifact(html);
|
|
expect(findings.find((f) => f.id === 'all-caps-no-tracking')).toBeUndefined();
|
|
});
|
|
|
|
it('does not flag a style block with no uppercase rule', () => {
|
|
const html = `<style>.x { color: red; }</style>`;
|
|
const findings = lintArtifact(html);
|
|
expect(findings.find((f) => f.id === 'all-caps-no-tracking')).toBeUndefined();
|
|
});
|
|
|
|
it('flags an uppercase rule in a SECOND <style> block', () => {
|
|
// Regression: the scan used to call `exec` once on a non-global
|
|
// regex, so only the first <style> block was inspected. Artifacts
|
|
// commonly emit a reset/normalize block before the components
|
|
// block; the offending uppercase rule sat in block #2 and slipped
|
|
// past. The scan now iterates every <style> block.
|
|
const html = `
|
|
<style>.reset { box-sizing: border-box; }</style>
|
|
<style>.eyebrow { text-transform: uppercase; font-size: 12px; }</style>
|
|
<span class="eyebrow">New</span>
|
|
`;
|
|
const findings = lintArtifact(html);
|
|
const hit = findings.find((f) => f.id === 'all-caps-no-tracking');
|
|
expect(hit).toBeDefined();
|
|
expect(hit.severity).toBe('P1');
|
|
});
|
|
|
|
it('does not flag an uppercase rule that is entirely inside a CSS comment', () => {
|
|
// Regression: the scan ran against the raw <style> body, so a
|
|
// commented-out rule like `/* .eyebrow { text-transform: uppercase; } */`
|
|
// matched `upperRe` and fired a P1 even though the browser ignores it.
|
|
// CSS comments are stripped before structural matching now.
|
|
const html = `
|
|
<style>
|
|
/* .eyebrow { text-transform: uppercase; } */
|
|
.eyebrow { font-size: 12px; }
|
|
</style>
|
|
<span class="eyebrow">New</span>
|
|
`;
|
|
const findings = lintArtifact(html);
|
|
expect(findings.find((f) => f.id === 'all-caps-no-tracking')).toBeUndefined();
|
|
});
|
|
|
|
it('still flags an active uppercase rule when surrounded by comments', () => {
|
|
// Comments are stripped only for structural matching; the live rule
|
|
// outside the comment must still fire.
|
|
const html = `
|
|
<style>
|
|
/* historical: removed in 2024 */
|
|
.eyebrow { text-transform: uppercase; font-size: 12px; }
|
|
/* trailing note */
|
|
</style>
|
|
<span class="eyebrow">New</span>
|
|
`;
|
|
const findings = lintArtifact(html);
|
|
expect(findings.find((f) => f.id === 'all-caps-no-tracking')).toBeDefined();
|
|
});
|
|
|
|
it('flags inline style with text-transform: uppercase and no letter-spacing', () => {
|
|
// Regression: the rule used to scan only <style> blocks, so an
|
|
// artifact emitting `<span style="text-transform: uppercase">NEW</span>`
|
|
// produced no finding even though the rendered output is the same
|
|
// ALL CAPS the typography rule prohibits without tracking.
|
|
const html = `<span style="text-transform: uppercase">NEW</span>`;
|
|
const findings = lintArtifact(html);
|
|
const hit = findings.find((f) => f.id === 'all-caps-no-tracking');
|
|
expect(hit).toBeDefined();
|
|
expect(hit.severity).toBe('P1');
|
|
});
|
|
|
|
it('flags inline style with text-transform: uppercase and too-small letter-spacing', () => {
|
|
const html = `<span style="text-transform: uppercase; letter-spacing: 0.02em">NEW</span>`;
|
|
const findings = lintArtifact(html);
|
|
expect(findings.find((f) => f.id === 'all-caps-no-tracking')).toBeDefined();
|
|
});
|
|
|
|
it('passes inline style with text-transform: uppercase and adequate letter-spacing in em', () => {
|
|
const html = `<span style="text-transform: uppercase; letter-spacing: 0.08em">NEW</span>`;
|
|
const findings = lintArtifact(html);
|
|
expect(findings.find((f) => f.id === 'all-caps-no-tracking')).toBeUndefined();
|
|
});
|
|
|
|
it('passes inline style with text-transform: uppercase and adequate letter-spacing in px', () => {
|
|
const html = `<span style="text-transform: uppercase; letter-spacing: 2px">NEW</span>`;
|
|
const findings = lintArtifact(html);
|
|
expect(findings.find((f) => f.id === 'all-caps-no-tracking')).toBeUndefined();
|
|
});
|
|
|
|
it('flags inline style on a tag that already carries other attributes', () => {
|
|
// Make sure the inline-style scan handles tags whose `style` is not
|
|
// the first attribute. The leading-boundary anchor must not anchor
|
|
// to start-of-string only.
|
|
const html = `<span class="x" style="text-transform: uppercase">NEW</span>`;
|
|
const findings = lintArtifact(html);
|
|
expect(findings.find((f) => f.id === 'all-caps-no-tracking')).toBeDefined();
|
|
});
|
|
|
|
it('does not double-fire when both <style> block and inline style are offending', () => {
|
|
// The inline-style scan should be skipped when the <style>-block
|
|
// scan already produced this finding — single corrective signal.
|
|
const html = `
|
|
<style>.eyebrow { text-transform: uppercase; font-size: 12px; }</style>
|
|
<span style="text-transform: uppercase">NEW</span>
|
|
`;
|
|
const findings = lintArtifact(html);
|
|
const hits = findings.filter((f) => f.id === 'all-caps-no-tracking');
|
|
expect(hits.length).toBe(1);
|
|
});
|
|
|
|
it('passes a 12px label with 1px tracking (resolves 0.06em via same-rule font-size)', () => {
|
|
// Regression: the previous absolute-fallback floor of >=1.5px was
|
|
// stricter than the craft rule. `font-size: 12px; letter-spacing: 1px`
|
|
// is `1 / 12 = 0.083em` — well above the 0.06em rule — and must pass.
|
|
const html = `
|
|
<style>
|
|
.eyebrow { font-size: 12px; text-transform: uppercase; letter-spacing: 1px; }
|
|
</style>
|
|
`;
|
|
const findings = lintArtifact(html);
|
|
expect(findings.find((f) => f.id === 'all-caps-no-tracking')).toBeUndefined();
|
|
});
|
|
|
|
it('passes a 14px label with 1px tracking (resolves 0.06em via same-rule font-size)', () => {
|
|
// 14px * 0.06 = 0.84px floor, so 1px tracking satisfies the rule.
|
|
const html = `
|
|
<style>
|
|
.badge { font-size: 14px; text-transform: uppercase; letter-spacing: 1px; }
|
|
</style>
|
|
`;
|
|
const findings = lintArtifact(html);
|
|
expect(findings.find((f) => f.id === 'all-caps-no-tracking')).toBeUndefined();
|
|
});
|
|
|
|
it('flags a 14px label with 0.5px tracking (below same-rule 0.06em floor)', () => {
|
|
// 14px * 0.06 = 0.84px floor; 0.5px is below the rule and must flag.
|
|
const html = `
|
|
<style>
|
|
.badge { font-size: 14px; text-transform: uppercase; letter-spacing: 0.5px; }
|
|
</style>
|
|
`;
|
|
const findings = lintArtifact(html);
|
|
expect(findings.find((f) => f.id === 'all-caps-no-tracking')).toBeDefined();
|
|
});
|
|
|
|
it('passes inline 12px label with 1px tracking', () => {
|
|
// Same regression as the <style>-block case but in the inline branch.
|
|
const html = `<span style="font-size: 12px; text-transform: uppercase; letter-spacing: 1px">NEW</span>`;
|
|
const findings = lintArtifact(html);
|
|
expect(findings.find((f) => f.id === 'all-caps-no-tracking')).toBeUndefined();
|
|
});
|
|
|
|
it('passes inline 14px label with 1px tracking', () => {
|
|
const html = `<span style="font-size: 14px; text-transform: uppercase; letter-spacing: 1px">NEW</span>`;
|
|
const findings = lintArtifact(html);
|
|
expect(findings.find((f) => f.id === 'all-caps-no-tracking')).toBeUndefined();
|
|
});
|
|
|
|
it('flags inline 14px label with 0.5px tracking', () => {
|
|
const html = `<span style="font-size: 14px; text-transform: uppercase; letter-spacing: 0.5px">NEW</span>`;
|
|
const findings = lintArtifact(html);
|
|
expect(findings.find((f) => f.id === 'all-caps-no-tracking')).toBeDefined();
|
|
});
|
|
|
|
it('passes inline 1px tracking even without a font-size (16px default fallback)', () => {
|
|
// When the same rule does not declare font-size, the conservative
|
|
// absolute fallback of >=1px keeps default-16px-body labels passing
|
|
// (1 / 16 ≈ 0.0625em, just over the 0.06em rule).
|
|
const html = `<span style="text-transform: uppercase; letter-spacing: 1px">NEW</span>`;
|
|
const findings = lintArtifact(html);
|
|
expect(findings.find((f) => f.id === 'all-caps-no-tracking')).toBeUndefined();
|
|
});
|
|
|
|
it('flags a 48px heading with 0.06rem tracking (rem ignores element font-size)', () => {
|
|
// Regression: `rem` was previously folded into the same branch as
|
|
// `em` and accepted at the 0.06 threshold. But `rem` is relative
|
|
// to the root font-size (16px default), not the element's own
|
|
// font-size, so on a 48px heading `0.06rem` resolves to 0.96px —
|
|
// about 0.02em of the element, well below the 0.06em rule.
|
|
const html = `
|
|
<style>
|
|
.display { font-size: 48px; text-transform: uppercase; letter-spacing: 0.06rem; }
|
|
</style>
|
|
<h1 class="display">Headline</h1>
|
|
`;
|
|
const findings = lintArtifact(html);
|
|
expect(findings.find((f) => f.id === 'all-caps-no-tracking')).toBeDefined();
|
|
});
|
|
|
|
it('passes a 16px label with 0.06rem tracking (rem ≈ 1px ≈ 0.06em on 16px)', () => {
|
|
// 0.06rem * 16px/rem = 0.96px; on a 16px element that is 0.06em —
|
|
// exactly at the floor. The rem branch must accept it.
|
|
const html = `
|
|
<style>
|
|
.eyebrow { font-size: 16px; text-transform: uppercase; letter-spacing: 0.06rem; }
|
|
</style>
|
|
`;
|
|
const findings = lintArtifact(html);
|
|
expect(findings.find((f) => f.id === 'all-caps-no-tracking')).toBeUndefined();
|
|
});
|
|
|
|
it('passes a 48px heading with 0.18rem tracking (rem converted, meets element 0.06em)', () => {
|
|
// 0.18rem * 16px/rem = 2.88px; 48px * 0.06 = 2.88px floor — the
|
|
// converted rem matches the per-element em floor exactly.
|
|
const html = `
|
|
<style>
|
|
.display { font-size: 48px; text-transform: uppercase; letter-spacing: 0.18rem; }
|
|
</style>
|
|
`;
|
|
const findings = lintArtifact(html);
|
|
expect(findings.find((f) => f.id === 'all-caps-no-tracking')).toBeUndefined();
|
|
});
|
|
|
|
it('flags inline 48px heading with 0.06rem tracking', () => {
|
|
const html = `<h1 style="font-size: 48px; text-transform: uppercase; letter-spacing: 0.06rem">Headline</h1>`;
|
|
const findings = lintArtifact(html);
|
|
expect(findings.find((f) => f.id === 'all-caps-no-tracking')).toBeDefined();
|
|
});
|
|
|
|
it('passes inline 16px label with 0.06rem tracking (rem ≈ 0.06em on 16px)', () => {
|
|
const html = `<span style="font-size: 16px; text-transform: uppercase; letter-spacing: 0.06rem">NEW</span>`;
|
|
const findings = lintArtifact(html);
|
|
expect(findings.find((f) => f.id === 'all-caps-no-tracking')).toBeUndefined();
|
|
});
|
|
|
|
it('passes uppercase rule whose letter-spacing dereferences a compliant :root token', () => {
|
|
// Regression: the tracking helper used to recognise only literal
|
|
// numeric values, so a tokenized rule — exactly the pattern the
|
|
// craft prompt steers artifacts toward — was wrongly reported as
|
|
// `all-caps-no-tracking`. The helper now resolves `var(--name)` to
|
|
// its `:root` definition and judges the literal value against the
|
|
// 0.06em floor.
|
|
const html = `
|
|
<style>
|
|
:root { --caps-tracking: 0.08em; }
|
|
.eyebrow { text-transform: uppercase; letter-spacing: var(--caps-tracking); }
|
|
</style>
|
|
<span class="eyebrow">New</span>
|
|
`;
|
|
const findings = lintArtifact(html);
|
|
expect(findings.find((f) => f.id === 'all-caps-no-tracking')).toBeUndefined();
|
|
});
|
|
|
|
it('flags uppercase rule whose letter-spacing dereferences a noncompliant :root token', () => {
|
|
// The token-resolution path must not blanket-pass `var()` refs:
|
|
// a token defined below the 0.06em floor still trips the lint.
|
|
const html = `
|
|
<style>
|
|
:root { --caps-tracking: 0.02em; }
|
|
.eyebrow { text-transform: uppercase; letter-spacing: var(--caps-tracking); }
|
|
</style>
|
|
<span class="eyebrow">New</span>
|
|
`;
|
|
const findings = lintArtifact(html);
|
|
expect(findings.find((f) => f.id === 'all-caps-no-tracking')).toBeDefined();
|
|
});
|
|
|
|
it('flags uppercase rule whose letter-spacing var() has no matching :root definition', () => {
|
|
// Unresolved references stay in place; the existing "no numeric
|
|
// letter-spacing" path then reports the rule as missing tracking.
|
|
const html = `
|
|
<style>
|
|
.eyebrow { text-transform: uppercase; letter-spacing: var(--caps-tracking); }
|
|
</style>
|
|
<span class="eyebrow">New</span>
|
|
`;
|
|
const findings = lintArtifact(html);
|
|
expect(findings.find((f) => f.id === 'all-caps-no-tracking')).toBeDefined();
|
|
});
|
|
|
|
it('passes uppercase rule whose letter-spacing var() has a compliant fallback', () => {
|
|
const html = `
|
|
<style>
|
|
.eyebrow { text-transform: uppercase; letter-spacing: var(--caps-tracking, 0.08em); }
|
|
</style>
|
|
<span class="eyebrow">New</span>
|
|
`;
|
|
const findings = lintArtifact(html);
|
|
expect(findings.find((f) => f.id === 'all-caps-no-tracking')).toBeUndefined();
|
|
});
|
|
|
|
it('passes inline uppercase whose letter-spacing dereferences a compliant :root token', () => {
|
|
const html = `
|
|
<style>:root { --caps-tracking: 0.08em; }</style>
|
|
<span style="text-transform: uppercase; letter-spacing: var(--caps-tracking)">NEW</span>
|
|
`;
|
|
const findings = lintArtifact(html);
|
|
expect(findings.find((f) => f.id === 'all-caps-no-tracking')).toBeUndefined();
|
|
});
|
|
|
|
it('flags a 3rem heading with 1px tracking (rem font-size resolves to 48px, 0.06em floor = 2.88px)', () => {
|
|
// Regression: the px-vs-element-font-size resolution previously
|
|
// matched only `font-size: <n>px`, so a `font-size: 3rem` heading
|
|
// fell through to the lenient `>= 1px` fallback and accepted 1px
|
|
// tracking — even though the rendered ~48px display has a 2.88px
|
|
// floor and 1px is well below the 0.06em rule. The helper now
|
|
// resolves `rem` font-size via the same root assumption used for
|
|
// tracking and applies the strict per-element floor.
|
|
const html = `
|
|
<style>
|
|
.display { font-size: 3rem; text-transform: uppercase; letter-spacing: 1px; }
|
|
</style>
|
|
<h1 class="display">Headline</h1>
|
|
`;
|
|
const findings = lintArtifact(html);
|
|
expect(findings.find((f) => f.id === 'all-caps-no-tracking')).toBeDefined();
|
|
});
|
|
|
|
it('passes a 3rem heading with 0.06em tracking (em path is unaffected by font-size unit)', () => {
|
|
// Sanity check: the rem font-size fix must not regress the em
|
|
// letter-spacing branch. `0.06em` is the rule, regardless of how
|
|
// font-size is expressed.
|
|
const html = `
|
|
<style>
|
|
.display { font-size: 3rem; text-transform: uppercase; letter-spacing: 0.06em; }
|
|
</style>
|
|
`;
|
|
const findings = lintArtifact(html);
|
|
expect(findings.find((f) => f.id === 'all-caps-no-tracking')).toBeUndefined();
|
|
});
|
|
|
|
it('passes a 3rem heading with 3px tracking (3 ≥ 48 * 0.06 = 2.88)', () => {
|
|
const html = `
|
|
<style>
|
|
.display { font-size: 3rem; text-transform: uppercase; letter-spacing: 3px; }
|
|
</style>
|
|
`;
|
|
const findings = lintArtifact(html);
|
|
expect(findings.find((f) => f.id === 'all-caps-no-tracking')).toBeUndefined();
|
|
});
|
|
|
|
it('flags a tokenized display size with 1px tracking (var() resolves to 3rem, then to 48px)', () => {
|
|
// Regression: same root cause via a CSS variable. The agent often
|
|
// hides the size behind a token (`--display-size: 3rem`); after
|
|
// `resolveCssVars` the body reads `font-size: 3rem;` and must take
|
|
// the same strict-floor branch. Without the fix, the rule slipped
|
|
// past via the lenient fallback.
|
|
const html = `
|
|
<style>
|
|
:root { --display-size: 3rem; }
|
|
.display { font-size: var(--display-size); text-transform: uppercase; letter-spacing: 1px; }
|
|
</style>
|
|
<h1 class="display">Headline</h1>
|
|
`;
|
|
const findings = lintArtifact(html);
|
|
expect(findings.find((f) => f.id === 'all-caps-no-tracking')).toBeDefined();
|
|
});
|
|
|
|
it('flags a tokenized px display size with 1px tracking', () => {
|
|
// The token-resolution path must also catch a px-valued token —
|
|
// `font-size: var(--display-size)` with `--display-size: 48px`
|
|
// resolves the same way and the 2.88px floor still applies.
|
|
const html = `
|
|
<style>
|
|
:root { --display-size: 48px; }
|
|
.display { font-size: var(--display-size); text-transform: uppercase; letter-spacing: 1px; }
|
|
</style>
|
|
`;
|
|
const findings = lintArtifact(html);
|
|
expect(findings.find((f) => f.id === 'all-caps-no-tracking')).toBeDefined();
|
|
});
|
|
|
|
it('flags a heading with 1em font-size (unresolvable unit) and 1px tracking', () => {
|
|
// When the rule explicitly declares font-size in a unit we cannot
|
|
// resolve (`em`, `%`, `calc(...)`, unresolved var), the helper
|
|
// refuses the lenient body-text fallback — the element might be
|
|
// arbitrarily large. The rule must use `em` letter-spacing or an
|
|
// explicit px/rem font-size to be verifiable.
|
|
const html = `
|
|
<style>
|
|
.display { font-size: 2em; text-transform: uppercase; letter-spacing: 1px; }
|
|
</style>
|
|
`;
|
|
const findings = lintArtifact(html);
|
|
expect(findings.find((f) => f.id === 'all-caps-no-tracking')).toBeDefined();
|
|
});
|
|
|
|
it('passes a heading with 1em font-size and 0.06em tracking (em path is verifiable)', () => {
|
|
// The conservative refusal applies only when the caller leans on
|
|
// the px fallback. Em letter-spacing is per-element by definition,
|
|
// so an em font-size declaration is irrelevant to the check.
|
|
const html = `
|
|
<style>
|
|
.display { font-size: 2em; text-transform: uppercase; letter-spacing: 0.08em; }
|
|
</style>
|
|
`;
|
|
const findings = lintArtifact(html);
|
|
expect(findings.find((f) => f.id === 'all-caps-no-tracking')).toBeUndefined();
|
|
});
|
|
|
|
it('flags inline 3rem heading with 1px tracking', () => {
|
|
// Same regression reproduced through the inline-style branch.
|
|
const html = `<h1 style="font-size: 3rem; text-transform: uppercase; letter-spacing: 1px">Headline</h1>`;
|
|
const findings = lintArtifact(html);
|
|
expect(findings.find((f) => f.id === 'all-caps-no-tracking')).toBeDefined();
|
|
});
|
|
|
|
it('flags an uppercase rule whose only `letter-spacing` is a custom-property declaration', () => {
|
|
// Regression: the previous substring regex matched
|
|
// `--letter-spacing: 0.08em` because it scanned the whole rule body
|
|
// for `letter-spacing\s*:`. Token-name declarations have no rendered
|
|
// effect, so the rule renders ALL CAPS without tracking and must
|
|
// still trip the P1 lint.
|
|
const html = `
|
|
<style>
|
|
.eyebrow { text-transform: uppercase; --letter-spacing: 0.08em; }
|
|
</style>
|
|
<span class="eyebrow">New</span>
|
|
`;
|
|
const findings = lintArtifact(html);
|
|
expect(findings.find((f) => f.id === 'all-caps-no-tracking')).toBeDefined();
|
|
});
|
|
|
|
it('flags a 48px heading whose only `font-size` is a custom-property declaration and tracking is below the floor', () => {
|
|
// Regression: `--display-font-size: 48px` previously satisfied the
|
|
// bail-out branch that detected an unresolvable font-size, masking
|
|
// the fact that no real font-size is declared. With token names
|
|
// ignored, the rule falls back to the conservative >=1px floor and
|
|
// 0.5px tracking is correctly flagged.
|
|
const html = `
|
|
<style>
|
|
.display { text-transform: uppercase; --display-font-size: 48px; letter-spacing: 0.5px; }
|
|
</style>
|
|
<h1 class="display">Headline</h1>
|
|
`;
|
|
const findings = lintArtifact(html);
|
|
expect(findings.find((f) => f.id === 'all-caps-no-tracking')).toBeDefined();
|
|
});
|
|
|
|
it('flags inline uppercase whose only `letter-spacing` is a custom-property declaration', () => {
|
|
const html = `<span style="text-transform: uppercase; --letter-spacing: 0.08em">NEW</span>`;
|
|
const findings = lintArtifact(html);
|
|
expect(findings.find((f) => f.id === 'all-caps-no-tracking')).toBeDefined();
|
|
});
|
|
|
|
it('flags an uppercase rule whose compliant letter-spacing is overridden by a later noncompliant one', () => {
|
|
// Regression: the helper used to pick the FIRST matching
|
|
// letter-spacing declaration in the rule, but CSS applies the LAST
|
|
// effective declaration in source order. So
|
|
// `.eyebrow { letter-spacing: 0.08em; letter-spacing: 0.02em }`
|
|
// renders the noncompliant 0.02em — the lint must judge against the
|
|
// last declaration, not the first.
|
|
const html = `
|
|
<style>
|
|
.eyebrow { text-transform: uppercase; letter-spacing: 0.08em; letter-spacing: 0.02em; }
|
|
</style>
|
|
<span class="eyebrow">New</span>
|
|
`;
|
|
const findings = lintArtifact(html);
|
|
expect(findings.find((f) => f.id === 'all-caps-no-tracking')).toBeDefined();
|
|
});
|
|
|
|
it('flags an inline uppercase whose compliant letter-spacing is overridden by a later noncompliant one', () => {
|
|
const html = `<span style="text-transform: uppercase; letter-spacing: 0.08em; letter-spacing: 0.02em">NEW</span>`;
|
|
const findings = lintArtifact(html);
|
|
expect(findings.find((f) => f.id === 'all-caps-no-tracking')).toBeDefined();
|
|
});
|
|
|
|
it('flags a 14px label whose 1px tracking would pass but a later font-size: 100px shifts the floor', () => {
|
|
// Regression: `resolveFontSizePx` used to pick the FIRST matching
|
|
// font-size declaration; the cascade resolves to the LAST. With
|
|
// `font-size: 14px; font-size: 100px`, the rendered floor is
|
|
// `100 * 0.06 = 6px`, so 1px tracking is well below the rule and
|
|
// must flag — even though the stale 14px would have accepted it
|
|
// (14 * 0.06 = 0.84px floor).
|
|
const html = `
|
|
<style>
|
|
.badge { font-size: 14px; font-size: 100px; text-transform: uppercase; letter-spacing: 1px; }
|
|
</style>
|
|
`;
|
|
const findings = lintArtifact(html);
|
|
expect(findings.find((f) => f.id === 'all-caps-no-tracking')).toBeDefined();
|
|
});
|
|
|
|
it('passes when the compliant letter-spacing is the LAST declaration (override of an earlier noncompliant one)', () => {
|
|
// Sanity check: the cascade fix must not regress the inverse case.
|
|
// An author intentionally restoring the floor with a later override
|
|
// — `letter-spacing: 0.02em; letter-spacing: 0.08em` — renders 0.08em
|
|
// and must not fire the lint.
|
|
const html = `
|
|
<style>
|
|
.eyebrow { text-transform: uppercase; letter-spacing: 0.02em; letter-spacing: 0.08em; }
|
|
</style>
|
|
`;
|
|
const findings = lintArtifact(html);
|
|
expect(findings.find((f) => f.id === 'all-caps-no-tracking')).toBeUndefined();
|
|
});
|
|
|
|
it('flags an uppercase rule when conflicting :root and [data-theme] tokens disagree on the floor', () => {
|
|
// Regression: `extractCssTokens` used to flatten all global theme-
|
|
// scope tokens to one map with last-write-wins, regardless of the
|
|
// selector that scoped each value. A scoped override that lifted
|
|
// the token above the floor could rescue a default-theme value
|
|
// that rendered below it, just because the second declaration
|
|
// happened to be parsed last. The helper now enumerates every
|
|
// applicable value and only passes if all resolutions satisfy the
|
|
// 0.06em floor — so the default-theme 0.02em still trips the lint.
|
|
const html = `
|
|
<style>
|
|
:root { --caps-tracking: 0.02em; }
|
|
[data-theme="dark"] { --caps-tracking: 0.08em; }
|
|
.eyebrow { text-transform: uppercase; letter-spacing: var(--caps-tracking); }
|
|
</style>
|
|
<span class="eyebrow">New</span>
|
|
`;
|
|
const findings = lintArtifact(html);
|
|
expect(findings.find((f) => f.id === 'all-caps-no-tracking')).toBeDefined();
|
|
});
|
|
|
|
it('flags an uppercase rule even when the conflicting :root override comes second', () => {
|
|
// Same regression but with declaration order swapped — the previous
|
|
// last-write-wins behaviour was order-dependent, so both orderings
|
|
// must fail when ANY resolution is below the floor.
|
|
const html = `
|
|
<style>
|
|
[data-theme="dark"] { --caps-tracking: 0.08em; }
|
|
:root { --caps-tracking: 0.02em; }
|
|
.eyebrow { text-transform: uppercase; letter-spacing: var(--caps-tracking); }
|
|
</style>
|
|
`;
|
|
const findings = lintArtifact(html);
|
|
expect(findings.find((f) => f.id === 'all-caps-no-tracking')).toBeDefined();
|
|
});
|
|
|
|
it('passes when every conflicting scoped token value clears the floor', () => {
|
|
// The conservative cascade must not over-fire: when ALL theme
|
|
// variants of a token satisfy the 0.06em rule, the artifact is
|
|
// compliant under every applicable theme and the lint must not
|
|
// fire.
|
|
const html = `
|
|
<style>
|
|
:root { --caps-tracking: 0.08em; }
|
|
[data-theme="dark"] { --caps-tracking: 0.10em; }
|
|
.eyebrow { text-transform: uppercase; letter-spacing: var(--caps-tracking); }
|
|
</style>
|
|
`;
|
|
const findings = lintArtifact(html);
|
|
expect(findings.find((f) => f.id === 'all-caps-no-tracking')).toBeUndefined();
|
|
});
|
|
|
|
it('passes when a single :root block redeclares the token with a compliant value last', () => {
|
|
// Regression: `extractCssTokens` used to record every distinct
|
|
// value seen for a custom property, even when the duplicates lived
|
|
// in the SAME cascade scope. CSS source-order cascade collapses
|
|
// `:root { --caps-tracking: 0.02em; --caps-tracking: 0.08em; }`
|
|
// to the second declaration — the first is dead weight, never
|
|
// reaches any element. Treating both as theme alternatives fed the
|
|
// stale 0.02em into `hasAdequateUppercaseTracking` and emitted a
|
|
// spurious P1 on what is normal CSS overriding. The fix collapses
|
|
// duplicate declarations within a single rule body to the last
|
|
// value before merging into the cross-scope token map.
|
|
const html = `
|
|
<style>
|
|
:root { --caps-tracking: 0.02em; --caps-tracking: 0.08em; }
|
|
.eyebrow { text-transform: uppercase; letter-spacing: var(--caps-tracking); }
|
|
</style>
|
|
<span class="eyebrow">New</span>
|
|
`;
|
|
const findings = lintArtifact(html);
|
|
expect(findings.find((f) => f.id === 'all-caps-no-tracking')).toBeUndefined();
|
|
});
|
|
|
|
it('flags a 48px heading with 1px tracking nested inside @media (innermost-rule scan)', () => {
|
|
// Regression: `upperRe` used `[^}]*` for the rule body, so an
|
|
// outer `@media (...) { .display { font-size: 48px; text-transform:
|
|
// uppercase; letter-spacing: 1px; } }` matched as one rule whose
|
|
// selector was `@media (...)` and whose body began with
|
|
// `.display { font-size: 48px`. `parseDeclarations` then read the
|
|
// first property as `.display { font-size`, lost the same-rule
|
|
// font-size, and `hasAdequateUppercaseTracking` fell back to the
|
|
// lenient inherited-size path that accepts 1px on a 48px heading.
|
|
// Restricting the body alternation to `[^{}]*` makes the regex
|
|
// skip the `@media` wrapper and match the inner rule directly,
|
|
// restoring the strict per-element 0.06em floor (48 * 0.06 =
|
|
// 2.88px), so 1px tracking is correctly flagged.
|
|
const html = `
|
|
<style>
|
|
@media (min-width: 768px) {
|
|
.display { font-size: 48px; text-transform: uppercase; letter-spacing: 1px; }
|
|
}
|
|
</style>
|
|
<h1 class="display">Headline</h1>
|
|
`;
|
|
const findings = lintArtifact(html);
|
|
expect(findings.find((f) => f.id === 'all-caps-no-tracking')).toBeDefined();
|
|
});
|
|
|
|
it('flags a 48px heading with 1px tracking nested inside @supports', () => {
|
|
// Same regression reproduced through @supports, the other
|
|
// common at-rule wrapper that previously hid noncompliant
|
|
// tracking from the lint.
|
|
const html = `
|
|
<style>
|
|
@supports (color: oklch(0 0 0)) {
|
|
.display { font-size: 48px; text-transform: uppercase; letter-spacing: 1px; }
|
|
}
|
|
</style>
|
|
<h1 class="display">Headline</h1>
|
|
`;
|
|
const findings = lintArtifact(html);
|
|
expect(findings.find((f) => f.id === 'all-caps-no-tracking')).toBeDefined();
|
|
});
|
|
|
|
it('passes paired light/dark token values that are each compliant in their own scope', () => {
|
|
// Regression: `extractCssTokens` merged token values by name across
|
|
// scopes (`--caps-tracking = [1px, 3px]`, `--display-size = [16px,
|
|
// 48px]`), and the tracking helper then took an independent
|
|
// per-token cartesian product. The impossible cross-theme pairing
|
|
// `(--display-size: 48px, --caps-tracking: 1px)` failed the
|
|
// 0.06em floor (48 * 0.06 = 2.88px > 1px) and emitted a false
|
|
// `all-caps-no-tracking` even though the artifact is compliant
|
|
// under both real themes:
|
|
// default: 16px size + 1px tracking — 1 / 16 ≈ 0.0625em ≥ 0.06em
|
|
// dark: 48px size + 3px tracking — 3 / 48 ≈ 0.0625em ≥ 0.06em
|
|
// The fix preserves per-scope token maps and evaluates per-theme
|
|
// effective maps so paired declarations stay paired.
|
|
const html = `
|
|
<style>
|
|
:root { --caps-tracking: 1px; --display-size: 16px; }
|
|
[data-theme="dark"] { --caps-tracking: 3px; --display-size: 48px; }
|
|
.display {
|
|
font-size: var(--display-size);
|
|
text-transform: uppercase;
|
|
letter-spacing: var(--caps-tracking);
|
|
}
|
|
</style>
|
|
<h1 class="display">Headline</h1>
|
|
`;
|
|
const findings = lintArtifact(html);
|
|
expect(findings.find((f) => f.id === 'all-caps-no-tracking')).toBeUndefined();
|
|
});
|
|
|
|
it('flags paired theme tokens when one scope is internally noncompliant', () => {
|
|
// The per-theme evaluation must not silently rescue a scope whose
|
|
// own paired values fall below the floor. Default theme here is
|
|
// 48px size + 1px tracking — 1 / 48 ≈ 0.021em, well below the
|
|
// 0.06em rule — and must flag, even though the dark scope is
|
|
// internally compliant.
|
|
const html = `
|
|
<style>
|
|
:root { --caps-tracking: 1px; --display-size: 48px; }
|
|
[data-theme="dark"] { --caps-tracking: 3px; --display-size: 48px; }
|
|
.display {
|
|
font-size: var(--display-size);
|
|
text-transform: uppercase;
|
|
letter-spacing: var(--caps-tracking);
|
|
}
|
|
</style>
|
|
`;
|
|
const findings = lintArtifact(html);
|
|
expect(findings.find((f) => f.id === 'all-caps-no-tracking')).toBeDefined();
|
|
});
|
|
});
|
|
|
|
describe('trust-gradient', () => {
|
|
it('flags a blue→cyan two-stop gradient with hex stops', () => {
|
|
// Regression: `craft/anti-ai-slop.md` documents blue→cyan as a
|
|
// P0 cardinal-sin trust gradient, but the existing purple-gradient
|
|
// rule only matches violet/indigo hex stops or the literal
|
|
// `purple`/`violet` keywords. A pure blue→cyan gradient slipped
|
|
// past unflagged. The new `trust-gradient` rule closes that gap.
|
|
const html = `<div style="background: linear-gradient(90deg, #3b82f6, #06b6d4)">Hi</div>`;
|
|
const findings = lintArtifact(html);
|
|
const hit = findings.find((f) => f.id === 'trust-gradient');
|
|
expect(hit).toBeDefined();
|
|
expect(hit.severity).toBe('P0');
|
|
});
|
|
|
|
it('flags a blue→cyan two-stop gradient with keyword stops', () => {
|
|
const html = `<div style="background: linear-gradient(90deg, blue, cyan)">Hi</div>`;
|
|
const findings = lintArtifact(html);
|
|
const hit = findings.find((f) => f.id === 'trust-gradient');
|
|
expect(hit).toBeDefined();
|
|
expect(hit.severity).toBe('P0');
|
|
});
|
|
|
|
it('flags a sky→cyan gradient (sky shares the blue ramp under another name)', () => {
|
|
const html = `<div style="background: linear-gradient(135deg, #0ea5e9, #22d3ee)">Hi</div>`;
|
|
const findings = lintArtifact(html);
|
|
expect(findings.find((f) => f.id === 'trust-gradient')).toBeDefined();
|
|
});
|
|
|
|
it('does not double-fire when purple-gradient already caught a purple→blue/cyan stop list', () => {
|
|
// A gradient that mixes purple/indigo with blue/cyan triggers
|
|
// purple-gradient first. The trust-gradient rule must skip in that
|
|
// case so the agent gets a single corrective signal.
|
|
const html = `<div style="background: linear-gradient(90deg, #6366f1, #06b6d4)">Hi</div>`;
|
|
const findings = lintArtifact(html);
|
|
expect(findings.find((f) => f.id === 'purple-gradient')).toBeDefined();
|
|
expect(findings.find((f) => f.id === 'trust-gradient')).toBeUndefined();
|
|
});
|
|
|
|
it('does not flag a blue-only gradient (no cyan stop)', () => {
|
|
// A single-color gradient (blue→darker-blue) is a different
|
|
// pattern; only the documented two-color blue→cyan trust ramp
|
|
// is the AI tell.
|
|
const html = `<div style="background: linear-gradient(90deg, #3b82f6, #1e40af)">Hi</div>`;
|
|
const findings = lintArtifact(html);
|
|
expect(findings.find((f) => f.id === 'trust-gradient')).toBeUndefined();
|
|
});
|
|
|
|
it('does not flag a gradient with only cyan stops', () => {
|
|
const html = `<div style="background: linear-gradient(90deg, #06b6d4, #0891b2)">Hi</div>`;
|
|
const findings = lintArtifact(html);
|
|
expect(findings.find((f) => f.id === 'trust-gradient')).toBeUndefined();
|
|
});
|
|
|
|
it('flags a blue→cyan gradient declared inside a <style> block', () => {
|
|
const html = `
|
|
<style>
|
|
.hero { background: linear-gradient(90deg, #3b82f6, #06b6d4); }
|
|
</style>
|
|
<div class="hero">Welcome</div>
|
|
`;
|
|
const findings = lintArtifact(html);
|
|
expect(findings.find((f) => f.id === 'trust-gradient')).toBeDefined();
|
|
});
|
|
});
|