@jgengine/react 0.13.0 → 0.15.0-next.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (101) hide show
  1. package/CHANGELOG.md +529 -1
  2. package/dist/accessibility.d.ts +39 -0
  3. package/dist/accessibility.js +61 -0
  4. package/dist/achievements.d.ts +50 -0
  5. package/dist/achievements.js +94 -0
  6. package/dist/cameraShake.d.ts +52 -0
  7. package/dist/cameraShake.js +53 -0
  8. package/dist/coachMarks.d.ts +72 -0
  9. package/dist/coachMarks.js +136 -0
  10. package/dist/codex.d.ts +25 -0
  11. package/dist/codex.js +62 -0
  12. package/dist/comboMeter.d.ts +63 -0
  13. package/dist/comboMeter.js +93 -0
  14. package/dist/components.js +4 -3
  15. package/dist/creditsScreen.d.ts +19 -0
  16. package/dist/creditsScreen.js +31 -0
  17. package/dist/cutscene.d.ts +66 -0
  18. package/dist/cutscene.js +132 -0
  19. package/dist/damageDirection.d.ts +51 -0
  20. package/dist/damageDirection.js +103 -0
  21. package/dist/dialogueView.d.ts +69 -0
  22. package/dist/dialogueView.js +111 -0
  23. package/dist/entityFrames.d.ts +114 -0
  24. package/dist/entityFrames.js +55 -0
  25. package/dist/fastTravel.d.ts +36 -0
  26. package/dist/fastTravel.js +103 -0
  27. package/dist/floatingText.d.ts +84 -0
  28. package/dist/floatingText.js +61 -0
  29. package/dist/gameViewport.js +8 -12
  30. package/dist/hooks.d.ts +42 -0
  31. package/dist/hooks.js +85 -0
  32. package/dist/hudLayout.js +138 -9
  33. package/dist/hudTheme.d.ts +104 -7
  34. package/dist/hudTheme.js +300 -2
  35. package/dist/i18n.d.ts +29 -0
  36. package/dist/i18n.js +44 -0
  37. package/dist/index.d.ts +34 -0
  38. package/dist/index.js +34 -0
  39. package/dist/interactionPrompt.d.ts +80 -0
  40. package/dist/interactionPrompt.js +112 -0
  41. package/dist/keybindingMenu.d.ts +59 -0
  42. package/dist/keybindingMenu.js +146 -0
  43. package/dist/killFeed.d.ts +63 -0
  44. package/dist/killFeed.js +88 -0
  45. package/dist/map.d.ts +123 -2
  46. package/dist/map.js +239 -36
  47. package/dist/menuRouter.d.ts +51 -0
  48. package/dist/menuRouter.js +49 -0
  49. package/dist/modals.d.ts +131 -0
  50. package/dist/modals.js +168 -0
  51. package/dist/notifications.d.ts +39 -0
  52. package/dist/notifications.js +111 -0
  53. package/dist/objectiveBanner.d.ts +62 -0
  54. package/dist/objectiveBanner.js +129 -0
  55. package/dist/panels.d.ts +10 -2
  56. package/dist/panels.js +9 -3
  57. package/dist/photoMode.d.ts +21 -0
  58. package/dist/photoMode.js +39 -0
  59. package/dist/previewFixtures.d.ts +30 -0
  60. package/dist/previewFixtures.js +46 -0
  61. package/dist/provider.d.ts +15 -0
  62. package/dist/provider.js +17 -1
  63. package/dist/questTracker.d.ts +24 -0
  64. package/dist/questTracker.js +31 -0
  65. package/dist/quickMenu.d.ts +39 -0
  66. package/dist/quickMenu.js +79 -0
  67. package/dist/radialMenu.d.ts +50 -0
  68. package/dist/radialMenu.js +69 -0
  69. package/dist/saveSlots.d.ts +81 -0
  70. package/dist/saveSlots.js +138 -0
  71. package/dist/scoreboard.d.ts +67 -0
  72. package/dist/scoreboard.js +71 -0
  73. package/dist/settings.d.ts +9 -3
  74. package/dist/settings.js +10 -4
  75. package/dist/shopGrid.d.ts +70 -0
  76. package/dist/shopGrid.js +129 -0
  77. package/dist/statusEffectBar.d.ts +52 -0
  78. package/dist/statusEffectBar.js +57 -0
  79. package/dist/streetGeometryPreview.d.ts +7 -0
  80. package/dist/streetGeometryPreview.js +119 -0
  81. package/dist/talentTree.d.ts +60 -0
  82. package/dist/talentTree.js +217 -0
  83. package/dist/timerReadout.d.ts +90 -0
  84. package/dist/timerReadout.js +111 -0
  85. package/dist/useDebouncedCommit.d.ts +54 -0
  86. package/dist/useDebouncedCommit.js +81 -0
  87. package/dist/waveHud.d.ts +49 -0
  88. package/dist/waveHud.js +53 -0
  89. package/dist/waypointMarkers.d.ts +80 -0
  90. package/dist/waypointMarkers.js +116 -0
  91. package/package.json +3 -3
  92. package/skills/ce-handoff/SKILL.md +149 -0
  93. package/skills/jgengine/SKILL.md +7 -3
  94. package/skills/jgengine/api.md +17 -7
  95. package/skills/jgengine/capabilities.md +12 -0
  96. package/skills/jgengine/recipes/minimal-game.md +7 -4
  97. package/skills/jgengine-ui/SKILL.md +8 -3
  98. package/skills/jgengine-ui/api.md +732 -23
  99. package/skills/jgengine-ui/capabilities.md +371 -2
  100. package/skills/jgengine-ui/recipes/overhead-entity-frames.md +136 -0
  101. package/skills/jgengine-ui/reference.md +7 -4
