mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 22:21:10 +02:00
Sweep 98 `transition-all` occurrences across 62 files and replace with targeted Tailwind transition utilities. Motivation: 1. `transition-all` animates every property, including CSS custom- property-backed colours. On first paint the vars may not have resolved yet, producing the P5 "white-on-white until first interaction" rendering bug. The same bug hit food/moodlit ListViews in the earlier theme migration. 2. Specific transitions also perform better — no layout-property interpolation overhead. Codemod scripts/migrate-transition-all.mjs classifies each class attribute by its sibling classes and picks one of: - `transition-opacity` — icon fade on group-hover - `transition-[width]` — progress-bar width anim - `transition-[transform,colors,box-shadow]` — scaled buttons/cards - `transition-[border-color,box-shadow]` — card hover:border+shadow - `transition-colors` — default (card/row hover) 91 / 98 auto-classified, 7 hand-migrated: - EntryItem → transition-[box-shadow] (ring fade) - NutritionProgressWidget → transition-[stroke-dashoffset,stroke] - OnboardingModal → transition-[width,background-color] - times/reports (3×) → transition-[width] / -[height] (bar anims) - presi/present → transition-[width,background-color] (dots) svelte-check clean with 0 errors; validate:all green. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
175 lines
5.8 KiB
JavaScript
175 lines
5.8 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* One-shot codemod: replace `transition-all` with specific transitions
|
|
* based on what the element actually animates (derived from sibling
|
|
* hover:/focus:/group-hover:/active: classes and the element's layout
|
|
* role).
|
|
*
|
|
* Why: `transition-all` animates *every* property, including custom-
|
|
* property-backed colours. On first paint, some CSS custom properties
|
|
* haven't resolved yet, producing the P5 "white-on-white until first
|
|
* interaction" rendering bug. Specific transitions also perf better
|
|
* (no layout-property interpolation).
|
|
*
|
|
* Strategy: this script parses each `class="..."` attribute that
|
|
* contains `transition-all` and picks one of:
|
|
*
|
|
* - `transition-opacity`
|
|
* When the element only changes opacity (icon fade on group-hover).
|
|
*
|
|
* - `transition-[width]`
|
|
* Progress bars — the element has `h-full rounded-full` pattern.
|
|
*
|
|
* - `transition-[transform,colors,box-shadow]`
|
|
* Scaled buttons / cards (`hover:scale-*` or `active:scale-*`).
|
|
*
|
|
* - `transition-[border-color,box-shadow]`
|
|
* Cards with hover:border + hover:shadow (no colour/bg change).
|
|
*
|
|
* - `transition-colors`
|
|
* Default for everything else (most card/row hover states).
|
|
*
|
|
* Ambiguous cases stay as `transition-all` — review the remaining list
|
|
* with `rg transition-all` and convert by hand.
|
|
*
|
|
* Usage:
|
|
* node scripts/migrate-transition-all.mjs [--dry-run]
|
|
*/
|
|
|
|
import { execSync } from 'node:child_process';
|
|
import { readFileSync, writeFileSync } from 'node:fs';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { dirname, join } from 'node:path';
|
|
|
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
const REPO_ROOT = join(__dirname, '..');
|
|
const DRY_RUN = process.argv.includes('--dry-run');
|
|
|
|
const SCAN_GLOBS = ['apps/mana/apps/web/src/**/*.svelte'];
|
|
|
|
function listFiles() {
|
|
const args = SCAN_GLOBS.map((g) => `"${g}"`).join(' ');
|
|
const out = execSync(`git ls-files ${args}`, {
|
|
cwd: REPO_ROOT,
|
|
encoding: 'utf8',
|
|
});
|
|
return out
|
|
.split('\n')
|
|
.map((p) => p.trim())
|
|
.filter(Boolean);
|
|
}
|
|
|
|
/** Decide the replacement for a transition-all occurrence based on sibling classes. */
|
|
function pickReplacement(classes) {
|
|
const has = (p) => classes.some((c) => p.test(c));
|
|
|
|
const hasScale = has(/:scale-/) || has(/^scale-/);
|
|
const hasOpacity = has(/opacity-\d/) || has(/:opacity-\d/);
|
|
const hasHoverBg = has(/(?:hover|focus|active|group-hover):bg-/);
|
|
const hasHoverBorder = has(/(?:hover|focus|active|group-hover):border-/);
|
|
const hasHoverShadow = has(/(?:hover|focus|active|group-hover):shadow-/);
|
|
const hasHoverText = has(/(?:hover|focus|active|group-hover):text-/);
|
|
// Progress bars: `h-full rounded-full` without any interactive variant.
|
|
const isProgressBar =
|
|
classes.includes('h-full') &&
|
|
classes.includes('rounded-full') &&
|
|
!hasScale &&
|
|
!hasHoverBg &&
|
|
!hasHoverBorder;
|
|
|
|
if (isProgressBar) return 'transition-[width]';
|
|
if (hasScale) return 'transition-[transform,colors,box-shadow]';
|
|
// Pure opacity fade (icon reveal on hover).
|
|
if (hasOpacity && !hasHoverBg && !hasHoverBorder && !hasHoverText && !hasHoverShadow) {
|
|
return 'transition-opacity';
|
|
}
|
|
// Card with border + shadow dance, no colour change.
|
|
if (hasHoverBorder && hasHoverShadow && !hasHoverBg && !hasHoverText) {
|
|
return 'transition-[border-color,box-shadow]';
|
|
}
|
|
// Any colour-ish interactive change.
|
|
if (hasHoverBg || hasHoverBorder || hasHoverText || hasHoverShadow) {
|
|
return 'transition-colors';
|
|
}
|
|
// No signal — leave as-is so the human can decide.
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Walk each class="..." attribute (including class={...} template strings)
|
|
* containing `transition-all` and rewrite it in place. Skips cases where
|
|
* no deterministic replacement is found.
|
|
*/
|
|
function migrateSource(src) {
|
|
let changes = 0;
|
|
let unresolved = 0;
|
|
|
|
// Match `class="..."` and `class={"..."}` constructs. Keep simple —
|
|
// we'll bail out if the value looks too complex to tokenise.
|
|
const classAttrRe = /class\s*=\s*(["'`])([\s\S]*?)\1/g;
|
|
|
|
const out = src.replace(classAttrRe, (full, quote, value) => {
|
|
if (!value.includes('transition-all')) return full;
|
|
|
|
// Tokenise on whitespace — good enough for Svelte class attributes
|
|
// that embed `{expr}` fragments; those stay opaque and we just
|
|
// skip them as a single token, which is fine because we only read
|
|
// known static classes.
|
|
const classes = value
|
|
.split(/\s+/)
|
|
.map((t) => t.trim())
|
|
.filter(Boolean);
|
|
|
|
if (!classes.some((c) => c === 'transition-all')) {
|
|
// `transition-all duration-300` etc. — remove the duration
|
|
// handling and just match the token itself.
|
|
return full;
|
|
}
|
|
|
|
const replacement = pickReplacement(classes);
|
|
if (!replacement) {
|
|
unresolved++;
|
|
return full;
|
|
}
|
|
|
|
const newClasses = classes.map((c) => (c === 'transition-all' ? replacement : c));
|
|
changes++;
|
|
return `class=${quote}${newClasses.join(' ')}${quote}`;
|
|
});
|
|
|
|
return { out, changes, unresolved };
|
|
}
|
|
|
|
function migrate() {
|
|
const paths = listFiles();
|
|
let totalChanges = 0;
|
|
let totalUnresolved = 0;
|
|
let changedFiles = 0;
|
|
|
|
for (const rel of paths) {
|
|
const abs = join(REPO_ROOT, rel);
|
|
const src = readFileSync(abs, 'utf8');
|
|
if (!src.includes('transition-all')) continue;
|
|
|
|
const { out, changes, unresolved } = migrateSource(src);
|
|
totalChanges += changes;
|
|
totalUnresolved += unresolved;
|
|
|
|
if (changes > 0) {
|
|
changedFiles++;
|
|
if (!DRY_RUN) writeFileSync(abs, out, 'utf8');
|
|
console.log(` ${String(changes).padStart(3)} → (${unresolved} left) ${rel}`);
|
|
} else if (unresolved > 0) {
|
|
console.log(` ${'·'.padStart(3)} (${unresolved} left) ${rel}`);
|
|
}
|
|
}
|
|
|
|
const verb = DRY_RUN ? 'Would migrate' : 'Migrated';
|
|
console.log(
|
|
`\n${verb} ${totalChanges} transition-all → specific, ` +
|
|
`${totalUnresolved} left ambiguous across ${changedFiles} file(s).`
|
|
);
|
|
if (DRY_RUN) console.log('Run without --dry-run to apply.');
|
|
}
|
|
|
|
migrate();
|