@jgengine/react 0.14.0 → 0.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +500 -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/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/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/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 +3 -1
- 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 +5 -3
- package/skills/jgengine-ui/api.md +708 -19
- package/skills/jgengine-ui/capabilities.md +362 -1
- package/skills/jgengine-ui/recipes/overhead-entity-frames.md +136 -0
- package/skills/jgengine-ui/reference.md +7 -4
package/dist/modals.d.ts
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { type CSSProperties, type ReactNode } from "react";
|
|
2
|
+
import { type ModalRecord, type ModalStack } from "@jgengine/core/ui/modalStack";
|
|
3
|
+
/**
|
|
4
|
+
* React chrome over the headless modal stack (`@jgengine/core/ui/modalStack`) — the blocking-overlay
|
|
5
|
+
* layer a game raises over its scene: a pause menu, a "quit to menu?" confirm, a reward popup.
|
|
6
|
+
* {@link useModalStack} is the DATA/HOOK layer (subscribe + the active record), {@link ModalHost}
|
|
7
|
+
* renders the top modal with a backdrop, focus trap, and Esc-to-cancel, and {@link ConfirmDialog} /
|
|
8
|
+
* {@link PauseMenu} are ready-to-wire, HudTheme-token-driven building blocks. All unskinned and
|
|
9
|
+
* game-owned: the model never interprets a modal's `kind`, so a game switches on it to pick which of
|
|
10
|
+
* these (or its own) content to show, and reskins everything via `HudTheme` tokens and the frame.
|
|
11
|
+
*/
|
|
12
|
+
/** The live modal view returned by {@link useModalStack}: the active record (or `null`) plus the stack depth. */
|
|
13
|
+
export interface ModalStackView {
|
|
14
|
+
/** The topmost open modal, or `null` when nothing is open. */
|
|
15
|
+
top: ModalRecord | null;
|
|
16
|
+
/** Number of stacked modals (for a "2 of 3"-style affordance or a dim-per-layer backdrop). */
|
|
17
|
+
depth: number;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Subscribe to a modal stack and re-render on every change, returning the active modal and depth.
|
|
21
|
+
*
|
|
22
|
+
* @capability use-modal-stack React hook binding a modal stack to a component — re-renders on push/pop/resolve
|
|
23
|
+
*/
|
|
24
|
+
export declare function useModalStack(stack: ModalStack): ModalStackView;
|
|
25
|
+
/** Props for {@link ModalHost}. */
|
|
26
|
+
export interface ModalHostProps {
|
|
27
|
+
/** The stack to render the top of. */
|
|
28
|
+
stack: ModalStack;
|
|
29
|
+
/**
|
|
30
|
+
* Render the active modal's content. Receives the record and controls bound to the stack —
|
|
31
|
+
* `resolve(result)` closes it with a free-form outcome, `cancel()` is the Esc/backdrop shortcut
|
|
32
|
+
* (`resolve(MODAL_CANCEL)`). Switch on `record.kind` to pick a {@link ConfirmDialog}, a
|
|
33
|
+
* {@link PauseMenu}, or your own content.
|
|
34
|
+
*/
|
|
35
|
+
children: (record: ModalRecord, controls: ModalControls) => ReactNode;
|
|
36
|
+
/** Dim the scene behind the modal. Default `true`. */
|
|
37
|
+
showBackdrop?: boolean;
|
|
38
|
+
/** Backdrop color (default `rgba(4,7,12,0.62)`, or the `--jg-backdrop` token). */
|
|
39
|
+
backdrop?: string;
|
|
40
|
+
/** Close the top modal (cancel) on backdrop click. Default `true`. */
|
|
41
|
+
closeOnBackdrop?: boolean;
|
|
42
|
+
/** Close the top modal (cancel) on Escape. Default `true`. */
|
|
43
|
+
closeOnEsc?: boolean;
|
|
44
|
+
/** z-index of the host's stacking context — sits above the HUD/windows. Default 80. */
|
|
45
|
+
zIndexBase?: number;
|
|
46
|
+
className?: string;
|
|
47
|
+
style?: CSSProperties;
|
|
48
|
+
}
|
|
49
|
+
/** Stack-bound controls handed to a {@link ModalHost} render callback. */
|
|
50
|
+
export interface ModalControls {
|
|
51
|
+
/** Resolve the active modal with a free-form result and close it. */
|
|
52
|
+
resolve: (result: string, payload?: unknown) => void;
|
|
53
|
+
/** Resolve the active modal with {@link MODAL_CONFIRM}. */
|
|
54
|
+
confirm: (payload?: unknown) => void;
|
|
55
|
+
/** Resolve the active modal with {@link MODAL_CANCEL} — the Esc / backdrop behavior. */
|
|
56
|
+
cancel: () => void;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Full-screen modal host: watches a stack, renders only the topmost modal centered over a dimmed
|
|
60
|
+
* backdrop, and manages accessibility — the panel is a `role="dialog" aria-modal`, focus moves into
|
|
61
|
+
* it on open and is trapped with Tab/Shift+Tab cycling, Escape and (optionally) a backdrop click
|
|
62
|
+
* cancel the top modal, and focus returns to the previously focused element on close. It renders
|
|
63
|
+
* nothing when the stack is empty. Skin the panel and backdrop via `HudTheme` tokens.
|
|
64
|
+
*
|
|
65
|
+
* @capability modal-host full-screen modal/dialog host over a modal stack — dimmed backdrop, focus trap, Esc/backdrop-to-cancel, aria-modal dialog
|
|
66
|
+
*/
|
|
67
|
+
export declare function ModalHost({ stack, children, showBackdrop, backdrop, closeOnBackdrop, closeOnEsc, zIndexBase, className, style, }: ModalHostProps): ReactNode;
|
|
68
|
+
/** Props for {@link ConfirmDialog}. */
|
|
69
|
+
export interface ConfirmDialogProps {
|
|
70
|
+
title: ReactNode;
|
|
71
|
+
/** Explanatory body copy under the title. */
|
|
72
|
+
body?: ReactNode;
|
|
73
|
+
/** Confirm button label (default `"Confirm"`). */
|
|
74
|
+
confirmLabel?: ReactNode;
|
|
75
|
+
/** Cancel button label (default `"Cancel"`). */
|
|
76
|
+
cancelLabel?: ReactNode;
|
|
77
|
+
onConfirm: () => void;
|
|
78
|
+
onCancel: () => void;
|
|
79
|
+
/** Style the confirm button as destructive (red). Default `false`. */
|
|
80
|
+
danger?: boolean;
|
|
81
|
+
/** Fixed dialog width (default 360). */
|
|
82
|
+
width?: number | string;
|
|
83
|
+
className?: string;
|
|
84
|
+
style?: CSSProperties;
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* A generic two-button confirmation dialog — title, optional body, and Cancel / Confirm buttons over
|
|
88
|
+
* a `HudTheme`-token-driven {@link HudFrame}. Presentation-only: wire `onConfirm`/`onCancel` to a
|
|
89
|
+
* modal stack's `resolve` (typically inside a {@link ModalHost} render callback) and reskin via
|
|
90
|
+
* `HudTheme` tokens or `danger` for a destructive action. It interprets nothing — the game supplies
|
|
91
|
+
* the copy.
|
|
92
|
+
*
|
|
93
|
+
* @capability confirm-dialog generic themeable confirm/cancel dialog — title, body, two buttons, danger styling
|
|
94
|
+
*/
|
|
95
|
+
export declare function ConfirmDialog({ title, body, confirmLabel, cancelLabel, onConfirm, onCancel, danger, width, className, style, }: ConfirmDialogProps): ReactNode;
|
|
96
|
+
/** One selectable row in a {@link PauseMenu}'s slot list (Settings, Save, Quit, …). */
|
|
97
|
+
export interface PauseMenuItem {
|
|
98
|
+
/** Stable id (also the `data-pause-item` value). */
|
|
99
|
+
id: string;
|
|
100
|
+
label: ReactNode;
|
|
101
|
+
onSelect: () => void;
|
|
102
|
+
/** Render as destructive (e.g. Quit). Default `false`. */
|
|
103
|
+
danger?: boolean;
|
|
104
|
+
/** Disable the row. Default `false`. */
|
|
105
|
+
disabled?: boolean;
|
|
106
|
+
}
|
|
107
|
+
/** Props for {@link PauseMenu}. */
|
|
108
|
+
export interface PauseMenuProps {
|
|
109
|
+
/** Menu heading (default `"Paused"`). */
|
|
110
|
+
title?: ReactNode;
|
|
111
|
+
/** Resume/close-the-menu action, wired to the primary Resume button. */
|
|
112
|
+
onResume: () => void;
|
|
113
|
+
/** Resume button label (default `"Resume"`). */
|
|
114
|
+
resumeLabel?: ReactNode;
|
|
115
|
+
/** The game-filled slot list beneath Resume (Settings, Save, Quit, …). */
|
|
116
|
+
items?: readonly PauseMenuItem[];
|
|
117
|
+
/** Fixed menu width (default 300). */
|
|
118
|
+
width?: number | string;
|
|
119
|
+
className?: string;
|
|
120
|
+
style?: CSSProperties;
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* A reskinnable pause-menu building block: a primary Resume button plus a game-filled list of slot
|
|
124
|
+
* items (Settings, Save, Quit, …), stacked in a `HudTheme`-token-driven {@link HudFrame}. It ships
|
|
125
|
+
* the common shape every game's pause screen needs and nothing genre-specific — the game supplies the
|
|
126
|
+
* title, item labels/order, and destructive styling, and reskins with `HudTheme` tokens. Drop it into
|
|
127
|
+
* a {@link ModalHost} keyed on a `"pause"` modal, or render it standalone.
|
|
128
|
+
*
|
|
129
|
+
* @capability pause-menu reskinnable pause-menu building block — Resume plus a game-filled Settings/Quit slot list, HudTheme-driven
|
|
130
|
+
*/
|
|
131
|
+
export declare function PauseMenu({ title, onResume, resumeLabel, items, width, className, style, }: PauseMenuProps): ReactNode;
|
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
|
+
}
|