mirror of
https://github.com/Nutlope/hallmark.git
synced 2026-08-14 12:35:33 +02:00
The finish tier: nine mechanical polish checks, none of them a gate
sloplint gains F1-F9, a tier that reports beside the gates without ever counting in the 58: straight quotes and three-period ellipses caught on a new position-preserving masked-prose view (code blocks, comments, and tags space-padded so line numbers stay exact; entity-encoded quotes match too, and doc.text now decodes quot/apos/hellip/mdash for future text checks), missing text-wrap balance on display heads, more than one filled-primary button, icon-in-coloured-circle coins, display-size numerics without tabular figures, one container recipe stamped across six-plus selectors, a second scroll-choreography pattern, and fake-human avatar hotlinks (the tier's one FAIL) with person-keyword stock URLs as the WARN half. gateSortKey groups F ids after the numbered gates; component scope inherits F4/F5/F6/F9. Verified: a fixture trips all nine with correct grades and lines; the canonical examples stay 0-FAIL (their handful of new F-WARNs is the tier working); the legacy sweep holds at exactly the 190-FAIL baseline.
This commit is contained in:
@@ -214,4 +214,12 @@ The CSS stamp at Step 6 records mobile pass alongside contrast: `· mobile: pass
|
||||
|
||||
---
|
||||
|
||||
## The finish tier (F-checks)
|
||||
|
||||
sloplint also runs nine FINISH checks, ids F1-F9. They are polish, not gates: confirm or fix each at Step 7 with the same seriousness as a WARN, but never count them in the 58, and never let one block a ship the gates approved. The rules live in [`finish.md`](finish.md) (F1-F6), [`layout-and-space.md`](layout-and-space.md) § Surfaces (F7), [`scroll-choreography.md`](scroll-choreography.md) (F8), and the imagery kit's avatar discipline (F9, whose fake-avatar half is the tier's one FAIL).
|
||||
|
||||
- **F1** straight quotes in prose · **F2** `...` instead of `…` · **F3** no `text-wrap: balance` on display heads · **F4** more than one filled-primary button recipe · **F5** icon in a coloured circle coin · **F6** display-size numerics without tabular figures · **F7** one container recipe stamped on six or more selectors · **F8** more than one scroll-choreography pattern · **F9** fake-human avatar hotlinks (FAIL) / person-keyword stock URLs (WARN).
|
||||
|
||||
---
|
||||
|
||||
If any answer is **yes**, fix it. Do not ship slop.
|
||||
|
||||
@@ -49,6 +49,13 @@
|
||||
* 11 uniform hover-scale across multiple selectors
|
||||
* 17 tooltip focus delay equals hover delay
|
||||
* 18 infinite auto-rotating animation without pause-on-hover
|
||||
* F1-F9 finish tier (never counted in the 58 gates; see slop-test.md § The
|
||||
* finish tier): F1 straight quotes in prose · F2 three-period ellipsis ·
|
||||
* F3 no text-wrap balance on display heads · F4 >1 filled primary button ·
|
||||
* F5 icon-in-coloured-circle coin · F6 display-size numerics without
|
||||
* tabular figures · F7 one container recipe on >= 6 selectors · F8 >1
|
||||
* scroll-choreography pattern · F9 fake-human avatar hotlinks (FAIL) and
|
||||
* person-keyword unsplash (WARN)
|
||||
* 23 accent token on viewport-scale backgrounds / display-size accent text
|
||||
* (WARN; posture-aware via the stamp; --render measures painted area
|
||||
* against the 5% budget and can FAIL)
|
||||
@@ -255,6 +262,8 @@ function parseHtml(file, src) {
|
||||
.replace(/<style\b[^>]*>[\s\S]*?<\/style>/gi, ' ')
|
||||
.replace(/<!--[\s\S]*?-->/g, ' ');
|
||||
const text = noScript.replace(/<[^>]*>/g, ' ')
|
||||
.replace(/"|�?34;/g, '"').replace(/�?39;|'/g, "'")
|
||||
.replace(/…|…/g, '\u2026').replace(/—|—/g, '\u2014')
|
||||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
.replace(/ /g, ' ').replace(/[ \t]+/g, ' ');
|
||||
return { file, src, styles, scripts, tags, text, lineOf };
|
||||
@@ -476,7 +485,7 @@ const trunc = (s, n = 72) => (s.length > n ? s.slice(0, n - 1) + '…' : s);
|
||||
|
||||
/* ----------------------------------------------------------- gate engine --- */
|
||||
|
||||
const COMPONENT_GATES = new Set(['1', '2', '7', '10', '14', '15', '22', '24', '26', '27', '30', '37', '38a', '39', '40', '41', '48']);
|
||||
const COMPONENT_GATES = new Set(['1', '2', '7', '10', '14', '15', '22', '24', '26', '27', '30', '37', '38a', '39', '40', '41', '48', 'F4', 'F5', 'F6', 'F9']);
|
||||
const PAGE_ONLY = new Set(['3', '4', '5', '17', '18', '19', '20', '25', '28', '33', '34', '42', '43', '46', '47', '49', '50', '51', '52', '53', '54', '55', '56', 'copy-tell']);
|
||||
|
||||
const findings = [];
|
||||
@@ -488,6 +497,8 @@ function report(gate, grade, file, line, evidence, fix) {
|
||||
|
||||
function gateSortKey(g) {
|
||||
if (g === '38a') return 38.5;
|
||||
const f = /^F(\d+)$/.exec(g);
|
||||
if (f) return 900 + Number(f[1]); // finish tier groups after the numbered gates; copy-tell stays last
|
||||
const n = parseFloat(g);
|
||||
return Number.isNaN(n) ? 999 : n;
|
||||
}
|
||||
@@ -1607,13 +1618,236 @@ function checkCopyTell(ctx) {
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------- finish tier (F1-F9) --- */
|
||||
/* Polish checks, never counted in the 58 gates. WARN unless noted; F9's
|
||||
fake-avatar half is FAIL. See slop-test.md § The finish tier. */
|
||||
|
||||
/* Position-preserving prose view: code-ish blocks, comments, and tags are
|
||||
space-padded (never collapsed) so match indices map straight to doc.lineOf.
|
||||
Entities stay ENCODED here; patterns match both forms. */
|
||||
function maskedProse(doc) {
|
||||
if (doc._prose == null) {
|
||||
const pad = (m) => m.replace(/[^\n]/g, ' ');
|
||||
doc._prose = doc.src
|
||||
.replace(/<(script|style|pre|code|kbd|samp|textarea)\b[^>]*>[\s\S]*?<\/\1>/gi, pad)
|
||||
.replace(/<!--[\s\S]*?-->/g, pad)
|
||||
.replace(/<[^>]*>/g, pad);
|
||||
}
|
||||
return doc._prose;
|
||||
}
|
||||
|
||||
function maxPxIn(value) { // largest px-equivalent in a value (handles clamp())
|
||||
let best = null;
|
||||
for (const m of String(value).matchAll(/(-?\d+(?:\.\d+)?)(px|rem|em)/g)) {
|
||||
const px = m[2] === 'px' ? parseFloat(m[1]) : parseFloat(m[1]) * 16;
|
||||
if (best == null || px > best) best = px;
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
function baseSelector(sel) { // strip pseudo-classes/elements
|
||||
return sel.replace(/::?[a-z-]+(\([^)]*\))?/gi, '').replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
/* gate F1: straight quotes in prose (curly quotes never flagged) */
|
||||
function checkF1(ctx) {
|
||||
const dq = /(?:"|"|�?34;)(?=[A-Za-z])|[A-Za-z](?:"|"|�?34;)/g;
|
||||
const ap = /[A-Za-z](?:'|�?39;|')(?=[A-Za-z])/g; // contractions/possessives only
|
||||
for (const doc of ctx.docs) {
|
||||
const prose = maskedProse(doc);
|
||||
const hits = [...prose.matchAll(dq), ...prose.matchAll(ap)].sort((a, b) => a.index - b.index);
|
||||
if (!hits.length) continue;
|
||||
const around = prose.slice(Math.max(0, hits[0].index - 16), hits[0].index + 16).replace(/\s+/g, ' ').trim();
|
||||
report('F1', 'WARN', doc.file, doc.lineOf(hits[0].index),
|
||||
`straight quote in prose x${hits.length} (first: ${trunc(around, 40)})`, 'Use typographic quotes: \u201C \u201D \u2019');
|
||||
}
|
||||
}
|
||||
|
||||
/* gate F2: three-period ellipsis in prose */
|
||||
function checkF2(ctx) {
|
||||
for (const doc of ctx.docs) {
|
||||
const prose = maskedProse(doc);
|
||||
const hits = [...prose.matchAll(/\.{3,}/g)];
|
||||
if (!hits.length) continue;
|
||||
report('F2', 'WARN', doc.file, doc.lineOf(hits[0].index),
|
||||
`three-period ellipsis in prose x${hits.length}`, 'Use a true ellipsis character (\u2026)');
|
||||
}
|
||||
}
|
||||
|
||||
/* gate F3: display heads without text-wrap balance (single WARN per file set) */
|
||||
function checkF3(ctx) {
|
||||
const H12 = /(^|[\s,>+~(])h[12]\b/i;
|
||||
const targets = [];
|
||||
let satisfied = false;
|
||||
for (const r of mergedDeclsBySelector(ctx.rules)) {
|
||||
const isH12 = H12.test(r.selector);
|
||||
const isDisplayClass = HEADING_SEL.test(r.selector)
|
||||
&& r.decls.some((d) => d.prop === 'font-size' && (maxPxIn(resolveVars(d.value, ctx.tokens)) ?? 0) >= 40);
|
||||
if (!isH12 && !isDisplayClass) continue;
|
||||
targets.push(r);
|
||||
if (r.decls.some((d) => /^text-wrap(-style)?$/.test(d.prop) && /balance|pretty/.test(d.value))) satisfied = true;
|
||||
}
|
||||
if (targets.length && !satisfied) {
|
||||
report('F3', 'WARN', targets[0].file, targets[0].line,
|
||||
`no text-wrap: balance on any display head (${targets.length} head rule(s), e.g. ${trunc(targets[0].selector, 32)})`,
|
||||
'Add text-wrap: balance to h1/h2-level heads');
|
||||
}
|
||||
}
|
||||
|
||||
/* gate F4: more than one filled-primary button recipe */
|
||||
function checkF4(ctx) {
|
||||
const BTN = /\b(btn|button|cta)\b|__(btn|button|cta)\b/i;
|
||||
const filled = new Map(); // base selector -> {r, d}
|
||||
for (const r of mergedDeclsBySelector(ctx.rules)) {
|
||||
if (isTokenRule(r) || /:hover|:active|:focus|:disabled|\.is-|\[disabled/i.test(r.selector)) continue;
|
||||
const first = baseSelector(r.selector.split(',')[0]);
|
||||
if (!BTN.test(first)) continue;
|
||||
for (const d of r.decls) {
|
||||
if (!/^background(-color)?$/.test(d.prop)) continue;
|
||||
const rv = resolveVars(d.value, ctx.tokens);
|
||||
if (/transparent|none/i.test(rv)) continue;
|
||||
if (/var\(\s*--color-(accent|ink)\b/.test(d.value) && !filled.has(first)) filled.set(first, { r, d });
|
||||
}
|
||||
}
|
||||
if (filled.size >= 2) {
|
||||
const sels = [...filled.keys()];
|
||||
const second = filled.get(sels[1]);
|
||||
report('F4', 'WARN', second.r.file, second.d.line,
|
||||
`${filled.size} filled primary-button recipes: ${trunc(sels.join(' + '), 52)}`,
|
||||
'One filled primary per page; demote the rest to outline/quiet variants');
|
||||
}
|
||||
}
|
||||
|
||||
/* gate F5: icon in a coloured circle coin */
|
||||
function checkF5(ctx) {
|
||||
for (const r of mergedDeclsBySelector(ctx.rules)) {
|
||||
if (isTokenRule(r) || !/icon|\bico\b/i.test(r.selector)) continue;
|
||||
const radius = r.decls.find((d) => d.prop === 'border-radius');
|
||||
if (!radius) continue;
|
||||
const rv = resolveVars(radius.value, ctx.tokens);
|
||||
const pct = /(\d+(?:\.\d+)?)%/.exec(rv);
|
||||
const round = (pct && parseFloat(pct[1]) >= 50) || ((maxPxIn(rv) ?? 0) >= 999);
|
||||
if (!round) continue;
|
||||
const bg = r.decls.find((d) => /^background(-color)?$/.test(d.prop)
|
||||
&& !/^(none|transparent)$/i.test(resolveVars(d.value, ctx.tokens).trim()));
|
||||
if (!bg) continue;
|
||||
const small = r.decls.some((d) => (d.prop === 'width' || d.prop === 'height')
|
||||
&& (pxOf(resolveVars(d.value, ctx.tokens)) ?? 999) <= 64);
|
||||
if (small) {
|
||||
report('F5', 'WARN', r.file, bg.line, `icon-in-coloured-circle coin: ${trunc(r.selector, 40)}`,
|
||||
'Drop the tinted coin; set the icon at text size in currentColor');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* gate F6: display-size numeric surfaces without tabular figures (selector heuristic) */
|
||||
function checkF6(ctx) {
|
||||
const NUM_SEL = /\b(stat|price|table|figure|count|num)[\w-]*/i;
|
||||
const hasTnum = (r) => r.decls.some((d) => d.prop === 'font-variant-numeric'
|
||||
|| (d.prop === 'font-feature-settings' && /tnum/.test(d.value)));
|
||||
if (ctx.rules.some((r) => /^(html|body|:root)\b/i.test(r.selector.trim()) && hasTnum(r))) return;
|
||||
const bySel = mergedDeclsBySelector(ctx.rules);
|
||||
const covered = new Set(bySel.filter(hasTnum).map((r) => baseSelector(r.selector)));
|
||||
const warned = new Set();
|
||||
for (const r of bySel) {
|
||||
if (isTokenRule(r) || !NUM_SEL.test(r.selector)) continue;
|
||||
const base = baseSelector(r.selector);
|
||||
if (warned.has(base) || covered.has(base)) continue;
|
||||
const fs2 = r.decls.find((d) => d.prop === 'font-size');
|
||||
if (!fs2 || (maxPxIn(resolveVars(fs2.value, ctx.tokens)) ?? 0) < 24) continue;
|
||||
warned.add(base);
|
||||
report('F6', 'WARN', r.file, fs2.line,
|
||||
`numeric selector ${trunc(base, 36)} at display size without tabular figures`,
|
||||
'Add font-variant-numeric: tabular-nums');
|
||||
}
|
||||
}
|
||||
|
||||
/* gate F7: container monotony - identical surface recipe across >= 6 selectors */
|
||||
function checkF7(ctx) {
|
||||
const byKey = new Map();
|
||||
for (const r of mergedDeclsBySelector(ctx.rules)) {
|
||||
if (isTokenRule(r) || /::(before|after)/.test(r.selector)) continue;
|
||||
const get = (re) => {
|
||||
const d = r.decls.filter((x) => re.test(x.prop)).pop();
|
||||
return d ? resolveVars(d.value, ctx.tokens).replace(/\s+/g, ' ').trim() : '';
|
||||
};
|
||||
const border = get(/^border$/)
|
||||
|| [get(/^border-width$/), get(/^border-style$/), get(/^border-color$/)].filter(Boolean).join(' ');
|
||||
const radius = get(/^border-radius$/);
|
||||
const surface = get(/^background(-color)?$/) || '';
|
||||
const shadow = get(/^box-shadow$/);
|
||||
if (!radius || (!border && !surface && !shadow)) continue;
|
||||
const key = [border, radius, surface, shadow].join(' | ');
|
||||
if (!byKey.has(key)) byKey.set(key, { sels: new Set(), r });
|
||||
for (const part of r.selector.split(',')) byKey.get(key).sels.add(baseSelector(part));
|
||||
}
|
||||
for (const v of byKey.values()) {
|
||||
if (v.sels.size >= 6) {
|
||||
report('F7', 'WARN', v.r.file, v.r.line,
|
||||
`same container recipe on ${v.sels.size} selectors (${trunc([...v.sels].slice(0, 4).join(', '), 48)}...)`,
|
||||
'Vary weight, radius, or fill by role; not every box is the same box');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* gate F8: more than one scroll-choreography pattern family */
|
||||
function checkF8(ctx) {
|
||||
const famOf = (sel) => {
|
||||
const m = /\.([a-z][\w-]*)/i.exec(sel) || /(^|[\s>+~])([a-z][\w-]*)/i.exec(sel);
|
||||
const name = m ? (m[1] || m[2]) : sel;
|
||||
return name.split('__')[0].split('--')[0].toLowerCase();
|
||||
};
|
||||
const NAVISH = /^(nav|header|topbar|masthead|banner)$/; // scroll-reactive navs are n10/n12 territory
|
||||
const timeline = new Map(), sticky = new Map(), animated = new Set();
|
||||
for (const r of ctx.rules) {
|
||||
if (r.keyframes || isTokenRule(r)) continue;
|
||||
for (const d of r.decls) {
|
||||
if (d.prop === 'animation-timeline' && !/^(none|auto)$/i.test(d.value.trim())
|
||||
&& !timeline.has(famOf(r.selector))) timeline.set(famOf(r.selector), { r, d });
|
||||
if (d.prop === 'animation' || d.prop === 'animation-name') animated.add(famOf(r.selector));
|
||||
if (d.prop === 'position' && /sticky/i.test(d.value)) sticky.set(famOf(r.selector), { r, d });
|
||||
}
|
||||
}
|
||||
const families = new Map(timeline);
|
||||
for (const [fam, v] of sticky) {
|
||||
if (!families.has(fam) && animated.has(fam) && !NAVISH.test(fam)) families.set(fam, v);
|
||||
}
|
||||
if (families.size > 1) {
|
||||
const names = [...families.keys()];
|
||||
const second = families.get(names[1]);
|
||||
report('F8', 'WARN', second.r.file, second.d.line,
|
||||
`${families.size} scroll-choreography patterns on one page (${trunc(names.join(', '), 44)})`,
|
||||
'Budget is one scroll pattern per page (scroll-choreography.md); cut the rest');
|
||||
}
|
||||
}
|
||||
|
||||
/* gate F9: fake-human avatar hotlinks (FAIL) / person-keyword unsplash (WARN) */
|
||||
function checkF9(ctx) {
|
||||
const FAKE = /(i\.pravatar\.cc|pravatar\.cc|api\.dicebear\.com|dicebear\.com|randomuser\.me|thispersondoesnotexist\.com|ui-avatars\.com)/i;
|
||||
const UNSPLASH = /https?:\/\/[^"'()\s]*unsplash\.com[^"'()\s]*/gi;
|
||||
const PERSONY = /person|face|portrait|people|headshot/i;
|
||||
const scan = (text, file, lineAt) => {
|
||||
const f = FAKE.exec(text);
|
||||
if (f) report('F9', 'FAIL', file, lineAt(f.index), `fake-human avatar service ${f[1]}`,
|
||||
'Use the Hallmark avatar kit or drop the face; a generated stranger is fabricated social proof');
|
||||
for (const m of text.matchAll(UNSPLASH)) {
|
||||
if (PERSONY.test(m[0])) report('F9', 'WARN', file, lineAt(m.index),
|
||||
`unsplash person-keyword URL ${trunc(m[0], 44)}`,
|
||||
'Faces as social proof need real people; keep unsplash for scenery and objects');
|
||||
}
|
||||
};
|
||||
for (const doc of ctx.docs) scan(doc.src, doc.file, (i) => doc.lineOf(i));
|
||||
for (const c of ctx.cssFiles) scan(c.raw, c.file, (i) => c.raw.slice(0, i).split('\n').length);
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------- runner --- */
|
||||
|
||||
const CHECKS = [check1, check2, check3, check4, check5, check7, check10, check11,
|
||||
check12, check14, check15, check17, check18, check19, check20, check22, check23, check24,
|
||||
check25, check26, check27, check28, check30, check33, check34, check37, check38a,
|
||||
check39, check40_41, check42, check43, check46, check47, check48, check49,
|
||||
check50, check51, check52, check53, check54, check55, check56, checkCopyTell];
|
||||
check50, check51, check52, check53, check54, check55, check56, checkCopyTell,
|
||||
checkF1, checkF2, checkF3, checkF4, checkF5, checkF6, checkF7, checkF8, checkF9];
|
||||
|
||||
const files = collectFiles(opts.paths);
|
||||
if (!files.length) { console.error('sloplint: no .html or .css files found'); process.exit(2); }
|
||||
|
||||
Reference in New Issue
Block a user