Skip to content

render: expand ChildList templates from the data model - #49

Merged
joestump merged 2 commits into
mainfrom
feat/44-childlist-templates
Jul 18, 2026
Merged

render: expand ChildList templates from the data model#49
joestump merged 2 commits into
mainfrom
feat/44-childlist-templates

Conversation

@joestump

Copy link
Copy Markdown
Collaborator

What

Implements the dynamic-template form of ChildList. A container whose Children.Template is set now renders one instance of the template component per element of the bound data-model list, instead of the [a2tea: dynamic children not yet supported] placeholder. updateDataModel on the list grows or shrinks the children on the next render.

How

  • render/template.go (new): renderTemplateChildren resolves Template.Path in the data model, converts the value to a list ([]any fast path, reflection for typed Go slices like []string), and renders Template.ComponentID once per element with that element pushed onto a scope stack.
  • Element-scoped bindings: lookupBinding consults the scope stack innermost-first — an empty path or / is the element itself, and multi-segment paths traverse nested maps and numeric list indices — before falling back to the surface data model, so instances can mix per-element and shared values. dynString now routes through it; behavior outside templates is unchanged.
  • Root/focus plumbing: childIDs counts the template component as referenced via the new childListIDs helper, so root derivation stays on the container and the focus walk reaches interactive components inside the template subtree.
  • Edge behavior: unresolved path or empty list expands to no children (the list appears when data arrives); a non-list value renders a monochrome caption diagnostic in the existing [a2tea: ...] style. Cycle safety rides renderComponent's existing ancestor-path seen set (the Surface renderer falsely reports a cycle when a component is referenced by two parents #10 behavior): a template component referencing its own container reports a cycle per instance instead of recursing.
  • docs/wire-format.md: moved ChildList templates from the "Not yet" list to implemented, with a description of the scoping rules.

Tests

render/template_test.go covers: template render with per-element bindings and ordering, grow/shrink via updateDataModel, empty and absent lists, cycle through the container, scalar []string elements with / self-binding (plus List bullet chrome), scope fallback to the surface data model, nested element paths, non-list diagnostic, root derivation with the template component declared first, and the static-ID form staying unaffected. go build ./... && go vet ./... && go test ./... all green.

Note: the website/ docs "Not yet" list (which links these gaps to backlog tickets) is deliberately untouched — it's deferred to the post-merge docs pass.

Fixes #44

🤖 Posted on behalf of @joestump by Claude.

A ChildList's dynamic template form now renders instead of drawing the
"[a2tea: dynamic children not yet supported]" placeholder. The template's
Path resolves in the surface data model to a list, and the template
component renders once per element with bindings scoped to that element:
lookupBinding consults the template scope stack innermost-first (an empty
path or "/" is the element itself; nested segments traverse maps and
numeric list indices) before falling back to the surface data model, so
instances can mix per-element and shared values. updateDataModel on the
bound list grows or shrinks the children on the next render.

An unresolved path or empty list expands to no children; a non-list value
renders a caption diagnostic. Cycle safety rides the existing seen-set
guard in renderComponent, so a template component referencing its own
container reports a cycle per instance instead of recursing. childIDs now
counts the template component as referenced, keeping root derivation on
the container and letting the focus walk reach the template subtree.

Covers static, template, grow/shrink, empty/absent-list, scalar-element,
nested-path, scope-fallback, non-list, root-derivation, and cycle cases
in render/template_test.go.

Closes #44

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

@joestump joestump left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adversarial review of the ChildList template implementation against #44. Verified locally on the branch: go build ./... && go vet ./... && go test ./... all green, plus independent probe tests for cases the PR's suite doesn't cover (nested templates, a self-nested template component, buttons/textfields inside a template, JSON null at the template path).

Blockers

None found.

