docs(navigator): P1 modernization design (hook split + UI store)

First of four planned phases reworking the Navigator on a clean
origin/Dev base. P1 is pure refactor (zero visible change): split
the 492-line useNavigator god-hook into wired-tools-style filters
(useNavigatorData / useNavigatorUiState / useNavigatorActions),
extract door lifecycle to useDoorState under src/hooks/rooms/widgets,
hoist the 9 local useState in NavigatorView into a Zustand
navigatorUiStore, migrate all 13 active consumers, and delete the
shim.

The Zustand UI store uses per-key selectors in useNavigatorUiState
to match createNitroStore's documented convention ("subscribe to
specific slices only").

Spec also anchors the visual rework (P4) target so architecture
decisions in P1 align with where we are heading: rich empty states,
card hover-reveal, saved-search chip row, filter intent chips,
sticky section headers, skeleton loaders.

Out of scope for P1 (each gets its own future spec): TanStack Query
migration of search (P2), reactive favourites/snapshot pattern (P3),
virtualization + empty states + persistence + chips (P4), Form
Action on search input (P6), WidgetErrorBoundary wrap (P5,
parallel-eligible).
This commit is contained in:
simoleo89
2026-05-26 20:31:31 +02:00
parent d5d5ca59a8
commit 66062c64ea
@@ -0,0 +1,549 @@
# Navigator Modernization — P1: Hook Split + UI Store
**Branch:** `feat/navigator-modernization` (forked from `origin/Dev` @ `d5d5ca59`)
**Date:** 2026-05-26
**Scope:** P1 of a 4-phase Navigator modernization sweep (P1 → P2 → P3 → P4).
**This spec covers ONLY P1.** P2 (TanStack Query), P3 (reactive snapshots),
and P4 (visual rework + virtualization + persistence) will each get their
own spec when P1 lands.
## 1. Context
The Nitro-V3 client has established patterns for god-hook
modernization, all visible on the current `origin/Dev` tip:
- **God-hook split into filters over a `useBetween` singleton.** Two
precedents:
- `useWiredTools` — 4 files (`useWiredToolsStore` + `useWiredToolsState`
+ `useWiredToolsActions` + `useWiredTools` shim). 630-line store.
- `useCatalog` — single 1055-line file holding store + three filters
(`useCatalogData` / `useCatalogUiState` / `useCatalogActions`).
- **Zustand UI stores** via `createNitroStore` (`src/state/createNitroStore.ts`)
for cross-feature UI flags.
- **Renderer snapshot consumer hooks** (`useSyncExternalStore`) — out of
scope for P1, used in P3.
- **`useNitroQuery`** for composer/parser request-response — out of
scope for P1, used in P2.
- **Co-located Vitest suites** under `src/`, sharing the renderer-SDK
stub at `src/nitro-renderer.mock.ts`.
`src/hooks/navigator/useNavigator.ts` is the largest remaining god-hook
on this branch: 492 lines, 21 event listeners, 9 internal `useState`,
consumed by 13 files (10 inside `src/components/navigator/` + 3
outside in `room-tools`, `room-filter-words`, and `catalog` views). It
mixes three logically separate concerns:
1. **Navigator data** — search results, categories, top-level
contexts, favourites, metadata.
2. **Door state** — doorbell, password prompt, accepted / no-answer /
wrong-password lifecycle.
3. **Local UI flags** — 9 `useState` in `NavigatorView.tsx` controlling
panel visibility and search lifecycle.
P1 separates these three and migrates all consumers.
## 2. Decisions
| Topic | Decision |
|---|---|
| Door state | **Extract** to `src/hooks/rooms/widgets/useDoorState.ts` |
| UI store scope | **All 9 flags** into `navigatorUiStore` Zustand |
| Shim retention | **Remove** `useNavigator` after all 13 consumers migrated |
| Filter shape | **Flat objects**, mirroring `useCatalog` and `useWiredTools` |
| File layout | **4 separate files**, mirroring `wired-tools` (not the monolithic `useCatalog.ts`) |
| Scope of P1 | **Pure refactor** — zero user-visible change |
| Branch | `feat/navigator-modernization` (forked from `origin/Dev`, not a sub-branch of any other modernization branch) |
## 3. Architecture
Mirrors the `wired-tools` layout exactly — 4 hook files in
`src/hooks/navigator/`, plus a sibling `navigatorUiStore.ts` for the
Zustand UI flags, plus `useDoorState.ts` extracted to
`src/hooks/rooms/widgets/`:
```
src/hooks/navigator/
├── useNavigatorStore.ts ← NEW: internal useBetween closure
│ (data state + non-door listeners + actions)
├── useNavigatorData.ts ← NEW: public filter — read-only data
├── useNavigatorUiState.ts ← NEW: public filter — read-only UI flags
├── useNavigatorActions.ts ← NEW: public filter — imperative actions
├── navigatorUiStore.ts ← NEW: Zustand UI store (9 flags + actions)
├── index.ts ← REWRITTEN: barrel exports the 3 filters,
│ useNavigatorUiStore, and re-exports useDoorState
└── useNavigator.ts ← DELETED at end of P1 (god-hook shim removed)
src/hooks/rooms/widgets/
└── useDoorState.ts ← NEW: extracted door lifecycle
```
### 3.1 Internal `useNavigatorStore` closure (in `useNavigatorStore.ts`)
The single `useBetween` singleton's internal function. Holds:
- All non-door state currently in `useNavigatorState` of the old
`useNavigator.ts`: `categories`, `eventCategories`,
`favouriteRoomIds`, `topLevelContext`, `topLevelContexts`,
`searchResult`, `navigatorSearches`, `navigatorData`.
- All non-door event listeners (16 of them): `FavouritesEvent`,
`FavouriteChangedEvent`, `RoomSettingsUpdatedEvent`,
`CanCreateRoomEventEvent`, `UserInfoEvent`, `UserPermissionsEvent`,
`RoomForwardEvent`, `RoomEntryInfoMessageEvent`,
`NavigatorMetadataEvent`, `NavigatorSearchEvent`,
`UserFlatCatsEvent`, `UserEventCatsEvent`, `FlatCreatedEvent`,
`NavigatorHomeRoomEvent`, `RoomEnterErrorEvent`,
`NavigatorOpenRoomCreatorEvent`, `NavigatorSearchesEvent`,
plus `NitroEventType.SOCKET_RECONNECTING`.
- `GetGuestRoomResultEvent` — dual-subscribed (see §5.2).
- `GenericErrorEvent` — dual-subscribed (see §5.3).
- New imperative actions `sendSearch` and `reloadCurrentSearch`,
extracted from the current `NavigatorView.tsx` locals (today defined
on lines 42-79 of `src/components/navigator/NavigatorView.tsx`).
### 3.2 The three filters (flat shape, wired-tools layout)
```ts
// useNavigatorData.ts
import { useBetween } from 'use-between';
import { useNavigatorStore } from './useNavigatorStore';
export const useNavigatorData = () => {
const {
categories, eventCategories, favouriteRoomIds,
topLevelContext, topLevelContexts,
searchResult, navigatorSearches, navigatorData,
} = useBetween(useNavigatorStore);
return {
categories, eventCategories, favouriteRoomIds,
topLevelContext, topLevelContexts,
searchResult, navigatorSearches, navigatorData,
};
};
// useNavigatorUiState.ts
import { useNavigatorUiStore } from './navigatorUiStore';
export const useNavigatorUiState = () => {
const isVisible = useNavigatorUiStore(s => s.isVisible);
const isReady = useNavigatorUiStore(s => s.isReady);
const isCreatorOpen = useNavigatorUiStore(s => s.isCreatorOpen);
const isRoomInfoOpen = useNavigatorUiStore(s => s.isRoomInfoOpen);
const isRoomLinkOpen = useNavigatorUiStore(s => s.isRoomLinkOpen);
const isOpenSavesSearches = useNavigatorUiStore(s => s.isOpenSavesSearches);
const isLoading = useNavigatorUiStore(s => s.isLoading);
const needsInit = useNavigatorUiStore(s => s.needsInit);
const needsSearch = useNavigatorUiStore(s => s.needsSearch);
return {
isVisible, isReady, isCreatorOpen, isRoomInfoOpen, isRoomLinkOpen,
isOpenSavesSearches, isLoading, needsInit, needsSearch,
};
};
// useNavigatorActions.ts
import { useBetween } from 'use-between';
import { useNavigatorStore } from './useNavigatorStore';
export const useNavigatorActions = () => {
const { sendSearch, reloadCurrentSearch } = useBetween(useNavigatorStore);
return { sendSearch, reloadCurrentSearch };
};
```
`useNavigatorActions` is intentionally small in P1 — favourite
toggles, room visits, and door responses keep flowing through their
existing direct composer calls in consumer components. We only hoist
the two functions that are currently prop-drilled into
`NavigatorSearchView` and the tab `onClick` handlers.
`useNavigatorUiState` uses per-key Zustand selectors (one selector
per flag) so a component re-renders only when a flag it actually
reads changes. The flat object it returns preserves the API shape
consumers expect.
### 3.3 `navigatorUiStore` (Zustand)
```ts
// src/hooks/navigator/navigatorUiStore.ts
import { createNitroStore } from '../../state/createNitroStore';
type NavigatorUiState = {
isVisible: boolean;
isReady: boolean;
isCreatorOpen: boolean;
isRoomInfoOpen: boolean;
isRoomLinkOpen: boolean;
isOpenSavesSearches: boolean;
isLoading: boolean;
needsInit: boolean;
needsSearch: boolean;
};
type NavigatorUiActions = {
show(): void;
hide(): void;
toggle(): void;
openCreator(): void;
closeCreator(): void;
setRoomInfoOpen(open: boolean): void;
toggleRoomInfo(): void;
setRoomLinkOpen(open: boolean): void;
toggleRoomLink(): void;
toggleSavesSearches(): void;
setLoading(loading: boolean): void;
markReady(): void;
markInitDone(): void;
requestSearch(): void; // sets needsSearch = true
consumeSearchRequest(): void; // sets needsSearch = false
};
const INITIAL: NavigatorUiState = {
isVisible: false,
isReady: false,
isCreatorOpen: false,
isRoomInfoOpen: false,
isRoomLinkOpen: false,
isOpenSavesSearches: false,
isLoading: false,
needsInit: true,
needsSearch: false,
};
export const useNavigatorUiStore = createNitroStore<NavigatorUiState & NavigatorUiActions>()((set) => ({
...INITIAL,
show: () => set({ isVisible: true, needsSearch: true }),
hide: () => set({ isVisible: false }),
toggle: () => set((s) => s.isVisible
? { isVisible: false }
: { isVisible: true, needsSearch: true }),
openCreator: () => set({ isVisible: true, isCreatorOpen: true }),
closeCreator: () => set({ isCreatorOpen: false }),
setRoomInfoOpen: (open) => set({ isRoomInfoOpen: open }),
toggleRoomInfo: () => set((s) => ({ isRoomInfoOpen: !s.isRoomInfoOpen })),
setRoomLinkOpen: (open) => set({ isRoomLinkOpen: open }),
toggleRoomLink: () => set((s) => ({ isRoomLinkOpen: !s.isRoomLinkOpen })),
toggleSavesSearches: () => set((s) => ({ isOpenSavesSearches: !s.isOpenSavesSearches })),
setLoading: (loading) => set({ isLoading: loading }),
markReady: () => set({ isReady: true }),
markInitDone: () => set({ needsInit: false }),
requestSearch: () => set({ needsSearch: true }),
consumeSearchRequest: () => set({ needsSearch: false }),
}));
```
The `linkTracker` in `NavigatorView.tsx` calls these actions directly
on `useNavigatorUiStore.getState()` instead of mutating local
`useState`. That collapses the switch statement from 30+ lines to a
clean dispatch table and eliminates the closure-over-stale-state hazard
where the tracker re-registers on every `isVisible` change (today at
`src/components/navigator/NavigatorView.tsx:162`).
### 3.4 `useDoorState` (extracted to `src/hooks/rooms/widgets/`)
```ts
// src/hooks/rooms/widgets/useDoorState.ts
import { DoorbellMessageEvent, FlatAccessDeniedMessageEvent,
GenericErrorEvent, GetGuestRoomResultEvent,
GetSessionDataManager, RoomDataParser,
RoomDoorbellAcceptedEvent } from '@nitrots/nitro-renderer';
import { useCallback, useState } from 'react';
import { useBetween } from 'use-between';
import { DoorStateType } from '../../../api';
import { useMessageEvent } from '../../events';
export type DoorStateSnapshot = {
roomInfo: RoomDataParser | null;
state: number; // DoorStateType.*
};
const INITIAL: DoorStateSnapshot = { roomInfo: null, state: DoorStateType.NONE };
const useDoorStateStore = () => {
const [snapshot, setSnapshot] = useState<DoorStateSnapshot>(INITIAL);
useMessageEvent<DoorbellMessageEvent>(DoorbellMessageEvent, event => {
const parser = event.getParser();
if (parser.userName && parser.userName.length > 0) return;
setSnapshot(prev => ({ ...prev, state: DoorStateType.STATE_WAITING }));
});
useMessageEvent<RoomDoorbellAcceptedEvent>(RoomDoorbellAcceptedEvent, event => {
const parser = event.getParser();
if (parser.userName && parser.userName.length > 0) return;
setSnapshot(prev => ({ ...prev, state: DoorStateType.STATE_ACCEPTED }));
});
useMessageEvent<FlatAccessDeniedMessageEvent>(FlatAccessDeniedMessageEvent, event => {
const parser = event.getParser();
if (parser.userName && parser.userName.length > 0) return;
setSnapshot(prev => ({ ...prev, state: DoorStateType.STATE_NO_ANSWER }));
});
useMessageEvent<GenericErrorEvent>(GenericErrorEvent, event => {
const parser = event.getParser();
if (parser.errorCode !== -100002) return; // door-only error code
setSnapshot(prev => ({ ...prev, state: DoorStateType.STATE_WRONG_PASSWORD }));
});
useMessageEvent<GetGuestRoomResultEvent>(GetGuestRoomResultEvent, event => {
const parser = event.getParser();
// ONLY handle the roomForward branch with door modes
if (!parser.roomForward) return;
if (parser.data.ownerName === GetSessionDataManager().userName) return;
if (parser.isGroupMember) return;
if (parser.data.doorMode === RoomDataParser.DOORBELL_STATE) {
setSnapshot({ roomInfo: parser.data, state: DoorStateType.START_DOORBELL });
} else if (parser.data.doorMode === RoomDataParser.PASSWORD_STATE) {
setSnapshot({ roomInfo: parser.data, state: DoorStateType.START_PASSWORD });
}
});
const reset = useCallback(() => setSnapshot(INITIAL), []);
return { snapshot, setSnapshot, reset };
};
export const useDoorState = () => useBetween(useDoorStateStore);
```
The current `NavigatorDoorStateView.tsx` does
`setDoorData({ roomInfo: null, state: DoorStateType.NONE })` to reset
— after P1 it calls `reset()`.
## 4. Consumer migration map (13 files)
| File | Reads today | Reads after P1 |
|---|---|---|
| `NavigatorView.tsx` | full `useNavigator()` + 9 local useState | `useNavigatorData` + `useNavigatorActions` + `useNavigatorUiStore` (one selector per flag) |
| `NavigatorDoorStateView.tsx` | `doorData`, `setDoorData` | `useDoorState` (`snapshot`, `setSnapshot`, `reset`) |
| `NavigatorRoomCreatorView.tsx` | `categories` | `useNavigatorData` |
| `NavigatorRoomInfoView.tsx` | `navigatorData`, `favouriteRoomIds` | `useNavigatorData` |
| `NavigatorRoomLinkView.tsx` | `navigatorData.enteredGuestRoom` | `useNavigatorData` |
| `NavigatorRoomSettingsBasicTabView.tsx` | `categories` | `useNavigatorData` |
| `NavigatorSearchResultItemView.tsx` | `favouriteRoomIds`, `navigatorData` | `useNavigatorData` |
| `NavigatorSearchResultItemInfoView.tsx` | `navigatorData` | `useNavigatorData` |
| `NavigatorSearchResultView.tsx` | `topLevelContext` | `useNavigatorData` |
| `NavigatorSearchView.tsx` | `topLevelContext` + `sendSearch` prop | `useNavigatorData` + `useNavigatorActions` |
| `CatalogLayoutRoomAdsView.tsx` | `navigatorData.currentRoomId` | `useNavigatorData` |
| `RoomFilterWordsWidgetView.tsx` | `navigatorData.currentRoomId` | `useNavigatorData` |
| `RoomToolsWidgetView.tsx` | `navigatorData` | `useNavigatorData` |
All 13 consumers get a one-line import swap (plus `NavigatorView`
which is more involved since it owns the 9 useState + linkTracker
dispatch + `sendSearch` prop drilling that all go away). No
behavioural change.
## 5. Dual-subscription edge cases
### 5.1 `useBetween` guarantee
`useDoorState` uses `useBetween(useDoorStateStore)`, so multiple
consumers (currently only `NavigatorDoorStateView`) share a single
listener registration — same as how `useNavigatorStore` works.
### 5.2 `GetGuestRoomResultEvent` — dual subscription
Today this event is handled in one place (current `useNavigator.ts`
lines 130-209) with three branches: `roomEnter`, `roomForward`, else.
After P1:
- `useDoorStateStore` subscribes and acts ONLY on the `roomForward`
branch when `doorMode` is `DOORBELL_STATE` or `PASSWORD_STATE` AND
the user is not the owner / not a group member.
- `useNavigatorStore` subscribes and handles `roomEnter`, the
`roomForward` branch WITHOUT door modes (direct `CreateRoomSession`
call), and the `else` branch.
Multiple subscribers to the same event is an accepted pattern (see
`FlatCreatedEvent` listened in `useNavigator` and elsewhere). Both
listeners register through `useMessageEvent` so the renderer event
bus dispatches to both.
### 5.3 `GenericErrorEvent` — dual subscription
- `useDoorStateStore` acts ONLY on `errorCode === -100002` (wrong
password).
- `useNavigatorStore` acts on `4009`, `4010`, `4011`, `4013` (room
management alerts via `simpleAlert`).
Each side filters by `errorCode` immediately — no cross-effects.
## 6. Visual direction (anchor for P4 — informational only)
P1 ships zero visual change. This section documents the visual
target that P4's spec will detail, so the architecture choices in
P1 align with where we are heading.
### 6.1 Current pain points (from user screenshots, 2026-05-26)
- **Tab "Pubbliche":** empty state is bare text "No rooms found".
- **Tab "Tutte le stanze":** popular rooms shown as a small thumbnail
grid; the "Party" category uses a compact list mode with no
visual hierarchy or live signal.
- **Tab "Eventi":** empty state is bare text "No rooms found".
- **Tab "Il mio mondo":** sparse list, no per-room preview.
- **Saved searches:** today a 600px-wide sidebar that resizes the
card and pushes content right.
- **Filter dropdown "Qualsiasi":** opaque about what filters exist.
### 6.2 Target shape (P4 spec will detail)
**Empty states with illustration + contextual CTA:**
```
┌─────────────────────────────────────┐
│ Navigator @ Habbo [×] │
│ [⚡][Pubbliche][Tutte][Eventi][Mio] │
├─────────────────────────────────────┤
│ [🔓 Aperte] [🚪 Campanello] [🔒] │
│ [filtra stanze...] 🔍 │
│ [🔖 staff] [🔖 party] [🔖 chill] + │
├─────────────────────────────────────┤
│ ╭──────────╮ │
│ │ 🏠 ✨ │ │
│ ╰──────────╯ │
│ Nessuna stanza pubblica │
│ ancora attiva │
│ │
│ [ Esplora stanze popolari → ] │
├─────────────────────────────────────┤
│ [+ Crea stanza] [Da qualche parte] │
└─────────────────────────────────────┘
```
**Card list with row-level hover-reveal:**
```
▼ Stanze più popolari [▦ ☰] [⚡]
┌─────────┐ Big Party Room
│ 🏠 🎵 │ 👤 22 · 🔓 Aperta · ★ 4.7
│ (img) │ by @Cocco
└─────────┘ [Entra] [ⓘ] [☆ favori] ← shown on row hover
─────────────────────────────────────────
▼ Party [▦ ☰] [⚡]
🟢 fcfcvcvcv 👤2 🔓 [ⓘ]
🔒 aaaaa 👤1 🚪 [ⓘ]
```
**Saved searches as horizontal chip row** above the filter input
(replaces the 600px sidebar — no layout shift on toggle).
**Filter intent as visible chips** instead of "Qualsiasi" dropdown:
`🔓 Aperte` `🚪 Campanello` `🔒 Con password` `👥 Solo amici`.
**Sticky section headers** when scrolling long lists.
**Skeleton loaders** during fetch (post-P2 when query state lands).
**Per-card actions on hover**: favourite ☆, info ⓘ, room link 🔗.
### 6.3 Why P1 architecture supports this
- `useNavigatorUiStore` makes future flags (`viewMode: 'compact' | 'expanded'`,
`lastTab`, `lastScrollTop`) trivial to add — they're new state on
the store; persistence can be added with a Zustand `persist`
middleware on a single line.
- Splitting `useDoorState` out means the visual rework of the door
prompt (a separate panel, possibly modal) can evolve independently
of Navigator search UI.
- Three flat filters mean a new card variant (compact-vs-expanded
list) reads `useNavigatorData` only — no risk of re-rendering the
whole Navigator when card-mode toggles.
## 7. Testing strategy
Coherent with `CLAUDE.md` "`yarn test` must stay green on every
commit":
| Suite | New / changed | Cases (target) |
|---|---|---|
| `navigatorUiStore.test.ts` | NEW | ~30: each action idempotent on no-op, transitions valid, `requestSearch`/`consumeSearchRequest` symmetric |
| `useDoorState.test.tsx` | NEW | ~12: each event listener happy path + filter-by-userName + filter-by-errorCode + reset() |
| `useNavigatorStore.test.tsx` | NEW (smoke) | ~5: 3 filters return expected shape, dispatch updates propagate to `useNavigatorData`, GenericError 4010 does NOT touch door state, GenericError -100002 DOES touch door state |
| Existing Vitest suites | Stay green | — |
All tests co-located under `src/`, alongside their subject. Reuse
`src/nitro-renderer.mock.ts` for event dispatching (the
`mockEventDispatcher` / `clearMockEventDispatcher` helpers).
CI gates that must stay green: `yarn typecheck` (TS 7 native),
`yarn test`, `yarn lint:hooks` (`react-hooks/rules-of-hooks: error`).
## 8. Compatibility with project conventions
`feat/navigator-modernization` is forked from `origin/Dev` @ `d5d5ca59`,
so it carries everything upstream has shipped through the floorplan
editor work + classic catalog view + emustats + housekeeping panel.
The design respects every constraint of this base:
- **No new dependencies.** Uses `zustand` (present), `use-between`
(present), `vitest` (present), `createNitroStore` (present at
`src/state/createNitroStore.ts`).
- **React 19 idioms** identical to the rest of the codebase. No
manual `useMemo`/`useCallback` unless the React Compiler asks for
them.
- **TypeScript strict** consistent with the rest of the project.
- **Co-located tests** under `src/` per the layout convention.
- **No conflicts with adopted patterns**: `useNitroEvent`,
`useMessageEvent`, `useBetween`, `createNitroStore`. The new
filters expose plain data — they don't call snapshot hooks
(`useSyncExternalStore`) inside `useBetween` scopes, so the
documented "snapshot-outside-useBetween" constraint never
triggers here.
- **Commit author** per house rules: `simoleo89
<simoleo89@users.noreply.github.com>` via per-command `-c`
overrides. **No Co-Authored-By trailer.**
- **Branch policy**: fresh branch off `origin/Dev`, pushable
fast-forward to `simoleo/feat/navigator-modernization` (which
doesn't yet exist on the fork — first push creates it). No
force-push required.
## 9. Out of scope (explicit)
- TanStack Query migration of search (P2).
- Reactive favourite icons via snapshot (P3).
- Live user counts via snapshot (P3).
- Virtualization of result list (P4).
- Empty-state component (P4).
- Saved-search chip row (P4).
- Persistence of tab/scroll/filter (P4).
- `useActionState` on search input (P6).
- `WidgetErrorBoundary` wrapping of Navigator sub-views (P5 —
independent, can land in parallel).
- Any visual change. P1 ships byte-identical UI.
- Any change to `NavigatorRoomSettings*` subtree (self-contained,
only reads `categories` in one tab).
## 10. Acceptance criteria
P1 is complete when:
1. `src/hooks/navigator/useNavigator.ts` does NOT exist (god-hook
removed).
2. `src/hooks/navigator/` contains `useNavigatorStore.ts`,
`useNavigatorData.ts`, `useNavigatorUiState.ts`,
`useNavigatorActions.ts`, `navigatorUiStore.ts`, and an updated
`index.ts`.
3. `src/hooks/rooms/widgets/useDoorState.ts` exists.
4. All 13 active consumers compile after their import swap.
5. `yarn typecheck` clean.
6. `yarn lint:hooks` clean.
7. `yarn test --run` green, with at least 3 new suites
(`navigatorUiStore`, `useDoorState`, `useNavigatorStore` smoke).
8. Manual smoke test: open Navigator, switch each top-level tab, run
a search, open a room with a doorbell, get rejected, open a room
with a password, enter the right password, enter wrong password,
open a room you own, click a favourite ☆, open RoomInfo, open
RoomLink. Each path renders identically to pre-P1 behaviour.
9. Branch `feat/navigator-modernization` pushed (fast-forward only)
to `simoleo/feat/navigator-modernization` on the user's fork.
## 11. Risk register
| Risk | Likelihood | Mitigation |
|---|---|---|
| A consumer reads a field we forgot to expose on a filter | medium | Type-checker catches it — all 13 consumers re-typecheck on swap |
| Dual-subscription on `GetGuestRoomResultEvent` causes double `CreateRoomSession` | low | `useDoorStateStore` only acts on doorMode bell/password; `useNavigatorStore` only acts on the other branches. Explicit `if` guards on both sides |
| `linkTracker` re-registration leaks because deps changed | low | New tracker reads `useNavigatorUiStore.getState()` instead of closure-captured state, so its `useEffect` deps shrink |
| `useDoorState` consumer in `NavigatorDoorStateView` regresses on `reset()` semantics | low | Smoke test in §10 covers this |
| Per-key Zustand selectors in `useNavigatorUiState` cause stale-closure issues | low | Each selector is one-shot, no derived values; identical pattern to existing Zustand stores in the codebase |
| Renderer SDK mismatch on local dev (e.g. floorplan-live-preview not in renderer's main) | medium | Already exists today regardless of this PR; surface in plan as a `yarn typecheck` caveat, not introduced by P1 |