@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,210 @@
1
+ # Focus Management
2
+
3
+ ## Overview
4
+
5
+ D-pad focus in an RSG SDK TypeScript app is virtual: one SceneGraph node holds real focus
6
+ for the whole screen, and which item is focused inside it is a SolidJS signal, so moving
7
+ focus is a signal write rather than a SceneGraph call. `FocusBoundary` owns that state and
8
+ `useFocusable` registers the items that can hold it, both from `@roku-sdk/navigation`.
9
+
10
+ ## Read the recipe first for two of these tasks
11
+
12
+ | If the request is | Read now, before answering |
13
+ |---|---|
14
+ | A dialog or overlay that holds focus while open and gives it back on close | `references/focus-recipes.md` → *Hold focus inside a dialog, and give it back* |
15
+ | Focus moving between regions on a key the layout does not already carry | `references/focus-recipes.md` → *Hand focus between regions of a screen* |
16
+
17
+ The tables below name the API for both, and stop there: the props you pass it, and where the
18
+ call has to sit for it to work at all, are only in the recipe. An answer assembled from a table
19
+ row is an invented one — so read the recipe and quote it, and never hand the pointer to the
20
+ user in place of an answer.
21
+
22
+ ## When to Use
23
+
24
+ Use this whenever the question is which item is focused, or what a key press does to it:
25
+
26
+ - a menu, row, list or grid that has to respond to the D-pad
27
+ - highlighting the focused item and dimming the rest
28
+ - an OK press that must act on the focused item
29
+ - handing focus between regions of a screen
30
+ - focus that starts on the wrong item, will not move, or is lost when the data changes
31
+ - an item that must be skipped while it is disabled
32
+
33
+ ### When not to use
34
+
35
+ | Situation | Use instead | Because |
36
+ |---|---|---|
37
+ | An overlay you draw yourself out of ordinary nodes | `ModalBoundary` + `useModal` from `@roku-sdk/components`, wired from `references/focus-recipes.md` | It already implements enter on open, dismiss on `back`, and restore on close |
38
+ | A `StandardDialog` | The `sdk-components` skill for what it is made of; nothing here for its focus | It renders a native SceneGraph modal, so focus enters and returns on its own. Point `initialFocusId` at the first button and leave `focusActive` alone — an enclosing `FocusBoundary` deactivates itself while it is up |
39
+ | A region a `FlexGrid` owns — its edges, its cells, its keys | `@roku-sdk/flex-grid`'s own `onEdgeReached` | The grid claims seven keys from the boundary — up, down, left, right, `OK`, `fastforward` and `rewind` — and by default consumes them whether or not they moved anything, so `onUnhandledKey` never sees one and a hand-off written there is dead code. `declineUnhandledNavigation: true` on the grid is the exception: claimed keys that moved nothing then do bubble |
40
+ | BrightScript component interop: `nextFocusUp`, `onFocusedChild`, focus driven on the render thread | `FocusableGroup` / `Focusable` | Those need real SceneGraph focus. It is a legacy path — do not start new work there |
41
+ | Imperative key routing the user asked for by name, or that is already in the file | `KeyHandler` | Every key costs a BrightScript round trip and movement logic ends up per component |
42
+ | Opening and closing a dialog from elsewhere in the app, telling a viewer's choice from a dismissal, or chaining several dialogs | The `dialogs-and-modals` skill | Holding focus while an overlay is up is this skill's job; deciding which overlay is up and what its close means is not |
43
+ | Moving between whole screens | The `screen-navigation` skill | Screen routing is not a focus concern; focus work starts once the screen is up |
44
+
45
+ ## Quick Reference
46
+
47
+ | Operation | API |
48
+ |---|---|
49
+ | Wire a screen for focus | `RootFocusBoundary` with `keyEvent`, `focusActive` and `setHandledKeys`, on an anchor node extending `FocusBoundaryBase` |
50
+ | Make an item focusable | `useFocusable()`, then attach the returned `ref` and read `isFocused()` |
51
+ | Act on OK on the focused item | `onKey: { OK: onPress(() => …) }` in `useFocusable` |
52
+ | Name a key without memorising its runtime string | `RemoteKey` from `@roku-sdk/interfaces` — `onKey: { [RemoteKey.Select]: … }`. Four members read differently from the string they carry: `Select` is `"OK"`, `Fwd` is `"fastforward"`, `Rev` is `"rewind"`, `Info` is `"options"`. Bare strings stay valid |
53
+ | Move on up/down, left/right, or all four | `navigation="vertical"`, `"horizontal"`, `"grid"` on the boundary |
54
+ | Route movement in an irregular layout | `navigationMap`: a `Map` from each identity to its neighbour per direction |
55
+ | Add a second region to a screen | A nested `FocusBoundary` with a `focusId` that the parent's `initialFocus` names |
56
+ | Skip an item while it is disabled | `canReceiveFocus: () => !disabled()` |
57
+ | Move focus from app logic | `useFocusActions()` → `setFocus(id)`; `focusedId()` to read it |
58
+ | Act on a key nobody handled, such as `back` | `onUnhandledKey` on the boundary |
59
+ | Reopen a region at its first item | `rememberLastFocus={false}` — the default `true` restores the last item |
60
+ | Mark the item an inactive region will return to — dialog open, or focus in another region | `hasFootprint()` from `useFocusable`; `hasReturnFootprint()` when nested regions or `rememberLastFocus={false}` must mark only where focus will land, and `useFocusActions().hasReturnFootprint()` for the region itself |
61
+
62
+ ## Instructions
63
+
64
+ `@roku-sdk/navigation` is not in the set `rk create` installs. Add it to the app's own
65
+ `package.json` first — `workspace:*` inside the SDK's pnpm workspace, otherwise the version the
66
+ app's other `@roku-sdk/*` packages are on — or every import below fails to resolve.
67
+
68
+ 1. **Wire the screen once.** Give the screen's anchor node `extends: "FocusBoundaryBase"`,
69
+ `keyEvent` and `focusActive` as input fields and `handledKeys` as an output field, then
70
+ pass all three to `RootFocusBoundary`. Without this no key reaches TypeScript and nothing
71
+ moves. One per anchor node — never give a nested boundary its own `keyEvent`.
72
+
73
+ For TypeScript descriptors, keep `FocusBoundaryBase`: registration selects the native
74
+ component when firmware reports `hasFeature("focusBoundaryBase")`, otherwise
75
+ `FocusBoundaryBaseShim`. XML/BrightScript does not go through that remap: extend or
76
+ create `FocusBoundaryBaseShim` by name to keep working on existing firmware.
77
+
78
+ 2. **Wrap each region in a `FocusBoundary`** and set `navigation` to the axis the region
79
+ moves on. Nest a region by giving it a `focusId` that its parent's `initialFocus` or
80
+ `navigationMap` names.
81
+
82
+ 3. **Point `initialFocus` at something registered in that same boundary** — a child's
83
+ `useFocusable({ focusId })` identity, or a nested boundary's `focusId`. An identity
84
+ registered one level further down never matches, and focus starts nowhere.
85
+
86
+ 4. **Call `useFocusable()` in every item that can hold focus**, attach the returned `ref` to
87
+ the item's root node, and put the item's own keys in `onKey`. Use `onPress` / `onRelease`
88
+ to fire on one edge instead of guarding `press` by hand.
89
+
90
+ 5. **Leave directional keys to the boundary.** A leaf handles OK and its own keys; up, down,
91
+ left and right are the boundary's job, so do not add them to a leaf's `onKey`.
92
+
93
+ 6. **Read `isFocused()` inside JSX** or another reactive scope, and never destructure
94
+ `props`. Reading either once at the top of a component captures a value that never
95
+ updates again.
96
+
97
+ 7. **Iterate with `<For>`**, never `items.map()`. `map` recreates every child when the array
98
+ changes, which unregisters the focused item and drops focus.
99
+
100
+ 8. **Create a `focusId` only when something names it** — `initialFocus`, a `navigationMap`
101
+ entry, `setFocus`, a `focusedId()` comparison, or a `FlexGrid` `focusId`. Every other
102
+ item gets one automatically, and linear navigation follows registration order.
103
+
104
+ ## Example
105
+
106
+ A screen wired end to end: the anchor node, one vertical region, three rows, per-row OK
107
+ handling, and focus styling driven by `isFocused()`.
108
+
109
+ ```tsx rsg-skill=complete
110
+ import { Group, Label, Rectangle } from "@roku-sdk/components";
111
+ import { FocusBoundary, onPress, RootFocusBoundary, useFocusable } from "@roku-sdk/navigation";
112
+ import { type AnchorNodeImplementation, defineAnchorNode, FieldType } from "@roku-sdk/rsg-ts";
113
+ import { For } from "@roku-sdk/solid";
114
+
115
+ const MainSceneDescription = {
116
+ componentName: "MainScene",
117
+ extends: "FocusBoundaryBase" as const,
118
+ inputFields: {
119
+ keyEvent: FieldType.Object(),
120
+ focusActive: FieldType.Boolean(true)
121
+ },
122
+ outputFields: {
123
+ handledKeys: FieldType.Object(),
124
+ exitApp: FieldType.Boolean()
125
+ }
126
+ };
127
+
128
+ // Two identities this screen names: the region the root boundary starts in,
129
+ // and the row that region starts on.
130
+ const menuId: object = {};
131
+ const homeRowId: object = {};
132
+
133
+ interface Item {
134
+ label: string;
135
+ disabled?: boolean;
136
+ focusId?: object;
137
+ }
138
+
139
+ const ITEMS: Item[] = [
140
+ { label: "Home", focusId: homeRowId },
141
+ { label: "Search" },
142
+ { label: "Settings", disabled: true }
143
+ ];
144
+
145
+ function MenuRow(props: { item: Item; y: number; onSelect: (label: string) => void }) {
146
+ const { ref, isFocused } = useFocusable({
147
+ focusId: props.item.focusId,
148
+ onKey: { OK: onPress(() => props.onSelect(props.item.label)) },
149
+ canReceiveFocus: () => !props.item.disabled
150
+ });
151
+
152
+ return (
153
+ <group ref={ref} translation={[0, props.y]}>
154
+ <rectangle width={480} height={72} color={isFocused() ? "0x0055FFFF" : "0x1A1A1AFF"} />
155
+ <label
156
+ text={props.item.label}
157
+ translation={[24, 20]}
158
+ color={isFocused() ? "0xFFFFFFFF" : "0x8A8A8AFF"}
159
+ />
160
+ </group>
161
+ );
162
+ }
163
+
164
+ const MainScene: AnchorNodeImplementation<typeof MainSceneDescription> = (inputs, outputs) => (
165
+ <RootFocusBoundary
166
+ keyEvent={inputs.keyEvent as Parameters<typeof RootFocusBoundary>[0]["keyEvent"]}
167
+ focusActive={inputs.focusActive as boolean}
168
+ setHandledKeys={outputs.handledKeys}
169
+ initialFocus={menuId}
170
+ onUnhandledKey={{ back: onPress(() => outputs.exitApp(true)) }}
171
+ >
172
+ <FocusBoundary focusId={menuId} initialFocus={homeRowId} navigation="vertical">
173
+ <For each={ITEMS}>
174
+ {(item, index) => (
175
+ <MenuRow item={item} y={index() * 88} onSelect={label => openScreen(label)} />
176
+ )}
177
+ </For>
178
+ </FocusBoundary>
179
+ </RootFocusBoundary>
180
+ );
181
+
182
+ function openScreen(label: string): void {
183
+ // app logic for the selected row
184
+ }
185
+
186
+ export default defineAnchorNode(MainSceneDescription, MainScene);
187
+ ```
188
+
189
+ ## Common Mistakes
190
+
191
+ | Symptom | Cause | Fix |
192
+ |---|---|---|
193
+ | Nothing moves and no handler fires | The anchor node is not wired, or the items have no boundary above them | `extends: "FocusBoundaryBase"`, `keyEvent` in, `handledKeys` out |
194
+ | The focus fields were added, but `inputs.keyEvent` and `outputs.handledKeys` come out untyped | The scaffold annotates the descriptor `: AnchorNodeDescriptor`, which widens the field maps, and `AnchorNodeImplementation<typeof …>` derives its types from what `typeof` still knows | Drop the annotation and keep `as const` on `extends`, as the example does — the descriptor should be inferred, not declared |
195
+ | The screen compiles but the app's `.test.tsx` stops compiling the moment the anchor node is wired: `Type '{}' is not assignable`, missing `keyEvent` and `focusActive` | `TestAnchorNode` derives the test component's props from the descriptor's field maps, so the three focus fields are now required props of it — the scaffolded `<TestApp />` passes none | Pass all three: `<TestApp keyEvent={{}} focusActive={true} handledKeys={() => undefined} />` — render `keyEvent` from a signal when the test has to press a key |
196
+ | `'Row' cannot be used as a JSX component. Its return type 'Component' is not a valid JSX element` | A focusable leaf was annotated `function Row(props): Component`. `Component` is the type of the function, not of what it returns | Annotate the value — `const Row: Component<Props> = props => …` — or leave the return type inferred, as the example does |
197
+ | OK works but the arrows do nothing | Wrong axis on `navigation`, or a `navigationMap` with that direction unmapped | Set the axis the region moves on; unmapped directions are deliberately not claimed |
198
+ | Focus drops whenever the list updates | `items.map()` in the JSX | Use `<For>` |
199
+ | The highlight never changes | `isFocused` read outside a reactive scope, or `props` destructured | Read it inside JSX, `createMemo`, `createEffect` or a callback |
200
+ | Focus starts nowhere, and even `onUnhandledKey` is dead | `initialFocus` names an id registered deeper than this boundary, or one nothing registered | Point it at a direct child: a leaf's `focusId`, or a nested boundary's `focusId` |
201
+ | Focus jumps back, or fights the keys | `gainFocus` on a SceneGraph node while the boundary is also moving focus | Inside a boundary, move focus with `setFocus` only |
202
+ | One item is never reachable | Its `canReceiveFocus` returns false | Check the condition — a disabled item is skipped by design |
203
+ | A whole branch looks unfocused — behind an open dialog, or once another region has focus | By design: `isFocused()` is `isActive() && focusedId() === focusId`, so it is false throughout an inactive branch, and false everywhere when `focusActive` is false | Draw the "focus returns here" marker from `hasFootprint()` |
204
+ | Two regions react to one key, or a nested region steals keys | A nested boundary was given `keyEvent` | Only the root boundary gets it |
205
+ | Focus is lost when app state changes | Focus state is held in app state and the subtree is rebuilt from it | Let the boundary own focus; read `isFocused()` |
206
+ | A handler armed something on key-down and the key-up never arrived, so a hold never ends | A release is routed by focus *at key-up time*, and focus moved while the key was held — something now in the path claimed the release, or the key left `handledKeys` and BrightScript filtered it out | Set `captureKeyPress` on the boundary that owns the handler: a consumed press is bound to it and the release is delivered there directly. `FlexGrid` exposes the same flag |
207
+
208
+ Props and options are typed with TSDoc in `@roku-sdk/navigation` — `IRootFocusBoundaryProps`,
209
+ `IFocusBoundaryProps`, `UseFocusableOptions`, `FocusActions`. Read a prop there rather than
210
+ guessing its name.
@@ -0,0 +1,290 @@
1
+ # Focus Recipes
2
+
3
+ Two procedures that need more than a row in a table, and that the body of the skill
4
+ therefore does not carry. Each is a whole screen's worth of decisions.
5
+
6
+ ## Hand focus between regions of a screen
7
+
8
+ ### First: check whether you need any code at all
9
+
10
+ A boundary hands its keys upward when the focused child declines them **and** its own
11
+ directional navigation finds no eligible neighbour. So for a regular layout — a menu on the
12
+ left, content on the right — the hand-off is already implemented: wrap both regions in one
13
+ boundary with `navigation="horizontal"`, and `right` at the menu's edge moves focus into the
14
+ content region with no callback anywhere. Reach for the recipe below only when the hand-off is
15
+ **conditional** (the target region has no data yet) or **non-directional** (`OK` on a menu row
16
+ jumps into the content it describes).
17
+
18
+ One region this recipe does not cover: a `FlexGrid`. It claims seven keys from the boundary — the
19
+ four movement keys, `OK`, `fastforward` and `rewind` — so neither the parent's navigation nor its
20
+ `onUnhandledKey` ever sees them, and the edge it reports arrives through the grid's own
21
+ `onEdgeReached`. Everything below still applies to the *receiving* side — `setFocus` is how focus
22
+ arrives at either kind of region.
23
+
24
+ ### Then: capture `setFocus` inside the boundary that owns both regions
25
+
26
+ `useFocusActions()` resolves against the *nearest* boundary, and `setFocus` only reaches
27
+ identities registered with that one. Both region boundaries register with their common parent,
28
+ so the hook has to be called from a component rendered **inside** that parent — which is why
29
+ this pattern exists as a component that renders nothing.
30
+
31
+ ```tsx rsg-skill=complete
32
+ import { Group, Label, Rectangle } from "@roku-sdk/components";
33
+ import { FocusBoundary, onPress, useFocusable, useFocusActions } from "@roku-sdk/navigation";
34
+ import { type Component, For, onCleanup } from "@roku-sdk/solid";
35
+
36
+ const menuId: object = {};
37
+ const contentId: object = {};
38
+ const firstMenuItemId: object = {};
39
+ const firstCardId: object = {};
40
+
41
+ const MENU = ["Home", "Movies", "Search"];
42
+ const CARDS = ["A", "B", "C", "D"];
43
+
44
+ function MenuRow(props: { label: string; focusId?: object; y: number; onEnter: () => void }) {
45
+ const { ref, isFocused } = useFocusable({
46
+ focusId: props.focusId,
47
+ onKey: { OK: onPress(() => props.onEnter()) }
48
+ });
49
+
50
+ return (
51
+ <group ref={ref} translation={[0, props.y]}>
52
+ <rectangle width={320} height={64} color={isFocused() ? "0x0055FFFF" : "0x1A1A1AFF"} />
53
+ <label text={props.label} translation={[24, 18]} />
54
+ </group>
55
+ );
56
+ }
57
+
58
+ function Card(props: { label: string; focusId?: object; x: number }) {
59
+ const { ref, isFocused } = useFocusable({ focusId: props.focusId });
60
+
61
+ return (
62
+ <group ref={ref} translation={[props.x, 0]}>
63
+ <rectangle width={240} height={135} color={isFocused() ? "0x0055FFFF" : "0x1A1A1AFF"} />
64
+ <label text={props.label} translation={[16, 100]} />
65
+ </group>
66
+ );
67
+ }
68
+
69
+ export const TwoRegionScreen: Component<{ focusId?: object; hasContent: () => boolean }> = props => {
70
+ // Written by the capture component below, read by the key handlers. Not a
71
+ // signal: nothing renders from it, and a signal write here would be a
72
+ // render-time update.
73
+ let focusRegion: ((id: object) => void) | undefined;
74
+
75
+ const CaptureRegionFocus: Component = () => {
76
+ const { setFocus } = useFocusActions();
77
+ focusRegion = setFocus;
78
+ onCleanup(() => {
79
+ focusRegion = undefined;
80
+ });
81
+ return null;
82
+ };
83
+
84
+ return (
85
+ <FocusBoundary focusId={props.focusId} initialFocus={menuId} navigation="horizontal">
86
+ <CaptureRegionFocus />
87
+
88
+ <FocusBoundary
89
+ focusId={menuId}
90
+ initialFocus={firstMenuItemId}
91
+ navigation="vertical"
92
+ onUnhandledKey={{
93
+ // Non-directional hand-off: `right` is already handled by the
94
+ // parent's horizontal navigation, but OK is not.
95
+ OK: onPress(() => {
96
+ if (props.hasContent()) focusRegion?.(contentId);
97
+ })
98
+ }}
99
+ >
100
+ <For each={MENU}>
101
+ {(label, index) => (
102
+ <MenuRow
103
+ label={label}
104
+ focusId={index() === 0 ? firstMenuItemId : undefined}
105
+ y={index() * 72}
106
+ onEnter={() => focusRegion?.(contentId)}
107
+ />
108
+ )}
109
+ </For>
110
+ </FocusBoundary>
111
+
112
+ <group translation={[400, 0]}>
113
+ <FocusBoundary
114
+ focusId={contentId}
115
+ initialFocus={firstCardId}
116
+ navigation="horizontal"
117
+ onUnhandledKey={{ back: onPress(() => focusRegion?.(menuId)) }}
118
+ >
119
+ <For each={CARDS}>
120
+ {(label, index) => (
121
+ <Card
122
+ label={label}
123
+ focusId={index() === 0 ? firstCardId : undefined}
124
+ x={index() * 264}
125
+ />
126
+ )}
127
+ </For>
128
+ </FocusBoundary>
129
+ </group>
130
+ </FocusBoundary>
131
+ );
132
+ };
133
+ ```
134
+
135
+ ### Common mistakes
136
+
137
+ | Symptom | Cause | Fix |
138
+ |---|---|---|
139
+ | `setFocus` on a region does nothing | It was called from a hook whose nearest boundary is one of the regions, not their parent | Capture `setFocus` in a component rendered as a direct child of the parent boundary |
140
+ | The hand-off fires but focus lands on the wrong item | The region remembers where it was; that is `rememberLastFocus`, default `true` | Pass `rememberLastFocus={false}` on the region that should reopen at its first item |
141
+ | `back` inside a region exits the screen instead of returning to the menu | The region did not claim `back`, so it bubbled past the parent to the root | Declare `back` in that region's `onUnhandledKey`; a handler that returns nothing consumes the key and stops it there |
142
+ | The whole screen stops navigating after a region is added | The parent boundary has no `navigation` and no `navigationMap`, so it performs no movement at all — there is no default | Set the axis the regions are laid out on |
143
+
144
+ ## Hold focus inside a dialog, and give it back
145
+
146
+ `ModalBoundary` and `useModal` from `@roku-sdk/components` implement this: enter on open,
147
+ dismiss on a key you name, restore on close. Do not hand-wire a boundary, a scrim and a
148
+ `Show` — the parts that are easy to get wrong are the ones this already does.
149
+
150
+ Placement is the whole trick. `useModal().open()` calls `setFocus` on the modal's identity, so
151
+ it must be called where the nearest boundary is the **common parent** of the content branch and
152
+ the `ModalBoundary` — the same rule as region hand-off above, for the same reason.
153
+
154
+ ```tsx rsg-skill=complete
155
+ import { Group, Label, ModalBoundary, Rectangle, useModal } from "@roku-sdk/components";
156
+ import { FocusBoundary, onPress, useFocusable } from "@roku-sdk/navigation";
157
+ import { type Component, createSignal, For } from "@roku-sdk/solid";
158
+
159
+ const listId: object = {};
160
+ const firstRowId: object = {};
161
+ const confirmId: object = {};
162
+
163
+ const ROWS = ["Play", "Add to list", "Delete"];
164
+
165
+ function Button(props: { label: string; focusId?: object; x: number; onSelect: () => void }) {
166
+ const { ref, isFocused } = useFocusable({
167
+ focusId: props.focusId,
168
+ onKey: { OK: onPress(() => props.onSelect()) }
169
+ });
170
+
171
+ return (
172
+ <group ref={ref} translation={[props.x, 0]}>
173
+ <rectangle width={240} height={64} color={isFocused() ? "0x0055FFFF" : "0x2B2B2BFF"} />
174
+ <label text={props.label} translation={[24, 18]} />
175
+ </group>
176
+ );
177
+ }
178
+
179
+ /**
180
+ * Rendered inside the boundary that owns both branches, which is what lets
181
+ * `open()` reach the dialog.
182
+ */
183
+ const ListWithConfirm: Component = () => {
184
+ const confirm = useModal();
185
+ const [result, setResult] = createSignal("");
186
+
187
+ const finish = (answer: string): void => {
188
+ setResult(answer);
189
+ confirm.close();
190
+ };
191
+
192
+ return (
193
+ <>
194
+ <FocusBoundary focusId={listId} initialFocus={firstRowId} navigation="vertical">
195
+ <For each={ROWS}>
196
+ {(label, index) => (
197
+ <Row
198
+ label={label}
199
+ focusId={index() === 0 ? firstRowId : undefined}
200
+ y={index() * 72}
201
+ onSelect={confirm.open}
202
+ />
203
+ )}
204
+ </For>
205
+ <label text={result()} translation={[0, 260]} />
206
+ </FocusBoundary>
207
+
208
+ <ModalBoundary
209
+ show={confirm.isOpen()}
210
+ focusId={confirm.focusId}
211
+ initialFocus={confirmId}
212
+ navigation="horizontal"
213
+ onDismiss={confirm.close}
214
+ dismissKeys={["back"]}
215
+ dim="0x000000AA"
216
+ >
217
+ <group translation={[520, 360]}>
218
+ <rectangle width={880} height={240} color="0x14142AFF" />
219
+ <label text="Delete this item?" translation={[48, 36]} />
220
+ <group translation={[48, 140]}>
221
+ <Button label="Delete" focusId={confirmId} x={0} onSelect={() => finish("deleted")} />
222
+ <Button label="Cancel" x={280} onSelect={() => finish("kept")} />
223
+ </group>
224
+ </group>
225
+ </ModalBoundary>
226
+ </>
227
+ );
228
+ };
229
+
230
+ function Row(props: { label: string; focusId?: object; y: number; onSelect: () => void }) {
231
+ const { ref, isFocused, hasFootprint } = useFocusable({
232
+ focusId: props.focusId,
233
+ onKey: { OK: onPress(() => props.onSelect()) }
234
+ });
235
+
236
+ // While the dialog is open every `isFocused()` in this branch is false, so the
237
+ // row the dialog will return to is drawn from `hasFootprint()` instead.
238
+ const color = (): string => {
239
+ if (isFocused()) return "0x0055FFFF";
240
+ return hasFootprint() ? "0x003399FF" : "0x1A1A1AFF";
241
+ };
242
+
243
+ return (
244
+ <group ref={ref} translation={[0, props.y]}>
245
+ <rectangle width={480} height={64} color={color()} />
246
+ <label text={props.label} translation={[24, 18]} />
247
+ </group>
248
+ );
249
+ }
250
+
251
+ export const ScreenWithDialog: Component<{ focusId?: object }> = props => (
252
+ <FocusBoundary focusId={props.focusId} initialFocus={listId}>
253
+ <ListWithConfirm />
254
+ </FocusBoundary>
255
+ );
256
+ ```
257
+
258
+ ### How the restore works, and why you do not write it
259
+
260
+ `close()` only clears `show`. Unmounting the dialog unregisters its boundary from the parent,
261
+ and the parent moves focus to the neighbour it sat next to in registration order — the content
262
+ branch. That branch then restores its own last focused child, because `rememberLastFocus`
263
+ defaults to `true`. Nothing in your code names the item focus returns to, which is why nothing
264
+ in your code can name the wrong one.
265
+
266
+ ### The footprint is not dialog-specific
267
+
268
+ `hasFootprint()` is `!isActive() && focusedId() === focusId`, so it is true of any branch that is
269
+ not the active one and still remembers an item. A dialog above it is one cause; focus moving to a
270
+ sibling region is another. A row styled from it therefore keeps its subdued marker while the user
271
+ is over in the menu — usually what you want, but decide it rather than discover it. To mark only
272
+ the dialog case, pass the dialog's `isOpen()` down and gate the marker on it.
273
+
274
+ It is also evaluated against its own boundary alone, so a nested region focus will not pass back
275
+ through keeps marking its last child, and so does a region with `rememberLastFocus={false}` that
276
+ will reopen at `initialFocus`. When only items focus will actually land on should be marked,
277
+ use `hasReturnFootprint()` from `useFocusable` instead; `useFocusActions().hasReturnFootprint()`
278
+ answers the same question for the whole region. Each dormant sibling region under the nearest
279
+ active ancestor still marks its own item — the one focus reaches when the user moves there — so
280
+ the tree shows a single footprint only while the root itself is inactive.
281
+
282
+ ### Common mistakes
283
+
284
+ | Symptom | Cause | Fix |
285
+ |---|---|---|
286
+ | The dialog appears but keys still drive the list behind it | `show` is true while focus never moved — `open()` was not used, or `focusId` differs between `useModal` and `ModalBoundary` | Drive both from one `useModal()`: `show={m.isOpen()}` and `focusId={m.focusId}` |
287
+ | `useModal` throws on mount | It was called outside any boundary | Call it from a component rendered inside the parent boundary |
288
+ | The dialog cannot be dismissed | No `dismissKeys` key was pressed, and no child claimed one | `dismissKeys` defaults to `back`; name every key that should close it, and wire `onDismiss` |
289
+ | The dialog opens with nothing focused | `initialFocus` names an identity that no child of the dialog registered | Point it at a button inside the dialog, or omit it entirely and let a hidden focusable be injected |
290
+ | Everything behind the dialog looks unfocused, including the row focus will return to | Correct, and by design: `isFocused()` is false throughout an inactive branch | Draw that indicator from `hasFootprint()` |