@roku-sdk/compat 0.0.0-stage → 2.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Roku, Inc.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,41 @@
1
- # Temporary Holding Version
1
+ # @roku-sdk/compat
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Shims for SceneGraph nodes that the SDK renders but firmware does not report as supported.
4
+
5
+ The package is a side-effect import. `rk build` and `rk dev` inject it ahead of the app entry, so
6
+ apps do not import it themselves. Set `app.compat: false` in `roku-config.ts` to leave it out.
7
+
8
+ ## What it does
9
+
10
+ Each shim registers a tag alias with `registerTagAlias` from `@roku-sdk/solid`. When the tag is
11
+ created, the alias asks `hasFeature("tween")` from `@roku-sdk/runtime/has-feature` whether the firmware
12
+ has the real node. If it does, the real node is created. If not, the shim is created instead.
13
+
14
+ | Tag | Native node | Firmware feature | Shim |
15
+ | --------- | ----------- | -------------------------- | -------------- |
16
+ | `<tween>` | `Tween` | `roku.hasFeature("tween")` | `RsgTweenShim` |
17
+
18
+ `hasFeature` returns false when `roku` or `roku.hasFeature` is absent, including Jest.
19
+ The firmware feature set starts empty;
20
+ until firmware adds `"tween"`, devices use the shim regardless of their OS version.
21
+
22
+ When `app.compat: false`, the Roku build injects only the native-or-refusal registration, not
23
+ the shim XML or BrightScript. Importing `@roku-sdk/rsg-ts` alone performs no registration; only
24
+ the Roku build injects these entries.
25
+
26
+ In a Roku build without shims, creating a gated tag when its feature is not reported throws an
27
+ error naming the missing firmware feature and this package. That is deliberate: a SceneGraph node type the
28
+ firmware does not have fails silently and leaves an empty screen.
29
+
30
+ ## Retiring a shim
31
+
32
+ Removing a shim drops support for every firmware that does not report its native feature, so it is a breaking
33
+ change:
34
+
35
+ - Remove shims only in a major SDK release, and state which firmware features that release requires.
36
+ - Batch removals into one release rather than shipping one breaking release per shim.
37
+ - Remove the shim's directory here and its entry in `refuse-gated-tags.ts` once all supported
38
+ firmware reports the feature. `@roku-sdk/solid` does not change.
39
+
40
+ An app that must keep running on older firmware can stay on the last `@roku-sdk/compat` release
41
+ that has the shim it needs.
@@ -0,0 +1,3 @@
1
+ import rokuSdkToolsLinterConfig from "@roku-sdk/dev-tools/linter-config/eslint.config.mjs";
2
+
3
+ export default rokuSdkToolsLinterConfig;
package/package.json CHANGED
@@ -1,6 +1,57 @@
1
1
  {
2
2
  "name": "@roku-sdk/compat",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "2.19.0",
4
+ "description": "Shims that stand in for SceneGraph nodes older Roku OS firmware lacks",
5
+ "sideEffects": true,
6
+ "main": "src/index.ts",
7
+ "exports": {
8
+ ".": "./src/index.ts",
9
+ "./refuse-gated-tags": "./src/refuse-gated-tags.ts"
10
+ },
11
+ "type": "module",
12
+ "files": [
13
+ "src/",
14
+ "eslint.config.mjs",
15
+ "tsconfig.json",
16
+ "xml.d.ts",
17
+ "README.md"
18
+ ],
19
+ "engines": {
20
+ "npm": ">=8.5.0",
21
+ "node": ">=24.0.0"
22
+ },
23
+ "peerDependencies": {
24
+ "@roku-sdk/runtime": "^2.19.0",
25
+ "@roku-sdk/solid": "^2.19.0"
26
+ },
27
+ "devDependencies": {
28
+ "@jest/globals": "^30.4.1",
29
+ "@types/jest": "^30.0.0",
30
+ "jest": "^30.4.1",
31
+ "jest-transform-stub": "^2.0.0",
32
+ "@roku-sdk/dev-tools": "2.19.0",
33
+ "@roku-sdk/runtime": "2.19.0",
34
+ "@roku-sdk/interfaces": "2.19.0",
35
+ "@roku-sdk/solid": "2.19.0"
36
+ },
37
+ "author": "Roku Inc",
38
+ "license": "MIT",
39
+ "publishConfig": {
40
+ "access": "public"
41
+ },
42
+ "repository": {
43
+ "type": "git",
44
+ "url": "git+https://gitlab.com/rokudev/rsg-sdk.git",
45
+ "directory": "packages/compat"
46
+ },
47
+ "homepage": "https://gitlab.com/rokudev/rsg-sdk/-/tree/main/packages/compat#readme",
48
+ "bugs": {
49
+ "url": "https://gitlab.com/rokudev/rsg-sdk/-/issues"
50
+ },
51
+ "scripts": {
52
+ "test": "jest --passWithNoTests",
53
+ "coverage": "jest --coverage --runInBand --passWithNoTests --testTimeout=60000 --forceExit",
54
+ "clean": "rm -rf dist",
55
+ "lint": "eslint --no-error-on-unmatched-pattern \"src/**/*.{ts,tsx,mjs,cjs,js}\""
56
+ }
6
57
  }
