@roku-sdk/ai 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.
Files changed (53) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +88 -2
  3. package/package.json +47 -3
  4. package/skills/ads-and-raf/SKILL.md +231 -0
  5. package/skills/ads-and-raf/references/integration-flows.md +332 -0
  6. package/skills/app-packaging/SKILL.md +107 -0
  7. package/skills/app-performance/SKILL.md +133 -0
  8. package/skills/cli-dev-loop/SKILL.md +217 -0
  9. package/skills/cli-dev-loop/references/logs-and-crashes.md +114 -0
  10. package/skills/cpu-and-heap-profiling/SKILL.md +108 -0
  11. package/skills/cpu-and-heap-profiling/references/reading-profiles.md +52 -0
  12. package/skills/data-entry-keyboards/SKILL.md +160 -0
  13. package/skills/device-management/SKILL.md +158 -0
  14. package/skills/dialogs-and-modals/SKILL.md +210 -0
  15. package/skills/effects/SKILL.md +183 -0
  16. package/skills/fetch-and-data-loading/SKILL.md +164 -0
  17. package/skills/fetch-and-data-loading/references/https-and-certificates.md +79 -0
  18. package/skills/flex-grid/SKILL.md +236 -0
  19. package/skills/flex-grid/references/focus-integration.md +355 -0
  20. package/skills/flex-grid/references/plugins.md +201 -0
  21. package/skills/focus-management/SKILL.md +210 -0
  22. package/skills/focus-management/references/focus-recipes.md +290 -0
  23. package/skills/fonts-and-glyphs/SKILL.md +231 -0
  24. package/skills/fonts-and-glyphs/references/glyph-tables.md +128 -0
  25. package/skills/logging-and-errors/SKILL.md +227 -0
  26. package/skills/logging-and-errors/references/uncaught-exceptions.md +137 -0
  27. package/skills/mcp-ai-dev-loop/SKILL.md +189 -0
  28. package/skills/mcp-ai-dev-loop/references/crash-and-console.md +68 -0
  29. package/skills/mcp-ai-dev-loop/references/live-debugging.md +129 -0
  30. package/skills/mcp-setup/SKILL.md +131 -0
  31. package/skills/project-setup/SKILL.md +210 -0
  32. package/skills/project-setup/references/roku-config.md +120 -0
  33. package/skills/screen-navigation/SKILL.md +166 -0
  34. package/skills/sdk-components/SKILL.md +213 -0
  35. package/skills/sdk-components/references/dialogs.md +148 -0
  36. package/skills/sdk-components/references/scenegraph-node-spelling.md +123 -0
  37. package/skills/sdk-installation/SKILL.md +129 -0
  38. package/skills/slideshow/SKILL.md +157 -0
  39. package/skills/slideshow/references/focus-and-handoff.md +187 -0
  40. package/skills/solid-on-roku/SKILL.md +181 -0
  41. package/skills/solid-on-roku/references/hermes-and-exports.md +130 -0
  42. package/skills/testing/SKILL.md +147 -0
  43. package/skills/testing/references/async-and-timing.md +97 -0
  44. package/skills/user-accounts-and-purchases/SKILL.md +164 -0
  45. package/skills-manifest.json +316 -0
  46. package/skills-manifest.schema.json +62 -0
  47. package/src/__tests__/manifest.test.ts +267 -0
  48. package/src/__tests__/snippets.test.ts +221 -0
  49. package/src/__tests__/trigger-routing.test.ts +77 -0
  50. package/src/index.ts +36 -0
  51. package/src/manifest.ts +215 -0
  52. package/src/router.ts +147 -0
  53. package/src/snippets.ts +212 -0
