Skip to content
240 changes: 240 additions & 0 deletions apps/daemon/src/lint-artifact.ts
Original file line number Diff line number Diff line change
Expand Up @@ -445,6 +445,15 @@ export function lintArtifact(rawHtml: unknown): LintFinding[] {
});
}

// ── P1-4: CTA hierarchy mismatch in commerce-critical regions ─────
// Read the artifact's own CSS to identify the primary button class
// (the first selector that paints a non-transparent background), then
// flag commerce CTAs in <header>/<nav>/<section class="hero"> whose
// class list doesn't include it. Advisory P1 — false positives are
// expected (outline CTA in header is a legitimate design choice).
const ctaFinding = detectCtaHierarchyMismatch(html);
if (ctaFinding) out.push(ctaFinding);

// ── P2-1: missing comment-mode anchor on <section> ────────────────
// Either `data-od-id` (web/mobile prototypes) or `data-screen-label`
// (decks) counts. Whichever the artifact uses, every <section> should
Expand Down Expand Up @@ -728,6 +737,14 @@ function findLastDecl(decls: CssDeclaration[], prop: string): CssDeclaration | u
return undefined;
}

function findLastBackgroundDecl(decls: CssDeclaration[]): CssDeclaration | undefined {
for (let i = decls.length - 1; i >= 0; i--) {
const decl = decls[i];
if (decl && (decl.prop === 'background' || decl.prop === 'background-color')) return decl;
}
return undefined;
}

// Split a CSS declaration body into `{ prop, value }` entries, lowercasing
// the property name and skipping custom properties (`--name`). Used by
// the uppercase-tracking lint so substring matches on `letter-spacing`
Expand Down Expand Up @@ -998,3 +1015,226 @@ function isGlobalThemeScopeSelector(s: string): boolean {
}
return false;
}

const COMMERCE_CTA_VERBS = [
/\bbuy\b/i,
/\bpurchase\b/i,
/\bcheckout\b/i,
/\border\s+now\b/i,
/\bplace\s+order\b/i,
/\bplace\s+your\s+order\b/i,
/\bget\s+started\b/i,
/\bshop\s+now\b/i,
/\badd\s+to\s+cart\b/i,
/\bsubscribe\b/i,
/\bsign\s+up\b/i,
/\bstart\s+free\b/i,
/\bbook\s+now\b/i,
/\breserve\b/i,
];

const PRIMARY_NAME_HINTS = ['primary', 'cta', 'accent', 'buy', 'checkout'];

function detectCtaHierarchyMismatch(html: string): LintFinding | null {
const primaryClass = findPrimaryButtonClass(html);
if (!primaryClass) return null;
const regions = extractCommerceRegions(html);
if (regions.length === 0) return null;
const offenders: string[] = [];
const seen = new Set<string>();
const ctaRe = /<(?:a|button)\b[^>]*\bclass\s*=\s*["']([^"']*)["'][^>]*>([\s\S]*?)<\/(?:a|button)>/gi;
for (const region of regions) {
for (const m of region.body.matchAll(ctaRe)) {
const classAttr = m[1] ?? '';
const text = stripTags(m[2] ?? '');
if (!isCommerceCtaText(text)) continue;
const classes = classAttr.split(/\s+/).filter(Boolean);
if (classes.includes(primaryClass)) continue;
const absolute = region.start + (m.index ?? 0);
const key = `cta:${absolute}`;
if (seen.has(key)) continue;
seen.add(key);
offenders.push(m[0]);
Comment thread
EthanGuo-coder marked this conversation as resolved.
}
}
if (offenders.length === 0) return null;
const first = offenders[0] ?? '';
return {
severity: 'P1',
id: 'cta-hierarchy-mismatch',
message: `${offenders.length} commerce CTA(s) in header/nav/hero use a non-primary button class while .${primaryClass} is the page primary.`,
fix: `Promote the header/hero purchase or sign-up CTA to .${primaryClass}. If an outline CTA in the header is intentional, ignore this warning.`,
snippet: clip(first),
};
}

// Scan every <style> block (mirroring the all-caps-no-tracking rule) and
// return the first class selector whose body paints a non-transparent
// background. "First with a background" is preferred over "highest
// specificity" because the linter has no DOM and cannot compute
// specificity; the first painted button rule is a robust proxy for the
// page primary across the templates this lint actually sees, and a name
// hint (primary / cta / accent / buy / checkout) tiebreaks when multiple
// candidates exist.
function findPrimaryButtonClass(html: string): string | null {
const candidates: { name: string; hasColor: boolean }[] = [];
for (const block of html.matchAll(/<style[^>]*>([\s\S]*?)<\/style>/gi)) {
const css = (block[1] ?? '').replace(/\/\*[\s\S]*?\*\//g, '');
for (const m of css.matchAll(/([^{}]+)\{([^{}]*)\}/g)) {
const prelude = m[1] ?? '';
const body = m[2] ?? '';
const decls = parseDeclarations(body);
const bg = findLastBackgroundDecl(decls);
if (!bg) continue;
if (!isPaintedBackground(bg.value)) continue;
const hasColor = decls.some((d) => d.prop === 'color');
const classNames = extractSelectorClassNames(prelude);
for (const name of classNames) {
candidates.push({ name, hasColor });
}
}
}
if (candidates.length === 0) return null;
const interactiveClasses = collectInteractiveClasses(html);
const eligible = candidates.filter((c) => interactiveClasses.has(c.name));
if (eligible.length === 0) return null;
const hinted = eligible.find((c) =>
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This hint pass is stronger than the comment above describes. eligible.find(...) returns the first class whose name contains any hint, so a source-ordered class like .cta-secondary or .accent-link will beat a later .btn-primary even when .btn-primary is the actual primary CTA. In that case the new finding will point authors at the wrong class and produce an avoidable false positive. Please make the hints a ranking/tiebreaker instead of an early winner—for example, prefer the first painted candidate unless a higher-ranked hint such as primary exists—and add a regression where .cta-secondary appears before .btn-primary.

🔁 Powered by Looper · runner=reviewer · agent=opencode · An autonomous AI dev team for your GitHub repos.

PRIMARY_NAME_HINTS.some((h) => c.name.toLowerCase().includes(h)),
);
if (hinted) return hinted.name;
const withColor = eligible.find((c) => c.hasColor);
return (withColor ?? eligible[0])?.name ?? null;
}

function collectInteractiveClasses(html: string): Set<string> {
const classes = new Set<string>();
for (const m of html.matchAll(/<(?:a|button)\b[^>]*\bclass\s*=\s*["']([^"']*)["']/gi)) {
for (const token of (m[1] ?? '').split(/\s+/)) {
if (token) classes.add(token);
}
}
return classes;
}

function extractSelectorClassNames(prelude: string): string[] {
const out: string[] = [];
const seen = new Set<string>();
for (const branch of prelude.split(',')) {
for (const tok of branch.matchAll(/\.([a-zA-Z][\w-]*)/g)) {
Comment thread
EthanGuo-coder marked this conversation as resolved.
Outdated
const name = tok[1];
if (!name || seen.has(name)) continue;
seen.add(name);
out.push(name);
}
}
return out;
}

function extractBalancedArgs(value: string, from: number): string | null {
let depth = 1;
for (let i = from; i < value.length; i++) {
const ch = value[i];
if (ch === '(') depth++;
else if (ch === ')') {
depth--;
if (depth === 0) return value.slice(from, i);
}
}
return null;
}

function findTopLevelSlash(args: string): number {
let depth = 0;
for (let i = args.length - 1; i >= 0; i--) {
const ch = args[i];
if (ch === ')') depth++;
else if (ch === '(') depth--;
else if (ch === '/' && depth === 0) return i;
}
return -1;
}

function splitTopLevel(args: string, sep: string): string[] {
const out: string[] = [];
let depth = 0;
let start = 0;
for (let i = 0; i < args.length; i++) {
const ch = args[i];
if (ch === '(') depth++;
else if (ch === ')') depth--;
else if (ch === sep && depth === 0) {
out.push(args.slice(start, i).trim());
start = i + 1;
}
}
out.push(args.slice(start).trim());
return out;
}

function parseAlpha(token: string | undefined): number | null {
if (!token) return null;
const trimmed = token.trim();
const calc = /^calc\(\s*(-?\d+(?:\.\d+)?)\s*%?\s*\)$/i.exec(trimmed);
if (calc) {
const n = Number.parseFloat(calc[1] ?? '');
return Number.isFinite(n) ? n : null;
}
const n = Number.parseFloat(trimmed);
return Number.isFinite(n) ? n : null;
}

function isPaintedBackground(value: string): boolean {
const v = value.trim().toLowerCase();
if (!v) return false;
if (v === 'transparent' || v === 'none' || v === 'inherit' || v === 'initial' || v === 'unset') return false;
if (/^#[0-9a-f]{3,8}\b/.test(v)) return true;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

isPaintedBackground still misses common CSS color forms here. ^#[0-9a-f]{3,8} treats fully transparent hex values like #0000 / #00000000 as painted, while a named color such as white falls through to return false. That means cta-hierarchy-mismatch can both invent a primary class from a transparent background and miss a real primary button that uses a keyword color, which breaks the rule on valid CSS that authors already write. Please parse 4/8-digit hex alpha before returning true, accept non-transparent keyword colors (or switch to a small CSS-color parser), and extend the matrix in lint-artifact.test.ts with #0000, #00000000, and white.

🔁 Powered by Looper · runner=reviewer · agent=opencode · An autonomous AI dev team for your GitHub repos.

if (/var\(--[\w-]+/.test(v)) return true;
if (/^rgb\(|^rgba\(|^hsl\(|^hsla\(/.test(v)) {
Comment thread
EthanGuo-coder marked this conversation as resolved.
const fnMatch = v.match(/^(rgba?|hsla?)\(/);
if (!fnMatch) return true;
const fn = fnMatch[1];
const args = extractBalancedArgs(v, fnMatch[0].length);
if (args === null) return true;
const slashIdx = findTopLevelSlash(args);
if (slashIdx >= 0) {
const alpha = parseAlpha(args.slice(slashIdx + 1));
if (alpha === null) return true;
return alpha !== 0;
}
const parts = splitTopLevel(args, ',');
if ((fn === 'rgba' || fn === 'hsla') && parts.length === 4) {
const alpha = parseAlpha(parts[3]);
if (alpha === null) return true;
return alpha !== 0;
}
return true;
}
if (/^linear-gradient\(|^radial-gradient\(/.test(v)) return true;
return false;
}

function extractCommerceRegions(html: string): { body: string; start: number }[] {
const regions: { body: string; start: number }[] = [];
const patterns = [
/<header\b[^>]*>([\s\S]*?)<\/header>/gi,
/<nav\b[^>]*>([\s\S]*?)<\/nav>/gi,
/<section\b[^>]*\bclass\s*=\s*["'][^"']*\bhero\b[^"']*["'][^>]*>([\s\S]*?)<\/section>/gi,
];
for (const re of patterns) {
for (const m of html.matchAll(re)) {
const body = m[1];
if (!body) continue;
const start = (m.index ?? 0) + m[0].indexOf(body);
regions.push({ body, start });
}
}
return regions;
}

function isCommerceCtaText(text: string): boolean {
return COMMERCE_CTA_VERBS.some((re) => re.test(text));
}

function stripTags(s: string): string {
return s.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim();
}
Loading
Loading