Skip to content

Custom elements + htpy-style HTML: kill the inline HTML/JS f-strings#16

Merged
KucharczykL merged 24 commits into
mainfrom
claude/custom-elements
Jun 14, 2026
Merged

Custom elements + htpy-style HTML: kill the inline HTML/JS f-strings#16
KucharczykL merged 24 commits into
mainfrom
claude/custom-elements

Conversation

@KucharczykL

Copy link
Copy Markdown
Owner

Implements docs/superpowers/plans/2026-06-13-html-js-authoring.md — replaces the trusted HTML/JS Python f-strings with three composing layers, then converts the three worst offenders.

Layers

  1. htpy-style sugar on Element (additive, keeps Media): Div(class_="x", hx_get="/y")[child1, child2] — kwargs attributes (class_class, hx_gethx-get, True→bare, False/None→omitted) and [] children. Still a walkable node tree.
  2. Custom Elements (TypeScript, light DOM): custom_element("tag", Props(...)) emits a semantic tag; behavior lives in ts/elements/<tag>.ts (customElements.define), wired by the native connectedCallback (replaces the onSwap shim — fires on parse and htmx swap).
  3. Typed contract codegen: one Python TypedDict per element (register_element) → manage.py gen_element_types writes ts/generated/props.ts (interface + attribute reader). Renaming a prop fails tsc.

Toolchain

  • tsc per-module (tsconfig.json) → games/static/js/dist/ (build-only, gitignored). make ts / make ts-check (in make check) / tsc --watch in make dev.
  • Docker builds CSS + TS in a Node assets stage (assets are built in the image, not committed).

Conversions (Alpine retired for each)

  • GameStatusSelector<game-status-selector> (was a ~70-line inline-Alpine f-string)
  • SessionDeviceSelector<session-device-selector> (deletes the shared Alpine dropdown helper)
  • played-row → <play-event-row> (deletes the @@TOKEN@@ template + .replace() hack)

The two selectors share ts/elements/dropdown.ts.

Notable fix

SimpleTable stringified its cells, silently dropping each cell component's declared Media — so a <game-status-selector> in a table cell never got its <script>. It now collects row/header media and re-attaches it, so Page() emits it.

Also

  • Cleared pre-existing ruff lint + format debt so make check is green end-to-end (separate commit).

Testing

make check green: ruff lint + format, tsc --noEmit (drift gate), 462 tests (unit + e2e). New e2e/test_custom_elements_e2e.py drives all three elements in real Chromium (open → select → PATCH/POST → DOM + DB updated).

🤖 Generated with Claude Code

KucharczykL and others added 11 commits June 13, 2026 21:01
The Game status dropdown is now a <game-status-selector> light-DOM custom
element: the Python builder emits the tag + kebab attrs htpy-style, behavior
lives in ts/elements/{dropdown,game-status-selector}.ts wired by the native
connectedCallback, and GameStatusSelectorProps is the codegen'd contract. The
~70-line inline-Alpine f-string is gone.

Also fix SimpleTable to collect and re-attach the media of its row/header
nodes: it stringifies cells into the table markup, which silently dropped each
cell component's declared Media — so a <game-status-selector> in a cell never
got its <script> emitted. Now Page() emits it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two visual regressions from the custom-element port:

1. The played-row nested its dropdown menu (which contains <button> options)
   inside the toggle <button>. A <button> may not contain another <button>;
   the HTML parser force-closes the toggle on the nested button, and the
   source's explicit </div> tags then close ancestors early — ejecting the
   Purchases/Sessions/etc. sections out of the centered max-w container
   (they rendered full-width). Make the menu a sibling of the toggle, wrapped
   in a relative div so it still anchors under the toggle.

2. Both selector toggles dropped the original
   `flex flex-row gap-4 justify-between items-center` wrapper around their
   content, so the chevron stacked under the label (the GameStatus label is a
   display:flex block). Restore the wrapper — chevron sits on the right with
   proper spacing again.

Verified by screenshot: sections back inside the centered container; both
dropdowns render correctly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Selector menu options were bare <button>s with no padding, so the open
  dropdown items were cramped. Add a shared option class (block w-full
  text-left px-4 py-2 + hover), matching the original <a> list items.
- The played-row's relative menu wrapper was a block div, so in the inline-flex
  button group the chevron toggle sat lower than the count button. Make the
  wrapper inline-flex and the group items-stretch so the two buttons align into
  one rounded group again.
- Rebuild base.css for the newly-used utilities.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
_GET_SESSION_COUNT_SCRIPT was a mark_safe string used as a child of the
view_game content tree. Under the "only Safe nodes render unescaped" rule, a
mark_safe *string* child is escaped — so the <script> showed as literal text
on the page. Make it a Safe node (and drop the now-unused mark_safe import).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@KucharczykL KucharczykL merged commit 008d92d into main Jun 14, 2026
3 of 4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant