Fixed
-
in/not_inno longer answer a constant when the right-hand side is not
literally an array.evaluateLeafgated both operators on
Array.isArray(value)and, without one, returnedfalseforinandtrue
fornot_in— for every row, with no validation error and no warning. The
two shapes that hit that path are exactly the two an author writes now that a
{{ref}}on the right-hand side resolves:value: "{{selected}}", where the
multi-select variable's flat value is the JSON-encoded string a
CheckboxGroupwrites ('["sleep","energy"]'), andvalue: ["{{selected}}"]
— the shape Studio's condition editor emits, because it splits its value field
on commas, so the reference lands as the single member of a one-member array.
The first was not an array at all; the second was an array of one JSON string,
which matched no row either. A membership gate over a multi-select therefore
either hid every row or showed every row.The right-hand side is now normalized to a member list: the value and each
of its members are decoded (one level of flattening), so all three shapes —
an authored array, a bare reference, and an array-wrapped reference — mean the
same list. Members compare stringified, because a decoded JSON array keeps
its members typed andRepeatdeliberately keeps a numeric row field numeric,
sonot_in(1, [1, 2])used to betrue.containsagainst an array variable
shares that comparison, so the two operators can no longer disagree about the
same data.Unchanged: an empty list (
value: [], what an empty Studio value field
yields) and a reference to a variable nobody has written both have no members,
soinmatches nothing andnot_inmatches everything — as documented. That
holds in either shape: an unresolved reference interpolates to the empty
string, and an empty-string member is dropped rather than kept as a member
that is empty, sovalue: ["{{unset}}"]cannot disagree with
value: "{{unset}}"— which matters because an empty-string variable is
reachable (InputElementwrites""on clear,Input.defaultValue: ""). A
right-hand side that is no list at all (operator: "in", value: "male") now
reads as a one-member list and logs aconsole.warnnaming the operator,
rather than silently answering a constant; nothing Studio can author produces
that shape. Everything routing through the shared evaluator inherits the fix:
elementrenderWhen,Button disabledWhen, and step branching in
resolveNextStepNumber. Refs #225; unblocks the "available"/"not yet tagged"
buckets of a grouped or dual-list multi-select. -
A condition can now compare a variable against another variable.
evaluateLeafcomparedcondition.valueverbatim, so a condition's
right-hand side could only ever be an authored literal — yet
screens/elements/RepeatElement.tshas always documented
{ variable: "item.sign", operator: "eq", value: "{{zodiacSign}}" }as the
way to makeRepeatbehave as a switch, and cites it as the reason there is no
separateMatchelement. That comparison never matched. It tested the
row's sign against the 14-character string"{{zodiacSign}}"and failed
silently — no validation error, no warning, just a repeated subtree in which
every row was filtered out. A documented contract that did not work.The rule now is:
{{name}}on a condition's right-hand side is resolved
against the variable map before the comparison, including inside an
in/not_inarray. A reference resolves to the variable's value, not
its display label — the same convention asImage mode:"expression"— so a
gate compares machine identifiers rather than translated copy. An unknown
reference resolves to the empty string rather than throwing, so a gate on a
variable nobody has written yet simply does not match, exactly as an
unsatisfied literal comparison would.Because this lands in the shared evaluator, everything that routes through it
inherits it with no other change: elementrenderWhen, and step-branch
routing inresolveNextStepNumber— a branch may now compare two answers
instead of a stored answer against a constant. Hosts calling the exported
evaluateCondition/evaluateLeafget the same behaviour. Refs #217; see
the UI package's CHANGELOG for the matching fix to the UI-thread gate, which
bypasses this function. -
A screen no longer fails because it contains an element type the installed
app does not know.UIElementSchemais az.discriminatedUnion("type", …)
over the element types a build knows, so a type published after an app shipped
missed every branch and failed the wholeelementsarray — and the
ComposableScreen renderer parsed with a throwing.parse. Publishing one new
element type therefore took down the entire screen on every already-installed
app, not just the element it could not draw: the error fallback has no
interactive control, and the back chevron lives in<ProgressBar>behind the
step'sdisplayProgressHeader, so on a header-off step there was no exit in
either direction —onContinuehad died with the subtree.The rule now is: an element type this build cannot render is omitted with its
subtree in front of the parse, reported, and the rest of the screen renders.
Publishing a screen that uses a new element type is safe for older apps, which
it was not before.Loosening the union was deliberately not the fix. A catch-all branch would have
swallowed real data bugs as well, so everything that is not an unknown type
still parses strictly: avariantoutside its enum or a missingidkeeps
failing loudly with its exact path.ScreenElementsSchemaitself stays
strict, so authoring- and publish-time validation still reports a typo'd
element type as an error rather than quietly dropping it. Omit is the contract
the runtime already implements at its other boundaries —renderElement's
terminalreturn null,buildAnimation's unknown-preset no-op,
OnboardingPage's unknown-step skip.A strip can take the screen's only way forward, so the same call now answers
for that too. A ComposableScreen authors its CTA inside the element tree,
so dropping an unknown root container leaveselements: [], which parses
cleanly: the loud throw this replaced would have become a silent screen with
nothing to press.resolveRenderableStepreturnsneedsEscapewhen nothing
that survived can complete the step —hasCompletingActionwalks the surviving
tree for a press-reachable"continue"or{type:"dismiss"}, following
runActions, the only thing in the runtime that callsonContinue— and the
renderer supplies its own button. Only ever after a strip: an authored screen
with no CTA is the author's business and does not acquire an SDK button.What this does not cover: no
sdkVersionreaches the backend, so Studio
cannot compute a capability floor or gate a publish on it. An element type
published to an audience running older builds is a partial screen plus a
warning in the host's logs — not an error anyone is shown before it ships.
Added
-
setVariable valueMode: "expression"now documents a function stdlib, and
the shipped example payload demonstrates it. Nothing in this package's schema
changed —valueis still a plain string and the grammar is not
schema-encoded — butSetVariableButtonAction's JSDoc is where an author
reads what an expression may contain, and theonboarding-example.ts
ComposableScreen step now computes a goal date, a grammatical goal sentence
and a weekly pace from one press. The evaluator itself lives in the UI package
(Runtime/elements/expression.ts); the two are joined by a peer-dependency
range, so on a UI build older than that the call cannot tokenize and the
template falls back to plain interpolation, storing the literal source text. -
resolveRenderableStepis public API — the whole render-boundary decision
in one pure call: what to parse, what to report, and whether the renderer must
supply its own way off the screen.deriveElementTypeNames(schema)is exported with it, because a rendering
package must key the strip on its own element union rather than this one's.
The two packages are peers joined by a peer-dependency range, so their
installed versions can legitimately differ; keying on the headless schema could
strip an element the installed UI draws perfectly well, or keep one it cannot
draw and throw the screen anyway. Mechanism shared, answer per package.Also exported:
KNOWN_ELEMENT_TYPES(this build's capability list, which is
what a publish-time gate would need),dropUnknownElementTypes/
dropUnknownElementTypesInStep,collectUnknownElementTypes/
collectUnknownElementTypesInSteps,formatUnknownElementTypes, and
hasCompletingAction.