# Plate Guide

Plate is a project tracker by Lunch. Each plate is a self-contained HTML
page combining a wiki, task tracker, dev log, and optional BOM and docs tabs. The canonical shared
assets live at `plates.lunchfirm.com`, but any repo can host its own
`api/plate.js` and KV store.

## How a plate works

A plate is an HTML file. It loads three shared assets from
`plates.lunchfirm.com`:

```
shared/tokens.css    -  Lunch Design System tokens
shared/plate.css     -  component styles
shared/plate.js      -  all client logic
```

The JS derives a slug from the URL path and calls the API for tasks, log
entries, wiki content, and (when present) BOM items and docs. All five
tab types are opt-in via DOM presence  -  include the panel HTML and
the shared layer self-activates.

## Creating a plate in any repo

1. Copy the HTML template from `plates.lunchfirm.com/plate/`. Note:
   served plate pages are pre-rendered with the plate's current wiki
   content injected into `.wiki-body` (for link previews and crawlers)
   -  after copying, empty it back to `<div class="wiki-body"></div>`.
2. Set `data-api` on the `<html>` tag **only if the plate's API is on a
   different origin** (e.g. a plate in repo X that calls the
   plates.lunchfirm.com API). Omit it when the plate lives in the same
   repo as its `api/plate.js`  -  this includes lunch-plates itself and
   any other repo that deploys its own copy of the API.
3. Point all asset references at plates.lunchfirm.com:
   ```html
   <link rel="stylesheet" href="https://plates.lunchfirm.com/shared/tokens.css">
   <link rel="stylesheet" href="https://plates.lunchfirm.com/shared/plate.css">
   <!-- at end of body: -->
   <script src="https://plates.lunchfirm.com/shared/plate.js"></script>
   ```
4. Change the project name, subtitle, and meta tags. `<title>` and
   `og:title` are the project name on its own  -  no suffix, no
   subtitle. On plates served by a plates deployment (this repo, or a
   repo that ships its own `api/render.js` + `middleware.js`),
   `og:image` and the meta/og description are derived at serve time
   from the first wiki image and the registry description  -  don't
   hand-maintain them. Only purely static plates (no renderer) need a
   manual `og:image` kept pointing at the first wiki image
5. Leave `.wiki-body` empty  -  wiki content lives in the `wiki` KV
   key, not the HTML file. Write it after deploy via the pencil icon
   on the live plate or via the API (see "Wiki edits" below)
6. Push. Tasks and log work automatically via the slug
7. Register the plate so it appears on the index page, in the Atom
   feed, and in the sitemap: POST `{"slug": "<slug>", "description":
   "one-liner"}` to `?slug=plates&key=registry` (see Registry below)
8. Optional: uncomment the BOM and/or Docs panels in the template to
   enable those tabs. The shared JS detects `.bom-list` / `.docs-list`
   presence and initializes automatically  -  no extra scripts needed.

For plates whose `api/plate.js` lives in the same repo/deploy, use
relative paths (`/shared/...`) and omit `data-api`. This applies to
lunch-plates itself and to any other repo that ships its own API.

## Password gating

Gates are enforced by the server. The password lives at `<slug>:gate`
in KV  -  never in the page source  -  and while it is set the API
refuses **all reads** for that slug unless the request carries the
password in the `x-plate-gate` header or the master write key in
`x-plate-key`. Gated content really is private: view-source and direct
API calls get nothing.

**Setting a gate** (no env vars, no deploys):

- In the browser: log in (click the project name), then click the
  "gate" control in the header and type a password. Click again to
  remove it.
- Via the API: `PUT ?slug=<slug>&key=gate` with body
  `{"password": "word"}` and the `x-plate-key` header. `DELETE` on the
  same URL removes it.

**Other gate operations:**

- `GET ?slug=<slug>&key=gate` returns `{gated: true|false}` (never the
  password).
- `POST ?slug=<slug>&key=gate` with `{"password": "trial"}` verifies a
  password: 200 on match (or if no gate is set), 401 otherwise. This is
  what the overlay uses.

