feat(scenario): let a scenario declare a component once and repeat it over data - #174
Merged
Conversation
… over data
Closes the "parameterised templates and data iteration" gap, aimed at the
failure mode the original audit named as dominant: a generator duplicating a
repeated structure by hand, every copy an opportunity to drift.
Ten near-identical cards used to be ten JSON subtrees written out. Now:
"components": { "stat_card": { "params": {...}, "template": {...} } },
"children": [{ "for-each": [ {...}, {...}, {...} ],
"template": { "use": "stat_card", "props": {...} } }]
`params` reuses `config`'s exact shape, so a component parameter is a
variable scoped to one instance instead of the file. Omitting `default`
makes it required.
The overrides key is `props`, not `config`, and that is not an
inconsistency: `variables::substitute` deliberately skips any object
carrying a literal `config` key, to protect the root declarations block.
Reusing the name would have left every `for-each` nested inside a `use`
silently unsubstituted — the failure mode this work exists to remove.
**`--fix` refuses on these scenarios**, exactly as it already does for
`include`. Violation paths carry resolved indices while `--fix` navigates
raw JSON; an iteration over ten items shifts everything after it by nine.
PR #145 established the refusal for `include` and #160 confirmed doing
better is impractical. Patching the wrong node silently is the one
unacceptable answer.
Every failure gets a named error saying where: a cycle reports the chain
rather than overflowing the stack, iterating a non-array reports what it
found instead, an unknown component, a missing required parameter and an
undeclared prop key all say so. A `for-each` that silently produced nothing
because a key was misspelled would be the worst possible outcome here.
Pass order is fixed and documented: substitution, then expansion, then
`include`, per document. So a `for-each` can iterate an array that came from
a `config` variable, and `components` is strictly file-local — reaching into
an included file's definitions is a named error in both directions rather
than an accident of scope.
One correction to the delivered work: the pre-expansion unresolved-reference
scan reported every template binding as a typo — six warnings on the
canonical example, each accusing the author of a mistake they had not made.
Warnings that are reliably wrong teach the reader to ignore warnings, which
would have cost more than the scan is worth, and would have undermined every
real diagnostic this chantier added. That scan now skips the directive
bodies, and runs again after expansion where the keys are gone and a
leftover `$name` is unambiguous. Both directions are tested: a correct
binding warns about nothing, a misspelled one is still caught.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes the "parameterised templates and data iteration" gap (High/M), aimed at the failure mode the original audit named as dominant: a generator duplicating a repeated structure by hand, every copy an opportunity to drift.
Ten near-identical cards used to mean ten JSON subtrees written out, each able to diverge on a colour, a
font-size, a strayposition. There was no way to say "this card, ten times, with this data".The shape
{ "components": { "stat_card": { "params": { "label": { "type": "string" }, "value": { "type": "number", "default": 0 }, "accent": { "type": "string", "default": "#6366F1" } }, "template": { "type": "card", "style": { "width": "300px", "height": "160px", "background": "#111827" }, "children": [ { "type": "text", "content": "$label", "style": { "color": "#94A3B8" } }, { "type": "counter", "from": 0, "to": "$value", "style": { "color": "$accent" } } ] } } }, "scenes": [{ "duration": 3.0, "layout": { "direction": "row", "gap": 24, "justify_content": "center" }, "children": [{ "for-each": [ { "label": "Revenue", "value": 1250, "accent": "#22C55E" }, { "label": "Users", "value": 340, "accent": "#3B82F6" }, { "label": "Growth", "value": 8, "accent": "#F59E0B" } ], "template": { "use": "stat_card", "props": { "label": "$label", "value": "$value", "accent": "$accent" } } }] }] }paramsreusesconfig's exact shape — a component parameter is a variable scoped to one instance instead of the whole file. Omittingdefaultmakes it required.Why the overrides key is
propsand notconfig. Not an inconsistency:variables::substitutedeliberately skips any object carrying a literalconfigkey, to protect the root declarations block. Reusing that name would have left everyfor-eachnested inside ausesilently unsubstituted — precisely the failure mode this work exists to remove.The index trap, answered the way this repo already answered it
Violation paths carry resolved indices while
--fixnavigates raw JSON. An iteration over ten items shifts everything after it by nine — the same defectincludehas, in a worse form.--fixrefuses, viaFixRefusal::UsesTemplateDirectivesalongside the existingUsesInclude. PR #145 established that refusal and #160 confirmed doing better is impractical. An end-to-end test drivescmd_validate --fixover an iterating scenario with a real geometry violation and asserts the file comes back byte-identical.The validator sees the expanded tree, so geometry is checked against what actually renders.
Nothing fails silently
A cycle reports the chain (
a -> b -> a) rather than overflowing the stack. Iterating a non-array reports what it found instead, and flags a$xxx-shaped value as a probable unresolved reference. An unknown component, a missing required parameter and an undeclared prop key each say so, with the structural path. Afor-eachthat quietly produced nothing because a key was misspelled would be the worst available outcome.Pass order, fixed and tested
Substitution → expansion →
include, per document, each included file running the same pipeline independently. Consequences, both tested:for-eachcan iterate an array that came from aconfigvariable or--var.componentsis strictly file-local: reaching into an included file's definitions is a named error, in both directions, rather than an accident of scope.One correction to the delivered work
The pre-expansion unresolved-reference scan reported every template binding as a typo — six warnings on the canonical example above, each telling the author they had misspelled a variable that in fact resolves perfectly.
That is not cosmetic. For a feature aimed at generated scenarios, a warning that is reliably wrong teaches the reader to ignore warnings, which would have undermined every genuine diagnostic this chantier has added — including the ones in this PR.
The scan now skips the directive bodies (
template,props,components), matching the precedent already in that function forconfig, and runs again after expansion, where those keys are gone and a leftover$nameis unambiguous. Both directions are pinned by tests: a correct binding warns about nothing, a misspelled$lablis still reported.Verification
cargo test --workspace: 27 targets, 1078 tests, 0 failurescargo fmt --all --checkandcargo clippy --workspace --all-targets -- -D warnings: cleanmainfor-eachtree and the hand-written equivalent resolve to identicalResolvedScenariochildren, compared through the real loader — the only proof that factoring changes nothing about the renderNot covered
scenes[]level — the named case ("ten cards") is achildrenproblem, and touchingSceneEntry's bespoke deserializer would have widened the surface without a demonstrated need.rustmotion schemadoes not document the new vocabulary: these keys are consumed beforeScenariois deserialized, so they never appear in the struct schemars reads. No functional impact, but a schema-driven autocomplete would not suggest them.rustmotion-studioedits by indexed JSON pointer and inherits the same index risk as--fix. Out of the listed scope, flagged rather than discovered later.CLAUDE.mdand the skills rules do not yet document the syntax.