@jgengine/react 0.22.0 → 0.23.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 (70) hide show
  1. package/CHANGELOG.md +96 -0
  2. package/dist/accessibility.d.ts +5 -0
  3. package/dist/accessibility.js +9 -0
  4. package/dist/achievements.js +4 -3
  5. package/dist/actionHud.d.ts +6 -1
  6. package/dist/actionHud.js +37 -17
  7. package/dist/bars.js +5 -2
  8. package/dist/barsPreview.js +2 -1
  9. package/dist/characterSheet.js +5 -4
  10. package/dist/coachMarks.js +3 -2
  11. package/dist/codex.js +4 -3
  12. package/dist/components.js +9 -2
  13. package/dist/creditsScreen.js +2 -1
  14. package/dist/engineStyles.d.ts +18 -0
  15. package/dist/engineStyles.js +119 -0
  16. package/dist/fastTravel.js +4 -3
  17. package/dist/hud.js +7 -6
  18. package/dist/hudButton.d.ts +29 -0
  19. package/dist/hudButton.js +31 -0
  20. package/dist/hudChromePreview.d.ts +8 -0
  21. package/dist/hudChromePreview.js +27 -0
  22. package/dist/hudFrame.js +15 -11
  23. package/dist/hudInteract.d.ts +26 -0
  24. package/dist/hudInteract.js +28 -0
  25. package/dist/hudLayout.js +4 -3
  26. package/dist/hudPanel.d.ts +31 -0
  27. package/dist/hudPanel.js +32 -0
  28. package/dist/hudTheme.d.ts +88 -1
  29. package/dist/hudTheme.js +123 -8
  30. package/dist/hudThemePreview.js +2 -1
  31. package/dist/hudThemeRoot.d.ts +15 -0
  32. package/dist/hudThemeRoot.js +20 -0
  33. package/dist/hudTokens.d.ts +39 -0
  34. package/dist/hudTokens.js +56 -0
  35. package/dist/iconTreatment.js +3 -2
  36. package/dist/iconsPreview.js +2 -1
  37. package/dist/keybindingMenu.js +3 -2
  38. package/dist/map.js +9 -8
  39. package/dist/modals.d.ts +6 -3
  40. package/dist/modals.js +42 -52
  41. package/dist/notices.d.ts +27 -0
  42. package/dist/notices.js +145 -0
  43. package/dist/notifications.js +4 -3
  44. package/dist/panels.d.ts +9 -2
  45. package/dist/panels.js +78 -35
  46. package/dist/photoMode.js +2 -1
  47. package/dist/presence.d.ts +9 -0
  48. package/dist/presence.js +25 -0
  49. package/dist/previewFixtures.js +6 -0
  50. package/dist/questTracker.js +3 -2
  51. package/dist/quickMenu.js +6 -5
  52. package/dist/saveSlots.js +4 -3
  53. package/dist/selectionHud.js +4 -3
  54. package/dist/shopGrid.js +2 -1
  55. package/dist/statusEffectBar.js +2 -1
  56. package/dist/streetGeometryPreview.js +3 -2
  57. package/dist/styles.css +72 -0
  58. package/dist/talentTree.js +3 -2
  59. package/dist/tweenNumber.d.ts +37 -0
  60. package/dist/tweenNumber.js +66 -0
  61. package/dist/uiCues.d.ts +20 -0
  62. package/dist/uiCues.js +37 -0
  63. package/dist/waveHud.js +2 -1
  64. package/dist/waypointMarkers.js +2 -1
  65. package/package.json +10 -6
  66. package/skills/jgengine/api.md +18 -18
  67. package/skills/jgengine-ui/SKILL.md +4 -0
  68. package/skills/jgengine-ui/api.md +233 -37
  69. package/skills/jgengine-ui/capabilities.md +52 -0
  70. package/skills/jgengine-ui/reference-primitives.md +4 -4
package/dist/modals.js CHANGED
@@ -1,8 +1,14 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { useCallback, useEffect, useReducer, } from "react";
2
+ import { useCallback, useEffect, useReducer, useRef, } from "react";
3
3
  import { MODAL_CANCEL, MODAL_CONFIRM, } from "@jgengine/core/ui/modalStack";
4
- import { HudFrame } from "./hudFrame.js";
4
+ import { useReducedMotion } from "./accessibility.js";
5
5
  import { useDialogBehavior } from "./dialogBehavior.js";
6
+ import { useEngineStyles } from "./engineStyles.js";
7
+ import { HudButton } from "./hudButton.js";
8
+ import { useHudInteract } from "./hudInteract.js";
9
+ import { HudPanel } from "./hudPanel.js";
10
+ import { hudType } from "./hudTokens.js";
11
+ import { usePresence } from "./presence.js";
6
12
  /**
7
13
  * Subscribe to a modal stack and re-render on every change, returning the active modal and depth.
8
14
  *
@@ -13,6 +19,7 @@ export function useModalStack(stack) {
13
19
  useEffect(() => stack.subscribe(bump), [stack]);
14
20
  return { top: stack.top(), depth: stack.depth() };
15
21
  }
22
+ const MODAL_EXIT_MS = 140;
16
23
  /**
17
24
  * Full-screen modal host: watches a stack, renders only the topmost modal centered over a dimmed
18
25
  * backdrop, and manages accessibility — the panel is a `role="dialog" aria-modal`, focus moves into
@@ -22,8 +29,27 @@ export function useModalStack(stack) {
22
29
  *
23
30
  * @capability modal-host full-screen modal/dialog host over a modal stack — dimmed backdrop, focus trap, Esc/backdrop-to-cancel, aria-modal dialog
24
31
  */