**The HTML side:** add a bare `data-gate` attribute to the `<html>` tag
of a gated plate. It is only a first-paint hint  -  it makes the
password overlay appear before any content flashes. The server is the
authority: a stale attribute on an ungated plate is ignored, and a
gated plate missing the attribute still works (reads come back 401 and
the overlay appears after load). Visitors' unlocks persist in
localStorage per plate.

Gated plates are excluded from the index page, the Atom feed, and the
sitemap, and the server-side renderer skips wiki injection for them.
Set `"gated": true` on the plate's registry entry as well  -  the
distribution surfaces re-check real gates, but the registry flag keeps
the listing honest.

**Migration note:** the old scheme (`data-gate="password"` compared in
the browser) is gone. A plate relying on it is public until you set its
password in KV  -  do that first, then strip the value from the
attribute.

## API

- **Base:** `https://plates.lunchfirm.com/api/plate`
- **Read:** `GET ?slug=<slug>&key=<key>` returns a JSON array (`[]` if unset).
  Gated slugs additionally require the `x-plate-gate` (gate password) or
  `x-plate-key` header  -  otherwise 401.
- **History:** `GET ?slug=<slug>&key=<key>&history=1` returns up to the
  last 10 pre-PUT snapshots, newest first, each as `{saved_at, data}`.
- **Append:** `POST ?slug=<slug>&key=<key>` with a single JSON object body.
  Auto-assigns `id` and `created_at`. Returns `201` with the stamped entry.
  Requires `x-plate-key` header.
- **Update one entry:** `PATCH ?slug=<slug>&key=<key>&id=<id>` with a JSON
  object of fields to merge. Returns `200` with the updated entry.
  The `id` field cannot be changed. Requires `x-plate-key` header.
- **Delete one entry:** `DELETE ?slug=<slug>&key=<key>&id=<id>`. Returns
  `200` with the removed entry. Requires `x-plate-key` header.
- **Bulk replace:** `PUT ?slug=<slug>&key=<key>` with JSON array body.
  **Destructive  -  overwrites the entire array.** The server snapshots
  the overwritten value to history first, so one bad PUT is recoverable
  via `&history=1`. Still: only use PUT for wiki content, clearing a
  slot (`[]`), or reordering. Requires `x-plate-key` header.
- **Gate ops:** `key=gate` is special  -  see Password gating above.
- **Auth probe:** `HEAD` with `x-plate-key` header, returns 200 or 401
- **Allowed keys:** `tasks`, `log`, `bom`, `wiki`, `wiki-draft` (deprecated), `docs`, `registry`
- **Slug format:** `^[a-z0-9-]+$`
- **Max body:** 512KB
- **CORS:** enabled for all origins

## Entry shapes

When using POST, omit `id` and `created_at`  -  the server assigns them.
The shapes below show what is stored / returned.

**tasks**
```json
{ "id": 1, "title": "string", "done": false, "tags": ["todo"], "section": "optional group name" }
```
Tags: `todo`, `bug`, `gotcha`, `note`. Section is optional; if present,
the UI groups tasks under collapsible headers.

**log**
```json
{
  "id": 1,
  "content": "what happened",
  "author": "claude",
  "images": ["https://pub-....r2.dev/plates/<slug>/....jpg"],
  "created_at": "2026-05-19T14:00:00.000Z"
}
```
Author entries from Claude with `"claude"`. ISO 8601 timestamp.
`images` is optional  -  an array of R2 URLs rendered inline under the
entry text (upload via `/api/upload?slug=<slug>`, image body, content
type header, `x-plate-key`; returns `{url}`). Every entry has a stable
anchor at `/<slug>/#log-<id>` and a standalone permalink page at
`/<slug>/log/<id>` with its own title and og tags  -  use the permalink
when citing or sharing a single entry.

**bom**
```json
{ "id": 1, "name": "Component Name", "fields": [{ "key": "value", "value": "100nF" }] }
```
Fields is a flexible key-value array. Add the BOM panel HTML to opt in.

