#!/usr/bin/env node /** * T5 focus and size codemod (spec 4.5, 4.6 and 10.8 step 5). Pure and idempotent: * * - One focus style: the base rule `:where(:focus-visible)` in globals.css draws it. Component * classes that remove the outline or draw an own ring go: `outline-none` (also behind * `focus:`/`focus-visible:`), `focus-visible:ring-3`, `focus-visible:ring-ring/50`, * `focus-visible:border-ring`, own outline rings (`focus-visible:outline-2 …-ring`, the hive * glass edge ring, which `.holo-stage` now provides) and variant focus colours. * - A row whose focus target sits inside (label with its input) keeps one ring on the row: * `has-[:focus-visible]:outline-*`/`ring-*` become the utility `focus-ring-within`. * - Compact sizes follow the pointer, not the viewport: `sm:h-*`/`sm:min-h-*`/`sm:size-*` (also * behind a further variant) become `pointer-fine:…`. * - `focus:outline-2 focus:outline-ring` stays: it marks elements focused by script after a * click, where :focus-visible does not match (guarded with an allow-list). * - A class string that held nothing but focus classes is dropped together with its line. * * Selection outlines (`outline-2 outline-foreground`) and menu items' `outline-hidden` stay. * * CLI: node scripts/codemod-t5-focus.mjs [--check] * --check lists the files that would change and exits 1 if there are any. */ import { readdirSync, readFileSync, statSync, writeFileSync } from "node:fs"; import { join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; /** Characters that belong to a class token (so a match never starts or ends inside one). */ const TOKEN_CHAR = String.raw`[\w:/\[\]().%-]`; const escape = (text) => text.replace(/[.*+?^${}()|[\]\\/]/g, "\\$&"); /** Classes removed outright. */ const REMOVED = [ "outline-none", "focus:outline-none", "focus-visible:outline-none", "focus-visible:ring-3", "focus-visible:ring-[3px]", "focus-visible:ring-ring/50", "focus-visible:border-ring", "focus:ring-3", "focus:ring-ring/50", "focus-visible:outline-1", "focus-visible:outline-2", "focus-visible:outline-ring", "focus-visible:outline-offset-2", "focus-visible:outline-holo-edge-strong", "focus-visible:ring-destructive/20", "focus-visible:border-destructive/40", "focus-visible:ring-warning/30", ]; /** Own rings on a row with an inner focus target; replaced by one `focus-ring-within`. */ const WITHIN = [ "has-[:focus-visible]:outline-2", "has-[:focus-visible]:outline-ring", "has-[:focus-visible]:ring-3", "has-[:focus-visible]:ring-ring/50", "has-focus-visible:ring-3", "has-focus-visible:ring-ring/50", ]; const WITHIN_UTILITY = "focus-ring-within"; function alternativesOf(classes) { return `(?:${classes.map(escape).join("|")})(?!${TOKEN_CHAR})`; } /** * Removes the classes so that exactly one separator stays: a class after a space goes with * that space, a class at the start of a string goes with the space after it. */ function removeClasses(line, classes) { const alternatives = alternativesOf(classes); return line .replace(new RegExp(` ${alternatives}`, "g"), "") .replace(new RegExp(`(? sourceFiles(join(path, name))); } return /\.tsx?$/.test(path) && !/\.spec\./.test(path) ? [path] : []; } function main(args) { const check = args.includes("--check"); const targets = args.filter((arg) => arg !== "--check"); if (targets.length === 0) { console.error("usage: node scripts/codemod-t5-focus.mjs [--check] "); return 2; } const changed = targets.flatMap(sourceFiles).filter((file) => { const before = readFileSync(file, "utf8"); const after = transform(before); if (after === before) return false; if (!check) writeFileSync(file, after); return true; }); for (const file of changed) console.log(file); console.log(`${changed.length} file(s) ${check ? "to migrate" : "migrated"}`); return check && changed.length > 0 ? 1 : 0; } if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { process.exitCode = main(process.argv.slice(2)); }