@jgengine/react 0.14.0 → 0.15.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 (96) hide show
  1. package/CHANGELOG.md +500 -1
  2. package/dist/accessibility.d.ts +39 -0
  3. package/dist/accessibility.js +61 -0
  4. package/dist/achievements.d.ts +50 -0
  5. package/dist/achievements.js +94 -0
  6. package/dist/cameraShake.d.ts +52 -0
  7. package/dist/cameraShake.js +53 -0
  8. package/dist/coachMarks.d.ts +72 -0
  9. package/dist/coachMarks.js +136 -0
  10. package/dist/codex.d.ts +25 -0
  11. package/dist/codex.js +62 -0
  12. package/dist/comboMeter.d.ts +63 -0
  13. package/dist/comboMeter.js +93 -0
  14. package/dist/components.js +4 -3
  15. package/dist/creditsScreen.d.ts +19 -0
  16. package/dist/creditsScreen.js +31 -0
  17. package/dist/cutscene.d.ts +66 -0
  18. package/dist/cutscene.js +132 -0
  19. package/dist/damageDirection.d.ts +51 -0
  20. package/dist/damageDirection.js +103 -0
  21. package/dist/dialogueView.d.ts +69 -0
  22. package/dist/dialogueView.js +111 -0
  23. package/dist/entityFrames.d.ts +114 -0
  24. package/dist/entityFrames.js +55 -0
  25. package/dist/fastTravel.d.ts +36 -0
  26. package/dist/fastTravel.js +103 -0
  27. package/dist/floatingText.d.ts +84 -0
  28. package/dist/floatingText.js +61 -0
  29. package/dist/gameViewport.js +8 -12
  30. package/dist/hooks.d.ts +42 -0
  31. package/dist/hooks.js +85 -0
  32. package/dist/hudTheme.d.ts +104 -7
  33. package/dist/hudTheme.js +300 -2
  34. package/dist/i18n.d.ts +29 -0
  35. package/dist/i18n.js +44 -0
  36. package/dist/index.d.ts +34 -0
  37. package/dist/index.js +34 -0
  38. package/dist/interactionPrompt.d.ts +80 -0
  39. package/dist/interactionPrompt.js +112 -0
  40. package/dist/keybindingMenu.d.ts +59 -0
  41. package/dist/keybindingMenu.js +146 -0
  42. package/dist/killFeed.d.ts +63 -0
  43. package/dist/killFeed.js +88 -0
  44. package/dist/map.d.ts +123 -2
  45. package/dist/map.js +239 -36
  46. package/dist/menuRouter.d.ts +51 -0
  47. package/dist/menuRouter.js +49 -0
  48. package/dist/modals.d.ts +131 -0
  49. package/dist/modals.js +168 -0
  50. package/dist/notifications.d.ts +39 -0
  51. package/dist/notifications.js +111 -0
  52. package/dist/objectiveBanner.d.ts +62 -0
  53. package/dist/objectiveBanner.js +129 -0
  54. package/dist/photoMode.d.ts +21 -0
  55. package/dist/photoMode.js +39 -0
  56. package/dist/previewFixtures.d.ts +30 -0
  57. package/dist/previewFixtures.js +46 -0
  58. package/dist/questTracker.d.ts +24 -0
  59. package/dist/questTracker.js +31 -0
  60. package/dist/quickMenu.d.ts +39 -0
  61. package/dist/quickMenu.js +79 -0
  62. package/dist/radialMenu.d.ts +50 -0
  63. package/dist/radialMenu.js +69 -0
  64. package/dist/saveSlots.d.ts +81 -0
  65. package/dist/saveSlots.js +138 -0
  66. package/dist/scoreboard.d.ts +67 -0
  67. package/dist/scoreboard.js +71 -0
  68. package/dist/settings.d.ts +9 -3
  69. package/dist/settings.js +10 -4
  70. package/dist/shopGrid.d.ts +70 -0
  71. package/dist/shopGrid.js +129 -0
  72. package/dist/statusEffectBar.d.ts +52 -0
  73. package/dist/statusEffectBar.js +57 -0
  74. package/dist/streetGeometryPreview.d.ts +7 -0
  75. package/dist/streetGeometryPreview.js +119 -0
  76. package/dist/talentTree.d.ts +60 -0
  77. package/dist/talentTree.js +217 -0
  78. package/dist/timerReadout.d.ts +90 -0
  79. package/dist/timerReadout.js +111 -0
  80. package/dist/useDebouncedCommit.d.ts +54 -0
  81. package/dist/useDebouncedCommit.js +81 -0
  82. package/dist/waveHud.d.ts +49 -0
  83. package/dist/waveHud.js +53 -0
  84. package/dist/waypointMarkers.d.ts +80 -0
  85. package/dist/waypointMarkers.js +116 -0
  86. package/package.json +3 -3
  87. package/skills/ce-handoff/SKILL.md +149 -0
  88. package/skills/jgengine/SKILL.md +3 -1
  89. package/skills/jgengine/api.md +17 -7
  90. package/skills/jgengine/capabilities.md +12 -0
  91. package/skills/jgengine/recipes/minimal-game.md +7 -4
  92. package/skills/jgengine-ui/SKILL.md +5 -3
  93. package/skills/jgengine-ui/api.md +708 -19
  94. package/skills/jgengine-ui/capabilities.md +362 -1
  95. package/skills/jgengine-ui/recipes/overhead-entity-frames.md +136 -0
  96. package/skills/jgengine-ui/reference.md +7 -4
