Commit Graph

474 Commits

Author SHA1 Message Date
simoleo89 c11a6c4699 feat(hooks): generalise security-level family + audit catch + reactivity test
Build on the useIsModerator landing (532cb28c) along three axes:

1. Family. Extract `useHasSecurityLevel(min)` as the primitive,
   backed by a fresh `useUserSecurityLevel()` raw-level reader. The
   six SecurityLevel constants (1..9) deserve named wrappers so the
   "show this only to X-and-up" pattern doesn't get re-derived ad-hoc
   each time: shipped `useIsModerator` / `useIsPlayerSupport` /
   `useIsCommunity` / `useIsAdmin` as one-line shims. Also added
   `useIsAmbassador()` as a sibling — not derived from security level,
   reads the boolean field on the snapshot directly.

2. Audit. The 532cb28c migration covered 6 React-render reads but
   missed 5 more discovered by a follow-up grep:
   - FurniEditorView (top-level `const isMod`)
   - InfoStandWidgetFurniView (inline JSX, mod-only build-tools button)
   - NavigatorRoomInfoView (3 reads in hasPermission(): isModerator
     and securityLevel >= COMMUNITY for the staff-pick gate. The
     userId read stays imperative — userId doesn't flip at runtime in
     practice, no reactivity gain.)
   - AvatarInfoWidgetPetView (inside useMemo with [roomSession] deps;
     migrated and isModerator added to the deps so a runtime
     promote/demote re-derives canPickUp without remount)
   - FurnitureMannequinView (inside useEffect; same treatment — added
     isModerator to the deps so the mode re-resolves on flip)

   The remaining ~17 reads (CanManipulateFurniture,
   AvatarInfoUtilities.populate*, useChatInputActions,
   useFurnitureDimmerWidget / useFurniturePlaylistEditorWidget /
   useFurnitureStickieWidget canModify checks, useCatalog admin
   filter, useNavigator door-mode guard) are click-time / event-time
   imperative — they read at the moment a user action fires, so a
   reactive value would be cached at hook execution and stale by the
   time the action runs. Leaving them on the synchronous manager read
   is correct.

3. Test. Added four cases pinning the contract:
   - useUserSecurityLevel returns the raw level.
   - useHasSecurityLevel does `>=` against the threshold.
   - Named wrappers map to the right constants (MODERATOR=5,
     COMMUNITY=7, ADMINISTRATOR=8).
   - **Reactive flip** — mutate the snapshot, dispatch the
     SESSION_DATA_UPDATED event on the mock dispatcher, assert the
     hook re-derives. Locks in the whole point of the snapshot
     pattern (a static read would pass cases 1-3 but fail case 4).

Mock changes:
- Added SecurityLevel class (mirrors the renderer enum 0..9) so the
  family wrappers resolve to actual numbers in jsdom — without it
  `useIsModerator()` would call `useHasSecurityLevel(undefined)` and
  the test would silently pass false-positives.

Verification: yarn typecheck clean, yarn lint:hooks clean, yarn test
213/213 (209 baseline + 4 new family/reactivity cases).
2026-05-19 18:18:20 +02:00
simoleo89 532cb28ca7 feat(hooks): useIsModerator() + migrate 6 component reads
Adds a reactive `useIsModerator()` derived from
`useUserDataSnapshot().securityLevel >= SecurityLevel.MODERATOR`
(mirrors the renderer-side getter at SessionDataManager.ts:684), and
migrates the six React component-body reads of
`GetSessionDataManager().isModerator`:

- ToolbarView (mod-only chat-input button)
- CatalogClassicView, CatalogModernView (admin toggles in catalog
  header)
- ChooserWidgetView (room-object id column visibility)
- YouTubePlayerView (room-control affordance — hook moved above the
  `if (!isOpen) return null` early return so the hook order stays
  stable when the player opens/closes)
- CalendarView (mod-only "open all" affordance)

UX impact: any future promote/demote that flips
SESSION_DATA_UPDATED now re-renders the mod-only UI live, instead of
requiring an F5. Imperative call sites
(AvatarInfoUtilities.populate*, CanManipulateFurniture,
RoomChatHandler) still read the manager directly — they run at click
time, not in a React render, so reactivity has no upside there.

Five of the six call sites are top-level component-body reads (no
early-return interaction). YouTubePlayerView has an
`if (!isOpen) return null` below the hook list, so the hook had to
move ABOVE it; same shape as the recent CatalogPurchaseWidgetView and
CatalogItemGridWidgetView fixes.

Verification: yarn typecheck clean, yarn lint:hooks clean, yarn test
209/209.
2026-05-19 18:07:17 +02:00
simoleo89 a029ee63cb fix(catalog,ci): catch hook-order violations + add CI gate
Two follow-ups to the CatalogPurchaseWidgetView fix (6bf3366):

1. CatalogItemGridWidgetView had the same shape — four useCallback
   declarations (handleDragStart / handleDragOver / handleDrop /
   handleDragEnd) sat below an `if(!currentPage) return null` early
   return. When currentPage flipped from null to a real page the hook
   count jumped by 4 and React would have thrown "Rendered more hooks
   than during the previous render" the moment any consumer rendered
   the grid in admin mode. Moved the four useCallback declarations
   above the early-return; their bodies are safe pre-load (only
   currentPage?.offers is accessed inside handleDrop, optional-chained
   already).

2. CI gate — the existing GitHub Actions workflow runs `yarn
   typecheck` and `yarn test`, but NOT `yarn eslint`. That's why this
   pattern slipped through twice in a row: ESLint flags it locally
   but no PR check enforces it. Full `yarn eslint` emits ~900
   pre-existing baseline errors (brace-style, indentation,
   recommended TS rules — out of scope for this branch), so a blanket
   step would always fail. Instead added a focused
   `eslint.hooks.config.mjs` + `yarn lint:hooks` script that runs
   ESLint with ONLY `react-hooks/rules-of-hooks: error`. Wired into
   ci.yml between `typecheck` and `test`. The local repo now has
   zero violations of the rule.

