# Agent Guide for teach-server

This document describes how an HTTP-capable agent (e.g. **Claude Code** desktop
or CLI) interacts with a teach-server deployment to upload single-HTML pages
for invited users. Claude Code is the recommended client because it has the
`WebFetch`, `Bash`, and `Write` tools needed for the full flow; Claude Cowork
does not currently expose those, so its agents cannot complete the bootstrap.

## Bootstrap (first run on a user's machine)

1. Ask the user for their teach-server base URL (e.g. `https://tmuh.ai`) and
   their invited email. Confirm both before proceeding.
2. Ask the user for their password **in-memory only**. Never `Write` it to
   disk, never echo it in a Bash command, never include it in a later message.
3. `GET /auth/salt?email=<email>` (use `WebFetch` or `curl`).
4. Compute `hash = SHA256(password + salt)` as lowercase hex (64 chars). In
   Claude Code: `echo -n "$PW$SALT" | sha256sum` inside a Bash call where
   `$PW` is the in-memory password — do **not** write the password to a file
   first.
5. `POST /auth/login` with `{ email, hash }`; receive `{ api_key, username }`.
6. Write `<workdir>/.teach-server/credentials.json` and `chmod 600`:

   ```json
   {
     "base_url": "https://tmuh.ai",
     "username": "alice",
     "api_key": "tk_xxxxxx…"
   }
   ```

7. `GET <base_url>/llms.txt` and write it to
   `<workdir>/.teach-server/agent-guide.md` for self-reference.

## Uploading a page

The agent accepts the following inputs from the user:

- `slug`: URL segment, `/^[a-z0-9][a-z0-9-]{1,38}[a-z0-9]$/`.
- `title`: **required**, human-friendly title, ≤ 200 chars.
- `html`: full HTML file (with inline CSS/JS), up to 10 MB.
- `category`: **optional**, one of the keys below. Omit it and the server
  auto-classifies the page from its title + content.

Categories:

| key | label |
|-----|-------|
| `health-edu` | 衛教 (patient/health education, training, assessments) |
| `teaching` | 教學・工作坊 (courses, workshops, how-to / build tutorials) |
| `schedule-form` | 排班・表單 (schedules, sign-ups, forms, meeting records) |
| `tool` | 工具・儀表板 (interactive tools, generators, dashboards) |
| `presentation` | 簡報・作品 (slides, showcases, covers, org/dept intros) |
| `other` | 其他 (anything else) |

Request:

```
POST /api/pages
Authorization: Bearer <api_key>
Content-Type: application/json

{ slug, title, html, category? }
```

Response: `{ url, category, has_thumbnail, updated_at }`. Tell the user the url.

### Thumbnails are automatic

You do **not** need to provide a thumbnail. After upload, the server renders your
page (headless browser) and stores a screenshot thumbnail automatically; it shows
on the homepage and `/demo` cards.

Optional override: if you want to supply your own image instead, add a `thumbnail`
field (base64 PNG, `data:image/png;base64,...` or raw, ≤ 500 KB decoded):

   POST /api/pages
   { "slug": "...", "title": "...", "html": "...", "thumbnail": "data:image/png;base64,..." }

## Error handling

Errors follow the shape:

```json
{ "error": { "code": "kebab-case", "message": "...", "http": 401 } }
```

Common codes:

| Code                  | Action                                                    |
|-----------------------|-----------------------------------------------------------|
| `not-invited`         | Ask user to contact an admin for an invitation link.      |
| `invite-not-redeemed` | User must open the invite URL in a browser first.         |
| `invalid-credentials` | Password wrong — re-prompt, don't retry automatically.    |
| `invalid-api-key`     | `credentials.json` stale; re-run login flow.              |
| `payload-too-large`   | HTML > 10 MB; ask user to slim or remove embedded assets.  |
| `slug-taken`          | Rare race; retry with same slug (overwrites).             |

## HTML format requirements (read this before generating any page)

Uploaded pages are served with a strict Content-Security-Policy. If you
ignore these rules, the CSS will silently fail to apply or the JS will
throw — the page will look broken to the user. The exact CSP is:

