# tanishksharma.com — Project Brief for AI Sessions

This is a **vanilla HTML/CSS/JS personal portfolio site** deployed on Vercel. It is **prerendered at build time**: `node build.js` (Vercel's `buildCommand`) reads `content.js` **and fetches articles/posts from Supabase**, then bakes every page into fully static, crawlable HTML. There is no client-side content *rendering* and no framework. Runtime-dynamic pieces: `/api/og.js` (Vercel Edge Function that generates article/post OG images from a `?title=` param — requires `@vercel/og` in `package.json`); the language toggle in `script.js` (flips visibility of already-baked translations); and the **view counter** on article/post pages (a small inline beacon + a live count read — see the Supabase section below); and the **footer mailing-list box** (anon INSERT into the Supabase `subscribers` table).

> **Articles & posts live in Supabase, not `content.js`.** The `content` table (project ref `ndgzwmyqnldlkmjwlmwr`) is the single source of truth for articles/posts; `build.js` fetches the published rows at build time (`loadWriting()`, using the public anon key). Edit them in the Supabase dashboard — a DB change auto-triggers a Vercel rebuild. `content.js` still holds **everything else** (work, engagements, side projects, recognitions, profiles, theme, static i18n) and is a build INPUT never shipped to the browser; after editing it run `node build.js`. Full details in `supabase/README.md`.

> **The DB-derived generated files are NOT committed.** `index.html` (the `/` **home hub** — greeting hero, latest-from-feed preview, work-at-a-glance, apps teaser, ongoing projects, contact; `renderHomePage` in `build.js`; the old `/about` page dissolved into it, 301'd), `classic.html` (the ORIGINAL trilingual homepage archived noindexed at `/classic`, still built from `templates/index.html`), `article/`, `post/`, `share/`, the one list page (`feed.html` — `/feed` is the ONLY stream; the old `/articles`, `/posts`, `/shares` and `/resume` pages are deleted, with `vercel.json` redirects to `/feed` and `/work`), and `sitemap.xml` are gitignored (they depend on live DB state; Vercel regenerates them each deploy). `work.html`, `about.html`, `contact.html`, `privacy.html` and the profile `<slug>.html` pages are still committed — they come purely from `build.js`/`content.js` (`templates/resume.html` survives only as the source of the résumé sheet baked into `/work`). Running `node build.js` needs network access to Supabase; if the DB is unreachable the build fails (DB-only, no fallback) and Vercel keeps the last good deploy.

---

## Build pipeline

`build.js` (zero dependencies — Node builtins only) turns templates + `content.js` into served HTML:

| Template (source, edit these) | Generated (served, do NOT hand-edit) |
|---|---|
| `templates/index.html` | `classic.html` (`/classic` — the original homepage, archived + noindexed; `/` is now the Facet home hub built via `LIST_PAGES.home`) |
| `templates/list.html` | `index.html`, `feed.html`, `work.html`, `contact.html`, `privacy.html` (the Facet list pages, via `LIST_PAGES`) |
| `templates/reading.html` | `article/<slug>.html`, `post/<slug>.html`, `share/<slug>.html` (one **Facet** reading page per published row in Supabase) |
| `templates/profile.html` | `<slug>.html` (one per `PROFILES` entry — "a page for you" letters) |
| — | `sitemap.xml` (regenerated from `content.js`) |

On the homepage, `build.js` (1) replaces the `<!--BUILD:ENGAGEMENTS/WORK/SIDE/ARTICLES/POSTS/RECOGNITIONS-->` markers with static cards — the `ARTICLES`/`POSTS` cards come from the Supabase `content` table (fetched in `loadWriting()`), the rest from the matching `content.js` arrays — (2) expands every `data-i18n` / `data-i18n-aria` hook into inline translations (see i18n below), and (3) injects `window.UI` (toasts, FAB label, typewriter words) **and** `window.THEME_PALETTE`. The main-site-design templates (`templates/index.html`, `templates/profile.html`) also have a `<!--BUILD:PREPAINT-->` marker that `build.js` fills with the shared dark-mode pre-paint script (`prePaintScript()`) — there is no hand-written pre-paint block anymore; the Facet pages (lists + reading) let `facet.js` own the scheme instead. Article/post/share pages are fully static **English** (bodies are not translated).

---

## File Map

| File | Purpose |
|---|---|
| `templates/index.html` | Classic-homepage source (static UI + `<!--BUILD:*-->` markers), served at `/classic` |
| `templates/reading.html` | The one reading-page source for article/post/share (meta placeholders + `<!--BUILD:READING-->`). **On Facet** (`apps/lib/facet.css|js`), not the main-site design system: the shared tab-bar chrome (`renderNav('/feed')`), then (`renderReading` in `build.js`) a **toolbar** (left `Back to feed` pill + right `Share` pill), an **A4 SHEET** — a raised `--surface` card (`.reading-sheet`, `--shadow-raised`, paper-margin padding, min-height) floating on the wash — holding the feed-card-shaped piece (author row, accent-barred title, `--font-reading` serif body, hashtag row, the feed's live like/share/views bar), and below the sheet a "Keep reading" strip. The whole page floats on a **fluid background pinned to the viewport** (the `.reading-wash` div: `bg-fixed` + `data-bg-fluid data-bg-fluid-fixed`, radius 0) that both the sheet and the page scroll over |
| `templates/profile.html` | "A page for you" letter source (`{{NAME}}` + `<!--BUILD:PREPAINT-->`) |
| `index.html`, `article/<slug>.html`, `post/<slug>.html`, `<slug>.html` (profiles) | **Generated** — overwritten by `node build.js`, do NOT hand-edit |
| `build.js` | Static-site generator (run after any `content.js`/template edit) |
| `api/og.js` | Vercel Edge Function — generates OG preview image from `?title=` param |
| `404.html` | Hand-authored standalone page on **Facet** (`apps/lib/facet.css|js`, same nav + settings FAB as the generated Facet pages; the whole site-chrome block (tab-bar with in-pill brand + nav/view-options riser sheets + brand assets) is hand-copied from `build.js` renderNav between the site-chrome markers — keep it in sync; `apps/index.html` carries the same hand-copy) |
| `style.css` | Styles for the classic-design pages (now only `classic.html` links it; the old ARTICLE PAGE / SITE HEADER sections are legacy). The Facet pages — lists AND reading — style themselves from `apps/lib/facet.css` + their template's own `<style>` |
| `script.js` | All JavaScript for the homepage (carousels, popups, lang toggle, theme, parallax) |
| `gem.js` | **Standalone, self-contained library** (one of two sanctioned extra JS files): RENDERS + LIGHTS animated SVG gemstones (cursor/tilt parallax) at any size. Rendering core only — page layout, click-to-expand, the gallery/carousel and the parallax toggle UI are the host's job. Hand-authored, NOT processed by `build.js`. Used by `gems.html` (the `/gems` viewer), the homepage gem collection row + gallery (built in `script.js`), and every non-home page's brand gem — inside the tab-bar brand tab that all Facet pages AND the reading pages now share (`renderBrandAssets()` in `build.js`: the theme's classic stone beside the Gadey wordmark; tap → full-screen stage + tilt-permission ask). Has its own copy-paste docs at the top of the file. |
| `bokeh.js` | **Standalone, self-contained library** (the other sanctioned extra JS file): the drifting "bokeh" background — 4 desynced layers of CSS radial-gradient pools, animated via the Web Animations API. One-line apply (`<html data-bokeh>` auto-mounts, or `Bokeh.mount()`); editable at runtime via `.update()`, `.setPalette()`, `.destroy()`. Reads the theme tokens (`--theme-color-rgb`, `--bg-a*-rgb`) so it recolours with the accent; derives the accents from the theme colour when they're unset. Hand-authored, dependency-free, NOT processed by `build.js`. Loaded on the classic homepage only now (the reading pages moved to Facet's own fluid background); `script.js` drives rainbow mode through `Bokeh.setPalette()`. Copy-paste docs at the top of the file. |
| `content.js` | Build input for everything EXCEPT articles/posts: `THEME_PALETTE`, `ENGAGEMENTS`, `RECOGNITIONS`, `WORK`, `SIDE_PROJECTS`, `PROFILES`, `I18N`. (Articles/posts come from Supabase — see `supabase/README.md`.) |
| `vercel.json` | Vercel config (`buildCommand`, `cleanUrls`, headers) |
| `.github/workflows/build-check.yml` | CI: rebuilds and fails if committed generated files are stale |
| `.claude/hooks/session-start.sh` | Runs `node build.js` at the start of each web session |
| `llms.txt` | **Hand-curated** AI/LLM summary — NOT generated; update by hand when major facts change |
| `demos/` | **One-off client/concept demos** (e.g. `demos/kiwabi/` — Kiwabi e-commerce concept). Each demo is a self-contained microsite **exempt from ALL site rules** — see "Demos" section below |
| `apps/` | **Mini apps** — the `/apps` grid page + one single-file app per `apps/<slug>.html`, styled with the vendored **Facet** design system (`apps/lib/facet.css|js`), not the main-site design system. Self-contained like `demos/` — see "Mini apps" section below |
| `assets/` | Images, fonts, PDF resume |

---

## Content Editing

**Engagements, recognitions, work experience, side projects** live in `content.js` (`ENGAGEMENTS`, `RECOGNITIONS`, `WORK`, `SIDE_PROJECTS`). **Run `node build.js` after editing**, then commit both the data and the regenerated HTML. (**Articles and posts** are the exception — they live in Supabase; see below.)

### Adding / editing a work role or side project

Edit the `WORK` (or `SIDE_PROJECTS`) array in `content.js` — do **not** touch the static markup, there is none; `templates/index.html` just has `<!--BUILD:WORK-->` / `<!--BUILD:SIDE-->`. A `WORK` entry: `logo` (emoji), `company` + `meta` (English-only brand/date lines — not translated), `title`, `desc`, `bullets[]`, `images[]`, optional `i18n: { ja, de }` for `title`/`desc`/`bullets`, and two resume-page-only optionals: `logoImg` (a real logo file under `assets/logos/`, emoji stays the fallback) and `resumeDates` (overrides the company date range on the resume PAGE only — the PDF and homepage are untouched). A `SIDE_PROJECTS` entry: `logo`, `role`, `org` + `date` (English-only), `desc`, optional `i18n` for `role`/`desc`. Rebuild.

### Adding a profile ("a page for you") page

Append `{ slug, name }` to `PROFILES` in `content.js` and rebuild — `build.js` generates `/<slug>.html` from `templates/profile.html` (one shared bespoke template; only the name varies). These are `noindex` and stay out of the sitemap. Edit the letter copy/design once in `templates/profile.html`.

### Adding a new article or post

Articles and posts live in the Supabase `content` table, **not** in `content.js`.
Add or edit a row in the Supabase dashboard (table editor) — set `status = 'published'`
to make it live (`draft` rows are invisible to the site and the public):

| Column | Notes |
|---|---|
| `type` | `'article'` or `'post'` |
| `slug` | URL segment (`/article/<slug>` or `/post/<slug>`), unique |
| `status` | `'draft'` or `'published'` |
| `title`, `subtitle` | subtitle is article-only |
| `description` | teaser → homepage card + `og:description` (this is the article `desc` / post `preview`) |
| `tags` | text[] (articles) |
| `read_time_minutes` | int (articles); `build.js` renders it as `"N min read"` |
| `published_at` | timestamptz; `build.js` shows it as `"Jun 2026"` |
| `body` | jsonb block array — `{type:'p'|'h2'|'ul'|'quote'|'divider', text/items}`. For a post, use `p` blocks; `build.js` joins them into the post body |
| `i18n` | jsonb: `{ ja: {…}, de: {…} }` — article keys `title/subtitle/tags/desc`, post keys `title/preview/full` |
| `sort_order` | int; controls homepage/sitemap order |
| `views` | managed by the view counter; leave at 0 |
| `likes` | managed by the feed's like button (`increment-like` fn + `record_like` RPC + `like_log`); leave at 0 |

On save, the deploy-hook trigger rebuilds the site automatically: Vercel reruns
`node build.js`, which fetches the published rows and bakes `article/<slug>.html`
/ `post/<slug>.html` (full body, OG tags, `api/og` image, canonical) and the
`sitemap.xml` entry. No HTML to hand-write, nothing to commit — the DB-derived
files are gitignored. To preview locally, run `node build.js` (it fetches the DB).
See `supabase/README.md` for the schema and editing workflow.

---

## Internationalisation (i18n)

The homepage supports **English (`en`), Japanese (`ja`), German (`de`)**, cycled via the globe button. Language is stored in `localStorage['lang']` (auto-detected from the browser on first visit, default English).

**All three languages are baked into the page as inline `<span data-tr="en|ja|de">` triple-spans** — only the active one is shown (English visible by default, so crawlers and no-JS visitors get full content and every translation lives in the source). `applyLang(lang)` in `script.js` does **not** re-render; it just calls `localize()` to flip `hidden` on the `data-tr` spans and apply the matching `data-aria-<lang>` labels.

Authoring stays the same — you write keys, `build.js` expands them:

1. **Static homepage text** (hero, section headings, work, side projects, skills, contact, FAB): tag the element in `templates/index.html` with `data-i18n="key"` (text) or `data-i18n-aria="key"` (aria-label). Each key must exist under **all three** locales in the `I18N` dict at the bottom of `content.js`. `build.js` turns `data-i18n` into the triple-span and `data-i18n-aria` into `data-aria-en/ja/de` attributes. Missing keys fall back to English.
2. **Data-driven content** (`ENGAGEMENTS`, `RECOGNITIONS`, `WORK`, `SIDE_PROJECTS` in `content.js`; articles/posts in the Supabase `content` table): English in the top-level fields/columns; translations in an optional per-item `i18n: { ja: {...}, de: {...} }` block/column. `build.js` bakes all three inline.

Not translated by design: brand/proper-noun + date lines (work `company`/`meta`, side-project `org`/`date`), and article/post **bodies** (the dedicated pages are English-only). Adding a new homepage section? Tag its text with `data-i18n`, add the keys to all three locales, and rebuild.

---

## CSS Design System

All design tokens are CSS custom properties on `:root` in `style.css`.

### Colors
```css
--bg              /* page background */
--text            /* primary text */
--muted           /* secondary / dimmed text: rgba(..., 0.4) */
--border          /* subtle borders: rgba(..., 0.1) */
--card-bg         /* card and button surfaces */
--theme-color     /* accent color — randomised from THEME_PALETTE on load */
--theme-color-rgb /* RGB channels of theme color for rgba() use */
```

Dark mode is applied via `[data-theme="dark"]` on `<html>`. Never use `prefers-color-scheme` in CSS directly — use the data attribute.

### Spacing
`--space-1` (4px) through `--space-24` (96px). Always use these — no raw px values for spacing.

### Typography
```css
--size-xs / sm / base / lg / xl / 2xl / 3xl / 4xl / 5xl
--font: 'Inter', sans-serif
```

### Radius
```css
--radius-sm: 4px   --radius-md: 6px
--radius-lg: 8px   --radius-full: 9999px  /* pill */
```

---

## Standard Button Components

**Always use these classes.** Never invent new button styles.

### `.btn-primary` — filled, high-emphasis action
```html
<a class="btn-primary" href="...">
  <svg ...></svg>
  Label
</a>
```
Dark background (`--text`), light text (`--bg`), `--radius-md`. Use for primary CTAs (download, submit).

### `.btn-ghost` — outlined, medium-emphasis
```html
<button class="btn-ghost">Label</button>
```
Transparent background, `--border` border. Use for secondary actions.

### `.icon-btn` — circular icon-only button (40×40px, 44px min touch target)
```html
<button class="icon-btn" aria-label="Describe action">
  <svg width="16" height="16" ...></svg>
</button>
```
Use for social links, utility actions (copy, share, pay). Always include `aria-label`.

### `.top-ctrl-btn` — squarish utility toggle (40×40px, 8px radius)
```html
<button class="top-ctrl-btn" id="..." aria-label="...">
  <svg ...></svg>
</button>
```
Reserved for fixed-position top-right controls. Currently used for theme and parallax toggles. Do not use for inline content.

### `.fab-btn` — floating action button (bottom-right pill)
The FAB is a single instance (`id="fab-btn"`). Do not add more FABs. It opens the speed-dial navigation menu.

---

## Parallax System

The site uses a **gyroscope/accelerometer parallax** effect (`DeviceOrientationEvent`) — elements shift subtly as the device is tilted, creating a 3D depth illusion.

### How it works
- `parallaxCtrl` in `script.js` exposes `.start()` and `.stop()`.
- Elements are registered in two arrays inside the IIFE: `OUTER` (slower) and `INNER` (faster, for layered depth).
- The user toggles it via the `#parallax-toggle-btn` in `.top-controls`.
- **iOS 13+**: requires explicit `DeviceOrientationEvent.requestPermission()` — handled by the toggle button click. Do NOT request permission on random page clicks.
- **Android**: no permission needed; auto-starts if `localStorage['parallax-enabled'] !== 'false'`.

### Adding an element to parallax
Add an entry to `OUTER` or `INNER` inside the `parallaxCtrl` IIFE in `script.js`:
```js
{ selector: '.your-class', xMax: 5, yMax: 4 }
// xMax/yMax = max pixel shift at full tilt (OUTER: 2–9, INNER: 2–5)
```
Use `OUTER` for the element itself. Add a corresponding `INNER` entry for any SVG or emoji inside it to get the layered depth effect.

**Elements currently in parallax:**
| Selector | Layer | xMax | yMax |
|---|---|---|---|
| `.hero-photo` | OUTER | 9 | 7 |
| `.icon-btn` | OUTER | 5 | 4 |
| `.btn-primary` | OUTER | 4 | 3 |
| `.work-role-icon` | OUTER | 6 | 5 |
| `.side-project-icon` | OUTER | 6 | 5 |
| `.gem-collection-gem` | OUTER | 7 | 6 |
| `.fab-btn` | OUTER | 3 | 2 |
| `.icon-btn svg` | INNER | 4 | 3 |
| `.btn-primary svg` | INNER | 3 | 2 |
| `.work-role-emoji` | INNER | 5 | 4 |
| `.side-project-emoji` | INNER | 5 | 4 |

---

## Theme / Color Scheme

- **Color scheme** (light/dark/auto) is stored in `localStorage['color-scheme']`.
- **Accent color** cycles through `THEME_PALETTE`. The palette is the **single source of truth in `content.js`** — `build.js` injects it as `window.THEME_PALETTE` (script.js reads that) and inlines the solids into the `<!--BUILD:PREPAINT-->` script so article/post/profile pages restore the homepage's accent before paint via `localStorage['theme-index']`. Never hardcode a second palette in a template or in `script.js`.
- The theme button cycles solids on tap; a long-press unlocks the animated rainbow/gradient mode.
- `applyScheme(scheme)` and `setScheme(scheme)` are the only correct ways to change the scheme — never write to `data-theme` directly.

---

## JavaScript Conventions

- **No frameworks, no imports.** Plain ES6+ in a single `script.js`.
- `content.js` is **not** shipped to the browser. `script.js` only consumes what `build.js` inlines: `window.UI` (dynamic strings) and `window.THEME_PALETTE`. Everything else is baked into static HTML.
- Attach listeners with the `on(idOrEl, evt, fn)` helper (no-ops if the element is missing) rather than an unguarded `getElementById(...).addEventListener(...)`.
- Toast notifications: always use `showToast('message')` — never `alert()`.
- `showToast` auto-dismisses after 2 seconds. Keep messages short (< 5 words).
- Animations: use `requestAnimationFrame`, not `setInterval`/CSS transitions, for smooth motion.
- Guard all `getElementById` results before use if the element may not exist on the current page.

---

## Accessibility Rules

- Every interactive element needs an `aria-label` if it has no visible text.
- Use `aria-hidden="true"` on decorative SVGs and emoji.
- Minimum touch target size: 44×44px (enforced via `min-width`/`min-height` on `.icon-btn`).
- Use semantic HTML: `<button>` for actions, `<a>` for navigation.
- Do not remove existing `aria-*` attributes.

---

## File & Section Handbook

For each area: where the truth lives → what to edit → what NOT to touch → how to apply. **Anything in `content.js` or `templates/` requires `node build.js` + committing the regenerated files.**

| Area | Source of truth | Edit this | Do NOT edit | Apply |
|---|---|---|---|---|
| Classic-homepage hero / skills / contact / FAB static text | `I18N` (en/ja/de) + `data-i18n` hooks in `templates/index.html` | the `I18N` key (all 3 locales) | the generated `classic.html` | rebuild |
| Articles | the `content` table in Supabase (`type='article'`) | the row (in the dashboard) | `article/<slug>.html`, the carousel markup | edit row → auto-rebuild (or `node build.js`) |
| Posts | the `content` table in Supabase (`type='post'`) | the row (in the dashboard) | `post/<slug>.html`, the marquee markup | edit row → auto-rebuild (or `node build.js`) |
| View counts | `views` column + `increment-view` fn (Supabase) | n/a (auto) | `viewBeaconScript`/`viewCountSpan` in `build.js`, refresh block in `script.js` | — |
| Engagements ("Now") | `ENGAGEMENTS` in `content.js` | the array entry | the marquee markup | rebuild |
| Work experience | `WORK` in `content.js` | the array entry (`company`/`meta` English-only) | `<!--BUILD:WORK-->`, generated cards | rebuild |
| Side projects | `SIDE_PROJECTS` in `content.js` | the array entry | `<!--BUILD:SIDE-->`, generated rows | rebuild |
| Recognitions / Awards | `RECOGNITIONS` in `content.js` | the array entry | the marquee markup | rebuild |
| Profile letters (`/<slug>`) | `PROFILES` + `templates/profile.html` | `{ slug, name }` rows; the shared template for design | the generated `<slug>.html` files | rebuild |
| Accent palette | `THEME_PALETTE` in `content.js` | the array | any palette copy in templates/`script.js` | rebuild |
| Pre-paint (dark-mode/accent) | `prePaintScript()` in `build.js` | that function | the inlined `<script>` in any page | rebuild |
| Site footer (gem row, link columns, mailing list) | `renderFooter()` in `build.js` (baked into every Facet + reading page via `<!--BUILD:FOOTER-->`) | that function; the `subscribers` table in Supabase holds signups | the baked `<footer>` in generated pages | rebuild |
| Classic-page styling | `style.css` (classic.html only) | `style.css` | — | — |
| Facet-page styling (lists, reading) | `apps/lib/facet.css` + the template's own `<style>` (`templates/list.html`, `templates/reading.html`) | the template `<style>` block | the vendored `apps/lib/facet.*` (re-pull upstream) | rebuild |
| Homepage behaviour | `script.js` | `script.js` | — | — |
| Gem library (render core) | `gem.js` (self-contained) | `gem.js` | — | — |
| Background animation | `bokeh.js` (self-contained) | `bokeh.js`; mount/`data-bokeh` in templates; rainbow wiring in `script.js` | the old CSS pseudo-element layers (removed) | rebuild (for template markup) |
| Homepage gems (row + gallery) | `script.js` (`initGems`) + `.gem-collection`/`.gem-gallery` markup in `templates/index.html` + styles in `style.css` | those | the generated `index.html` | rebuild (for the markup) |
| AI/LLM summary | `llms.txt` (**hand-curated**) | `llms.txt` directly | — | keep in sync by hand |
| Client/concept demos | `demos/<name>/` (each self-contained) | the demo's own files | main-site files from inside a demo | — (no build step) |

---

## Demos (`demos/`)

`demos/<name>/` holds **one-off client/concept demos** (first one: `demos/kiwabi/`, an e-commerce concept for the haircare brand Kiwabi). Rules:

- Each demo is a **fully self-contained microsite**: its own HTML, CSS and JS files live inside its folder and reference each other with absolute paths (`/demos/<name>/...` — required because `cleanUrls` serves pages extensionless, which breaks relative links).
- Demos are **exempt from every main-site rule**: they may have their own CSS files, multiple JS files, their own design tokens/palette, external fonts, inline styles — none of the design-system, i18n, single-`script.js` or `content.js` conventions apply inside `demos/`.
- Demos are **hand-authored and never touched by `build.js`** — no build step, no regeneration, nothing to keep in sync. They are deliberately absent from `sitemap.xml` and `llms.txt`.
- Every demo page carries `<meta name="robots" content="noindex, nofollow">`.
- The reverse also holds: **never let a demo leak into the main site** — don't link demos from the homepage, don't import main-site CSS/JS into a demo (or vice versa), and don't move demo logic into `script.js`/`style.css`.
- Internal links within a demo must be extensionless (`/demos/kiwabi/products`, not `products.html`) to match Vercel's `cleanUrls`.

## Mini apps (`apps/`)

`apps/` holds the **mini web-apps** ("Mini web-apps I've built. Utility and fun.") specced by the Notion "Mini Apps" page and its per-app PRDs. Rules:

- **Source of truth is Notion**: the "Mini Apps" page defines shared rules + the Apps grid page; each app has its own self-contained PRD. Edit behaviour per those specs.
- `apps/index.html` is the grid page served at `/apps`; each app is **one hand-authored HTML file** `apps/<slug>.html` served extensionless at `/apps/<slug>` (Vercel `cleanUrls`) — internal links must be extensionless.
- Apps use the **Facet design system**, vendored at `apps/lib/facet.css` + `apps/lib/facet.js` (upstream: https://facet-kappa.vercel.app — keep the vendored copies verbatim in sync with upstream; don't fork them; re-pull upstream rather than editing them here). **All 13 apps + the `/apps` launcher are on the Default theme** (paper/ink, no `data-theme` attribute), built from library components only — canonical tab-bar/sheet/menu markup, `.snap`+`.snap-section` pager (auto-mounts on full-viewport snaps; opt a continuous-scroll page out with `data-pager="off"`), choice grid, number input, sliders + `data-minor/label/now` tick scales, the `.result` verdict block, `facet.chart`, `svg[data-icon]` glyphs, `data-mode-toggle`, the nudge/`overlay-guide` install flow, and the service worker via `data-service-worker` on the facet.js tag. `inflation.html` is the reference. Any app-specific code beyond data + calculator logic must be **justified in a banner comment** — only the bespoke *visualisations* (money-scale's log climb, gdp's pie drill-down, passport's world choropleth, explorer's live map) carry tokens-only app CSS, because the library has no primitive for them. Library gaps found while building are handed to the owner in chat as paste-ready items for the Facet repo (the old `apps/facet-library-fixes.md` hand-off file was retired); don't silently work around a gap — surface it. The earlier `apps/lib/velvet.*` shim was abandoned.
- Shared area plumbing: `apps/sw.js` (one service worker for the whole area, registered by every app) + `apps/manifest.webmanifest` (plus per-app manifests in `apps/m/` and icons in `apps/icons/`); `apps/views.js` (once-per-session open beacon → Supabase `app_views`, drives the `/apps` popularity ranking and counts); `apps/meta.js` (offline fallback snapshot of the Supabase `app_meta` table — the DB is the live source of truth for app names/blurbs/versions/publish dates/release logs/featured placement; `/apps` + `/apps/about` fetch it at runtime, no rebuild needed; version < 1 renders an in-development chip); `apps/about.html` (one shared per-app info page at `/apps/about?app=<slug>`, linked from every app's menu — release log, opens, share, bug report). Adding an app: add its `app_meta` + `app_views` rows in Supabase, `meta.js` fallback entry, icon, per-app manifest, and About menu link. App data is normally baked into each file as constants and an app should still work offline for its core function. Runtime network calls ARE allowed where a feature genuinely needs one (e.g. the Real-time explorer's live map, the anime quote quiz's Supabase leaderboard): reach a backend over its public REST endpoint with the public anon key, exactly like the site's view counter, degrade gracefully when offline, and keep any client-side key public/anon-only (never a service-role secret).
- Like `demos/`, the `apps/` area is **exempt from the main-site rules** (own design system, own CSS/JS in its own files) and is NOT processed by `build.js`. The reverse also holds: never import main-site CSS/JS into an app, never leak app logic into `script.js`/`style.css`, and keep apps out of `sitemap.xml` (it's generated from `content.js` and doesn't know about them).

## What NOT to Do

- Do not hand-edit generated files — edit the source (`content.js`/`templates/`, or the Supabase `content` table for articles/posts) and run `node build.js`. The profile `<slug>.html`, `work.html`, `contact.html` and `privacy.html` pages are committed and CI (`build-check`) fails if they're stale; `index.html`, `classic.html`, `article/`, `post/`, `share/`, `feed.html`, and `sitemap.xml` are gitignored (DB-derived, regenerated by Vercel each deploy) so they're not committed or staleness-checked.
- Do not define a theme palette anywhere except `THEME_PALETTE` in `content.js`.
- Do not re-declare design tokens inline in a page — link `/style.css` (microsites excepted, and documented as such).
- Do not auto-overwrite `llms.txt` from a script — it is hand-curated.
- Do not add `maximum-scale` / `user-scalable=no` to a viewport meta (blocks pinch-zoom).
- Do not introduce npm runtime deps, a bundler, a framework, or TypeScript.
- Do not create new CSS files — all styles go in `style.css`. (Demos under `demos/` and mini apps under `apps/` are exempt — see their sections.)
- Do not create new JS files — all logic goes in `script.js` (content data in `content.js`). The only exceptions are the two vendored standalone libraries, `gem.js` (gem rendering) and `bokeh.js` (the drifting background): keep each self-contained, dependency-free, and not wired into `build.js`. Do not move site logic into them, and do not split them into more files. (Demos under `demos/` and mini apps under `apps/` are exempt — see their sections.)
- Do not hardcode colors as hex values in CSS — use the design tokens.
- Do not add `!important` declarations.
- Do not duplicate button styles — use the existing component classes above.
- Do not add parallax to fixed UI controls (`.top-controls`, `.fab-items`, overlays).
- Do not add comments explaining what code does — only comment the non-obvious WHY.