3. useSessionSnapshots.test.tsx — added eslint-disable-next-line
   comments on the three lines that intentionally violate the rule
   (they're the assertions that the broken pattern crashes). Without
   the comments the new CI gate would fail on the regression-guard
   suite.

Verification: yarn lint:hooks green, yarn typecheck clean, yarn test
209/209.
2026-05-19 17:57:28 +02:00
simoleo89 6bf3366af7 fix(catalog): stabilise hook order in CatalogPurchaseWidgetView
React reported "Rendered more hooks than during the previous render"
when CatalogPurchaseWidgetView transitioned from currentOffer=null to
a real offer: hook count jumped from 22 to 23 because the
useMemo/useEffect block for the builders-club placement state sat
*below* the `if(!currentOffer) return null` early-return on line 140.
On the first render it never ran; on the next render (offer loaded)
it did, and React's hook-call tracker flagged the divergence and
unmounted the component via the error boundary.

Fix: move the three builders-club hooks (useMemo builderPlaceableStatus,
useMemo buildersClubPlaceOneButtonStyle, useEffect interval) above the
early return. They already short-circuit cleanly when
isBuildersClubPlaceable is false — added a defensive `!currentOffer`
guard on the first useMemo and an explicit `!!currentOffer` clause on
the derived isBuildersClubPlaceable so the .product access stays safe
when offer is null. Behavior unchanged for the loaded-offer path; the
early-render path now runs the hooks but their bodies no-op.

Verification: yarn typecheck clean, yarn test 209/209.
2026-05-19 17:43:20 +02:00
simoleo89 d28819db89 fix(snapshots): re-apply the 3 snapshot-consumer migrations with the use-between/useSyncExternalStore incompatibility resolved
Root cause of last session's "(intermediate value)() is undefined" at
ToolbarView.tsx:46:

  use-between 1.x ships its own React-dispatcher proxy (ownDispatcher
  in node_modules/use-between/release/index.esm.js:54-169) that
  re-implements only useState, useReducer, useEffect, useLayoutEffect,
  useCallback, useMemo, useRef and useImperativeHandle. It does NOT
  implement useSyncExternalStore. When the inner state function of
  useBetween(stateFn) calls useSyncExternalStore (directly or via
  useExternalSnapshot / useUserDataSnapshot), React resolves the
  dispatcher to use-between's proxy, finds .useSyncExternalStore
  missing, and calls undefined() — that's the exact production crash
  in Firefox. Chrome reports the same as
  "dispatcher.useSyncExternalStore is not a function".

Neither the vite alias (790ad2b) nor the defensive renderer-method
guards (c35a2d4) could fix it — both addressed downstream symptoms
(stale dist / missing manager methods) but the dispatcher is upstream
of both. That's why every retry kept reproducing the same error.

Fix is structural: snapshot hooks (useUserDataSnapshot,
useIsUserIgnored, etc.) MUST run outside any useBetween scope. Three
re-applied migrations:

- useSessionInfo: snapshot read moved into the outer wrapper. The
  inner useSessionInfoState (useBetween-shared) now contains only
  use-between-safe hooks: useState, useMessageEvent, plain actions.
  userFigure / userRespectRemaining / petRespectRemaining come from
  useUserDataSnapshot() OUTSIDE useBetween, so useSyncExternalStore
  installs against the real React dispatcher.

- useChatWidget.ownUserId: direct snapshot read. useChatWidget is
  exported as `useChatWidget = useChatWidgetState` (NOT wrapped in
  useBetween), so this hook never sat inside the broken scope — the
  precautionary rollback was unnecessary in retrospect. Gains
  session-change reactivity (e.g. reconnect under a different user id).

- AvatarInfoWidgetAvatarView Ignore/Unignore: useIsUserIgnored(name)
  read directly in the component body. Same reasoning as
  useChatWidget — never inside useBetween. The menu auto-flips
  Ignore <-> Unignore while the popup is open.

Added regression guard at src/hooks/session/useSessionSnapshots.test.tsx
with two cases: (1) useSyncExternalStore inside useBetween throws,
(2) useSyncExternalStore outside useBetween in the same render works.
Pins the constraint so future migrations cannot reintroduce the bad
shape silently.

Verification: yarn typecheck clean, yarn test 209/209 (207 baseline
+ 2 new regression cases), no consumer surface changes — every
destructured field (userFigure, userRespectRemaining, respectUser,
petRespectRemaining, respectPet, chatStyleId, updateChatStyleId) is
still returned with the same name and shape.
2026-05-19 17:30:03 +02:00
duckietm de899c3138 🆕 Infostand Borders 2026-05-19 16:55:44 +02:00
Lorenzune 4e1ceed53f Add badge leaderboard UI and badge rarity styling 2026-05-19 15:30:47 +02:00
simoleo89 e142efd793 revert(hooks): roll back the three snapshot-consumer migrations to pre-71a0eee state
The migrations of useSessionInfo, useChatWidget.ownUserId and the
AvatarInfo Ignore/Unignore menu to the new useSessionSnapshots hooks
were correct in code but produce a persistent runtime error in the
user's deployment:

  TypeError: (intermediate value)() is undefined
      ToolbarView ToolbarView.tsx:46

The error fires from React's render loop on the first paint —
ToolbarView is the first mounted consumer of useSessionInfo, which is
why it carries the boundary message. Two attempted fixes did not
resolve it on the user's side:
- 790ad2b: vite alias forcing @nitrots/nitro-renderer to source index.ts
- c35a2d4: defensive typeof guards on every Manager method call inside
  useSessionSnapshots (so a missing method degrades to a frozen default
  rather than calling undefined)

Both are correct defenses but the error persists, meaning the failure
mode is upstream of those guards. Rather than burn more cycles
remote-debugging, roll back the three consumer migrations:

- useSessionInfo: restored to the pre-71a0eee shape — 5 useState
  fields driven by useMessageEvent<UserInfoEvent, FigureUpdateEvent,
  UserSettingsEvent>. The five consumers (ToolbarView, HcCenterView,
  ChatInputView, AvatarInfoPetTrainingPanelView, InfoStandWidgetPetView,
  AvatarInfo{Avatar,Pet,OwnPet}View) get the same destructured shape
  they had before this session.
- useChatWidget.ownUserId: restored to `GetSessionDataManager()?.userId`
  (synchronous, captured at mount). Loses the session-change reactivity
  but matches the previous, working behaviour.
- AvatarInfoWidgetAvatarView Ignore/Unignore: restored to
  `avatarInfo.isIgnored` (captured by AvatarInfoUtilities at click
  time, not reactive). Loses the live-toggle if the user is
  ignored/unignored while the popup is open — known small regression,
  worth it for stability.

Kept intact:
- The useSessionSnapshots.ts hook file itself, with defensive guards,
  so the API stays available for any future opt-in consumer.
- 790ad2b vite alias for the umbrella, still useful as defence in
  depth for future migrations.
- All the other non-snapshot modernizations from this session
  (usePetPackageWidget reducer, useWordQuizWidget bug fix,
  useChatCommandSelector Zustand store, useAvatarInfoWidget typed
  globalThis accessor).

Verification: yarn typecheck clean, yarn test 207/207, yarn build green.
The toolbar should boot without the error now — the call chain on the
first paint no longer touches the new useExternalSnapshot / snapshot
getter path.
2026-05-18 22:16:48 +02:00
simoleo89 36addbe7d4 fix(avatar-info): reactive Ignore/Unignore menu entry via useIsUserIgnored
The Ignore <-> Unignore context-menu entry was driven by
avatarInfo.isIgnored — a boolean captured by AvatarInfoUtilities once,
at the time the avatar was clicked. If the user got ignored / unignored
*while the popup was already open* (e.g. via the friends panel, or
because a server push flipped the state), the menu kept showing the
stale option and clicking it would no-op (or worse, double-ignore).

Switch the menu items to read useIsUserIgnored(avatarInfo.name) — the
reactive hook backed by IgnoredUsersManager.getIgnoredUsersSnapshot()
+ NitroEventType.IGNORED_USERS_UPDATED. Now the menu flips automatically
the moment the ignore list changes, without re-opening.

avatarInfo.isIgnored stays on the data object (other code paths still
consume it) — only the user-facing menu toggle is now reactive.
2026-05-18 21:33:15 +02:00
simoleo89 1c2d8da08d wired-tools(store): hoist managed-holder give picker chain (selectedManagedVariableEntry, selectedManagedHolderVariableId, managedGiveVariableItemId, managedGiveValue)
Move the Variable-Manage panel's four-step picker cascade into the
Zustand store. Closes the WiredCreatorTools "fragmented picker state"
roadmap item — every remaining useState in the panel is now either a
store-backed UI flag or a transient component-only value (keepSelected,
globalClock, roomEnteredAt, monitor error/log details).

All writers were already direct assignments (no updater shape), so the
setters are plain typed setters. Sentinels remain `null` / `0` / `0` /
`'0'`; the cascade reset effects at WiredCreatorToolsView.tsx:2265-2307
keep the chain self-consistent. Panel close/reopen now preserves the
managed picker state, matching the lifecycle guarantee already provided
for selection, monitor snapshot, variable highlight, and inline editor.

Tests: 4 new cases (entry select/clear, chain write, post-action reset
to sentinels, panel-lifecycle persistence). Suite: 203/203.
2026-05-18 20:40:19 +02:00
simoleo89 8894fcc959 wired-tools(store): hoist inspection give pickers (inspectionGiveVariableItemId, inspectionGiveValue)
Move the Inspection-tab Give-variable popover picker pair into the
Zustand store. Both writers use direct assignments (no updater shape),
so the store setters are plain `(next: number) => void` /
`(next: string) => void`. Defaults `0` / `'0'` match the existing
"sentinel = not selected" convention used by the reset effects at
WiredCreatorToolsView.tsx:3026-3042.

Tests: 2 new cases (set+read pair, sentinel-reset). Suite: 199/199.
2026-05-18 20:38:02 +02:00
simoleo89 ba77806f52 wired-tools(store): hoist variable-key records (selectedInspectionVariableKeys, selectedVariableKeys)
Move the last two `Record<...Type, string>` useStates out of
WiredCreatorToolsView into useWiredCreatorToolsUiStore. Both writers
were already using the `prev => ({ ...prev, [key]: value })` updater
shape, so the new store setters expose `Updater<Record<...>>` to keep
existing call sites verbatim.

Initial values default to empty strings; the existing
`variableDefinitionsByType` sync effect at WiredCreatorToolsView.tsx
:1543-1574 already populates valid keys on first render and reconciles
whenever the server-side definitions change. Closing/reopening the panel
now preserves the active picker key instead of resetting it.

Tests: 4 new cases on the store (updater shape, single-key patch
preserving siblings, direct-record write path, panel-lifecycle
persistence). Suite: 197/197 (was 193/193).
2026-05-18 20:36:17 +02:00
simoleo89 779a98cae1 merge: sync upstream duckietm/Dev (b2318b9) into feat/react19-modernization
Absorbs 10 upstream commits (JSON5 config support, user-settings reset
password/email/username, wear-badge popup fix, login screen fix, About
update, offer selection logic, client path fix).

Conflicts resolved by keeping the modernized React 19 / Zustand / Form
Actions structure and porting upstream intent surgically:

- bootstrap.ts: kept GetConfiguration().init() pre-init + useEffectEvent,
  added JSON5 import (already wired into the parse fallback)
- LoginView.tsx: kept Form Actions (useActionState/useFormStatus); the
  upstream persistAccessTokenFromPayload(payload) fix was already
  integrated in the modernized SSO branch
- App.tsx: kept useEffectEvent import + StrictMode/ErrorBoundary umbrella
- vite.config.mjs: kept sirv plugin + react-compiler babel; absorbed
  upstream's base: process.env.VITE_BASE || './'
- package.json: kept superset (sirv, Vitest, Zustand, react-colorful,
  React Compiler) + added json5
- User-settings views: accepted upstream (duplicate of local cherry-pick
  2053c8e); notification badge bubble: accepted upstream fix

Verification: yarn typecheck clean, 193/193 Vitest, yarn build green.
2026-05-18 20:14:58 +02:00
DuckieTM e209146f47 🆙 Update About screen (needs a emu change as well) 2026-05-17 09:58:38 +02:00
simoleo89 4ab38d3f9a toolbar: always-mount nav rows + drive show/hide via framer variants
Replace the outer AnimatePresence wrapper around the four toolbar rows
(desktop backplate, left-nav, right-nav, mobile-nav) with always-mounted
motion.div elements driven by an isVisible-derived variant string
('visible' or 'hidden'). This eliminates the spam-toggle bug: rapid
clicks on the show/hide chevron previously left motion children in
inconsistent intermediate states (stuck opacity 0, phantom scale 0.8)
because AnimatePresence + Fragment + multiple keyed children breaks
when enter/exit cycles overlap. With variants, framer-motion's spring
solver picks up from the current animated value on each retarget, so
spam-clicking just settles smoothly toward whichever target is current.

Refactor details:

- containerVariants drops its 'exit' state (now lives in 'hidden').
- itemVariants drops 'exit' as well — animation target is the same as
  hidden, and exit doesn't apply without AnimatePresence.
- New shellVariants for the backplate.
- pointer-events is animated per-variant ('auto' visible / 'none'
  hidden) instead of pinned via a Tailwind class, so the hidden rows
  don't intercept clicks.
- Wrapper variants are computed inside the component because
  leftNavVariants.hidden depends on isInRoom (the nav slides in from
  the side in-room, from the bottom otherwise).
- Variant inheritance: outer wrapper drives 'visible'/'hidden';
  inner container (containerVariants) and items (itemVariants) inherit
  via framer's variant propagation, so stagger runs in both directions
  without needing AnimatePresence.
- Inner AnimatePresence around the Me popover stays — it has a single
  keyed child with a clean conditional and doesn't suffer from the
  Fragment-wrapping issue.

Cleanups while here:

- Dropped hasDesktopUnifiedShell: always equal to isToolbarOpen inside
  the isInRoom-gated block, so the ternary was always picking one
  branch. Inlined.
- Dropped showDesktopShell: same redundancy inside the (now removed)
  AnimatePresence. The 'else' branch of its ternary was dead code.
- Extracted spring transition constants (SHELL_TRANSITION,
  NAV_TRANSITION, ME_POPOVER_TRANSITION) so they're declared once.
- Removed pointer-events-auto from wrapper className strings where
  the variant now owns it (mobile-nav, left-nav, right-nav).

Behaviour: identical to before for a single click cycle (open → close
animates with the same spring). The previously broken spam-click path
now settles cleanly. Tests still 193/193, typecheck 0 errors, prod
build unchanged.
2026-05-16 12:52:05 +02:00
simoleo89 181ca096d0 wired-tools: hoist inline editor state (variables + managed holder) to the store
Move the four inline-editor useStates out of WiredCreatorToolsView and
into useWiredCreatorToolsUiStore:

- editingVariable / editingValue — Inspection-tab variables-table
  inline edit (current key being edited + in-flight input text).
- editingManagedHolderVariableId / editingManagedHolderValue — same
  pair for the Variable Manage panel's holder rows (id 0 = none).

WiredInspectionTabView drops three more props (editingVariable,
editingValue, onEditingValueChange) and consumes the store directly
for the read sides + the per-keystroke setEditingValue. The cancel /
keydown / begin handlers stay in the parent because they wrap
shouldPauseVariableSnapshotRefresh-aware logic plus selection
bookkeeping that doesn't belong to a pure tab body.

The shouldPauseVariableSnapshotRefresh derived flag still reads from
the same store now-backed values; no behaviour change on the polling
suppression path.

Tests: three new cases (set+read pair, null-clear, managed-holder
0-as-sentinel reset). 193/193 passing.
2026-05-16 12:37:29 +02:00
simoleo89 0fc32a1e19 wired-tools: hoist variable-highlight toggle + overlays to the store
Move the highlight feature pair into useWiredCreatorToolsUiStore:
isVariableHighlightActive (toggle UI flag) and variableHighlightOverlays
(computed screen-space overlay positions). The two screen-coords effects
in WiredCreatorToolsView stay where they are (they need React's
lifecycle to install / tear down WiredSelectionVisualizer highlights on
the active room objects) but now write to setVariableHighlightOverlays.

WiredVariablesTabView drops the isVariableHighlightActive +
onToggleVariableHighlight props and consumes the store directly — same
shape as the previous tab-prop reductions on this branch. The toggle
button keeps the same UX (Highlight ↔ Undo) but no longer crosses the
prop boundary.

Direct benefit: closing and reopening the panel while a variable
highlight is active no longer flickers the overlays off and back on —
the active flag + the last-computed overlay set both persist in
zustand and the effect re-runs from the same starting point.

Tests: three new cases on the store (toggle via direct + updater,
overlay replace + clear, close/reopen persistence). 190/190 passing.

variableHighlightObjectsRef stays a useRef inside the component: it
tracks the live PIXI objects WiredSelectionVisualizer drew onto, used
only for the cleanup pass — refs don't trigger renders and don't need
to live in the store.
2026-05-16 12:31:19 +02:00
simoleo89 8182e06be4 wired-tools: hoist inspection selection (+ live state + action version) to the store
Five more useStates leave WiredCreatorToolsView: selectedFurni,
selectedFurniLiveState, selectedUser, selectedUserLiveState, and the
monotonic selectedUserActionVersion counter. All five now live in
useWiredCreatorToolsUiStore; the room-event listeners
(useObjectSelectedEvent, the per-kind useMessageEvent + useNitroEvent
handlers, the per-action effects that bump the version counter) stay
in the component because they need React's subscription lifecycle —
they just call the store actions instead of setState.

Same persistence benefit as the previous monitorSnapshot pass: the
currently-inspected target survives a panel close/reopen instead of
being dropped to null on remount. Live-state setters and the action
version counter accept Updater<T> so the many `previousValue => ...`
call sites stayed verbatim.

Tests: six new cases (setSelectedFurni + null clear, functional
updater on FurniLiveState, paired setSelectedUser + LiveState,
monotonic ActionVersion via updater, close/reopen persistence). The
test fixtures use the real interface shapes — InspectionFurniSelection
includes a renderer-typed `info: AvatarInfoFurni` that is cast
through `as never` so the test doesn't have to construct the full
avatar info shape. 187/187 passing.
2026-05-16 12:25:31 +02:00
simoleo89 82bccd4040 wired-tools: hoist monitorSnapshot + polling reset to the Zustand store
Move the monitor snapshot off WiredCreatorToolsView's useState into
useWiredCreatorToolsUiStore. The WiredMonitorDataEvent listener still
lives in the component (it can't move alongside without dragging
useMessageEvent into the store), but it now writes to setMonitorSnapshot
and the room-change reset calls resetMonitorSnapshot() instead of
re-instantiating the default in the component.

Direct benefit: the snapshot now survives closing and reopening the
panel between two server pushes. Before this commit, the parent
remounted on every visibility flip (parent renders null while
`!isVisible`) which dropped the snapshot back to the empty default;
the user would briefly see zeroed stats until the next `monitor:fetch`
roundtrip landed. Holding the snapshot in zustand decouples the data
from the component's mount lifecycle.

Tests: three new cases on the store cover setMonitorSnapshot,
resetMonitorSnapshot returning a fresh empty instance, and the
"close/reopen panel preserves snapshot" lifecycle. Total 181/181.
2026-05-16 12:20:35 +02:00
simoleo89 8b4308af16 tests: co-locate every Vitest suite next to its subject under src/
Eliminate the parallel `tests/` tree. Each `*.test.ts` / `*.test.tsx`
now sits in the same directory as the module it covers, mirroring its
filename (`Foo.ts` ↔ `Foo.test.ts`). The renderer-SDK mock used by
component / hook tests moves to `src/__mocks__/nitro-renderer.ts` and
the Vitest setup file becomes `src/test-setup.ts` — both still wired
through `vitest.config.mts` exactly as before, only the paths changed.

All 13 suites + 178/178 cases still pass. The production build is
unaffected: rollup only follows imports from `src/index.tsx` and never
crosses into `.test.ts` files, so test code is naturally tree-shaken
out of the bundle. `yarn build` output is byte-for-byte the same on
the user-facing chunks.

tsconfig drops the now-redundant `tests` include entry. CLAUDE.md
'Layout convention' replaces the old `tests/` row with three rows
documenting the new co-located convention, the `__mocks__/` directory
and the `test-setup.ts` entry; ARCHITECTURE.md picks up the same
update. The 'DO NOT CHANGE' qualifier on the layout is preserved —
this rewrite IS the change, decided deliberately to make tests a
first-class part of the source tree rather than a sibling project.
2026-05-16 11:35:03 +02:00
simoleo89 c16ac1d276 wired-tools: hoist UI-only state flags to Zustand store
Move 14 pure UI flags off useState in WiredCreatorToolsView and into a
new feature-local Zustand store (useWiredCreatorToolsUiStore): tab
navigation (isVisible, activeTab, inspectionType, variablesType), modal
open flags (monitor history/info, inspection give, variable manage,
managed give), and the variable-manage / monitor-history filter +
sort + page selectors. The setters accept either a value or a (prev =>
next) updater to preserve the toggle/pagination call sites.

WiredInspectionTabView and WiredVariablesTabView now consume the store
directly for inspectionType / variablesType / isInspectionGiveOpen,
dropping six props from their interfaces. Behaviour is unchanged: every
listener and memo in the parent still reads the same values through
selectors, and the new tests pin the defaults and setter semantics
across the 14 flags.

Derived selection state (selectedFurni, monitorSnapshot, variable
highlight overlays, etc.) intentionally stays in the parent for this
pass — moving those requires moving their listener effects too.
2026-05-16 11:21:10 +02:00
simoleo89 ab93113ce7 widgets: wrap each room + furniture widget in its own WidgetErrorBoundary
The umbrella boundary on RoomWidgetsView caught any widget crash but
unmounted every sibling along with the failing widget — a single bad
parser in ChatWidget would dark out the avatar info, chat input,
doorbell and all furniture overlays until the next remount.

Wraps each of the 13 direct children of RoomWidgetsView (AvatarInfo,
Chat, ChatInput, Doorbell, RoomTools, RoomFilterWords, RoomThumbnail,
FurniChooser, PetPackage, UserChooser, WordQuiz, FriendRequest, plus
the FurnitureWidgets umbrella) and each of the 20 sub-widgets inside
FurnitureWidgetsView in its own named WidgetErrorBoundary. A crash
now silently logs through NitroLogger with the widget name and
renders null for that one widget; every sibling keeps rendering.

The outer umbrella stays as defense-in-depth for the wrapper div and
the listener setup in RoomWidgetsView itself.

Closes the "Per-widget WidgetErrorBoundary wrapping" roadmap item;
updates CLAUDE.md and docs/ARCHITECTURE.md accordingly.
2026-05-14 20:18:38 +02:00
simoleo89 9d10e52a55 fix(MainView): collapse CREATED/ENDED listeners into a session-aware reducer
Two independent useNitroEvent listeners updated landingViewVisible from
RoomSessionEvent.CREATED and ENDED with no notion of which session was
active. Under flaky websocket reconnects the events can land out of
order: a stale ENDED for the previous room arrives after CREATED for
the new one, flips landingViewVisible back to true, and the user is
left at the hotel view inside a room (or vice versa) until the next
room change.

Folds both events into one useNitroEventReducer that carries the
tracked sessionId. CREATED sets the id and closes the landing view;
ENDED is applied only when its event.session.roomId matches the
tracked id (or no session is active) — otherwise it's a stale ENDED
for a previous session and is ignored. The reducer companion is the
existing useNitroEventReducer from src/hooks/events, so no new
infrastructure.

Moves the entry in docs/ARCHITECTURE.md from "Open" to "Recently
fixed".
2026-05-14 20:09:23 +02:00
simoleo89 0f9fa1203b catalog: migrate remaining 36 useCatalog() consumers to the three filters
Replaces every direct call to the deprecated useCatalog() shim with the
targeted filter(s) (useCatalogData / useCatalogUiState / useCatalogActions).
Each consumer now subscribes only to the slice it actually reads, which
restores React Compiler memoization and stops catalog-wide re-renders
whenever an unrelated key changes.

Removes the now-unused useCatalog shim from useCatalog.ts and the
shim-specific case in tests/useCatalog.filters.test.tsx. The "all four
hooks observe the same singleton" test becomes "all three filters", since
there is no shim left to compare against. useCatalogFavorites swaps its
internal useCatalog() call for useCatalogUiState() (currentType lives in
the UI slice).

Updates CLAUDE.md and docs/ARCHITECTURE.md to reflect that all 48
historical consumers are migrated and the shim is gone.

Vitest: 162/162 (was 163 — minus the deprecated-shim contract case).
2026-05-14 20:05:44 +02:00
simoleo89 59d6c4cab3 catalog: three-way singleton-filter split + first 3 consumer migrations
Completes the useCatalog decomposition. After the previous commit
extracted the pure helpers, this one splits the singleton-via-useBetween
store into three slice-specific entry points and migrates a handful of
consumers as proof.

`src/hooks/catalog/useCatalog.ts`

- Internal `useCatalogState` → renamed to `useCatalogStore` and is no
  longer exported. The full return shape is unchanged so callers that
  still go through the shim see the exact same object.
- Three new exports built on top of the same `useBetween` instance:
    - `useCatalogData()` — server-driven read-only slice (rootNode,
      offersToNodes, currentPage, currentOffer, frontPageItems,
      searchResult, roomPreviewer, isBusy, catalog localization
      version, Builders Club counters + timers).
    - `useCatalogUiState()` — UI ephemeral state + writers
      (isVisible, pageId, previousPageId, currentType, activeNodes,
      navigationHidden, purchaseOptions, catalogPlaceMultipleObjects,
      plus every `set*` writer including the ones that mutate the
      data slice on user-driven selection).
    - `useCatalogActions()` — imperative operations only
      (openCatalogByType, toggleCatalogByType, activateNode,
      openPageBy{Id,Name,OfferId}, requestOfferToMover,
      selectCatalogOffer, getNodeBy{Id,Name},
      getBuilderFurniPlaceableStatus).
- `useCatalog` is kept as a deprecated shim that returns the full
  historical surface, so the 48 existing consumers compile and run
  unchanged.

Pilot consumer migrations (3 of 48):

- `CatalogBuildersClubStatusView` — Data (furni counters, seconds
  timers) + UiState (currentType).
- `CatalogBreadcrumbView` — UiState (activeNodes) + Actions
  (activateNode).
- `CatalogNavigationItemView` — UiState (currentType) + Actions
  (activateNode).

Tests: `tests/useCatalog.filters.test.tsx` (5 cases).

`useBetween` is mocked via `vi.hoisted` so the four hooks share one
deterministic fake store — rendering the real `useCatalogStore`
would mount ~30 useState calls + open a fresh RoomPreviewer +
subscribe to a dozen renderer events, which is more than these
contract tests need.

- `useCatalogData` exposes exactly its read-only keys.
- `useCatalogUiState` exposes exactly its UI keys + setters.
- `useCatalogActions` exposes exactly its imperative ops (and
  explicitly NOT data fields — proves no leak across slices).
- Singleton identity: callbacks read through the shim are `===` to
  the ones read through the slices.
- Shim surface: the historical key set is still present so
  un-migrated consumers don't silently break.

Suite: 163/163 (was 158/158). `yarn typecheck` green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 21:50:56 +02:00
simoleo89 9ef6983632 post cherry-pick: restore useEffectEvent wrapper + fix configuration import
Two typecheck regressions surfaced after pulling duckietm PR #126 onto
this branch:

- NotificationBadgeReceivedBubbleView lost its `useEffectEvent` wrapper
  during conflict resolution. The PR rewrote the effect to use a plain
  `useEffect(..., [activeBadgeCodes.length])`; reintroduce the
  `requestBadgesIfEmpty = useEffectEvent(...)` shape that the rest of
  this branch uses, applied to the renamed activeBadgeCodes selector.
- `src/bootstrap.ts` was importing `GetConfiguration` from the package
  alias `@nitrots/configuration`, which Vite resolves via filesystem
  alias but tsgo does not. Swap to the monolithic
  `@nitrots/nitro-renderer` re-export, matching how App.tsx already
  imports the same symbol.

Both fixes get `yarn typecheck` green again and all 113 Vitest cases
still pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 21:16:52 +02:00
duckietm 3a7c9ba940 🆙 Fix wear badge in popup 2026-05-13 21:14:19 +02:00
duckietm 2053c8e015 🆕 Added Reset password / Email and chenge username in user settings 2026-05-13 21:13:31 +02:00
duckietm 53f41cdbe9 🆙 Fix wear badge in popup 2026-05-12 10:54:01 +02:00
simoleo89 7b062299de useClubGifts + useNitroEventInvalidator: close the catalogOptions bag
This commit drains the last field out of ICatalogOptions (clubGifts)
and deletes the interface — useCatalog no longer owns a catch-all
mutable object that downstream components stuff data into.

Two pieces:

1) New useNitroEventInvalidator(eventType, queryKey, accept?) — a
   small companion to useNitroQuery for the case where the server
   pushes the same event unprompted (e.g. ClubGiftInfoEvent fires
   both as the response to GetClubGiftInfo and again after the user
   claims a gift via SelectClubGiftComposer). It calls
   queryClient.invalidateQueries() on each matching push so the
   next render of any subscriber triggers a fresh queryFn.