```
default-src 'self' https:;
script-src  'self' 'unsafe-inline' https:;
style-src   'self' 'unsafe-inline' https:;
img-src     'self' data: https:;
media-src   'self' data: https:
```

### Do

- Produce a single self-contained `.html` file: inline `<style>`, inline
  `<script>`, and embed small images as `data:` URIs.
- Use `https://` CDNs for external scripts, stylesheets, or fonts (jsDelivr,
  unpkg, Google Fonts all work). Bare `http://` is blocked.
- For images: inline SVG, `data:image/...` URIs, or `https://` URLs.
- For audio/video: `data:` URIs or `https://` URLs both work (`media-src`).
  base64 inflates ~33% and counts toward the 10 MB page limit (~7.5 MB of
  inline media max), so mix — small clips/SFX as `data:`, photo walls and
  full songs/videos as `https://` URLs.
- Autoplay with sound is blocked by browsers until the user interacts with
  the page. Trigger `audio.play()` from a click/tap handler (e.g. a play
  button or the first user gesture), never on page load, or the audio
  silently never plays. A `muted` `<video>` may autoplay; unmuted may not.
- Test mental model: if you'd need to add a `<link rel="stylesheet" href="style.css">`
  pointing to a second file, don't — inline the CSS into the page itself.

### Do not

- Do **not** use `eval()`, `new Function()`, `setTimeout('code', ...)`, or
  `Function` constructors. The CSP does not enable `'unsafe-eval'`, so all
  of these will throw at runtime. If you need a templating library, pick
  one that compiles without eval (e.g. small hand-rolled template literals).
- Do **not** rely on `@font-face src: url(data:...)` — the font directive
  falls back to `default-src` which does not include `data:`. Use an
  `https://` font CDN instead.
- Do **not** load resources over `http://`; browsers block mixed content
  and the CSP rejects it.
- Do **not** include the user's `api_key` inside uploaded HTML.
- Do **not** upload any HTML that fetches `credentials.json` or other
  local files.
- Do **not** exceed 10 MB for the entire HTML file (inline assets count
  toward this limit).

### Self-check before uploading

Before calling `POST /api/pages`, run this mental checklist on your generated
HTML:

1. No `<link rel="stylesheet" href="...">` to a separate file? Everything
   inline or HTTPS?
2. No `<script src="./foo.js">` — scripts are inline or HTTPS CDN?
3. No `eval(`, `new Function(`, or `setTimeout('` with a string argument?
4. Images are `data:`, inline SVG, or HTTPS URLs?
5. Total size under 10 MB?

If any check fails, rewrite before uploading.

## Auditing HTML you did not write

A common flow: the user built the page themselves (or used a tool that
doesn't know about teach-server's CSP) and now asks you to upload it. **You
must audit the file against the CSP above before uploading.** If you skip
this step and the upload violates the CSP, the server responds 200 OK, the
page URL works, but CSS silently fails to apply or JS throws — the user
sees a broken page and (reasonably) blames the host. Catching this in the
agent saves a support round-trip.

### Audit procedure

`Read` the HTML file, then search it for each pattern below. Claude Code
users can use the built-in `Grep` tool (ripgrep-backed); other agents can
shell out via `Bash: grep -En '<pattern>' page.html`.

**Blocking issues (do not upload until fixed):**

| Pattern to search | Why it breaks |
|---|---|
| `http://[^"'\s]` | Mixed content — browser blocks the resource load |
| `eval\s*\(` | CSP blocks without `'unsafe-eval'` |
| `new\s+Function\s*\(` | Same as `eval()` under CSP |
| `setTimeout\s*\(\s*['"\`]` | String-form timer = implicit eval |
| `setInterval\s*\(\s*['"\`]` | Same |
| `<script[^>]+src="(?!https:)[^"]+"` | External script not on https (excluding same-origin `./` which will 404 anyway) |
| `<link[^>]+href="(?!https:)[^"]+\.css` | External CSS not on https |

**Warnings (upload is fine, but tell the user what will still not work):**

