Frontend Labs
⋯

public/js/lib/missions.js

// Missions: two or three small challenges per station, ticked off
// automatically when you do the thing. Progress persists per browser.
import { h, storage } from "./dom.js";

export const MISSIONS = {
  overview: [["tour", "Play the tour of a click, start to finish"]],
  network: [
    ["waterfall", "Fire the request burst and read the waterfall"],
    ["whoami", "See which HTTP version and TLS cipher you negotiated"],
    ["tls", "Step through the TLS 1.3 handshake"],
  ],
  render: [
    ["composite", "Animate with transform: skip Layout and Paint"],
    ["thrash", "Measure layout thrashing against batched reads"],
  ],
  eventloop: [
    ["predict", "Predict a snippet's output correctly"],
    ["verify", "Run a snippet for real and match the model"],
  ],
  engine: [
    ["shapes", "Make two objects with different hidden classes"],
    ["megamorphic", "Push a call site to megamorphic"],
  ],
  threads: [
    ["jank", "Freeze the ball with main-thread work, then fix it with a worker"],
    ["race", "Lose updates in a data race, then fix it with Atomics"],
  ],
  storage: [
    ["idb", "Write and read back a record in IndexedDB"],
    ["inactive", "Trigger a TransactionInactiveError on purpose"],
  ],
  vitals: [
    ["cls", "Push CLS past 0.1"],
    ["inp", "Make INP poor, then make it good with yielding"],
  ],
  debug: [
    ["errors", "Catch an uncaught error and an unhandled rejection"],
    ["csp", "Trigger a CSP violation report"],
    ["leak", "Create a memory leak and find it"],
  ],
};

const store = storage("browser-lab:missions", {});
const done = store.get();
const listeners = new Set();

export const missions = {
  isDone: (station, id) => !!done[`${station}:${id}`],
  complete(station, id) {
    const key = `${station}:${id}`;
    if (done[key]) return;
    done[key] = Date.now();
    store.set(done);
    const label = MISSIONS[station]?.find(([m]) => m === id)?.[1];
    if (label) toast(`Mission complete: ${label}`);
    listeners.forEach((fn) => fn());
  },
  progress(station) {
    const list = station ? MISSIONS[station] : Object.entries(MISSIONS).flatMap(([s, l]) => l.map(([id]) => [s, id]));
    const n = station ? list.filter(([id]) => done[`${station}:${id}`]).length : list.filter(([s, id]) => done[`${s}:${id}`]).length;
    return { done: n, total: list.length };
  },
  onChange: (fn) => (listeners.add(fn), () => listeners.delete(fn)),
  reset() {
    for (const k of Object.keys(done)) delete done[k];
    store.set(done);
    listeners.forEach((fn) => fn());
  },
};

// The mission checklist shown at the top of each station.
export function missionList(station) {
  const ul = h("ul", { class: "missions", "aria-label": "Missions" });
  const render = () =>
    ul.replaceChildren(
      ...MISSIONS[station].map(([id, label]) =>
        h("li", { class: missions.isDone(station, id) ? "done" : "", "data-mission": id }, label),
      ),
    );
  render();
  missions.onChange(render);
  return ul;
}

function toast(text) {
  let stack = document.getElementById("toasts");
  if (!stack) document.body.append((stack = h("div", { id: "toasts", role: "status" })));
  const el = h("div", { class: "toast" }, "🏅 ", text);
  stack.append(el);
  setTimeout(() => el.remove(), 3500);
}

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