diff --git a/CLAUDE.md b/CLAUDE.md
index 298af56..6456fa5 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -35,6 +35,7 @@ games/ — Django app: models, views, templates, forms, signals, tasks,
common/ — Shared utilities: time formatting, component system, criteria, layout, icons
timetracker/ — Django project: settings, URL root, ASGI/WSGI
tests/ — Pytest tests
+e2e/ — Playwright browser tests (run via `make test-e2e`)
contrib/ — One-off scripts (exchange rate import)
docs/ — Additional documentation
```
@@ -57,12 +58,12 @@ docs/ — Additional documentation
### Key patterns
-**Layout system** (`common/layout.py`): Views call `render_page(request, content, title=...)` instead of Django's `render()`. This assembles a full HTML document via `Page()` — analogous to FastHTML's `fast_app()`. `Page()` handles the `
`, navbar, toast container, JS includes, and FOUC-prevention script. The navbar shows today's playtime and last-7-days playtime from the `model_counts` context processor.
+**Layout system** (`common/layout.py`): Views call `render_page(request, content, title=...)` instead of Django's `render()`. This assembles a full HTML document via `Page()` — analogous to FastHTML's `fast_app()`. `Page()` handles the ``, navbar, toast container, FOUC-prevention script, and **JS includes**: it calls `collect_media(content)` to gather every component's declared `Media` and emits the `')
+def StaticScript(filename: str) -> SafeText:
+ """A plain (classic, non-module) `')
+
+
+# Media for the Flowbite-datepicker year picker (vendored UMD bundle). Declared
+# on the YearPicker node so Page() loads it wherever a YearPicker appears.
+_YEAR_PICKER_MEDIA = Media(js_external=("datepicker.umd.js",))
+
+
def YearPicker(
year: int | None = None,
available_years: tuple[int, ...] = (),
url_template: str = "",
-) -> SafeText:
+) -> Node:
"""A Flowbite-datepicker year picker.
`year` is the selected year, or ``None`` for the all-time view (the empty
@@ -567,8 +554,8 @@ def YearPicker(
placeholder, substituted with the chosen year in JS (keeps this component
decoupled from the project's URL names).
- The Flowbite-datepicker UMD bundle is *not* loaded here — the view hoists it
- via ``render_page(scripts=...)``.
+ The Flowbite-datepicker UMD bundle is declared as ``media`` on the returned
+ node, so ``Page()`` loads it automatically.
"""
label = str(year) if year is not None else "Choose a year"
selected = str(year) if year is not None else ""
@@ -579,7 +566,8 @@ def YearPicker(
"hover:bg-neutral-tertiary-medium focus:ring-4 focus:ring-brand-medium"
)
years_csv = ",".join(str(y) for y in available_years)
- return mark_safe(f"""
{{
picker.update();
}}
}});
-""")
+""",
+ media=_YEAR_PICKER_MEDIA,
+ )
def AddForm(
form,
*,
request,
- fields: SafeText | str | None = None,
- additional_row: SafeText | str = "",
+ fields: Node | SafeText | str | None = None,
+ additional_row: Node | SafeText | str = "",
submit_class: str = "mt-3",
-) -> SafeText:
+) -> Node:
"""Page body for the generic add/edit form (Python equivalent of add.html).
`fields` overrides the default ``form.as_div()`` field markup (used by the
@@ -651,11 +641,11 @@ def AddForm(
is applied to the main Submit button (the session form passes "" to match
its original markup).
"""
- field_markup = fields if fields is not None else mark_safe(form.as_div())
+ field_markup = fields if fields is not None else Safe(form.as_div())
submit_attrs = [("class", submit_class)] if submit_class else []
- inner_form = Component(
- tag_name="form",
+ inner_form = Element(
+ "form",
attributes=[("method", "post"), ("enctype", "multipart/form-data")],
children=[
CsrfInput(request),
@@ -683,10 +673,10 @@ def SearchField(
search_string: str = "",
id: str = "search_string",
placeholder: str = "Search",
-) -> SafeText:
+) -> Element:
"""Generate a search form with icon, input field, and submit button."""
- return Component(
- tag_name="form",
+ return Element(
+ "form",
attributes=[("class", "max-w-md")],
children=[
Label(
@@ -699,7 +689,7 @@ def SearchField(
Div(
attributes=[("class", "relative")],
children=[
- mark_safe(
+ Safe(
''
'
'
@@ -724,8 +714,8 @@ def SearchField(
("required", ""),
],
),
- Component(
- tag_name="button",
+ Element(
+ "button",
attributes=[
("type", "submit"),
(
@@ -746,13 +736,13 @@ def SearchField(
def H1(
- children: list[HTMLTag] | HTMLTag | None = None,
+ children: Children = None,
badge: str = "",
-) -> SafeText:
+) -> Element:
"""Heading with optional badge count."""
children = children or []
heading_class = "mb-4 text-3xl font-extrabold leading-none tracking-tight text-gray-900 dark:text-white"
- badge_html = ""
+ badge_html: Node | str = ""
if badge:
heading_class = "flex items-center " + heading_class
@@ -767,21 +757,20 @@ def H1(
children=[badge],
)
- return Component(
- tag_name="h1",
+ return Element(
+ "h1",
attributes=[("class", heading_class)],
- children=(children if isinstance(children, list) else [children])
- + ([badge_html] if badge_html else []),
+ children=as_children(children) + ([badge_html] if badge_html else []),
)
def Modal(
modal_id: str,
- children: list[HTMLTag] | HTMLTag | None = None,
-) -> SafeText:
+ children: Children = None,
+) -> Node:
"""Modal overlay with container. Content (form, buttons) goes in children."""
children = children or []
- outer = Div(
+ return Div(
attributes=[
("id", modal_id),
(
@@ -799,52 +788,24 @@ def Modal(
"shadow-lg/50 rounded-md bg-white dark:bg-gray-900",
),
],
- children=(children if isinstance(children, list) else [children]),
+ children=as_children(children),
),
],
)
- return mark_safe(str(outer))
-
-
-def Td(
- attributes: list[HTMLAttribute] | None = None,
- children: list[HTMLTag] | HTMLTag | None = None,
-) -> SafeText:
- attributes = attributes or []
- children = children or []
- return Component(tag_name="td", attributes=attributes, children=children)
-
-
-def Tr(
- attributes: list[HTMLAttribute] | None = None,
- children: list[HTMLTag] | HTMLTag | None = None,
-) -> SafeText:
- attributes = attributes or []
- children = children or []
- return Component(tag_name="tr", attributes=attributes, children=children)
-
-
-def Th(
- attributes: list[HTMLAttribute] | None = None,
- children: list[HTMLTag] | HTMLTag | None = None,
-) -> SafeText:
- attributes = attributes or []
- children = children or []
- return Component(tag_name="th", attributes=attributes, children=children)
def TableTd(
- children: list[HTMLTag] | HTMLTag | None = None,
-) -> SafeText:
+ children: Children = None,
+) -> Element:
"""Styled table cell."""
children = children or []
return Td(
attributes=[("class", "px-6 py-4 min-w-20-char max-w-20-char")],
- children=children if isinstance(children, list) else [children],
+ children=as_children(children),
)
-def TableRow(data: dict | list | None = None) -> SafeText:
+def TableRow(data: dict | list | None = None) -> Element:
"""Generate a from a row data dict or list.
Dict form: {"row_id": "...", "cell_data": [...], "hx_trigger": ..., ...}
@@ -879,7 +840,7 @@ def TableRow(data: dict | list | None = None) -> SafeText:
if data.get("hx_swap"):
tr_attrs.append(("hx-swap", data["hx_swap"]))
- cell_elements: list[SafeText] = []
+ cell_elements: list[Node] = []
for i, cell in enumerate(cells):
if i == 0:
cell_elements.append(
@@ -903,18 +864,18 @@ def TableRow(data: dict | list | None = None) -> SafeText:
def Icon(
name: str,
- attributes: list[HTMLAttribute] | None = None,
-) -> SafeText:
- return mark_safe(get_icon(name))
+ attributes: Attributes | None = None,
+) -> Node:
+ return Safe(get_icon(name))
def TableHeader(
- children: list[HTMLTag] | HTMLTag | None = None,
-) -> SafeText:
+ children: Children = None,
+) -> Element:
"""Table caption."""
children = children or []
- return Component(
- tag_name="caption",
+ return Element(
+ "caption",
attributes=[
(
"class",
@@ -922,7 +883,7 @@ def TableHeader(
"text-gray-900 bg-white dark:text-white dark:bg-gray-900",
),
],
- children=children if isinstance(children, list) else [children],
+ children=as_children(children),
)
@@ -1001,11 +962,11 @@ def _pagination_nav(page_obj, elided_page_range, request) -> str:
def SimpleTable(
columns: list[str] | None = None,
rows: list | None = None,
- header_action: SafeText | str | None = None,
+ header_action: Child | None = None,
page_obj=None,
elided_page_range=None,
request=None,
-) -> SafeText:
+) -> Node:
"""Paginated table. Python equivalent of the old simple_table.html."""
columns = columns or []
rows = rows or []
@@ -1024,7 +985,7 @@ def SimpleTable(
if page_obj and elided_page_range:
pagination_html = _pagination_nav(page_obj, elided_page_range, request)
- return mark_safe(
+ return Safe(
''
'
'
'
'
@@ -1044,7 +1005,7 @@ def paginated_table_content(
page_obj=None,
elided_page_range=None,
request=None,
-) -> SafeText:
+) -> Node:
"""Standard list-page body: a max-width Div wrapping a SimpleTable.
`data` is the table dict with keys ``columns``, ``rows`` and
diff --git a/common/components/search_select.py b/common/components/search_select.py
index 9755592..a58b41f 100644
--- a/common/components/search_select.py
+++ b/common/components/search_select.py
@@ -21,11 +21,13 @@ user types.
from collections.abc import Callable, Iterable
from typing import TypedDict
-from django.utils.safestring import SafeText
-from common.components.core import Component, HTMLAttribute
+from common.components.core import Attributes, Element, HTMLAttribute, Media, Node
from common.components.primitives import Div, Input, Pill, Span, Template
+# Both comboboxes are wired by search_select.js.
+_SEARCH_SELECT_MEDIA = Media(js=("search_select.js",))
+
class SearchSelectOption(TypedDict):
value: str | int
@@ -141,11 +143,11 @@ def _data_attributes(data: dict[str, str]) -> list[HTMLAttribute]:
return [(f"data-{key}", str(value)) for key, value in data.items()]
-def _hidden_input(name: str, value) -> SafeText:
+def _hidden_input(name: str, value) -> Node:
return Input(type="hidden", attributes=[("name", name), ("value", str(value))])
-def _label_slot(text: str, *, extra_class: str = "") -> SafeText:
+def _label_slot(text: str, *, extra_class: str = "") -> Node:
"""A ```` holding a row/pill's visible label. JS fills this
one node when cloning the shape from a ````, so labels are the only
thing the JS sets — all classes and structure stay server-side."""
@@ -159,7 +161,7 @@ def _label_slot(text: str, *, extra_class: str = "") -> SafeText:
_BLANK_OPTION: SearchSelectOption = {"value": "", "label": "", "data": {}}
-def _option_row(option: SearchSelectOption) -> SafeText:
+def _option_row(option: SearchSelectOption) -> Node:
return Div(
attributes=[
("data-search-select-option", ""),
@@ -174,14 +176,14 @@ def _option_row(option: SearchSelectOption) -> SafeText:
def _combobox_shell(
*,
- container_attributes: list[HTMLAttribute],
- pills: SafeText,
- search_attributes: list[HTMLAttribute],
- options_children: list[SafeText],
+ container_attributes: Attributes,
+ pills: Node,
+ search_attributes: Attributes,
+ options_children: list[Node],
always_visible: bool,
items_visible: int,
- templates: list[SafeText] | None = None,
-) -> SafeText:
+ templates: list[Node] | None = None,
+) -> Node:
"""Assemble the shared, domain-agnostic combobox skeleton.
Every combobox built on top of this shell has the same three regions in the
@@ -213,7 +215,7 @@ def _combobox_shell(
children=[*options_children, no_results],
)
- children: list[SafeText] = [pills, search, options_panel, *(templates or [])]
+ children: list[Node] = [pills, search, options_panel, *(templates or [])]
return Div(attributes=container_attributes, children=children)
@@ -232,7 +234,7 @@ def SearchSelect(
id: str = "",
sync_url: bool = False,
autofocus: bool = False,
-) -> SafeText:
+) -> Node:
"""Render the search-select widget. See module docstring for the contract."""
selected = [_normalize_option(option) for option in (selected or [])]
options = [_normalize_option(option) for option in (options or [])]
@@ -242,7 +244,7 @@ def SearchSelect(
# pill — the committed label shows inside the search box instead, with a
# lone hidden input carrying the value. Both keep the hidden input(s) inside
# `[data-search-select-pills]` so the JS reads/writes values uniformly.
- pills_children: list[SafeText] = []
+ pills_children: list[Node] = []
search_value = ""
if multi_select:
for option in selected:
@@ -283,7 +285,7 @@ def SearchSelect(
# ── Templates the JS clones: a row when results are fetched, a pill when
# multi-select adds chosen items. ──
- templates: list[SafeText] = []
+ templates: list[Node] = []
if search_url:
templates.append(
Template(
@@ -322,12 +324,12 @@ def SearchSelect(
always_visible=always_visible,
items_visible=items_visible,
templates=templates,
- )
+ ).with_media(_SEARCH_SELECT_MEDIA)
-def _filter_remove_button() -> SafeText:
- return Component(
- tag_name="button",
+def _filter_remove_button() -> Node:
+ return Element(
+ "button",
attributes=[
("type", "button"),
("data-pill-remove", ""),
@@ -338,7 +340,7 @@ def _filter_remove_button() -> SafeText:
)
-def _filter_value_pill(option: SearchSelectOption, kind: str) -> SafeText:
+def _filter_value_pill(option: SearchSelectOption, kind: str) -> Node:
"""An include (✓) or exclude (✗) value pill. ``kind`` is "include"/"exclude"."""
symbol = "✓" if kind == "include" else "✗"
css = (
@@ -357,7 +359,7 @@ def _filter_value_pill(option: SearchSelectOption, kind: str) -> SafeText:
)
-def _filter_modifier_pill(modifier_value: str, label: str) -> SafeText:
+def _filter_modifier_pill(modifier_value: str, label: str) -> Node:
"""The lone, sticky modifier pill (e.g. "(Any)"/"(None)")."""
return Span(
attributes=[
@@ -369,9 +371,9 @@ def _filter_modifier_pill(modifier_value: str, label: str) -> SafeText:
)
-def _filter_action_button(action: str, symbol: str, title: str) -> SafeText:
- return Component(
- tag_name="button",
+def _filter_action_button(action: str, symbol: str, title: str) -> Node:
+ return Element(
+ "button",
attributes=[
("type", "button"),
("data-search-select-action", action),
@@ -382,7 +384,7 @@ def _filter_action_button(action: str, symbol: str, title: str) -> SafeText:
)
-def _filter_option_row(value: str | int, label: str) -> SafeText:
+def _filter_option_row(value: str | int, label: str) -> Node:
"""A value row with include (+) and exclude (−) buttons."""
return Div(
attributes=[
@@ -404,7 +406,7 @@ def _filter_option_row(value: str | int, label: str) -> SafeText:
)
-def _filter_modifier_row(modifier_value: str, label: str) -> SafeText:
+def _filter_modifier_row(modifier_value: str, label: str) -> Node:
"""A pinned pseudo-option row. It carries no ``data-search-select-option`` so the text
filter never hides it — modifiers stay visible at the top of the panel."""
return Div(
@@ -432,7 +434,7 @@ def FilterSelect(
placeholder: str = "Search…",
id: str = "",
free_text: bool = False,
-) -> SafeText:
+) -> Node:
"""Include/exclude filter combobox built on the shared ``_combobox_shell``.
Like ``SearchSelect`` but each value row carries +/− buttons that add an
@@ -470,7 +472,7 @@ def FilterSelect(
# pills — but the stored state guarantees they never coexist, so we render
# both channels unconditionally. Non-presence modifiers (INCLUDES_ALL /
# INCLUDES_ONLY) coexist with value pills and render side by side.
- pills_children: list[SafeText] = []
+ pills_children: list[Node] = []
if active_modifier_label:
pills_children.append(_filter_modifier_pill(modifier, active_modifier_label))
for option in included:
@@ -504,7 +506,7 @@ def FilterSelect(
# ── Templates the JS clones: include/exclude pills (added on click), the
# modifier pill (when modifiers exist), and a value row (when fetched). ──
- templates: list[SafeText] = [
+ templates: list[Node] = [
Template(
attributes=[("data-search-select-template", "pill-include")],
children=[_filter_value_pill(_BLANK_OPTION, "include")],
@@ -557,7 +559,7 @@ def FilterSelect(
always_visible=False,
items_visible=items_visible,
templates=templates,
- )
+ ).with_media(_SEARCH_SELECT_MEDIA)
def searchselect_selected(
diff --git a/common/layout.py b/common/layout.py
index a143335..acc96ec 100644
--- a/common/layout.py
+++ b/common/layout.py
@@ -8,6 +8,7 @@ it hoists shared `` content (the `_HEADERS` block, analogous to
"""
import json
+from typing import TYPE_CHECKING
from django.contrib.messages import get_messages
from django.http import HttpRequest, HttpResponse
@@ -19,6 +20,9 @@ from django_htmx.jinja import django_htmx_script
from games.templatetags.version import version, version_date
+if TYPE_CHECKING:
+ from common.components import Node
+
# Static head script that sets the dark/light class before paint (avoids FOUC).
_THEME_FOUC_SCRIPT = """\n'
f" {django_htmx_script(nonce=None)}\n"
f' \n'
- ' \n'
- ' \n'
- ' \n'
+ # Vendored bundles (flowbite 2.4.1, alpinejs/@alpinejs/mask 3.15.12) —
+ # served locally so pages work offline (and in browser tests). The mask
+ # plugin must load before Alpine core; both stay deferred.
+ f' \n'
+ f' \n'
+ f' \n'
f" {_THEME_FOUC_SCRIPT}\n"
" \n"
)
@@ -325,7 +352,7 @@ def Page(
f' {content}
\n'
f' {version()} ({version_date()}) \n'
" \n"
- f" {scripts}\n"
+ f" {all_scripts}\n"
f" {_main_script(mastered)}\n"
" \n"
'
\n'
@@ -339,10 +366,10 @@ def Page(
def render_page(
request: HttpRequest,
- content: SafeText | str,
+ content: "Node | SafeText | str",
*,
title: str = "",
- scripts: SafeText | str = "",
+ scripts: "Node | SafeText | str" = "",
mastered: bool = False,
status: int = 200,
) -> HttpResponse:
diff --git a/docs/superpowers/plans/2026-06-13-html-js-authoring.md b/docs/superpowers/plans/2026-06-13-html-js-authoring.md
new file mode 100644
index 0000000..c6c0b69
--- /dev/null
+++ b/docs/superpowers/plans/2026-06-13-html-js-authoring.md
@@ -0,0 +1,1210 @@
+# 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;
+ }
+}
+```
+
+- [ ] **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"]]),
+ 'hi
',
+ )
+
+ def test_getitem_multiple_children(self):
+ from common.components import Div
+
+ self.assertEqual(render(Div()["a", "b"]), "a\nb
")
+
+ 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("hi ", 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 ``children `` and declare its compiled module.
+
+ The module path mirrors the source layout: ``ts/elements/.ts`` compiles
+ to ``dist/elements/.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("[data-toggle]");
+ const menu = host.querySelector("[data-menu]");
+ const label = host.querySelector("[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("[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 = {
+ [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("`).
+
+- [ ] **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(" 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("[data-toggle]");
+ const menu = this.querySelector("[data-menu]");
+ const count = this.querySelector("[data-count]");
+ const addPlay = this.querySelector("[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(".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 4–6; 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 `readProps` 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/.js",))`; `ModuleScript` resolves it as `static("js/dist/elements/.js")` — matches the `outDir`.
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.
diff --git a/e2e/test_boolean_filter_e2e.py b/e2e/test_boolean_filter_e2e.py
index 1a22677..8264129 100644
--- a/e2e/test_boolean_filter_e2e.py
+++ b/e2e/test_boolean_filter_e2e.py
@@ -21,9 +21,10 @@ def _bar_page(filter_json: str = "") -> str:
Boolean filter E2E
-
-
-
+
+
+
+
{FilterBar(filter_json=filter_json, preset_list_url="/p/l", preset_save_url="/p/s")}
diff --git a/e2e/test_date_filter_e2e.py b/e2e/test_date_filter_e2e.py
index 352691f..a108d69 100644
--- a/e2e/test_date_filter_e2e.py
+++ b/e2e/test_date_filter_e2e.py
@@ -29,9 +29,10 @@ def _bar_page(filter_json: str = "") -> str:
Date filter E2E
-
-
-
+
+
+
+
{PurchaseFilterBar(filter_json=filter_json, preset_list_url="/p/l", preset_save_url="/p/s")}
diff --git a/e2e/test_date_range_picker_e2e.py b/e2e/test_date_range_picker_e2e.py
index 7b48968..bb057ea 100644
--- a/e2e/test_date_range_picker_e2e.py
+++ b/e2e/test_date_range_picker_e2e.py
@@ -28,10 +28,11 @@ def _bar_page(filter_json: str = "") -> str:
Date range picker E2E
-
-
+
+
+
-
+
{PurchaseFilterBar(filter_json=filter_json, preset_list_url="/p/l", preset_save_url="/p/s")}
diff --git a/e2e/test_range_slider_e2e.py b/e2e/test_range_slider_e2e.py
index 3fd080d..8ff5fe9 100644
--- a/e2e/test_range_slider_e2e.py
+++ b/e2e/test_range_slider_e2e.py
@@ -17,9 +17,10 @@ def _bar_page(filter_json: str = "") -> str:
Range Slider E2E
-
-
-
+
+
+
+
{FilterBar(filter_json=filter_json, preset_list_url="/p/l", preset_save_url="/p/s")}
diff --git a/e2e/test_search_select_e2e.py b/e2e/test_search_select_e2e.py
index 210e41d..af7840a 100644
--- a/e2e/test_search_select_e2e.py
+++ b/e2e/test_search_select_e2e.py
@@ -4,35 +4,43 @@ from django.http import HttpResponse
from django.test import override_settings
from common.components import SearchSelect
+
def e2e_test_view(request):
html = f"""
SearchSelect E2E Test
-
+
+
+
- {SearchSelect(
- name="games",
- selected=[{"value": "7", "label": "Game A", "data": {}}],
- options=[
- {"value": "7", "label": "Game A", "data": {}},
- {"value": "8", "label": "Game B", "data": {}},
- ],
- multi_select=False
- )}
+ {
+ SearchSelect(
+ name="games",
+ selected=[{"value": "7", "label": "Game A", "data": {}}],
+ options=[
+ {"value": "7", "label": "Game A", "data": {}},
+ {"value": "8", "label": "Game B", "data": {}},
+ ],
+ multi_select=False,
+ )
+ }
"""
return HttpResponse(html)
+
urlpatterns = [
path("test-search-select/", e2e_test_view),
]
+
@pytest.mark.django_db
@override_settings(ROOT_URLCONF="e2e.test_search_select_e2e")
def test_search_select_backspace_clears_single_select(live_server, page):
@@ -52,9 +60,9 @@ def test_search_select_backspace_clears_single_select(live_server, page):
}""")
search_input = page.locator("input[data-search-select-search]")
-
+
assert search_input.input_value() == "Game A"
-
+
hidden_input = page.locator('input[name="games"]')
assert hidden_input.first.get_attribute("value") == "7"
@@ -85,7 +93,7 @@ def test_search_select_typing_replaces_single_select(live_server, page):
page.goto(live_server.url + "/test-search-select/")
search_input = page.locator("input[data-search-select-search]")
-
+
search_input.focus()
assert search_input.input_value() == ""
diff --git a/e2e/test_string_filter_e2e.py b/e2e/test_string_filter_e2e.py
index 142e3b7..f2de0a3 100644
--- a/e2e/test_string_filter_e2e.py
+++ b/e2e/test_string_filter_e2e.py
@@ -16,9 +16,10 @@ def _bar_page(filter_json: str = "") -> str:
String filter E2E
-
-
-
+
+
+
+
{PlatformFilterBar(filter_json=filter_json, preset_list_url="/p/l", preset_save_url="/p/s")}
diff --git a/e2e/test_widgets_e2e.py b/e2e/test_widgets_e2e.py
new file mode 100644
index 0000000..4e95e88
--- /dev/null
+++ b/e2e/test_widgets_e2e.py
@@ -0,0 +1,135 @@
+"""Browser tests for widget JavaScript (search_select.js, range_slider.js,
+add_purchase.js) and their onSwap() initialization lifecycle.
+
+These run a real Chromium via pytest-playwright against pytest-django's
+``live_server``. All JavaScript under test is served locally from
+``games/static/js/`` (htmx, Alpine, Flowbite and the widget files are
+vendored), so no network access is needed beyond the live server itself.
+
+Browser binaries must be installed once: ``uv run playwright install chromium``.
+"""
+
+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
+
+
+def open_filter_bar(page: Page) -> None:
+ page.click("#filter-bar button:has-text('Filters')")
+ expect(page.locator("#filter-bar-body")).to_be_visible()
+
+
+def status_filter_widget(page: Page):
+ return page.locator('[data-search-select][data-name="status"]')
+
+
+def test_search_select_initializes_on_page_load(authenticated_page: Page, live_server):
+ """Clicking into a FilterSelect search box opens its options panel —
+ proof that onSwap ran the widget initializer on the initial page load."""
+ page = authenticated_page
+ page.goto(f"{live_server.url}{reverse('games:list_games')}")
+ open_filter_bar(page)
+
+ widget = status_filter_widget(page)
+ widget.locator("[data-search-select-search]").click()
+
+ options_panel = widget.locator("[data-search-select-options]")
+ expect(options_panel).to_be_visible()
+ # The pinned "(Any)" modifier pseudo-option is rendered server-side and
+ # only becomes interactable through the initialized panel.
+ expect(
+ options_panel.locator("[data-search-select-modifier-option]").first
+ ).to_have_text("(Any)")
+
+
+def test_search_select_adds_include_pill(authenticated_page: Page, live_server):
+ """Clicking an enum option row adds an include pill (full widget wiring)."""
+ page = authenticated_page
+ page.goto(f"{live_server.url}{reverse('games:list_games')}")
+ open_filter_bar(page)
+
+ widget = status_filter_widget(page)
+ widget.locator("[data-search-select-search]").click()
+ widget.locator('[data-search-select-option][data-label="Finished"]').click()
+
+ pill = widget.locator("[data-search-select-pills] [data-pill]")
+ expect(pill).to_have_count(1)
+ expect(pill).to_contain_text("Finished")
+
+
+def test_range_slider_mode_toggle_fires_exactly_once(
+ authenticated_page: Page, live_server
+):
+ """One click on the mode toggle flips the slider from range to point mode
+ exactly once. Double-bound listeners (the old force-re-init bug) would
+ flip it twice, leaving data-mode unchanged."""
+ page = authenticated_page
+ page.goto(f"{live_server.url}{reverse('games:list_games')}")
+ open_filter_bar(page)
+
+ block = page.locator(".range-slider-block").first
+ slider = block.locator(".range-slider")
+ expect(slider).to_have_attribute("data-mode", "range")
+
+ block.locator(".range-mode-toggle").click()
+ expect(slider).to_have_attribute("data-mode", "point")
+
+
+def test_widgets_initialize_inside_htmx_swapped_content(
+ authenticated_page: Page, live_server
+):
+ """Widgets arriving via an htmx swap initialize without a page load.
+
+ The filter bar is re-fetched and swapped in with htmx.ajax — fresh,
+ uninitialized DOM. The swapped-in FilterSelect must open its panel and the
+ swapped-in slider must toggle exactly once, proving the htmx:load half of
+ onSwap and the once-per-element guard."""
+ page = authenticated_page
+ page.goto(f"{live_server.url}{reverse('games:list_games')}")
+
+ page.evaluate(
+ "htmx.ajax('GET', window.location.pathname, "
+ "{target: '#filter-bar', select: '#filter-bar', swap: 'outerHTML'})"
+ )
+ # The swapped-in bar arrives collapsed again; opening it proves the swap
+ # happened and the fresh DOM is in place.
+ open_filter_bar(page)
+
+ widget = status_filter_widget(page)
+ widget.locator("[data-search-select-search]").click()
+ expect(widget.locator("[data-search-select-options]")).to_be_visible()
+
+ block = page.locator(".range-slider-block").first
+ slider = block.locator(".range-slider")
+ expect(slider).to_have_attribute("data-mode", "range")
+ block.locator(".range-mode-toggle").click()
+ expect(slider).to_have_attribute("data-mode", "point")
+
+
+def test_add_purchase_type_toggles_disabled_fields(
+ authenticated_page: Page, live_server
+):
+ """add_purchase.js disables name/related-purchase while type is "game"
+ and re-enables them for other types."""
+ page = authenticated_page
+ page.goto(f"{live_server.url}{reverse('games:add_purchase')}")
+
+ name_input = page.locator("#id_name")
+ expect(name_input).to_be_disabled()
+
+ page.select_option("#id_type", "dlc")
+ expect(name_input).to_be_enabled()
+
+ page.select_option("#id_type", "game")
+ expect(name_input).to_be_disabled()
diff --git a/games/forms.py b/games/forms.py
index c5fad57..f3b1c67 100644
--- a/games/forms.py
+++ b/games/forms.py
@@ -6,6 +6,7 @@ from common.components import (
DEFAULT_PREFETCH,
SearchSelect,
SearchSelectOption,
+ render,
searchselect_selected,
)
from common.components.primitives import Checkbox
@@ -28,23 +29,32 @@ autofocus_input_widget = forms.TextInput(attrs={"autofocus": "autofocus"})
class PrimitiveCheckboxWidget(forms.CheckboxInput):
"""Adapts Django's CheckboxInput to use our Checkbox component."""
+
def render(self, name, value, attrs=None, renderer=None):
final_attrs = self.build_attrs(self.attrs, attrs)
checked = self.check_test(value)
- attributes = [(k, str(v)) for k, v in final_attrs.items() if k not in ("type", "name", "value", "checked")]
-
+ attributes = [
+ (k, str(v))
+ for k, v in final_attrs.items()
+ if k not in ("type", "name", "value", "checked")
+ ]
+
# Django uses boolean values differently for checkboxes, we omit value if empty
- return str(Checkbox(
- name=name,
- label=None,
- checked=checked,
- value=str(value) if value else "1",
- attributes=attributes
- ))
+ # render() returns a safe string (Django widgets must not be autoescaped).
+ return render(
+ Checkbox(
+ name=name,
+ label=None,
+ checked=checked,
+ value=str(value) if value else "1",
+ attributes=attributes,
+ )
+ )
class PrimitiveWidgetsMixin:
"""Automatically applies primitive custom widgets to native Django form fields."""
+
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
for field_name, field in self.fields.items():
@@ -130,19 +140,22 @@ class SearchSelectWidget(forms.Widget):
def render(self, name, value, attrs=None, renderer=None):
selected = searchselect_selected(self._values(value), self.options_resolver)
autofocus = bool((attrs or {}).get("autofocus"))
- return SearchSelect(
- name=name,
- selected=selected,
- options=None,
- search_url=self.search_url,
- multi_select=self.multi_select,
- items_visible=self.items_visible,
- items_scroll=self.items_scroll,
- prefetch=self.prefetch,
- always_visible=self.always_visible,
- placeholder=self.placeholder,
- id=(attrs or {}).get("id", ""),
- autofocus=autofocus,
+ # Django widgets must return a safe string; the component is a node.
+ return render(
+ SearchSelect(
+ name=name,
+ selected=selected,
+ options=None,
+ search_url=self.search_url,
+ multi_select=self.multi_select,
+ items_visible=self.items_visible,
+ items_scroll=self.items_scroll,
+ prefetch=self.prefetch,
+ always_visible=self.always_visible,
+ placeholder=self.placeholder,
+ id=(attrs or {}).get("id", ""),
+ autofocus=autofocus,
+ )
)
def value_from_datadict(self, data, files, name):
diff --git a/games/static/base.css b/games/static/base.css
index fe7c0fb..da52310 100644
--- a/games/static/base.css
+++ b/games/static/base.css
@@ -1952,9 +1952,17 @@
border-top-left-radius: 0;
border-top-right-radius: 0;
}
+ .rounded-l-lg {
+ border-top-left-radius: var(--radius-lg);
+ border-bottom-left-radius: var(--radius-lg);
+ }
.rounded-tl-none {
border-top-left-radius: 0;
}
+ .rounded-r-lg {
+ border-top-right-radius: var(--radius-lg);
+ border-bottom-right-radius: var(--radius-lg);
+ }
.rounded-tr-md {
border-top-right-radius: var(--radius-md);
}
@@ -2055,6 +2063,12 @@
.border-blue-200 {
border-color: var(--color-blue-200);
}
+ .border-blue-600 {
+ border-color: var(--color-blue-600);
+ }
+ .border-blue-700 {
+ border-color: var(--color-blue-700);
+ }
.border-brand {
border-color: var(--color-brand);
}
@@ -2185,6 +2199,9 @@
.bg-gray-100 {
background-color: var(--color-gray-100);
}
+ .bg-gray-200 {
+ background-color: var(--color-gray-200);
+ }
.bg-gray-400 {
background-color: var(--color-gray-400);
}
@@ -2667,6 +2684,9 @@
.text-blue-500 {
color: var(--color-blue-500);
}
+ .text-blue-600 {
+ color: var(--color-blue-600);
+ }
.text-blue-800 {
color: var(--color-blue-800);
}
@@ -3076,6 +3096,13 @@
}
}
}
+ .hover\:border-gray-300 {
+ &:hover {
+ @media (hover: hover) {
+ border-color: var(--color-gray-300);
+ }
+ }
+ }
.hover\:border-green-600 {
&:hover {
@media (hover: hover) {
@@ -3672,6 +3699,11 @@
border-color: var(--color-amber-700);
}
}
+ .dark\:border-blue-500 {
+ &:is(.dark *) {
+ border-color: var(--color-blue-500);
+ }
+ }
.dark\:border-blue-700 {
&:is(.dark *) {
border-color: var(--color-blue-700);
@@ -3707,6 +3739,11 @@
border-color: var(--color-red-700);
}
}
+ .dark\:border-transparent {
+ &:is(.dark *) {
+ border-color: transparent;
+ }
+ }
.dark\:bg-amber-900 {
&:is(.dark *) {
background-color: var(--color-amber-900);
@@ -4004,6 +4041,15 @@
}
}
}
+ .dark\:hover\:text-blue-500 {
+ &:is(.dark *) {
+ &:hover {
+ @media (hover: hover) {
+ color: var(--color-blue-500);
+ }
+ }
+ }
+ }
.dark\:hover\:text-gray-300 {
&:is(.dark *) {
&:hover {
diff --git a/games/static/js/add_purchase.js b/games/static/js/add_purchase.js
index 248d38a..05a9253 100644
--- a/games/static/js/add_purchase.js
+++ b/games/static/js/add_purchase.js
@@ -1,4 +1,4 @@
-import { getEl, disableElementsWhenTrue } from "./utils.js";
+import { getEl, disableElementsWhenTrue, onSwap } from "./utils.js";
const RELATED_PURCHASE_URL = "/tracker/purchase/related-purchase-by-game";
@@ -38,8 +38,9 @@ function setupElementHandlers() {
]);
}
-document.addEventListener("DOMContentLoaded", setupElementHandlers);
-document.addEventListener("htmx:afterSwap", setupElementHandlers);
-getEl("#id_type").addEventListener("change", () => {
+onSwap("#id_type", (typeSelect) => {
setupElementHandlers();
+ typeSelect.addEventListener("change", () => {
+ setupElementHandlers();
+ });
});
diff --git a/games/static/js/alpine-mask.min.js b/games/static/js/alpine-mask.min.js
new file mode 100644
index 0000000..bc35b09
--- /dev/null
+++ b/games/static/js/alpine-mask.min.js
@@ -0,0 +1 @@
+(()=>{function x(n){n.directive("mask",(e,{value:l,expression:r},{effect:s,evaluateLater:i,cleanup:u})=>{let p=()=>r,f="";queueMicrotask(()=>{if(["function","dynamic"].includes(l)){let o=i(r);s(()=>{p=t=>{let c;return n.dontAutoEvaluateFunctions(()=>{o(d=>{c=typeof d=="function"?d(t):d},{scope:{$input:t,$money:M.bind({el:e})}})}),c},a(e,!1)})}else a(e,!1);if(e._x_model){e._x_model.get()!==e.value&&(e._x_model.get()===null&&e.value===""||e._x_model.set(e.value));let o=e._x_forceModelUpdate;e._x_forceModelUpdate=t=>{t=String(t);let c=p(t);c&&c!=="false"&&(t=m(c,t)),f=t,o(t),e._x_model.set(t)}}});let g=new AbortController;u(()=>{g.abort()}),e.addEventListener("input",()=>a(e),{signal:g.signal,capture:!0}),e.addEventListener("blur",()=>a(e,!1),{signal:g.signal});function a(o,t=!0){let c=o.value,d=p(c);if(!d||d==="false")return!1;if(f.length-o.value.length===1)return f=o.value;let h=()=>{f=o.value=m(d,c)};t?v(o,d,()=>{h()}):h()}}).before("model")}function v(n,e,l){let r=n.selectionStart,s=n.value;l();let i=s.slice(0,r),u=m(e,i).length;n.setSelectionRange(u,u)}var _={9:/[0-9]/,a:/[a-zA-Z]/,"*":/[a-zA-Z0-9]/};function m(n,e){let l=0,r=0,s="";for(;l{let a="",o=0;for(let t=f.length-1;t>=0;t--)f[t]!==g&&(o===3?(a=f[t]+g+a,o=0):a=f[t]+a,o++);return a},i=n.startsWith("-")?"-":"",u=n.replaceAll(new RegExp(`[^0-9\\${e}]`,"g"),""),p=Array.from({length:u.split(e)[0].length}).fill("9").join("");return p=`${i}${s(p,l)}`,r>0&&n.includes(e)&&(p+=`${e}`+"9".repeat(r)),queueMicrotask(()=>{this.el.value.endsWith(e)||this.el.value[this.el.selectionStart-1]===e&&this.el.setSelectionRange(this.el.selectionStart-1,this.el.selectionStart-1)}),p}document.addEventListener("alpine:init",()=>{window.Alpine.plugin(x)});})();
diff --git a/games/static/js/alpine.min.js b/games/static/js/alpine.min.js
new file mode 100644
index 0000000..ab371ef
--- /dev/null
+++ b/games/static/js/alpine.min.js
@@ -0,0 +1,5 @@
+(()=>{var ee=!1,re=!1,W=[],ne=-1,ie=!1;function Ve(t){Dn(t)}function Ue(){ie=!0}function qe(){ie=!1,We()}function Dn(t){W.includes(t)||W.push(t),We()}function Ke(t){let e=W.indexOf(t);e!==-1&&e>ne&&W.splice(e,1)}function We(){if(!re&&!ee){if(ie)return;ee=!0,queueMicrotask(In)}}function In(){ee=!1,re=!0;for(let t=0;tt.effect(e,{scheduler:r=>{oe?Ve(r):r()}}),se=t.raw}function ae(t){R=t}function Ye(t){let e=()=>{};return[n=>{let i=R(n);return t._x_effects||(t._x_effects=new Set,t._x_runEffects=()=>{t._x_effects.forEach(o=>o())}),t._x_effects.add(i),e=()=>{i!==void 0&&(t._x_effects.delete(i),j(i))},i},()=>{e()}]}function St(t,e){let r=!0,n,i,o=R(()=>{let s=t(),a=JSON.stringify(s);if(!r&&(typeof s=="object"||s!==n)){let c=typeof n=="object"?JSON.parse(i):n;queueMicrotask(()=>{e(s,c)})}n=s,i=a,r=!1});return()=>j(o)}async function Xe(t){Ue();try{await t(),await Promise.resolve()}finally{qe()}}var Ze=[],Qe=[],tr=[];function er(t){tr.push(t)}function et(t,e){typeof e=="function"?(t._x_cleanups||(t._x_cleanups=[]),t._x_cleanups.push(e)):(e=t,Qe.push(e))}function At(t){Ze.push(t)}function Ot(t,e,r){t._x_attributeCleanups||(t._x_attributeCleanups={}),t._x_attributeCleanups[e]||(t._x_attributeCleanups[e]=[]),t._x_attributeCleanups[e].push(r)}function ce(t,e){t._x_attributeCleanups&&Object.entries(t._x_attributeCleanups).forEach(([r,n])=>{(e===void 0||e.includes(r))&&(n.forEach(i=>i()),delete t._x_attributeCleanups[r])})}function rr(t){for(t._x_effects?.forEach(Ke);t._x_cleanups?.length;)t._x_cleanups.pop()()}var le=new MutationObserver(pe),ue=!1;function ut(){le.observe(document,{subtree:!0,childList:!0,attributes:!0,attributeOldValue:!0}),ue=!0}function fe(){kn(),le.disconnect(),ue=!1}var lt=[];function kn(){let t=le.takeRecords();lt.push(()=>t.length>0&&pe(t));let e=lt.length;queueMicrotask(()=>{if(lt.length===e)for(;lt.length>0;)lt.shift()()})}function m(t){if(!ue)return t();fe();let e=t();return ut(),e}var de=!1,vt=[];function nr(){de=!0}function ir(){de=!1,pe(vt),vt=[]}function pe(t){if(de){vt=vt.concat(t);return}let e=[],r=new Set,n=new Map,i=new Map;for(let o=0;o{s.nodeType===1&&s._x_marker&&r.add(s)}),t[o].addedNodes.forEach(s=>{if(s.nodeType===1){if(r.has(s)){r.delete(s);return}s._x_marker||e.push(s)}})),t[o].type==="attributes")){let s=t[o].target,a=t[o].attributeName,c=t[o].oldValue,l=()=>{n.has(s)||n.set(s,[]),n.get(s).push({name:a,value:s.getAttribute(a)})},u=()=>{i.has(s)||i.set(s,[]),i.get(s).push(a)};s.hasAttribute(a)&&c===null?l():s.hasAttribute(a)?(u(),l()):u()}i.forEach((o,s)=>{ce(s,o)}),n.forEach((o,s)=>{Ze.forEach(a=>a(s,o))});for(let o of r)e.some(s=>s.contains(o))||Qe.forEach(s=>s(o));for(let o of e)o.isConnected&&tr.forEach(s=>s(o));e=null,r=null,n=null,i=null}function Ct(t){return P(F(t))}function N(t,e,r){return t._x_dataStack=[e,...F(r||t)],()=>{t._x_dataStack=t._x_dataStack.filter(n=>n!==e)}}function F(t){return t._x_dataStack?t._x_dataStack:typeof ShadowRoot=="function"&&t instanceof ShadowRoot?F(t.host):t.parentNode?F(t.parentNode):[]}function P(t){return new Proxy({objects:t},$n)}function or(t,e){return t===null||t===Object.prototype?null:Object.prototype.hasOwnProperty.call(t,e)?t:or(Object.getPrototypeOf(t),e)}var $n={ownKeys({objects:t}){return Array.from(new Set(t.flatMap(e=>Object.keys(e))))},has({objects:t},e){return e==Symbol.unscopables?!1:t.some(r=>Object.prototype.hasOwnProperty.call(r,e)||Reflect.has(r,e))},get({objects:t},e,r){return e=="toJSON"?Ln:Reflect.get(t.find(n=>Reflect.has(n,e))||{},e,r)},set({objects:t},e,r,n){let i;for(let s of t)if(i=or(s,e),i)break;i||(i=t[t.length-1]);let o=Object.getOwnPropertyDescriptor(i,e);return o?.set&&o?.get?o.set.call(n,r)||!0:Reflect.set(i,e,r)}};function Ln(){return Reflect.ownKeys(this).reduce((e,r)=>(e[r]=Reflect.get(this,r),e),{})}function rt(t){let e=n=>typeof n=="object"&&!Array.isArray(n)&&n!==null,r=(n,i="")=>{Object.entries(Object.getOwnPropertyDescriptors(n)).forEach(([o,{value:s,enumerable:a}])=>{if(a===!1||s===void 0||typeof s=="object"&&s!==null&&s.__v_skip)return;let c=i===""?o:`${i}.${o}`;typeof s=="object"&&s!==null&&s._x_interceptor?n[o]=s.initialize(t,c,o):e(s)&&s!==n&&!(s instanceof Element)&&r(s,c)})};return r(t)}function Tt(t,e=()=>{}){let r={initialValue:void 0,_x_interceptor:!0,initialize(n,i,o){return t(this.initialValue,()=>jn(n,i),s=>me(n,i,s),i,o)}};return e(r),n=>{if(typeof n=="object"&&n!==null&&n._x_interceptor){let i=r.initialize.bind(r);r.initialize=(o,s,a)=>{let c=n.initialize(o,s,a);return r.initialValue=c,i(o,s,a)}}else r.initialValue=n;return r}}function jn(t,e){return e.split(".").reduce((r,n)=>r[n],t)}function me(t,e,r){if(typeof e=="string"&&(e=e.split(".")),e.length===1)t[e[0]]=r;else{if(e.length===0)throw error;return t[e[0]]||(t[e[0]]={}),me(t[e[0]],e.slice(1),r)}}var sr={};function x(t,e){sr[t]=e}function H(t,e){let r=Fn(e);return Object.entries(sr).forEach(([n,i])=>{Object.defineProperty(t,`$${n}`,{get(){return i(e,r)},enumerable:!1})}),t}function Fn(t){let[e,r]=he(t),n={interceptor:Tt,...e};return et(t,r),n}function ar(t,e,r,...n){try{return r(...n)}catch(i){nt(i,t,e)}}function nt(...t){return cr(...t)}var cr=Bn;function lr(t){cr=t}function Bn(t,e,r=void 0){t=Object.assign(t??{message:"No error message given."},{el:e,expression:r}),console.warn(`Alpine Expression Error: ${t.message}
+
+${r?'Expression: "'+r+`"
+
+`:""}`,e),setTimeout(()=>{throw t},0)}var it=!0;function Mt(t){let e=it;it=!1;let r=t();return it=e,r}function T(t,e,r={}){let n;return _(t,e)(i=>n=i,r),n}function _(...t){return ur(...t)}var ur=()=>{};function fr(t){ur=t}var dr;function pr(t){dr=t}function mr(t,e){let r={};H(r,t);let n=[r,...F(t)],i=typeof e=="function"?zn(n,e):Vn(n,e,t);return ar.bind(null,t,e,i)}function zn(t,e){return(r=()=>{},{scope:n={},params:i=[],context:o}={})=>{if(!it){ft(r,e,P([n,...t]),i);return}let s=e.apply(P([n,...t]),i);ft(r,s)}}var _e={};function Hn(t,e){if(_e[t])return _e[t];let r=Object.getPrototypeOf(async function(){}).constructor,n=/^[\n\s]*if.*\(.*\)/.test(t.trim())||/^(let|const)\s/.test(t.trim())?`(async()=>{ ${t} })()`:t,o=(()=>{try{let s=new r(["__self","scope"],`with (scope) { __self.result = ${n} }; __self.finished = true; return __self.result;`);return Object.defineProperty(s,"name",{value:`[Alpine] ${t}`}),s}catch(s){return nt(s,e,t),Promise.resolve()}})();return _e[t]=o,o}function Vn(t,e,r){let n=Hn(e,r);return(i=()=>{},{scope:o={},params:s=[],context:a}={})=>{n.result=void 0,n.finished=!1;let c=P([o,...t]);if(typeof n=="function"){let l=n.call(a,n,c).catch(u=>nt(u,r,e));n.finished?(ft(i,n.result,c,s,r),n.result=void 0):l.then(u=>{ft(i,u,c,s,r)}).catch(u=>nt(u,r,e)).finally(()=>n.result=void 0)}}}function ft(t,e,r,n,i){if(it&&typeof e=="function"){let o=e.apply(r,n);o instanceof Promise?o.then(s=>ft(t,s,r,n)).catch(s=>nt(s,i,e)):t(o)}else typeof e=="object"&&e instanceof Promise?e.then(o=>t(o)):t(e)}function hr(...t){return dr(...t)}function _r(t,e,r={}){let n={};H(n,t);let i=[n,...F(t)],o=P([r.scope??{},...i]),s=r.params??[];if(e.includes("await")){let a=Object.getPrototypeOf(async function(){}).constructor,c=/^[\n\s]*if.*\(.*\)/.test(e.trim())||/^(let|const)\s/.test(e.trim())?`(async()=>{ ${e} })()`:e;return new a(["scope"],`with (scope) { let __result = ${c}; return __result }`).call(r.context,o)}else{let a=/^[\n\s]*if.*\(.*\)/.test(e.trim())||/^(let|const)\s/.test(e.trim())?`(()=>{ ${e} })()`:e,l=new Function(["scope"],`with (scope) { let __result = ${a}; return __result }`).call(r.context,o);return typeof l=="function"&&it?l.apply(o,s):l}}var ye="x-";function O(t=""){return ye+t}function gr(t){ye=t}var Rt={};function p(t,e){return Rt[t]=e,{before(r){if(!Rt[r]){console.warn(String.raw`Cannot find directive \`${r}\`. \`${t}\` will use the default order of execution`);return}let n=G.indexOf(r);G.splice(n>=0?n:G.indexOf("DEFAULT"),0,t)}}}function xr(t){return Object.keys(Rt).includes(t)}function pt(t,e,r){if(e=Array.from(e),t._x_virtualDirectives){let o=Object.entries(t._x_virtualDirectives).map(([a,c])=>({name:a,value:c})),s=be(o);o=o.map(a=>s.find(c=>c.name===a.name)?{name:`x-bind:${a.name}`,value:`"${a.value}"`}:a),e=e.concat(o)}let n={};return e.map(wr((o,s)=>n[o]=s)).filter(Sr).map(qn(n,r)).sort(Kn).map(o=>Un(t,o))}function be(t){return Array.from(t).map(wr()).filter(e=>!Sr(e))}var ge=!1,dt=new Map,yr=Symbol();function br(t){ge=!0;let e=Symbol();yr=e,dt.set(e,[]);let r=()=>{for(;dt.get(e).length;)dt.get(e).shift()();dt.delete(e)},n=()=>{ge=!1,r()};t(r),n()}function he(t){let e=[],r=a=>e.push(a),[n,i]=Ye(t);return e.push(i),[{Alpine:B,effect:n,cleanup:r,evaluateLater:_.bind(_,t),evaluate:T.bind(T,t)},()=>e.forEach(a=>a())]}function Un(t,e){let r=()=>{},n=Rt[e.type]||r,[i,o]=he(t);Ot(t,e.original,o);let s=()=>{t._x_ignore||t._x_ignoreSelf||(n.inline&&n.inline(t,e,i),n=n.bind(n,t,e,i),ge?dt.get(yr).push(n):n())};return s.runCleanups=o,s}var Nt=(t,e)=>({name:r,value:n})=>(r.startsWith(t)&&(r=r.replace(t,e)),{name:r,value:n}),Pt=t=>t;function wr(t=()=>{}){return({name:e,value:r})=>{let{name:n,value:i}=Er.reduce((o,s)=>s(o),{name:e,value:r});return n!==e&&t(n,e),{name:n,value:i}}}var Er=[];function ot(t){Er.push(t)}function Sr({name:t}){return vr().test(t)}var vr=()=>new RegExp(`^${ye}([^:^.]+)\\b`);function qn(t,e){return({name:r,value:n})=>{r===n&&(n="");let i=r.match(vr()),o=r.match(/:([a-zA-Z0-9\-_:]+)/),s=r.match(/\.[^.\]]+(?=[^\]]*$)/g)||[],a=e||t[r]||r;return{type:i?i[1]:null,value:o?o[1]:null,modifiers:s.map(c=>c.replace(".","")),expression:n,original:a}}}var xe="DEFAULT",G=["ignore","ref","id","data","anchor","bind","init","for","model","modelable","transition","show","if",xe,"teleport"];function Kn(t,e){let r=G.indexOf(t.type)===-1?xe:t.type,n=G.indexOf(e.type)===-1?xe:e.type;return G.indexOf(r)-G.indexOf(n)}function J(t,e,r={},n={}){return t.dispatchEvent(new CustomEvent(e,{detail:r,bubbles:!0,composed:!0,cancelable:!0,...n}))}function D(t,e){if(typeof ShadowRoot=="function"&&t instanceof ShadowRoot){Array.from(t.children).forEach(i=>D(i,e));return}let r=!1;if(e(t,()=>r=!0),r)return;let n=t.firstElementChild;for(;n;)D(n,e,!1),n=n.nextElementSibling}function E(t,...e){console.warn(`Alpine Warning: ${t}`,...e)}var Ar=!1;function Or(){Ar&&E("Alpine has already been initialized on this page. Calling Alpine.start() more than once can cause problems."),Ar=!0,document.body||E("Unable to initialize. Trying to load Alpine before `` is available. Did you forget to add `defer` in Alpine's `"]
+ result = str(
+ components.Element(
+ tag_name="div", children=[""]
+ )
)
self.assertNotIn("