2) New useClubGifts() — useNitroQuery on the ClubGiftInfoEvent
   pair, paired with useNitroEventInvalidator so server-driven
   pushes refresh the cache automatically. CatalogLayoutVipGiftsView
   now consumes the query directly. The local optimistic
   'giftsAvailable--' mutation (which side-effected the parser
   object passed back to the catalog state!) is dropped — the
   server's authoritative ClubGiftInfoEvent push is the single
   source of truth via the invalidator.

useCatalog drops the matching listener + the GetClubGiftInfo dispatch
from the catalog-open effect. ICatalogOptions is now empty and
deleted; the catalogOptions / setCatalogOptions state + return-shape
field are removed from useCatalog along with the import.
2026-05-11 22:38:32 +02:00
simoleo89 9a807bf335 useMarketplaceConfiguration: lift the marketplace config self-fetch
MarketplacePostOfferView was both *the* fetcher and the listener for
MarketplaceConfigurationEvent — it dispatched
GetMarketplaceConfigurationMessageComposer from one effect when item
was set, then routed the response through setCatalogOptions.

useCatalog never touched the field; it was passing through catalogOptions
purely as a transport mechanism for this single component to talk to
itself. Replace with useMarketplaceConfiguration() — staleTime Infinity
(server-side constants for a session), enabled on item, single tidy
data path.