| Pattern | Effect |
|---|---|
| `@font-face[\s\S]*?src:\s*url\(\s*data:` | The data: font is rejected by `font-src` fallback; text falls back to system font. Suggest switching to an https:// font CDN. |
| `<iframe[^>]+src="(?!https:)` | Non-https iframes blocked by `frame-src` fallback. |

**Size check:** `Bash: wc -c page.html` must be ≤ 10485760 (10 MB).

### How to report findings to the user

1. If there are **blockers**, do not upload. Show the user each violation
   with line number and the reason, and offer to fix them in-place (e.g.
   rewrite `eval()` into a function reference, change `http://` to
   `https://`, inline a referenced `style.css`). Re-audit after fixes.
2. If there are only **warnings**, you may upload, but mention each one in
   the confirmation message so the user knows what will silently degrade.
3. If the file passes clean, upload and report the URL as usual.

### One-liner audit (copy-paste ready)

For Claude Code users, this Bash command flags the blockers at once:

```bash
grep -En "http://[^\"'\\s]|eval[[:space:]]*\\(|new[[:space:]]+Function[[:space:]]*\\(|setTimeout[[:space:]]*\\([[:space:]]*['\"\`]|setInterval[[:space:]]*\\([[:space:]]*['\"\`]" page.html || echo "no blockers found"
```

Empty output = no blockers. Any matches = show them to the user and fix
before uploading.

## GitHub Trend Prompt Lab

teach-server publishes a public concept area for agents that want to track
fast-growing open source GitHub repositories and produce "rebuild prompt packs."
The prompt generation agent is named **zodiac**.
The public demo list is `/demo`; the read-only zodiac dashboard is
`/u/github_trend_lab/`; the machine-readable entry is
`/.well-known/teach-server.json`; the detailed spec is `/docs/github-trend-prompts.md`.

The goal is not to compress a repository into a prompt that reproduces the
original source verbatim. The expected output is a structured, license-aware
prompt pack for rebuilding a functionally equivalent project:

- Source repository URL, commit SHA, license key, language, size, star counts.
- Trend window and scoring method.
- Purpose, architecture, file map, public API, UI/CLI behavior, build commands.
- Implementation prompt, file-by-file plan, test prompt, known gaps.
- Verification notes from a second agent or sandbox run.

Agents should only process public repositories with a detected SPDX license,
should keep attribution links, and should avoid copying long source passages
into the generated prompt pack.

The default automation model is local cron starting Codex CLI in non-interactive
mode. The Codex job should do static analysis only: reading GitHub API responses,
README, license, manifests, source files, tests, and CI files is allowed; running
candidate repository install/build/test/package scripts, shell scripts, Makefile
targets, or binaries is not allowed. Every important prompt-pack claim should
cite a source with repo, commit SHA, path, line range, and commit-pinned URL.
Each selected repo should produce three documents — trend report, application
report, and rebuild prompt — and package them into a teach-server-compatible
single HTML page for the public `github_trend_lab` user.

## AI proxy

The platform exposes an AI proxy so hosted pages (and agents with an API key) can
call LLM endpoints without managing provider secrets. Two authentication modes:

### (A) Programmatic access via API key (Bearer)

Full access including image generation/editing.

**curl example — non-streaming chat:**

```bash
curl -X POST https://tmuh.ai/api/ai/chat \
  -H "Authorization: Bearer tk_..." \
  -H "Content-Type: application/json" \
  -d '{"model":"gpt-4.1-mini","messages":[{"role":"user","content":"hello"}],"stream":false}'
# -> { "text": "...", "model": "gpt-4.1-mini", "usage": {...} }
```

**fetch example — streaming chat:**

```js
const res = await fetch('/api/ai/chat', {
  method: 'POST',
  headers: { 'Authorization': 'Bearer tk_...', 'Content-Type': 'application/json' },
  body: JSON.stringify({
    model: 'gemini-2.5-flash',
    messages: [{ role: 'user', content: '你好' }],
    stream: true,
  }),
});
const reader = res.body.getReader();
const dec = new TextDecoder();
while (true) {
  const { done, value } = await reader.read();
  if (done) break;
  for (const line of dec.decode(value).split('\n')) {
    if (line.startsWith('data: ') && line !== 'data: [DONE]') {
      const { delta } = JSON.parse(line.slice(6));
      process.stdout.write(delta);
    }
  }
}
```

