@jgengine/react 0.28.0 → 0.30.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -26,6 +26,37 @@ At publish, rename this heading to the new version and mirror the entries into
26
26
  `packages/core/src/meta/changelog.ts` (the typed `CHANGELOG` export).
27
27
  -->
28
28
 
29
+ ## 0.30.0
30
+
31
+ ### Migrate
32
+
33
+ - **Bump lockstep SDK packages to `^0.30.0`:** `@jgengine/{core,rapier,react,ws,node,sql,convex,shell,editor,assets,navbake}`. CLI `jgengine` is `0.27.0`; `@jgengine/github` is `0.17.0`.
34
+
35
+ ### Added
36
+
37
+ - `ColorblindFrame` (`@jgengine/react/accessibility`) wraps the 3D canvas and applies the player's colour-vision mode to that box alone, so HUD, menu and caption text keep their own contrast. `colorblindFrameFilter` (`@jgengine/core/ui/accessibility`) resolves a mode to the CSS filter, and `useColorblindMode` reads the mode from the settings store the settings rows write; the 3D shell does both for the mounted game.
38
+ - `SettingsSection` (`@jgengine/react/settingsBlocks`) is one `useMenuNav` menu: a d-pad or left stick moves between rows and left/right steps the focused value — slider by its step, toggle flipped, select to the next option, confirm arms a keybind — through the same `onAdjust` path the arrow keys use. `onAdjust` (`@jgengine/react/menuNav`) is the option that makes left/right adjust instead of move.
39
+ - `createStanding` (`@jgengine/core/progression/standing`) holds per-faction or per-district standing over authored tracks and moves it from named outcomes, so an outcome that resolves to nothing throws at the call instead of silently moving nothing.
40
+ - `createGateSet` (`@jgengine/core/progression/gates`) evaluates authored milestones against standing and money, and refuses to load when a gate's unlock id resolves to nothing.
41
+ - `createCareerBank` (`@jgengine/core/progression/careerBank`) keeps persistent career money apart from a run's haul, so a failed run loses the haul and keeps the bank.
42
+ - `createUpgradeTree` (`@jgengine/core/progression/upgrades`) prices each rank from the shared curve primitive and gates purchases on prerequisites and standing.
43
+
44
+ ### Changed
45
+
46
+ - `AccessibilityProvider` (`@jgengine/react/accessibility`) sets its data attributes and text-scale var without filtering its own subtree. A colour matrix over the HUD and menu root recoloured the text the rest of the UI assumes, so wrap the game canvas in `ColorblindFrame` instead; a custom root can still offset it with its own `ColorblindFilters` and `filter: url(#jg-cb-<mode>)`.
47
+ - World health bars and nameplates derive their range from the render camera and the target height when a game sets no `maxDistance`: an overhead camera reaches the units it looks down at, and a bar is kept while the target it annotates still covers 18 px, instead of hiding every target past a fixed 60 units.
48
+ - A configured `worldHealthBars.maxDistance` or `nameplates.maxDistance` that hides every target in view now warns once with the nearest measured distance and the prop to raise, instead of rendering nothing.
49
+
50
+ ## 0.29.0
51
+
52
+ ### Migrate
53
+
54
+ - **Bump lockstep SDK packages to `^0.29.0`:** `@jgengine/{core,rapier,react,ws,node,sql,convex,shell,editor,assets,navbake}`. CLI `jgengine` is `0.26.0`; `@jgengine/github` is `0.16.0`.
55
+
56
+ ### Fixed
57
+
58
+ - `MissionView.remaining` (`@jgengine/core/gameplay`) now counts down from a `survive` objective's own `duration`, not only from a `timeout`. A defend or hold objective reports the seconds it still has to run, which is the one number its HUD needs; a completed run still reports null.
59
+
29
60
  ## 0.28.0
30
61
 
31
62
  ### Migrate
@@ -1,5 +1,6 @@
1
1
  import { type CSSProperties, type ReactNode } from "react";
