Frontend Labs
⋯

public/js/stations/vitals.js

// Station 7: Core Web Vitals (§35, §83). The numbers are collected from page
// load by lib/vitals.js; this station shows them and lets you break them.
import { h, station, panel, button, blockFor, ms } from "../lib/dom.js";
import { missions, missionList } from "../lib/missions.js";
import { vitals, onVitals, rating, THRESHOLDS, loafs, support, resetINP } from "../lib/vitals.js";

const fmt = (name, v) => (v == null ? "—" : name === "CLS" ? v.toFixed(3) : ms(v));

function gauge(name, desc) {
  const value = h("b", {}, "—");
  const needle = h("span", { class: "needle" });
  const el = h("div", { class: "gauge", "data-metric": name }, h("small", {}, name), value, h("div", { class: "meter" }, needle), h("small", {}, desc));
  return {
    el,
    update(v) {
      value.textContent = fmt(name, v);
      const [good, poor] = THRESHOLDS[name];
      // place the needle: 0–33% good, 33–66% needs improvement, 66–100% poor
      const pos = v == null ? 0 : v <= good ? (v / good) * 33 : v <= poor ? 33 + ((v - good) / (poor - good)) * 33 : Math.min(98, 66 + ((v - poor) / poor) * 33);
      needle.style.left = `${pos}%`;
      el.className = `gauge ${rating(name, v)}`;
    },
  };
}

// Give the browser a chance to paint between chunks of work.
const yieldToMain = () =>
  globalThis.scheduler?.yield ? scheduler.yield() : new Promise((r) => setTimeout(r, 0));

