Marketing site for PizenLabs. React + TypeScript + Tailwind, built with Vite and deployed to GitHub Pages.
npm install
npm run dev # local dev server
npm run build # production build into dist/
npm run preview # serve dist/ locally
npm run lint # eslint
npm run typecheck # tsc --noEmitnpm run build also runs scripts/postbuild.mjs, which writes dist/404.html
(a copy of the SPA shell, so deep links resolve client-side) and dist/.nojekyll.
src/
pages/ PizenLabsHome, Articles, Article — route bodies
components/ PageShell (the frame), Backdrop, cards, ArticleBody
lib/content.ts all copy, links, projects and articles — edit here
lib/icons.ts the only lucide-react import surface
lib/router.ts pathname → route, kept in sync with History
lib/usePageMeta.ts per-route title, description, canonical, og:*
lib/useReveal.ts one IntersectionObserver for the whole page
lib/useScrollProgress.ts rAF-coalesced scroll progress bar
lib/useSpotlight.ts pointer-following card highlight
lib/useTheme.ts light/dark state, persistence, OS preference
index.css design tokens, components, animations
styles/fonts.css GENERATED — see Fonts below
scripts/fetch-fonts.mjs regenerates fonts.css and public/fonts/
Copy lives in src/lib/content.ts so wording changes never require touching
markup or styles.
| Path | Page |
|---|---|
/ |
pages/PizenLabsHome.tsx |
/articles |
pages/Articles.tsx — index, filtered by category |
/articles/<slug> |
pages/Article.tsx — one post |
| anything else | the 404 in App.tsx |
App.tsx matches on window.location.pathname. Links are plain <a href>, so
each route is a fresh document and scroll restoration, the back button, and the
browser's own fragment handling stay native. GitHub Pages serves dist/404.html
— the SPA shell — for any deep link, so no rewrite rule is needed.
Header anchors are therefore written site-absolute (/#philosophy): from
/articles, a bare #philosophy would resolve to /articles#philosophy. That
cross-page fragment is resolved before React mounts the target, so App.tsx
re-issues the scroll after the route renders — html's scroll-padding-top
already keeps the section clear of the fixed header.
Add an entry to ARTICLES in src/lib/content.ts. Nothing else has to change:
the slug becomes the URL, the category becomes a filter chip, the date drives
the ordering, and the reading time is counted from the body rather than stored.
{
slug: 'why-we-publish-everything',
title: 'Why we publish everything',
standfirst: 'One sentence — the card, the post intro, and the meta description.',
category: 'Company',
published: '2026-11-02',
body: [
{ type: 'paragraph', text: 'Prose with `code`, **strong**, and [a link](https://example.com).' },
{ type: 'heading', text: 'A section' },
{ type: 'list', items: ['First point', 'Second point'] },
],
}body is a block union, not Markdown: components/ArticleBody.tsx renders it
with no parser and no dangerouslySetInnerHTML, so inline syntax that does not
match — an unbalanced **, a stray backtick — renders as the text the author
wrote instead of breaking the post. Categories are derived from the posts
(ARTICLE_CATEGORIES), so a post under a new category becomes its own chip
without editing the page.
Per-route <title>, description, canonical, and og tags are set by
usePageMeta. That is client-side: a crawler fetching a deep link still sees
the home page's tags from the static shell, which only real prerendering could
fix.
Light and dark are two token sets behind one class. :root is light, .dark on
<html> is dark, and both are declared in the token block at the top of
src/index.css. Nothing below that block hardcodes a color — that is what makes
a theme change a one-class flip instead of a sweep through every component.
A scale's number never means "how light"; it means "how far from the content".
ink runs 900 (page base) → 400 (hairlines), bone runs 50 (strongest text) →
500 (smallest tertiary copy), and the ramps invert between themes. So
text-bone-50 is near-black on light and near-white on dark, and no component
needed a dark: variant. darkMode: 'class' exists only for the two
dark: utilities in the theme toggle.
Three rules to keep it working:
- Palette steps are RGB channel triplets, not colors. They are declared
247 249 248sotailwind.config.jscan emitrgb(var(--ink-900) / <alpha-value>), which is what makesbg-ink-600/60compose. Anywhere CSS needs one as a value it must be wrapped —rgb(var(--x)). A barevar(--x)there is invalid at computed-value time, which throws nothing and looks fine in the cascade: the declaration is simply dropped and the property falls back to inherited or transparent. - Effect tokens (
--wash, panel fills, grid, auroras, glows) are ordinary colors. They are only used from that stylesheet, never by a utility class, so they stay hex/rgba and are used bare. - Both themes must clear WCAG AA. Every text step is checked against the page
background and the
.panelsurface, which is one step lighter in both themes. Ratios are recorded besidebone.400/bone.500andforest.*inindex.css— re-check them when changing a step.
- A short inline script in
<head>(seeindex.html) readslocalStorage['pizenlabs-theme'], falls back toprefers-color-scheme, and puts the class on<html>before the first paint. It has to be inline and synchronous: a hashed external file would be a second render-blocking request, and the alternative is a white flash for dark-theme visitors. useTheme()reads that class back rather than re-deriving the preference, so the first React render cannot disagree with what is already painted.- With no stored choice the OS preference stays live — it keeps following
the system until the visitor uses the toggle, which writes the choice to
localStorage. A
storagelistener keeps other tabs in sync. <meta name="theme-color">is rewritten on every change, because the media-query form of that tag can only follow the OS and would ignore a manual override.
src/styles/fonts.css is generated. Do not edit it by hand. To refresh the
fonts (e.g. after changing weights), edit the Google Fonts query in
scripts/fetch-fonts.mjs and run:
node scripts/fetch-fonts.mjs public/fonts src/styles/fonts.cssThis pulls only the latin subset as a variable woff2 — one file per family
instead of one per weight — and writes matching @font-face rules. The files are
committed, so a normal build needs no network access.
Self-hosting replaced a render-blocking stylesheet request to
fonts.googleapis.com plus the font requests it triggered. All three faces are
preloaded from index.html and finish downloading before first paint.
Decisions here are deliberate; several look unusual until you know why.
- The theme bootstrap is inline, not a module. It is the one script that must beat first paint; see Theming above.
- Switching themes is instant, with no cross-fade. Animating it means
transitioning color on every element, and the usual implementation injects a
!importanttransition onto<html>for ~200 ms — which replaces thetransitionproperty outright and so breaks the hover and reveal motion for that window. Instant is also the reduced-motion-safe default. optimizeDeps.include: ['lucide-react']. The previous config excluded lucide-react, which made the browser walk its ~1500 ES modules one request at a time: a cold dev load of the home page issued 1465 requests / ~8.2 MB. Now 42.- One fixed
Backdrop. The grid and the two aurora fields live in a singleposition: fixedlayer. They used to be a set of full-viewport gradient divs inside each section, so every scroll repainted several large surfaces. - Auroras use
transformkeyframes only, nofilter: blur(). Blur is a per-frame paint cost; a radial gradient is already soft and composites free. - No permanent
will-change. It was previously set on every.revealelement, pinning each into its own GPU layer for the life of the page. Opacity and transform transitions already promote to the compositor. - Never fade the LCP candidate from
opacity: 0. Chrome will not report an element whose computed opacity is still 0, so animating the hero's intro paragraph delayed the measured LCP from 552 ms to 1436 ms. The hero uses.reveal-shift(transform only) and.line-mask(clipped transform) for its primary text, and reserves opacity fades for secondary elements. content-visibility: autowas tried and reverted. It removes the deferred subtree from Chromium's accessibility tree until it is rendered, which hid the closing section's heading and link from assistive tech. On a page this small the saving is not worth it.- Scroll and pointer handlers never call
setStatedirectly. They are passive, coalesce into onerequestAnimationFrame, and skip the write when the rounded value has not changed. Card highlights write CSS custom properties instead of re-rendering. - Icons are re-exported from
src/lib/icons.tsso the lucide dependency is explicit and auditable.
All motion is transform / opacity only, and
@media (prefers-reduced-motion: reduce) resolves every animation to its end
state — nothing is left invisible or mid-transition. Scroll reveals use one
shared IntersectionObserver that unobserves each element once it has fired, so
the browser stops doing work as soon as the page settles.
Skip link, one <h1>, ordered headings, visible focus rings, labelled external
links with rel="noreferrer noopener", and alt/aria-hidden set on every
image and icon. The theme toggle is a role="switch" with aria-checked rather
than a button whose accessible name changes on activation — a control that
renames itself is awkward to announce, and aria-checked is readable without
re-focusing it.
All body and label text clears WCAG AA (4.5:1) against both the page background
and the .panel surface in both themes — see the contrast notes in
tailwind.config.js and the token block in index.css before changing
bone.400 or bone.500. The skip link was also corrected while theming: it was
near-black on the brand green (2.4:1) and is now --accent-bright with
--accent-contrast, 8.8:1 on light and 10:1 on dark.