What I specifically hunted and confirmed correct:

  • Scope stack correctness (render/template.go:27-38): push/pop around each instance is balanced, innermost-first lookup in lookupBinding handles nested templates correctly (verified: an outer /groups template containing an inner /members template renders all leaves with correct element scoping), and outside templates the stack is empty so dynString behavior is byte-identical to the old s.data lookup — including the "" key for / paths, matching applyDataModel's storage.
  • Cycle safety: rides renderComponent's unwound seen set, so N sequential instances of the same template component are not mistaken for a cycle (the 3-element render test would fail otherwise), while a template component wrapping its own container — and even a component that is its own nested-template target (root -> grp -> grp) — reports [a2tea: cycle at ...] instead of recursing. Verified both.
  • Root/focus plumbing (childListIDs): the template component counts as referenced in both rootID (NewSurface) and deriveRootID (applyComponents), and collectFocusables walks into the template subtree. TestTemplateRootDerivation genuinely exercises the declaration-order trap.
  • []string handling: asList's reflection path converts typed slices element-by-element; the scalar-element test asserts real content and the List bullet chrome, not mere execution.
  • No bypasses left: every ChildList consumer routes through renderChildren/childListIDs; the only remaining direct s.data read is editSeed, which runs at edit time when the scope stack is provably empty — equivalent.
  • Test quality: assertions are behavioral (content presence, ordering via strings.Index, absence after shrink, placeholder leakage), not smoke tests. Grow and shrink are both asserted.

All four Done-when items are satisfied (docs update done in docs/wire-format.md; website/ correctly deferred to the docs pass per the workflow rule).

Nits (non-blocking)

  1. render/template.go:31 — JSON null renders a producer-error diagnostic. updateDataModel {path: "/items", value: null} is a natural JSON idiom for clearing a list, but asList(nil) reports false (reflect.Invalid), so the surface shows [a2tea: template path "/items" is not a list] instead of expanding to no children. Consider treating a nil value like an unresolved path (return nil before the asList check) so agents can clear a list with null.
  2. Shared focus across instances. An interactive component inside a template is one focusable ID: with 2+ elements, focusing it renders every instance in focused (reverse) style simultaneously, and activation/FieldValues carry no element identity. This is consistent with the surface's documented adjacency-list-reuse semantics ("one interactive element however many times it is drawn") and out of #44's scope, but it's worth a sentence in docs/wire-format.md's template paragraph so producers don't put buttons in templates expecting per-row actions.
  3. resolveElementPath doesn't unescape JSON Pointer ~0/~1 tokens. Matches the pre-existing flat data model (applyDataModel doesn't either), so this is a consistency nit, not a regression — but if element keys can contain / or ~ it will misresolve. Same bucket: sub-path updates (/items/0/name) don't merge into the stored /items list; that's the pre-existing flat-model limitation, and template lists correctly respond to whole-list updates as the ticket requires.

Verdict: approve-quality. Nothing here ships a bug or misses the ticket contract.

🤖 Posted on behalf of @joestump by Claude.

Resolve docs/wire-format.md conflict by keeping both newly implemented
bullets (ChildList templates and the createSurface no-op from #47) and
dropping both from the 'Not yet' list.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@joestump
joestump merged commit 94b4754 into main Jul 18, 2026
1 check passed
joestump added a commit that referenced this pull request Jul 18, 2026
Union merge of interactive tab switching (#45) with the five feature PRs
that landed on main since the branch forked (#48 createSurface no-op,
#49 ChildList templates, #51 Modal open/close, #52 editable inputs,
#53 InputSubmitted/ChoiceSelected dispatch). Nothing from either side
was dropped:

- render/render.go: Surface keeps ALL state — activeTabs (branch) plus
  fieldValues/checkValues/choiceValues/sliderValues/choiceCursor/
  openModals/scope (main). isInteractive now also covers non-empty Tabs
  bars. collectFocusables walks only the ACTIVE tab's subtree, so
  inputs and modals inside inactive tabs are excluded from the focus
  ring, and only an open modal's content joins it. Key routing:
  left/right step a focused slider and switch tabs on a focused tab
  bar; h/l switch tabs only on the tab bar and stay literal runes on a
  focused TextField/DateTimeInput; enter/space/esc keep main's
  button/checkbox/choice/modal/InputSubmitted behavior.
- render/composite.go: deleteSurface clears every state map including
  activeTabs; applyComponents preserves active-tab, focus, modal, and
  edited-value state across merges, with out-of-range tabs clamping to
  the first at read time.
- render/tabs_test.go: two new tests pin the union — slider-focused vs
  tab-bar-focused left/right disambiguation, and inputs/modals inside
  an inactive tab excluded from the focus ring.
- README.md / docs/wire-format.md: merged feature descriptions; the
  'not yet' lists are now empty (issues #42#47 all landed).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@joestump
joestump deleted the feat/44-childlist-templates branch July 18, 2026 20:15
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.

ChildList templates: render data-model-driven child lists, not just explicit IDs

1 participant