refactor(theming): replace transition-all with specific transitions

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>
This commit is contained in:
Till JS 2026-04-22 15:57:49 +02:00
parent 46c03e6a5b
commit ea71d3c215
63 changed files with 285 additions and 102 deletions

View file

@ -0,0 +1,175 @@
#!/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();