@jgengine/react 0.22.0 → 0.24.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 +110 -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 +254 -37
  69. package/skills/jgengine-ui/capabilities.md +58 -0
  70. package/skills/jgengine-ui/reference-primitives.md +4 -4
@@ -0,0 +1,119 @@
1
+ import { useInsertionEffect } from "react";
2
+ import { HUD_FONT_STACK } from "./hudTokens.js";
3
+ const ms = (speed, fallback) => `calc(var(--jg-motion-${speed}, ${fallback}ms) * var(--jg-motion-scale, 1))`;
4
+ const FAST = ms("fast", 120);
5
+ const BASE = ms("base", 180);
6
+ const SLOW = ms("slow", 320);
7
+ const EASE = "var(--jg-ease, cubic-bezier(0.2, 0, 0, 1))";
8
+ const EASE_OUT = "var(--jg-ease-out, cubic-bezier(0.16, 1, 0.3, 1))";
9
+ const SPRING = "var(--jg-ease-spring, cubic-bezier(0.34, 1.56, 0.64, 1))";
10
+ const HIGHLIGHT = "var(--jg-highlight, inset 0 1px 0 rgba(255,255,255,0.08))";
11
+ const RAISED = "var(--jg-elevation-raised, 0 1px 2px rgba(0,0,0,0.45), 0 4px 12px -2px rgba(0,0,0,0.4))";
12
+ const OVERLAY = "var(--jg-elevation-overlay, 0 2px 4px rgba(0,0,0,0.4), 0 10px 24px -4px rgba(0,0,0,0.5))";
13
+ const MODAL = "var(--jg-elevation-modal, 0 4px 8px rgba(0,0,0,0.35), 0 24px 56px -12px rgba(0,0,0,0.7))";
14
+ const FOCUS = "var(--jg-focus-ring, 0 0 0 2px #0a0c10, 0 0 0 4px var(--jg-accent, #38bdf8))";
15
+ const ENABLED = ':not(:disabled, [aria-disabled="true"])';
16
+ const KILL_MOTION = "animation-duration: 1ms !important; animation-delay: 0ms !important; animation-iteration-count: 1 !important; transition-duration: 1ms !important; transition-delay: 0ms !important;";
17
+ /**
18
+ * The engine stylesheet: every `jg-*` keyframe the shared blocks and registry components animate
19
+ * with, the `[data-jg-*]` interaction-state rules behind {@link HudButton} / {@link HudPanel}, the
20
+ * quiet hover/press/focus defaults for plain buttons inside the HUD root, and reduced-motion
21
+ * handling (`prefers-reduced-motion` or `[data-reduced-motion="true"]` zero `--jg-motion-scale`).
22
+ * Generic HUD rules sit in `:where()` so any game selector wins. The shell injects it; games that
23
+ * mount outside the shell import `@jgengine/react/styles.css`, which holds the same text.
24
+ */
25
+ export const ENGINE_STYLESHEET = `/* @jgengine/react engine stylesheet */
26
+ :root { --jg-motion-scale: 1; }
27
+ @media (prefers-reduced-motion: reduce) { :root { --jg-motion-scale: 0; } }
28
+ [data-reduced-motion="true"] { --jg-motion-scale: 0; }
29
+ @media (prefers-reduced-motion: reduce) {
30
+ [data-jg-hud] *, [data-jg-hud] *::before, [data-jg-hud] *::after { ${KILL_MOTION} }
31
+ }
32
+ [data-reduced-motion="true"] *, [data-reduced-motion="true"] *::before, [data-reduced-motion="true"] *::after { ${KILL_MOTION} }
33
+
34
+ @keyframes jg-fade-in { from { opacity: 0; } to { opacity: 1; } }
35
+ @keyframes jg-fade-out { from { opacity: 1; } to { opacity: 0; } }
36
+ @keyframes jg-panel-in { from { opacity: 0; transform: translateY(6px) scale(0.98); } to { opacity: 1; transform: none; } }
37
+ @keyframes jg-panel-out { from { opacity: 1; transform: none; } to { opacity: 0; transform: translateY(4px) scale(0.98); } }
38
+ @keyframes jg-slide-up { from { transform: translateY(14px); opacity: 0; } to { transform: translateY(0); opacity: 1; } }
39
+ @keyframes jg-slide-down { from { transform: translateY(-18px); opacity: 0; } to { transform: translateY(0); opacity: 1; } }
40
+ @keyframes jg-pop { 0% { transform: scale(0.4); opacity: 0; } 60% { transform: scale(1.18); opacity: 1; } 100% { transform: scale(1); opacity: 1; } }
41
+ @keyframes jg-flash { 0% { opacity: 0.9; } 100% { opacity: 0; } }
42
+ @keyframes jg-sheen { from { background-position: -160px 0; } to { background-position: 260px 0; } }
43
+ @keyframes jg-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.55; } }
44
+ @keyframes jg-ready-glow { 0%, 100% { filter: brightness(1); } 50% { filter: brightness(1.45); } }
45
+ @keyframes jg-shake { 0%, 100% { transform: translateX(0); } 20% { transform: translateX(-3px); } 40% { transform: translateX(3px); } 60% { transform: translateX(-2px); } 80% { transform: translateX(2px); } }
46
+ @keyframes jg-float-up { from { transform: translateY(0); opacity: 1; } to { transform: translateY(-46px); opacity: 0; } }
47
+ @keyframes jg-spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
48
+ @keyframes jg-delta-up { 0% { color: var(--jg-success, #3fa34d); text-shadow: 0 0 10px var(--jg-success, #3fa34d); scale: 1.12; } 100% { scale: 1; } }
49
+ @keyframes jg-delta-down { 0% { color: var(--jg-danger, #e5484d); text-shadow: 0 0 10px var(--jg-danger, #e5484d); scale: 0.94; } 100% { scale: 1; } }
50
+
51
+ [data-jg-hud] { font-family: var(--jg-font-body, ${HUD_FONT_STACK}); -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; text-rendering: optimizeLegibility; }
52
+ :where([data-jg-hud]) :where(button, [role="button"], a[href], summary, [data-jg-interactive]):focus-visible { outline: 2px solid var(--jg-accent, #38bdf8); outline-offset: 2px; }
53
+ :where([data-jg-hud]) :where(button, [role="button"]) { transition: scale ${FAST} ${EASE}, filter ${FAST} ${EASE}; }
54
+ @media (hover: hover) {
55
+ :where([data-jg-hud]) :where(button, [role="button"]):where(${ENABLED}):hover { filter: brightness(1.08); }
56
+ }
57
+ :where([data-jg-hud]) :where(button, [role="button"]):where(${ENABLED}):active { scale: var(--jg-press-scale, 0.97); }
58
+
59
+ [data-jg-button] { appearance: none; box-sizing: border-box; position: relative; isolation: isolate; display: inline-flex; align-items: center; justify-content: center; gap: var(--jg-space-sm, 8px); min-height: 36px; padding: 0 var(--jg-space-lg, 16px); margin: 0; font-family: var(--jg-font-body, ${HUD_FONT_STACK}); font-size: var(--jg-type-md, 14px); font-weight: 600; line-height: 1; letter-spacing: 0.01em; white-space: nowrap; color: var(--jg-text, #f5f7fa); background: linear-gradient(180deg, rgba(255,255,255,0.10), rgba(255,255,255,0.03)), var(--jg-slot-bg, rgba(12,15,21,0.78)); border: var(--jg-slot-border, 1px solid rgba(255,255,255,0.13)); border-radius: var(--jg-slot-radius, 8px); box-shadow: ${HIGHLIGHT}, ${RAISED}; cursor: pointer; user-select: none; -webkit-tap-highlight-color: transparent; transition: translate ${FAST} ${EASE}, scale ${FAST} ${SPRING}, box-shadow ${FAST} ${EASE}, border-color ${FAST} ${EASE}, background-color ${FAST} ${EASE}; }
60
+ [data-jg-button]::after { content: ""; position: absolute; inset: 0; z-index: -1; border-radius: inherit; background: var(--jg-hover-overlay, rgba(255,255,255,0.07)); opacity: 0; pointer-events: none; transition: opacity ${FAST} ${EASE}; }
61
+ @media (hover: hover) {
62
+ [data-jg-button]${ENABLED}:hover { translate: 0 var(--jg-hover-lift, -1px); box-shadow: ${HIGHLIGHT}, ${OVERLAY}; }
63
+ [data-jg-button]${ENABLED}:hover::after { opacity: 1; }
64
+ }
65
+ [data-jg-button]${ENABLED}:active { translate: 0 0; scale: var(--jg-press-scale, 0.97); box-shadow: ${HIGHLIGHT}, ${RAISED}; transition-duration: calc(60ms * var(--jg-motion-scale, 1)); }
66
+ [data-jg-button]${ENABLED}:active::after { opacity: 1; background: var(--jg-press-overlay, rgba(0,0,0,0.14)); }
67
+ [data-jg-button]:focus-visible { outline: none; box-shadow: ${FOCUS}, ${RAISED}; }
68
+ [data-jg-button]:is(:disabled, [aria-disabled="true"]) { opacity: var(--jg-disabled-opacity, 0.45); cursor: not-allowed; box-shadow: none; }
69
+ [data-jg-button][data-variant="primary"] { color: var(--jg-surface-deep, #0a0c10); background: linear-gradient(180deg, rgba(255,255,255,0.22), rgba(255,255,255,0) 60%), var(--jg-accent, #38bdf8); border: 1px solid rgba(255,255,255,0.28); }
70
+ [data-jg-button][data-variant="danger"] { color: #fff; background: linear-gradient(180deg, rgba(255,255,255,0.16), rgba(255,255,255,0) 60%), var(--jg-danger, #e5484d); border: 1px solid rgba(255,255,255,0.22); }
71
+ [data-jg-button][data-variant="ghost"] { background: transparent; border-color: transparent; box-shadow: none; }
72
+ [data-jg-button][data-variant="ghost"]${ENABLED}:hover { box-shadow: none; }
73
+ [data-jg-button][data-size="sm"] { min-height: 28px; padding: 0 var(--jg-space-md, 12px); font-size: var(--jg-type-sm, 13px); }
74
+ [data-jg-button][data-size="lg"] { min-height: 44px; padding: 0 var(--jg-space-xl, 24px); font-size: var(--jg-type-lg, 16px); }
75
+ [data-jg-button][data-size="icon"] { min-height: 0; width: 28px; height: 28px; padding: 0; font-size: var(--jg-type-sm, 13px); }
76
+ [data-jg-button][data-block] { display: flex; width: 100%; }
77
+
78
+ [data-jg-panel] { box-sizing: border-box; color: var(--jg-text, #f5f7fa); font-family: var(--jg-font-body, ${HUD_FONT_STACK}); background: var(--jg-frame-bg, rgba(12,15,21,0.9)); border: var(--jg-frame-border, 1px solid rgba(255,255,255,0.13)); border-radius: var(--jg-frame-radius, 10px); box-shadow: var(--jg-frame-glow, ${HIGHLIGHT}, ${RAISED}); }
79
+ [data-jg-panel][data-elevation="overlay"] { box-shadow: var(--jg-frame-glow, ${HIGHLIGHT}), ${OVERLAY}; }
80
+ [data-jg-panel][data-elevation="modal"] { box-shadow: var(--jg-frame-glow, ${HIGHLIGHT}), ${MODAL}; }
81
+ [data-jg-panel][data-state="open"] { animation: jg-panel-in ${BASE} ${EASE_OUT} both; }
82
+ [data-jg-panel][data-state="closed"] { animation: jg-panel-out ${FAST} ${EASE} both; pointer-events: none; }
83
+ [data-jg-window][data-state="open"] { animation: jg-panel-in ${BASE} ${EASE_OUT} both; }
84
+ [data-jg-window][data-state="closed"] { animation: jg-panel-out ${FAST} ${EASE} both; pointer-events: none !important; }
85
+ [data-jg-panel-header] { display: flex; align-items: center; justify-content: space-between; gap: var(--jg-space-sm, 8px); font-size: var(--jg-type-xs, 12px); font-weight: 650; letter-spacing: var(--jg-tracking-label, 0.08em); text-transform: uppercase; color: var(--jg-text-dim, #94a3b8); }
86
+
87
+ [data-modal-backdrop] { animation: jg-fade-in ${BASE} ${EASE_OUT} both; -webkit-backdrop-filter: blur(3px); backdrop-filter: blur(3px); }
88
+ [data-modal-host] > [role="dialog"] { animation: jg-panel-in ${BASE} ${EASE_OUT} both; }
89
+ [data-toast] { animation: jg-slide-up ${BASE} ${EASE_OUT} both; }
90
+ [data-levelup] { animation: jg-pop ${SLOW} ${SPRING} both; }
91
+ [data-jg-delta="up"] { display: inline-block; animation: jg-delta-up ${SLOW} ${EASE_OUT}; }
92
+ [data-jg-delta="down"] { display: inline-block; animation: jg-delta-down ${SLOW} ${EASE_OUT}; }
93
+ [data-action-id] { transition: translate ${FAST} ${EASE}, scale ${FAST} ${SPRING}, border-color ${FAST} ${EASE}, box-shadow ${FAST} ${EASE}; }
94
+ @media (hover: hover) { [data-action-id][data-enabled]:hover { translate: 0 var(--jg-hover-lift, -1px); } }
95
+ [data-action-id][data-enabled]:active { translate: 0 0; scale: var(--jg-press-scale, 0.97); }
96
+ [data-action-id]:focus-visible { outline: none; box-shadow: ${FOCUS}; }
97
+ `;
98
+ const STYLE_ID = "jg-engine-styles";
99
+ /** Injects {@link ENGINE_STYLESHEET} into `doc` once. Safe to call repeatedly and during SSR (no-op). */
100
+ export function ensureEngineStyles(doc = typeof document === "undefined" ? undefined : document) {
101
+ if (doc === undefined || doc.getElementById(STYLE_ID) !== null)
102
+ return;
103
+ const style = doc.createElement("style");
104
+ style.id = STYLE_ID;
105
+ style.setAttribute("data-jg-engine-styles", "");
106
+ style.textContent = ENGINE_STYLESHEET;
107
+ doc.head.prepend(style);
108
+ }
109
+ /**
110
+ * Ensures the engine stylesheet is in the document before the calling component paints. Engine
111
+ * primitives call it, so they animate and show interaction states even outside the shell.
112
+ *
113
+ * @capability engine-styles inject the engine stylesheet (jg-* keyframes, interaction states, reduced motion) once
114
+ */
115
+ export function useEngineStyles() {
116
+ useInsertionEffect(() => {
117
+ ensureEngineStyles();
118
+ }, []);
119
+ }
@@ -1,5 +1,6 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useEffect, useReducer } from "react";
3
+ import { hudType } from "./hudTokens.js";
3
4
  /**
4
5
  * Subscribe a component to a fast-travel network's discovery changes. Returns the
5
6
  * network unchanged; the hook only forces a re-render when a point is discovered
@@ -73,11 +74,11 @@ export function FastTravelMenu({ network, from, onTravel, currentId, title = "Fa
73
74
  borderRadius: 6,
74
75
  background: "transparent",
75
76
  color: "rgba(226,232,240,0.85)",
76
- fontSize: 11,
77
+ fontSize: hudType("xs"),
77
78
  padding: "3px 8px",
78
79
  cursor: "pointer",
79
80
  }, children: "\u2715" })) : null] }), destinations.length === 0 ? (_jsx("p", { style: { margin: 0, padding: "16px 12px", fontSize: 12, color: "rgba(148,163,184,0.8)" }, children: emptyLabel })) : (_jsx("div", { style: { overflowY: "auto", padding: 6, display: "flex", flexDirection: "column", gap: 8 }, children: groups.map((group) => (_jsxs("div", { "data-region": group.region ?? "", children: [group.region !== null ? (_jsx("div", { style: {
80
- fontSize: 10,
81
+ fontSize: hudType("xs"),
81
82
  fontWeight: 700,
82
83
  letterSpacing: 1,
83
84
  textTransform: "uppercase",
@@ -98,6 +99,6 @@ export function FastTravelMenu({ network, from, onTravel, currentId, title = "Fa
98
99
  color: "#e2e8f0",
99
100
  textAlign: "left",
100
101
  cursor: isCurrent ? "default" : "pointer",
101
- }, children: [_jsx("span", { style: { fontSize: 18, width: 22, textAlign: "center", flexShrink: 0 }, children: point.icon ?? "📍" }), _jsxs("span", { style: { display: "flex", flexDirection: "column", minWidth: 0, flex: 1 }, children: [_jsx("span", { style: { fontSize: 13, fontWeight: 600 }, children: point.name }), isCurrent ? (_jsx("span", { style: { fontSize: 10, color: "rgba(56,189,248,0.85)" }, children: "You are here" })) : null] }), distance !== null && !isCurrent ? (_jsx("span", { style: { fontSize: 11, color: "rgba(148,163,184,0.75)", whiteSpace: "nowrap" }, children: distance })) : null] }) }, point.id));
102
+ }, children: [_jsx("span", { style: { fontSize: 18, width: 22, textAlign: "center", flexShrink: 0 }, children: point.icon ?? "📍" }), _jsxs("span", { style: { display: "flex", flexDirection: "column", minWidth: 0, flex: 1 }, children: [_jsx("span", { style: { fontSize: 13, fontWeight: 600 }, children: point.name }), isCurrent ? (_jsx("span", { style: { fontSize: hudType("xs"), color: "rgba(56,189,248,0.85)" }, children: "You are here" })) : null] }), distance !== null && !isCurrent ? (_jsx("span", { style: { fontSize: hudType("xs"), color: "rgba(148,163,184,0.75)", whiteSpace: "nowrap" }, children: distance })) : null] }) }, point.id));
102
103
  }) })] }, group.region ?? "__none__"))) }))] }));
103
104
  }
package/dist/hud.js CHANGED
@@ -5,6 +5,7 @@ import { iconForItemId } from "./gameIcons.js";
5
5
  import { hudFrameStyle } from "./hudFrame.js";
6
6
  import { IconTreatment, schoolForItem } from "./iconTreatment.js";
7
7
  import { useCurrency, useEntityStat, useGameClock, useGameStore, useInventory, localPlayerEntity } from "./hooks.js";
8
+ import { hudType } from "./hudTokens.js";
8
9
  /**
9
10
  * Optional HUD building blocks bound to live game state — not a finished game face. Every game
10
11
  * owns custom presentation (see AGENTS.md); import these only as temporary scaffolding, previews,
@@ -47,7 +48,7 @@ export function StatBar({ statId = "health", entityId, tone = "health", label, s
47
48
  const range = stat.max - stat.min;
48
49
  const fraction = range <= 0 ? 0 : Math.max(0, Math.min(1, (stat.current - stat.min) / range));
49
50
  const [from, to] = (TONES[tone] ?? TONES.neutral)(fraction);
50
- return (_jsxs("div", { className: className, style: { ...PANEL, padding: "6px 8px", width, ...style }, "data-stat": statId, children: [(label !== undefined || icon !== undefined || showValue) && (_jsxs("div", { style: { display: "flex", alignItems: "center", gap: 6, marginBottom: 4, fontSize: 11, fontWeight: 600, letterSpacing: 0.3 }, children: [icon, _jsx("span", { style: { textTransform: "uppercase", opacity: 0.75 }, children: label ?? statId }), showValue ? (_jsxs("span", { style: { marginLeft: "auto", fontVariantNumeric: "tabular-nums", opacity: 0.9 }, children: [Math.round(stat.current), _jsxs("span", { style: { opacity: 0.5 }, children: [" / ", Math.round(stat.max)] })] })) : null] })), _jsx("div", { style: { height: 8, borderRadius: 999, background: "rgba(255,255,255,0.10)", overflow: "hidden" }, children: _jsx("div", { style: { width: `${fraction * 100}%`, height: "100%", borderRadius: 999, background: `linear-gradient(90deg, ${from}, ${to})`, transition: "width 160ms ease" } }) })] }));
51
+ return (_jsxs("div", { className: className, style: { ...PANEL, padding: "6px 8px", width, ...style }, "data-stat": statId, children: [(label !== undefined || icon !== undefined || showValue) && (_jsxs("div", { style: { display: "flex", alignItems: "center", gap: 6, marginBottom: 4, fontSize: hudType("xs"), fontWeight: 600, letterSpacing: 0.3 }, children: [icon, _jsx("span", { style: { textTransform: "uppercase", opacity: 0.75 }, children: label ?? statId }), showValue ? (_jsxs("span", { style: { marginLeft: "auto", fontVariantNumeric: "tabular-nums", opacity: 0.9 }, children: [Math.round(stat.current), _jsxs("span", { style: { opacity: 0.5 }, children: [" / ", Math.round(stat.max)] })] })) : null] })), _jsx("div", { style: { height: 8, borderRadius: 999, background: "rgba(255,255,255,0.10)", overflow: "hidden" }, children: _jsx("div", { style: { width: `${fraction * 100}%`, height: "100%", borderRadius: 999, background: `linear-gradient(90deg, ${from}, ${to})`, transition: "width 160ms ease" } }) })] }));
51
52
  }
52
53
  /**
53
54
  * A numbered hotbar bound to an inventory — painted iconed slots (a `GameIcon` glyph over a
@@ -73,7 +74,7 @@ export function Hotbar({ inventoryId, activeSlot, keys, slotSize = 46, itemIcon,
73
74
  height: slotSize,
74
75
  position: "relative",
75
76
  borderColor: active ? "rgba(56,189,248,0.9)" : "rgba(255,255,255,0.10)",
76
- }, children: _jsx("span", { style: { position: "absolute", top: 2, left: 4, fontSize: 9, fontWeight: 700, opacity: 0.55 }, children: keycap }) })) }, index));
77
+ }, children: _jsx("span", { style: { position: "absolute", top: 2, left: 4, fontSize: hudType("xs"), fontWeight: 700, opacity: 0.55 }, children: keycap }) })) }, index));
77
78
  }) }));
78
79
  }
79
80
  /**
@@ -99,7 +100,7 @@ export function Speedometer({ entityId, scale = 3.6, unit = "km/h", max = 60, si
99
100
  const large = sweep * (toFrac - fromFrac) > 180 ? 1 : 0;
100
101
  return `M ${cx + radius * Math.cos(a0)} ${cy + radius * Math.sin(a0)} A ${radius} ${radius} 0 ${large} 1 ${cx + radius * Math.cos(a1)} ${cy + radius * Math.sin(a1)}`;
101
102
  };
102
- return (_jsxs("div", { className: className, style: { ...PANEL, width: size, padding: 6, textAlign: "center", ...style }, children: [_jsxs("svg", { width: size - 12, height: (size - 12) * 0.72, viewBox: `0 0 ${size} ${size * 0.72}`, style: { display: "block", margin: "0 auto" }, children: [_jsx("path", { d: arc(0, 1), fill: "none", stroke: "rgba(255,255,255,0.12)", strokeWidth: 7, strokeLinecap: "round" }), _jsx("path", { d: arc(0, fraction), fill: "none", stroke: fraction > 0.85 ? "#f87171" : "#38bdf8", strokeWidth: 7, strokeLinecap: "round" })] }), _jsx("div", { style: { marginTop: -8, fontVariantNumeric: "tabular-nums", fontWeight: 700, fontSize: 18, lineHeight: 1 }, children: Math.round(speed) }), _jsx("div", { style: { fontSize: 9, opacity: 0.6, letterSpacing: 0.4, textTransform: "uppercase" }, children: unit })] }));
103
+ return (_jsxs("div", { className: className, style: { ...PANEL, width: size, padding: 6, textAlign: "center", ...style }, children: [_jsxs("svg", { width: size - 12, height: (size - 12) * 0.72, viewBox: `0 0 ${size} ${size * 0.72}`, style: { display: "block", margin: "0 auto" }, children: [_jsx("path", { d: arc(0, 1), fill: "none", stroke: "rgba(255,255,255,0.12)", strokeWidth: 7, strokeLinecap: "round" }), _jsx("path", { d: arc(0, fraction), fill: "none", stroke: fraction > 0.85 ? "#f87171" : "#38bdf8", strokeWidth: 7, strokeLinecap: "round" })] }), _jsx("div", { style: { marginTop: -8, fontVariantNumeric: "tabular-nums", fontWeight: 700, fontSize: 18, lineHeight: 1 }, children: Math.round(speed) }), _jsx("div", { style: { fontSize: hudType("xs"), opacity: 0.6, letterSpacing: 0.4, textTransform: "uppercase" }, children: unit })] }));
103
104
  }
104
105
  /**
105
106
  * A time-of-day clock reading the sim calendar — `Day N · HH:MM`, 24h or 12h. `controls` adds
@@ -119,13 +120,13 @@ export function Clock({ format = "24h", showDay = true, controls = false, style,
119
120
  }
120
121
  return `${hour.toString().padStart(2, "0")}:${mm}`;
121
122
  }, [hour, minute, format]);
122
- return (_jsxs("div", { className: className, style: { ...PANEL, padding: "5px 10px", display: "inline-flex", alignItems: "center", gap: 8, fontVariantNumeric: "tabular-nums", ...style }, children: [showDay ? _jsxs("span", { style: { fontSize: 11, opacity: 0.6 }, children: ["Day ", day + 1] }) : null, _jsx("span", { style: { fontWeight: 700, fontSize: 14 }, children: label }), controls ? (_jsxs("span", { style: { display: "inline-flex", gap: 3, marginLeft: 4 }, children: [_jsx(ClockPill, { label: clock.paused ? "▶" : "❚❚", active: false, onClick: () => (clock.paused ? clock.controls.play() : clock.controls.pause()) }), clock.speeds.map((s) => (_jsx(ClockPill, { label: `${s}×`, active: !clock.paused && clock.speed === s, onClick: () => clock.controls.setSpeed(s) }, s)))] })) : null] }));
123
+ return (_jsxs("div", { className: className, style: { ...PANEL, padding: "5px 10px", display: "inline-flex", alignItems: "center", gap: 8, fontVariantNumeric: "tabular-nums", ...style }, children: [showDay ? _jsxs("span", { style: { fontSize: hudType("xs"), opacity: 0.6 }, children: ["Day ", day + 1] }) : null, _jsx("span", { style: { fontWeight: 700, fontSize: 14 }, children: label }), controls ? (_jsxs("span", { style: { display: "inline-flex", gap: 3, marginLeft: 4 }, children: [_jsx(ClockPill, { label: clock.paused ? "▶" : "❚❚", active: false, onClick: () => (clock.paused ? clock.controls.play() : clock.controls.pause()) }), clock.speeds.map((s) => (_jsx(ClockPill, { label: `${s}×`, active: !clock.paused && clock.speed === s, onClick: () => clock.controls.setSpeed(s) }, s)))] })) : null] }));
123
124
  }
124
125
  function ClockPill({ label, active, onClick }) {
125
126
  return (_jsx("button", { type: "button", onClick: onClick, style: {
126
127
  pointerEvents: "auto",
127
128
  cursor: "pointer",
128
- fontSize: 10,
129
+ fontSize: hudType("xs"),
129
130
  fontWeight: 600,
130
131
  padding: "1px 6px",
131
132
  borderRadius: 6,
@@ -139,7 +140,7 @@ function ClockPill({ label, active, onClick }) {
139
140
  * (enemies remaining, timer). Pure display: pass the current `wave` and whatever subtitle you track.
140
141
  */