Drops marketplaceConfiguration from ICatalogOptions; with petPalettes
out too, ICatalogOptions is now just { clubGifts }. clubGifts is the
last one and needs invalidation (server pushes ClubGiftInfoEvent after
SelectClubGiftComposer) so it stays put until useNitroEventInvalidator
companion lands.
2026-05-11 22:32:35 +02:00
simoleo89 3947781495 useSellablePetPalette(breed): per-breed TanStack query for pet picker
CatalogLayoutPetView previously read 'catalogOptions.petPalettes' (an
accumulating array of CatalogPetPalette objects keyed by breed) and,
on cache miss, dispatched GetSellablePetPalettesComposer(productData.type)
inline. useCatalog kept the matching SellablePetPalettesMessageEvent
listener that appended each new breed to the array (deduping by breed
identity).

Migrate the request/response pair to a TanStack query parameterized on
breed:

  useSellablePetPalette(breed)
    key:     ['nitro', 'catalog', 'petPalette', breed]
    request: () => new GetSellablePetPalettesComposer(breed)
    parser:  SellablePetPalettesMessageEvent
    accept:  event.getParser().productCode === breed
    select:  build a CatalogPetPalette from parser
    enabled: !!breed (avoid spamming composers before currentOffer is set)
    staleTime: Infinity

