Files
timetracker/docs/superpowers/plans/2026-06-13-html-js-authoring.md
T
lukas 3ff3eed164 Implementation plan: typed custom-element + htpy-style authoring
Bite-sized TDD plan for the design spec: TS toolchain scaffold, htpy-style
Element sugar, custom-element registry + codegen, then the three exemplar
conversions (GameStatusSelector, SessionDeviceSelector, played-row) retiring
their inline Alpine/@@TOKEN@@ f-strings, plus CI/Docker/docs wiring.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 20:51:08 +02:00

1211 lines
42 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# HTML + JS Component Authoring Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Replace the trusted HTML/JS f-strings (Alpine selectors, `@@TOKEN@@` played-row) with htpy-style Python markup + TypeScript custom elements bound by a codegen'd typed contract.
**Architecture:** Three composing layers — (1) additive htpy-style sugar on the existing `Element` node (kwargs attributes + `[]` children), keeping `Media`; (2) light-DOM custom elements whose behavior lives in TypeScript with the native `connectedCallback` lifecycle; (3) one Python `TypedDict` per element, codegen'd into a TS interface + attribute reader so server↔client drift fails `tsc`.
**Tech Stack:** Python 3.12 / Django, pytest + Playwright, TypeScript (`tsc` per-module, no bundler), pnpm, Tailwind, HTMX, the existing `common/components` node tree.
**Design spec:** `docs/superpowers/specs/2026-06-13-html-js-authoring-design.md`
---
## File structure
| Path | Responsibility | Create/Modify |
| --- | --- | --- |
| `tsconfig.json` | TS compiler config (per-module emit to `dist/`) | Create |
| `package.json` | add `typescript` devDep + scripts | Modify |
| `.gitignore` | ignore compiled `dist/` + generated TS | Modify |
| `Makefile` | `ts` target; wire into `dev`/`check` | Modify |
| `ts/globals.d.ts` | ambient types (`window.fetchWithHtmxTriggers`) | Create |
| `ts/elements/dropdown.ts` | shared value-selector dropdown behavior | Create |
| `ts/elements/game-status-selector.ts` | game status element | Create |
| `ts/elements/session-device-selector.ts` | session device element | Create |
| `ts/elements/play-event-row.ts` | played-count control | Create |
| `ts/generated/props.ts` | codegen output (interfaces + readers) | Generated |
| `common/components/core.py` | `Element.__getitem__`, kwargs attrs | Modify |
| `common/components/primitives.py` | `_attrs_from_kwargs` + kwargs in `_html_element` | Modify |
| `common/components/custom_elements.py` | registry, `custom_element()` builder, Props specs | Create |
| `games/management/commands/gen_element_types.py` | codegen command | Create |
| `common/components/domain.py` | convert the two selectors | Modify |
| `games/views/game.py` | convert played-row | Modify |
| `tests/test_node_tree.py` | htpy-style sugar tests | Modify |
| `tests/test_custom_elements.py` | registry/codegen/builder tests | Create |
| `e2e/test_custom_elements_e2e.py` | browser tests for the 3 elements | Create |
---
## Task 1: TypeScript toolchain scaffold
**Files:**
- Modify: `package.json`
- Create: `tsconfig.json`, `ts/globals.d.ts`
- Modify: `.gitignore`, `Makefile`
- [ ] **Step 1: Add the TypeScript dependency**
Edit `package.json` — add to `devDependencies`:
```json
"typescript": "^5.6.0"
```
- [ ] **Step 2: Install**
Run: `pnpm install`
Expected: adds `typescript`, no errors.
- [ ] **Step 3: Create `tsconfig.json`**
```json
{
"compilerOptions": {
"target": "ES2022",
"module": "ES2022",
"moduleResolution": "Bundler",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"strict": true,
"noEmitOnError": true,
"forceConsistentCasingInFileNames": true,
"rootDir": "ts",
"outDir": "games/static/js/dist"
},
"include": ["ts/**/*.ts"]
}
```
- [ ] **Step 4: Create `ts/globals.d.ts`**
```typescript
export {};
declare global {
interface Window {
fetchWithHtmxTriggers(input: RequestInfo | URL, init?: RequestInit): Promise<Response>;
}
}
```
- [ ] **Step 5: Create a smoke source to prove the pipeline**
Create `ts/_smoke.ts`:
```typescript
export const SMOKE: number = 1;
```
- [ ] **Step 6: Compile and verify output appears**
Run: `pnpm exec tsc`
Then: `test -f games/static/js/dist/_smoke.js && echo OK`
Expected: prints `OK`.
- [ ] **Step 7: Delete the smoke source**
Run: `rm ts/_smoke.ts games/static/js/dist/_smoke.js`
- [ ] **Step 8: Ignore generated + compiled output**
Append to `.gitignore`:
```
# TypeScript: compiled output and codegen are build-only
/games/static/js/dist/
/ts/generated/
```
- [ ] **Step 9: Add the `ts` Makefile target and wire it in**
In `Makefile`, add a `ts` target and a `gen-element-types` target (the command lands in Task 3; the target is defined now and will work once the command exists):
```makefile
gen-element-types:
uv run python manage.py gen_element_types
ts: gen-element-types
pnpm exec tsc
ts-check: gen-element-types
pnpm exec tsc --noEmit
```
Change the `dev` target to also run the TS watcher — replace the existing `dev:` recipe with:
```makefile
dev:
@pnpm concurrently \
--names "Django,Tailwind,TS" \
--prefix-colors "blue,green,magenta" \
"uv run python -Wa manage.py runserver" \
"pnpm tailwindcss -i ./common/input.css -o ./games/static/base.css --watch" \
"pnpm exec tsc --watch"
```
Change `check:` to include the type-check gate:
```makefile
check: lint format-check ts-check test
```
- [ ] **Step 10: Commit**
```bash
git add package.json pnpm-lock.yaml tsconfig.json ts/globals.d.ts .gitignore Makefile
git commit -m "Add TypeScript toolchain (tsc per-module, build-only)"
```
(If pnpm produced no lockfile change, omit `pnpm-lock.yaml`.)
---
## Task 2: htpy-style sugar on `Element`
**Files:**
- Modify: `common/components/core.py` (add `Element.__getitem__`)
- Modify: `common/components/primitives.py` (kwargs attributes in the `_html_element` factory)
- Test: `tests/test_node_tree.py`
- [ ] **Step 1: Write the failing tests**
Add to `tests/test_node_tree.py`:
```python
class HtpyStyleSugarTest(unittest.TestCase):
def test_getitem_sets_children(self):
from common.components import Div, Span
self.assertEqual(
render(Div(class_="card")[Span()["hi"]]),
'<div class="card"><span>hi</span></div>',
)
def test_getitem_multiple_children(self):
from common.components import Div
self.assertEqual(render(Div()["a", "b"]), "<div>a\nb</div>")
def test_kwargs_class_underscore_becomes_class(self):
from common.components import Div
self.assertIn('class="x"', render(Div(class_="x")))
def test_kwargs_inner_underscore_becomes_hyphen(self):
from common.components import Div
self.assertIn('hx-get="/y"', render(Div(hx_get="/y")))
def test_kwargs_true_renders_bare_attr(self):
from common.components import Div
self.assertIn('hidden="hidden"', render(Div(hidden=True)))
def test_kwargs_false_and_none_omitted(self):
from common.components import Div
html = render(Div(hidden=False, title=None))
self.assertNotIn("hidden", html)
self.assertNotIn("title", html)
def test_getitem_preserves_media(self):
from common.components import Div, Media, collect_media
node = Div(class_="x").with_media(Media(js=("a.js",)))["child"]
self.assertEqual(collect_media(node).js, ("a.js",))
```
- [ ] **Step 2: Run to verify failure**
Run: `uv run pytest tests/test_node_tree.py::HtpyStyleSugarTest -v`
Expected: FAIL — `TypeError: 'Element' object is not subscriptable` / unexpected kwargs.
- [ ] **Step 3: Add `__getitem__` to `Element`**
In `common/components/core.py`, inside `class Element(Node):`, after `__init__`:
```python
def __getitem__(self, children: "Children | Node") -> "Element":
"""htpy-style children: ``Div(class_="x")[child1, child2]``.
Returns an Element with the same tag/attributes/media and these
children, so the tree stays walkable (Media still bubbles)."""
items = children if isinstance(children, tuple) else (children,)
clone = Element(self.tag_name, self.attributes, list(items))
clone.media = self.media
return clone
```
- [ ] **Step 4: Add the kwargs→attributes helper and wire it into the factory**
In `common/components/primitives.py`, add near the top (after imports):
```python
def _attrs_from_kwargs(attrs: dict[str, object]) -> list[HTMLAttribute]:
"""Translate htpy-style attribute kwargs to (name, value) pairs.
``class_`` -> ``class`` (trailing underscore stripped); ``hx_get`` ->
``hx-get`` (inner underscores to hyphens); ``True`` -> bare attribute;
``False`` / ``None`` -> omitted."""
result: list[HTMLAttribute] = []
for key, value in attrs.items():
if value is None or value is False:
continue
name = key.rstrip("_").replace("_", "-")
result.append((name, name if value is True else value)) # type: ignore[arg-type]
return result
```
Then change the `_html_element` factory's inner `element` function to accept and merge kwargs:
```python
def _html_element(tag_name: str):
"""Build a generic element builder for ``tag_name`` (the whitelist factory)."""
def element(
attributes: Attributes | None = None,
children: Children = None,
**attrs: object,
) -> Element:
merged = as_attributes(attributes) + _attrs_from_kwargs(attrs)
return Element(tag_name, merged, children)
element.__name__ = element.__qualname__ = tag_name[:1].upper() + tag_name[1:]
element.__doc__ = f"Builder for the <{tag_name}> element."
return element
```
- [ ] **Step 5: Run to verify pass**
Run: `uv run pytest tests/test_node_tree.py::HtpyStyleSugarTest -v`
Expected: PASS (7 tests).
- [ ] **Step 6: Run the full component suite (no regressions)**
Run: `uv run pytest tests/test_node_tree.py tests/test_components.py tests/test_rendered_pages.py -q`
Expected: PASS.
- [ ] **Step 7: Lint + format**
Run: `uv run ruff check common/components/ && uv run ruff format common/components/core.py common/components/primitives.py`
Expected: clean.
- [ ] **Step 8: Commit**
```bash
git add common/components/core.py common/components/primitives.py tests/test_node_tree.py
git commit -m "htpy-style sugar on Element: kwargs attributes + [] children"
```
---
## Task 3: Custom-element registry, builder, and codegen
**Files:**
- Create: `common/components/custom_elements.py`
- Create: `games/management/commands/gen_element_types.py`
- Modify: `common/components/__init__.py` (export `custom_element`, `register_element`)
- Test: `tests/test_custom_elements.py`
- [ ] **Step 1: Write the failing tests**
Create `tests/test_custom_elements.py`:
```python
import unittest
from typing import TypedDict
from common.components import custom_element, render
from common.components.custom_elements import (
ElementSpec,
_ts_for_spec,
register_element,
)
class SampleProps(TypedDict):
game_id: int
status: str
is_on: bool
class CustomElementBuilderTest(unittest.TestCase):
def test_serializes_props_to_kebab_attributes(self):
html = render(
custom_element(
"x-sample", {"game_id": 3, "status": "f"}, children=["hi"]
)
)
self.assertIn("<x-sample", html)
self.assertIn('game-id="3"', html)
self.assertIn('status="f"', html)
self.assertIn(">hi</x-sample>", html)
def test_declares_compiled_module_media(self):
from common.components import collect_media
node = custom_element("x-sample", {"game_id": 3})
self.assertEqual(collect_media(node).js, ("dist/elements/x-sample.js",))
class CodegenTest(unittest.TestCase):
def test_emits_interface_and_reader(self):
spec = ElementSpec("x-sample", "XSample", SampleProps)
ts = _ts_for_spec(spec)
self.assertIn("export interface XSampleProps {", ts)
self.assertIn("gameId: number;", ts)
self.assertIn("status: string;", ts)
self.assertIn("isOn: boolean;", ts)
self.assertIn(
"export function readXSampleProps(el: HTMLElement): XSampleProps", ts
)
self.assertIn('Number(el.getAttribute("game-id"))', ts)
self.assertIn('el.getAttribute("status") ?? ""', ts)
self.assertIn('el.getAttribute("is-on") === "true"', ts)
class RegistryTest(unittest.TestCase):
def test_register_adds_spec(self):
from common.components.custom_elements import ELEMENT_REGISTRY
before = len(ELEMENT_REGISTRY)
register_element("x-reg-test", "XRegTest", SampleProps)
self.assertEqual(len(ELEMENT_REGISTRY), before + 1)
self.assertEqual(ELEMENT_REGISTRY[-1].tag, "x-reg-test")
```
- [ ] **Step 2: Run to verify failure**
Run: `uv run pytest tests/test_custom_elements.py -v`
Expected: FAIL — `ImportError: cannot import name 'custom_element'`.
- [ ] **Step 3: Implement the registry, builder, and codegen helpers**
Create `common/components/custom_elements.py`:
```python
"""Custom-element builder, registry, and TypeScript codegen.
A custom element is a light-DOM Web Component: the Python builder emits a
semantic tag whose typed props become kebab-case attributes and whose behavior
lives in a compiled TS module (loaded via Media). One ``TypedDict`` per element
is the single source of truth for the server<->client contract; ``gen_element_types``
turns each registered spec into a TS interface + attribute reader so drift fails
``tsc``.
"""
from dataclasses import dataclass
from typing import Mapping, get_type_hints
from common.components.core import Children, Element, HTMLAttribute, Media, Node
@dataclass(frozen=True)
class ElementSpec:
tag: str # e.g. "game-status-selector"
ts_name: str # e.g. "GameStatusSelector"
props: type # a TypedDict subclass
ELEMENT_REGISTRY: list[ElementSpec] = []
def register_element(tag: str, ts_name: str, props: type) -> None:
"""Register an element so codegen can emit its TS contract."""
ELEMENT_REGISTRY.append(ElementSpec(tag, ts_name, props))
def _kebab(name: str) -> str:
return name.replace("_", "-")
def custom_element(
tag: str, props: Mapping[str, object], *, children: Children = None
) -> Node:
"""Emit ``<tag kebab-attrs>children</tag>`` and declare its compiled module.
The module path mirrors the source layout: ``ts/elements/<tag>.ts`` compiles
to ``dist/elements/<tag>.js``, which ``Media`` loads via ``ModuleScript``."""
attributes: list[HTMLAttribute] = [
(_kebab(key), value) for key, value in props.items() # type: ignore[misc]
]
return Element(tag, attributes, children).with_media(
Media(js=(f"dist/elements/{tag}.js",))
)
# ── Codegen ──────────────────────────────────────────────────────────────────
_TYPE_MAP = {int: "number", float: "number", str: "string", bool: "boolean"}
def _camel(name: str) -> str:
head, *tail = name.split("_")
return head + "".join(part.title() for part in tail)
def _reader_expr(name: str, python_type: type) -> str:
attr = _kebab(name)
if python_type in (int, float):
return f'Number(el.getAttribute("{attr}"))'
if python_type is bool:
return f'el.getAttribute("{attr}") === "true"'
return f'el.getAttribute("{attr}") ?? ""'
def _ts_for_spec(spec: ElementSpec) -> str:
hints = get_type_hints(spec.props)
interface_lines = "\n".join(
f" {_camel(name)}: {_TYPE_MAP[python_type]};"
for name, python_type in hints.items()
)
reader_lines = "\n".join(
f" {_camel(name)}: {_reader_expr(name, python_type)},"
for name, python_type in hints.items()
)
return (
f"export interface {spec.ts_name}Props {{\n{interface_lines}\n}}\n\n"
f"export function read{spec.ts_name}Props(el: HTMLElement): "
f"{spec.ts_name}Props {{\n return {{\n{reader_lines}\n }};\n}}"
)
def render_props_module() -> str:
"""The full ``ts/generated/props.ts`` content for every registered element."""
header = "// GENERATED by `manage.py gen_element_types` — do not edit.\n"
blocks = [_ts_for_spec(spec) for spec in ELEMENT_REGISTRY]
return header + "\n" + "\n\n".join(blocks) + "\n"
```
- [ ] **Step 4: Export the public helpers**
In `common/components/__init__.py`, add to the imports and `__all__`:
```python
from common.components.custom_elements import custom_element, register_element
```
and add `"custom_element"`, `"register_element"` to `__all__`.
- [ ] **Step 5: Run to verify pass**
Run: `uv run pytest tests/test_custom_elements.py -v`
Expected: PASS.
- [ ] **Step 6: Create the codegen management command**
Create `games/management/commands/gen_element_types.py`:
```python
"""Write ts/generated/props.ts from the registered custom-element specs."""
from pathlib import Path
from django.conf import settings
from django.core.management.base import BaseCommand
# Importing the components package triggers element registration at import time.
import common.components # noqa: F401
from common.components.custom_elements import render_props_module
class Command(BaseCommand):
help = "Generate ts/generated/props.ts from registered custom elements."
def handle(self, *args, **options) -> None:
output_dir = Path(settings.BASE_DIR) / "ts" / "generated"
output_dir.mkdir(parents=True, exist_ok=True)
target = output_dir / "props.ts"
target.write_text(render_props_module(), encoding="utf-8")
self.stdout.write(self.style.SUCCESS(f"Wrote {target}"))
```
- [ ] **Step 7: Verify the command runs**
Run: `uv run python manage.py gen_element_types`
Expected: prints `Wrote .../ts/generated/props.ts`; the file exists (only the header so far, since no elements are registered yet).
- [ ] **Step 8: Lint + format + commit**
```bash
uv run ruff check common/ games/ && uv run ruff format common/components/custom_elements.py games/management/commands/gen_element_types.py
git add common/components/custom_elements.py common/components/__init__.py games/management/commands/gen_element_types.py tests/test_custom_elements.py
git commit -m "Custom-element registry, builder, and TS codegen"
```
---
## Task 4: Shared dropdown behavior + GameStatusSelector element
**Files:**
- Create: `ts/elements/dropdown.ts`, `ts/elements/game-status-selector.ts`
- Modify: `common/components/custom_elements.py` (add `GameStatusSelectorProps` + registration), `common/components/domain.py` (rewrite `GameStatusSelector`), `common/components/__init__.py`
- Test: `tests/test_custom_elements.py`, `e2e/test_custom_elements_e2e.py`
- [ ] **Step 1: Write the shared dropdown TS helper**
Create `ts/elements/dropdown.ts`:
```typescript
export interface DropdownConfig {
patchUrl: string;
bodyKey: string; // server field name, e.g. "status" or "device_id"
event: string; // dispatched on document.body after a successful PATCH
csrf: string;
numericValue?: boolean; // parse the option value as a number
}
// Wires a light-DOM value-selector dropdown that lives inside `host`.
// Markup hooks (rendered server-side): [data-toggle], [data-menu],
// [data-label], and one or more [data-option][data-value].
export function initDropdown(host: HTMLElement, config: DropdownConfig): void {
const toggle = host.querySelector<HTMLElement>("[data-toggle]");
const menu = host.querySelector<HTMLElement>("[data-menu]");
const label = host.querySelector<HTMLElement>("[data-label]");
if (!toggle || !menu || !label) return;
const close = () => {
menu.hidden = true;
};
toggle.addEventListener("click", (event) => {
event.stopPropagation();
menu.hidden = !menu.hidden;
});
document.addEventListener("click", (event) => {
if (!host.contains(event.target as Node)) close();
});
host.querySelectorAll<HTMLElement>("[data-option]").forEach((option) => {
option.addEventListener("click", (event) => {
event.preventDefault();
event.stopPropagation();
const raw = option.dataset.value ?? "";
label.innerHTML = option.innerHTML;
close();
const body: Record<string, unknown> = {
[config.bodyKey]: config.numericValue ? Number(raw) : raw,
};
window
.fetchWithHtmxTriggers(config.patchUrl, {
method: "PATCH",
headers: { "Content-Type": "application/json", "X-CSRFToken": config.csrf },
body: JSON.stringify(body),
})
.then(() => document.body.dispatchEvent(new CustomEvent(config.event)))
.catch(() => console.error("Failed to update", config.patchUrl));
});
});
}
```
- [ ] **Step 2: Register `GameStatusSelectorProps`**
In `common/components/custom_elements.py`, at the bottom add:
```python
from typing import TypedDict
class GameStatusSelectorProps(TypedDict):
game_id: int
status: str
csrf: str
register_element("game-status-selector", "GameStatusSelector", GameStatusSelectorProps)
```
- [ ] **Step 3: Rewrite `GameStatusSelector` (Python) htpy-style**
In `common/components/domain.py`, replace the entire `GameStatusSelector` function with:
```python
def GameStatusSelector(game, game_statuses, csrf_token: str) -> Node:
"""Light-DOM custom element; behavior in ts/elements/game-status-selector.ts."""
from common.components import custom_element
from common.components.custom_elements import GameStatusSelectorProps
from common.components.primitives import Button, Div, Icon, Span, Ul, Li
_MENU = (
"absolute top-[105%] left-0 w-full whitespace-nowrap z-10 text-sm "
"font-medium bg-gray-800/20 backdrop-blur-lg rounded-md rounded-t-none "
"border border-gray-200 dark:border-gray-700"
)
options = [
Li()[
Element(
"button",
[("type", "button"), ("data-option", ""), ("data-value", str(value))],
GameStatus(status=value, children=[label], display="flex"),
)
]
for value, label in game_statuses
]
current_label = Span(data_label="")[
GameStatus(
status=game.status,
children=[game.get_status_display()],
display="flex",
)
]
toggle = Element(
"button",
[("type", "button"), ("data-toggle", ""), ("class", "px-4 py-2")],
[current_label, Icon("arrowdown")],
)
menu = Div(data_menu="", hidden=True, class_=_MENU)[Ul()[*options]]
dropdown = Div(data_dropdown="", class_="inline-flex rounded-md shadow-2xs relative")[
toggle, menu
]
return custom_element(
"game-status-selector",
GameStatusSelectorProps(game_id=game.id, status=game.status, csrf=csrf_token),
children=[Div(class_="flex gap-2 items-center")[dropdown]],
)
```
(Delete `_dropdown_button_html` later, in Task 5, once `SessionDeviceSelector` no longer needs it.)
- [ ] **Step 4: Write the GameStatusSelector element**
Create `ts/elements/game-status-selector.ts`:
```typescript
import { readGameStatusSelectorProps } from "../generated/props.js";
import { initDropdown } from "./dropdown.js";
class GameStatusSelectorElement extends HTMLElement {
connectedCallback(): void {
const props = readGameStatusSelectorProps(this);
initDropdown(this, {
patchUrl: `/api/games/${props.gameId}/status`,
bodyKey: "status",
event: "status-changed",
csrf: props.csrf,
});
}
}
customElements.define("game-status-selector", GameStatusSelectorElement);
```
- [ ] **Step 5: Codegen + compile**
Run: `make ts`
Expected: writes `ts/generated/props.ts` (now containing `GameStatusSelectorProps`), then compiles to `games/static/js/dist/elements/game-status-selector.js`, `dist/elements/dropdown.js`, `dist/generated/props.js` with no type errors.
- [ ] **Step 6: Write the Python render test**
Add to `tests/test_custom_elements.py`:
```python
class GameStatusSelectorRenderTest(unittest.TestCase):
def test_emits_tag_props_and_media(self):
import django
django.setup()
from types import SimpleNamespace
from common.components import GameStatusSelector, collect_media, render
game = SimpleNamespace(id=7, status="f", get_status_display=lambda: "Finished")
node = GameStatusSelector(game, [("u", "Unplayed"), ("f", "Finished")], "tok")
html = render(node)
self.assertIn("<game-status-selector", html)
self.assertIn('game-id="7"', html)
self.assertIn('status="f"', html)
self.assertIn('csrf="tok"', html)
self.assertIn("data-option", html)
self.assertIn('data-value="u"', html)
self.assertNotIn("x-data", html) # no Alpine left
self.assertIn("dist/elements/game-status-selector.js", collect_media(node).js)
```
- [ ] **Step 7: Run Python tests**
Run: `uv run pytest tests/test_custom_elements.py tests/test_rendered_pages.py -q`
Expected: PASS (the games list page now renders `<game-status-selector>`).
- [ ] **Step 8: Write the e2e test**
Create `e2e/test_custom_elements_e2e.py`:
```python
import pytest
from django.urls import reverse
from playwright.sync_api import Page, expect
@pytest.fixture
def authenticated_page(live_server, page: Page, django_user_model) -> Page:
django_user_model.objects.create_user(username="tester", password="secret123")
page.goto(f"{live_server.url}{reverse('login')}")
page.fill('input[name="username"]', "tester")
page.fill('input[name="password"]', "secret123")
page.click('input[type="submit"]')
page.wait_for_url(f"{live_server.url}/tracker**")
return page
@pytest.mark.django_db
def test_game_status_selector_opens_and_patches(
authenticated_page: Page, live_server, django_user_model
):
from games.models import Game, Platform
platform = Platform.objects.create(name="PC", icon="pc")
game = Game.objects.create(name="Test Game", platform=platform, status="u")
page = authenticated_page
page.goto(f"{live_server.url}{reverse('games:list_games')}")
host = page.locator("game-status-selector").first
expect(host).to_be_attached()
# Menu hidden until toggled.
host.locator("[data-toggle]").click()
expect(host.locator("[data-menu]")).to_be_visible()
# Selecting Finished PATCHes and updates the label.
host.locator('[data-option][data-value="f"]').click()
game.refresh_from_db()
assert game.status == "f"
```
- [ ] **Step 9: Run the e2e test**
Run: `uv run pytest e2e/test_custom_elements_e2e.py::test_game_status_selector_opens_and_patches -v`
Expected: PASS (real Chromium upgrades the element, `connectedCallback` wires it).
- [ ] **Step 10: Lint, format, commit**
```bash
uv run ruff check common/ games/ && uv run ruff format common/components/domain.py common/components/custom_elements.py tests/test_custom_elements.py e2e/test_custom_elements_e2e.py
git add common/components/domain.py common/components/custom_elements.py common/components/__init__.py ts/elements/dropdown.ts ts/elements/game-status-selector.ts tests/test_custom_elements.py e2e/test_custom_elements_e2e.py
git commit -m "GameStatusSelector: custom element + typed contract (retire Alpine)"
```
---
## Task 5: SessionDeviceSelector element
**Files:**
- Create: `ts/elements/session-device-selector.ts`
- Modify: `common/components/custom_elements.py`, `common/components/domain.py` (rewrite `SessionDeviceSelector`, delete `_dropdown_button_html`)
- Test: `tests/test_custom_elements.py`, `e2e/test_custom_elements_e2e.py`
- [ ] **Step 1: Register `SessionDeviceSelectorProps`**
In `common/components/custom_elements.py` add:
```python
class SessionDeviceSelectorProps(TypedDict):
session_id: int
csrf: str
register_element(
"session-device-selector", "SessionDeviceSelector", SessionDeviceSelectorProps
)
```
- [ ] **Step 2: Rewrite `SessionDeviceSelector` (Python) htpy-style**
In `common/components/domain.py`, replace the entire `SessionDeviceSelector` function with:
```python
def SessionDeviceSelector(session, session_devices, csrf_token: str) -> Node:
"""Light-DOM custom element; behavior in ts/elements/session-device-selector.ts."""
from common.components import custom_element
from common.components.custom_elements import SessionDeviceSelectorProps
from common.components.primitives import Div, Icon, Li, Span, Ul
_MENU = (
"absolute top-[105%] left-0 w-full whitespace-nowrap z-10 text-sm "
"font-medium bg-gray-800/20 backdrop-blur-lg rounded-md rounded-t-none "
"border border-gray-200 dark:border-gray-700"
)
current_name = session.device.name if session.device else "Unknown"
options = [
Li()[
Element(
"button",
[("type", "button"), ("data-option", ""), ("data-value", str(device.id))],
children=[device.name],
)
]
for device in session_devices
]
toggle = Element(
"button",
[("type", "button"), ("data-toggle", ""), ("class", "px-4 py-2")],
[Span(data_label="")[current_name], Icon("arrowdown")],
)
menu = Div(data_menu="", hidden=True, class_=_MENU)[Ul()[*options]]
dropdown = Div(data_dropdown="", class_="inline-flex rounded-md shadow-2xs relative")[
toggle, menu
]
return custom_element(
"session-device-selector",
SessionDeviceSelectorProps(session_id=session.id, csrf=csrf_token),
children=[Div(class_="flex gap-2 items-center")[dropdown]],
)
```
- [ ] **Step 3: Delete the dead Alpine helper**
In `common/components/domain.py`, delete the now-unused `_dropdown_button_html` function.
- [ ] **Step 4: Write the element**
Create `ts/elements/session-device-selector.ts`:
```typescript
import { readSessionDeviceSelectorProps } from "../generated/props.js";
import { initDropdown } from "./dropdown.js";
class SessionDeviceSelectorElement extends HTMLElement {
connectedCallback(): void {
const props = readSessionDeviceSelectorProps(this);
initDropdown(this, {
patchUrl: `/api/session/${props.sessionId}/device`,
bodyKey: "device_id",
event: "device-changed",
csrf: props.csrf,
numericValue: true,
});
}
}
customElements.define("session-device-selector", SessionDeviceSelectorElement);
```
- [ ] **Step 5: Codegen + compile**
Run: `make ts`
Expected: `props.ts` now includes `SessionDeviceSelectorProps`; compiles clean.
- [ ] **Step 6: Add the Python render test**
Add to `tests/test_custom_elements.py`:
```python
class SessionDeviceSelectorRenderTest(unittest.TestCase):
def test_emits_tag_and_options(self):
import django
django.setup()
from types import SimpleNamespace
from common.components import SessionDeviceSelector, render
session = SimpleNamespace(id=4, device=SimpleNamespace(name="Desktop"))
devices = [SimpleNamespace(id=1, name="Desktop"), SimpleNamespace(id=2, name="Deck")]
html = render(SessionDeviceSelector(session, devices, "tok"))
self.assertIn("<session-device-selector", html)
self.assertIn('session-id="4"', html)
self.assertIn('data-value="2"', html)
self.assertNotIn("x-data", html)
```
- [ ] **Step 7: Add the e2e test**
Add to `e2e/test_custom_elements_e2e.py`:
```python
@pytest.mark.django_db
def test_session_device_selector_patches(authenticated_page: Page, live_server):
from games.models import Device, Game, Platform, Session
platform = Platform.objects.create(name="PC", icon="pc")
game = Game.objects.create(name="Test Game", platform=platform)
desktop = Device.objects.create(name="Desktop")
deck = Device.objects.create(name="Deck")
session = Session.objects.create(
game=game, device=desktop, timestamp_start="2025-01-01 00:00:00+00:00"
)
page = authenticated_page
page.goto(f"{live_server.url}{reverse('games:list_sessions')}")
host = page.locator("session-device-selector").first
expect(host).to_be_attached()
host.locator("[data-toggle]").click()
host.locator(f'[data-option][data-value="{deck.id}"]').click()
session.refresh_from_db()
assert session.device_id == deck.id
```
- [ ] **Step 8: Run tests**
Run: `uv run pytest tests/test_custom_elements.py e2e/test_custom_elements_e2e.py -q`
Expected: PASS.
- [ ] **Step 9: Lint, format, commit**
```bash
uv run ruff check common/ games/ && uv run ruff format common/components/domain.py common/components/custom_elements.py tests/test_custom_elements.py e2e/test_custom_elements_e2e.py
git add common/components/domain.py common/components/custom_elements.py ts/elements/session-device-selector.ts tests/test_custom_elements.py e2e/test_custom_elements_e2e.py
git commit -m "SessionDeviceSelector: custom element; delete Alpine dropdown helper"
```
---
## Task 6: play-event-row element
**Files:**
- Create: `ts/elements/play-event-row.ts`
- Modify: `common/components/custom_elements.py`, `games/views/game.py` (rewrite `_played_row`, delete `_PLAYED_ROW_TEMPLATE`)
- Test: `tests/test_custom_elements.py`, `e2e/test_custom_elements_e2e.py`
- [ ] **Step 1: Register `PlayEventRowProps`**
In `common/components/custom_elements.py` add:
```python
class PlayEventRowProps(TypedDict):
game_id: int
csrf: str
api_create_url: str
register_element("play-event-row", "PlayEventRow", PlayEventRowProps)
```
- [ ] **Step 2: Rewrite `_played_row` htpy-style + delete the template**
In `games/views/game.py`, delete the `_PLAYED_ROW_TEMPLATE` string constant entirely, and replace the `_played_row` function with:
```python
def _played_row(game: Game, request: HttpRequest) -> Node:
"""'Played N times' control as a custom element (ts/elements/play-event-row.ts)."""
from common.components import custom_element
from common.components.custom_elements import PlayEventRowProps
played = game.playevents.count()
add_pe = reverse("games:add_playevent")
add_pe_for_game = reverse("games:add_playevent_for_game", args=[game.id])
_BTN = (
"px-4 py-2 text-sm font-medium text-gray-900 bg-white border border-gray-200 "
"hover:bg-gray-100 hover:text-blue-700 dark:bg-gray-800 dark:border-gray-700 "
"dark:text-white dark:hover:bg-gray-700 hover:cursor-pointer"
)
_MENU = (
"absolute top-full -left-px w-auto whitespace-nowrap z-10 text-sm font-medium "
"bg-gray-800/20 backdrop-blur-lg rounded-md rounded-tl-none border "
"border-gray-200 dark:border-gray-700"
)
count_button = A(href=add_pe)[
Element(
"button",
[("type", "button"), ("class", _BTN + " rounded-s-lg")],
[Span(data_count="")[str(played)], " times"],
)
]
menu = Div(data_menu="", hidden=True, class_=_MENU)[
Ul()[
Li(attributes=[("class", "px-4 py-2")])[
A(href=add_pe_for_game)["Add playthrough..."]
],
Li(attributes=[("class", "px-4 py-2 cursor-pointer")], children=None)[
Element(
"button",
[("type", "button"), ("data-add-play", "")],
children=["Played times +1"],
)
],
]
]
toggle = Element(
"button",
[("type", "button"), ("data-toggle", ""), ("class", _BTN + " rounded-e-lg relative")],
[Icon("arrowdown"), menu],
)
group = Div(class_="inline-flex rounded-md shadow-2xs relative")[count_button, toggle]
return custom_element(
"play-event-row",
PlayEventRowProps(
game_id=game.id,
csrf=get_token(request),
api_create_url=reverse("api-1.0.0:create_playevent"),
),
children=[Div(class_="flex gap-2 items-center")[Span(class_="uppercase")["Played"], group]],
)
```
Ensure `A`, `Div`, `Span`, `Ul`, `Li`, `Icon`, `Element` are imported in `games/views/game.py` (most already are; add any missing from `common.components`).
- [ ] **Step 3: Write the element**
Create `ts/elements/play-event-row.ts`:
```typescript
import { readPlayEventRowProps } from "../generated/props.js";
class PlayEventRowElement extends HTMLElement {
connectedCallback(): void {
const props = readPlayEventRowProps(this);
const toggle = this.querySelector<HTMLElement>("[data-toggle]");
const menu = this.querySelector<HTMLElement>("[data-menu]");
const count = this.querySelector<HTMLElement>("[data-count]");
const addPlay = this.querySelector<HTMLElement>("[data-add-play]");
if (!toggle || !menu) return;
const close = () => {
menu.hidden = true;
};
toggle.addEventListener("click", (event) => {
event.stopPropagation();
menu.hidden = !menu.hidden;
});
document.addEventListener("click", (event) => {
if (!this.contains(event.target as Node)) close();
});
addPlay?.addEventListener("click", (event) => {
event.preventDefault();
event.stopPropagation();
if (count) count.textContent = String(Number(count.textContent) + 1);
close();
window
.fetchWithHtmxTriggers(props.apiCreateUrl, {
method: "POST",
headers: { "Content-Type": "application/json", "X-CSRFToken": props.csrf },
body: JSON.stringify({ game_id: props.gameId }),
})
.catch(() => {
if (count) count.textContent = String(Number(count.textContent) - 1);
console.error("Failed to record play");
});
});
}
}
customElements.define("play-event-row", PlayEventRowElement);
```
- [ ] **Step 4: Codegen + compile**
Run: `make ts`
Expected: clean compile; `props.ts` includes `PlayEventRowProps`.
- [ ] **Step 5: Assert via the rendered game page (integration)**
`_played_row` calls `game.playevents.count()`, which needs a saved row, so the
contract is asserted through the rendered detail page rather than a unit stub.
Add to `tests/test_rendered_pages.py` (inside `RenderedPagesTest`):
```python
def test_view_game_uses_play_event_row_element(self):
game = Game.objects.create(name="Played Game", platform=self.platform)
html = self.get("games:view_game", args=[game.id]).content.decode()
self.assertIn("<play-event-row", html)
self.assertIn('game-id="', html)
self.assertNotIn("@@", html) # token-replace hack gone
self.assertNotIn("x-data", html) # Alpine gone from this control
```
(`RenderedPagesTest.setUp` already creates `self.platform`; if not, add
`self.platform = Platform.objects.create(name="PC", icon="pc")` there. Ensure
`Game` and `Platform` are imported in the test module.)
- [ ] **Step 6: Add the e2e test**
Add to `e2e/test_custom_elements_e2e.py`:
```python
@pytest.mark.django_db
def test_play_event_row_increments(authenticated_page: Page, live_server):
from games.models import Game, Platform
platform = Platform.objects.create(name="PC", icon="pc")
game = Game.objects.create(name="Test Game", platform=platform)
page = authenticated_page
page.goto(f"{live_server.url}{reverse('games:view_game', args=[game.id])}")
host = page.locator("play-event-row").first
expect(host).to_be_attached()
host.locator("[data-toggle]").click()
host.locator("[data-add-play]").click()
expect(host.locator("[data-count]")).to_have_text("1")
assert game.playevents.count() == 1
```
- [ ] **Step 7: Run tests**
Run: `uv run pytest tests/test_rendered_pages.py e2e/test_custom_elements_e2e.py -q`
Expected: PASS.
- [ ] **Step 8: Lint, format, commit**
```bash
uv run ruff check common/ games/ && uv run ruff format games/views/game.py common/components/custom_elements.py
git add games/views/game.py common/components/custom_elements.py ts/elements/play-event-row.ts tests/test_rendered_pages.py e2e/test_custom_elements_e2e.py
git commit -m "played-row: custom element; delete @@TOKEN@@ template + Alpine"
```
---
## Task 7: CI, Docker, and docs
**Files:**
- Modify: `Dockerfile`
- Modify: `CLAUDE.md`
- [ ] **Step 1: Build TS in the Docker image**
In `Dockerfile`, find the stage that runs the Tailwind/`make css` build (the stage with pnpm available) and add a TS build right after it:
```dockerfile
RUN make ts
```
(If the image builds CSS via `pnpm tailwindcss ...` directly rather than `make css`, add `RUN uv run python manage.py gen_element_types && pnpm exec tsc` instead, ensuring `uv`, `pnpm`, and the source tree are present in that stage.)
- [ ] **Step 2: Verify the full check gate passes**
Run: `make check`
Expected: lint clean, format clean, `tsc --noEmit` clean (drift gate), all Python tests pass.
- [ ] **Step 3: Run the whole suite including e2e**
Run: `make ts && uv run pytest`
Expected: all unit + e2e tests pass (the `dist/` modules must be compiled first so the live_server serves them).
- [ ] **Step 4: Document the pattern in CLAUDE.md**
In `CLAUDE.md`, under the component-system / frontend section, add:
```markdown
**Interactive components are custom elements, not inline JS.** A component that
needs behavior emits a semantic tag via `custom_element("tag", Props(...))`
(light DOM, server-rendered inner markup built with the node builders). Behavior
lives in `ts/elements/<tag>.ts` (TypeScript, vanilla DOM, `customElements.define`),
compiled per-module by `tsc` to `games/static/js/dist/` (build-only, gitignored;
run `make ts`). The server↔client contract is one Python `TypedDict` per element
registered via `register_element(...)`; `manage.py gen_element_types` codegens
`ts/generated/props.ts` (interface + attribute reader), so renaming a prop fails
`tsc` (`make ts-check`). Do NOT author HTML/JS as Python f-strings, and do NOT
add new inline Alpine `x-data` blobs — Alpine remains only for trivial existing
toggles. htpy-style markup: `Div(class_="x", hx_get="/y")[child1, child2]`.
```
- [ ] **Step 5: Commit**
```bash
git add Dockerfile CLAUDE.md
git commit -m "Build TS in Docker; document the custom-element authoring pattern"
```
---
## Self-review notes
- **Spec coverage:** Layer 1 (htpy sugar) = Task 2; Layer 2 (custom elements) = Tasks 46; Layer 3 (typed contract/codegen) = Task 3; toolchain = Task 1; CI/Docker/docs = Task 7. All three exemplars (GameStatusSelector, SessionDeviceSelector, played-row) have a dedicated task. Alpine retired in each conversion; existing `.js` untouched (only `ts/` compiled). Build-only/gitignored output set in Task 1.
- **Known soft spot:** Task 6 Step 5's unit test is awkward because `game.playevents.count()` needs a DB row; the real assertion is the integration page test in Step 6 and the e2e in Step 7. The executor should rely on those two and keep/trim the unit stub accordingly.
- **Type/name consistency:** `custom_element` / `register_element` / `render_props_module` / `_ts_for_spec` / `ELEMENT_REGISTRY` / `ElementSpec` are used consistently. TS readers are named `read<TsName>Props` and imported from `../generated/props.js`; `initDropdown` shared by the two selectors; the data-attribute hooks (`data-toggle`, `data-menu`, `data-label`/`data-count`, `data-option`/`data-value`, `data-add-play`) match between each Python builder and its TS.
- **Media path:** `custom_element` declares `Media(js=("dist/elements/<tag>.js",))`; `ModuleScript` resolves it as `static("js/dist/elements/<tag>.js")` — matches the `outDir`.