# tanishksharma.com — Project Brief for AI Sessions

> **URLs.** Live (from `main`): **https://tanishksharma.com** · Staging (from the `staging` branch): **https://staging-tanishksharma.vercel.app** (a vercel.app domain pinned to the `staging` branch; the old `staging.tanishksharma.com` subdomain is retired). Always share these evolving links, never a per-deployment URL. Features build on `staging`; `main` is the live site and merges only on an explicit ship.

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 draws every link-preview card from a `?card=` param, with live counts read from Supabase at render time — requires `@vercel/og` in `package.json`; see the Brand section); 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); the **footer mailing-list box** (anon INSERT into the Supabase `subscribers` table); and the **social layer** — one shared inline script (`socialScript()` in `build.js`, injected at `<!--BUILD:SOCIAL-->` on both the Facet list pages and the reading pages) that owns Google sign-in (Supabase implicit flow, hand-rolled, no supabase-js; the session lives in `localStorage['sb-session']`), the like bar (signed in → the `record_user_like` RPC, signed out → the anonymous `increment-like` edge function as before; a signed-in load also re-reads the account's real likes once and settles every heart and `liked-<slug>` hint on the page against them), the likes modal (who liked, from the `like_list` view), the **profile popup** any named person opens (a second dialog, from the public `profiles` view), sharing (a completed share calls `record_share`), the live refresh of every baked card count on the list pages, the reading page's **comments** (live-fetched from the `comments` table, never baked — one-level-deep replies always shown and posted through the one composer in reply mode, a heart and count per comment from `comment_likes`/`comment_like_counts`, a Report on anyone else's comment that writes an `inbound_messages` row of kind `report`, and a Hide for the owner account; a comment somebody answered is soft-`removed` rather than deleted out from under its replies — it keeps its words and its time and loses only its name, reading as "Deleted user"), the **Likes section** above the comments (every liker as one run of overlapping faces, accounts first, opening the likes modal), and the **Account tab** — the first tab of the settings sheet on every Facet and reading page (`renderSettingsSheet()` bakes the panel, `socialScript()` fills it): signed out, one line of copy and Sign in with Google; signed in, the identity block plus My activity (Comments / Likes tabs), Privacy and data (which also carries Restore data — the reliable way to reclaim a deleted account's rows, the sign-in-return dialog being best-effort), Message me and Log out, each opening a dialog of the same shape as the likes modal (the tab bar's settings button wears the account's photo while there is a session and the log-in glyph otherwise, and every social dialog holds a counted page-scroll lock while open). Deleting an account deletes **only the auth user**: comments and likes stay visible as "Deleted user" and can be claimed back by signing in again with the same email (the `delete-account` / `reclaim-account` edge functions, the `restore_key`/`deleted` columns — see `supabase/README.md`).

> **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`, `links.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`, `links.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** (loaded live from `https://facet.tanishksharma.com/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/comment/share/views bar — then the **likes** section (the run of liker faces) and the **comments** section — heading, live thread, composer — filled at runtime by `socialScript()`), 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 — **every link-preview card the site shows**, drawn at request time. `?card=home\|feed\|apps\|work` are the four page cards; `?card=article\|post\|share` take `&title=&desc=&date=&slug=` (a share also takes `&linktitle=&linkdomain=&img=`); `?card=app&slug=<slug>` is one mini app's own card (name, phrase and category read live from `app_meta`, the icon from `/apps/icons/<slug>.png`, unknown slugs falling back to a real icon so a bad link can't 500). **The card designs live in this file and nowhere else** — `build.js` only decides which card a page points at. Live data at render time (anon key): the feed card shows the 3 newest published rows with their like/comment/view counts, the apps card the top 3 `app_meta` by `featured_rank`, a piece's card its own counts by slug — every fetch has a timeout and a static fallback, so a slow DB can't break a preview. Satori rules apply: flexbox only, px numbers, no grid, no line-clamp (text is cut to a character budget) |
| `404.html` | Hand-authored standalone page on **Facet** (loaded live from facet.tanishksharma.com, 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 the live `facet.tanishksharma.com/lib/facet.css` + their template's own `<style>` |
| `script.js` | All JavaScript for the homepage (carousels, popups, lang toggle, theme, parallax) |
| gem.js (remote) | **The animated-gemstone engine, loaded live from the Facet repo** — `https://facet.tanishksharma.com/lib/gem.js` on every page that needs it; there is NO local copy in this repo (deleted 30 Jul 2026). Improve it upstream in `tanishksharma/facet`. 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. 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). |
| `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. |
| `links.html` | **Generated + committed** — `/links`, the link-in-bio page (`LINK_GROUPS` + `LINK_TILES` + `renderLinksPage`/`linksScript` in `build.js`): three boxed `.list.list-boxed` stacks (reach me / on this site / projects) with **no visible headings and no page title** — the page opens straight into the first stack, and the `<h1>` ships `visually-hidden` (via `hideHeading` on the `LIST_PAGES` entry, honoured by `renderPlainHeading`) so a screen reader still gets one. The column caps at 34rem and centres, so the cap only bites on wide screens and phones keep their edge-to-edge rows. Full-width bold rows: a 40px tile, strong title, optional subline, chevron — everything centred against the row (the library top-aligns a two-line row; this page overrides that). Tiles are hand-drawn full-colour brand marks (iMessage, WhatsApp, Apple-Mail, Instagram gradient, LinkedIn, X), the **DP** on the Website row, an **accent tile** (`--accent-1` / `--on-accent-1`, so it re-colours with the theme) carrying a briefcase on the résumé row, and neutral ink tiles for the site/project rows — `LINK_TILES` is **links-only**: never touch the footer socials or Facet's icon set for it. Deliberately no hero and no background layer; the site chrome, footer and visit beacon stay. Every row carries `data-event="links-<slug>"`, and one delegated listener POSTs that value to the Supabase `link_hits` table (`keepalive`, fire-and-forget, never blocks the navigation). Indexable and in the sitemap, but off the tab bar — reachable from the nav sheet and the footer's Pages column, like `/privacy` |
| `wa.html`, `mail.html`, `x.html`, `li.html`, `ig.html`, `gh.html`, `cv.html`, `card.html`, `meet.html` | **Generated + committed** — the short links (`SHORT_LINKS` + `buildShortLinks()` in `build.js`) — see the section below |
| `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 |
| `openly/` | **The Openly archive** — everything preserved from the founder's 2018–2020 hyperlocal-marketplace startup, one product per subfolder behind four flat URLs — `/openly/website` (`website/`, the canonical site the WORK entry links to), `/openly/oldwebsite` (`oldwebsite/`), `/openly/consumer` and `/openly/business` (entry pages `consumer.html`/`business.html`: story + stats beside an aspect-locked iPhone frame on wide screens, the app edge-to-edge with a one-time intro sheet on phones; the app code lives in `app-consumer/` and `app-business/` (both apps' Cordova `www/` exports, REVIVED as browser/PWA demos: each serves a fake `cordova.js` — the filename the native build normally injects — that fires deviceready, stubs every plugin, and answers all `cordova.plugin.http` API calls from `demo-data.js`, a generated South-Bengaluru dataset with persistent orders that advance status over time, a fake Razorpay and real Google Maps (the shared Maps JS key `AIzaSyCPx08ji03sIp54B4toTB4sMhfeoiNAfgc`, baked into the map script tags and reusable across projects — canonical record on the Notion Productivity Stack; browser key, protected by referrer restriction to tanishksharma.com/*, not secrecy), and pregenerated SVG photos in `demo-assets/`; the app code itself is untouched. login = any number, OTP 1234; installable via per-app manifest + network-first sw.js). Store links inside the archived sites open the demo apps; the two sites cross-link in their footers; the `Openly (2018–20)` footer column links all four; `vercel.json` 308s every older URL shape here. All demos-class: self-contained, exempt from site rules, noindexed, never touched by `build.js`. Site internal links rewritten to absolute extensionless subpaths at import time; `vercel.json` 308s the old `/openly-new`/`/openly-old` paths here. A `url` on a WORK entry links the company line on both the classic homepage card and the `/work` resume sheet |
| `apps/` | **Mini apps** — the `/apps` grid page + one single-file app per `apps/<slug>.html`, styled with the **Facet** design system loaded live from `https://facet.tanishksharma.com/lib/`, 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 like button — anonymous taps go through the `increment-like` fn (`record_like` RPC + `like_log`), signed-in ones through the `record_user_like` RPC (same `like_log` table, `user_id` + `user_name` columns); the `like_list` view feeds the "who liked" modal. Leave at 0 |
| `shares` | managed by the share button — a completed share (native sheet resolved, or the clipboard fallback copied) calls the `record_share` RPC, which increments and returns the new total. An event, not a toggle. Leave at 0 |
| — | **comments** live in their own `comments` table (with the `comment_counts` view), not here; `build.js` bakes each card's count and the reading page fetches the thread live |

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` | — |
| Comments, replies, comment likes, reports, sign-in, the likes modal + Likes section, profile popups | the `comments` table (`parent_id`, `removed`) + `comment_likes` + `like_list` / `comment_counts` / `comment_like_counts` / `profiles` views + `record_user_like` + `inbound_messages` kind `report` (Supabase); Google auth on the same project | `socialScript()` + `socialCss()` + `readingLikes()` + `readingComments()` + `feedActions()` in `build.js` | the `<!--BUILD:SOCIAL-->` markers' output in generated pages | rebuild |
| Settings sheet (Account / Options / Style tabs) | `renderSettingsSheet()` in `build.js` for the tabs + panel shells; `socialScript()` for everything inside the Account panel and its dialogs; the `delete-account` / `reclaim-account` edge functions for the account itself | those | the baked `<aside id="settings-sheet">`; the hand-copied chrome in `404.html` / `apps/index.html` (out of scope, deliberately without the Account tab) | rebuild |
| 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) | the live `facet.tanishksharma.com/lib/facet.css` + the template's own `<style>` (`templates/list.html`, `templates/reading.html`) | the template `<style>` block; the library itself in the `tanishksharma/facet` repo | — | 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) |

---

## Brand — the name, the titles, the cards, the favicon

The site is called **Mission Control**; Tanishk Sharma is the person. Every
generated page carries `og:site_name = "Mission Control"`, and the home page
adds a `WebSite` JSON-LD block (name Mission Control, alternateName Tanishk
Sharma). The constants live at the top of `build.js` (`BRAND`, `BRAND_LINE`,
`FEED_LINE`, `FAVICONS`).

**Titles** (the tab, and the bold line in a share):

| Page | `<title>` and `og:title` |
|---|---|
| `/` | Mission Control by Tanishk |
| `/feed` | Articles, Posts, Shares - Tanishk Sharma |
| `/apps` | My App Library - Tanishk Sharma (hand-authored in `apps/index.html`) |
| `/work` | Professional Summary - Tanishk Sharma |
| `/contact`, `/links`, `/privacy` | `<Page> @ Mission Control` |
| an article | tab: `<Article Title> @ Mission Control` · og: `Article by Tanishk @ Mission Control` |
| a post | Post by Tanishk @ Mission Control |
| a share | Shared by Tanishk @ Mission Control |

`og:description` doubles as the meta description everywhere. An article's is
`<Title> - <teaser…>`, since its og:title doesn't carry the title; a post and
a share are their own text.

**Cards.** `og:image` points at `/api/og?card=…` for home, feed, work, apps
and every article/post/share (`ogCardUrl` / `readingCardUrl` in `build.js`).
`/contact`, `/links` and `/privacy` deliberately have **no image** — a wrong
card is worse than none. The designs themselves are in `api/og.js`.

**Favicon set** (`FAVICONS` in `build.js`, hand-copied into `404.html` and
`apps/index.html`): `favicon-32.png` and `favicon-16.png` first — the
squircle DP — then the older `favicon.ico` and `favicon.svg` underneath as
fallbacks, then `apple-touch-icon.png`. `assets/logos/fluffbotics.png` exists
because the OG renderer can't read the `.ico` the résumé sheet uses.

## Short links (`/wa`, `/mail`, `/x`, `/li`, `/ig`, `/gh`, `/cv`, `/card`, `/meet`)

Permalinks short enough to say out loud. Each is a tiny standalone page —
no chrome, no Facet, `noindex` — that logs a hit and replaces itself with its
destination: `/wa` → the WhatsApp chat, `/mail` → a mailto, `/x` `/li` `/ig`
`/gh` → the profiles, `/cv` → the résumé PDF, `/card` → `assets/tanishk.vcf`
(the vCard: name, mobile, email, site — no photo yet), `/meet` → the Google
Calendar appointment booking page (`MEET_URL` in `build.js`).

They come from `SHORT_LINKS` + `buildShortLinks()` in `build.js` and are
**committed** like `work.html` — generated, not hand-written, because eight
copies of the same four lines is eight places to fix a bug. Add one by adding
its row to `SHORT_LINKS` and rebuilding; `cleanUrls` serves each file
extensionless, and nothing in `vercel.json` claims those paths.

The hit goes to the Supabase `link_hits` table (`{ slug }`, anon INSERT, same
shape as the `subscribers` box) from an inline `<head>` script, fire-and-forget
with `keepalive: true` — otherwise `location.replace` on the next line cancels
it — and swallows every error, because a dead beacon must never cost the
visitor the redirect. The body is a plain `<a>`, so the page still works with
JavaScript off.

`link_hits` also collects the `/links` rows: the short pages send their own
slug (`wa`, `cv`, …) and a links row sends its full `data-event`
(`links-whatsapp`), so one table shows both how people arrive and what they
tap once they are there.

## 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**, loaded LIVE from **https://facet.tanishksharma.com/lib/facet.css** + **facet.js** on every page — never vendored, no local copy (the old `apps/lib/facet.*` copies were deleted 30 Jul 2026; source repo `tanishksharma/facet`, component reference at https://facet.tanishksharma.com/llms.txt; improve the library upstream, never fork it here). The library is served no-cache, so upstream changes appear on the apps immediately. **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; an app's own HTML still opens offline from the service-worker cache, but the live-loaded Facet library does not (accepted trade-off — freshness beats offline styling until a frozen `/lib/v1` exists). 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).
- **Every app page's `<head>` carries the brand's og tags** — `og:title` "&lt;App name&gt; @ Mission Control", `og:description` the app's phrase, `og:image` `/api/og?card=app&slug=<slug>`, `og:site_name` "Mission Control" and `summary_large_image` — hand-written, since `build.js` never touches this area. Adding an app means adding its head block too; the values come from the app's `app_meta` row (mirrored in `meta.js`).
- 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`, `links.html`, `privacy.html` and the short-link pages (`wa/mail/x/li/ig/gh/cv/card.html`) 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 exception is the one remaining standalone library, `bokeh.js` (the drifting background): keep it self-contained, dependency-free, and not wired into `build.js`; do not move site logic into it. (`gem.js` is no longer local — it loads live from the Facet repo.) Do not re-vendor the Facet library or gem.js — every page loads them live from `facet.tanishksharma.com/lib/`. (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.
