@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
package/dist/modals.js ADDED
@@ -0,0 +1,168 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useCallback, useEffect, useReducer, useRef, } from "react";
3
+ import { MODAL_CANCEL, MODAL_CONFIRM, } from "@jgengine/core/ui/modalStack";
4
+ import { HudFrame } from "./hudFrame.js";
5
+ /**
6
+ * Subscribe to a modal stack and re-render on every change, returning the active modal and depth.
7
+ *
8
+ * @capability use-modal-stack React hook binding a modal stack to a component — re-renders on push/pop/resolve
9
+ */
10
+ export function useModalStack(stack) {
11
+ const [, bump] = useReducer((n) => n + 1, 0);
12
+ useEffect(() => stack.subscribe(bump), [stack]);
13
+ return { top: stack.top(), depth: stack.depth() };
14
+ }
15
+ /** @internal Focusable descendants of `root`, in DOM order, skipping disabled/hidden ones. */
16
+ function focusableWithin(root) {
17
+ const selector = 'a[href],button:not([disabled]),textarea:not([disabled]),input:not([disabled]),select:not([disabled]),[tabindex]:not([tabindex="-1"])';
18
+ return Array.from(root.querySelectorAll(selector)).filter((el) => el.offsetParent !== null || el === document.activeElement);
19
+ }
20
+ /**
21
+ * Full-screen modal host: watches a stack, renders only the topmost modal centered over a dimmed
22
+ * backdrop, and manages accessibility — the panel is a `role="dialog" aria-modal`, focus moves into
23
+ * it on open and is trapped with Tab/Shift+Tab cycling, Escape and (optionally) a backdrop click
24
+ * cancel the top modal, and focus returns to the previously focused element on close. It renders
25
+ * nothing when the stack is empty. Skin the panel and backdrop via `HudTheme` tokens.
26
+ *
27
+ * @capability modal-host full-screen modal/dialog host over a modal stack — dimmed backdrop, focus trap, Esc/backdrop-to-cancel, aria-modal dialog
28
+ */
29
+ export function ModalHost({ stack, children, showBackdrop = true, backdrop, closeOnBackdrop = true, closeOnEsc = true, zIndexBase = 80, className, style, }) {
30
+ const { top } = useModalStack(stack);
31
+ const panelRef = useRef(null);
32
+ const restoreRef = useRef(null);
33
+ const stepId = top?.id ?? null;
34
+ const controls = {
35
+ resolve: useCallback((result, payload) => stack.resolve(result, payload !== undefined ? { payload } : undefined), [stack]),
36
+ confirm: useCallback((payload) => stack.resolve(MODAL_CONFIRM, payload !== undefined ? { payload } : undefined), [stack]),
37
+ cancel: useCallback(() => stack.resolve(MODAL_CANCEL), [stack]),
38
+ };
39
+ // Move focus into the dialog on open; restore it on close.
40
+ useEffect(() => {
41
+ if (stepId === null || typeof document === "undefined")
42
+ return undefined;
43
+ restoreRef.current = document.activeElement;
44
+ const panel = panelRef.current;
45
+ if (panel !== null) {
46
+ const first = focusableWithin(panel)[0] ?? panel;
47
+ first.focus();
48
+ }
49
+ return () => {
50
+ restoreRef.current?.focus?.();
51
+ };
52
+ }, [stepId]);
53
+ if (top === null)
54
+ return null;
55
+ const dim = backdrop ?? "var(--jg-backdrop, rgba(4,7,12,0.62))";
56
+ const onKeyDown = (event) => {
57
+ if (closeOnEsc && event.key === "Escape") {
58
+ event.stopPropagation();
59
+ controls.cancel();
60
+ return;
61
+ }
62
+ if (event.key !== "Tab")
63
+ return;
64
+ const panel = panelRef.current;
65
+ if (panel === null)
66
+ return;
67
+ const focusable = focusableWithin(panel);
68
+ if (focusable.length === 0) {
69
+ event.preventDefault();
70
+ return;
71
+ }
72
+ const first = focusable[0];
73
+ const last = focusable[focusable.length - 1];
74
+ const active = document.activeElement;
75
+ if (event.shiftKey && active === first) {
76
+ event.preventDefault();
77
+ last.focus();
78
+ }
79
+ else if (!event.shiftKey && active === last) {
80
+ event.preventDefault();
81
+ first.focus();
82
+ }
83
+ };
84
+ return (_jsxs("div", { "data-modal-host": "", className: className, onKeyDown: onKeyDown, style: {
85
+ position: "fixed",
86
+ inset: 0,
87
+ zIndex: zIndexBase,
88
+ display: "flex",
89
+ alignItems: "center",
90
+ justifyContent: "center",
91
+ pointerEvents: "auto",
92
+ ...style,
93
+ }, children: [showBackdrop ? (_jsx("div", { "data-modal-backdrop": "", onClick: closeOnBackdrop ? () => controls.cancel() : undefined, style: { position: "fixed", inset: 0, background: dim } })) : null, _jsx("div", { ref: panelRef, role: "dialog", "aria-modal": true, "aria-label": typeof top.payload === "object" && top.payload !== null && "title" in top.payload ? String(top.payload.title ?? top.kind) : top.kind, "data-modal": top.kind, "data-modal-id": top.id, tabIndex: -1, style: { position: "relative", pointerEvents: "auto", outline: "none", maxWidth: "calc(100vw - 32px)", maxHeight: "calc(100vh - 32px)" }, children: children(top, controls) })] }));
94
+ }
95
+ const DIALOG_TITLE = { margin: 0, fontSize: 17, fontWeight: 700, letterSpacing: 0.2 };
96
+ const DIALOG_BODY = { margin: "10px 0 0", fontSize: 13.5, lineHeight: 1.55, color: "rgba(226,232,240,0.82)" };
97
+ const BUTTON_ROW = { display: "flex", gap: 10, marginTop: 20, justifyContent: "flex-end" };
98
+ const BUTTON_BASE = {
99
+ appearance: "none",
100
+ borderRadius: "var(--jg-slot-radius, 8px)",
101
+ border: "1px solid transparent",
102
+ fontSize: 13,
103
+ fontWeight: 600,
104
+ padding: "8px 16px",
105
+ cursor: "pointer",
106
+ font: "inherit",
107
+ };
108
+ function primaryButtonStyle(danger) {
109
+ return {
110
+ ...BUTTON_BASE,
111
+ background: danger ? "#e5484d" : "var(--jg-accent, #38bdf8)",
112
+ color: danger ? "#fff" : "#06121b",
113
+ };
114
+ }
115
+ const GHOST_BUTTON = {
116
+ ...BUTTON_BASE,
117
+ background: "var(--jg-slot-bg, rgba(148,163,184,0.12))",
118
+ color: "var(--jg-bar-text, #e2e8f0)",
119
+ border: "var(--jg-slot-border, 1px solid rgba(148,163,184,0.28))",
120
+ };
121
+ /**
122
+ * A generic two-button confirmation dialog — title, optional body, and Cancel / Confirm buttons over
123
+ * a `HudTheme`-token-driven {@link HudFrame}. Presentation-only: wire `onConfirm`/`onCancel` to a
124
+ * modal stack's `resolve` (typically inside a {@link ModalHost} render callback) and reskin via
125
+ * `HudTheme` tokens or `danger` for a destructive action. It interprets nothing — the game supplies
126
+ * the copy.
127
+ *
128
+ * @capability confirm-dialog generic themeable confirm/cancel dialog — title, body, two buttons, danger styling
129
+ */
130
+ export function ConfirmDialog({ title, body, confirmLabel = "Confirm", cancelLabel = "Cancel", onConfirm, onCancel, danger = false, width = 360, className, style, }) {
131
+ return (_jsxs(HudFrame, { variation: "themed", width: width, padding: 20, interactive: true, className: className, style: style, children: [_jsx("h2", { "data-confirm-title": "", style: DIALOG_TITLE, children: title }), body !== undefined ? _jsx("p", { "data-confirm-body": "", style: DIALOG_BODY, children: body }) : null, _jsxs("div", { style: BUTTON_ROW, children: [_jsx("button", { type: "button", "data-confirm-cancel": "", onClick: onCancel, style: GHOST_BUTTON, children: cancelLabel }), _jsx("button", { type: "button", "data-confirm-accept": "", onClick: onConfirm, style: primaryButtonStyle(danger), children: confirmLabel })] })] }));
132
+ }
133
+ const PAUSE_TITLE = {
134
+ margin: "0 0 14px",
135
+ fontSize: 12,
136
+ fontWeight: 700,
137
+ letterSpacing: 2,
138
+ textTransform: "uppercase",
139
+ textAlign: "center",
140
+ color: "var(--jg-accent, #38bdf8)",
141
+ };
142
+ const PAUSE_ITEM_BASE = {
143
+ ...BUTTON_BASE,
144
+ display: "block",
145
+ width: "100%",
146
+ textAlign: "center",
147
+ padding: "10px 16px",
148
+ marginTop: 8,
149
+ };
150
+ /**
151
+ * A reskinnable pause-menu building block: a primary Resume button plus a game-filled list of slot
152
+ * items (Settings, Save, Quit, …), stacked in a `HudTheme`-token-driven {@link HudFrame}. It ships
153
+ * the common shape every game's pause screen needs and nothing genre-specific — the game supplies the
154
+ * title, item labels/order, and destructive styling, and reskins with `HudTheme` tokens. Drop it into
155
+ * a {@link ModalHost} keyed on a `"pause"` modal, or render it standalone.
156
+ *
157
+ * @capability pause-menu reskinnable pause-menu building block — Resume plus a game-filled Settings/Quit slot list, HudTheme-driven
158
+ */
159
+ export function PauseMenu({ title = "Paused", onResume, resumeLabel = "Resume", items = [], width = 300, className, style, }) {
160
+ return (_jsxs(HudFrame, { variation: "themed", width: width, padding: 20, interactive: true, className: className, style: style, children: [_jsx("div", { "data-pause-title": "", style: PAUSE_TITLE, children: title }), _jsx("button", { type: "button", "data-pause-resume": "", onClick: onResume, style: { ...PAUSE_ITEM_BASE, ...primaryButtonStyle(false), marginTop: 0 }, children: resumeLabel }), items.map((item) => (_jsx("button", { type: "button", "data-pause-item": item.id, onClick: item.onSelect, disabled: item.disabled, style: {
161
+ ...PAUSE_ITEM_BASE,
162
+ ...(item.danger
163
+ ? { background: "transparent", color: "#f0787c", border: "1px solid rgba(229,72,77,0.5)" }
164
+ : GHOST_BUTTON),
165
+ opacity: item.disabled === true ? 0.5 : 1,
166
+ cursor: item.disabled === true ? "not-allowed" : "pointer",
167
+ }, children: item.label }, item.id)))] }));
168
+ }
@@ -0,0 +1,39 @@
1
+ import { type CSSProperties, type ReactNode } from "react";
2
+ import type { NotificationEntry, NotificationStore } from "@jgengine/core/game/notifications";
3
+ /** Subscribe to a notification store's live list. */
4
+ export declare function useNotifications<TMeta = unknown>(store: NotificationStore<TMeta>): readonly NotificationEntry<TMeta>[];
5
+ /** Props for {@link NotificationBell}. */
6
+ export interface NotificationBellProps {
7
+ store: NotificationStore;
8
+ onClick?: () => void;
9
+ icon?: ReactNode;
10
+ className?: string;
11
+ style?: CSSProperties;
12
+ }
13
+ /**
14
+ * Notification bell — an icon button with an unread-count badge, bound to a
15
+ * notification store. Toggle a {@link NotificationCenter} panel from its click.
16
+ *
17
+ * @capability notification-bell icon button with a live unread-count badge over a notification store
18
+ */
19
+ export declare function NotificationBell({ store, onClick, icon, className, style }: NotificationBellProps): ReactNode;
20
+ /** Props for {@link NotificationCenter}. */
21
+ export interface NotificationCenterProps {
22
+ store: NotificationStore;
23
+ title?: string;
24
+ emptyLabel?: string;
25
+ onClose?: () => void;
26
+ /** Format an entry's timestamp; default is a relative "2m ago" using the current time. */
27
+ formatTime?: (at: number) => string;
28
+ className?: string;
29
+ style?: CSSProperties;
30
+ }
31
+ /**
32
+ * Notification center panel — the scrollable log toasts don't provide: newest
33
+ * notifications with a kind-colored marker, title/body, relative time, and an
34
+ * unread highlight; a header with the unread count, mark-all-read, and clear.
35
+ * Clicking an entry marks it read. Bind it to a `createNotificationCenter`.
36
+ *
37
+ * @capability notification-center-panel scrollable notification log with kind markers, read tracking, relative time, and mark-all-read/clear
38
+ */
39
+ export declare function NotificationCenter({ store, title, emptyLabel, onClose, formatTime, className, style, }: NotificationCenterProps): ReactNode;
@@ -0,0 +1,111 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useSyncExternalStore } from "react";
3
+ /** Subscribe to a notification store's live list. */
4
+ export function useNotifications(store) {
5
+ return useSyncExternalStore(store.subscribe, store.list, store.list);
6
+ }
7
+ const KIND_COLOR = {
8
+ info: "#38bdf8",
9
+ success: "#4ade80",
10
+ warning: "#f59e0b",
11
+ danger: "#ef4444",
12
+ };
13
+ function kindColor(kind) {
14
+ return KIND_COLOR[kind] ?? "var(--jg-accent, #94a3b8)";
15
+ }
16
+ function defaultRelativeTime(at, nowMs) {
17
+ const seconds = Math.max(0, Math.round((nowMs - at) / 1000));
18
+ if (seconds < 60)
19
+ return "just now";
20
+ const minutes = Math.round(seconds / 60);
21
+ if (minutes < 60)
22
+ return `${minutes}m ago`;
23
+ const hours = Math.round(minutes / 60);
24
+ if (hours < 24)
25
+ return `${hours}h ago`;
26
+ return `${Math.round(hours / 24)}d ago`;
27
+ }
28
+ /**
29
+ * Notification bell — an icon button with an unread-count badge, bound to a
30
+ * notification store. Toggle a {@link NotificationCenter} panel from its click.
31
+ *
32
+ * @capability notification-bell icon button with a live unread-count badge over a notification store
33
+ */
34
+ export function NotificationBell({ store, onClick, icon = "🔔", className, style }) {
35
+ const list = useNotifications(store);
36
+ const unread = list.reduce((count, entry) => (entry.read ? count : count + 1), 0);
37
+ return (_jsxs("button", { type: "button", className: className, "data-notification-bell": true, onClick: onClick, style: {
38
+ position: "relative",
39
+ width: 40,
40
+ height: 40,
41
+ borderRadius: 10,
42
+ border: "1px solid var(--jg-ring, rgba(148,163,184,0.3))",
43
+ background: "rgba(17,22,30,0.85)",
44
+ color: "#e2e8f0",
45
+ fontSize: 18,
46
+ cursor: "pointer",
47
+ ...style,
48
+ }, children: [icon, unread > 0 ? (_jsx("span", { "data-notification-badge": true, style: {
49
+ position: "absolute",
50
+ top: -6,
51
+ right: -6,
52
+ minWidth: 18,
53
+ height: 18,
54
+ padding: "0 4px",
55
+ borderRadius: 9999,
56
+ background: "#ef4444",
57
+ color: "#fff",
58
+ fontSize: 11,
59
+ fontWeight: 700,
60
+ display: "flex",
61
+ alignItems: "center",
62
+ justifyContent: "center",
63
+ }, children: unread > 99 ? "99+" : unread })) : null] }));
64
+ }
65
+ /**
66
+ * Notification center panel — the scrollable log toasts don't provide: newest
67
+ * notifications with a kind-colored marker, title/body, relative time, and an
68
+ * unread highlight; a header with the unread count, mark-all-read, and clear.
69
+ * Clicking an entry marks it read. Bind it to a `createNotificationCenter`.
70
+ *
71
+ * @capability notification-center-panel scrollable notification log with kind markers, read tracking, relative time, and mark-all-read/clear
72
+ */
73
+ export function NotificationCenter({ store, title = "Notifications", emptyLabel = "You're all caught up.", onClose, formatTime, className, style, }) {
74
+ const list = useNotifications(store);
75
+ const unread = list.reduce((count, entry) => (entry.read ? count : count + 1), 0);
76
+ const nowMs = typeof Date !== "undefined" ? Date.now() : 0;
77
+ const time = formatTime ?? ((at) => defaultRelativeTime(at, nowMs));
78
+ const headerButton = {
79
+ border: "1px solid rgba(148,163,184,0.4)",
80
+ borderRadius: 6,
81
+ background: "transparent",
82
+ color: "rgba(226,232,240,0.85)",
83
+ fontSize: 11,
84
+ padding: "3px 8px",
85
+ cursor: "pointer",
86
+ };
87
+ return (_jsxs("div", { className: className, "data-notification-center": true, style: {
88
+ width: 300,
89
+ maxHeight: 420,
90
+ display: "flex",
91
+ flexDirection: "column",
92
+ borderRadius: 14,
93
+ background: "linear-gradient(160deg, rgba(20,24,32,0.97), rgba(11,14,19,0.97))",
94
+ border: "1px solid var(--jg-ring, rgba(148,163,184,0.32))",
95
+ color: "#e2e8f0",
96
+ fontFamily: "ui-sans-serif, system-ui, sans-serif",
97
+ boxShadow: "0 18px 44px rgba(0,0,0,0.5)",
98
+ ...style,
99
+ }, children: [_jsxs("div", { style: { display: "flex", alignItems: "center", justifyContent: "space-between", gap: 8, padding: "10px 12px", borderBottom: "1px solid rgba(148,163,184,0.2)" }, children: [_jsxs("span", { style: { fontSize: 12, fontWeight: 700, letterSpacing: 1.2, textTransform: "uppercase" }, children: [title, unread > 0 ? _jsxs("span", { style: { marginLeft: 6, color: "#f87171" }, children: ["(", unread, ")"] }) : null] }), _jsxs("span", { style: { display: "flex", gap: 6 }, children: [_jsx("button", { type: "button", "data-notification-mark-all": true, style: headerButton, onClick: () => store.markAllRead(), children: "Mark all read" }), _jsx("button", { type: "button", "data-notification-clear": true, style: headerButton, onClick: () => store.clear(), children: "Clear" }), onClose !== undefined ? _jsx("button", { type: "button", style: headerButton, onClick: onClose, children: "\u2715" }) : null] })] }), list.length === 0 ? (_jsx("p", { style: { margin: 0, padding: "16px 12px", fontSize: 12, color: "rgba(148,163,184,0.8)" }, children: emptyLabel })) : (_jsx("ul", { style: { listStyle: "none", margin: 0, padding: 6, overflowY: "auto", display: "flex", flexDirection: "column", gap: 4 }, children: list.map((entry) => (_jsx("li", { children: _jsxs("button", { type: "button", "data-notification": entry.id, "data-kind": entry.kind, "data-read": entry.read, onClick: () => store.markRead(entry.id), style: {
100
+ width: "100%",
101
+ display: "flex",
102
+ gap: 9,
103
+ padding: "8px 9px",
104
+ borderRadius: 9,
105
+ border: "1px solid transparent",
106
+ background: entry.read ? "transparent" : "rgba(56,189,248,0.08)",
107
+ color: "#e2e8f0",
108
+ textAlign: "left",
109
+ cursor: "pointer",
110
+ }, children: [_jsx("span", { style: { marginTop: 5, width: 8, height: 8, flexShrink: 0, borderRadius: 9999, background: kindColor(entry.kind) } }), _jsxs("span", { style: { display: "flex", flexDirection: "column", gap: 2, minWidth: 0, flex: 1 }, children: [_jsxs("span", { style: { display: "flex", justifyContent: "space-between", gap: 8 }, children: [_jsx("span", { style: { fontSize: 13, fontWeight: entry.read ? 500 : 700 }, children: entry.title }), _jsx("span", { style: { fontSize: 10, color: "rgba(148,163,184,0.7)", whiteSpace: "nowrap" }, children: time(entry.at) })] }), entry.body !== undefined ? (_jsx("span", { style: { fontSize: 11, color: "rgba(203,213,225,0.75)" }, children: entry.body })) : null] })] }) }, entry.id))) }))] }));
111
+ }
@@ -0,0 +1,62 @@
1
+ import { type CSSProperties, type ReactNode } from "react";
2
+ import type { ObjectiveBannerController, ObjectiveBannerView } from "@jgengine/core/ui/objectiveBanner";
3
+ /**
4
+ * Subscribe to a banner controller, drive its clock on an animation frame (unless
5
+ * the game already ticks it), and return the banner to draw right now — or `null`
6
+ * when idle. Re-renders every frame while a banner is animating.
7
+ *
8
+ * @capability use-objective-banner React hook binding an objective-banner controller — advances its clock per frame and returns the current banner view
9
+ */
10
+ export declare function useObjectiveBanner(controller: ObjectiveBannerController, animate?: boolean): ObjectiveBannerView | null;
11
+ /** Reskin tokens for {@link ObjectiveBannerHost}. Per-`kind` overrides layer on top. */
12
+ export interface ObjectiveBannerTheme {
13
+ /** Accent for the title glow and the divider rule. Default reads `--jg-accent`. */
14
+ accent?: string;
15
+ /** Title text color. Default near-white. */
16
+ title?: string;
17
+ /** Subtitle text color. */
18
+ subtitle?: string;
19
+ /** Font family for the banner. */
20
+ fontFamily?: string;
21
+ /** Title font size (CSS length). Default `"clamp(2.5rem, 8vw, 6rem)"`. */
22
+ titleSize?: string;
23
+ /** Subtitle font size (CSS length). Default `"clamp(1rem, 2.5vw, 1.5rem)"`. */
24
+ subtitleSize?: string;
25
+ }
26
+ /** Props for {@link ObjectiveBannerHost}. */
27
+ export interface ObjectiveBannerHostProps {
28
+ /** The controller to render. */
29
+ controller: ObjectiveBannerController;
30
+ /** Base reskin tokens. */
31
+ theme?: ObjectiveBannerTheme;
32
+ /**
33
+ * Per-`kind` theme overrides, keyed by the free-string `kind` a game announces
34
+ * with ("victory", "defeat", "wave", …). The matching entry layers over `theme`,
35
+ * so a game colors each banner variant without the model interpreting `kind`.
36
+ */
37
+ kindThemes?: Record<string, ObjectiveBannerTheme>;
38
+ /**
39
+ * Drive `advance()` on an animation frame. Default `true`. Turn off if the game
40
+ * already advances the controller from its own tick.
41
+ */
42
+ animate?: boolean;
43
+ /** Dismiss the active banner when the overlay is clicked. Default `false`. */
44
+ dismissOnClick?: boolean;
45
+ /** Extra `z-index` for the overlay root. Default `50`. */
46
+ zIndex?: number;
47
+ className?: string;
48
+ style?: CSSProperties;
49
+ }
50
+ /**
51
+ * A full-screen overlay that renders an {@link ObjectiveBannerController} as the
52
+ * classic transient centered title stamp — "WAVE 3", "VICTORY", "OBJECTIVE
53
+ * COMPLETE" — flying in, holding, and fading out. It subscribes to the model,
54
+ * drives `advance()` on an animation frame (unless the game ticks it), and maps the
55
+ * current banner's phase + progress to opacity/scale/translate. Reskin the base
56
+ * look with {@link ObjectiveBannerTheme} and color each variant via `kindThemes`
57
+ * keyed on the game-owned `kind`. Presentation only: all timing and queueing live
58
+ * in the core model, and `kind` is never interpreted here.
59
+ *
60
+ * @capability objective-banner-host full-screen overlay that renders a core objective-banner controller as a fly-in / hold / fade-out centered title + subtitle stamp, theme- and per-kind-skinnable
61
+ */
62
+ export declare function ObjectiveBannerHost({ controller, theme, kindThemes, animate, dismissOnClick, zIndex, className, style, }: ObjectiveBannerHostProps): ReactNode;
@@ -0,0 +1,129 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useEffect, useReducer, useRef } from "react";
3
+ /**
4
+ * Subscribe to a banner controller, drive its clock on an animation frame (unless
5
+ * the game already ticks it), and return the banner to draw right now — or `null`
6
+ * when idle. Re-renders every frame while a banner is animating.
7
+ *
8
+ * @capability use-objective-banner React hook binding an objective-banner controller — advances its clock per frame and returns the current banner view
9
+ */
10
+ export function useObjectiveBanner(controller, animate = true) {
11
+ const [, bump] = useReducer((n) => n + 1, 0);
12
+ const rafRef = useRef(0);
13
+ useEffect(() => controller.subscribe(bump), [controller]);
14
+ useEffect(() => {
15
+ if (!animate)
16
+ return;
17
+ let running = true;
18
+ const loop = () => {
19
+ if (!running)
20
+ return;
21
+ controller.advance();
22
+ rafRef.current = requestAnimationFrame(loop);
23
+ };
24
+ rafRef.current = requestAnimationFrame(loop);
25
+ return () => {
26
+ running = false;
27
+ cancelAnimationFrame(rafRef.current);
28
+ };
29
+ }, [controller, animate]);
30
+ return controller.current();
31
+ }
32
+ function resolveTheme(theme) {
33
+ return {
34
+ accent: theme?.accent ?? "var(--jg-accent, #38bdf8)",
35
+ title: theme?.title ?? "#f8fafc",
36
+ subtitle: theme?.subtitle ?? "rgba(226,232,240,0.85)",
37
+ fontFamily: theme?.fontFamily ?? "ui-sans-serif, system-ui, sans-serif",
38
+ titleSize: theme?.titleSize ?? "clamp(2.5rem, 8vw, 6rem)",
39
+ subtitleSize: theme?.subtitleSize ?? "clamp(1rem, 2.5vw, 1.5rem)",
40
+ };
41
+ }
42
+ function lerp(a, b, t) {
43
+ return a + (b - a) * t;
44
+ }
45
+ /** Ease-out cubic for the fly-in so it decelerates into place. */
46
+ function easeOut(t) {
47
+ return 1 - (1 - t) ** 3;
48
+ }
49
+ /**
50
+ * The animated transform + opacity for a banner from its phase and progress. The
51
+ * banner flies up into place on the way `in`, holds fully visible, then lifts and
52
+ * fades on the way `out`. Presentation-only — nothing here reads `kind`.
53
+ */
54
+ function bannerMotion(phase, progress) {
55
+ switch (phase) {
56
+ case "in": {
57
+ const e = easeOut(progress);
58
+ return { opacity: progress, translateY: lerp(36, 0, e), scale: lerp(0.82, 1, e) };
59
+ }
60
+ case "hold":
61
+ return { opacity: 1, translateY: 0, scale: 1 };
62
+ case "out":
63
+ default:
64
+ return { opacity: 1 - progress, translateY: lerp(0, -22, progress), scale: lerp(1, 1.06, progress) };
65
+ }
66
+ }
67
+ /**
68
+ * A full-screen overlay that renders an {@link ObjectiveBannerController} as the
69
+ * classic transient centered title stamp — "WAVE 3", "VICTORY", "OBJECTIVE
70
+ * COMPLETE" — flying in, holding, and fading out. It subscribes to the model,
71
+ * drives `advance()` on an animation frame (unless the game ticks it), and maps the
72
+ * current banner's phase + progress to opacity/scale/translate. Reskin the base
73
+ * look with {@link ObjectiveBannerTheme} and color each variant via `kindThemes`
74
+ * keyed on the game-owned `kind`. Presentation only: all timing and queueing live
75
+ * in the core model, and `kind` is never interpreted here.
76
+ *
77
+ * @capability objective-banner-host full-screen overlay that renders a core objective-banner controller as a fly-in / hold / fade-out centered title + subtitle stamp, theme- and per-kind-skinnable
78
+ */
79
+ export function ObjectiveBannerHost({ controller, theme, kindThemes, animate = true, dismissOnClick = false, zIndex = 50, className, style, }) {
80
+ const view = useObjectiveBanner(controller, animate);
81
+ if (view === null)
82
+ return null;
83
+ const merged = { ...theme, ...kindThemes?.[view.kind] };
84
+ const t = resolveTheme(merged);
85
+ const motion = bannerMotion(view.phase, view.progress);
86
+ return (_jsx("div", { className: className, "data-objective-banner-host": "", onClick: dismissOnClick ? () => controller.skip() : undefined, style: {
87
+ position: "absolute",
88
+ inset: 0,
89
+ display: "flex",
90
+ flexDirection: "column",
91
+ alignItems: "center",
92
+ justifyContent: "center",
93
+ pointerEvents: dismissOnClick ? "auto" : "none",
94
+ zIndex,
95
+ fontFamily: t.fontFamily,
96
+ textAlign: "center",
97
+ ...style,
98
+ }, children: _jsxs("div", { "data-objective-banner": view.id, "data-kind": view.kind, "data-phase": view.phase, style: {
99
+ display: "flex",
100
+ flexDirection: "column",
101
+ alignItems: "center",
102
+ gap: "0.5em",
103
+ opacity: motion.opacity,
104
+ transform: `translateY(${motion.translateY}px) scale(${motion.scale})`,
105
+ willChange: "opacity, transform",
106
+ }, children: [_jsx("div", { "data-objective-banner-title": "", style: {
107
+ margin: 0,
108
+ fontSize: t.titleSize,
109
+ fontWeight: 900,
110
+ letterSpacing: "0.06em",
111
+ textTransform: "uppercase",
112
+ lineHeight: 1,
113
+ color: t.title,
114
+ textShadow: `0 0 0.35em ${t.accent}, 0 4px 24px rgba(0,0,0,0.6)`,
115
+ }, children: view.title }), _jsx("div", { "aria-hidden": "true", style: {
116
+ width: "min(40vw, 320px)",
117
+ height: 2,
118
+ background: `linear-gradient(90deg, transparent, ${t.accent}, transparent)`,
119
+ opacity: 0.9,
120
+ } }), view.subtitle !== undefined ? (_jsx("div", { "data-objective-banner-subtitle": "", style: {
121
+ margin: 0,
122
+ fontSize: t.subtitleSize,
123
+ fontWeight: 600,
124
+ letterSpacing: "0.14em",
125
+ textTransform: "uppercase",
126
+ color: t.subtitle,
127
+ textShadow: "0 2px 12px rgba(0,0,0,0.6)",
128
+ }, children: view.subtitle })) : null] }) }));
129
+ }
package/dist/panels.d.ts CHANGED
@@ -61,9 +61,15 @@ export declare function usePanels(defs: readonly PanelDef[], options?: UsePanels
61
61
  * map). Accessible: every window is a `role="dialog"` with an `aria-label` and a focusable close button;