**wiki**
```json
[{ "id": 1, "html": "<h2>...</h2><p>...</p>", "updated_at": "2026-05-26T00:00:00.000Z" }]
```
Empty array `[]` means no content has been written yet. Content loads
from this key on page init and saves directly back on edit.

**docs**
```json
{ "id": 1, "title": "Doc Title", "body": "freeform text content" }
```
Collapsible reference entries with autosave-on-blur. Add the Docs panel HTML to opt in.

**registry** (slug `plates`, key `registry`  -  the cross-plate index)
```json
{
  "id": 1,
  "slug": "vmk26",
  "name": "VMK'26",
  "description": "a dedicated polygon modeling kiosk",
  "done": false,
  "gated": false,
  "unlisted": false,
  "created_at": "2026-06-10T00:00:00.000Z"
}
```
One entry per plate, stored at `plates:registry`. POST to register,
PATCH to edit. `name` is optional (the slug is shown when absent);
`description` feeds the index page and the plate's meta/og description;
`done` marks a completed project (the only manual status  -  active vs
resting is derived from log recency); `gated: true` hides the plate
from the index, feed, and sitemap. `unlisted: true` also hides it from
those three surfaces, but (unlike `gated`) the plate stays fully public
 -  use it to register a plate before it is deployed so its row never
links to a 404, then PATCH `unlisted` off (or run
`node scripts/new-plate.mjs --publish <slug>`) once it is live.

**tabs** (dynamic wiki tabs)
```json
{ "id": 1, "label": "Quoting", "wiki": "cleanpap-quoting", "pos": 1 }
```
Each entry becomes an extra header tab holding a wiki panel, added at
runtime by the shared layer (and server-rendered by `api/render.js` on
plates it serves, so crawlers and link previews see the content). The
`wiki` field is the KV slug the tab's content lives under (key is
always `wiki`); when a tab is created in the browser it defaults to
`<plate-slug>-<slugified-label>`. `pos` drives ordering (fallback:
`id`). Renaming only changes `label`  -  content stays attached.
Deleting a tab removes only the config entry; the wiki content stays
in KV and can be re-attached by creating a tab whose slugified label
matches, or by POSTing an entry whose `wiki` points at the old slug.
In the browser (authed): `+` in the tab bar adds a tab; the active
dynamic tab shows ✎ ‹ › × controls for rename / reorder / remove.

**wiki-draft** (deprecated  -  retained for backward compatibility)

## Distribution surfaces

All derived from the registry and the logs  -  nothing to maintain:

- **Front door:** `https://plates.lunchfirm.com/` lists every
  registered, ungated plate with its description and a derived status
  (active = log entry in the last 30 days; resting since; done).
- **Log permalinks:** every entry is addressable  -  anchor
  `/<slug>/#log-<id>` within the plate, standalone page at
  `/<slug>/log/<id>` for sharing, unfurling, and indexing.
- **Atom feed:** `https://plates.lunchfirm.com/feed.xml`  -  the latest
  50 log entries across all ungated plates, linking to permalinks.
  Posting a log entry is publishing.
- **Sitemap:** `https://plates.lunchfirm.com/sitemap.xml` covers the
  index, plates, and entry permalinks.
- **llms.txt:** `https://plates.lunchfirm.com/llms.txt` describes the
  system and its machine-readable endpoints for agents.
- **Freshness:** each plate header shows "last transmission <date>"
  from its newest log entry.

A plate's `og:image` is derived at serve time from the first image in
its wiki body, and its meta/og description from the registry
`description`. Titles stay the bare project name  -  never append the
description or any suffix to a title.

## .plate.local

Plates can live in any repo. To help agents find the plate config
without asking, create a `.plate.local` file in the repo root.

**Single plate per repo:**

```json
{
  "slug": "vmk26",
  "api": "https://plates.lunchfirm.com/api/plate",
  "plate_path": "/absolute/path/to/plate/index.html",
  "write_key": "the-key"
}
```

