BXUP Track progress & test yourself — free
Frontend & Mobile · Hands-on lab · ~5 min read
FRONTEND & MOBILE · HANDS-ON LAB

Lab 04 · CSS Migration Slice with a Pixel-Diff Gate

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.

Time & tier

~90 minutes hands-on · Modernize stage (maps to module M4, playbook PB-17) · $0 — local Playwright, any assistant.

Setup

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,
    });
  });
}

Guided steps

  1. Compile the Sass and capture the legacy baseline.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.
  2. Run the gate against the migrated variant.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.
  3. Diagnose regression one from the diff, not the code. One failing state shows uniform edge drift — content shifted inward/outward on every side. Measure it: zoom the diff, count pixels, then and only then open the code and the Sass settings to explain the mechanism. Write the root cause in migration-log.md before fixing.
  4. Diagnose regression two. Another state's diff shows content present in expected but missing in actual (or vice versa). Reproduce it live: /?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.
  5. Fix both in the 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.
  6. Re-run until green.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.
  7. Adversarial check on your own gate. Change the migrated title to 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.
  8. Write the migration log. In 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).
Warning

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.

Verify

  • Three golden screenshots exist, generated from the legacy variant only.
  • The initial gated run failed, and both failures are documented with diff evidence before any fix was applied.
  • After fixes, npx playwright test passes 3/3 at the strict threshold, and re-running twice stays green (no flaky diffs).
  • The step-7 adversarial mutation was caught by the gate, then reverted.
  • migration-log.md names both regression mechanisms precisely and states the codemod/LLM/gate division of labor for the rest of the migration.

Self-grading rubric (pass = 7/10)

  • Both seeds caught by the gate (0–4): found via diff report before code-reading = 4; found by reading code first = 2 (the point was the harness).
  • Root-cause precision (0–3): each regression explained by mechanism — the exact value or selector semantics lost = 3; symptom-level only = 1.
  • Gate integrity (0–3): baselines from legacy only, adversarial check performed, strict threshold retained = 3; any snapshot regenerated from migrated output = 0 for this row.
▸Answer key — the two seeded regressionsoptional deep dive — click to expand
  • Seed 1 — spacing scale collision (fails the rest diff, and all three states show it). The house Sass scale defines $space-4: 1.125rem — deliberately off Tailwind's default where p-4 = 1rem. The migrated card uses p-4, silently shrinking padding by 0.125rem (2px at 16px root) on all four sides. The diff overlay shows uniform edge drift; text lines shift up/left. Fix: p-[1.125rem], or better, define the house scale in the Tailwind v4 @theme block (--spacing-4: 1.125rem) so the whole migration inherits it. This is the classic "same token name, different value" trap — the single most common real-world Tailwind-migration regression, and precisely the ambiguity class that mechanical converters and LLMs both get wrong without the settings file in context.
  • Seed 2 — lost :focus-within reveal (fails the focus diff). The Sass rule is &:hover &__actions, &:focus-within &__actions { opacity: 1 }. The migrated classes carry only group-hover:opacity-100 — the focus-within half of the selector was dropped, so keyboard users can never reveal Share/Archive. The hover diff passes; only the focus-state screenshot exposes it, which is why the harness captures three states, not one. Fix: add group-focus-within:opacity-100. Note the severity difference: seed 1 is cosmetic drift; seed 2 is an accessibility regression that a rest-state-only visual test would never see.
  • Also acceptable to flag (not seeded, no penalty either way):duration-100 vs the Sass 120ms (sub-threshold for static screenshots — worth a NIT in the log and a good catch if your diff threshold flagged nothing but you noted it from the code).

Scoring: full marks requires that the harness surfaced both seeds (steps 2–4) and that your log states the general rules — migrate the theme scale before migrating any component, and screenshot every interactive state the Sass selectors mention, because each compound selector half is a state you must diff.

Key Takeaway:

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.

This is the free reading view. Open BXUP to track your progress, run the hands-on labs, and prove it with original, exam-pattern assessments.
© Anvi Innovate · BXUP — Be Exponentially Upskilled · BXUP is an independent learning platform, not affiliated with or endorsed by AWS, Microsoft, Google, NVIDIA, IBM, Salesforce, Databricks, Oracle or Anthropic. All trademarks belong to their respective owners. · bxup.in