@@ -0,0 +1,355 @@
1
+ # Focus Integration
2
+
3
+ A grid is in one of two modes, and the mode decides everything about how keys reach it. One
4
+ config field selects it.
5
+
6
+ | `focusId` | Mode | How keys arrive |
7
+ |---|---|---|
8
+ | passed | **top-level** | The grid renders its own nested `FocusBoundary` and claims seven keys from the enclosing boundary — the four movement keys, `OK`, `fastforward` and `rewind` |
9
+ | omitted | **bare** (nested in another grid's cell) | No boundary exists. The parent cell's `KeyDelegate` calls `handle.forwardKeyEvent(key, press)` |
10
+
11
+ Nothing else distinguishes them: same config, same handle, same plugins.
12
+
13
+ ## Top-level: hand focus to a sibling region at an edge
14
+
15
+ The grid claims `left`, `right`, `up`, `down`, `OK`, `fastforward` and `rewind`, and nothing
16
+ else — a key outside that set bubbles to the enclosing boundary untouched, which is how a
17
+ screen's `back` still works. By default a claimed key is consumed whether or not it moved
18
+ anything, so the enclosing `onUnhandledKey` never sees one; `declineUnhandledNavigation: true`
19
+ changes that, and is covered under nested grids below, where it matters most. Those are the runtime strings the firmware delivers, which is what
20
+ `handleAdditionalKeyEvents` is compared against; the enum members for the last two are spelled
21
+ `RemoteKey.Fwd` and `RemoteKey.Rev`. Add to the set with `handleAdditionalKeyEvents`, and catch
22
+ what the grid then does not use with `onUnhandledAdditionalKey`; both are top-level concerns and
23
+ neither exists in bare mode. The list is filtered against the seven above, so naming one of them
24
+ there is dropped rather than rejected — a caller cannot re-route a navigation key and break the
25
+ grid.
26
+
27
+ `onEdgeReached` is the region hand-off cue. Its return is the half that reads the other way round
28
+ from most APIs:
29
+
30
+ | | |
31
+ |---|---|
32
+ | Signature | `onEdgeReached?: (edge: PositionValue) => void` |
33
+ | The argument | One of `"top"`, `"bottom"`, `"left"`, `"right"`. `PositionValue` aliases the `Position` string enum, whose members hold exactly those strings, so `edge === "top"` and `edge === Position.Top` both compile and both match. Both names are exported from 2.15.0 on, and an install older than that types the argument as the bare literal union — so comparing against the literals is the form that holds on every version |
34
+ | The return | `void`. There is no consume signal — returning `true` is meaningless, and the key is already spent |
35
+ | When it fires | The user was **already** at that boundary and pressed further. Never on a wrap, and never on a press that moved focus |
36
+
37
+ So moving focus is your call to make, and the only thing the grid promises is the report.
38
+
39
+ ```tsx rsg-skill=complete
40
+ import { Group, Label, Rectangle } from "@roku-sdk/components";
41
+ import type { CellRenderProps } from "@roku-sdk/flex-grid";
42
+ import { defineFlexGrid, fixedFocusStrategy, floatingFocusYStrategy } from "@roku-sdk/flex-grid";
43
+ import { FocusBoundary, useFocusable, useFocusActions } from "@roku-sdk/navigation";
44
+ import { type Component, onCleanup } from "@roku-sdk/solid";
45
+
46
+ interface Card {
47
+ title: string;
48
+ }
49
+
50
+ const screenId: object = {};
51
+ const navId: object = {};
52
+ const gridId: object = {};
53
+ const firstTabId: object = {};
54
+
55
+ const Shelf = defineFlexGrid<Card>();
56
+
57
+ const CardCell: Component<CellRenderProps<Card>> = props => (
58
+ <group>
59
+ <rectangle
60
+ width={props.width}
61
+ height={props.height}
62
+ color={props.focused() ? "0x0055FFFF" : "0x1A1A1AFF"}
63
+ />
64
+ <label text={props.item?.title ?? "…"} translation={[16, 120]} />
65
+ </group>
66
+ );
67
+
68
+ function cardCellComponent(): Component<CellRenderProps<Card>> {
69
+ return CardCell;
70
+ }
71
+
72
+ function NavTab(props: { label: string; focusId?: object; x: number }) {
73
+ const { ref, isFocused } = useFocusable({ focusId: props.focusId });
74
+
75
+ return (
76
+ <group ref={ref} translation={[props.x, 0]}>
77
+ <rectangle width={200} height={56} color={isFocused() ? "0x0055FFFF" : "0x1A1A1AFF"} />
78
+ <label text={props.label} translation={[16, 16]} />
79
+ </group>
80
+ );
81
+ }
82
+
83
+ /** Rendered inside the screen's boundary, which is what makes both regions reachable. */
84
+ export const ShelfScreen: Component = () => {
85
+ // Written by the capture component, read by the edge callback.
86
+ let focusRegion: ((id: object) => void) | undefined;
87
+
88
+ const CaptureRegionFocus: Component = () => {
89
+ const { setFocus } = useFocusActions();
90
+ focusRegion = setFocus;
91
+ onCleanup(() => {
92
+ focusRegion = undefined;
93
+ });
94
+ return null;
95
+ };
96
+
97
+ // Annotated with the literals rather than `PositionValue`: the values are those
98
+ // strings, and a handler typed this way stays assignable on an SDK too old to
99
+ // export the alias.
100
+ const onEdgeReached = (edge: "top" | "bottom" | "left" | "right"): void => {
101
+ if (edge === "top") focusRegion?.(navId);
102
+ };
103
+
104
+ // The grid is built by this component's JSX, inside the boundary below — not
105
+ // above it. A grid created outside the boundary's owner becomes a second root
106
+ // and never receives a key.
107
+ return (
108
+ <FocusBoundary focusId={screenId} initialFocus={gridId} navigation="vertical">
109
+ <CaptureRegionFocus />
110
+
111
+ <FocusBoundary focusId={navId} initialFocus={firstTabId} navigation="horizontal">
112
+ <NavTab label="Home" focusId={firstTabId} x={0} />
113
+ <NavTab label="Movies" x={220} />
114
+ </FocusBoundary>
115
+
116
+ <Shelf
117
+ focusId={gridId}
118
+ numRows={30}
119
+ numCols={6}
120
+ cellSize={[280, 158]}
121
+ cellSpacing={[24, 32]}
122
+ translation={[80, 240]}
123
+ focus={{
124
+ horizontal: { strategy: fixedFocusStrategy, options: { focusOffset: 80 } },
125
+ vertical: { strategy: floatingFocusYStrategy, options: { focusOffset: 0 } }
126
+ }}
127
+ getCellComponent={cardCellComponent}
128
+ onEdgeReached={onEdgeReached}
129
+ />
130
+ </FocusBoundary>
131
+ );
132
+ };
133
+ ```
134
+
135
+ ## Focusable cells (`setFocusBoundary`)
136
+
137
+ A composite cell — several buttons, a nested grid, real internal navigation — can hand the grid
138
+ its own `FocusBoundary` identity instead of intercepting keys with a `KeyDelegate`. While that
139
+ cell is focused, the grid parks its boundary's virtual focus there, so keys arrive through the
140
+ focus chain and grid navigation runs only when the cell **declines**.
141
+
142
+ `setFocusBoundary` is a 3.x addition later than 3.0.0, so an install predating it types
143
+ `CellRenderProps` without the prop and the sample below does not compile there. Where
144
+ `props.setFocusBoundary` is missing, `setKeyDelegate` is the composite-cell mechanism every version
145
+ has, with the key order inverted: the delegate runs *before* grid navigation and hands a key back by
146
+ returning `false`, and `isFocused()` does not work inside the cell because no boundary of its own is
147
+ focused.
148
+
149
+ ```tsx rsg-skill=complete
150
+ import { Group, Label, Rectangle } from "@roku-sdk/components";
151
+ import type { CellRenderProps } from "@roku-sdk/flex-grid";
152
+ import { FocusBoundary } from "@roku-sdk/navigation";
153
+ import type { Component } from "@roku-sdk/solid";
154
+
155
+ interface ShelfItem {
156
+ title: string;
157
+ }
158
+
159
+ export const CompositeCell: Component<CellRenderProps<ShelfItem>> = props => {
160
+ const cellRef: object = {};
161
+ props.setFocusBoundary(cellRef);
162
+
163
+ return (
164
+ <FocusBoundary focusId={cellRef} initialFocus={cellRef} rememberLastFocus={false}>
165
+ <group>
166
+ <rectangle
167
+ width={props.width}
168
+ height={props.height}
169
+ color={props.focused() ? "0x0055FFFF" : "0x1A1A1AFF"}
170
+ />
171
+ <label text={props.item?.title ?? "…"} translation={[16, 120]} />
172
+ </group>
173
+ </FocusBoundary>
174
+ );
175
+ };
176
+ ```
177
+
178
+ Requires the outer grid to be in boundary mode (`focusId` set). The registration is **not**
179
+ cleared on pool recycle — drive per-item state from `props.item` and prefer
180
+ `rememberLastFocus={false}`. `rememberLastFocus` remembers per pool node, not per item, so a
181
+ recycled node reopens on whatever the previous item last focused; to remember per item, store
182
+ the last sub-focus on the item and derive `initialFocus` from it. Shared pools support this pattern: when a grid focuses a node,
183
+ a focus relay moves the cell's registration to that grid. All grids sharing the pool
184
+ must be under the same root `FocusBoundary`.
185
+
186
+ ## Nested grids — boundary mode (preferred)
187
+
188
+ Give the inner grid a `focusId` too, register that identity in the parent cell's
189
+ `FocusBoundary` (`navigationMap` moves sub-focus into it), and set
190
+ `declineUnhandledNavigation: true` so keys the inner grid could not act on keep bubbling.
191
+ Without it the inner boundary claims every movement key and the user is trapped at its edges.
192
+
193
+ `declineUnhandledNavigation` is the same vintage as `setFocusBoundary` — a 3.x addition later than
194
+ 3.0.0 — so an install predating it rejects the config key, and a nested grid there cannot decline a
195
+ movement key it could not act on.
196
+
197
+ ```tsx rsg-skill=complete
198
+ import { Group } from "@roku-sdk/components";
199
+ import type { CellRenderProps } from "@roku-sdk/flex-grid";
200
+ import { createFlexGrid, fixedFocusStrategy, floatingFocusYStrategy } from "@roku-sdk/flex-grid";
201
+ import { FocusBoundary } from "@roku-sdk/navigation";
202
+ import type { Component } from "@roku-sdk/solid";
203
+
204
+ interface Episode {
205
+ title: string;
206
+ }
207
+
208
+ interface Series {
209
+ episodes: Episode[];
210
+ }
211
+
212
+ const nestedGridId: object = {};
213
+ const detailZoneId: object = {};
214
+
215
+ export const SeriesCell: Component<CellRenderProps<Series>> = props => {
216
+ const cellRef: object = {};
217
+ props.setFocusBoundary(cellRef);
218
+
219
+ const inner = createFlexGrid<Episode>({
220
+ focusId: nestedGridId,
221
+ declineUnhandledNavigation: true,
222
+ numRows: 1,
223
+ numCols: 12,
224
+ cellSize: [220, 124],
225
+ focus: {
226
+ horizontal: { strategy: fixedFocusStrategy, options: { focusOffset: 40 } },
227
+ vertical: { strategy: floatingFocusYStrategy, options: { focusOffset: 0 } }
228
+ },
229
+ getCellComponent: () => EpisodeCell,
230
+ onItemSelected: (_row, _col, item) => item !== undefined
231
+ });
232
+
233
+ const navigationMap = new Map<object, Partial<Record<"up" | "down" | "left" | "right", object>>>([
234
+ [detailZoneId, { right: nestedGridId }],
235
+ [nestedGridId, { left: detailZoneId }]
236
+ ]);
237
+
238
+ return (
239
+ <FocusBoundary
240
+ focusId={cellRef}
241
+ initialFocus={detailZoneId}
242
+ rememberLastFocus={false}
243
+ navigationMap={navigationMap}
244
+ >
245
+ <group>{inner.element}</group>
246
+ </FocusBoundary>
247
+ );
248
+ };
249
+
250
+ const EpisodeCell: Component<CellRenderProps<Episode>> = () => <group />;
251
+ ```
252
+
253
+ Escapes are declarative: a `navigationMap` entry on the cell catches a direction (e.g. `left`
254
+ back to a Detail zone), and a direction the cell has no entry for is declined so the outer grid
255
+ changes row.
256
+
257
+ ## Bare: a grid inside another grid's cell
258
+
259
+ The inner grid gets no `focusId`. The outer cell registers a `KeyDelegate` while it is focused
260
+ and forwards keys into the inner grid, using the return value to decide whether the outer grid
261
+ should act instead.
262
+
263
+ Forward **both** press and release. A release is what clears a hold timer and settles a smooth
264
+ scroll, so swallowing it leaves the inner grid gliding after the user let go.
265
+
266
+ ```tsx rsg-skill=complete
267
+ import { Group, Label, Rectangle } from "@roku-sdk/components";
268
+ import type { CellRenderProps, KeyDelegate } from "@roku-sdk/flex-grid";
269
+ import { createFlexGrid, fixedFocusStrategy, floatingFocusYStrategy } from "@roku-sdk/flex-grid";
270
+ import { type Component, createEffect, onCleanup } from "@roku-sdk/solid";
271
+
272
+ interface Episode {
273
+ title: string;
274
+ }
275
+
276
+ interface Series {
277
+ episodes: Episode[];
278
+ }
279
+
280
+ const EpisodeCell: Component<CellRenderProps<Episode>> = props => (
281
+ <group>
282
+ <rectangle
283
+ width={props.width}
284
+ height={props.height}
285
+ color={props.focused() ? "0x0055FFFF" : "0x1A1A1AFF"}
286
+ />
287
+ <label text={props.item?.title ?? "…"} translation={[12, 96]} />
288
+ </group>
289
+ );
290
+
291
+ function episodeCellComponent(): Component<CellRenderProps<Episode>> {
292
+ return EpisodeCell;
293
+ }
294
+
295
+ /** A cell of the outer grid that expands into an inner grid of episodes. */
296
+ export const SeriesCell: Component<CellRenderProps<Series>> = props => {
297
+ // No focusId: this grid has no boundary and is driven entirely by the
298
+ // delegate below.
299
+ const inner = createFlexGrid<Episode>({
300
+ numRows: 1,
301
+ numCols: 12,
302
+ cellSize: [220, 124],
303
+ cellSpacing: [12, 0],
304
+ focus: {
305
+ horizontal: { strategy: fixedFocusStrategy, options: { focusOffset: 40 } },
306
+ vertical: { strategy: floatingFocusYStrategy, options: { focusOffset: 0 } }
307
+ },
308
+ getCellComponent: episodeCellComponent,
309
+ onItemSelected: (row, col, item) => item !== undefined
310
+ });
311
+
312
+ createEffect(() => {
313
+ if (!props.focused()) {
314
+ props.setKeyDelegate(null);
315
+ return;
316
+ }
317
+
318
+ const delegate: KeyDelegate = {
319
+ handleKey(key, press) {
320
+ // false means the inner grid did nothing with it, so the outer
321
+ // grid gets its turn — that is how an edge escapes the inner row.
322
+ return inner.forwardKeyEvent(key, press);
323
+ }
324
+ };
325
+
326
+ props.setKeyDelegate(delegate);
327
+ onCleanup(() => props.setKeyDelegate(null));
328
+ });
329
+
330
+ return <group>{inner.element}</group>;
331
+ };
332
+ ```
333
+
334
+ ### What `forwardKeyEvent` returns
335
+
336
+ The boolean is the whole contract: `true` means the inner grid consumed the key and the parent
337
+ must not act on it.
338
+
339
+ | Returns `true` | Returns `false` |
340
+ |---|---|
341
+ | The focused inner cell's own `KeyDelegate` consumed it | A directional press at a non-wrapping edge that moved nothing |
342
+ | A directional press moved focus, or wrapped under a wrap strategy | An idle horizontal release with no hold timer or smooth scroll to settle |
343
+ | A directional press or re-press that a live smooth-scroll session consumed | `OK` when `onItemSelected` is absent or returned `false` — deliberate, so selection bubbles |
344
+ | A directional release that did real work: cleared a hold timer, settled a scroll | A key the grid does not act on at all, such as a `play` release |
345
+ | `OK` accepted by an `onItemSelected` that returned `true` | A key listed in `handleAdditionalKeyEvents` that the focused cell declined |
346
+
347
+ Two asymmetries worth knowing, because both are silent:
348
+
349
+ - **Bare mode has no claimed-key filter.** In top-level mode only the boundary's keys reach a
350
+ cell's delegate; here *every* key you forward is offered to the focused inner cell first,
351
+ including keys with no navigation meaning. Forwarding a key the inner grid cannot use is
352
+ harmless, and forwarding only the arrows is what makes an inner cell's `play` handler dead.
353
+ - **`onEdgeReached` fires in both modes**, so an inner grid can report its own edges. Use it
354
+ when the reaction belongs to the grid; use the `false` return when the reaction belongs to the
355
+ parent cell, which is the usual case.
@@ -0,0 +1,201 @@
1
+ # Plugins
2
+
3
+ Everything optional about a grid is a plugin, and every plugin lives on its own subpath. That is
4
+ deliberate: the main barrel does not re-export them, so a grid without a row title never carries
5
+ the row-title code. Import the factory from the subpath, call it, and pass the result in
6
+ `plugins`.
7
+
8
+ | Subpath | Factory | What it owns |
9
+ |---|---|---|
10
+ | `@roku-sdk/flex-grid/focus-highlight` | `createFocusHighlightPlugin` | The highlight node around the focused cell, its tweens, layer and visibility |
11
+ | `@roku-sdk/flex-grid/zoom-focus` | `withZoomFocus`, `ScaleTween` | Scale-on-focus on cell templates (not a `plugins[]` entry) |
12
+ | `@roku-sdk/flex-grid/row-titles` | `createRowTitlePlugin` | A title drawn per row, scrolling with it |
13
+ | `@roku-sdk/flex-grid/bob-in-the-middle` | `createBobPlugin` | An interstitial band between two rows |
14
+ | `@roku-sdk/flex-grid/empty-placeholder` | `createEmptyPlaceholderPlugin` | What a cell shows while its row has not loaded |
15
+ | `@roku-sdk/flex-grid/row-clip` | `createRowCardsClipPlugin` | A rect one row's cards scroll through and are cut at |
16
+ | `@roku-sdk/flex-grid/row-backdrop` | `createRowBackdropPlugin` | One backdrop behind a run of rows, under every card |
17
+ | `@roku-sdk/flex-grid/perf` | `createPerfPlugin` | Frame and transaction counters, for a measurement session |
18
+
19
+ `@roku-sdk/flex-grid/compose-h-strategy` is on the same principle but is not a plugin: it
20
+ composes a horizontal focus *strategy* out of extensions, and its result goes in
21
+ `focus.horizontal.strategy`.
22
+
23
+ **One import trap.** Two different types are called `FocusHighlightConfig`. The main barrel
24
+ exports a legacy one that nothing reads; the plugin's own is on its subpath. Import the config
25
+ type from `@roku-sdk/flex-grid/focus-highlight` alongside the factory, or the fields you pass
26
+ will be rejected against a shape that has nothing to do with the plugin.
27
+
28
+ ## The focus highlight
29
+
30
+ | Field | Meaning |
31
+ |---|---|
32
+ | `render` | Your content instead of the default rectangle. Receives `width`, `height` and `focusedRow` as **accessors** |
33
+ | `layer` | `"front"` (default) draws above the cards, `"back"` below them |
34
+ | `visible` | An accessor. Returning false hides the node **and** clears every cell's `focused` signal, which is what dims in-cell outlines while a tab bar or a dialog owns focus |
35
+ | `hideOnSmoothScroll` | Fade the highlight out while a held scroll glides, and back in when it settles |
36
+ | `color` | The default rectangle's colour. Ignored once `render` is set |
37
+
38
+ `render` runs **once**, untracked, with no Solid parent context — it is a factory, not a
39
+ component body. So read its accessors inside the JSX it returns, and never destructure the
40
+ context object: doing either captures the first focused cell's size and keeps it forever.
41
+
42
+ ```tsx rsg-skill=complete
43
+ import { Poster } from "@roku-sdk/components";
44
+ import type { GridPlugin } from "@roku-sdk/flex-grid";
45
+ import { createFocusHighlightPlugin } from "@roku-sdk/flex-grid/focus-highlight";
46
+ import type { Accessor } from "@roku-sdk/solid";
47
+
48
+ interface Card {
49
+ title: string;
50
+ }
51
+
52
+ const BORDER = 12;
53
+
54
+ /**
55
+ * `width` and `height` stay accessors all the way into the JSX props, so the
56
+ * frame resizes when focus moves to a row with a different cell size.
57
+ */
58
+ export function cardFocusFrame(uri: string): GridPlugin<Card> {
59
+ return createFocusHighlightPlugin<Card>({
60
+ layer: "back",
61
+ hideOnSmoothScroll: true,
62
+ render: (ctx: { width: Accessor<number>; height: Accessor<number>; focusedRow: Accessor<number> }) => (
63
+ <poster
64
+ uri={uri}
65
+ width={ctx.width() + BORDER * 2}
66
+ height={ctx.height() + BORDER * 2}
67
+ translation={[-BORDER, -BORDER]}
68
+ />
69
+ )
70
+ });
71
+ }
72
+ ```
73
+
74
+ ## Scale-on-focus with `withZoomFocus`
75
+
76
+ Scale-on-focus is a **template** concern, not a plugin. Wrap cell templates at module scope
77
+ with `withZoomFocus` from `@roku-sdk/flex-grid/zoom-focus` so only templates that need zoom
78
+ pull in `ScaleTween`.
79
+
80
+ `withZoomFocus` arrived in **3.0.0**, which removed the `createZoomFocusPlugin` entry along
81
+ with the `getItemScale` and `requiredNodeSignals` hooks a scale plugin needed. On a 2.x
82
+ install (`@roku-sdk/flex-grid@maintenance-2x`) neither the wrapper nor its subpath export
83
+ exists, so zoom is still a `plugins[]` entry there; see that install's
84
+ `BREAKING_CHANGES.md` for the plugin-to-wrapper migration table.
85
+
86
+ When templates own the focus visual (zoom scale, in-cell outline), still add
87
+ `createFocusHighlightPlugin` to `plugins` with `renderNode: false` — also **3.0.0**, and the
88
+ half of the zoom migration that is easy to miss. It gates per-cell `focused` — returning
89
+ `false` from `visible` clears every cell's focus styling while a tab bar or dialog owns focus.
90
+ Dropping the highlight plugin because zoom draws the visual is how `visible` stops working.
91
+
92
+ ```tsx rsg-skill=complete
93
+ import type { Component } from "@roku-sdk/solid";
94
+ import type { CellRenderProps, GridPlugin } from "@roku-sdk/flex-grid";
95
+ import { createFocusHighlightPlugin } from "@roku-sdk/flex-grid/focus-highlight";
96
+ import { withZoomFocus } from "@roku-sdk/flex-grid/zoom-focus";
97
+ import type { Accessor } from "@roku-sdk/solid";
98
+
99
+ interface Card {
100
+ title: string;
101
+ }
102
+
103
+ const PortraitCell: Component<CellRenderProps<Card>> = (props) => (
104
+ <label text={props.item?.title ?? ""} width={props.width} />
105
+ );
106
+
107
+ const ZoomedPortraitCell = withZoomFocus(PortraitCell, {
108
+ focusedScale: 1,
109
+ unfocusedScale: 0.88,
110
+ durationSec: 0.2
111
+ });
112
+
113
+ /**
114
+ * @param gridHasFocus false while a tab bar or dialog owns focus, which drops
115
+ * every cell's focused styling via the highlight plugin's `visible`.
116
+ */
117
+ export function zoomedShelfPlugins(gridHasFocus: Accessor<boolean>): GridPlugin<Card>[] {
118
+ return [
119
+ createFocusHighlightPlugin<Card>({ visible: gridHasFocus, renderNode: false })
120
+ ];
121
+ }
122
+
123
+ export { ZoomedPortraitCell };
124
+ ```
125
+
126
+ ## A backdrop behind several rows
127
+
128
+ An image or colour behind a run of rows — a brand band under a pill row and a shelf — is
129
+ `createRowBackdropPlugin`, not row chrome. Chrome belongs to one row; this draws each region as
130
+ one node behind every row of the grid, which scrolls with the rows and glides with them when a
131
+ gap such as the bob opens above or inside it. Cards scroll over it horizontally.
132
+
133
+ | Field | Meaning |
134
+ |---|---|
135
+ | `regions` | An accessor returning `{ rows: [first, last], left?, width?, …your fields }[]`. `rows` is inclusive and is the region's **identity**: keep the tuple and the node stays; your other fields reach `render` as updates |
136
+ | `render` | `(region, size)` — runs **once per mount**, y = 0 at the region top. Read `region()` inside the JSX for anything that changes |
137
+ | `size.fill` | Pass as `ref` to each node spanning the whole region. The plugin owns its `id`, `width` and `height` and glides the height with the rows. The node must live as long as the region, so never put one under `<Show>` |
138
+ | `size.width`, `size.height` | Accessors. `height` is where the rows are heading, not tweened — place content that must not stretch against it |
139
+ | `above` / `below` | Px past the first row's cards and the last row's. `above` defaults to the grid's title reserve, so the band covers the first row's title; `below` to 0 |
140
+ | `left` / `width` | Default to the grid's viewport. In the coordinates the grid's `translation` uses, so `left: 0` with the screen width is full bleed on an inset grid |
141
+
142
+ A gap opening **inside** the region stretches it, so a `Poster` fill needs
143
+ `loadDisplayMode="scaleToZoom"` or the art distorts. A region more than about two viewports from
144
+ focus is unmounted and re-rendered on return, which reloads its images: bound them with
145
+ `loadWidth` / `loadHeight`, and set `uri` last so cached art does not draw before those apply.
146
+ `createPerfPlugin` reports each mount's nodes as "createElement called after warmup"; that is this
147
+ lifetime, not a leak.
148
+
149
+ `createRowBackdropPlugin` is a 3.x addition later than 3.0.0, so an install predating it has no
150
+ `@roku-sdk/flex-grid/row-backdrop` entry and the sample below does not compile there.
151
+
152
+ ```tsx rsg-skill=complete
153
+ import type { GridPlugin } from "@roku-sdk/flex-grid";
154
+ import { createFocusHighlightPlugin } from "@roku-sdk/flex-grid/focus-highlight";
155
+ import { createRowBackdropPlugin } from "@roku-sdk/flex-grid/row-backdrop";
156
+ import type { Accessor } from "@roku-sdk/solid";
157
+
158
+ interface Card {
159
+ title: string;
160
+ }
161
+
162
+ interface Brand {
163
+ name: string;
164
+ artUri: string;
165
+ }
166
+
167
+ const SCREEN_WIDTH = 1920;
168
+ const BAND_HEIGHT = 725;
169
+
170
+ /**
171
+ * The selected brand's art across the whole screen behind rows 7 and 8. A new
172
+ * brand updates the poster and label in place; the `rows` tuple never changes.
173
+ */
174
+ export function brandBandPlugins(brand: Accessor<Brand>): GridPlugin<Card>[] {
175
+ return [
176
+ // First: a back-layer highlight listed before it would draw under the band.
177
+ createRowBackdropPlugin({
178
+ regions: () => [{ rows: [7, 8] as const, left: 0, width: SCREEN_WIDTH, brand: brand() }],
179
+ render: (region, size) => (
180
+ <group>
181
+ <poster
182
+ ref={size.fill}
183
+ loadWidth={SCREEN_WIDTH}
184
+ loadHeight={BAND_HEIGHT}
185
+ loadDisplayMode="scaleToZoom"
186
+ uri={region().brand.artUri}
187
+ />
188
+ <label text={region().brand.name} translation={[108, 284]} />
189
+ </group>
190
+ )
191
+ }),
192
+ createFocusHighlightPlugin<Card>({ layer: "back" })
193
+ ];
194
+ }
195
+ ```
196
+
197
+ ## Cost of a plugin array
198
+
199
+ `plugins` is a structural prop, so a fresh array on every parent render remounts the grid and
200
+ drops focus. Build it once — a module-level constant, a `createMemo`, or a function called once
201
+ like the two above — and never inline the factory calls into JSX.