@jgengine/react 0.22.0 → 0.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/CHANGELOG.md +96 -0
  2. package/dist/accessibility.d.ts +5 -0
  3. package/dist/accessibility.js +9 -0
  4. package/dist/achievements.js +4 -3
  5. package/dist/actionHud.d.ts +6 -1
  6. package/dist/actionHud.js +37 -17
  7. package/dist/bars.js +5 -2
  8. package/dist/barsPreview.js +2 -1
  9. package/dist/characterSheet.js +5 -4
  10. package/dist/coachMarks.js +3 -2
  11. package/dist/codex.js +4 -3
  12. package/dist/components.js +9 -2
  13. package/dist/creditsScreen.js +2 -1
  14. package/dist/engineStyles.d.ts +18 -0
  15. package/dist/engineStyles.js +119 -0
  16. package/dist/fastTravel.js +4 -3
  17. package/dist/hud.js +7 -6
  18. package/dist/hudButton.d.ts +29 -0
  19. package/dist/hudButton.js +31 -0
  20. package/dist/hudChromePreview.d.ts +8 -0
  21. package/dist/hudChromePreview.js +27 -0
  22. package/dist/hudFrame.js +15 -11
  23. package/dist/hudInteract.d.ts +26 -0
  24. package/dist/hudInteract.js +28 -0
  25. package/dist/hudLayout.js +4 -3
  26. package/dist/hudPanel.d.ts +31 -0
  27. package/dist/hudPanel.js +32 -0
  28. package/dist/hudTheme.d.ts +88 -1
  29. package/dist/hudTheme.js +123 -8
  30. package/dist/hudThemePreview.js +2 -1
  31. package/dist/hudThemeRoot.d.ts +15 -0
  32. package/dist/hudThemeRoot.js +20 -0
  33. package/dist/hudTokens.d.ts +39 -0
  34. package/dist/hudTokens.js +56 -0
  35. package/dist/iconTreatment.js +3 -2
  36. package/dist/iconsPreview.js +2 -1
  37. package/dist/keybindingMenu.js +3 -2
  38. package/dist/map.js +9 -8
  39. package/dist/modals.d.ts +6 -3
  40. package/dist/modals.js +42 -52
  41. package/dist/notices.d.ts +27 -0
  42. package/dist/notices.js +145 -0
  43. package/dist/notifications.js +4 -3
  44. package/dist/panels.d.ts +9 -2
  45. package/dist/panels.js +78 -35
  46. package/dist/photoMode.js +2 -1
  47. package/dist/presence.d.ts +9 -0
  48. package/dist/presence.js +25 -0
  49. package/dist/previewFixtures.js +6 -0
  50. package/dist/questTracker.js +3 -2
  51. package/dist/quickMenu.js +6 -5
  52. package/dist/saveSlots.js +4 -3
  53. package/dist/selectionHud.js +4 -3
  54. package/dist/shopGrid.js +2 -1
  55. package/dist/statusEffectBar.js +2 -1
  56. package/dist/streetGeometryPreview.js +3 -2
  57. package/dist/styles.css +72 -0
  58. package/dist/talentTree.js +3 -2
  59. package/dist/tweenNumber.d.ts +37 -0
  60. package/dist/tweenNumber.js +66 -0
  61. package/dist/uiCues.d.ts +20 -0
  62. package/dist/uiCues.js +37 -0
  63. package/dist/waveHud.js +2 -1
  64. package/dist/waypointMarkers.js +2 -1
  65. package/package.json +10 -6
  66. package/skills/jgengine/api.md +18 -18
  67. package/skills/jgengine-ui/SKILL.md +4 -0
  68. package/skills/jgengine-ui/api.md +233 -37
  69. package/skills/jgengine-ui/capabilities.md +52 -0
  70. package/skills/jgengine-ui/reference-primitives.md +4 -4
package/CHANGELOG.md CHANGED
@@ -26,6 +26,102 @@ At publish, rename this heading to the new version and mirror the entries into
26
26
  `packages/core/src/meta/changelog.ts` (the typed `CHANGELOG` export).
27
27
  -->
28
28
 