25
- export function ModalHost({ stack, children, showBackdrop = true, backdrop, closeOnBackdrop = true, closeOnEsc = true, zIndexBase = 80, className, style, }) {
26
- const { top } = useModalStack(stack);
32
+ export function ModalHost({ stack, children, showBackdrop = true, backdrop, closeOnBackdrop = true, closeOnEsc = true, zIndexBase = 80, onInteract, className, style, }) {
33
+ useEngineStyles();
34
+ const { top: live } = useModalStack(stack);
35
+ const last = useRef(live);
36
+ if (live !== null)
37
+ last.current = live;
38
+ const presence = usePresence(live !== null, useReducedMotion() ? 0 : MODAL_EXIT_MS);
39
+ const top = live ?? (presence === "closed" ? last.current : null);
40
+ const report = useHudInteract("modal", top?.kind, onInteract);
41
+ const reportedId = useRef(null);
42
+ useEffect(() => {
43
+ const id = live?.id ?? null;
44
+ if (id === reportedId.current)
45
+ return;
46
+ const wasOpen = reportedId.current !== null;
47
+ reportedId.current = id;
48
+ if (id !== null)
49
+ report("open");
50
+ else if (wasOpen)
51
+ report("close");
52
+ }, [live, report]);
27
53
  const controls = {
28
54
  resolve: useCallback((result, payload) => stack.resolve(result, payload !== undefined ? { payload } : undefined), [stack]),
29
55
  confirm: useCallback((payload) => stack.resolve(MODAL_CONFIRM, payload !== undefined ? { payload } : undefined), [stack]),
@@ -37,7 +63,7 @@ export function ModalHost({ stack, children, showBackdrop = true, backdrop, clos
37
63
  if (top === null)
38
64
  return null;
39
65
  const dim = backdrop ?? "var(--jg-backdrop, rgba(4,7,12,0.62))";
40
- return (_jsxs("div", { "data-modal-host": "", className: className, style: {
66
+ return (_jsxs("div", { "data-modal-host": "", "data-state": live === null ? "closed" : "open", className: className, style: {
41
67
  position: "fixed",
42
68
  inset: 0,
43
69
  zIndex: zIndexBase,
@@ -46,37 +72,14 @@ export function ModalHost({ stack, children, showBackdrop = true, backdrop, clos
46
72
  justifyContent: "center",
47
73
  pointerEvents: "auto",
48
74
  ...style,
49
- }, 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) })] }));
75
+ }, children: [showBackdrop ? (_jsx("div", { "data-modal-backdrop": "", onClick: closeOnBackdrop ? () => controls.cancel() : undefined, style: { position: "fixed", inset: 0, background: dim, pointerEvents: live === null ? "none" : undefined } })) : 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: live === null ? "none" : "auto", outline: "none", maxWidth: "calc(100vw - 32px)", maxHeight: "calc(100vh - 32px)" }, children: children(top, controls) })] }));
50
76
  }
51
- const DIALOG_TITLE = { margin: 0, fontSize: 17, fontWeight: 700, letterSpacing: 0.2 };
52
- const DIALOG_BODY = { margin: "10px 0 0", fontSize: 13.5, lineHeight: 1.55, color: "rgba(226,232,240,0.82)" };
77
+ const DIALOG_TITLE = { margin: 0, fontFamily: "var(--jg-font-display, inherit)", fontSize: hudType("lg"), fontWeight: 700, letterSpacing: 0.1, lineHeight: 1.3 };
78
+ const DIALOG_BODY = { margin: "10px 0 0", fontSize: hudType("md"), lineHeight: 1.55, color: "var(--jg-text-dim, rgba(226,232,240,0.82))" };
53
79
  const BUTTON_ROW = { display: "flex", gap: 10, marginTop: 20, justifyContent: "flex-end" };
54
- const BUTTON_BASE = {
55
- appearance: "none",
56
- borderRadius: "var(--jg-slot-radius, 8px)",
57
- border: "1px solid transparent",
58
- fontSize: 13,
59
- fontWeight: 600,
60
- padding: "8px 16px",
61
- cursor: "pointer",
62
- font: "inherit",
63
- };
64
- function primaryButtonStyle(danger) {
65
- return {
66
- ...BUTTON_BASE,
67
- background: danger ? "#e5484d" : "var(--jg-accent, #38bdf8)",
68
- color: danger ? "#fff" : "#06121b",
69
- };
70
- }
71
- const GHOST_BUTTON = {
72
- ...BUTTON_BASE,
73
- background: "var(--jg-slot-bg, rgba(148,163,184,0.12))",
74
- color: "var(--jg-bar-text, #e2e8f0)",
75
- border: "var(--jg-slot-border, 1px solid rgba(148,163,184,0.28))",
76
- };
77
80
  /**
78
81
  * A generic two-button confirmation dialog — title, optional body, and Cancel / Confirm buttons over
79
- * a `HudTheme`-token-driven {@link HudFrame}. Presentation-only: wire `onConfirm`/`onCancel` to a
82
+ * a `HudTheme`-token-driven {@link HudPanel}. Presentation-only: wire `onConfirm`/`onCancel` to a
80
83
  * modal stack's `resolve` (typically inside a {@link ModalHost} render callback) and reskin via
81
84
  * `HudTheme` tokens or `danger` for a destructive action. It interprets nothing — the game supplies
82
85
  * the copy.
@@ -84,28 +87,22 @@ const GHOST_BUTTON = {
84
87
  * @capability confirm-dialog generic themeable confirm/cancel dialog — title, body, two buttons, danger styling
85
88
  */
86
89
  export function ConfirmDialog({ title, body, confirmLabel = "Confirm", cancelLabel = "Cancel", onConfirm, onCancel, danger = false, width = 360, className, style, }) {
87
- 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 })] })] }));
90
+ return (_jsxs(HudPanel, { elevation: "modal", width: width, padding: 20, 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(HudButton, { "data-confirm-cancel": "", cue: "cancel", onClick: onCancel, children: cancelLabel }), _jsx(HudButton, { "data-confirm-accept": "", cue: "confirm", variant: danger ? "danger" : "primary", onClick: onConfirm, children: confirmLabel })] })] }));
88
91
  }
