Migrate a Sass module to Tailwind v4 the hybrid way — mechanical conversion first, LLM for the ambiguous 20% — with Playwright screenshot diffs as the merge gate. Two visual regressions are seeded in the migrated code; the gate must catch both before the PR passes.
Dev inherited a marketing site with 40 Sass modules and a mandate: Tailwind v4 by end of quarter. His first attempt was pure LLM — "convert this file to Tailwind" — and it shipped a card whose padding quietly shrank by two pixels and a hover interaction that died entirely. Nobody noticed for a week because nobody was looking; there was no gate that looks for you. In this lab you run one migration slice properly: baseline screenshots first, migrate, diff, and treat every red diff as a defect until proven intentional.
The hybrid strategy this lab teaches has a specific division of labor, and getting it right is what makes a 40-module migration finishable. The mechanical 80% — renaming utility classes, converting straightforward property-value pairs, updating config syntax — belongs to the codemod or the official upgrade tool, because those transformations are deterministic and a machine does them without drift. The ambiguous 20% — a custom Sass function, a compound selector, a house spacing scale that collides with a framework default — is where the LLM earns its place, because those cases need judgment about intent that a codemod can't encode. But the LLM has no eyes and no memory of what the component looked like a minute ago, so its judgment on visual matters is unverified until something renders both versions and compares them pixel for pixel. That something is the Playwright gate. The three roles — codemod for the deterministic bulk, LLM for the ambiguous minority, pixel diff for the verdict — are the reusable pattern; this lab runs it once at small scale so the mechanism is muscle memory before you face the other 39 modules.
~90 minutes hands-on · Modernize stage (maps to module M4, playbook PB-17) · $0 — local Playwright, any assistant.
npm create vite@latest migrate-lab -- --template react-ts
cd migrate-lab && npm install
npm install -D sass playwright @playwright/test tailwindcss @tailwindcss/vite
npx playwright install chromium
mkdir -p src/legacy src/migrated tests
The legacy Sass module (two of the six components shown; author Button, Alert, Table, Nav in the same style, or migrate just these two and scale the lesson). src/legacy/_settings.scss:
$space-3: 0.75rem;
$space-4: 1.125rem; // house scale: NOT the usual 1rem
$radius: 10px;
$c-border: #e2e8f0;
$c-text: #0f172a;
$c-muted: #64748b;
src/legacy/card.scss:
@use "settings" as *;
.card {
padding: $space-4;
border: 1px solid $c-border;
border-radius: $radius;
color: $c-text;
&__title { font-size: 1.125rem; font-weight: 600; margin-bottom: $space-3; }
&__meta { color: $c-muted; font-size: 0.875rem; }
&__actions { opacity: 0; transition: opacity 120ms ease; }
&:hover &__actions,
&:focus-within &__actions { opacity: 1; }
}
The React components. src/legacy/Card.tsx (used by both versions — only the class names differ):
export function Card({ variant }: { variant: "legacy" | "tw" }) {
const c = variant === "legacy"
? { root: "card", title: "card__title", meta: "card__meta", actions: "card__actions" }
: {
root: "tw-card group p-4 border border-slate-200 rounded-[10px] text-slate-900",
title: "text-lg font-semibold mb-3",
meta: "text-slate-500 text-sm",
actions: "opacity-0 group-hover:opacity-100 transition-opacity duration-100",
};
return (
<article className={c.root} tabIndex={0}>
<h3 className={c.title}>Quarterly report</h3>
<p className={c.meta}>Updated 2 days ago · 14 pages</p>
<div className={c.actions}>
<button className="underline text-sm">Share</button>{" "}
<button className="underline text-sm">Archive</button>
</div>
</article>
);
}
The tw branch above is the migrated output you are gating — it was produced by an upgrade-tool-plus-LLM pass and committed as-is. Two regressions are seeded in it. Do not hunt them by reading; let the harness find them.
Preview page src/App.tsx:
import { Card } from "./legacy/Card";
import "./legacy/card-compiled.css"; // sass src/legacy/card.scss src/legacy/card-compiled.css
export default function App() {
const variant = new URLSearchParams(location.search).get("v") === "tw" ? "tw" : "legacy";
return (
<main style={{ padding: 32, maxWidth: 480 }}>
<Card variant={variant as "legacy" | "tw"} />
</main>
);
}
The gate, tests/visual.spec.ts:
import { test, expect } from "@playwright/test";
const states = [
{ name: "rest", act: async () => {} },
{ name: "hover", act: async (page: any) => page.locator("article").hover() },
{ name: "focus", act: async (page: any) => page.locator("article").focus() },
];
for (const s of states) {
test(`card ${s.name} matches legacy baseline`, async ({ page }) => {
await page.goto("/?v=tw");
await s.act(page);
await expect(page.locator("article")).toHaveScreenshot(`card-${s.name}.png`, {
maxDiffPixelRatio: 0.001,
});
});
}
npx sass src/legacy/card.scss src/legacy/card-compiled.css, start npm run dev, and confirm /?v=legacy renders with hover-revealed actions. Then generate baselines from the legacy variant: temporarily point the test's goto at /?v=legacy and run npx playwright test --update-snapshots. Restore goto to /?v=tw. Expected: three golden PNGs in tests/visual.spec.ts-snapshots/. The legacy render is ground truth; the migration must match it.npx playwright test. Expected: failures. Open the HTML report (npx playwright show-report) and study the diff images — Playwright shows expected, actual, and a red-pixel overlay per state. Record which states fail and what the overlay shows.migration-log.md before fixing./?v=tw, exercise hover with the mouse, then Tab into the card with the keyboard and compare against the legacy variant. Name the exact selector semantics the migration dropped.tw class strings — with an assistant, grounded. Paste the failing class strings, the original Sass, and your two root-cause notes; ask for the corrected Tailwind (v4 supports arbitrary values like p-[1.125rem] and variants like group-focus-within:). Review its answer against the Sass semantics yourself — you already know the mechanism, so you can catch a wrong fix.npx playwright test → 3/3 pass at maxDiffPixelRatio: 0.001. If a state still fails, iterate via the report, never by staring at the browser.font-semibold → font-bold and re-run. Expected: the gate fails. If it passes, your baselines or thresholds are wrong — fix the harness and re-verify both seeds are still caught by reverting your fixes momentarily. A gate you haven't seen fail is not a gate.migration-log.md: the two regressions with mechanism, which diff state exposed each, the fix, and the rule you'd adopt for the remaining 38 modules (what the codemod/upgrade tool does, what the LLM does, what the pixel gate does).Never regenerate baselines from the migrated variant to make tests pass. Updating snapshots to match the defect is the visual-testing equivalent of deleting a failing assertion — the gate goes green and the regression ships.
npx playwright test passes 3/3 at the strict threshold, and re-running twice stays green (no flaky diffs).migration-log.md names both regression mechanisms precisely and states the codemod/LLM/gate division of labor for the rest of the migration.In hybrid CSS migrations the LLM is a translator with no eyes — pixel diffs across rest, hover, and focus states are the eyes, the legacy render is the only legitimate baseline, and a gate you've never watched fail is decoration, not verification.