diff --git a/common/components/__init__.py b/common/components/__init__.py
index 5072ee1..6e8795e 100644
--- a/common/components/__init__.py
+++ b/common/components/__init__.py
@@ -18,6 +18,7 @@ from common.components.core import (
randomid,
render,
)
+from common.components.custom_elements import custom_element, register_element
from common.components.date_range_picker import (
DateRangeCalendar,
DateRangeField,
@@ -91,6 +92,8 @@ from common.utils import truncate
__all__ = [
"truncate",
"BaseComponent",
+ "custom_element",
+ "register_element",
"Element",
"Fragment",
"Media",
diff --git a/common/components/custom_elements.py b/common/components/custom_elements.py
new file mode 100644
index 0000000..61eec33
--- /dev/null
+++ b/common/components/custom_elements.py
@@ -0,0 +1,104 @@
+"""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, TypedDict, 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) # type: ignore[misc]
+ for key, value in props.items()
+ ]
+ 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"
+
+
+# ── Element prop schemas (registered at import time) ─────────────────────────
+
+
+class GameStatusSelectorProps(TypedDict):
+ game_id: int
+ status: str
+ csrf: str
+
+
+register_element("game-status-selector", "GameStatusSelector", GameStatusSelectorProps)
diff --git a/games/management/commands/gen_element_types.py b/games/management/commands/gen_element_types.py
new file mode 100644
index 0000000..6e2402e
--- /dev/null
+++ b/games/management/commands/gen_element_types.py
@@ -0,0 +1,21 @@
+"""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}"))
diff --git a/tests/test_custom_elements.py b/tests/test_custom_elements.py
new file mode 100644
index 0000000..ff77e2b
--- /dev/null
+++ b/tests/test_custom_elements.py
@@ -0,0 +1,58 @@
+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")