89
92
  const PAUSE_TITLE = {
90
93
  margin: "0 0 14px",
91
- fontSize: 12,
94
+ fontSize: hudType("xs"),
92
95
  fontWeight: 700,
93
- letterSpacing: 2,
96
+ letterSpacing: "0.18em",
94
97
  textTransform: "uppercase",
95
98
  textAlign: "center",
96
99
  color: "var(--jg-accent, #38bdf8)",
97
100
  };
98
- const PAUSE_ITEM_BASE = {
99
- ...BUTTON_BASE,
100
- display: "block",
101
- width: "100%",
102
- textAlign: "center",
103
- padding: "10px 16px",
104
- marginTop: 8,
105
- };
101
+ const PAUSE_ITEM = { marginTop: 8 };
102
+ const PAUSE_DANGER_ITEM = { ...PAUSE_ITEM, color: "var(--jg-danger, #e5484d)", borderColor: "rgba(229,72,77,0.45)" };
106
103
  /**
107
104
  * A reskinnable pause-menu building block: a primary Resume button plus a game-filled list of slot
108
- * items (Settings, Save, Quit, …), stacked in a `HudTheme`-token-driven {@link HudFrame}. It ships
105
+ * items (Settings, Save, Quit, …), stacked in a `HudTheme`-token-driven {@link HudPanel}. It ships
109
106
  * the common shape every game's pause screen needs and nothing genre-specific — the game supplies the
110
107
  * title, item labels/order, and destructive styling, and reskins with `HudTheme` tokens. Drop it into
111
108
  * a {@link ModalHost} keyed on a `"pause"` modal, or render it standalone.
@@ -113,12 +110,5 @@ const PAUSE_ITEM_BASE = {
113
110
  * @capability pause-menu reskinnable pause-menu building block — Resume plus a game-filled Settings/Quit slot list, HudTheme-driven
114
111
  */
115
112
  export function PauseMenu({ title = "Paused", onResume, resumeLabel = "Resume", items = [], width = 300, className, style, }) {
116
- 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: {
117
- ...PAUSE_ITEM_BASE,
118
- ...(item.danger
119
- ? { background: "transparent", color: "#f0787c", border: "1px solid rgba(229,72,77,0.5)" }
120
- : GHOST_BUTTON),
121
- opacity: item.disabled === true ? 0.5 : 1,
122
- cursor: item.disabled === true ? "not-allowed" : "pointer",
123
- }, children: item.label }, item.id)))] }));
113
+ return (_jsxs(HudPanel, { elevation: "modal", width: width, padding: 20, className: className, style: style, children: [_jsx("div", { "data-pause-title": "", style: PAUSE_TITLE, children: title }), _jsx(HudButton, { "data-pause-resume": "", cue: "resume", variant: "primary", size: "lg", block: true, onClick: onResume, children: resumeLabel }), items.map((item) => (_jsx(HudButton, { "data-pause-item": item.id, cue: `pause:${item.id}`, size: "lg", block: true, onClick: item.onSelect, disabled: item.disabled, style: item.danger ? PAUSE_DANGER_ITEM : PAUSE_ITEM, children: item.label }, item.id)))] }));
124
114
  }