141
142
  export function WaveBanner({ wave, label = "Wave", subtitle, style, className, }) {
142
- return (_jsxs("div", { className: className, style: { ...PANEL, padding: "6px 16px", textAlign: "center", display: "inline-block", ...style }, children: [_jsx("div", { style: { fontSize: 10, letterSpacing: 2, textTransform: "uppercase", opacity: 0.65 }, children: label }), _jsx("div", { style: { fontSize: 24, fontWeight: 800, lineHeight: 1.1 }, children: wave }), subtitle !== undefined ? _jsx("div", { style: { fontSize: 11, opacity: 0.7, marginTop: 2 }, children: subtitle }) : null] }));
143
+ return (_jsxs("div", { className: className, style: { ...PANEL, padding: "6px 16px", textAlign: "center", display: "inline-block", ...style }, children: [_jsx("div", { style: { fontSize: hudType("xs"), letterSpacing: 2, textTransform: "uppercase", opacity: 0.65 }, children: label }), _jsx("div", { style: { fontSize: 24, fontWeight: 800, lineHeight: 1.1 }, children: wave }), subtitle !== undefined ? _jsx("div", { style: { fontSize: hudType("xs"), opacity: 0.7, marginTop: 2 }, children: subtitle }) : null] }));
143
144
  }