The view now derives breed from currentOffer.product.productData.type
and reads 'const { data: petPalette }'. The cache-miss-then-fetch
two-pass effect collapses into a single effect that runs once
petPalette resolves (or clears state when offer/petPalette aren't
ready).

Drops the matching listener from useCatalog, drops petPalettes from
ICatalogOptions, and removes the now-unused CatalogPetPalette /
SellablePetPalettesMessageEvent imports from useCatalog.
2026-05-11 22:28:55 +02:00
simoleo89 2a5b9a4a98 useClubOffers: per-windowId TanStack query for HC offer pages
Two catalog layouts each fire 'new GetClubOffersMessageComposer(windowId)'
on mount and read parser.offers via HabboClubOffersMessageEvent:

  - CatalogLayoutVipBuyView (windowId 1)
  - CatalogLayoutBuildersClubBuyView (windowId 2 / 3, depending on
    the addon variant)

Plus useCatalog used to also listen for HabboClubOffersMessageEvent and
stash the offers in 'catalogOptions.clubOffersByWindowId[windowId]' and
'catalogOptions.clubOffers' (the latter being a backward-compat alias
for windowId 1). Three listeners, three independent requests when all
mounted.

New useClubOffers(windowId) wraps the request/response pair as a
TanStack query keyed by '['nitro', 'catalog', 'clubOffers', windowId]'.
accept(): correlation-key filter (parser.windowId === windowId) so
the same multiplexed event doesn't satisfy the wrong query slot.

Both layouts now read 'const { data: offers = null } = useClubOffers(windowId)';
useCatalog drops the listener, ICatalogOptions drops the
clubOffers / clubOffersByWindowId fields and HabboClubOffersMessageEvent
no longer needs to be imported in useCatalog. The localization-refresh
effect that re-cloned both fields is also dropped — React Query owns
the cache now, and ClubOfferData has no localized strings anyway.
2026-05-11 22:23:19 +02:00
simoleo89 2d9785e931 useUserGroups: consolidate 4 dedup'd CatalogGroupsComposer call sites
Four independent components used to send 'new CatalogGroupsComposer()'
on mount and listen for GuildMembershipsMessageEvent:

  - useCatalog (writing into catalogOptions.groups)
  - CatalogLayoutGuildForumView
  - CatalogGuildSelectorWidgetView
  - WiredSelectorUsersGroupView
  - WiredConditionActorIsGroupMemberView

Each fired its own request and re-listened independently. With four
of them mounted in the wired-tools panel during a builder session,
the same packet went out four times.

