diff --git a/apps/mana/apps/web/src/lib/modules/calc/components/ModernSkin.svelte b/apps/mana/apps/web/src/lib/modules/calc/components/ModernSkin.svelte index d1fb096d6..cc0664f67 100644 --- a/apps/mana/apps/web/src/lib/modules/calc/components/ModernSkin.svelte +++ b/apps/mana/apps/web/src/lib/modules/calc/components/ModernSkin.svelte @@ -64,7 +64,7 @@ {#each buttons as row} {#each row as btn} @@ -490,7 +490,7 @@
{#each filteredLinks as link (link.id)}
@@ -545,7 +545,7 @@
@@ -553,28 +553,28 @@ diff --git a/apps/mana/apps/web/src/routes/welcome/+page.svelte b/apps/mana/apps/web/src/routes/welcome/+page.svelte index e72a2c2c4..3c97f1f57 100644 --- a/apps/mana/apps/web/src/routes/welcome/+page.svelte +++ b/apps/mana/apps/web/src/routes/welcome/+page.svelte @@ -77,7 +77,7 @@
{#each appConfig.features as feature}
diff --git a/scripts/migrate-transition-all.mjs b/scripts/migrate-transition-all.mjs new file mode 100644 index 000000000..4f9f4bfe4 --- /dev/null +++ b/scripts/migrate-transition-all.mjs @@ -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();