import fs from 'node:fs'; import path from 'node:path'; import process from 'node:process'; const repoRoot = process.cwd(); const defaultRoot = path.join(repoRoot, 'apps', 'taro', 'src'); const allowedPillRadiusSelectors = [ 'avatar', 'progress', 'track', 'fill', 'chip', 'badge', 'tag', 'pill', 'meta', 'option-prefix', 'watermark', 'scrollbar', 'thumb', 'dot', '::before', '::after', ]; const allowedLinearGradientSelectors = [ 'body', 'page', 'layout', 'student-page', 'admin-page', 'platform-page', 'bootstrap-page', 'header', 'hero', 'band', 'surface', 'module', 'tone-', 'button.primary', 'primary-button', 'avatar', 'profile-avatar', 'avatar-choice-icon', 'brand-icon', 'shell::after', 'mini-avatar', 'sidebar-user-tag', 'form', 'panel', 'revealed', 'trend', 'points', 'nested', 'asset-watermark', ]; const allowedBackdropBlurSelectors = [ 'sidebar', 'mobile-dock', 'mobile-nav', ]; function parseArgs(argv) { const options = { root: defaultRoot, json: false, }; for (let index = 0; index < argv.length; index += 1) { const arg = argv[index]; if (arg === '--json') options.json = true; else if (arg === '--root') { options.root = path.resolve(repoRoot, argv[index + 1] || ''); index += 1; } else if (arg.startsWith('--root=')) { options.root = path.resolve(repoRoot, arg.slice('--root='.length)); } } return options; } function normalizeSlashes(value) { return value.replace(/\\/g, '/'); } function relative(filePath) { return normalizeSlashes(path.relative(repoRoot, filePath)); } function readText(filePath) { return fs.readFileSync(filePath, 'utf8').replace(/\r\n/g, '\n'); } function walkCssFiles(dir) { if (!fs.existsSync(dir)) return []; const result = []; for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { const entryPath = path.join(dir, entry.name); if (entry.isDirectory()) result.push(...walkCssFiles(entryPath)); else if (entry.name.endsWith('.css')) result.push(entryPath); } return result.sort((a, b) => relative(a).localeCompare(relative(b))); } function createCollector() { const checks = []; function push(status, id, message, details = {}) { checks.push({ status, id, message, details }); } return { checks, pass: (id, message, details) => push('pass', id, message, details), fail: (id, message, details) => push('fail', id, message, details), }; } function summarize(checks, scannedFiles) { return checks.reduce( (summary, item) => { summary[item.status] += 1; return summary; }, { fail: 0, pass: 0, scannedFiles }, ); } function lineNumber(text, index) { return text.slice(0, index).split('\n').length; } function selectorBefore(text, index) { const before = text.slice(0, index); const open = before.lastIndexOf('{'); const close = before.lastIndexOf('}'); if (open <= close) return ''; const selectorStart = before.lastIndexOf('}', open - 1); return before.slice(selectorStart + 1, open).trim().replace(/\s+/g, ' '); } function selectorMatches(selector, tokens) { const normalized = selector.toLowerCase(); return tokens.some(token => normalized.includes(token)); } function collectBorderRadiusViolations(files) { const violations = []; const radiusPattern = /border-radius\s*:\s*([^;]+);/gi; for (const filePath of files) { const text = readText(filePath); for (const match of text.matchAll(radiusPattern)) { const value = match[1].trim(); const selector = selectorBefore(text, match.index || 0); if (/999px/.test(value)) { if (!selectorMatches(selector, allowedPillRadiusSelectors)) { violations.push({ file: relative(filePath), line: lineNumber(text, match.index || 0), selector, value, reason: '999px radius is reserved for progress bars, chips, badges and similar pill controls.', }); } continue; } const pxValues = [...value.matchAll(/([0-9]+(?:\.[0-9]+)?)px/g)].map(item => Number(item[1])); const tooLarge = pxValues.filter(item => item > 32); if (tooLarge.length) { violations.push({ file: relative(filePath), line: lineNumber(text, match.index || 0), selector, value, reason: 'Legacy question-bank cards, panels and buttons should stay within the 8-32px rounded scale.', }); } } } return violations; } function collectPatternViolations(files, pattern, reason, allow = () => false) { const violations = []; for (const filePath of files) { const text = readText(filePath); for (const match of text.matchAll(pattern)) { const selector = selectorBefore(text, match.index || 0); if (allow(selector, match, text, filePath)) continue; violations.push({ file: relative(filePath), line: lineNumber(text, match.index || 0), selector, value: match[0].trim(), reason, }); } } return violations; } function emitCheck(collector, id, message, violations) { if (violations.length) collector.fail(id, message, { violations: violations.slice(0, 30), count: violations.length }); else collector.pass(id, message); } function main() { const options = parseArgs(process.argv.slice(2)); const files = walkCssFiles(options.root); const collector = createCollector(); emitCheck( collector, 'css.border_radius', 'Taro CSS keeps legacy radii within the old question-bank rounded scale', collectBorderRadiusViolations(files), ); emitCheck( collector, 'css.viewport_font_size', 'Taro CSS does not scale font-size with viewport units', collectPatternViolations(files, /font-size\s*:\s*[^;]*(?:vw|vh|vmin|vmax)[^;]*;/gi, 'Use fixed px/rpx-like sizing, not viewport-scaled font sizes.'), ); emitCheck( collector, 'css.letter_spacing', 'Taro CSS does not use negative letter spacing', collectPatternViolations(files, /letter-spacing\s*:\s*-\s*[^;]+;/gi, 'Letter spacing must be 0 or positive.'), ); emitCheck( collector, 'css.decorative_effects', 'Taro CSS uses only legacy-approved grid, banner, module and state gradients', [ ...collectPatternViolations(files, /radial-gradient\s*\(/gi, 'Do not add decorative radial/orb gradients.'), ...collectPatternViolations( files, /(^|[;\s{])filter\s*:\s*[^;]*blur\s*\(/gi, 'Do not add raw blurred decorative backgrounds; use scoped backdrop-filter only for legacy glass navigation.', ), ...collectPatternViolations( files, /backdrop-filter\s*:\s*[^;]*blur\s*\(/gi, 'Backdrop blur is only allowed for legacy glass navigation surfaces.', selector => selectorMatches(selector, allowedBackdropBlurSelectors), ), ...collectPatternViolations( files, /linear-gradient\s*\(/gi, 'Linear gradients must be scoped to legacy grid backgrounds, blue banners, module cards, primary actions or state panels.', selector => selectorMatches(selector, allowedLinearGradientSelectors), ), ], ); emitCheck( collector, 'css.pill_radius_scope', '999px radius is scoped to progress, chips, badges, avatars, scrollbars and decorative legacy markers', collectBorderRadiusViolations(files).filter(item => item.value.includes('999px')), ); const payload = { summary: summarize(collector.checks, files.length), root: relative(options.root), checks: collector.checks, }; if (options.json) { console.log(JSON.stringify(payload, null, 2)); } else { console.log(`Taro visual guardrails: ${payload.summary.fail} fail(s), ${payload.summary.pass} pass(es), ${payload.summary.scannedFiles} file(s) scanned`); for (const item of collector.checks) { const marker = item.status === 'fail' ? 'FAIL' : 'PASS'; console.log(`[${marker}] ${item.id}: ${item.message}`); } } if (payload.summary.fail > 0) process.exitCode = 1; } main();