144
145
  /** A currency counter — an icon (emoji/char, default a coin) plus the live amount for `currencyId`. *
145
146
  * @capability hud-coins currency counter widget bound to a currency id
@@ -0,0 +1,29 @@
1
+ import { type ButtonHTMLAttributes, type ReactNode } from "react";
2
+ import { type HudInteractListener } from "./hudInteract.js";
3
+ /** Visual weight of a {@link HudButton}. */
4
+ export type HudButtonVariant = "secondary" | "primary" | "danger" | "ghost";
5
+ /** Size of a {@link HudButton}; `icon` is a 28px square. */
6
+ export type HudButtonSize = "sm" | "md" | "lg" | "icon";
7
+ /** Props for {@link HudButton}: native button attributes plus theme-driven variant, size, and feedback hooks. */
8
+ export interface HudButtonProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, "type"> {
9
+ variant?: HudButtonVariant;
10
+ size?: HudButtonSize;
11
+ /** Stretch to the container width. */
12
+ block?: boolean;
13
+ /** Native button type. Default `"button"`. */
14
+ type?: "button" | "submit" | "reset";
15
+ /** Feedback cue name, rendered as `data-jg-cue` and passed to `onInteract`. */
16
+ cue?: string;
17
+ /** Called on hover, press, focus, and activate — the seam a UI sound system hooks. */
18
+ onInteract?: HudInteractListener;
19
+ children?: ReactNode;
20
+ }
21
+ /**
22
+ * Themed HUD button: reads the `HudTheme` slot, accent, elevation, motion, and state tokens, and
23
+ * gets hover lift, press scale, a focus-visible ring, and a disabled state from the engine
24
+ * stylesheet. `className` and `style` restyle it per game. Interactions report to `onInteract` and
25
+ * the nearest `HudInteractProvider`.
26
+ *
27
+ * @capability hud-button themed button with hover lift, press scale, focus ring, disabled state, and an interaction cue seam
28
+ */
29
+ export declare const HudButton: import("react").ForwardRefExoticComponent<HudButtonProps & import("react").RefAttributes<HTMLButtonElement>>;
@@ -0,0 +1,31 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { forwardRef } from "react";
3
+ import { useEngineStyles } from "./engineStyles.js";
4
+ import { useHudInteract } from "./hudInteract.js";
5
+ /**
6
+ * Themed HUD button: reads the `HudTheme` slot, accent, elevation, motion, and state tokens, and
7
+ * gets hover lift, press scale, a focus-visible ring, and a disabled state from the engine
8
+ * stylesheet. `className` and `style` restyle it per game. Interactions report to `onInteract` and
9
+ * the nearest `HudInteractProvider`.
10
+ *
11
+ * @capability hud-button themed button with hover lift, press scale, focus ring, disabled state, and an interaction cue seam
12
+ */
13
+ export const HudButton = forwardRef(function HudButton({ variant = "secondary", size = "md", block, type = "button", cue, onInteract, onClick, onPointerEnter, onPointerDown, onFocus, disabled, children, ...rest }, ref) {
14
+ useEngineStyles();
15
+ const report = useHudInteract("button", cue, onInteract);
16
+ return (_jsx("button", { ...rest, ref: ref, type: type, disabled: disabled, "data-jg-button": "", "data-variant": variant, "data-size": size, "data-block": block === true ? "" : undefined, "data-jg-cue": cue, onPointerEnter: (event) => {
17
+ if (disabled !== true)
18
+ report("hover");
19
+ onPointerEnter?.(event);
20
+ }, onPointerDown: (event) => {
21
+ if (disabled !== true)
22
+ report("press");
23
+ onPointerDown?.(event);
24
+ }, onFocus: (event) => {
25
+ report("focus");
26
+ onFocus?.(event);
27
+ }, onClick: (event) => {
28
+ report("activate");
29
+ onClick?.(event);
30
+ }, children: children }));
31
+ });
@@ -0,0 +1,8 @@
1
+ /**
2
+ * The shared HUD chrome under the shell's default root theme, from static values: a glass frame with
3
+ * vitals and a counter, an action bar, a window, a confirm dialog, a pause menu, and the button
4
+ * variants. Screenshots identically; the before/after evidence for theme and primitive changes.
5
+ */
6
+ export declare function HudChromePreview({ className }: {
7
+ className?: string;
8
+ }): import("react").JSX.Element;
@@ -0,0 +1,27 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { actionCooldownFromFraction, actionCost, resolveAction } from "@jgengine/core/ui/actionModel";
3
+ import { ActionButton } from "./actionHud.js";
4
+ import { HealthBar, ManaBar } from "./bars.js";
5
+ import { HudButton } from "./hudButton.js";
6
+ import { HudFrame } from "./hudFrame.js";
7
+ import { HudPanel } from "./hudPanel.js";
8
+ import { HudThemeRoot } from "./hudThemeRoot.js";
9
+ import { ConfirmDialog, PauseMenu } from "./modals.js";
10
+ import { Window } from "./panels.js";
11
+ import { TweenNumber } from "./tweenNumber.js";
12
+ const ACTIONS = [
13
+ resolveAction({ id: "slash", label: "Slash", icon: "⚔", hotkey: "1" }),
14
+ resolveAction({ id: "guard", label: "Guard", icon: "⛨", hotkey: "2", active: true }),
15
+ resolveAction({ id: "bolt", label: "Bolt", icon: "⚡", hotkey: "3", costs: [actionCost("mana", 30, 50)] }),
16
+ resolveAction({ id: "heal", label: "Heal", icon: "✚", hotkey: "4", cooldown: actionCooldownFromFraction(4200, 0.6) }),
17
+ resolveAction({ id: "nova", label: "Nova", icon: "✺", hotkey: "5", costs: [actionCost("mana", 80, 50)] }),
18
+ ];
19
+ const noop = () => undefined;
20
+ /**
21
+ * The shared HUD chrome under the shell's default root theme, from static values: a glass frame with
22
+ * vitals and a counter, an action bar, a window, a confirm dialog, a pause menu, and the button
23
+ * variants. Screenshots identically; the before/after evidence for theme and primitive changes.
24
+ */
25
+ export function HudChromePreview({ className }) {
26
+ return (_jsx("div", { className: className, style: { position: "relative", width: "100%", height: "100%", minHeight: 720, overflow: "hidden", background: "radial-gradient(ellipse at 30% 20%, #3b5a46, #1b2a24 55%, #0f1714)" }, children: _jsxs(HudThemeRoot, { children: [_jsx("div", { style: { position: "absolute", left: 24, top: 24, width: 280 }, children: _jsx(HudFrame, { title: "Warden \u00B7 Lv 12", aside: _jsx(TweenNumber, { value: 12480 }), padding: 12, children: _jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 6 }, children: [_jsx(HealthBar, { value: 78, max: 100, label: "HP", width: "100%" }), _jsx(ManaBar, { value: 50, max: 80, label: "MP", width: "100%" })] }) }) }), _jsx("div", { style: { position: "absolute", left: 24, bottom: 24, display: "flex", gap: 8 }, children: ACTIONS.map((action, index) => (_jsx(ActionButton, { action: action, focused: index === 0 }, action.id))) }), _jsx(Window, { title: "Inventory", x: 340, y: 24, width: 260, closable: true, onClose: noop, children: _jsx("div", { style: { display: "grid", gridTemplateColumns: "repeat(5, 1fr)", gap: 6 }, children: Array.from({ length: 10 }, (_, index) => (_jsx("div", { style: { aspectRatio: "1", background: "var(--jg-slot-bg)", border: "var(--jg-slot-border)", borderRadius: "var(--jg-slot-radius)" } }, index))) }) }), _jsx("div", { style: { position: "absolute", left: 640, top: 24 }, children: _jsx(ConfirmDialog, { title: "Abandon the expedition?", body: "Your haul stays in the wagon, but the crew returns home.", confirmLabel: "Abandon", danger: true, onConfirm: noop, onCancel: noop }) }), _jsx("div", { style: { position: "absolute", left: 1040, top: 24 }, children: _jsx(PauseMenu, { onResume: noop, items: [{ id: "settings", label: "Settings", onSelect: noop }, { id: "quit", label: "Quit to title", onSelect: noop, danger: true }] }) }), _jsx("div", { style: { position: "absolute", left: 340, top: 300 }, children: _jsx(HudPanel, { title: "Buttons", padding: 14, children: _jsxs("div", { style: { display: "flex", gap: 8, flexWrap: "wrap", width: 420 }, children: [_jsx(HudButton, { variant: "primary", children: "Deploy" }), _jsx(HudButton, { children: "Inspect" }), _jsx(HudButton, { variant: "ghost", children: "Skip" }), _jsx(HudButton, { variant: "danger", children: "Scrap" }), _jsx(HudButton, { disabled: true, children: "Locked" }), _jsx(HudButton, { size: "sm", children: "Small" })] }) }) })] }) }));
27
+ }
package/dist/hudFrame.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { hudElevation, hudFont, hudType } from "./hudTokens.js";
2
3
  /**
3
4
  * Bare style object for a HUD frame's look (no wrapper element) — spread it onto
4
5
  * a widget that already owns its element when it just needs the chrome CSS. The
@@ -18,7 +19,7 @@ export function hudFrameStyle(variation = "glass", shape = "rounded") {
18
19
  borderImage: "var(--jg-frame-image, none) var(--jg-frame-slice, 0) / var(--jg-frame-scale, 1) var(--jg-frame-fill, stretch)",
19
20
  cursor: "var(--jg-skin-cursor, auto)",
20
21
  color: "var(--jg-bar-text, #f4f6fb)",
21
- fontFamily: "ui-sans-serif, system-ui, -apple-system, sans-serif",
22
+ fontFamily: hudFont.body,
22
23
  }
23
24
  : variation === "retro"
24
25
  ? {
@@ -33,18 +34,19 @@ export function hudFrameStyle(variation = "glass", shape = "rounded") {
33
34
  border: "1px solid rgba(255,255,255,0.10)",
34
35
  borderRadius: 10,
35
36
  color: "#f4f6fb",
36
- fontFamily: "ui-sans-serif, system-ui, -apple-system, sans-serif",
37
- boxShadow: "0 6px 20px rgba(0,0,0,0.45)",
37
+ fontFamily: hudFont.body,
38
+ boxShadow: hudElevation("raised"),
38
39
  }
39
40
  : {
40
41
  background: "rgba(10,12,16,0.62)",
41
- backdropFilter: "blur(6px)",
42
- WebkitBackdropFilter: "blur(6px)",
43
- border: "1px solid rgba(255,255,255,0.10)",
42
+ backgroundImage: "linear-gradient(180deg, rgba(255,255,255,0.05), rgba(255,255,255,0) 45%)",
43
+ backdropFilter: "blur(8px) saturate(1.2)",
44
+ WebkitBackdropFilter: "blur(8px) saturate(1.2)",
45
+ border: "1px solid rgba(255,255,255,0.12)",
44
46
  borderRadius: 10,
45
47
  color: "#f4f6fb",
46
- fontFamily: "ui-sans-serif, system-ui, -apple-system, sans-serif",
47
- boxShadow: "0 4px 16px rgba(0,0,0,0.35)",
48
+ fontFamily: hudFont.body,
49
+ boxShadow: hudElevation("raised"),
48
50
  };
49
51
  if (shape === "circle")
50
52
  return { ...base, borderRadius: 9999 };
@@ -74,9 +76,11 @@ export function HudFrame({ variation = "glass", shape = "rounded", title, aside,
74
76
  alignItems: "center",
75
77
  gap: 8,
76
78
  marginBottom: 6,
77
- fontSize: 10,
78
- letterSpacing: 1.4,
79
+ fontSize: hudType("xs"),
80
+ fontWeight: 650,
81
+ letterSpacing: "var(--jg-tracking-label, 0.08em)",
82
+ lineHeight: 1.2,
79
83
  textTransform: "uppercase",
80
- opacity: 0.75,
84
+ opacity: 0.82,
81
85
  }, children: [_jsx("span", { children: title }), aside !== undefined ? _jsx("span", { children: aside }) : null] })) : null, children] }));
82
86
  }
@@ -0,0 +1,26 @@
1
+ import { type ReactNode } from "react";
2
+ /** What happened to an engine HUD primitive. */
3
+ export type HudInteractKind = "hover" | "press" | "activate" | "focus" | "open" | "close";
4
+ /** One interaction reported by an engine HUD primitive to an `onInteract` listener. */
5
+ export interface HudInteractEvent {
6
+ kind: HudInteractKind;
7
+ /** The primitive's `cue` (also rendered as `data-jg-cue`), naming which feedback to play. */
8
+ cue?: string;
9
+ /** Primitive family, e.g. `"button"`, `"panel"`, `"window"`, `"action"`. */
10
+ source: string;
11
+ }
12
+ /** Listener for {@link HudInteractEvent}s. */
13
+ export type HudInteractListener = (event: HudInteractEvent) => void;
14
+ /**
15
+ * Routes interactions from every engine HUD primitive below it (buttons, panels, windows, action
16
+ * slots) to one listener — the seam a UI sound or haptics system hooks without per-button wiring.
17
+ * Primitives also expose `data-jg-cue` and a per-instance `onInteract`.
18
+ *
19
+ * @capability hud-interact one listener for hover/press/activate/open/close from every engine HUD primitive
20
+ */
21
+ export declare function HudInteractProvider({ onInteract, children }: {
22
+ onInteract: HudInteractListener;
23
+ children?: ReactNode;
24
+ }): ReactNode;
25
+ /** Returns a reporter that calls the instance listener, then the nearest {@link HudInteractProvider}. */
26
+ export declare function useHudInteract(source: string, cue?: string, onInteract?: HudInteractListener): (kind: HudInteractKind) => void;
@@ -0,0 +1,28 @@
1
+ import { createContext, createElement, useCallback, useContext } from "react";
2
+ const HudInteractContext = createContext(null);
3
+ /**
4
+ * Routes interactions from every engine HUD primitive below it (buttons, panels, windows, action
5
+ * slots) to one listener — the seam a UI sound or haptics system hooks without per-button wiring.
6
+ * Primitives also expose `data-jg-cue` and a per-instance `onInteract`.
7
+ *
8
+ * @capability hud-interact one listener for hover/press/activate/open/close from every engine HUD primitive
9
+ */
10
+ export function HudInteractProvider({ onInteract, children }) {
11
+ const parent = useContext(HudInteractContext);
12
+ const listener = useCallback((event) => {
13
+ onInteract(event);
14
+ parent?.(event);
15
+ }, [onInteract, parent]);
16
+ return createElement(HudInteractContext.Provider, { value: listener }, children);
17
+ }
18
+ /** Returns a reporter that calls the instance listener, then the nearest {@link HudInteractProvider}. */
19
+ export function useHudInteract(source, cue, onInteract) {
20
+ const context = useContext(HudInteractContext);
21
+ return useCallback((kind) => {
22
+ if (onInteract === undefined && context === null)
23
+ return;
24
+ const event = cue === undefined ? { kind, source } : { kind, source, cue };
25
+ onInteract?.(event);
26
+ context?.(event);
27
+ }, [context, cue, onInteract, source]);
28
+ }
package/dist/hudLayout.js CHANGED
@@ -11,6 +11,7 @@ import { useGameLayoutMode, useRegisterLayoutRegion } from "./gameViewport.js";
11
11
  import { useOptionalGamePhase } from "./hooks.js";
