Phase 4: Page() collects component media; drop manual scripts= threading

Page() now calls collect_media(content) and emits the ModuleScript /
StaticScript tags itself, so views no longer thread scripts= for
component-owned JS. The list views (game/session/purchase/device/
platform/playevent) compose with Fragment(filter_bar, content) instead of
mark_safe(str(filter_bar) + str(content)) — keeping the node tree intact
so the filter bar's media (filter_bar.js + search_select.js +
range_slider.js, and date_range_picker.js on purchases) reaches Page().
The stats views drop _STATS_SCRIPTS; YearPicker's datepicker.umd.js is
collected from its declared media.

The scripts= argument remains for page-specific glue not owned by a
component (the add-form helpers add_game.js / add_purchase.js /
add_session.js, alongside search_select.js for their form widgets).

Adds regression tests asserting the list and stats pages auto-load their
widget scripts with no scripts= in the view, and documents the node/
media model in CLAUDE.md.

https://claude.ai/code/session_01BKurBhE3Qj25p7Bfsg7EeK
This commit is contained in:
Claude
2026-06-13 07:32:35 +00:00
parent 0819ddb87d
commit 2d3ae4e04f
10 changed files with 57 additions and 53 deletions
+6 -5
View File
@@ -58,12 +58,12 @@ docs/ — Additional documentation
### Key patterns ### 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 `<head>`, 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 `<head>`, navbar, toast container, FOUC-prevention script, and **JS includes**: it calls `collect_media(content)` to gather every component's declared `Media` and emits the `<script>` tags automatically — so views do **not** pass `scripts=` for component-owned JS. The `scripts=` argument remains only for page-specific glue not owned by a reusable component (e.g. the add-form helper `add_*.js`). The navbar shows today's/last-7-days playtime from the `model_counts` context processor.
**Component system** (`common/components/`): Pure-Python HTML builders, split into four submodules re-exported via `common/components/__init__.py`: **Component system** (`common/components/`): a FastHTML-style **lazy node tree**. Components are `Node` objects that render to HTML only when asked (`str(node)` / `Page()`), so `Page()` can walk a finished tree and collect each component's JS. Split into submodules re-exported via `common/components/__init__.py`:
- **`core.py`** — `Component(tag_name, attributes, children)`: the fundamental builder. `_render_element()` is `@lru_cache`-memoized (4096 entries, always active). Attribute values are always HTML-escaped; children are escaped unless they are `SafeText`. `randomid()` generates stable hash-based IDs. - **`core.py`** — the node layer. `Node` (base; `__html__`/`__str__` return a `SafeString`), `Element` (the single class for *any* HTML element), `Safe` (wraps pre-rendered/trusted HTML), `Fragment` (ordered children, no wrapper tag — use instead of `str(a)+str(b)`), `BaseComponent` (base for higher-level components: implement `render()`, declare `media`), and `Media` (declarative JS deps with order-preserving dedup merge; `collect_media()` sums them over a tree, `node.with_media(...)` attaches them). `_render_element()` is `@lru_cache`-memoized (4096). Attribute values are always escaped; children escaped unless `SafeText`/`Node`. The legacy `Component(tag_name=...)` function still returns a `SafeText` string (Node-aware children) for back-compat. `randomid()` generates stable hash-based IDs.
- **`primitives.py`** — Generic HTML: `A()`, `Button()` (with color/size/icon params), `ButtonGroup()`, `Div()`, `Span()`, `Label()`, `Input()`, `Icon()`, `Popover()`, `PopoverTruncated()`, `SearchField()`, `H1()`, `Modal()`, `SimpleTable()`, `TableRow()`, `TableTd()`, `TableHeader()`, `paginated_table_content()`, `AddForm()`, `Pill()`, `CsrfInput()`, `ModuleScript()` - **`primitives.py`** — Generic HTML. Plain leaf builders (`Div`, `Span`, `P`, `Ul`, `Li`, `Strong`, `Label`, `Template`, `Td`, `Tr`, `Th`) are **generated from a whitelist** via the `_html_element(tag)` factory over `Element` — not hand-written per tag. Builders that add classes/behaviour are written out: `A()`, `Button()`, `ButtonGroup()`, `Input()`, `Checkbox()`, `Radio()`, `Pill()`, `Icon()`, `Popover()`, `PopoverTruncated()`, `SearchField()`, `H1()`, `Modal()`, `SimpleTable()`, `TableRow()`, `TableTd()`, `TableHeader()`, `paginated_table_content()`, `AddForm()`, `YearPicker()` (declares datepicker media), `CsrfInput()`/`ModuleScript()`/`StaticScript()` (script-tag string helpers used by `Page()`).
- **`domain.py`** — Domain-specific: `GameLink()`, `GameStatus()` (colored dot + label), `GameStatusSelector()` (Alpine.js PATCH dropdown), `SessionDeviceSelector()` (Alpine.js PATCH dropdown), `LinkedPurchase()`, `NameWithIcon()`, `PriceConverted()`, `PurchasePrice()` - **`domain.py`** — Domain-specific: `GameLink()`, `GameStatus()` (colored dot + label), `GameStatusSelector()` (Alpine.js PATCH dropdown), `SessionDeviceSelector()` (Alpine.js PATCH dropdown), `LinkedPurchase()`, `NameWithIcon()`, `PriceConverted()`, `PurchasePrice()`
- **`filters.py`** — Filter UI: `FilterBar()`, `SessionFilterBar()`, `PurchaseFilterBar()` (built from `FilterSelect` widgets) - **`filters.py`** — Filter UI: `FilterBar()`, `SessionFilterBar()`, `PurchaseFilterBar()` (built from `FilterSelect` widgets)
- **`search_select.py`** — `SearchSelect()` (form combobox) + `FilterSelect()` (include/exclude filter combobox with pinned Any/None modifiers) + `SearchSelectOption`, all built on a shared `_combobox_shell`; wired by `games/static/js/search_select.js` - **`search_select.py`** — `SearchSelect()` (form combobox) + `FilterSelect()` (include/exclude filter combobox with pinned Any/None modifiers) + `SearchSelectOption`, all built on a shared `_combobox_shell`; wired by `games/static/js/search_select.js`
@@ -166,7 +166,8 @@ Pytest settings are in `pyproject.toml` under `[tool.pytest.ini_options]` (`DJAN
- **Name variables with complete words** — readable, unabbreviated identifiers in both Python and JavaScript (e.g. `template` not `tpl`, `event` not `e`, `element` not `el`, `removeButton` not `removeBtn`, `option`/`value` not single letters in loops). This applies to new code and to code you touch. - **Name variables with complete words** — readable, unabbreviated identifiers in both Python and JavaScript (e.g. `template` not `tpl`, `event` not `e`, `element` not `el`, `removeButton` not `removeBtn`, `option`/`value` not single letters in loops). This applies to new code and to code you touch.
- **Use `render_page()` not `render()`** for all full-page HTTP responses. Import from `common.layout`. - **Use `render_page()` not `render()`** for all full-page HTTP responses. Import from `common.layout`.
- **Build UI with Python components** from `common.components`, not raw HTML strings or Django templates. `SafeText` children pass through unescaped; plain strings are auto-escaped. - **Build UI with Python components** from `common.components`, not raw HTML strings or Django templates. `SafeText` children pass through unescaped; plain strings are auto-escaped.
- **Prefer the named element primitives over raw `Component(tag_name=…)`** — use `Div()`, `Span()`, `Input()`, `Label()`, `Template()`, etc. from `common.components` instead of `Component(tag_name="div")`. Reach for `Component` directly only when no primitive fits (e.g. a bare, custom-styled `<button>` where the opinionated `Button()` would inject unwanted classes). Add a new primitive rather than repeating an inline `Component` for a standard tag. - **Components are nodes; use the named builders, not the legacy `Component()`** — build with `Div()`, `Span()`, `Element("tag", ...)`, etc., which return `Node` objects. For a tag with no builder, add it to the whitelist in `primitives.py` (one line) or use `Element("tag", attrs, children)`. Use `Fragment(a, b, ...)` to group siblings (never `str(a)+str(b)`, which flattens the tree and drops media). Wrap trusted pre-rendered HTML in `Safe(html)` (the `mark_safe` analogue). The legacy `Component(tag_name=…)` function is retained for back-compat only.
- **JS-bearing components declare `Media`, they don't rely on the view** — give a component `class Media: js = (...)` (a `BaseComponent`) or `return node.with_media(Media(js=...))`. `Page()` collects and emits it. Never re-add `scripts=ModuleScript(...)` threading in a view for a component that can declare its own dependency.
- **Filter views** accept `?filter=<JSON>` (structured) and fall back to `?search_string=` (free-text). New filter criteria go in `games/filters.py`; new criterion types go in `common/criteria.py`. - **Filter views** accept `?filter=<JSON>` (structured) and fall back to `?search_string=` (free-text). New filter criteria go in `games/filters.py`; new criterion types go in `common/criteria.py`.
- **Signals handle side-effects** — do not manually recalculate `Game.playtime` or `Purchase.num_purchases`; the signals in `games/signals.py` do this on save/delete. - **Signals handle side-effects** — do not manually recalculate `Game.playtime` or `Purchase.num_purchases`; the signals in `games/signals.py` do this on save/delete.
- **Button colors**: `blue` (primary action), `red` (destructive), `gray` (secondary), `green` (positive). Icon buttons use `icon=True`. - **Button colors**: `blue` (primary action), `red` (destructive), `gray` (secondary), `green` (positive). Icon buttons use `icon=True`.
+16 -2
View File
@@ -276,9 +276,23 @@ def Page(
scripts: SafeText | str = "", scripts: SafeText | str = "",
mastered: bool = False, mastered: bool = False,
) -> SafeText: ) -> SafeText:
"""Assemble a full HTML document around `content` (the fast_app equivalent).""" """Assemble a full HTML document around `content` (the fast_app equivalent).
Scripts are collected from `content`'s component tree: every component
declares its JS via `Media`, and `collect_media` gathers (deduped) the union
for the whole page. The `scripts` argument remains for page-specific glue
that isn't owned by a reusable component (e.g. the add-form helpers).
"""
from common.components import ModuleScript, StaticScript, collect_media
from games.views.general import global_current_year, model_counts from games.views.general import global_current_year, model_counts
media = collect_media(content)
collected_scripts = "".join(
[str(ModuleScript(name)) for name in media.js]
+ [str(StaticScript(name)) for name in media.js_external]
)
all_scripts = collected_scripts + (str(scripts) if scripts else "")
counts = model_counts(request) counts = model_counts(request)
year = global_current_year(request)["global_current_year"] year = global_current_year(request)["global_current_year"]
navbar = Navbar( navbar = Navbar(
@@ -328,7 +342,7 @@ def Page(
f' <div id="main-container" class="flex flex-1 flex-col pt-8 pb-16">{content}</div>\n' f' <div id="main-container" class="flex flex-1 flex-col pt-8 pb-16">{content}</div>\n'
f' <span class="fixed left-2 bottom-2 text-xs text-slate-300 dark:text-slate-600">{version()} ({version_date()})</span>\n' f' <span class="fixed left-2 bottom-2 text-xs text-slate-300 dark:text-slate-600">{version()} ({version_date()})</span>\n'
" </div>\n" " </div>\n"
f" {scripts}\n" f" {all_scripts}\n"
f" {_main_script(mastered)}\n" f" {_main_script(mastered)}\n"
" <!-- hx-swap-oob makes sure the modal gets removed upon any HTMX response -->\n" " <!-- hx-swap-oob makes sure the modal gets removed upon any HTMX response -->\n"
' <div id="global-modal-container" hx-swap-oob="true"></div>\n' ' <div id="global-modal-container" hx-swap-oob="true"></div>\n'
+2 -6
View File
@@ -2,9 +2,9 @@ from django.contrib.auth.decorators import login_required
from django.http import HttpRequest, HttpResponse from django.http import HttpRequest, HttpResponse
from django.shortcuts import get_object_or_404, redirect from django.shortcuts import get_object_or_404, redirect
from django.urls import reverse from django.urls import reverse
from django.utils.safestring import mark_safe
from common.components import ( from common.components import (
Fragment,
A, A,
AddForm, AddForm,
Button, Button,
@@ -12,7 +12,6 @@ from common.components import (
Icon, Icon,
paginated_table_content, paginated_table_content,
DeviceFilterBar, DeviceFilterBar,
ModuleScript,
) )
from common.layout import render_page from common.layout import render_page
from common.time import dateformat, local_strftime from common.time import dateformat, local_strftime
@@ -76,14 +75,11 @@ def list_devices(request: HttpRequest) -> HttpResponse:
preset_list_url=reverse("games:list_presets") + "?mode=devices", preset_list_url=reverse("games:list_presets") + "?mode=devices",
preset_save_url=reverse("games:save_preset") + "?mode=devices", preset_save_url=reverse("games:save_preset") + "?mode=devices",
) )
content = mark_safe(str(filter_bar) + str(content)) content = Fragment(filter_bar, content)
return render_page( return render_page(
request, request,
content, content,
title="Manage devices", title="Manage devices",
scripts=ModuleScript("range_slider.js")
+ ModuleScript("search_select.js")
+ ModuleScript("filter_bar.js"),
) )
+2 -4
View File
@@ -11,6 +11,7 @@ from django.urls import reverse
from django.utils.safestring import SafeText, mark_safe from django.utils.safestring import SafeText, mark_safe
from common.components import ( from common.components import (
Fragment,
H1, H1,
A, A,
AddForm, AddForm,
@@ -145,14 +146,11 @@ def list_games(request: HttpRequest, search_string: str = "") -> HttpResponse:
preset_list_url=reverse("games:list_presets"), preset_list_url=reverse("games:list_presets"),
preset_save_url=reverse("games:save_preset"), preset_save_url=reverse("games:save_preset"),
) )
content = mark_safe(str(filter_bar) + str(content)) content = Fragment(filter_bar, content)
return render_page( return render_page(
request, request,
content, content,
title="Manage games", title="Manage games",
scripts=ModuleScript("range_slider.js")
+ ModuleScript("search_select.js")
+ ModuleScript("filter_bar.js"),
) )
+4 -10
View File
@@ -13,16 +13,14 @@ from django.urls import reverse
from django.utils.timezone import localtime from django.utils.timezone import localtime
from django.utils.timezone import now as timezone_now from django.utils.timezone import now as timezone_now
from common.components import StaticScript
from common.layout import render_page from common.layout import render_page
from common.time import format_duration from common.time import format_duration
from games.models import Game, Platform, Purchase, Session from games.models import Game, Platform, Purchase, Session
from games.views.stats_content import stats_content from games.views.stats_content import stats_content
from games.views.stats_data import compute_stats from games.views.stats_data import compute_stats
# Flowbite-datepicker UMD bundle (vendored, v2.0.0), hoisted into the stats # The Flowbite-datepicker UMD bundle is declared as media on the YearPicker
# pages for YearPicker. # component, so Page() loads it automatically on the stats pages.
_STATS_SCRIPTS = StaticScript("datepicker.umd.js")
def model_counts(request: HttpRequest) -> dict[str, bool]: def model_counts(request: HttpRequest) -> dict[str, bool]:
@@ -76,9 +74,7 @@ def use_custom_redirect(
def stats_alltime(request: HttpRequest) -> HttpResponse: def stats_alltime(request: HttpRequest) -> HttpResponse:
request.session["return_path"] = request.path request.session["return_path"] = request.path
data = compute_stats(None) data = compute_stats(None)
return render_page( return render_page(request, stats_content(data), title=data["title"])
request, stats_content(data), title=data["title"], scripts=_STATS_SCRIPTS
)
@login_required @login_required
@@ -92,9 +88,7 @@ def stats(request: HttpRequest, year: int = 0) -> HttpResponse:
return HttpResponseRedirect(reverse("games:stats_alltime")) return HttpResponseRedirect(reverse("games:stats_alltime"))
request.session["return_path"] = request.path request.session["return_path"] = request.path
data = compute_stats(year) data = compute_stats(year)
return render_page( return render_page(request, stats_content(data), title=data["title"])
request, stats_content(data), title=data["title"], scripts=_STATS_SCRIPTS
)
@login_required @login_required
+2 -6
View File
@@ -2,9 +2,9 @@ from django.contrib.auth.decorators import login_required
from django.http import HttpRequest, HttpResponse from django.http import HttpRequest, HttpResponse
from django.shortcuts import get_object_or_404, redirect from django.shortcuts import get_object_or_404, redirect
from django.urls import reverse from django.urls import reverse
from django.utils.safestring import mark_safe
from common.components import ( from common.components import (
Fragment,
A, A,
AddForm, AddForm,
Button, Button,
@@ -12,7 +12,6 @@ from common.components import (
Icon, Icon,
paginated_table_content, paginated_table_content,
PlatformFilterBar, PlatformFilterBar,
ModuleScript,
) )
from common.layout import render_page from common.layout import render_page
from common.time import dateformat, local_strftime from common.time import dateformat, local_strftime
@@ -83,14 +82,11 @@ def list_platforms(request: HttpRequest) -> HttpResponse:
preset_list_url=reverse("games:list_presets") + "?mode=platforms", preset_list_url=reverse("games:list_presets") + "?mode=platforms",
preset_save_url=reverse("games:save_preset") + "?mode=platforms", preset_save_url=reverse("games:save_preset") + "?mode=platforms",
) )
content = mark_safe(str(filter_bar) + str(content)) content = Fragment(filter_bar, content)
return render_page( return render_page(
request, request,
content, content,
title="Manage platforms", title="Manage platforms",
scripts=ModuleScript("range_slider.js")
+ ModuleScript("search_select.js")
+ ModuleScript("filter_bar.js"),
) )
+2 -5
View File
@@ -9,9 +9,9 @@ from django.http import HttpRequest, HttpResponse, HttpResponseRedirect
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
from django.urls import reverse from django.urls import reverse
from django.utils.safestring import mark_safe
from common.components import ( from common.components import (
Fragment,
A, A,
AddForm, AddForm,
Button, Button,
@@ -151,14 +151,11 @@ def list_playevents(request: HttpRequest) -> HttpResponse:
preset_list_url=reverse("games:list_presets") + "?mode=playevents", preset_list_url=reverse("games:list_presets") + "?mode=playevents",
preset_save_url=reverse("games:save_preset") + "?mode=playevents", preset_save_url=reverse("games:save_preset") + "?mode=playevents",
) )
content = mark_safe(str(filter_bar) + str(content)) content = Fragment(filter_bar, content)
return render_page( return render_page(
request, request,
content, content,
title="Manage play events", title="Manage play events",
scripts=ModuleScript("range_slider.js")
+ ModuleScript("search_select.js")
+ ModuleScript("filter_bar.js"),
) )
+3 -6
View File
@@ -14,6 +14,7 @@ from django.utils.safestring import SafeText, mark_safe
from django.views.decorators.http import require_POST from django.views.decorators.http import require_POST
from common.components import ( from common.components import (
Fragment,
A, A,
AddForm, AddForm,
Button, Button,
@@ -129,22 +130,18 @@ def list_purchases(request: HttpRequest) -> HttpResponse:
elided_page_range=elided_page_range, elided_page_range=elided_page_range,
request=request, request=request,
) )
from common.components import ModuleScript, PurchaseFilterBar from common.components import PurchaseFilterBar
filter_bar = PurchaseFilterBar( filter_bar = PurchaseFilterBar(
filter_json=filter_json, filter_json=filter_json,
preset_list_url=reverse("games:list_presets"), preset_list_url=reverse("games:list_presets"),
preset_save_url=reverse("games:save_preset"), preset_save_url=reverse("games:save_preset"),
) )
content = mark_safe(str(filter_bar) + str(content)) content = Fragment(filter_bar, content)
return render_page( return render_page(
request, request,
content, content,
title="Manage purchases", title="Manage purchases",
scripts=ModuleScript("range_slider.js")
+ ModuleScript("search_select.js")
+ ModuleScript("date_range_picker.js")
+ ModuleScript("filter_bar.js"),
) )
+2 -4
View File
@@ -11,6 +11,7 @@ from django.utils import timezone
from django.utils.safestring import SafeText, mark_safe from django.utils.safestring import SafeText, mark_safe
from common.components import ( from common.components import (
Fragment,
A, A,
AddForm, AddForm,
Button, Button,
@@ -176,14 +177,11 @@ def list_sessions(request: HttpRequest, search_string: str = "") -> HttpResponse
preset_list_url=reverse("games:list_presets"), preset_list_url=reverse("games:list_presets"),
preset_save_url=reverse("games:save_preset"), preset_save_url=reverse("games:save_preset"),
) )
content = mark_safe(str(filter_bar) + str(content)) content = Fragment(filter_bar, content)
return render_page( return render_page(
request, request,
content, content,
title="Manage sessions", title="Manage sessions",
scripts=ModuleScript("range_slider.js")
+ ModuleScript("search_select.js")
+ ModuleScript("filter_bar.js"),
) )
+18 -5
View File
@@ -57,6 +57,22 @@ class RenderedPagesTest(TestCase):
marker, html, f"Found double-escaped markup ({marker!r}) in output" marker, html, f"Found double-escaped markup ({marker!r}) in output"
) )
# --- scripts auto-collected from component media (Phase 4) ---------------
def test_list_page_auto_loads_widget_scripts(self):
"""The games list view passes no scripts= argument; the filter bar's
components declare their JS and Page() collects it."""
html = self.get("games:list_games").content.decode()
self.assertIn("js/filter_bar.js", html)
self.assertIn("js/search_select.js", html)
self.assertIn("js/range_slider.js", html)
def test_stats_page_auto_loads_datepicker(self):
"""YearPicker declares the datepicker UMD bundle as media; the stats
view no longer hoists it by hand."""
html = self.get("games:stats_alltime").content.decode()
self.assertIn("js/datepicker.umd.js", html)
# --- layout wrapper ------------------------------------------------------ # --- layout wrapper ------------------------------------------------------
def test_page_layout_wrapper(self): def test_page_layout_wrapper(self):
@@ -395,15 +411,12 @@ class PurchaseListDateFilterTest(TestCase):
html, html,
) )
self.assertIn( self.assertIn(
'name="filter-date-purchased-max" id="filter-date-purchased-max" ' 'name="filter-date-purchased-max" id="filter-date-purchased-max" value=""',
'value=""',
html, html,
) )
def test_date_refunded_not_null(self): def test_date_refunded_not_null(self):
response = self._get( response = self._get({"date_refunded": {"value": "", "modifier": "NOT_NULL"}})
{"date_refunded": {"value": "", "modifier": "NOT_NULL"}}
)
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
html = response.content.decode() html = response.content.decode()
self.assertNotIn("EARLY-MARKER", html) self.assertNotIn("EARLY-MARKER", html)