Figment.so
Mac hostingWebsite hostingGuidesHow to usePricing

Safari Dark-Mode CSS Rendering Regression Test

Short answer: drive prefers-color-scheme through Playwright's WebKit engine on a hosted Mac, snapshot each page in light and dark, and diff with toHaveScreenshot. For CSS-variable regressions, the Figment $29/month virtual Intel macOS runs Playwright's WebKit build and is enough. If you also need to catch components that read native NSAppearance, book the Figment M-series Mac from $95/month — the exact chip and RAM are quoted on request.

You ship a design-token rename and CI stays green — until a reader on Safari pings you to say the dark-mode hero text has vanished into its background. The regression was in the diff, but your DOM-level tests never looked at rendered colour. This guide automates the comparison on the real WebKit engine, diffs each pair against a stored baseline, and fails the build when only one mode drifts.

Why Emulation Differs from a Real Safari Session

WebKit supports prefers-color-scheme through the standard media feature and window.matchMedia. The canonical CSS pattern declares variables once and overrides them inside the dark query:

:root { --bg: #ffffff; --text: #111111; }

@media (prefers-color-scheme: dark) {
  :root { --bg: #1a1a1a; --text: #f0f0f0; }
}

page.emulateMedia({ colorScheme: 'dark' }) injects the preference without touching the OS setting, which covers the overwhelming majority of CSS-variable regressions. What it does not exercise are code paths that query NSAppearance from a native macOS control or a web component that reads the system scrollbar style. For those, the host's actual appearance has to change — which means a macOS host, not a Linux WebKit build.

For standard variable-driven theming, emulation is accurate and fast enough for CI.

The Light/Dark Fixture

The fixture runs each target page twice — once light, once dark — and names its screenshots {slug}-light.png and {slug}-dark.png so a human reviewer always sees paired files.

// tests/fixtures/dark-mode.ts
import { test as base, expect } from '@playwright/test';

type ColorMode = 'light' | 'dark';

type DarkModeFixtures = {
  screenshotPair: (slug: string, url: string) => Promise<void>;
};

export const test = base.extend<DarkModeFixtures>({
  screenshotPair: async ({ page }, use) => {
    const capture = async (slug: string, url: string) => {
      for (const mode of ['light', 'dark'] as ColorMode[]) {
        await page.emulateMedia({ colorScheme: mode });
        await page.goto(url, { waitUntil: 'networkidle' });
        // Let any CSS transitions triggered by the media change settle.
        await page.waitForTimeout(200);
        await expect(page).toHaveScreenshot(`${slug}-${mode}.png`, {
          threshold: 0.15,       // 0 strict – 1 lax, YIQ per-pixel tolerance
          maxDiffPixels: 300,    // cap total differing pixels; tune per page
        });
      }
    };
    await use(capture);
  },
});

export { expect };
// tests/dark-mode-regression.spec.ts
import { test } from './fixtures/dark-mode';

const PAGES = [
  { slug: 'home',      url: 'https://staging.example.com/' },
  { slug: 'pricing',   url: 'https://staging.example.com/pricing' },
  { slug: 'blog-post', url: 'https://staging.example.com/blog/sample' },
];

test.use({ browserName: 'webkit' });

for (const { slug, url } of PAGES) {
  test(`dark-mode pair: ${slug}`, async ({ screenshotPair }) => {
    await screenshotPair(slug, url);
  });
}

On first run Playwright writes baseline PNGs into a {testFile}-snapshots/ directory next to the spec. Subsequent runs compare against them; --update-snapshots regenerates them. The code above is an example, not a benchmark — run it against your own staging URLs before committing baselines.

Threshold Options and Per-Mode Tuning

Playwright's toHaveScreenshot uses the pixelmatch comparator. threshold (default 0.2) sets a YIQ per-pixel colour tolerance between 0 (strict) and 1 (lax); maxDiffPixels caps the absolute count of pixels allowed to differ regardless of how far each one differs. Start from these working values and adjust once you see a few real runs:

Page characteristicthresholdmaxDiffPixels
Flat-colour marketing page0.10100
Pages with gradients or shadows0.20300
Pages with charts or data visuals0.25800
Pages with video thumbnails or embeds0.301500

Keep the strictest values on anything driven entirely by CSS variables — any drift there is a real regression. Loosen them where third-party embeds or decoded image content varies run-to-run.

Flagging Failures That Appear in Only One Mode

A failure in the dark pair but not the light pair almost always points at a missing dark rule for a specific component. The inverse — a light-only failure — usually means a prefers-color-scheme: dark block leaked a value outside its media query.

Playwright writes an HTML report to playwright-report/index.html; the diff view shows baseline, current, and pixel-diff overlay side-by-side. When only the dark variant fails, the paired light screenshot in the same report confirms the light baseline is intact and narrows the blame to the dark ruleset. On GitHub Actions, upload the report when a run fails:

- uses: actions/upload-artifact@v4
  if: failure()
  with:
    name: playwright-report
    path: playwright-report/
    retention-days: 7

Regenerate baselines after an intentional token change:

npx playwright test --update-snapshots --grep "dark-mode pair"

Review the new PNGs before committing. git diff --stat on the snapshot folder is a quick sanity check that only the pages you expected have changed references.

Picking the Right Host: a Worked Cost Comparison

Playwright ships a WebKit build per platform; on Linux it is a standalone build that diverges from Safari's exact rendering (fonts, text shaping, CoreGraphics compositing). On macOS the WebKit binary tracks the Safari that ships with that OS, so colour and layout output are much closer to what a Safari user actually sees. If your suite only checks CSS variable values through emulation, the virtual Intel macOS plan is enough. If any component reads NSAppearance, you want physical Apple-silicon macOS.

Twelve months of a mid-tier Mac mini, headline rates only, before setup or taxes stated in each offer:

OfferHeadline12 monthsSource
Figment virtual Intel macOS (2 vCPU, 8 GiB, 80 GiB)$29/mo$348/mac-hosting
Figment M-series (entry; chip/RAM confirmed in quote)from $95/mofrom $1,140/mac-hosting
CloudMac dedicated M4, 16 GB, 256 GB — monthly$99.99/mo$1,199.88cloudmac.org/pricing
CloudMac dedicated M4, 16 GB, 256 GB — annual$999/year$999cloudmac.org
MacStadium M4.S, 16 GB, 256 GB$149/mo$1,788macstadium.com/pricing

Read that honestly: CloudMac's $999 annual on the 256 GB M4 is cheaper over a year than 12 × $95 at Figment, and its 256 GB tier was listed as reservable at verification time while 512 GB and 1 TB showed "fully reserved". MacStadium's M4.S is a specific 16 GB / 256 GB machine with a published 99.99 % uptime target plus support. The Figment $95 figure is an entry price, not an equivalent M4 — exact chip, RAM and disk are confirmed in the quote, and the physical M-series plans currently collect requests until supply is confirmed.

What Figment adds, and what this page is really about, is the specific managed CI work for the browser-rendering case: a Mac prepared with your Playwright toolchain, your baselines wired into the CI secret store, and the dark/light suite running on every pull request. The managed plans include that setup, backups, monitoring and a fixed monthly support window; the $29 virtual Intel macOS plan is self-serve and sized for emulation-only regression suites.

Start

Pick the plan that matches the component you need to cover:

  • CSS-variable regressions only, WebKit via emulation → $29 virtual Intel macOS.
  • Native-control dark-mode checks or Apple-silicon toolchains → M-series quote.

Start the setup →


Subscribe for Figma design and website publishing guides.


Figment.so

Contact

Twitter

Website pricing

Compute index

About

Privacy

Terms