diff --git a/docs/superpowers/specs/2026-06-13-html-js-authoring-design.md b/docs/superpowers/specs/2026-06-13-html-js-authoring-design.md new file mode 100644 index 0000000..8a905e0 --- /dev/null +++ b/docs/superpowers/specs/2026-06-13-html-js-authoring-design.md @@ -0,0 +1,157 @@ +# HTML + JS component authoring — design + +**Date:** 2026-06-13 +**Status:** Approved (design); pending implementation plan +**Branch context:** follows the lazy node-tree component system (`Element`/`Safe`/`Fragment`/`Media`) and the `Children`/`Attributes` typing work. + +## Problem + +Trusted HTML and JavaScript are authored as Python f-strings in several places. Two distinct pains: + +- **HTML-as-string** — `Navbar`, `_TOAST_CONTAINER`, the played-row markup skeleton, and the generally verbose `Element("div", attributes=[...], children=[...])` call shape. +- **JS-in-string** — the genuinely ugly ones: `GameStatusSelector` (~70 lines) and `SessionDeviceSelector` (~50 lines) inline an Alpine `x-data="{...}"` blob with `fetchWithHtmxTriggers`, server-value interpolation (`{game.status}`), **and** `{{ }}` brace-doubling throughout; `_PLAYED_ROW_TEMPLATE` dodges the brace collision entirely by switching to `@@TOKEN@@` placeholders + a `.replace()` loop. + +You cannot node-tree JavaScript, so the JS pain needs a different answer than the HTML pain. The newer widgets (`search_select`, `range_slider`, `filter_bar`) already moved behavior into real `.js` files wired by `onSwap` + `data-*` attributes; the Alpine selectors are the holdouts that still inline their JS. + +## Goal + +Establish the *right* way to author interactive, server-rendered components in this codebase, and convert a few exemplars to prove it. North-star principle: + +> The server never writes a line of JavaScript. The server↔client boundary is a typed, declarative contract. Behavior lives in real, tooled TypeScript files. + +## Decisions (locked during brainstorming) + +| Decision | Choice | +| --- | --- | +| HTML authoring | **htpy-*style* sugar on the existing `Element`** (not the htpy library) — keeps `Media`/`collect_media`, no build step | +| JS runtime model | **Custom Elements** (Web Components), light DOM | +| Server↔client contract | **Typed contract + codegen** (one Python `Props` type → generated TS interface + reader) | +| JS language | **TypeScript** (real `.ts`, compiled) | +| Build tool | **`tsc` per-module** (no bundler) — preserves per-component `Media` loading | +| Alpine, for converted components | **Retired** — behavior rewritten as vanilla TS in the element class | +| Exemplars | **`GameStatusSelector` + `SessionDeviceSelector` + played-row** | +| Compiled output | **Build-only, gitignored** (produced by `make` + Docker) | +| Existing hand-written `.js` | **Left as-is**, migrated to TS later | + +## Architecture + +Three independent layers composing through one typed seam: + +``` +Python (server) TypeScript (client) +───────────────── ─────────────────── +htpy-style Element ──renders──► (vanilla DOM behavior) + │ ▲ + └── GameStatusSelectorProps ─codegen─┘ generated props.ts (interface + typed reader) + (one Python type = the whole server↔client contract) +``` + +- **Layer 1 — htpy-style HTML** removes HTML-string / verbose-`Element` ugliness, pure Python, no build, `Media` untouched. +- **Layer 2 — Custom Elements (TS)** removes JS-string ugliness; behavior in real typed modules with a native lifecycle. +- **Layer 3 — Typed contract codegen** makes the seam type-safe in both languages from a single Python source. + +### Layer 1 — htpy-style sugar on `Element` + +Additive only. Existing `Element("div", attributes=[...], children=[...])` and `Div([("class","x")], "hi")` keep working. + +- **Attributes as kwargs:** `Div(class_="card", hx_get="/x", disabled=True)`. Translation: trailing `_` stripped (`class_`→`class`); inner `_`→`-` (`hx_get`→`hx-get`, `data_id`→`data-id`); `True`→bare attribute, `False`/`None`→omitted. +- **Children via `[]`:** `Div(class_="card")[H1["Title"], body]`. `Element.__getitem__` normalizes through the existing `as_children` and returns an `Element` carrying the same attributes and media. + +The result is still a walkable `Element` tree, so `collect_media` / `Media` are unaffected. This is the "htpy feel on our own node so the asset system survives" decision. + +Example: + +```python +Div(class_="flex gap-2 items-center")[ + Icon("play"), + Span(class_="label")[name], +] +``` + +### Layer 2 — Custom Elements (TypeScript, light DOM) + +- Python builder emits a **semantic tag**: `Element("game-status-selector", attrs).with_media(Media(js=("dist/elements/game-status-selector.js",)))`. +- **Light DOM** (no shadow root — Tailwind's global classes must apply). The server renders the inner markup (htpy-style); the element enhances it. +- **Native lifecycle replaces `onSwap`:** `connectedCallback()` fires when the browser parses or htmx-swaps the element in; `disconnectedCallback()` provides free teardown. No init registry, no guard flags. +- Behavior is **vanilla TS** — the element class owns its state (dropdown open/closed, PATCH-on-select via `fetchWithHtmxTriggers`). Alpine retired for these three. +- Source `ts/elements/.ts` → compiled `games/static/js/dist/elements/.js`, loaded only on pages that use it (via `Media`). + +### Layer 3 — Typed contract (one Python type → the whole seam) + +Each element declares its props once, in Python: + +```python +class GameStatusSelectorProps(TypedDict): + game_id: int + status: str + csrf: str +``` + +- The **Python builder** takes these typed args and serializes them to kebab-case attributes (`game-id="3"`). +- **Codegen** reads the registered Props types and emits, per component, into `ts/generated/props.ts`: + - an **interface** — `GameStatusSelectorProps { gameId: number; status: string; csrf: string }`, and + - a **typed reader** — `readGameStatusSelectorProps(el): GameStatusSelectorProps` that pulls and parses attributes (`Number(el.getAttribute("game-id"))`, etc.). +- The element imports the generated reader. The entire server↔client boundary is generated from one Python type: rename `game_id` in Python, regenerate, and `tsc` fails until the element updates. Drift is caught at build time; no hand-written `getAttribute` soup, no silent attr-name drift. + +Type map: `int`/`float` → `number`, `str` → `string`, `bool` → `boolean`. Field `game_id` → attr `game-id` → TS prop `gameId`. Reader parsing follows the type (number → `Number(...)`, bool → presence / `=== "true"`, string → `getAttribute(...) ?? ""`). + +## Toolchain (`tsc` per-module, build-only) + +Layout: + +``` +ts/ + elements/game-status-selector.ts # hand-written element classes + generated/props.ts # codegen output (gitignored) + globals.d.ts # ambient: window.fetchWithHtmxTriggers, htmx +tsconfig.json # strict, ES2022, lib [ES2022, DOM, DOM.Iterable] + # rootDir: ts/ → outDir: games/static/js/dist/ +``` + +- **`games/static/js/dist/` is the only compiled output**, trivially gitignored, never colliding with hand-written `.js`. `Media` references `dist/elements/...`. +- **package.json**: add `typescript` devDep; scripts `build:ts` (`tsc -p tsconfig.json`), `watch:ts` (`tsc -p tsconfig.json --watch`). +- **Makefile**: `make ts` = codegen → `tsc`; `make dev` also runs `tsc --watch` (beside Django runserver + Tailwind watch); `make check` gains `tsc --noEmit` as a drift gate. +- **.gitignore**: `games/static/js/dist/`, `ts/generated/`. +- **Docker**: add a `make ts` step in the image build (npm already present for Tailwind); compiled JS baked into the image. Runtime stays offline. +- **TS lint/format**: deferred — `tsc --strict` is the only gate for now. + +### Codegen mechanics + +- A registry maps `tag → Props type` (e.g. a decorator `@element("game-status-selector", GameStatusSelectorProps)` on the Python builder, collected into a module-level registry). +- A Django management command (or script) imports the registry and writes `ts/generated/props.ts` (interface + reader per component). +- **Ordering:** codegen runs before `tsc` (the generated file is a `tsc` input). CI runs codegen then `tsc --noEmit`, so Python/TS drift fails the build. No committed generated artifact to diff against — `tsc` failing on drift is the gate. + +## Exemplar conversions + +1. **`GameStatusSelector` → ``** — Python builds the light-DOM htpy-style; `game-status-selector.ts` wires the dropdown toggle + click→PATCH `/api/games/{id}/status` via `fetchWithHtmxTriggers` with CSRF, and updates the displayed status. Deletes the ~70-line f-string + brace-doubling. +2. **`SessionDeviceSelector` → ``** — same shape; PATCH `/api/session/{id}/device`. +3. **played-row → ``** (non-Alpine) — deletes `_PLAYED_ROW_TEMPLATE` and the `@@TOKEN@@` / `.replace()` hack; Python builds markup htpy-style; `play-event-row.ts` owns the dropdown + add-playthrough POST. URLs are server-reversed and passed as attributes. Proves the pattern is not Alpine-only. + +## Testing + +- **Python**: builders render the correct tag + attributes (extend `test_components` / `test_rendered_pages`); assert no f-string remnants remain. +- **Type-check**: `tsc --noEmit` in `make check` — type errors, including contract drift, fail CI. +- **e2e (Playwright)**: real Chromium upgrades the custom elements natively; port/extend the existing widget-e2e pattern for all three (open dropdown → select → PATCH → DOM updates). + +## Risks and mitigations + +1. **Element module must be loaded before its tag appears.** Full-page render loads the module via `Media`; htmx row-swaps reuse the already-defined element. Constraint to document: a fragment response that introduces a brand-new element type must include that element's `Media`. (Same limitation class as today's "`onSwap` needs the script present.") +2. **A build step is now required** for `make dev` and Docker. One-time wiring, mitigated by Make/Docker integration. +3. **First TypeScript in the repo** — adds `typescript`, `tsconfig.json`, a Docker build step. Scoped to `ts/`; existing `.js` untouched. +4. **CSRF/PATCH parity** — the vanilla TS must replicate the Alpine version's fetch/CSRF/`HX-Trigger` behavior; it reuses the existing `fetchWithHtmxTriggers`; e2e guards it. +5. **Codegen ↔ build ordering** — codegen must precede `tsc`; encoded in `make ts`. + +## Out of scope (YAGNI) + +- Migrating the existing hand-written `.js` to TypeScript (later, incrementally). +- Bundling / minification of app JS. +- Shadow DOM / scoped styles. +- A general island / props-blob hydration runtime (custom elements cover these three). +- TS lint/format tooling (prettier/eslint). + +## Future on-ramps (not now) + +- **More custom elements**: migrate the remaining `onSwap` widgets to custom elements once the pattern is proven. +- **Existing `.js` → TS**: incremental, file by file (`tsc` checks mixed projects). +- The typed contract already positions the boundary for full type-safety as more client code becomes TS.