@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.
- package/CHANGELOG.md +529 -1
- package/dist/accessibility.d.ts +39 -0
- package/dist/accessibility.js +61 -0
- package/dist/achievements.d.ts +50 -0
- package/dist/achievements.js +94 -0
- package/dist/cameraShake.d.ts +52 -0
- package/dist/cameraShake.js +53 -0
- package/dist/coachMarks.d.ts +72 -0
- package/dist/coachMarks.js +136 -0
- package/dist/codex.d.ts +25 -0
- package/dist/codex.js +62 -0
- package/dist/comboMeter.d.ts +63 -0
- package/dist/comboMeter.js +93 -0
- package/dist/components.js +4 -3
- package/dist/creditsScreen.d.ts +19 -0
- package/dist/creditsScreen.js +31 -0
- package/dist/cutscene.d.ts +66 -0
- package/dist/cutscene.js +132 -0
- package/dist/damageDirection.d.ts +51 -0
- package/dist/damageDirection.js +103 -0
- package/dist/dialogueView.d.ts +69 -0
- package/dist/dialogueView.js +111 -0
- package/dist/entityFrames.d.ts +114 -0
- package/dist/entityFrames.js +55 -0
- package/dist/fastTravel.d.ts +36 -0
- package/dist/fastTravel.js +103 -0
- package/dist/floatingText.d.ts +84 -0
- package/dist/floatingText.js +61 -0
- package/dist/gameViewport.js +8 -12
- package/dist/hooks.d.ts +42 -0
- package/dist/hooks.js +85 -0
- package/dist/hudLayout.js +138 -9
- package/dist/hudTheme.d.ts +104 -7
- package/dist/hudTheme.js +300 -2
- package/dist/i18n.d.ts +29 -0
- package/dist/i18n.js +44 -0
- package/dist/index.d.ts +34 -0
- package/dist/index.js +34 -0
- package/dist/interactionPrompt.d.ts +80 -0
- package/dist/interactionPrompt.js +112 -0
- package/dist/keybindingMenu.d.ts +59 -0
- package/dist/keybindingMenu.js +146 -0
- package/dist/killFeed.d.ts +63 -0
- package/dist/killFeed.js +88 -0
- package/dist/map.d.ts +123 -2
- package/dist/map.js +239 -36
- package/dist/menuRouter.d.ts +51 -0
- package/dist/menuRouter.js +49 -0
- package/dist/modals.d.ts +131 -0
- package/dist/modals.js +168 -0
- package/dist/notifications.d.ts +39 -0
- package/dist/notifications.js +111 -0
- package/dist/objectiveBanner.d.ts +62 -0
- package/dist/objectiveBanner.js +129 -0
- package/dist/panels.d.ts +10 -2
- package/dist/panels.js +9 -3
- package/dist/photoMode.d.ts +21 -0
- package/dist/photoMode.js +39 -0
- package/dist/previewFixtures.d.ts +30 -0
- package/dist/previewFixtures.js +46 -0
- package/dist/provider.d.ts +15 -0
- package/dist/provider.js +17 -1
- package/dist/questTracker.d.ts +24 -0
- package/dist/questTracker.js +31 -0
- package/dist/quickMenu.d.ts +39 -0
- package/dist/quickMenu.js +79 -0
- package/dist/radialMenu.d.ts +50 -0
- package/dist/radialMenu.js +69 -0
- package/dist/saveSlots.d.ts +81 -0
- package/dist/saveSlots.js +138 -0
- package/dist/scoreboard.d.ts +67 -0
- package/dist/scoreboard.js +71 -0
- package/dist/settings.d.ts +9 -3
- package/dist/settings.js +10 -4
- package/dist/shopGrid.d.ts +70 -0
- package/dist/shopGrid.js +129 -0
- package/dist/statusEffectBar.d.ts +52 -0
- package/dist/statusEffectBar.js +57 -0
- package/dist/streetGeometryPreview.d.ts +7 -0
- package/dist/streetGeometryPreview.js +119 -0
- package/dist/talentTree.d.ts +60 -0
- package/dist/talentTree.js +217 -0
- package/dist/timerReadout.d.ts +90 -0
- package/dist/timerReadout.js +111 -0
- package/dist/useDebouncedCommit.d.ts +54 -0
- package/dist/useDebouncedCommit.js +81 -0
- package/dist/waveHud.d.ts +49 -0
- package/dist/waveHud.js +53 -0
- package/dist/waypointMarkers.d.ts +80 -0
- package/dist/waypointMarkers.js +116 -0
- package/package.json +3 -3
- package/skills/ce-handoff/SKILL.md +149 -0
- package/skills/jgengine/SKILL.md +7 -3
- package/skills/jgengine/api.md +17 -7
- package/skills/jgengine/capabilities.md +12 -0
- package/skills/jgengine/recipes/minimal-game.md +7 -4
- package/skills/jgengine-ui/SKILL.md +8 -3
- package/skills/jgengine-ui/api.md +732 -23
- package/skills/jgengine-ui/capabilities.md +371 -2
- package/skills/jgengine-ui/recipes/overhead-entity-frames.md +136 -0
- 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
|
-
*
|
|
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
|
-
*
|
|
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
|
+
}
|