@velo/ui (0.2.0)
Installation
@velo:registry=npm install @velo/ui@0.2.0"@velo/ui": "0.2.0"About this package
@velo/ui
Game-neutral React command tiles, tooltips and the Debug Tools model-preview screen shared by Crownfall and Riftwake. Games supply their own model catalogues, preview factories and inspector content; the package never imports either game.
Install
The package is public on the Forgejo npm registry owned by velo. Add the scope to the consuming
project's .npmrc:
@velo:registry=https://git.amhk.dk/api/packages/velo/npm/
React, ReactDOM and Babylon are peer dependencies, so the game owns exactly one copy of each. Declare them in the game itself:
npm install @velo/ui react@^19.3.0 react-dom@^19.3.0 @babylonjs/core@^9.29.0
npm install --save-dev @types/react@^19.3.0 @types/react-dom@^19.3.0
Every automatic release is a new 0.x.0 minor and ^0.N.0 accepts only 0.N.x, so adopting a
release is a deliberate range and lockfile bump in the game.
Imports
Everything is exported from the package root:
import {
CommandTile,
CommandTooltipProvider,
useTooltip,
DebugTools,
DebugModelPreview,
PreviewAnimationControls,
filterDebugModels,
createInspectionPreview,
framePreviewModel,
PreviewPlayback,
type CommandTileProps,
type UseTooltipOptions,
type TooltipAnchorProps,
type DebugToolsProps,
type DebugModel,
type ModelPreview,
type PreviewModel,
type PreviewModelFactory,
type PreviewAnimation,
type PreviewPlaybackState,
} from '@velo/ui';
The built JavaScript is plain ES modules with .js specifiers, including Babylon deep imports such
as @babylonjs/core/scene.js, so it loads in Node (for unit tests) as well as in bundlers. JSX
consumers compile with "jsx": "react-jsx" under either NodeNext or Bundler resolution. Babylon's
declarations reference WebGPU types, so consumers use skipLibCheck (as both games do) or install
@webgpu/types.
Stylesheet
The Debug Tools styles are a separate file and are not imported by the JavaScript. Load
@velo/ui/debug-tools.css where you mount DebugTools, for example next to its lazy import:
if (new URL(location.href).searchParams.get('debug') === 'tools') {
const { DebugTools } = await import('@velo/ui');
await import('@velo/ui/debug-tools.css');
root.render(<DebugTools gameName="Game" gamePath={import.meta.env.BASE_URL} {...loaders} />);
}
CommandTile uses the class names command-tile, tile-button, tile-badge and tile-tooltip
(with data-open) and ships no styles; each game styles its own palettes.
Tooltips for any element
useTooltip gives a single element a styled tooltip without CommandTile's palette conventions.
Spread anchorProps on the element (its ref, hover and focus handlers and aria-describedby) and
render tooltip next to it. The tooltip shows on hover and keyboard focus and hides on pointer
leave, blur and Escape. The element keeps its own accessible name and should not also carry a
title.
import '@velo/ui/tooltip.css';
import { useTooltip } from '@velo/ui';
function ToolButton({ panelRef }: { panelRef: RefObject<Element | null> }) {
const id = useId();
const { anchorProps, tooltip } = useTooltip({
id,
side: 'below', // 'above' | 'below' | 'right'; flips to the opposite side when it does not fit
boundsRef: panelRef, // optional: stay inside this element instead of the whole viewport
content: (
<>
<strong>Paint texture</strong>
<span>Choose a texture, then hold the left mouse button to paint.</span>
</>
),
});
return (
<>
<button type="button" aria-label="Paint texture" {...anchorProps} />
{tooltip}
</>
);
}
The tooltip is a .ui-tooltip portal on document.body. @velo/ui/tooltip.css styles it from the
custom properties --ui-tooltip-background, --ui-tooltip-border, --ui-tooltip-ink and
--ui-tooltip-muted; set them on :root (per theme) because the tooltip is outside the game's
containers. Unlike CommandTile, each useTooltip is independent and needs no provider.
Lifecycle obligations
- Tooltips. Wrap every palette that should share one open tooltip in a single
CommandTooltipProvider; a tile outside a provider never opens its tooltip. Each tooltip is a portal ondocument.bodywith the giventooltipId, so ids must be unique in the document. An open tooltip listens for window resize and capture-phase scroll to stay inside the viewport and removes those listeners when it closes or unmounts. A tile whose palette is collapsed (no layout) closes its tooltip. - Debug Tools.
loadModelsreturns the game's catalogue andloadPreviewModelreturns itsPreviewModelFactory; both may lazily import game code. Keep both functions stable (module-level or memoised), because a new identity reloads the catalogue or preview. Game-specific inspectors are passed aschildren. The screen never reads or writes session storage. Return to {game} links togamePathwithout thedebugparameter; when the screen is a popup opened from that game page on the same origin, the link focuses the opener and closes the popup instead of navigating, so a popup that inherited the player's session never starts a second session. - Model preview.
createInspectionPreview(canvas, modelId, factory, onError, onPlaybackChange)owns one BabylonEngine,Scene, camera, light, resize observer, context-menu listener andPreviewPlayback. Calldispose()exactly when the preview is no longer shown; it is safe during loading and idempotent. The factory must create everything inside the supplied scene, so scene disposal releases it; it must not touch gameplay state.DebugModelPreviewdoes this for React. - Playback.
PreviewPlaybacksamples clips on an inspection clock in seconds, never the gameplay clock. Loops reload the clip so procedural state restarts. Afterdispose()it ignores every call. - Framing.
framePreviewModelfits enabled, visible meshes under the root and throws a readable error for empty or degenerate geometry.
Browser checks for the canvas, camera and data attributes (data-camera-*, data-animation-time)
live in each game's debug-tools browser spec; the shared scenario helper stays in the repository
tests/ and drives the screen through its public DOM only.
Local development
From this directory:
npm ci
npm run typecheck # sources and tests
npm run lint
npm test # playback, framing, tooltip and Debug Tools contracts (happy-dom)
npm run build # dist/ with JavaScript, declarations, source maps and debug-tools.css
npm run test:consumer # pack, install into a clean project, type-check JSX and load JS + CSS
test:consumer installs the tarball with only the declared peers and type packages, fails on any
out-of-range peer or second React/ReactDOM/Babylon copy, and rejects built imports of CSS,
undeclared modules or repository source. Pass -- --keep to keep the temporary project.
Optional local linking
To try unpublished changes in a game that already depends on @velo/ui, build here and install the
directory into the game without saving it:
npm run build
cd ../../games/<game> && npm install --no-save ../../packages/ui
Until each game migrates, the games still import this package's src/ directly. Their Vite configs
dedupe React and Babylon for that, but TypeScript and tsx resolve those imports from
packages/ui/node_modules when it exists, which yields duplicate Babylon/React types in root
checks. Remove packages/ui/node_modules before running root type-checks or game unit tests.
Publishing
The Publish UI workflow (.forgejo/workflows/publish-ui.yml) runs on pushes to main that change
this directory and on manual dispatch. It runs the checks and the packed-consumer check before it
configures the registry token, then picks the next 0.x.0 above the registry's versions, builds and
publishes to https://git.amhk.dk/api/packages/velo/npm/. The committed version is not the
release version. The PACKAGE_REGISTRY_TOKEN Actions secret must belong to an account with package
write access.
Dependencies
Development dependencies
| ID | Version |
|---|---|
| @babylonjs/core | ^9.29.0 |
| @happy-dom/global-registrator | ^21.0.1 |
| @types/node | ^26.6.4 |
| @types/react | ^19.3.0 |
| @types/react-dom | ^19.3.0 |
| eslint | ^10.12.0 |
| react | ^19.3.0 |
| react-dom | ^19.3.0 |
| tsx | ^4.23.15 |
| typescript | ^5.9.3 |
| typescript-eslint | ^8.71.1 |
Peer dependencies
| ID | Version |
|---|---|
| @babylonjs/core | ^9.29.0 |
| react | ^19.3.0 |
| react-dom | ^19.3.0 |