Types, fixture, Zustand store, Pannellum wrapper, design-system helpers
Adds the load-bearing scaffold every later phase consumes. No UI changes visible to a user opening the app, but every subsequent phase is pure composition on top. Runs in parallel with the brand-language-and-identity workstream.
Tasks
- Extend
src/types/index.ts— addProject,Building,Floor,Floorplate,Layout,Unit,PriceBand,Amenity,Architect,Developer,PaymentPlan,TourNode.UnitStatusandAspectexported as discriminated unions (UnitStatus = 'available' | 'reserved' | 'sold' | 'on-hold' | 'anonymised';Aspect = 'sea' | 'city' | 'lagoon' | 'pool').<StatusPill>uses an exhaustiveRecord<UnitStatus, OxidisedToken>— TS errors if a status is added without a colour. ExtendBuyerProfilewithsavedUnits: string[],lookupId?: string,mode: 'buyer' | 'broker-shortlist'. - Create
src/data/riviera.ts— Riviera Project value populated with content from real Mered + H&dM source material. ~30-unit subset with realistic prices/aspects/statuses. 4 floorplates (l2 + l6 real, 2 stub-tagged). 2 layouts. 6 POIs againstriviera/360-aerial.jpg.tourNodes[]graph (only aerial activated; interior nodes structurally complete withpanoramaUrlundefined — placeholders pending Roman's incoming files). Each voice-bearing string tagged[copy:provisional]; factual strings[copy:locked]. Project-level flags:welcomeDefault,demoMode: true,anonymiseStatuses: false. Single source of truth for all module content. - Create
src/lib/pannellum.tsx—'use client'<Panorama>wrapper, dynamic import insideuseEffect. Full anticipated prop surface from day one:panoramaUrl,panoramaType?(default'equirect'),pois?,onPoiClick?,onSceneChange?,autoLoad?,compass?,loading?: ReactNodeslot (Skeleton-White scrim with progress shimmer),onLoad?. Hide default Pannellum UI. Forwarddata-testid. - Create
src/lib/pannellum.stories.tsx— stories: Default, WithLoadingSlot, WithHotspots, WithFallback. Storybook 10/Vite fallback =next/dynamicin iframe story (cheap). Canvas polyfill is OUT — moves to follow-onsales-app-react-pannellum-spikeif Roman wants. Phase 1 exit gate: Next dev/projects/riviera/exteriorworks in browser. - Create
src/lib/buyer-profile-store.ts— Zustand store withpersistmiddleware. Addzustand(~3kB) +zodto deps. Persist config:{ name: 'oo:buyer-profile:v1', version: 1, partialize, migrate, onRehydrateStorage }. Zod schema validates rehydrated shape; on mismatch, clear key + start anonymous. HookuseBuyerProfile()returns selected slice. RootLayout does NOT wrap in Provider — Zustand stores are global. - Create
src/lib/format.ts—formatAed,formatArea. Consume Zustand store's currency + units slices. - Create
src/lib/asset-fallback.tsx—<AssetOrPlaceholder src alt kind="render|panorama">. Skeleton-White card, Mono small caps. Render → "Render available on request"; panorama → "Interior walkthrough — available on request from your advisor". Same panorama copy reused in PoiDetail disabled-CTA tooltips. - Create
src/data/index.ts—getProject(slug): Project | null+<MissingProjectFallback>. Storybook decorator routes throughgetProject(toolbarSlug)so cote/velvet show the fallback rather than Riviera-content-under-cote-accent. - Extract
src/components/brand/Wordmark.tsx— single component the brand workstream replaces wholesale. TopBar consumes<Wordmark />, never inline SVG. - Configure ESLint rule banning bare string literals >30 chars in
src/features/**andsrc/components/**. Pair with Vitest assertion that fixture token (e.g. "mother-of-pearl") appears in rendered output. - Configure Vitest scripts:
"test","test:storybook". Convention:*.test.tsxco-located. - Configure Storybook story title taxonomy:
Modules/<PascalGroup>/<ComponentName>. Document inAGENTS.md.
Eval pack
| Field | Value |
|---|---|
| Artefact | (a) Storybook URL ?path=/story/foundation-pannellum--default. (b) git diff of types + fixture. (c) package.json shows zustand + zod. |
| 2-min check | Open Storybook, drag panorama, wc -l src/data/riviera.ts >200 lines. |
| Self-check | tsc --noEmit 0 errors. Smoke passes (incl. corrupt-JSON Zustand recovery). Gate-passed: phase-1. |