@jgengine/react 0.9.0 → 0.11.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 (62) hide show
  1. package/CHANGELOG.md +34 -3
  2. package/LICENSE +203 -661
  3. package/README.md +1 -1
  4. package/dist/actionHud.d.ts +174 -0
  5. package/dist/actionHud.js +292 -0
  6. package/dist/cards.d.ts +59 -0
  7. package/dist/cards.js +116 -0
  8. package/dist/chat.d.ts +6 -4
  9. package/dist/chat.js +16 -7
  10. package/dist/chatBubbles.js +2 -1
  11. package/dist/components.d.ts +20 -0
  12. package/dist/components.js +29 -1
  13. package/dist/display.d.ts +1 -0
  14. package/dist/display.js +1 -0
  15. package/dist/engineStore.d.ts +3 -0
  16. package/dist/engineStore.js +3 -0
  17. package/dist/fogOverlay.d.ts +3 -0
  18. package/dist/fogOverlay.js +3 -0
  19. package/dist/gameViewport.d.ts +54 -0
  20. package/dist/gameViewport.js +356 -0
  21. package/dist/hooks.d.ts +22 -0
  22. package/dist/hooks.js +58 -12
  23. package/dist/hud.d.ts +87 -0
  24. package/dist/hud.js +144 -0
  25. package/dist/hudLayout.d.ts +39 -4
  26. package/dist/hudLayout.js +152 -11
  27. package/dist/index.d.ts +10 -0
  28. package/dist/index.js +10 -0
  29. package/dist/keyHint.d.ts +30 -0
  30. package/dist/keyHint.js +31 -0
  31. package/dist/map.d.ts +69 -1
  32. package/dist/map.js +54 -1
  33. package/dist/provider.d.ts +2 -0
  34. package/dist/provider.js +4 -0
  35. package/dist/rotateDevice.d.ts +24 -0
  36. package/dist/rotateDevice.js +83 -0
  37. package/dist/save.d.ts +25 -0
  38. package/dist/save.js +37 -0
  39. package/dist/selectSnapshot.d.ts +2 -0
  40. package/dist/selectSnapshot.js +2 -0
  41. package/dist/selectionHud.d.ts +133 -0
  42. package/dist/selectionHud.js +182 -0
  43. package/dist/settings.d.ts +3 -0
  44. package/dist/skillCheckPaint.d.ts +2 -0
  45. package/dist/skillCheckPaint.js +2 -0
  46. package/dist/social.d.ts +3 -3
  47. package/dist/social.js +31 -10
  48. package/dist/startScreen.d.ts +62 -0
  49. package/dist/startScreen.js +62 -0
  50. package/dist/store.d.ts +24 -0
  51. package/dist/store.js +9 -0
  52. package/package.json +4 -4
  53. package/skills/jgengine/SKILL.md +51 -0
  54. package/skills/jgengine/api.md +434 -0
  55. package/skills/jgengine/capabilities.md +29 -0
  56. package/skills/jgengine/reference-cartridge.md +72 -0
  57. package/skills/jgengine-ui/SKILL.md +36 -0
  58. package/skills/jgengine-ui/api.md +1852 -0
  59. package/skills/jgengine-ui/capabilities.md +142 -0
  60. package/skills/jgengine-ui/reference.md +500 -0
  61. package/skills/jgengine-ui/references/visual-scorecard.md +73 -0
  62. package/llms.txt +0 -1633
package/README.md CHANGED
@@ -1,3 +1,3 @@
1
1
  # @jgengine/react
2
2
 
