What changed, and why

Glassbox used to reach production through a container: CI built an nginx image, pushed it to ghcr.io, and a k8s cluster pulled it. This PR retires that whole path. Hosting moves to Vercel, whose git integration builds and serves the site straight from this repo. A push to main becomes the production deploy. A pull request gets a preview URL.

Nothing under src/ or tests/ changes. What leaves is plumbing: 90 lines of container machinery (Dockerfile 26, nginx.conf 45, .dockerignore 19) and CI's image job, 23 deleted lines of ci.yml. What arrives is smaller still, a 15-line vercel.json plus a README section. The part worth real review is the serving policy nginx used to enforce, mapped rule for rule in the next section.

The infra half of the move lives in the companion PR on the workspace repo, mseeks/zo#20: the DNS flip and the k8s stack retirement.

vercel.json: nginx's serving policy, distilled

The old nginx.conf did five things. Four of them either move here or become platform defaults; the fifth (/healthz for k8s probes) had no job left to do.

The whole file. Two rules: immutable hashed assets, SPA fallback.

vercel.json · 15 lines
vercel.json15 lines · JSON
1{
2 "$schema": "https://openapi.vercel.sh/vercel.json",
3 "headers": [
4 {
5 "source": "/assets/(.*)",
6 "headers": [
7 {
8 "key": "Cache-Control",
9 "value": "public, max-age=31536000, immutable"
10 }
11 ]
12 }
13 ],
14 "rewrites": [{ "source": "/((?!assets/).*)", "destination": "/index.html" }]
nginx rulewhere it lives now
location /assets/Cache-Control: public, max-age=31536000, immutablethe headers rule here, byte-identical value
try_files $uri $uri/ /index.html (SPA fallback)the rewrites rule here
try_files $uri =404 inside /assets/the rewrite skips /assets, so a missing hashed asset stays a plain 404
gzip on + typesVercel compresses at its edge (gzip and brotli), no config
location = /index.htmlno-cacheVercel's static default already makes browsers revalidate HTML
location = /healthz for liveness/readiness probesdropped — no pod, no probes
Each serving rule, accounted for. (nginx's access_log off tuning has no job on a managed edge.)

CI: two jobs, not three

CI drops to two jobs. The deleted third, image, ran only on main and only after verify and e2e both passed. It logged in to ghcr.io, built the Docker image, and pushed :latest plus a :<sha> tag. One thing consumed those images: the cluster Deployment, which pulled :latest on restart. The companion PR retires that Deployment, so the job would now build artifacts nobody fetches. verify and e2e themselves are untouched, same steps and same triggers, byte for byte.

The verify job, unchanged. Below it (folded): the equally unchanged Playwright job — now the end of the file.

.github/workflows/ci.yml · 48 lines
.github/workflows/ci.yml48 lines · YAML
⋯ 12 lines hidden (lines 1–12)
1name: CI
2 
3on:
4 push:
5 branches: [main]
6 pull_request:
7 
8# Cancel superseded runs on the same ref to save CI minutes.
9concurrency:
10 group: ci-${{ github.ref }}
11 cancel-in-progress: true
12 
13jobs:
14 verify:
15 name: lint · format · test · build
16 runs-on: ubuntu-latest
17 steps:
18 - uses: actions/checkout@v4
19 - uses: actions/setup-node@v4
20 with:
21 node-version-file: .nvmrc
22 cache: npm
23 - run: npm ci
24 - run: npm run lint
25 - run: npm run format:check
26 - run: npm run test:coverage
27 - run: npm run build
⋯ 21 lines hidden (lines 28–48)
28 
29 e2e:
30 name: playwright smoke
31 runs-on: ubuntu-latest
32 steps:
33 - uses: actions/checkout@v4
34 - uses: actions/setup-node@v4
35 with:
36 node-version-file: .nvmrc
37 cache: npm
38 - run: npm ci
39 - run: npx playwright install --with-deps chromium
40 - run: npm run test:e2e
41 env:
42 CI: true
43 - uses: actions/upload-artifact@v4
44 if: failure()
45 with:
46 name: playwright-report
47 path: playwright-report/
48 retention-days: 7

The README says where the site lives now

The README gains a Deploy section between CI and Layout. It answers what a contributor actually asks. Where does the site run? What happens on a push, or on a PR? What does vercel.json own? Project settings, meaning the framework preset and the custom domain, stay in the Vercel dashboard on purpose. This repo never carried cluster manifests either; hosting-account plumbing lives outside it, same as before.

The new Deploy section, between CI and Layout.

README.md · 139 lines
README.md139 lines · Markdown
⋯ 77 lines hidden (lines 1–77)
1# Glassbox
2 
3**The black box, made of glass.** A small library of systems lessons you can
4poke, prod, and see straight through. It runs locally on Vite and React. The
5hardest ideas in computing usually arrive sealed shut, taken on faith. Glassbox
6cracks them open.
7 
8## The collection
9 
10| Lesson | Subject | Accent | Display | Credit |
11| ----------------------- | --------------------------------------------------------------- | ---------- | ------------------- | --------------------------------- |
12| Concurrency Foundations | Threads, primitives, patterns, memory models | steel-blue | Fraunces | I–VII |
13| The ACID Lab | Atomicity, Consistency, Isolation, Durability | teal | EB Garamond | A · C · I · D |
14| CAP & PACELC | Distributed consistency trade-offs under partition and latency | coral | Spectral | Brewer · Gilbert & Lynch · PACELC |
15| SWIM | Failure detection + gossip in cluster membership | warm rose | Cormorant Garamond | Das · Gupta · Motivala · 2002 |
16| UDP | Datagram delivery, loss, duplication, and ordering | tangerine | Bricolage Grotesque | RFC 768 · 1980 |
17| Bloom Filters | Probabilistic set membership at scale | violet | Playfair Display | Burton H. Bloom · 1970 |
18| The Bloom Clock | Probabilistic causality with constant-size clocks | gold | Instrument Serif | Distributed causality |
19| The Cuckoo Filter | Probabilistic set membership that also supports deletion | coral | Fraunces | Fan et al. · 2014 |
20| LSM Trees | Write-optimised storage where time becomes depth | sediment | Bitter | O'Neil et al. · 1996 |
21| The Weight of Memory | From one hand-woven bit to an ocean nobody can picture | amber | Instrument Serif | A history of almost nothing |
22| Merkle Trees | Tamper-evident data with whisper-sized inclusion proofs | patina | Libre Caslon | Ralph C. Merkle · 1979 |
23| The One-Way Machine | Cryptographic hashing: SHA-1, SHA-2, SHA-3 | copper | Zilla Slab | NIST · FIPS 180 |
24| Trie | Prefix trees, drawn so the route is the word | pine | Fraunces | Edward Fredkin · 1960 |
25| gRPC | Remote procedure calls: typed contracts on a binary HTTP/2 wire | cyan | Bricolage Grotesque | Google · 2015 |
26| B-Trees | Balanced on-disk index where a node is a whole page | petrol | Zilla Slab | Bayer & McCreight · 1970 |
27| HyperLogLog | Counting distinct items in fixed, tiny memory | brass | Big Shoulders | Flajolet et al. · 2007 |
28| Vantage-Point Trees | Nearest-neighbour search using only distance | amber | Big Shoulders | Peter Yianilos · 1993 |
29| TLS | A private, verified channel across a hostile public wire | aqua | Spectral | IETF · RFC 8446 |
30| Binary Trees | Search, traversal, balance, and rotation, drawn as plates | blueprint | Syne | A structural study |
31| SSTables | Immutable sorted tables: one-seek reads and k-way compaction | oxblood | Bodoni Moda | Sorted String Tables |
32| Paxos | Consensus on a single value a majority can never take back | aegean | Cinzel | Leslie Lamport · 1998 |
33| The Saga Pattern | Distributed transactions as local commits with compensations | gold | Marcellus | Garcia-Molina & Salem · 1987 |
34| The Swarm | BitTorrent: a crowd that becomes a server, named by its hash | signal | Yeseva One | Bram Cohen · 2001 |
35 
36Each lesson lives under `src/lessons/<slug>/`. It ships its own prose and CSS,
37plus a few small interactive labs. They share a common paper (parchment ink,
38**JetBrains Mono** for every numeric/credit/eyebrow), but each carries its own
39display typography and accent color.
40 
41The whole collection is **light/dark**. A System / Light / Dark switch in the nav
42sets a `data-theme` attribute on `<html>` — it follows your OS until you pick,
43then your choice persists and wins. Every lesson ships a complementary version of
44its design for the other mode, so the toggle re-skins the entire site.
45 
46Lessons load lazily via `React.lazy`. The entry bundle stays small, and each
47lesson's chunk only ships when it's opened.
48 
49## Run locally
50 
51```sh
52npm install
53npm run dev
54```
55 
56Deep-link a specific lesson with the `?lesson=` query parameter, e.g.
57`?lesson=swim`.
58 
59## Build, test, lint, format
60 
61```sh
62npm run build # production bundle into dist/
63npm run preview # serve the production bundle locally
64npm test # run the Vitest suite once
65npm run test:coverage # Vitest with v8 coverage (engines gated at 90%)
66npm run test:watch # re-run on change
67npm run test:e2e # Playwright smoke (boots a dev server itself)
68npm run lint # ESLint over the source tree
69npm run format # Prettier write
70npm run format:check # Prettier check (CI-friendly)
71```
72 
73CI runs lint, `format:check`, the Vitest suite, the production build, and the
74Playwright smoke on every push and pull request. The workflow lives in
75[`.github/workflows/ci.yml`](./.github/workflows/ci.yml). Node version is pinned
76in [`.nvmrc`](./.nvmrc).
77 
78## Deploy
79 
80The site is hosted on **Vercel** at **https://glassbox.mseeks.me**. Deploys
81ride Vercel's git integration: every push to `main` becomes the production
82deployment, and every pull request gets a preview URL. There is nothing to run
83here.
84 
85[`vercel.json`](./vercel.json) carries the two serving rules that matter: the
86content-hashed `/assets` are cached for a year (immutable), and unknown paths
87outside `/assets` fall back to the app shell so a stray deep link or refresh
88never 404s (routing is client-side via `?lesson=`). A hashed asset that no
89longer exists stays a plain 404, as it was under nginx. Compression, TLS, and
90HTML revalidation are Vercel defaults.
91 
⋯ 48 lines hidden (lines 92–139)
92## Layout
93 
94Each lesson is fully self-contained under `src/lessons/<slug>/`: prose
95`sections/`, interactive `labs/`, lesson-local `components/`, a pure
96`engine/index.js`, and its own `<slug>.css`. A lean `<Name>Lesson.jsx` wires
97them together. `index.js` re-exports it for the lazy loader.
98 
99```
100src/
101 main.jsx entry — mounts <App>
102 App.jsx shell: sticky nav, ?lesson= routing, focus management
103 lesson-catalog.js the lesson registry (id-keyed metadata; Component
104 derived per id, one source of truth)
105 index-page/ the landing index (IndexPage, Glyph, index-page.css)
106 shared/
107 tokens.css design tokens (paper, parchment ink, --font-mono;
108 light/dark values keyed on data-theme)
109 utilities.css shell-scoped utility classes + global reduced-motion
110 theme.js pure light/dark precedence model (System/Light/Dark)
111 useTheme.js store/hook: applies the theme to <html>, tracks the OS
112 ThemeToggle.jsx the System/Light/Dark switch parked in the nav
113 usePrefersReducedMotion.js gate JS/SMIL motion (see Design notes)
114 reveal.jsx reveal-on-scroll hooks + <Reveal>
115 useScrollSpy.js active-section spy + reduced-motion scrollToId
116 lessons/
117 <slug>/
118 index.js re-exports the lesson's default component
119 <Name>Lesson.jsx wires sections + labs into the page
120 sections/ prose chapters
121 labs/ interactive widgets
122 components/ lesson-local building blocks
123 engine/index.js pure, unit-tested logic, no React/DOM (all 23)
124 <slug>.css the lesson's own type + accent system, both modes
125tests/
126 <lesson>-engine.test.js Vitest, one suite per engine
127 lesson-catalog.test.js registry shape + query-param resolution
128 index-page.test.jsx jsdom + Testing-Library component test
129 e2e/ Playwright smoke (index ↔ lesson round trips)
130```
131 
132Every lesson keeps its logic in a pure `engine/index.js`, backed by a Vitest
133suite. `src/lessons/bloom-filters/engine/index.js` (tested in
134`tests/bloom-math.test.js`) is the canonical example.
135 
136## Design notes
137 
138See [AGENTS.md](./AGENTS.md) for the lesson grammar (hero eyebrow format,
139section numbering, family glue vs. per-lesson personality).

How this was verified

The local gate ran green on this branch. npm run ci chains ESLint, prettier --check, the Vitest suite, and a production build. That build also proves the premise of the cache rule, since the output lands in content-hashed files under dist/assets/. Playwright still runs in this PR's CI, as on every PR.

After cutover, four requests against https://glassbox.mseeks.me settle serving parity. A hashed asset should answer with the immutable cache header. / should come back revalidation-friendly. A nonsense path such as /no-such-page should serve the app shell with 200. And a made-up /assets/nope.js should stay a 404 rather than turn into HTML. Merging this PR alone changes nothing live; the ordering (Vercel project first, DNS flip second, namespace deletion last) is documented in mseeks/zo#20.