Claude
Skills
Sign in
Back

font-loading-audit

Included with Lifetime
$97 forever

Audits font loading behavior: FOIT/FOUT detection via timed screenshots, font-display validation per family, font file sizes and format efficiency (WOFF2 vs WOFF vs TTF), preload link validation, unused font declarations, and subsetting opportunities. Produces a font-by-font report with loading timeline and recommendations.

Security

What this skill does


# Font Loading Audit

Perform a comprehensive font loading audit. Inspects every @font-face
declaration, checks font-display strategy, measures font file transfer sizes,
validates preload hints, detects unused font declarations, identifies format
inefficiencies, and captures timed screenshots to detect FOIT (Flash of
Invisible Text) and FOUT (Flash of Unstyled Text).

## When to Use

- Diagnosing invisible or unstyled text flashes during page load.
- Verifying that `font-display: swap` or `optional` is set correctly.
- Checking that fonts are served in WOFF2 format for optimal compression.
- Identifying unused @font-face declarations that waste bandwidth.
- Auditing `<link rel="preload" as="font">` correctness.
- Estimating subsetting savings for fonts with limited character usage.

## Prerequisites

- **Playwright MCP server** connected and responding (all `mcp__playwright__browser_*` tools available).
- **Chromium-based browser** required for CDP CSS domain, Network domain, and `document.fonts` API.
- Target page must be reachable from the browser instance.

## Workflow

### Step 1 -- Set Up Network Monitoring for Font Requests

Enable CDP Network monitoring before navigation to capture font request
timing, transfer sizes, and content types.

```javascript
browser_run_code({
  code: `async (page) => {
    const client = await page.context().newCDPSession(page);
    await client.send('Network.enable');

    const fontRequests = {};

    client.on('Network.requestWillBeSent', (params) => {
      const url = params.request.url;
      if (url.match(/\\.(woff2?|ttf|otf|eot)(\\?|$)/i) || params.type === 'Font') {
        fontRequests[params.requestId] = {
          url,
          method: params.request.method,
          timestamp: params.timestamp,
          initiator: params.initiator ? {
            type: params.initiator.type,
            url: params.initiator.url || null
          } : null
        };
      }
    });

    client.on('Network.responseReceived', (params) => {
      if (fontRequests[params.requestId]) {
        fontRequests[params.requestId].status = params.response.status;
        fontRequests[params.requestId].mimeType = params.response.mimeType;
        fontRequests[params.requestId].protocol = params.response.protocol;
        fontRequests[params.requestId].responseTimestamp = params.timestamp;
        fontRequests[params.requestId].headers = {
          contentLength: params.response.headers['content-length'] || null,
          contentType: params.response.headers['content-type'] || null,
          cacheControl: params.response.headers['cache-control'] || null,
          accessControlAllowOrigin: params.response.headers['access-control-allow-origin'] || null
        };
      }
    });

    client.on('Network.loadingFinished', (params) => {
      if (fontRequests[params.requestId]) {
        fontRequests[params.requestId].encodedDataLength = params.encodedDataLength;
        fontRequests[params.requestId].finishedTimestamp = params.timestamp;
      }
    });

    client.on('Network.loadingFailed', (params) => {
      if (fontRequests[params.requestId]) {
        fontRequests[params.requestId].failed = true;
        fontRequests[params.requestId].errorText = params.errorText;
        fontRequests[params.requestId].blockedReason = params.blockedReason || null;
      }
    });

    page.__fontRequests = fontRequests;
    page.__cdpClient = client;

    return 'Font network monitoring enabled';
  }`
})
```

### Step 2 -- Capture Early Screenshot (FOIT/FOUT Detection)

Take a screenshot immediately after navigation starts to capture the initial
text rendering state before custom fonts load.

```
browser_navigate({ url: "<target_url>" })
```

Take the first screenshot as quickly as possible after navigation to catch
FOIT (invisible text) or FOUT (system font fallback):

```
browser_take_screenshot({ type: "png", filename: "font-loading-t0-initial.png" })
```