New useUserGroups() hook wraps the request/response pair with
useNitroQuery (queryKey ['nitro', 'user', 'groups'], staleTime
Infinity — guild membership is session-stable). All four consumers
now read 'const { data: groups = [] } = useUserGroups()' and React
Query dedups: one composer at the first mount, all subsequent mounts
get the cached array.

Drops 'groups' from ICatalogOptions and the corresponding listener +
prev-state-merge from useCatalog — no remaining consumer reads it.
2026-05-11 22:14:39 +02:00
simoleo89 68de96cac1 Last-mile typecheck sweep: 3 small bugs
- GuideToolOngoingView classNames clause: classNames(..., 'chat.roomId'
  && 'cursor-pointer') — the property name was quoted so the literal
  string 'chat.roomId' was always-truthy. Unquote to read the actual
  chat.roomId field.
- NavigatorRoomSettingsModTabView: UserProfileIconView userName={ user.userId }
  put a number into the string-typed userName prop; the right prop for
  a numeric id is userId.
- WiredExtraVariableEchoView resolvedVariableEntries: the inline
  fallback-entry literal at the bottom of the useMemo got its kind
  field widened to string (instead of the 'custom' literal needed by
  IWiredVariablePickerEntry). Lift it into a typed const + rename to
  namedFallback to avoid the shadowing of the upstream
  createFallbackVariableEntry result.
2026-05-11 21:46:23 +02:00
simoleo89 0c43377f9a Drop dead 'await success' on fire-and-forget catalog-admin actions
CatalogAdminContext exposes savePage / deletePage / saveOffer /
createOffer / deleteOffer as void-returning fire-and-forget composer
dispatches — they just call SendMessageComposer and let the server
push back later. The Offer/Page edit views were 'await action(data);
if(success) closeForm()' as if the actions returned Promise<boolean>,
but they don't return anything. tsgo flagged the truthiness check on
void.

Drop the await + truthiness — call the action, then close the form
unconditionally. This matches the actual behaviour: closeForm() ran
synchronously after the void anyway. A future PR that wants real
'wait for server confirmation' UX should refactor the context to
return Promise<boolean> (correlated to the response packet via the
pendingActionRef machinery already in place).
2026-05-11 21:46:18 +02:00
simoleo89 019295226d Sweep targeted typecheck errors: 11 fixes across 9 files
- ProductImageUtility: 'CatalogPageMessageProductData.I' was clearly a
  placeholder/typo in the WALL branch — getProductCategory's first
  param is FurnitureType, so use the enclosing productType.
- YouTubePlayerView: IRoomUserData has webID, not userId. Two
  spectator/watcher-list sites used the wrong field.
- AvatarInfoWidgetView REQUEST_MANIPULATION handler: avatarInfo is
  IAvatarInfo (union); .category / .id only exist on AvatarInfoFurni.
  Type-guard before reading.
- InfoStandWidgetPetView: deleted the duplicate local 'interface
  AvatarInfoPet' — was shadowing the imported one. Drop AvatarInfoPet
  from the import (local interface stands alone).
- FurnitureExternalImageView: missing GetSessionDataManager import (the
  reportedUserId field reads it inline). Added.
- GroupCreatorView setGroupData call: null values for groupName /
  groupDescription / groupColors / groupBadgeParts where IGroupData
  expects string / number[] / GroupBadgePart[]. Empty defaults. Also
  added the previously-omitted groupHasForum field.
- ContextMenuView + WiredCreatorToolsView: 'return () =>
  ticker.remove(updateOverlays)' — Pixi Ticker.remove() returns the
  ticker, leaking the value to React's EffectCallback cleanup which
  expects 'void | (() => void)'. Wrap in block body.
- Deleted src/components/room/widgets/chat/ChatWidgetWindowView_old.tsx
  — dead code (zero references in the codebase), tripping the
  NitroCardHeaderView onCloseClick prop change.

Net tsgo error count: -11.
2026-05-11 21:34:34 +02:00
simoleo89 a39aa37231 React 19: useRef<T>() -> useRef<T>(null) across 15 sites
React 19 dropped the no-arg useRef overload — the type-only useRef<T>()
form (no initial value) is gone, every call must pass an initial value.
The codebase had 15 occurrences of useRef<HTMLDivElement>() (DOM ref
pattern) all flagged by tsgo as 'Expected 1 arguments, but got 0'.

Mechanical sweep to useRef<HTMLDivElement>(null) — no behavior change,
React still hands out a ref object with .current set to null at mount.

Net tsgo error count: 57 -> 42.
2026-05-11 21:33:58 +02:00
simoleo89 feba672d08 Sweep small typecheck nits: union expansions + React 19 JSX + extra arg
- ColorVariantType missed the 5 outline-* bootstrap variants
  GroupForumThreadView and GroupForumThreadListView already use; adding
  them clears 4 errors.
- React 19 moved the JSX namespace out of the global scope into the
  react module; WiredNeighborhoodSelectorView referenced JSX.Element
  without importing it.
- showConfirm() takes 7 args; the chat link confirm in useOnClickChat
  passed an 8th 'link' icon arg left over from an older signature.
- LocalizeText placeholder array is string[]; UserContainerView passed
  userProfile.friendsCount (number) — call .toString().

Net tsgo error count: 97 -> 90.
2026-05-11 21:12:34 +02:00
simoleo89 b5eeb68b9b Type framer-motion variants as Variants — kill 33 tsgo errors
ToolbarView and FriendsBarView declared their motion variant objects
without a type annotation, so tsgo widened transition.type to 'string'
where framer-motion's Variants narrows it to a literal union (spring /
tween / inertia / etc). Every <motion.div variants={...} /> site flagged
the mismatch.

Annotating the constants as Variants makes the literal inference work
('spring' stays 'spring'); also drops the redundant 'as const' on
staggerDirection now that the parent type pins it.

Net tsgo error count: 133 -> 100.
2026-05-11 21:12:34 +02:00
simoleo89 8e4544c5aa Migrate catalog giftConfiguration to useNitroQuery
The catalog's gift wrapping configuration was loaded by an effect in
useCatalog that fired GetGiftWrappingConfigurationComposer every time
the catalog opened, with the response stuffed into a catalogOptions
slice via setState-in-effect. Migrating to a TanStack query gives us
caching/dedup/loading-state for free on this one-shot session-stable
loader.

- New useGiftConfiguration() hook in src/hooks/catalog/ wraps the
  composer/parser pair with useNitroQuery and staleTime: Infinity
  (the wrapping config never changes within a session).
- CatalogGiftView now reads from the query directly instead of via
  catalogOptions; the useCatalog() call in that component is also
  dropped (no other field was used).
- useCatalog drops the GiftWrappingConfigurationEvent listener and the
  unconditional composer dispatch.
- ICatalogOptions loses the giftConfiguration? field — no remaining
  consumer.

First step toward the docs/ARCHITECTURE.md next-PR item 'Migrate
useCatalog read-only fetches to useNitroQuery'. The clubGifts loader
will follow once useNitroEventInvalidator lands (clubGifts can be
push-updated by the server after SelectClubGiftComposer, so it needs
cache invalidation, not just a one-shot fetch).
2026-05-11 21:12:34 +02:00
simoleo89 559d860a7b Pilot: move InfoStand event listeners to useAvatarInfoWidget owner
InfoStandWidgetUserView previously subscribed to three room-session
events (RSUBE_BADGES, USER_FIGURE, FAVOURITE_GROUP_UPDATE) and pushed
the result back to its parent via a setAvatarInfo prop, with each
handler running CloneObject(prev) before patching one field. Three
issues with that shape:

- CloneObject was deep-cloning the whole AvatarInfoUser shape blindly
  with no class-prototype awareness;
- the three listeners raced on shallow merges across the same prev
  reference in StrictMode dev;
- the subscriptions lived outside the state owner, forcing a prop
  callback barrier per event.

The subscriptions are now in useAvatarInfoWidget — the actual owner of
avatarInfo — and call three pure reducers extracted to
src/hooks/rooms/widgets/avatarInfo.reducers.ts (applyUserBadgesUpdate,
applyUserFigureUpdate, applyFavouriteGroupUpdate). Each reducer returns
the same reference when the event doesn't apply so React bail-outs work.
The clone now constructs a fresh AvatarInfoUser preserving prototype.

