Skip to content

v1.75.0

Latest

Choose a tag to compare

@github-actions github-actions released this 07 Sep 08:43
· 5 commits to main since this release
56207c5

Fixed

  • in / not_in no longer answer a constant when the right-hand side is not
    literally an array.
    evaluateLeaf gated both operators on
    Array.isArray(value) and, without one, returned false for in and true
    for not_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
    CheckboxGroup writes ('["sleep","energy"]'), and value: ["{{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 and Repeat deliberately keeps a numeric row field numeric,
    so not_in(1, [1, 2]) used to be true. contains against 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,
    so in matches nothing and not_in matches 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, so value: ["{{unset}}"] cannot disagree with
    value: "{{unset}}" — which matters because an empty-string variable is
    reachable (InputElement writes "" 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 a console.warn naming the operator,
    rather than silently answering a constant; nothing Studio can author produces
    that shape. Everything routing through the shared evaluator inherits the fix:
    element renderWhen, 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.
    evaluateLeaf compared condition.value verbatim, so a condition's
    right-hand side could only ever be an authored literal — yet
    screens/elements/RepeatElement.ts has always documented
    { variable: "item.sign", operator: "eq", value: "{{zodiacSign}}" } as the
    way to make Repeat behave as a switch, and cites it as the reason there is no
    separate Match element. 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_in array. A reference resolves to the variable's value, not
    its display label — the same convention as Image 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: element renderWhen, and step-branch
    routing in resolveNextStepNumber — a branch may now compare two answers
    instead of a stored answer against a constant. Hosts calling the exported
    evaluateCondition / evaluateLeaf get 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.
    UIElementSchema is a z.discriminatedUnion("type", …)
    over the element types a build knows, so a type published after an app shipped
    missed every branch and failed the whole elements array — 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's displayProgressHeader, so on a header-off step there was no exit in
    either direction — onContinue had 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: a variant outside its enum or a missing id keeps
    failing loudly with its exact path. ScreenElementsSchema itself 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
    terminal return 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 leaves elements: [], which parses
    cleanly: the loud throw this replaced would have become a silent screen with
    nothing to press. resolveRenderableStep returns needsEscape when nothing
    that survived can complete the step — hasCompletingAction walks the surviving
    tree for a press-reachable "continue" or {type:"dismiss"}, following
    runActions, the only thing in the runtime that calls onContinue — 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 sdkVersion reaches 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 — value is still a plain string and the grammar is not
    schema-encoded — but SetVariableButtonAction's JSDoc is where an author
    reads what an expression may contain, and the onboarding-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.

  • resolveRenderableStep is 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.