@roku-sdk/ai 0.0.0-stage → 2.18.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 +181 -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 +205 -0
  22. package/skills/focus-management/references/focus-recipes.md +282 -0
  23. package/skills/fonts-and-glyphs/SKILL.md +176 -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,205 @@
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` |
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
+ 2. **Wrap each region in a `FocusBoundary`** and set `navigation` to the axis the region
74
+ moves on. Nest a region by giving it a `focusId` that its parent's `initialFocus` or
75
+ `navigationMap` names.
76
+
77
+ 3. **Point `initialFocus` at something registered in that same boundary** — a child's
78
+ `useFocusable({ focusId })` identity, or a nested boundary's `focusId`. An identity
79
+ registered one level further down never matches, and focus starts nowhere.
80
+
81
+ 4. **Call `useFocusable()` in every item that can hold focus**, attach the returned `ref` to
82
+ the item's root node, and put the item's own keys in `onKey`. Use `onPress` / `onRelease`
83
+ to fire on one edge instead of guarding `press` by hand.
84
+
85
+ 5. **Leave directional keys to the boundary.** A leaf handles OK and its own keys; up, down,
86
+ left and right are the boundary's job, so do not add them to a leaf's `onKey`.
87
+
88
+ 6. **Read `isFocused()` inside JSX** or another reactive scope, and never destructure
89
+ `props`. Reading either once at the top of a component captures a value that never
90
+ updates again.
91
+
92
+ 7. **Iterate with `<For>`**, never `items.map()`. `map` recreates every child when the array
93
+ changes, which unregisters the focused item and drops focus.
94
+
95
+ 8. **Create a `focusId` only when something names it** — `initialFocus`, a `navigationMap`
96
+ entry, `setFocus`, a `focusedId()` comparison, or a `FlexGrid` `focusId`. Every other
97
+ item gets one automatically, and linear navigation follows registration order.
98
+
99
+ ## Example
100
+
101
+ A screen wired end to end: the anchor node, one vertical region, three rows, per-row OK
102
+ handling, and focus styling driven by `isFocused()`.
103
+
104
+ ```tsx rsg-skill=complete
105
+ import { Group, Label, Rectangle } from "@roku-sdk/components";
106
+ import { FocusBoundary, onPress, RootFocusBoundary, useFocusable } from "@roku-sdk/navigation";
107
+ import { type AnchorNodeImplementation, defineAnchorNode, FieldType } from "@roku-sdk/rsg-ts";
108
+ import { For } from "@roku-sdk/solid";
109
+
110
+ const MainSceneDescription = {
111
+ componentName: "MainScene",
112
+ extends: "FocusBoundaryBase" as const,
113
+ inputFields: {
114
+ keyEvent: FieldType.Object(),
115
+ focusActive: FieldType.Boolean(true)
116
+ },
117
+ outputFields: {
118
+ handledKeys: FieldType.Object(),
119
+ exitApp: FieldType.Boolean()
120
+ }
121
+ };
122
+
123
+ // Two identities this screen names: the region the root boundary starts in,
124
+ // and the row that region starts on.
125
+ const menuId: object = {};
126
+ const homeRowId: object = {};
127
+
128
+ interface Item {
129
+ label: string;
130
+ disabled?: boolean;
131
+ focusId?: object;
132
+ }
133
+
134
+ const ITEMS: Item[] = [
135
+ { label: "Home", focusId: homeRowId },
136
+ { label: "Search" },
137
+ { label: "Settings", disabled: true }
138
+ ];
139
+
140
+ function MenuRow(props: { item: Item; y: number; onSelect: (label: string) => void }) {
141
+ const { ref, isFocused } = useFocusable({
142
+ focusId: props.item.focusId,
143
+ onKey: { OK: onPress(() => props.onSelect(props.item.label)) },
144
+ canReceiveFocus: () => !props.item.disabled
145
+ });
146
+
147
+ return (
148
+ <group ref={ref} translation={[0, props.y]}>
149
+ <rectangle width={480} height={72} color={isFocused() ? "0x0055FFFF" : "0x1A1A1AFF"} />
150
+ <label
151
+ text={props.item.label}
152
+ translation={[24, 20]}
153
+ color={isFocused() ? "0xFFFFFFFF" : "0x8A8A8AFF"}
154
+ />
155
+ </group>
156
+ );
157
+ }
158
+
159
+ const MainScene: AnchorNodeImplementation<typeof MainSceneDescription> = (inputs, outputs) => (
160
+ <RootFocusBoundary
161
+ keyEvent={inputs.keyEvent as Parameters<typeof RootFocusBoundary>[0]["keyEvent"]}
162
+ focusActive={inputs.focusActive as boolean}
163
+ setHandledKeys={outputs.handledKeys}
164
+ initialFocus={menuId}
165
+ onUnhandledKey={{ back: onPress(() => outputs.exitApp(true)) }}
166
+ >
167
+ <FocusBoundary focusId={menuId} initialFocus={homeRowId} navigation="vertical">
168
+ <For each={ITEMS}>
169
+ {(item, index) => (
170
+ <MenuRow item={item} y={index() * 88} onSelect={label => openScreen(label)} />
171
+ )}
172
+ </For>
173
+ </FocusBoundary>
174
+ </RootFocusBoundary>
175
+ );
176
+
177
+ function openScreen(label: string): void {
178
+ // app logic for the selected row
179
+ }
180
+
181
+ export default defineAnchorNode(MainSceneDescription, MainScene);
182
+ ```
183
+
184
+ ## Common Mistakes
185
+
186
+ | Symptom | Cause | Fix |
187
+ |---|---|---|
188
+ | 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 |
189
+ | 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 |
190
+ | 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 |
191
+ | `'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 |
192
+ | 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 |
193
+ | Focus drops whenever the list updates | `items.map()` in the JSX | Use `<For>` |
194
+ | The highlight never changes | `isFocused` read outside a reactive scope, or `props` destructured | Read it inside JSX, `createMemo`, `createEffect` or a callback |
195
+ | 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` |
196
+ | 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 |
197
+ | One item is never reachable | Its `canReceiveFocus` returns false | Check the condition — a disabled item is skipped by design |
198
+ | 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()` |
199
+ | Two regions react to one key, or a nested region steals keys | A nested boundary was given `keyEvent` | Only the root boundary gets it |
200
+ | 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()` |
201
+ | 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 |
202
+
203
+ Props and options are typed with TSDoc in `@roku-sdk/navigation` — `IRootFocusBoundaryProps`,
204
+ `IFocusBoundaryProps`, `UseFocusableOptions`, `FocusActions`. Read a prop there rather than
205
+ guessing its name.
@@ -0,0 +1,282 @@
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
+ ### Common mistakes
275
+
276
+ | Symptom | Cause | Fix |
277
+ |---|---|---|
278
+ | 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}` |
279
+ | `useModal` throws on mount | It was called outside any boundary | Call it from a component rendered inside the parent boundary |
280
+ | 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` |
281
+ | 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 |
282
+ | 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()` |
@@ -0,0 +1,176 @@
1
+ # Fonts and Glyphs
2
+
3
+ ## Overview
4
+
5
+ Two questions decide whether text in an RSG SDK app renders the way you meant: which typeface is
6
+ supplying the characters, and whether that typeface actually has them. Get the first wrong and
7
+ text comes out at a size nobody can read, or ignores the size you set. Get the second wrong and
8
+ you ship empty boxes — and the boxes appear on a device you did not test, because the firmware's
9
+ fallback font hides the problem everywhere else.
10
+
11
+ ## When to Use
12
+
13
+ - text rendering as empty boxes, on any device or only on some, and whether `fallbackGlyph` helps
14
+ - symbols that were fine until the app started shipping a typeface of its own
15
+ - a symbol needed in a label — arrow, chevron, star, check mark, remote-button icon
16
+ - text that renders far too small, or ignores the size you set
17
+ - shipping a `.ttf` or `.otf` with the app, and a build that refuses two of them
18
+ - deciding whether a shape should be a character at all
19
+
20
+ ### When not to use
21
+
22
+ | Situation | Use instead | Because |
23
+ |---|---|---|
24
+ | Which node draws the text — `Label`, `SimpleLabel`, `ScrollingLabel`, `MultiStyleLabel` | The `sdk-components` skill | This skill owns the typeface, that one owns the node |
25
+ | Brand marks, multi-colour or gradient artwork | The `sdk-components` skill | That is a `Poster` and an image, not a character |
26
+ | Where an asset file belongs in the project before anything can import it | The `project-setup` skill | Project layout is that skill's job |
27
+
28
+ ## Read the reference first for this task
29
+
30
+ | If the request is | Read now, before answering |
31
+ |---|---|
32
+ | Naming a specific glyph constant, or auditing which ones survive a custom font | `references/glyph-tables.md` |
33
+
34
+ The body tells you which *group* a symbol falls in and what that means. The 45 constants, their
35
+ codepoints and their three groups are only in the reference. Guessing a constant name is the one
36
+ mistake here that compiles.
37
+
38
+ ## Quick Reference
39
+
40
+ | Job | Reach for |
41
+ |---|---|
42
+ | Any arrow, chevron, star, tick, or remote-button icon | A named constant from `@roku-sdk/components/glyphs` — never a typed or escaped literal |
43
+ | A character you can simply type (`·`, `°`, currency, quotes, maths) | Type it. These get no constants on purpose |
44
+ | A brand mark or anything multi-colour | Not a character at all — that is `sdk-components` territory |
45
+ | Set a typeface on a `Label` | A `Font` node in the `font` prop, with **both** `uri` and `size` |
46
+ | `Font.size` | Points. **Defaults to 1**, which is why an otherwise correct font renders as a line of dots |
47
+ | Set a typeface on a `SimpleLabel` | `fontUri` plus `fontSize` — no `Font` node |
48
+ | Built-in font names | Ten. The eight `font:…SystemFont` names are **fixed size and ignore `fontSize`**; only `font:SystemFontFile` and `font:BoldSystemFontFile` respect it |
49
+ | Ship a typeface with the app | `import fontUri from "./fonts/whatever.ttf"` — the import resolves to the packaged path at build time |
50
+ | Two font files with the same filename | A build error if their contents differ, reuse if identical. Rename one |
51
+ | Text arriving from outside with characters your font lacks | Constrain the text or widen the typeface. **`Font.fallbackGlyph` does not substitute anything on current firmware** — see instruction 8 |
52
+ | A `Font` with a `size` and no `uri` | Draws nothing at all. It does not fall back to the system font — omit the `Font` node instead |
53
+ | Which symbols survive a custom app font | The 32 private-use glyphs and `●` `★` `☆`. The other ten do not |
54
+
55
+ ## Instructions
56
+
57
+ 1. **Take symbols from the constants, not from your keyboard or an escape.** Import by name from
58
+ `@roku-sdk/components/glyphs` and interpolate. A literal like `"\uE025"` is the same character
59
+ today and an unlabelled magic number the moment the font revision moves; the constant for that
60
+ one is `LEFT_BUTTON_GLYPH`.
61
+
62
+ 2. **Know the one fact that decides whether a symbol renders: which font supplies it.** The
63
+ constants are grouped by that, not by codepoint:
64
+ - **RokuFont01 private-use glyphs** (32 of them) — RokuFont01 is the firmware's system-wide
65
+ fallback font, so these render under a system font *and* under a custom app font.
66
+ - **Unicode characters that RokuFont01 backs** — `●` `★` `☆`. Same fallback, so also safe.
67
+ - **RokuUI-Regular symbols** (10 of them — the arrows, dashes, bullet, ellipsis, tick and
68
+ ballot X). RokuUI-Regular is the default UI font but is **not** in the fallback chain. Under
69
+ a system font they are fine; under a custom app font they get no backstop and go tofu unless
70
+ your font happens to supply them.
71
+
72
+ So the counter-intuitive part: if your app ships a typeface, the constants that need care are
73
+ the plain-Unicode-looking ones, not the exotic private-use ones.
74
+
75
+ 3. **Remember that a custom font wins outright where it defines a codepoint.** No fallback occurs.
76
+ A licensed font carrying its own private-use glyphs at `U+E0xx` will render *those* in place of
77
+ the Roku icons, which looks like corruption rather than a font choice.
78
+
79
+ 4. **Give a `Font` node a `size`, always.** `Font.size` is in points and defaults to **1**. A
80
+ `Font` with a correct `uri` and no `size` is the classic "the font loaded but the text is
81
+ unreadable" report. Set both together and treat them as one prop.
82
+
83
+ 5. **On `SimpleLabel`, use `fontUri` and `fontSize` — and know which built-in names ignore the
84
+ size.** Of the ten built-in names, eight are fixed-size (`font:Smallest…`, `font:Small…`,
85
+ `font:Medium…`, `font:Large…`, each plain and `Bold`) and **discard `fontSize` silently**. Only
86
+ `font:SystemFontFile` and `font:BoldSystemFontFile`, and any font file of your own, respect it.
87
+ If a size change does nothing, the name is the reason.
88
+
89
+ 6. **Import font files; do not write packaged paths by hand.** `import fontUri from
90
+ "./fonts/inter.ttf"` gives you a string that is the packaged path, and the build copies the
91
+ file for you. Both `.ttf` and `.otf` are declared. Writing the path as a literal skips the copy
92
+ and ships a reference to a file that is not there.
93
+
94
+ 7. **Expect a build error when two font files share a filename.** The build keys them by filename
95
+ alone, so two files called `Inter.ttf` in different directories collide. Identical contents are
96
+ silently reused; different contents fail the build with a `Font naming conflict` naming both
97
+ sources. Rename one — the directory does not disambiguate them.
98
+
99
+ 8. **Do not plan around `fallbackGlyph`.** `Font.fallbackGlyph` reads like the answer to
100
+ uncontrollable text — a character of your choosing instead of a box — and on current firmware
101
+ it does not deliver one. Measured on a Roku Native running 16.0.4: set as a bare `u25CF` (the
102
+ form the SDK's own documentation shows) and as a literal `●`, against both a symbol only the
103
+ default UI font has and a codepoint no font on the device has, the boxes were unchanged in
104
+ every combination. The property is real and the SDK does forward it; what is missing is any
105
+ visible effect. Treat unsupported characters as something to prevent, not something to
106
+ substitute: constrain the text, or ship a typeface that covers it.
107
+
108
+ 9. **Do not use firmware placeholder syntax for app text.** Translated firmware strings may
109
+ contain tokens like `{BACK_BUTTON_GLYPH}`. Those are runtime text tokens resolved by the
110
+ firmware's own string layer, not TypeScript symbols; putting one in a label renders the braces
111
+ literally. Interpolate the imported constant instead.
112
+
113
+ ## Example
114
+
115
+ A row of chevrons and a rating, using a bundled typeface — imported, sized, and with a fallback
116
+ character for anything the typeface lacks.
117
+
118
+ ```tsx rsg-skill=complete
119
+ import { BLACK_STAR, LEFT_BUTTON_GLYPH, RIGHT_BUTTON_GLYPH, WHITE_STAR } from "@roku-sdk/components/glyphs";
120
+ import { Font, Label, LayoutGroup } from "@roku-sdk/components";
121
+
122
+ import titleFontUri from "./fonts/title.ttf";
123
+
124
+ function Rating(props: { filled: number; total: number }) {
125
+ return (
126
+ <label
127
+ text={BLACK_STAR.repeat(props.filled) + WHITE_STAR.repeat(props.total - props.filled)}
128
+ width={200}
129
+ height={48}
130
+ color="0xFFFFFFFF"
131
+ />
132
+ );
133
+ }
134
+
135
+ export function CarouselChrome(props: { title: string; rating: number }) {
136
+ return (
137
+ <layoutGroup layoutDirection="horiz" itemSpacings={[16]}>
138
+ {/* Private-use glyphs: the firmware fallback covers these even under a custom font. */}
139
+ <label text={LEFT_BUTTON_GLYPH} width={48} height={48} color="0xFFFFFFFF" />
140
+ <label
141
+ text={props.title}
142
+ width={600}
143
+ height={48}
144
+ color="0xFFFFFFFF"
145
+ // `size` is not optional in practice -- its default is 1 point,
146
+ // and a `Font` without a `uri` draws nothing at all.
147
+ font={<font uri={titleFontUri} size={32} />}
148
+ />
149
+ <Rating filled={props.rating} total={5} />
150
+ <label text={RIGHT_BUTTON_GLYPH} width={48} height={48} color="0xFFFFFFFF" />
151
+ </layoutGroup>
152
+ );
153
+ }
154
+ ```
155
+
156
+ ## Anti-patterns
157
+
158
+ Keyed by what you see on screen, because that is what arrives first.
159
+
160
+ | Symptom | Cause | Fix |
161
+ |---|---|---|
162
+ | Text shows empty boxes where symbols should be | The character is in neither font — geometric arrows like `◀` `▶` are the usual culprit | Use the named constant; `LEFT_ARROW` and friends are the shapes that exist |
163
+ | Arrows, ticks and dashes vanished after the app shipped its own typeface | Those are RokuUI-Regular symbols and get no RokuFont01 backstop under a custom font | Keep them in system-font text, or make the custom typeface supply them |
164
+ | Icons turned into unrelated shapes under a custom typeface | The custom font defines the same private-use codepoints and wins outright | Use a font without private-use glyphs, or draw those icons in system-font text |
165
+ | Text renders at size 1 — a readable string, unreadably small | `Font.size` was left at its default | Set `size` whenever you set `uri` |
166
+ | Changing the size does nothing at all | The `fontUri` names one of the eight fixed-size built-ins | Switch to `font:SystemFontFile` or a font file |
167
+ | Characters from a backend show as boxes and `fallbackGlyph` changed nothing | It has no visible effect on current firmware, in either value form | Constrain the text or widen the typeface; do not keep tuning the value |
168
+ | A label went completely blank after a `Font` node was added | The `Font` has a `size` but no `uri`, which draws nothing rather than falling back | Give it a `uri`, or drop the `Font` node and let the system font apply |
169
+ | The build fails naming two font files | Same filename, different contents, keyed by filename | Rename one of them |
170
+ | A private-use escape like `"\uE0nn"` sits in app code | The constant exists and the literal does not say which symbol it is | Import the constant |
171
+ | Braces render literally in a label | Firmware placeholder syntax used as app text | Interpolate the constant |
172
+
173
+ The glyph groups here are device-verified against firmware 15.3.5 on a Roku Express 4K+ — every
174
+ constant rendered in a label under a custom typeface. Re-verify on a device rather than re-wording
175
+ if you need to change what they claim. `IFontProps` and `ISimpleLabelProps` carry TSDoc in the
176
+ installed package; prefer it over any prose, including this file, when the two disagree.