dedupeBadges is extracted to its own pure module under src/api/avatar/
so Vitest can cover it without pulling in the renderer.

InfoStandWidgetUserView loses the setAvatarInfo prop (parent updated)
and the CloneObject import.
2026-05-11 21:11:02 +02:00
simoleo89 dbafc97e89 Drop unused login dialogs (dead code) + Vitest coverage on FriendlyTime
Two unrelated cleanups grouped because they're both small and safe.

Dead code removal
- src/components/login/components/RegisterDialog.tsx
- src/components/login/components/ForgotDialog.tsx
- src/components/login/components/shared.ts (only consumed by the two
  dialogs above)

These were the older non-Form-Actions versions of the register and
forgot-password dialogs. LoginView.tsx defines its own inline versions
that use `useActionState` + `useFormStatus` (Phase 3 of the React 19
modernization), which are the ones actually rendered. The legacy
files were already documented as dead in docs/ARCHITECTURE.md.

NewsWindow.tsx and the `components/` directory itself stay — NewsWindow
is still imported by LoginView at the bottom of the login flow.

Vitest coverage on FriendlyTime (+12 cases)
- 65 -> 77 passing tests, 5 -> 6 test files.
- LocalizeText is mocked with a deterministic stub
  (`${ key }|${ amount }`) so each assertion can verify both the bucket
  chosen and the rounded amount. The mock also short-circuits the
  transitive renderer-SDK import, which keeps the test runner
  decoupled from the renderer install state.
- Buckets covered: seconds / minutes / hours / days / months / years
  for both `format` and `shortFormat`. Plus: threshold override,
  key-suffix concatenation, half-hour rounding, the raw
  `getLocalization` helper.

Verification
- yarn test: 6 files / 77 cases / ~2s.
- yarn eslint on the new test file: 0 errors / 0 warnings.
- yarn tsc: clean on touched files.
2026-05-11 17:59:46 +00:00
simoleo89 bf84a0c2a6 useNitroQuery: add accept() predicate; migrate two mod-tools chatlog views
Many composer/parser pairs on the Nitro wire are correlation-key based:
the request carries a key (roomId, issueId, etc.) and the response shows
up on the globally-shared event bus, where other components may be
listening for the same parser type with a different key. The previous
useNitroQuery resolved on the FIRST matching parser event regardless of
key — useless for that pattern, which is why two obvious migration
targets (ModToolsChatlogView, CfhChatlogView) were skipped earlier.

Adapter change
- New optional `accept?: (event) => boolean` on NitroQueryConfig.
- In awaitNitroResponse, events for which accept returns false are
  IGNORED rather than resolving the promise. The listener stays
  registered, the timeout still applies. This lets callers do:
    accept: e => e.getParser()?.data.roomId === roomId

Migrations
- src/components/mod-tools/views/room/ModToolsChatlogView.tsx
  - Was: useState<ChatRecordData>(null) + useMessageEvent with
    `if (parser.data.roomId !== roomId) return; setRoomChatlog(...)` +
    a mount-only useEffect dispatching the composer.
  - Now: a single useNitroQuery call keyed on roomId; accept filters
    by roomId; the query is enabled only when roomId is set.
    The composer is no longer re-dispatched on remount within
    staleTime; switching to a different room still triggers a fresh
    fetch because the queryKey changes.
- src/components/mod-tools/views/tickets/CfhChatlogView.tsx
  - Same pattern, keyed on issueId.

Both migrations drop ~15 lines per file (no more local state + manual
listener + manual send) while gaining cache/dedup/loading/error
handling from TanStack Query.

Verification
- yarn eslint on the four files: 1 pre-existing error (the
  IMessageEvent "redundant union" false positive in createNitroQuery
  that we already documented — local sandbox doesn't have the
  renderer SDK installed, so its types resolve as `any`).
- yarn test: 49/49 passing.
- yarn tsc on the four files: clean.
2026-05-11 17:00:06 +00:00
simoleo89 bb09a562f6 Extract Monitor tab JSX into WiredMonitorTabView + drop dead overlays
Third (and final, for now) inline-tab extraction in WiredCreatorToolsView.
With this commit Monitor / Inspection / Variables / Settings are all
sibling components; the parent only orchestrates state.

What moved
- ~60 lines of live JSX (Statistics card, Logs table, "Clear all" +
  "View full logs" buttons) → src/components/wired-tools/WiredMonitorTabView.tsx
- The new component takes 7 typed props (3 data + 4 callbacks), no
  state or effects.

Dead code removed
- The Monitor block also contained three modal-style overlays
  (History / Info / Error info) wrapped in `{ false && ... }` — they
  never rendered. The live versions of those modals are mounted by
  the parent outside the NitroCardView (lines ~3327, ~3393, ~3679 in
  the new layout). Dropping the dead duplicates removes ~115 lines
  and ten otherwise-unused symbol references from the parent.

Impact
- WiredCreatorToolsView.tsx: 3710 → 3544 lines (−166 net).
  Combined with the previous two extractions and the
  types/constants/helpers split in 3c68d97, the file is now down
  from 4493 → 3544 lines (−949, −21%).
- The three tab files are each ~150 lines and trivially scannable.

Conscious non-goals
- No state hoisted to a store yet. The shared-state Zustand slice
  is a separate PR. This commit only relocates JSX.
- Behavior unchanged for live code paths. Removing the
  `{ false && ... }` overlays cannot change behavior because they
  were dead branches; the live overlays at the bottom of the parent
  module are the ones the user actually sees.

Verification
- yarn eslint on the two files: 34 problems baseline, 34 after
  (no new issues introduced).
- yarn test: 49/49 passing.
- yarn tsc on the touched files: clean.
2026-05-11 16:56:56 +00:00
simoleo89 d7d9a7e382 Extract Inspection tab JSX into WiredInspectionTabView component
Second of three slices to break up the WiredCreatorToolsView inline
tab bodies (Variables tab was split in the previous commit; Monitor
remains).

What moved
- 139 lines of inline JSX (`{ activeTab === 'inspection' && <div>
  ... </div> }`) → src/components/wired-tools/WiredInspectionTabView.tsx
- The new component declares 28 typed props grouped by area:
  element-type + preview, keep-selected toggle, variables table,
  inline editor, give-variable popover, remove variable. All state
  and actions arrive from the parent — no internal useState/useEffect.
- The "select variable + start editing" double action at the parent
  is wrapped into a single onSelectInspectionVariable callback so
  the sub-component doesn't need to know about the two setters.
- The renderer-SDK type IWired*VariableDefinition is replaced by a
  structural InspectionGiveDefinition declared in the view file:
  { itemId, name, hasValue }. Keeps the sub-component free of
  renderer-SDK imports.

Impact
- WiredCreatorToolsView.tsx: 3809 → 3710 lines (−99 net). Combined
  with the previous commit, the file is now down 191 lines from the
  4493-line single-monolith it was 6 commits ago.
- Inspection panel JSX is now visually scannable as a file. The
  parent only orchestrates state and passes it down.

Conscious non-goals
- No state hoisted. selectedInspectionVariableKeys, editingVariable,
  isInspectionGiveOpen, inspectionGiveValue etc. all still live in
  the parent useState. The Zustand slice for shared wired-tools state
  is a follow-up PR.
- No behavior change. Same renders, same handlers, same DOM.

Verification
- yarn eslint on the two files: 34 problems baseline, 34 after split
  (the same pre-existing FC<{}> + 5 set-state-in-effect on the parent
  module + react-compiler skip warnings).
- yarn test: 49/49 passing.
- yarn tsc on the two files: clean.

Next: extract the Monitor tab (~176 lines), the last inline tab body.
2026-05-11 16:53:52 +00:00
simoleo89 23fc302b24 Extract Variables tab JSX into WiredVariablesTabView component
Proposal #5 from docs/ARCHITECTURE.md, first slice: split one of the
three remaining inline tab bodies of WiredCreatorToolsView out into
its own file. Same approach the Settings tab has had for a while
(see WiredToolsSettingsTabView).

