Tightens the child model so the type is honest end to end. Previously a
``SafeText``/``mark_safe`` string passed as a child rendered unescaped — a
trusted-HTML-as-string backdoor that ``Child = Node | str`` couldn't express
(every ``SafeText`` is a ``str``). Now ``_child_key`` escapes *every* string
child; the only way to put trusted pre-rendered HTML into the tree is a
``Safe`` node. So a ``str`` child is always untrusted text — which is exactly
what the renderer escapes.
Converted the trusted-HTML children that relied on the old passthrough:
- ``CsrfInput`` and the Alpine selectors (``GameStatusSelector`` /
``SessionDeviceSelector``) now return ``Safe`` nodes instead of ``mark_safe``
strings — they are always tree children.
- ``popover_content`` is now a ``Child`` (it is rendered as a child); the one
HTML caller (``LinkedPurchase``) passes ``Safe(...)``.
- View-side children that were ``mark_safe`` strings → ``Safe(...)``:
``_played_row`` (game detail), the stat SVGs and `` `` spacer (game),
the login table (auth), the manual session-form field/label markup
(session), and ``_purchase_name`` (stats).
- ``SimpleTable.header_action`` typed ``Child``.
The script-tag string helpers (``ModuleScript`` / ``StaticScript`` /
``ExternalScript``) stay ``SafeText`` strings: they are only ever joined into
the ``scripts=`` string, never used as tree children.
``Children`` regains a bare ``Node`` member (a single node child is valid);
the one ``*children`` site (``Popover``) normalises via ``as_children`` first.
Tests that asserted the old SafeText-passthrough now assert the new rule
(mark_safe child escaped; ``Safe`` node passes through). Full suite green
(445; +2 new escaping tests).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Twin of the children fix: builders annotated ``attributes`` as
``list[HTMLAttribute] | None``, and ``list`` is invariant, so passing the
``list[tuple[str, str]]`` a caller naturally writes was a type error.
Add ``Attributes = Sequence[HTMLAttribute]`` (covariant) and use it for the
``attributes`` parameter of every builder. Locals that get appended/concatenated
stay a concrete ``list[HTMLAttribute]`` via the new ``as_attributes()``
normaliser, mirroring ``as_children()`` — builders call it once up front so
``attributes + [...]`` keeps working on a real list.
Pyright on common/components drops 45 → 42; the remaining errors are all
pre-existing and unrelated (django-stubs model access, the ``mark_safe``
``_Wrapped`` return type, and the separate ``FilterSelect`` options-list
invariance). Full suite green (443).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The builders annotated their ``children`` parameter as
``list[HTMLTag] | HTMLTag | None`` where ``HTMLTag = str``. ``list[str]`` is
invariant, so passing ``list[Element]`` / ``list[Node]`` — the normal case —
was a type error everywhere a component nested children.
Introduce a proper child type in core:
Child = Node | str
Children = Sequence[Child] | str | None
``Sequence`` is covariant, so ``list[Element]`` / ``list[Node]`` are accepted;
``Child`` includes ``Node`` so node children are no longer rejected. ``Element``
itself also accepts a bare ``Node`` (it wraps one), typed ``Children | Node``.
Replace the ``list[HTMLTag] | HTMLTag | None`` annotations across primitives /
domain with ``Children``, and add ``as_children()`` to normalise a ``children``
argument to a ``list[Child]`` — retiring the repeated
``children if isinstance(children, list) else [children]`` dance that defeated
type narrowing. Inline ``mark_safe(...)`` SVG/markup children become ``Safe(...)``
nodes (a ``Node`` child instead of a stub-typed string).
Pyright on the component package drops from 43 to 22 errors; the remaining 22
are pre-existing and unrelated (django-stubs model access, the ``mark_safe``
``_Wrapped`` return type, and ``list[HTMLAttribute]`` attribute invariance).
Full suite green (443).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Cleanup of hacky leftovers from the node-tree migration (no behaviour
change):
- Return annotations: the component builders return Node subtrees, not
SafeText strings, but ~40 functions still declared `-> SafeText`. Correct
them to `-> Node` across filters / search_select / date_range_picker /
domain. The genuine string returners keep `-> SafeText`: the Alpine
selectors (GameStatusSelector / SessionDeviceSelector, which build f-string
markup) and the script-tag helpers (CsrfInput / ModuleScript /
ExternalScript / StaticScript).
- layout.render_page / layout.Page / AddForm now accept `Node` in their
`content` / `scripts` / `fields` parameters (TYPE_CHECKING import in
layout to avoid the components import cycle), matching what views already
pass.
- session._session_fields builds a `Fragment(*rows, separator="\n")` instead
of `mark_safe("\n".join(str(row) ...))` — keeps the tree intact so media
could bubble, per the Fragment convention.
- Inline SVG icon children use `Safe(...)` nodes instead of `mark_safe(...)`
strings (filters mode-toggle + collapse icons, date_range_picker calendar
icon).
- _filter_field reads the widget's own id from its node `.attributes`
(`_widget_id`) for the label's `for`, dropping the superfluous `for_widget`
argument that always rendered `for="None"`. Removes the two TODOs whose
premise ("the Component function can't expose the id") the class/node
refactor retired, plus RangeSlider's dead commented-out Label block.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Generic leaf builders (Div, Span, Td, Tr, Th, Ul, Li, Strong, Label,
Template, P) are now generated from one _html_element factory over the
single Element class — the tag name is data, not a per-tag body. Only
elements that add classes/behaviour (Button, Pill, Checkbox, Radio,
Input, A, SearchField, H1, Modal, AddForm, tables) stay hand-written.
All primitives now return Node objects; string-built widgets (Icon,
SimpleTable, YearPicker) return Safe, and YearPicker declares its
datepicker media. Raw concatenation (_popover_html, Popover slot) uses
Fragment.
Node.__str__/__html__ now return a SafeString: a node's rendered output
is safe HTML by construction, so str(node) stays safe when fed back into
a child list or template (matching the old SafeText behaviour and
preventing double-escaping).
Consumers adapted: the form widgets (SearchSelectWidget,
PrimitiveCheckboxWidget) return render(component) so Django gets a safe
string; the session form's manual field markup joins via str(row).
Component tests render nodes to HTML before asserting.
https://claude.ai/code/session_01BKurBhE3Qj25p7Bfsg7EeK
Serve alpinejs 3.15.12, @alpinejs/mask 3.15.12, flowbite 2.4.1 and
flowbite-datepicker 2.0.0 from games/static/js/ instead of jsdelivr, so
pages (and browser tests) work without network access. Adds the
StaticScript primitive for vendored UMD bundles, which cannot be loaded
as ES modules.
https://claude.ai/code/session_01BKurBhE3Qj25p7Bfsg7EeK
Add a Template() primitive for the standard <template> tag and export it. Replace
inline Component(tag_name="div"/"span"/"input"/"template") in search_select.py
and Pill with Div/Span/Input/Template; drop the private _template helper in favour
of Template at the call sites. Bare custom-styled <button>s stay on Component
(the opinionated Button() would inject unwanted classes). Document the
prefer-primitives convention in CLAUDE.md.
https://claude.ai/code/session_01XzhXvMvw42CQGc9kmin3GS
Spell out the abbreviated data-ss-* hook attributes (data-search-select-option,
-label, -mode, -template, -action, -type, -modifier, -modifier-option, -pills,
-search, -options, -no-results) and the JS expando properties (_searchSelectInit,
_searchSelectLabel, _searchSelectDirty, _searchSelectOption) across components,
JS, and tests — no abbreviations left in the widget's hooks.
https://claude.ai/code/session_01XzhXvMvw42CQGc9kmin3GS
Eliminate the Python/JS class-string duplication: the server renders hidden
<template> prototypes (row, pill, include/exclude/modifier pills) using the same
component functions, and search_select.js clones them, filling only the
[data-ss-label] slot, value, and data-* attrs. All Tailwind class strings and DOM
structure now live solely in the Python components — the JS no longer hardcodes
any class. Pill gains an opt-in label_slot; the shell takes a templates list.
Companion issue #8 tracks the further HTMX-idiomatic step of returning rendered
row HTML from the search endpoint.
https://claude.ai/code/session_01XzhXvMvw42CQGc9kmin3GS