@@ -0,0 +1,27 @@
1
+ import { type ReactNode } from "react";
2
+ import type { Notice, NoticeFeed } from "@jgengine/core/game/notices";
3
+ export type { Notice, NoticeTone } from "@jgengine/core/game/notices";
4
+ /** Where {@link NoticeStack} sits: below top-edge HUD bars, over the play area, or above bottom action bars. */
5
+ export type NoticePlacement = "top" | "center" | "bottom";
6
+ type Shown = {
7
+ notice: Notice;
8
+ leaving: boolean;
9
+ };
10
+ /** Keeps removed notices on screen, in place, for the exit animation; new ones join the end. @internal */
11
+ export declare function reconcileShown(previous: readonly Shown[], live: readonly Notice[]): Shown[];
12
+ /** Live notices of `feed`, re-rendering on change and pruning each one when it expires. */
13
+ export declare function useNoticeList(feed: NoticeFeed | null): readonly Notice[];
14
+ /**
15
+ * Animated, toned notice stack for a {@link NoticeFeed} — the default renderer the shell mounts for
16
+ * `ctx.game.notices`. Uses `--jg-*` theme vars with fallbacks, 14px text, and never takes pointer input.
17
+ *
18
+ * @capability notice-feed render ctx.game.notices as an animated toned stack (enter/exit motion, ×N stacking)
19
+ */
20
+ export declare function NoticeStack({ feed, placement, scale, renderNotice, }: {
21
+ /** Defaults to the surrounding game's `ctx.game.notices`. */
22
+ feed?: NoticeFeed;
23
+ placement?: NoticePlacement;
24
+ /** Multiplies text and spacing (the shell passes the player's UI scale). */
25
+ scale?: number;
26
+ renderNotice?: (notice: Notice) => ReactNode;
27
+ }): import("react").JSX.Element | null;
@@ -0,0 +1,145 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useEffect, useState, useSyncExternalStore } from "react";
3
+ import { useOptionalGameContext } from "./provider.js";
4
+ const EXIT_MS = 200;
5
+ const EMPTY = [];
6
+ const NO_FEED_SUBSCRIBE = () => () => undefined;
7
+ const TONE_COLOR = {
8
+ info: "var(--jg-accent, #7cc4ff)",
9
+ success: "var(--jg-success, #3ecf8e)",
10
+ warn: "var(--jg-warning, #f5b544)",
11
+ deny: "var(--jg-danger, #f26d6d)",
12
+ };
13
+ const TONE_LABEL = { info: "Info", success: "Done", warn: "Warning", deny: "Can't do that" };
14
+ const PLACEMENT_STYLE = {
15
+ top: { top: "clamp(64px, 14%, 150px)" },
16
+ center: { top: "34%" },
17
+ bottom: { bottom: "clamp(120px, 22%, 230px)", flexDirection: "column-reverse" },
18
+ };
19
+ const KEYFRAMES = `
20
+ @keyframes jg-notice-in { from { opacity: 0; transform: translateY(-8px) scale(0.97); } to { opacity: 1; transform: none; } }
21
+ @keyframes jg-notice-out { from { opacity: 1; transform: none; } to { opacity: 0; transform: translateY(-6px) scale(0.98); } }
22
+ @keyframes jg-notice-shake { 0%,100% { transform: none; } 20% { transform: translateX(-5px); } 45% { transform: translateX(4px); } 70% { transform: translateX(-2px); } }
23
+ @keyframes jg-notice-pop { 0% { transform: scale(1); } 40% { transform: scale(1.035); } 100% { transform: scale(1); } }
24
+ [data-jg-notice] { animation: jg-notice-in 180ms cubic-bezier(.2,.8,.2,1) both; }
25
+ [data-jg-notice][data-leaving] { animation: jg-notice-out ${EXIT_MS}ms ease-in both; }
26
+ [data-jg-notice-body][data-bump="deny"] { animation: jg-notice-shake 260ms ease-out; }
27
+ [data-jg-notice-body][data-bump="other"] { animation: jg-notice-pop 220ms ease-out; }
28
+ @media (prefers-reduced-motion: reduce) { [data-jg-notice], [data-jg-notice][data-leaving], [data-jg-notice-body][data-bump] { animation-duration: 1ms; } }
29
+ `;
30
+ function ToneGlyph({ tone }) {
31
+ const path = tone === "deny"
32
+ ? "M5 5l6 6M11 5l-6 6"
33
+ : tone === "success"
34
+ ? "M4.5 8.5l2.5 2.5 4.5-5"
35
+ : tone === "warn"
36
+ ? "M8 4.5v4.5M8 11.4v.1"
37
+ : "M8 7.2v4.3M8 4.6v.1";
38
+ return (_jsx("span", { "aria-hidden": true, style: {
39
+ flex: "none",
40
+ width: 20,
41
+ height: 20,
42
+ borderRadius: 999,
43
+ display: "grid",
44
+ placeItems: "center",
45
+ background: TONE_COLOR[tone],
46
+ color: "var(--jg-surface-deep, #0b1018)",
47
+ }, children: _jsx("svg", { width: "16", height: "16", viewBox: "0 0 16 16", fill: "none", stroke: "currentColor", strokeWidth: "2.2", strokeLinecap: "round", strokeLinejoin: "round", children: _jsx("path", { d: path }) }) }));
48
+ }
49
+ /** Keeps removed notices on screen, in place, for the exit animation; new ones join the end. @internal */
50
+ export function reconcileShown(previous, live) {
51
+ const byId = new Map(live.map((notice) => [notice.id, notice]));
52
+ const next = previous.map((entry) => {
53
+ const current = byId.get(entry.notice.id);
54
+ return current === undefined ? { notice: entry.notice, leaving: true } : { notice: current, leaving: false };
55
+ });
56
+ const known = new Set(previous.map((entry) => entry.notice.id));
57
+ for (const notice of live)
58
+ if (!known.has(notice.id))
59
+ next.push({ notice, leaving: false });
60
+ return next;
61
+ }
62
+ /** Live notices of `feed`, re-rendering on change and pruning each one when it expires. */
63
+ export function useNoticeList(feed) {
64
+ const list = useSyncExternalStore(feed?.subscribe ?? NO_FEED_SUBSCRIBE, feed === null ? () => EMPTY : feed.list, feed === null ? () => EMPTY : feed.list);
65
+ const [, setRetry] = useState(0);
66
+ useEffect(() => {
67
+ if (feed === null || list.length === 0)
68
+ return;
69
+ const soonest = Math.min(...list.map((notice) => notice.expiresAt));
70
+ const timer = setTimeout(() => {
71
+ feed.prune();
72
+ // A feed on a non-wall clock may not have expired yet; poll gently until it does.
73
+ if (feed.list() === list)
74
+ setRetry((value) => value + 1);
75
+ }, Math.max(16, soonest - Date.now()));
76
+ return () => clearTimeout(timer);
77
+ });
78
+ return list;
79
+ }
80
+ /**
81
+ * Animated, toned notice stack for a {@link NoticeFeed} — the default renderer the shell mounts for
82
+ * `ctx.game.notices`. Uses `--jg-*` theme vars with fallbacks, 14px text, and never takes pointer input.
83
+ *
84
+ * @capability notice-feed render ctx.game.notices as an animated toned stack (enter/exit motion, ×N stacking)
85
+ */
86
+ export function NoticeStack({ feed, placement = "top", scale = 1, renderNotice, }) {
87
+ const ctx = useOptionalGameContext();
88
+ const source = feed ?? ctx?.game.notices ?? null;
89
+ const live = useNoticeList(source);
90
+ const [shown, setShown] = useState(() => reconcileShown([], live));
91
+ const [seenLive, setSeenLive] = useState(live);
92
+ if (seenLive !== live) {
93
+ setSeenLive(live);
94
+ setShown(reconcileShown(shown, live));
95
+ }
96
+ const anyLeaving = shown.some((entry) => entry.leaving);
97
+ useEffect(() => {
98
+ if (!anyLeaving)
99
+ return;
100
+ const timer = setTimeout(() => setShown((previous) => previous.filter((entry) => !entry.leaving)), EXIT_MS);
101
+ return () => clearTimeout(timer);
102
+ }, [anyLeaving]);
103
+ if (shown.length === 0)
104
+ return null;
105
+ return (_jsxs("div", { "data-jg-notices": placement, role: "status", "aria-live": "polite", style: {
106
+ position: "absolute",
107
+ left: "50%",
108
+ transform: "translateX(-50%)",
109
+ zIndex: 45,
110
+ display: "flex",
111
+ flexDirection: "column",
112
+ alignItems: "center",
113
+ gap: 6 * scale,
114
+ width: "min(460px, calc(100% - 32px))",
115
+ pointerEvents: "none",
116
+ ...PLACEMENT_STYLE[placement],
117
+ }, children: [_jsx("style", { children: KEYFRAMES }), shown.map(({ notice, leaving }) => (_jsx("div", { "data-jg-notice": notice.tone, "data-leaving": leaving ? "" : undefined, style: { maxWidth: "100%" }, children: renderNotice !== undefined ? renderNotice(notice) : (_jsxs("div", { "data-jg-notice-body": true, "data-bump": notice.count > 1 ? (notice.tone === "deny" ? "deny" : "other") : undefined, style: {
118
+ display: "flex",
119
+ alignItems: "center",
120
+ gap: 10 * scale,
121
+ padding: `${8 * scale}px ${14 * scale}px ${8 * scale}px ${10 * scale}px`,
122
+ borderRadius: 10 * scale,
123
+ background: "var(--jg-surface, rgba(13, 18, 27, 0.9))",
124
+ border: "1px solid var(--jg-edge, rgba(255, 255, 255, 0.12))",
125
+ boxShadow: `inset 3px 0 0 ${TONE_COLOR[notice.tone]}, 0 8px 24px rgba(0, 0, 0, 0.35)`,
126
+ backdropFilter: "blur(8px)",
127
+ WebkitBackdropFilter: "blur(8px)",
128
+ color: "var(--jg-text, #f1f5f9)",
129
+ fontFamily: "var(--jg-font-body, ui-sans-serif, system-ui, -apple-system, 'Segoe UI', sans-serif)",
130
+ fontSize: 14 * scale,
131
+ fontWeight: 600,
132
+ lineHeight: 1.3,
133
+ letterSpacing: "0.01em",
134
+ textShadow: "0 1px 1px rgba(0, 0, 0, 0.35)",
135
+ }, children: [_jsx(ToneGlyph, { tone: notice.tone }), _jsxs("span", { style: { position: "absolute", width: 1, height: 1, overflow: "hidden", clip: "rect(0 0 0 0)" }, children: [TONE_LABEL[notice.tone], ": "] }), _jsx("span", { children: notice.text }), notice.count > 1 ? (_jsxs("span", { style: {
136
+ flex: "none",
137
+ fontSize: 12 * scale,
138
+ fontWeight: 700,
139
+ fontVariantNumeric: "tabular-nums",
140
+ padding: `${1 * scale}px ${6 * scale}px`,
141
+ borderRadius: 999,
142
+ background: "rgba(255, 255, 255, 0.12)",
143
+ color: "var(--jg-text-dim, rgba(241, 245, 249, 0.8))",
144
+ }, children: ["\u00D7", notice.count] })) : null] }, notice.count)) }, notice.id)))] }));
145
+ }
@@ -1,5 +1,6 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useSyncExternalStore } from "react";
3
+ import { hudType } from "./hudTokens.js";
3
4
  /** Subscribe to a notification store's live list. */
