@jgengine/react 0.13.0 → 0.15.0-next.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 (101) hide show
  1. package/CHANGELOG.md +529 -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/hudLayout.js +138 -9
  33. package/dist/hudTheme.d.ts +104 -7
  34. package/dist/hudTheme.js +300 -2
  35. package/dist/i18n.d.ts +29 -0
  36. package/dist/i18n.js +44 -0
  37. package/dist/index.d.ts +34 -0
  38. package/dist/index.js +34 -0
  39. package/dist/interactionPrompt.d.ts +80 -0
  40. package/dist/interactionPrompt.js +112 -0
  41. package/dist/keybindingMenu.d.ts +59 -0
  42. package/dist/keybindingMenu.js +146 -0
  43. package/dist/killFeed.d.ts +63 -0
  44. package/dist/killFeed.js +88 -0
  45. package/dist/map.d.ts +123 -2
  46. package/dist/map.js +239 -36
  47. package/dist/menuRouter.d.ts +51 -0
  48. package/dist/menuRouter.js +49 -0
  49. package/dist/modals.d.ts +131 -0
  50. package/dist/modals.js +168 -0
  51. package/dist/notifications.d.ts +39 -0
  52. package/dist/notifications.js +111 -0
  53. package/dist/objectiveBanner.d.ts +62 -0
  54. package/dist/objectiveBanner.js +129 -0
  55. package/dist/panels.d.ts +10 -2
  56. package/dist/panels.js +9 -3
  57. package/dist/photoMode.d.ts +21 -0
  58. package/dist/photoMode.js +39 -0
  59. package/dist/previewFixtures.d.ts +30 -0
  60. package/dist/previewFixtures.js +46 -0
  61. package/dist/provider.d.ts +15 -0
  62. package/dist/provider.js +17 -1
  63. package/dist/questTracker.d.ts +24 -0
  64. package/dist/questTracker.js +31 -0
  65. package/dist/quickMenu.d.ts +39 -0
  66. package/dist/quickMenu.js +79 -0
  67. package/dist/radialMenu.d.ts +50 -0
  68. package/dist/radialMenu.js +69 -0
  69. package/dist/saveSlots.d.ts +81 -0
  70. package/dist/saveSlots.js +138 -0
  71. package/dist/scoreboard.d.ts +67 -0
  72. package/dist/scoreboard.js +71 -0
  73. package/dist/settings.d.ts +9 -3
  74. package/dist/settings.js +10 -4
  75. package/dist/shopGrid.d.ts +70 -0
  76. package/dist/shopGrid.js +129 -0
  77. package/dist/statusEffectBar.d.ts +52 -0
  78. package/dist/statusEffectBar.js +57 -0
  79. package/dist/streetGeometryPreview.d.ts +7 -0
  80. package/dist/streetGeometryPreview.js +119 -0
  81. package/dist/talentTree.d.ts +60 -0
  82. package/dist/talentTree.js +217 -0
  83. package/dist/timerReadout.d.ts +90 -0
  84. package/dist/timerReadout.js +111 -0
  85. package/dist/useDebouncedCommit.d.ts +54 -0
  86. package/dist/useDebouncedCommit.js +81 -0
  87. package/dist/waveHud.d.ts +49 -0
  88. package/dist/waveHud.js +53 -0
  89. package/dist/waypointMarkers.d.ts +80 -0
  90. package/dist/waypointMarkers.js +116 -0
  91. package/package.json +3 -3
  92. package/skills/ce-handoff/SKILL.md +149 -0
  93. package/skills/jgengine/SKILL.md +7 -3
  94. package/skills/jgengine/api.md +17 -7
  95. package/skills/jgengine/capabilities.md +12 -0
  96. package/skills/jgengine/recipes/minimal-game.md +7 -4
  97. package/skills/jgengine-ui/SKILL.md +8 -3
  98. package/skills/jgengine-ui/api.md +732 -23
  99. package/skills/jgengine-ui/capabilities.md +371 -2
  100. package/skills/jgengine-ui/recipes/overhead-entity-frames.md +136 -0
  101. package/skills/jgengine-ui/reference.md +7 -4