12
12
  import { useHudLayoutPersist } from "./hudLayoutPersist.js";
13
13
  import { useHudViewport } from "./hudViewport.js";
14
+ import { hudFont, hudType } from "./hudTokens.js";
14
15
  /** Whether a HUD element opted into `showDuring` is visible in the current phase; `undefined` = always visible (default). */
15
16
  export function hudVisibleInPhase(showDuring, phase) {
16
17
  return showDuring === undefined || showDuring.includes(phase);
@@ -479,7 +480,7 @@ export function HudCanvas({ layout, editChord, compactScale, showDuring, classNa
479
480
  borderRadius: 4,
480
481
  background: "rgba(180, 20, 20, 0.95)",
481
482
  color: "#fff",
482
- font: "600 10px/1.4 system-ui, sans-serif",
483
+ font: `600 ${hudType("xs")}/1.4 ${hudFont.body}`,
483
484
  whiteSpace: "nowrap",
484
485
  }, children: ["\u26A0 ", o.id, " escapes viewport (", overflowEdges(o), ")"] }) }, o.id))) })) : null, editing ? _jsx(HudEditBar, { layout: layout }) : null] }) }));
485
486
  }
@@ -520,7 +521,7 @@ function HudChip({ label, anchor, children, }) {
520
521
  background: "rgba(10, 10, 14, 0.72)",
521
522
  border: "1px solid rgba(255, 255, 255, 0.22)",
522
523
  color: "rgba(255, 255, 255, 0.92)",
523
- font: "600 11px/1.2 system-ui, sans-serif",
524
+ font: `600 ${hudType("xs")}/1.2 ${hudFont.body}`,
524
525
  letterSpacing: "0.04em",
525
526
  textTransform: "uppercase",
526
527
  cursor: "pointer",
@@ -695,7 +696,7 @@ export function HudPanel({ id, anchor = "top-left", order, compact: compactMode
695
696
  borderRadius: 4,
696
697
  background: "rgba(10, 10, 14, 0.85)",
697
698
  color: "rgba(255, 255, 255, 0.9)",
698
- font: "10px/1.5 system-ui, sans-serif",
699
+ font: `${hudType("xs")}/1.5 ${hudFont.body}`,
699
700
  whiteSpace: "nowrap",
700
701
  }, children: id }), resizable ? _jsx(ResizeHandles, { axes: resizeSpec.axes, onResizeStart: onResizeStart }) : null] })) : null;