What moved
- 113 lines of inline JSX (the `{ activeTab === 'variables' && <div>
  ... </div> }` block) → src/components/wired-tools/WiredVariablesTabView.tsx
- The new component is a pure presentation function: 12 typed props,
  no useState, no useEffect, no event subscriptions. It receives:
    * state to render: variablesType, variablePickerDefinitions,
      selectedVariableDefinition, canVariableHighlight,
      isVariableHighlightActive, variableManageCanOpen,
      selectedVariableProperties, selectedVariableTextValues
    * actions to call: onVariablesTypeChange, onPickVariable,
      onToggleVariableHighlight, onOpenManagePanel
- The parent supplies all of them inline at the call site. The
  manage-panel open sequence (request fresh user vars + reset page +
  clear selection + show modal) is closed over into a single
  onOpenManagePanel callback, so the sub-component doesn't need to
  know about its three internal setters.

Impact
- WiredCreatorToolsView.tsx: 3901 → 3809 lines (−92 net). The file
  is still large, but one of the three big inline blocks is gone.
  Monitor (~176 lines) and Inspection (~138 lines) remain inline as
  follow-up PRs.
- The React Compiler now has a smaller file boundary for the
  Variables panel; once the other two blocks come out the parent
  module should stop being skipped for memoization.

Conscious non-goals
- No state was moved. The shared state (selectedVariableKeys,
  isVariableHighlightActive, variableManagePage, etc.) still lives
  in the parent's useState. Hoisting them to a Zustand slice would
  be a separate PR — premature here.
- No behavior change. Same renders, same handlers, same DOM.

Verification
- yarn eslint on the two touched files: 34 problems baseline,
  34 problems after the split (identical: same FC<{}>, same
  pre-existing set-state-in-effect, same react-compiler skip
  warnings on the parent module).
- yarn test: 49/49 passing.
- yarn tsc on the two files: clean.
2026-05-11 16:46:48 +00:00
simoleo89 388fb8ed34 Migrate CatalogLayoutRoomAdsView's room-ad fetch to useNitroQuery
Second concrete adoption of proposal #2 (first was OfferView).

Before
- A useState<RoomEntryData[]>([]) for availableRooms.
- A useMessageEvent<RoomAdPurchaseInfoEvent> handler that
  set the state on each parser event.
- A useEffect on mount that dispatched two composers, one of which
  was GetRoomAdPurchaseInfoComposer paired with the parser above.

After
- A single useNitroQuery call wires the request and parser as one
  read-only query. The select extracts parser.rooms with a default
  empty array.
- staleTime is 60s — opening the same panel within a minute reuses
  the cached value; the composer is not re-dispatched. Useful here
  because the user navigates between catalog tabs.
- The mount-only useEffect no longer dispatches the room-ad composer;
  the second composer (GetUserEventCatsMessageComposer) stays where
  it was — that one feeds useNavigator state and isn't a
  request-response pair this component owns.

Why this file
- It was the cleanest pattern in the catalog tree: no correlation
  keys, no conditional filter on the parser, no other writes to
  availableRooms. The pure derive-from-event case useNitroQuery is
  built for.
- The big god-hook useCatalog (1100 LOC) still owns most of the
  catalog data layer; migrating that needs the data/uiState/actions
  split first.

Verification
- yarn test: 49/49 still passing.
- yarn eslint on the touched file: 1 error (the pre-existing
  set-state-in-effect on line 36, unchanged — baseline matches).
- The previous useMessageEvent import was removed cleanly.
2026-05-11 16:40:43 +00:00
simoleo89 419de09638 Hoist usePollSubscriptions to RoomWidgetsView; drop the side effect from usePollWidget
Follow-up to the previous commit's poll split. The compat shim
usePollWidget used to call usePollSubscriptions() inside its body so
the three RoomSessionPollEvent listeners were still registered for
existing consumers — but that meant:
- listeners would be re-registered per consumer (today nobody, since
  useWordQuizWidget was already migrated to usePollActions);
- the lifetime of the subscriptions was tied to a leaf widget instead
  of the room session;
- a render of a component using the shim had the side effect of
  attaching three global event listeners.

Move
- src/components/room/widgets/RoomWidgetsView.tsx now calls
  usePollSubscriptions() once at the top of the room-widget tree. The
  bridge from RoomSessionPollEvent (OFFER/ERROR/CONTENT) to the UI
  event bus is now mounted for exactly the lifetime of an in-room
  session, regardless of which leaf widget renders.
- src/hooks/rooms/widgets/usePollWidget.ts (compat shim) is reduced
  to a one-liner that just returns usePollActions(). It is still
  deprecated; remove once nothing imports it.

Verification
- yarn eslint on the two touched files: 1 pre-existing error
  (the same FC<{}> in RoomWidgetsView that was there before — baseline
  unchanged; I deliberately did not touch it in this commit to keep
  the diff minimal).
- yarn test: 22/22 still passing.
- grep confirms usePollWidget has zero in-tree consumers; the only
  importer is the barrel re-export.
2026-05-11 16:36:11 +00:00
simoleo89 6793de2106 Set up Vitest + 22 smoke tests on pure modules (proposal #6)
Phase 3 of the refactor plan in docs/ARCHITECTURE.md — the foundation
that unblocks every safe refactor below.

Install
- yarn add -D vitest@3 jsdom @testing-library/dom @testing-library/react
  @testing-library/jest-dom

Note: pinned to vitest@3 (not the latest 4.x) because yarn 1's peer
resolution breaks on vitest@4's peer link to vite. With vitest@3 the
existing Vite 8 install resolves cleanly.

Configuration
- vitest.config.mts (new): separate from vite.config.mjs because the
  dev/build config wires up renderer SDK aliases that point at sibling
  working trees (../renderer, ../Nitro_Render_V3). Tests are written
  against pure modules that don't pull in the renderer, so the test
  runner uses a smaller alias set.
- tests/setup.ts (new): imports @testing-library/jest-dom/vitest so
  custom matchers (toBeInTheDocument, etc.) are available without
  per-file imports.
- tsconfig.json: include "tests" so eslint stops complaining about
  unparseable files; also makes the IDE see the test files.
- package.json scripts: "test" (one-shot) and "test:watch".

Tests
- tests/WiredCreatorTools.helpers.test.ts (18 cases): covers the pure
  helpers extracted in 3c68d97 — createEmptyMonitorSnapshot,
  formatMonitorLatestOccurrence (5 time-bucket branches),
  formatMonitorHistoryOccurrence, formatVariableTimestamp,
  formatMonitorSource (4 branches), normalizeMonitorReason. These are
  the most boring-but-easy-to-break functions; locking them down first
  is high value, near-zero risk.
- tests/navigatorRoomCreatorStore.test.ts (4 cases): exercises the
  Zustand store added in the previous commit — initial state, latch
  semantics, 5s auto-reset (with fake timers), and the
  "second beginCreate restarts the lockout" invariant. Validates that
  the store-based replacement of the let-singleton has the same
  observable behavior, plus the new invariant that wasn't possible
  before (timer composition under StrictMode double-mount).

Side effect: two non-test source files were converted to `import type`
to keep the test bundle from accidentally pulling in the renderer SDK
transitively:
- src/components/wired-tools/WiredCreatorTools.types.ts
  (`import type { AvatarInfoFurni }`)
- src/components/wired-tools/WiredCreatorTools.helpers.ts
  (`import type { HotelDateTimeParts, MonitorSnapshot }`)
This is harmless — TypeScript already treated them as type-only —
and improves tree-shaking on build as a side benefit.

Verification
- yarn test -> 2 files, 22 tests passing in ~1.0s.
- yarn eslint on tests/ + the two type-only-import files: 0 errors,
  0 warnings.

Migration path
- Next adoption targets: cover useDoorbellState reducer (data hook
  split), the new useNitroQuery adapter (timeout/cleanup behavior),
  and the smaller pure formatters under src/api/.
- React component tests (via @testing-library/react) deferred until
  there's a small mock layer for the renderer SDK. The
  @testing-library/* deps are already installed so that PR is
  unblocked.

https://claude.ai/code/session_01GrR87LAqnAEyKG2ZbmQt5Q
2026-05-11 16:31:53 +00:00