tree payload: server-owned child role, slot wrapper and summary verdicts (drift audit 1, 2, 5) #243

Closed
opened 2026-10-12 22:46:34 +02:00 by dominik.polakovics · 0 comments

This was generated by AI during triage.

Parent

#234 (drift audit), items 1, 2 and 5.

Summary

The server decides, once per node, what role a child plays in the element tree (item row, slot wrapper, nested element, body text) and what its summary line is, and sends those verdicts in the tree payload. Every server path and the whole web client read the verdict instead of re-deriving it from the child's own schema flags. Today the three rules exist in nine places that already disagree.

Current behavior

Confirmed at dcc1067.

Child role. elemops.IsItem(ownerSchema, child) (introduced by #239) decides "is this child an item of its parent" from the parent's items.element. Every other place decides from the child's schema, hidden || group == inline:

  • server/pending.go isFoldedElement, server/linktargets.go (row listing for the link picker);
  • client api/content.ts isInlineGroupNode, isHiddenNode, flattenChildren; elementcard.ts isCardNode, hasSubList; and the move-sibling visibility check in elementcrud.ts, which does not exclude hidden nodes and so disagrees even with isCardNode.

The loader (element/load.go validateItems) only requires an item element to exist and not be inline. It does not require hidden: true, and nothing requires a hidden element to be some owner's item. The two rules coincide only because every shipped item element (img, card) happens to set hidden: true. A non-hidden item element is an item to the server but a card, link target and pending element to the client. A hidden element placed outside its owner is invisible to the client but is not an item to IsItem.

Slot wrapper. Four behaviours:

Where Rule
elemops.SlotWrapperName (all of l10n) block, named slot, non-empty name param
server/linktargets.go top-level block named slot, no name check
server/pending.go any node named slot, any depth, folded into the parent's fingerprint
client elementcrud.ts isSlotWrapper, api/content.ts flattenChildren block named slot, no name check, depth 1

A nameless top-level {{< slot >}} is transparent to the client and to pending/linktargets but an ordinary element to l10n, which will give it a uid. A self-closing {{< slot />}} is dropped by linktargets and pending and is a normal node everywhere else.

Summary. elemops.ElementSummary (schema summary key, then the first SummaryText param, then the basename of the first SummaryBasename param) is what the link picker and link-resolve label show. ElementForm.tsx rowSummary follows the same order from the form's summary roles. elementcard.ts elementSummary is different: body text first, then every text/textarea param joined with ·, never the summary key, never a basename. A card and the link picker describe the same element differently. server/linktargets_test.go says it "mirrors rowSummary" in a comment; nothing runs the two against each other.

Payload. GET /api/page returns content.Document; Shortcode.MarshalJSON (internal/content/json.go) emits {kind, name, markup, params, children} and nothing about role or summary. The client derives everything from /api/elements.

Desired behavior

  • One function in elemops owns the child-role rule and returns a verdict for a (parent schema, child) pair: item, slot, element or text. IsItem and SlotWrapperName are its building blocks or are folded into it. holdsElement in l10n, isFoldedElement, the link-picker row listing and the translation itemAt all call it; no server path reads hidden or group == inline to decide a role.
  • One function owns the slot-wrapper rule for all callers, with the depth rule explicit (a wrapper is a top-level block slot; what a nameless one or a nested one is must be decided once and documented). Pending, linktargets, the client and l10n give the same answer for the nameless, nested and self-closing cases.
  • The tree payload carries, per shortcode node, its role verdict and its summary string as computed by ElementSummary. The client reads them. isInlineGroupNode, isHiddenNode, isCardNode, hasSubList, isSlotWrapper, SLOT_WRAPPER_NAME, the elementcrud.ts visibility check and elementcard.ts elementSummary either disappear or become readers of the payload field. The client no longer needs a schema lookup to render the tree, the cards, the translation grid, the link picker or the pending count.
  • The loader enforces the convention: an element named by some owner's items.element must declare hidden: true, and an element declaring hidden: true must be named by some owner's items.element. Either violation is a schema error reported the way other sidecar errors are (harness check and the editor's schema-error list).
  • scaffold/site/AGENTS.md's "Insertability" paragraph (the hidden: true sentence) and "Item collections" state the enforced rule, and the sidecar schema section documents hidden as "only for items children, enforced". The add-element skill's items/hidden guidance points at that section.

Decisions

  • Role is derived from the parent, served by the server. The parent's items.element is the only thing that makes a child an item; the child's flags are presentation. The server already owns IsItem since #239, and the CLAUDE.md one-owner rule says the client does not re-derive: a verdict in the payload is the chosen form, as the form's summary roles already are.
  • Enforce hidden ⇔ item in the loader rather than let the two drift: the maintainer's call. The alternative, letting the client render non-hidden items as cards, would make the editor show a row the form also edits inline.
  • Summary is served, not fixtured. It depends on schema roles plus the node's params, which the server already has; one string per node is cheaper than shipping the rule to the client. The card's "body text first" behaviour is dropped; the card shows the same line as the link picker. If an element has no summary the card falls back to the element label, as rowSummary does with the item label today.
  • Slot wrapper = top-level block named slot. The name param is required by the sidecar, so a nameless one is a schema violation the loader or harness check reports, not a case each reader resolves differently. A nested slot is an ordinary element (what the client does today) and l10n keeps treating a below-top-level wrapper as opaque body only if that is what this single rule says; the implementer picks one answer and the tests pin it for every reader.

Key interfaces

  • elemops.IsItem, elemops.SlotWrapperName, elemops.ElementSummary (internal/elemops/items.go, slots.go, summary.go) — existing owners to build on.
  • content.Shortcode JSON shape (internal/content/json.go) and the client BlockNode/InlineNode types (web/src/api/content.ts) — gain the role and summary fields. If content must stay free of schema knowledge, the server attaches the verdicts in its page answer (server/translation.go pageAnswer) rather than in content.
  • element.Schema.Hidden, validateItems (internal/element/load.go) — the enforcement.
  • Client readers: web/src/api/content.ts, elementcard.ts, elementcrud.ts, translationgrid.ts, components/ElementCard.tsx, LanguagesView.tsx, TranslationPlaceholder.tsx, ElementForm.tsx.
  • Server readers: server/pending.go, server/linktargets.go, server/translation.go, l10n/l10n.go holdsElement.

Rules touched and their owners (CLAUDE.md)

  • Child role: elemops (new or extended IsItem); served to the client.
  • Slot wrapper: elemops.SlotWrapperName; served to the client.
  • Element summary: elemops.ElementSummary; served to the client.
  • Items element must be hidden: element loader.

Affected docs and skills

  • scaffold/site/AGENTS.md: "Insertability", "Item collections", the sidecar schema's hidden line, and "Slots" if the nameless/nested answer changes documented behaviour.
  • scaffold/site/.claude/skills/add-element/SKILL.md: items/hidden guidance points at the section; no restated rule.
  • internal/element/agentsdoc_test.go and scaffold/skills_test.go stay green.

Acceptance criteria

  • No server package other than elemops evaluates "hidden or inline" to decide whether a node is an item, and no server package other than elemops matches the name slot.
  • The tree payload of GET /api/page carries a role verdict and a summary string per shortcode node, documented in the payload's Go type and TypeScript type.
  • web/src contains no function that derives item, card, slot-wrapper or summary from a node's schema; grep for isHiddenNode, isInlineGroupNode, isCardNode, isSlotWrapper, SLOT_WRAPPER_NAME and elementSummary finds only payload readers or nothing.
  • A card, the link picker and the translation grid show the same summary for the same element (a vitest or Go test asserts the card reads the served string).
  • A sidecar declaring hidden: true for an element no owner names as its item, or naming an item element that is not hidden, fails to load with a reported schema error; harness check on the scaffold site still passes.
  • A nameless, a nested and a self-closing slot each get one documented treatment, asserted by a test on pending, on the link picker, on l10n and on the client.
  • The move-sibling visibility check and the pending fingerprint agree with the card rule for a hidden node (regression test for the elementcrud.ts discrepancy).
  • AGENTS.md and the add-element skill updated as above; drift tests green.

How to verify

  • go vet ./... && go test ./...; cd web && npm test.
  • harness check --site scaffold/site passes; a temporary sidecar with hidden: true and no owner makes it fail.
  • Start harness serve on the scaffold site: open the showcase page, the text-media and figures cards show the picker's summary; the link picker's element list and the cards list the same rows.

Assumptions to check

  • content.Shortcode can grow JSON-only fields without breaking the parse/serialize round trip and the fuzz corpus (internal/content/testdata); otherwise attach the verdicts in the server's page answer.
  • Every scaffold and feature-bundle item element already sets hidden: true (checked for img and card; check every bundle under scaffold/ and deckle-hosted's site templates, which this repo imports read-only).
  • l10n's "below-top-level wrapper is opaque body" (l10n/uids.go) is compatible with whichever nested-slot answer is chosen.

Out of scope

  • Everything else in #234: chip grammar, defaults, placement, links, params, name grammars, loader helpers, fixtures, skill prose (tracked in the sibling child issue).
  • Changing the canonical row layout (#239) or the items split/compose.
  • New client features beyond reading the served verdicts.

Blocked by

None - can start immediately.

> *This was generated by AI during triage.* ## Parent #234 (drift audit), items 1, 2 and 5. ## Summary The server decides, once per node, what role a child plays in the element tree (item row, slot wrapper, nested element, body text) and what its summary line is, and sends those verdicts in the tree payload. Every server path and the whole web client read the verdict instead of re-deriving it from the child's own schema flags. Today the three rules exist in nine places that already disagree. ## Current behavior Confirmed at `dcc1067`. **Child role.** `elemops.IsItem(ownerSchema, child)` (introduced by #239) decides "is this child an item of its parent" from the **parent's** `items.element`. Every other place decides from the **child's** schema, `hidden || group == inline`: - `server/pending.go` `isFoldedElement`, `server/linktargets.go` (row listing for the link picker); - client `api/content.ts` `isInlineGroupNode`, `isHiddenNode`, `flattenChildren`; `elementcard.ts` `isCardNode`, `hasSubList`; and the move-sibling visibility check in `elementcrud.ts`, which does not exclude hidden nodes and so disagrees even with `isCardNode`. The loader (`element/load.go` `validateItems`) only requires an item element to exist and not be inline. It does not require `hidden: true`, and nothing requires a hidden element to be some owner's item. The two rules coincide only because every shipped item element (`img`, `card`) happens to set `hidden: true`. A non-hidden item element is an item to the server but a card, link target and pending element to the client. A hidden element placed outside its owner is invisible to the client but is not an item to `IsItem`. **Slot wrapper.** Four behaviours: | Where | Rule | |---|---| | `elemops.SlotWrapperName` (all of l10n) | block, named `slot`, non-empty `name` param | | `server/linktargets.go` | top-level block named `slot`, no `name` check | | `server/pending.go` | any node named `slot`, any depth, folded into the parent's fingerprint | | client `elementcrud.ts` `isSlotWrapper`, `api/content.ts` `flattenChildren` | block named `slot`, no `name` check, depth 1 | A nameless top-level `{{< slot >}}` is transparent to the client and to pending/linktargets but an ordinary element to l10n, which will give it a uid. A self-closing `{{< slot />}}` is dropped by linktargets and pending and is a normal node everywhere else. **Summary.** `elemops.ElementSummary` (schema `summary` key, then the first `SummaryText` param, then the basename of the first `SummaryBasename` param) is what the link picker and link-resolve label show. `ElementForm.tsx` `rowSummary` follows the same order from the form's summary roles. `elementcard.ts` `elementSummary` is different: body text first, then every `text`/`textarea` param joined with ` · `, never the `summary` key, never a basename. A card and the link picker describe the same element differently. `server/linktargets_test.go` says it "mirrors `rowSummary`" in a comment; nothing runs the two against each other. **Payload.** `GET /api/page` returns `content.Document`; `Shortcode.MarshalJSON` (`internal/content/json.go`) emits `{kind, name, markup, params, children}` and nothing about role or summary. The client derives everything from `/api/elements`. ## Desired behavior - One function in `elemops` owns the child-role rule and returns a verdict for a (parent schema, child) pair: `item`, `slot`, `element` or `text`. `IsItem` and `SlotWrapperName` are its building blocks or are folded into it. `holdsElement` in l10n, `isFoldedElement`, the link-picker row listing and the translation `itemAt` all call it; no server path reads `hidden` or `group == inline` to decide a role. - One function owns the slot-wrapper rule for all callers, with the depth rule explicit (a wrapper is a top-level block `slot`; what a nameless one or a nested one is must be decided once and documented). Pending, linktargets, the client and l10n give the same answer for the nameless, nested and self-closing cases. - The tree payload carries, per shortcode node, its role verdict and its summary string as computed by `ElementSummary`. The client reads them. `isInlineGroupNode`, `isHiddenNode`, `isCardNode`, `hasSubList`, `isSlotWrapper`, `SLOT_WRAPPER_NAME`, the `elementcrud.ts` visibility check and `elementcard.ts` `elementSummary` either disappear or become readers of the payload field. The client no longer needs a schema lookup to render the tree, the cards, the translation grid, the link picker or the pending count. - The loader enforces the convention: an element named by some owner's `items.element` must declare `hidden: true`, and an element declaring `hidden: true` must be named by some owner's `items.element`. Either violation is a schema error reported the way other sidecar errors are (`harness check` and the editor's schema-error list). - `scaffold/site/AGENTS.md`'s "Insertability" paragraph (the `hidden: true` sentence) and "Item collections" state the enforced rule, and the sidecar schema section documents `hidden` as "only for items children, enforced". The `add-element` skill's items/hidden guidance points at that section. ## Decisions - **Role is derived from the parent, served by the server.** The parent's `items.element` is the only thing that makes a child an item; the child's flags are presentation. The server already owns `IsItem` since #239, and the CLAUDE.md one-owner rule says the client does not re-derive: a verdict in the payload is the chosen form, as the form's summary roles already are. - **Enforce `hidden` ⇔ item in the loader** rather than let the two drift: the maintainer's call. The alternative, letting the client render non-hidden items as cards, would make the editor show a row the form also edits inline. - **Summary is served, not fixtured.** It depends on schema roles plus the node's params, which the server already has; one string per node is cheaper than shipping the rule to the client. The card's "body text first" behaviour is dropped; the card shows the same line as the link picker. If an element has no summary the card falls back to the element label, as `rowSummary` does with the item label today. - **Slot wrapper = top-level block named `slot`.** The `name` param is required by the sidecar, so a nameless one is a schema violation the loader or `harness check` reports, not a case each reader resolves differently. A nested `slot` is an ordinary element (what the client does today) and l10n keeps treating a below-top-level wrapper as opaque body only if that is what this single rule says; the implementer picks one answer and the tests pin it for every reader. ## Key interfaces - `elemops.IsItem`, `elemops.SlotWrapperName`, `elemops.ElementSummary` (`internal/elemops/items.go`, `slots.go`, `summary.go`) — existing owners to build on. - `content.Shortcode` JSON shape (`internal/content/json.go`) and the client `BlockNode`/`InlineNode` types (`web/src/api/content.ts`) — gain the role and summary fields. If `content` must stay free of schema knowledge, the server attaches the verdicts in its page answer (`server/translation.go` `pageAnswer`) rather than in `content`. - `element.Schema.Hidden`, `validateItems` (`internal/element/load.go`) — the enforcement. - Client readers: `web/src/api/content.ts`, `elementcard.ts`, `elementcrud.ts`, `translationgrid.ts`, `components/ElementCard.tsx`, `LanguagesView.tsx`, `TranslationPlaceholder.tsx`, `ElementForm.tsx`. - Server readers: `server/pending.go`, `server/linktargets.go`, `server/translation.go`, `l10n/l10n.go` `holdsElement`. ## Rules touched and their owners (CLAUDE.md) - Child role: `elemops` (new or extended `IsItem`); served to the client. - Slot wrapper: `elemops.SlotWrapperName`; served to the client. - Element summary: `elemops.ElementSummary`; served to the client. - Items element must be hidden: `element` loader. ## Affected docs and skills - `scaffold/site/AGENTS.md`: "Insertability", "Item collections", the sidecar schema's `hidden` line, and "Slots" if the nameless/nested answer changes documented behaviour. - `scaffold/site/.claude/skills/add-element/SKILL.md`: items/hidden guidance points at the section; no restated rule. - `internal/element/agentsdoc_test.go` and `scaffold/skills_test.go` stay green. ## Acceptance criteria - [ ] No server package other than `elemops` evaluates "hidden or inline" to decide whether a node is an item, and no server package other than `elemops` matches the name `slot`. - [ ] The tree payload of `GET /api/page` carries a role verdict and a summary string per shortcode node, documented in the payload's Go type and TypeScript type. - [ ] `web/src` contains no function that derives item, card, slot-wrapper or summary from a node's schema; `grep` for `isHiddenNode`, `isInlineGroupNode`, `isCardNode`, `isSlotWrapper`, `SLOT_WRAPPER_NAME` and `elementSummary` finds only payload readers or nothing. - [ ] A card, the link picker and the translation grid show the same summary for the same element (a vitest or Go test asserts the card reads the served string). - [ ] A sidecar declaring `hidden: true` for an element no owner names as its item, or naming an item element that is not hidden, fails to load with a reported schema error; `harness check` on the scaffold site still passes. - [ ] A nameless, a nested and a self-closing `slot` each get one documented treatment, asserted by a test on pending, on the link picker, on l10n and on the client. - [ ] The move-sibling visibility check and the pending fingerprint agree with the card rule for a hidden node (regression test for the `elementcrud.ts` discrepancy). - [ ] AGENTS.md and the `add-element` skill updated as above; drift tests green. ## How to verify - `go vet ./... && go test ./...`; `cd web && npm test`. - `harness check --site scaffold/site` passes; a temporary sidecar with `hidden: true` and no owner makes it fail. - Start `harness serve` on the scaffold site: open the showcase page, the text-media and figures cards show the picker's summary; the link picker's element list and the cards list the same rows. ## Assumptions to check - `content.Shortcode` can grow JSON-only fields without breaking the parse/serialize round trip and the fuzz corpus (`internal/content/testdata`); otherwise attach the verdicts in the server's page answer. - Every scaffold and feature-bundle item element already sets `hidden: true` (checked for `img` and `card`; check every bundle under `scaffold/` and `deckle-hosted`'s site templates, which this repo imports read-only). - l10n's "below-top-level wrapper is opaque body" (`l10n/uids.go`) is compatible with whichever nested-slot answer is chosen. ## Out of scope - Everything else in #234: chip grammar, defaults, placement, links, params, name grammars, loader helpers, fixtures, skill prose (tracked in the sibling child issue). - Changing the canonical row layout (#239) or the items split/compose. - New client features beyond reading the served verdicts. ## Blocked by None - can start immediately.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
Cloonar/deckle#243
No description provided.