**STT (speech-to-text):**

```bash
curl -X POST https://tmuh.ai/api/ai/stt \
  -H "Authorization: Bearer tk_..." \
  -H "Content-Type: application/json" \
  -d '{"audio":"<base64 or dataURI>","model":"whisper-1"}'
# -> { "text": "..." }
```

**TTS (text-to-speech) — Bearer only:**

```bash
curl -X POST https://tmuh.ai/api/ai/tts \
  -H "Authorization: Bearer tk_..." \
  -H "Content-Type: application/json" \
  -d '{"text":"你好，歡迎使用本站。","voice":"female"}'
# -> { "audio": "<base64 WAV>", "mime": "audio/wav" }
```

Voices: `male` (Puck) or `female` (Kore). Counts against the text quota bucket.

**TTS is Bearer-only** — exactly like image generation, it **cannot be called from a
hosted page at runtime** (the embedded helper has no `tts`; a hosted-page request to
`/api/ai/tts` is blocked). Generate narration during the build, then **bake it into the
page as a base64 audio `data:` URI.** The endpoint returns WAV (uncompressed PCM, ~48
KB/sec — large); to fit the 10 MB page budget, **convert to mp3 locally and bake that**:

```bash
# 1) get WAV from the proxy (Bearer), write it out
curl -s -X POST https://tmuh.ai/api/ai/tts -H "Authorization: Bearer tk_..." \
  -H "Content-Type: application/json" -d '{"text":"...","voice":"female"}' \
  | python3 -c 'import sys,json,base64; open("n.wav","wb").write(base64.b64decode(json.load(sys.stdin)["audio"]))'
# 2) compress to mp3 locally (needs ffmpeg)
ffmpeg -y -i n.wav -b:a 64k n.mp3
# 3) embed in the page as: <audio src="data:audio/mpeg;base64,<base64 of n.mp3>">
```

If a local TTS that already emits mp3 is available, you may use that instead (see the
asset-source choice in the Slideshow section). No `ffmpeg`? You can bake the raw WAV, but
it is ~6× larger — keep narration short.

**Image generation / editing (Bearer only):**

```bash
# Generation
curl -X POST https://tmuh.ai/api/ai/image \
  -H "Authorization: Bearer tk_..." \
  -H "Content-Type: application/json" \
  -d '{"model":"gpt-image-2","prompt":"a sunset over mountains","n":1}'
# -> { "images": [{ "b64": "..." }] }

# Editing — include "image" (dataURI) and optional "mask"
curl -X POST https://tmuh.ai/api/ai/image \
  -H "Authorization: Bearer tk_..." \
  -H "Content-Type: application/json" \
  -d '{"model":"gpt-image-2","prompt":"add snow","image":"data:image/png;base64,..."}'
```

### (B) Embedded chatbot in a hosted page — no key required

When a page is served from `/u/<username>/<slug>/`, the server recognises the
`Referer` header and attributes the AI call to the page owner's quota. **No API
key goes into the HTML.** Supported: chat + stt. **Image and TTS are Bearer-only**
— pre-generate them during the build and bake the results into the page (a hosted
page cannot call `/api/ai/image` or `/api/ai/tts` at runtime).

Minimal chatbot example (load `/static/ai.js` → `TeachAI.chat`):

```html
<script src="/static/ai.js"></script>
<div id="log"></div>
<input id="q"><button id="send">送出</button>
<script>
  document.getElementById('send').onclick = async () => {
    const q = document.getElementById('q').value;
    const bubble = document.createElement('p'); document.getElementById('log').append(bubble);
    await TeachAI.chat({
      model: 'gpt-4.1-mini',
      messages: [{ role: 'user', content: q }],
      stream: true,
      onDelta: (t) => { bubble.textContent += t; },
    });
  };
</script>
```

`TeachAI.chat` signature:

```js
TeachAI.chat({ model, messages, stream?, onDelta? })
// non-stream -> { text, model, usage }
// stream + onDelta -> onDelta(delta) called per chunk; resolves when done
```

`TeachAI.stt` signature:

```js
TeachAI.stt({ audio, model? })
// audio: base64 string or data URI, decoded ≤ 20 MB
// -> { text }
```

> The embedded helper has **no `tts` and no `image`** — both are Bearer-only. For narration
> in a hosted page, pre-generate it (Bearer) and bake a base64 audio `data:` URI into the HTML
> (see the TTS section above for the WAV→mp3 bake recipe).
```

### Models

| Type | Models |
|------|--------|
| chat | `gpt-4.1-mini`, `gemini-2.5-flash` |
| stt | `whisper-1` |
| image (Bearer only) | `gpt-image-2`, `gemini-2.5-flash-image` |
| tts | `gemini-2.5-flash-preview-tts` |

Available model list: `GET /api/ai/models` (no auth required).

### Quota and error codes

- **500** text (chat + stt) calls / user / day (default; admin-adjustable — GET /api/ai/models for current limits)
- **20** image calls / user / day (default; admin-adjustable)
- Successful responses include `X-AI-Quota-Remaining` header.

| Code | HTTP | Meaning |
|------|------|---------|
| `ai-quota-exceeded` | 429 | Daily limit reached |
| `unknown-model` | 400 | Model name not recognised |
| `image-requires-bearer` | 403 | Image endpoint called without Bearer key |
| `hosted-referer-blocked` | 403 | Image called from a hosted page (not allowed) |
| `ai-auth-required` | 401 | No valid session or Bearer key |
| `audio-too-large` | 400 | Decoded audio exceeds 20 MB |
| `ai-busy` | 503 | Server busy / queue full — retry shortly (honour Retry-After) |

## Slideshow video (self-contained HTML)

Combine slide images with narration audio — and optionally aligned captions — into a fully self-contained HTML slideshow. All assets are base64-embedded, so the page works offline and has no external dependencies.

**What "video" means here:** teach-server has **no server-side video encoding** — there is no ffmpeg/headless browser on the server and no endpoint that returns an `.mp4`. The recommended way to ship a "video" is this **client-side HTML slideshow**: `<img>` swaps timed to `<audio>` narration. If you genuinely need a real `.mp4`, encode it yourself (locally — see below) and embed it via an `https://` URL (preferred) or, **for short clips only**, a `data:video/mp4;base64,...` URI (`media-src` allows it, but base64 inflates ~33% and counts toward the 10 MB page limit).

### Picking your asset source (ask the user first)

Before generating anything, **scan your own local environment for image / TTS / STT / video tooling** (e.g. a local image generator, a local/OS TTS such as `say`, a local Whisper, `ffmpeg`). Then **let the user choose, per asset type**, where each one comes from:

- **Local tools** — generate images / audio / video on the user's own machine, then embed the resulting files as `data:` URIs (or `https://` URLs). Uses no tmuh.ai quota; pick this when the user has capable local tools or wants full control.
- **tmuh.ai AI proxy** — use `/api/ai/image`, `/api/ai/tts`, `/api/ai/stt` below. No local setup; counts toward the daily quota (default 500 text / 20 image; GET /api/ai/models for current limits).

Report what tooling you found locally and which source you'll use for each asset (images vs narration vs captions) **before** proceeding — the user may, for example, have a local image generator but want tmuh.ai voices, or vice versa.

### Workflow

1. **Generate images** — a local image tool, or `POST /api/ai/image` for each slide (**Bearer required**).
2. **Generate audio** — a local TTS, or `POST /api/ai/tts` for each slide's narration text (**Bearer required**; the proxy returns WAV — convert to mp3 locally before baking, see the TTS section's WAV→mp3 recipe). TTS cannot be called from the hosted page at runtime.
3. *(Optional)* **Generate captions** — a local STT, or `POST /api/ai/stt` on the audio to get aligned text captions (this one *can* run in the page if you prefer, via `TeachAI.stt`).
4. **Assemble HTML** — embed all image data and audio data as base64 `data:` URIs in a single HTML file with a built-in `<img>`/`<audio>` player. **Bake mp3 audio** (`data:audio/mpeg;base64,...`), not raw WAV.
5. **Upload** — `POST /api/pages` as usual (single HTML ≤ 10 MB; see size notes below).