3
- React UI layer for [JGengine](https://github.com/Noisemaker111/jgengine): `GameProvider`, hooks, and headless primitives over `@jgengine/core`. AGPL-3.0-only.
3
+ React UI layer for [JGengine](https://github.com/Noisemaker111/jgengine): `GameProvider`, hooks, and headless primitives over `@jgengine/core`. Apache-2.0.
@@ -0,0 +1,174 @@
1
+ import { type CSSProperties, type ReactNode } from "react";
2
+ import type { AbilitySlotSnapshot } from "@jgengine/core/combat/abilityKit";
3
+ import { type ActionDef, type ResolvedAction } from "@jgengine/core/ui/actionModel";
4
+ import { type PopoverSide, type TooltipContent } from "@jgengine/core/ui/tooltipModel";
5
+ /**
6
+ * Composable React renderers over the headless action view model (`@jgengine/core/ui/actionModel`) —
7
+ * the data/renderer/chrome split for an RTS command card, an ability action bar, a build menu, or a
8
+ * radial. `useActionBar` is the DATA/HOOK layer (resolution + focus/hover/keyboard/hotkey), `ActionBarChrome`
9
+ * is the composable RENDERER (swap `renderItem` for custom chrome, or ignore it and consume the model
10
+ * directly for a radial), and `ActionBar` is the batteries-included default. A game swaps renderer or
11
+ * chrome without forking the logic. Layout, dimensions, hotkeys, and catalog-id mapping stay caller-owned.
12
+ */
13
+ /**
14
+ * Adapt a combat {@link AbilitySlotSnapshot} into an {@link ActionDef} — the glue that binds an
15
+ * `AbilityKit` (via `useAbilitySlots`) to the action renderers without the game re-deriving cooldown
16
+ * and cost shapes. `extra` supplies the caller-owned bits the snapshot cannot know (label, icon,
17
+ * hotkey, description, resource name). Pure, so it is unit-testable.
18
+ *
19
+ * @capability action-from-ability-slot adapt a combat ability-kit snapshot into an action-bar action def
20
+ */
21
+ export declare function actionFromAbilitySlot(slot: AbilitySlotSnapshot, extra?: Partial<ActionDef> & {
22
+ resourceId?: string;
23
+ }): ActionDef;
24
+ /** The intent a key press resolves to over an action bar: move focus, activate, or ignore. */
25
+ export interface ActionBarKeyResult {
26
+ handled: boolean;
27
+ focusId?: string;
28
+ activateId?: string;
29
+ }
30
+ /**
31
+ * Pure keyboard/controller resolver for an action bar: arrows/Home/End move grid focus, Enter/Space
32
+ * activate the focused action, and any other single key routes through {@link actionByHotkey}. Returns
33
+ * the resulting intent without touching the DOM, so it unit-tests headless and `useActionBar` is a
34
+ * thin shell over it.
35
+ */
36
+ export declare function actionBarKeyAction(actions: readonly ResolvedAction[], focusedId: string | null, columns: number, key: string, options?: {
37
+ wrap?: boolean;
38
+ }): ActionBarKeyResult;
39
+ /** A minimal keyboard-event shape — a real `KeyboardEvent`/React synthetic satisfies it. */
40
+ export interface ActionKeyEvent {
41
+ key: string;
42
+ preventDefault(): void;
43
+ }
44
+ /** Options for {@link useActionBar}. */
45
+ export interface UseActionBarOptions {
46
+ /** Grid width for keyboard focus math and the default grid renderer (default: number of actions). */
47
+ columns?: number;
48
+ /** Wrap focus at the grid edges (default false). */
49
+ wrap?: boolean;
50
+ /** Fired when an action is activated by click, Enter/Space, or its hotkey. */
51
+ onActivate?: (id: string) => void;
52
+ /** Set false to ignore single-key hotkeys (default true). */
53
+ hotkeys?: boolean;
54
+ }
55
+ /** The live action-bar model returned by {@link useActionBar}. */
56
+ export interface ActionBarModel {
57
+ actions: readonly ResolvedAction[];
58
+ columns: number;
59
+ focusedId: string | null;
60
+ hoveredId: string | null;
61
+ setFocus: (id: string | null) => void;
62
+ setHover: (id: string | null) => void;
63
+ activate: (id: string) => void;
64
+ onKeyDown: (event: ActionKeyEvent) => void;
65
+ }
66
+ /**
67
+ * The DATA/HOOK layer: resolve `defs` into a live view model with focus, hover, keyboard grid
68
+ * navigation, and hotkey activation. Rendering-agnostic — feed the returned model to
69
+ * {@link ActionBarChrome}, or read it directly to lay out a radial or a bespoke card.
70
+ *
71
+ * @capability action-bar-model headless action-collection model with focus, hover, keyboard, and hotkey routing
72
+ */
73
+ export declare function useActionBar(defs: readonly ActionDef[], options?: UseActionBarOptions): ActionBarModel;
74
+ /** Render context passed to a custom {@link ActionBarChrome} item renderer. */
75
+ export interface ActionItemContext {
76
+ focused: boolean;
77
+ hovered: boolean;
78
+ model: ActionBarModel;
79
+ }
80
+ /**
81
+ * The default per-item chrome — a cooldown-aware action button with hotkey cap, cost line, radial
82
+ * cooldown wipe, and full rest/hover/focus/pressed/active/disabled states. Accessible: it is a real
83
+ * `<button>` with `aria-disabled`, `aria-pressed` for toggles, `aria-keyshortcuts`, and a native
84
+ * `title` describing why it is blocked. Swap it via `ActionBarChrome`'s `renderItem`, or drop it into
85
+ * a radial layout yourself.
86
+ *
87
+ * @capability action-button cooldown/cost/hotkey action button with complete interaction states
88
+ */
89
+ export declare function ActionButton({ action, focused, hovered, size, showHotkey, tabIndex, buttonRef, onActivate, onFocus, onHover, renderIcon, className, style, }: {
90
+ action: ResolvedAction;
91
+ focused?: boolean;
92
+ hovered?: boolean;
93
+ size?: number;
94
+ showHotkey?: boolean;
95
+ tabIndex?: number;
96
+ buttonRef?: (node: HTMLButtonElement | null) => void;
97
+ onActivate?: (id: string) => void;
98
+ onFocus?: (id: string) => void;
99
+ onHover?: (id: string | null) => void;
100
+ renderIcon?: (action: ResolvedAction) => ReactNode;
101
+ className?: string;
102
+ style?: CSSProperties;
103
+ }): import("react").JSX.Element;
104
+ /**
105
+ * The composable RENDERER: lay the model's actions out as a `grid` or `list` toolbar with roving
106
+ * tabindex, arrow-key navigation, and a moved DOM focus. `renderItem` swaps per-item chrome; omit it
107
+ * for {@link ActionButton}. A radial or bespoke UI can skip this entirely and read the model. Purely
108
+ * a wiring/behavior layer — no panel skin — so a game art-directs freely.
109
+ *
110
+ * @capability action-bar-chrome accessible grid/list toolbar renderer over an action-bar model with swappable item chrome
111
+ */
112
+ export declare function ActionBarChrome({ model, layout, itemSize, gap, ariaLabel, renderItem, className, style, }: {
113
+ model: ActionBarModel;
114
+ layout?: "grid" | "list";
115
+ itemSize?: number;
116
+ gap?: number;
117
+ ariaLabel?: string;
118
+ renderItem?: (action: ResolvedAction, ctx: ActionItemContext) => ReactNode;
119
+ className?: string;
120
+ style?: CSSProperties;
121
+ }): import("react").JSX.Element;
122
+ /**
123
+ * The batteries-included default: a skinned glass panel wrapping {@link useActionBar} +
124
+ * {@link ActionBarChrome}. Pass `defs`, choose `grid`/`list`, and hand it `onActivate`. The renderer
125
+ * and item chrome are still swappable via `renderItem`; a game that wants a different frame composes
126
+ * the hook and chrome directly.
127
+ *
128
+ * @capability action-bar drop-in cooldown/cost/hotkey action bar over the headless action model
129
+ */
130
+ export declare function ActionBar({ defs, columns, layout, wrap, hotkeys, itemSize, ariaLabel, onActivate, renderItem, className, style, }: {
131
+ defs: readonly ActionDef[];
132
+ columns?: number;
133
+ layout?: "grid" | "list";
134
+ wrap?: boolean;
135
+ hotkeys?: boolean;
136
+ itemSize?: number;
137
+ ariaLabel?: string;
138
+ onActivate?: (id: string) => void;
139
+ renderItem?: (action: ResolvedAction, ctx: ActionItemContext) => ReactNode;
140
+ className?: string;
141
+ style?: CSSProperties;
142
+ }): import("react").JSX.Element;
143
+ /**
144
+ * An accessible popover shell positioned by the pure {@link placePopover} math — flips and clamps
145
+ * against the viewport, renders nothing while closed, and defaults to `role="tooltip"`. Reuse it for
146
+ * an action's hover/focus description or any anchored panel.
147
+ *
148
+ * @capability popover viewport-flipping, viewport-clamped anchored popover/tooltip shell
149
+ */
150
+ export declare function Popover({ open, anchorRef, side, gap, role, id, children, className, style, }: {
151
+ open: boolean;
152
+ anchorRef: {
153
+ current: HTMLElement | null;
154
+ };
155
+ side?: PopoverSide;
156
+ gap?: number;
157
+ role?: string;
158
+ id?: string;
159
+ children: ReactNode;
160
+ className?: string;
161
+ style?: CSSProperties;
162
+ }): import("react").JSX.Element | null;
163
+ /**
164
+ * Render {@link TooltipContent} (from `actionTooltip`) as a styled block: title, subtitle, body,
165
+ * hotkey, cost lines, cooldown, and a warning footer of blocking notes. Drop it inside a
166
+ * {@link Popover} for an action's hover/focus description.
167
+ *
168
+ * @capability action-tooltip structured action tooltip body (title, cost, cooldown, blocking notes)
169
+ */
170
+ export declare function ActionTooltip({ content, className, style, }: {
171
+ content: TooltipContent;
172
+ className?: string;
173
+ style?: CSSProperties;
174
+ }): import("react").JSX.Element;
@@ -0,0 +1,292 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useCallback, useEffect, useMemo, useRef, useState, } from "react";
3
+ import { actionByHotkey, actionCooldownFromFraction, actionCost, moveGridFocus, resolveActionCollection, } from "@jgengine/core/ui/actionModel";
4
+ import { actionTooltip, placePopover } from "@jgengine/core/ui/tooltipModel";
5
+ import { Keycap } from "./keyHint.js";
6
+ /**
7
+ * Composable React renderers over the headless action view model (`@jgengine/core/ui/actionModel`) —
8
+ * the data/renderer/chrome split for an RTS command card, an ability action bar, a build menu, or a
9
+ * radial. `useActionBar` is the DATA/HOOK layer (resolution + focus/hover/keyboard/hotkey), `ActionBarChrome`
10
+ * is the composable RENDERER (swap `renderItem` for custom chrome, or ignore it and consume the model
11
+ * directly for a radial), and `ActionBar` is the batteries-included default. A game swaps renderer or
12
+ * chrome without forking the logic. Layout, dimensions, hotkeys, and catalog-id mapping stay caller-owned.
13
+ */
14
+ /**
15
+ * Adapt a combat {@link AbilitySlotSnapshot} into an {@link ActionDef} — the glue that binds an
16
+ * `AbilityKit` (via `useAbilitySlots`) to the action renderers without the game re-deriving cooldown
17
+ * and cost shapes. `extra` supplies the caller-owned bits the snapshot cannot know (label, icon,
18
+ * hotkey, description, resource name). Pure, so it is unit-testable.
19
+ *
20
+ * @capability action-from-ability-slot adapt a combat ability-kit snapshot into an action-bar action def
21
+ */
22
+ export function actionFromAbilitySlot(slot, extra) {
23
+ const def = { id: slot.id };
24
+ if (slot.cooldownRemainingMs > 0) {
25
+ def.cooldown = actionCooldownFromFraction(slot.cooldownRemainingMs, slot.cooldownFraction);
26
+ }
27
+ if (slot.resourceCost > 0) {
28
+ const available = slot.state === "no-resource" ? 0 : slot.resourceCost;
29
+ def.costs = [actionCost(extra?.resourceId ?? "resource", slot.resourceCost, available)];
30
+ }
31
+ if (extra?.label !== undefined)
32
+ def.label = extra.label;
33
+ if (extra?.description !== undefined)
34
+ def.description = extra.description;
35
+ if (extra?.icon !== undefined)
36
+ def.icon = extra.icon;
37
+ if (extra?.hotkey !== undefined)
38
+ def.hotkey = extra.hotkey;
39
+ if (extra?.group !== undefined)
40
+ def.group = extra.group;
41
+ if (extra?.active !== undefined)
42
+ def.active = extra.active;
43
+ if (extra?.disabled !== undefined)
44
+ def.disabled = extra.disabled;
45
+ if (extra?.reasons !== undefined)
46
+ def.reasons = extra.reasons;
47
+ return def;
48
+ }
49
+ const ARROW_DIRECTIONS = {
50
+ ArrowRight: "right",
51
+ ArrowLeft: "left",
52
+ ArrowUp: "up",
53
+ ArrowDown: "down",
54
+ Home: "first",
55
+ End: "last",
56
+ };
57
+ /**
58
+ * Pure keyboard/controller resolver for an action bar: arrows/Home/End move grid focus, Enter/Space
59
+ * activate the focused action, and any other single key routes through {@link actionByHotkey}. Returns
60
+ * the resulting intent without touching the DOM, so it unit-tests headless and `useActionBar` is a
61
+ * thin shell over it.
62
+ */
63
+ export function actionBarKeyAction(actions, focusedId, columns, key, options) {
64
+ const index = focusedId === null ? -1 : actions.findIndex((a) => a.id === focusedId);
65
+ const dir = ARROW_DIRECTIONS[key];
66
+ if (dir !== undefined) {
67
+ const next = moveGridFocus(index, actions.length, columns, dir, { wrap: options?.wrap === true });
68
+ const nextId = actions[next]?.id;
69
+ return nextId === undefined ? { handled: false } : { handled: true, focusId: nextId };
70
+ }
71
+ if (key === "Enter" || key === " " || key === "Spacebar") {
72
+ return focusedId === null ? { handled: false } : { handled: true, activateId: focusedId };
73
+ }
74
+ const hotkeyId = actionByHotkey(actions, key);
75
+ return hotkeyId === null ? { handled: false } : { handled: true, focusId: hotkeyId, activateId: hotkeyId };
76
+ }
77
+ /**
78
+ * The DATA/HOOK layer: resolve `defs` into a live view model with focus, hover, keyboard grid
79
+ * navigation, and hotkey activation. Rendering-agnostic — feed the returned model to
80
+ * {@link ActionBarChrome}, or read it directly to lay out a radial or a bespoke card.
81
+ *
82
+ * @capability action-bar-model headless action-collection model with focus, hover, keyboard, and hotkey routing
83
+ */
84
+ export function useActionBar(defs, options) {
85
+ const [focusedId, setFocusedId] = useState(null);
86
+ const [hoveredId, setHoveredId] = useState(null);
87
+ const actions = useMemo(() => resolveActionCollection(defs), [defs]);
88
+ const columns = options?.columns ?? Math.max(1, actions.length);
89
+ const activate = useCallback((id) => options?.onActivate?.(id), [options]);
90
+ const onKeyDown = useCallback((event) => {
91
+ const result = actionBarKeyAction(actions, focusedId, columns, event.key, { wrap: options?.wrap === true });
92
+ if (!result.handled)
93
+ return;
94
+ if (result.activateId !== undefined && options?.hotkeys === false && result.focusId === result.activateId) {
95
+ // hotkey routing suppressed
96
+ }
97
+ else {
98
+ if (result.focusId !== undefined)
99
+ setFocusedId(result.focusId);
100
+ if (result.activateId !== undefined)
101
+ activate(result.activateId);
102
+ }
103
+ event.preventDefault();
104
+ }, [actions, focusedId, columns, options, activate]);
105
+ return {
106
+ actions,
107
+ columns,
108
+ focusedId,
109
+ hoveredId,
110
+ setFocus: setFocusedId,
111
+ setHover: setHoveredId,
112
+ activate,
113
+ onKeyDown,
114
+ };
115
+ }
116
+ const ITEM_BASE = {
117
+ position: "relative",
118
+ boxSizing: "border-box",
119
+ display: "flex",
120
+ flexDirection: "column",
121
+ alignItems: "center",
122
+ justifyContent: "center",
123
+ gap: 2,
124
+ border: "1px solid rgba(255,255,255,0.14)",
125
+ borderRadius: 8,
126
+ background: "rgba(14,17,22,0.72)",
127
+ color: "#eef2f8",
128
+ font: "600 12px/1.1 ui-sans-serif, system-ui, sans-serif",
129
+ cursor: "pointer",
130
+ userSelect: "none",
131
+ overflow: "hidden",
132
+ };
133
+ /**
134
+ * The default per-item chrome — a cooldown-aware action button with hotkey cap, cost line, radial
135
+ * cooldown wipe, and full rest/hover/focus/pressed/active/disabled states. Accessible: it is a real
136
+ * `<button>` with `aria-disabled`, `aria-pressed` for toggles, `aria-keyshortcuts`, and a native
137
+ * `title` describing why it is blocked. Swap it via `ActionBarChrome`'s `renderItem`, or drop it into
138
+ * a radial layout yourself.
139
+ *
140
+ * @capability action-button cooldown/cost/hotkey action button with complete interaction states
141
+ */
142
+ export function ActionButton({ action, focused = false, hovered = false, size = 52, showHotkey = true, tabIndex, buttonRef, onActivate, onFocus, onHover, renderIcon, className, style, }) {
143
+ const tip = actionTooltip(action);
144
+ const title = [tip.title, ...(tip.notes ?? [])].join("\n");
145
+ const cooldown = action.cooldown;
146
+ const wipeDeg = cooldown !== null && !cooldown.ready ? Math.round(cooldown.fraction * 360) : 0;
147
+ return (_jsxs("button", { type: "button", ref: buttonRef, className: className, "data-action-id": action.id, "data-active": action.active ? "" : undefined, "data-enabled": action.enabled ? "" : undefined, "data-focused": focused ? "" : undefined, "aria-disabled": !action.enabled, "aria-pressed": action.active, "aria-keyshortcuts": action.hotkey, "aria-label": action.label, title: title, tabIndex: tabIndex, onClick: () => onActivate?.(action.id), onFocus: () => onFocus?.(action.id), onPointerEnter: () => onHover?.(action.id), onPointerLeave: () => onHover?.(null), style: {
148
+ ...ITEM_BASE,
149
+ width: size,
150
+ height: size,
151
+ opacity: action.enabled ? 1 : 0.5,
152
+ borderColor: action.active
153
+ ? "rgba(56,189,248,0.95)"
154
+ : focused
155
+ ? "rgba(148,197,255,0.9)"
156
+ : hovered
157
+ ? "rgba(255,255,255,0.3)"
158
+ : "rgba(255,255,255,0.14)",
159
+ boxShadow: focused
160
+ ? "0 0 0 2px rgba(96,165,250,0.7)"
161
+ : action.active
162
+ ? "0 0 0 1px rgba(56,189,248,0.6)"
163
+ : "none",
164
+ transform: hovered && action.enabled ? "translateY(-1px)" : "none",
165
+ transition: "transform 120ms ease, border-color 120ms ease, box-shadow 120ms ease",
166
+ ...style,
167
+ }, children: [showHotkey && action.hotkey !== undefined ? (_jsx("span", { style: { position: "absolute", top: 2, left: 3 }, "aria-hidden": true, children: _jsx(Keycap, { style: { fontSize: 9, padding: "0 3px", opacity: 0.85 }, children: action.hotkey }) })) : null, _jsx("span", { style: { fontSize: renderIcon ? undefined : 18, lineHeight: 1 }, "data-action-icon": true, "aria-hidden": true, children: renderIcon ? renderIcon(action) : (action.icon ?? action.label.slice(0, 2)) }), action.costs.length > 0 ? (_jsx("span", { "data-action-costs": true, style: { display: "flex", gap: 4, fontSize: 9 }, children: action.costs.map((cost) => (_jsx("span", { style: { color: cost.met ? "rgba(147,197,253,0.9)" : "#f87171" }, children: cost.amount }, cost.resourceId))) })) : null, wipeDeg > 0 ? (_jsx("span", { "data-action-cooldown": true, "aria-hidden": true, style: {
168
+ position: "absolute",
169
+ inset: 0,
170
+ pointerEvents: "none",
171
+ background: `conic-gradient(rgba(4,6,10,0.62) ${wipeDeg}deg, transparent 0deg)`,
172
+ }, children: _jsx("span", { style: {
173
+ position: "absolute",
174
+ inset: 0,
175
+ display: "flex",
176
+ alignItems: "center",
177
+ justifyContent: "center",
178
+ fontSize: 13,
179
+ fontWeight: 700,
180
+ fontVariantNumeric: "tabular-nums",
181
+ }, children: (action.cooldown.remainingMs / 1000).toFixed(action.cooldown.remainingMs < 10000 ? 1 : 0) }) })) : null] }));
182
+ }
183
+ /**
184
+ * The composable RENDERER: lay the model's actions out as a `grid` or `list` toolbar with roving
185
+ * tabindex, arrow-key navigation, and a moved DOM focus. `renderItem` swaps per-item chrome; omit it
186
+ * for {@link ActionButton}. A radial or bespoke UI can skip this entirely and read the model. Purely
187
+ * a wiring/behavior layer — no panel skin — so a game art-directs freely.
188
+ *
189
+ * @capability action-bar-chrome accessible grid/list toolbar renderer over an action-bar model with swappable item chrome
190
+ */
191
+ export function ActionBarChrome({ model, layout = "grid", itemSize = 52, gap = 6, ariaLabel = "Actions", renderItem, className, style, }) {
192
+ const refs = useRef(new Map());
193
+ const { actions, focusedId, hoveredId, columns } = model;
194
+ const rovingId = focusedId ?? actions[0]?.id ?? null;
195
+ useEffect(() => {
196
+ if (focusedId === null)
197
+ return;
198
+ refs.current.get(focusedId)?.focus();
199
+ }, [focusedId]);
200
+ const containerStyle = layout === "grid"
201
+ ? { display: "grid", gridTemplateColumns: `repeat(${Math.max(1, columns)}, ${itemSize}px)`, gap }
202
+ : { display: "flex", flexDirection: "column", gap };
203
+ return (_jsx("div", { role: "toolbar", "aria-label": ariaLabel, "aria-orientation": layout === "list" ? "vertical" : "horizontal", className: className, "data-action-bar": "", onKeyDown: model.onKeyDown, style: { ...containerStyle, ...style }, children: actions.map((action) => {
204
+ const ctx = {
205
+ focused: focusedId === action.id,
206
+ hovered: hoveredId === action.id,
207
+ model,
208
+ };
209
+ if (renderItem !== undefined)
210
+ return _jsx("div", { children: renderItem(action, ctx) }, action.id);
211
+ return (_jsx(ActionButton, { action: action, focused: ctx.focused, hovered: ctx.hovered, size: itemSize, tabIndex: rovingId === action.id ? 0 : -1, buttonRef: (node) => {
212
+ if (node === null)
213
+ refs.current.delete(action.id);
214
+ else
215
+ refs.current.set(action.id, node);
216
+ }, onActivate: model.activate, onFocus: model.setFocus, onHover: model.setHover }, action.id));
217
+ }) }));
218
+ }
219
+ const PANEL = {
220
+ padding: 8,
221
+ borderRadius: 12,
222
+ background: "rgba(10,12,16,0.6)",
223
+ backdropFilter: "blur(6px)",
224
+ WebkitBackdropFilter: "blur(6px)",
225
+ border: "1px solid rgba(255,255,255,0.1)",
226
+ boxShadow: "0 4px 16px rgba(0,0,0,0.35)",
227
+ };
228
+ /**
229
+ * The batteries-included default: a skinned glass panel wrapping {@link useActionBar} +
230
+ * {@link ActionBarChrome}. Pass `defs`, choose `grid`/`list`, and hand it `onActivate`. The renderer
231
+ * and item chrome are still swappable via `renderItem`; a game that wants a different frame composes
232
+ * the hook and chrome directly.
233
+ *
234
+ * @capability action-bar drop-in cooldown/cost/hotkey action bar over the headless action model
235
+ */
236
+ export function ActionBar({ defs, columns, layout = "grid", wrap, hotkeys, itemSize, ariaLabel, onActivate, renderItem, className, style, }) {
237
+ const model = useActionBar(defs, { columns, wrap, hotkeys, onActivate });
238
+ return (_jsx("div", { className: className, "data-action-panel": "", style: { ...PANEL, display: "inline-block", ...style }, children: _jsx(ActionBarChrome, { model: model, layout: layout, itemSize: itemSize, ariaLabel: ariaLabel, renderItem: renderItem }) }));
239
+ }
240
+ /**
241
+ * An accessible popover shell positioned by the pure {@link placePopover} math — flips and clamps
242
+ * against the viewport, renders nothing while closed, and defaults to `role="tooltip"`. Reuse it for
243
+ * an action's hover/focus description or any anchored panel.
244
+ *
245
+ * @capability popover viewport-flipping, viewport-clamped anchored popover/tooltip shell
246
+ */
247
+ export function Popover({ open, anchorRef, side = "top", gap = 8, role = "tooltip", id, children, className, style, }) {
248
+ const ref = useRef(null);
249
+ const [pos, setPos] = useState(null);
250
+ useEffect(() => {
251
+ if (!open || typeof window === "undefined")
252
+ return;
253
+ const anchor = anchorRef.current;
254
+ const node = ref.current;
255
+ if (anchor === null || node === null)
256
+ return;
257
+ const a = anchor.getBoundingClientRect();
258
+ const c = node.getBoundingClientRect();
259
+ const placement = placePopover({ x: a.left, y: a.top, width: a.width, height: a.height }, { width: c.width, height: c.height }, { width: window.innerWidth, height: window.innerHeight }, { preferred: side, gap });
260
+ setPos({ left: placement.left, top: placement.top });
261
+ }, [open, side, gap, anchorRef]);
262
+ if (!open)
263
+ return null;
264
+ return (_jsx("div", { ref: ref, role: role, id: id, "data-popover": "", className: className, style: {
265
+ position: "fixed",
266
+ left: pos?.left ?? -9999,
267
+ top: pos?.top ?? -9999,
268
+ zIndex: 1000,
269
+ pointerEvents: "none",
270
+ ...style,
271
+ }, children: children }));
272
+ }
273
+ /**
274
+ * Render {@link TooltipContent} (from `actionTooltip`) as a styled block: title, subtitle, body,
275
+ * hotkey, cost lines, cooldown, and a warning footer of blocking notes. Drop it inside a
276
+ * {@link Popover} for an action's hover/focus description.
277
+ *
278
+ * @capability action-tooltip structured action tooltip body (title, cost, cooldown, blocking notes)
279
+ */
280
+ export function ActionTooltip({ content, className, style, }) {
281
+ return (_jsxs("div", { className: className, "data-action-tooltip": "", style: {
282
+ maxWidth: 240,
283
+ padding: "8px 10px",
284
+ borderRadius: 8,
285
+ background: "rgba(8,10,14,0.95)",
286
+ border: "1px solid rgba(255,255,255,0.14)",
287
+ color: "#eef2f8",
288
+ font: "500 12px/1.35 ui-sans-serif, system-ui, sans-serif",
289
+ boxShadow: "0 8px 24px rgba(0,0,0,0.5)",
290
+ ...style,
291
+ }, children: [_jsxs("div", { style: { display: "flex", alignItems: "baseline", gap: 8 }, children: [_jsx("strong", { style: { fontSize: 13 }, children: content.title }), content.hotkey !== undefined ? (_jsx(Keycap, { style: { marginLeft: "auto", fontSize: 10 }, children: content.hotkey })) : null] }), content.subtitle !== undefined ? (_jsx("div", { style: { fontSize: 10, textTransform: "uppercase", letterSpacing: 0.4, opacity: 0.6 }, children: content.subtitle })) : null, content.body !== undefined ? _jsx("div", { style: { marginTop: 4, opacity: 0.9 }, children: content.body }) : null, content.costs !== undefined && content.costs.length > 0 ? (_jsx("div", { style: { marginTop: 4, display: "flex", gap: 8, fontSize: 11 }, children: content.costs.map((cost) => (_jsxs("span", { style: { color: cost.met ? "rgba(147,197,253,0.95)" : "#f87171" }, children: [cost.amount, " ", cost.resourceId] }, cost.resourceId))) })) : null, content.cooldown != null ? (_jsxs("div", { style: { marginTop: 4, fontSize: 11, opacity: 0.8 }, children: [(content.cooldown.remainingMs / 1000).toFixed(1), "s cooldown"] })) : null, content.notes !== undefined && content.notes.length > 0 ? (_jsx("div", { style: { marginTop: 6, paddingTop: 4, borderTop: "1px solid rgba(255,255,255,0.1)", fontSize: 11, color: "#fca5a5" }, children: content.notes.map((note) => (_jsx("div", { children: note }, note))) })) : null] }));
292
+ }
@@ -0,0 +1,59 @@
1
+ import type { CSSProperties, ReactNode } from "react";
2
+ import type { CardPileState } from "@jgengine/core/cards/cardPile";
3
+ import { type DragLayer } from "./dragLayer.js";
4
+ /** The four French-deck suits. */
5
+ export type Suit = "clubs" | "diamonds" | "hearts" | "spades";
6
+ /** A playing-card rank, ace through king. */
7
+ export type Rank = "A" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9" | "10" | "J" | "Q" | "K";
8
+ /** Suits in canonical order (clubs, diamonds, hearts, spades). */
9
+ export declare const SUITS: readonly Suit[];
10
+ /** Ranks in ascending order, ace low. */
11
+ export declare const RANKS: readonly Rank[];
12
+ /** True for the red suits (hearts, diamonds). */
13
+ export declare function isRedSuit(suit: Suit): boolean;
14
+ /** A single card in a stack: an id plus its face and orientation. */
15
+ export interface PlayingCard {
16
+ id: string;
17
+ rank: Rank;
18
+ suit: Suit;
19
+ faceDown?: boolean;
20
+ }
21
+ /** A single playing-card face: rank + suit pips, or a patterned back when `faceDown`. */
22
+ export declare function CardFace({ rank, suit, faceDown, width, height, className, style, children, }: {
23
+ rank: Rank;
24
+ suit: Suit;
25
+ faceDown?: boolean;
26
+ width?: number;
27
+ height?: number;
28
+ className?: string;
29
+ style?: CSSProperties;
30
+ children?: ReactNode;
31
+ }): import("react").JSX.Element;
32
+ /**
33
+ * An overlapping fan of `CardFace`s. Plain-props: pass `cards`. Optional drag: pass a `layer`
34
+ * to make each card a `DraggableCard`, and `dropId` to wrap the pile in a `DropZone`.
35
+ */
36
+ export declare function StackedPile({ cards, faceDown, offsetX, offsetY, cardWidth, cardHeight, layer, dropId, draggableFrom, className, cardClassName, onCardClick, renderOverlay, }: {
37
+ cards: readonly PlayingCard[];
38
+ faceDown?: boolean;
39
+ offsetX?: number;
40
+ offsetY?: number;
41
+ cardWidth?: number;
42
+ cardHeight?: number;
43
+ layer?: DragLayer<PlayingCard>;
44
+ dropId?: string;
45
+ draggableFrom?: number;
46
+ className?: string;
47
+ cardClassName?: string;
48
+ onCardClick?: (card: PlayingCard, index: number) => void;
49
+ renderOverlay?: (card: PlayingCard, index: number) => ReactNode;
50
+ }): import("react").JSX.Element;
51
+ /**
52
+ * `StackedPile` bound to a headless `CardPileState` zone: reads the ordered card ids from
53
+ * `pile.zones[zone]` and resolves each to a `PlayingCard` via `cardOf`.
54
+ */
55
+ export declare function CardStack({ pile, zone, cardOf, ...rest }: {
56
+ pile: CardPileState;
57
+ zone: string;
58
+ cardOf: (id: string) => PlayingCard;
59
+ } & Omit<Parameters<typeof StackedPile>[0], "cards">): import("react").JSX.Element;
package/dist/cards.js ADDED
@@ -0,0 +1,116 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { DraggableCard, DropZone } from "./dragLayer.js";
3
+ /** Suits in canonical order (clubs, diamonds, hearts, spades). */
4
+ export const SUITS = ["clubs", "diamonds", "hearts", "spades"];
5
+ /** Ranks in ascending order, ace low. */
6
+ export const RANKS = ["A", "2", "3", "4", "5", "6", "7", "8", "9", "10", "J", "Q", "K"];
7
+ /** True for the red suits (hearts, diamonds). */
8
+ export function isRedSuit(suit) {
9
+ return suit === "hearts" || suit === "diamonds";
10
+ }
11
+ const SUIT_PATHS = {
12
+ hearts: _jsx("path", { d: "M12 21C7 17 3 13.5 3 9.5A4.5 4.5 0 0112 7a4.5 4.5 0 019 2.5C21 13.5 17 17 12 21z" }),
13
+ diamonds: _jsx("path", { d: "M12 2l7.5 10L12 22 4.5 12z" }),
14
+ spades: (_jsx("path", { d: "M12 2C8.5 6 3.5 8.5 3.5 13A4 4 0 0011 15.7C10.7 18 9.7 19.3 8 21h8c-1.7-1.7-2.7-3-3-5.3A4 4 0 0020.5 13C20.5 8.5 15.5 6 12 2z" })),
15
+ clubs: (_jsx("path", { d: "M12 2a3.6 3.6 0 00-2.9 5.7A3.6 3.6 0 108.4 14.6 3.6 3.6 0 0011 15.3C10.8 18 9.8 19.4 8 21h8c-1.8-1.6-2.8-3-3-5.7a3.6 3.6 0 102.6-6.9A3.6 3.6 0 0012 2z" })),
16
+ };
17
+ function SuitGlyph({ suit, size = 16 }) {
18
+ return (_jsx("svg", { viewBox: "0 0 24 24", width: size, height: size, fill: "currentColor", "aria-hidden": "true", "data-suit-glyph": suit, style: { display: "block" }, children: SUIT_PATHS[suit] }));
19
+ }
20
+ const SUIT_LABEL = {
21
+ clubs: "clubs",
22
+ diamonds: "diamonds",
23
+ hearts: "hearts",
24
+ spades: "spades",
25
+ };
26
+ /** A single playing-card face: rank + suit pips, or a patterned back when `faceDown`. */
27
+ export function CardFace({ rank, suit, faceDown = false, width = 64, height = 90, className, style, children, }) {
28
+ const radius = Math.round(Math.min(width, height) * 0.11);
29
+ const base = {
30
+ width,
31
+ height,
32
+ borderRadius: radius,
33
+ boxSizing: "border-box",
34
+ position: "relative",
35
+ userSelect: "none",
36
+ ...style,
37
+ };
38
+ if (faceDown) {
39
+ return (_jsxs("div", { className: className, "data-card-face": "", "data-face-down": "", role: "img", "aria-label": "Face-down card", style: {
40
+ ...base,
41
+ border: "1px solid #1e2a52",
42
+ background: "repeating-linear-gradient(45deg, #2b3a72 0, #2b3a72 4px, #1b2650 4px, #1b2650 8px)",
43
+ }, children: [_jsx("div", { style: {
44
+ position: "absolute",
45
+ inset: Math.round(Math.min(width, height) * 0.09),
46
+ borderRadius: Math.max(2, radius - 3),
47
+ border: "1px solid rgba(255,255,255,0.35)",
48
+ } }), children] }));
49
+ }
50
+ const color = isRedSuit(suit) ? "#c02434" : "#16181d";
51
+ const cornerFont = Math.round(height * 0.16);
52
+ const corner = (rotated) => ({
53
+ position: "absolute",
54
+ display: "flex",
55
+ flexDirection: "column",
56
+ alignItems: "center",
57
+ lineHeight: 1,
58
+ color,
59
+ fontFamily: "Georgia, 'Times New Roman', serif",
60
+ fontWeight: 700,
61
+ fontSize: cornerFont,
62
+ ...(rotated
63
+ ? { right: Math.round(width * 0.06), bottom: Math.round(height * 0.05), transform: "rotate(180deg)" }
64
+ : { left: Math.round(width * 0.06), top: Math.round(height * 0.05) }),
65
+ });
66
+ return (_jsxs("div", { className: className, "data-card-face": "", "data-rank": rank, "data-suit": suit, role: "img", "aria-label": `${rank} of ${SUIT_LABEL[suit]}`, style: {
67
+ ...base,
68
+ border: "1px solid #c8ccd6",
69
+ background: "#ffffff",
70
+ }, children: [_jsxs("span", { "data-card-corner": "top", style: corner(false), children: [_jsx("span", { "data-card-rank": true, children: rank }), _jsx(SuitGlyph, { suit: suit, size: cornerFont })] }), _jsx("div", { style: {
71
+ position: "absolute",
72
+ inset: 0,
73
+ display: "flex",
74
+ alignItems: "center",
75
+ justifyContent: "center",
76
+ color,
77
+ }, children: _jsx(SuitGlyph, { suit: suit, size: Math.round(Math.min(width, height) * 0.5) }) }), _jsxs("span", { "data-card-corner": "bottom", style: corner(true), children: [_jsx("span", { "data-card-rank": true, children: rank }), _jsx(SuitGlyph, { suit: suit, size: cornerFont })] }), children] }));
78
+ }
79
+ /**
80
+ * An overlapping fan of `CardFace`s. Plain-props: pass `cards`. Optional drag: pass a `layer`
81
+ * to make each card a `DraggableCard`, and `dropId` to wrap the pile in a `DropZone`.
82
+ */
83
+ export function StackedPile({ cards, faceDown, offsetX = 0, offsetY = 20, cardWidth = 64, cardHeight = 90, layer, dropId, draggableFrom = 0, className, cardClassName, onCardClick, renderOverlay, }) {
84
+ const last = Math.max(0, cards.length - 1);
85
+ const width = cardWidth + Math.abs(offsetX) * last;
86
+ const height = cardHeight + Math.abs(offsetY) * last;
87
+ const baseLeft = offsetX < 0 ? -offsetX * last : 0;
88
+ const baseTop = offsetY < 0 ? -offsetY * last : 0;
89
+ const body = (_jsx("div", { className: className, "data-card-stack": "", "data-count": cards.length, style: { position: "relative", width, height }, children: cards.map((card, index) => {
90
+ const positioned = {
91
+ position: "absolute",
92
+ left: baseLeft + offsetX * index,
93
+ top: baseTop + offsetY * index,
94
+ zIndex: index,
95
+ };
96
+ const face = (_jsx(CardFace, { rank: card.rank, suit: card.suit, faceDown: card.faceDown ?? faceDown, width: cardWidth, height: cardHeight, className: cardClassName, children: renderOverlay?.(card, index) }));
97
+ const clickable = onCardClick !== undefined;
98
+ if (layer !== undefined && index >= draggableFrom) {
99
+ return (_jsx("div", { style: positioned, children: _jsx(DraggableCard, { id: card.id, value: card, layer: layer, onRotate: false, children: face }) }, card.id));
100
+ }
101
+ return (_jsx("div", { "data-card-slot": index, style: { ...positioned, cursor: clickable ? "pointer" : undefined }, onClick: clickable ? () => onCardClick(card, index) : undefined, children: face }, card.id));
102
+ }) }));
103
+ if (layer !== undefined && dropId !== undefined) {
104
+ return (_jsx(DropZone, { id: dropId, layer: layer, children: body }));
105
+ }
106
+ return body;
107
+ }
108
+ /**
109
+ * `StackedPile` bound to a headless `CardPileState` zone: reads the ordered card ids from
110
+ * `pile.zones[zone]` and resolves each to a `PlayingCard` via `cardOf`.
111
+ */
112
+ export function CardStack({ pile, zone, cardOf, ...rest }) {
113
+ const ids = pile.zones[zone] ?? [];
114
+ const cards = ids.map(cardOf);
115
+ return _jsx(StackedPile, { cards: cards, ...rest });
116
+ }