4
5
  export function useNotifications(store) {
5
6
  return useSyncExternalStore(store.subscribe, store.list, store.list);
@@ -55,7 +56,7 @@ export function NotificationBell({ store, onClick, icon = "🔔", className, sty
55
56
  borderRadius: 9999,
56
57
  background: "#ef4444",
57
58
  color: "#fff",
58
- fontSize: 11,
59
+ fontSize: hudType("xs"),
59
60
  fontWeight: 700,
60
61
  display: "flex",
61
62
  alignItems: "center",
@@ -80,7 +81,7 @@ export function NotificationCenter({ store, title = "Notifications", emptyLabel
80
81
  borderRadius: 6,
81
82
  background: "transparent",
82
83
  color: "rgba(226,232,240,0.85)",
83
- fontSize: 11,
84
+ fontSize: hudType("xs"),
84
85
  padding: "3px 8px",
85
86
  cursor: "pointer",
86
87
  };
@@ -107,5 +108,5 @@ export function NotificationCenter({ store, title = "Notifications", emptyLabel
107
108
  color: "#e2e8f0",
108
109
  textAlign: "left",
109
110
  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
+ }, 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: hudType("xs"), color: "rgba(148,163,184,0.7)", whiteSpace: "nowrap" }, children: time(entry.at) })] }), entry.body !== undefined ? (_jsx("span", { style: { fontSize: hudType("xs"), color: "rgba(203,213,225,0.75)" }, children: entry.body })) : null] })] }) }, entry.id))) }))] }));
111
112
  }
package/dist/panels.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { type CSSProperties, type ReactNode } from "react";
2
2
  import { type PanelDef, type PanelPosition, type PanelState } from "@jgengine/core/ui/panelModel";
3
3
  import { type HudFrameShape, type HudFrameVariation } from "./hudFrame.js";
4
+ import { type HudInteractListener } from "./hudInteract.js";
4
5
  /**
5
6
  * React chrome over the headless panel/window model (`@jgengine/core/ui/panelModel`) — the toggleable,
6
7
  * draggable, z-stacked windows a WoW-style UI is made of (B for bag, C for character, ESC to close).
@@ -69,8 +70,10 @@ export declare function usePanels(defs: readonly PanelDef[], options?: UsePanels
69
70
  *
70
71
  * @capability panel-host render a manager's open windows as draggable, closable, z-stacked dialogs above the HUD
71
72
  */
