From ea71d3c2150d64d81d15c2ee8992b8e3e2f09314 Mon Sep 17 00:00:00 2001 From: Till JS Date: Wed, 22 Apr 2026 15:57:49 +0200 Subject: [PATCH] refactor(theming): replace transition-all with specific transitions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../modules/calc/components/ModernSkin.svelte | 4 +- .../widgets/NutritionProgressWidget.svelte | 2 +- .../moodlit/components/mood/MoodCard.svelte | 2 +- .../components/mood/MoodFullscreen.svelte | 2 +- .../components/upload/UploadDropzone.svelte | 2 +- .../components/AchievementCard.svelte | 4 +- .../skilltree/components/SkillCard.svelte | 4 +- .../modules/times/components/EntryItem.svelte | 2 +- .../modules/times/components/TimerCard.svelte | 2 +- .../times/components/clock/WorldMap.svelte | 2 +- .../todo/components/OnboardingModal.svelte | 2 +- .../components/board-views/FokusLayout.svelte | 2 +- .../web/src/routes/(app)/calc/+page.svelte | 2 +- .../routes/(app)/calc/standard/+page.svelte | 8 +- .../web/src/routes/(app)/cards/+page.svelte | 2 +- .../web/src/routes/(app)/chat/+page.svelte | 4 +- .../routes/(app)/chat/archive/+page.svelte | 2 +- .../routes/(app)/chat/templates/+page.svelte | 2 +- .../src/routes/(app)/citycorners/+page.svelte | 2 +- .../citycorners/cities/[slug]/+page.svelte | 4 +- .../cities/[slug]/locations/[id]/+page.svelte | 4 +- .../cities/[slug]/map/+page.svelte | 2 +- .../web/src/routes/(app)/context/+page.svelte | 4 +- .../(app)/context/documents/+page.svelte | 2 +- .../routes/(app)/context/spaces/+page.svelte | 2 +- .../(app)/context/spaces/[id]/+page.svelte | 2 +- .../web/src/routes/(app)/food/+page.svelte | 18 +- .../src/routes/(app)/food/[id]/+page.svelte | 4 +- .../src/routes/(app)/food/add/+page.svelte | 3 +- .../web/src/routes/(app)/guides/+page.svelte | 2 +- .../src/routes/(app)/inventory/+page.svelte | 2 +- .../inventory/collections/new/+page.svelte | 2 +- .../src/routes/(app)/llm-test/+page.svelte | 4 +- .../web/src/routes/(app)/memoro/+page.svelte | 2 +- .../routes/(app)/memoro/archive/+page.svelte | 2 +- .../web/src/routes/(app)/moodlit/+page.svelte | 2 +- .../routes/(app)/moodlit/moods/+page.svelte | 4 +- .../web/src/routes/(app)/music/+page.svelte | 2 +- .../routes/(app)/music/playlists/+page.svelte | 2 +- .../routes/(app)/music/projects/+page.svelte | 2 +- .../web/src/routes/(app)/picture/+page.svelte | 2 +- .../routes/(app)/picture/board/+page.svelte | 2 +- .../(app)/presi/present/[id]/+page.svelte | 2 +- .../src/routes/(app)/questions/+page.svelte | 2 +- .../(app)/questions/collections/+page.svelte | 2 +- .../routes/(app)/questions/new/+page.svelte | 3 +- .../web/src/routes/(app)/quotes/+page.svelte | 6 +- .../routes/(app)/quotes/lists/+page.svelte | 2 +- .../(app)/skilltree/achievements/+page.svelte | 2 +- .../web/src/routes/(app)/storage/+page.svelte | 2 +- .../(app)/storage/favorites/+page.svelte | 4 +- .../routes/(app)/storage/files/+page.svelte | 4 +- .../storage/files/[folderId]/+page.svelte | 4 +- .../routes/(app)/storage/search/+page.svelte | 4 +- .../src/routes/(app)/times/clock/+page.svelte | 2 +- .../routes/(app)/times/projects/+page.svelte | 2 +- .../(app)/times/projects/[id]/+page.svelte | 2 +- .../routes/(app)/times/reports/+page.svelte | 8 +- .../web/src/routes/(app)/uload/+page.svelte | 16 +- .../src/routes/(app)/uload/links/+page.svelte | 10 +- .../src/routes/(app)/uload/tags/+page.svelte | 6 +- .../apps/web/src/routes/welcome/+page.svelte | 2 +- scripts/migrate-transition-all.mjs | 175 ++++++++++++++++++ 63 files changed, 285 insertions(+), 102 deletions(-) create mode 100644 scripts/migrate-transition-all.mjs 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();