Wait 500ms and capture another:

```
browser_wait_for({ time: 0.5 })
```

```
browser_take_screenshot({ type: "png", filename: "font-loading-t1-500ms.png" })
```

Wait 1 second more:

```
browser_wait_for({ time: 1 })
```

```
browser_take_screenshot({ type: "png", filename: "font-loading-t2-1500ms.png" })
```

Wait until fonts are fully loaded:

```
browser_wait_for({ time: 2 })
```

```
browser_take_screenshot({ type: "png", filename: "font-loading-t3-final.png" })
```

### Step 3 -- Check document.fonts API Status

Enumerate all fonts tracked by the browser's FontFaceSet API to check
their load status.

```javascript
browser_evaluate({
  function: `() => {
    const fontSet = document.fonts;
    const fonts = [];

    fontSet.forEach((fontFace) => {
      fonts.push({
        family: fontFace.family,
        style: fontFace.style,
        weight: fontFace.weight,
        stretch: fontFace.stretch,
        unicodeRange: fontFace.unicodeRange,
        display: fontFace.display,
        status: fontFace.status // 'unloaded', 'loading', 'loaded', 'error'
      });
    });

    // Group by family
    const byFamily = {};
    for (const font of fonts) {
      const family = font.family.replace(/['"]/g, '');
      if (!byFamily[family]) {
        byFamily[family] = { variants: [], display: font.display, statuses: new Set() };
      }
      byFamily[family].variants.push({
        weight: font.weight,
        style: font.style,
        status: font.status,
        display: font.display
      });
      byFamily[family].statuses.add(font.status);
    }

    // Convert sets for serialization
    const familyReport = {};
    for (const [family, data] of Object.entries(byFamily)) {
      familyReport[family] = {
        variantCount: data.variants.length,
        display: data.display,
        allLoaded: Array.from(data.statuses).every(s => s === 'loaded'),
        statuses: Array.from(data.statuses),
        variants: data.variants
      };
    }

    return {
      readyState: fontSet.status, // 'loading' or 'loaded'
      totalFontFaces: fonts.length,
      byFamily: familyReport
    };
  }`
})
```

### Step 4 -- Extract @font-face Rules via CDP

Use the CDP CSS domain to enumerate all @font-face rules from all stylesheets,
including their font-display values and source URLs.

```javascript
browser_run_code({
  code: `async (page) => {
    const client = page.__cdpClient || await page.context().newCDPSession(page);
    await client.send('CSS.enable');

    // Get all stylesheets
    const fontFaceRules = [];
    const styleSheetIds = [];

    // Collect stylesheet IDs
    client.on('CSS.styleSheetAdded', (params) => {
      styleSheetIds.push(params.header);
    });

    // Wait for stylesheets to be reported
    await page.waitForTimeout(1000);

    // Get stylesheet text and parse @font-face rules
    for (const header of styleSheetIds) {
      try {
        const { text } = await client.send('CSS.getStyleSheetText', {
          styleSheetId: header.styleSheetId
        });

        // Extract @font-face blocks
        const fontFaceRegex = /@font-face\\s*\\{([^}]+)\\}/gi;
        let match;
        while ((match = fontFaceRegex.exec(text)) !== null) {
          const block = match[1];

          const getProperty = (prop) => {
            const propRegex = new RegExp(prop + '\\\\s*:\\\\s*([^;]+)', 'i');
            const m = block.match(propRegex);
            return m ? m[1].trim() : null;
          };

          fontFaceRules.push({
            family: (getProperty('font-family') || '').replace(/['"]/g, ''),
            style: getProperty('font-style') || 'normal',
            weight: getProperty('font-weight') || '400',
            display: getProperty('font-display') || null,
            src: getProperty('src'),
            unicodeRange: getProperty('unicode-range') || null,
            sourceSheet: header.sourceURL || header.title || 'inline',
            isInline: header.isInline || false
          });
        }
      } catch (e) {
        // Some stylesheets may not be accessible

Related in Security