Claude Code — project memory for Octane
This file is read automatically by Claude Code at session start. It captures
the conventions and current state of this branch so a new session can hit
the ground running.
TL;DR
This client carries a long-running React 19.2 modernization: React 19
idioms + supporting infrastructure (TanStack Query, Zustand, Vitest,
React Compiler, error boundaries), god-hook splits, and logic-bug audits.
Working base is now main (tracking duckietm/Octane). The earlier
feat/react19-modernization long-running branch was superseded — feature
work now ships as small focused PRs against duckietm:Dev, staged through
Dev then merged to main. (feat/react19-modernization still exists on the
fork as backup; do not force-push it.)
Navigator modernization landed (merged to main 2026-05-28, PRs
#168/#169/#170): the 492-line useNavigator god-hook was split into
useNavigatorStore + useNavigatorData/useNavigatorUiState/
useNavigatorSearch filters (wired-tools layout), door lifecycle extracted
to src/hooks/rooms/widgets/useDoorState.ts, 9 UI flags moved to a Zustand
navigatorUiStore, search migrated to a query hook, and 5 sub-views wrapped
in WidgetErrorBoundary. Caveat: duckietm patched useNavigatorSearch
post-merge (05d71dd1) — see the useOctaneQuery fragility note below.
When syncing upstream, expect conflicts in App.tsx / bootstrap.ts /
LoginView.tsx on React 19 imports — always keep the modernized version.
Local-dev game assets are served by a small Vite plugin (sirv middleware
mounted on /nitro-assets and /swf, reading from
E:\Users\simol\Desktop\DEV\Nitro-Files) — NOT by symlinking inside
public/. The symlink path triggers chokidar on ~177k files and the dev
server hangs for minutes on Windows. See vite.config.mjs and the
.gitignore note.
Detailed status, decisions, and next steps live in docs/ARCHITECTURE.md —
read that before starting anything non-trivial.
Commands
| Goal |
Command |
| Dev server |
yarn start |
| Production build |
yarn build |
| Serve production build |
yarn preview (defaults to http://localhost:4173) |
| Lint |
yarn lint (Biome) |
| Hook-order lint (CI gate) |
yarn lint:hooks |
| Type-check (TS 7 native, fast) |
yarn typecheck |
| Test (Vitest, once) |
yarn test |
| Test (watch) |
yarn test:watch |
Setup walkthrough
-
Clone the renderer SDK as a sibling of this repo.
vite.config.mjs resolves the @octane/* aliases against
../octane-renderer (preferred) or ../renderer (legacy). If neither
exists, the dev server and build now fail fast with a message
pointing here.
cd .. # parent of octane
git clone <renderer-repo> octane-renderer
cd octane-renderer && yarn install
pixi.js resolves to the renderer's node_modules, so
vite.config.mjs also checks that the installed copy matches the
version the renderer's package.json pins and fails fast telling you
to rerun yarn install there. Re-run it whenever the renderer bumps
Pixi.
-
Install client deps.
cd ../octane
yarn install
-
Materialize the runtime configuration. public/configuration/
ships .example files. Copy the ones you need without the .example
suffix and point them at your game server (websocket URL, asset
base URL, UI texts, etc.). The dev server doesn't fail if these are
missing but the client renders a blank/error screen at runtime.
-
Run.
- Dev:
yarn start (Vite, HMR, includes the renderer source).
- Production preview:
yarn build && yarn preview.
The renderer SDK (@octane/renderer) is consumed via a filesystem
link to a sibling working tree — ../octane-renderer (preferred) or
../renderer (legacy). Without it, yarn typecheck reports TS2307 across
the codebase — that's expected on a sandbox without the renderer, not a
regression.
Runtime verification
This client can't be driven headless — the room renders via PixiJS/WebGL,
which hangs in a headless browser, and login needs an SSO session. To verify a
change at runtime, use the Claude-in-Chrome extension against the user's
real, logged-in localhost:5173 session: it shares the browser's SSO cookie
(auto-login) and uses the real GPU. Toolbar buttons are canvas (not in the
accessibility tree) — locate them by screenshot/zoom, then click by coordinate.
The preview_* (headless) tools can't bind 5173 here and won't get past
login/WebGL, so they're not usable for in-game verification.
Stack snapshot
- React 19.2.5,
react-dom 19.2.5, @types/react 19.2.x.
- TypeScript: TS 6 (
typescript) for the tools that need its JavaScript API (@typescript-eslint/parser,
peer <6.1), and TS 7 (the typescript7 alias of typescript@7, run as
node node_modules/typescript7/bin/tsc) for the typecheck script.
- UI primitives: Base UI (
@base-ui/react, imported per subpath). Radix UI is no longer a
dependency. Use the src/common/ wrappers instead of native controls or hand-rolled
ARIA widgets:
Checkbox, Radio (a controlled one-item RadioGroup, drop-in for
<input type="radio" checked onChange>), Select (drop-in for <select> + <option>,
change event keeps target.value), NumberInput (drop-in for type="number", event keeps
target.value / target.valueAsNumber, role spinbutton, hover/focus up-down overlay), Slider, FloatingPopover,
TabBar, InlineDialog (dialog mounted inline; modal="trap-focus" for the catalog confirms), CollapsibleSection, Tooltip,
ToggleButton (every aria-pressed two-state button, incl. InfiniteGrid.Item), ListSelect (Base UI Select with the
caller's own trigger/classes, for custom drop menus), SuggestInput (text input + Autocomplete list, replaces <datalist>),
Slider with orientation="vertical". Hint (in common/Tooltip.tsx) gives an icon-only control a tooltip without a
wrapper element: use it instead of a title attribute and keep an aria-label. A div role="button" is never right:
use BaseButton / ToggleButton (unless the region contains its own button).
Radios that share a name keep native group behaviour (arrow keys, one tab stop).
<button> is BaseButton (Base UI Button, still a native <button>; it sets type="button" unless a
type is passed, so form submit buttons need an explicit type="submit"). Deliberately native: text
<input> and <textarea> (Base UI Input is a plain input; there is no textarea primitive), and the
draggable-window dialogs (UserSettingsView, LayoutMiniCameraView: Dialog.Popup needs a Dialog.Portal,
which mounts one render late, but DraggableWindow looks up its handle and measures its size once at
mount, and the camera preview effect needs its canvas at mount).
Skin them through the octane-* classes (.octane-checkbox[data-checked],
.octane-radio, .octane-select-trigger, .octane-number-input): CSS written for
input[type=...] / select must be mirrored onto those classes. Base UI roots render as
<i> so a window's generic span / input rules do not hit them. In tests, read
aria-checked / aria-disabled (not .checked / .disabled), pick options with
src/test-utils/selectOption.ts, and use getByRole('checkbox'|'radio', { name }) rather
than getByLabelText for wrapped boxes (the label also matches the hidden input).
- Vite 8 +
@vitejs/plugin-react 6 + babel-plugin-react-compiler 1.0.
- Biome for lint and format; ESLint 10 +
@typescript-eslint/parser 8 + eslint-plugin-react-hooks@7
only for the Rules of Hooks gate (yarn lint:hooks).
- TanStack Query 5 (
@tanstack/react-query + devtools).
- Zustand 5.
- Vitest 3 + jsdom +
@testing-library/react + @testing-library/jest-dom.
react-error-boundary 6.
Layout convention (DO NOT CHANGE)
Established by the team and recorded in docs/ARCHITECTURE.md proposal #3
(rejected the src/features/ alternative). Stay on this layout — every PR
that violates it will need to be reworked.
src/components/<area>/<feature>/ → views (.tsx only)
e.g. src/components/room/widgets/doorbell/DoorbellWidgetView.tsx
src/hooks/<area>/<feature?>/ → hooks, FLAT files, no per-feature subfolder
e.g. src/hooks/rooms/widgets/useDoorbellState.ts
src/hooks/rooms/widgets/useDoorbellActions.ts
src/hooks/rooms/widgets/useDoorbellWidget.ts (deprecated shim)
src/api/ → cross-cutting helpers (LocalizeText, composers, formatters)
src/common/ → reusable UI primitives + error boundary
src/state/ → Zustand stores (cross-feature only)
src/**/*.test.{ts,tsx} → Vitest suites co-located next to their subject (e.g. `Foo.ts` + `Foo.test.ts`)
src/octane-renderer.mock.ts → hand-written renderer-SDK stub for tests (aliased over `@octane/renderer`)
src/test-setup.ts → Vitest setupFiles entry (jest-dom matchers, etc.)
When splitting a god-hook the convention is 3 files, all flat in the
hooks barrel directory:
use<Feature>State.ts — state + event subscriptions + derived values
use<Feature>Actions.ts — pure imperative actions (no state writes)
use<Feature>Widget.ts — deprecated wrapper that composes the two and
preserves the old return shape so existing consumers don't break
See useDoorbellState/useDoorbellActions/useDoorbellWidget as the
canonical pattern.
Patterns to use
useSessionSnapshots (renderer snapshot pattern, React-side — OPT-IN)
For state that lives on a renderer Manager and is invalidated through
OctaneEventType.*_UPDATED, the file
src/hooks/session/useSessionSnapshots.ts exposes eight consumer hooks
backed by useSyncExternalStore:
const userData = useUserDataSnapshot(); // SessionData
const room = useActiveRoomSessionSnapshot(); // RoomSession
const ignored = useIgnoredUsersSnapshot(); // ReadonlyArray<number> (user ids)
const isIgn = useIsUserIgnored(userId); // boolean, memoized
const badges = useGroupBadgesSnapshot(); // ReadonlyMap<number,string>
const badge = useGroupBadge(groupId); // string, memoized
const vols = useVolumesSnapshot(); // sound volumes
const users = useRoomUserListSnapshot(); // ReadonlyArray<IRoomUserData>
Each hook has defensive typeof method === 'function' guards against
a stale renderer bundle and degrades to a frozen default snapshot if
the renderer doesn't expose the matching getter (kept module-level so
React's bailout still works on the degraded path).
Adoption status: three pilot consumers shipped (commit d28819d,
2026-05-19). useSessionInfo reads userFigure / respectsLeft /
respectsPetLeft from useUserDataSnapshot; useChatWidget.ownUserId
reads from the snapshot directly; AvatarInfoWidgetAvatarView flips
its Ignore/Unignore menu via useIsUserIgnored.
The legacy use-between dispatcher proxy could not execute
useSyncExternalStore hooks and caused the original snapshot crash.
The project now uses useSharedHook, whose source runs in a normal
React host and publishes its snapshot through a Zustand vanilla store.
Shared sources must be registered at module scope so the root registry
can mount them before their first consumer renders.
const useSessionInfoState = () => {
const [chatStyleId, setChatStyleId] = useState(0);
// … useMessageEvent, actions …
return { chatStyleId, /* actions */ };
};
export const useSessionInfo = () => {
const shared = useSharedHook(useSessionInfoState);
const userData = useUserDataSnapshot();
return { ...shared, userFigure: userData.figure, /* etc */ };
};
registerSharedHook(useSessionInfoState);
Regression guard: src/hooks/session/useSessionSnapshots.test.tsx
and src/state/useSharedHook.test.tsx cover snapshot and shared-source
lifecycle behavior. A CI gate (yarn lint:hooks →
react-hooks/rules-of-hooks: error) blocks any future commit that
reintroduces hook-order issues.
useOctaneEventState / useMessageEventState
For "derived state from a single event" replace the two-step
useState + useOctaneEvent(e => setState(...)) with a single call:
const foo = useOctaneEventState(SomeEvent, e => e.payload, initial);
const data = useMessageEventState(SomeParser, e => e.getParser()?.field ?? null, null);
The selector is held in a useLayoutEffect-refreshed ref so the listener
stays registered across renders. Both hooks are exported from
src/hooks/events.
useOctaneQuery
For composer/parser request-response pairs:
const { data } = useOctaneQuery<SomeParser, SomeData>({
key: ['octane', 'domain', 'request', ...args],
request: () => new SomeComposer(args),
parser: SomeParser,
select: e => e.getParser()?.data,
accept: e => e.getParser()?.correlationKey === args, // optional, for shared event bus
staleTime: 60_000,
});
Already wired up; QueryClientProvider is mounted in src/index.tsx.
Companion useOctaneEventInvalidator(eventType, queryKey, accept?) —
import from src/api/octane-query. Subscribes to the renderer event
and invalidates the query slot on every push, so server-driven
refresh paths work the same as the initial request/response (e.g.
ClubGiftInfoEvent firing again after the user claims a gift).
⚠️ Fragility — do NOT use useOctaneQuery for primary visible data.
The one-shot listener inside awaitOctaneResponse (register listener →
await one matching response → remove itself) is fragile against
renderer-bundle quirks: for some parsers the event fires but the listener
never matches, so the promise never resolves and query.data stays
undefined forever — the UI shows the server's response arriving in logs
but renders blank. This bit ModTools Room/CFH chatlog (reverted to
useMessageEvent + useEffect) and then Navigator search (P2 shipped
with useOctaneQuery, duckietm reverted it in 05d71dd1 to the god-hook
pattern). Rule: reserve useOctaneQuery for config / secondary fetches
where a brief blank is tolerable. For anything that is the primary visible
content of a panel, use useMessageEvent + useState/useEffect — that's
what the rest of the codebase does and it's robust.
Singleton-filter split for Zustand-backed shared hooks
When a hook backs many consumers but most only need either state OR
actions (not both), split it without breaking the shared-singleton
guarantee:
// internal: state + actions in one closure
const useFooStore = () => {
const [ data, setData ] = useState(...);
// listeners, effects, actions ...
return { data, doThing };
};
// public: read-only filter
export const useFooState = () => {
const { data } = useSharedHook(useFooStore);
return { data };
};
// public: imperative filter
export const useFooActions = () => {
const { doThing } = useSharedHook(useFooStore);
return { doThing };
};
// deprecated shim — keeps the historical return shape
export const useFoo = () => useSharedHook(useFooStore);
registerSharedHook(useFooStore);
useSharedHook ensures all three entry points read the same Zustand
store, while the registered source owns listeners and effects once.
Zustand stores
For cross-feature UI state (avoid module-level let):
import { createOctaneStore } from '@/state/createOctaneStore';
export const useFooStore = createOctaneStore<FooState>()((set) => ({
...
}));
Components subscribe to slices, not the whole store:
const value = useFooStore(s => s.value);
Adoptions: src/components/navigator/views/navigatorRoomCreatorStore.ts (create-room lockout) and src/components/wired-tools/wiredCreatorToolsUiStore.ts (UI-only flags for the WiredCreatorTools panel — tab nav, modal/popover open, monitor + variable-manage filters).
WidgetErrorBoundary
Wrap any in-room widget tree so a crash degrades gracefully (logs to
OctaneLogger, falls back to null). Already applied at RoomWidgetsView
as an umbrella; per-widget wrapping is a follow-up.
<WidgetErrorBoundary name="ChatWidget">
<ChatWidgetView />
</WidgetErrorBoundary>
Form Actions
Login / Register / Forgot in src/components/login/LoginView.tsx use
useActionState + useFormStatus. The legacy non-Action versions in
src/components/login/components/{Register,Forgot}Dialog.tsx and
shared.ts have been removed (dead code).
Configuration pre-init in bootstrap
src/bootstrap.ts calls await GetConfiguration().init() before
importing ./index. Otherwise the first paint dumps a flood of
"Missing configuration key" warnings while components synchronously
read asset.url, login.endpoint, … against an empty store before
prepare()'s deferred init lands.
Asset serving in dev
Game assets (bundled/, c_images/, gamedata/, swf/...) are NOT
copied or symlinked under public/. They're served by a custom Vite
plugin (octaneAssetsServer in vite.config.mjs) that mounts sirv
on /nitro-assets and /swf, reading from
E:\Users\simol\Desktop\DEV\Nitro-Files\. sirv is a connect-style
middleware that bypasses chokidar entirely, so the ~177k asset files
never enter the watch graph. The plugin also wires the same handler
into configurePreviewServer so yarn preview keeps working.
What's wired up and what isn't
| Adopted |
Pilot sites |
Renderer snapshot consumer hooks (useSessionSnapshots) |
useSessionInfo (userFigure / userRespectRemaining / petRespectRemaining via useUserDataSnapshot), useChatWidget.ownUserId (via useUserDataSnapshot), AvatarInfoWidgetAvatarView Ignore/Unignore (via useIsUserIgnored), ModToolsView selected-user presence dot (via useRoomUserListSnapshot — green when still in the active room, gray when they've left). The 8 hooks (userData / activeRoomSession / ignoredUsers / groupBadges / soundVolumes / roomUserList / isUserIgnored / groupBadge) keep their typeof-guard defensive fallbacks for stale-renderer paths. |
| Reactive event-driven local state (companion to snapshots — when there is no manager-snapshot to read from yet) |
AvatarInfoWidgetAvatarView Give/Remove Rights — local controllerLevel initialized from avatarInfo.targetRoomControllerLevel, kept reactive via useMessageEvent<FlatControllerAddedEvent> / FlatControllerRemovedEvent filtered by parser.data.userId === avatarInfo.webID, plus optimistic bump on click so the moderate submenu flips immediately. Same shape as useIsUserIgnored but the source is the renderer event bus, not a snapshot getter — use this when adding a manager-side snapshot for the same data isn't justified. |
useOctaneEventState + companions (Reducer, ExternalSnapshot) |
OfferView, useAvatarInfoWidget (figure/badges/group reducer), useInventoryFurni (pure reducers + fragments useRef) |
useOctaneQuery + useOctaneEventInvalidator |
CatalogLayoutRoomAdsView, ModToolsChatlogView, CfhChatlogView, useGiftConfiguration, useUserGroups, useClubOffers(windowId), useSellablePetPalette(breed), useMarketplaceConfiguration, useClubGifts (with invalidator) |
| Zustand |
NavigatorRoomCreatorView (useRoomCreatorStore), WiredCreatorToolsView (useWiredCreatorToolsUiStore — every panel-lifecycle-relevant flag, snapshot, selection, highlight, inline editor, picker chain hoisted; what's left in the component as useState is genuinely transient: keepSelected, globalClock, roomEnteredAt, selectedMonitorErrorType, selectedMonitorLogDetails) |
| God-hook split (state + actions + shim) |
doorbell, poll, furni-chooser, user-chooser, friend-request, chat-input |
| God-hook split (Zustand-backed shared source + state filter + actions filter + shim) |
wired-tools, translation, notification, friends, catalog (three-way: useCatalogData / useCatalogUiState / useCatalogActions — all 48 consumers migrated, deprecated useCatalog shim removed) |
| Navigator modernization (merged to main 2026-05-28, PRs #168/#169/#170) |
492-line useNavigator god-hook split into a Zustand-backed shared useNavigatorStore + flat filters useNavigatorData / useNavigatorUiState / useNavigatorSearch; door bell/password lifecycle extracted to src/hooks/rooms/widgets/useDoorState.ts (dual-subscribes GetGuestRoomResultEvent + GenericErrorEvent alongside the nav store, each filtering by branch/errorCode); 9 UI flags + currentTabCode/currentFilter in Zustand navigatorUiStore (src/hooks/navigator/navigatorUiStore.ts); all 5 Navigator sub-views wrapped in WidgetErrorBoundary; old shim deleted. useNavigatorSearch was reverted by duckietm (05d71dd1) from useOctaneQuery to useMessageEvent + useEffect — see the useOctaneQuery fragility note. Specs/plans under docs/superpowers/. |
WidgetErrorBoundary |
RoomWidgetsView umbrella + per-widget wrap on all 13 room widgets and all 20 furniture widgets (so a crash in one widget no longer takes down its siblings) |
| Vitest |
207/207 cases — pure helpers (incl. 4 new on getPetPackageNameError) + 2 Zustand store suites (navigatorRoomCreatorStore, wiredCreatorToolsUiStore with 45 cases including the picker-chain hoists) + 2 component-/hook-level pilots (WidgetErrorBoundary, useDoorbellState) on top of the renderer-SDK mock at src/octane-renderer.mock.ts, 34 cases on the catalog pure helpers, 4 contract cases on the catalog filters. Tests are co-located under src/, alongside their subject. |
| Form Actions |
Login / Register / Forgot (LoginView.tsx) |
Upstream origin/Dev absorbed (merge 779a98c) |
Through b2318b9 (2026-05-18): JSON5, user-settings reset password/email/username, wear-badge popup fix, login screen fix, About, offer-selection refactor |
| Not yet |
Notes |
Split useChatWidget / useAvatarInfoWidget (data/actions) |
Both state-driven via events with no clean imperative actions to extract — split still skip-motivated, but useAvatarInfoWidget got a typed __octaneAvatarClickControl accessor + module-scope DEBOUNCE const in 2026-05-18 (commit 05ff7df). useChatWidget.ownUserId reactive migration re-applied 2026-05-19 in d28819d via useUserDataSnapshot. |
Split usePetPackageWidget / useWordQuizWidget / useChatCommandSelector (data/actions) |
Data/actions split remains a bad fit, but all three got real modernization in 2026-05-18 instead: usePetPackageWidget → useReducer + extracted getPetPackageNameError pure helper + 4 tests; useWordQuizWidget → fixed stale-closure bug in setUserAnswers updater + useRef for the timeout handle; useChatCommandSelector → module-level let cache replaced with a Zustand store. |
| Migrate more consumers to renderer snapshot hooks |
Unblocked. Three pilot consumers shipped 2026-05-19 (d28819d), pattern documented above. Next candidates: any code reading from GetSessionDataManager().userId / userName / clubLevel / securityLevel, GetRoomSessionManager().getActiveSession(), or GetSoundManager().<volume> synchronously — those don't re-render today when the value changes. CI gate yarn lint:hooks and src/hooks/session/useSessionSnapshots.test.tsx guard hook correctness. |
| Widen the component / hook test coverage |
Mock layer is in place (src/octane-renderer.mock.ts) and 3+ hook/component pilots pass. Good follow-up targets: LoginView Form Actions happy/error paths, OfferView with useOctaneQuery. (Acceptable only as a side-effect of a real change — coverage growth on its own is deprioritized per session feedback.) |
Known open logic bugs
None on this branch. The two previously-open races are closed:
MainView CREATED/ENDED race → fixed in 9d10e52 via a session-aware
reducer pattern.
LayoutFurniImageView / LayoutAvatarImageView async fetch race →
fixed in 97c9717 via requestIdRef guard on the async callback.
See docs/ARCHITECTURE.md "Recently fixed" for fix shapes.
House rules
- Never merge a branch that violates the layout convention above.
The hooks-adapter approach that put hooks under
src/components/... is
wrong and a recurring temptation.
- Skip-motivated god-hook splits are fine — when a hook's actions
mutate internal state, document the reason and move on rather than
forcing a bad split.
yarn test must stay green. yarn typecheck + yarn test --run
must both pass.
- Lint baseline: don't regress. Some pre-existing errors (
FC<{}>,
IMessageEvent | undefined redundant union in the local sandbox where
the renderer SDK isn't installed) are out of scope here.
Where everything lives
- Architecture doc:
docs/ARCHITECTURE.md
- Test runner config:
vitest.config.mts (separate from vite.config.mjs)
- Test setup:
src/test-setup.ts
- Test convention: co-located under
src/ next to the subject (src/<path>/Foo.ts ↔ src/<path>/Foo.test.ts). No separate tests/ tree.
- React Query adapter:
src/api/octane-query/createOctaneQuery.ts
- Zustand factory:
src/state/createOctaneStore.ts
- Error boundary:
src/common/error-boundary/WidgetErrorBoundary.tsx
- Event hooks (
useOctaneEvent, useMessageEvent, useOctaneEventState,
useMessageEventState): src/hooks/events/
- Wired-tools split (types/constants/helpers + 3 tab views):
src/components/wired-tools/
- User account settings (cherry-picked from upstream PR #126):
src/components/user-settings/UserAccountSettingsView.tsx
- Access-token persistence helper (used by login + remember + rotate):
src/api/auth/accessToken.ts (persistAccessTokenFromPayload)
- Asset middleware:
octaneAssetsServer() in vite.config.mjs
- Configuration pre-init:
src/bootstrap.ts (await GetConfiguration().init()
before import('./index'))
- Catalog pure helpers:
src/hooks/catalog/useCatalog.helpers.ts
(buildCatalogNodeTree, findNodeById / findNodeByName,
getNodesByOfferIdFromMap, getOfferProductKeys,
normalizeCatalogType, resolveBuilderFurniPlaceableStatus)
- Catalog three-way filter split:
useCatalogData /
useCatalogUiState / useCatalogActions in
src/hooks/catalog/useCatalog.ts (all 48 consumers migrated;
deprecated useCatalog shim removed)
- Navigator hooks:
src/hooks/navigator/ — useNavigatorStore.ts
(internal closure), useNavigatorData.ts / useNavigatorUiState.ts /
useNavigatorSearch.ts (filters), navigatorUiStore.ts (Zustand UI
flags + setTab/setFilter). Door lifecycle: src/hooks/rooms/widgets/useDoorState.ts.
Specs/plans: docs/superpowers/specs/2026-05-2*-navigator-*.md
- Avatar editor thumbnails:
src/api/avatar/avatarThumbnailUrls.ts (blob/object-URL cache, canvas shaping)
behind AvatarEditorThumbnailsHelper. Part thumbnails go render target -> canvas -> one PNG encode;
they are never turned into a data: URL in between. The LRU budgets real image bytes (48 MB, was
200 MB of base64 characters) and revokes evicted URLs after a grace period, so a render already in
flight keeps its image. A browser without toBlob/createObjectURL falls back to a data URL.
- Floorplan editor preview panel defaults to the flat
FloorplanPreviewSVG; the 2D/3D switch in its head mounts
the WebGL view on demand and remembers the choice in localStorage (octane.floorplan.preview3d), so legacy
devices never pay for a second GL context unless asked.
- Floorplan editor 3D preview (PixiJS 8, own WebGL context):
src/components/floorplan-editor/views/Floorplan3DView.tsx
(React panel + orbit controls) over src/components/floorplan-editor/scene3d/
(FloorplanScene.ts instanced heightmap mesh + GLSL, heightmap.ts tile columns
and camera, mat4.ts). PixiJS is await imported so the editor bundle and jsdom
never load it unless the panel is open; Vitest aliases pixi.js to src/pixi.mock.ts.
- Floorplan editor look (official-client "Éditeur Sol" layout): scoped
.octane-floorplan-window
styles in src/css/floorplan-editor/FloorplanEditorView.css (imported from src/index.tsx);
labels fall back to English through src/components/floorplan-editor/state/localize.ts
when the external texts lack a key.
- Wired creator tools paged windows:
src/components/wired-tools/WiredRoomLogsView.tsx (room log,
WiredRoomLogsPageComposer / WiredLogPageEvent) and WiredVariableOwnersView.tsx (holders of a
variable, WiredVariableHoldersPageComposer / WiredVariableHoldersPageEvent), both on
WiredPagedTable.tsx + WiredPaging.helpers.ts (pure paging and rate limiter) and
src/hooks/wired-tools/useWiredPageRequests.ts. The "Manage" button of the Variables tab opens the
owners window; its "Manage" links feed the existing holder detail panel in WiredCreatorToolsView.tsx.
- Wired settings tab (
src/components/wired-tools/WiredToolsSettingsTabView.tsx): permissions and timezone go
through the official WiredMenuPermissionsSaveComposer, reload/rollback through WiredRoomStateActionComposer
(store actions saveRoomTimezone, reloadRoomWired, rollbackRoomWired). Client config: wired.timezones
(comma list; empty = browser list), wired.selfdonation.enabled shows the sandbox donation button that opens
WiredSelfDonationView.tsx (SelfDonationMessageComposer; the server gates on hotel.selfdonation.enabled + acc_debug).
- Wired variable fx (layout codes 130-135,
wf_xtra_varfx_*, with wf_xtra_var_fx_* as aliases): one editor
src/components/wired/views/extras/WiredExtraVariableFxView.tsx (prop category) on the pure tables and
param/token codecs in src/api/wired/WiredVariableFx.ts, which mirror the server's WiredVariableFxStyles /
WiredExtraVariableFx. The four server packets (WiredVariableFxConfigs/ConfigsRemoved/Status/StatusRemoved)
feed src/hooks/wired/wiredVariableFxStore.ts through useWiredVariableFxEvents; WiredVariableFxView
(src/components/room/widgets/wired-fx/, mounted in RoomView, renders nothing) drives
useWiredVariableFxRoomDrawing / wiredVariableFxRoomDrawing.ts, which decide visibility and hand each holder its
stack through IRoomEngine.setRoomObjectVariableFx. The renderer draws it as AIR does, a sprite in the avatar / pet /
furni visualization (Octane-Renderer/packages/room/src/object/visualization/variablefx/, 150 ms fade in, 350 ms
out, the habbicon as the stack's layer-100 entry); each value is painted by the renderer's port of AIR's painters
(VariableFxVisualizer / VariableFxRendererRegistry, renderers/*, AIR's variablefx_* PNGs in
Octane-Renderer/packages/assets/src/assets/variablefx/images, loaded as local://variablefx), wrapped per value by
src/api/wired/WiredVariableFxVisualizer.ts. Colour / width ids are AIR's (WIRED_FX_COLOR, WIRED_FX_WIDTH,
per-style options in wiredVariableFxAppearance). src/css/room/WiredVariableFx.css only holds the editor rule. Only custom:<id>
variable tokens are offered for overrides/audience, since that is what the server resolves.
- Wired setup quick menu (
src/components/wired/views/WiredBaseView.tsx, the ≡ button): copy / paste /
paste-into / clear picks / reset / save-without-closing, backed by useWired (clipboardEntry,
copyWiredToClipboard, pasteWiredFromClipboard, resetWiredToDefault, clearWiredPicks,
saveWiredAndKeepOpen) and the pure helpers in src/api/wired/WiredClipboard.ts. Paste and reset work by
handing the views a cloned definition object (prototype kept, data fields replaced), so every view re-reads
it through its [trigger] effect; the clipboard is one entry per holder+code for the session.
- AIR text (pixel-exact labels):
src/common/native-text/ holds the port of AIR's glyph rasteriser (NativeText).
NativeLabel draws a text in the font its computed CSS asks for (resolveCssNativeFont: every Ubuntu face becomes AIR's
Ubuntu, Volter stays Volter, unsupported faces / letter spacing stay browser text), keeping the CSS line height and wrap width;
nativeTextChildren turns text-only children into one label and is wired into Text, BaseButton (so Button too), window
titles (OctaneCardHeaderView) and tabs (OctaneCardTabsItemView). CssNativeText is the fixed-style variant used by the
catalogue. Config ui.native.text.enabled (default true) turns it off; jsdom never draws it (tests see plain text, enable it with
forceNativeTextEnabled). NativeTextTree wraps a view's returned JSX and labels every text-only element in it (inputs, selects,
code, running text with inline markup and component-owned text excluded); the login views, navigator, inventory,
every other view under src/components use it, except the room chat bubbles and the windows that already draw NativeText (GroupNativeLayout, mod-tools native
layouts, the catalogue's CssNativeText). The SKIP list keeps form fields, code and SVG text out; text beside elements without text of their own (a checkbox, radio,
icon) gets labels too. Select / ListSelect draw the value, options (keyed by highlighted / selected state) and group labels
natively; the select sizer measures in the regular Ubuntu cut (.octane-select-sizer.is-native). Chat bubbles use ChatNativeContent
(room/widgets/chat): the name and message laid out together by layoutNativeRuns (one AIR TextField, wrapped at the bubble's
max-width), styles read from the bubble CSS; markup (links, mentions, colour tags), emoji, name prefixes/icons and translations
stay browser text. NativeTextRasterCache reuses finished rasters (16 MB LRU). Rasters (Ubuntu and Volter) are drawn at the device pixel ratio itself (getNativeTextRasterScale; on a fractional ratio such as Windows 125 % at the nearest whole font size, halves down: Volter 9 px as 11 px) and shown pixelated, so they are never resampled; only the letter-spaced canvas path keeps the next whole scale. A one-line label in a text-overflow: ellipsis box that its drawn raster overflows is shortened with "..." (fitNativeText); boxes sized to their text are left alone. Never wrap a .map callback's keyed element (the key
must stay on the returned element): the outer view's tree already covers it.
- Renderer-SDK mock for Vitest:
src/octane-renderer.mock.ts
(aliased over @octane/renderer via vitest.config.mts).
Hosts the explicit OctaneLogger mock, the mockEventDispatcher /
clearMockEventDispatcher helpers used by hook tests, the
RoomSessionDoorbellEvent stub, and a long list of placeholder
classes/enums kept around just so the src/api/* barrel cascade
imports without throwing. Grow this file when a new test needs a
symbol; prefer real deterministic stubs over vi.fn().
Furni names (furnidata-driven)
Furni name/description are furnidata-driven (FurnitureData by classname) — the
client does NOT get furni display names from the server. The 3 furni surfaces
refresh live on the window event octane-localization-updated: catalog
(useCatalog.ts), inventory (useInventoryFurni.ts), infostand
(useAvatarInfoWidget.ts). The renderer's FurnitureDataReload packet (header
10047) dispatches that event on server-pushed furnidata changes — no client code
needed.
Claude Code — project memory for Octane
This file is read automatically by Claude Code at session start. It captures the conventions and current state of this branch so a new session can hit the ground running.
TL;DR
This client carries a long-running React 19.2 modernization: React 19 idioms + supporting infrastructure (TanStack Query, Zustand, Vitest, React Compiler, error boundaries), god-hook splits, and logic-bug audits.
Working base is now
main(trackingduckietm/Octane). The earlierfeat/react19-modernizationlong-running branch was superseded — feature work now ships as small focused PRs againstduckietm:Dev, staged through Dev then merged to main. (feat/react19-modernizationstill exists on the fork as backup; do not force-push it.)Navigator modernization landed (merged to main 2026-05-28, PRs #168/#169/#170): the 492-line
useNavigatorgod-hook was split intouseNavigatorStore+useNavigatorData/useNavigatorUiState/useNavigatorSearchfilters (wired-tools layout), door lifecycle extracted tosrc/hooks/rooms/widgets/useDoorState.ts, 9 UI flags moved to a ZustandnavigatorUiStore, search migrated to a query hook, and 5 sub-views wrapped inWidgetErrorBoundary. Caveat: duckietm patcheduseNavigatorSearchpost-merge (05d71dd1) — see theuseOctaneQueryfragility note below.When syncing upstream, expect conflicts in
App.tsx/bootstrap.ts/LoginView.tsxon React 19 imports — always keep the modernized version.Local-dev game assets are served by a small Vite plugin (
sirvmiddleware mounted on/nitro-assetsand/swf, reading fromE:\Users\simol\Desktop\DEV\Nitro-Files) — NOT by symlinking insidepublic/. The symlink path triggers chokidar on ~177k files and the dev server hangs for minutes on Windows. Seevite.config.mjsand the.gitignorenote.Detailed status, decisions, and next steps live in
docs/ARCHITECTURE.md— read that before starting anything non-trivial.Commands
yarn startyarn buildyarn preview(defaults to http://localhost:4173)yarn lint(Biome)yarn lint:hooksyarn typecheckyarn testyarn test:watchSetup walkthrough
Clone the renderer SDK as a sibling of this repo.
vite.config.mjsresolves the@octane/*aliases against../octane-renderer(preferred) or../renderer(legacy). If neither exists, the dev server and build now fail fast with a message pointing here.pixi.jsresolves to the renderer'snode_modules, sovite.config.mjsalso checks that the installed copy matches the version the renderer'spackage.jsonpins and fails fast telling you to rerunyarn installthere. Re-run it whenever the renderer bumps Pixi.Install client deps.
Materialize the runtime configuration.
public/configuration/ships.examplefiles. Copy the ones you need without the.examplesuffix and point them at your game server (websocket URL, asset base URL, UI texts, etc.). The dev server doesn't fail if these are missing but the client renders a blank/error screen at runtime.Run.
yarn start(Vite, HMR, includes the renderer source).yarn build && yarn preview.The renderer SDK (
@octane/renderer) is consumed via a filesystem link to a sibling working tree —../octane-renderer(preferred) or../renderer(legacy). Without it,yarn typecheckreports TS2307 across the codebase — that's expected on a sandbox without the renderer, not a regression.Runtime verification
This client can't be driven headless — the room renders via PixiJS/WebGL, which hangs in a headless browser, and login needs an SSO session. To verify a change at runtime, use the Claude-in-Chrome extension against the user's real, logged-in
localhost:5173session: it shares the browser's SSO cookie (auto-login) and uses the real GPU. Toolbar buttons are canvas (not in the accessibility tree) — locate them by screenshot/zoom, then click by coordinate. Thepreview_*(headless) tools can't bind 5173 here and won't get past login/WebGL, so they're not usable for in-game verification.Stack snapshot
react-dom19.2.5,@types/react19.2.x.typescript) for the tools that need its JavaScript API (@typescript-eslint/parser, peer<6.1), and TS 7 (thetypescript7alias oftypescript@7, run asnode node_modules/typescript7/bin/tsc) for thetypecheckscript.@base-ui/react, imported per subpath). Radix UI is no longer a dependency. Use thesrc/common/wrappers instead of native controls or hand-rolled ARIA widgets:Checkbox,Radio(a controlled one-itemRadioGroup, drop-in for<input type="radio" checked onChange>),Select(drop-in for<select>+<option>, change event keepstarget.value),NumberInput(drop-in fortype="number", event keepstarget.value/target.valueAsNumber, role spinbutton, hover/focus up-down overlay),Slider,FloatingPopover,TabBar,InlineDialog(dialog mounted inline;modal="trap-focus"for the catalog confirms),CollapsibleSection,Tooltip,ToggleButton(everyaria-pressedtwo-state button, incl.InfiniteGrid.Item),ListSelect(Base UI Select with the caller's own trigger/classes, for custom drop menus),SuggestInput(text input + Autocomplete list, replaces<datalist>),Sliderwithorientation="vertical".Hint(incommon/Tooltip.tsx) gives an icon-only control a tooltip without a wrapper element: use it instead of atitleattribute and keep anaria-label. Adiv role="button"is never right: useBaseButton/ToggleButton(unless the region contains its own button). Radios that share anamekeep native group behaviour (arrow keys, one tab stop).<button>isBaseButton(Base UI Button, still a native<button>; it setstype="button"unless atypeis passed, so form submit buttons need an explicittype="submit"). Deliberately native: text<input>and<textarea>(Base UIInputis a plain input; there is no textarea primitive), and the draggable-window dialogs (UserSettingsView,LayoutMiniCameraView: Dialog.Popup needs a Dialog.Portal, which mounts one render late, butDraggableWindowlooks up its handle and measures its size once at mount, and the camera preview effect needs its canvas at mount). Skin them through theoctane-*classes (.octane-checkbox[data-checked],.octane-radio,.octane-select-trigger,.octane-number-input): CSS written forinput[type=...]/selectmust be mirrored onto those classes. Base UI roots render as<i>so a window's genericspan/inputrules do not hit them. In tests, readaria-checked/aria-disabled(not.checked/.disabled), pick options withsrc/test-utils/selectOption.ts, and usegetByRole('checkbox'|'radio', { name })rather thangetByLabelTextfor wrapped boxes (the label also matches the hidden input).@vitejs/plugin-react6 +babel-plugin-react-compiler1.0.@typescript-eslint/parser8 +eslint-plugin-react-hooks@7only for the Rules of Hooks gate (yarn lint:hooks).@tanstack/react-query+ devtools).@testing-library/react+@testing-library/jest-dom.react-error-boundary6.Layout convention (DO NOT CHANGE)
Established by the team and recorded in
docs/ARCHITECTURE.mdproposal #3 (rejected thesrc/features/alternative). Stay on this layout — every PR that violates it will need to be reworked.When splitting a god-hook the convention is 3 files, all flat in the hooks barrel directory:
use<Feature>State.ts— state + event subscriptions + derived valuesuse<Feature>Actions.ts— pure imperative actions (no state writes)use<Feature>Widget.ts— deprecated wrapper that composes the two and preserves the old return shape so existing consumers don't breakSee
useDoorbellState/useDoorbellActions/useDoorbellWidgetas the canonical pattern.Patterns to use
useSessionSnapshots(renderer snapshot pattern, React-side — OPT-IN)For state that lives on a renderer Manager and is invalidated through
OctaneEventType.*_UPDATED, the filesrc/hooks/session/useSessionSnapshots.tsexposes eight consumer hooks backed byuseSyncExternalStore:Each hook has defensive
typeof method === 'function'guards against a stale renderer bundle and degrades to a frozen default snapshot if the renderer doesn't expose the matching getter (kept module-level so React's bailout still works on the degraded path).Adoption status: three pilot consumers shipped (commit
d28819d, 2026-05-19).useSessionInforeads userFigure / respectsLeft / respectsPetLeft fromuseUserDataSnapshot;useChatWidget.ownUserIdreads from the snapshot directly;AvatarInfoWidgetAvatarViewflips its Ignore/Unignore menu viauseIsUserIgnored.The legacy
use-betweendispatcher proxy could not executeuseSyncExternalStorehooks and caused the original snapshot crash. The project now usesuseSharedHook, whose source runs in a normal React host and publishes its snapshot through a Zustand vanilla store. Shared sources must be registered at module scope so the root registry can mount them before their first consumer renders.Regression guard:
src/hooks/session/useSessionSnapshots.test.tsxandsrc/state/useSharedHook.test.tsxcover snapshot and shared-source lifecycle behavior. A CI gate (yarn lint:hooks→react-hooks/rules-of-hooks: error) blocks any future commit that reintroduces hook-order issues.useOctaneEventState/useMessageEventStateFor "derived state from a single event" replace the two-step
useState + useOctaneEvent(e => setState(...))with a single call:The selector is held in a
useLayoutEffect-refreshed ref so the listener stays registered across renders. Both hooks are exported fromsrc/hooks/events.useOctaneQueryFor composer/parser request-response pairs:
Already wired up;
QueryClientProvideris mounted insrc/index.tsx.Companion
useOctaneEventInvalidator(eventType, queryKey, accept?)— import fromsrc/api/octane-query. Subscribes to the renderer event and invalidates the query slot on every push, so server-driven refresh paths work the same as the initial request/response (e.g. ClubGiftInfoEvent firing again after the user claims a gift).⚠️ Fragility — do NOT use
useOctaneQueryfor primary visible data. The one-shot listener insideawaitOctaneResponse(register listener → await one matching response → remove itself) is fragile against renderer-bundle quirks: for some parsers the event fires but the listener never matches, so the promise never resolves andquery.datastaysundefinedforever — the UI shows the server's response arriving in logs but renders blank. This bit ModTools Room/CFH chatlog (reverted touseMessageEvent + useEffect) and then Navigator search (P2 shipped withuseOctaneQuery, duckietm reverted it in05d71dd1to the god-hook pattern). Rule: reserveuseOctaneQueryfor config / secondary fetches where a brief blank is tolerable. For anything that is the primary visible content of a panel, useuseMessageEvent + useState/useEffect— that's what the rest of the codebase does and it's robust.Singleton-filter split for Zustand-backed shared hooks
When a hook backs many consumers but most only need either state OR actions (not both), split it without breaking the shared-singleton guarantee:
useSharedHookensures all three entry points read the same Zustand store, while the registered source owns listeners and effects once.Zustand stores
For cross-feature UI state (avoid module-level
let):Components subscribe to slices, not the whole store:
Adoptions:
src/components/navigator/views/navigatorRoomCreatorStore.ts(create-room lockout) andsrc/components/wired-tools/wiredCreatorToolsUiStore.ts(UI-only flags for the WiredCreatorTools panel — tab nav, modal/popover open, monitor + variable-manage filters).WidgetErrorBoundaryWrap any in-room widget tree so a crash degrades gracefully (logs to OctaneLogger, falls back to
null). Already applied atRoomWidgetsViewas an umbrella; per-widget wrapping is a follow-up.Form Actions
Login / Register / Forgot in
src/components/login/LoginView.tsxuseuseActionState+useFormStatus. The legacy non-Action versions insrc/components/login/components/{Register,Forgot}Dialog.tsxandshared.tshave been removed (dead code).Configuration pre-init in bootstrap
src/bootstrap.tscallsawait GetConfiguration().init()before importing./index. Otherwise the first paint dumps a flood of "Missing configuration key" warnings while components synchronously readasset.url,login.endpoint, … against an empty store beforeprepare()'s deferred init lands.Asset serving in dev
Game assets (
bundled/,c_images/,gamedata/,swf/...) are NOT copied or symlinked underpublic/. They're served by a custom Vite plugin (octaneAssetsServerinvite.config.mjs) that mountssirvon/nitro-assetsand/swf, reading fromE:\Users\simol\Desktop\DEV\Nitro-Files\. sirv is a connect-style middleware that bypasses chokidar entirely, so the ~177k asset files never enter the watch graph. The plugin also wires the same handler intoconfigurePreviewServersoyarn previewkeeps working.What's wired up and what isn't
useSessionSnapshots)useSessionInfo(userFigure / userRespectRemaining / petRespectRemaining viauseUserDataSnapshot),useChatWidget.ownUserId(viauseUserDataSnapshot),AvatarInfoWidgetAvatarViewIgnore/Unignore (viauseIsUserIgnored),ModToolsViewselected-user presence dot (viauseRoomUserListSnapshot— green when still in the active room, gray when they've left). The 8 hooks (userData / activeRoomSession / ignoredUsers / groupBadges / soundVolumes / roomUserList / isUserIgnored / groupBadge) keep their typeof-guard defensive fallbacks for stale-renderer paths.AvatarInfoWidgetAvatarViewGive/Remove Rights — localcontrollerLevelinitialized fromavatarInfo.targetRoomControllerLevel, kept reactive viauseMessageEvent<FlatControllerAddedEvent>/FlatControllerRemovedEventfiltered byparser.data.userId === avatarInfo.webID, plus optimistic bump on click so the moderate submenu flips immediately. Same shape asuseIsUserIgnoredbut the source is the renderer event bus, not a snapshot getter — use this when adding a manager-side snapshot for the same data isn't justified.useOctaneEventState+ companions (Reducer, ExternalSnapshot)OfferView,useAvatarInfoWidget(figure/badges/group reducer),useInventoryFurni(pure reducers + fragments useRef)useOctaneQuery+useOctaneEventInvalidatorCatalogLayoutRoomAdsView,ModToolsChatlogView,CfhChatlogView,useGiftConfiguration,useUserGroups,useClubOffers(windowId),useSellablePetPalette(breed),useMarketplaceConfiguration,useClubGifts(with invalidator)NavigatorRoomCreatorView(useRoomCreatorStore),WiredCreatorToolsView(useWiredCreatorToolsUiStore— every panel-lifecycle-relevant flag, snapshot, selection, highlight, inline editor, picker chain hoisted; what's left in the component asuseStateis genuinely transient: keepSelected, globalClock, roomEnteredAt, selectedMonitorErrorType, selectedMonitorLogDetails)doorbell,poll,furni-chooser,user-chooser,friend-request,chat-inputwired-tools,translation,notification,friends,catalog(three-way:useCatalogData/useCatalogUiState/useCatalogActions— all 48 consumers migrated, deprecateduseCatalogshim removed)useNavigatorgod-hook split into a Zustand-backed shareduseNavigatorStore+ flat filtersuseNavigatorData/useNavigatorUiState/useNavigatorSearch; door bell/password lifecycle extracted tosrc/hooks/rooms/widgets/useDoorState.ts(dual-subscribesGetGuestRoomResultEvent+GenericErrorEventalongside the nav store, each filtering by branch/errorCode); 9 UI flags +currentTabCode/currentFilterin ZustandnavigatorUiStore(src/hooks/navigator/navigatorUiStore.ts); all 5 Navigator sub-views wrapped inWidgetErrorBoundary; old shim deleted.useNavigatorSearchwas reverted by duckietm (05d71dd1) fromuseOctaneQuerytouseMessageEvent + useEffect— see the useOctaneQuery fragility note. Specs/plans underdocs/superpowers/.WidgetErrorBoundaryRoomWidgetsViewumbrella + per-widget wrap on all 13 room widgets and all 20 furniture widgets (so a crash in one widget no longer takes down its siblings)getPetPackageNameError) + 2 Zustand store suites (navigatorRoomCreatorStore,wiredCreatorToolsUiStorewith 45 cases including the picker-chain hoists) + 2 component-/hook-level pilots (WidgetErrorBoundary, useDoorbellState) on top of the renderer-SDK mock atsrc/octane-renderer.mock.ts, 34 cases on the catalog pure helpers, 4 contract cases on the catalog filters. Tests are co-located undersrc/, alongside their subject.origin/Devabsorbed (merge779a98c)b2318b9(2026-05-18): JSON5, user-settings reset password/email/username, wear-badge popup fix, login screen fix, About, offer-selection refactoruseChatWidget/useAvatarInfoWidget(data/actions)useAvatarInfoWidgetgot a typed__octaneAvatarClickControlaccessor + module-scope DEBOUNCE const in 2026-05-18 (commit05ff7df).useChatWidget.ownUserIdreactive migration re-applied 2026-05-19 ind28819dviauseUserDataSnapshot.usePetPackageWidget/useWordQuizWidget/useChatCommandSelector(data/actions)getPetPackageNameErrorpure helper + 4 tests; useWordQuizWidget → fixed stale-closure bug insetUserAnswersupdater +useReffor the timeout handle; useChatCommandSelector → module-levelletcache replaced with a Zustand store.d28819d), pattern documented above. Next candidates: any code reading fromGetSessionDataManager().userId / userName / clubLevel / securityLevel,GetRoomSessionManager().getActiveSession(), orGetSoundManager().<volume>synchronously — those don't re-render today when the value changes. CI gateyarn lint:hooksandsrc/hooks/session/useSessionSnapshots.test.tsxguard hook correctness.src/octane-renderer.mock.ts) and 3+ hook/component pilots pass. Good follow-up targets:LoginViewForm Actions happy/error paths,OfferViewwithuseOctaneQuery. (Acceptable only as a side-effect of a real change — coverage growth on its own is deprioritized per session feedback.)Known open logic bugs
None on this branch. The two previously-open races are closed:
MainViewCREATED/ENDED race → fixed in9d10e52via a session-aware reducer pattern.LayoutFurniImageView/LayoutAvatarImageViewasync fetch race → fixed in97c9717viarequestIdRefguard on the async callback.See
docs/ARCHITECTURE.md"Recently fixed" for fix shapes.House rules
src/components/...is wrong and a recurring temptation.yarn testmust stay green.yarn typecheck+yarn test --runmust both pass.FC<{}>,IMessageEvent | undefinedredundant union in the local sandbox where the renderer SDK isn't installed) are out of scope here.Where everything lives
docs/ARCHITECTURE.mdvitest.config.mts(separate fromvite.config.mjs)src/test-setup.tssrc/next to the subject (src/<path>/Foo.ts↔src/<path>/Foo.test.ts). No separatetests/tree.src/api/octane-query/createOctaneQuery.tssrc/state/createOctaneStore.tssrc/common/error-boundary/WidgetErrorBoundary.tsxuseOctaneEvent,useMessageEvent,useOctaneEventState,useMessageEventState):src/hooks/events/src/components/wired-tools/src/components/user-settings/UserAccountSettingsView.tsxsrc/api/auth/accessToken.ts(persistAccessTokenFromPayload)octaneAssetsServer()invite.config.mjssrc/bootstrap.ts(await GetConfiguration().init()beforeimport('./index'))src/hooks/catalog/useCatalog.helpers.ts(buildCatalogNodeTree,findNodeById/findNodeByName,getNodesByOfferIdFromMap,getOfferProductKeys,normalizeCatalogType,resolveBuilderFurniPlaceableStatus)useCatalogData/useCatalogUiState/useCatalogActionsinsrc/hooks/catalog/useCatalog.ts(all 48 consumers migrated; deprecateduseCatalogshim removed)src/hooks/navigator/—useNavigatorStore.ts(internal closure),useNavigatorData.ts/useNavigatorUiState.ts/useNavigatorSearch.ts(filters),navigatorUiStore.ts(Zustand UI flags +setTab/setFilter). Door lifecycle:src/hooks/rooms/widgets/useDoorState.ts. Specs/plans:docs/superpowers/specs/2026-05-2*-navigator-*.mdsrc/api/avatar/avatarThumbnailUrls.ts(blob/object-URL cache, canvas shaping) behindAvatarEditorThumbnailsHelper. Part thumbnails go render target -> canvas -> one PNG encode; they are never turned into adata:URL in between. The LRU budgets real image bytes (48 MB, was 200 MB of base64 characters) and revokes evicted URLs after a grace period, so a render already in flight keeps its image. A browser withouttoBlob/createObjectURLfalls back to a data URL.FloorplanPreviewSVG; the 2D/3D switch in its head mounts the WebGL view on demand and remembers the choice in localStorage (octane.floorplan.preview3d), so legacy devices never pay for a second GL context unless asked.src/components/floorplan-editor/views/Floorplan3DView.tsx(React panel + orbit controls) oversrc/components/floorplan-editor/scene3d/(FloorplanScene.tsinstanced heightmap mesh + GLSL,heightmap.tstile columns and camera,mat4.ts). PixiJS isawait imported so the editor bundle and jsdom never load it unless the panel is open; Vitest aliasespixi.jstosrc/pixi.mock.ts..octane-floorplan-windowstyles insrc/css/floorplan-editor/FloorplanEditorView.css(imported fromsrc/index.tsx); labels fall back to English throughsrc/components/floorplan-editor/state/localize.tswhen the external texts lack a key.src/components/wired-tools/WiredRoomLogsView.tsx(room log,WiredRoomLogsPageComposer/WiredLogPageEvent) andWiredVariableOwnersView.tsx(holders of a variable,WiredVariableHoldersPageComposer/WiredVariableHoldersPageEvent), both onWiredPagedTable.tsx+WiredPaging.helpers.ts(pure paging and rate limiter) andsrc/hooks/wired-tools/useWiredPageRequests.ts. The "Manage" button of the Variables tab opens the owners window; its "Manage" links feed the existing holder detail panel inWiredCreatorToolsView.tsx.src/components/wired-tools/WiredToolsSettingsTabView.tsx): permissions and timezone go through the officialWiredMenuPermissionsSaveComposer, reload/rollback throughWiredRoomStateActionComposer(store actionssaveRoomTimezone,reloadRoomWired,rollbackRoomWired). Client config:wired.timezones(comma list; empty = browser list),wired.selfdonation.enabledshows the sandbox donation button that opensWiredSelfDonationView.tsx(SelfDonationMessageComposer; the server gates onhotel.selfdonation.enabled+acc_debug).wf_xtra_varfx_*, withwf_xtra_var_fx_*as aliases): one editorsrc/components/wired/views/extras/WiredExtraVariableFxView.tsx(propcategory) on the pure tables and param/token codecs insrc/api/wired/WiredVariableFx.ts, which mirror the server'sWiredVariableFxStyles/WiredExtraVariableFx. The four server packets (WiredVariableFxConfigs/ConfigsRemoved/Status/StatusRemoved) feedsrc/hooks/wired/wiredVariableFxStore.tsthroughuseWiredVariableFxEvents;WiredVariableFxView(src/components/room/widgets/wired-fx/, mounted inRoomView, renders nothing) drivesuseWiredVariableFxRoomDrawing/wiredVariableFxRoomDrawing.ts, which decide visibility and hand each holder its stack throughIRoomEngine.setRoomObjectVariableFx. The renderer draws it as AIR does, a sprite in the avatar / pet / furni visualization (Octane-Renderer/packages/room/src/object/visualization/variablefx/, 150 ms fade in, 350 ms out, the habbicon as the stack's layer-100 entry); each value is painted by the renderer's port of AIR's painters (VariableFxVisualizer/VariableFxRendererRegistry,renderers/*, AIR'svariablefx_*PNGs inOctane-Renderer/packages/assets/src/assets/variablefx/images, loaded aslocal://variablefx), wrapped per value bysrc/api/wired/WiredVariableFxVisualizer.ts. Colour / width ids are AIR's (WIRED_FX_COLOR,WIRED_FX_WIDTH, per-style options inwiredVariableFxAppearance).src/css/room/WiredVariableFx.cssonly holds the editor rule. Onlycustom:<id>variable tokens are offered for overrides/audience, since that is what the server resolves.src/components/wired/views/WiredBaseView.tsx, the ≡ button): copy / paste / paste-into / clear picks / reset / save-without-closing, backed byuseWired(clipboardEntry,copyWiredToClipboard,pasteWiredFromClipboard,resetWiredToDefault,clearWiredPicks,saveWiredAndKeepOpen) and the pure helpers insrc/api/wired/WiredClipboard.ts. Paste and reset work by handing the views a cloned definition object (prototype kept, data fields replaced), so every view re-reads it through its[trigger]effect; the clipboard is one entry per holder+code for the session.src/common/native-text/holds the port of AIR's glyph rasteriser (NativeText).NativeLabeldraws a text in the font its computed CSS asks for (resolveCssNativeFont: every Ubuntu face becomes AIR's Ubuntu, Volter stays Volter, unsupported faces / letter spacing stay browser text), keeping the CSS line height and wrap width;nativeTextChildrenturns text-only children into one label and is wired intoText,BaseButton(soButtontoo), window titles (OctaneCardHeaderView) and tabs (OctaneCardTabsItemView).CssNativeTextis the fixed-style variant used by the catalogue. Configui.native.text.enabled(default true) turns it off; jsdom never draws it (tests see plain text, enable it withforceNativeTextEnabled).NativeTextTreewraps a view's returned JSX and labels every text-only element in it (inputs, selects, code, running text with inline markup and component-owned text excluded); the login views, navigator, inventory, every other view undersrc/componentsuse it, except the room chat bubbles and the windows that already draw NativeText (GroupNativeLayout, mod-tools native layouts, the catalogue's CssNativeText). The SKIP list keeps form fields, code and SVG text out; text beside elements without text of their own (a checkbox, radio, icon) gets labels too.Select/ListSelectdraw the value, options (keyed by highlighted / selected state) and group labels natively; the select sizer measures in the regular Ubuntu cut (.octane-select-sizer.is-native). Chat bubbles useChatNativeContent(room/widgets/chat): the name and message laid out together bylayoutNativeRuns(one AIR TextField, wrapped at the bubble's max-width), styles read from the bubble CSS; markup (links, mentions, colour tags), emoji, name prefixes/icons and translations stay browser text.NativeTextRasterCachereuses finished rasters (16 MB LRU). Rasters (Ubuntu and Volter) are drawn at the device pixel ratio itself (getNativeTextRasterScale; on a fractional ratio such as Windows 125 % at the nearest whole font size, halves down: Volter 9 px as 11 px) and shownpixelated, so they are never resampled; only the letter-spaced canvas path keeps the next whole scale. A one-line label in atext-overflow: ellipsisbox that its drawn raster overflows is shortened with "..." (fitNativeText); boxes sized to their text are left alone. Never wrap a.mapcallback's keyed element (the key must stay on the returned element): the outer view's tree already covers it.src/octane-renderer.mock.ts(aliased over@octane/rendererviavitest.config.mts). Hosts the explicitOctaneLoggermock, themockEventDispatcher/clearMockEventDispatcherhelpers used by hook tests, theRoomSessionDoorbellEventstub, and a long list of placeholder classes/enums kept around just so thesrc/api/*barrel cascade imports without throwing. Grow this file when a new test needs a symbol; prefer real deterministic stubs overvi.fn().Furni names (furnidata-driven)
Furni name/description are furnidata-driven (
FurnitureDataby classname) — the client does NOT get furni display names from the server. The 3 furni surfaces refresh live on the window eventoctane-localization-updated: catalog (useCatalog.ts), inventory (useInventoryFurni.ts), infostand (useAvatarInfoWidget.ts). The renderer'sFurnitureDataReloadpacket (header 10047) dispatches that event on server-pushed furnidata changes — no client code needed.