2
- import { type AccessibilityState, type AccessibilityStore } from "@jgengine/core/ui/accessibility";
2
+ import { type AccessibilityState, type AccessibilityStore, type ColorblindMode } from "@jgengine/core/ui/accessibility";
3
+ import type { SettingsStore } from "@jgengine/core/settings/settingsModel";
3
4
  interface AccessibilityContextValue {
4
5
  state: AccessibilityState;
5
6
  store: AccessibilityStore;
@@ -8,21 +9,53 @@ interface AccessibilityContextValue {
8
9
  export declare function useAccessibility(): AccessibilityContextValue;
9
10
  /**
10
11
  * Hidden SVG `<defs>` holding the `feColorMatrix` colorblind filters that
11
- * `AccessibilityProvider` references by `filter: url(#jg-cb-<mode>)`. Rendered
12
- * automatically inside the provider; export standalone for custom roots.
12
+ * `filter: url(#jg-cb-<mode>)` references. Mounted by {@link ColorblindFrame};
13
+ * export it standalone for a root that applies the filter itself.
13
14
  *
14
15
  * @capability colorblind-filters SVG feColorMatrix defs (protanopia/deuteranopia/tritanopia/grayscale) referenced by the accessibility colorblind filter
15
16
  */
16
17
  export declare function ColorblindFilters(): ReactNode;
18
+ /** Props for {@link ColorblindFrame}. */
19
+ export interface ColorblindFrameProps {
20
+ /** The player's colour-vision mode. `none` (default) renders the frame untouched. */
21
+ mode?: ColorblindMode;
22
+ children?: ReactNode;
23
+ className?: string;
24
+ style?: CSSProperties;
25
+ }
26
+ /**
27
+ * Apply the player's colour-vision mode to one rendered frame: a box carrying
28
+ * `filter: url(#jg-cb-<mode>)` plus the filter defs it references. Put it around the canvas of a 3D
29
+ * game and nothing else — the filter is one compositor pass over that box's pixels, so HUD, menu and
30
+ * caption text outside it keep the contrast and hues the rest of the UI assumes.
31
+ *
32
+ * The filter needs no framebuffer copy and no extra WebGL draw: the browser filters the canvas layer
33
+ * as it composites, so switching modes costs nothing per frame beyond that pass. Gameplay stays
34
+ * unaffected; the cost is one full-screen colour-matrix pass while a mode other than `none` is set.
35
+ *
36
+ * @capability colorblind-frame apply the player's colour-vision mode to the 3D frame alone, leaving HUD and menu text unfiltered
37
+ */
38
+ export declare function ColorblindFrame({ mode, children, className, style }: ColorblindFrameProps): ReactNode;
39
+ /**
40
+ * The live colour-vision mode from a settings store — the same store the settings rows write, so the
41
+ * frame follows the player's choice the moment they pick it. Pass the store the shell owns; read
42
+ * `useAccessibilitySettings().colorblind` instead when a `SettingsProvider` is above the caller.
43
+ *
44
+ * @capability colorblind-frame-mode read the live colour-vision mode from the settings store that the settings rows write
45
+ */
46
+ export declare function useColorblindMode(store: SettingsStore): ColorblindMode;
17
47
  /**
18
48
  * Apply accessibility preferences to a subtree: exposes `--jg-text-scale` for
19
- * text to scale off, sets `data-reduced-motion` / `data-high-contrast` /
20
- * `data-colorblind` / `data-captions` for CSS to respond to, and wraps the tree
21
- * in the selected colorblind `feColorMatrix` filter. Reads a
49
+ * text to scale off, and sets `data-reduced-motion` / `data-high-contrast` /
50
+ * `data-colorblind` / `data-captions` for CSS to respond to. Reads a
22
51
  * `createAccessibilityStore` and re-renders on change; provides the state to
23
52
  * `useAccessibility`.
24
53
  *
25
- * @capability accessibility-provider apply accessibility preferences to a subtree — text-scale CSS var, reduced-motion/high-contrast/colorblind/captions data attrs, and the colorblind color-matrix filter
54
+ * It deliberately does not filter its own subtree: this is the HUD and menu root, and a colour matrix
55
+ * over text costs the contrast the rest of the UI was designed around. Wrap the 3D canvas in
56
+ * {@link ColorblindFrame} instead.
57
+ *
58
+ * @capability accessibility-provider apply accessibility preferences to a subtree — text-scale CSS var and reduced-motion/high-contrast/colorblind/captions data attrs
26
59
  */
27
60
  export declare function AccessibilityProvider({ store, children, className, style, }: {
28
61
  store: AccessibilityStore;
@@ -1,6 +1,7 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { createContext, useContext, useSyncExternalStore } from "react";
3
- import { COLORBLIND_MATRICES, } from "@jgengine/core/ui/accessibility";
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { createContext, useContext, useSyncExternalStore, } from "react";
3
+ import { COLORBLIND_MATRICES, colorblindFrameFilter, } from "@jgengine/core/ui/accessibility";
4
+ import { readColorblind } from "@jgengine/core/settings/accessibilitySettings";
4
5
  const AccessibilityContext = createContext(null);
5
6
  const FILTER_MODES = [
6
7
  "protanopia",
@@ -17,31 +18,58 @@ export function useAccessibility() {
17
18
  }
18
19
  /**
19
20
  * Hidden SVG `<defs>` holding the `feColorMatrix` colorblind filters that
20
- * `AccessibilityProvider` references by `filter: url(#jg-cb-<mode>)`. Rendered
21
- * automatically inside the provider; export standalone for custom roots.
21
+ * `filter: url(#jg-cb-<mode>)` references. Mounted by {@link ColorblindFrame};
22
+ * export it standalone for a root that applies the filter itself.
22
23
  *
23
24
  * @capability colorblind-filters SVG feColorMatrix defs (protanopia/deuteranopia/tritanopia/grayscale) referenced by the accessibility colorblind filter
24
25
  */
25
26
  export function ColorblindFilters() {
26
27
  return (_jsx("svg", { "aria-hidden": true, width: 0, height: 0, style: { position: "absolute" }, "data-colorblind-filters": true, children: _jsx("defs", { children: FILTER_MODES.map((mode) => (_jsx("filter", { id: `jg-cb-${mode}`, colorInterpolationFilters: "sRGB", children: _jsx("feColorMatrix", { type: "matrix", values: COLORBLIND_MATRICES[mode] ?? "" }) }, mode))) }) }));
27
28
  }
29
+ /**
30
+ * Apply the player's colour-vision mode to one rendered frame: a box carrying
31
+ * `filter: url(#jg-cb-<mode>)` plus the filter defs it references. Put it around the canvas of a 3D
32
+ * game and nothing else — the filter is one compositor pass over that box's pixels, so HUD, menu and
33
+ * caption text outside it keep the contrast and hues the rest of the UI assumes.
34
+ *
35
+ * The filter needs no framebuffer copy and no extra WebGL draw: the browser filters the canvas layer
36
+ * as it composites, so switching modes costs nothing per frame beyond that pass. Gameplay stays
37
+ * unaffected; the cost is one full-screen colour-matrix pass while a mode other than `none` is set.
38
+ *
39
+ * @capability colorblind-frame apply the player's colour-vision mode to the 3D frame alone, leaving HUD and menu text unfiltered
40
+ */
41
+ export function ColorblindFrame({ mode = "none", children, className, style }) {
42
+ const filter = colorblindFrameFilter(mode);
43
+ return (_jsxs(_Fragment, { children: [_jsx(ColorblindFilters, {}), _jsx("div", { className: className, "data-colorblind-frame": mode, style: filter === null ? style : { ...style, filter }, children: children })] }));
44
+ }
45
+ /**
46
+ * The live colour-vision mode from a settings store — the same store the settings rows write, so the
47
+ * frame follows the player's choice the moment they pick it. Pass the store the shell owns; read
48
+ * `useAccessibilitySettings().colorblind` instead when a `SettingsProvider` is above the caller.
49
+ *
50
+ * @capability colorblind-frame-mode read the live colour-vision mode from the settings store that the settings rows write
51
+ */
52
+ export function useColorblindMode(store) {
53
+ return useSyncExternalStore(store.subscribe, () => readColorblind(store), () => readColorblind(store));
54
+ }
28
55
  /**
29
56
  * Apply accessibility preferences to a subtree: exposes `--jg-text-scale` for
30
- * text to scale off, sets `data-reduced-motion` / `data-high-contrast` /
31
- * `data-colorblind` / `data-captions` for CSS to respond to, and wraps the tree
32
- * in the selected colorblind `feColorMatrix` filter. Reads a
57
+ * text to scale off, and sets `data-reduced-motion` / `data-high-contrast` /
58
+ * `data-colorblind` / `data-captions` for CSS to respond to. Reads a
33
59
  * `createAccessibilityStore` and re-renders on change; provides the state to
34
60
  * `useAccessibility`.
35
61
  *
36
- * @capability accessibility-provider apply accessibility preferences to a subtree — text-scale CSS var, reduced-motion/high-contrast/colorblind/captions data attrs, and the colorblind color-matrix filter
62
+ * It deliberately does not filter its own subtree: this is the HUD and menu root, and a colour matrix
63
+ * over text costs the contrast the rest of the UI was designed around. Wrap the 3D canvas in
64
+ * {@link ColorblindFrame} instead.
65
+ *
66
+ * @capability accessibility-provider apply accessibility preferences to a subtree — text-scale CSS var and reduced-motion/high-contrast/colorblind/captions data attrs
37
67
  */
38
68
  export function AccessibilityProvider({ store, children, className, style, }) {
39
69
  const state = useSyncExternalStore(store.subscribe, store.get, store.get);
40
70
  const rootStyle = { ...style };
41
71
  rootStyle["--jg-text-scale"] = String(state.textScale);
42
- if (state.colorblind !== "none")
43
- rootStyle.filter = `url(#jg-cb-${state.colorblind})`;
44
- return (_jsx(AccessibilityContext.Provider, { value: { state, store }, children: _jsxs("div", { className: className, "data-accessibility": true, "data-reduced-motion": state.reducedMotion, "data-high-contrast": state.highContrast, "data-colorblind": state.colorblind, "data-captions": state.captions, style: rootStyle, children: [_jsx(ColorblindFilters, {}), children] }) }));
72
+ return (_jsx(AccessibilityContext.Provider, { value: { state, store }, children: _jsx("div", { className: className, "data-accessibility": true, "data-reduced-motion": state.reducedMotion, "data-high-contrast": state.highContrast, "data-colorblind": state.colorblind, "data-captions": state.captions, style: rootStyle, children: children }) }));
45
73
  }
46
74
  /**
47
75
  * OS "reduce motion" preference (`prefers-reduced-motion: reduce`) as a live
package/dist/menuNav.d.ts CHANGED
@@ -43,6 +43,12 @@ export interface UseMenuNavOptions {
43
43
  enabled?: boolean;
44
44
  /** Confirm (Enter/Space/A). Called with the focused id. */
45
45
  onConfirm?: (id: string) => void;
46
+ /**
47
+ * Left/right (arrow keys, d-pad, left stick) change the focused item's value by one step instead of
48
+ * moving focus. Give it to a menu whose items are settings rows; hold-to-repeat applies, so a held
49
+ * direction steps repeatedly. Up/down and confirm/back are unchanged.
50
+ */
51
+ onAdjust?: (id: string, delta: -1 | 1) => void;
46
52
  /** Back (Escape/Backspace/B/Start). */
47
53
  onBack?: () => void;
48
54
  /** Poll a connected gamepad for menu intents. Default true. */
@@ -58,6 +64,6 @@ export interface UseMenuNavOptions {
58
64
  *
59
65
  * @capability use-menu-nav keyboard and gamepad focus navigation for a menu — roving focus order, confirm/back, disabled-item skipping, no focus trap
60
66
  */
61
- export declare function useMenuNav({ ids, columns, wrap, disabled, initialId, enabled, onConfirm, onBack, gamepad, repeat, }: UseMenuNavOptions): MenuNavController;
67
+ export declare function useMenuNav({ ids, columns, wrap, disabled, initialId, enabled, onConfirm, onBack, onAdjust, gamepad, repeat, }: UseMenuNavOptions): MenuNavController;
62
68
  /** Render prop for a menu whose items come from {@link useMenuNav}. */
63
69
  export type MenuNavRender = (nav: MenuNavController) => ReactNode;
package/dist/menuNav.js CHANGED
@@ -14,7 +14,7 @@ function isTextEntry(target) {
14
14
  *
15
15
  * @capability use-menu-nav keyboard and gamepad focus navigation for a menu — roving focus order, confirm/back, disabled-item skipping, no focus trap
16
16
  */
17
- export function useMenuNav({ ids, columns = 1, wrap = true, disabled, initialId, enabled = true, onConfirm, onBack, gamepad = true, repeat, }) {
17
+ export function useMenuNav({ ids, columns = 1, wrap = true, disabled, initialId, enabled = true, onConfirm, onBack, onAdjust, gamepad = true, repeat, }) {
18
18
  const disabledIndices = useMemo(() => {
19
19
  if (disabled === undefined || disabled.length === 0)
20
20
  return undefined;
@@ -36,16 +36,28 @@ export function useMenuNav({ ids, columns = 1, wrap = true, disabled, initialId,
36
36
  idsRef.current = ids;
37
37
  const activeIndexRef = useRef(activeIndex);
38
38
  activeIndexRef.current = activeIndex;
39
- const callbacks = useRef({ onConfirm, onBack });
40
- callbacks.current = { onConfirm, onBack };
39
+ const callbacks = useRef({ onConfirm, onBack, onAdjust });
40
+ callbacks.current = { onConfirm, onBack, onAdjust };
41
41
  const elements = useRef(new Map());
42
42
  const pendingFocus = useRef(true);
43
+ // The first pass claims focus only when nothing else owns it; later moves focus the new item.
44
+ const claimed = useRef(false);
45
+ const firstEnablePass = useRef(true);
43
46
  const focusActive = useCallback(() => {
44
47
  const id = menuNavIdAt(idsRef.current, activeIndexRef.current);
45
48
  if (id === null)
46
49
  return;
47
50
  elements.current.get(id)?.focus({ preventScroll: true });
48
51
  }, []);
52
+ // A control or dialog that already owns focus keeps it; the first move brings focus into the menu. Without
53
+ // this a menu mounted inside a dialog takes focus from the dialog's own initial control, and the dialog's
54
+ // opener is never restored on close.
55
+ const claimFocus = useCallback(() => {
56
+ const active = typeof document === "undefined" ? null : document.activeElement;
57
+ if (active !== null && active !== document.body && active !== document.documentElement)
58
+ return;
59
+ focusActive();
60
+ }, [focusActive]);
49
61
  const move = useCallback((direction) => {
50
62
  const layout = {
51
63
  count: idsRef.current.length,
@@ -75,11 +87,19 @@ export function useMenuNav({ ids, columns = 1, wrap = true, disabled, initialId,
75
87
  activate(menuNavIdAt(idsRef.current, activeIndexRef.current));
76
88
  else if (intent === "back")
77
89
  callbacks.current.onBack?.();
90
+ else if (intent === "left" || intent === "right") {
91
+ const adjust = callbacks.current.onAdjust;
92
+ if (adjust === undefined) {
93
+ move(intent);
94
+ return;
95
+ }
96
+ const id = menuNavIdAt(idsRef.current, activeIndexRef.current);
97
+ if (id !== null)
98
+ adjust(id, intent === "right" ? 1 : -1);
99
+ }
78
100
  else
79
101
  move(intent);
80
102
  }, [activate, move]);
81
- const moveRef = useRef(move);
82
- moveRef.current = move;
83
103
  const runIntentRef = useRef(runIntent);
84
104
  runIntentRef.current = runIntent;
85
105
  const repeatRef = useRef(repeat);
@@ -88,14 +108,24 @@ export function useMenuNav({ ids, columns = 1, wrap = true, disabled, initialId,
88
108
  if (!pendingFocus.current)
89
109
  return;
90
110
  pendingFocus.current = false;
91
- focusActive();
92
- }, [activeIndex, focusActive]);
111
+ if (claimed.current) {
112
+ focusActive();
113
+ return;
114
+ }
115
+ claimed.current = true;
116
+ claimFocus();
117
+ }, [activeIndex, claimFocus, focusActive]);
93
118
  useEffect(() => {
94
119
  if (!enabled)
95
120
  return;
96
121
  pendingFocus.current = true;
122
+ if (firstEnablePass.current) {
123
+ firstEnablePass.current = false;
124
+ claimFocus();
125
+ return;
126
+ }
97
127
  focusActive();
98
- }, [enabled, focusActive]);
128
+ }, [enabled, claimFocus, focusActive]);
99
129
  useEffect(() => {
100
130
  const index = ids.indexOf(activeId ?? "");
101
131
  if (activeId !== null && index >= 0)
@@ -201,12 +231,12 @@ export function useMenuNav({ ids, columns = 1, wrap = true, disabled, initialId,
201
231
  }
202
232
  if (isNew) {
203
233
  held.set(button, { pressedAt: now, lastFiredAt: now });
204
- moveRef.current(intent);
234
+ runIntentRef.current(intent);
205
235
  continue;
206
236
  }
207
237
  if (record !== undefined && shouldRepeatMenuNav(now, record, repeatRef.current)) {
208
238
  record.lastFiredAt = now;
209
- moveRef.current(intent);
239
+ runIntentRef.current(intent);
210
240
  }
211
241
  }
212
242
  for (const button of [...held.keys()])
@@ -222,12 +252,12 @@ export function useMenuNav({ ids, columns = 1, wrap = true, disabled, initialId,
222
252
  }
223
253
  if (stick === null || stick.intent !== axisIntent) {
224
254
  stick = { intent: axisIntent, pressedAt: now, lastFiredAt: now };
225
- moveRef.current(axisIntent);
255
+ runIntentRef.current(axisIntent);
226
256
  return;
227
257
  }
228
258
  if (shouldRepeatMenuNav(now, stick, repeatRef.current)) {
229
259
  stick.lastFiredAt = now;
230
- moveRef.current(axisIntent);
260
+ runIntentRef.current(axisIntent);
231
261
  }
232
262
  };
233
263
  frame = requestAnimationFrame(poll);
@@ -1,25 +1,39 @@
1
1
  import { type CSSProperties, type ReactNode } from "react";
2
+ import type { SettingValue } from "@jgengine/core/settings/settingsModel";
3
+ import { type MenuNavItemProps } from "./menuNav.js";
2
4
  import type { SettingsCategoryView, SettingsKeybindRow, SettingsRow } from "./settings.js";
5
+ /**
6
+ * One adjustment of a settings row: a slider moves by its step inside its bounds, a toggle flips, a
7
+ * select moves one option (wrapping at both ends). Returns the value to write, or `undefined` when the
8
+ * kind has nothing to step. Shared by the keyboard and the gamepad so both change a row the same way.
9
+ *
10
+ * @capability adjust-setting-row step a settings row value once, for keyboard and gamepad alike
11
+ */
12
+ export declare function adjustSettingRow(row: SettingsRow, delta: -1 | 1): SettingValue | undefined;
3
13
  /**
4
14
  * One slider row from a {@link SettingsRow}: mirrors the thumb locally and commits on release, so a
5
15
  * drag never storms the settings store or the graphics rebuild it drives.
6
16
  *
7
17
  * @capability settings-slider a settings slider row that mirrors locally and persists on release
8
18
  */
9
- export declare function SettingsSlider({ row }: {
19
+ export declare function SettingsSlider({ row, itemProps }: {
10
20
  row: SettingsRow;
21
+ itemProps?: MenuNavItemProps;
11
22
  }): ReactNode;
12
23
  /** One on/off row from a {@link SettingsRow}. */
13
- export declare function SettingsToggle({ row }: {
24
+ export declare function SettingsToggle({ row, itemProps }: {
14
25
  row: SettingsRow;
26
+ itemProps?: MenuNavItemProps;
15
27
  }): ReactNode;
16
28
  /** One choice row from a {@link SettingsRow}, rendered as a pressed-state option group. */
17
- export declare function SettingsSelect({ row }: {
29
+ export declare function SettingsSelect({ row, itemProps }: {
18
30
  row: SettingsRow;
31
+ itemProps?: MenuNavItemProps;
19
32
  }): ReactNode;
20
- /** Render the control a {@link SettingsRow} kind asks for. */
21
- export declare function SettingsRowControl({ row }: {
33
+ /** Render the control a {@link SettingsRow} kind asks for. `itemProps` carries `useMenuNav` focus tracking from {@link SettingsSection}. */
34
+ export declare function SettingsRowControl({ row, itemProps }: {
22
35
  row: SettingsRow;
36
+ itemProps?: MenuNavItemProps;
23
37
  }): ReactNode;
24
38
  /** One rebindable action row: current key glyph, armed-capture state and a reset button. */
25
39
  export interface SettingsKeybindControlProps {
@@ -30,6 +44,8 @@ export interface SettingsKeybindControlProps {
30
44
  onArm?: () => void;
31
45
  /** Props the caller can spread onto the binding button for focus tracking. */
32
46
  buttonProps?: Record<string, unknown>;
47
+ /** Props for the row element (a `useMenuNav` item). */
48
+ rowProps?: MenuNavItemProps;
33
49
  }
34
50
  /**
35
51
  * A controls row for one action: its label, the bound key (click to rebind), a conflict/armed state
@@ -37,7 +53,7 @@ export interface SettingsKeybindControlProps {
37
53
  *
38
54
  * @capability settings-keybind-row one rebindable controls row — key glyph, armed state and reset
39
55
  */
40
- export declare function SettingsKeybindControl({ row, armed, onArm, buttonProps }: SettingsKeybindControlProps): ReactNode;
56
+ export declare function SettingsKeybindControl({ row, armed, onArm, buttonProps, rowProps }: SettingsKeybindControlProps): ReactNode;
41
57
  /** How a {@link SettingsSection} handles a rebind capture. */
42
58
  export interface SettingsSectionProps {
43
59
  /** The category to render: its rows and its rebindable actions. */
@@ -46,6 +62,10 @@ export interface SettingsSectionProps {
46
62
  heading?: boolean;
47
63
  /** Allow click-to-rebind on the controls rows. Default true. */
48
64
  capture?: boolean;
65
+ /** Take arrow keys, the d-pad and the left stick for row navigation and value edits. Default true. */
66
+ enabled?: boolean;
67
+ /** Row focused on mount. Default the first row. */
68
+ initialId?: string;
49
69
  className?: string;
50
70
  style?: CSSProperties;
51
71
  /** Called with the armed action id (or `null`) so a game can suspend its own keys. */
@@ -56,9 +76,15 @@ export interface SettingsSectionProps {
56
76
  * game picks where it sits and how wide it is; the block reads HudTheme tokens and the live rows the
57
77
  * settings controller hands it, and writes every change straight back to that controller's store.
58
78
  *
59
- * @capability settings-section settings category block — rows plus click-to-capture keybind rows, HudTheme-styled, store-backed
79
+ * The rows are one `useMenuNav` menu: up/down (or the d-pad/stick) move between them, left/right step
80
+ * the focused value — a slider by its step, a toggle flipped, a select to the next/previous option —
81
+ * and Enter/Space/A confirms, which flips a toggle, steps a select forward or arms a keybind.
82
+ * Keyboard and gamepad share that one mapping, so a held direction repeats at the same rate for both,
83
+ * and no row carries its own key handling.
84
+ *
85
+ * @capability settings-section settings category block — rows plus click-to-capture keybind rows, HudTheme-styled, store-backed, keyboard and gamepad navigable
60
86
  */
61
- export declare function SettingsSection({ category, heading, capture, className, style, onCaptureChange, }: SettingsSectionProps): ReactNode;
87
+ export declare function SettingsSection({ category, heading, capture, enabled, initialId, className, style, onCaptureChange, }: SettingsSectionProps): ReactNode;
62
88
  /** The live category tabs for a settings screen: a scrollable rail with pressed state. */
63
89
  export interface SettingsTabsProps {
64
90
  /** The live categories from the settings controller. */
@@ -1,6 +1,7 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { useEffect, useRef, useState } from "react";
2
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
3
3
  import { hudSpace, hudType, hudTransition } from "./hudTokens.js";
4
+ import { useMenuNav } from "./menuNav.js";
4
5
  import { ScrollRail } from "./scrollRail.js";
5
6
  import { useDebouncedCommit } from "./useDebouncedCommit.js";
6
7
  const rowStyle = {
@@ -24,8 +25,35 @@ const valueStyle = {
24
25
  minWidth: 48,
25
26
  textAlign: "right",
26
27
  };
27
- function RowShell({ label, children }) {
28
- return (_jsxs("div", { "data-settings-row": "", style: rowStyle, children: [_jsx("span", { style: labelStyle, children: label }), _jsx("div", { style: { display: "flex", alignItems: "center", gap: hudSpace("sm"), minWidth: 0 }, children: children })] }));
28
+ function RowShell({ label, children, itemProps }) {
29
+ return (_jsxs("div", { "data-settings-row": "", style: rowStyle, ...itemProps, children: [_jsx("span", { style: labelStyle, children: label }), _jsx("div", { style: { display: "flex", alignItems: "center", gap: hudSpace("sm"), minWidth: 0 }, children: children })] }));
30
+ }
31
+ /**
32
+ * One adjustment of a settings row: a slider moves by its step inside its bounds, a toggle flips, a
33
+ * select moves one option (wrapping at both ends). Returns the value to write, or `undefined` when the
34
+ * kind has nothing to step. Shared by the keyboard and the gamepad so both change a row the same way.
35
+ *
36
+ * @capability adjust-setting-row step a settings row value once, for keyboard and gamepad alike
37
+ */
38
+ export function adjustSettingRow(row, delta) {
39
+ if (row.kind === "toggle")
40
+ return !Boolean(row.value);
41
+ if (row.kind === "select") {
42
+ const options = row.options ?? [];
43
+ if (options.length === 0)
44
+ return undefined;
45
+ const current = options.findIndex((option) => option.value === String(row.value));
46
+ const base = current < 0 ? (delta > 0 ? -1 : 0) : current;
47
+ return options[(base + delta + options.length) % options.length]?.value;
48
+ }
49
+ if (row.kind !== "slider")
50
+ return undefined;
51
+ const step = row.step ?? 1;
52
+ const min = row.min ?? Number.NEGATIVE_INFINITY;
53
+ const max = row.max ?? Number.POSITIVE_INFINITY;
54
+ const next = Math.min(max, Math.max(min, Number(row.value) + step * delta));
55
+ // Settings persist as plain numbers, so keep float noise out of values like 0.30000000000000004.
56
+ return Math.round(next * 1e6) / 1e6;
29
57
  }
30
58
  /**
31
59
  * One slider row from a {@link SettingsRow}: mirrors the thumb locally and commits on release, so a
@@ -33,14 +61,14 @@ function RowShell({ label, children }) {
33
61
  *
34
62
  * @capability settings-slider a settings slider row that mirrors locally and persists on release
35
63
  */
36
- export function SettingsSlider({ row }) {
64
+ export function SettingsSlider({ row, itemProps }) {
37
65
  const { value, onInput, flush } = useDebouncedCommit(Number(row.value), (next) => row.set(next));
38
- return (_jsxs(RowShell, { label: row.label, children: [_jsx("input", { type: "range", min: row.min, max: row.max, step: row.step, value: value, "aria-label": row.label, onChange: (event) => onInput(Number(event.target.value)), onPointerUp: flush, onKeyUp: flush, onBlur: flush, style: { flex: 1, minWidth: 120, accentColor: "var(--jg-accent, #6ee7a8)" } }), _jsx("span", { style: valueStyle, children: row.format?.(value) ?? String(value) })] }));
66
+ return (_jsxs(RowShell, { label: row.label, itemProps: itemProps, children: [_jsx("input", { type: "range", min: row.min, max: row.max, step: row.step, value: value, "aria-label": row.label, onChange: (event) => onInput(Number(event.target.value)), onPointerUp: flush, onKeyUp: flush, onBlur: flush, style: { flex: 1, minWidth: 120, accentColor: "var(--jg-accent, #6ee7a8)" } }), _jsx("span", { style: valueStyle, children: row.format?.(value) ?? String(value) })] }));
39
67
  }
40
68
  /** One on/off row from a {@link SettingsRow}. */
41
- export function SettingsToggle({ row }) {
69
+ export function SettingsToggle({ row, itemProps }) {
42
70
  const on = Boolean(row.value);
43
- return (_jsx(RowShell, { label: row.label, children: _jsx("button", { type: "button", role: "switch", "aria-checked": on, "aria-label": row.label, onClick: () => row.set(!on), style: {
71
+ return (_jsx(RowShell, { label: row.label, itemProps: itemProps, children: _jsx("button", { type: "button", role: "switch", "aria-checked": on, "aria-label": row.label, onClick: () => row.set(!on), style: {
44
72
  position: "relative",
45
73
  width: 48,
46
74
  height: 26,
@@ -62,9 +90,9 @@ export function SettingsToggle({ row }) {
62
90
  } }) }) }));
63
91
  }
64
92
  /** One choice row from a {@link SettingsRow}, rendered as a pressed-state option group. */
65
- export function SettingsSelect({ row }) {
93
+ export function SettingsSelect({ row, itemProps }) {
66
94
  const current = String(row.value);
67
- return (_jsx(RowShell, { label: row.label, children: _jsx("div", { role: "group", "aria-label": row.label, style: { display: "flex", flexWrap: "wrap", justifyContent: "flex-end", gap: 4 }, children: (row.options ?? []).map((option) => {
95
+ return (_jsx(RowShell, { label: row.label, itemProps: itemProps, children: _jsx("div", { role: "group", "aria-label": row.label, style: { display: "flex", flexWrap: "wrap", justifyContent: "flex-end", gap: 4 }, children: (row.options ?? []).map((option) => {
68
96
  const selected = option.value === current;
69
97
  return (_jsx("button", { type: "button", "aria-pressed": selected, onClick: () => row.set(option.value), style: {
70
98
  minHeight: 30,
@@ -80,13 +108,13 @@ export function SettingsSelect({ row }) {
80
108
  }, children: option.label }, option.value));
81
109
  }) }) }));
82
110
  }
83
- /** Render the control a {@link SettingsRow} kind asks for. */
84
- export function SettingsRowControl({ row }) {
111
+ /** Render the control a {@link SettingsRow} kind asks for. `itemProps` carries `useMenuNav` focus tracking from {@link SettingsSection}. */
112
+ export function SettingsRowControl({ row, itemProps }) {
85
113
  if (row.kind === "slider")
86
- return _jsx(SettingsSlider, { row: row });
114
+ return _jsx(SettingsSlider, { row: row, itemProps: itemProps });
87
115
  if (row.kind === "toggle")
88
- return _jsx(SettingsToggle, { row: row });
89
- return _jsx(SettingsSelect, { row: row });
116
+ return _jsx(SettingsToggle, { row: row, itemProps: itemProps });
117
+ return _jsx(SettingsSelect, { row: row, itemProps: itemProps });
90
118
  }
91
119
  /**
92
120
  * A controls row for one action: its label, the bound key (click to rebind), a conflict/armed state
@@ -94,8 +122,8 @@ export function SettingsRowControl({ row }) {
94
122
  *
95
123
  * @capability settings-keybind-row one rebindable controls row — key glyph, armed state and reset
96
124
  */
97
- export function SettingsKeybindControl({ row, armed = false, onArm, buttonProps }) {
98
- return (_jsxs("div", { "data-settings-keybind": row.action, "data-armed": armed ? "" : undefined, style: rowStyle, children: [_jsx("span", { style: labelStyle, children: row.label }), _jsxs("div", { style: { display: "flex", alignItems: "center", gap: hudSpace("xs") }, children: [_jsx("button", { type: "button", "data-settings-capture": "", ...buttonProps, onClick: onArm, disabled: armed, title: "Click, then press a key \u2014 Escape cancels", style: {
125
+ export function SettingsKeybindControl({ row, armed = false, onArm, buttonProps, rowProps }) {
126
+ return (_jsxs("div", { "data-settings-keybind": row.action, "data-armed": armed ? "" : undefined, style: rowStyle, ...rowProps, children: [_jsx("span", { style: labelStyle, children: row.label }), _jsxs("div", { style: { display: "flex", alignItems: "center", gap: hudSpace("xs") }, children: [_jsx("button", { type: "button", "data-settings-capture": "", ...buttonProps, onClick: onArm, disabled: armed, title: "Click, then press a key \u2014 Escape cancels", style: {
99
127
  minWidth: 84,
100
128
  minHeight: 30,
101
129
  padding: `0 ${hudSpace("sm")}`,
@@ -117,43 +145,95 @@ export function SettingsKeybindControl({ row, armed = false, onArm, buttonProps
117
145
  cursor: "pointer",
118
146
  }, children: "\u21BA" })] })] }));
119
147
  }
148
+ const ROW_ITEM_PREFIX = "row:";
149
+ const KEY_ITEM_PREFIX = "key:";
120
150
  /**
121
151
  * One settings category as a block: its rows, then its rebindable actions with click-to-capture. The
122
152
  * game picks where it sits and how wide it is; the block reads HudTheme tokens and the live rows the
123
153
  * settings controller hands it, and writes every change straight back to that controller's store.
124
154
  *
125
- * @capability settings-section settings category block — rows plus click-to-capture keybind rows, HudTheme-styled, store-backed
155
+ * The rows are one `useMenuNav` menu: up/down (or the d-pad/stick) move between them, left/right step
156
+ * the focused value — a slider by its step, a toggle flipped, a select to the next/previous option —
157
+ * and Enter/Space/A confirms, which flips a toggle, steps a select forward or arms a keybind.
158
+ * Keyboard and gamepad share that one mapping, so a held direction repeats at the same rate for both,
159
+ * and no row carries its own key handling.
160
+ *
161
+ * @capability settings-section settings category block — rows plus click-to-capture keybind rows, HudTheme-styled, store-backed, keyboard and gamepad navigable
126
162
  */
127
- export function SettingsSection({ category, heading = true, capture = true, className, style, onCaptureChange, }) {
163
+ export function SettingsSection({ category, heading = true, capture = true, enabled = true, initialId, className, style, onCaptureChange, }) {
128
164
  const [armed, setArmed] = useState(null);
165
+ // Mirrors `armed` synchronously. Two key events in one tick — Escape then the key after it — would both
166
+ // see the pre-render state without it, and the second would rebind what Escape just cancelled.
167
+ const armedRef = useRef(null);
168
+ const arm = useCallback((action) => {
169
+ armedRef.current = action;
170
+ setArmed(action);
171
+ }, []);
129
172
  const callbacks = useRef({ onCaptureChange });
130
173
  callbacks.current = { onCaptureChange };
131
174
  useEffect(() => {
132
175
  callbacks.current.onCaptureChange?.(armed);
133
176
  }, [armed]);
177
+ const rows = category.rows;
178
+ const keybinds = category.keybinds;
179
+ const ids = useMemo(() => [...rows.map((row) => `${ROW_ITEM_PREFIX}${row.id}`), ...keybinds.map((row) => `${KEY_ITEM_PREFIX}${row.action}`)], [rows, keybinds]);
180
+ const adjust = useCallback((id, delta) => {
181
+ // A keybind row has no value to step; confirming it is what arms the capture.
182
+ if (id.startsWith(KEY_ITEM_PREFIX))
183
+ return;
184
+ const row = rows.find((candidate) => `${ROW_ITEM_PREFIX}${candidate.id}` === id);
185
+ if (row === undefined)
186
+ return;
187
+ const next = adjustSettingRow(row, delta);
188
+ if (next !== undefined)
189
+ row.set(next);
190
+ }, [rows]);
191
+ const confirm = useCallback((id) => {
192
+ if (id.startsWith(ROW_ITEM_PREFIX)) {
193
+ const row = rows.find((candidate) => `${ROW_ITEM_PREFIX}${candidate.id}` === id);
194
+ // A slider's value belongs to left/right; confirming it would jump the thumb for a keyboard user.
195
+ if (row === undefined || row.kind === "slider")
196
+ return;
197
+ const next = adjustSettingRow(row, 1);
198
+ if (next !== undefined)
199
+ row.set(next);
200
+ return;
201
+ }
202
+ if (!capture)
203
+ return;
204
+ arm(id.slice(KEY_ITEM_PREFIX.length));
205
+ }, [arm, capture, rows]);
206
+ const nav = useMenuNav({
207
+ ids,
208
+ ...(initialId === undefined ? {} : { initialId: `${ROW_ITEM_PREFIX}${initialId}` }),
209
+ enabled: enabled && armed === null,
210
+ onAdjust: adjust,
211
+ onConfirm: confirm,
212
+ });
134
213
  useEffect(() => {
135
214
  if (armed === null)
136
215
  return;
137
- const row = category.keybinds.find((candidate) => candidate.action === armed);
216
+ const row = keybinds.find((candidate) => candidate.action === armed);
138
217
  if (row === undefined) {
139
- setArmed(null);
218
+ arm(null);
140
219
  return;
141
220
  }
142
221
  const onKey = (event) => {
143
222
  event.preventDefault();
144
223
  event.stopPropagation();
145
- if (event.code === "Escape") {
146
- setArmed(null);
224
+ if (armedRef.current !== row.action)
147
225
  return;
148
- }
149
- row.rebind(event.code);
150
- setArmed(null);
226
+ arm(null);
227
+ if (event.code !== "Escape")
228
+ row.rebind(event.code);
151
229
  };
152
230
  const onPointer = (event) => {
153
231
  event.preventDefault();
154
232
  event.stopPropagation();
233
+ if (armedRef.current !== row.action)
234
+ return;
235
+ arm(null);
155
236
  row.rebind(`mouse${event.button}`);
156
- setArmed(null);
157
237
  };
158
238
  const onContext = (event) => event.preventDefault();
159
239
  window.addEventListener("keydown", onKey, true);
@@ -164,8 +244,8 @@ export function SettingsSection({ category, heading = true, capture = true, clas
164
244
  window.removeEventListener("pointerdown", onPointer, true);
165
245
  window.removeEventListener("contextmenu", onContext, true);
166
246
  };
167
- }, [armed, category.keybinds]);
168
- return (_jsxs("section", { "data-settings-section": category.id, className: className, style: { display: "flex", flexDirection: "column", gap: hudSpace("xs"), ...style }, children: [heading ? (_jsx("h3", { style: {
247
+ }, [armed, arm, keybinds]);
248
+ return (_jsxs("section", { ...nav.containerProps, "data-settings-section": category.id, className: className, style: { display: "flex", flexDirection: "column", gap: hudSpace("xs"), ...style }, children: [heading ? (_jsx("h3", { style: {
169
249
  margin: 0,
170
250
  marginBottom: hudSpace("xs"),
171
251
  fontFamily: "var(--jg-font-display, inherit)",
@@ -174,7 +254,7 @@ export function SettingsSection({ category, heading = true, capture = true, clas
174
254
  letterSpacing: "0.18em",
175
255
  textTransform: "uppercase",
176
256
  color: "var(--jg-accent, #6ee7a8)",
177
- }, children: category.label })) : null, category.rows.map((row) => (_jsx(SettingsRowControl, { row: row }, row.id))), category.keybinds.map((row) => (_jsx(SettingsKeybindControl, { row: row, armed: armed === row.action, onArm: capture ? () => setArmed(row.action) : undefined }, row.action)))] }));
257
+ }, children: category.label })) : null, rows.map((row) => (_jsx(SettingsRowControl, { row: row, itemProps: nav.itemProps(`${ROW_ITEM_PREFIX}${row.id}`) }, row.id))), keybinds.map((row) => (_jsx(SettingsKeybindControl, { row: row, armed: armed === row.action, onArm: capture ? () => arm(row.action) : undefined, rowProps: nav.itemProps(`${KEY_ITEM_PREFIX}${row.action}`) }, row.action)))] }));
178
258
  }
179
259
  /**
180
260
  * The category tab rail for a settings screen: a scrollable row of buttons with pressed state. The
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jgengine/react",
3
- "version": "0.28.0",
3
+ "version": "0.30.0",
4
4
  "description": "React UI layer for JGengine: GameProvider, hooks, and headless primitives over @jgengine/core.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -35,7 +35,7 @@
35
35
  "styles": "bun scripts/styles.ts"
36
36
  },
37
37
  "dependencies": {
38
- "@jgengine/core": "^0.28.0"
38
+ "@jgengine/core": "^0.30.0"
39
39
  },
40
40
  "peerDependencies": {
41
41
  "react": "^19.0.0",
@@ -6,7 +6,7 @@
6
6
 
7
7
  - `CHANGELOG` (const): const CHANGELOG: Record<string, ChangelogEntry> — Per-version engine changelog keyed by semver string (e.g. `"0.10.0"`).
8
8
  - `ChangelogEntry` (interface): interface ChangelogEntry — One release's migrate steps plus added/changed/removed notes (typed mirror of CHANGELOG.md).
9
- - `VERSION` (const): const VERSION: "0.28.0" — Installed `@jgengine/core` semver — compare against {@link CHANGELOG} keys when migrating.
9
+ - `VERSION` (const): const VERSION: "0.30.0" — Installed `@jgengine/core` semver — compare against {@link CHANGELOG} keys when migrating.
10
10
 
11
11
  ## @jgengine/core/authoring
12
12
 
@@ -285,7 +285,7 @@
285
285
 
286
286
  - `CHANGELOG` (const): const CHANGELOG: Record<string, ChangelogEntry> — Per-version engine changelog keyed by semver string (e.g. `"0.10.0"`).
287
287
  - `ChangelogEntry` (interface): interface ChangelogEntry — One release's migrate steps plus added/changed/removed notes (typed mirror of CHANGELOG.md).
288
- - `VERSION` (const): const VERSION: "0.28.0" — Installed `@jgengine/core` semver — compare against {@link CHANGELOG} keys when migrating.
288
+ - `VERSION` (const): const VERSION: "0.30.0" — Installed `@jgengine/core` semver — compare against {@link CHANGELOG} keys when migrating.
289
289
 
290
290
  ## @jgengine/core/runtime/adapter
291
291
 
@@ -407,6 +407,7 @@
407
407
  - `TEXT_SCALE_MAX` (const): const TEXT_SCALE_MAX: 2 — Maximum UI text scale.
408
408
  - `TEXT_SCALE_MIN` (const): const TEXT_SCALE_MIN: 0.75 — Minimum UI text scale.
409
409
  - `clampTextScale` (function): function clampTextScale(scale: number): number — Clamp a text-scale multiplier into the supported `[TEXT_SCALE_MIN, TEXT_SCALE_MAX]` range.
410
+ - `colorblindFrameFilter` (function): function colorblindFrameFilter(mode: ColorblindMode): string | null — The CSS filter that applies `mode` to one rendered frame — a `url(#jg-cb-<mode>)` reference to the `feColorMatrix` defs {@link COLORBLIND_MATRICES} drives. `"none"` maps to `null` (no filter), so a caller can spread it onto the canvas alone and leave HUD and menu text at its own contrast.
410
411
  - `createAccessibilityStore` (function): function createAccessibilityStore(initial: Partial<AccessibilityState> = {}): AccessibilityStore — Serializable, observable store of accessibility preferences (reduced motion, high contrast, text scale, colorblind mode, captions). Genre-agnostic plumbing like input rebinding — a game binds these to its settings UI and adapts its presentation; `snapshot`/`restore` round-trip through a save or settings blob.
411
412
  - `reducedMotionDuration` (function): function reducedMotionDuration(state: AccessibilityState, ms: number, reduced = 0): number — Return `ms` when motion is allowed, or `reduced` (default 0) when `reducedMotion` is set — the animation-duration gate.
412
413
 
@@ -652,7 +653,7 @@
652
653
  ## @jgengine/react
653
654
 
654
655
  - `AbilitySlotBindingOptions` (interface): interface AbilitySlotBindingOptions { intervalMs?: number } — ⚠ undocumented · used by `useEventMeter`: Bind a `createEventMeter` (`@jgengine/core/stats/eventMeter`) heat/streak gauge to a component — the react-render half of the ult/adrenaline…
655
- - `AccessibilityProvider` (function): function AccessibilityProvider({ store, children, className, style, }: { store: AccessibilityStore; children: ReactNode; className?: string; style?: CSSProperties; }): ReactNode — Apply accessibility preferences to a subtree: exposes `--jg-text-scale` for text to scale off, sets `data-reduced-motion` / `data-high-contrast` / `data-colorblind` / `data-captions` for CSS to respond to, and wraps the tree in the selected colorblind `feColorMatrix` filter. Reads a `createAccessibilityStore` and re-renders on change; provides the state to `useAccessibility`.
656
+ - `AccessibilityProvider` (function): function AccessibilityProvider({ store, children, className, style, }: { store: AccessibilityStore; children: ReactNode; className?: string; style?: CSSProperties; }): ReactNode — Apply accessibility preferences to a subtree: exposes `--jg-text-scale` for text to scale off, and sets `data-reduced-motion` / `data-high-contrast` / `data-colorblind` / `data-captions` for CSS to respond to. Reads a `createAccessibilityStore` and re-renders on change; provides the state to `useAccessibility`.
656
657
  - `AchievementGallery` (function): function AchievementGallery({ achievements, title = "Achievements", maskSecrets = true, renderIcon, columns = 2, emptyLabel = "No achievements yet.", className, style, }: AchievementGalleryProps): ReactNode — Achievement/trophy gallery — a responsive grid of cards showing unlocked vs. locked state, a progress bar for counter achievements, and a header summary of completion and score. Secret+locked entries mask their name/description. Feed it `useAchievements(tracker)`.
657
658
  - `AchievementGalleryProps` (interface): interface AchievementGalleryProps — Props for {@link AchievementGallery}.
658
659
  - `AchievementToast` (function): function AchievementToast({ name, description, icon, points, heading = "Achievement Unlocked", className, style, }: AchievementToastProps): ReactNode — The unlock-moment banner — icon, "Achievement Unlocked" heading, name, and optional points. Purely presentational; pair the game's `onUnlock` seam with a toast queue and render one of these per entry.
@@ -703,7 +704,9 @@
703
704
  - `Codex` (function): function Codex({ entries, title = "Codex", columns = 3, maskSecrets = true, renderIcon, onSelect, emptyLabel = "Nothing discovered yet.", className, style, }: CodexProps): ReactNode — Codex / bestiary gallery — category tabs, a responsive grid of entry cards (discovered vs. locked, secret entries masked until found), and a header summarizing completion. Feed it `codex.list()`; wire `onSelect` to a detail pane.
704
705
  - `CodexProps` (interface): interface CodexProps — Props for {@link Codex}.
705
706
  - `Coins` (function): function Coins({ currencyId, icon = "🪙", style, className, }: { currencyId: string; icon?: ReactNode; style?: CSSProperties; className?: string; }): React.JSX.Element — A currency counter — an icon (emoji/char, default a coin) plus the live amount for `currencyId`. *
706
- - `ColorblindFilters` (function): function ColorblindFilters(): ReactNode — Hidden SVG `<defs>` holding the `feColorMatrix` colorblind filters that `AccessibilityProvider` references by `filter: url(#jg-cb-<mode>)`. Rendered automatically inside the provider; export standalone for custom roots.
707
+ - `ColorblindFilters` (function): function ColorblindFilters(): ReactNode — Hidden SVG `<defs>` holding the `feColorMatrix` colorblind filters that `filter: url(#jg-cb-<mode>)` references. Mounted by {@link ColorblindFrame}; export it standalone for a root that applies the filter itself.
708
+ - `ColorblindFrame` (function): function ColorblindFrame({ mode = "none", children, className, style }: ColorblindFrameProps): ReactNode — Apply the player's colour-vision mode to one rendered frame: a box carrying `filter: url(#jg-cb-<mode>)` plus the filter defs it references. Put it around the canvas of a 3D game and nothing else — the filter is one compositor pass over that box's pixels, so HUD, menu and caption text outside it keep the contrast and hues the rest of the UI assumes.
709
+ - `ColorblindFrameProps` (interface): interface ColorblindFrameProps — Props for {@link ColorblindFrame}.
707
710
  - `ComboMeterHud` (function): function ComboMeterHud({ meter, tierColors, theme, animate = true, hideWhenEmpty = true, barWidth = 220, className, style, }: ComboMeterHudProps): ReactNode — A drop-in HUD for a core {@link ComboMeter}: the big live combo count ("×12"), the current free-string tier label, the derived score multiplier, and a draining window bar (reusing the shared atomic bar) that empties as the decay window runs out and re-fills on every hit. The active tier's color — supplied by the game via `tierColors`, keyed on the free-string tier id the model never interprets — drives the count glow, the label, and the bar fill, so a game reskins the escalation ("good" → "great" → "savage") without the engine hardcoding any tier meaning. Presentation only: all counting, timing, tiers, and the multiplier live in the core model.
708
711
  - `ComboMeterHudProps` (interface): interface ComboMeterHudProps — Props for {@link ComboMeterHud}.
709
712
  - `ComboMeterTheme` (interface): interface ComboMeterTheme — Reskin tokens for {@link ComboMeterHud}.
@@ -1067,6 +1070,7 @@
1067
1070
  - `useChat` (function): function useChat(channelId: string, options?: { limit?: number }): ChatMessage[] — ⚠ undocumented
1068
1071
  - `useChatBubbles` (function): function useChatBubbles(options?: ChatBubblesOptions): readonly ChatBubble[] — ⚠ undocumented
1069
1072
  - `useCoachMarks` (function): function useCoachMarks(sequence: CoachMarkSequence): CoachMarkView | null — Subscribe to a coach-mark sequence and re-render on every change, returning the current step view (or `null` when the tour is complete or waiting on a gate).
1073
+ - `useColorblindMode` (function): function useColorblindMode(store: SettingsStore): ColorblindMode — The live colour-vision mode from a settings store — the same store the settings rows write, so the frame follows the player's choice the moment they pick it. Pass the store the shell owns; read `useAccessibilitySettings().colorblind` instead when a `SettingsProvider` is above the caller.
1070
1074
  - `useComboMeter` (function): function useComboMeter(meter: ComboMeter, animate = true): ComboMeterView — Subscribe to a combo meter and re-render it each animation frame so the decay window bar drains live, returning the pooled view to draw right now. Reading the view each frame advances the meter's clock and fires window expiry, so a combo that times out visibly drops even with no further hits. The returned object is the model's pooled view — read its fields immediately, do not retain it.
1071
1075
  - `useCurrency` (function): function useCurrency(currencyId: string): number — ⚠ undocumented
1072
1076
  - `useDamageDirection` (function): function useDamageDirection(tracker: DamageDirectionTracker): readonly DamageIndicator[] — Subscribe to a damage-direction tracker and animate. Because indicators fade continuously on a clock (not just on discrete events), this drives a `requestAnimationFrame` loop while any indicator is live and stops when the screen is clear, then wakes again on the next hit. Returns the tracker's current `active()` array (reused between frames — copy if you retain it).
@@ -1110,7 +1114,7 @@
1110
1114
  - `useLocalPlayerDead` (function): function useLocalPlayerDead(healthStatId = "health"): boolean — ⚠ undocumented
1111
1115
  - `useLocale` (function): function useLocale(): Locale — Subscribe to the active locale — re-renders on `setLocale`.
1112
1116
  - `useMarkers` (function): function useMarkers<TMeta = unknown>(markers: MarkerSet<TMeta>): readonly MapMarker<TMeta>[] — Subscribe to a native marker set or external marker source, or read a static marker array.
1113
- - `useMenuNav` (function): function useMenuNav({ ids, columns = 1, wrap = true, disabled, initialId, enabled = true, onConfirm, onBack, gamepad = true, repeat, }: UseMenuNavOptions): MenuNavController — Bind a menu's focus order to the DOM: arrow keys, WASD, Home/End and a standard gamepad move a roving focus, Enter/Space/A confirm, Escape/Backspace/B/Start go back. Tab is left to the browser, so a menu never traps focus, and typing in a text field is never intercepted. The menu is pure state + listeners — it unmounts with the screen, so it costs nothing during play.
1117
+ - `useMenuNav` (function): function useMenuNav({ ids, columns = 1, wrap = true, disabled, initialId, enabled = true, onConfirm, onBack, onAdjust, gamepad = true, repeat, }: UseMenuNavOptions): MenuNavController — Bind a menu's focus order to the DOM: arrow keys, WASD, Home/End and a standard gamepad move a roving focus, Enter/Space/A confirm, Escape/Backspace/B/Start go back. Tab is left to the browser, so a menu never traps focus, and typing in a text field is never intercepted. The menu is pure state + listeners — it unmounts with the screen, so it costs nothing during play.
1114
1118
  - `useModalStack` (function): function useModalStack(stack: ModalStack): ModalStackView — Subscribe to a modal stack and re-render on every change, returning the active modal and depth.
1115
1119
  - `useNearestWorldItem` (function): function useNearestWorldItem(radius: number): WorldItemRecord | null — Nearest ground item within `radius` of the local player — drives a pickup prompt/highlight.
1116
1120
  - `useNotifications` (function): function useNotifications<TMeta = unknown>(store: NotificationStore<TMeta>): readonly NotificationEntry<TMeta>[] — Subscribe to a notification store's live list.
@@ -1161,9 +1165,12 @@
1161
1165
 
1162
1166
  ## @jgengine/react/accessibility
1163
1167
 
1164
- - `AccessibilityProvider` (function): function AccessibilityProvider({ store, children, className, style, }: { store: AccessibilityStore; children: ReactNode; className?: string; style?: CSSProperties; }): ReactNode — Apply accessibility preferences to a subtree: exposes `--jg-text-scale` for text to scale off, sets `data-reduced-motion` / `data-high-contrast` / `data-colorblind` / `data-captions` for CSS to respond to, and wraps the tree in the selected colorblind `feColorMatrix` filter. Reads a `createAccessibilityStore` and re-renders on change; provides the state to `useAccessibility`.
1165
- - `ColorblindFilters` (function): function ColorblindFilters(): ReactNode — Hidden SVG `<defs>` holding the `feColorMatrix` colorblind filters that `AccessibilityProvider` references by `filter: url(#jg-cb-<mode>)`. Rendered automatically inside the provider; export standalone for custom roots.
1168
+ - `AccessibilityProvider` (function): function AccessibilityProvider({ store, children, className, style, }: { store: AccessibilityStore; children: ReactNode; className?: string; style?: CSSProperties; }): ReactNode — Apply accessibility preferences to a subtree: exposes `--jg-text-scale` for text to scale off, and sets `data-reduced-motion` / `data-high-contrast` / `data-colorblind` / `data-captions` for CSS to respond to. Reads a `createAccessibilityStore` and re-renders on change; provides the state to `useAccessibility`.
1169
+ - `ColorblindFilters` (function): function ColorblindFilters(): ReactNode — Hidden SVG `<defs>` holding the `feColorMatrix` colorblind filters that `filter: url(#jg-cb-<mode>)` references. Mounted by {@link ColorblindFrame}; export it standalone for a root that applies the filter itself.
1170
+ - `ColorblindFrame` (function): function ColorblindFrame({ mode = "none", children, className, style }: ColorblindFrameProps): ReactNode — Apply the player's colour-vision mode to one rendered frame: a box carrying `filter: url(#jg-cb-<mode>)` plus the filter defs it references. Put it around the canvas of a 3D game and nothing else — the filter is one compositor pass over that box's pixels, so HUD, menu and caption text outside it keep the contrast and hues the rest of the UI assumes.
1171
+ - `ColorblindFrameProps` (interface): interface ColorblindFrameProps — Props for {@link ColorblindFrame}.
1166
1172
  - `useAccessibility` (function): function useAccessibility(): AccessibilityContextValue — Read the live accessibility state + its store; throws without an {@link AccessibilityProvider} above.
1173
+ - `useColorblindMode` (function): function useColorblindMode(store: SettingsStore): ColorblindMode — The live colour-vision mode from a settings store — the same store the settings rows write, so the frame follows the player's choice the moment they pick it. Pass the store the shell owns; read `useAccessibilitySettings().colorblind` instead when a `SettingsProvider` is above the caller.
1167
1174
  - `usePrefersReducedMotion` (function): function usePrefersReducedMotion(): boolean — OS "reduce motion" preference (`prefers-reduced-motion: reduce`) as a live boolean — SSR-safe (defaults false). Use it to seed an accessibility store's `reducedMotion` default.
1168
1175
  - `useReducedMotion` (function): function useReducedMotion(): boolean — True when motion should be skipped: the OS `prefers-reduced-motion` preference, or the nearest {@link AccessibilityProvider}'s `reducedMotion` setting. Works without a provider.
1169
1176
 
@@ -1746,7 +1753,7 @@
1746
1753
  - `MenuNavItemProps` (interface): interface MenuNavItemProps — Props a menu item needs so {@link useMenuNav} can focus it and track pointer/focus.
1747
1754
  - `MenuNavRender` (type): type MenuNavRender = (nav: MenuNavController) => ReactNode — Render prop for a menu whose items come from {@link useMenuNav}.
1748
1755
  - `UseMenuNavOptions` (interface): interface UseMenuNavOptions — How {@link useMenuNav} is wired.
1749
- - `useMenuNav` (function): function useMenuNav({ ids, columns = 1, wrap = true, disabled, initialId, enabled = true, onConfirm, onBack, gamepad = true, repeat, }: UseMenuNavOptions): MenuNavController — Bind a menu's focus order to the DOM: arrow keys, WASD, Home/End and a standard gamepad move a roving focus, Enter/Space/A confirm, Escape/Backspace/B/Start go back. Tab is left to the browser, so a menu never traps focus, and typing in a text field is never intercepted. The menu is pure state + listeners — it unmounts with the screen, so it costs nothing during play.
1756
+ - `useMenuNav` (function): function useMenuNav({ ids, columns = 1, wrap = true, disabled, initialId, enabled = true, onConfirm, onBack, onAdjust, gamepad = true, repeat, }: UseMenuNavOptions): MenuNavController — Bind a menu's focus order to the DOM: arrow keys, WASD, Home/End and a standard gamepad move a roving focus, Enter/Space/A confirm, Escape/Backspace/B/Start go back. Tab is left to the browser, so a menu never traps focus, and typing in a text field is never intercepted. The menu is pure state + listeners — it unmounts with the screen, so it costs nothing during play.
1750
1757
 
1751
1758
  ## @jgengine/react/menuRouter
1752
1759
 
@@ -1921,16 +1928,17 @@
1921
1928
 
1922
1929
  ## @jgengine/react/settingsBlocks
1923
1930
 
1924
- - `SettingsKeybindControl` (function): function SettingsKeybindControl({ row, armed = false, onArm, buttonProps }: SettingsKeybindControlProps): ReactNode — A controls row for one action: its label, the bound key (click to rebind), a conflict/armed state and reset. The caller owns capture; this is the row's presentation.
1931
+ - `SettingsKeybindControl` (function): function SettingsKeybindControl({ row, armed = false, onArm, buttonProps, rowProps }: SettingsKeybindControlProps): ReactNode — A controls row for one action: its label, the bound key (click to rebind), a conflict/armed state and reset. The caller owns capture; this is the row's presentation.
1925
1932
  - `SettingsKeybindControlProps` (interface): interface SettingsKeybindControlProps — One rebindable action row: current key glyph, armed-capture state and a reset button.
1926
- - `SettingsRowControl` (function): function SettingsRowControl({ row }: { row: SettingsRow }): ReactNode — Render the control a {@link SettingsRow} kind asks for.
1927
- - `SettingsSection` (function): function SettingsSection({ category, heading = true, capture = true, className, style, onCaptureChange, }: SettingsSectionProps): ReactNode — One settings category as a block: its rows, then its rebindable actions with click-to-capture. The game picks where it sits and how wide it is; the block reads HudTheme tokens and the live rows the settings controller hands it, and writes every change straight back to that controller's store.
1933
+ - `SettingsRowControl` (function): function SettingsRowControl({ row, itemProps }: { row: SettingsRow; itemProps?: MenuNavItemProps }): ReactNode — Render the control a {@link SettingsRow} kind asks for. `itemProps` carries `useMenuNav` focus tracking from {@link SettingsSection}.
1934
+ - `SettingsSection` (function): function SettingsSection({ category, heading = true, capture = true, enabled = true, initialId, className, style, onCaptureChange, }: SettingsSectionProps): ReactNode — One settings category as a block: its rows, then its rebindable actions with click-to-capture. The game picks where it sits and how wide it is; the block reads HudTheme tokens and the live rows the settings controller hands it, and writes every change straight back to that controller's store.
1928
1935
  - `SettingsSectionProps` (interface): interface SettingsSectionProps — How a {@link SettingsSection} handles a rebind capture.
1929
- - `SettingsSelect` (function): function SettingsSelect({ row }: { row: SettingsRow }): ReactNode — One choice row from a {@link SettingsRow}, rendered as a pressed-state option group.
1930
- - `SettingsSlider` (function): function SettingsSlider({ row }: { row: SettingsRow }): ReactNode — One slider row from a {@link SettingsRow}: mirrors the thumb locally and commits on release, so a drag never storms the settings store or the graphics rebuild it drives.
1936
+ - `SettingsSelect` (function): function SettingsSelect({ row, itemProps }: { row: SettingsRow; itemProps?: MenuNavItemProps }): ReactNode — One choice row from a {@link SettingsRow}, rendered as a pressed-state option group.
1937
+ - `SettingsSlider` (function): function SettingsSlider({ row, itemProps }: { row: SettingsRow; itemProps?: MenuNavItemProps }): ReactNode — One slider row from a {@link SettingsRow}: mirrors the thumb locally and commits on release, so a drag never storms the settings store or the graphics rebuild it drives.
1931
1938
  - `SettingsTabs` (function): function SettingsTabs({ categories, activeId, onSelect, leading, tabProps, className, style }: SettingsTabsProps): ReactNode — The category tab rail for a settings screen: a scrollable row of buttons with pressed state. The game owns the surrounding layout; this block keeps every category reachable on narrow screens.
1932
1939
  - `SettingsTabsProps` (interface): interface SettingsTabsProps — The live category tabs for a settings screen: a scrollable rail with pressed state.
1933
- - `SettingsToggle` (function): function SettingsToggle({ row }: { row: SettingsRow }): ReactNode — One on/off row from a {@link SettingsRow}.
1940
+ - `SettingsToggle` (function): function SettingsToggle({ row, itemProps }: { row: SettingsRow; itemProps?: MenuNavItemProps }): ReactNode — One on/off row from a {@link SettingsRow}.
1941
+ - `adjustSettingRow` (function): function adjustSettingRow(row: SettingsRow, delta: -1 | 1): SettingValue | undefined — One adjustment of a settings row: a slider moves by its step inside its bounds, a toggle flips, a select moves one option (wrapping at both ends). Returns the value to write, or `undefined` when the kind has nothing to step. Shared by the keyboard and the gamepad so both change a row the same way.
1934
1942
 
1935
1943
  ## @jgengine/react/shopGrid
1936
1944
 
@@ -3654,9 +3662,9 @@
3654
3662
  - `ProjectileTracers` (function): function ProjectileTracers({ lifeMs = 130 }: { lifeMs?: number }): React.JSX.Element — ⚠ undocumented
3655
3663
  - `Reticle` (function): function Reticle({ className }: { className?: string }): React.JSX.Element — ⚠ undocumented
3656
3664
  - `WorldBarSample` (interface): interface WorldBarSample { entityId?: string; x: number; y: number; percent: number; pxPerMeter?: number } — ⚠ undocumented
3657
- - `WorldEntityBars` (function): function WorldEntityBars({ statId, height = 2.2, roles, resolveRole, maxDistance = 60, occlude = true, tickMs = 80, maxSamples = 64, }: { statId: string; height?: number; roles?: readonly CatalogEntityRole[]; resolveRole?: (entity: SceneEntity) => CatalogEntityRole | undefined; /** Hide bars beyond … — ⚠ undocumented
3665
+ - `WorldEntityBars` (function): function WorldEntityBars({ statId, height = 2.2, roles, resolveRole, maxDistance, occlude = true, tickMs = 80, maxSamples = 64, }: { statId: string; height?: number; roles?: readonly CatalogEntityRole[]; resolveRole?: (entity: SceneEntity) => CatalogEntityRole | undefined; /** Hide bars beyond this … — ⚠ undocumented
3658
3666
  - `WorldFloatText` (function): function WorldFloatText({ height = 1.9, lifeMs = 950 }: { height?: number; lifeMs?: number }): React.JSX.Element — ⚠ undocumented
3659
- - `WorldNameplates` (function): function WorldNameplates({ statId = "health", height = 2.3, roles, resolveRole, maxDistance = 40, occlude = true, tickMs = 120, maxSamples = 64, resolveName, showHealth = true, className, nameplateClassName, nameClassName, barClassName, fillClassName, renderNameplate, }: WorldNameplatesProps): React… — Billboarded name + 78×6px HP bar over every nearby non-local entity that passes `roles`/`maxDistance` — headless (className/data-* slots on every part, `renderNameplate` for a full swap), turned on declaratively via `defineGame({ nameplates })` rather than mounted by hand.
3667
+ - `WorldNameplates` (function): function WorldNameplates({ statId = "health", height = 2.3, roles, resolveRole, maxDistance, occlude = true, tickMs = 120, maxSamples = 64, resolveName, showHealth = true, className, nameplateClassName, nameClassName, barClassName, fillClassName, renderNameplate, }: WorldNameplatesProps): React.JSX.… — Billboarded name + 78×6px HP bar over every nearby non-local entity that passes `roles`/`maxDistance` — headless (className/data-* slots on every part, `renderNameplate` for a full swap), turned on declaratively via `defineGame({ nameplates })` rather than mounted by hand.
3660
3668
  - `WorldNameplatesProps` (interface): interface WorldNameplatesProps — Props for `WorldNameplates` — entity filter, refresh rate, and headless className/render hooks.
3661
3669
  - `WorldObjectHighlights` (function): function WorldObjectHighlights({ color = "#facc15", radius, y = 0.05 }: WorldObjectHighlightsProps): React.JSX.Element — Ground ring over every `ctx.scene.object.selection`-ed placed object — the object-layer counterpart to `WorldEntityBars`/`WorldNameplates`. Mount it once in the game's scene (headless: no defaults are imposed beyond a visible ring) instead of hand-rolling a selection highlight through `WorldOverlay` against external state.
3662
3670
  - `WorldObjectHighlightsProps` (interface): interface WorldObjectHighlightsProps — Props for {@link WorldObjectHighlights}.
@@ -4,7 +4,7 @@
4
4
 
5
5
  Reach for these before hand-rolling. Each row is *the thing you need* → *the primitive that already does it*.
6
6
 
7
- ## accessibility-provider — apply accessibility preferences to a subtree — text-scale CSS var, reduced-motion/high-contrast/colorblind/captions data attrs, and the colorblind color-matrix filter
7
+ ## accessibility-provider — apply accessibility preferences to a subtree — text-scale CSS var and reduced-motion/high-contrast/colorblind/captions data attrs
8
8
 
9
9
  - `AccessibilityProvider` (function) · `import { AccessibilityProvider } from "@jgengine/react"`
10
10
 
@@ -52,6 +52,10 @@ Reach for these before hand-rolling. Each row is *the thing you need* → *the p
52
52
 
53
53
  - `ActionTooltip` (function) · `import { ActionTooltip } from "@jgengine/react"`
54
54
 
55
+ ## adjust-setting-row — step a settings row value once, for keyboard and gamepad alike
56
+
57
+ - `adjustSettingRow` (function) · `import { adjustSettingRow } from "@jgengine/react/settingsBlocks"`
58
+
55
59
  ## animation — turn a killed rigged character into a physics ragdoll on death
56
60
 
57
61
  - `useRagdoll` (function) · `import { useRagdoll } from "@jgengine/shell/render/useRagdoll"`
@@ -159,6 +163,18 @@ Reach for these before hand-rolling. Each row is *the thing you need* → *the p
159
163
 
160
164
  - `ColorblindFilters` (function) · `import { ColorblindFilters } from "@jgengine/react"`
161
165
 
166
+ ## colorblind-frame — apply the player's colour-vision mode to the 3D frame alone, leaving HUD and menu text unfiltered
167
+
168
+ - `ColorblindFrame` (function) · `import { ColorblindFrame } from "@jgengine/react"`
169
+
170
+ ## colorblind-frame-filter — resolve a colorblind mode to the CSS filter for the 3D frame alone
171
+
172
+ - `colorblindFrameFilter` (function) · `import { colorblindFrameFilter } from "@jgengine/core/ui/accessibility"`
173
+
174
+ ## colorblind-frame-mode — read the live colour-vision mode from the settings store that the settings rows write
175
+
176
+ - `useColorblindMode` (function) · `import { useColorblindMode } from "@jgengine/react"`
177
+
162
178
  ## combo-meter-hud — drop-in HUD for a core combo meter — big live count, free-string tier label, derived multiplier, and a draining decay-window bar, per-tier colored from a caller map and HudTheme-token skinnable
163
179
 
164
180
  - `ComboMeterHud` (function) · `import { ComboMeterHud } from "@jgengine/react"`
@@ -758,7 +774,7 @@ Reach for these before hand-rolling. Each row is *the thing you need* → *the p
758
774
 
759
775
  - `SettingsKeybindControl` (function) · `import { SettingsKeybindControl } from "@jgengine/react/settingsBlocks"`
760
776
 
761
- ## settings-section — settings category block — rows plus click-to-capture keybind rows, HudTheme-styled, store-backed
777
+ ## settings-section — settings category block — rows plus click-to-capture keybind rows, HudTheme-styled, store-backed, keyboard and gamepad navigable
762
778
 
763
779
  - `SettingsSection` (function) · `import { SettingsSection } from "@jgengine/react/settingsBlocks"`
764
780
 
@@ -279,9 +279,9 @@ HUD-only games (`presentation: "hud"` or a camera with rig `"none"`) use the sam
279
279
 
280
280
  **Present it any way you want.** `useSettings()` (`@jgengine/react`) returns the live controller — `{ categories, actions, variant, surface, isOpen, open, close, setOpen }` — so a game can drive its own pause-menu button, or render `categories`/`actions` (rows carry `value`/`set`/bounds, keybinds carry `rebind`/`reset`) entirely inside its own HUD. `useHasSettings()` gates a custom entry; `useSetting(id, fallback)` reads/writes one value. Set a slider's `min`/`max` explicitly — an omitted range collapses the thumb to 0/1.
281
281
 
282
- **Composing your own settings layout.** `SettingsSection` (`@jgengine/react/settingsBlocks`) renders one controller category — its rows, then its click-to-capture keybind rows — and `SettingsTabs` renders the category rail (pass `tabProps` from `useMenuNav` so a gamepad can switch categories). `SettingsRowControl`, `SettingsSlider`, `SettingsToggle`, `SettingsSelect` and `SettingsKeybindControl` are the individual rows if the layout is not a list. All of them read `--jg-*` tokens and write through the controller, so the game's rebinds land in the same keybinding store as the stock menu. The stock menu uses these same blocks, so a game-composed screen and the engine dialog cannot drift.
282
+ **Composing your own settings layout.** `SettingsSection` (`@jgengine/react/settingsBlocks`) renders one controller category - its rows, then its click-to-capture keybind rows - as one `useMenuNav` menu: the d-pad and left stick move between rows, left/right step the focused value (slider by its step, toggle flipped, select to the next option), and confirm arms a keybind. Arrow keys take the same `onAdjust` path, so a keyboard and a pad cannot diverge. `SettingsTabs` renders the category rail (pass `tabProps` from `useMenuNav` so a gamepad can switch categories). `SettingsRowControl`, `SettingsSlider`, `SettingsToggle`, `SettingsSelect` and `SettingsKeybindControl` are the individual rows if the layout is not a list; pass the `itemProps` they accept so focus tracking still works. All of them read `--jg-*` tokens and write through the controller, so the game's rebinds land in the same keybinding store as the stock menu. The stock menu uses these same blocks, so a game-composed screen and the engine dialog cannot drift.
283
283
 
284
- **Accessibility is a shipped category.** The settings menu includes `accessibility` — text size, colourblind palette, reduce motion, high contrast, captions and hold-or-toggle — unless the game hides it. Text size multiplies every `--jg-type-*` token through `--jg-text-scale`, reduce motion zeroes `--jg-motion-scale`, and high contrast/colourblind/captions set `data-*` hooks on the HUD root; read them with `useAccessibilitySettings()` and `useHoldVsToggle()` for a custom layout. Apply the colourblind matrix (`ColorblindFilters`, `@jgengine/react/accessibility`) around the surface that should be daltonized — the shell sets the hook but does not wrap the 3D canvas.
284
+ **Accessibility is a shipped category.** The settings menu includes `accessibility` — text size, colourblind palette, reduce motion, high contrast, captions and hold-or-toggle — unless the game hides it. Text size multiplies every `--jg-type-*` token through `--jg-text-scale`, reduce motion zeroes `--jg-motion-scale`, and high contrast/colourblind/captions set `data-*` hooks on the HUD root; read them with `useAccessibilitySettings()` and `useHoldVsToggle()` for a custom layout. The 3D shell applies the colourblind matrix to its canvas alone with `ColorblindFrame` (`@jgengine/react/accessibility`), so HUD and menu text keep the contrast the rest of the UI assumes. A custom presentation wraps its own canvas in `ColorblindFrame` with `useColorblindMode(store)`; wrapping the HUD or menu root instead recolours the text, which is why `AccessibilityProvider` sets `data-colorblind` and leaves the filtering to the frame.
285
285
 
286
286
  ## 8. Motion and game feel
287
287