@@ -0,0 +1,138 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useReducer, useEffect } from "react";
3
+ import { GameIcon } from "./gameIcons.js";
4
+ import { HudFrame } from "./hudFrame.js";
5
+ /**
6
+ * Subscribe to a {@link SaveSlots} index and re-render on any change (write,
7
+ * clear, rename, restore). Returns the live list of slots in menu order. Wire it
8
+ * into a save-select screen and mutate the model in event handlers — the menu
9
+ * re-renders itself.
10
+ *
11
+ * @capability use-save-slots React hook binding a save-slots metadata index — re-renders on change and returns the ordered slot list
12
+ */
13
+ export function useSaveSlots(model) {
14
+ const [, bump] = useReducer((n) => n + 1, 0);
15
+ useEffect(() => model.subscribe(bump), [model]);
16
+ return model.list();
17
+ }
18
+ function resolveTheme(theme) {
19
+ return {
20
+ accent: theme?.accent ?? "var(--jg-accent, #38bdf8)",
21
+ cardBg: theme?.cardBg ?? "var(--jg-frame-bg, linear-gradient(180deg, rgba(20,24,32,0.86), rgba(10,12,16,0.9)))",
22
+ cardBorder: theme?.cardBorder ?? "var(--jg-frame-border, 1px solid rgba(255,255,255,0.12))",
23
+ text: theme?.text ?? "#f1f5f9",
24
+ muted: theme?.muted ?? "rgba(203,213,225,0.72)",
25
+ chipBg: theme?.chipBg ?? "rgba(148,163,184,0.16)",
26
+ fontFamily: theme?.fontFamily ?? "ui-sans-serif, system-ui, sans-serif",
27
+ radius: theme?.radius ?? "var(--jg-frame-radius, 12px)",
28
+ };
29
+ }
30
+ /**
31
+ * Turn a `savedAt` timestamp into a compact relative label ("just now", "4m ago",
32
+ * "2d ago"). Presentation-only helper — the model stores raw ms.
33
+ */
34
+ export function relativeSavedAt(savedAt, nowMs = Date.now()) {
35
+ const delta = Math.max(0, nowMs - savedAt);
36
+ const s = Math.floor(delta / 1000);
37
+ if (s < 45)
38
+ return "just now";
39
+ const m = Math.floor(s / 60);
40
+ if (m < 60)
41
+ return `${m}m ago`;
42
+ const h = Math.floor(m / 60);
43
+ if (h < 24)
44
+ return `${h}h ago`;
45
+ const d = Math.floor(h / 24);
46
+ return `${d}d ago`;
47
+ }
48
+ const rowButton = (accent, primary) => ({
49
+ pointerEvents: "auto",
50
+ cursor: "pointer",
51
+ borderRadius: 8,
52
+ border: primary ? "none" : "1px solid rgba(148,163,184,0.28)",
53
+ background: primary ? accent : "rgba(15,20,28,0.6)",
54
+ color: primary ? "#0b1220" : "#e2e8f0",
55
+ fontSize: 12,
56
+ fontWeight: 700,
57
+ letterSpacing: 0.4,
58
+ padding: "7px 12px",
59
+ display: "inline-flex",
60
+ alignItems: "center",
61
+ gap: 6,
62
+ });
63
+ /** One filled slot's meta rendered as chips. */
64
+ function MetaChips({ slot, t, metaIcons, }) {
65
+ const entries = Object.entries(slot.meta);
66
+ if (entries.length === 0)
67
+ return null;
68
+ return (_jsx("div", { style: { display: "flex", flexWrap: "wrap", gap: 6, marginTop: 8 }, children: entries.map(([key, value]) => {
69
+ const icon = metaIcons?.[key];
70
+ return (_jsxs("span", { "data-save-slot-chip": key, style: {
71
+ display: "inline-flex",
72
+ alignItems: "center",
73
+ gap: 5,
74
+ background: t.chipBg,
75
+ color: t.muted,
76
+ borderRadius: 999,
77
+ padding: "3px 9px",
78
+ fontSize: 11,
79
+ fontWeight: 600,
80
+ lineHeight: 1.3,
81
+ }, children: [icon !== undefined ? _jsx(GameIcon, { name: icon, size: 12, color: t.accent }) : null, _jsx("span", { style: { opacity: 0.7, textTransform: "uppercase", letterSpacing: 0.6, fontSize: 9 }, children: key }), _jsx("span", { style: { color: t.text }, children: String(value) })] }, key));
82
+ }) }));
83
+ }
84
+ /**
85
+ * A drop-in save-select / profile menu that renders a {@link SaveSlots} index as
86
+ * a column of cards. A filled slot shows its name, the free-string `meta` the
87
+ * game supplied as chips, and a relative "saved" time, with Load and Delete
88
+ * actions; an empty slot shows a "New Game" affordance. A top-level Continue
89
+ * button targets the newest save (`model.mostRecent()`). This is a starting-point
90
+ * game-front-end building block — the game owns final art, wording, and layout;
91
+ * reskin it with {@link SaveSlotMenuTheme} or HudTheme CSS vars. Presentation
92
+ * only: all state lives in the core model, and `meta` keys are never interpreted.
93
+ *
94
+ * @capability save-slot-menu drop-in save-select / profile menu that renders a core save-slots index as New / Continue / Load / Delete cards with free-string meta chips and relative save times, theme-skinnable
95
+ */
96
+ export function SaveSlotMenu({ model, onLoad, onNew, onDelete, onContinue, title = "Select Save", theme, now = Date.now, metaIcons, className, style, }) {
97
+ const slots = useSaveSlots(model);
98
+ const t = resolveTheme(theme);
99
+ const nowMs = now();
100
+ const recent = model.mostRecent();
101
+ return (_jsxs("div", { className: className, "data-save-slot-menu": "", style: {
102
+ display: "flex",
103
+ flexDirection: "column",
104
+ gap: 12,
105
+ width: "min(92vw, 460px)",
106
+ fontFamily: t.fontFamily,
107
+ color: t.text,
108
+ ...style,
109
+ }, children: [_jsxs("div", { style: { display: "flex", alignItems: "center", justifyContent: "space-between", gap: 12 }, children: [_jsx("div", { style: { fontSize: 20, fontWeight: 800, letterSpacing: 0.4 }, children: title }), onContinue !== undefined && recent !== null ? (_jsxs("button", { type: "button", "data-save-slot-continue": "", style: rowButton(t.accent, true), onClick: () => onContinue(recent), children: [_jsx(GameIcon, { name: "skip", size: 14, color: "#0b1220" }), "Continue"] })) : null] }), slots.map((slot) => {
110
+ const isRecent = recent !== null && slot.id === recent.id;
111
+ return (_jsx(HudFrame, { interactive: true, variation: "glass", padding: 14, style: {
112
+ background: t.cardBg,
113
+ border: isRecent ? `1px solid ${t.accent}` : t.cardBorder,
114
+ borderRadius: t.radius,
115
+ boxShadow: isRecent ? `0 0 0 1px ${t.accent}, 0 8px 26px rgba(0,0,0,0.4)` : "0 6px 20px rgba(0,0,0,0.35)",
116
+ }, children: slot.empty ? (_jsxs("button", { type: "button", "data-save-slot-new": "", onClick: () => onNew?.(slot), style: {
117
+ pointerEvents: "auto",
118
+ cursor: "pointer",
119
+ width: "100%",
120
+ display: "flex",
121
+ alignItems: "center",
122
+ gap: 12,
123
+ background: "transparent",
124
+ border: "none",
125
+ color: t.muted,
126
+ padding: "6px 2px",
127
+ textAlign: "left",
128
+ }, children: [_jsx("span", { style: {
129
+ display: "inline-flex",
130
+ alignItems: "center",
131
+ justifyContent: "center",
132
+ width: 40,
133
+ height: 40,
134
+ borderRadius: 10,
135
+ border: `1px dashed ${t.muted}`,
136
+ }, children: _jsx(GameIcon, { name: "star", size: 18, color: t.muted }) }), _jsxs("span", { children: [_jsx("span", { style: { display: "block", fontSize: 15, fontWeight: 700, color: t.text }, children: "New Game" }), _jsxs("span", { style: { fontSize: 11 }, children: ["Empty slot \u00B7 ", slot.id] })] })] })) : (_jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 4 }, children: [_jsxs("div", { style: { display: "flex", alignItems: "baseline", justifyContent: "space-between", gap: 10 }, children: [_jsx("span", { "data-save-slot-name": "", style: { fontSize: 16, fontWeight: 800 }, children: slot.name ?? slot.id }), slot.savedAt !== undefined ? (_jsx("span", { style: { fontSize: 11, fontWeight: 600, color: t.accent, whiteSpace: "nowrap" }, children: relativeSavedAt(slot.savedAt, nowMs) })) : null] }), _jsx(MetaChips, { slot: slot, t: t, metaIcons: metaIcons }), _jsxs("div", { style: { display: "flex", gap: 8, marginTop: 12 }, children: [_jsxs("button", { type: "button", "data-save-slot-load": "", style: { ...rowButton(t.accent, true), flex: 1, justifyContent: "center" }, onClick: () => onLoad?.(slot), children: [_jsx(GameIcon, { name: "chest", size: 14, color: "#0b1220" }), "Load"] }), _jsxs("button", { type: "button", "data-save-slot-delete": "", style: rowButton(t.accent, false), onClick: () => onDelete?.(slot), "aria-label": `Delete ${slot.name ?? slot.id}`, children: [_jsx(GameIcon, { name: "cross", size: 14 }), "Delete"] })] })] })) }, slot.id));
137
+ })] }));
138
+ }
@@ -0,0 +1,67 @@
1
+ import { type CSSProperties, type ReactNode } from "react";
2
+ import { type RankableRow, type RankedEntry, type RankLeaderboardOptions } from "@jgengine/core/game/leaderboardRank";
3
+ import { type GameIconName } from "./gameIcons.js";
4
+ /** The medal tokens {@link medalFor} emits for the podium. */
5
+ export type MedalToken = "gold" | "silver" | "bronze";
6
+ /** Reskin tokens for {@link Scoreboard}. Every color/spacing value reads a `--jg-*` token first. */
7
+ export interface ScoreboardTheme {
8
+ /** Panel background. Default reads `--jg-frame-bg`. */
9
+ bg?: string;
10
+ /** Panel border. Default reads `--jg-frame-border`. */
11
+ border?: string;
12
+ /** Panel corner radius. Default reads `--jg-frame-radius`. */
13
+ radius?: string;
14
+ /** Row / header text color. */
15
+ text?: string;
16
+ /** Muted text (rank column, header labels). */
17
+ muted?: string;
18
+ /** Accent used for the local-row highlight. Default reads `--jg-accent`. */
19
+ accent?: string;
20
+ /** Background wash behind the local ("you") row. */
21
+ localBg?: string;
22
+ /** Font family. */
23
+ fontFamily?: string;
24
+ /** Podium medal colors keyed by token. */
25
+ medals?: Record<MedalToken, string>;
26
+ }
27
+ /** Props for {@link Scoreboard}. Pass either `entries` (already ranked) or `rows` + `options`. */
28
+ export interface ScoreboardProps {
29
+ /** Already-ranked entries from {@link rankLeaderboard}. Takes precedence over `rows`. */
30
+ entries?: readonly RankedEntry[];
31
+ /** Raw rows to rank in-component. Ignored when `entries` is given. */
32
+ rows?: readonly RankableRow[];
33
+ /** Ranking options applied to `rows`. Ignored when `entries` is given. */
34
+ options?: RankLeaderboardOptions;
35
+ /** Optional heading rendered above the table (e.g. "TOP RUNNERS"). */
36
+ title?: ReactNode;
37
+ /** Label for the score column header. Default `"Score"`. */
38
+ scoreLabel?: string;
39
+ /**
40
+ * Map a `userId` to a display name. Falls back to the entry's `label`, then the
41
+ * raw `userId`. The model never carries display names it does not own.
42
+ */
43
+ nameFor?: (entry: RankedEntry) => ReactNode;
44
+ /** Format a raw score for display. Default `String(value)`. */
45
+ formatScore?: (value: number) => ReactNode;
46
+ /**
47
+ * Podium icon for a medal token. Default: a filled star tinted by the medal
48
+ * color. Return `null` to render just the numeric rank.
49
+ */
50
+ medalIcon?: (medal: MedalToken) => GameIconName | null;
51
+ /** Reskin tokens. */
52
+ theme?: ScoreboardTheme;
53
+ className?: string;
54
+ style?: CSSProperties;
55
+ }
56
+ /**
57
+ * A reskinnable ranked-score table for the {@link rankLeaderboard} selector: a rank
58
+ * column (medal icon/color for the top three via {@link medalFor}, otherwise the
59
+ * number), a name column, and a score column. The local player's row (`isLocal`) is
60
+ * washed and accented so "you" stands out. Feed it already-ranked `entries`, or raw
61
+ * `rows` plus `options` and it ranks them for you. All colors and spacing come from
62
+ * {@link ScoreboardTheme} / HudTheme `--jg-*` tokens — the component reads the model
63
+ * and never styles by game meaning.
64
+ *
65
+ * @capability scoreboard-table reskinnable ranked-score / leaderboard table over the rankLeaderboard selector — medal-colored podium, local-row highlight, HudTheme-token driven
66
+ */
67
+ export declare function Scoreboard({ entries, rows, options, title, scoreLabel, nameFor, formatScore, medalIcon, theme, className, style, }: ScoreboardProps): ReactNode;
@@ -0,0 +1,71 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import {} from "react";
3
+ import { medalFor, rankLeaderboard, } from "@jgengine/core/game/leaderboardRank";
4
+ import { GameIcon } from "./gameIcons.js";
5
+ function resolveTheme(theme) {
6
+ return {
7
+ bg: theme?.bg ?? "var(--jg-frame-bg, linear-gradient(180deg, rgba(20,24,32,0.92), rgba(10,12,16,0.95)))",
8
+ border: theme?.border ?? "var(--jg-frame-border, 1px solid rgba(255,255,255,0.12))",
9
+ radius: theme?.radius ?? "var(--jg-frame-radius, 12px)",
10
+ text: theme?.text ?? "#f1f5f9",
11
+ muted: theme?.muted ?? "rgba(203,213,225,0.65)",
12
+ accent: theme?.accent ?? "var(--jg-accent, #38bdf8)",
13
+ localBg: theme?.localBg ?? "rgba(56,189,248,0.16)",
14
+ fontFamily: theme?.fontFamily ?? "ui-sans-serif, system-ui, sans-serif",
15
+ medals: theme?.medals ?? { gold: "#f5c542", silver: "#cbd5e1", bronze: "#cd7f32" },
16
+ };
17
+ }
18
+ /**
19
+ * A reskinnable ranked-score table for the {@link rankLeaderboard} selector: a rank
20
+ * column (medal icon/color for the top three via {@link medalFor}, otherwise the
21
+ * number), a name column, and a score column. The local player's row (`isLocal`) is
22
+ * washed and accented so "you" stands out. Feed it already-ranked `entries`, or raw
23
+ * `rows` plus `options` and it ranks them for you. All colors and spacing come from
24
+ * {@link ScoreboardTheme} / HudTheme `--jg-*` tokens — the component reads the model
25
+ * and never styles by game meaning.
26
+ *
27
+ * @capability scoreboard-table reskinnable ranked-score / leaderboard table over the rankLeaderboard selector — medal-colored podium, local-row highlight, HudTheme-token driven
28
+ */
29
+ export function Scoreboard({ entries, rows, options, title, scoreLabel = "Score", nameFor, formatScore, medalIcon, theme, className, style, }) {
30
+ const t = resolveTheme(theme);
31
+ const ranked = entries ?? rankLeaderboard(rows ?? [], options ?? {});
32
+ const cell = { padding: "0.5em 0.75em", textAlign: "left", whiteSpace: "nowrap" };
33
+ const headCell = {
34
+ ...cell,
35
+ fontSize: "0.72em",
36
+ fontWeight: 700,
37
+ letterSpacing: "0.08em",
38
+ textTransform: "uppercase",
39
+ color: t.muted,
40
+ };
41
+ return (_jsxs("div", { className: className, "data-jgui": "scoreboard", style: {
42
+ display: "inline-flex",
43
+ flexDirection: "column",
44
+ gap: "0.5em",
45
+ background: t.bg,
46
+ border: t.border,
47
+ borderRadius: t.radius,
48
+ padding: "0.75em",
49
+ color: t.text,
50
+ fontFamily: t.fontFamily,
51
+ minWidth: 280,
52
+ ...style,
53
+ }, children: [title !== undefined ? (_jsx("div", { "data-jgui": "scoreboard-title", style: { fontSize: "0.95em", fontWeight: 800, letterSpacing: "0.06em", textTransform: "uppercase", padding: "0 0.25em" }, children: title })) : null, _jsxs("table", { style: { borderCollapse: "collapse", width: "100%", fontSize: "0.95em" }, children: [_jsx("thead", { children: _jsxs("tr", { children: [_jsx("th", { style: { ...headCell, textAlign: "center", width: "3em" }, children: "#" }), _jsx("th", { style: headCell, children: "Name" }), _jsx("th", { style: { ...headCell, textAlign: "right" }, children: scoreLabel })] }) }), _jsx("tbody", { children: ranked.map((entry, index) => {
54
+ const medal = medalFor(entry.rank);
55
+ const medalColor = medal !== null ? t.medals[medal] : undefined;
56
+ const icon = medal !== null ? (medalIcon ? medalIcon(medal) : "star") : null;
57
+ const name = nameFor ? nameFor(entry) : entry.label ?? entry.userId;
58
+ const rowKey = `${entry.userId}:${index}`;
59
+ return (_jsxs("tr", { "data-scoreboard-row": entry.userId, "data-rank": entry.rank, "data-local": entry.isLocal ? "" : undefined, "data-tie": entry.isTie ? "" : undefined, style: {
60
+ background: entry.isLocal ? t.localBg : "transparent",
61
+ boxShadow: entry.isLocal ? `inset 3px 0 0 ${t.accent}` : undefined,
62
+ }, children: [_jsx("td", { style: { ...cell, textAlign: "center" }, children: _jsxs("span", { style: {
63
+ display: "inline-flex",
64
+ alignItems: "center",
65
+ justifyContent: "center",
66
+ gap: "0.25em",
67
+ color: medalColor ?? t.muted,
68
+ fontWeight: medal !== null ? 800 : 600,
69
+ }, children: [icon !== null ? _jsx(GameIcon, { name: icon, size: 16, color: medalColor }) : null, entry.rank] }) }), _jsx("td", { style: { ...cell, color: entry.isLocal ? t.accent : t.text, fontWeight: entry.isLocal ? 700 : 500 }, children: name }), _jsx("td", { style: { ...cell, textAlign: "right", fontVariantNumeric: "tabular-nums", fontWeight: 600 }, children: formatScore ? formatScore(entry.value) : entry.value })] }, rowKey));
70
+ }) })] })] }));
71
+ }
@@ -65,11 +65,17 @@ export declare function SettingsControllerProvider({ controller, children, }: {
65
65
  export declare function useSettings(): SettingsController;
66
66
  /** True when the game has any setting or game-action to show — gate your own settings entry on it. */
67
67
  export declare function useHasSettings(): boolean;
68
+ /**
69
+ * Default HudTheme-token skin for {@link SettingsTrigger} — the 8×8 chrome button
70
+ * games re-authored seven times. Pass your own `className` only to override placement
71
+ * or brand chrome; the gear glyph already scales with `text-base` / `1em`.
72
+ */
73
+ export declare const SETTINGS_TRIGGER_CLASSNAME = "pointer-events-auto flex h-8 w-8 items-center justify-center rounded-md border border-[var(--jg-edge-bright)] bg-[var(--jg-surface)]/80 text-base text-[var(--jg-text-dim)] shadow-[0_1px_2px_rgba(0,0,0,0.6)] transition hover:bg-[var(--jg-surface-deep)] hover:text-[var(--jg-accent)]";
68
74
  /**
69
75
  * Inline settings entry — drop it anywhere in your game's menu or HUD; it opens
70
- * the themed settings menu. Headless: pass `className` for placement/skin and
71
- * `children` to replace the default gear glyph. Renders nothing when the game
72
- * has no settings to show, so it never leaves a dead button behind.
76
+ * the themed settings menu. Headless: pass `className` only when overriding the
77
+ * default HudTheme skin; pass `children` to replace the gear glyph. Renders nothing
78
+ * when the game has no settings to show, so it never leaves a dead button behind.
73
79
  */
74
80
  export declare function SettingsTrigger({ className, children, label, }: {
75
81
  className?: string;
package/dist/settings.js CHANGED
@@ -47,13 +47,19 @@ export function useHasSettings() {
47
47
  const s = useSettings();
48
48
  return s.categories.length > 0 || s.actions.length > 0;
49
49
  }
50
+ /**
51
+ * Default HudTheme-token skin for {@link SettingsTrigger} — the 8×8 chrome button
52
+ * games re-authored seven times. Pass your own `className` only to override placement
53
+ * or brand chrome; the gear glyph already scales with `text-base` / `1em`.
54
+ */
55
+ export const SETTINGS_TRIGGER_CLASSNAME = "pointer-events-auto flex h-8 w-8 items-center justify-center rounded-md border border-[var(--jg-edge-bright)] bg-[var(--jg-surface)]/80 text-base text-[var(--jg-text-dim)] shadow-[0_1px_2px_rgba(0,0,0,0.6)] transition hover:bg-[var(--jg-surface-deep)] hover:text-[var(--jg-accent)]";
50
56
  /**
51
57
  * Inline settings entry — drop it anywhere in your game's menu or HUD; it opens
52
- * the themed settings menu. Headless: pass `className` for placement/skin and
53
- * `children` to replace the default gear glyph. Renders nothing when the game
54
- * has no settings to show, so it never leaves a dead button behind.
58
+ * the themed settings menu. Headless: pass `className` only when overriding the
59
+ * default HudTheme skin; pass `children` to replace the gear glyph. Renders nothing
60
+ * when the game has no settings to show, so it never leaves a dead button behind.
55
61
  */
56
- export function SettingsTrigger({ className, children, label = "Settings", }) {
62
+ export function SettingsTrigger({ className = SETTINGS_TRIGGER_CLASSNAME, children, label = "Settings", }) {
57
63
  const settings = useSettings();
58
64
  if (settings.categories.length === 0 && settings.actions.length === 0)
59
65
  return null;
@@ -0,0 +1,70 @@
1
+ import { type CSSProperties, type ReactNode } from "react";
2
+ import { type WalletState } from "@jgengine/core/economy/wallet";
3
+ import type { ShopStock, ShopStockEntry } from "@jgengine/core/economy/shopStock";
4
+ import { type GameIconName } from "./gameIcons.js";
5
+ /**
6
+ * Subscribe to a shop's stock and re-render whenever it changes (buy, sell, restock, price, add,
7
+ * remove, restore). Returns the current entry list — a detached snapshot safe to map over.
8
+ *
9
+ * @capability use-shop-stock React hook binding a shop-stock model — re-renders on any stock change and returns the entry list
10
+ */
11
+ export declare function useShopStock(shop: ShopStock): ShopStockEntry[];
12
+ /** Reskin tokens for {@link ShopGrid}. Falls back to shared `--jg-*` HudTheme tokens, then neutral defaults. */
13
+ export interface ShopGridTheme {
14
+ /** Card background. Default reads `--jg-slot-bg`. */
15
+ cardBg?: string;
16
+ /** Card border. Default reads `--jg-slot-border`. */
17
+ cardBorder?: string;
18
+ /** Card corner radius. Default reads `--jg-slot-radius`. */
19
+ cardRadius?: string;
20
+ /** Accent for icons/prices/Buy button. Default reads `--jg-accent`. */
21
+ accent?: string;
22
+ /** Primary text color. */
23
+ text?: string;
24
+ /** Muted/secondary text color. */
25
+ muted?: string;
26
+ /** Font family. */
27
+ fontFamily?: string;
28
+ }
29
+ /** Props for {@link ShopGrid}. */
30
+ export interface ShopGridProps {
31
+ /** The vendor stock model to render. */
32
+ shop: ShopStock;
33
+ /** The caller-owned wallet the grid buys/sells against. The shop never owns it. */
34
+ wallet: WalletState;
35
+ /** Called with the new wallet after a successful buy or sell — the caller adopts it. */
36
+ onWalletChange: (wallet: WalletState) => void;
37
+ /** Reskin tokens. */
38
+ theme?: ShopGridTheme;
39
+ /** Optional per-currency icon overrides, keyed by the free-string currency. */
40
+ currencyIcons?: Record<string, GameIconName>;
41
+ /** Optional entry→icon picker. Defaults to keyword matching on `kind`/`id`. */
42
+ iconFor?: (entry: ShopStockEntry) => GameIconName;
43
+ /** Show a Sell button on entries that carry a `sellPrice`. Default `false`. */
44
+ showSell?: boolean;
45
+ /**
46
+ * Currencies to show in the wallet readout, in order. Default: every currency referenced by the
47
+ * stock (prices + sell prices), in first-seen order.
48
+ */
49
+ walletCurrencies?: readonly string[];
50
+ /** Panel header label. Default `"Shop"`. */
51
+ title?: ReactNode;
52
+ /** Human label for an entry — default is the capitalized `kind`. */
53
+ labelFor?: (entry: ShopStockEntry) => ReactNode;
54
+ /** Minimum card width (CSS length) for the responsive grid. Default `"140px"`. */
55
+ minCardWidth?: string;
56
+ className?: string;
57
+ style?: CSSProperties;
58
+ }
59
+ /**
60
+ * A drop-in vendor/shop grid: item cards (icon, name, price with a currency glyph, stock count or
61
+ * "∞") over a caller-owned wallet, plus a wallet balance readout. Each card's Buy button disables
62
+ * when the wallet cannot afford it (`shop.canAfford`) or the item is sold out; an optional Sell
63
+ * button appears for entries with a `sellPrice`. Buying/selling routes through the core shop model
64
+ * and hands the debited/credited wallet back via `onWalletChange` — the component never owns the
65
+ * wallet. Purely token-driven presentation (shared `--jg-*` HudTheme tokens, reskinnable via
66
+ * {@link ShopGridTheme}); `kind` and `currency` are free strings the game styles, never interpreted.
67
+ *
68
+ * @capability shop-grid-host drop-in vendor/shop grid over a caller-owned wallet — item cards with icon/price/stock, afford-aware Buy, optional Sell, and a balance readout, token-themed
69
+ */
70
+ export declare function ShopGrid({ shop, wallet, onWalletChange, theme, currencyIcons, iconFor, showSell, walletCurrencies, title, labelFor, minCardWidth, className, style, }: ShopGridProps): ReactNode;
@@ -0,0 +1,129 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useEffect, useReducer } from "react";
3
+ import { balance } from "@jgengine/core/economy/wallet";
4
+ import { GameIcon, iconForItemId } from "./gameIcons.js";
5
+ import { HudFrame } from "./hudFrame.js";
6
+ /**
7
+ * Subscribe to a shop's stock and re-render whenever it changes (buy, sell, restock, price, add,
8
+ * remove, restore). Returns the current entry list — a detached snapshot safe to map over.
9
+ *
10
+ * @capability use-shop-stock React hook binding a shop-stock model — re-renders on any stock change and returns the entry list
11
+ */
12
+ export function useShopStock(shop) {
13
+ const [, bump] = useReducer((n) => n + 1, 0);
14
+ useEffect(() => shop.subscribe(bump), [shop]);
15
+ return shop.list();
16
+ }
17
+ function resolveTheme(theme) {
18
+ return {
19
+ cardBg: theme?.cardBg ?? "var(--jg-slot-bg, rgba(12,14,20,0.72))",
20
+ cardBorder: theme?.cardBorder ?? "var(--jg-slot-border, 1px solid rgba(255,255,255,0.12))",
21
+ cardRadius: theme?.cardRadius ?? "var(--jg-slot-radius, 10px)",
22
+ accent: theme?.accent ?? "var(--jg-accent, #38bdf8)",
23
+ text: theme?.text ?? "#f1f5f9",
24
+ muted: theme?.muted ?? "rgba(203,213,225,0.72)",
25
+ fontFamily: theme?.fontFamily ?? "ui-sans-serif, system-ui, sans-serif",
26
+ };
27
+ }
28
+ /** Default currency → icon mapping via {@link iconForItemId} (which knows "gold"/"gem"/… keywords). */
29
+ function currencyIcon(currency, override) {
30
+ return override?.[currency] ?? iconForItemId(currency) ?? "coin";
31
+ }
32
+ /** Default entry → icon mapping from the free-string `kind`, falling back to a chest. */
33
+ function entryIcon(entry, iconFor) {
34
+ if (iconFor !== undefined)
35
+ return iconFor(entry);
36
+ return iconForItemId(entry.kind) ?? iconForItemId(entry.id) ?? "chest";
37
+ }
38
+ function capitalize(value) {
39
+ return value.length === 0 ? value : value[0].toUpperCase() + value.slice(1);
40
+ }
41
+ function referencedCurrencies(entries) {
42
+ const seen = [];
43
+ const add = (currency) => {
44
+ if (!seen.includes(currency))
45
+ seen.push(currency);
46
+ };
47
+ for (const entry of entries) {
48
+ add(entry.price.currency);
49
+ if (entry.sellPrice !== undefined)
50
+ add(entry.sellPrice.currency);
51
+ }
52
+ return seen;
53
+ }
54
+ function PriceTag({ price, icon, accent, muted, }) {
55
+ return (_jsxs("span", { style: { display: "inline-flex", alignItems: "center", gap: 4, color: muted, fontVariantNumeric: "tabular-nums" }, children: [_jsx(GameIcon, { name: icon, size: 15, color: accent }), _jsx("span", { style: { fontWeight: 700, color: "inherit" }, children: price.amount })] }));
56
+ }
57
+ /**
58
+ * A drop-in vendor/shop grid: item cards (icon, name, price with a currency glyph, stock count or
59
+ * "∞") over a caller-owned wallet, plus a wallet balance readout. Each card's Buy button disables
60
+ * when the wallet cannot afford it (`shop.canAfford`) or the item is sold out; an optional Sell
61
+ * button appears for entries with a `sellPrice`. Buying/selling routes through the core shop model
62
+ * and hands the debited/credited wallet back via `onWalletChange` — the component never owns the
63
+ * wallet. Purely token-driven presentation (shared `--jg-*` HudTheme tokens, reskinnable via
64
+ * {@link ShopGridTheme}); `kind` and `currency` are free strings the game styles, never interpreted.
65
+ *
66
+ * @capability shop-grid-host drop-in vendor/shop grid over a caller-owned wallet — item cards with icon/price/stock, afford-aware Buy, optional Sell, and a balance readout, token-themed
67
+ */
68
+ export function ShopGrid({ shop, wallet, onWalletChange, theme, currencyIcons, iconFor, showSell = false, walletCurrencies, title = "Shop", labelFor, minCardWidth = "140px", className, style, }) {
69
+ const entries = useShopStock(shop);
70
+ const t = resolveTheme(theme);
71
+ const currencies = walletCurrencies ?? referencedCurrencies(entries);
72
+ const onBuy = (entry) => {
73
+ const result = shop.buy(entry.id, wallet);
74
+ if (result.ok)
75
+ onWalletChange(result.wallet);
76
+ };
77
+ const onSell = (entry) => {
78
+ const result = shop.sell(entry.id, wallet);
79
+ if (result.ok)
80
+ onWalletChange(result.wallet);
81
+ };
82
+ const walletReadout = (_jsx("div", { "data-shop-grid-wallet": true, style: { display: "flex", flexWrap: "wrap", gap: 12, alignItems: "center" }, children: currencies.map((currency) => (_jsxs("span", { "data-shop-grid-balance": currency, style: { display: "inline-flex", alignItems: "center", gap: 5, color: t.text, fontVariantNumeric: "tabular-nums", fontWeight: 700 }, children: [_jsx(GameIcon, { name: currencyIcon(currency, currencyIcons), size: 18, color: t.accent }), balance(wallet, currency)] }, currency))) }));
83
+ return (_jsx(HudFrame, { variation: "themed", title: title, aside: walletReadout, interactive: true, className: className, style: { fontFamily: t.fontFamily, color: t.text, ...style }, children: _jsx("div", { "data-shop-grid": true, style: {
84
+ display: "grid",
85
+ gridTemplateColumns: `repeat(auto-fill, minmax(${minCardWidth}, 1fr))`,
86
+ gap: 10,
87
+ }, children: entries.map((entry) => {
88
+ const soldOut = entry.qty !== null && entry.qty <= 0;
89
+ const affordable = shop.canAfford(entry.id, wallet);
90
+ const buyDisabled = soldOut || !affordable;
91
+ return (_jsxs("div", { "data-shop-item": entry.id, "data-kind": entry.kind, style: {
92
+ display: "flex",
93
+ flexDirection: "column",
94
+ gap: 6,
95
+ padding: 10,
96
+ background: t.cardBg,
97
+ border: t.cardBorder,
98
+ borderRadius: t.cardRadius,
99
+ opacity: soldOut ? 0.55 : 1,
100
+ }, children: [_jsxs("div", { style: { display: "flex", alignItems: "center", justifyContent: "space-between", gap: 6 }, children: [_jsx("div", { style: {
101
+ display: "grid",
102
+ placeItems: "center",
103
+ width: 40,
104
+ height: 40,
105
+ borderRadius: 8,
106
+ background: "rgba(0,0,0,0.28)",
107
+ }, children: _jsx(GameIcon, { name: entryIcon(entry, iconFor), size: 28, color: t.accent }) }), _jsx("span", { "data-shop-item-qty": true, style: { fontSize: 12, fontWeight: 700, color: t.muted, fontVariantNumeric: "tabular-nums" }, children: entry.qty === null ? "∞" : `x${entry.qty}` })] }), _jsx("div", { style: { fontSize: 13, fontWeight: 700, color: t.text, lineHeight: 1.2 }, children: labelFor?.(entry) ?? capitalize(entry.kind) }), _jsxs("div", { style: { display: "flex", alignItems: "center", justifyContent: "space-between", gap: 6 }, children: [_jsx(PriceTag, { price: entry.price, icon: currencyIcon(entry.price.currency, currencyIcons), accent: t.accent, muted: t.text }), entry.sellPrice !== undefined ? (_jsxs("span", { style: { display: "inline-flex", alignItems: "center", gap: 3, fontSize: 11, color: t.muted }, children: ["sell", _jsx(PriceTag, { price: entry.sellPrice, icon: currencyIcon(entry.sellPrice.currency, currencyIcons), accent: t.muted, muted: t.muted })] })) : null] }), _jsxs("div", { style: { display: "flex", gap: 6 }, children: [_jsx("button", { type: "button", "data-shop-buy": entry.id, disabled: buyDisabled, onClick: () => onBuy(entry), style: {
108
+ flex: 1,
109
+ padding: "7px 8px",
110
+ borderRadius: 8,
111
+ border: "none",
112
+ fontWeight: 800,
113
+ fontSize: 12,
114
+ letterSpacing: "0.03em",
115
+ cursor: buyDisabled ? "not-allowed" : "pointer",
116
+ color: buyDisabled ? t.muted : "#0b1017",
117
+ background: buyDisabled ? "rgba(255,255,255,0.08)" : t.accent,
118
+ }, children: soldOut ? "Sold Out" : "Buy" }), showSell && entry.sellPrice !== undefined ? (_jsx("button", { type: "button", "data-shop-sell": entry.id, onClick: () => onSell(entry), style: {
119
+ padding: "7px 10px",
120
+ borderRadius: 8,
121
+ border: t.cardBorder,
122
+ fontWeight: 700,
123
+ fontSize: 12,
124
+ cursor: "pointer",
125
+ color: t.text,
126
+ background: "transparent",
127
+ }, children: "Sell" })) : null] })] }, entry.id));
128
+ }) }) }));
129
+ }
@@ -0,0 +1,52 @@
1
+ import { type CSSProperties, type ReactNode } from "react";
2
+ import { type StatusEffectView } from "@jgengine/core/combat";
3
+ import { type GameIconName } from "./gameIcons.js";
4
+ import { type IconSchool } from "./iconTreatment.js";
5
+ /**
6
+ * `StatusEffectBar` (#status-effect-hud): a drop-in timeline HUD row that renders active
7
+ * buffs/debuffs as painted icons, each wrapped in a radial countdown ring and a stack-count badge,
8
+ * driven straight off the engine's existing status model (`StatusInstance` → `StatusEffectView`).
9
+ * A game passes its live effect views and gets a polished, reskinnable effect bar — no re-deriving
10
+ * ring math or timers. `kind` is a free string the game styles: the component never interprets
11
+ * buff-vs-debuff semantics, it only maps `kind` to an icon/color/label the game can fully override.
12
+ * Ring, track, and frame read `HudTheme` tokens (`--jg-accent`, `--jg-slot-*`), so a theme change
13
+ * reskins the whole row.
14
+ */
15
+ /** How a `kind` resolves to a glyph: a `GameIcon` name, an explicit node, or `null` for the fallback. */
16
+ export type StatusIconResolver = (kind: string) => GameIconName | ReactNode | null;
17
+ /** Props for {@link StatusEffectBar}. */
18
+ export interface StatusEffectBarProps {
19
+ /** Live effect views from the status model (`toStatusEffectViews(...)`). Rendered in order. */
20
+ effects: readonly StatusEffectView[];
21
+ /** Icon face size in px. Default 44. */
22
+ size?: number;
23
+ /** Gap between icons in px. Default 8. */
24
+ gap?: number;
25
+ /**
26
+ * Resolve a `kind` to its glyph. Default: the matching `GameIcon` keyword, else an hourglass.
27
+ * Return a `GameIconName`, any `ReactNode`, or `null` to fall through to the default.
28
+ */
29
+ iconFor?: StatusIconResolver;
30
+ /** Ring color per `kind`. Default reads `--jg-accent`. Style debuffs red / buffs green here. */
31
+ colorFor?: (kind: string) => string;
32
+ /** School gradient behind the glyph per `kind`. Default inferred from the `kind` keyword. */
33
+ schoolFor?: (kind: string) => IconSchool;
34
+ /** Tooltip/`aria-label` per effect. Default: the `kind`, with a `xN` suffix when stacked. */
35
+ labelFor?: (effect: StatusEffectView) => string;
36
+ /** Show the remaining-seconds readout under each icon. Default true. */
37
+ showTimer?: boolean;
38
+ /** Ring stroke width in px. Default 3. */
39
+ ringWidth?: number;
40
+ className?: string;
41
+ style?: CSSProperties;
42
+ }
43
+ /**
44
+ * A horizontal timeline of active status effects — one countdown-ring icon per effect. Presentation
45
+ * only and pure over its `effects` prop, so it renders straight off the status model's snapshot.
46
+ * `kind` is a free string the game styles (icon / color / label); the component never interprets
47
+ * buff-vs-debuff semantics. Ring, track, and frame read `HudTheme` tokens, so a theme change reskins
48
+ * the whole row.
49
+ *
50
+ * @capability status-effect-bar timeline HUD row of active statuses — icon + radial countdown ring + stack badge, driven off the status model, HudTheme-skinned
51
+ */
52
+ export declare function StatusEffectBar({ effects, size, gap, iconFor, colorFor, schoolFor, labelFor, showTimer, ringWidth, className, style, }: StatusEffectBarProps): ReactNode;
@@ -0,0 +1,57 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import {} from "react";
3
+ import { statusEffectRemainingFraction } from "@jgengine/core/combat";
4
+ import { iconForItemId, isGameIconName } from "./gameIcons.js";
5
+ import { IconTreatment, schoolForItem } from "./iconTreatment.js";
6
+ const FALLBACK_ICON = "hourglass";
7
+ function defaultIcon(kind) {
8
+ return iconForItemId(kind) ?? FALLBACK_ICON;
9
+ }
10
+ function resolveGlyph(kind, iconFor) {
11
+ const custom = iconFor?.(kind) ?? null;
12
+ if (custom === null)
13
+ return { icon: defaultIcon(kind) };
14
+ if (typeof custom === "string")
15
+ return isGameIconName(custom) ? { icon: custom } : { icon: defaultIcon(kind) };
16
+ return { node: custom };
17
+ }
18
+ function defaultLabel(effect) {
19
+ return effect.stacks > 1 ? `${effect.kind} x${effect.stacks}` : effect.kind;
20
+ }
21
+ function remainingSeconds(remainingMs) {
22
+ const seconds = remainingMs / 1000;
23
+ if (seconds >= 10)
24
+ return `${Math.ceil(seconds)}`;
25
+ if (seconds >= 1)
26
+ return seconds.toFixed(1);
27
+ return remainingMs > 0 ? "<1" : "0";
28
+ }
29
+ /** A single effect icon: painted face + stack badge, overlaid with its radial countdown ring. */
30
+ function StatusEffectIcon({ effect, size, ringColor, school, glyph, label, showTimer, ringWidth, }) {
31
+ const fraction = statusEffectRemainingFraction(effect);
32
+ const radius = (size - ringWidth) / 2;
33
+ const circumference = 2 * Math.PI * radius;
34
+ const dashOffset = circumference * (1 - fraction);
35
+ return (_jsxs("div", { "data-status-effect": effect.kind, "data-remaining-fraction": fraction.toFixed(4), title: label, "aria-label": label, style: { display: "flex", flexDirection: "column", alignItems: "center", gap: 3 }, children: [_jsxs("div", { style: { position: "relative", width: size, height: size }, children: [_jsx(IconTreatment, { ...(glyph.node !== undefined ? { glyph: glyph.node } : glyph.icon !== undefined ? { icon: glyph.icon } : {}), school: school, size: size, count: effect.stacks }), _jsxs("svg", { width: size, height: size, viewBox: `0 0 ${size} ${size}`, "aria-hidden": "true", style: { position: "absolute", inset: 0, transform: "rotate(-90deg)", pointerEvents: "none", overflow: "visible" }, children: [_jsx("circle", { cx: size / 2, cy: size / 2, r: radius, fill: "none", stroke: "rgba(0,0,0,0.55)", strokeWidth: ringWidth }), _jsx("circle", { "data-countdown-ring": "", cx: size / 2, cy: size / 2, r: radius, fill: "none", stroke: ringColor, strokeWidth: ringWidth, strokeLinecap: "round", strokeDasharray: circumference, strokeDashoffset: dashOffset, style: { transition: "stroke-dashoffset 200ms linear" } })] })] }), showTimer ? (_jsx("span", { "data-status-timer": "", style: {
36
+ fontSize: 10,
37
+ fontWeight: 700,
38
+ fontVariantNumeric: "tabular-nums",
39
+ color: "var(--jg-bar-text, #f5f7fa)",
40
+ textShadow: "0 1px 2px rgba(0,0,0,0.9)",
41
+ lineHeight: 1,
42
+ }, children: remainingSeconds(effect.remainingMs) })) : null] }));
43
+ }
44
+ /**
45
+ * A horizontal timeline of active status effects — one countdown-ring icon per effect. Presentation
46
+ * only and pure over its `effects` prop, so it renders straight off the status model's snapshot.
47
+ * `kind` is a free string the game styles (icon / color / label); the component never interprets
48
+ * buff-vs-debuff semantics. Ring, track, and frame read `HudTheme` tokens, so a theme change reskins
49
+ * the whole row.
50
+ *
51
+ * @capability status-effect-bar timeline HUD row of active statuses — icon + radial countdown ring + stack badge, driven off the status model, HudTheme-skinned
52
+ */
53
+ export function StatusEffectBar({ effects, size = 44, gap = 8, iconFor, colorFor, schoolFor, labelFor, showTimer = true, ringWidth = 3, className, style, }) {
54
+ if (effects.length === 0)
55
+ return null;
56
+ return (_jsx("div", { className: className, "data-status-effect-bar": "", style: { display: "flex", alignItems: "flex-start", gap, ...style }, children: effects.map((effect) => (_jsx(StatusEffectIcon, { effect: effect, size: size, ringColor: colorFor?.(effect.kind) ?? "var(--jg-accent, #38bdf8)", school: schoolFor?.(effect.kind) ?? schoolForItem(effect.kind), glyph: resolveGlyph(effect.kind, iconFor), label: labelFor?.(effect) ?? defaultLabel(effect), showTimer: showTimer, ringWidth: ringWidth }, effect.id))) }));
57
+ }