62
62
  * ESC-close is wired by the manager. Unskinned — art-direct via `HudTheme` tokens and the `variation`.
63
63
  *
64
- * @capability panel-host render a manager's open windows as draggable, closable, z-stacked dialogs
64
+ * The host establishes its own stacking context at `zIndexBase` (default 40) so windows always paint
65
+ * *above* the HUD (stat bars, nameplates, frames typically sit at z 10–30) — no game should have to
66
+ * rediscover "my vitals bar bleeds through the open window" and wrap this in a `z-*` of its own. Per-
67
+ * window focus order still resolves inside that context, so clicking a window raises it over its peers.
68
+ * Raise `zIndexBase` to sit above a full-screen modal, or lower it to tuck windows beneath one.
69
+ *
70
+ * @capability panel-host render a manager's open windows as draggable, closable, z-stacked dialogs above the HUD
65
71
  */
66
- export declare function PanelHost({ manager, render, panels, variation, shape, width, className, windowClassName, windowStyle, bodyStyle, }: {
72
+ export declare function PanelHost({ manager, render, panels, variation, shape, width, zIndexBase, className, windowClassName, windowStyle, bodyStyle, }: {
67
73
  manager: PanelsManager;
68
74
  /** Content for a panel by id. Takes precedence over `panels`. */
69
75
  render?: (id: string) => ReactNode;
@@ -73,6 +79,8 @@ export declare function PanelHost({ manager, render, panels, variation, shape, w
73
79
  shape?: HudFrameShape;
74
80
  /** Default window width (per-panel unless overridden by the panel content). */
75
81
  width?: number | string;
82
+ /** z-index of the host's stacking context — the floor every window paints above. Default 40 (above the HUD). */
83
+ zIndexBase?: number;
76
84
  className?: string;
77
85
  windowClassName?: string;
78
86
  windowStyle?: CSSProperties;
package/dist/panels.js CHANGED
@@ -186,12 +186,18 @@ function cascade(index) {
186
186
  * map). Accessible: every window is a `role="dialog"` with an `aria-label` and a focusable close button;
187
187
  * ESC-close is wired by the manager. Unskinned — art-direct via `HudTheme` tokens and the `variation`.
188
188
  *
189
- * @capability panel-host render a manager's open windows as draggable, closable, z-stacked dialogs
189
+ * The host establishes its own stacking context at `zIndexBase` (default 40) so windows always paint
190
+ * *above* the HUD (stat bars, nameplates, frames typically sit at z 10–30) — no game should have to
191
+ * rediscover "my vitals bar bleeds through the open window" and wrap this in a `z-*` of its own. Per-
192
+ * window focus order still resolves inside that context, so clicking a window raises it over its peers.
193
+ * Raise `zIndexBase` to sit above a full-screen modal, or lower it to tuck windows beneath one.
194
+ *
195
+ * @capability panel-host render a manager's open windows as draggable, closable, z-stacked dialogs above the HUD
190
196
  */
191
- export function PanelHost({ manager, render, panels, variation, shape, width, className, windowClassName, windowStyle, bodyStyle, }) {
197
+ export function PanelHost({ manager, render, panels, variation, shape, width, zIndexBase = 40, className, windowClassName, windowStyle, bodyStyle, }) {
192
198
  const open = orderedOpen(manager.state, manager.defs);
193
199
  const content = (id) => render !== undefined ? render(id) : (panels?.[id] ?? null);
194
- return (_jsx("div", { "data-jg-panel-host": "", className: className, style: { position: "absolute", inset: 0, pointerEvents: "none" }, children: open.map((def, index) => (_jsx(WindowView, { title: def.title ?? def.id, ariaLabel: def.title ?? def.id, closable: def.closable !== false, onClose: () => manager.close(def.id), onFocus: () => manager.focus(def.id), onMove: (pos) => manager.move(def.id, pos), pos: manager.state.pos[def.id] ?? cascade(index), z: manager.state.z[def.id] ?? 1, width: width, variation: variation, shape: shape, className: windowClassName, style: windowStyle, bodyStyle: bodyStyle, children: content(def.id) }, def.id))) }));
200
+ return (_jsx("div", { "data-jg-panel-host": "", className: className, style: { position: "absolute", inset: 0, zIndex: zIndexBase, pointerEvents: "none" }, children: open.map((def, index) => (_jsx(WindowView, { title: def.title ?? def.id, ariaLabel: def.title ?? def.id, closable: def.closable !== false, onClose: () => manager.close(def.id), onFocus: () => manager.focus(def.id), onMove: (pos) => manager.move(def.id, pos), pos: manager.state.pos[def.id] ?? cascade(index), z: manager.state.z[def.id] ?? 1, width: width, variation: variation, shape: shape, className: windowClassName, style: windowStyle, bodyStyle: bodyStyle, children: content(def.id) }, def.id))) }));
195
201
  }
196
202
  /**
197
203
  * A single standalone window — title bar, close button, and title-bar drag over {@link HudFrame} chrome —
@@ -0,0 +1,21 @@
1
+ import { type CSSProperties, type ReactNode } from "react";
2
+ import type { PhotoModeState, PhotoModeStore } from "@jgengine/core/ui/photoMode";
3
+ /** Subscribe to a photo-mode store's live state. */
4
+ export declare function usePhotoMode(store: PhotoModeStore): PhotoModeState;
5
+ /** Props for {@link PhotoModeControls}. */
6
+ export interface PhotoModeControlsProps {
7
+ store: PhotoModeStore;
8
+ /** Take the shot — wire to shell `captureCanvas` + `downloadImage`. */
9
+ onCapture?: () => void;
10
+ className?: string;
11
+ style?: CSSProperties;
12
+ }
13
+ /**
14
+ * Photo-mode toolbar — a hide/show-HUD toggle, a capture button, and an exit
15
+ * button, bound to a `createPhotoModeStore`. Presentation-only: the game wires
16
+ * `onCapture` to shell `captureCanvas`/`downloadImage` and reads `hideHud` to
17
+ * drop its gameplay HUD.
18
+ *
19
+ * @capability photo-mode-controls photo-mode toolbar (hide-HUD toggle, capture, exit) bound to a photo-mode store
20
+ */
21
+ export declare function PhotoModeControls({ store, onCapture, className, style }: PhotoModeControlsProps): ReactNode;
@@ -0,0 +1,39 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useSyncExternalStore } from "react";
3
+ /** Subscribe to a photo-mode store's live state. */
4
+ export function usePhotoMode(store) {
5
+ return useSyncExternalStore(store.subscribe, store.get, store.get);
6
+ }
7
+ const BTN = {
8
+ borderRadius: 8,
9
+ border: "1px solid rgba(148,163,184,0.4)",
10
+ background: "rgba(17,22,30,0.85)",
11
+ color: "#e2e8f0",
12
+ fontSize: 12,
13
+ fontWeight: 600,
14
+ padding: "6px 12px",
15
+ cursor: "pointer",
16
+ };
17
+ /**
18
+ * Photo-mode toolbar — a hide/show-HUD toggle, a capture button, and an exit
19
+ * button, bound to a `createPhotoModeStore`. Presentation-only: the game wires
20
+ * `onCapture` to shell `captureCanvas`/`downloadImage` and reads `hideHud` to
21
+ * drop its gameplay HUD.
22
+ *
23
+ * @capability photo-mode-controls photo-mode toolbar (hide-HUD toggle, capture, exit) bound to a photo-mode store
24
+ */
25
+ export function PhotoModeControls({ store, onCapture, className, style }) {
26
+ const state = usePhotoMode(store);
27
+ return (_jsxs("div", { className: className, "data-photo-mode-controls": true, style: {
28
+ display: "inline-flex",
29
+ alignItems: "center",
30
+ gap: 8,
31
+ padding: 8,
32
+ borderRadius: 12,
33
+ background: "rgba(6,9,14,0.86)",
34
+ border: "1px solid var(--jg-ring, rgba(148,163,184,0.3))",
35
+ color: "#e2e8f0",
36
+ fontFamily: "ui-sans-serif, system-ui, sans-serif",
37
+ ...style,
38
+ }, children: [_jsx("span", { style: { fontSize: 11, letterSpacing: 1.4, textTransform: "uppercase", color: "rgba(203,213,225,0.75)", padding: "0 4px" }, children: "\uD83D\uDCF7 Photo" }), _jsx("button", { type: "button", "data-photo-hide-hud": true, "aria-pressed": state.hideHud, onClick: () => store.setHideHud(!state.hideHud), style: { ...BTN, ...(state.hideHud ? { borderColor: "rgba(56,189,248,0.6)", color: "#bae6fd" } : {}) }, children: state.hideHud ? "HUD hidden" : "HUD shown" }), onCapture !== undefined ? (_jsx("button", { type: "button", "data-photo-capture": true, onClick: onCapture, style: { ...BTN, borderColor: "rgba(74,222,128,0.6)", color: "#bbf7d0" }, children: "Capture" })) : null, _jsx("button", { type: "button", "data-photo-exit": true, onClick: () => store.exit(), style: BTN, children: "Exit" })] }));
39
+ }