mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-21 02:26:31 +03:00
detector: four human-review rules — nav-CTA oklch contrast, numbered section labels, floating side-tab stripes, repeated card text
Four gaps found shipping in Opus 4.8 eval samples during human review: 1. low-contrast (extended): the browser adapters parsed text/own-bg colors with parseRgb only, so Chrome's oklch()-serialized computed colors silently skipped every contrast check — a flat dark-on-dark nav CTA (broader nav selector beating the button class) shipped at 1.5:1 undetected. checkElementColorsDOM and readOwnBackgroundColor now fall back to parseAnyColor. Near-threshold ratios print two decimals so a 4.497 finding no longer reads "4.5 needs 4.5". 2. NEW numbered-section-labels (slop, advisory): tiny (<=13px) styled numeric index labels riding beside section headings, repeated across 2+ sections with distinct indices. Sibling of repeated-section-kickers (which deliberately excludes bare numeric labels); handles both the direct prev-sibling shape and label-before-heading-wrapper shape. List/nav/table/card-item numbering is exempt. 3. side-tab (extended): the vertical pseudo-element stripe scan required the stripe to touch both corners (top/bottom 0 or height 100%), so a left accent bar inset a few px from each end evaded it; small end insets (<=20px each) now count. Added a browser-side pseudo-element check (getComputedStyle(el, '::before'/'::after')) since runtime- assigned custom-property colors are invisible to the text scanner. Selection-state exemptions stay as narrowed: only aria-selected=true / aria-current / active-class markers exempt, plus button/link affordances on the horizontal variant. 4. NEW repeated-container-text (quality): the same literal string (>=4 chars, contains letters) rendered 3+ times at 3+ structurally distinct positions inside one bordered/elevated container. Parallel/templated repetition (table cells, calendar grids, nav lists, identical sibling rows) never counts — structural signatures, not word lists. Verified: each rule fires on its repro sample via the file:// browser scan; clean eval samples add no new findings (the new low-contrast hits on other samples are genuine sub-AA oklch button pairs). Full test suite green; browser bundle regenerated; README/homepage rule counts bumped 49 -> 51 (docs-integrity test enforces them). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
50fc88ec85
commit
7a99e1725d
@@ -1,6 +1,6 @@
|
||||
# Impeccable
|
||||
|
||||
Design guidance for AI coding agents. 1 skill, 23 commands, live browser iteration, and 49 deterministic detector rules for AI-generated frontend design.
|
||||
Design guidance for AI coding agents. 1 skill, 23 commands, live browser iteration, and 51 deterministic detector rules for AI-generated frontend design.
|
||||
|
||||
> **Quick start:** From your project root, run `npx impeccable install`, then run `/impeccable init` inside your AI coding tool. Full docs: [impeccable.style](https://impeccable.style).
|
||||
|
||||
@@ -13,7 +13,7 @@ Every model trained on the same SaaS templates. Skip the guidance and you get th
|
||||
Impeccable adds:
|
||||
- **One setup flow.** `/impeccable init` writes `PRODUCT.md` and offers `DESIGN.md`, so later commands know the audience, brand/product lane, voice, anti-references, colors, type, and components.
|
||||
- **23 commands.** A shared design vocabulary with your AI: `polish`, `audit`, `critique`, `distill`, `animate`, `bolder`, `quieter`, and more.
|
||||
- **49 deterministic detector rules** plus LLM-only critique checks. The CLI and browser extension run the deterministic rules with no LLM and no API key.
|
||||
- **51 deterministic detector rules** plus LLM-only critique checks. The CLI and browser extension run the deterministic rules with no LLM and no API key.
|
||||
|
||||
## What's Included
|
||||
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
# Impeccable CLI
|
||||
|
||||
Detect UI anti-patterns and design quality issues from the command line. Scans HTML, CSS, JSX, TSX, Vue, and Svelte files for 49 deterministic rules, including AI-generated UI tells, accessibility violations, and general design quality problems.
|
||||
Detect UI anti-patterns and design quality issues from the command line. Scans HTML, CSS, JSX, TSX, Vue, and Svelte files for 51 deterministic rules, including AI-generated UI tells, accessibility violations, and general design quality problems.
|
||||
|
||||
## Quick Start
|
||||
|
||||
@@ -56,7 +56,7 @@ npx impeccable detect --fast src/
|
||||
|
||||
**Quality**: tiny body text, cramped padding, long line lengths, small touch targets
|
||||
|
||||
49 deterministic detector rules in total. See the full catalog at [impeccable.style/slop](https://impeccable.style/slop).
|
||||
51 deterministic detector rules in total. See the full catalog at [impeccable.style/slop](https://impeccable.style/slop).
|
||||
|
||||
## Exit Codes
|
||||
|
||||
|
||||
@@ -1477,6 +1477,7 @@ if (IS_BROWSER) {
|
||||
|
||||
const findings = [
|
||||
...checkElementBordersDOM(el).map(f => ({ type: f.id, detail: f.snippet })),
|
||||
...checkElementPseudoStripeDOM(el).map(f => ({ type: f.id, detail: f.snippet })),
|
||||
...checkElementColorsDOM(el).map(f => ({ type: f.id, detail: f.snippet })),
|
||||
...checkElementMotionDOM(el).map(f => ({ type: f.id, detail: f.snippet })),
|
||||
...checkElementGlowDOM(el).map(f => ({ type: f.id, detail: f.snippet })),
|
||||
@@ -1526,6 +1527,22 @@ if (IS_BROWSER) {
|
||||
addBrowserFindings(groupMap, document.body, sectionKickerFindings);
|
||||
}
|
||||
|
||||
const numberedLabelFindings = checkNumberedSectionLabelsDOM()
|
||||
.map(f => ({ type: f.id, detail: f.snippet }))
|
||||
.filter(f => _ruleOk(f.type));
|
||||
if (numberedLabelFindings.length > 0) {
|
||||
pageLevelFindings.push(...numberedLabelFindings);
|
||||
addBrowserFindings(groupMap, document.body, numberedLabelFindings);
|
||||
}
|
||||
|
||||
const repeatedTextFindings = checkRepeatedContainerTextDOM()
|
||||
.map(f => ({ type: f.id, detail: f.snippet }))
|
||||
.filter(f => _ruleOk(f.type));
|
||||
if (repeatedTextFindings.length > 0) {
|
||||
pageLevelFindings.push(...repeatedTextFindings);
|
||||
addBrowserFindings(groupMap, document.body, repeatedTextFindings);
|
||||
}
|
||||
|
||||
const layoutFindings = checkLayout().filter(f => _ruleOk(f.type));
|
||||
for (const f of layoutFindings) {
|
||||
const el = f.el || document.body;
|
||||
|
||||
@@ -283,6 +283,17 @@ const ANTIPATTERNS = [
|
||||
skillSection: 'Typography',
|
||||
skillGuideline: 'repeated eyebrow or kicker labels as section scaffolding',
|
||||
},
|
||||
{
|
||||
id: 'numbered-section-labels',
|
||||
category: 'slop',
|
||||
scopes: ['type'],
|
||||
severity: 'advisory',
|
||||
name: 'Tiny numbered section labels',
|
||||
description:
|
||||
'Small numeric index labels riding next to section headings, repeated section after section, are AI editorial scaffolding — a page numbering its own chapters instead of earning structure. Let hierarchy, content, and rhythm carry the sequence.',
|
||||
skillSection: 'Layout & Space',
|
||||
skillGuideline: 'numbered section markers',
|
||||
},
|
||||
{
|
||||
id: 'numbered-section-markers',
|
||||
category: 'slop',
|
||||
@@ -465,6 +476,13 @@ const ANTIPATTERNS = [
|
||||
skillSection: 'Layout & Space',
|
||||
skillGuideline: 'content wider than its container',
|
||||
},
|
||||
{
|
||||
id: 'repeated-container-text',
|
||||
category: 'quality',
|
||||
name: 'Same text repeated inside one container',
|
||||
description:
|
||||
'The same literal text rendered three or more times in structurally different spots inside a single card or panel is redundant messaging — usually a status or label wired into every slot of a template. Say it once, in the slot where it matters most.',
|
||||
},
|
||||
{
|
||||
id: 'clipped-overflow-container',
|
||||
category: 'quality',
|
||||
@@ -827,7 +845,11 @@ function checkColors(opts) {
|
||||
// like `text-paper/60` on `bg-ink` sections are the FP pattern.
|
||||
const isAlphaFallbackFP = !DETECTOR_IS_BROWSER && !effectiveBg && (textColor.a != null && textColor.a < 1);
|
||||
if (!isAlphaFallbackFP) {
|
||||
findings.push({ id: 'low-contrast', snippet: `${ratio.toFixed(1)}:1 (need ${threshold}:1) — text ${colorToHex(textColor)} on ${colorToHex(bgs[worstIdx])}` });
|
||||
// Near-threshold ratios (e.g. 4.497) would round to the threshold
|
||||
// itself at one decimal and read as "4.5 needs 4.5" — show two
|
||||
// decimals there so the finding stays legible.
|
||||
const ratioLabel = ratio.toFixed(1) === threshold.toFixed(1) ? ratio.toFixed(2) : ratio.toFixed(1);
|
||||
findings.push({ id: 'low-contrast', snippet: `${ratioLabel}:1 (need ${threshold}:1) — text ${colorToHex(textColor)} on ${colorToHex(bgs[worstIdx])}` });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1484,8 +1506,16 @@ function scanCssTextForPseudoStripe(content) {
|
||||
let edge = null;
|
||||
let thicknessPx = null;
|
||||
if (verticalCandidate) {
|
||||
// Full-height stripes hug both corners; the "floating" variant backs
|
||||
// off each end by a small inset (top/bottom a few px) so the bar
|
||||
// clears the card's corners. Both read as the same side-tab accent —
|
||||
// corner treatment is styling, not a different pattern.
|
||||
const topPx = cssLengthToPx(resolveVarRefs(String(offsets.top ?? ''), customProps));
|
||||
const bottomPx = cssLengthToPx(resolveVarRefs(String(offsets.bottom ?? ''), customProps));
|
||||
const fullHeight = (isZeroOffset(offsets.top) && isZeroOffset(offsets.bottom))
|
||||
|| /^100(?:\.0*)?%$/.test(heightValue);
|
||||
|| /^100(?:\.0*)?%$/.test(heightValue)
|
||||
|| (topPx != null && bottomPx != null
|
||||
&& topPx >= 0 && topPx <= 20 && bottomPx >= 0 && bottomPx <= 20);
|
||||
if (fullHeight) {
|
||||
edge = isZeroOffset(offsets.left) ? 'left'
|
||||
: isZeroOffset(offsets.right) ? 'right' : null;
|
||||
@@ -2035,7 +2065,11 @@ function checkHtmlPatterns(html) {
|
||||
// `background: #abc`. Real browsers always decompose, so the fallback is
|
||||
// a no-op there.
|
||||
function readOwnBackgroundColor(el, computedStyle) {
|
||||
const bg = parseRgb(computedStyle.backgroundColor);
|
||||
// Real browsers keep wide-gamut/computed color functions (oklch(), oklab(),
|
||||
// color-mix() results) in getComputedStyle output, which plain parseRgb
|
||||
// misses — a flat oklch button background would silently skip every
|
||||
// contrast check without the parseAnyColor fallback.
|
||||
const bg = parseRgb(computedStyle.backgroundColor) || parseAnyColor(computedStyle.backgroundColor);
|
||||
if (DETECTOR_IS_BROWSER || (bg && bg.a >= 0.1)) return bg;
|
||||
const rawStyle = el.getAttribute?.('style') || '';
|
||||
const bgMatch = rawStyle.match(/background(?:-color)?\s*:\s*([^;]+)/i);
|
||||
@@ -2230,6 +2264,77 @@ function checkElementBordersDOM(el) {
|
||||
});
|
||||
}
|
||||
|
||||
// Browser-side twin of scanCssTextForPseudoStripe. The text scanner reads
|
||||
// stylesheet source, so a stripe whose color only exists at runtime (an
|
||||
// inline per-card custom property, a JS-assigned var) or whose geometry
|
||||
// resolves in layout never matches it. In a real browser the pseudo-element's
|
||||
// computed style carries the actual used color and px geometry — check those
|
||||
// directly. Gates mirror the text scanner: 3-12px thick, chromatic fill,
|
||||
// spanning (nearly) the full edge; corner rounding on the host card is
|
||||
// irrelevant. Exemptions stay narrow: structural/prose tags, real selection
|
||||
// markers (isTabContextElement), and button/link affordances for the
|
||||
// horizontal variant.
|
||||
function checkElementPseudoStripeDOM(el) {
|
||||
const tag = el.tagName.toLowerCase();
|
||||
if (BORDER_SAFE_TAGS.has(tag) || tag === 'summary') return [];
|
||||
if (el.closest?.('nav, blockquote, pre')) return [];
|
||||
if (!isRenderedForBrowserRule(el)) return [];
|
||||
const rect = el.getBoundingClientRect();
|
||||
if (rect.width < 40 || rect.height < 20) return [];
|
||||
if (isTabContextElement(el)) return [];
|
||||
|
||||
const findings = [];
|
||||
for (const which of ['::before', '::after']) {
|
||||
let ps;
|
||||
try { ps = getComputedStyle(el, which); } catch { continue; }
|
||||
if (!ps || ps.content === 'none' || ps.content === '') continue;
|
||||
if (ps.position !== 'absolute' && ps.position !== 'fixed') continue;
|
||||
if ((parseFloat(ps.opacity) || 0) <= 0.01 || ps.display === 'none') continue;
|
||||
const w = parseFloat(ps.width) || 0;
|
||||
const h = parseFloat(ps.height) || 0;
|
||||
if (!(w > 0 && h > 0)) continue;
|
||||
|
||||
// Used values: for absolutely-positioned boxes the browser resolves
|
||||
// both edge offsets after layout, so left/right (and top/bottom) are
|
||||
// real distances, never "auto".
|
||||
const left = parseFloat(ps.left);
|
||||
const right = parseFloat(ps.right);
|
||||
const top = parseFloat(ps.top);
|
||||
const bottom = parseFloat(ps.bottom);
|
||||
const hugs = (v) => Number.isFinite(v) && v >= -2 && v <= 2;
|
||||
|
||||
let edge = null;
|
||||
let thickness = null;
|
||||
// Vertical stripe: narrow box spanning (nearly) the full height of the
|
||||
// host, hugging its left or right edge. "Nearly" tolerates the floating
|
||||
// variant that backs off each end by a small inset.
|
||||
if (w >= 3 && w <= 12 && h >= rect.height - 44 && h >= rect.height * 0.5) {
|
||||
edge = hugs(left) ? 'left' : hugs(right) ? 'right' : null;
|
||||
thickness = w;
|
||||
}
|
||||
// Horizontal stripe riding the top or bottom edge. Button/link-styled
|
||||
// hosts keep their underline affordances.
|
||||
if (!edge && h >= 3 && h <= 12 && w >= rect.width - 44 && w >= rect.width * 0.5) {
|
||||
const cls = String(el.getAttribute?.('class') || el.className || '');
|
||||
if (!/(?:^|[\s_-])(?:btn|button|link)(?:$|[\s\w_-])/i.test(cls)) {
|
||||
edge = hugs(top) ? 'top' : hugs(bottom) ? 'bottom' : null;
|
||||
thickness = h;
|
||||
}
|
||||
}
|
||||
if (!edge) continue;
|
||||
|
||||
const bg = parseRgb(ps.backgroundColor) || parseAnyColor(ps.backgroundColor);
|
||||
if (!bg || (bg.a ?? 1) < 0.1) continue;
|
||||
if (Math.max(bg.r, bg.g, bg.b) - Math.min(bg.r, bg.g, bg.b) < 30) continue;
|
||||
|
||||
findings.push({
|
||||
id: 'side-tab',
|
||||
snippet: `${classSelector(el)}${which} — absolute ${thickness}px pseudo-element stripe (${edge})`,
|
||||
});
|
||||
}
|
||||
return findings;
|
||||
}
|
||||
|
||||
function checkElementColorsDOM(el) {
|
||||
const tag = el.tagName.toLowerCase();
|
||||
// No early SAFE_TAGS bail here — checkColors() does its own gating that
|
||||
@@ -2243,7 +2348,12 @@ function checkElementColorsDOM(el) {
|
||||
const effectiveBg = resolveBackground(el);
|
||||
return checkColors({
|
||||
tag,
|
||||
textColor: parseRgb(style.color),
|
||||
// Chrome serializes computed colors specified in modern spaces as
|
||||
// oklch()/oklab() strings; without the parseAnyColor fallback the text
|
||||
// color comes back null and the low-contrast / gray-on-color checks
|
||||
// silently never run (the shipped miss: a nav CTA whose text color was
|
||||
// an oklch token near its own oklch background).
|
||||
textColor: parseRgb(style.color) || parseAnyColor(style.color),
|
||||
bgColor: readOwnBackgroundColor(el, style),
|
||||
effectiveBg,
|
||||
effectiveBgStops: effectiveBg ? null : resolveGradientStops(el),
|
||||
@@ -2800,6 +2910,143 @@ function checkRepeatedSectionKickersDOM() {
|
||||
return checkRepeatedSectionKickers({ candidates });
|
||||
}
|
||||
|
||||
// ── Numbered section labels ─────────────────────────────────────────────────
|
||||
// Sibling of the repeated-kicker rule: instead of a tracked uppercase word,
|
||||
// the section scaffold is a tiny numeric index riding beside each section
|
||||
// heading — bare and zero-padded, or an index joined to a short micro-label
|
||||
// by a separator glyph. The kicker rule deliberately excludes bare 1-2 digit
|
||||
// labels; this rule owns that shape.
|
||||
|
||||
const NUMBERED_LABEL_TAGS = new Set(['span', 'p', 'div', 'small', 'em', 'strong', 'b']);
|
||||
|
||||
// Returns { index, text } when the trimmed text reads as a section index
|
||||
// label, else null. Two accepted shapes: a zero-padded/two-digit bare index,
|
||||
// or a 1-2 digit index followed by a non-word separator and a short label.
|
||||
function parseNumberedLabelText(rawText) {
|
||||
const text = (rawText || '').replace(/\s+/g, ' ').trim();
|
||||
if (!text || text.length > 40) return null;
|
||||
let m = /^(\d{2})$/.exec(text);
|
||||
if (!m) m = /^(\d{1,2})\s*[^\w\s]\s*\S/.exec(text);
|
||||
if (!m) return null;
|
||||
const index = parseInt(m[1], 10);
|
||||
if (!Number.isFinite(index) || index > 40) return null;
|
||||
return { index, text };
|
||||
}
|
||||
|
||||
function isNumberedSectionLabelCandidate(opts) {
|
||||
const {
|
||||
headingTag, headingText, headingFontSize,
|
||||
labelTag, labelIndex, labelText,
|
||||
labelFontSize, labelLetterSpacing, labelFontWeight,
|
||||
labelFontFamily, labelTextTransform, labelColor,
|
||||
} = opts;
|
||||
if (!['h2', 'h3', 'h4'].includes(headingTag)) return false;
|
||||
if (!headingText || headingText.length < 3) return false;
|
||||
if (!labelTag || !NUMBERED_LABEL_TAGS.has(labelTag)) return false;
|
||||
if (labelIndex == null || !labelText) return false;
|
||||
// Tiny rendered size is the tell — a display-scale section number is a
|
||||
// different (deliberate) device and stays legal.
|
||||
if (!(labelFontSize > 0 && labelFontSize <= 13)) return false;
|
||||
// The heading must be visibly larger where we can resolve its size.
|
||||
// clamp()/var() sizes come back unparseable (0) in the static engine —
|
||||
// the remaining gates carry the check there.
|
||||
if (headingFontSize > 0 && headingFontSize < labelFontSize * 1.3) return false;
|
||||
// Deliberate micro-label styling separates the scaffold from incidental
|
||||
// small text: mono face, bold weight, tracking, uppercase, or accent color.
|
||||
const weight = Number(labelFontWeight) || 400;
|
||||
return /mono/i.test(labelFontFamily || '')
|
||||
|| weight >= 600
|
||||
|| (labelLetterSpacing || 0) >= 0.5
|
||||
|| (labelTextTransform || '') === 'uppercase'
|
||||
|| isAccentColor(labelColor || '');
|
||||
}
|
||||
|
||||
function collectNumberedSectionLabelCandidates(doc, getStyle, resolveLetterSpacing) {
|
||||
const candidates = [];
|
||||
const seenLabels = new Set();
|
||||
for (const heading of doc.querySelectorAll('h2, h3, h4')) {
|
||||
if (heading.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue;
|
||||
// The index sits either directly before the heading, or before the
|
||||
// wrapper the heading leads (label | <div><h2>…</h2>…</div>).
|
||||
let label = heading.previousElementSibling;
|
||||
if (!label) {
|
||||
const parent = heading.parentElement;
|
||||
const firstChild = parent?.children?.[0];
|
||||
if (firstChild === heading) label = parent.previousElementSibling;
|
||||
}
|
||||
if (!label || seenLabels.has(label)) continue;
|
||||
if (label.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue;
|
||||
if (HEADING_TAGS.has(label.tagName.toLowerCase())) continue;
|
||||
if (isRepeatedKickerCardContext(heading, label)) continue;
|
||||
|
||||
const labelText = cleanInlineText(label) || (label.textContent || '').replace(/\s+/g, ' ').trim();
|
||||
const parsed = parseNumberedLabelText(labelText);
|
||||
if (!parsed) continue;
|
||||
|
||||
const headingStyle = getStyle(heading);
|
||||
const labelStyle = getStyle(label);
|
||||
const headingText = (heading.textContent || '').replace(/\s+/g, ' ').trim();
|
||||
const headingFontSize = resolveLetterSpacing(headingStyle.fontSize || '', 16) || parseFloat(headingStyle.fontSize) || 0;
|
||||
const labelFontSize = resolveLetterSpacing(labelStyle.fontSize || '', 16) || parseFloat(labelStyle.fontSize) || 0;
|
||||
|
||||
if (!isNumberedSectionLabelCandidate({
|
||||
headingTag: heading.tagName.toLowerCase(),
|
||||
headingText,
|
||||
headingFontSize,
|
||||
labelTag: label.tagName.toLowerCase(),
|
||||
labelIndex: parsed.index,
|
||||
labelText: parsed.text,
|
||||
labelFontSize,
|
||||
labelLetterSpacing: resolveLetterSpacing(labelStyle.letterSpacing || '', labelFontSize),
|
||||
labelFontWeight: labelStyle.fontWeight || '',
|
||||
labelFontFamily: labelStyle.fontFamily || '',
|
||||
labelTextTransform: labelStyle.textTransform || '',
|
||||
labelColor: labelStyle.color || '',
|
||||
})) {
|
||||
continue;
|
||||
}
|
||||
|
||||
seenLabels.add(label);
|
||||
candidates.push({
|
||||
index: parsed.index,
|
||||
labelText: parsed.text.slice(0, 24),
|
||||
headingTag: heading.tagName.toLowerCase(),
|
||||
headingText: headingText.replace(/^"|"$/g, '').slice(0, 60),
|
||||
});
|
||||
}
|
||||
return candidates;
|
||||
}
|
||||
|
||||
function checkNumberedSectionLabels(opts) {
|
||||
const { candidates, minCount = 2 } = opts;
|
||||
if (!Array.isArray(candidates) || candidates.length < minCount) return [];
|
||||
// A repeated identical number is some other device; the scaffold counts up.
|
||||
const distinctIndices = new Set(candidates.map(c => c.index));
|
||||
if (distinctIndices.size < 2) return [];
|
||||
return candidates.map(candidate => ({
|
||||
id: 'numbered-section-labels',
|
||||
snippet: `tiny numbered label "${candidate.labelText}" beside ${candidate.headingTag} "${candidate.headingText}" (${candidates.length} on page)`,
|
||||
}));
|
||||
}
|
||||
|
||||
function checkNumberedSectionLabelsFromDoc(doc, win) {
|
||||
const candidates = collectNumberedSectionLabelCandidates(
|
||||
doc,
|
||||
(el) => win.getComputedStyle(el),
|
||||
(value, fontSize) => resolveLengthPx(value, fontSize) || 0,
|
||||
);
|
||||
return checkNumberedSectionLabels({ candidates });
|
||||
}
|
||||
|
||||
function checkNumberedSectionLabelsDOM() {
|
||||
const candidates = collectNumberedSectionLabelCandidates(
|
||||
document,
|
||||
(el) => getComputedStyle(el),
|
||||
(value, fontSize) => resolveLengthPx(value, fontSize) || 0,
|
||||
);
|
||||
return checkNumberedSectionLabels({ candidates });
|
||||
}
|
||||
|
||||
function checkElementMotionDOM(el) {
|
||||
const tag = el.tagName.toLowerCase();
|
||||
if (SAFE_TAGS.has(tag)) return [];
|
||||
@@ -3894,6 +4141,135 @@ function checkPageLayout(doc, win) {
|
||||
return findings;
|
||||
}
|
||||
|
||||
// ── Repeated text inside one container ──────────────────────────────────────
|
||||
// The same literal string rendered 3+ times in structurally different spots
|
||||
// inside one bordered/elevated container — typically a status word wired
|
||||
// into every slot of a card template. Legitimate repetition is structural:
|
||||
// table columns, calendar grids, nav/menu lists, and templated sibling rows
|
||||
// all repeat text in *parallel* positions, so occurrences whose element
|
||||
// paths inside the container are identical (or live in dedicated repetition
|
||||
// structures) never count. Only 3+ occurrences at 3+ distinct structural
|
||||
// positions flag.
|
||||
|
||||
const REPEATED_TEXT_SKIP_SELECTOR = [
|
||||
'table',
|
||||
'select',
|
||||
'datalist',
|
||||
'nav',
|
||||
'menu',
|
||||
'[role="navigation"]',
|
||||
'[role="menu"]',
|
||||
'[role="menubar"]',
|
||||
'[role="listbox"]',
|
||||
'[role="grid"]',
|
||||
'[role="tablist"]',
|
||||
'[role="radiogroup"]',
|
||||
'[aria-hidden="true"]',
|
||||
].join(',');
|
||||
|
||||
const REPEATED_TEXT_CONTAINER_TAGS = new Set([
|
||||
'div', 'section', 'article', 'aside', 'main', 'figure', 'form', 'fieldset', 'details', 'li',
|
||||
]);
|
||||
|
||||
// A container worth attributing text to: visibly bounded (border on most
|
||||
// sides or an elevation shadow) and surface-like (radius or own background).
|
||||
function isRepeatedTextContainer(style) {
|
||||
if (!style) return false;
|
||||
const hasShadow = !!(style.boxShadow && style.boxShadow !== 'none' && style.boxShadow !== '');
|
||||
const borderSides = ['Top', 'Right', 'Bottom', 'Left']
|
||||
.filter(side => (parseFloat(style[`border${side}Width`]) || 0) >= 1).length;
|
||||
const hasBorder = borderSides >= 3;
|
||||
const hasRadius = (parseFloat(style.borderRadius) || 0) > 0;
|
||||
const bg = parseRgb(style.backgroundColor) || parseAnyColor(style.backgroundColor);
|
||||
const hasBg = !!(bg && (bg.a ?? 1) > 0.1);
|
||||
return isCardLikeFromProps(hasShadow, hasBorder, hasRadius, hasBg);
|
||||
}
|
||||
|
||||
function collectRepeatedContainerTextFindings(doc, getStyle, opts = {}) {
|
||||
const isVisible = opts.isVisible || (() => true);
|
||||
const findings = [];
|
||||
|
||||
const containers = [];
|
||||
const containerSet = new Set();
|
||||
for (const el of doc.querySelectorAll('*')) {
|
||||
if (!REPEATED_TEXT_CONTAINER_TAGS.has(el.tagName.toLowerCase())) continue;
|
||||
if (el.closest?.(REPEATED_TEXT_SKIP_SELECTOR)) continue;
|
||||
if (!isRepeatedTextContainer(getStyle(el))) continue;
|
||||
containers.push(el);
|
||||
containerSet.add(el);
|
||||
}
|
||||
|
||||
for (const container of containers) {
|
||||
if (!isVisible(container)) continue;
|
||||
const descendants = container.querySelectorAll('*');
|
||||
// Page-scale wrappers that merely happen to carry a background are not
|
||||
// the "one card" this rule reasons about.
|
||||
if (descendants.length > 250) continue;
|
||||
|
||||
const groups = new Map();
|
||||
for (const d of descendants) {
|
||||
// Attribute text to the innermost container only.
|
||||
let anc = d.parentElement;
|
||||
let ownedByInner = false;
|
||||
while (anc && anc !== container) {
|
||||
if (containerSet.has(anc)) { ownedByInner = true; break; }
|
||||
anc = anc.parentElement;
|
||||
}
|
||||
if (ownedByInner) continue;
|
||||
if (d.closest?.(REPEATED_TEXT_SKIP_SELECTOR)) continue;
|
||||
// Icon-font glyph names read as text but render as symbols.
|
||||
if (/icon|material-symbols|(?:^|\s)fa[srlbd]?(?:\s|-|$)/i.test(String(d.getAttribute?.('class') || ''))) continue;
|
||||
if (!isVisible(d)) continue;
|
||||
|
||||
const direct = [...d.childNodes]
|
||||
.filter(n => n.nodeType === 3)
|
||||
.map(n => n.textContent)
|
||||
.join(' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
if (direct.length < 4 || direct.length > 48) continue;
|
||||
if (!/[a-zA-Z]/.test(direct)) continue;
|
||||
|
||||
// Structural signature: the element path from the occurrence up to
|
||||
// the container. Parallel/templated repetition shares one signature.
|
||||
const sig = [];
|
||||
for (let cur = d; cur && cur !== container; cur = cur.parentElement) {
|
||||
const cls = String(cur.getAttribute?.('class') || '')
|
||||
.trim().split(/\s+/).filter(Boolean).sort().join('.');
|
||||
sig.push(cur.tagName.toLowerCase() + (cls ? `.${cls}` : ''));
|
||||
}
|
||||
if (!groups.has(direct)) groups.set(direct, []);
|
||||
groups.get(direct).push(sig.join('>'));
|
||||
}
|
||||
|
||||
for (const [text, sigs] of groups) {
|
||||
if (sigs.length < 3) continue;
|
||||
if (new Set(sigs).size < 3) continue;
|
||||
findings.push({
|
||||
id: 'repeated-container-text',
|
||||
snippet: `"${text.slice(0, 40)}" rendered ${sigs.length}× in distinct spots inside ${classSelector(container)}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
return findings;
|
||||
}
|
||||
|
||||
function checkRepeatedContainerTextFromDoc(doc, win) {
|
||||
return collectRepeatedContainerTextFindings(
|
||||
doc,
|
||||
(el) => win.getComputedStyle(el),
|
||||
{ isVisible: (el) => String(win.getComputedStyle(el).display || '') !== 'none' },
|
||||
);
|
||||
}
|
||||
|
||||
function checkRepeatedContainerTextDOM() {
|
||||
return collectRepeatedContainerTextFindings(
|
||||
document,
|
||||
(el) => getComputedStyle(el),
|
||||
{ isVisible: isRenderedForBrowserRule },
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Cream / beige palette (the default "tasteful" AI surface) ────────────────
|
||||
// A warm, lightly-tinted off-white page background — light, with R≥G≥B and a
|
||||
// small warm tint (not white, not a strong color). The current reflex surface.
|
||||
@@ -5815,6 +6191,7 @@ if (IS_BROWSER) {
|
||||
|
||||
const findings = [
|
||||
...checkElementBordersDOM(el).map(f => ({ type: f.id, detail: f.snippet })),
|
||||
...checkElementPseudoStripeDOM(el).map(f => ({ type: f.id, detail: f.snippet })),
|
||||
...checkElementColorsDOM(el).map(f => ({ type: f.id, detail: f.snippet })),
|
||||
...checkElementMotionDOM(el).map(f => ({ type: f.id, detail: f.snippet })),
|
||||
...checkElementGlowDOM(el).map(f => ({ type: f.id, detail: f.snippet })),
|
||||
@@ -5864,6 +6241,22 @@ if (IS_BROWSER) {
|
||||
addBrowserFindings(groupMap, document.body, sectionKickerFindings);
|
||||
}
|
||||
|
||||
const numberedLabelFindings = checkNumberedSectionLabelsDOM()
|
||||
.map(f => ({ type: f.id, detail: f.snippet }))
|
||||
.filter(f => _ruleOk(f.type));
|
||||
if (numberedLabelFindings.length > 0) {
|
||||
pageLevelFindings.push(...numberedLabelFindings);
|
||||
addBrowserFindings(groupMap, document.body, numberedLabelFindings);
|
||||
}
|
||||
|
||||
const repeatedTextFindings = checkRepeatedContainerTextDOM()
|
||||
.map(f => ({ type: f.id, detail: f.snippet }))
|
||||
.filter(f => _ruleOk(f.type));
|
||||
if (repeatedTextFindings.length > 0) {
|
||||
pageLevelFindings.push(...repeatedTextFindings);
|
||||
addBrowserFindings(groupMap, document.body, repeatedTextFindings);
|
||||
}
|
||||
|
||||
const layoutFindings = checkLayout().filter(f => _ruleOk(f.type));
|
||||
for (const f of layoutFindings) {
|
||||
const el = f.el || document.body;
|
||||
|
||||
@@ -26,8 +26,10 @@ import {
|
||||
checkElementQuality,
|
||||
checkCreamPalette,
|
||||
checkHtmlPatterns,
|
||||
checkNumberedSectionLabelsFromDoc,
|
||||
checkPageLayout,
|
||||
checkPageQualityFromDoc,
|
||||
checkRepeatedContainerTextFromDoc,
|
||||
checkRepeatedSectionKickersFromDoc,
|
||||
resolveBackground,
|
||||
resolveBorderRadiusPx,
|
||||
@@ -202,6 +204,12 @@ async function detectHtml(filePath, options = {}) {
|
||||
for (const f of runPageCheck('repeated-section-kickers', () => checkRepeatedSectionKickersFromDoc(document, window))) {
|
||||
findings.push(finding(f.id, filePath, f.snippet));
|
||||
}
|
||||
for (const f of runPageCheck('numbered-section-labels', () => checkNumberedSectionLabelsFromDoc(document, window))) {
|
||||
findings.push(finding(f.id, filePath, f.snippet));
|
||||
}
|
||||
for (const f of runPageCheck('repeated-container-text', () => checkRepeatedContainerTextFromDoc(document, window))) {
|
||||
findings.push(finding(f.id, filePath, f.snippet));
|
||||
}
|
||||
for (const f of runPageCheck('layout-rules', () => checkPageLayout(document, window))) {
|
||||
findings.push(finding(f.id, filePath, f.snippet));
|
||||
}
|
||||
|
||||
@@ -181,6 +181,17 @@ const ANTIPATTERNS = [
|
||||
skillSection: 'Typography',
|
||||
skillGuideline: 'repeated eyebrow or kicker labels as section scaffolding',
|
||||
},
|
||||
{
|
||||
id: 'numbered-section-labels',
|
||||
category: 'slop',
|
||||
scopes: ['type'],
|
||||
severity: 'advisory',
|
||||
name: 'Tiny numbered section labels',
|
||||
description:
|
||||
'Small numeric index labels riding next to section headings, repeated section after section, are AI editorial scaffolding — a page numbering its own chapters instead of earning structure. Let hierarchy, content, and rhythm carry the sequence.',
|
||||
skillSection: 'Layout & Space',
|
||||
skillGuideline: 'numbered section markers',
|
||||
},
|
||||
{
|
||||
id: 'numbered-section-markers',
|
||||
category: 'slop',
|
||||
@@ -363,6 +374,13 @@ const ANTIPATTERNS = [
|
||||
skillSection: 'Layout & Space',
|
||||
skillGuideline: 'content wider than its container',
|
||||
},
|
||||
{
|
||||
id: 'repeated-container-text',
|
||||
category: 'quality',
|
||||
name: 'Same text repeated inside one container',
|
||||
description:
|
||||
'The same literal text rendered three or more times in structurally different spots inside a single card or panel is redundant messaging — usually a status or label wired into every slot of a template. Say it once, in the slot where it matters most.',
|
||||
},
|
||||
{
|
||||
id: 'clipped-overflow-container',
|
||||
category: 'quality',
|
||||
|
||||
+373
-4
@@ -126,7 +126,11 @@ function checkColors(opts) {
|
||||
// like `text-paper/60` on `bg-ink` sections are the FP pattern.
|
||||
const isAlphaFallbackFP = !DETECTOR_IS_BROWSER && !effectiveBg && (textColor.a != null && textColor.a < 1);
|
||||
if (!isAlphaFallbackFP) {
|
||||
findings.push({ id: 'low-contrast', snippet: `${ratio.toFixed(1)}:1 (need ${threshold}:1) — text ${colorToHex(textColor)} on ${colorToHex(bgs[worstIdx])}` });
|
||||
// Near-threshold ratios (e.g. 4.497) would round to the threshold
|
||||
// itself at one decimal and read as "4.5 needs 4.5" — show two
|
||||
// decimals there so the finding stays legible.
|
||||
const ratioLabel = ratio.toFixed(1) === threshold.toFixed(1) ? ratio.toFixed(2) : ratio.toFixed(1);
|
||||
findings.push({ id: 'low-contrast', snippet: `${ratioLabel}:1 (need ${threshold}:1) — text ${colorToHex(textColor)} on ${colorToHex(bgs[worstIdx])}` });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -783,8 +787,16 @@ function scanCssTextForPseudoStripe(content) {
|
||||
let edge = null;
|
||||
let thicknessPx = null;
|
||||
if (verticalCandidate) {
|
||||
// Full-height stripes hug both corners; the "floating" variant backs
|
||||
// off each end by a small inset (top/bottom a few px) so the bar
|
||||
// clears the card's corners. Both read as the same side-tab accent —
|
||||
// corner treatment is styling, not a different pattern.
|
||||
const topPx = cssLengthToPx(resolveVarRefs(String(offsets.top ?? ''), customProps));
|
||||
const bottomPx = cssLengthToPx(resolveVarRefs(String(offsets.bottom ?? ''), customProps));
|
||||
const fullHeight = (isZeroOffset(offsets.top) && isZeroOffset(offsets.bottom))
|
||||
|| /^100(?:\.0*)?%$/.test(heightValue);
|
||||
|| /^100(?:\.0*)?%$/.test(heightValue)
|
||||
|| (topPx != null && bottomPx != null
|
||||
&& topPx >= 0 && topPx <= 20 && bottomPx >= 0 && bottomPx <= 20);
|
||||
if (fullHeight) {
|
||||
edge = isZeroOffset(offsets.left) ? 'left'
|
||||
: isZeroOffset(offsets.right) ? 'right' : null;
|
||||
@@ -1334,7 +1346,11 @@ function checkHtmlPatterns(html) {
|
||||
// `background: #abc`. Real browsers always decompose, so the fallback is
|
||||
// a no-op there.
|
||||
function readOwnBackgroundColor(el, computedStyle) {
|
||||
const bg = parseRgb(computedStyle.backgroundColor);
|
||||
// Real browsers keep wide-gamut/computed color functions (oklch(), oklab(),
|
||||
// color-mix() results) in getComputedStyle output, which plain parseRgb
|
||||
// misses — a flat oklch button background would silently skip every
|
||||
// contrast check without the parseAnyColor fallback.
|
||||
const bg = parseRgb(computedStyle.backgroundColor) || parseAnyColor(computedStyle.backgroundColor);
|
||||
if (DETECTOR_IS_BROWSER || (bg && bg.a >= 0.1)) return bg;
|
||||
const rawStyle = el.getAttribute?.('style') || '';
|
||||
const bgMatch = rawStyle.match(/background(?:-color)?\s*:\s*([^;]+)/i);
|
||||
@@ -1529,6 +1545,77 @@ function checkElementBordersDOM(el) {
|
||||
});
|
||||
}
|
||||
|
||||
// Browser-side twin of scanCssTextForPseudoStripe. The text scanner reads
|
||||
// stylesheet source, so a stripe whose color only exists at runtime (an
|
||||
// inline per-card custom property, a JS-assigned var) or whose geometry
|
||||
// resolves in layout never matches it. In a real browser the pseudo-element's
|
||||
// computed style carries the actual used color and px geometry — check those
|
||||
// directly. Gates mirror the text scanner: 3-12px thick, chromatic fill,
|
||||
// spanning (nearly) the full edge; corner rounding on the host card is
|
||||
// irrelevant. Exemptions stay narrow: structural/prose tags, real selection
|
||||
// markers (isTabContextElement), and button/link affordances for the
|
||||
// horizontal variant.
|
||||
function checkElementPseudoStripeDOM(el) {
|
||||
const tag = el.tagName.toLowerCase();
|
||||
if (BORDER_SAFE_TAGS.has(tag) || tag === 'summary') return [];
|
||||
if (el.closest?.('nav, blockquote, pre')) return [];
|
||||
if (!isRenderedForBrowserRule(el)) return [];
|
||||
const rect = el.getBoundingClientRect();
|
||||
if (rect.width < 40 || rect.height < 20) return [];
|
||||
if (isTabContextElement(el)) return [];
|
||||
|
||||
const findings = [];
|
||||
for (const which of ['::before', '::after']) {
|
||||
let ps;
|
||||
try { ps = getComputedStyle(el, which); } catch { continue; }
|
||||
if (!ps || ps.content === 'none' || ps.content === '') continue;
|
||||
if (ps.position !== 'absolute' && ps.position !== 'fixed') continue;
|
||||
if ((parseFloat(ps.opacity) || 0) <= 0.01 || ps.display === 'none') continue;
|
||||
const w = parseFloat(ps.width) || 0;
|
||||
const h = parseFloat(ps.height) || 0;
|
||||
if (!(w > 0 && h > 0)) continue;
|
||||
|
||||
// Used values: for absolutely-positioned boxes the browser resolves
|
||||
// both edge offsets after layout, so left/right (and top/bottom) are
|
||||
// real distances, never "auto".
|
||||
const left = parseFloat(ps.left);
|
||||
const right = parseFloat(ps.right);
|
||||
const top = parseFloat(ps.top);
|
||||
const bottom = parseFloat(ps.bottom);
|
||||
const hugs = (v) => Number.isFinite(v) && v >= -2 && v <= 2;
|
||||
|
||||
let edge = null;
|
||||
let thickness = null;
|
||||
// Vertical stripe: narrow box spanning (nearly) the full height of the
|
||||
// host, hugging its left or right edge. "Nearly" tolerates the floating
|
||||
// variant that backs off each end by a small inset.
|
||||
if (w >= 3 && w <= 12 && h >= rect.height - 44 && h >= rect.height * 0.5) {
|
||||
edge = hugs(left) ? 'left' : hugs(right) ? 'right' : null;
|
||||
thickness = w;
|
||||
}
|
||||
// Horizontal stripe riding the top or bottom edge. Button/link-styled
|
||||
// hosts keep their underline affordances.
|
||||
if (!edge && h >= 3 && h <= 12 && w >= rect.width - 44 && w >= rect.width * 0.5) {
|
||||
const cls = String(el.getAttribute?.('class') || el.className || '');
|
||||
if (!/(?:^|[\s_-])(?:btn|button|link)(?:$|[\s\w_-])/i.test(cls)) {
|
||||
edge = hugs(top) ? 'top' : hugs(bottom) ? 'bottom' : null;
|
||||
thickness = h;
|
||||
}
|
||||
}
|
||||
if (!edge) continue;
|
||||
|
||||
const bg = parseRgb(ps.backgroundColor) || parseAnyColor(ps.backgroundColor);
|
||||
if (!bg || (bg.a ?? 1) < 0.1) continue;
|
||||
if (Math.max(bg.r, bg.g, bg.b) - Math.min(bg.r, bg.g, bg.b) < 30) continue;
|
||||
|
||||
findings.push({
|
||||
id: 'side-tab',
|
||||
snippet: `${classSelector(el)}${which} — absolute ${thickness}px pseudo-element stripe (${edge})`,
|
||||
});
|
||||
}
|
||||
return findings;
|
||||
}
|
||||
|
||||
function checkElementColorsDOM(el) {
|
||||
const tag = el.tagName.toLowerCase();
|
||||
// No early SAFE_TAGS bail here — checkColors() does its own gating that
|
||||
@@ -1542,7 +1629,12 @@ function checkElementColorsDOM(el) {
|
||||
const effectiveBg = resolveBackground(el);
|
||||
return checkColors({
|
||||
tag,
|
||||
textColor: parseRgb(style.color),
|
||||
// Chrome serializes computed colors specified in modern spaces as
|
||||
// oklch()/oklab() strings; without the parseAnyColor fallback the text
|
||||
// color comes back null and the low-contrast / gray-on-color checks
|
||||
// silently never run (the shipped miss: a nav CTA whose text color was
|
||||
// an oklch token near its own oklch background).
|
||||
textColor: parseRgb(style.color) || parseAnyColor(style.color),
|
||||
bgColor: readOwnBackgroundColor(el, style),
|
||||
effectiveBg,
|
||||
effectiveBgStops: effectiveBg ? null : resolveGradientStops(el),
|
||||
@@ -2099,6 +2191,143 @@ function checkRepeatedSectionKickersDOM() {
|
||||
return checkRepeatedSectionKickers({ candidates });
|
||||
}
|
||||
|
||||
// ── Numbered section labels ─────────────────────────────────────────────────
|
||||
// Sibling of the repeated-kicker rule: instead of a tracked uppercase word,
|
||||
// the section scaffold is a tiny numeric index riding beside each section
|
||||
// heading — bare and zero-padded, or an index joined to a short micro-label
|
||||
// by a separator glyph. The kicker rule deliberately excludes bare 1-2 digit
|
||||
// labels; this rule owns that shape.
|
||||
|
||||
const NUMBERED_LABEL_TAGS = new Set(['span', 'p', 'div', 'small', 'em', 'strong', 'b']);
|
||||
|
||||
// Returns { index, text } when the trimmed text reads as a section index
|
||||
// label, else null. Two accepted shapes: a zero-padded/two-digit bare index,
|
||||
// or a 1-2 digit index followed by a non-word separator and a short label.
|
||||
function parseNumberedLabelText(rawText) {
|
||||
const text = (rawText || '').replace(/\s+/g, ' ').trim();
|
||||
if (!text || text.length > 40) return null;
|
||||
let m = /^(\d{2})$/.exec(text);
|
||||
if (!m) m = /^(\d{1,2})\s*[^\w\s]\s*\S/.exec(text);
|
||||
if (!m) return null;
|
||||
const index = parseInt(m[1], 10);
|
||||
if (!Number.isFinite(index) || index > 40) return null;
|
||||
return { index, text };
|
||||
}
|
||||
|
||||
function isNumberedSectionLabelCandidate(opts) {
|
||||
const {
|
||||
headingTag, headingText, headingFontSize,
|
||||
labelTag, labelIndex, labelText,
|
||||
labelFontSize, labelLetterSpacing, labelFontWeight,
|
||||
labelFontFamily, labelTextTransform, labelColor,
|
||||
} = opts;
|
||||
if (!['h2', 'h3', 'h4'].includes(headingTag)) return false;
|
||||
if (!headingText || headingText.length < 3) return false;
|
||||
if (!labelTag || !NUMBERED_LABEL_TAGS.has(labelTag)) return false;
|
||||
if (labelIndex == null || !labelText) return false;
|
||||
// Tiny rendered size is the tell — a display-scale section number is a
|
||||
// different (deliberate) device and stays legal.
|
||||
if (!(labelFontSize > 0 && labelFontSize <= 13)) return false;
|
||||
// The heading must be visibly larger where we can resolve its size.
|
||||
// clamp()/var() sizes come back unparseable (0) in the static engine —
|
||||
// the remaining gates carry the check there.
|
||||
if (headingFontSize > 0 && headingFontSize < labelFontSize * 1.3) return false;
|
||||
// Deliberate micro-label styling separates the scaffold from incidental
|
||||
// small text: mono face, bold weight, tracking, uppercase, or accent color.
|
||||
const weight = Number(labelFontWeight) || 400;
|
||||
return /mono/i.test(labelFontFamily || '')
|
||||
|| weight >= 600
|
||||
|| (labelLetterSpacing || 0) >= 0.5
|
||||
|| (labelTextTransform || '') === 'uppercase'
|
||||
|| isAccentColor(labelColor || '');
|
||||
}
|
||||
|
||||
function collectNumberedSectionLabelCandidates(doc, getStyle, resolveLetterSpacing) {
|
||||
const candidates = [];
|
||||
const seenLabels = new Set();
|
||||
for (const heading of doc.querySelectorAll('h2, h3, h4')) {
|
||||
if (heading.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue;
|
||||
// The index sits either directly before the heading, or before the
|
||||
// wrapper the heading leads (label | <div><h2>…</h2>…</div>).
|
||||
let label = heading.previousElementSibling;
|
||||
if (!label) {
|
||||
const parent = heading.parentElement;
|
||||
const firstChild = parent?.children?.[0];
|
||||
if (firstChild === heading) label = parent.previousElementSibling;
|
||||
}
|
||||
if (!label || seenLabels.has(label)) continue;
|
||||
if (label.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue;
|
||||
if (HEADING_TAGS.has(label.tagName.toLowerCase())) continue;
|
||||
if (isRepeatedKickerCardContext(heading, label)) continue;
|
||||
|
||||
const labelText = cleanInlineText(label) || (label.textContent || '').replace(/\s+/g, ' ').trim();
|
||||
const parsed = parseNumberedLabelText(labelText);
|
||||
if (!parsed) continue;
|
||||
|
||||
const headingStyle = getStyle(heading);
|
||||
const labelStyle = getStyle(label);
|
||||
const headingText = (heading.textContent || '').replace(/\s+/g, ' ').trim();
|
||||
const headingFontSize = resolveLetterSpacing(headingStyle.fontSize || '', 16) || parseFloat(headingStyle.fontSize) || 0;
|
||||
const labelFontSize = resolveLetterSpacing(labelStyle.fontSize || '', 16) || parseFloat(labelStyle.fontSize) || 0;
|
||||
|
||||
if (!isNumberedSectionLabelCandidate({
|
||||
headingTag: heading.tagName.toLowerCase(),
|
||||
headingText,
|
||||
headingFontSize,
|
||||
labelTag: label.tagName.toLowerCase(),
|
||||
labelIndex: parsed.index,
|
||||
labelText: parsed.text,
|
||||
labelFontSize,
|
||||
labelLetterSpacing: resolveLetterSpacing(labelStyle.letterSpacing || '', labelFontSize),
|
||||
labelFontWeight: labelStyle.fontWeight || '',
|
||||
labelFontFamily: labelStyle.fontFamily || '',
|
||||
labelTextTransform: labelStyle.textTransform || '',
|
||||
labelColor: labelStyle.color || '',
|
||||
})) {
|
||||
continue;
|
||||
}
|
||||
|
||||
seenLabels.add(label);
|
||||
candidates.push({
|
||||
index: parsed.index,
|
||||
labelText: parsed.text.slice(0, 24),
|
||||
headingTag: heading.tagName.toLowerCase(),
|
||||
headingText: headingText.replace(/^"|"$/g, '').slice(0, 60),
|
||||
});
|
||||
}
|
||||
return candidates;
|
||||
}
|
||||
|
||||
function checkNumberedSectionLabels(opts) {
|
||||
const { candidates, minCount = 2 } = opts;
|
||||
if (!Array.isArray(candidates) || candidates.length < minCount) return [];
|
||||
// A repeated identical number is some other device; the scaffold counts up.
|
||||
const distinctIndices = new Set(candidates.map(c => c.index));
|
||||
if (distinctIndices.size < 2) return [];
|
||||
return candidates.map(candidate => ({
|
||||
id: 'numbered-section-labels',
|
||||
snippet: `tiny numbered label "${candidate.labelText}" beside ${candidate.headingTag} "${candidate.headingText}" (${candidates.length} on page)`,
|
||||
}));
|
||||
}
|
||||
|
||||
function checkNumberedSectionLabelsFromDoc(doc, win) {
|
||||
const candidates = collectNumberedSectionLabelCandidates(
|
||||
doc,
|
||||
(el) => win.getComputedStyle(el),
|
||||
(value, fontSize) => resolveLengthPx(value, fontSize) || 0,
|
||||
);
|
||||
return checkNumberedSectionLabels({ candidates });
|
||||
}
|
||||
|
||||
function checkNumberedSectionLabelsDOM() {
|
||||
const candidates = collectNumberedSectionLabelCandidates(
|
||||
document,
|
||||
(el) => getComputedStyle(el),
|
||||
(value, fontSize) => resolveLengthPx(value, fontSize) || 0,
|
||||
);
|
||||
return checkNumberedSectionLabels({ candidates });
|
||||
}
|
||||
|
||||
function checkElementMotionDOM(el) {
|
||||
const tag = el.tagName.toLowerCase();
|
||||
if (SAFE_TAGS.has(tag)) return [];
|
||||
@@ -3193,6 +3422,135 @@ function checkPageLayout(doc, win) {
|
||||
return findings;
|
||||
}
|
||||
|
||||
// ── Repeated text inside one container ──────────────────────────────────────
|
||||
// The same literal string rendered 3+ times in structurally different spots
|
||||
// inside one bordered/elevated container — typically a status word wired
|
||||
// into every slot of a card template. Legitimate repetition is structural:
|
||||
// table columns, calendar grids, nav/menu lists, and templated sibling rows
|
||||
// all repeat text in *parallel* positions, so occurrences whose element
|
||||
// paths inside the container are identical (or live in dedicated repetition
|
||||
// structures) never count. Only 3+ occurrences at 3+ distinct structural
|
||||
// positions flag.
|
||||
|
||||
const REPEATED_TEXT_SKIP_SELECTOR = [
|
||||
'table',
|
||||
'select',
|
||||
'datalist',
|
||||
'nav',
|
||||
'menu',
|
||||
'[role="navigation"]',
|
||||
'[role="menu"]',
|
||||
'[role="menubar"]',
|
||||
'[role="listbox"]',
|
||||
'[role="grid"]',
|
||||
'[role="tablist"]',
|
||||
'[role="radiogroup"]',
|
||||
'[aria-hidden="true"]',
|
||||
].join(',');
|
||||
|
||||
const REPEATED_TEXT_CONTAINER_TAGS = new Set([
|
||||
'div', 'section', 'article', 'aside', 'main', 'figure', 'form', 'fieldset', 'details', 'li',
|
||||
]);
|
||||
|
||||
// A container worth attributing text to: visibly bounded (border on most
|
||||
// sides or an elevation shadow) and surface-like (radius or own background).
|
||||
function isRepeatedTextContainer(style) {
|
||||
if (!style) return false;
|
||||
const hasShadow = !!(style.boxShadow && style.boxShadow !== 'none' && style.boxShadow !== '');
|
||||
const borderSides = ['Top', 'Right', 'Bottom', 'Left']
|
||||
.filter(side => (parseFloat(style[`border${side}Width`]) || 0) >= 1).length;
|
||||
const hasBorder = borderSides >= 3;
|
||||
const hasRadius = (parseFloat(style.borderRadius) || 0) > 0;
|
||||
const bg = parseRgb(style.backgroundColor) || parseAnyColor(style.backgroundColor);
|
||||
const hasBg = !!(bg && (bg.a ?? 1) > 0.1);
|
||||
return isCardLikeFromProps(hasShadow, hasBorder, hasRadius, hasBg);
|
||||
}
|
||||
|
||||
function collectRepeatedContainerTextFindings(doc, getStyle, opts = {}) {
|
||||
const isVisible = opts.isVisible || (() => true);
|
||||
const findings = [];
|
||||
|
||||
const containers = [];
|
||||
const containerSet = new Set();
|
||||
for (const el of doc.querySelectorAll('*')) {
|
||||
if (!REPEATED_TEXT_CONTAINER_TAGS.has(el.tagName.toLowerCase())) continue;
|
||||
if (el.closest?.(REPEATED_TEXT_SKIP_SELECTOR)) continue;
|
||||
if (!isRepeatedTextContainer(getStyle(el))) continue;
|
||||
containers.push(el);
|
||||
containerSet.add(el);
|
||||
}
|
||||
|
||||
for (const container of containers) {
|
||||
if (!isVisible(container)) continue;
|
||||
const descendants = container.querySelectorAll('*');
|
||||
// Page-scale wrappers that merely happen to carry a background are not
|
||||
// the "one card" this rule reasons about.
|
||||
if (descendants.length > 250) continue;
|
||||
|
||||
const groups = new Map();
|
||||
for (const d of descendants) {
|
||||
// Attribute text to the innermost container only.
|
||||
let anc = d.parentElement;
|
||||
let ownedByInner = false;
|
||||
while (anc && anc !== container) {
|
||||
if (containerSet.has(anc)) { ownedByInner = true; break; }
|
||||
anc = anc.parentElement;
|
||||
}
|
||||
if (ownedByInner) continue;
|
||||
if (d.closest?.(REPEATED_TEXT_SKIP_SELECTOR)) continue;
|
||||
// Icon-font glyph names read as text but render as symbols.
|
||||
if (/icon|material-symbols|(?:^|\s)fa[srlbd]?(?:\s|-|$)/i.test(String(d.getAttribute?.('class') || ''))) continue;
|
||||
if (!isVisible(d)) continue;
|
||||
|
||||
const direct = [...d.childNodes]
|
||||
.filter(n => n.nodeType === 3)
|
||||
.map(n => n.textContent)
|
||||
.join(' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
if (direct.length < 4 || direct.length > 48) continue;
|
||||
if (!/[a-zA-Z]/.test(direct)) continue;
|
||||
|
||||
// Structural signature: the element path from the occurrence up to
|
||||
// the container. Parallel/templated repetition shares one signature.
|
||||
const sig = [];
|
||||
for (let cur = d; cur && cur !== container; cur = cur.parentElement) {
|
||||
const cls = String(cur.getAttribute?.('class') || '')
|
||||
.trim().split(/\s+/).filter(Boolean).sort().join('.');
|
||||
sig.push(cur.tagName.toLowerCase() + (cls ? `.${cls}` : ''));
|
||||
}
|
||||
if (!groups.has(direct)) groups.set(direct, []);
|
||||
groups.get(direct).push(sig.join('>'));
|
||||
}
|
||||
|
||||
for (const [text, sigs] of groups) {
|
||||
if (sigs.length < 3) continue;
|
||||
if (new Set(sigs).size < 3) continue;
|
||||
findings.push({
|
||||
id: 'repeated-container-text',
|
||||
snippet: `"${text.slice(0, 40)}" rendered ${sigs.length}× in distinct spots inside ${classSelector(container)}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
return findings;
|
||||
}
|
||||
|
||||
function checkRepeatedContainerTextFromDoc(doc, win) {
|
||||
return collectRepeatedContainerTextFindings(
|
||||
doc,
|
||||
(el) => win.getComputedStyle(el),
|
||||
{ isVisible: (el) => String(win.getComputedStyle(el).display || '') !== 'none' },
|
||||
);
|
||||
}
|
||||
|
||||
function checkRepeatedContainerTextDOM() {
|
||||
return collectRepeatedContainerTextFindings(
|
||||
document,
|
||||
(el) => getComputedStyle(el),
|
||||
{ isVisible: isRenderedForBrowserRule },
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Cream / beige palette (the default "tasteful" AI surface) ────────────────
|
||||
// A warm, lightly-tinted off-white page background — light, with R≥G≥B and a
|
||||
// small warm tint (not white, not a strong color). The current reflex surface.
|
||||
@@ -3680,6 +4038,17 @@ export {
|
||||
isRepeatedKickerCandidate,
|
||||
collectRepeatedSectionKickerCandidates,
|
||||
checkRepeatedSectionKickersDOM,
|
||||
parseNumberedLabelText,
|
||||
isNumberedSectionLabelCandidate,
|
||||
collectNumberedSectionLabelCandidates,
|
||||
checkNumberedSectionLabels,
|
||||
checkNumberedSectionLabelsFromDoc,
|
||||
checkNumberedSectionLabelsDOM,
|
||||
isRepeatedTextContainer,
|
||||
collectRepeatedContainerTextFindings,
|
||||
checkRepeatedContainerTextFromDoc,
|
||||
checkRepeatedContainerTextDOM,
|
||||
checkElementPseudoStripeDOM,
|
||||
checkElementMotionDOM,
|
||||
checkElementGlowDOM,
|
||||
checkElementAIPaletteDOM,
|
||||
|
||||
@@ -521,7 +521,7 @@ import '../styles/testimonials.css';
|
||||
<article class="ks-bento-tile ks-bento-tile--span-6" id="why-ci">
|
||||
<span class="ks-bento-num" data-color="patina">06</span>
|
||||
<h3 class="why-panel-title">Block slop before it ships.</h3>
|
||||
<p class="why-panel-body">A detector you can wire into PR checks. 49 deterministic rules, no LLM, exit codes the build can read.</p>
|
||||
<p class="why-panel-body">A detector you can wire into PR checks. 51 deterministic rules, no LLM, exit codes the build can read.</p>
|
||||
<div class="why-visual why-visual--ci">
|
||||
<div class="why-ci-window">
|
||||
<div class="why-ci-header">
|
||||
@@ -799,7 +799,7 @@ import '../styles/testimonials.css';
|
||||
</li>
|
||||
<li>
|
||||
<strong>CLI for CI</strong>
|
||||
<span><code>npx impeccable detect src/</code> in a PR check. 49 deterministic rules. JSON output, exit codes for build gates.</span>
|
||||
<span><code>npx impeccable detect src/</code> in a PR check. 51 deterministic rules. JSON output, exit codes for build gates.</span>
|
||||
<a href="https://www.npmjs.com/package/impeccable" target="_blank" rel="noopener">View on npm →</a>
|
||||
</li>
|
||||
<li>
|
||||
|
||||
@@ -375,6 +375,39 @@ describe('detectHtml — static HTML/CSS fixtures', () => {
|
||||
);
|
||||
assert.match(numbered[0].snippet, /01, 02, 03/);
|
||||
});
|
||||
|
||||
it('numbered-section-labels: tiny repeated index labels flag, deliberate/list/card numbering passes', async () => {
|
||||
const f = await detectHtml(path.join(FIXTURES, 'numbered-section-labels.html'));
|
||||
const labels = f.filter(r => r.antipattern === 'numbered-section-labels');
|
||||
const snippets = labels.map(r => r.snippet).join(' | ');
|
||||
assert.equal(
|
||||
labels.length,
|
||||
4,
|
||||
`expected 4 numbered-label findings, got ${labels.length}: ${snippets}`
|
||||
);
|
||||
for (const heading of ['Alpha ships first', 'Beta earns trust', 'Gamma holds the line', 'Delta closes the loop']) {
|
||||
assert.match(snippets, new RegExp(heading), `expected label beside "${heading}" to flag`);
|
||||
}
|
||||
for (const heading of ['Epsilon', 'Zeta', 'Eta', 'Theta', 'Iota', 'Kappa', 'Lambda', 'Mu']) {
|
||||
assert.doesNotMatch(snippets, new RegExp(heading), `label beside "${heading}" should pass`);
|
||||
}
|
||||
});
|
||||
|
||||
it('repeated-container-text: same string in 3+ distinct slots of one card flags; structural repetition passes', async () => {
|
||||
const f = await detectHtml(path.join(FIXTURES, 'repeated-container-text.html'));
|
||||
const repeats = f.filter(r => r.antipattern === 'repeated-container-text');
|
||||
const snippets = repeats.map(r => r.snippet).join(' | ');
|
||||
assert.equal(
|
||||
repeats.length,
|
||||
2,
|
||||
`expected 2 repeated-text findings, got ${repeats.length}: ${snippets}`
|
||||
);
|
||||
assert.match(snippets, /Suspended.*3×|Suspended" rendered 3/, 'expected the 3-slot status word to flag');
|
||||
assert.match(snippets, /Unavailable" rendered 4/, 'expected the 4-slot status word to flag');
|
||||
for (const passText of ['Rolled back', 'On schedule', 'Overview page', 'Standby mode', 'Open slot', 'Rescheduled', '2026']) {
|
||||
assert.doesNotMatch(snippets, new RegExp(passText), `"${passText}" should pass`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('detectHtml — icon-tile-stack', () => {
|
||||
|
||||
@@ -12,9 +12,12 @@ import {
|
||||
} from '../cli/engine/detect-antipatterns.mjs';
|
||||
import { filterByScopes } from '../cli/engine/registry/antipatterns.mjs';
|
||||
import {
|
||||
checkColors,
|
||||
checkElementTextOverflowDOM,
|
||||
checkHeroEyebrow,
|
||||
checkHoverContrast,
|
||||
checkNumberedSectionLabels,
|
||||
parseNumberedLabelText,
|
||||
checkHtmlPatterns,
|
||||
checkPageTypography,
|
||||
isScreenReaderOnlyTextStyle,
|
||||
@@ -1022,6 +1025,22 @@ describe('side-tab — pseudo-element stripe variant', () => {
|
||||
expect(scanCssTextForPseudoStripe(css)).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('detects floating stripe inset a few px from each end', () => {
|
||||
// The evasion shape from human review: same left-edge accent bar, but
|
||||
// backed off the card's corners by a small top/bottom inset so it never
|
||||
// touches an edge (and needs no corner rounding to read as a side tab).
|
||||
const css = '.row::before { content: ""; position: absolute; left: 0; top: 12px; bottom: 12px; width: 3px; border-radius: 3px; background: oklch(0.65 0.19 15); }';
|
||||
const f = scanCssTextForPseudoStripe(css);
|
||||
expect(f).toHaveLength(1);
|
||||
expect(f[0].id).toBe('side-tab');
|
||||
expect(f[0].snippet).toContain('(left: 0)');
|
||||
});
|
||||
|
||||
test('skips deeply-inset partial rail (not an edge-spanning stripe)', () => {
|
||||
const css = '.rail::before { position: absolute; left: 0; top: 40px; bottom: 40px; width: 4px; background: #3b82f6; }';
|
||||
expect(scanCssTextForPseudoStripe(css)).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('unresolvable custom-property color errs toward detection', () => {
|
||||
const css = '.card::before { position: absolute; left: 0; top: 0; bottom: 0; width: 5px; background: var(--from-external-sheet); }';
|
||||
expect(scanCssTextForPseudoStripe(css)).toHaveLength(1);
|
||||
@@ -1106,6 +1125,63 @@ describe('side-tab — pseudo-element stripe variant', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Low contrast — modern computed-color serializations (browser adapter path)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('checkColors — oklch computed colors', () => {
|
||||
test('flat dark-on-dark oklch CTA pair parses and fails contrast', () => {
|
||||
// Real browsers hand back oklch() strings from getComputedStyle for
|
||||
// colors authored in modern spaces; the adapters must not lose them.
|
||||
const textColor = parseAnyColor('oklch(0.34 0.01 70)');
|
||||
const bgColor = parseAnyColor('oklch(0.22 0.01 70)');
|
||||
expect(textColor).toBeTruthy();
|
||||
expect(bgColor).toBeTruthy();
|
||||
const f = checkColors({
|
||||
tag: 'a',
|
||||
textColor,
|
||||
bgColor,
|
||||
effectiveBg: bgColor,
|
||||
effectiveBgStops: null,
|
||||
fontSize: 14.4,
|
||||
fontWeight: 500,
|
||||
hasDirectText: true,
|
||||
isEmojiOnly: false,
|
||||
bgClip: '',
|
||||
bgImage: '',
|
||||
classList: 'btn btn-primary',
|
||||
});
|
||||
expect(f.some(r => r.id === 'low-contrast')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Numbered section labels — pure helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('numbered-section-labels — pure helpers', () => {
|
||||
test('parseNumberedLabelText accepts zero-padded and separator forms only', () => {
|
||||
expect(parseNumberedLabelText('01')).toEqual({ index: 1, text: '01' });
|
||||
expect(parseNumberedLabelText('12')).toEqual({ index: 12, text: '12' });
|
||||
expect(parseNumberedLabelText('04 / rollout')).toMatchObject({ index: 4 });
|
||||
expect(parseNumberedLabelText('6 · getting started')).toMatchObject({ index: 6 });
|
||||
expect(parseNumberedLabelText('7')).toBeNull();
|
||||
expect(parseNumberedLabelText('Step 3')).toBeNull();
|
||||
expect(parseNumberedLabelText('12 minute read')).toBeNull();
|
||||
expect(parseNumberedLabelText('50% off everything')).toBeNull();
|
||||
expect(parseNumberedLabelText('')).toBeNull();
|
||||
});
|
||||
|
||||
test('checkNumberedSectionLabels needs 2+ candidates with 2+ distinct indices', () => {
|
||||
const candidate = (index) => ({ index, labelText: String(index).padStart(2, '0'), headingTag: 'h2', headingText: 'Heading' });
|
||||
expect(checkNumberedSectionLabels({ candidates: [candidate(1)] })).toHaveLength(0);
|
||||
expect(checkNumberedSectionLabels({ candidates: [candidate(1), candidate(1)] })).toHaveLength(0);
|
||||
const flagged = checkNumberedSectionLabels({ candidates: [candidate(1), candidate(2)] });
|
||||
expect(flagged).toHaveLength(2);
|
||||
expect(flagged[0].id).toBe('numbered-section-labels');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Radial-gradient background halo
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Numbered Section Labels Fixture</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: Georgia, serif;
|
||||
font-size: 16px;
|
||||
margin: 0;
|
||||
padding: 40px;
|
||||
background: #ffffff;
|
||||
color: #222222;
|
||||
}
|
||||
.column { width: 520px; padding: 20px; }
|
||||
h2 { font-size: 28px; margin: 0 0 12px; }
|
||||
h3 { font-size: 22px; margin: 0 0 10px; }
|
||||
section { margin-bottom: 48px; }
|
||||
|
||||
/* ── flag column styles ── */
|
||||
.sec-index {
|
||||
font-family: monospace;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: #b45309;
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.sec-index-micro {
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 2px;
|
||||
color: #b45309;
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
/* ── pass column styles ── */
|
||||
.big-index {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
font-family: monospace;
|
||||
display: block;
|
||||
}
|
||||
.plain-note {
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
letter-spacing: 0;
|
||||
color: #666666;
|
||||
display: block;
|
||||
}
|
||||
.step-label {
|
||||
font-family: monospace;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
display: block;
|
||||
}
|
||||
.card {
|
||||
border: 1px solid #d4d4d4;
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- ══ SHOULD FLAG: tiny numeric index labels repeated across sections ══ -->
|
||||
<div class="column">
|
||||
|
||||
<section>
|
||||
<span class="sec-index">01</span>
|
||||
<h2>Alpha ships first</h2>
|
||||
<p>Direct previous-sibling label: tiny, mono, bold, zero-padded index.</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<span class="sec-index">02</span>
|
||||
<h2>Beta earns trust</h2>
|
||||
<p>Second section repeating the same index scaffold.</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<span class="sec-index">03</span>
|
||||
<div>
|
||||
<h2>Gamma holds the line</h2>
|
||||
<p>Label precedes the wrapper; the heading leads the wrapper.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<span class="sec-index-micro">04 / ROLLOUT</span>
|
||||
<h2>Delta closes the loop</h2>
|
||||
<p>Index joined to a tracked micro-label by a separator glyph.</p>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- ══ SHOULD PASS ══ -->
|
||||
<div class="column">
|
||||
|
||||
<section>
|
||||
<span class="big-index">05</span>
|
||||
<h2>Epsilon reads large</h2>
|
||||
<p>16px index is a deliberate display device, not a micro label.</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<span class="plain-note">12 · minute read</span>
|
||||
<h2>Zeta stays plain</h2>
|
||||
<p>Parses as an index but carries no micro-label styling: regular weight, neutral color, no tracking, serif body face.</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<span class="sec-index">Step 6</span>
|
||||
<h2>Eta walks steps</h2>
|
||||
<p>Word-first label is not a numeric index.</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<span class="sec-index">7</span>
|
||||
<h2>Theta counts casually</h2>
|
||||
<p>A bare unpadded single digit is ordinary list numbering.</p>
|
||||
</section>
|
||||
|
||||
<ol>
|
||||
<li>
|
||||
<span class="step-label">08</span>
|
||||
<h3>Iota lives in a list</h3>
|
||||
</li>
|
||||
<li>
|
||||
<span class="step-label">09</span>
|
||||
<h3>Kappa lives in a list too</h3>
|
||||
</li>
|
||||
</ol>
|
||||
|
||||
<article class="card">
|
||||
<span class="sec-index">10</span>
|
||||
<h3>Lambda sits in a card</h3>
|
||||
<p>Per-card indices are item numbering, not section scaffolding.</p>
|
||||
</article>
|
||||
|
||||
<nav aria-label="Progress">
|
||||
<span class="sec-index">11</span>
|
||||
<h3>Mu tracks progress</h3>
|
||||
</nav>
|
||||
|
||||
</div>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,129 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Repeated Container Text Fixture</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: Georgia, serif;
|
||||
font-size: 16px;
|
||||
margin: 0;
|
||||
padding: 40px;
|
||||
background: #f5f5f4;
|
||||
color: #222222;
|
||||
}
|
||||
.card {
|
||||
background: #ffffff;
|
||||
border: 1px solid #d6d3d1;
|
||||
border-radius: 10px;
|
||||
padding: 20px;
|
||||
width: 360px;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
.shadow-card {
|
||||
background: #ffffff;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.12);
|
||||
padding: 20px;
|
||||
width: 360px;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
h2 { font-size: 22px; margin: 0 0 10px; }
|
||||
.big-word { font-size: 30px; font-weight: 700; color: #b91c1c; }
|
||||
.tag { font-size: 12px; text-transform: uppercase; color: #b91c1c; }
|
||||
.row { padding: 6px 0; border-bottom: 1px solid #eeeeee; }
|
||||
.status { font-size: 13px; color: #57534e; }
|
||||
td, th { padding: 6px 10px; font-size: 14px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- ══ SHOULD FLAG ══ -->
|
||||
|
||||
<!-- Same word wired into three structurally different slots of one card -->
|
||||
<div class="card" id="flag-status-card">
|
||||
<h2>Museumplein departure</h2>
|
||||
<div class="headline"><span class="big-word">Suspended</span></div>
|
||||
<div class="meta"><span class="tag">Suspended</span></div>
|
||||
<p class="note">This service is <strong>Suspended</strong> until further notice.</p>
|
||||
</div>
|
||||
|
||||
<!-- Four distinct slots inside one elevated card -->
|
||||
<div class="shadow-card" id="flag-ticket-card">
|
||||
<h2>Evening show</h2>
|
||||
<div class="banner"><span class="big-word">Unavailable</span></div>
|
||||
<div class="detail"><em class="tag">Unavailable</em></div>
|
||||
<div class="footer"><span class="status">Unavailable</span></div>
|
||||
<p class="aside">Currently <strong>Unavailable</strong> at this venue.</p>
|
||||
</div>
|
||||
|
||||
<!-- ══ SHOULD PASS ══ -->
|
||||
|
||||
<!-- Table columns inside a card repeat by design -->
|
||||
<div class="card" id="pass-table-card">
|
||||
<h2>Deploy history</h2>
|
||||
<table>
|
||||
<tr><td>build 12</td><td>Rolled back</td></tr>
|
||||
<tr><td>build 13</td><td>Rolled back</td></tr>
|
||||
<tr><td>build 14</td><td>Rolled back</td></tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Parallel templated rows: identical structural position each time -->
|
||||
<div class="card" id="pass-parallel-rows">
|
||||
<h2>Departures</h2>
|
||||
<div class="row"><span class="status">On schedule</span></div>
|
||||
<div class="row"><span class="status">On schedule</span></div>
|
||||
<div class="row"><span class="status">On schedule</span></div>
|
||||
</div>
|
||||
|
||||
<!-- Nav list inside a card -->
|
||||
<div class="card" id="pass-nav-card">
|
||||
<h2>Sections</h2>
|
||||
<nav>
|
||||
<div class="a"><span>Overview page</span></div>
|
||||
<div class="b"><em>Overview page</em></div>
|
||||
<div class="c"><strong>Overview page</strong></div>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<!-- Short strings never count -->
|
||||
<div class="card" id="pass-short-text">
|
||||
<h2>Toggles</h2>
|
||||
<div class="one"><span>Off</span></div>
|
||||
<div class="two"><em>Off</em></div>
|
||||
<div class="three"><strong>Off</strong></div>
|
||||
</div>
|
||||
|
||||
<!-- Digits-only strings never count -->
|
||||
<div class="card" id="pass-numbers">
|
||||
<h2>Scores</h2>
|
||||
<div class="one"><span>2026</span></div>
|
||||
<div class="two"><em>2026</em></div>
|
||||
<div class="three"><strong>2026</strong></div>
|
||||
</div>
|
||||
|
||||
<!-- One occurrence per card, three cards -->
|
||||
<div class="card" id="pass-spread-a"><span class="tag">Standby mode</span></div>
|
||||
<div class="card" id="pass-spread-b"><em class="tag">Standby mode</em></div>
|
||||
<div class="card" id="pass-spread-c"><strong class="tag">Standby mode</strong></div>
|
||||
|
||||
<!-- Calendar-style grid repeats by design -->
|
||||
<div class="card" id="pass-grid-card">
|
||||
<h2>Availability</h2>
|
||||
<div role="grid">
|
||||
<div class="cell-a"><span>Open slot</span></div>
|
||||
<div class="cell-b"><em>Open slot</em></div>
|
||||
<div class="cell-c"><strong>Open slot</strong></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Only two structurally distinct occurrences: below threshold -->
|
||||
<div class="card" id="pass-two-spots">
|
||||
<h2>Corner case</h2>
|
||||
<div class="headline"><span class="big-word">Rescheduled</span></div>
|
||||
<div class="meta"><span class="tag">Rescheduled</span></div>
|
||||
</div>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user