|
| 1 | +--- |
| 2 | +number: 6 |
| 3 | +title: "Canonical Examples — Design Spec" |
| 4 | +state: open |
| 5 | +labels: |
| 6 | +--- |
| 7 | + |
| 8 | +# Canonical Examples for violetear |
| 9 | + |
| 10 | +**Status:** Draft → review |
| 11 | +**Date:** 2026-05-20 |
| 12 | + |
| 13 | +## Goal |
| 14 | + |
| 15 | +Replace `examples/` (currently 9 ad-hoc files with overlapping concerns) with a minimal canonical set of **5 progressively-richer, independent showcases** that together stress-test the entire shipped violetear surface, while each remaining simple enough to read end to end. |
| 16 | + |
| 17 | +Each example is independent — readable in isolation, no shared helper module, no assumption that the previous example has been read. The progression is in *capability tier*, not in domain or code reuse. |
| 18 | + |
| 19 | +## Scope |
| 20 | + |
| 21 | +**In scope:** |
| 22 | +- Rewrite `examples/` from scratch — delete all 9 existing files, add 5 new ones. |
| 23 | +- One thin smoke test per example in `tests/test_examples_canonical.py` so they don't bit-rot when the framework changes. |
| 24 | +- File-naming convention: numeric prefix (`01_`, `02_`, …) so they sort by tier. |
| 25 | + |
| 26 | +**Out of scope (deferred):** |
| 27 | +- Deep pedagogical documentation (decided 2026-05-20: defer until after the set lands). |
| 28 | +- `@app.shared` usage in tier 5 — feature is unimplemented (`issues/4-...md`). Tier 5 uses the manual `@app.server.realtime` + broadcast pattern that is the only currently-working multi-user shape. When `@app.shared` ships, example 5 gets revised. |
| 29 | +- A `Component` subclass showcase — pattern exists in `markup.py:Component` but no example currently exercises it; deferred to a future addition. |
| 30 | +- A dedicated CSS DSL deep dive — coverage in `docs/examples/` (the Quarto fixture set) is already adequate for the DSL itself. |
| 31 | +- Any new framework features. Gaps found during the build get filed as a new `issues/` entry and continue; we do not stop the build to fix the framework unless a gap is blocking. |
| 32 | + |
| 33 | +**Untouched:** |
| 34 | +- `docs/examples/` and `tests/test_examples.py` (CSS fixture suite — separate purpose). |
| 35 | +- `tests/test_engine.py`, `test_state.py`, `test_websocket.py`, `test_pwa.py`, `test_unit.py` (framework unit/integration tests — orthogonal to the new examples). |
| 36 | + |
| 37 | +## The 5 tiers (overview) |
| 38 | + |
| 39 | +| # | File | Tier | Domain | Surface stressed | |
| 40 | +|---|---|---|---|---| |
| 41 | +| 1 | `01_static.py` | Pure markup + CSS, no server | Design-tokens reference page | `Document`, `Element`, `HTML.*`, `StyleSheet`, `Style`, `Color`/`Colors`, units. Writes `01_static.html` + `01_static.css` to disk. | |
| 42 | +| 2 | `02_ssr.py` | Server-only, no Pyodide bundle | Guestbook (GET list + POST add) | `App`, `@app.view`, served stylesheet via `doc.style(href=...)`, native FastAPI form handling, multiple routes. | |
| 43 | +| 3 | `03_interactive.py` | SSR + client-side Python, single user | Unit converter (m / ft / in, with a server-side "precise" mode) | `@app.client.callback`, `@app.local` reactive state, `data-bind-text`/`-value`, `@app.client.on("ready")`, `@app.server.rpc`, `violetear.dom.DOM` API. | |
| 44 | +| 4 | `04_pwa.py` | Installable + offline | Pomodoro timer | `pwa=Manifest(...)`, Service Worker asset caching, `violetear.storage` for cross-reload state, `@app.local` + persistent backing, an `asyncio` tick loop on the client. | |
| 45 | +| 5 | `05_realtime.py` | Multi-user via WebSocket | Chat room with presence | `@app.server.on("connect")`/`("disconnect")`, `@app.server.realtime`, `@app.client.realtime` with `.broadcast()` and `.invoke(client_id, ...)`, manual server-side shared state (`messages: list`, `users: dict[client_id, name]`). | |
| 46 | + |
| 47 | +## Detailed designs |
| 48 | + |
| 49 | +### 01 — Design-tokens reference page (`01_static.py`) |
| 50 | + |
| 51 | +**Domain.** A single-page design-tokens reference: palette swatches, typography scale (h1–h6 + body + small + caption), spacing scale (4 / 8 / 16 / 24 / 32 / 48 / 64 px), and a section showing all `Unit` types side by side. |
| 52 | + |
| 53 | +**Shape.** |
| 54 | +- No `App`, no FastAPI import. |
| 55 | +- Build a `Document` + `StyleSheet` in module scope. |
| 56 | +- `if __name__ == "__main__":` writes `01_static.html` and `01_static.css` to the same directory as the script. |
| 57 | +- Top-of-file docstring: "Run `python examples/01_static.py` then open `01_static.html` in a browser." |
| 58 | + |
| 59 | +**Concretely stresses.** |
| 60 | +- `StyleSheet` with many `.select(...)` rules. |
| 61 | +- `Style` fluent builder (`.font(...)`, `.color(...)`, `.padding(...)`, `.background(...)`, `.border(...)`, `.rounded(...)`, `.flexbox(...)`). |
| 62 | +- A wide slice of `Colors.*` (named color registry). |
| 63 | +- Multiple `Unit` types (px, rem, %, em). |
| 64 | +- `Document` with `head` styles, `body` content tree, `HTML.div(...).style(...)` chaining, `ElementSet.spawn(...)` for the swatch grid. |
| 65 | + |
| 66 | +**Verification.** Test imports the module, invokes its build function, asserts `<!DOCTYPE html>` in rendered HTML and a known color hex in rendered CSS. |
| 67 | + |
| 68 | +### 02 — Guestbook (`02_ssr.py`) |
| 69 | + |
| 70 | +**Domain.** A guestbook: GET `/` shows a list of entries (name + message + timestamp) plus a form; POST `/entries` appends an entry and redirects back to `/`. |
| 71 | + |
| 72 | +**Shape.** |
| 73 | +- Module-level `app = App(title="Guestbook")`. |
| 74 | +- Module-level `entries: list[dict] = []` — in-memory store; reset on restart, intentional simplicity. |
| 75 | +- `@app.view("/")` returns a `Document` rendering the list + the form. |
| 76 | +- `@app.api.post("/entries")` accepts form fields (FastAPI's native form handling — we don't introduce a new violetear primitive for this), appends, returns a 303 redirect to `/`. |
| 77 | +- A single `StyleSheet` served via `doc.style(href="/style.css", sheet=...)` to exercise the auto-served-CSS path. |
| 78 | + |
| 79 | +**Concretely stresses.** |
| 80 | +- `@app.view` for GET routes. |
| 81 | +- Native FastAPI `@app.api.post` for form-driven mutation (a deliberate signal: violetear doesn't yet have a first-class form-POST helper, and that's fine for SSR — FastAPI is right there). |
| 82 | +- `doc.style(href=..., sheet=...)` registering an auto-served stylesheet route. |
| 83 | +- Multiple `<form>` / `<input>` / `<button>` elements via the markup builder. |
| 84 | + |
| 85 | +**Verification.** Test boots app via `TestClient`; GET `/` returns 200 with the empty-state markup; POST `/entries` with form data returns 303; subsequent GET shows the new entry. |
| 86 | + |
| 87 | +### 03 — Unit converter (`03_interactive.py`) |
| 88 | + |
| 89 | +**Domain.** A length converter with three live-linked inputs (meters, feet, inches). A "mode" toggle switches between *quick* (client-only arithmetic) and *precise* (server RPC that returns a high-precision result). Last-used values restored on reload via `violetear.storage`. |
| 90 | + |
| 91 | +**Shape.** |
| 92 | +- `@app.local @dataclass class UiState: meters: float = 1.0; feet: float = 3.28; inches: float = 39.37; mode: str = "quick"`. |
| 93 | +- Three `@app.client.callback async def on_*_change(event)` handlers — each reads its own input, computes the others, mutates the proxy (which auto-syncs the DOM via `data-bind-value`). |
| 94 | +- `@app.server.rpc async def precise_convert(meters: float) -> dict` — returns `{"feet": …, "inches": …}` with deliberately more decimal places. |
| 95 | +- `@app.client.on("ready") async def restore():` — reads `store.last_state` if present and writes it back into `UiState`. |
| 96 | +- Single `@app.view("/")` returning the page. |
| 97 | + |
| 98 | +**Concretely stresses.** |
| 99 | +- `@app.local` reactive proxy mutated from client. |
| 100 | +- `data-bind-value` on the inputs (SSR-rendered, hydrated). |
| 101 | +- Multiple `@app.client.callback`s on different events. |
| 102 | +- `@app.client.on("ready")` lifecycle hook. |
| 103 | +- `@app.server.rpc` with float arguments + dict return. |
| 104 | +- `violetear.storage.store` round-trip. |
| 105 | +- `violetear.dom.DOM.find(...)` for any direct DOM lookups needed. |
| 106 | + |
| 107 | +**Verification.** Test asserts SSR markup contains `data-bind-value="UiState.meters"` etc., the bundle compiles, and POSTing to `/_violetear/rpc/precise_convert` returns the expected dict. |
| 108 | + |
| 109 | +### 04 — Pomodoro timer (`04_pwa.py`) |
| 110 | + |
| 111 | +**Domain.** A pomodoro timer with three modes (work 25m / short break 5m / long break 15m), session counter, and start/pause/reset controls. State persists across reload (so refreshing mid-session resumes correctly). Installable as a PWA; works offline once the bundle is cached. |
| 112 | + |
| 113 | +**Shape.** |
| 114 | +- `app = App(title="Pomodoro", version="1.0.0")` — version pinned (PWA needs a stable version per `README.md`; otherwise the SW re-downloads on every restart). |
| 115 | +- `@app.local @dataclass class PomodoroState: mode: str = "work"; seconds_left: int = 1500; running: bool = False; sessions: int = 0`. |
| 116 | +- `@app.client.on("ready")` — restore from `store.pomodoro`. If `running` was `true`, set it back to `false` (pause on reload — simpler than computing elapsed wall-time, and a familiar UX). User clicks "start" to resume from `seconds_left`. |
| 117 | +- `@app.client` (non-callback, plain client function) `async def tick():` — loops while `PomodoroState.running` is true, decrementing `seconds_left` each second via `asyncio.sleep(1)`, mutating the proxy (auto-DOM-update), and writing to `store.pomodoro` on each tick. |
| 118 | +- `@app.client.callback`s for `start`, `pause`, `reset`, `switch_mode`. |
| 119 | +- `@app.view("/", pwa=Manifest(name="Pomodoro", short_name="🍅", theme_color="#dc2626", ...))`. |
| 120 | + |
| 121 | +**Concretely stresses.** |
| 122 | +- `@app.view` with a custom `Manifest` object. |
| 123 | +- Service Worker asset caching (the bundle + the stylesheet get cached). |
| 124 | +- `violetear.storage.store` written every second (validates the round-trip-on-mutation pattern). |
| 125 | +- A long-running `asyncio` loop on the client side (validates that Pyodide-side concurrency works). |
| 126 | +- `@app.local` mutation from a non-callback client function. |
| 127 | + |
| 128 | +**Verification.** Test asserts the manifest endpoint serves the expected JSON (name, theme_color, scope = `/`), the SW endpoint serves a script that lists the bundle URL in its assets list, the bundle compiles. |
| 129 | + |
| 130 | +### 05 — Chat room with presence (`05_realtime.py`) |
| 131 | + |
| 132 | +**Domain.** A chat room. Anyone who connects picks a display name; all messages broadcast to everyone; a sidebar shows who is online; join/leave events appear in the chat as system messages. |
| 133 | + |
| 134 | +**Shape.** |
| 135 | +- Module-level state: |
| 136 | + - `messages: list[dict] = []` — `{from: str, text: str, ts: float}` |
| 137 | + - `users: dict[str, str] = {}` — `client_id → display_name` |
| 138 | +- `@app.server.on("connect") async def join(client_id):` — adds entry to `users` (initial name = `"anon-<short>"`), broadcasts a "user joined" system message, calls `set_user_list.broadcast(...)`. |
| 139 | +- `@app.server.on("disconnect") async def leave(client_id):` — removes from `users`, broadcasts "user left" + new list. |
| 140 | +- `@app.server.realtime async def post_message(text: str, from_id: str):` — appends to `messages`, calls `receive_message.broadcast(...)`. |
| 141 | +- `@app.server.realtime async def set_name(client_id: str, new_name: str):` — updates `users[client_id]`, broadcasts `set_user_list`. |
| 142 | +- `@app.client.realtime async def receive_message(msg: dict):` — appends to a chat DOM node. |
| 143 | +- `@app.client.realtime async def set_user_list(users: dict):` — re-renders the sidebar. |
| 144 | +- `@app.client.on("connect") async def request_history():` — once the WS is up, sends a realtime ping to the server (`request_history`). The server-side `@app.server.realtime async def request_history(client_id):` handler then calls `receive_history.invoke(client_id, messages=..., users=...)` — exercising the targeted reverse-RPC path (vs. the broadcast path used for message fan-out). |
| 145 | +- `@app.client.on("ready")` — UI init (focus the input box, etc.). |
| 146 | +- `@app.client.callback`s for the "send" button and the "change name" button. |
| 147 | + |
| 148 | +**Concretely stresses.** |
| 149 | +- Full WS lifecycle: connect, disconnect, message in both directions. |
| 150 | +- Both `.broadcast(...)` and (potentially) `.invoke(client_id, ...)` — the latter for the initial-state push. |
| 151 | +- `@app.client.on("connect")` (one of the features we wired in `feat: e32a24b`). |
| 152 | +- `@app.server.realtime` *and* `@app.client.realtime` in the same file, exercising the matched-pair pattern. |
| 153 | +- Manual server-side shared state — gracefully degraded form of what `@app.shared` will replace. |
| 154 | + |
| 155 | +**Verification.** Test connects two `TestClient.websocket_connect` sessions concurrently; the second client receives the first's join broadcast; sending a message from client A causes client B to receive a `receive_message` envelope with the right shape. |
| 156 | + |
| 157 | +## File conventions |
| 158 | + |
| 159 | +- **Naming.** `examples/0N_<slug>.py`. Numeric prefix → sort order matches tier order. Slug is one or two words. |
| 160 | +- **Top-of-file docstring** — 5–10 lines: |
| 161 | + - What this example demonstrates (tier + key features). |
| 162 | + - How to run it (`python examples/0N_*.py`). |
| 163 | + - For tiers 2–5: the URL to open in a browser. |
| 164 | + - For tier 5: how to test multi-user (open two tabs). |
| 165 | +- **No shared helpers.** Every example is self-contained. If two examples need the same utility, both inline it. |
| 166 | +- **Inline comments kept light.** The file should read end-to-end without commentary — code structure does most of the explaining. One-line comments only at non-obvious moments. (Deep pedagogical docs are a separate later effort.) |
| 167 | +- **Imports at module top.** No lazy imports inside functions, except where required by the Pyodide/server split (`from violetear.dom import DOM` inside a `@app.client.*` function is the established pattern and is correct). |
| 168 | +- **`if __name__ == "__main__":`** at the bottom: tier 1 writes files; tiers 2–5 call `app.run()` (uvicorn). |
| 169 | + |
| 170 | +## Disposition of existing examples |
| 171 | + |
| 172 | +Delete all 9 in a single commit when the first new example lands: |
| 173 | + |
| 174 | +``` |
| 175 | +basic_pwa.py broadcast.py full_pwa.py |
| 176 | +hello_world.py quickstart.py reactivity.py |
| 177 | +rpc_call.py server_realtime.py simple_client.py |
| 178 | +``` |
| 179 | + |
| 180 | +Rationale: a transitional `_legacy/` directory adds clutter without value — the git history is the archive. The new set replaces the old completely. |
| 181 | + |
| 182 | +(Note: the `examples/reactivity.py` was the source of the `class_name=` bug surfaced in slice 1 and fixed in `540a354`. Deleting it removes any lingering reference to the broken pattern.) |
| 183 | + |
| 184 | +## Test strategy |
| 185 | + |
| 186 | +Add `tests/test_examples_canonical.py` with **one thin smoke test per example**. Goal: catch regressions when the framework changes, not to validate the examples' *behavior* in depth (that's what the example itself demonstrates by running). |
| 187 | + |
| 188 | +Each test: |
| 189 | +- Imports the example module (top-level execution must succeed — exposes any module-load bug immediately). |
| 190 | +- For tier 1: invokes the build function and asserts the output strings contain expected markers. |
| 191 | +- For tiers 2–5: builds a `TestClient(example_module.app.api)`, exercises one happy-path request, asserts on key markers (right HTML, right manifest, right WS envelope shape). |
| 192 | + |
| 193 | +Total cost: roughly 5 small tests, ~150 lines combined. |
| 194 | + |
| 195 | +## Build order |
| 196 | + |
| 197 | +Build one example at a time, in numerical order. After each: |
| 198 | + |
| 199 | +1. The file lands. |
| 200 | +2. The smoke test lands. |
| 201 | +3. `make` (= `make test-unit`) is green locally. |
| 202 | +4. Commit: `feat(examples): canonical 0N_<slug> — <one-line description>`. |
| 203 | +5. Push so CI runs on each commit (catches Python-3.13-only issues early). |
| 204 | + |
| 205 | +After the last example lands, in a final commit: |
| 206 | +- Remove the 9 legacy files. |
| 207 | +- Update `README.md`'s quickstart to reference the new canonical examples (1-line nudge: "See `examples/01_static.py` … `examples/05_realtime.py` for canonical demos."). |
| 208 | +- Update `AGENTS.md`'s "Common workflows" to point at the canonical set. |
| 209 | + |
| 210 | +## Open decision points (call out anything to flip) |
| 211 | + |
| 212 | +- **Test strategy: thin smoke tests vs runnable-only?** Recommendation above is thin tests. Alternative: examples are demos, not test fixtures; you run them manually when developing. Lean toward tests because uncovered examples *will* bit-rot. |
| 213 | +- **Tier-1 output location.** Currently writes alongside the script (`examples/01_static.html`). Alternative: `examples/_out/` directory. Lean toward alongside for "open it and see" simplicity. |
| 214 | +- **Tier-5 initial state push** — *resolved in-spec.* Pattern: client `@app.client.on("connect")` sends a realtime ping to the server; server-side `@app.server.realtime` handler responds with `.invoke(client_id, ...)`. This exercises both directions in one flow (client realtime + server reverse-RPC-targeted). Mentioning here so any reader who expects "client GETs a /history endpoint" knows we deliberately avoided HTTP for the initial push. |
| 215 | + |
| 216 | +## Non-decisions (locked in upstream) |
| 217 | + |
| 218 | +- 5 examples, not 6 (no separate `@app.shared` example until the feature ships). |
| 219 | +- Independent showcases, not cumulative. |
| 220 | +- Per-example domains as listed (design tokens / guestbook / unit converter / pomodoro / chat). |
| 221 | +- Examples are removed wholesale; no `_legacy/` transition directory. |
| 222 | +- Spec lives at `issues/6-...md` per repo convention (not `docs/superpowers/specs/...`). |
0 commit comments