### Minimal runnable example

```html
<!doctype html>
<html lang="zh-Hant">
<head><meta charset="utf-8"><title>Slideshow</title>
<style>
  body { display:flex; flex-direction:column; align-items:center; font-family:sans-serif; background:#111; color:#eee; }
  #stage img { max-width:90vw; max-height:60vh; border-radius:8px; }
  #cap { min-height:1.5em; font-size:1.1rem; margin:.5rem 0; }
  button { padding:.5rem 1.5rem; font-size:1rem; cursor:pointer; }
</style>
</head>
<body>
<div id="stage"><img id="slide" alt=""></div>
<p id="cap"></p>
<button id="play">▶ 播放</button>
<audio id="narration"></audio>
<script>
const slides = [
  // audio is mp3 (converted from the WAV /api/ai/tts returns — smaller to bake in)
  { img: "data:image/png;base64,AAA...", cap: "第一張：開場", audio: "data:audio/mpeg;base64,SUQzB..." },
  { img: "data:image/png;base64,BBB...", cap: "第二張：重點", audio: "data:audio/mpeg;base64,SUQzB..." },
];
const $ = (id) => document.getElementById(id);
function show(n) { const s = slides[n]; $("slide").src = s.img; $("cap").textContent = s.cap; }
function playFrom(n) {
  if (n >= slides.length) { $("play").textContent = "▶ 重播"; return; }
  show(n);
  const a = $("narration"); a.src = slides[n].audio;
  a.onended = () => playFrom(n + 1);
  a.play();
}
show(0);
$("play").onclick = () => { $("play").textContent = "⏸ 播放中"; playFrom(0); };
</script>
</body>
</html>
```

When building this as an agent, replace the placeholder `data:` strings with the actual `b64` values returned by `/api/ai/image` (prefixed `data:image/png;base64,`) and, for audio, the narration **converted to mp3** (prefixed `data:audio/mpeg;base64,`) — see the TTS section for the WAV→mp3 bake recipe.

### Key reminders

- **Bake mp3, not WAV** — `/api/ai/tts` returns uncompressed WAV (~48 KB/sec). Baking that raw eats the 10 MB budget fast. Convert each narration clip to mp3 locally (`ffmpeg -i n.wav -b:a 64k n.mp3`, ~8 KB/sec) and embed `data:audio/mpeg;base64,...`. If you used a local TTS that already emits mp3, bake that directly.
- **Layout is free** — the deck does **not** have to be 16:9. A 9:16 vertical poster or a long-form vertical-scroll layout works just as well (e.g. `/u/greenamon/inflation-for-teens/` is a tall vertical page). Pick the aspect ratio and orientation that fit the content; the `<img>`/`<audio>` driving logic is the same.
- **Autoplay policy** — browsers block audio with sound until the user interacts. Always trigger `audio.play()` from a click handler (like the play button above), never on page load.
- **Size budget** — base64 inflates ~33%; 10 MB page limit means roughly 7.5 MB of raw media. A 10-slide deck with ~300 KB PNG and ~80 KB mp3 per slide is under 5 MB encoded — comfortable. (The same deck with raw WAV narration would be ~2× larger.)
- **Pre-generate in Bearer mode** — generate all images and audio with Bearer **before** assembling the HTML; bake the results in. A hosted page cannot call `/api/ai/image` or `/api/ai/tts` at runtime (embedded pages only get chat + stt). Captions via `/api/ai/stt` *may* run in-page if you prefer.
- **CSP-safe** — `data:` URIs are allowed for `img-src` and `media-src`. The example above uses no `eval`, no external resources, and no `http://` — it will pass the CSP audit clean.
- **Reference** — `/u/greenamon/nvidia-gtc-taipei-2026/` (slideshow) and `/u/greenamon/inflation-for-teens/` (long vertical layout) are live single-file self-contained pages.

## Page styles (optional)

teach-server hosts a small menu of **opt-in** visual presets. They are **reference only** —
a hosted page is always the author's own design and is never constrained by the site. Use a
preset only when you (or the user) want a ready-made look that is **guaranteed CSP-safe** on
this platform; otherwise design freely.