@@ -0,0 +1,31 @@
1
+ // Copyright (c) 2026 Roku, Inc.
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { beforeAll, describe, expect, jest, test } from "@jest/globals";
5
+ import { hasFeature } from "@roku-sdk/runtime/has-feature";
6
+ import { hasTagAlias, registerTagAlias } from "@roku-sdk/solid";
7
+
8
+ jest.mock("@roku-sdk/solid", () => ({ hasTagAlias: jest.fn(), registerTagAlias: jest.fn() }));
9
+ jest.mock("@roku-sdk/runtime/has-feature", () => ({ hasFeature: jest.fn() }));
10
+
11
+ let resolveTween: (() => string) | undefined;
12
+ beforeAll(async () => {
13
+ await import("../refuse-gated-tags");
14
+ resolveTween = jest.mocked(registerTagAlias).mock.calls.find(([tag]) => tag === "tween")?.[1];
15
+ });
16
+
17
+ describe("tween refusal alias", () => {
18
+ test("checks for an existing alias before registering", () => {
19
+ expect(hasTagAlias).toHaveBeenCalledWith("tween");
20
+ expect(resolveTween).toBeDefined();
21
+ });
22
+ test("keeps the native node only when firmware reports tween", () => {
23
+ jest.mocked(hasFeature).mockReturnValue(true);
24
+ expect(resolveTween!()).toBe("tween");
25
+ expect(hasFeature).toHaveBeenCalledWith("tween");
26
+ });
27
+ test("refuses with the missing feature and compat named", () => {
28
+ jest.mocked(hasFeature).mockReturnValue(false);
29
+ expect(resolveTween!).toThrow('<tween> requires the "tween" firmware feature or @roku-sdk/compat');
30
+ });
31
+ });
@@ -0,0 +1,276 @@
1
+ // Copyright (c) 2026 Roku, Inc.
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ // Source-contract guards, not a BrightScript execution harness. Device parity still needs
5
+ // the tween-parity fixture on both firmware versions; Jest cannot run SceneGraph Animation.
6
+ import { readFileSync } from "node:fs";
7
+ import path from "node:path";
8
+
9
+ import { describe, expect, test } from "@jest/globals";
10
+
11
+ const shim = readFileSync(path.join(__dirname, "../tween/rsg-tween-shim.brs"), "utf8");
12
+ const body = (name: string): string => {
13
+ const match = shim.match(new RegExp(`(?:sub|function) ${name}\\([^]*?end (?:sub|function)`, "i"));
14
+ expect(match).not.toBeNull();
15
+ return match![0];
16
+ };
17
+
18
+ describe("Tween BrightScript native-contract source guards", () => {
19
+ test("binds anonymous target references without requiring a caller-assigned id", () => {
20
+ const bind = body("bindField");
21
+ expect(bind).toContain('if m.top.targetNode.id = "" then');
22
+ expect(bind).toContain('m.top.targetNode.id = "rsg-tween-" + createObject("roDeviceInfo").GetRandomUUID()');
23
+ expect(bind.indexOf("GetRandomUUID()")).toBeLessThan(bind.indexOf("desiredField ="));
24
+ expect(body("onTargetNodeChanged")).not.toContain('if target.id = "" then return');
25
+ });
26
+
27
+ test("uses the last-written scalar or vector trigger", () => {
28
+ expect(body("init")).toContain("m.useScalarTarget = true");
29
+ expect(body("onTargetValueChanged")).toContain("m.useScalarTarget = true");
30
+ expect(body("onTargetVectorChanged")).toContain("m.useScalarTarget = false");
31
+ expect(body("buildVectorTarget")).toContain(
32
+ "if not m.useScalarTarget and m.top.targetVector <> invalid and m.top.targetVector.count() >= 2 then"
33
+ );
34
+ for (const name of ["fireAnimation", "snapWithoutAnim"]) {
35
+ expect(body(name)).toContain(
36
+ "if not m.useScalarTarget and m.top.targetVector <> invalid and m.top.targetVector.count() >= 1 then"
37
+ );
38
+ expect(body(name)).toContain("toVal = m.top.targetVector[0]");
39
+ }
40
+ });
41
+
42
+ test("reports an integer millisecond remainder without 32-bit epoch arithmetic", () => {
43
+ const report = body("fireAnimationStop");
44
+ expect(report).toContain("totalMs# = currentTime.AsSeconds() * 1000#");
45
+ expect(report).toContain("Int(delaySeconds * 1000#)");
46
+ expect(report).toContain("seconds# = Int(totalMs# / 1000#)");
47
+ expect(report).toContain("milliseconds = Int(totalMs# - seconds# * 1000#)");
48
+ expect(report).toContain("[seconds#, milliseconds]");
49
+ expect(
50
+ body("onProposedStopsChanged").match(/fireAnimationStop\(index, closestStop, newDuration\)/g)
51
+ ).toHaveLength(2);
52
+ });
53
+
54
+ test("rejects underfoot candidates in both moving searches and stops zero-distance settles without restarting", () => {
55
+ const selection = body("pickStop1D");
56
+ expect(selection).toContain("stopPosition > fromPos");
57
+ expect(selection).toContain("stopPosition < fromPos");
58
+ expect(selection).not.toMatch(/stopPosition [<>]= fromPos/);
59
+ expect(selection).toContain("closestStop = defaultTo");
60
+ expect(selection).toContain("index = -1");
61
+ // The vector search uses the same strict comparison, per component.
62
+ const vector = body("pickStopVector");
63
+ for (const c of [0, 1]) {
64
+ expect(vector).toContain(`not (candidate[${c}] > fromVec[${c}])`);
65
+ expect(vector).toContain(`not (candidate[${c}] < fromVec[${c}])`);
66
+ }
67
+ expect(vector).not.toMatch(/candidate\[\d\] [<>]= fromVec/);
68
+ const settle = body("onProposedStopsChanged");
69
+ const fastPaths = [...settle.matchAll(/if newDistance = 0 then([^]*?)end if/g)].map(match => match[1]);
70
+ expect(fastPaths).toHaveLength(2);
71
+ for (const fastPath of fastPaths) {
72
+ expect(fastPath).toContain("stopAtValue(");
73
+ expect(fastPath).toContain("fireAnimationStop(index, closestStop, 0)");
74
+ expect(fastPath).toContain("return");
75
+ expect(fastPath).not.toContain('"start"');
76
+ }
77
+ expect(fastPaths[0]).toContain("stopAtValue(fromPos)");
78
+ expect(fastPaths[1]).toContain("stopAtValue(fromVec)");
79
+ });
80
+
81
+ test("pins the stopped value before stop for scalar and vector snaps and settles", () => {
82
+ const stop = body("stopAtValue");
83
+ expect(stop).toMatch(/m\.interp\.keyValue = \[value, value\][^]*m\.anim\.control = "stop"/);
84
+ const snap = body("fireAnimation");
85
+ expect(snap).toContain("stopAtValue(toVal)");
86
+ expect(snap).toContain("stopAtValue(toVec)");
87
+ const settle = body("onProposedStopsChanged");
88
+ expect(settle).toContain("stopAtValue(fromPos)");
89
+ expect(settle).toContain("stopAtValue(fromVec)");
90
+ });
91
+
92
+ test("reads the current easing at the shared start point, including settle re-arms", () => {
93
+ const arm = body("armSpan");
94
+ expect(arm).toMatch(/m\.anim\.easeFunction = m\.top\.easeFunction[^]*m\.anim\.control {4}= "start"/);
95
+ expect(shim.match(/m\.anim\.easeFunction = m\.top\.easeFunction/g)).toHaveLength(1);
96
+ expect(body("onProposedStopsChanged").match(/armSpan\(/g)).toHaveLength(2);
97
+ });
98
+
99
+ test("starts every span through armSpan, which records the armed destination", () => {
100
+ // The retry timer's start is the only other.
101
+ expect(shim.match(/control\s*= "start"/g)).toHaveLength(2);
102
+ const arm = body("armSpan");
103
+ expect(arm).toMatch(/m\.interp\.keyValue = \[fromValue, toValue\][^]*m\.anim\.control {4}= "start"/);
104
+ // Stop resets the target to keyValue[0]; writing the keys after it would flash the
105
+ // previous span's start for a frame on every animated retarget or settle.
106
+ expect(arm).toMatch(/m\.interp\.keyValue = \[fromValue, toValue\][^]*m\.anim\.control {4}= "stop"/);
107
+ expect(arm.indexOf("m.interp.keyValue")).toBeLessThan(arm.indexOf('m.anim.control = "stop"'));
108
+ expect(arm).toContain("m.armed = { node: m.top.targetNode, to: toValue }");
109
+ expect(body("fireAnimation").match(/armSpan\(/g)).toHaveLength(2);
110
+ expect(body("onProposedStopsChanged")).toContain("armSpan(fromPos, closestStop, newDuration)");
111
+ expect(body("onProposedStopsChanged")).toContain("armSpan([fromVec[0], fromVec[1]], toVec, newDuration)");
112
+ // Snaps and pins land on a destination too, so they record it as well.
113
+ expect(body("stopAtValue")).toContain("m.armed = { node: m.top.targetNode, to: value }");
114
+ expect(body("snapWithoutAnim").match(/m\.armed = \{ node: m\.top\.targetNode, to: /g)).toHaveLength(2);
115
+ });
116
+
117
+ test("a stopped transition with no parent releases the Animation", () => {
118
+ expect(body("onAnimStateChanged")).toMatch(
119
+ /m\.anim\.state = "stopped" and m\.top\.getParent\(\) = invalid then releaseDetached\(\)/
120
+ );
121
+ // Deferred a frame on the retry timer, not run inside the Animation's own state observer.
122
+ const detached = body("releaseDetached");
123
+ expect(detached).toContain("m.releasePending = true");
124
+ expect(detached).toContain("startRetryTimer()");
125
+ expect(detached).not.toContain("releaseAnim()");
126
+ expect(body("onRetryTimerFire")).toMatch(/m\.top\.getParent\(\) = invalid then releaseAnim\(\)/);
127
+ });
128
+
129
+ test("a stopped transition with a parent does not release", () => {
130
+ const state = body("onAnimStateChanged");
131
+ expect(state).not.toMatch(/getParent\(\) <> invalid/);
132
+ expect(state).not.toContain("releaseAnim()");
133
+ // The condition requires a missing parent, so an attached node just goes idle.
134
+ expect(state.match(/releaseDetached\(\)/g)).toHaveLength(1);
135
+ expect(state).toMatch(/getParent\(\) = invalid then releaseDetached/);
136
+ expect(state).toContain("m.top.animationState = [data.getData(), curVal]");
137
+ });
138
+
139
+ test("has no owner guard Timer", () => {
140
+ expect(shim).not.toMatch(/ownerGuard|OwnerGuard/);
141
+ // The retry timer is the only Timer the shim creates.
142
+ expect(shim.match(/createObject\("roSGNode", "Timer"\)/g)).toHaveLength(1);
143
+ });
144
+
145
+ test("skips a write of the armed destination, never across a target change or a stop", () => {
146
+ const guard = body("isArmedFor");
147
+ expect(guard).toContain("if m.armed = invalid then return false");
148
+ // Keyframes armed on a previous target (FlexGrid's X tween follows the focused row)
149
+ // must not carry over to the new one.
150
+ expect(guard).toContain("if not m.armed.node.isSameNode(m.top.targetNode) then return false");
151
+ expect(guard).toContain("if ownX and abs(armedTo[0] - target[0]) >= 0.01 then return false");
152
+ expect(guard).toContain("if ownY and abs(armedTo[1] - target[1]) >= 0.01 then return false");
153
+ expect(body("onTargetNodeChanged")).toMatch(/m\.armed = invalid[^]*fireAnimation\(\)/);
154
+ expect(body("onControlChanged")).toContain('if ctrl = "stop" then m.armed = invalid');
155
+ expect(body("releaseAnim")).toContain("m.armed = invalid");
156
+ // Checked after the snap path, so a duration-0 write always lands, and before the restart.
157
+ const fire = body("fireAnimation");
158
+ const floatGuard = fire.indexOf("if isArmedFor(toVal, true, true) then return");
159
+ const vectorGuard = fire.indexOf('if isArmedFor(toVec, axis <> "y", axis <> "x") then return');
160
+ expect(floatGuard).toBeGreaterThan(fire.indexOf("stopAtValue(toVal)"));
161
+ expect(floatGuard).toBeLessThan(fire.indexOf("armSpan(fromVal, toVal, m.top.duration)"));
162
+ expect(vectorGuard).toBeGreaterThan(fire.indexOf("stopAtValue(toVec)"));
163
+ expect(vectorGuard).toBeLessThan(fire.indexOf("armSpan(fromVec, toVec, m.top.duration)"));
164
+ });
165
+
166
+ test('resolves the axis to "x", "y" or "all" so there is one vector axis path', () => {
167
+ const resolve = body("resolvedAxis");
168
+ expect(resolve).toContain('if axis = "x" or axis = "y" then return axis');
169
+ expect(resolve).toContain('return "all"');
170
+ expect(shim).not.toContain('"both"');
171
+ expect(shim).not.toContain("AxisBoth");
172
+
173
+ // Nothing outside resolvedAxis() may branch on the raw axis field.
174
+ for (const name of ["buildVectorTarget", "fireAnimation", "onProposedStopsChanged", "normalizeVectorStops"]) {
175
+ expect(body(name)).not.toContain("m.top.axis");
176
+ }
177
+ });
178
+
179
+ test("settles one whole [x, y] candidate on every owned component instead of a dominant axis", () => {
180
+ const settle = body("onProposedStopsChanged");
181
+ // The dominant-axis hack and the per-component projection are gone.
182
+ expect(settle).not.toContain("abs(kvTo[0] - kvFrom[0]) >= abs(kvTo[1] - kvFrom[1])");
183
+ expect(settle).not.toContain("isAxisPair");
184
+ expect(settle).not.toContain("for axisIdx");
185
+ expect(shim).not.toMatch(/function pickStop2D/);
186
+ // The axis decides which components are owned; one point is chosen for all of them.
187
+ expect(settle).toContain('ownX = (axis <> "y")');
188
+ expect(settle).toContain('ownY = (axis <> "x")');
189
+ expect(settle).toContain("picked = pickStopVector(stops, fromVec, ownX, ownY, dirX, dirY, unfiltered)");
190
+ expect(settle).toContain("if ownX then toVec[0] = candidate[0]");
191
+ expect(settle).toContain("if ownY then toVec[1] = candidate[1]");
192
+ // Speed is held along the path, so both distances are Euclidean over owned components.
193
+ expect(settle).toContain("newDistance = vecDistance(fromVec, toVec, ownX, ownY)");
194
+ expect(settle).toContain("originalDistance = vecDistance(kvFrom, kvTo, ownX, ownY)");
195
+ // A single candidate or a span that moves no owned component drops the direction filter.
196
+ expect(settle).toContain("unfiltered = (stops.count() = 1) or (dirX = 0 and dirY = 0)");
197
+ // Nothing ahead keeps the armed destination, like the 1D path.
198
+ expect(settle).toContain("candidate = [kvTo[0], kvTo[1]]");
199
+ });
200
+
201
+ test("picks the nearest accepted candidate by Euclidean distance", () => {
202
+ expect(body("vecDistance")).toContain("return sqr(dx * dx + dy * dy)");
203
+ const vector = body("pickStopVector");
204
+ expect(vector).toContain("distance = vecDistance(fromVec, candidate, ownX, ownY)");
205
+ expect(vector).toContain("best = -1");
206
+ // A component that is not moving (dir 0) adds no constraint: only signed dirs filter.
207
+ expect(vector).toContain("if dirX > 0 and");
208
+ expect(vector).toContain("if dirY < 0 and");
209
+ });
210
+
211
+ test("normalizes the vector stop shapes to [x, y] tuples", () => {
212
+ const normalize = body("normalizeVectorStops");
213
+ // A flat pair is two candidates, never one destination point.
214
+ expect(normalize).not.toContain("return [[stops[0], stops[1]]]");
215
+ expect(body("onProposedStopsChanged")).toContain("normalized = normalizeVectorStops(stops, axis)");
216
+ });
217
+
218
+ test('accepts only [x, y] pairs as stops on axis "all", skipping scalars instead of lifting them', () => {
219
+ const normalize = body("normalizeVectorStops");
220
+ // Under "all" a stop must have the vector's arity; a scalar is skipped, not lifted to
221
+ // [s, s] (the lift settled a legacy flat [X, Y] focus stop onto the row below).
222
+ const allBranch = normalize.slice(normalize.indexOf('if axis = "all" then'), normalize.indexOf("else if"));
223
+ expect(allBranch).toContain('if type(candidate) = "roArray" and candidate.count() = 2 then');
224
+ expect(allBranch).toContain("skipped = skipped + 1");
225
+ expect(allBranch).not.toContain("[candidate, candidate]");
226
+ // Skips are logged once per settle, after the loop, not per entry.
227
+ expect(normalize.indexOf("print")).toBeGreaterThan(normalize.indexOf("end for"));
228
+ // "x" / "y" still read a scalar as the owned coordinate.
229
+ expect(normalize.slice(normalize.indexOf("else if"))).toContain("candidates.push([candidate, candidate])");
230
+ // No usable candidate means no settle; the reported index maps back into proposedStops.
231
+ const settle = body("onProposedStopsChanged");
232
+ expect(settle).toContain("if stops.count() = 0 then return");
233
+ expect(settle).toContain("index = normalized.indices[index]");
234
+ });
235
+
236
+ test('accepts only scalars as stops on axis "x" / "y", skipping arrays instead of reading one component', () => {
237
+ const normalize = body("normalizeVectorStops");
238
+ // Under "x" / "y" a stop is the one owned coordinate; an array of any length, [x, y]
239
+ // included, is skipped and counted with the "all" skips, never read on one component.
240
+ const axisBranch = normalize.slice(normalize.indexOf("else if"), normalize.indexOf("end for"));
241
+ expect(axisBranch).toContain('else if type(candidate) = "roArray" then\n skipped = skipped + 1');
242
+ expect(axisBranch).not.toContain("candidate.count() >= 2");
243
+ expect(normalize).not.toMatch(/count\(\) >= 2/);
244
+ // Scalars still compete, reported at their original index.
245
+ expect(axisBranch).toContain("candidates.push([candidate, candidate])");
246
+ expect(axisBranch).toContain("indices.push(i)");
247
+ // One log per settle names the expected shape for the axis in use.
248
+ const log = normalize.slice(normalize.indexOf("end for"));
249
+ expect(log).toContain("not [x, y] pairs on axis");
250
+ expect(log).toContain("not scalars on axis");
251
+ expect(log.match(/print /g)).toHaveLength(2);
252
+ });
253
+
254
+ test("only replays written targets and compares node identity rather than id strings", () => {
255
+ expect(body("init")).toContain("m.targetWritten = false");
256
+ for (const name of ["onTargetValueChanged", "onTargetVectorChanged"]) {
257
+ expect(body(name)).toContain("m.targetWritten = true");
258
+ }
259
+ const rebind = body("onTargetNodeChanged");
260
+ expect(rebind).toContain("target.isSameNode(m.lastTarget)");
261
+ expect(rebind).toContain("if m.targetWritten then fireAnimation()");
262
+ expect(rebind).not.toContain("m.top.axis");
263
+ expect(rebind).not.toContain("m.lastTargetId");
264
+ });
265
+ });
266
+
267
+ test("filters array stops before scalar arithmetic and preserves source indices", () => {
268
+ const settle = body("onProposedStopsChanged");
269
+ expect(settle).toMatch(/normalizeScalarStops\(stops\)[^]*if stops.count\(\) = 0 then return[^]*pickStop1D\(stops/);
270
+ expect(settle).toMatch(
271
+ /index = picked\[0\][^]*if index < 0 then[^]*index = 0[^]*else[^]*index = normalized.indices\[index\]/
272
+ );
273
+ expect(body("pickStop1D")).toContain("index = -1");
274
+ expect(body("normalizeScalarStops")).toContain('if type(candidate) = "roArray" then');
275
+ expect(body("normalizeScalarStops")).toContain("if skipped > 0 then print");
276
+ });
@@ -0,0 +1,34 @@
1
+ // Copyright (c) 2026 Roku, Inc.
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { beforeAll, describe, expect, jest, test } from "@jest/globals";
5
+ import { hasFeature } from "@roku-sdk/runtime/has-feature";
6
+ import { registerTagAlias } from "@roku-sdk/solid";
7
+
8
+ jest.mock("@roku-sdk/solid", () => ({ registerTagAlias: jest.fn() }));
9
+ jest.mock("@roku-sdk/runtime/has-feature", () => ({ hasFeature: jest.fn() }));
10
+
11
+ let resolveTween: (() => string) | undefined;
12
+
13
+ beforeAll(async () => {
14
+ await import("../index");
15
+ resolveTween = jest.mocked(registerTagAlias).mock.calls.find(([tag]) => tag === "tween")?.[1];
16
+ });
17
+
18
+ describe("tween shim alias", () => {
19
+ test("is registered for the tween tag", () => {
20
+ expect(resolveTween).toBeDefined();
21
+ });
22
+
23
+ test("keeps the native node when the firmware has it", () => {
24
+ jest.mocked(hasFeature).mockReturnValue(true);
25
+ expect(resolveTween!()).toBe("tween");
26
+ expect(hasFeature).toHaveBeenCalledWith("tween");
27
+ });
28
+
29
+ test("creates the shim when the firmware does not", () => {
30
+ jest.mocked(hasFeature).mockReturnValue(false);
31
+ expect(resolveTween!()).toBe("RsgTweenShim");
32
+ expect(hasFeature).toHaveBeenCalledWith("tween");
33
+ });
34
+ });
package/src/index.ts ADDED
@@ -0,0 +1,4 @@
1
+ // Copyright (c) 2026 Roku, Inc.
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import "./tween";
@@ -0,0 +1,18 @@
1
+ // Copyright (c) 2026 Roku, Inc.
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ // Injected only by Roku builds without compat; importing rsg-ts alone stays tree-shakeable.
5
+ import { hasFeature } from "@roku-sdk/runtime/has-feature";
6
+ import { hasTagAlias, registerTagAlias } from "@roku-sdk/solid";
7
+
8
+ const GATED_TAGS: Record<string, string> = { tween: "tween" };
9
+
10
+ for (const [tag, feature] of Object.entries(GATED_TAGS)) {
11
+ if (hasTagAlias(tag)) continue;
12
+ registerTagAlias(tag, () => {
13
+ if (hasFeature(feature)) return tag;
14
+ throw new Error(
15
+ `<${tag}> requires the "${feature}" firmware feature or @roku-sdk/compat: this firmware does not report that feature.`
16
+ );
17
+ });
18
+ }
@@ -0,0 +1,9 @@
1
+ // Copyright (c) 2026 Roku, Inc.
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import "./rsg-tween-shim.xml";
5
+
6
+ import { hasFeature } from "@roku-sdk/runtime/has-feature";
7
+ import { registerTagAlias } from "@roku-sdk/solid";
8
+
9
+ registerTagAlias("tween", () => (hasFeature("tween") ? "tween" : "RsgTweenShim"));
@@ -0,0 +1,618 @@
1
+ ' Copyright (c) 2026 Roku, Inc.
2
+ ' SPDX-License-Identifier: MIT
3
+ '
4
+ ' Stand-in for the firmware Tween node when firmware does not report the "tween" feature.
5
+ '
6
+ ' Drives an Animation + FloatFieldInterpolator | Vector2dFieldInterpolator pair against the
7
+ ' Tween field contract. The pair is built here and inserted next to this node, in its parent,
8
+ ' so `fieldToInterp` resolves the target's id in the scope the target lives in; it is removed
9
+ ' again when targetNode is cleared, or when a span ends after this node was detached from its
10
+ ' parent (see onAnimStateChanged and releaseDetached). Value type (float vs vector2d) is detected from the target
11
+ ' field and the trigger written; a scalar written to a vector field is auto-expanded to [v, v].
12
+ '
13
+ ' Axis modes are "x", "y" and "all", resolved by resolvedAxis() before anything reasons about
14
+ ' them, so there is one vector code path.
15
+ '
16
+ ' Settle (onProposedStopsChanged): candidates are accepted strictly ahead in the direction of
17
+ ' travel (> / <), a stationary span takes the absolute-nearest (sticky step), and the settle
18
+ ' duration scales to hold velocity (floored at 0.02s, 0.05s zero-span fallback). On a vector
19
+ ' field a candidate is a whole [x, y] point: it must be strictly ahead on every owned component
20
+ ' that moves, the nearest such point by Euclidean distance wins, and it is applied to every
21
+ ' owned component at once. There is no dominant axis.
22
+ '
23
+ ' A candidate has the same arity as the value it settles. On a float field, or a vector field
24
+ ' under axis "x" / "y", that is a scalar: the coordinate on the owned component; an array
25
+ ' there is skipped. Under axis "all" it is an [x, y] pair; a scalar there is skipped, never
26
+ ' lifted to [s, s]. See normalizeVectorStops().
27
+
28
+ sub init()
29
+ m.anim = invalid
30
+ m.interp = invalid
31
+ m.armed = invalid ' { node, to }: the last destination armed or pinned, and on which node
32
+ m.retryTimer = invalid
33
+ m.releasePending = false ' a detached node's Animation is to be released on the next frame
34
+ m.pendingFire = false
35
+ m.lastTarget = invalid
36
+ m.targetWritten = false
37
+ m.useScalarTarget = true
38
+ m.valueType = invalid ' "float" | "vector2d", detected lazily
39
+ end sub
40
+
41
+ ' Vector fields animate as vector2d; everything else as float. A targetVector write also
42
+ ' forces vector2d (handled at its trigger).
43
+ function detectValueType() as string
44
+ field = m.top.field
45
+ if field = "translation" or field = "scale" or field = "scaleRotateCenter" then
46
+ return "vector2d"
47
+ end if
48
+ return "float"
49
+ end function
50
+
51
+ ' Lazily build m.anim / m.interp in this node's parent. Returns false while there is no target
52
+ ' or no parent to put them in.
53
+ function ensureAnimReady() as boolean
54
+ if m.top.targetNode = invalid then return false
55
+ parent = m.top.getParent()
56
+ if parent = invalid then return false
57
+ if m.valueType = invalid then m.valueType = detectValueType()
58
+
59
+ if m.anim <> invalid then
60
+ animParent = m.anim.getParent()
61
+ if animParent = invalid or not animParent.isSameNode(parent) then
62
+ if animParent <> invalid then animParent.removeChild(m.anim)
63
+ parent.appendChild(m.anim)
64
+ end if
65
+ else
66
+ m.anim = createObject("roSGNode", "Animation")
67
+ m.anim.repeat = false
68
+ m.anim.observeFieldScopedEx("state", "onAnimStateChanged")
69
+ parent.appendChild(m.anim)
70
+ end if
71
+
72
+ wantType = "FloatFieldInterpolator"
73
+ if m.valueType = "vector2d" then wantType = "Vector2DFieldInterpolator"
74
+ if m.interp <> invalid and LCase(m.interp.subtype()) <> LCase(wantType) then
75
+ m.anim.removeChild(m.interp)
76
+ m.interp = invalid
77
+ end if
78
+ if m.interp = invalid then
79
+ m.interp = createObject("roSGNode", wantType)
80
+ m.interp.key = [0.0, 1.0]
81
+ m.anim.appendChild(m.interp)
82
+ end if
83
+ return true
84
+ end function
85
+
86
+ ' Take the animation out of the parent, so a disposed tween leaves nothing behind in a tree
87
+ ' it no longer belongs to and holds no node.
88
+ sub releaseAnim()
89
+ m.pendingFire = false
90
+ m.releasePending = false
91
+ m.armed = invalid
92
+ if m.retryTimer <> invalid then m.retryTimer.control = "stop"
93
+ if m.anim = invalid then return
94
+ m.anim.unobserveFieldScoped("state")
95
+ m.anim.control = "stop"
96
+ animParent = m.anim.getParent()
97
+ if animParent <> invalid then animParent.removeChild(m.anim)
98
+ m.anim = invalid
99
+ m.interp = invalid
100
+ end sub
101
+
102
+ ' The Animation lives beside this node, not under it, so removing this node from the tree does
103
+ ' not remove it. The usual release is immediate: the Tween wrapper clears targetNode when it is
104
+ ' disposed, and onTargetNodeChanged calls releaseAnim. For a raw <tween> removed mid-flight
105
+ ' without clearing targetNode there is no timer and no polling: a span is finite and every span
106
+ ' that starts ends in "stopped", so that transition is the moment to check ownership (see
107
+ ' onAnimStateChanged). The Animation lingers in the parent until its span ends. A raw <tween>
108
+ ' removed while idle or paused is not caught: a paused span never reaches "stopped", and a node
109
+ ' has no destructor and no observable parent to hook instead. Clear targetNode first.
110
+ '
111
+ ' The release is deferred by one frame rather than run inside the Animation's own state
112
+ ' observer, because unobserving a field and removing the node from inside that field's callback
113
+ ' is not something SceneGraph documents as safe. The deferral reuses the retry Timer.
114
+ sub releaseDetached()
115
+ m.releasePending = true
116
+ startRetryTimer()
117
+ end sub
118
+
119
+ ' A trigger can arrive before this node is inserted into its parent; retry on the next frames
120
+ ' until it is.
121
+ sub scheduleRetry()
122
+ m.pendingFire = true
123
+ startRetryTimer()
124
+ end sub
125
+
126
+ sub startRetryTimer()
127
+ if m.retryTimer = invalid then
128
+ m.retryTimer = createObject("roSGNode", "Timer")
129
+ m.retryTimer.duration = 0.001
130
+ m.retryTimer.repeat = false
131
+ m.retryTimer.observeFieldScoped("fire", "onRetryTimerFire")
132
+ m.top.appendChild(m.retryTimer)
133
+ end if
134
+ m.retryTimer.control = "start"
135
+ end sub
136
+
137
+ sub onRetryTimerFire()
138
+ if m.releasePending then
139
+ m.releasePending = false
140
+ ' Re-check: a new span may have started, or the node been re-attached, since the request.
141
+ if m.anim <> invalid then
142
+ if m.anim.state = "stopped" and m.top.getParent() = invalid then releaseAnim()
143
+ end if
144
+ return
145
+ end if
146
+ if not m.pendingFire then return
147
+ if m.top.targetNode = invalid then return
148
+ fireAnimation()
149
+ end sub
150
+
151
+ ' Bind the interpolator to the CURRENT target's field. Without a rebind on target change a
152
+ ' pooled node recycled onto another cell keeps animating its previous owner.
153
+ sub bindField()
154
+ ' Interpolators address a node by id, but the public API only requires a reference.
155
+ ' Give anonymous targets a stable unique id without changing a caller-assigned id.
156
+ if m.top.targetNode.id = "" then
157
+ m.top.targetNode.id = "rsg-tween-" + createObject("roDeviceInfo").GetRandomUUID()
158
+ end if
159
+ desiredField = m.top.targetNode.id + "." + m.top.field
160
+ if m.interp.fieldToInterp <> desiredField then
161
+ m.interp.fieldToInterp = desiredField
162
+ end if
163
+ end sub
164
+
165
+ ' Target-node changes animate the standing target, but only after a target write.
166
+ sub onTargetNodeChanged()
167
+ target = m.top.targetNode
168
+ if target = invalid then
169
+ m.lastTarget = invalid
170
+ releaseAnim()
171
+ return
172
+ end if
173
+ if m.lastTarget <> invalid then
174
+ if target.isSameNode(m.lastTarget) then return
175
+ end if
176
+ m.lastTarget = target
177
+ ' Whatever was armed belongs to the previous node; the new one starts from its own value.
178
+ m.armed = invalid
179
+
180
+ if m.targetWritten then fireAnimation()
181
+ end sub
182
+
183
+ sub onTargetValueChanged()
184
+ m.useScalarTarget = true
185
+ m.targetWritten = true
186
+ m.valueType = detectValueType()
187
+ fireAnimation()
188
+ end sub
189
+
190
+ sub onTargetVectorChanged()
191
+ m.useScalarTarget = false
192
+ m.targetWritten = true
193
+ ' An explicit vector target forces vector2d regardless of field name.
194
+ m.valueType = "vector2d"
195
+ fireAnimation()
196
+ end sub
197
+
198
+ sub onControlChanged()
199
+ if m.anim = invalid then return
200
+ ctrl = m.top.control
201
+ if ctrl = "" then return
202
+ ' A stop puts the target back at the span's start, so the armed destination no longer holds.
203
+ if ctrl = "stop" then m.armed = invalid
204
+ m.anim.control = ctrl
205
+ end sub
206
+
207
+ ' The axis mode. Every read of the axis goes through here so there is exactly one set of
208
+ ' vector semantics: "x" and "y" move one component and hold the other at preserveOther,
209
+ ' "all" moves the whole vector.
210
+ function resolvedAxis() as string
211
+ axis = m.top.axis
212
+ if axis = "x" or axis = "y" then return axis
213
+ return "all"
214
+ end function
215
+
216
+ ' Build the vector2d target from the trigger inputs (scalar auto-expansion + axis rules).
217
+ function buildVectorTarget() as object
218
+ if not m.useScalarTarget and m.top.targetVector <> invalid and m.top.targetVector.count() >= 2 then
219
+ return [m.top.targetVector[0], m.top.targetVector[1]]
220
+ end if
221
+ v = m.top.targetValue
222
+ axis = resolvedAxis()
223
+ preserveOther = m.top.preserveOther
224
+ if axis = "y" then return [preserveOther, v]
225
+ if axis = "x" then return [v, preserveOther]
226
+ ' "all": the scalar goes on both slots (uniform scale), as the native node does.
227
+ return [v, v]
228
+ end function
229
+
230
+ sub fireAnimation()
231
+ ' A trigger can be written before targetNode; the native node keeps it, so hold it until
232
+ ' onTargetNodeChanged delivers a target.
233
+ if m.top.targetNode = invalid then
234
+ m.pendingFire = true
235
+ return
236
+ end if
237
+ if not ensureAnimReady() then
238
+ if m.top.duration = 0 then
239
+ snapWithoutAnim()
240
+ else
241
+ scheduleRetry()
242
+ end if
243
+ return
244
+ end if
245
+ m.pendingFire = false
246
+ bindField()
247
+
248
+ targetNode = m.top.targetNode
249
+
250
+ if m.valueType = "float" then
251
+ toVal = m.top.targetValue
252
+ if not m.useScalarTarget and m.top.targetVector <> invalid and m.top.targetVector.count() >= 1 then
253
+ toVal = m.top.targetVector[0]
254
+ end if
255
+ if m.top.duration = 0 then
256
+ stopAtValue(toVal)
257
+ targetNode.setField(m.top.field, toVal)
258
+ return
259
+ end if
260
+ if isArmedFor(toVal, true, true) then return
261
+ fromVal = targetNode.getField(m.top.field)
262
+ armSpan(fromVal, toVal, m.top.duration)
263
+ else
264
+ toVec = buildVectorTarget()
265
+ if m.top.duration = 0 then
266
+ stopAtValue(toVec)
267
+ targetNode.setField(m.top.field, toVec)
268
+ return
269
+ end if
270
+ axis = resolvedAxis()
271
+ if isArmedFor(toVec, axis <> "y", axis <> "x") then return
272
+ liveVec = targetNode.getField(m.top.field)
273
+ if liveVec = invalid then liveVec = [0, 0]
274
+ preserveOther = m.top.preserveOther
275
+ if axis = "y" then
276
+ fromVec = [preserveOther, liveVec[1]]
277
+ else if axis = "x" then
278
+ fromVec = [liveVec[0], preserveOther]
279
+ else
280
+ fromVec = liveVec
281
+ end if
282
+ armSpan(fromVec, toVec, m.top.duration)
283
+ end if
284
+
285
+ date = createObject("roDateTime")
286
+ m.top.animationStart = [date.AsSeconds(), date.GetMilliseconds()]
287
+ end sub
288
+
289
+ ' The one place a span starts. Records the destination so a later write of the same one is a
290
+ ' no-op (see isArmedFor). Every start reads the current ease, including a settle re-arm.
291
+ ' Stop resets the target to keyValue[0], so the new keys go in first: the reset then lands on
292
+ ' fromValue (the live position) rather than on the previous span's start.
293
+ sub armSpan(fromValue as dynamic, toValue as dynamic, duration as float)
294
+ m.anim.easeFunction = m.top.easeFunction
295
+ m.anim.duration = duration
296
+ m.interp.keyValue = [fromValue, toValue]
297
+ m.anim.control = "stop"
298
+ m.anim.control = "start"
299
+ m.armed = { node: m.top.targetNode, to: toValue }
300
+ end sub
301
+
302
+ ' True when `target` is the destination last armed or pinned on the current target node, on
303
+ ' every owned component. Writing it again must not restart: FlexGrid re-writes the settled
304
+ ' position as a settle lands, and a restart re-arms the last few pixels over the full duration,
305
+ ' so the end of the settle crawls. The other renderer implementations follow the same rule. A
306
+ ' target-node change and a "stop" control clear the record, so neither is ever skipped.
307
+ function isArmedFor(target as dynamic, ownX as boolean, ownY as boolean) as boolean
308
+ if m.armed = invalid then return false
309
+ if not m.armed.node.isSameNode(m.top.targetNode) then return false
310
+ armedTo = m.armed.to
311
+ if type(target) <> "roArray" then
312
+ if type(armedTo) = "roArray" then return false
313
+ return abs(armedTo - target) < 0.01
314
+ end if
315
+ if type(armedTo) <> "roArray" then return false
316
+ if ownX and abs(armedTo[0] - target[0]) >= 0.01 then return false
317
+ if ownY and abs(armedTo[1] - target[1]) >= 0.01 then return false
318
+ return true
319
+ end function
320
+
321
+ ' Stop resets the target to keyValue[0], so pin both keys before issuing the control.
322
+ sub stopAtValue(value as dynamic)
323
+ m.interp.keyValue = [value, value]
324
+ m.anim.control = "stop"
325
+ m.armed = { node: m.top.targetNode, to: value }
326
+ end sub
327
+
328
+ ' A snap needs no animation, so it lands even before this node has a parent.
329
+ sub snapWithoutAnim()
330
+ m.pendingFire = false
331
+ if m.valueType = invalid then m.valueType = detectValueType()
332
+ if m.valueType = "float" then
333
+ toVal = m.top.targetValue
334
+ if not m.useScalarTarget and m.top.targetVector <> invalid and m.top.targetVector.count() >= 1 then
335
+ toVal = m.top.targetVector[0]
336
+ end if
337
+ m.top.targetNode.setField(m.top.field, toVal)
338
+ m.armed = { node: m.top.targetNode, to: toVal }
339
+ else
340
+ toVec = buildVectorTarget()
341
+ m.top.targetNode.setField(m.top.field, toVec)
342
+ m.armed = { node: m.top.targetNode, to: toVec }
343
+ end if
344
+ end sub
345
+
346
+ sub onProposedStopsChanged(data as dynamic)
347
+ if m.anim = invalid or m.interp = invalid then return
348
+ if not ensureAnimReady() then return
349
+ stops = data.getData()
350
+ if stops = invalid or stops.count() = 0 then return
351
+ if m.interp.keyValue = invalid or m.interp.keyValue.count() < 2 then return
352
+ bindField()
353
+
354
+ targetNode = m.top.targetNode
355
+ kvFrom = m.interp.keyValue[0]
356
+ kvTo = m.interp.keyValue[1]
357
+
358
+ if m.valueType = "float" then
359
+ normalized = normalizeScalarStops(stops)
360
+ stops = normalized.candidates
361
+ if stops.count() = 0 then return
362
+ fromPos = targetNode.getField(m.top.field)
363
+ isIncreasing = (kvTo > kvFrom)
364
+ isStationary = (kvTo = kvFrom)
365
+ picked = pickStop1D(stops, fromPos, isIncreasing, isStationary, kvTo)
366
+ index = picked[0]
367
+ if index < 0 then
368
+ index = 0
369
+ else
370
+ index = normalized.indices[index]
371
+ end if
372
+ closestStop = picked[1]
373
+
374
+ originalDistance = abs(kvTo - kvFrom)
375
+ newDistance = abs(closestStop - fromPos)
376
+ if newDistance = 0 then
377
+ stopAtValue(fromPos)
378
+ fireAnimationStop(index, closestStop, 0)
379
+ return
380
+ end if
381
+ newDuration = scaleSettle(m.anim.duration, originalDistance, newDistance)
382
+
383
+ armSpan(fromPos, closestStop, newDuration)
384
+
385
+ fireAnimationStop(index, closestStop, newDuration)
386
+ return
387
+ end if
388
+
389
+ ' vector2d. One code path for every axis mode. A candidate is a whole [x, y] point, chosen
390
+ ' once and applied to every component the axis owns: "x" and "y" own one component and hold
391
+ ' the other where it is, "all" owns both. There is no dominant-axis selection, so a diagonal glide settles X and Y together onto one candidate.
392
+ axis = resolvedAxis()
393
+ normalized = normalizeVectorStops(stops, axis)
394
+ stops = normalized.candidates
395
+ ' No candidate of the right arity: no settle. The armed span keeps running to its own
396
+ ' target and onAnimationStop does not fire, as for an empty proposedStops.
397
+ if stops.count() = 0 then return
398
+
399
+ fromVec = targetNode.getField(m.top.field)
400
+ if fromVec = invalid then fromVec = [0, 0]
401
+
402
+ ownX = (axis <> "y")
403
+ ownY = (axis <> "x")
404
+ dirX = 0
405
+ dirY = 0
406
+ if ownX then dirX = sgn(kvTo[0] - kvFrom[0])
407
+ if ownY then dirY = sgn(kvTo[1] - kvFrom[1])
408
+
409
+ ' One candidate is a destination, not a choice, so the direction-of-travel filter is
410
+ ' dropped for it. A span that moves no owned component (FlexGrid's sticky-step arming)
411
+ ' also drops the filter and takes the absolute-nearest candidate.
412
+ unfiltered = (stops.count() = 1) or (dirX = 0 and dirY = 0)
413
+
414
+ picked = pickStopVector(stops, fromVec, ownX, ownY, dirX, dirY, unfiltered)
415
+ index = picked[0]
416
+ if index < 0 then
417
+ ' Nothing ahead: keep the armed span running to its own target, as the 1D path does
418
+ ' when no candidate is accepted.
419
+ candidate = [kvTo[0], kvTo[1]]
420
+ index = 0
421
+ else
422
+ candidate = stops[index]
423
+ ' Report the index into proposedStops as written, not into the filtered list.
424
+ index = normalized.indices[index]
425
+ end if
426
+
427
+ toVec = [fromVec[0], fromVec[1]]
428
+ if ownX then toVec[0] = candidate[0]
429
+ if ownY then toVec[1] = candidate[1]
430
+
431
+ ' The reported position is a scalar: the owned component for "x"/"y", and for "all" the
432
+ ' component that travels further, which is also the one that dominated the pre-unification
433
+ ' report.
434
+ reportIdx = 0
435
+ if axis = "y" then reportIdx = 1
436
+ if axis = "all" and abs(toVec[1] - fromVec[1]) > abs(toVec[0] - fromVec[0]) then reportIdx = 1
437
+ closestStop = toVec[reportIdx]
438
+
439
+ newDistance = vecDistance(fromVec, toVec, ownX, ownY)
440
+ originalDistance = vecDistance(kvFrom, kvTo, ownX, ownY)
441
+ if newDistance = 0 then
442
+ stopAtValue(fromVec)
443
+ fireAnimationStop(index, closestStop, 0)
444
+ return
445
+ end if
446
+ newDuration = scaleSettle(m.anim.duration, originalDistance, newDistance)
447
+
448
+ armSpan([fromVec[0], fromVec[1]], toVec, newDuration)
449
+
450
+ fireAnimationStop(index, closestStop, newDuration)
451
+ end sub
452
+
453
+ ' Euclidean length of b - a over the owned components; an unowned component contributes 0.
454
+ function vecDistance(a as object, b as object, ownX as boolean, ownY as boolean) as float
455
+ dx = 0
456
+ dy = 0
457
+ if ownX then dx = b[0] - a[0]
458
+ if ownY then dy = b[1] - a[1]
459
+ return sqr(dx * dx + dy * dy)
460
+ end function
461
+
462
+ ' Bring `proposedStops` on a vector field to one shape, a list of [x, y] candidates, keeping
463
+ ' only entries whose arity matches the value being settled. Each entry is judged on its own,
464
+ ' so in a mixed list the valid entries still compete. Returns { candidates, indices }, where
465
+ ' indices[k] is the proposedStops index candidates[k] came from.
466
+ '
467
+ ' - axis "x" / "y": the settled value is the one owned coordinate, so a candidate must be a
468
+ ' scalar s (only the owned component is read; [s, s] is just a carrier). An array of any
469
+ ' length, [x, y] included, is skipped and logged once per settle.
470
+ ' - axis "all": the settled value is the whole vector, so a candidate must be an [x, y]
471
+ ' pair. Scalars, and arrays of any other length, are skipped and logged once per settle.
472
+ '
473
+ ' Scalars are deliberately not lifted to [s, s] under "all". That lift turned a legacy flat
474
+ ' [X, Y] focus-highlight stop into two diagonal candidates [X, X] and [Y, Y]: holding left
475
+ ' and releasing settled the highlight onto the row below. The same rule extends to wider
476
+ ' values: a Rect2D field would take [x, y, w, h] candidates and skip anything else.
477
+ '
478
+ ' Pairs are likewise not read on one component under "x" / "y": a pair there is the wrong
479
+ ' arity for the one coordinate being settled, and reading half of it hides a caller that
480
+ ' meant "all".
481
+ function normalizeVectorStops(stops as object, axis as string) as object
482
+ candidates = []
483
+ indices = []
484
+ skipped = 0
485
+ for i = 0 to stops.count() - 1
486
+ candidate = stops[i]
487
+ if axis = "all" then
488
+ if type(candidate) = "roArray" and candidate.count() = 2 then
489
+ candidates.push(candidate)
490
+ indices.push(i)
491
+ else
492
+ skipped = skipped + 1
493
+ end if
494
+ else if type(candidate) = "roArray" then
495
+ skipped = skipped + 1
496
+ else
497
+ candidates.push([candidate, candidate])
498
+ indices.push(i)
499
+ end if
500
+ end for
501
+ if skipped > 0 then
502
+ if axis = "all" then
503
+ print "RsgTweenShim: skipped "; skipped; " proposedStops entries that are not [x, y] pairs on axis "; chr(34); "all"; chr(34)
504
+ else
505
+ print "RsgTweenShim: skipped "; skipped; " proposedStops entries that are not scalars on axis "; chr(34); axis; chr(34)
506
+ end if
507
+ end if
508
+ return { candidates: candidates, indices: indices }
509
+ end function
510
+
511
+ ' Vector candidate selection. A candidate is accepted when it lies strictly ahead on every
512
+ ' owned component that is moving (a component the span does not move imposes no constraint);
513
+ ' among accepted candidates the nearest by Euclidean distance over the owned components wins.
514
+ ' Unfiltered (single destination or stationary span) accepts everything. Returns [index] with
515
+ ' -1 when nothing was accepted.
516
+ function pickStopVector(stops as object, fromVec as object, ownX as boolean, ownY as boolean, dirX as integer, dirY as integer, unfiltered as boolean) as object
517
+ best = -1
518
+ bestDistance = invalid
519
+ for i = 0 to stops.count() - 1
520
+ candidate = stops[i]
521
+ accept = true
522
+ if not unfiltered then
523
+ if dirX > 0 and not (candidate[0] > fromVec[0]) then accept = false
524
+ if dirX < 0 and not (candidate[0] < fromVec[0]) then accept = false
525
+ if dirY > 0 and not (candidate[1] > fromVec[1]) then accept = false
526
+ if dirY < 0 and not (candidate[1] < fromVec[1]) then accept = false
527
+ end if
528
+ if accept then
529
+ distance = vecDistance(fromVec, candidate, ownX, ownY)
530
+ if bestDistance = invalid or distance < bestDistance then
531
+ bestDistance = distance
532
+ best = i
533
+ end if
534
+ end if
535
+ end for
536
+ return [best]
537
+ end function
538
+
539
+ ' Unified 1D candidate selection: accept strictly ahead in the direction of travel; a
540
+ ' stationary span takes the absolute-nearest. Returns [index, stopValue].
541
+ ' Float fields take scalar stops only, retaining the caller's original indices.
542
+ function normalizeScalarStops(stops as object) as object
543
+ candidates = []
544
+ indices = []
545
+ skipped = 0
546
+ for i = 0 to stops.count() - 1
547
+ candidate = stops[i]
548
+ if type(candidate) = "roArray" then
549
+ skipped = skipped + 1
550
+ else
551
+ candidates.push(candidate)
552
+ indices.push(i)
553
+ end if
554
+ end for
555
+ if skipped > 0 then print "RsgTweenShim: skipped "; skipped; " proposedStops entries that are not scalars on a float field"
556
+ return { candidates: candidates, indices: indices }
557
+ end function
558
+
559
+ function pickStop1D(stops as object, fromPos as float, isIncreasing as boolean, isStationary as boolean, defaultTo as float) as object
560
+ closestStop = defaultTo
561
+ minDistance = invalid
562
+ index = -1
563
+ for i = 0 to stops.count() - 1
564
+ stopPosition = stops[i]
565
+ distance = abs(stopPosition - fromPos)
566
+ accept = false
567
+ if isStationary then
568
+ accept = true
569
+ else if isIncreasing then
570
+ if stopPosition > fromPos then accept = true
571
+ else
572
+ if stopPosition < fromPos then accept = true
573
+ end if
574
+ if accept then
575
+ if minDistance = invalid or distance < minDistance then
576
+ minDistance = distance
577
+ closestStop = stopPosition
578
+ index = i
579
+ end if
580
+ end if
581
+ end for
582
+ return [index, closestStop]
583
+ end function
584
+
585
+ ' Velocity-preserving settle duration: hold the original span's units-per-second across
586
+ ' the shortened distance. Zero-length span → 0.05s; floored at 0.02s.
587
+ function scaleSettle(originalDuration as float, originalDistance as float, newDistance as float) as float
588
+ if originalDistance > 0 then
589
+ newDuration = originalDuration * newDistance / originalDistance
590
+ else
591
+ newDuration = 0.05
592
+ end if
593
+ if newDuration < 0.02 then newDuration = 0.02
594
+ return newDuration
595
+ end function
596
+
597
+ sub onAnimStateChanged(data as dynamic)
598
+ ' A span has ended. If this node was detached meanwhile, nobody owns the Animation any more.
599
+ ' Judge the live state, not the notification: a restart delivers "stopped" after armSpan has
600
+ ' already started the next span.
601
+ if m.anim <> invalid then
602
+ if m.anim.state = "stopped" and m.top.getParent() = invalid then releaseDetached()
603
+ end if
604
+ curVal = invalid
605
+ if m.top.targetNode <> invalid and m.top.field <> "" then
606
+ curVal = m.top.targetNode.getField(m.top.field)
607
+ end if
608
+ m.top.animationState = [data.getData(), curVal]
609
+ end sub
610
+
611
+ ' Keep epoch milliseconds in double precision: a 32-bit BRS integer overflows here.
612
+ sub fireAnimationStop(index as integer, stopPos as float, delaySeconds as float)
613
+ currentTime = createObject("roDateTime")
614
+ totalMs# = currentTime.AsSeconds() * 1000# + currentTime.GetMilliseconds() + Int(delaySeconds * 1000#)
615
+ seconds# = Int(totalMs# / 1000#)
616
+ milliseconds = Int(totalMs# - seconds# * 1000#)
617
+ m.top.animationStop = [index, stopPos, [seconds#, milliseconds]]
618
+ end sub
@@ -0,0 +1,71 @@
1
+ <?xml version="1.0" encoding="utf-8"?>
2
+ <!-- Copyright (c) 2026 Roku, Inc. -->
3
+ <!-- SPDX-License-Identifier: MIT -->
4
+ <!--
5
+ Stand-in for the firmware `Tween` node when firmware does not report the "tween" feature.
6
+
7
+ `@roku-sdk/compat` aliases the `<tween>` tag to this component when the firmware has no
8
+ native `Tween`, so app source is unchanged either way. It presents the same field contract
9
+ as the native node, defaults included, and nothing more: a field the native node lacks would
10
+ work with the shim and break with the native node. It drives an `Animation` + `*FieldInterpolator` pair it
11
+ builds itself.
12
+
13
+ That pair is inserted next to this node, in its parent, rather than under it: an
14
+ interpolator resolves the `id` in `fieldToInterp` within the component scope that holds its
15
+ `Animation`, and this component's own scope cannot see the target. Because the pair is not
16
+ under this node, removing this node does not remove it. The `Tween` wrapper clears `targetNode`
17
+ when it is disposed, which releases the pair at once. A raw `<tween>` removed mid-flight
18
+ without clearing `targetNode` is released when its span ends, which is the next "stopped"
19
+ state; nothing polls. A raw `<tween>` removed while idle or paused never reaches that state,
20
+ so its Animation stays in the parent; clear `targetNode` first.
21
+
22
+ Field-order discipline (BrightScript fires onChange in declaration order): all config
23
+ fields (targetNode, field, duration, easeFunction, axis, preserveOther) are declared
24
+ BEFORE the trigger fields (targetValue, targetVector) so their writes land before the
25
+ trigger's onChange fires.
26
+ -->
27
+ <component name="RsgTweenShim" extends="Group">
28
+ <interface>
29
+ <!-- ── Config (must precede the trigger fields) ──────────────────────────── -->
30
+ <field id="targetNode" type="node" onChange="onTargetNodeChanged" />
31
+ <!-- Field on targetNode to animate ("translation", "scale", "opacity", ...). -->
32
+ <field id="field" type="string" value="translation" />
33
+ <field id="duration" type="float" value="0.333" />
34
+ <field id="easeFunction" type="string" value="linear" />
35
+ <!-- axis: "x" | "y" | "all". Vector fields only. Anything other than "x" or "y" is
36
+ "all". -->
37
+ <!-- @ts-field axis { type: "'x' | 'y' | 'all'" } -->
38
+ <field id="axis" type="string" value="all" />
39
+ <!-- Value held on the non-animated axis for single-axis vector animation. -->
40
+ <field id="preserveOther" type="float" value="0" />
41
+
42
+ <!-- ── Triggers (writing one starts the animation) ───────────────────────── -->
43
+ <!-- Float target. For a vector field a scalar is auto-expanded to [v, v]. -->
44
+ <field id="targetValue" type="float" value="0" onChange="onTargetValueChanged" />
45
+ <!-- Vector2d target [x, y]. -->
46
+ <!-- @ts-field targetVector { type: "[number, number]" } -->
47
+ <field id="targetVector" type="array" value="[]" onChange="onTargetVectorChanged" />
48
+
49
+ <!-- Settle candidates. A candidate has the arity of the settled value: float fields
50
+ and axis x/y on a vector field: number[]; axis all (the default) on a vector field:
51
+ [number, number][]; axis is ignored on float fields. Entries of the wrong arity are
52
+ skipped (a scalar under "all" is never expanded to [s, s], an array under "x" / "y"
53
+ is never read on one component). On a vector field one whole point is chosen
54
+ (strictly ahead on every moving owned component, then nearest) and applied to every
55
+ component the axis owns. -->
56
+ <!-- @ts-field proposedStops { type: "number[] | [number, number][]" } -->
57
+ <field id="proposedStops" type="array" value="[]" onChange="onProposedStopsChanged" />
58
+
59
+ <!-- Explicit playback control: "start" | "stop" | "pause" | "resume" | "finish". -->
60
+ <field id="control" type="string" value="" onChange="onControlChanged" />
61
+
62
+ <!-- ── Outputs (render thread → TypeScript) ──────────────────────────────── -->
63
+ <!-- @ts-field animationStart { propName: "onAnimationStart", type: "(t: [number, number]) => void" } -->
64
+ <field id="animationStart" type="array" value="[0,0]" />
65
+ <!-- @ts-field animationStop { propName: "onAnimationStop", type: "(t: [number, number, [number, number]]) => void" } -->
66
+ <field id="animationStop" type="array" value="[0,0]" />
67
+ <!-- @ts-field animationState { propName: "onAnimationState", type: "(t: [import(\"@roku-sdk/components\").AnimationState, number | import(\"@roku-sdk/components\").Vector2D]) => void" } -->
68
+ <field id="animationState" type="array" value="[]" />
69
+ </interface>
70
+ <script type="text/brightscript" uri="rsg-tween-shim.brs" />
71
+ </component>
@@ -0,0 +1,79 @@
1
+ // Auto-generated TypeScript declarations for XML component import.
2
+ // DO NOT EDIT — this file is regenerated during build.
3
+ //
4
+ // Source component: RsgTweenShim
5
+ // Extends: Group
6
+
7
+ import type { IGroupProps } from "@roku-sdk/components";
8
+ import type { TypedXmlComponent } from "@roku-sdk/interfaces";
9
+
10
+ interface RsgTweenShimFields {
11
+ targetNode: import("@roku-sdk/interfaces").DomNode;
12
+ /** @default "translation" */
13
+ field: string;
14
+ /** @default "0.333" */
15
+ duration: number;
16
+ /** @default "linear" */
17
+ easeFunction: string;
18
+ /** @default "all" */
19
+ axis: string;
20
+ /** @default "0" */
21
+ preserveOther: number;
22
+ /** @default "0" */
23
+ targetValue: number;
24
+ /** @default "[]" */
25
+ targetVector: unknown[];
26
+ /** @default "[]" */
27
+ proposedStops: unknown[];
28
+ /** @default "" */
29
+ control: string;
30
+ /** @default "[0,0]" */
31
+ animationStart: unknown[];
32
+ /** @default "[0,0]" */
33
+ animationStop: unknown[];
34
+ /** @default "[]" */
35
+ animationState: unknown[];
36
+ }
37
+
38
+ export interface IRsgTweenShimProps extends IGroupProps {
39
+ targetNode?: import("@roku-sdk/interfaces").DomNode;
40
+ field?: string;
41
+ duration?: number;
42
+ easeFunction?: string;
43
+ axis?: 'x' | 'y' | 'all';
44
+ preserveOther?: number;
45
+ targetValue?: number;
46
+ targetVector?: [number, number];
47
+ proposedStops?: number[] | [number, number][];
48
+ control?: string;
49
+ onAnimationStart?: (t: [number, number]) => void;
50
+ onAnimationStop?: (t: [number, number, [number, number]]) => void;
51
+ onAnimationState?: (t: [import("@roku-sdk/components").AnimationState, number | import("@roku-sdk/components").Vector2D]) => void;
52
+ }
53
+
54
+ declare const _default: TypedXmlComponent<"RsgTweenShim", RsgTweenShimFields>;
55
+ export default _default;
56
+
57
+ declare module "@roku-sdk/solid" {
58
+ namespace JSX {
59
+ interface IntrinsicElements {
60
+ rsgTweenShim: IRsgTweenShimProps;
61
+ }
62
+ }
63
+ }
64
+
65
+ declare module "@roku-sdk/solid/jsx-runtime" {
66
+ namespace JSX {
67
+ interface IntrinsicElements {
68
+ rsgTweenShim: IRsgTweenShimProps;
69
+ }
70
+ }
71
+ }
72
+
73
+ declare module "solid-js" {
74
+ namespace JSX {
75
+ interface IntrinsicElements {
76
+ rsgTweenShim: IRsgTweenShimProps;
77
+ }
78
+ }
79
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,11 @@
1
+ {
2
+ "extends": "@roku-sdk/dev-tools/typescript-config/tsconfig.base.json",
3
+ "compilerOptions": {
4
+ "outDir": "./dist",
5
+ "rootDir": "./src",
6
+ "types": ["@roku-sdk/interfaces/global"]
7
+ },
8
+ "include": ["src/**/*"],
9
+ "exclude": ["dist", "node_modules"],
10
+ "files": ["xml.d.ts"]
11
+ }
package/xml.d.ts ADDED
@@ -0,0 +1,4 @@
1
+ declare module "*.xml" {
2
+ const componentName: string;
3
+ export default componentName;
4
+ }