**Multiple plates per repo:**

```json
{
  "api": "https://jtt-plates.vercel.app/api/plate",
  "write_key": "the-key",
  "plates": {
    "cleanpap": "/absolute/path/to/cleanpap/index.html",
    "plate-state": "/absolute/path/to/plate-state/index.html"
  }
}
```

When `plates` is present, derive the slug from the key and the
plate path from the value. `api` and `write_key` are shared across
all plates in the repo.

Fields:

- **slug**  -  the plate's KV namespace (single-plate form only)
- **api**  -  API base URL (defaults to `https://plates.lunchfirm.com/api/plate`)
- **plate_path**  -  absolute path to the plate's HTML file on disk (single-plate form only)
- **plates**  -  map of slug to plate path (multi-plate form)
- **write_key**  -  auth key for writes

Add `.plate.local` to `.gitignore` (it contains the write key).
The user creates it once per machine per project.

## Making API calls

Use `python3` with `urllib` for all plate API calls. Do not use `curl`
or other shell HTTP tools  -  token-saving proxies can filter their
output and break JSON parsing.

## Workflow for plate updates

Read `.plate.local` from the repo root first to get `slug`, `api`,
and `write_key`. If it doesn't exist, ask the user.

**Appending** (most common  -  log entries, tasks, BOM rows):

```python
python3 -c "
import json, urllib.request
req = urllib.request.Request(
  'API_URL?slug=SLUG&key=log',
  data=json.dumps({'content': 'what happened', 'author': 'claude'}).encode(),
  headers={'Content-Type': 'application/json', 'x-plate-key': 'KEY'},
  method='POST')
resp = urllib.request.urlopen(req)
print(resp.status, json.loads(resp.read()))
"
```

POST auto-assigns `id` and `created_at`. Verify HTTP 201.

**Updating one entry** (mark task done, edit a title, etc.):

```python
python3 -c "
import json, urllib.request
req = urllib.request.Request(
  'API_URL?slug=SLUG&key=tasks&id=3',
  data=json.dumps({'done': True}).encode(),
  headers={'Content-Type': 'application/json', 'x-plate-key': 'KEY'},
  method='PATCH')
resp = urllib.request.urlopen(req)
print(resp.status, json.loads(resp.read()))
"
```

Merges the fields you send into the entry. The `id` cannot be changed.
Verify HTTP 200.

**Deleting one entry:**

```python
python3 -c "
import json, urllib.request
req = urllib.request.Request(
  'API_URL?slug=SLUG&key=tasks&id=3',
  headers={'x-plate-key': 'KEY'},
  method='DELETE')
resp = urllib.request.urlopen(req)
print(resp.status, json.loads(resp.read()))
"
```

Returns the removed entry. Verify HTTP 200.

**Bulk replace (PUT)  -  destructive, rarely needed:**

PUT overwrites the entire array. Use it for wiki content, clearing a
slot (`[]`), or reordering  -  per-entry POST/PATCH/DELETE for
everything else.

The server keeps the last 10 overwritten values per key: before every
PUT it snapshots the old array to `<slug>:<key>:history`. If a PUT goes
wrong, recover with:

```python
python3 -c "
import json, urllib.request
hist = json.load(urllib.request.urlopen('API_URL?slug=SLUG&key=KEY_NAME&history=1'))
print(json.dumps(hist[0], indent=2))  # newest snapshot: {saved_at, data}
"
```

then PUT `hist[0]['data']` back.

## Wiki edits

Wiki content is stored in the `wiki` KV key and loaded on page init.
The HTML file's `.wiki-body` is an empty shell  -  all content lives
in KV. Any `.wiki` element with a `data-wiki` attribute is editable.

**Browser editing:** click the pencil icon, edit in-place, click Save.
Changes are live immediately  -  no deploy, no commit needed.

**Claude editing:** read and write the `wiki` key via the API.

