Frontend Labs
⋯

public/js/lib/vitals.js

// Core Web Vitals, collected from page load onward — the same algorithms the
// web-vitals library uses, in ~100 lines (§83). Started by main.js on load,
// so the numbers cover the whole visit, not just the Vitals station.

export const THRESHOLDS = {
  LCP: [2500, 4000], // ms
  INP: [200, 500], // ms
  CLS: [0.1, 0.25], // unitless
  FCP: [1800, 3000],
  TTFB: [800, 1800],
};

export const rating = (name, v) =>
  v == null ? "none" : v <= THRESHOLDS[name][0] ? "good" : v <= THRESHOLDS[name][1] ? "needs-improvement" : "poor";

export const vitals = { LCP: null, INP: null, CLS: 0, FCP: null, TTFB: null, lcpElement: null, interactions: 0, last: null };
export const loafs = []; // recent long animation frames
const listeners = new Set();
export const onVitals = (fn) => (listeners.add(fn), () => listeners.delete(fn));
const emit = () => listeners.forEach((fn) => fn(vitals));

const supported = PerformanceObserver.supportedEntryTypes ?? [];
const observe = (type, cb, opts = {}) => {
  if (!supported.includes(type)) return false;
  new PerformanceObserver((list) => cb(list.getEntries())).observe({ type, buffered: true, ...opts });
  return true;
};
export const support = {
  lcp: supported.includes("largest-contentful-paint"),
  cls: supported.includes("layout-shift"),
  inp: supported.includes("event"),
  loaf: supported.includes("long-animation-frame"),
};

// TTFB: from the navigation entry.
const nav = performance.getEntriesByType("navigation")[0];
if (nav) vitals.TTFB = nav.responseStart - nav.startTime;

// FCP
observe("paint", (entries) => {
  for (const e of entries) if (e.name === "first-contentful-paint") vitals.FCP = e.startTime;
  emit();
});

// LCP: the last candidate reported before the first input wins.
observe("largest-contentful-paint", (entries) => {
  const last = entries.at(-1);
  vitals.LCP = last.startTime;
  vitals.lcpElement = last.element;
  emit();
});

// CLS: shifts are grouped into "session windows" (gap < 1s, window < 5s);
// CLS is the largest window. Shifts right after input don't count.
let windowValue = 0;
let windowEntries = [];
observe("layout-shift", (entries) => {
  for (const e of entries) {
    if (e.hadRecentInput) continue;
    const first = windowEntries[0];
    const last = windowEntries.at(-1);
    if (last && e.startTime - last.startTime < 1000 && e.startTime - first.startTime < 5000) {
      windowValue += e.value;
      windowEntries.push(e);
    } else {
      windowValue = e.value;
      windowEntries = [e];
    }
    vitals.CLS = Math.max(vitals.CLS, windowValue);
  }
  emit();
});

// INP: the slowest interaction (or the 98th percentile once there are
// 50+ interactions). One interaction = all events sharing an interactionId.
const byInteraction = new Map();
const updateINP = (entries) => {
  for (const e of entries) {
    if (!e.interactionId) continue;
    const d = Math.max(byInteraction.get(e.interactionId) ?? 0, e.duration);
    byInteraction.set(e.interactionId, d);
    vitals.last = { name: e.name, duration: d, target: e.target?.textContent?.trim().slice(0, 40) };
  }
  const all = [...byInteraction.values()].sort((a, b) => b - a);
  vitals.interactions = all.length;
  vitals.INP = all.length ? all[Math.min(all.length - 1, Math.floor(all.length / 50))] : null;
  emit();
};
observe("event", updateINP, { durationThreshold: 16 });
observe("first-input", updateINP);

// Long Animation Frames: which frames were slow, and which scripts made them slow.
observe("long-animation-frame", (entries) => {
  for (const e of entries) {
    loafs.unshift({
      start: e.startTime,
      duration: e.duration,
      blocking: e.blockingDuration,
      scripts: e.scripts.map((s) => ({ invoker: s.invoker, source: s.sourceURL?.split("/").pop(), duration: s.duration })),
    });
  }
  loafs.length = Math.min(loafs.length, 15);
  emit();
});

export const resetINP = () => {
  byInteraction.clear();
  updateINP([]);
};

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