@@ -219,18 +219,14 @@ export function GameViewportProvider({ platforms, className, style, children, })
219
219
  }, [regions, metrics.layout, visual, vw, vh, coarsePointer, mobileSupported, safeArea]);
220
220
  const collisions = useMemo(() => detectLayoutCollisions(regions), [regions]);
221
221
  const forbidden = useMemo(() => collisions.filter((c) => c.severity === "forbid"), [collisions]);
222
- useEffect(() => {
223
- const root = rootRef.current;
224
- if (root === null)
225
- return;
226
- if (forbidden.length === 0) {
227
- root.removeAttribute("data-jg-layout-collision");
228
- return;
222
+ const collisionReport = forbidden.length === 0 ? undefined : JSON.stringify(forbidden);
223
+ const lastWarnedRef = useRef(undefined);
224
+ if (collisionReport !== lastWarnedRef.current) {
225
+ lastWarnedRef.current = collisionReport;
226
+ if (collisionReport !== undefined) {
227
+ console.warn(`[jgengine] ${formatLayoutCollisions(forbidden)}\n mode=${layout.mode} viewport=${Math.round(vw)}x${Math.round(vh)} safe=${safeArea.top}/${safeArea.right}/${safeArea.bottom}/${safeArea.left}`);
229
228
  }
230
- const report = JSON.stringify(forbidden);
231
- root.setAttribute("data-jg-layout-collision", report);
232
- console.warn(`[jgengine] ${formatLayoutCollisions(forbidden)}\n mode=${layout.mode} viewport=${Math.round(vw)}x${Math.round(vh)} safe=${safeArea.top}/${safeArea.right}/${safeArea.bottom}/${safeArea.left}`);
233
- }, [forbidden, layout.mode, vw, vh, safeArea]);
229
+ }
234
230
  useEffect(() => {
235
231
  const ids = new Set();
236
232
  for (const collision of forbidden) {
@@ -265,7 +261,7 @@ export function GameViewportProvider({ platforms, className, style, children, })
265
261
  "--jg-safe-left": `${safeArea.left}px`,
266
262
  ...style,
267
263
  };
268
- return (_jsx(RegionRegistryContext.Provider, { value: registry, children: _jsx(GameLayoutContext.Provider, { value: layout, children: _jsxs("div", { ref: rootRef, "data-jg-viewport": "", "data-jg-layout-mode": layout.mode, "data-jg-orientation": layout.orientation, className: className, style: rootStyle, children: [_jsx("div", { ref: probeRef, "aria-hidden": true, style: SAFE_AREA_PROBE_STYLE }), children] }) }) }));
264
+ return (_jsx(RegionRegistryContext.Provider, { value: registry, children: _jsx(GameLayoutContext.Provider, { value: layout, children: _jsxs("div", { ref: rootRef, "data-jg-viewport": "", "data-jg-layout-mode": layout.mode, "data-jg-orientation": layout.orientation, "data-jg-layout-collision": collisionReport, className: className, style: rootStyle, children: [_jsx("div", { ref: probeRef, "aria-hidden": true, style: SAFE_AREA_PROBE_STYLE }), children] }) }) }));
269
265
  }
270
266
  /** The live shared viewport layout. Returns a neutral default outside a `GameViewportProvider` so it never throws in previews. */
271
267
  export function useGameViewportLayout() {
package/dist/hooks.d.ts CHANGED
@@ -175,4 +175,46 @@ export interface UseAxisChannelResult {
175
175
  * constant at the call site) unless a rebind is intended.
176
176
  */
177
177
  export declare function useAxisChannel(config: AxisChannelConfig): UseAxisChannelResult;
178
+ /**
179
+ * Re-render at a steady rate. Returns a monotonically increasing tick count driven by a
180
+ * setInterval, for HUD elements that display wall-clock-derived values (cooldowns, cast bars,
181
+ * swing timers) without an engine subscription to hang off. `hz <= 0` disables the ticker.
182
+ *
183
+ * @capability hud-ticker re-render a HUD element at a steady hz for time-derived readouts (cooldowns, cast/swing bars) — no hand-rolled setInterval effects
184
+ */
185
+ export declare function useTicker(hz?: number): number;
186
+ /** Minimal add/removeEventListener surface accepted by useDomEvent (window, document, elements). */
187
+ export interface DomEventTarget {
188
+ addEventListener(type: string, listener: (event: never) => void, options?: unknown): void;
189
+ removeEventListener(type: string, listener: (event: never) => void, options?: unknown): void;
190
+ }
191
+ /**
192
+ * Attach a DOM event listener with automatic cleanup. `resolveTarget` runs inside the effect, so
193
+ * `() => window` and ref-reading resolvers are SSR-safe; return null to skip attaching. The
194
+ * handler is kept in a ref, so a fresh closure per render never re-binds the listener.
195
+ * Re-binds only when `type` or `options.capture`/`options.passive` change.
196
+ *
197
+ * @capability dom-event attach a window/document/element event listener with automatic cleanup and a stable handler ref — no hand-rolled addEventListener effects
198
+ */
199
+ export declare function useDomEvent<E>(resolveTarget: () => DomEventTarget | null, type: string, handler: (event: E) => void, options?: {
200
+ capture?: boolean;
201
+ passive?: boolean;
202
+ }): void;
203
+ /**
204
+ * Run a requestAnimationFrame loop while `active`, calling `onFrame(deltaSeconds)` each frame.
205
+ * The callback is kept in a ref so re-renders never restart the loop; cleanup cancels the
206
+ * pending frame. For scene-graph work inside a canvas prefer R3F's useFrame — this is for
207
+ * DOM-side animation outside the renderer.
208
+ *
209
+ * @capability raf-loop run a cancellable requestAnimationFrame loop for DOM-side animation with delta seconds — no hand-rolled RAF effects
210
+ */
211
+ export declare function useRafLoop(onFrame: (deltaSeconds: number) => void, active?: boolean): void;
212
+ /**
213
+ * Pin a scrollable element to its bottom whenever `dep` changes (typically a length or the list
214
+ * itself). Attach the returned ref to the scroll container. Owns the log/chat/console
215
+ * scroll-to-bottom effect so panels don't hand-roll it.
216
+ *
217
+ * @capability auto-scroll pin a log/chat/console panel to its newest line as entries arrive — no hand-rolled scrollTop effects
218
+ */
219
+ export declare function useAutoScroll<T extends HTMLElement>(dep: unknown): import("react").RefObject<T | null>;
178
220
  export {};
package/dist/hooks.js CHANGED
@@ -375,3 +375,88 @@ export function useAxisChannel(config) {
375
375
  const channel = useMemo(() => new AxisChannel(config), [config]);
376
376
  return { channel, isDown };
377
377
  }
378
+ /**
379
+ * Re-render at a steady rate. Returns a monotonically increasing tick count driven by a
380
+ * setInterval, for HUD elements that display wall-clock-derived values (cooldowns, cast bars,
381
+ * swing timers) without an engine subscription to hang off. `hz <= 0` disables the ticker.
382
+ *
383
+ * @capability hud-ticker re-render a HUD element at a steady hz for time-derived readouts (cooldowns, cast/swing bars) — no hand-rolled setInterval effects
384
+ */
385
+ export function useTicker(hz = 10) {
386
+ const [tick, setTick] = useState(0);
387
+ useEffect(() => {
388
+ if (!Number.isFinite(hz) || hz <= 0)
389
+ return undefined;
390
+ const timer = setInterval(() => setTick((value) => value + 1), 1000 / hz);
391
+ return () => clearInterval(timer);
392
+ }, [hz]);
393
+ return tick;
394
+ }
395
+ /**
396
+ * Attach a DOM event listener with automatic cleanup. `resolveTarget` runs inside the effect, so
397
+ * `() => window` and ref-reading resolvers are SSR-safe; return null to skip attaching. The
398
+ * handler is kept in a ref, so a fresh closure per render never re-binds the listener.
399
+ * Re-binds only when `type` or `options.capture`/`options.passive` change.
400
+ *
401
+ * @capability dom-event attach a window/document/element event listener with automatic cleanup and a stable handler ref — no hand-rolled addEventListener effects
402
+ */
403
+ export function useDomEvent(resolveTarget, type, handler, options) {
404
+ const handlerRef = useRef(handler);
405
+ handlerRef.current = handler;
406
+ const capture = options?.capture ?? false;
407
+ const passive = options?.passive;
408
+ useEffect(() => {
409
+ const target = resolveTarget();
410
+ if (target === null)
411
+ return undefined;
412
+ const listener = (event) => handlerRef.current(event);
413
+ const listenerOptions = passive === undefined ? { capture } : { capture, passive };
414
+ target.addEventListener(type, listener, listenerOptions);
415
+ return () => target.removeEventListener(type, listener, listenerOptions);
416
+ // resolveTarget is intentionally excluded: it is expected to be an inline closure over a
417
+ // stable target (window, document, a ref) and re-running on its identity would re-bind
418
+ // every render.
419
+ // eslint-disable-next-line react-hooks/exhaustive-deps
420
+ }, [type, capture, passive]);
421
+ }
422
+ /**
423
+ * Run a requestAnimationFrame loop while `active`, calling `onFrame(deltaSeconds)` each frame.
424
+ * The callback is kept in a ref so re-renders never restart the loop; cleanup cancels the
425
+ * pending frame. For scene-graph work inside a canvas prefer R3F's useFrame — this is for
426
+ * DOM-side animation outside the renderer.
427
+ *
428
+ * @capability raf-loop run a cancellable requestAnimationFrame loop for DOM-side animation with delta seconds — no hand-rolled RAF effects
429
+ */
430
+ export function useRafLoop(onFrame, active = true) {
431
+ const frameRef = useRef(onFrame);
432
+ frameRef.current = onFrame;
433
+ useEffect(() => {
434
+ if (!active || typeof requestAnimationFrame !== "function")
435
+ return undefined;
436
+ let handle = 0;
437
+ let last = performance.now();
438
+ const step = (now) => {
439
+ frameRef.current(Math.max(0, now - last) / 1000);
440
+ last = now;
441
+ handle = requestAnimationFrame(step);
442
+ };
443
+ handle = requestAnimationFrame(step);
444
+ return () => cancelAnimationFrame(handle);
445
+ }, [active]);
446
+ }
447
+ /**
448
+ * Pin a scrollable element to its bottom whenever `dep` changes (typically a length or the list
449
+ * itself). Attach the returned ref to the scroll container. Owns the log/chat/console
450
+ * scroll-to-bottom effect so panels don't hand-roll it.
451
+ *
452
+ * @capability auto-scroll pin a log/chat/console panel to its newest line as entries arrive — no hand-rolled scrollTop effects
453
+ */
454
+ export function useAutoScroll(dep) {
455
+ const ref = useRef(null);
456
+ useEffect(() => {
457
+ const node = ref.current;
458
+ if (node !== null)
459
+ node.scrollTop = node.scrollHeight;
460
+ }, [dep]);
461
+ return ref;
462
+ }
@@ -1,27 +1,73 @@
1
1
  import { type CSSProperties } from "react";
2
2
  /**
3
- * `HudTheme` (#1034): one token object that restyles shared token-driven building blocks at once.
4
- * It is the superset of the atomic-bar tokens (#1033) — `hudThemeVars(theme)` emits the same
5
- * `--jg-*` custom properties the bars already read, plus frame / slot / minimap-ring tokens — so
6
- * setting a **game-authored** theme on any HUD ancestor re-skins those primitives. Purely CSS-token
3
+ * `HudTheme` (#1034, #1573): one token object that restyles every shared token-driven building
4
+ * block at once — the atomic bars (#1033), frames, slots, the minimap ring, and the chrome the
5
+ * registry blocks read (surfaces, edges, text, status colors, fonts, rarity). `hudThemeVars(theme)`
6
+ * emits the whole `--jg-*` vocabulary, so setting a **game-authored** theme on any HUD ancestor
7
+ * re-skins `packages/react` primitives and `registry/jgengine` components together. Purely CSS-token
7
8
  * driven (no image assets). Tokens are not a finished UI: every game still owns composition, skin,
8
9
  * and art direction (see AGENTS.md). Built-in presets are demo/scaffold starting points only.
9
10
  *
10
- * @capability hud-theme game-authored token object for bars, frames, slots, and minimap ring
11
+ * @capability hud-theme game-authored token object covering bars, frames, slots, chrome, text, status colors, fonts, and rarity
11
12
  */
12
- /** Per-readout fill colors plus a shared accent/glow. */
13
+ /** Per-readout fill colors plus a shared accent/glow. Each `*Deep` is the shaded companion fill. */
13
14
  export interface HudThemePalette {
14
15
  health: string;
15
16
  healthLow: string;
17
+ healthDeep: string;
16
18
  mana: string;
19
+ manaDeep: string;
17
20
  stamina: string;
21
+ staminaDeep: string;
18
22
  shield: string;
23
+ shieldDeep: string;
19
24
  xp: string;
25
+ xpDeep: string;
20
26
  soul: string;
21
27
  ammo: string;
22
28
  boss: string;
23
29
  /** Accent used for glow/keycap/active states. */
24
30
  accent: string;
31
+ /** Shaded accent for gradients and pressed states. */
32
+ accentDeep: string;
33
+ /** Translucent accent for outer glows. */
34
+ accentGlow: string;
35
+ /** Soft accent wash for large background gradients. */
36
+ accentDim: string;
37
+ }
38
+ /** Chrome surfaces, edges, and text — what panels, overlays, and menus are built out of. */
39
+ export interface HudThemeSurface {
40
+ surface: string;
41
+ surfaceDeep: string;
42
+ edge: string;
43
+ edgeBright: string;
44
+ text: string;
45
+ textDim: string;
46
+ /** Full-screen dim behind modals and overlays. */
47
+ backdrop: string;
48
+ }
49
+ /** Semantic status colors — outcome, threat, and relationship coloring. */
50
+ export interface HudThemeStatus {
51
+ danger: string;
52
+ warning: string;
53
+ success: string;
54
+ hostile: string;
55
+ friendly: string;
56
+ neutral: string;
57
+ }
58
+ /** Typography roles. Any CSS `font-family` list. */
59
+ export interface HudThemeFont {
60
+ display: string;
61
+ numeric: string;
62
+ body: string;
63
+ }
64
+ /** Item-rarity coloring shared by loot cards, tooltips, and inventory slots. */
65
+ export interface HudThemeRarity {
66
+ common: string;
67
+ uncommon: string;
68
+ rare: string;
69
+ epic: string;
70
+ legendary: string;
25
71
  }
26
72
  /** Frame material + shape tokens (panels, `HudFrame variation="themed"`). */
27
73
  export interface HudThemeFrame {
@@ -48,9 +94,13 @@ export interface HudThemeSlot {
48
94
  border: string;
49
95
  radius: string;
50
96
  }
51
- /** A full HUD theme. */
97
+ /** A full HUD theme. Author one with {@link hudTheme} or {@link deriveHudTheme}. */
52
98
  export interface HudTheme {
53
99
  palette: HudThemePalette;
100
+ surface: HudThemeSurface;
101
+ status: HudThemeStatus;
102
+ font: HudThemeFont;
103
+ rarity: HudThemeRarity;
54
104
  frame: HudThemeFrame;
55
105
  bar: HudThemeBar;
56
106
  slot: HudThemeSlot;
@@ -61,6 +111,26 @@ export interface HudTheme {
61
111
  export declare function hudThemeVars(theme: HudTheme): CSSProperties;
62
112
  /** Default theme tokens for previews/scaffold — not a finished game look; author a custom theme. */
63
113
  export declare const defaultHudTheme: HudTheme;
114
+ /** Per-group partial overrides accepted by {@link hudTheme}. */
115
+ export interface HudThemeOverrides {
116
+ palette?: Partial<HudThemePalette>;
117
+ surface?: Partial<HudThemeSurface>;
118
+ status?: Partial<HudThemeStatus>;
119
+ font?: Partial<HudThemeFont>;
120
+ rarity?: Partial<HudThemeRarity>;
121
+ frame?: Partial<HudThemeFrame>;
122
+ bar?: Partial<HudThemeBar>;
123
+ slot?: Partial<HudThemeSlot>;
124
+ ring?: string;
125
+ }
126
+ /**
127
+ * Builds a full {@link HudTheme} from per-group partials, filling the rest from
128
+ * {@link defaultHudTheme}. Reach for this when a game wants to name exact values; use
129
+ * {@link deriveHudTheme} when a seed color pair is enough.
130
+ *
131
+ * @capability hud-theme-author fill a full HudTheme from per-group partial overrides
132
+ */
133
+ export declare function hudTheme(overrides: HudThemeOverrides): HudTheme;
64
134
  /** Built-in theme presets for demos/scaffold only — not product identity; games author their own theme. */
65
135
  export declare const HUD_THEME_PRESETS: {
66
136
  "arcane-stone": HudTheme;
@@ -69,8 +139,35 @@ export declare const HUD_THEME_PRESETS: {
69
139
  "sleek-hex": HudTheme;
70
140
  "elemental-circle": HudTheme;
71
141
  "sandbox-slot": HudTheme;
142
+ ember: HudTheme;
143
+ synthwave: HudTheme;
144
+ fieldkit: HudTheme;
72
145
  };
73
146
  /** Built-in demo/scaffold theme preset name — not a game identity. */
74
147
  export type HudThemePreset = keyof typeof HUD_THEME_PRESETS;
148
+ /** Seed colors for {@link deriveHudTheme}. Only `accent` and `surface` are required. */
149
+ export interface HudThemeSeed {
150
+ accent: string;
151
+ surface: string;
152
+ text?: string;
153
+ health?: string;
154
+ mana?: string;
155
+ stamina?: string;
156
+ xp?: string;
157
+ shield?: string;
158
+ danger?: string;
159
+ warning?: string;
160
+ friendly?: string;
161
+ fontDisplay?: string;
162
+ }
163
+ /**
164
+ * Derives a coherent full theme from an accent/surface pair — shades, edges, dim text, frames,
165
+ * bar troughs, and slots all fall out of the seed, so a game gets one consistent look across the
166
+ * `packages/react` primitives and the `registry/jgengine` blocks without naming 50 tokens.
167
+ * Hex seeds only (`#rgb` or `#rrggbb`).
168
+ *
169
+ * @capability hud-theme-derive build a full HudTheme from an accent + surface seed pair
170
+ */
171
+ export declare function deriveHudTheme(seed: HudThemeSeed): HudTheme;
75
172
  /** Resolves a preset name (or a full theme) to a `HudTheme`; falls back to the default theme. */
76
173
  export declare function resolveHudTheme(theme?: HudThemePreset | HudTheme): HudTheme;
package/dist/hudTheme.js CHANGED
@@ -1,15 +1,20 @@
1
1
  import {} from "react";
2
2
  /** Emits the CSS custom properties for a theme — spread onto any HUD ancestor to re-skin the subtree. */
3
3
  export function hudThemeVars(theme) {
4
- const { palette, frame, bar, slot } = theme;
4
+ const { palette, surface, status, font, rarity, frame, bar, slot } = theme;
5
5
  return {
6
6
  // atomic-bar tokens (same names the bars read — #1033)
7
7
  "--jg-health": palette.health,
8
8
  "--jg-health-low": palette.healthLow,
9
+ "--jg-health-deep": palette.healthDeep,
9
10
  "--jg-mana": palette.mana,
11
+ "--jg-mana-deep": palette.manaDeep,
10
12
  "--jg-stamina": palette.stamina,
13
+ "--jg-stamina-deep": palette.staminaDeep,
11
14
  "--jg-shield": palette.shield,
15
+ "--jg-shield-deep": palette.shieldDeep,
12
16
  "--jg-xp": palette.xp,
17
+ "--jg-xp-deep": palette.xpDeep,
13
18
  "--jg-soul": palette.soul,
14
19
  "--jg-ammo": palette.ammo,
15
20
  "--jg-boss": palette.boss,
@@ -21,6 +26,30 @@ export function hudThemeVars(theme) {
21
26
  "--jg-bar-text": bar.text,
22
27
  // shared chrome tokens
23
28
  "--jg-accent": palette.accent,
29
+ "--jg-accent-deep": palette.accentDeep,
30
+ "--jg-accent-glow": palette.accentGlow,
31
+ "--jg-accent-dim": palette.accentDim,
32
+ "--jg-surface": surface.surface,
33
+ "--jg-surface-deep": surface.surfaceDeep,
34
+ "--jg-edge": surface.edge,
35
+ "--jg-edge-bright": surface.edgeBright,
36
+ "--jg-text": surface.text,
37
+ "--jg-text-dim": surface.textDim,
38
+ "--jg-backdrop": surface.backdrop,
39
+ "--jg-danger": status.danger,
40
+ "--jg-warning": status.warning,
41
+ "--jg-success": status.success,
42
+ "--jg-hostile": status.hostile,
43
+ "--jg-friendly": status.friendly,
44
+ "--jg-neutral": status.neutral,
45
+ "--jg-font-display": font.display,
46
+ "--jg-font-numeric": font.numeric,
47
+ "--jg-font-body": font.body,
48
+ "--jg-rarity-common": rarity.common,
49
+ "--jg-rarity-uncommon": rarity.uncommon,
50
+ "--jg-rarity-rare": rarity.rare,
51
+ "--jg-rarity-epic": rarity.epic,
52
+ "--jg-rarity-legendary": rarity.legendary,
24
53
  "--jg-frame-bg": frame.bg,
25
54
  "--jg-frame-border": frame.border,
26
55
  "--jg-frame-radius": frame.radius,
@@ -44,6 +73,43 @@ export const defaultHudTheme = {
44
73
  ammo: "#f8b84e",
45
74
  boss: "#ef4444",
46
75
  accent: "#38bdf8",
76
+ healthDeep: "#7f1d1f",
77
+ manaDeep: "#1d4ed8",
78
+ staminaDeep: "#3f6212",
79
+ shieldDeep: "#0e7490",
80
+ xpDeep: "#6b21a8",
81
+ accentDeep: "#0b5f88",
82
+ accentGlow: "rgba(56, 189, 248, 0.5)",
83
+ accentDim: "rgba(56, 189, 248, 0.18)",
84
+ },
85
+ surface: {
86
+ surface: "#141820",
87
+ surfaceDeep: "#0a0c10",
88
+ edge: "#2c3340",
89
+ edgeBright: "#4a5566",
90
+ text: "#f5f7fa",
91
+ textDim: "#94a3b8",
92
+ backdrop: "rgba(4,7,12,0.62)",
93
+ },
94
+ status: {
95
+ danger: "#e5484d",
96
+ warning: "#f8b84e",
97
+ success: "#3fa34d",
98
+ hostile: "#e5484d",
99
+ friendly: "#38bdf8",
100
+ neutral: "#c9b458",
101
+ },
102
+ font: {
103
+ display: '"Segoe UI", system-ui, sans-serif',
104
+ numeric: 'Consolas, "Cascadia Mono", "SF Mono", "Roboto Mono", monospace',
105
+ body: '"Segoe UI", system-ui, sans-serif',
106
+ },
107
+ rarity: {
108
+ common: "#b4b2a8",
109
+ uncommon: "#57c14f",
110
+ rare: "#4a86d8",
111
+ epic: "#a04fd0",
112
+ legendary: "#e0862e",
47
113
  },
48
114
  frame: {
49
115
  bg: "linear-gradient(180deg, rgba(20,24,32,0.86), rgba(10,12,16,0.9))",
@@ -62,15 +128,73 @@ export const defaultHudTheme = {
62
128
  slot: { bg: "rgba(12,14,20,0.7)", border: "1px solid rgba(255,255,255,0.12)", radius: "8px" },
63
129
  ring: "rgba(140,170,200,0.35)",
64
130
  };
65
- function theme(overrides) {
131
+ /**
132
+ * Builds a full {@link HudTheme} from per-group partials, filling the rest from
133
+ * {@link defaultHudTheme}. Reach for this when a game wants to name exact values; use
134
+ * {@link deriveHudTheme} when a seed color pair is enough.
135
+ *
136
+ * @capability hud-theme-author fill a full HudTheme from per-group partial overrides
137
+ */
138
+ export function hudTheme(overrides) {
66
139
  return {
67
140
  palette: { ...defaultHudTheme.palette, ...overrides.palette },
141
+ surface: { ...defaultHudTheme.surface, ...overrides.surface },
142
+ status: { ...defaultHudTheme.status, ...overrides.status },
143
+ font: { ...defaultHudTheme.font, ...overrides.font },
144
+ rarity: { ...defaultHudTheme.rarity, ...overrides.rarity },
68
145
  frame: { ...defaultHudTheme.frame, ...overrides.frame },
69
146
  bar: { ...defaultHudTheme.bar, ...overrides.bar },
70
147
  slot: { ...defaultHudTheme.slot, ...overrides.slot },
71
148
  ring: overrides.ring ?? defaultHudTheme.ring,
72
149
  };
73
150
  }
151
+ const theme = hudTheme;
152
+ /** Builds a chrome-led preset: the named surface/edge values drive frames, troughs, and slots. */
153
+ function chrome(spec) {
154
+ return theme({
155
+ palette: {
156
+ health: spec.fills.health,
157
+ healthLow: spec.deeps.health,
158
+ healthDeep: spec.deeps.health,
159
+ mana: spec.fills.mana,
160
+ manaDeep: spec.deeps.mana,
161
+ stamina: spec.fills.stamina,
162
+ staminaDeep: spec.deeps.stamina,
163
+ shield: spec.fills.shield,
164
+ shieldDeep: spec.deeps.shield,
165
+ xp: spec.fills.xp,
166
+ xpDeep: spec.deeps.xp,
167
+ soul: mix(spec.fills.xp, "#ffffff", 0.25),
168
+ ammo: spec.status.warning,
169
+ boss: spec.status.danger,
170
+ accent: spec.accent,
171
+ accentDeep: spec.accentDeep,
172
+ accentGlow: spec.accentGlow,
173
+ accentDim: alpha(spec.accent, 0.18),
174
+ },
175
+ surface: {
176
+ surface: spec.surface,
177
+ surfaceDeep: spec.surfaceDeep,
178
+ edge: spec.edge,
179
+ edgeBright: spec.edgeBright,
180
+ text: spec.text,
181
+ textDim: spec.textDim,
182
+ backdrop: alpha(spec.surfaceDeep, 0.72),
183
+ },
184
+ status: spec.status,
185
+ font: { display: spec.display },
186
+ rarity: spec.rarity,
187
+ frame: {
188
+ bg: `linear-gradient(180deg, ${alpha(spec.surface, 0.92)}, ${alpha(spec.surfaceDeep, 0.94)})`,
189
+ border: `1px solid ${spec.edge}`,
190
+ radius: "6px",
191
+ glow: `0 6px 20px ${alpha(spec.surfaceDeep, 0.7)}`,
192
+ },
193
+ bar: { track: alpha(spec.surfaceDeep, 0.8), frame: spec.surfaceDeep, text: spec.text },
194
+ slot: { bg: alpha(spec.surface, 0.8), border: `1px solid ${spec.edge}`, radius: "6px" },
195
+ ring: alpha(spec.edgeBright, 0.55),
196
+ });
197
+ }
74
198
  /** Built-in theme presets for demos/scaffold only — not product identity; games author their own theme. */
75
199
  export const HUD_THEME_PRESETS = {
76
200
  "arcane-stone": theme({
@@ -145,7 +269,181 @@ export const HUD_THEME_PRESETS = {
145
269
  slot: { bg: "#4a4a4a", border: "3px solid #2a2a2a", radius: "0px" },
146
270
  ring: "rgba(240,192,64,0.5)",
147
271
  }),
272
+ ember: chrome({
273
+ accent: "#e3b054",
274
+ accentGlow: "rgba(227, 176, 84, 0.55)",
275
+ accentDeep: "#8a6425",
276
+ surface: "#17120d",
277
+ surfaceDeep: "#0b0906",
278
+ edge: "#57452c",
279
+ edgeBright: "#93794c",
280
+ text: "#f2e7d0",
281
+ textDim: "#a3947a",
282
+ fills: { health: "#5cc35f", mana: "#4a86d8", stamina: "#d9c33f", xp: "#a566d9", shield: "#9fb9c9" },
283
+ deeps: { health: "#26662c", mana: "#20406f", stamina: "#6e621c", xp: "#4e2c6e", shield: "#48606e" },
284
+ status: {
285
+ danger: "#e0483e",
286
+ warning: "#e8a33d",
287
+ success: "#6fce6f",
288
+ hostile: "#d84a3a",
289
+ friendly: "#4fa9e0",
290
+ neutral: "#d9d04f",
291
+ },
292
+ display: '"Palatino Linotype", Palatino, "Book Antiqua", Georgia, serif',
293
+ rarity: {
294
+ common: "#b4b2a8",
295
+ uncommon: "#57c14f",
296
+ rare: "#4a86d8",
297
+ epic: "#a04fd0",
298
+ legendary: "#e0862e",
299
+ },
300
+ }),
301
+ synthwave: chrome({
302
+ accent: "#41e6f0",
303
+ accentGlow: "rgba(65, 230, 240, 0.55)",
304
+ accentDeep: "#136b74",
305
+ surface: "#12101f",
306
+ surfaceDeep: "#080711",
307
+ edge: "#3c3467",
308
+ edgeBright: "#6a5daa",
309
+ text: "#e9ecff",
310
+ textDim: "#8d8bb3",
311
+ fills: { health: "#3ee08a", mana: "#5a7bff", stamina: "#f0e341", xp: "#e050d8", shield: "#8ad4f0" },
312
+ deeps: { health: "#136b44", mana: "#243487", stamina: "#77701a", xp: "#6e2069", shield: "#2f6d85" },
313
+ status: {
314
+ danger: "#ff4d6a",
315
+ warning: "#ffb347",
316
+ success: "#3ee08a",
317
+ hostile: "#ff4d6a",
318
+ friendly: "#41e6f0",
319
+ neutral: "#f0e341",
320
+ },
321
+ display: '"Segoe UI", system-ui, sans-serif',
322
+ rarity: {
323
+ common: "#9fa4bd",
324
+ uncommon: "#3ee08a",
325
+ rare: "#5a7bff",
326
+ epic: "#e050d8",
327
+ legendary: "#ffb347",
328
+ },
329
+ }),
330
+ fieldkit: chrome({
331
+ accent: "#d8c169",
332
+ accentGlow: "rgba(216, 193, 105, 0.45)",
333
+ accentDeep: "#6e6230",
334
+ surface: "#14160f",
335
+ surfaceDeep: "#0a0b07",
336
+ edge: "#454a35",
337
+ edgeBright: "#6f7657",
338
+ text: "#e6e8d8",
339
+ textDim: "#95997f",
340
+ fills: { health: "#7fb84a", mana: "#57a8b8", stamina: "#c9b73e", xp: "#b88f4a", shield: "#a8b4a0" },
341
+ deeps: { health: "#3d5c1f", mana: "#265059", stamina: "#645a1c", xp: "#5c451f", shield: "#4e594a" },
342
+ status: {
343
+ danger: "#d84f35",
344
+ warning: "#d8952f",
345
+ success: "#7fb84a",
346
+ hostile: "#d84f35",
347
+ friendly: "#57a8b8",
348
+ neutral: "#c9b73e",
349
+ },
350
+ display: '"Arial Narrow", "Roboto Condensed", "Segoe UI", sans-serif',
351
+ rarity: {
352
+ common: "#a8ab99",
353
+ uncommon: "#7fb84a",
354
+ rare: "#57a8b8",
355
+ epic: "#b06fc9",
356
+ legendary: "#d8952f",
357
+ },
358
+ }),
148
359
  };
360
+ function hexToRgb(hex) {
361
+ const raw = hex.replace("#", "");
362
+ const full = raw.length === 3 ? raw.split("").map((c) => c + c).join("") : raw;
363
+ return {
364
+ r: parseInt(full.slice(0, 2), 16),
365
+ g: parseInt(full.slice(2, 4), 16),
366
+ b: parseInt(full.slice(4, 6), 16),
367
+ };
368
+ }
369
+ function rgbToHex({ r, g, b }) {
370
+ const channel = (v) => Math.round(Math.min(255, Math.max(0, v))).toString(16).padStart(2, "0");
371
+ return `#${channel(r)}${channel(g)}${channel(b)}`;
372
+ }
373
+ function mix(from, to, amount) {
374
+ const a = hexToRgb(from);
375
+ const b = hexToRgb(to);
376
+ return rgbToHex({
377
+ r: a.r + (b.r - a.r) * amount,
378
+ g: a.g + (b.g - a.g) * amount,
379
+ b: a.b + (b.b - a.b) * amount,
380
+ });
381
+ }
382
+ function alpha(hex, value) {
383
+ const { r, g, b } = hexToRgb(hex);
384
+ return `rgba(${r}, ${g}, ${b}, ${value})`;
385
+ }
386
+ /**
387
+ * Derives a coherent full theme from an accent/surface pair — shades, edges, dim text, frames,
388
+ * bar troughs, and slots all fall out of the seed, so a game gets one consistent look across the
389
+ * `packages/react` primitives and the `registry/jgengine` blocks without naming 50 tokens.
390
+ * Hex seeds only (`#rgb` or `#rrggbb`).
391
+ *
392
+ * @capability hud-theme-derive build a full HudTheme from an accent + surface seed pair
393
+ */
394
+ export function deriveHudTheme(seed) {
395
+ const { accent, surface } = seed;
396
+ const text = seed.text ?? mix("#f4f4f0", accent, 0.12);
397
+ const health = seed.health ?? "#e0483e";
398
+ const mana = seed.mana ?? "#4a86d8";
399
+ const stamina = seed.stamina ?? "#d9c33f";
400
+ const xp = seed.xp ?? "#a566d9";
401
+ const shield = seed.shield ?? "#9fb9c9";
402
+ const danger = seed.danger ?? "#e0483e";
403
+ const surfaceDeep = mix(surface, "#000000", 0.5);
404
+ const edge = mix(surface, accent, 0.22);
405
+ const edgeBright = mix(surface, accent, 0.42);
406
+ return hudTheme({
407
+ palette: {
408
+ health,
409
+ healthLow: mix(health, "#000000", 0.3),
410
+ healthDeep: mix(health, "#000000", 0.5),
411
+ mana,
412
+ manaDeep: mix(mana, "#000000", 0.5),
413
+ stamina,
414
+ staminaDeep: mix(stamina, "#000000", 0.5),
415
+ shield,
416
+ shieldDeep: mix(shield, "#000000", 0.5),
417
+ xp,
418
+ xpDeep: mix(xp, "#000000", 0.5),
419
+ soul: mix(xp, "#ffffff", 0.25),
420
+ ammo: seed.warning ?? "#e8a33d",
421
+ boss: danger,
422
+ accent,
423
+ accentDeep: mix(accent, "#000000", 0.55),
424
+ accentGlow: alpha(accent, 0.5),
425
+ accentDim: alpha(accent, 0.18),
426
+ },
427
+ surface: { surface, surfaceDeep, edge, edgeBright, text, textDim: mix(text, surface, 0.42), backdrop: alpha(surfaceDeep, 0.72) },
428
+ status: {
429
+ danger,
430
+ warning: seed.warning ?? "#e8a33d",
431
+ success: accent,
432
+ hostile: danger,
433
+ friendly: seed.friendly ?? mana,
434
+ neutral: stamina,
435
+ },
436
+ font: { display: seed.fontDisplay ?? defaultHudTheme.font.display },
437
+ frame: {
438
+ bg: `linear-gradient(180deg, ${alpha(surface, 0.92)}, ${alpha(surfaceDeep, 0.94)})`,
439
+ border: `1px solid ${edge}`,
440
+ glow: `0 6px 20px ${alpha(surfaceDeep, 0.7)}`,
441
+ },
442
+ bar: { track: alpha(surfaceDeep, 0.8), frame: surfaceDeep, text },
443
+ slot: { bg: alpha(surface, 0.8), border: `1px solid ${edge}`, radius: "6px" },
444
+ ring: alpha(edgeBright, 0.55),
445
+ });
446
+ }
149
447
  /** Resolves a preset name (or a full theme) to a `HudTheme`; falls back to the default theme. */
150
448
  export function resolveHudTheme(theme) {
151
449
  if (theme === undefined)