```python
python3 <<'PY'
import json, urllib.request

# Read current wiki content
data = json.load(urllib.request.urlopen('API_URL?slug=SLUG&key=wiki'))
html = data[0]['html'] if data else ''

# ... modify html ...

# Write updated content
payload = json.dumps([{"id": 1, "html": html, "updated_at": "2026-05-26T00:00:00.000Z"}]).encode()
req = urllib.request.Request(
  'API_URL?slug=SLUG&key=wiki',
  data=payload,
  headers={'Content-Type': 'application/json', 'x-plate-key': 'KEY'},
  method='PUT')
print(urllib.request.urlopen(req).status)
PY
```

**Multiple editable panels:** a plate can have more than one editable
content tab. `data-wiki="wiki"` uses the page slug (default for the
main project panel). `data-wiki="custom-slug"` uses that value as the
KV slug  -  the key is always `wiki`. For example, ryco's BLE tab uses
`data-wiki="ryco-ble"`, which stores content at `ryco-ble:wiki` in KV.

## Image uploads

`POST https://plates.lunchfirm.com/api/upload?slug=<slug>` with the raw
image bytes as the body, `Content-Type` set to the image MIME type, and
the `x-plate-key` header. Accepts jpeg, png, gif, webp, svg. 10MB max.
Returns `{ "url": "..." }`  -  a public R2 URL to use in wiki `<img>`
tags. The in-browser wiki editor uses this same endpoint for pasted and
dropped images.

```python
python3 -c "
import urllib.request
req = urllib.request.Request(
  'https://plates.lunchfirm.com/api/upload?slug=SLUG',
  data=open('photo.jpg', 'rb').read(),
  headers={'Content-Type': 'image/jpeg', 'x-plate-key': 'KEY'},
  method='POST')
resp = urllib.request.urlopen(req)
print(resp.read().decode())
"
```

## 3D models

Plates support interactive 3D viewers inline via the `<plate-3d>` custom
element. Powered by Online3DViewer (MIT, self-hosted). Supports STEP,
OBJ, FBX, GLB, STL, 3MF, and more  -  anything Fusion 360 can export.

**Setup:** add the script tag once, before `</body>`:

```html
<script src="https://plates.lunchfirm.com/shared/plate-3d.js"></script>
```

For plates in the lunch-plates repo, use the relative path:

```html
<script src="/shared/plate-3d.js"></script>
```

**Usage:** drop a `<plate-3d>` tag in wiki content or any panel:

```html
<plate-3d src="https://pub-e21b8aebebd747c6beb357d6cc0391cc.r2.dev/myproject/board.step"></plate-3d>
```

Drag to rotate, scroll to zoom, right-drag to pan.

**OBJ with materials:** pass the OBJ and MTL as comma-separated URLs:

```html
<plate-3d src="https://r2.dev/.../model.obj, https://r2.dev/.../model.mtl"></plate-3d>
```

**Custom height:** default is 400px. Override with the `height` attribute:

```html
<plate-3d src="board.glb" height="600px"></plate-3d>
```

**Format notes:**

- STEP/IGES: parsed in-browser via OpenCascade WASM (~5MB, fetched from
  CDN on first use, cached after). All other formats work offline.
- OBJ+MTL: preserves Fusion's flat appearance colors.
- FBX: preserves materials but the loader is heavier.
- GLB: smallest files, best materials. Requires conversion from Fusion
  (FBX to Blender to GLB, or a Fusion glTF export plugin).
- STL: geometry only, no colors. Gets a neutral gray default.

The 3D engine (~1MB) lazy-loads only when a `<plate-3d>` tag is on the
page. Zero overhead for plates that don't use it.

## Conventions

- **Never update a plate without explicit user confirmation.** The plate
  records agreed intent, not working chatter. If you think something
  should be logged, ask first.
- One commit / one API call per logical change
- Keep entries concise  -  records, not essays
- After a meaningful change ships, it's reasonable to ask "want me to
  log this to the plate?" rather than staying silent
- Author field for Claude-written log entries: `"claude"`

## HTML template structure