701
702
  if (panel === undefined)
@@ -0,0 +1,31 @@
1
+ import { type HTMLAttributes, type ReactNode } from "react";
2
+ import { type HudInteractListener } from "./hudInteract.js";
3
+ /** Shadow height of a {@link HudPanel}. */
4
+ export type HudPanelElevation = "raised" | "overlay" | "modal";
5
+ /** Props for {@link HudPanel}. */
6
+ export interface HudPanelProps extends Omit<HTMLAttributes<HTMLDivElement>, "title"> {
7
+ /** Show or hide with an enter/exit transition. Unset renders a static panel with no transition. */
8
+ open?: boolean;
9
+ /** Uppercase header label at the top-left. */
10
+ title?: ReactNode;
11
+ /** Header content at the top-right, opposite `title`. */
12
+ aside?: ReactNode;
13
+ /** Shadow height. Default `raised`. */
14
+ elevation?: HudPanelElevation;
15
+ padding?: number | string;
16
+ width?: number | string;
17
+ /** Accept pointer events. Default `true`. */
18
+ interactive?: boolean;
19
+ /** Feedback cue name, rendered as `data-jg-cue` and passed to `onInteract`. */
20
+ cue?: string;
21
+ /** Called with `open` and `close` as the panel shows and hides. */
22
+ onInteract?: HudInteractListener;
23
+ }
24
+ /**
25
+ * Themed panel/window frame over the `HudTheme` frame, elevation, and motion tokens. It enters with
26
+ * a short rise-and-fade and stays mounted through its exit when `open` turns false
27
+ * (`data-state="open" | "closed"`); reduced motion skips both. `className` and `style` restyle it.
28
+ *
29
+ * @capability hud-panel themed panel frame with elevation, header slots, and open/close transitions
30
+ */
31
+ export declare const HudPanel: import("react").ForwardRefExoticComponent<HudPanelProps & import("react").RefAttributes<HTMLDivElement>>;
@@ -0,0 +1,32 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { forwardRef, useEffect, useRef } from "react";
3
+ import { useReducedMotion } from "./accessibility.js";
4
+ import { useEngineStyles } from "./engineStyles.js";
5
+ import { useHudInteract } from "./hudInteract.js";
6
+ import { usePresence } from "./presence.js";
7
+ const EXIT_MS = 140;
8
+ /**
9
+ * Themed panel/window frame over the `HudTheme` frame, elevation, and motion tokens. It enters with
10
+ * a short rise-and-fade and stays mounted through its exit when `open` turns false
11
+ * (`data-state="open" | "closed"`); reduced motion skips both. `className` and `style` restyle it.
12
+ *
13
+ * @capability hud-panel themed panel frame with elevation, header slots, and open/close transitions
14
+ */
15
+ export const HudPanel = forwardRef(function HudPanel({ open, title, aside, elevation = "raised", padding = 12, width, interactive = true, cue, onInteract, style, children, ...rest }, ref) {
16
+ useEngineStyles();
17
+ const reduced = useReducedMotion();
18
+ const shown = open ?? true;
19
+ const presence = usePresence(shown, reduced ? 0 : EXIT_MS);
20
+ const report = useHudInteract("panel", cue, onInteract);
21
+ const wasOpen = useRef(shown);
22
+ useEffect(() => {
23
+ if (wasOpen.current === shown)
24
+ return;
25
+ wasOpen.current = shown;
26
+ report(shown ? "open" : "close");
27
+ }, [shown, report]);
28
+ if (presence === "unmounted")
29
+ return null;
30
+ const hasHeader = title !== undefined || aside !== undefined;
31
+ return (_jsxs("div", { ...rest, ref: ref, "data-jg-panel": "", "data-state": open === undefined ? undefined : presence, "data-elevation": elevation, "data-jg-cue": cue, style: { padding, width, pointerEvents: interactive ? "auto" : undefined, ...style }, children: [hasHeader ? (_jsxs("div", { "data-jg-panel-header": "", style: { marginBottom: 8 }, children: [_jsx("span", { children: title }), aside !== undefined ? _jsx("span", { children: aside }) : null] })) : null, children] }));
32
+ });