**Available presets** (`GET /docs/styles/<name>`):

- `minimalist-executive` — quiet, spacious boardroom: hairline rules, whitespace, one accent.
- `editorial-report` — magazine/report: strong type hierarchy, pull-quotes, source-grounded.
- `data-heavy-finance` — KPI/chart-led: stat cards, aligned numeric tables, explicit units.

Each guide gives design tokens (fonts, palette, type scale, spacing), layout and component
conventions, a CSP-safety checklist, and a copy-paste minimal example.

**How to apply:**

1. Decide with the user whether they want a preset at all. If not, skip this entirely.
2. `GET /docs/styles/<name>` and read the guide.
3. **Inline** the CSS into your self-contained HTML (a `<style>` tag). Do **not** `<link>`
   to teach-server for styles — pages stay self-contained.
4. Self-host fonts via an `https://` CDN `<link>` (the presets show how). `data:` fonts,
   `unsafe-eval`, and `http://` resources are blocked by CSP; the presets stay clear of all.

These presets are independent of teach-server's own site styling — they shape the pages you
publish, not the platform chrome.

## Realtime multiplayer

Hosted pages can opt into multi-user realtime over Socket.IO (same origin, same
port). The server is a format-agnostic relay plus a built-in presence channel —
it does not understand your game messages, it just broadcasts them to others in
the same room.

- Room = the page (`u/<username>/<slug>`), with an optional `roomId` sub-room.
- Identity = a transient nickname you supply on join + a server-assigned socket id.
  Nothing is stored; presence disappears when the tab closes.
- Cursor coordinates are 0..1 document ratios (including scroll), so they line up
  across different screen sizes.
- Custom game messages ride on `relay { event, data, to? }` — `data` is any JSON,
  the server never inspects it. `to` (a socket id, same-room only) sends privately.
- Late joiners call `request-state`; existing peers reply with `relay(to:from)`.
- Limits (configurable): 50 peers/room, 16KB/msg, 30 msg/s, 20 cursor/s, nickname 32 chars.

Load the client (and optional helper):

```html
<script src="/static/socket.io.js"></script>
<script src="/static/realtime.js"></script>
```

Minimal example — prompt for a nickname, then show everyone's cursors:

```html
<script src="/static/socket.io.js"></script>
<script src="/static/realtime.js"></script>
<script>
  const nickname = prompt('輸入暱稱') || 'anonymous';
  const rt = TeachRealtime.joinRoom({ username: 'alice', slug: 'deck', nickname });
  const layer = document.createElement('div');
  Object.assign(layer.style, { position:'fixed', inset:0, pointerEvents:'none', zIndex:9999 });
  document.body.appendChild(layer);
  const dots = new Map();
  rt.onPresence((peers) => {
    const ids = new Set(peers.map(p => p.id));
    for (const [id, el] of dots) if (!ids.has(id)) { el.remove(); dots.delete(id); }
    for (const p of peers) {
      if (!p.cursor) continue;
      let el = dots.get(p.id);
      if (!el) {
        el = document.createElement('div');
        el.textContent = '▸ ' + p.nickname;
        Object.assign(el.style, { position:'absolute', font:'12px sans-serif', background:'#0008', color:'#fff', padding:'1px 4px', borderRadius:'3px' });
        layer.appendChild(el); dots.set(p.id, el);
      }
      el.style.left = (p.cursor.x * document.documentElement.scrollWidth - window.scrollX) + 'px';
      el.style.top  = (p.cursor.y * document.documentElement.scrollHeight - window.scrollY) + 'px';
    }
  });
  rt.sendCursor();
</script>
```

For a multiplayer game (e.g. a shooter), use `relay` for your own message
formats and `request-state` so late joiners can sync:

```js
const rt = TeachRealtime.joinRoom({ username:'alice', slug:'neon-shooter', roomId:'lobby1', nickname });
rt.send('move', { x, y, vx, vy });
rt.on('move', (data, from) => updateShip(from, data));
rt.onRequestState((from) => rt.send('state', myFullState, from)); // reply privately
rt.requestState();
```
