Files
bookorbit/client/scripts/validate-styles.mjs
T
neon f49541cb20 style(client): refine app shell actions
Move GitHub actions into the sidebar footer and tighten header grouping.
2026-08-15 23:55:28 +00:00

103 lines
4.3 KiB
JavaScript

import { readdir, readFile } from 'node:fs/promises'
import { fileURLToPath } from 'node:url'
import path from 'node:path'
const clientRoot = fileURLToPath(new URL('..', import.meta.url))
const sourceDirectory = path.join(clientRoot, 'src')
/**
* Text colours that resolve from a per-theme tuned token. Fading these with alpha
* composites over the surface and bypasses the tuning: `--muted-foreground` is 0.52
* lightness in light mode and 0.725 in dark, so the same alpha step lands at a very
* different contrast ratio per theme and reliably fails AA in dark mode.
*
* Alpha on fills, borders, rings and shadows is fine. Only text is restricted.
*/
const TUNED_TEXT_TOKENS = [
'foreground',
'muted-foreground',
'sidebar-foreground',
'sidebar-accent-foreground',
'card-foreground',
'popover-foreground',
'primary',
'primary-foreground',
'secondary-foreground',
'accent-foreground',
'destructive',
'destructive-foreground',
]
// Matches `text-muted-foreground/70` including any Tailwind variant prefix
// (`hover:`, `placeholder:`, `group-data-[active=true]/item:` and so on).
const FADED_TEXT_PATTERN = new RegExp(String.raw`(?:[\w[\]=.\-/]+:)*text-(?:${TUNED_TEXT_TOKENS.join('|')})\/\d+`, 'g')
const LEGACY_SETTINGS_BUTTON_PATTERN = /\bsettings-btn-(?:primary|outline|secondary|danger)\b/g
const BUTTON_OPENING_TAG_PATTERN = /<Button\b[\s\S]*?>/g
const DESTRUCTIVE_BUTTON_VARIANT_PATTERN = /\bvariant=["']destructive(?:-outline|-ghost)?["']/
const DESTRUCTIVE_BUTTON_COLOR_OVERRIDE_PATTERN =
/\b(?:text|bg|border)-(?:destructive|destructive-foreground|muted-foreground)\b|\bhover:(?:text|bg|border)-/
/**
* Decorative, non-informational text that is deliberately near-invisible. Contrast
* requirements do not apply because the text carries no meaning, so a tuned token
* would be wrong here rather than merely dimmer.
*
* `text-white/*` and `text-black/*` are intentionally out of scope: they sit over book
* covers, reader scrims and lightboxes rather than over a theme surface.
*/
const ALLOWED = new Map([['views/NotFoundView.vue', 'oversized decorative 404 numeral, carries no information']])
async function sourceFiles(directory) {
const entries = await readdir(directory, { withFileTypes: true })
const files = await Promise.all(
entries.map(async (entry) => {
const entryPath = path.join(directory, entry.name)
if (entry.isDirectory()) return entry.name === '__tests__' ? [] : sourceFiles(entryPath)
return entry.isFile() && /\.(css|ts|vue)$/.test(entry.name) && !/\.(spec|test)\.ts$/.test(entry.name) ? [entryPath] : []
}),
)
return files.flat()
}
const errors = []
const unusedAllowances = new Set(ALLOWED.keys())
for (const file of await sourceFiles(sourceDirectory)) {
// normalise separators so the ALLOWED keys (POSIX-style) also match on Windows
const relativePath = path.relative(sourceDirectory, file).split(path.sep).join('/')
const source = await readFile(file, 'utf8')
const lines = source.split('\n')
for (const match of source.matchAll(BUTTON_OPENING_TAG_PATTERN)) {
if (!DESTRUCTIVE_BUTTON_VARIANT_PATTERN.test(match[0]) || !DESTRUCTIVE_BUTTON_COLOR_OVERRIDE_PATTERN.test(match[0])) continue
const line = source.slice(0, match.index).split('\n').length
errors.push(`${relativePath}:${line}: destructive Button color override - keep destructive styling centralized in the shared Button variants.`)
}
for (const [index, line] of lines.entries()) {
for (const match of line.matchAll(LEGACY_SETTINGS_BUTTON_PATTERN)) {
errors.push(`${relativePath}:${index + 1}: ${match[0]} - use the shared Button component and its variants.`)
}
for (const match of line.matchAll(FADED_TEXT_PATTERN)) {
if (ALLOWED.has(relativePath)) {
unusedAllowances.delete(relativePath)
continue
}
errors.push(
`${relativePath}:${index + 1}: ${match[0]} - fade-free text only. Use --foreground for interactive elements, --muted-foreground for secondary text, --primary for active state.`,
)
}
}
}
for (const stalePath of unusedAllowances) {
errors.push(`${stalePath}: listed in the validate-styles allowlist but no longer has faded text. Remove the entry.`)
}
if (errors.length > 0) {
throw new Error(`Style validation failed:\n${errors.join('\n')}`)
}
console.log('Validated style tokens and shared settings buttons')