```html
<html lang="en" data-theme="dark" data-api="https://plates.lunchfirm.com/api/plate">
<head>
  <title>Project Name</title>
  <meta property="og:title" content="Project Name" />
  <script>
    (function() {
      var t = 'dark';
      try { t = localStorage.getItem('plate-theme') || 'dark'; } catch(e) {}
      document.documentElement.dataset.theme = t;
    })();
  </script>
  <link rel="alternate" type="application/atom+xml" href="https://plates.lunchfirm.com/feed.xml">
  <link rel="stylesheet" href="https://plates.lunchfirm.com/shared/tokens.css">
  <link rel="stylesheet" href="https://plates.lunchfirm.com/shared/plate.css">
</head>
<body>
<div class="shell">
  <header class="header">
    <div class="header-top">
      <div class="project-name">
        Project Name <span class="project-subtitle">// subtitle</span>
      </div>
      <div class="header-right">
        <div class="theme-switch">
          <button data-set-theme="light">Day</button>
          <span class="theme-divider">/</span>
          <button data-set-theme="dark">Night</button>
        </div>
        <div class="project-meta">Context</div>
      </div>
    </div>
    <nav class="tabs">
      <button class="tab active" data-tab="project">Project</button>
      <button class="tab" data-tab="tasks">Tasks</button>
      <!-- optional: <button class="tab" data-tab="bom">BOM</button> -->
      <!-- optional: <button class="tab" data-tab="docs">Docs</button> -->
      <button class="tab" data-tab="log">Log</button>
    </nav>
  </header>

  <div class="panel active" data-panel="project">
    <div class="wiki" data-wiki="wiki">
      <div class="toc"></div>
      <div class="wiki-body"></div>
    </div>
  </div>

  <div class="panel" data-panel="tasks">
    <div class="task-filters">
      <button class="filter-btn active" data-filter="all">All</button>
      <button class="filter-btn" data-filter="todo">Todo</button>
      <button class="filter-btn" data-filter="bug">Bug</button>
      <button class="filter-btn" data-filter="gotcha">Gotcha</button>
      <button class="filter-btn" data-filter="note">Note</button>
    </div>
    <ul class="task-list"></ul>
    <form class="task-add">
      <input type="text" class="task-add-input" placeholder="Add a task...">
      <select class="task-add-tag">
        <option value="todo">Todo</option>
        <option value="bug">Bug</option>
        <option value="gotcha">Gotcha</option>
        <option value="note">Note</option>
      </select>
      <input type="text" class="task-add-section" placeholder="Section" list="section-list">
      <datalist id="section-list"></datalist>
      <button type="submit" class="btn">Add</button>
    </form>
  </div>

  <!-- optional BOM panel -->
  <!--
  <div class="panel" data-panel="bom">
    <ul class="bom-list"></ul>
    <form class="bom-add">
      <input type="text" class="bom-add-input" placeholder="Add a BOM item...">
      <button type="submit" class="btn">Add</button>
    </form>
  </div>
  -->

  <!-- optional Docs panel -->
  <!--
  <div class="panel" data-panel="docs">
    <ul class="docs-list"></ul>
    <form class="docs-add">
      <input type="text" class="docs-add-input" placeholder="Add a doc entry...">
      <button type="submit" class="btn">Add</button>
    </form>
  </div>
  -->

  <div class="panel" data-panel="log">
    <div class="log-list"></div>
    <form class="log-add">
      <textarea class="log-add-input" placeholder="Write a log entry..." rows="3"></textarea>
      <div class="log-add-row">
        <input type="text" class="log-add-author" placeholder="Author" value="chris">
        <button type="submit" class="btn">Post</button>
      </div>
    </form>
  </div>
</div>
<script src="https://plates.lunchfirm.com/shared/plate.js"></script>
</body>
</html>
```

Omit `data-api` when the plate is deployed from the same repo as its
`api/plate.js` (lunch-plates, or any repo with its own API + KV store).
Only set it when the API lives on a different origin.