@@ -0,0 +1,39 @@
1
+ import { type CSSProperties, type ReactNode } from "react";
2
+ import { type AccessibilityState, type AccessibilityStore } from "@jgengine/core/ui/accessibility";
3
+ interface AccessibilityContextValue {
4
+ state: AccessibilityState;
5
+ store: AccessibilityStore;
6
+ }
7
+ /** Read the live accessibility state + its store; throws without an {@link AccessibilityProvider} above. */
8
+ export declare function useAccessibility(): AccessibilityContextValue;
9
+ /**
10
+ * 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.
13
+ *
14
+ * @capability colorblind-filters SVG feColorMatrix defs (protanopia/deuteranopia/tritanopia/grayscale) referenced by the accessibility colorblind filter
15
+ */
16
+ export declare function ColorblindFilters(): ReactNode;
17
+ /**
18
+ * 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
22
+ * `createAccessibilityStore` and re-renders on change; provides the state to
23
+ * `useAccessibility`.
24
+ *
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
26
+ */
27
+ export declare function AccessibilityProvider({ store, children, className, style, }: {
28
+ store: AccessibilityStore;
29
+ children: ReactNode;
30
+ className?: string;
31
+ style?: CSSProperties;
32
+ }): ReactNode;
33
+ /**
34
+ * OS "reduce motion" preference (`prefers-reduced-motion: reduce`) as a live
35
+ * boolean — SSR-safe (defaults false). Use it to seed an accessibility store's
36
+ * `reducedMotion` default.
37
+ */
38
+ export declare function usePrefersReducedMotion(): boolean;
39
+ export {};
@@ -0,0 +1,61 @@
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";
4
+ const AccessibilityContext = createContext(null);
5
+ const FILTER_MODES = [
6
+ "protanopia",
7
+ "deuteranopia",
8
+ "tritanopia",
9
+ "grayscale",
10
+ ];
11
+ /** Read the live accessibility state + its store; throws without an {@link AccessibilityProvider} above. */
12
+ export function useAccessibility() {
13
+ const value = useContext(AccessibilityContext);
14
+ if (value === null)
15
+ throw new Error("useAccessibility must be used within an <AccessibilityProvider>");
16
+ return value;
17
+ }
18
+ /**
19
+ * 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.
22
+ *
23
+ * @capability colorblind-filters SVG feColorMatrix defs (protanopia/deuteranopia/tritanopia/grayscale) referenced by the accessibility colorblind filter
24
+ */
25
+ export function ColorblindFilters() {
26
+ 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
+ * 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
33
+ * `createAccessibilityStore` and re-renders on change; provides the state to
34
+ * `useAccessibility`.
35
+ *
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
37
+ */
38
+ export function AccessibilityProvider({ store, children, className, style, }) {
39
+ const state = useSyncExternalStore(store.subscribe, store.get, store.get);
40
+ const rootStyle = { ...style };
41
+ 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] }) }));
45
+ }
46
+ /**
47
+ * OS "reduce motion" preference (`prefers-reduced-motion: reduce`) as a live
48
+ * boolean — SSR-safe (defaults false). Use it to seed an accessibility store's
49
+ * `reducedMotion` default.
50
+ */
51
+ export function usePrefersReducedMotion() {
52
+ return useSyncExternalStore((listener) => {
53
+ if (typeof window === "undefined" || typeof window.matchMedia !== "function")
54
+ return () => undefined;
55
+ const query = window.matchMedia("(prefers-reduced-motion: reduce)");
56
+ query.addEventListener("change", listener);
57
+ return () => query.removeEventListener("change", listener);
58
+ }, () => typeof window !== "undefined" && typeof window.matchMedia === "function"
59
+ ? window.matchMedia("(prefers-reduced-motion: reduce)").matches
60
+ : false, () => false);
61
+ }
@@ -0,0 +1,50 @@
1
+ import { type CSSProperties, type ReactNode } from "react";
2
+ import type { AchievementTracker, AchievementView } from "@jgengine/core/game/achievements";
3
+ /**
4
+ * Subscribe a component to an achievement tracker's live view list. The list
5
+ * keeps a stable identity between changes, so this re-renders only on unlock or
6
+ * progress — not every frame.
7
+ */
8
+ export declare function useAchievements(tracker: AchievementTracker): readonly AchievementView[];
9
+ /** Props for {@link AchievementToast}. */
10
+ export interface AchievementToastProps {
11
+ name: string;
12
+ description?: string;
13
+ /** Icon/glyph node the game supplies; the engine never picks art. */
14
+ icon?: ReactNode;
15
+ points?: number;
16
+ /** Small heading above the name. Default `"Achievement Unlocked"`. */
17
+ heading?: string;
18
+ className?: string;
19
+ style?: CSSProperties;
20
+ }
21
+ /**
22
+ * The unlock-moment banner — icon, "Achievement Unlocked" heading, name, and
23
+ * optional points. Purely presentational; pair the game's `onUnlock` seam with
24
+ * a toast queue and render one of these per entry.
25
+ *
26
+ * @capability achievement-toast unlock-moment banner (icon, heading, name, points) for an achievement toast queue
27
+ */
28
+ export declare function AchievementToast({ name, description, icon, points, heading, className, style, }: AchievementToastProps): ReactNode;
29
+ /** Props for {@link AchievementGallery}. */
30
+ export interface AchievementGalleryProps {
31
+ achievements: readonly AchievementView[];
32
+ title?: string;
33
+ /** Render secret + still-locked achievements as masked "???". Default true. */
34
+ maskSecrets?: boolean;
35
+ /** Optional per-achievement icon; falls back to the def `icon` glyph then a lock/trophy. */
36
+ renderIcon?: (achievement: AchievementView) => ReactNode;
37
+ columns?: number;
38
+ emptyLabel?: string;
39
+ className?: string;
40
+ style?: CSSProperties;
41
+ }
42
+ /**
43
+ * Achievement/trophy gallery — a responsive grid of cards showing unlocked vs.
44
+ * locked state, a progress bar for counter achievements, and a header summary
45
+ * of completion and score. Secret+locked entries mask their name/description.
46
+ * Feed it `useAchievements(tracker)`.
47
+ *
48
+ * @capability achievement-gallery responsive achievement/trophy grid with unlocked/locked state, counter progress bars, secret masking, and a completion/score header
49
+ */
50
+ export declare function AchievementGallery({ achievements, title, maskSecrets, renderIcon, columns, emptyLabel, className, style, }: AchievementGalleryProps): ReactNode;
@@ -0,0 +1,94 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useSyncExternalStore } from "react";
3
+ /**
4
+ * Subscribe a component to an achievement tracker's live view list. The list
5
+ * keeps a stable identity between changes, so this re-renders only on unlock or
6
+ * progress — not every frame.
7
+ */
8
+ export function useAchievements(tracker) {
9
+ return useSyncExternalStore(tracker.subscribe, tracker.list, tracker.list);
10
+ }
11
+ /**
12
+ * The unlock-moment banner — icon, "Achievement Unlocked" heading, name, and
13
+ * optional points. Purely presentational; pair the game's `onUnlock` seam with
14
+ * a toast queue and render one of these per entry.
15
+ *
16
+ * @capability achievement-toast unlock-moment banner (icon, heading, name, points) for an achievement toast queue
17
+ */
18
+ export function AchievementToast({ name, description, icon, points, heading = "Achievement Unlocked", className, style, }) {
19
+ return (_jsxs("div", { className: className, "data-achievement-toast": true, style: {
20
+ display: "flex",
21
+ alignItems: "center",
22
+ gap: 12,
23
+ minWidth: 240,
24
+ padding: "10px 14px",
25
+ borderRadius: 12,
26
+ background: "linear-gradient(160deg, rgba(30,26,14,0.96), rgba(16,13,8,0.96))",
27
+ border: "1px solid var(--jg-rarity-legendary, rgba(245,158,11,0.55))",
28
+ boxShadow: "0 12px 34px rgba(0,0,0,0.5)",
29
+ color: "#f8fafc",
30
+ fontFamily: "ui-sans-serif, system-ui, sans-serif",
31
+ ...style,
32
+ }, children: [_jsx("div", { "data-achievement-toast-icon": true, style: {
33
+ display: "flex",
34
+ alignItems: "center",
35
+ justifyContent: "center",
36
+ width: 40,
37
+ height: 40,
38
+ borderRadius: 10,
39
+ background: "rgba(245,158,11,0.16)",
40
+ border: "1px solid rgba(245,158,11,0.4)",
41
+ fontSize: 20,
42
+ flexShrink: 0,
43
+ }, children: icon ?? "🏆" }), _jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 1, minWidth: 0 }, children: [_jsx("span", { style: { fontSize: 9, letterSpacing: 1.6, textTransform: "uppercase", color: "rgba(245,158,11,0.85)" }, children: heading }), _jsx("span", { style: { fontSize: 14, fontWeight: 700, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }, children: name }), description !== undefined ? (_jsx("span", { style: { fontSize: 11, color: "rgba(226,232,240,0.7)" }, children: description })) : null] }), points !== undefined ? (_jsx("span", { style: { marginLeft: "auto", fontSize: 13, fontWeight: 700, color: "var(--jg-rarity-legendary, #f59e0b)", fontVariantNumeric: "tabular-nums" }, children: points })) : null] }));
44
+ }
45
+ const MASK = "???";
46
+ /**
47
+ * Achievement/trophy gallery — a responsive grid of cards showing unlocked vs.
48
+ * locked state, a progress bar for counter achievements, and a header summary
49
+ * of completion and score. Secret+locked entries mask their name/description.
50
+ * Feed it `useAchievements(tracker)`.
51
+ *
52
+ * @capability achievement-gallery responsive achievement/trophy grid with unlocked/locked state, counter progress bars, secret masking, and a completion/score header
53
+ */
54
+ export function AchievementGallery({ achievements, title = "Achievements", maskSecrets = true, renderIcon, columns = 2, emptyLabel = "No achievements yet.", className, style, }) {
55
+ const unlockedCount = achievements.filter((a) => a.unlocked).length;
56
+ const score = achievements.reduce((sum, a) => sum + (a.unlocked ? a.points ?? 0 : 0), 0);
57
+ const hasPoints = achievements.some((a) => a.points !== undefined);
58
+ return (_jsxs("div", { className: className, "data-achievement-gallery": true, style: {
59
+ borderRadius: 14,
60
+ padding: 14,
61
+ background: "linear-gradient(160deg, rgba(20,24,32,0.97), rgba(11,14,19,0.97))",
62
+ border: "1px solid var(--jg-ring, rgba(148,163,184,0.3))",
63
+ color: "#e2e8f0",
64
+ fontFamily: "ui-sans-serif, system-ui, sans-serif",
65
+ ...style,
66
+ }, children: [_jsxs("div", { style: { display: "flex", alignItems: "baseline", justifyContent: "space-between", marginBottom: 10 }, children: [_jsx("span", { style: { fontSize: 13, fontWeight: 700, letterSpacing: 1.4, textTransform: "uppercase" }, children: title }), _jsxs("span", { style: { fontSize: 11, color: "rgba(203,213,225,0.75)", fontVariantNumeric: "tabular-nums" }, children: [unlockedCount, "/", achievements.length, hasPoints ? ` · ${score} pts` : ""] })] }), achievements.length === 0 ? (_jsx("p", { style: { margin: 0, fontSize: 12, color: "rgba(148,163,184,0.8)" }, children: emptyLabel })) : (_jsx("ul", { style: {
67
+ listStyle: "none",
68
+ margin: 0,
69
+ padding: 0,
70
+ display: "grid",
71
+ gridTemplateColumns: `repeat(${Math.max(1, columns)}, minmax(0, 1fr))`,
72
+ gap: 8,
73
+ }, children: achievements.map((achievement) => {
74
+ const masked = maskSecrets && achievement.secret === true && !achievement.unlocked;
75
+ const name = masked ? MASK : achievement.name;
76
+ const description = masked ? "Hidden achievement" : achievement.description;
77
+ const showProgress = !achievement.unlocked && achievement.target !== undefined && achievement.target > 0;
78
+ const icon = renderIcon?.(achievement) ?? (masked ? "🔒" : achievement.icon ?? (achievement.unlocked ? "🏆" : "◻"));
79
+ return (_jsxs("li", { "data-achievement": achievement.id, "data-unlocked": achievement.unlocked, style: {
80
+ display: "flex",
81
+ gap: 10,
82
+ padding: 10,
83
+ borderRadius: 10,
84
+ background: achievement.unlocked ? "rgba(245,158,11,0.10)" : "rgba(148,163,184,0.06)",
85
+ border: `1px solid ${achievement.unlocked ? "rgba(245,158,11,0.4)" : "rgba(148,163,184,0.18)"}`,
86
+ opacity: achievement.unlocked ? 1 : 0.75,
87
+ }, children: [_jsx("div", { style: { fontSize: 20, width: 26, textAlign: "center", flexShrink: 0 }, children: icon }), _jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 2, minWidth: 0, flex: 1 }, children: [_jsxs("span", { style: { fontSize: 13, fontWeight: 700, color: achievement.unlocked ? "#f8fafc" : "rgba(226,232,240,0.85)" }, children: [name, achievement.points !== undefined && !masked ? (_jsx("span", { style: { marginLeft: 6, fontSize: 10, color: "rgba(245,158,11,0.85)", fontWeight: 600 }, children: achievement.points })) : null] }), description !== undefined ? (_jsx("span", { style: { fontSize: 11, color: "rgba(203,213,225,0.7)" }, children: description })) : null, showProgress ? (_jsx("div", { "data-achievement-progress": true, style: { marginTop: 3, height: 5, borderRadius: 9999, background: "rgba(148,163,184,0.2)", overflow: "hidden" }, children: _jsx("div", { style: {
88
+ width: `${Math.round(achievement.fraction * 100)}%`,
89
+ height: "100%",
90
+ borderRadius: 9999,
91
+ background: "var(--jg-accent, #f59e0b)",
92
+ } }) })) : null] })] }, achievement.id));
93
+ }) }))] }));
94
+ }
@@ -0,0 +1,52 @@
1
+ import { type CSSProperties, type ReactNode } from "react";
2
+ import type { CameraShakeController } from "@jgengine/core/vfx/cameraShake";
3
+ import { type HudFrameVariation } from "./hudFrame.js";
4
+ /** A live read of a camera-shake controller: current trauma `0..1` and the last game-owned `kind`. */
5
+ export interface CameraShakeReadout {
6
+ /** Current trauma in `[0, 1]`. */
7
+ trauma: number;
8
+ /** The last free-string `kind` handed to the controller, or `undefined`. */
9
+ kind: string | undefined;
10
+ }
11
+ /**
12
+ * Subscribe to a camera-shake controller and re-render on every change (add /
13
+ * decay / clear). Optionally drives `update()` on an animation frame so the trauma
14
+ * meter bleeds down live even when no game loop ticks the controller. Returns the
15
+ * current trauma + kind for a HUD readout.
16
+ *
17
+ * @capability use-camera-shake React hook binding a camera-shake controller — subscribes for live trauma/kind and can self-drive its decay on a frame loop
18
+ */
19
+ export declare function useCameraShake(controller: CameraShakeController, animate?: boolean): CameraShakeReadout;
20
+ /** Props for {@link CameraShakeMeter}. */
21
+ export interface CameraShakeMeterProps {
22
+ /** The controller to read. */
23
+ controller: CameraShakeController;
24
+ /**
25
+ * Drive the controller's decay on an animation frame so the meter bleeds down
26
+ * live. Default `false` — leave off when a shell consumer already ticks
27
+ * `update()` each frame (the usual case), on to make the panel self-sufficient.
28
+ */
29
+ animate?: boolean;
30
+ /** Header label. Default `"CAMERA SHAKE"`. */
31
+ title?: string;
32
+ /**
33
+ * Human labels per game-owned `kind`, so the readout can show "Explosion" for a
34
+ * `"explosion"` impact. The model never interprets `kind`; this is pure display.
35
+ */
36
+ kindLabels?: Record<string, string>;
37
+ /** Frame skin. Default `"glass"`. */
38
+ variation?: HudFrameVariation;
39
+ className?: string;
40
+ style?: CSSProperties;
41
+ }
42
+ /**
43
+ * A drop-in HUD panel that visualizes a core {@link CameraShakeController}: a
44
+ * trauma meter (reusing the shared {@link StaminaBar} building block) plus a live
45
+ * percentage and the current impact `kind` label. Presentation only — it reads the
46
+ * controller and never interprets `kind`, mapping it through `kindLabels` for
47
+ * display. A shell component applies the same controller's `offset()` to the
48
+ * camera; this panel is the on-screen readout of how much shake is in flight.
49
+ *
50
+ * @capability camera-shake-meter HUD readout for a core camera-shake controller — a trauma meter plus current impact-kind label, reskinnable and kind-labelled
51
+ */
52
+ export declare function CameraShakeMeter({ controller, animate, title, kindLabels, variation, className, style, }: CameraShakeMeterProps): ReactNode;
@@ -0,0 +1,53 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useEffect, useReducer, useRef } from "react";
3
+ import { StaminaBar } from "./bars.js";
4
+ import { HudFrame } from "./hudFrame.js";
5
+ /**
6
+ * Subscribe to a camera-shake controller and re-render on every change (add /
7
+ * decay / clear). Optionally drives `update()` on an animation frame so the trauma
8
+ * meter bleeds down live even when no game loop ticks the controller. Returns the
9
+ * current trauma + kind for a HUD readout.
10
+ *
11
+ * @capability use-camera-shake React hook binding a camera-shake controller — subscribes for live trauma/kind and can self-drive its decay on a frame loop
12
+ */
13
+ export function useCameraShake(controller, animate = true) {
14
+ const [, bump] = useReducer((n) => n + 1, 0);
15
+ const rafRef = useRef(0);
16
+ useEffect(() => controller.subscribe(bump), [controller]);
17
+ useEffect(() => {
18
+ if (!animate)
19
+ return;
20
+ let running = true;
21
+ let last = performance.now();
22
+ const loop = () => {
23
+ if (!running)
24
+ return;
25
+ const nowMs = performance.now();
26
+ controller.update((nowMs - last) / 1000);
27
+ last = nowMs;
28
+ rafRef.current = requestAnimationFrame(loop);
29
+ };
30
+ rafRef.current = requestAnimationFrame(loop);
31
+ return () => {
32
+ running = false;
33
+ cancelAnimationFrame(rafRef.current);
34
+ };
35
+ }, [controller, animate]);
36
+ return { trauma: controller.trauma(), kind: controller.kind() };
37
+ }
38
+ /**
39
+ * A drop-in HUD panel that visualizes a core {@link CameraShakeController}: a
40
+ * trauma meter (reusing the shared {@link StaminaBar} building block) plus a live
41
+ * percentage and the current impact `kind` label. Presentation only — it reads the
42
+ * controller and never interprets `kind`, mapping it through `kindLabels` for
43
+ * display. A shell component applies the same controller's `offset()` to the
44
+ * camera; this panel is the on-screen readout of how much shake is in flight.
45
+ *
46
+ * @capability camera-shake-meter HUD readout for a core camera-shake controller — a trauma meter plus current impact-kind label, reskinnable and kind-labelled
47
+ */
48
+ export function CameraShakeMeter({ controller, animate = false, title = "CAMERA SHAKE", kindLabels, variation = "glass", className, style, }) {
49
+ const { trauma, kind } = useCameraShake(controller, animate);
50
+ const pct = Math.round(trauma * 100);
51
+ const kindLabel = kind === undefined ? "—" : kindLabels?.[kind] ?? kind;
52
+ return (_jsx(HudFrame, { className: className, variation: variation, title: title, aside: _jsxs("span", { style: { fontVariantNumeric: "tabular-nums", opacity: 0.85 }, children: [pct, "%"] }), padding: 12, width: 240, style: style, children: _jsxs("div", { "data-camera-shake-meter": "", style: { display: "flex", flexDirection: "column", gap: 8 }, children: [_jsx(StaminaBar, { value: pct, max: 100, label: "TRAUMA", fill: "#f97316", showValue: false, width: "100%" }), _jsxs("div", { "data-camera-shake-kind": kind ?? "", style: { display: "flex", justifyContent: "space-between", fontSize: 12, opacity: 0.85 }, children: [_jsx("span", { style: { textTransform: "uppercase", letterSpacing: 0.4, opacity: 0.7 }, children: "Last impact" }), _jsx("span", { style: { fontWeight: 700 }, children: kindLabel })] })] }) }));
53
+ }
@@ -0,0 +1,72 @@
1
+ import { type CSSProperties, type ReactNode } from "react";
2
+ import type { CoachMarkSequence, CoachMarkView } from "@jgengine/core/ui/coachMarks";
3
+ /**
4
+ * Subscribe to a coach-mark sequence and re-render on every change, returning the
5
+ * current step view (or `null` when the tour is complete or waiting on a gate).
6
+ *
7
+ * @capability use-coach-marks React hook binding a coach-mark sequence to a component — re-renders on advance/skip/gate changes
8
+ */
9
+ export declare function useCoachMarks(sequence: CoachMarkSequence): CoachMarkView | null;
10
+ /** Reskin tokens for {@link CoachMarkHost} / {@link CoachMark}. */
11
+ export interface CoachMarkTheme {
12
+ /** Accent for the counter, Next button, and pointer. Default reads `--jg-accent`. */
13
+ accent?: string;
14
+ /** Callout background. */
15
+ background?: string;
16
+ /** Callout text color. */
17
+ text?: string;
18
+ /** Dim color for the backdrop / spotlight. */
19
+ backdrop?: string;
20
+ /** Callout corner radius (px). */
21
+ radius?: number;
22
+ }
23
+ /** Props for {@link CoachMark}. */
24
+ export interface CoachMarkProps {
25
+ /** The step view to render (from `sequence.current()` / {@link useCoachMarks}). */
26
+ view: CoachMarkView;
27
+ /** Absolute-position style computed for the callout (see {@link CoachMarkHost}). */
28
+ positionStyle?: CSSProperties;
29
+ onNext: () => void;
30
+ onSkip: () => void;
31
+ nextLabel?: string;
32
+ doneLabel?: string;
33
+ skipLabel?: string;
34
+ theme?: CoachMarkTheme;
35
+ className?: string;
36
+ style?: CSSProperties;
37
+ }
38
+ /**
39
+ * A single coach-mark callout — title, body, an "N of M" counter, a Next/Got-it
40
+ * button, and a Skip-tour link. Presentation-only; position it with
41
+ * `positionStyle` or drop it into {@link CoachMarkHost}. Reskin via {@link CoachMarkTheme}.
42
+ *
43
+ * @capability coach-mark single onboarding callout — title/body, N-of-M counter, Next + Skip controls, theme-reskinnable
44
+ */
45
+ export declare function CoachMark({ view, positionStyle, onNext, onSkip, nextLabel, doneLabel, skipLabel, theme, className, style, }: CoachMarkProps): ReactNode;
46
+ /** Props for {@link CoachMarkHost}. */
47
+ export interface CoachMarkHostProps {
48
+ /** The sequence to drive. */
49
+ sequence: CoachMarkSequence;
50
+ /** Resolve a step's `anchor` string to a DOM element. Default: `document.querySelector`. */
51
+ resolveAnchor?: (anchor: string) => Element | null;
52
+ /** Dim the rest of the screen and spotlight the anchor. Default `true`. */
53
+ showBackdrop?: boolean;
54
+ /** Fired the first time the tour reports complete (no eligible step). */
55
+ onComplete?: () => void;
56
+ nextLabel?: string;
57
+ doneLabel?: string;
58
+ skipLabel?: string;
59
+ theme?: CoachMarkTheme;
60
+ className?: string;
61
+ style?: CSSProperties;
62
+ }
63
+ /**
64
+ * Full-screen coach-mark host: watches a sequence, positions the current step's
65
+ * callout beside its anchored element (or centered when unanchored) via
66
+ * `getBoundingClientRect`, and draws an optional dimmed backdrop with a spotlight
67
+ * cutout around the anchor. Next advances, Skip ends the tour — both persist so
68
+ * hints never re-show. Reskin with {@link CoachMarkTheme}.
69
+ *
70
+ * @capability coach-mark-host full-screen onboarding coach-mark host — anchored/centered callout, dimmed spotlight backdrop, Next/Skip wired to the sequence
71
+ */
72
+ export declare function CoachMarkHost({ sequence, resolveAnchor, showBackdrop, onComplete, nextLabel, doneLabel, skipLabel, theme, className, style, }: CoachMarkHostProps): ReactNode;
@@ -0,0 +1,136 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useEffect, useLayoutEffect, useReducer, useState } from "react";
3
+ /**
4
+ * Subscribe to a coach-mark sequence and re-render on every change, returning the
5
+ * current step view (or `null` when the tour is complete or waiting on a gate).
6
+ *
7
+ * @capability use-coach-marks React hook binding a coach-mark sequence to a component — re-renders on advance/skip/gate changes
8
+ */
9
+ export function useCoachMarks(sequence) {
10
+ const [, bump] = useReducer((n) => n + 1, 0);
11
+ useEffect(() => sequence.subscribe(bump), [sequence]);
12
+ return sequence.current();
13
+ }
14
+ function resolveTheme(theme) {
15
+ return {
16
+ accent: theme?.accent ?? "var(--jg-accent, #38bdf8)",
17
+ background: theme?.background ?? "linear-gradient(160deg, rgba(20,24,32,0.98), rgba(11,14,19,0.98))",
18
+ text: theme?.text ?? "#e2e8f0",
19
+ backdrop: theme?.backdrop ?? "rgba(4,7,12,0.62)",
20
+ radius: theme?.radius ?? 14,
21
+ };
22
+ }
23
+ const CALLOUT_WIDTH = 288;
24
+ const GAP = 14;
25
+ function calloutStyle(rect, placement) {
26
+ if (rect === null || placement === "center") {
27
+ return { top: "50%", left: "50%", transform: "translate(-50%, -50%)" };
28
+ }
29
+ switch (placement) {
30
+ case "top":
31
+ return { top: rect.top - GAP, left: rect.left + rect.width / 2, transform: "translate(-50%, -100%)" };
32
+ case "left":
33
+ return { top: rect.top + rect.height / 2, left: rect.left - GAP, transform: "translate(-100%, -50%)" };
34
+ case "right":
35
+ return { top: rect.top + rect.height / 2, left: rect.right + GAP, transform: "translate(0, -50%)" };
36
+ case "bottom":
37
+ default:
38
+ return { top: rect.bottom + GAP, left: rect.left + rect.width / 2, transform: "translate(-50%, 0)" };
39
+ }
40
+ }
41
+ /**
42
+ * A single coach-mark callout — title, body, an "N of M" counter, a Next/Got-it
43
+ * button, and a Skip-tour link. Presentation-only; position it with
44
+ * `positionStyle` or drop it into {@link CoachMarkHost}. Reskin via {@link CoachMarkTheme}.
45
+ *
46
+ * @capability coach-mark single onboarding callout — title/body, N-of-M counter, Next + Skip controls, theme-reskinnable
47
+ */
48
+ export function CoachMark({ view, positionStyle, onNext, onSkip, nextLabel = "Next", doneLabel = "Got it", skipLabel = "Skip tour", theme, className, style, }) {
49
+ const t = resolveTheme(theme);
50
+ const last = view.remaining <= 1;
51
+ return (_jsxs("div", { className: className, "data-coach-mark": view.step.id, style: {
52
+ position: "fixed",
53
+ width: CALLOUT_WIDTH,
54
+ maxWidth: "calc(100vw - 24px)",
55
+ boxSizing: "border-box",
56
+ padding: 16,
57
+ borderRadius: t.radius,
58
+ background: t.background,
59
+ border: "1px solid var(--jg-ring, rgba(148,163,184,0.32))",
60
+ color: t.text,
61
+ fontFamily: "ui-sans-serif, system-ui, sans-serif",
62
+ boxShadow: "0 20px 50px rgba(0,0,0,0.55)",
63
+ pointerEvents: "auto",
64
+ ...positionStyle,
65
+ ...style,
66
+ }, children: [_jsxs("div", { "data-coach-mark-counter": true, style: { fontSize: 10, letterSpacing: 1.4, textTransform: "uppercase", fontWeight: 700, color: t.accent }, children: [view.index + 1, " of ", view.total] }), _jsx("div", { "data-coach-mark-title": true, style: { marginTop: 6, fontSize: 15, fontWeight: 700 }, children: view.step.title }), view.step.body !== undefined ? (_jsx("p", { "data-coach-mark-body": true, style: { margin: "6px 0 0", fontSize: 12.5, lineHeight: 1.5, color: "rgba(203,213,225,0.85)" }, children: view.step.body })) : null, _jsxs("div", { style: { marginTop: 14, display: "flex", alignItems: "center", justifyContent: "space-between", gap: 10 }, children: [_jsx("button", { type: "button", "data-coach-mark-skip": true, onClick: onSkip, style: {
67
+ border: "none",
68
+ background: "transparent",
69
+ color: "rgba(148,163,184,0.85)",
70
+ fontSize: 11.5,
71
+ cursor: "pointer",
72
+ padding: 0,
73
+ textDecoration: "underline",
74
+ }, children: skipLabel }), _jsx("button", { type: "button", "data-coach-mark-next": true, onClick: onNext, style: {
75
+ border: "1px solid transparent",
76
+ borderRadius: 8,
77
+ background: t.accent,
78
+ color: "#06121b",
79
+ fontSize: 12.5,
80
+ fontWeight: 700,
81
+ padding: "6px 14px",
82
+ cursor: "pointer",
83
+ }, children: last ? doneLabel : nextLabel })] })] }));
84
+ }
85
+ /**
86
+ * Full-screen coach-mark host: watches a sequence, positions the current step's
87
+ * callout beside its anchored element (or centered when unanchored) via
88
+ * `getBoundingClientRect`, and draws an optional dimmed backdrop with a spotlight
89
+ * cutout around the anchor. Next advances, Skip ends the tour — both persist so
90
+ * hints never re-show. Reskin with {@link CoachMarkTheme}.
91
+ *
92
+ * @capability coach-mark-host full-screen onboarding coach-mark host — anchored/centered callout, dimmed spotlight backdrop, Next/Skip wired to the sequence
93
+ */
94
+ export function CoachMarkHost({ sequence, resolveAnchor, showBackdrop = true, onComplete, nextLabel, doneLabel, skipLabel, theme, className, style, }) {
95
+ const view = useCoachMarks(sequence);
96
+ const [rect, setRect] = useState(null);
97
+ const anchor = view?.step.anchor;
98
+ const stepId = view?.step.id;
99
+ useLayoutEffect(() => {
100
+ if (view === null && onComplete !== undefined && sequence.isComplete())
101
+ onComplete();
102
+ }, [view, onComplete, sequence]);
103
+ useLayoutEffect(() => {
104
+ if (anchor === undefined || typeof document === "undefined") {
105
+ setRect(null);
106
+ return;
107
+ }
108
+ const resolve = resolveAnchor ?? ((selector) => document.querySelector(selector));
109
+ const measure = () => {
110
+ const el = resolve(anchor);
111
+ setRect(el !== null ? el.getBoundingClientRect() : null);
112
+ };
113
+ measure();
114
+ window.addEventListener("resize", measure);
115
+ window.addEventListener("scroll", measure, true);
116
+ return () => {
117
+ window.removeEventListener("resize", measure);
118
+ window.removeEventListener("scroll", measure, true);
119
+ };
120
+ }, [anchor, stepId, resolveAnchor]);
121
+ if (view === null)
122
+ return null;
123
+ const t = resolveTheme(theme);
124
+ const placement = view.step.placement ?? (view.step.anchor !== undefined ? "bottom" : "center");
125
+ const spotlight = showBackdrop && rect !== null;
126
+ return (_jsxs("div", { className: className, "data-coach-mark-host": true, style: { position: "fixed", inset: 0, zIndex: 60, pointerEvents: "none", ...style }, children: [showBackdrop ? (spotlight ? (_jsx("div", { "data-coach-mark-spotlight": true, style: {
127
+ position: "fixed",
128
+ top: rect.top - 6,
129
+ left: rect.left - 6,
130
+ width: rect.width + 12,
131
+ height: rect.height + 12,
132
+ borderRadius: 10,
133
+ boxShadow: `0 0 0 9999px ${t.backdrop}`,
134
+ pointerEvents: "auto",
135
+ } })) : (_jsx("div", { "data-coach-mark-backdrop": true, style: { position: "fixed", inset: 0, background: t.backdrop, pointerEvents: "auto" } }))) : null, _jsx(CoachMark, { view: view, positionStyle: calloutStyle(rect, placement), onNext: () => sequence.advance(), onSkip: () => sequence.skipAll(), nextLabel: nextLabel, doneLabel: doneLabel, skipLabel: skipLabel, theme: theme })] }));
136
+ }
@@ -0,0 +1,25 @@
1
+ import { type CSSProperties, type ReactNode } from "react";
2
+ import type { CodexEntryView } from "@jgengine/core/game/codex";
3
+ /** Props for {@link Codex}. */
4
+ export interface CodexProps {
5
+ entries: readonly CodexEntryView[];
6
+ title?: string;
7
+ columns?: number;
8
+ /** Mask name/description of secret + undiscovered entries as "???". Default true. */
9
+ maskSecrets?: boolean;
10
+ /** Optional per-entry icon; falls back to the def `icon` glyph, a lock, or a silhouette dot. */
11
+ renderIcon?: (entry: CodexEntryView) => ReactNode;
12
+ onSelect?: (id: string) => void;
13
+ emptyLabel?: string;
14
+ className?: string;
15
+ style?: CSSProperties;
16
+ }
17
+ /**
18
+ * Codex / bestiary gallery — category tabs, a responsive grid of entry cards
19
+ * (discovered vs. locked, secret entries masked until found), and a header
20
+ * summarizing completion. Feed it `codex.list()`; wire `onSelect` to a detail
21
+ * pane.
22
+ *
23
+ * @capability codex-gallery codex/bestiary gallery with category tabs, discovered/locked cards, secret masking, and a completion header
24
+ */
25
+ export declare function Codex({ entries, title, columns, maskSecrets, renderIcon, onSelect, emptyLabel, className, style, }: CodexProps): ReactNode;