29
+ ## 0.23.0
30
+
31
+ ### Migrate
32
+
33
+ - **Bump lockstep SDK packages to `^0.23.0`:** `@jgengine/{core,rapier,react,ws,node,sql,convex,shell,editor,assets,navbake}`. CLI `jgengine` is `0.20.0`; `@jgengine/github` is `0.10.0`.
34
+
35
+ ### Added
36
+
37
+ - `player_pose` agent-bridge verb (`@jgengine/shell`, `window.__jgengineAgent`, `drive --rpc '{"method":"player_pose"}'`) returns the local player's position and heading, and `debug_snapshot` carries the same `player` field, so a drive can prove movement from position deltas instead of screenshots.
38
+ - `sim_advance` agent-bridge verb (`@jgengine/shell`, `drive --rpc '{"method":"sim_advance","seconds":30}'`) steps the mounted sim driver — input, movement, `onTick`, behaviors, bound actions — in 0.05 s frames without rendering, so a headless drive can reach waves, shifts and timers that never arrive at software-rendered frame rates. Capped at 120 s per call.
39
+ - `time_scale` agent-bridge verb sets the game clock's timescale and reports `{ timescale, now }`.
40
+ - `GameContextObjectEntry.scenery` (`@jgengine/core`): an object kind placed once and left alone. Its runtime placements now render through the shared instanced static-object batches that editor-authored props already used.
41
+ - `BUILDING_STYLE_PROBABILITIES` and `resolveBuildingProbabilities` (`@jgengine/core/world/buildings`): each `BuildingStyle` now carries facade-part probabilities, so an industrial yard shows few windows and roof plant, a ruin loses awnings and clotheslines, coastal fronts gain awnings. `building({ probabilities })` overrides any part.
42
+ - `debug_summary` agent-bridge verb and `debug_snapshot` `detail: "summary"`: fps, frame/sim ms, draw calls, triangles, entities, warnings and top culprit in about 1 KB. `drive --rpc '{"method":"debug_snapshot"}'` prints the summary; `"detail":"full"` keeps the full snapshot.
43
+ - `modelLoadStarts()` (`@jgengine/shell/render/modelLoad`) counts model streaming bursts for capture quiescence checks.
44
+ - `jgengine` warns when it is not the CLI the caller meant: a global install run inside a jgengine checkout, a project-local `jgengine` shadowed, or a CLI built for an older SDK than the project installs. Harness runs print the CLI version and harness revision.
45
+ - `withGraphicsQualityOverride` and `parseGraphicsQuality` (`@jgengine/core/settings/graphicsSettings`) pin a graphics tier for one session without persisting it.
46
+ - The player shell reads `?quality=<tier>` and pins that tier for the page. `bun run shoot`/`drive --quality draft` use it (low tier, half size) for fast iteration shots; `--quality full` stays the default for PR evidence.
47
+ - `jgengine doctor` flags a silent game: one that neither declares `defineGame({ audio })` cues nor calls `ctx.game.audio.play`/`loop`. Four reference games shipped without a single sound; the fix text points at procedural `SoundDef` synth patches, which need no asset files.
48
+ - `drive --key mouse0:<ms>` (also `mouse1`, `mouse2`) holds that mouse button over the play canvas, matching the shell's pointer binding codes, so hold-to-fire weapons can be driven headlessly. Keyboard codes are unchanged.
49
+ - `drive --right-click-at x,y` sends a native right click in CSS viewport pixels, so RTS move/attack/gather orders and context actions can be driven headlessly; `--click-at` stays a left click.
50
+ - `ctx.scene.entity.selection` (`@jgengine/core`): the player's selected entities as a reactive `SelectionSet`. The shell pointer (`pointer.select` click / marquee) now writes it instead of private component state, so game HUDs can show the selected unit's card or a "3 units selected" summary and can set the selection themselves (select-all, control groups); the selection ring follows either way. Despawned ids drop out.
51
+ - `LightingConfig.iblAmbientScale` (default 0.5) and `LightingConfig.fallbackKey` (default true); `balanceLightingForIbl` in `@jgengine/core/render/lightingBalance` applies them.
52
+ - `DirectionalLightingConfig.shadowRadius` (PCF penumbra in texels, default 1.25).
53
+ - `DEFAULT_AO` in `@jgengine/core/render/postProcessing` and `mergePostProcessing` in `@jgengine/core/render/lookPreset`.
54
+ - Loaded GLB models get a default painted finish: flat-colour, vertex-colour and palette-atlas materials have their exported roughness/metalness/`envMapIntensity` normalized and compile with one shared object-space detail shader (mottling, wear, roughness break-up, micro-normal, cavity). Normal-mapped, cutout and transparent materials are untouched.
55
+ - `GraphicsProfile.surfaceDetail` (`"off" | "lite" | "full"`, `@jgengine/core/settings/graphicsProfile`): `"lite"` on low, `"full"` on medium and up.
56
+ - `ModelConfig.surfaceDetail: false` opts one model out; `defineGame({ surfaceDetail: false })` opts a game out.
57
+ - `createSurfaceRibbonMaterial`, `ribbonSurfaceFrame`, `resolvePathSurface` and `useSurfaceRibbonMaterial` (`@jgengine/shell/terrain`) shade ground ribbons with the terrain's procedural surface functions, plus wear and a noise-warped soft edge.
58
+ - `road({ surface })` (`@jgengine/core/world/features`) picks the carriageway surface; `false` keeps flat colour.
59
+ - `AuthoredScene`/`AuthoredPaths` `pathSurfaceDetail`/`surfaceDetail` props and path `meta.surface`, `meta.edgeSoftness`, `meta.wear`, `meta.surfaceDetail`.
60
+ - `EditableTerrain.gridBounds` (`@jgengine/core/world/terraform`) exposes the world extent of the paint/height cell grid.
61
+ - Painted terrain renders each layer's `surface` as its own procedural ground (grass, dirt, gravel, rock, sand, mud, snow, road) with albedo variation, roughness and micro-normals, blended along noise-warped, height-aware edges, in play and in the editor viewport. No game config or assets needed.
62
+ - `TerrainDetailConfig.surfaceStrength` (0..1, default 1) scales the per-surface response; `detail: false` opts a terrain out to the flat vertex-colour ground.
63
+ - `@jgengine/shell/terrain` exports `packTerrainPaintWeights`, `createTerrainPaintTextures`, `terrainSurfaceClass`, `createTerrainDetailMaterial` and `TerrainQualityContext`/`useTerrainQuality`.
64
+ - `ctx.game.notices` (`@jgengine/core/game/notices`, `createNoticeFeed`): a player-facing notice feed with info/success/warn/deny tones, stacking dedupe (repeats show ×N and refresh their lifetime), a cap, TTL, `onPush`, `retune` and `snapshot`/`restore`. Pure state on the context clock, so headless and hosted runs are unaffected.
65
+ - The shell mounts `NoticeStack` (`@jgengine/react/notices`) for every game: animated toned cards below top HUD bars, 14px text on `--jg-*` vars with fallbacks, no pointer capture. Configure with `notices: { placement, hidden, refusals, cues, cap, ttlMs }` or remove with `notices: false`.
66
+ - Refusal feedback: `validate` may return `{ reason, message }`. `reason` stays the machine code; a `message` is shown automatically as a deduped deny notice with the deny cue when the local player's command is refused, locally or from a host. `ctx.game.commands` emits a `command.refused` event for every refusal; `attachRefusalNotices` is the headless hookup.
67
+ - UI sound cues: `useUiCue()` / `playUiCue` (`@jgengine/react/uiCues`) play `click`, `hover`, `open`, `close`, `confirm`, `deny`, `notify`. Soft procedural defaults (`@jgengine/core/audio/uiCues`) mix on a new "ui" bus with a "UI volume" setting under master volume. A game sound named `ui.<cue>` replaces one; `audio.uiCues` overrides or silences cues (`false` for all).
68
+ - `notice_push` and `run_command` agent-bridge verbs for `drive --rpc`.
69
+ - `ui-feedback` dev probe (apps/dev) exercising refusals, the feed and cues.
70
+ - `HudTheme` gains a type scale with a 12px floor, spacing, motion (durations and easings), elevation (layered shadows and a lit top rim), and hover/press/focus/disabled state tokens; `hudThemeVars` emits them as `--jg-type-*`, `--jg-space-*`, `--jg-motion-*`, `--jg-ease*`, `--jg-elevation-*`, `--jg-highlight`, `--jg-hover-*`, `--jg-press-*`, `--jg-focus-ring`, and `--jg-disabled-opacity`. `@jgengine/react/hudTokens` reads them in inline styles (`hudType`, `hudSpace`, `hudTransition`, `hudElevation`, `hudFont`, `HUD_FOCUS_RING`).
71
+ - Engine stylesheet `@jgengine/react/styles.css` (also `ENGINE_STYLESHEET` / `useEngineStyles` in `@jgengine/react/engineStyles`): every `jg-*` keyframe the shared blocks and registry use, `[data-jg-*]` interaction states, quiet hover/press/focus defaults for plain HUD buttons, and reduced-motion handling for `prefers-reduced-motion` and `[data-reduced-motion="true"]`. The `jgengine create` template imports it.
72
+ - `HudThemeRoot`: the shell wraps every `GameUI` in it, applying `defaultHudTheme` and the stylesheet. `defineGame({ hudTheme })` swaps the root theme; `hudTheme: false` opts out.
73
+ - Primitives `HudButton`, `HudPanel` (open/close transition via `usePresence`), and `TweenNumber` / `useTweenedValue` with an up/down delta flash; `useReducedMotion` in `@jgengine/react/accessibility`.
74
+ - `HudInteractProvider` / `useHudInteract`, plus `cue` and `onInteract` on the primitives, windows, modals, and `ActionButton`: one seam for a UI sound system.
75
+
76
+ ### Changed
77
+
78
+ - Static-object batches group same-model props per 64 m cell from 3 placements (was 24 m cells and more than 8), so sparse city dressing such as palms, lamps and benches shares draws. Harbor Heat's opening drops from 5,310 to 4,650 draw calls.
79
+ - `building()` districts with a non-generic `style` generate a different facade mix than before (previously a style changed colours only). `generic` is unchanged.
80
+ - `bun run shoot/drive` and the scaffolded/bundled `shoot.mjs`/`drive.mjs` parse one shared flag table: `--click-at`, `--right-click-at`, `--width`, `--height`, `--port`, `--size`, `--settle`, `--param`, `--probe`, `--reload`, key chords and mouse holds work in both; unknown flags fail instead of being ignored or taken as a game id; dev-runner-only flags are refused by name in the portable harness.
81
+ - Play-mode capture readiness waits until staged `--state`/`--run` commands have ticked, their entities mounted and model streaming stopped, and never declares ready over "Loading game view…". A rejected staged command fails the capture.
82
+ - Play-mode captures of a game with a start menu and no `capture.play` click its start action and report what they clicked.
83
+ - Monorepo tests, type-checks and `bun run gen` resolve `@jgengine/*` to package source through the `paths` in `tsconfig.base.json` (`bun run gen:source-paths` keeps every workspace tsconfig in sync), so a fresh checkout or worktree is testable with no build. Published packages are unchanged: `exports` still point at `dist`, and the built output is byte-identical.
84
+ - The export manifest is computed from the files each package's build emits, not from `dist` on disk, so it can no longer drift with a stale or partial build. `bun run ship:preflight` now fails with the fix (`bun run gen`) when any generated artifact is stale.
85
+ - `bun run agent:worktree -- <name>` produces a clean, ready worktree locally and in cloud sessions: it links dependencies, `Games` and the dev runner's ignored assets from the main checkout instead of installing and building. `agent:bootstrap` only installs; pass `--build` to also build dist.
86
+ - The procedural IBL probe has a zenith/horizon/ground gradient and an HDR sun disc plus a broad lobe, with the horizon leaning toward the ambient colour, at intensity 0.45 (was a 2-colour gradient at 0.35), so forms get a lit and a shaded side from IBL alone.
87
+ - Under IBL (every look but `"flat"`), authored ambient is halved, and lighting with no directional light gets a soft shadowless key light (intensity 0.6, tinted by the ambient colour).
88
+ - A post chain that omits `ao` now runs `DEFAULT_AO` on medium tier and above; `ao: false` still opts out. `NEUTRAL_POST_PROCESSING` no longer sets `ao: false`.
89
+ - Styled look presets deep-merge their post stages under a partial game `postProcessing` instead of being dropped whole.
90
+ - Shadows use `PCFShadowMap` instead of `PCFSoftShadowMap`, which three r182 compiles as unfiltered basic shadows (stair-stepped edges), with a default radius of 1.25 texels on configured and cascaded lights.
91
+ - GTAO samples are tiered: 8 on medium, 12 on high (was 16), 16 on ultra.
92
+ - A model `material.rim` override now chains the material's live program cache key instead of a snapshot.
93
+ - Authored paths now render as dirt (or their `meta.surface`/kind) with wheel ruts and edges that fade into the terrain, instead of flat-colour ribbons with hard cut edges.
94
+ - `road()` carriageways, curbs, sidewalks and welded junctions, and city studio ground (streets, block land, sidewalks, drives, parks), shade with matching procedural asphalt, gravel, concrete and dirt. City volumes opt out with `meta.surfaceDetail: false`.
95
+ - Terrain noise and micro-normal GLSL moved to shared exports in `terrainSurfaceShader.ts` so terrain and ribbons compile the same code.
96
+ - A terrain without `detail` now renders with default procedural detail instead of flat vertex colour.
97
+ - Slope rock no longer fades with `detail.strength`; it follows `surfaceStrength`.
98
+ - The textured terrain path's normal map is applied in view space (it was added to the view-space normal as a world-space offset).
99
+ - The `low` graphics tier takes a cheap terrain surface path without micro-normals.
100
+ - The shell plays UI cues for every engine HUD primitive (`HudButton`, `HudPanel`, `Window`, `ModalHost`, `ActionButton`): activate → `click` (or the primitive's `cue` when it names a cue, e.g. `cue="confirm"`), button/action hover → `hover`, panel open/close → `open`/`close`. Off with `notices: false` or `notices.cues: false`; `audio.uiCues` silences single cues. `uiCueForInteraction` (`@jgengine/react/uiCues`) exposes the mapping.
101
+ - `CommandRejection`, `CommandValidationError`, `CommandResult`, `RunCommandResult` and `TransportRunCommandResult` carry an optional `message`; the ws hosts forward it. Results without one are unchanged.
102
+ - `defaultHudTheme` is the engine's default look: a system UI font stack led by Inter, layered translucent panels with a lit rim and hairline borders, and gradient slots.
103
+ - `Window` / `PanelHost` render on `HudPanel` with enter and exit transitions and a `HudButton` close; `ModalHost` fades its backdrop and lifts its dialog, with an exit; `ConfirmDialog` / `PauseMenu` use `HudPanel` and `HudButton`; `HudFrame` and `ActionButton` read the font, elevation, focus, and motion tokens.
104
+ - Text under 12px in `@jgengine/react` and `registry/jgengine` now uses the type scale's `xs` step (12px floor).
105
+
106
+ ### Fixed
107
+
108
+ - Games on the `rts` and `topDown` camera rigs hear positional sound again. The spatial-audio listener sat at the camera, 35–50 m above the ground, so the default 24 m falloff silenced every ground event even at screen centre; for these rigs it now sits where the camera looks at the ground. Chase, first-person and other rigs keep the camera-anchored listener.
109
+ - The bundled `jgengine drive` and `jgengine shoot` harnesses let Chrome pick a free debug port instead of guessing one from the clock, so they no longer attach to another Chrome that holds the same port. An interrupted drive or shoot (SIGINT/SIGTERM) now closes its Chrome instead of leaving it running.
110
+ - `jgengine doctor` flags component folders reached through a `tsconfig` path alias (such as a shared `@/components/ui/*` registry) that game code imports but `src/index.css` never scans with `@source`. Their Tailwind classes were silently not generated, so those HUD blocks rendered tiny and unstyled; the fix text names the exact `@source` line to add.
111
+ - `jgengine doctor` now flags a 3D game that sets neither a `look` preset nor `postProcessing`; such a game renders the unstyled `"neutral"` look with no tone-map grade, AO or bloom. Its fix text no longer claims cinematic is the default. Write `look: "neutral"` when unstyled output is deliberate.
112
+ - `jgengine create` scaffolds name `look: "cinematic"` explicitly, so a new 3D game starts graded instead of on the unstyled default.
113
+ - Generated city districts (`@jgengine/shell` `CityRenderer`) fade their developed ground to transparent over its outer 9 m, so it melts into the terrain or authored paint beyond it instead of ending in a hard, stair-stepped green rim.
114
+ - City districts (`@jgengine/shell` `CityRenderer`) draw a crossing of gravel lanes as gravel instead of a dark asphalt slab; a crossing that touches any paved street stays asphalt.
115
+ - The published CLI and scaffold browser driver restore game keyboard focus before key holds and wait for a rendered frame under the held key. Menu clicks and slow headless rendering no longer silently swallow movement inputs; failed frame sampling releases held keys.
116
+ - After a click on a control that then unmounts (a start screen's "Play", a menu's last button), `@jgengine/shell` returns focus to the play surface instead of leaving it on `<body>`, so gameplay keys work without an extra click on the canvas.
117
+ - `sim_advance` (`@jgengine/shell` agent bridge) yields to the browser every ten simulated seconds, so models for entities spawned mid-advance mount as in play; a 30 s advance through Beacon Bastion's first wave previously left every raider with health bars but no model.
118
+ - Generated city districts no longer lay a checkerboard over their ground. The developed-ground fabric drew one flat quad per 2.5 m cell with a per-cell random roll that flipped neighbours between zone bands; it is now one shared-vertex sheet coloured from smooth noise, so yards and lots blend.
119
+ - Authored terrain paint (paths, clearings, painted layers) now blends smoothly between neighbouring cells in the runtime ground and the editor sculpt view, instead of snapping each vertex to its 2 m cell and drawing grid-aligned stair-step edges. Unpainted cells blend toward the caller's base ground color, so painted edges fade into the terrain palette.
120
+ - A terrain detail `material` no longer overrides the ground's authored roughness with its roughness map. The map now varies roughness around the authored value, so scanned ground maps (roughness near 0.45) stop giving dry terrain a wet specular sheen; games no longer need to lower `material.strength` to stay matte.
121
+ - A terrain without a `waterLevel` no longer paints the detail shader's beach sand band. Previously the band defaulted to water at height 0, so any flat terrain near 0 (a park, a meadow) rendered as beach; six reference games set `detail.waterLevel` far below the world only to avoid it. Set `waterLevel` on the terrain or `detail.waterLevel` to keep a shoreline.
122
+ - Atomic vitals bars (`HealthBar`, `ManaBar`, … in `@jgengine/react`) cap their frame at a quarter of the bar height, so thin bars (e.g. `height: "4px"`) show their fill instead of only the two 2px frame lines.
123
+ - Built-in world health bars (`worldHealthBars`, `@jgengine/shell`) span about 1.4 m at the entity's on-screen scale (28–112 px) instead of a fixed 112 px, so top-down and RTS cameras no longer paint bars ten times wider than the units under them.
124
+
29
125
  ## 0.22.0
30
126
 
31
127
  ### Migrate
@@ -36,4 +36,9 @@ export declare function AccessibilityProvider({ store, children, className, styl
36
36
  * `reducedMotion` default.
37
37
  */
38
38
  export declare function usePrefersReducedMotion(): boolean;
39
+ /**
40
+ * True when motion should be skipped: the OS `prefers-reduced-motion` preference, or the nearest
41
+ * {@link AccessibilityProvider}'s `reducedMotion` setting. Works without a provider.
42
+ */
43
+ export declare function useReducedMotion(): boolean;
39
44
  export {};
@@ -59,3 +59,12 @@ export function usePrefersReducedMotion() {
59
59
  ? window.matchMedia("(prefers-reduced-motion: reduce)").matches
60
60
  : false, () => false);
61
61
  }
62
+ /**
63
+ * True when motion should be skipped: the OS `prefers-reduced-motion` preference, or the nearest
64
+ * {@link AccessibilityProvider}'s `reducedMotion` setting. Works without a provider.
65
+ */
66
+ export function useReducedMotion() {
67
+ const prefers = usePrefersReducedMotion();
68
+ const value = useContext(AccessibilityContext);
69
+ return prefers || value?.state.reducedMotion === true;
70
+ }
@@ -1,5 +1,6 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useSyncExternalStore } from "react";
3
+ import { hudType } from "./hudTokens.js";
3
4
  /**
4
5
  * Subscribe a component to an achievement tracker's live view list. The list
5
6
  * keeps a stable identity between changes, so this re-renders only on unlock or
@@ -40,7 +41,7 @@ export function AchievementToast({ name, description, icon, points, heading = "A
40
41
  border: "1px solid rgba(245,158,11,0.4)",
41
42
  fontSize: 20,
42
43
  flexShrink: 0,
43
- }, children: icon ?? "🏆" }), _jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 1, minWidth: 0 }, children: [_jsx("span", { style: { fontSize: 9, letterSpacing: 1.6, textTransform: "uppercase", color: "rgba(245,158,11,0.85)" }, children: heading }), _jsx("span", { style: { fontSize: 14, fontWeight: 700, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }, children: name }), description !== undefined ? (_jsx("span", { style: { fontSize: 11, color: "rgba(226,232,240,0.7)" }, children: description })) : null] }), points !== undefined ? (_jsx("span", { style: { marginLeft: "auto", fontSize: 13, fontWeight: 700, color: "var(--jg-rarity-legendary, #f59e0b)", fontVariantNumeric: "tabular-nums" }, children: points })) : null] }));
44
+ }, children: icon ?? "🏆" }), _jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 1, minWidth: 0 }, children: [_jsx("span", { style: { fontSize: hudType("xs"), letterSpacing: 1.6, textTransform: "uppercase", color: "rgba(245,158,11,0.85)" }, children: heading }), _jsx("span", { style: { fontSize: 14, fontWeight: 700, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }, children: name }), description !== undefined ? (_jsx("span", { style: { fontSize: hudType("xs"), color: "rgba(226,232,240,0.7)" }, children: description })) : null] }), points !== undefined ? (_jsx("span", { style: { marginLeft: "auto", fontSize: 13, fontWeight: 700, color: "var(--jg-rarity-legendary, #f59e0b)", fontVariantNumeric: "tabular-nums" }, children: points })) : null] }));
44
45
  }
45
46
  const MASK = "???";
46
47
  /**
@@ -63,7 +64,7 @@ export function AchievementGallery({ achievements, title = "Achievements", maskS
63
64
  color: "#e2e8f0",
64
65
  fontFamily: "ui-sans-serif, system-ui, sans-serif",
65
66
  ...style,
66
- }, children: [_jsxs("div", { style: { display: "flex", alignItems: "baseline", justifyContent: "space-between", marginBottom: 10 }, children: [_jsx("span", { style: { fontSize: 13, fontWeight: 700, letterSpacing: 1.4, textTransform: "uppercase" }, children: title }), _jsxs("span", { style: { fontSize: 11, color: "rgba(203,213,225,0.75)", fontVariantNumeric: "tabular-nums" }, children: [unlockedCount, "/", achievements.length, hasPoints ? ` · ${score} pts` : ""] })] }), achievements.length === 0 ? (_jsx("p", { style: { margin: 0, fontSize: 12, color: "rgba(148,163,184,0.8)" }, children: emptyLabel })) : (_jsx("ul", { style: {
67
+ }, children: [_jsxs("div", { style: { display: "flex", alignItems: "baseline", justifyContent: "space-between", marginBottom: 10 }, children: [_jsx("span", { style: { fontSize: 13, fontWeight: 700, letterSpacing: 1.4, textTransform: "uppercase" }, children: title }), _jsxs("span", { style: { fontSize: hudType("xs"), color: "rgba(203,213,225,0.75)", fontVariantNumeric: "tabular-nums" }, children: [unlockedCount, "/", achievements.length, hasPoints ? ` · ${score} pts` : ""] })] }), achievements.length === 0 ? (_jsx("p", { style: { margin: 0, fontSize: 12, color: "rgba(148,163,184,0.8)" }, children: emptyLabel })) : (_jsx("ul", { style: {
67
68
  listStyle: "none",
68
69
  margin: 0,
69
70
  padding: 0,
@@ -84,7 +85,7 @@ export function AchievementGallery({ achievements, title = "Achievements", maskS
84
85
  background: achievement.unlocked ? "rgba(245,158,11,0.10)" : "rgba(148,163,184,0.06)",
85
86
  border: `1px solid ${achievement.unlocked ? "rgba(245,158,11,0.4)" : "rgba(148,163,184,0.18)"}`,
86
87
  opacity: achievement.unlocked ? 1 : 0.75,
87
- }, children: [_jsx("div", { style: { fontSize: 20, width: 26, textAlign: "center", flexShrink: 0 }, children: icon }), _jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 2, minWidth: 0, flex: 1 }, children: [_jsxs("span", { style: { fontSize: 13, fontWeight: 700, color: achievement.unlocked ? "#f8fafc" : "rgba(226,232,240,0.85)" }, children: [name, achievement.points !== undefined && !masked ? (_jsx("span", { style: { marginLeft: 6, fontSize: 10, color: "rgba(245,158,11,0.85)", fontWeight: 600 }, children: achievement.points })) : null] }), description !== undefined ? (_jsx("span", { style: { fontSize: 11, color: "rgba(203,213,225,0.7)" }, children: description })) : null, showProgress ? (_jsx("div", { "data-achievement-progress": true, style: { marginTop: 3, height: 5, borderRadius: 9999, background: "rgba(148,163,184,0.2)", overflow: "hidden" }, children: _jsx("div", { style: {
88
+ }, children: [_jsx("div", { style: { fontSize: 20, width: 26, textAlign: "center", flexShrink: 0 }, children: icon }), _jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 2, minWidth: 0, flex: 1 }, children: [_jsxs("span", { style: { fontSize: 13, fontWeight: 700, color: achievement.unlocked ? "#f8fafc" : "rgba(226,232,240,0.85)" }, children: [name, achievement.points !== undefined && !masked ? (_jsx("span", { style: { marginLeft: 6, fontSize: hudType("xs"), color: "rgba(245,158,11,0.85)", fontWeight: 600 }, children: achievement.points })) : null] }), description !== undefined ? (_jsx("span", { style: { fontSize: hudType("xs"), color: "rgba(203,213,225,0.7)" }, children: description })) : null, showProgress ? (_jsx("div", { "data-achievement-progress": true, style: { marginTop: 3, height: 5, borderRadius: 9999, background: "rgba(148,163,184,0.2)", overflow: "hidden" }, children: _jsx("div", { style: {
88
89
  width: `${Math.round(achievement.fraction * 100)}%`,
89
90
  height: "100%",
90
91
  borderRadius: 9999,
@@ -2,6 +2,7 @@ import { type CSSProperties, type ReactNode } from "react";
2
2
  import type { AbilitySlotSnapshot } from "@jgengine/core/combat/abilityKit";
3
3
  import { type ActionDef, type ResolvedAction } from "@jgengine/core/ui/actionModel";
4
4
  import { type PopoverSide, type TooltipContent } from "@jgengine/core/ui/tooltipModel";
5
+ import { type HudInteractListener } from "./hudInteract.js";
5
6
  /**
6
7
  * Composable React renderers over the headless action view model (`@jgengine/core/ui/actionModel`) —
7
8
  * the data/renderer/chrome split for an RTS command card, an ability action bar, a build menu, or a
@@ -86,8 +87,12 @@ export interface ActionItemContext {
86
87
  *
87
88
  * @capability action-button cooldown/cost/hotkey action button with complete interaction states
88
89
  */
89
- export declare function ActionButton({ action, focused, hovered, size, showHotkey, tabIndex, buttonRef, onActivate, onFocus, onHover, renderIcon, className, style, }: {
90
+ export declare function ActionButton({ action, focused, hovered, size, showHotkey, tabIndex, buttonRef, onActivate, onFocus, onHover, renderIcon, cue, onInteract, className, style, }: {
90
91
  action: ResolvedAction;
92
+ /** Feedback cue name (default the action id), rendered as `data-jg-cue`. */
93
+ cue?: string;
94
+ /** Called on hover, press, and activate — the seam a UI sound system hooks. */
95
+ onInteract?: HudInteractListener;
91
96
  focused?: boolean;
92
97
  hovered?: boolean;
93
98
  size?: number;
package/dist/actionHud.js CHANGED
@@ -2,6 +2,9 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useCallback, useEffect, useMemo, useRef, useState, } from "react";
3
3
  import { actionByHotkey, actionCooldownFromFraction, actionCost, moveGridFocus, resolveActionCollection, } from "@jgengine/core/ui/actionModel";
4
4
  import { actionTooltip, placePopover } from "@jgengine/core/ui/tooltipModel";
5
+ import { useEngineStyles } from "./engineStyles.js";
6
+ import { useHudInteract } from "./hudInteract.js";
7
+ import { HUD_FOCUS_RING, hudElevation, hudFont, hudType } from "./hudTokens.js";
5
8
  import { Keycap } from "./keyHint.js";
6
9
  /**
7
10
  * Composable React renderers over the headless action view model (`@jgengine/core/ui/actionModel`) —
@@ -126,8 +129,11 @@ const ITEM_BASE = {
126
129
  border: "var(--jg-slot-border, 1px solid rgba(255,255,255,0.14))",
127
130
  borderRadius: "var(--jg-slot-radius, 8px)",
128
131
  background: "var(--jg-slot-bg, rgba(14,17,22,0.72))",
129
- color: "#eef2f8",
130
- font: "600 12px/1.1 ui-sans-serif, system-ui, sans-serif",
132
+ color: "var(--jg-text, #eef2f8)",
133
+ fontFamily: hudFont.body,
134
+ fontSize: hudType("xs"),
135
+ fontWeight: 600,
136
+ lineHeight: 1.1,
131
137
  cursor: "pointer",
132
138
  userSelect: "none",
133
139
  overflow: "hidden",
@@ -141,32 +147,43 @@ const ITEM_BASE = {
141
147
  *
142
148
  * @capability action-button cooldown/cost/hotkey action button with complete interaction states
143
149
  */
144
- export function ActionButton({ action, focused = false, hovered = false, size = 52, showHotkey = true, tabIndex, buttonRef, onActivate, onFocus, onHover, renderIcon, className, style, }) {
150
+ export function ActionButton({ action, focused = false, hovered = false, size = 52, showHotkey = true, tabIndex, buttonRef, onActivate, onFocus, onHover, renderIcon, cue, onInteract, className, style, }) {
145
151
  const tip = actionTooltip(action);
146
152
  const title = [tip.title, ...(tip.notes ?? [])].join("\n");
147
153
  const cooldown = action.cooldown;
148
154
  const wipeDeg = cooldown !== null && !cooldown.ready ? Math.round(cooldown.fraction * 360) : 0;
149
- 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: {
155
+ useEngineStyles();
156
+ const report = useHudInteract("action", cue ?? action.id, onInteract);
157
+ 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, "data-jg-cue": cue ?? action.id, "aria-disabled": !action.enabled, "aria-pressed": action.active, "aria-keyshortcuts": action.hotkey, "aria-label": action.label, title: title, tabIndex: tabIndex, onClick: () => {
158
+ report("activate");
159
+ onActivate?.(action.id);
160
+ }, onFocus: () => onFocus?.(action.id), onPointerDown: () => {
161
+ if (action.enabled)
162
+ report("press");
163
+ }, onPointerEnter: () => {
164
+ if (action.enabled)
165
+ report("hover");
166
+ onHover?.(action.id);
167
+ }, onPointerLeave: () => onHover?.(null), style: {
150
168
  ...ITEM_BASE,
151
169
  width: size,
152
170
  height: size,
153
- opacity: action.enabled ? 1 : 0.5,
171
+ opacity: action.enabled ? 1 : "var(--jg-disabled-opacity, 0.45)",
154
172
  borderColor: action.active
155
- ? "rgba(56,189,248,0.95)"
173
+ ? "var(--jg-accent, #38bdf8)"
156
174
  : focused
157
- ? "rgba(148,197,255,0.9)"
175
+ ? "var(--jg-edge-bright, rgba(148,197,255,0.9))"
158
176
  : hovered
159
177
  ? "rgba(255,255,255,0.3)"
160
- : "rgba(255,255,255,0.14)",
178
+ : undefined,
161
179
  boxShadow: focused
162
- ? "0 0 0 2px rgba(96,165,250,0.7)"
180
+ ? HUD_FOCUS_RING
163
181
  : action.active
164
- ? "0 0 0 1px rgba(56,189,248,0.6)"
165
- : "none",
166
- transform: hovered && action.enabled ? "translateY(-1px)" : "none",
167
- transition: "transform 120ms ease, border-color 120ms ease, box-shadow 120ms ease",
182
+ ? "0 0 0 1px var(--jg-accent, #38bdf8), 0 0 14px var(--jg-accent-glow, rgba(56,189,248,0.5))"
183
+ : hudElevation("raised"),
184
+ translate: hovered && action.enabled ? "0 var(--jg-hover-lift, -1px)" : undefined,
168
185
  ...style,
169
- }, 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: {
186
+ }, children: [showHotkey && action.hotkey !== undefined ? (_jsx("span", { style: { position: "absolute", top: 2, left: 3 }, "aria-hidden": true, children: _jsx(Keycap, { style: { fontSize: hudType("xs"), 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: hudType("xs") }, 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: {
170
187
  position: "absolute",
171
188
  inset: 0,
172
189
  pointerEvents: "none",
@@ -287,8 +304,11 @@ export function ActionTooltip({ content, className, style, }) {
287
304
  background: "rgba(8,10,14,0.95)",
288
305
  border: "1px solid rgba(255,255,255,0.14)",
289
306
  color: "#eef2f8",
290
- font: "500 12px/1.35 ui-sans-serif, system-ui, sans-serif",
291
- boxShadow: "0 8px 24px rgba(0,0,0,0.5)",
307
+ fontFamily: hudFont.body,
308
+ fontSize: hudType("xs"),
309
+ fontWeight: 500,
310
+ lineHeight: 1.35,
311
+ boxShadow: hudElevation("overlay"),
292
312
  ...style,
293
- }, 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] }));
313
+ }, children: [_jsxs("div", { style: { display: "flex", alignItems: "baseline", gap: 8 }, children: [_jsx("strong", { style: { fontSize: hudType("sm") }, children: content.title }), content.hotkey !== undefined ? (_jsx(Keycap, { style: { marginLeft: "auto", fontSize: hudType("xs") }, children: content.hotkey })) : null] }), content.subtitle !== undefined ? (_jsx("div", { style: { fontSize: hudType("xs"), textTransform: "uppercase", letterSpacing: "var(--jg-tracking-label, 0.08em)", opacity: 0.65 }, 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: hudType("xs") }, 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: hudType("xs"), 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: hudType("xs"), color: "#fca5a5" }, children: content.notes.map((note) => (_jsx("div", { children: note }, note))) })) : null] }));
294
314
  }
package/dist/bars.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import {} from "react";
3
3
  import { useEntityStat, useGameStore, localPlayerEntity } from "./hooks.js";
4
+ import { hudType } from "./hudTokens.js";
4
5
  const TOKEN_VAR = {
5
6
  health: "--jg-health",
6
7
  healthLow: "--jg-health-low",
@@ -85,7 +86,9 @@ function BarView({ fraction, valueText, config, label, width = 200, segments, en
85
86
  flex: endCap === undefined ? undefined : "1 1 auto",
86
87
  width: endCap === undefined ? "100%" : undefined,
87
88
  height: cssVar("height"),
88
- border: `${cssVar("frameWidth")} solid ${cssVar("frame")}`,
89
+ // The frame never takes more than a quarter of the height, so thin bars (4px roster/overhead
90
+ // bars) keep a visible fill instead of collapsing to two frame lines.
91
+ border: `min(${cssVar("frameWidth")}, calc(${cssVar("height")} / 4)) solid ${cssVar("frame")}`,
89
92
  background: cssVar("track"),
90
93
  backgroundImage: "var(--jg-bar-skin-track, none)",
91
94
  boxShadow: cssVar("bevel"),
@@ -120,7 +123,7 @@ function BarView({ fraction, valueText, config, label, width = 200, segments, en
120
123
  alignItems: "center",
121
124
  justifyContent: label === undefined ? "center" : "space-between",
122
125
  padding: "0 6px",
123
- fontSize: 11,
126
+ fontSize: hudType("xs"),
124
127
  fontWeight: 700,
125
128
  letterSpacing: 0.3,
126
129
  color: cssVar("text"),
@@ -2,6 +2,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import {} from "react";
3
3
  import { AmmoCounter, barTokens, BossBar, ExperienceBar, HealthBar, ManaBar, ShieldBar, SoulBar, StaminaBar, } from "./bars.js";
4
4
  import { hudTheme, hudThemeVars } from "./hudTheme.js";
5
+ import { hudType } from "./hudTokens.js";
5
6
  /**
6
7
  * A deterministic preview of every atomic vitals bar plus a composed example — the fixture behind
7
8
  * #1033's visual evidence. All values are static (no game provider), so it renders identically every
@@ -28,7 +29,7 @@ function Portrait({ color }) {
28
29
  } }));
29
30
  }
30
31
  function AtomicMatrix({ label, tokens }) {
31
- return (_jsxs("div", { style: { ...PANEL, ...(tokens === undefined ? {} : barTokens(tokens)) }, children: [_jsx("span", { style: { fontSize: 11, fontWeight: 700, letterSpacing: 0.6, textTransform: "uppercase", color: "rgba(255,255,255,0.6)" }, children: label }), _jsx(HealthBar, { value: 82, max: 100, label: "HP" }), _jsx(HealthBar, { value: 14, max: 100, label: "HP" }), _jsx(ShieldBar, { value: 40, max: 60, label: "SH" }), _jsx(ManaBar, { value: 55, max: 80, label: "MP" }), _jsx(StaminaBar, { value: 90, max: 100, label: "ST" }), _jsx(ExperienceBar, { value: 3, max: 10, segments: 10, showValue: false, label: "XP" }), _jsx(SoulBar, { value: 3, max: 5, segments: 5, showValue: false, label: "SOUL" }), _jsx(AmmoCounter, { loaded: 17, reserve: 90, magazine: 30 }), _jsx(BossBar, { value: 640, max: 1000, name: "Ancient Wyrm", width: "100%" }), _jsxs("div", { style: { display: "flex", alignItems: "center", gap: 8, marginTop: 4 }, children: [_jsx(Portrait, { color: tokens?.health ?? "#e5484d" }), _jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 4, flex: "1 1 auto" }, children: [_jsx(HealthBar, { value: 82, max: 100, width: "100%", showValue: false }), _jsx(ShieldBar, { value: 40, max: 60, width: "100%", showValue: false })] })] })] }));
32
+ return (_jsxs("div", { style: { ...PANEL, ...(tokens === undefined ? {} : barTokens(tokens)) }, children: [_jsx("span", { style: { fontSize: hudType("xs"), fontWeight: 700, letterSpacing: 0.6, textTransform: "uppercase", color: "rgba(255,255,255,0.6)" }, children: label }), _jsx(HealthBar, { value: 82, max: 100, label: "HP" }), _jsx(HealthBar, { value: 14, max: 100, label: "HP" }), _jsx(ShieldBar, { value: 40, max: 60, label: "SH" }), _jsx(ManaBar, { value: 55, max: 80, label: "MP" }), _jsx(StaminaBar, { value: 90, max: 100, label: "ST" }), _jsx(ExperienceBar, { value: 3, max: 10, segments: 10, showValue: false, label: "XP" }), _jsx(SoulBar, { value: 3, max: 5, segments: 5, showValue: false, label: "SOUL" }), _jsx(AmmoCounter, { loaded: 17, reserve: 90, magazine: 30 }), _jsx(BossBar, { value: 640, max: 1000, name: "Ancient Wyrm", width: "100%" }), _jsxs("div", { style: { display: "flex", alignItems: "center", gap: 8, marginTop: 4 }, children: [_jsx(Portrait, { color: tokens?.health ?? "#e5484d" }), _jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 4, flex: "1 1 auto" }, children: [_jsx(HealthBar, { value: 82, max: 100, width: "100%", showValue: false }), _jsx(ShieldBar, { value: 40, max: 60, width: "100%", showValue: false })] })] })] }));
32
33
  }
33
34
  function ThemeFills() {
34
35
  const themed = hudTheme({ palette: { health: "#7fb06d", healthLow: "#d89b38" } });
@@ -2,6 +2,7 @@ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-run
2
2
  import {} from "react";
3
3
  import { EquipmentSlots } from "./slots.js";
4
4
  import { HudFrame } from "./hudFrame.js";
5
+ import { hudType } from "./hudTokens.js";
5
6
  const DEFAULT_ARRANGEMENT = [
6
7
  { id: "head", label: "Head", position: "left" },
7
8
  { id: "chest", label: "Chest", position: "left" },
@@ -29,7 +30,7 @@ export function defaultEquipLayout(items = {}) {
29
30
  }
30
31
  function SlotLabel({ children }) {
31
32
  return (_jsx("span", { style: {
32
- fontSize: 9,
33
+ fontSize: hudType("xs"),
33
34
  letterSpacing: 0.6,
34
35
  fontVariant: "small-caps",
35
36
  textTransform: "lowercase",
@@ -92,7 +93,7 @@ export function Paperdoll({ slots, portrait, slotSize = 44, onSlotActivate, clas
92
93
  return (_jsxs("div", { "data-paperdoll": "", className: className, style: { display: "flex", flexDirection: "column", alignItems: "center", gap: 12, ...style }, children: [_jsxs("div", { style: { display: "flex", gap: 16, alignItems: "flex-start", justifyContent: "center" }, children: [column(left, "right"), _jsx("div", { "data-paperdoll-portrait": "", style: { display: "flex", alignItems: "center", justifyContent: "center", alignSelf: "stretch" }, children: portrait ?? _jsx(Silhouette, { size: slotSize * 1.4 }) }), column(right, "left")] }), bottom.length > 0 ? (_jsx("div", { style: { display: "flex", gap: 12, justifyContent: "center" }, children: bottom.map((slot) => (_jsx(EquipSlotCell, { slot: slot, slotSize: slotSize, align: "center", ...(onSlotActivate === undefined ? {} : { onSlotActivate }) }, slot.id))) })) : null] }));
93
94
  }
94
95
  function StatListRow({ row }) {
95
- return (_jsxs("div", { "data-stat-row": row.id, style: { display: "flex", justifyContent: "space-between", alignItems: "baseline", gap: 12, padding: "2px 0" }, children: [_jsx("span", { style: { fontSize: 11, fontVariant: "small-caps", letterSpacing: 0.5, color: "var(--jg-bar-text, #f4f6fb)", opacity: 0.72 }, children: row.label }), _jsxs("span", { style: { fontSize: 12, fontWeight: 700, fontVariantNumeric: "tabular-nums", color: "var(--jg-accent, #38bdf8)" }, children: [row.value, row.hint !== undefined ? (_jsx("span", { style: { marginLeft: 5, fontWeight: 500, color: "var(--jg-bar-text, #f4f6fb)", opacity: 0.5 }, children: row.hint })) : null] })] }));
96
+ return (_jsxs("div", { "data-stat-row": row.id, style: { display: "flex", justifyContent: "space-between", alignItems: "baseline", gap: 12, padding: "2px 0" }, children: [_jsx("span", { style: { fontSize: hudType("xs"), fontVariant: "small-caps", letterSpacing: 0.5, color: "var(--jg-bar-text, #f4f6fb)", opacity: 0.72 }, children: row.label }), _jsxs("span", { style: { fontSize: 12, fontWeight: 700, fontVariantNumeric: "tabular-nums", color: "var(--jg-accent, #38bdf8)" }, children: [row.value, row.hint !== undefined ? (_jsx("span", { style: { marginLeft: 5, fontWeight: 500, color: "var(--jg-bar-text, #f4f6fb)", opacity: 0.5 }, children: row.hint })) : null] })] }));
96
97
  }
97
98
  /**
98
99
  * Renders derived character stats as clean small-caps label→value rows, token-themed. Rows carrying a
@@ -112,7 +113,7 @@ export function StatList({ stats, className, style }) {
112
113
  groups.push({ name: row.group, rows: [row] });
113
114
  }
114
115
  return (_jsx("div", { "data-stat-list": "", className: className, style: { display: "flex", flexDirection: "column", gap: 4, ...style }, children: groups.map((group, index) => (_jsxs("div", { "data-stat-group": group.name ?? "", children: [group.name !== undefined ? (_jsx("div", { style: {
115
- fontSize: 9,
116
+ fontSize: hudType("xs"),
116
117
  letterSpacing: 1.4,
117
118
  textTransform: "uppercase",
118
119
  opacity: 0.55,
@@ -130,5 +131,5 @@ export function StatList({ stats, className, style }) {
130
131
  */
131
132
  export function CharacterSheet({ slots, stats, name, subtitle, portrait, header, title = "Character", variation = "themed", slotSize, onSlotActivate, className, style, }) {
132
133
  const hasHeader = header !== undefined || name !== undefined || subtitle !== undefined;
133
- return (_jsx(HudFrame, { variation: variation, title: title, interactive: true, padding: 14, ...(className === undefined ? {} : { className }), ...(style === undefined ? {} : { style }), children: _jsxs("div", { "data-character-sheet": "", style: { display: "flex", flexDirection: "column", gap: 12, minWidth: 260 }, children: [hasHeader ? (_jsx("div", { "data-character-header": "", style: { display: "flex", flexDirection: "column", gap: 1 }, children: header ?? (_jsxs(_Fragment, { children: [name !== undefined ? _jsx("div", { style: { fontSize: 16, fontWeight: 800, letterSpacing: 0.2 }, children: name }) : null, subtitle !== undefined ? (_jsx("div", { style: { fontSize: 11, opacity: 0.65, fontVariant: "small-caps", letterSpacing: 0.4 }, children: subtitle })) : null] })) })) : null, _jsxs("div", { style: { display: "flex", gap: 18, alignItems: "flex-start", flexWrap: "wrap" }, children: [_jsx(Paperdoll, { slots: slots, ...(portrait === undefined ? {} : { portrait }), ...(slotSize === undefined ? {} : { slotSize }), ...(onSlotActivate === undefined ? {} : { onSlotActivate }) }), _jsx(StatList, { stats: stats, style: { flex: "1 1 140px", minWidth: 140 } })] })] }) }));
134
+ return (_jsx(HudFrame, { variation: variation, title: title, interactive: true, padding: 14, ...(className === undefined ? {} : { className }), ...(style === undefined ? {} : { style }), children: _jsxs("div", { "data-character-sheet": "", style: { display: "flex", flexDirection: "column", gap: 12, minWidth: 260 }, children: [hasHeader ? (_jsx("div", { "data-character-header": "", style: { display: "flex", flexDirection: "column", gap: 1 }, children: header ?? (_jsxs(_Fragment, { children: [name !== undefined ? _jsx("div", { style: { fontSize: 16, fontWeight: 800, letterSpacing: 0.2 }, children: name }) : null, subtitle !== undefined ? (_jsx("div", { style: { fontSize: hudType("xs"), opacity: 0.65, fontVariant: "small-caps", letterSpacing: 0.4 }, children: subtitle })) : null] })) })) : null, _jsxs("div", { style: { display: "flex", gap: 18, alignItems: "flex-start", flexWrap: "wrap" }, children: [_jsx(Paperdoll, { slots: slots, ...(portrait === undefined ? {} : { portrait }), ...(slotSize === undefined ? {} : { slotSize }), ...(onSlotActivate === undefined ? {} : { onSlotActivate }) }), _jsx(StatList, { stats: stats, style: { flex: "1 1 140px", minWidth: 140 } })] })] }) }));
134
135
  }
@@ -1,5 +1,6 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useEffect, useLayoutEffect, useReducer, useState } from "react";
3
+ import { hudType } from "./hudTokens.js";
3
4
  /**
4
5
  * Subscribe to a coach-mark sequence and re-render on every change, returning the
5
6
  * current step view (or `null` when the tour is complete or waiting on a gate).
@@ -63,11 +64,11 @@ export function CoachMark({ view, positionStyle, onNext, onSkip, nextLabel = "Ne
63
64
  pointerEvents: "auto",
64
65
  ...positionStyle,
65
66
  ...style,
66
- }, children: [_jsxs("div", { "data-coach-mark-counter": true, style: { fontSize: 10, letterSpacing: 1.4, textTransform: "uppercase", fontWeight: 700, color: t.accent }, children: [view.index + 1, " of ", view.total] }), _jsx("div", { "data-coach-mark-title": true, style: { marginTop: 6, fontSize: 15, fontWeight: 700 }, children: view.step.title }), view.step.body !== undefined ? (_jsx("p", { "data-coach-mark-body": true, style: { margin: "6px 0 0", fontSize: 12.5, lineHeight: 1.5, color: "rgba(203,213,225,0.85)" }, children: view.step.body })) : null, _jsxs("div", { style: { marginTop: 14, display: "flex", alignItems: "center", justifyContent: "space-between", gap: 10 }, children: [_jsx("button", { type: "button", "data-coach-mark-skip": true, onClick: onSkip, style: {
67
+ }, children: [_jsxs("div", { "data-coach-mark-counter": true, style: { fontSize: hudType("xs"), letterSpacing: 1.4, textTransform: "uppercase", fontWeight: 700, color: t.accent }, children: [view.index + 1, " of ", view.total] }), _jsx("div", { "data-coach-mark-title": true, style: { marginTop: 6, fontSize: 15, fontWeight: 700 }, children: view.step.title }), view.step.body !== undefined ? (_jsx("p", { "data-coach-mark-body": true, style: { margin: "6px 0 0", fontSize: 12.5, lineHeight: 1.5, color: "rgba(203,213,225,0.85)" }, children: view.step.body })) : null, _jsxs("div", { style: { marginTop: 14, display: "flex", alignItems: "center", justifyContent: "space-between", gap: 10 }, children: [_jsx("button", { type: "button", "data-coach-mark-skip": true, onClick: onSkip, style: {
67
68
  border: "none",
68
69
  background: "transparent",
69
70
  color: "rgba(148,163,184,0.85)",
70
- fontSize: 11.5,
71
+ fontSize: hudType("xs"),
71
72
  cursor: "pointer",
72
73
  padding: 0,
73
74
  textDecoration: "underline",
package/dist/codex.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useMemo, useState } from "react";
3
+ import { hudType } from "./hudTokens.js";
3
4
  const ALL = "__all";
4
5
  const MASK = "???";
5
6
  /**
@@ -27,7 +28,7 @@ export function Codex({ entries, title = "Codex", columns = 3, maskSecrets = tru
27
28
  background: active ? "rgba(56,189,248,0.2)" : "transparent",
28
29
  color: active ? "#bae6fd" : "rgba(226,232,240,0.75)",
29
30
  padding: "3px 9px",
30
- fontSize: 11,
31
+ fontSize: hudType("xs"),
31
32
  fontWeight: 600,
32
33
  cursor: "pointer",
33
34
  });
@@ -40,7 +41,7 @@ export function Codex({ entries, title = "Codex", columns = 3, maskSecrets = tru
40
41
  color: "#e2e8f0",
41
42
  fontFamily: "ui-sans-serif, system-ui, sans-serif",
42
43
  ...style,
43
- }, children: [_jsxs("div", { style: { display: "flex", alignItems: "baseline", justifyContent: "space-between", marginBottom: 10 }, children: [_jsx("span", { style: { fontSize: 13, fontWeight: 700, letterSpacing: 1.4, textTransform: "uppercase" }, children: title }), _jsxs("span", { style: { fontSize: 11, color: "rgba(203,213,225,0.75)", fontVariantNumeric: "tabular-nums" }, children: [discovered, "/", entries.length, " discovered"] })] }), categories.length > 0 ? (_jsxs("div", { style: { display: "flex", flexWrap: "wrap", gap: 5, marginBottom: 10 }, children: [_jsx("button", { type: "button", "data-codex-tab": ALL, style: tabStyle(tab === ALL), onClick: () => setTab(ALL), children: "All" }), categories.map((category) => (_jsx("button", { type: "button", "data-codex-tab": category, style: tabStyle(tab === category), onClick: () => setTab(category), children: category }, category)))] })) : null, shown.length === 0 ? (_jsx("p", { style: { margin: 0, fontSize: 12, color: "rgba(148,163,184,0.8)" }, children: emptyLabel })) : (_jsx("ul", { style: { listStyle: "none", margin: 0, padding: 0, display: "grid", gridTemplateColumns: `repeat(${columns}, 1fr)`, gap: 8 }, children: shown.map((entry) => {
44
+ }, children: [_jsxs("div", { style: { display: "flex", alignItems: "baseline", justifyContent: "space-between", marginBottom: 10 }, children: [_jsx("span", { style: { fontSize: 13, fontWeight: 700, letterSpacing: 1.4, textTransform: "uppercase" }, children: title }), _jsxs("span", { style: { fontSize: hudType("xs"), color: "rgba(203,213,225,0.75)", fontVariantNumeric: "tabular-nums" }, children: [discovered, "/", entries.length, " discovered"] })] }), categories.length > 0 ? (_jsxs("div", { style: { display: "flex", flexWrap: "wrap", gap: 5, marginBottom: 10 }, children: [_jsx("button", { type: "button", "data-codex-tab": ALL, style: tabStyle(tab === ALL), onClick: () => setTab(ALL), children: "All" }), categories.map((category) => (_jsx("button", { type: "button", "data-codex-tab": category, style: tabStyle(tab === category), onClick: () => setTab(category), children: category }, category)))] })) : null, shown.length === 0 ? (_jsx("p", { style: { margin: 0, fontSize: 12, color: "rgba(148,163,184,0.8)" }, children: emptyLabel })) : (_jsx("ul", { style: { listStyle: "none", margin: 0, padding: 0, display: "grid", gridTemplateColumns: `repeat(${columns}, 1fr)`, gap: 8 }, children: shown.map((entry) => {
44
45
  const masked = maskSecrets && entry.secret === true && !entry.discovered;
45
46
  const name = masked ? MASK : entry.name;
46
47
  const icon = renderIcon?.(entry) ?? (masked ? "🔒" : entry.discovered ? entry.icon ?? "📖" : "◻");
@@ -57,6 +58,6 @@ export function Codex({ entries, title = "Codex", columns = 3, maskSecrets = tru
57
58
  color: "#e2e8f0",
58
59
  cursor: onSelect !== undefined ? "pointer" : "default",
59
60
  opacity: entry.discovered ? 1 : 0.7,
60
- }, children: [_jsx("span", { style: { fontSize: 24, filter: entry.discovered ? "none" : "grayscale(1) brightness(0.7)" }, children: icon }), _jsx("span", { style: { fontSize: 11, fontWeight: entry.discovered ? 700 : 500, textAlign: "center" }, children: name }), entry.discovered && entry.description !== undefined ? (_jsx("span", { style: { fontSize: 10, color: "rgba(203,213,225,0.7)", textAlign: "center" }, children: entry.description })) : null] }) }, entry.id));
61
+ }, children: [_jsx("span", { style: { fontSize: 24, filter: entry.discovered ? "none" : "grayscale(1) brightness(0.7)" }, children: icon }), _jsx("span", { style: { fontSize: hudType("xs"), fontWeight: entry.discovered ? 700 : 500, textAlign: "center" }, children: name }), entry.discovered && entry.description !== undefined ? (_jsx("span", { style: { fontSize: hudType("xs"), color: "rgba(203,213,225,0.7)", textAlign: "center" }, children: entry.description })) : null] }) }, entry.id));
61
62
  }) }))] }));
62
63
  }
@@ -159,8 +159,15 @@ export function ToastStack({ action, limit = 4, className, renderToast, }) {
159
159
  const entries = useFeed({ action, limit });
160
160
  if (entries.length === 0)
161
161
  return null;
162
- const newestFirst = [...entries].reverse();
163
- return (_jsx("div", { className: className, "data-toast-stack": action, children: newestFirst.map((entry, index) => (_jsx("div", { "data-toast": true, children: renderToast !== undefined ? renderToast(entry, index) : defaultToast(entry) }, `${entry.at}-${index}`))) }));
162
+ // Keys stay stable as newer entries arrive so a toast's entrance animation plays once.
163
+ const seen = new Map();
164
+ const keyed = entries.map((entry) => {
165
+ const n = seen.get(entry.at) ?? 0;
166
+ seen.set(entry.at, n + 1);
167
+ return { entry, key: `${entry.at}-${n}` };
168
+ });
169
+ const newestFirst = keyed.reverse();
170
+ return (_jsx("div", { className: className, "data-toast-stack": action, children: newestFirst.map(({ entry, key }, index) => (_jsx("div", { "data-toast": true, children: renderToast !== undefined ? renderToast(entry, index) : defaultToast(entry) }, key))) }));
164
171
  }
165
172
  export function DeathScreen({ statId = "health", open, className, children, }) {
166
173
  const dead = useLocalPlayerDead(statId);
@@ -1,5 +1,6 @@
1
1
  import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import {} from "react";
3
+ import { hudType } from "./hudTokens.js";
3
4
  /**
4
5
  * Renders a {@link CreditsDocument} as headed sections of credited lines. Skinned entirely through
5
6
  * the `--jg-*` HudTheme tokens, so it inherits the game's look from the surrounding theme block;
@@ -22,7 +23,7 @@ export function CreditsScreen({ document, className, style, renderEntry, }) {
22
23
  }, children: document.title })), document.sections.map((section) => (_jsxs("section", { "data-jg": "credits-section", style: { display: "flex", flexDirection: "column", gap: 8 }, children: [_jsx("h3", { style: {
23
24
  margin: 0,
24
25
  fontFamily: "var(--jg-font-display, inherit)",
25
- fontSize: 11,
26
+ fontSize: hudType("xs"),
26
27
  fontWeight: 700,
27
28
  letterSpacing: "0.24em",
28
29
  textTransform: "uppercase",
@@ -0,0 +1,18 @@
1
+ /**
2
+ * The engine stylesheet: every `jg-*` keyframe the shared blocks and registry components animate
3
+ * with, the `[data-jg-*]` interaction-state rules behind {@link HudButton} / {@link HudPanel}, the
4
+ * quiet hover/press/focus defaults for plain buttons inside the HUD root, and reduced-motion
5
+ * handling (`prefers-reduced-motion` or `[data-reduced-motion="true"]` zero `--jg-motion-scale`).
6
+ * Generic HUD rules sit in `:where()` so any game selector wins. The shell injects it; games that
7
+ * mount outside the shell import `@jgengine/react/styles.css`, which holds the same text.
8
+ */
9
+ export declare const ENGINE_STYLESHEET: string;
10
+ /** Injects {@link ENGINE_STYLESHEET} into `doc` once. Safe to call repeatedly and during SSR (no-op). */
11
+ export declare function ensureEngineStyles(doc?: Document | undefined): void;
12
+ /**
13
+ * Ensures the engine stylesheet is in the document before the calling component paints. Engine
14
+ * primitives call it, so they animate and show interaction states even outside the shell.
15
+ *
16
+ * @capability engine-styles inject the engine stylesheet (jg-* keyframes, interaction states, reduced motion) once
17
+ */
18
+ export declare function useEngineStyles(): void;