export function mount() {
  const gauges = {
    LCP: gauge("LCP", "Largest Contentful Paint: when the main content showed up"),
    INP: gauge("INP", "Interaction to Next Paint: worst input → paint delay"),
    CLS: gauge("CLS", "Cumulative Layout Shift: how much things jumped"),
    FCP: gauge("FCP", "First Contentful Paint (diagnostic)"),
    TTFB: gauge("TTFB", "Time to First Byte (diagnostic)"),
  };
  const lastEl = h("p", { class: "note", "aria-live": "polite" });
  const loafBody = h("tbody");
  const slot = h("div", {}); // layout shifts get injected here
  let sawPoorINP = false;

  const render = () => {
    for (const [k, g] of Object.entries(gauges)) g.update(vitals[k]);
    lastEl.textContent = vitals.last ? `Last interaction: ${vitals.last.name} on "${vitals.last.target}" took ${ms(vitals.last.duration)} to next paint · ${vitals.interactions} interactions so far` : "No interactions measured yet.";
    loafBody.replaceChildren(...loafs.slice(0, 8).map((l) =>
      h("tr", {}, h("td", {}, ms(l.start)), h("td", {}, ms(l.duration)), h("td", {}, ms(l.blocking)),
        h("td", { class: "mono" }, l.scripts.map((s) => `${s.invoker} (${s.source || "?"}) ${ms(s.duration)}`).join("; ") || "—"))));
    if (vitals.CLS > 0.1) missions.complete("vitals", "cls");
    if (vitals.last && vitals.last.duration > 500) sawPoorINP = true;
  };
  const off = onVitals(render);

  const status = h("span", { class: "note", "aria-live": "polite" });
  const shift = () => {
    status.textContent = "An \"ad\" will load in 800ms…";
    // Shifts within 500ms of an input don't count toward CLS, so this
    // simulates what really hurts: content that arrives late, on its own,
    // and then resizes itself. Both shifts land in one session window.
    setTimeout(() => {
      const ad = h("div", { class: "shift-banner" }, "📢 A late-loading ad with no space reserved for it");
      slot.prepend(ad);
      status.textContent = "Shifted! Everything below the ad jumped down…";
      setTimeout(() => {
        ad.classList.add("expanded");
        ad.append(h("div", {}, "…and then the ad resized itself. Shifted again."));
        status.textContent = "Shifted twice, 400ms apart: one session window, so the two scores add up.";
      }, 400);
    }, 800);
  };
  const fixShift = () => {
    slot.replaceChildren();
    status.textContent = "Banners removed. The fix in real life: reserve space (min-height or aspect-ratio) before content arrives.";
  };

  const slowClick = (e) => {
    e.currentTarget.textContent = "Working… (350ms blocking)";
    blockFor(350); // the handler hogs the main thread: no paint until it ends
    e.currentTarget.textContent = "Slow handler (350ms)";
  };
  const yieldingClick = async (e) => {
    const btn = e.currentTarget;
    btn.textContent = "Working in chunks…";
    for (let i = 0; i < 35; i++) {
      blockFor(10); // the same 350ms of work…
      await yieldToMain(); // …but the browser can paint after the first chunk
    }
    btn.textContent = "Same work, yielding";
    // The next paint came right after the first 10ms chunk, so this
    // interaction's INP contribution is small even though the work is the same.
    setTimeout(() => {
      if (sawPoorINP && vitals.last && vitals.last.duration < 200) missions.complete("vitals", "inp");
    }, 300);
  };

  render();
  const el = station(
    "Core Web Vitals",
    "Google's three user-centric metrics: loading (LCP), responsiveness (INP) and visual stability (CLS). Good means p75 of real users under 2.5s, 200ms and 0.1. These gauges measure this visit, with the same algorithms as the web-vitals library.",
    missionList("vitals"),
    slot, // late banners land here, above everything: shifts only count when visible
    h("div", { class: "gauges" }, Object.values(gauges).map((g) => g.el)),
    panel("Break CLS",
      h("div", { class: "row" }, button("Load a late banner", shift, { class: "danger" }), button("Remove banners", fixShift), status),
      h("p", {}, "Each banner lands at the top of the page, pushing everything below it down. CLS scores how much of the viewport moved and how far. Content that moves off-screen doesn't count, which is why real ads at the top hurt most."),
      !support.cls ? h("p", { class: "bad" }, "This browser doesn't report layout shifts.") : ""),
    panel("Break INP, then fix it",
      h("p", {}, "Both buttons do 350ms of work. The first does it in one block, so the click can't paint for 350ms. The second splits it into 10ms chunks and yields between them with ", h("code", {}, "scheduler.yield()"), " (or setTimeout as a fallback)."),
      h("div", { class: "row" },
        button("Slow handler (350ms)", slowClick, { class: "danger" }),
        button("Same work, yielding", yieldingClick, { class: "primary" }),
        button("reset INP", () => (resetINP(), render()), { class: "link" })),
      lastEl,
      !support.inp ? h("p", { class: "bad" }, "This browser doesn't expose Event Timing, so INP can't be measured here.") : ""),
    panel("Long animation frames: which script made the frame slow",
      support.loaf
        ? h("table", {}, h("thead", {}, h("tr", {}, h("th", {}, "start"), h("th", {}, "duration"), h("th", {}, "blocking"), h("th", {}, "scripts"))), loafBody)
        : h("p", {}, "This browser doesn't support the Long Animation Frames API (Chromium 123+)."),
      h("p", { class: "note" }, "In production, send these with the vitals so you know which handler to fix, not just that INP is bad.")),
    panel("Report vitals from real users",
      h("pre", { class: "code" }, `import { onLCP, onINP, onCLS } from "web-vitals/attribution";

function send(metric) {
  const body = JSON.stringify({ name: metric.name, value: metric.value,
    rating: metric.rating, id: metric.id, page: location.pathname,
    debug: metric.attribution });          // which element / script / phase
  // sendBeacon survives the page being closed; fetch keepalive is the fallback
  navigator.sendBeacon?.("/rum", body) ||
    fetch("/rum", { body, method: "POST", keepalive: true });
}
onLCP(send); onINP(send); onCLS(send);`.split("\n").map((l) => h("span", {}, l)))),
  );
  return { el, unmount: off };
}

Generated from SOURCE.md · 8 Oct 2026 16:36