72
- export declare function PanelHost({ manager, render, panels, variation, shape, width, zIndexBase, className, windowClassName, windowStyle, bodyStyle, }: {
73
+ export declare function PanelHost({ manager, render, panels, variation, shape, width, zIndexBase, className, windowClassName, windowStyle, bodyStyle, onInteract, }: {
73
74
  manager: PanelsManager;
75
+ /** Called as windows open and close (`cue` is `panel:<id>`) — the seam a UI sound system hooks. */
76
+ onInteract?: HudInteractListener;
74
77
  /** Content for a panel by id. Takes precedence over `panels`. */
75
78
  render?: (id: string) => ReactNode;
76
79
  /** Static content map by panel id, used when `render` is omitted. */
@@ -94,10 +97,14 @@ export declare function PanelHost({ manager, render, panels, variation, shape, w
94
97
  *
95
98
  * @capability window standalone draggable, closable window primitive over HudFrame — no manager required
96
99
  */
97
- export declare function Window({ title, ariaLabel, closable, onClose, x, y, onMove, z, width, variation, shape, className, style, bodyStyle, children, }: {
100
+ export declare function Window({ title, ariaLabel, closable, onClose, x, y, onMove, z, width, variation, shape, className, style, bodyStyle, cue, onInteract, children, }: {
98
101
  title: ReactNode;
99
102
  ariaLabel?: string;
100
103
  closable?: boolean;
104
+ /** Feedback cue name, rendered as `data-jg-cue` and passed to `onInteract`. */
105
+ cue?: string;
106
+ /** Called with `open` when the window mounts — the seam a UI sound system hooks. */
107
+ onInteract?: HudInteractListener;
101
108
  onClose?: () => void;
102
109
  /** Initial (uncontrolled) or current (controlled, with `onMove`) x position. Default 56. */
103
110
  x?: number;
package/dist/panels.js CHANGED
@@ -1,8 +1,14 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useCallback, useEffect, useRef, useState, } from "react";
3
3
  import { closePanel, closeTopPanel, createPanelState, focusPanel, isOpen as isPanelOpen, movePanel, openPanel, orderedOpen, panelByHotkey, } from "@jgengine/core/ui/panelModel";
4
+ import { useReducedMotion } from "./accessibility.js";
5
+ import { HudButton } from "./hudButton.js";
4
6
  import { HudFrame } from "./hudFrame.js";
7
+ import { useHudInteract } from "./hudInteract.js";
8
+ import { HudPanel } from "./hudPanel.js";
9
+ import { hudType } from "./hudTokens.js";
5
10
  import { observeBrowserSuspension } from "./browserLifecycle.js";
11
+ import { usePresence } from "./presence.js";
6
12
  /**
7
13
  * Pure keybind resolver for a panel set: ESC resolves to `closeTop` when a closable window is open, and
8
14
  * any other key routes through {@link panelByHotkey} (trying `code` then `key`) to a `toggle`. Returns
@@ -168,43 +174,78 @@ const TITLEBAR_STYLE = {
168
174
  alignItems: "center",
169
175
  justifyContent: "space-between",
170
176
  gap: 8,
171
- padding: "6px 8px 6px 10px",
177
+ padding: "8px 8px 8px 12px",
172
178
  cursor: "grab",
173
179
  touchAction: "none",
174
180
  userSelect: "none",
175
- fontSize: 12,
176
- fontWeight: 600,
177
- letterSpacing: 0.3,
178
- borderBottom: "var(--jg-frame-border, 1px solid rgba(255,255,255,0.10))",
179
- };
180
- const CLOSE_BUTTON_STYLE = {
181
- display: "inline-flex",
182
- alignItems: "center",
183
- justifyContent: "center",
184
- width: 20,
185
- height: 20,
186
- padding: 0,
187
- borderRadius: 6,
188
- border: "1px solid transparent",
189
- background: "transparent",
190
- color: "inherit",
191
- font: "inherit",
192
- lineHeight: 1,
193
- cursor: "pointer",
194
- opacity: 0.75,
181
+ fontSize: hudType("sm"),
182
+ fontWeight: 650,
183
+ letterSpacing: 0.2,
184
+ borderBottom: "1px solid var(--jg-edge, rgba(255,255,255,0.10))",
195
185
  };
186
+ const CLOSE_BUTTON_STYLE = { width: 24, height: 24, color: "inherit", opacity: 0.8 };
187
+ /** Exit-transition length for windows; matches the stylesheet's `jg-panel-out` at `--jg-motion-fast`. */
188
+ const WINDOW_EXIT_MS = 140;
196
189
  /** @internal Shared, fully-controlled window view — title bar (drag + close) plus a HudFrame body. */
197
- function WindowView({ title, ariaLabel, closable, onClose, onFocus, onMove, pos, z, width, variation, shape, className, style, bodyStyle, children, }) {
190
+ function WindowView({ title, ariaLabel, open = true, closable, onClose, onFocus, onMove, pos, z, width, variation, shape, cue, onInteract, className, style, bodyStyle, children, }) {
198
191
  const retireDrag = useRef(null);
199
192
  useEffect(() => () => retireDrag.current?.(), []);
200
- return (_jsx("div", { role: "dialog", "aria-label": ariaLabel, "data-jg-window": "", className: className, onPointerDown: () => onFocus?.(), style: { position: "absolute", left: pos.x, top: pos.y, zIndex: z, pointerEvents: "auto", ...style }, children: _jsxs(HudFrame, { variation: variation, shape: shape, width: width, padding: 0, interactive: true, children: [_jsxs("div", { "data-jg-window-titlebar": "", onPointerDown: (event) => {
201
- if (event.button !== 0 || !event.isPrimary || retireDrag.current !== null)
202
- return;
203
- onFocus?.();
204
- if (onMove !== undefined) {
205
- retireDrag.current = beginWindowDrag(event, pos, onMove, () => { retireDrag.current = null; });
206
- }
207
- }, style: TITLEBAR_STYLE, children: [_jsx("span", { "data-jg-window-title": "", children: title }), closable ? (_jsx("button", { type: "button", "data-jg-window-close": "", "aria-label": "Close", onClick: onClose, onPointerDown: (event) => event.stopPropagation(), style: CLOSE_BUTTON_STYLE, children: "\u2715" })) : null] }), _jsx("div", { "data-jg-window-body": "", style: { padding: 10, ...bodyStyle }, children: children })] }) }));
193
+ useEffect(() => {
194
+ if (!open)
195
+ retireDrag.current?.();
196
+ }, [open]);
197
+ const reduced = useReducedMotion();
198
+ const presence = usePresence(open, reduced ? 0 : WINDOW_EXIT_MS);
199
+ const report = useHudInteract("window", cue, onInteract);
200
+ const reported = useRef(null);
201
+ useEffect(() => {
202
+ if (reported.current === open)
203
+ return;
204
+ reported.current = open;
205
+ report(open ? "open" : "close");
206
+ }, [open, report]);
207
+ if (presence === "unmounted")
208
+ return null;
209
+ const frameBody = (_jsxs(_Fragment, { children: [_jsxs("div", { "data-jg-window-titlebar": "", onPointerDown: (event) => {
210
+ if (event.button !== 0 || !event.isPrimary || retireDrag.current !== null)
211
+ return;
212
+ onFocus?.();
213
+ if (onMove !== undefined) {
214
+ retireDrag.current = beginWindowDrag(event, pos, onMove, () => { retireDrag.current = null; });
215
+ }
216
+ }, style: TITLEBAR_STYLE, children: [_jsx("span", { "data-jg-window-title": "", children: title }), closable ? (_jsx(HudButton, { variant: "ghost", size: "icon", "data-jg-window-close": "", "aria-label": "Close", cue: "window-close", onClick: onClose, onPointerDown: (event) => event.stopPropagation(), style: CLOSE_BUTTON_STYLE, children: "\u2715" })) : null] }), _jsx("div", { "data-jg-window-body": "", style: { padding: 12, ...bodyStyle }, children: children })] }));
217
+ return (_jsx("div", { role: "dialog", "aria-label": ariaLabel, "data-jg-window": "", "data-state": presence, "data-jg-cue": cue, className: className, onPointerDown: () => onFocus?.(), style: { position: "absolute", left: pos.x, top: pos.y, zIndex: z, pointerEvents: "auto", ...style }, children: variation === undefined ? (_jsx(HudPanel, { elevation: "modal", padding: 0, width: width, style: shape === "square" ? { borderRadius: 0 } : undefined, children: frameBody })) : (_jsx(HudFrame, { variation: variation, shape: shape, width: width, padding: 0, interactive: true, children: frameBody })) }));
218
+ }
219
+ /** @internal Keeps just-closed panels rendered (closing) for `exitMs` so their exit transition plays. */
220
+ function useLeavingPanels(open, exitMs) {
221
+ const previous = useRef(open);
222
+ const timers = useRef(new Map());
223
+ const [leaving, setLeaving] = useState([]);
224
+ const openKey = open.map((def) => def.id).join("\u0000");
225
+ useEffect(() => {
226
+ const openIds = new Set(open.map((def) => def.id));
227
+ const gone = exitMs <= 0 ? [] : previous.current.map((def, index) => ({ def, index })).filter(({ def }) => !openIds.has(def.id));
228
+ previous.current = open;
229
+ for (const id of openIds) {
230
+ clearTimeout(timers.current.get(id));
231
+ timers.current.delete(id);
232
+ }
233
+ for (const { def } of gone) {
234
+ timers.current.set(def.id, setTimeout(() => {
235
+ timers.current.delete(def.id);
236
+ setLeaving((current) => current.filter((entry) => entry.def.id !== def.id));
237
+ }, exitMs));
238
+ }
239
+ setLeaving((current) => {
240
+ const kept = current.filter(({ def }) => !openIds.has(def.id) && !gone.some((g) => g.def.id === def.id));
241
+ return kept.length === 0 && gone.length === 0 && current.length === 0 ? current : [...kept, ...gone];
242
+ });
243
+ }, [openKey, exitMs]);
244
+ useEffect(() => () => {
245
+ for (const timer of timers.current.values())
246
+ clearTimeout(timer);
247
+ }, []);
248
+ return leaving;
208
249
  }
209
250
  /** @internal Fallback cascade position for a window whose state has no override yet. */
210
251
  function cascade(index) {
@@ -225,10 +266,12 @@ function cascade(index) {
225
266
  *
226
267
  * @capability panel-host render a manager's open windows as draggable, closable, z-stacked dialogs above the HUD
227
268
  */
228
- export function PanelHost({ manager, render, panels, variation, shape, width, zIndexBase = 40, className, windowClassName, windowStyle, bodyStyle, }) {
269
+ export function PanelHost({ manager, render, panels, variation, shape, width, zIndexBase = 40, className, windowClassName, windowStyle, bodyStyle, onInteract, }) {
229
270
  const open = orderedOpen(manager.state, manager.defs);
271
+ const leaving = useLeavingPanels(open, useReducedMotion() ? 0 : WINDOW_EXIT_MS);
230
272
  const content = (id) => render !== undefined ? render(id) : (panels?.[id] ?? null);
231
- 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))) }));
273
+ const windows = [...open.map((def, index) => ({ def, index, open: true })), ...leaving.map((entry) => ({ ...entry, open: false }))];
274
+ return (_jsx("div", { "data-jg-panel-host": "", className: className, style: { position: "absolute", inset: 0, zIndex: zIndexBase, pointerEvents: "none" }, children: windows.map(({ def, index, open: isOpenWindow }) => (_jsx(WindowView, { open: isOpenWindow, cue: `panel:${def.id}`, onInteract: onInteract, 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))) }));
232
275
  }
233
276
  /**
234
277
  * A single standalone window — title bar, close button, and title-bar drag over {@link HudFrame} chrome —
@@ -238,7 +281,7 @@ export function PanelHost({ manager, render, panels, variation, shape, width, zI
238
281
  *
239
282
  * @capability window standalone draggable, closable window primitive over HudFrame — no manager required
240
283
  */
241
- export function Window({ title, ariaLabel, closable = true, onClose, x, y, onMove, z, width, variation, shape, className, style, bodyStyle, children, }) {
284
+ export function Window({ title, ariaLabel, closable = true, onClose, x, y, onMove, z, width, variation, shape, className, style, bodyStyle, cue, onInteract, children, }) {
242
285
  const controlled = x !== undefined && y !== undefined && onMove !== undefined;
243
286
  const [localPos, setLocalPos] = useState({ x: x ?? 56, y: y ?? 56 });
244
287
  const pos = controlled ? { x: x, y: y } : localPos;
@@ -248,5 +291,5 @@ export function Window({ title, ariaLabel, closable = true, onClose, x, y, onMov
248
291
  onMove?.(next);
249
292
  }, [controlled, onMove]);
250
293
  const label = ariaLabel ?? (typeof title === "string" ? title : undefined);
251
- return (_jsx(WindowView, { title: title, ariaLabel: label, closable: closable, onClose: onClose, onMove: handleMove, pos: pos, z: z, width: width, variation: variation, shape: shape, className: className, style: style, bodyStyle: bodyStyle, children: children }));
294
+ return (_jsx(WindowView, { title: title, ariaLabel: label, closable: closable, onClose: onClose, onMove: handleMove, pos: pos, z: z, width: width, variation: variation, shape: shape, className: className, style: style, bodyStyle: bodyStyle, cue: cue, onInteract: onInteract, children: children }));
252
295
  }
package/dist/photoMode.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useSyncExternalStore } from "react";
3
+ import { hudType } from "./hudTokens.js";
3
4
  /** Subscribe to a photo-mode store's live state. */
4
5
  export function usePhotoMode(store) {
5
6
  return useSyncExternalStore(store.subscribe, store.get, store.get);
@@ -35,5 +36,5 @@ export function PhotoModeControls({ store, onCapture, className, style }) {
35
36
  color: "#e2e8f0",
36
37
  fontFamily: "ui-sans-serif, system-ui, sans-serif",
37
38
  ...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
+ }, children: [_jsx("span", { style: { fontSize: hudType("xs"), 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
40
  }
@@ -0,0 +1,9 @@
1
+ /** Mount state for an element with an exit transition: `closed` keeps it mounted until the exit ends. */
2
+ export type PresenceState = "open" | "closed" | "unmounted";
3
+ /**
4
+ * Keeps an element mounted for `exitMs` after `open` turns false, so a `data-state="closed"` exit
5
+ * animation can play. Returns `"unmounted"` once the exit has finished (or at once when `exitMs` is 0).
6
+ *
7
+ * @capability presence keep a closing element mounted through its exit transition
8
+ */
9
+ export declare function usePresence(open: boolean, exitMs: number): PresenceState;
@@ -0,0 +1,25 @@
1
+ import { useEffect, useState } from "react";
2
+ /**
3
+ * Keeps an element mounted for `exitMs` after `open` turns false, so a `data-state="closed"` exit
4
+ * animation can play. Returns `"unmounted"` once the exit has finished (or at once when `exitMs` is 0).
5
+ *
6
+ * @capability presence keep a closing element mounted through its exit transition
7
+ */
8
+ export function usePresence(open, exitMs) {
9
+ const [mounted, setMounted] = useState(open);
10
+ useEffect(() => {
11
+ if (open) {
12
+ setMounted(true);
13
+ return;
14
+ }
15
+ if (exitMs <= 0) {
16
+ setMounted(false);
17
+ return;
18
+ }
19
+ const timer = setTimeout(() => setMounted(false), exitMs);
20
+ return () => clearTimeout(timer);
21
+ }, [open, exitMs]);
22
+ if (open)
23
+ return "open";
24
+ return mounted ? "closed" : "unmounted";
25
+ }
@@ -1,4 +1,5 @@
1
1
  import { BarsPreview } from "./barsPreview.js";
2
+ import { HudChromePreview } from "./hudChromePreview.js";
2
3
  import { HudThemePreview } from "./hudThemePreview.js";
3
4
  import { IconsPreview } from "./iconsPreview.js";
4
5
  import { StreetGeometryPreview } from "./streetGeometryPreview.js";
@@ -20,6 +21,11 @@ import { ScrollRailPreview } from "./scrollRailPreview.js";
20
21
  */
21
22
  export const PREVIEW_FIXTURES = {
22
23
  ScrollRailPreview: { name: "ScrollRailPreview", description: "Reachable caller-owned settings categories in shared scrolling chrome.", component: ScrollRailPreview },
24
+ HudChromePreview: {
25
+ name: "HudChromePreview",
26
+ description: "Shared HUD chrome under the default root theme: frame, vitals, action bar, window, dialogs, buttons.",
27
+ component: HudChromePreview,
28
+ },
23
29
  HudThemePreview: {
24
30
  name: "HudThemePreview",
25
31
  description: "Every HudTheme genre preset × atomic bars + a themed frame + a slot row.",