# Area Guide (Main · User Portal)

**Portal:** Main · **Routes:** `main.portal.courses.index` (`GET /property/academy?tab=area-guide`; `GET /area-guide` and `GET /area-guide/{area}` redirect there) · `main.portal.area-guide.narration` (`GET /property/academy/area-guide/narration/{area}/{locale}/{chapter}`) · `main.portal.area-guide.chapter-image` · `main.portal.area-guide.chat` (`POST /property/academy/area-guide/chat`) · `main.portal.area-guide.activity` (`POST /property/academy/area-guide/activity`) · **Nav:** "Learning Hub" → **Area Guide** — **in the tab strip since 2026-09-18** (hidden from 2026-09-13 while the section was admin-only). `Lms/Index.vue` binds the tab's `hidden` to the server's `areaGuide.hidden`, so the button shows unless the section lock is showing THIS reader the coming-soon panel; `ShowTabs` resolves `?tab=area-guide` and lazy-mounts the body either way, so `/area-guide/{area}`, tutorial back links and road hand-offs keep working. What the reader gets INSIDE is narrowed separately by the one-area pin (below). Navigation visibility does not bypass the locks · **Gated by:** `['auth','main','contact.verified']` + TEMPORARY admin-only lock, TrainingAccess and membership Function access (in `CoursesController::areaGuideState()`); the narration, chat, walk-station and walk-visit endpoints carry `feature:area-guide` **and apply that same combination themselves** through `AreaGuideViewerAccess::allowsGuide()` (2026-09-14)

> **It is a TAB, not a page (2026-08-27), and a HIDDEN one (2026-09-13).** The
> guide moved into the [Learning Hub](/docs/modules_handbook/main/lms/readMe.md):
> "where should I buy" is reference material a member READS before a course
> about it, not a tool they operate, and every other portal nav entry at that
> level is a tool. It is registered **fourth** in `Lms/Index.vue`'s `tabs`
> (after "How to Use PropertyLab", DMAIC 之路 and 案例复盘) with `hidden: true`, so it has **no
> button on the strip** and is reached only through `?tab=area-guide` — an Area
> Tutorial back link, the DMAIC road's hand-off, or a copied guide URL. Remove
> `hidden: true` to restore the button. What moved is only the chrome — the
> guide itself is
> [AreaGuidePanel.vue](/resources/js/Components/AreaGuide/AreaGuidePanel.vue),
> mounted by [Lms/Index.vue](/resources/js/Pages/Main/Portal/Lms/Index.vue).
> `ShowTabs` lazy-mounts the active tab, so a reader who never opens the tab
> still downloads neither three.js nor Mapbox.
>
> **The tab owns its query params (2026-09-14).** It declares
> `queryParams: ['country', 'region', 'area']`, and `ShowTabs` deletes exactly
> those when the reader leaves the tab — the pair to `AreaGuidePanel.syncUrl`,
> which writes them. Without it, a URL copied from e-Learning still carried the
> guide's position. Three consequences: params the tab does not own survive (an
> unrelated `utm=` is kept), a param the INCOMING tab also declares is kept, and
> a reader who leaves the guide and comes back inside the same history entry
> lands on the default country/region rather than where they were, because the
> deep link the panel re-reads at setup is gone.

## What it does

**Registry and catalogue map reader (2026-09-13):** countries, regions and areas are read through the [shared Area Guide registry](/docs/modules_handbook/shared/project-catalogue/area-guide-registry/readMe.md). The authorized reader can additionally enable catalogue project markers, uploaded model placements and map-media viewers through `AREA_GUIDE_MAP_ENABLED`. Project details reuse the complete MY detail surface in a drawer. This does not activate the feature or migrate the real master by itself; rollout and remaining acceptance checks belong to the [catalogue map requirements checklist](/docs/modules_handbook/main/area-guide/catalogue-map-requirements.md).

An interactive map of the markets we sell in, where every area carries the
story we tell buyers: a tagline, a two-paragraph investment story, labelled
facts (rail, drivers, tenants, stock) and a positioning badge
(Prime / Hot / Mature / Emerging / Watchlist).

**Malaysia** (2026-09-13, PR #149) is the migrated country: KL & Selangor's
twelve areas — KLCC, Bukit Bintang, Cochrane, Maluri, TRX, Bangsar, Bangsar
South, Old Klang Road, Mont Kiara, Petaling Jaya, Subang Jaya, Cyberjaya — on
ONE continuous Mapbox map, in **English or Chinese**, each area narrating
itself in a pre-rendered voice **a chapter at a time**, with videos hanging on
the map at the places they were shot and a
**chatbot** that answers questions about the neighbourhood. Penang and Johor
are `soon` shells. An area with a [walkable session](/docs/modules_handbook/main/area-guide/walkable-session/readMe.md)
(Cochrane) offers **Walk this area**; an area with published
[Area Tutorials](/docs/modules_handbook/manage/area-tutorials/readMe.md) lists
its corridor drives.

**When catalogue map mode is disabled, Hong Kong and the UAE** (Dubai + Abu Dhabi) keep the original
illustrated rendering: **country → region → area**, an illustrated
low-poly world (three.js) for the first two levels and the **real pitched
street map** (Mapbox Standard: true building heights, roads, street names)
inside an area. Their content now also comes from the active registry; this
does not add Malaysian narration or chat capabilities to those countries.

> **Two guides in one panel, on purpose.** Proving the new map on one country
> keeps it a reviewable change instead of a rewrite of three at once. The
> split is visible in [AreaGuidePanel.vue](/resources/js/Components/AreaGuide/AreaGuidePanel.vue),
> which renders [MalaysiaGuide.vue](/resources/js/Components/AreaGuide/MalaysiaGuide.vue)
> for Malaysia and its own three-level flow for the other two. Newly authored
> countries can use the continuous street renderer without requiring a new
> illustrated geometry asset; the registry's map profile selects the renderer.
> When `areaGuide.mapEnabled` is true, every country uses the existing generic
> continuous renderer so the catalogue layer has a real Mapbox map to attach to.
> Each layer uses that registry country's explicit `iso2`; a missing code never
> falls back to MY. This does not add MY chat or narration to another country.
>
> **The rule, exactly, is `AreaGuidePanel`'s `isContinuous`:** a country gets the
> continuous renderer when `mapEnabled` is on, OR its map profile names the
> `street` / `continuous` renderer, OR its asset is `westMalaysia`, OR it has no
> painted `lands` at all. Only a country WITH painted lands, while the catalogue
> map is OFF, falls to the illustrated `AreaMap3d` + `AreaStreetMap` flow — Hong
> Kong and the UAE in the legacy content. **The class names are historical:**
> `MalaysiaGuide` / `MalaysiaMap` are the generic continuous renderer, not
> Malaysia's alone.

> **Working on this module locally?** The guide's content lives in the shared
> master database, so a developer machine either READS production (no edits
> possible — the account is SELECT-only) or edits an isolated local copy. Which
> switches, how to pull production's content down, and why its media can be
> blank locally with nothing in any log:
> [registry handbook → Working on the Area Guide locally](/docs/modules_handbook/shared/project-catalogue/area-guide-registry/readMe.md#working-on-the-area-guide-locally-2026-09-18)
> and
> [map handbook → Getting production's media onto a development machine](/docs/modules_handbook/shared/project-catalogue/area-guide-map/readMe.md#getting-productions-media-onto-a-development-machine-2026-09-18).

> **OPEN TO MEMBERS 2026-09-18** (`AREA_GUIDE_LOCKED=false` on production),
> with the tab in the strip and the guide **TEMPORARILY PINNED TO ONE AREA**.
>
> **The one-area pin.** `AREA_GUIDE_PINNED_AREA=bangsar-south` (config
> `area_guide_content.pinned_area` → the `pinnedArea` prop → `AreaGuidePanel`)
> opens the guide on that area and hides everything that would take the reader
> off it: the **country switcher**, the **language toggle**, the **region
> chips** and both **"All areas"** ways back (`MalaysiaGuide`'s `pinned` prop,
> whose `closeArea()` becomes a no-op). A URL naming another country or area is
> overruled by the pin, since a deep link would otherwise be the way out.
> The reason is content, not policy: only Bangsar South has published map
> content, and a guide whose other areas open onto an empty map reads as broken
> rather than new. **DELETE the env line (+ `config:cache`) and the guide is
> whole again** — nothing is hidden from the registry and no story is touched.
> A key that is not a live area is IGNORED and the guide behaves normally, so a
> typo cannot strand a reader on a page with no navigation.
>
> *(The alternative — hiding the other areas from the registry itself with
> `area-guide:show-only`, see the
> [registry handbook](/docs/modules_handbook/shared/project-catalogue/area-guide-registry/readMe.md)
> — was deliberately NOT used: it would also take those areas' written stories
> and narration away from the admin editor, and this is meant to be temporary.)*
>
> **The assistant switch.** `AREA_GUIDE_CHAT_ENABLED=false` (config
> `area_guide_content.chat_enabled`) turns the per-area "Ask about …" box off
> for everyone. Temporary for the same reason as the pin: the answers are only
> as good as the area copy, and most areas are still being written.
> Two halves, and BOTH are required — `CoursesController::areaGuideState()`
> sends an EMPTY `chatAreas` list so the panel never offers the box, and
> `AreaGuideController::chat()` `abort_unless`es on the same config so a reader
> who kept the URL is refused. A control taken off a page is not a feature
> turned off, and every answer here spends the **company** AI key.
> Unset means ON. Only a recognised false value turns it off — this project's
> `env()` is CakePHP's, so the STRING `"false"` is truthy and a naive cast would
> silently leave the assistant on (`filter_var(..., FILTER_NULL_ON_FAILURE)`).
> ⚠️ phpunit boots `.env`, so both this and `AREA_GUIDE_PINNED_AREA` are pinned
> in `phpunit.xml` beside `AREA_GUIDE_LOCKED` — a developer whose own `.env`
> carries the operational value would otherwise turn a dozen chat and gate
> assertions into failures (and, the other way round, into tautologies).
>
> One thing is held back for good until the policy changes:
> **360° panoramas stay administrator-only** — that
> is the asset's own `audience` (decision D4), not this flag, so no reader
> route serves one whatever the section lock says. A member gets the map (3D
> models and location videos), the area stories, narration,
> the AI questions (unless the assistant switch above is off),
> the walkable sessions and the Area Tutorials.
>
> **The lock below STAYS in the code as an operational kill switch.** It was
> written to be deleted when the section opened; it is kept instead because
> this section's content is edited live and shared across sites, and one env
> line plus a `config:cache` takes it back off members without a deploy. The
> "remove it for good" list at the end of this block is still accurate if that
> day comes.
>
> **The lockdown, as it works.** While the flag is on the section is
> admin-preview only: an admin sees the real guide plus an amber banner (with a
> "Preview what members see" link), a member gets the coming-soon panel. It
> was a copy of the Analyze Property lock — a self-contained middleware — until
> the guide became a tab: **a tab has no path of its own for middleware to
> match**, and a path-matching lock would have taken the whole Learning Hub
> with it, so the same three rules now live in
> `CoursesController::areaGuideState()` and reach the page as the `areaGuide`
> prop (`locked` · `hidden` · `previewUrl` · `exitUrl` · `mapboxToken` · `mapEnabled` ·
> `gameAreas` · `chatAreas` · `countries` · `malaysia` · `narration` — map credentials,
> capabilities and full content are withheld when locked: a locked
> reader gets neither the map nor the stories).
>
> **`hidden` is the tab BUTTON's own flag (2026-09-18), separate from `locked`.**
> Only the section lock hides the button — while the guide is being written
> there is nothing to advertise. A trainee's Day-2 lock and the membership gate
> keep the button, because each of those panels names a door the reader can walk
> through themselves; hiding them would leave a member with no idea the section
> exists. An admin sees the button even while the section is locked, with the
> banner. The tab stays reachable by URL either way. It still hangs off
> `features.area_guide_locked` (`AREA_GUIDE_LOCKED`, default **true**; phpunit
> forces it off so the suite exercises the real guide), and opening the
> section is `AREA_GUIDE_LOCKED=false` — no deploy.
>
> **Why `=false` is safe here, and only here (2026-09-14).** `env()` in this
> codebase is CakePHP's and hands back the RAW STRING, so a plain `(bool)` cast
> reads `"false"` as TRUE — the trap that used to leave this section locked
> while the `.env` said it was open. `config/features.php` now parses the value
> (`filter_var(..., FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE)`), so
> **`false` / `0` / `off` / `no` / an empty value all open it, an UNSET flag
> keeps it locked, and anything unrecognised fails CLOSED.** Its neighbour
> `ANALYZE_PROPERTY_LOCKED` is deliberately NOT changed with it and still
> requires `=0`: re-parsing that one would OPEN any environment already
> carrying the ineffective `=false`. Do not generalise this rule to it.
>
> **The lock is authorization, not a withheld prop (2026-09-14).** It used to
> live only in `areaGuideState()`, which meant a member who knew the URLs could
> still call the reader endpoints directly — chat in particular, spending the
> company AI key. The narration, chat, walk-station and walk-visit endpoints,
> and the Area Tutorial player, now each call
> [`AreaGuideViewerAccess::allowsGuide()`](/src/AreaGuide/Support/AreaGuideViewerAccess.php)
> — the same three rules, server-side — and answer **403**; chat returns it in
> the panel's own `{answer: null, error: …}` shape. The map, asset and media
> routes call `allows()`, which is that plus the catalogue-map capability. The
> Learning Hub also withholds the **Area Tutorial cards** from a locked reader,
> since those cards name the guide's areas and drives.
>
> ⚠️ **Deploy note.** With `AREA_GUIDE_LOCKED` unset or true a plain member gets
> 403 from those endpoints instead of being served — that is the point of the
> flag. Production runs it `=false` since 2026-09-18. `=false` only works
> because the value is parsed (above); an env edit needs `config:cache` where
> config is cached.
>
> **Before opening it anywhere, check what a member would actually get:**
> `php artisan tinker scripts/area-guide-reader-check.php` prints the four
> gates (the section lock, the content source, the map capability, the
> membership matrix) and then the registry's areas and the published assets
> split by audience — so "the matrix is set to selected memberships with none
> chosen" and "every panorama is admin-only, and the models are still drafts"
> are visible before a member finds them. Read-only.
>
> To remove the lock for good, delete: `areaGuideState()`'s lock branch in
> [CoursesController.php](/app/Http/Controllers/Main/Portal/CoursesController.php)
> (keeping the props it builds), the `area_guide_locked` branch in
> [AreaGuideViewerAccess.php](/src/AreaGuide/Support/AreaGuideViewerAccess.php)
> (keeping the trainee and Function-access checks — they are not temporary),
> [AreaGuideComingSoon.vue](/resources/js/Components/AreaGuide/AreaGuideComingSoon.vue),
> [AreaGuideLockBanner.vue](/resources/js/Components/AreaGuide/AreaGuideLockBanner.vue)
> (+ both mounts in [Lms/Index.vue](/resources/js/Pages/Main/Portal/Lms/Index.vue)),
> [AreaGuideLockdownTest.php](/tests/Feature/Main/Portal/AreaGuide/AreaGuideLockdownTest.php),
> the `area_guide_locked` entry in [config/features.php](/config/features.php),
> and the `AREA_GUIDE_LOCKED` lines in `.env.example` / `phpunit.xml`.

> **Sub-module:** [Walkable Session](/docs/modules_handbook/main/area-guide/walkable-session/readMe.md)
> — an area you WALK instead of read (2026-09-08). A 3D avatar on the area's
> real street map, with the developments that really stand there as beacons:
> walk up to one and it opens its live catalogue figures, a question derived
> from those figures, and the reading to take away. Cochrane first; an area is
> switched on in the active registry (`config/area_guide_game.php` in legacy mode), and its stations are
> whatever the project catalogue says stands in the circle. On Malaysia's
> continuous map the walk is a view the reader steps into (**Walk this area**
> on the map card); on the illustrated guide the avatar is simply on the
> street map.

> **Area Tutorials (2026-08-30).** An area's story can also list guided **corridor drives** — an
> animated Mapbox route that stops at each condo with its live Subsale Database figures, chaptered
> to the team's walkthrough video. That is a separate, database-backed module with an admin builder:
> [Area Tutorials](/docs/modules_handbook/manage/area-tutorials/readMe.md). This panel only renders
> the "Drive the area" list (the `tutorials` prop, published tutorials keyed by `area_key`) and links
> to `/property/academy/area-tutorials/{uuid}`. Its admin picks the area from the registry below.
> **A locked reader is sent none of them** (the cards name the guide's areas and drives), and the
> player at that URL applies `allowsGuide()` too. The hub casts the map to an object, so an empty
> one ships as `{}` rather than `[]` — the page declares the prop as an Object, and an empty PHP
> array is now the common case. That shape cannot be pinned with `assertInertia`, which decodes the
> payload to a PHP array where the two are indistinguishable; the guard reads the raw Inertia JSON.

## How it works — shared registry (Phase 1)

- **One selected source.** [AreaGuideRegistry](/src/AreaGuide/Services/AreaGuideRegistry.php)
  exposes `countries($full)`, `country($key)`, `findArea($key)`, `walkableAreas()`
  and `source()`. `AREA_GUIDE_CONTENT_SOURCE=legacy` is the default and preserves
  the versioned import baseline plus Malaysia's PHP copy. After an explicit import,
  `database` reads active countries, regions and areas from the selected content
  database. There is no automatic import or fallback on a missing table, an empty
  result, a connection failure or an invalid database configuration.
- **Global ownership, with an explicit local development workspace.**
  [config/area_guide_content.php](/config/area_guide_content.php) selects
  `AREA_GUIDE_CONTENT_CONNECTION=catalogue` by default. Outside local/testing,
  the connection guard requires the same host, port and database as the configured
  live master; a site's copied catalogue cannot silently become a private registry.
  Local/testing may explicitly choose `area_guide_local`, a separate loopback
  database named by `AREA_GUIDE_LOCAL_DATABASE`. This lets developers edit local
  Area content while their catalogue project connection remains read-only.
  It is a development workspace, not a second production source.
- **Admin authoring.** Manage → Portal → **Area Guide** (`GET /manage/area-guide`,
  `manage.area-guide.index`) lists the hierarchy. `POST {type}`, `PUT {type}/{id}`
  and `DELETE {type}/{id}` under that prefix manage countries, regions and areas.
  Viewing requires an admin and `view-area-guide`; writing also requires
  `manage-area-guide`, database source mode and `AREA_GUIDE_CONTENT_EDITING=true`.
  Import, immutable keys, revisions, archive rules and actor provenance belong to
  the [registry handbook](/docs/modules_handbook/shared/project-catalogue/area-guide-registry/readMe.md).
- **Full content is authorized page data.** `CoursesController::areaGuideState()`
  sends `countries`, the compatibility `malaysia` prop (regions still keyed by slug),
  `gameAreas`, `chatAreas`, `source` and `unavailable`. Existing global, trainee and
  membership locks are evaluated before full content is read. A shared-source
  failure clears countries, stories, capabilities and the Mapbox token and sets
  `unavailable=true`; the rest of the Learning Hub remains available.
- **Selectors get a smaller contract.** On authenticated academy, road, Area Tutorial
  and Area Guide admin routes, `HandleInertiaRequests` shares `areaGuideSource` and
  lazy `areaGuideCountries = countries(false)`: names, keys, coordinates and map
  metadata, without stories. Unrelated routes receive neither prop. Database
  failure returns an authoritative `[]`. [useAreaGuideRegistry](/resources/js/composables/useAreaGuideRegistry.js)
  supplies the tutorial selectors/labels and road handoffs; the panel receives the
  authorized full payload. Any supplied array, including `[]`, wins over bundled
  content. Static frontend fallback is allowed only for legacy/older unversioned
  hosts when that array is absent, never for database mode.
- **Rendering assets stay in code.** The database stores map profile/asset keys;
  [registry.js](/resources/js/utils/areaGuide/registry.js) resolves versioned geometry
  and translated copy. Existing Malaysian, Hong Kong and UAE renderers remain;
  new country pins can use the generic continuous street map. This is not uploaded
  building-model support. Deep links resolve the active registry's keys, including
  newly authored areas; `soon` regions do not resolve to an open area.
- **Three panel states, not two (2026-09-14).** `areaGuideState()` sends
  `unavailable` when the shared guide content could not be read, and the same
  catch also empties `countries`. `Lms/Index.vue` forwards it to
  `AreaGuidePanel`, which renders a FIRST branch ahead of the empty-registry
  one: `[data-area-guide-unavailable]` — *"Area Guide is temporarily
  unavailable."* That is deliberately distinct from the empty-`countries`
  *"Area Guide is not available right now."*: one is a passing fault that is
  expected back, the other is a guide with nothing published. Until now the
  flag was sent and dropped, so an outage read to the member as a new install.
- **Capability and ownership boundaries remain explicit.** Chat and narration
  consume `country('malaysia')`; `chatAreas` exposes only Malaysian keys outside
  `soon` regions, so a new country does not show a chat control that cannot answer.
  **One class decides that set:**
  [`MalaysianGuideAreas`](/src/AreaGuide/Support/MalaysianGuideAreas.php) — the
  registry's own `findArea()` rule (skip `soon` regions) narrowed to Malaysia —
  and the hub's `chatAreas` prop, `ChatRequest`'s `area` rule, the narration
  route and `area-guide:narrate` all read it, so the keys one of them accepts
  are exactly the keys the others do. An area under a `soon` region is a 422
  from chat and a 404 from narration, with no AI call spent.
  Walkable worlds come from `walkableAreas()`. Tutorials/stops, learner visits and
  narration `media` rows remain on their original site database; this phase shares
  the area identity/copy, not those records or audio files. Shared narration media
  ownership is still pending.

## How it works — catalogue map reader

- **Capability:** `CoursesController::areaGuideState()` sends `mapEnabled` only
  when the Guide's existing locks allow access, the registry is available and
  `area_guide_content.map_enabled` is true. `Lms/Index.vue` forwards it without
  changing the hidden navigation tab. Backend map/media routes enforce their
  own authorization; changing a frontend prop does not grant access.
- **One real map:** `AreaGuidePanel` passes the active country through
  `MalaysiaGuide` to `MalaysiaMap`. After Mapbox's `load`, a `shallowRef` passes
  the actual map to `CatalogueMapLayer`, with the registry's explicit ISO2
  country code. The layer reads the bounded viewport from
  `GET /property/academy/area-guide/map`; published new-project and subsale
  memberships share the same project markers. The map uses Mercator projection
  for uploaded model coordinates. The layer removes listeners, markers and
  custom layers before its parent calls `map.remove()` in `onUnmounted`.
- **Complete project details:** a project marker or uploaded model emits its
  descriptor to `AreaGuidePanel`, which opens `ProjectDrawer`. The MY drawer
  uses the [shared Project Detail content](/docs/modules_handbook/shared/project-detail/readMe.md)
  and canonical page payload, including all existing tabs. HK/AE still use their
  standalone detail pages until their distinct payload/request adapters exist.
  Project selection and drawer tab/unit changes do not mutate the Guide's URL
  or map camera.
- **The drawer is a real modal, and its failures are told apart (2026-09-14).**
  It carries `role="dialog"` + `aria-modal="true"` with a Tab/Shift+Tab wrap and
  a `focusin` listener that pulls back focus landing on the page beneath;
  anything CONTAINED BY or FOLLOWING the panel counts as a higher layer, so a
  nested dialog (the hero lightbox) stays reachable and owns its own Escape.
  Three failure kinds, because "Try again" on a permanent failure is a lie:
  **transient** (network / 5xx) is the only one that offers a retry;
  **gone** (404, uuid mismatch, no detail URL) offers nothing; **standalone**
  (a non-MY country's 422, an unknown adapter) offers an **"Open project page"**
  new-tab link built from the 422 body's own `href`, falling back to the map
  feed's `href`, and passed through a same-origin guard so a `//host/…` value in
  a response body is refused outright rather than rendered.
- **A card inside the drawer opens a NEW TAB.** The Developer Record and New
  Supply cards render as plain `<a target="_blank" rel="noopener noreferrer">`
  when the content is in the drawer, never as an Inertia `<Link>` — following
  one in place would replace the Learning Hub underneath and lose the reader's
  area, map camera and the drawer itself. The standalone project page is
  unchanged and still uses `<Link>`.
- **A PANORAMA LEAVES THE PAGE (2026-09-18, user).** Selecting a camera marker
  no longer opens the reader's modal — it opens
  `GET /property/academy/area-guide/panoramas/{uuid}?from=…`
  (`main.portal.area-guide.panorama`) in a NEW TAB, rendering the SAME Inertia
  component the admin's `manage.area-guide.panoramas.show` renders. A
  360° photograph in a modal beside the story was a few hundred pixels of a
  sphere with a close button over it. One component, so the viewer, the flights
  between panoramas, the building markers and the little-planet intro cannot
  drift between the two audiences; everything that used to be tested on the
  panel side now lives in `Pages/Manage/AreaGuide/Panorama.test.js`.
  - **The reader route hands the page `canManage: false` and `reader: true`.**
    `canManage` alone could not carry it — an administrator on the MANAGE page
    also has it false whenever editing is off for the site, and would then lose
    the note that says so. `reader` decides two things and no more: which detail
    endpoint the building drawer calls (the reader's
    `/property/academy/area-guide/map/buildings/{uuid}`, never a manage URL its
    middleware would refuse), and that the admin-facing "editing is disabled"
    note is not shown to someone who was never offered editing.
  - **The gate is `authorizeAsset()`'s, unchanged**, so a panorama's `audience`
    still decides. Every panorama is `AUDIENCE_ADMIN` today (decision D4), so in
    practice the page serves an administrator browsing the reader's guide; a
    guide-audience panorama would reach a member through the same route with
    nothing to change, and a DRAFT still reaches nobody but an admin.
  - ⚠️ **`?from=` is an open-redirect surface and is checked server-side.** The
    tab has no history to go back through, so the page is TOLD where Back goes.
    Only a same-site path is accepted: an absolute URL, a scheme-relative
    `//host`, a `javascript:` scheme, a backslash (which a browser reads as a
    slash) and any control character all fall back to the guide rather than
    being cleaned up and followed. The link itself is opened with `noopener`.
  - A **video** marker still opens the modal: it is a rectangle, and a rectangle
    beside the story is what it wants to be.
- **Map media:** selecting a video first requests
  `GET /property/academy/area-guide/map/assets/{uuid}`. The viewport response
  does not include building markers; only the authorized detail response
  is passed to `AssetViewer`. (For panoramas the same endpoints now serve the
  standalone page above rather than the modal.) A panorama's building marker (a click-placed ring with the
  building's name, `area_guide_hotspots` — the traced outlines were retired 2026-09-15)
  returns EITHER a project UUID, which
  becomes the canonical project-detail request URL and opens the same drawer, OR a
  custom-building UUID (a tower that is not a catalogue project, introduced by an admin
  with free-form tabs of rich text — see the
  [map handbook](/docs/modules_handbook/shared/project-catalogue/area-guide-map/readMe.md)),
  which opens `BuildingDrawer` from `GET /property/academy/area-guide/map/buildings/{uuid}`
  over the still-open viewer. A panorama may also carry arrow hotspots to other panoramas;
  clicking one flies there inside the same viewer (`AreaGuidePanel::flyToAsset` fetches the
  destination's asset JSON and swaps `selectedAsset` without unmounting `AssetViewer`), and
  readers only receive arrows whose destination is published on this site and whose
  position an admin has confirmed (an automatic return arrow stays hidden until then).
  A panorama's third marker kind (2026-09-15) opens a location VIDEO — the same video asset a map
  pin plays: the viewer re-emits it as `select-video` and `AreaGuidePanel::openVideo()` reads it
  from the same authorized asset endpoint into a SECOND `AssetViewer` stacked over the panorama,
  exactly as `BuildingDrawer` stacks, so closing the video returns the reader to the view they
  were standing in. Members have no standalone video page (nor panorama page) yet; an admin gets
  the full-screen video page in a new tab instead. Readers never receive a marker whose video is
  still a draft.
  A 3D building model on the map opens what it stands for: the project drawer for a
  catalogue project, or `BuildingDrawer` (`select-building` → `openBuilding`) for a custom
  building that is not a catalogue project — the same building record a panorama marker
  can link, so one write serves both places. A panorama opens with a "little planet" intro
  and flies between linked panoramas with a blur-and-crossfade, never a black screen; linked
  images are preloaded so the switch is quick.
  Media image/video delivery retains its endpoint's audience checks, including
  administrator-only panoramas. Switching country, closing the viewer, choosing
  another media marker or disabling the capability cancels the previous asset
  request and ignores late responses.
- **Existing learning flows:** area selection, narration, chat capability,
  walk stations/questions, tutorial links and their existing storage ownership
  remain in their current components. Catalogue map overlays are additional
  map content; they do not replace station questions or corridor tutorials.
- **Validation:** focused frontend tests cover enabled/disabled wiring,
  explicit country, real-map readiness, child-before-map cleanup, marker
  loading, stale responses and project drawer events. Existing Guide URL and
  walk tests remain in the verification set. This does not claim browser,
  real-asset or production acceptance.

## How it works — Malaysia

- **One map, no levels.** [MalaysiaMap.vue](/resources/js/Components/AreaGuide/MalaysiaMap.vue)
  is a single Mapbox Standard instance. Our ILLUSTRATION is drawn on top of it:
  an opaque teal sea, the West Malaysia states as `fill-extrusion` slabs
  painted in a colour from their flag (`fill-extrusion-vertical-gradient`
  shades the side walls, which is what makes them read as extruded), and their
  names. All of it fades out between **z7.6 and z9.4**; the Klang Valley
  districts fade in from **z8.2** and the landmark pins from **z8.8**. Below
  that the reader is simply on the real map, with real building heights —
  Exchange 106 and the Twin Towers stand on their own footprints. **Those zoom
  constants ARE the feature** and are named at the top of the component.
  There is no level to be on, so there is nothing to switch.
- **Why three.js could not do this.** It has no tiles and no detail levels, so
  there is no zoom at which its scene can dissolve into real streets. "One
  continuous map" and "three.js at the top" cannot both be true. The trade was
  the low-poly charm, bought back with the painted sea and the flag-coloured
  slabs.
- **Still ONE `new Map()` per visit.** Mapbox meters a map load per
  instantiation, not per zoom or pan. The map is created once and flown around
  for the rest of the visit; every area click is a `flyTo`. Switching to Hong
  Kong unmounts it (the PR's own choice — the two guides swap with `v-if`),
  so a reader who flips countries repeatedly does pay a load per flip.
- **Content comes from the active registry, in BOTH languages**, and reaches the
  tab inside the `areaGuide` prop (`areaGuide.malaysia`). It moved out of JS
  the day the chatbot was added: the model's dossier must be assembled on the
  SERVER, or a reader could tell it what to believe about an area before
  asking a question. `config/area_guide.php` supplies that copy only in legacy
  mode; database mode uses the authored records. Both languages travel in the same payload, so the toggle
  is free — no reload, no second request, and the reader keeps their place on
  the map. Geometry stays in
  [geoMalaysia.js](/resources/js/utils/areaGuide/geoMalaysia.js). The
  registry side of Malaysia (`malaysia.js`) is explained in its own section
  below.
- **Narration is PRE-RENDERED, never synthesised on view.**
  `php artisan area-guide:narrate` reads the active registry's Malaysian areas,
  then walks every area × EN/ZH through
  [GeminiTtsClient](/app/Helpers/GeminiTtsClient.php) and stores the audio via
  `MediaService` under the `area-narration` collection, tagged
  `meta: {area, locale}`. The tab reads them back through
  [AreaNarration.php](/app/Support/AreaNarration.php) as
  `areaGuide.narration` — `{area: ['en', 'zh']}`, WHICH areas are voiced, never
  a URL: a signed GCS URL lives 15 minutes and this tab is built for long
  dwell, so the URL is minted per play by `AreaGuideController@narration`
  (a redirect to a fresh signed URL, 404 when the area has no audio; **403**
  for a reader the lock holds back — the audio IS the story the lock withholds).
  **What is read aloud is the tagline and then the story paragraphs. The
  labelled facts are NOT read** — a list read out ("Landmark, colon, Exchange
  106") is what a screen reader does, not someone showing you a neighbourhood.
  The command renders only areas the tab actually serves (`MalaysianGuideAreas`),
  so an area under a `soon` region is skipped unless `--include-soon` asks for
  it — the flag exists for rendering ahead of a region going live.
  **A row whose bucket object has gone is NOT a 404.** Signing does not check
  that the object still exists, so the player is redirected to storage, which
  then refuses it; recover with `area-guide:narrate --area=<key> --force`. The
  URL is null, and the route 404s, only when SIGNING itself fails.
  **Nothing is written back into source** — the media row IS the record, so
  re-rendering an area is not a commit and not a merge conflict. Re-runnable:
  an area that already has audio is skipped unless `--force`, which stores
  the replacement successfully before deleting the old media (file AND row).
  These remain site-local, ownerless `Media` rows tagged by area key and locale.
  A registry failure stops the command before any synthesis; playback returns
  503 when the registry is unavailable and 404 for an unknown area or absent audio.
  It **autoplays** when an area is opened, which is a deliberate exception:
  opening an area is a click, on a guide that promises to talk you through the
  neighbourhood. Three things keep that honest — a browser that blocks autoplay
  is not fought (the button is the gesture it wanted), pressing **pause** turns
  autoplay off for the rest of the visit (`sessionStorage`), and **leaving the
  landmark stops it** — as does stepping into the walk.
- **The chatbot is grounded and bounded.** `POST /property/academy/area-guide/chat` →
  `AreaGuideController@chat`, under `AiRequest::PROMPT_AREA_GUIDE_CHAT`
  ([resources/prompts/area_guide_chat.md](/resources/prompts/area_guide_chat.md)).
  The browser sends an area KEY and a question, never the copy; the controller
  builds the dossier from the active registry's Malaysian copy. The prompt lets it use general knowledge
  freely and **forbids stating any price, psf, yield, rental figure, or a
  foreign-ownership / MM2H / stamp-duty rule** — those go to an agent, because
  they go stale, differ per project and nationality, and a foreign buyer will
  quote them back at us. It runs on the **COMPANY key**, so the limits are the
  whole budget: `throttle:12,1` on the route for the burst and
  `DAILY_QUESTIONS = 60` per user in the controller for the bill. **The reader
  gate comes before all of that (2026-09-14):** `ChatRequest::authorize()` calls
  `AreaGuideViewerAccess::allowsGuide()`, which Laravel runs BEFORE the rules,
  so a refused reader never reaches the registry, never reaches the rate limiter
  and never spends a call; `failedAuthorization()` returns the panel's own
  `{answer: null, error: …}` shape at 403, and the controller repeats the check
  for any caller that skips the Form Request. A provider outage returns 503 with
  the same error shape, never a 500. The thread is
  not persisted (a conversation table here has its own privacy story) and
  clears when the reader opens another area.
- *(Phase 1 gave every area one optional YouTube field. That field is **retired**
  — see "THE AREA'S OWN VIDEO BOX IS RETIRED" below. A video now hangs on the map
  marker for the place it was shot at.)*
- **The country view has a target on it.** A continuous map lost what the old
  country LEVEL was good at: three clickable regions telling an arriving reader
  where to go. Region pins are back (one live, two `soon`), shown BELOW the
  landmark-pin threshold so the two sets hand over with never both or neither on
  screen. Clicking one flies past the crossfade to z9.7, where the districts and
  landmark pins have already appeared.
- **The camera orbits a landmark, and only a landmark.** Opening one flies down
  and then turns; the turn never starts during the descent. It belongs to the
  landmark, not the page: below **z13.4** there are no 3D buildings to turn
  around, and more than ~4km off-centre is leaving rather than adjusting, so
  both end it — and both restore **north**, because a country at a stranger's
  bearing is a reader with no compass. Any gesture stops it dead and it resumes
  3.5s after they stop, easing the pitch back first so the same shot returns.
  Two traps: `originalEvent` is what distinguishes a person's gesture from our
  own camera moves (without it the orbit stops itself on frame one), and the
  release is bound to raw `mousedown`/`touchstart`/`wheel` rather than Mapbox's
  `dragstart`, which only fires past a drag threshold — until then a per-frame
  `setBearing` fights the drag handler and panning appears broken.
- **Labels are placed, not averaged.** A state's name goes at the point furthest
  from any edge of its largest landmass, not its centroid: Terengganu is a strip
  and Selangor wraps Kuala Lumpur, so the average of either is not in the middle
  of it. Two states carry a `label_at` override because their REGION PIN sits on
  the same spot (Selangor's natural point is 4px from the KL pin at country
  zoom). Kuala Lumpur is an `enclave` and has no label until the districts.
- **Place labels are OFF below z9.** Mapbox Standard renders them above every
  slot an added layer can reach, so they cannot be painted over — the sea
  covered Thailand's land and left Hat Yai's name in the water. They come back
  with the real map, where town names are how a stranger orients.
- **The walk is a second view of the same area.** The walkable session needs
  [AreaStreetMap.vue](/resources/js/Components/AreaGuide/AreaStreetMap.vue) —
  the avatar is a custom layer of that map — and two Mapbox maps cannot share
  the screen. So for an area in `gameAreas`, MalaysiaGuide fetches the stations
  as it opens (through [useAreaWalk](/resources/js/composables/useAreaWalk.js),
  the same composable the illustrated guide uses) and, once they are in, shows
  **Walk this area · N stops** on the map card. The button mounts the street map
  OVER the continuous one (kept with `v-show` afterwards — a remount is a billed
  map load) with the HUD and the station card on top, and is the way back. The
  narration is stopped on the way in. The area's story and chat stay where they
  were.
- **A change of LANGUAGE is not a change of place (2026-09-14).** Toggling
  EN/中文 re-localizes the open area into a NEW object with the same key, and
  every watcher that used to treat that as a new place did something visible
  and wrong: re-flying the camera, stopping the orbit, rebuilding every marker,
  dropping the open station card and refetching the stations mid-walk. All of
  them are now keyed on the KEY — `activeArea?.key`, and for the pin lists a
  `pinShape()` digest (key, lat, lng, zoom, face/emoji/icon, soon) that
  deliberately EXCLUDES names — with a `relabel()` pass that writes the new
  language onto the existing pin buttons in place. A region pin whose CAMERA
  actually changed is still rebuilt, because its click handler closes over the
  object it was built from. `useAreaWalk` watches the key for the same reason,
  so a reader mid-walk stays mid-walk.
- **The toggle resets to English when it disappears.** It is gated by
  `hasTranslations` — any country, region or area with Chinese copy — so a
  reader who chose 中文 and then switched to a country without it was left with
  Chinese chrome and Chinese basemap labels over English content and **no
  control to switch back**. A watch on `hasTranslations` now sets `locale` back
  to `en` at exactly that moment.
- **The map camera is validated before Mapbox sees it (2026-09-14).**
  `countryMapProfile()` in [registry.js](/resources/js/utils/areaGuide/registry.js)
  clamps the authored `map` profile with the same rules the Manage editor's
  canvas uses: centre to ±180 / ±85, zoom and minZoom to 0–22 with zoom floored
  at minZoom, pitch 0–85, bearing ±360, and `bounds` required to be a 2×2 finite
  array in range **with sw < ne on both axes** — Mapbox does not reject inverted
  bounds, it builds a nonsense `maxBounds`. A malformed field falls back to the
  camera derived from the country's own pins; an explicit `bounds: null` still
  means "no limit". Matching the editor is the point: the server's Form Request
  does not validate bounds ORDERING, so before this the reader and the editor
  could disagree about the same saved profile. A country saved with no centre or
  zoom at all is normal — the reader frames it from its region and area pins.
- **Two camera fixes and one stacking fix, all from the same reading pass.**
  (a) A region-pin click sets a one-flush `regionFlightPending` so its own
  flight survives the panel closing the open area in answer; the `activeArea`
  watcher skips `resetView()` for exactly that flush, and closing an area any
  other way still returns to the country view. (b) A `?region=`-only deep link —
  and a region picked while the map was still loading — now flies to the region
  instead of leaving the camera on the country (`startOnRegion` plus an internal
  `regionPickedBeforeLoad`); note the link must name `?country=` too, since
  `resolveDeepLink` ignores a bare `?region=`. (c) `giveUp()` now REMOVES an
  abandoned `Map`, so a watchdog timeout or a pre-load error no longer leaves a
  billed map loading into a detached node until the tab changes.
- **The walk view is on top, and the catalogue panel gets out of its way.**
  `MalaysiaMap` takes a `covered` prop (true while the walk is up) and passes it
  to `CatalogueMapLayer` as `panelHidden`, which `v-show`s the info/toggle box
  away so its checkboxes are unreachable too — the feed, markers and models keep
  running underneath. The walk overlay also moved to `z-30` inside an `isolate`
  container, so the map's own layers stack inside that box rather than against
  the page. Together they stop the catalogue toggles painting over
  `AreaStreetMap`'s bottom-left *"Click anywhere to walk there"* hint. Accepted
  cost: the feed's error line and Retry are hidden with the panel, so a feed
  failure during a walk is silent until the reader leaves the walk.
- **The URL and the deep link are the panel's, not the guide's.** The open area
  is AreaGuidePanel's `areaKey` (MalaysiaGuide receives it as `area` and emits
  `update:area`), so `?country=malaysia&region=kl-selangor&area=cochrane`,
  `/area-guide/cochrane` and the DMAIC road's hand-off all work the same way for
  every country — see the URL bullet under Hong Kong and the UAE. Malaysia has no
  region level, so its live region (KL & Selangor) is simply written as open.
- **Failure is loud, never blank.** No token, a CDN refused by an ad blocker, a
  request black-holed (12s watchdog), a revoked / over-quota token (`error`
  before first paint) all end at the same notice, and the areas and their
  stories still open from the list. There is no three.js fallback for Malaysia:
  its content and geometry no longer match what `AreaMap3d` expects.
- **One screen, and only the prose gives way.** Both columns are exactly the
  viewport's height, so nothing is left over to become white space — which is
  what a sticky map produced, a sticky element shorter than its row leaving the
  rest of that row empty.
- **THE STORY OWNS THE RIGHT COLUMN** (2026-09-19, user). It used to sit in a
  short scroller UNDER the map, and that made it read as a **caption**: a few
  lines of grey text with a picture above them taking the eye. The reading is
  the point — the map is what the reading MOVES — so the story now gets a
  full-height column of its own (pictures, prose, the rail, the voice) and the
  map gets the whole of the other one. The corridor drives and the **chat** sit
  below it, the chat still taking every pixel after everything else because it
  is the one element whose useful height is not fixed.
  **Nothing about the scroll → camera path changed with the move**, which is why
  the move cost nothing: whichever chapter is nearest the middle of the scroller
  is still the one the map has flown to, and the rail still jumps by scrolling.
  The one visible consequence is that **the rail turned horizontal**: down the
  side of a short box it read as an index, across the top of a tall column it
  reads as progress through a story, which is what it is.
- **THE STORY IS CHAPTERS, AND SCROLLING THEM DRIVES THE MAP** (2026-09-18,
  user: "make it very very interactive to elaborate the story OF THE AREA").
  One paragraph IS one chapter. The map stays put above a short scroller, and
  whichever chapter is nearest the MIDDLE of that scroller is the one the map
  has flown to; the current chapter is set darker and larger while the rest fade
  back, and a vertical rail of dots says how long the story is and jumps.
  - **One paragraph per chapter is the whole contract.** The reader's `story` is
    per-locale, the cameras in `profile.chapters` are not, and they meet on the
    INDEX — no second structure to fall out of step. A locale with fewer
    paragraphs simply has fewer chapters, never a padded one with no words.
  - **A chapter with no `center` does not move the map.** That is what makes a
    story that is only prose behave exactly as it did before this existed, with
    no migration: today's two-paragraph areas became two cameraless chapters.
  - **Nearest-to-the-centre, NOT `IntersectionObserver`**
    ([useStoryChapters.js](/resources/js/composables/useStoryChapters.js)). IO
    answers "is this inside the root by more than N%", which in a scroller a few
    hundred pixels tall is true for two chapters at once for most of the scroll
    — so the answer flickers between them at exactly the moment the map would be
    flying. Nearest-to-the-centre has one answer at every scroll position by
    construction. Measured on `requestAnimationFrame`, because a trackpad fires
    scroll far faster than the screen refreshes and the map is the expensive
    consumer.
  - ⚠️ **Two edge cases that are load-bearing.** The LAST chapter can never
    reach the middle of a scroller it does not fill, so at the bottom of the
    scroll the last chapter IS the answer — but that rule is guarded on the box
    having a height and something to scroll, because an unlaid-out scroller
    reports 0 for both and `0 + 0 >= -2` is true: without the guard the story
    opens on its LAST chapter for one frame and the map jumps to the end of the
    area before the reader has read a word.
  - The rail JUMPS by scrolling, never by flying directly. The scroll is the
    only path to the camera, so the rail and a hand cannot disagree.
  - ⚠️ **A jump lands on the chapter's TOP, and used to CENTRE it** (fixed
    2026-09-19, user: "the view is no good… make sure the chapter view is start
    from the top"). Centring computes `offsetTop - (boxHeight - chapterHeight)/2`,
    which was fine while a chapter was a paragraph — and wrong the moment
    chapters gained pictures, because a chapter with a picture is TALLER than
    this short scroller, so that term goes negative and the jump overshoots by
    half the overflow. Every auto-advance landed on a cropped picture and a
    paragraph starting mid-sentence. A chapter is a scene that begins with its
    picture; you start it at the beginning.
  - ⚠️ **THE READING LINE MOVED TO THE TOP, and that is the other half of the
    same fix.** Top-aligned jumps broke the old "nearest the middle of the
    viewport" rule outright: a chapter is ~330px tall in a ~700px column, so the
    centre line sits 350px down — already PAST the chapter the jump had just
    arrived at. Every auto-advance therefore **skipped a chapter**: its voice
    never played, the rail moved two, and the map flew somewhere nobody had read
    about (user: "when the chapter 1 is ended, it direct turn to chapter 2").
    `readingLine()` now puts it `min(96px, 40% of the box)` below the top, which
    is also just what reading is — you start at the top and work down.
    - **A constant, not a fraction**, because chapters differ enormously: one
      with a picture is ~330px and one that is four lines of prose is ~110px.
      Any proportional line generous enough for the tall one overshoots the
      short one completely — the same bug again, for the chapters with no
      pictures.
    - `measure()` asks **containment first** (the chapter the line is inside),
      falling back to nearest for the gaps between chapters and the space at
      either end. Chapters do not overlap, so at most one can contain a point:
      the single-answer property that made this worth choosing over
      `IntersectionObserver` is intact.
    - The **bottom-of-scroll** rule is separate and still correct — and it is
      why a regression test for this needs SIX chapters. With three, the scroll
      bottoms out before the second jump and that rule answers instead, hiding
      the bug.
  - ⚠️⚠️ **`offsetTop` IS NOT MEASURED FROM THE SCROLLER, and this was the real
    one.** It is measured from `offsetParent` — the nearest POSITIONED ancestor
    — and nothing between a chapter and the page is positioned: not the
    scroller, not the story card, not the column, not the grid. So every
    `offsetTop` came back inflated by the page header, the card's padding, the
    title row and the player. In `measure()` the damage was **invisible**,
    because the same inflation sits in both the value compared and the
    `scrollTop` just set from it — the two errors cancelled and the rail named
    the right chapter. On screen it was not invisible at all: `scrollTo` fed
    that inflated number straight to the browser, so the view landed a whole
    header lower than the chapter it claimed to be showing (user: "chapter 2 is
    … But it turn to chapter 3"). `geometry()` takes a rect difference against
    the scroller instead, which has no such assumption — and is immune to the
    next person adding `relative` to any wrapper.
    **The fixtures model the trap:** `layOut()` stubs `getBoundingClientRect`
    AND an `offsetTop` inflated by a `chrome` argument, so anything that goes
    back to reading `offsetTop` fails the test the same way it fails on screen
    rather than quietly reading a zero.
- **THE VOICE READS A CHAPTER AND THEN TURNS THE PAGE** (2026-09-19, user: "AI
  voice that reads the chapters and auto-advances"). Narration is rendered **one
  audio file per chapter**, not one per area, and a chapter finishing SCROLLS to
  the next one — which is what moves the map, the rail and the next audio. One
  path, so the voice and a reader's own scrolling can never disagree; at the last
  chapter it simply stops rather than looping.
  - **Why per-chapter, and not a single file with timestamps.** One long file
    cannot say where one chapter ends and the next begins, so the voice could
    never be the thing that advances the story — which is the whole feature.
  - It **reuses the existing pipeline unchanged** (user's choice): still
    pre-rendered by `area-guide:narrate` into GCS through `MediaService`, still
    ownerless media keyed by `meta`, still nothing written back into the content.
    `meta` simply gained a `chapter`, and `chapters()` returns index => text
    preserving the ORIGINAL indexes so a skipped paragraph cannot shift the rest.
  - ⚠️ **If the render fails with an HTTP error from Google, check the KEY
    first.** `GeminiTtsClient` read `.env` only until 2026-09-19 and ignored the
    key saved on Manage → AI Providers — so the page said *Verified / Connected*
    while the voice sent an empty key. Fixed (it resolves the UI key first now,
    like every other Gemini consumer), and pinned by `GeminiTtsClientTest`; the
    full story is in the [AI handbook](/docs/modules_handbook/shared/ai/readMe.md)'s
    text-to-speech section.
  - ⚠️ **Old whole-area audio is deliberately invisible, not remapped.**
    `AreaNarration::available()` skips any row without an integer `chapter`
    rather than treating it as chapter 0, because it reads the OLD script — the
    one that opened with the `tagline` this guide no longer has. Re-render with
    `area-guide:narrate --area=<key> --force` and it returns as real chapters.
    **Nothing detects stale audio**: an edited story keeps the old voice until
    someone runs that command.
  - `available()` now returns `{area: {locale: [chapterIndexes]}}`, and the panel
    offers a play button **only for a chapter the server actually has** — a
    chapter with no audio falls silent rather than 404ing at the reader. This is
    also why **nothing appears at all on a machine with no rendered narration**:
    the control is absent, not broken. Run `area-guide:narrate` to get one.
  - **It moved into the story column and became a PLAYER, not a button**
    (2026-09-19, user). It used to be a bare circle beside the area's name in the
    map's header — which was where the story used to be too, so when the story
    moved the control was left a column away from the words it reads. It now
    sits between the story's title and its prose, and carries a **track** (where
    the voice is, to the pixel — a ring around a 36px button could only answer
    that to within about a fifth), the **elapsed / total time**, a **status line**
    naming the chapter, and **four bars that move** while it speaks. The bars and
    the track are `aria-hidden`; everything they convey is said in words by the
    status line, and `prefers-reduced-motion` stops the motion while keeping both.
  - ⚠️ **This is why the story block is mounted with `v-show`, never `v-if`.**
    The player's `<audio>` element has to EXIST before the reader's first click,
    because that click is what marks it user-activated for autoplay. Built after
    the click that opened the area, it has no gesture left to carry and the guide
    sits silent on the one area most readers open.
- **A CHAPTER IS A SCENE: PICTURES, THEN WORDS, WITH THE MAP ALREADY THERE**
  (2026-09-19). Each chapter carries an ordered set of images, cross-fading, with
  dots to step them by hand on the current chapter. The pictures come FIRST in
  the chapter, because a reader looks before they read.
  - **The pictures come from the AVATAR LIBRARY**, not from a per-chapter upload.
    That was the deciding reason: one place an admin puts an image and many
    places it can be used, instead of a second uploader whose files nothing else
    can reach.
  - ⚠️ **A reader never receives an avatar uuid.** The stored value is a uuid of
    `area_guide_avatars`; `AreaGuideRegistry::resolveChapterImages()` swaps them
    for `image_urls` addressed by **area + chapter + index** and drops the uuids
    from the reader's copy entirely. The library's own image route is
    admin-gated, so a reader holding uuids could otherwise walk the whole
    library — and a browser should not be building URLs out of identifiers.
  - It is **ONE query for the whole tree**, called after the tree is built rather
    than inside `present()` (which runs per record, and would be a query per area
    for a handful of pictures). The media uuid becomes each URL's `?v=`, so a
    replaced picture is never served from a cache holding the old one.
- **WHAT A READER DID IN HERE REACHES THE LEAD'S TRAIL** (2026-09-19, user).
  Five acts — area **opened**, story **read**, **360 view** opened, **building**
  opened, **video** played — posted by
  [useAreaGuideActivity.js](/resources/js/composables/useAreaGuideActivity.js) to
  `POST /property/academy/area-guide/activity`, and shown on Leads → Show and
  `LeadDetailModal` under their own **Area Guide** filter chip.
  - **An endpoint exists because nothing here is a page load.** The whole guide
    is one screen; a chapter scrolled past or a drawer opened is invisible to the
    server otherwise.
  - ⚠️ **ACTS, NEVER TIME** (the user chose this knowing the cost). There is no
    heartbeat and nothing accumulates seconds, so *"how long did they look at
    it"* has no answer here — do not imply otherwise in a report or a UI label.
  - The act is a **name**, not a type number (`ActivityRequest::ACTS`). This is
    the one trail endpoint a READER can write through; a free-numbered type would
    let one post "Paid for a membership" onto their own lead.
  - It can never break the page: every failure is swallowed, each act is said
    once per visit, `keepalive: true` so a report fired as the reader clicks away
    still leaves, and an area that is not in the guide is answered
    `{"recorded": false}` rather than 422'd at someone mid-scroll.
  - The `story` row keeps how **far** they got — "reached chapter 3 of 6" —
    because coalescing folds later reports into the FIRST row. Full rules in the
    [Activity Log handbook](/docs/modules_handbook/shared/activity-log/readMe.md).
- **AN AREA IS NAMED, NOT PINNED** (2026-09-19, user: "that design is ugly…
  actually no need icon… can totally remove the icon"). An area used to be a
  white disc carrying a pictogram, with a stem and a contact shadow — a TOKEN,
  which is the right shape for something you are meant to click and the wrong
  one for a district. Three things had already taken over the jobs that disc was
  doing: the **traced boundary** says where the area is, the **building markers**
  say what is worth clicking, and the story column names the area in its header.
  All the disc added was a fourth thing competing for the eye, in the middle of
  the markers that actually do something.
  What replaced it is what a real map does: **type**. Tracked-out capitals with
  a white halo, always on rather than waiting for a hover — a place name that
  has to be hovered for is not a place name. It does not lift on hover either:
  lifting is what a token does, and type that jumps off the ground reads as a bug.
  - ⚠️ **On a pitched 3D map, type on the ground is not enough, and the first
    attempt proved it** (user: "cannot, it will be blocked"). Laid flat, the name
    lands on whatever tower happens to be standing there — the buildings are the
    tallest thing on screen and the name is the shortest. So the name is
    **hoisted on a leader line** (`.ag-pin-lead`) that drops back to an anchor
    dot on the area's own point, and the marker is anchored by its BOTTOM — the
    dot — so the thread lands where the area actually is. The label clears the
    skyline; the thread is what keeps it attached to a place instead of floating
    over the city. This is the standard answer for labelling a pitched city view.
  - **The halo is EIGHT hard offsets, not a blur.** Hoisted above the roofline
    the type can land on pale sky one second and a dark tower face the next, and
    no single colour survives both — so it keeps one colour and carries its own
    background. The corners matter as much as the sides, or the diagonals of A
    and S bleed into what is behind them. A blurred shadow instead grows a grey
    cloud around small tracked-out capitals and smears them.
  - **The open area is told apart by its THREAD going bright, not by the name
    changing colour** — the name has to stay legible either way.
  - **Region pins keep their token**, and that is deliberate rather than an
    oversight: at country zoom they are the only thing to click, so they have to
    read as targets. The two are never on screen together — `syncMarkers()` hands
    over at `PINS_IN` — so the difference reads as "these are for clicking, those
    are place names" rather than as an inconsistency.
  - `face()` and `GLYPHS` survive for the region pins; nothing here is dead code.
- **The traced boundary says where the area STOPS.** An admin traces a ring on a
  map in the editor and it is stored open in `profile.boundary`; every renderer
  closes it, so a ring saved closed and one saved open draw identically. The
  reader gets **three** layers under `slot: 'bottom'`: a pale brand fill, a
  crisp line, and — the one that does the work — a MASK, one polygon covering
  the world with the area punched out as a hole. A fill and a line say "this
  shape exists"; only the mask says "and everything else is not it", which is
  the entire reason a reader looking at a street map of KL could not otherwise
  tell where Bangsar South ends. Outside is quietened, never hidden: those
  streets are how a reader places the area. No boundary = the map exactly as it
  was, and an area without one costs no style work at all.
- **`tagline` and `facts` are GONE** (2026-09-18, user). Removed from the admin
  form, from the reader, from `AreaGuideContent::TRANSLATION_FIELDS`, and
  cleared out of the stored rows. Three things went with them and are worth
  knowing: the area LIST rows lost their one-line description, the narration
  script no longer opens with the tagline, and the AI assistant's dossier no
  longer carries the labelled facts.
- **The catalogue layer's panel is an EDITOR's instrument** (2026-09-18, user:
  "it blocked the user view"). Its layer switches — *Uploaded building models*,
  *Basemap 3D objects* and the note about clipping — render only when
  `CatalogueMapLayer` is mounted with `editor`, and for a reader the whole box
  is hidden unless it carries something they need: why the map looks empty
  (`zoomIn`), that more exists than is drawn (`truncated`), a failed feed, or a
  model that would not load. The idle "Loading map content… / Blue dots open
  published project details" state is gone for readers, because it sat over the
  bottom-left of the map saying nothing they could act on. Failure stays loud.
- **ONE COLOUR PER MARKER KIND** (2026-09-18, user), defined once as
  `MARKER_TONES` in [panoramaIcons.js](/resources/js/utils/areaGuide/panoramaIcons.js)
  and used by the map and by the markers inside a panorama, so a reader learns
  each sign once:

  | Kind | Colour | Why |
  |---|---|---|
  | 360° panorama | brand blue | the flagship and the anchor; every fly-to arrow inside a panorama is already this |
  | Video | violet | still media you press play on, one step from the panorama so the two read as a pair. ⚠️ **Since 2026-09-19 a video is not a disc at all** — see below; its violet moved to the play badge |
  | Catalogue project | emerald | not media — a place we actually sell, and green is what "available" looks like on a map |
  | Custom building | dark navy | our own written page; a value step inside the brand family, not a fifth hue competing |
  | *(being placed)* | amber | RESERVED, and it means UNFINISHED. No kind may take it. |

- **A VIDEO MARKER IS A FACE, NOT A THUMBNAIL** (2026-09-19, user: "make the
  video avatar rounded-full… make it interactive and interesting, make people
  click it"). It was a rectangle with a "Play" bar stuck underneath — the shape
  of a card in a list, which is the one thing on a map that is not card-shaped.
  It is now a **round avatar** (`.ag-video-pin` in
  [CatalogueMapLayer.vue](/resources/js/Components/AreaGuide/CatalogueMapLayer.vue)),
  because a circle reads as a PERSON, and a person on a map reads as "someone is
  going to tell you about this place" — which is exactly what pressing it does.
  - **A play badge** on the corner keeps the promise explicit: a face alone says
    "a person", a face with a badge says "a person who is about to tell you
    something".
  - **A breathing halo** is the only thing on this map that MOVES, so the eye
    finds it without anything shouting. A slow 2.6s breath, not a ping — it runs
    for as long as the map is open, and anything sharper becomes something the
    reader wants to make stop.
  - **It WAVES every three seconds** (user: 摇一下摇一下). Two leans and a
    settle over 0.8s, then 2.2s of stillness — the pause is what makes the next
    one read as a wave rather than a permanent jitter. It pivots near the BOTTOM
    of the disc (`transform-origin: 50% 88%`), because a circle turned about its
    own centre is a circle that has not moved; the off-centre pivot is the only
    reason the motion is visible at all. Several markers are offset from each
    other, since avatars waving in perfect unison read as a machine.
  - ⚠️ **The wave lives on a WRAPPER, never on the face.** The wave and the
    hover lift are both `transform`, and a running animation overrides a plain
    declaration — on one element the hover would simply never apply.
    `.ag-video-turn` carries the wave, `.ag-video-face` inside it carries the
    lift. On hover the wave is REMOVED rather than paused, because
    `animation-play-state: paused` freezes it at whatever angle it had reached
    and would leave the avatar sitting crooked for as long as it was hovered.
  - **The name arrives on hover**, floating beside the face on transform and
    opacity alone. Always-on names would be a column of overlapping labels the
    moment two videos are near each other; growing the marker to fit the text
    would animate layout on a node Mapbox repositions every frame, and a marker
    that widens on hover shoves its neighbours right where the reader is aiming.
  - **A draft is an AMBER RING, never an amber face.** Recolouring a photograph
    of a person is not a status, it is a broken image.
  - ⚠️ **Its `<style>` block is UNSCOPED, and has to be.** These markers are
    built with `document.createElement` and handed to Mapbox, so they never
    carry Vue's scope attribute and a `scoped` block would match none of them.
    `MalaysiaMap`'s pin styles are unscoped for exactly the same reason.
  - ⚠️ **The face carries a BACKGROUND** because these avatars are cut-out
    portraits on transparent ground. Without one the circle's corners show the
    map straight through the avatar's shoulders, which reads as a rendering
    fault rather than a face.
- **THE VIDEO PLAYS AT THE TOP OF THE GUIDE, NOT IN A MODAL** (2026-09-19,
  user). Pressing a video marker opens a **stage** above the map and the story
  (`[data-video-stage]` in `MalaysiaGuide`), with the title, the player and a
  close button. A modal has to be dismissed before you can look at the thing it
  is about, and the whole point of a location video is watching it beside the
  place it was shot at — so the stage stays open while the reader carries on
  with the map and the story.
  - ⚠️ **Opening it SCROLLS THE PAGE TO IT** (user: "otherwise if in mobile view
    user dont know top side has the video"). On a phone the map fills the
    screen, so a video appearing above it is a thing that happened off-screen —
    and pressing a marker with nothing visibly changing is indistinguishable
    from pressing a marker that does not work.
  - ⚠️ **The guide's height becomes conditional.** With no video it is ONE
    SCREEN (`lg:h-[calc(100vh-9rem)]`), which is what stops a short column
    leaving white space. A stage needs room the viewport does not have, so the
    lock becomes a MINIMUM and the page scrolls — which is also what lets the
    stage scroll itself into view.
  - **A video opened from INSIDE a panorama keeps its modal.** That one stacks
    over the panorama, so closing it leaves the reader standing exactly where
    they were; moving it to the top would mean leaving the panorama to watch it.
  - **It closes when the reader leaves the area**, by a `watch` on the open area
    key rather than a hook in `selectArea()` — clicking an area in the list is
    only one of the ways that key changes, and a deep link, a prop change and
    the DMAIC road's hand-off all bypass it. A film of somewhere else is not a
    thing to leave lying at the top of the next area's guide.

  **Colour is the SECOND channel, never the only one.** Each kind also has its
  own glyph and names itself in its `aria-label`, so nothing is lost to a
  colour-blind reader or a screen reader; the pairing is what makes the map
  readable at a glance.
  ⚠️ **Tailwind only scanned `.vue` until this change.** These markers are built
  as HTML STRINGS in `utils/areaGuide/*.js`, so their classes were generated
  only by the coincidence of appearing in some `.vue` file too — and
  `[animation-duration:9s]` on the arrows' orbit ring was never generated at
  all, so it spun at the default 1s. `resources/css/app.css` now carries
  `@source "../js/**/*.js"`. Any future marker colour added in a `.js` helper
  depends on that line.
- **A model's marker says WHICH KIND it is** (2026-09-18, user). An uploaded 3D
  model opens either a catalogue project's drawer or an admin-written building
  page, and until now both markers were the same single character — the only way
  to find out which was to click. They now carry different glyphs from
  [panoramaIcons.js](/resources/js/utils/areaGuide/panoramaIcons.js): **two
  towers** for a catalogue project, **one tower with a bookmark** for a custom
  building (the ribbon meaning "we wrote this one up"). A silhouette, not a badge
  or a tint, because at 20px nothing else survives — and the marker's
  `aria-label` names the kind in words, since a screen reader cannot read a
  drawing.
- **The look pad makes the 3D discoverable** ([MapLookPad.vue](/resources/js/Components/AreaGuide/MapLookPad.vue),
  2026-09-18, user: "make them like play game drag it to control the view"). Mapbox
  turns and tilts only with ctrl held, the right mouse button, or a two-finger
  twist — gestures nobody finds, so a reader drags, the map slides sideways, and
  they conclude the towers are a picture. The pad is a **look** control, never a
  move one: left/right is bearing, up/down is pitch, and it touches neither the
  centre nor the zoom, so it cannot lose the reader. Three ways in — press and
  drag it like a stick (speed from the distance off centre, held until let go),
  tap an arrow for one measured step, or arrow keys once it has focus. The loop
  is **time-based and capped at 50ms a frame**, so a 144Hz monitor turns at the
  same speed as a 60Hz one and a backgrounded tab cannot come back and fling the
  camera. There is deliberately **no reset in the middle** — that is where a hand
  grabs a stick, and Mapbox's own compass already returns to north.
- **The area TAGLINE is not printed above the map** (2026-09-18, user). With an
  area open it repeated, in a thinner voice, what the story's first paragraph
  says directly underneath, and cost a row on a layout whose whole claim is one
  screen. The area LIST still prints its region or country blurb, which
  describes something the reader cannot see yet.
- ⚠️ **The Mapbox wordmark and attribution stay.** Mapbox's Terms of Service
  require both on every map; removing them needs a plan that grants it, not a
  CSS rule. The attribution is already mounted `compact: true` (the ⓘ, expanded
  on demand), which is the smallest form the terms allow.
- **Scroll zooms on a mouse, ctrl-scroll on touch.** `cooperativeGestures`
  follows `(pointer: fine)`. The guard exists for touch, where the map is full
  width and would swallow the scroll reaching the story under it; on a pointer
  device the map is one column of two and every map a reader has used zooms on a
  plain scroll.

## Legacy source only — two files for Malaysia

The following is the retained legacy/import-baseline workflow, not the database
authoring workflow. In database mode, edit the hierarchy in Manage → Portal →
Area Guide; do not update these files to change live registry content.

Malaysia is the one country described in TWO places, and the rule is that
each place owns different fields:

| | [malaysia.js](/resources/js/utils/areaGuide/malaysia.js) — the **registry** | [config/area_guide.php](/config/area_guide.php) — the **copy** |
|---|---|---|
| Holds | `key`, `name`, `emoji`, `lat`, `lng`, `zoom` per area; the regions (with `soon`); the map geometry reference | Everything the reader sees and hears: `en`/`zh` name, story; `badge` (with its `zh` label); `face`/`icon` for the pin; `zoom` for the continuous map; the `states` colour table; region blurbs. ⚠️ The file still CONTAINS `tagline`, `facts` and `video` for every area; all three are **retired** (2026-09-18/19) and nothing reads them any more — they are dead weight in the baseline, not fields to maintain |
| Read by | **The runtime no longer reads it** (see the note below): only `registry.js`'s `resolveCountries` fallback, for an explicitly legacy or older unversioned host that supplies no `countries` array, and the PHP drift guards, which read the file as plain text | The guide (as the `areaGuide.malaysia` prop), the narration command, the chatbot's dossier, `ChatRequest`'s allowed keys — **in legacy mode only**; database mode reads the authored records |
| Must not hold | Prose — a second copy of a story is a second thing to forget | — |

> **Who actually reads `malaysia.js` today (corrected 2026-09-14).** The row
> above used to say the deep link walked `COUNTRIES`, the admin pick-list read
> `areaOptions.js` and the road's Analyze card read the bundle "without a
> request". None of that is the current runtime: `resolveDeepLink` walks
> `countries.value` — the server-supplied registry — and the pick-lists and the
> road use [`useAreaGuideRegistry()`](/resources/js/composables/useAreaGuideRegistry.js)
> over the `areaGuideCountries` / `areaGuideSource` page props;
> `areaOptions.js`'s legacy `AREA_OPTIONS` / `areaByKey` exports have no
> non-test importers. The bundled file is read only by `resolveCountries`'s
> legacy fallback and by the PHP drift guards (`AreaGuideContentTest`,
> `GrowthAreasConfigTest`, `AreaStationFinderTest`), which `file_get_contents`
> it. The same correction was made in `malaysia.js`'s and `countries.js`'s own
> header comments.

**They must agree.** `AreaGuideContentTest::test_the_registry_names_the_same_areas_at_the_same_coordinates`
reads `malaysia.js` as a plain file (the way `AreaStationFinderTest` already
reads Cochrane's centre) and fails the build when the two name different keys
or put one at different coordinates. So **an area is added to both or to
neither**: registry line in `malaysia.js` (one line), full entry in the config
(both languages), and — if it is to be walked — `config/area_guide_game.php`.
`countries.js` marks the country with `copy: 'config/area_guide.php'`, which
is what tells `areaGuide.test.js` to check its areas as a registry and leave
the prose to phpunit.

*Original reason for the split (superseded by registry props):* PHP cannot be imported by the Vue bundle, and the
consumers in the left column need the keys at import time — the admin form,
the road card and the deep link all render before any request could fetch
them. The alternative (threading the config through props into five pages)
was more machinery than a one-line registry with a guard.

## How it works — Hong Kong and the UAE rendering

*This is the ILLUSTRATED renderer, which those two countries use **only while the
catalogue map is off** (see `isContinuous` above). With `AREA_GUIDE_MAP_ENABLED`
on, they render on the continuous map like everyone else; the URL, deep-link and
walk rules below hold for both renderers.*

- **Legacy content is static JS, one file per country.** Its baseline stories live in
  [hongKong.js](/resources/js/utils/areaGuide/hongKong.js) and
  [uae.js](/resources/js/utils/areaGuide/uae.js);
  [countries.js](/resources/js/utils/areaGuide/countries.js) is assembly only
  (`COUNTRIES` + the shared `BADGE_TONES`, still used by Malaysia's panel) and
  documents the shared shape. The explicit import snapshots this content;
  database mode reads the shared records instead. The rendering described below
  is preserved independently of which content source is active.
- **Geometry is pre-simplified offline — split the same way.**
  [geoMalaysia.js](/resources/js/utils/areaGuide/geoMalaysia.js),
  [geoHongKong.js](/resources/js/utils/areaGuide/geoHongKong.js) and
  [geoUae.js](/resources/js/utils/areaGuide/geoUae.js) hold the low-poly maps
  (~32KB total) as plain `{ name, rings }` outer boundaries — holes and islets
  dropped, Douglas-Peucker simplified, so the browser does zero geo work;
  [geo.js](/resources/js/utils/areaGuide/geo.js) merges them into the `GEO`
  keys the content files reference. Sources + licence: geoBoundaries MYS/ARE
  ADM1 and OSM relation 913110 (ODbL 1.0 — OpenStreetMap contributors), DOSM
  data-open districts. To regenerate: download the source GeoJSON, run a
  Douglas-Peucker pass (tolerance ≈ 0.004–0.018° by map size, drop rings with
  area < ~0.0002–0.004 deg², round to 3–4 decimals) and paste the arrays back.
- **The 3D scene is one reusable component.**
  [AreaMap3d.vue](/resources/js/Components/AreaGuide/AreaMap3d.vue) extrudes
  the rings into a cartoon island scene (three.js `ExtrudeGeometry`) and draws
  each pin as a cream disc + emoji sprite. Props: `lands`, `pins`, `highlight`
  (dim all but the named lands — the UAE region views), `fit` (`'lands'`
  frames geometry, `'pins'` frames markers — Abu Dhabi's emirate dwarfs its
  city), `activePin`. Emits `select`. three.js objects live in plain `let`s
  (never `ref` — the VrPanoramaViewer deep-reactivity trap), the renderer is
  created in `onMounted` and disposed (with `forceContextLoss`) in
  `onBeforeUnmount`, and the whole component is `defineAsyncComponent`-lazy so
  three.js stays out of the app bundle. If the WebGL context cannot be created
  the component catches it, `devWarn`s and renders a plain notice in place of
  the canvas — the side panel's stories still work.
- **The area level is the real map, and it costs nothing new.**
  [AreaStreetMap.vue](/resources/js/Components/AreaGuide/AreaStreetMap.vue)
  reuses the Mapbox runtime the public project pages already load —
  [utils/mapboxLoader.js](/resources/js/utils/mapboxLoader.js) injects
  Mapbox GL from its CDN on demand, and the token arrives in the `areaGuide`
  prop (`config('services.mapbox.token')`, `MAPBOX_TOKEN` — a PUBLIC `pk.…`
  token, restricted by URL in the Mapbox dashboard rather than treated as a
  secret). Same `mapbox://styles/mapbox/standard` style as
  [LocationMap.vue](/resources/js/Components/ProjectDetail/LocationMap.vue),
  deliberately diverging on `pitch: 55`, a fixed `bearing: -18`, and
  `cooperativeGestures: !finePointer()` — a mouse or trackpad zooms on a plain
  scroll, and only a touch device needs two fingers (see *Scroll zooms on a
  mouse* above; the guard exists to stop a full-width map swallowing the scroll
  that is trying to reach the story under it). Each area may carry a `zoom` (≈15+ for a block,
  13 for a township, 12 for a planning region); it defaults to 15.2.
- **One map load per visit — the reason both maps use `v-show`, never `v-if`.**
  Mapbox meters a **map load per `new Map()`**, and this is a *browsing*
  surface: a reader hops between areas and flips Streets/Illustrated freely.
  So both maps stay mounted and swap visibility. Two consequences to preserve:
  the street map is bound to the **last opened area** (not `area`, which goes
  null between areas) and flies itself with `flyTo` when those coordinates
  change; and each map is told when it is hidden — `AreaMap3d` stops drawing
  (`active`), `AreaStreetMap` calls `map.resize()` on the way back, since a
  `display:none` container measures 0×0. Mapbox only downloads once a reader
  actually opens an area (`v-if="lastArea"` gates the first mount), and
  `onBeforeUnmount` still calls `map.remove()`.
- **Failure is loud, never blank.** No token (the Streets control is hidden
  and the area level keeps the illustrated map), a CDN refused by an ad blocker
  or proxy (an `error` listener, added to the shared `loadMapbox` as an
  optional second argument), a request black-holed (a 12s watchdog), a
  revoked / URL-restricted / over-quota token (`map.on('error')` before first
  paint), and a synchronous throw all end at the same notice.
- **Every area has its own URL (2026-09-08).** The panel writes what is open
  back into the address bar — `?tab=area-guide&country=malaysia&region=kl-selangor&area=cochrane`,
  the same shape the DMAIC road's A card builds with `areaGuideHref()` — so a
  reader who finds an area worth sharing can copy the address, bookmark it or
  paste it to someone and get that area back. `resolveDeepLink` reads those
  params at setup, before the first paint. There is also a short URL per
  location, **`/area-guide/cochrane`**, which redirects to the canonical
  academy one. Two rules hold this together:
    - **`history.replaceState`, never a push and never an Inertia visit.** It
      mirrors [ShowTabs](/resources/js/Components/ShowTabs.vue)' `?tab=` sync.
      A real visit would remount the panel on every click — a BILLED Mapbox
      map load each time — and a pushed entry would put the browser's Back
      button in competition with the panel's own "All areas" / region chips.
    - **The short route forwards the key.** `/area-guide/{area}` remains a
      redirect, while the panel's finder walks the active server-supplied
      countries and regions for the slug,
      falling back to the country map for a key it cannot place.
- **The session, when an area has one.** The avatar is simply on the street
  map whenever an area in `gameAreas` is open — a CUSTOM LAYER on this same
  map, never a second one. State lives in
  [useAreaWalk](/resources/js/composables/useAreaWalk.js); everything else is
  in [its own doc](/docs/modules_handbook/main/area-guide/walkable-session/readMe.md).
- **Panel state is four refs, plus the language.** [AreaGuidePanel.vue](/resources/js/Components/AreaGuide/AreaGuidePanel.vue)
  tracks country / region / area, which map an open area shows, and `locale` —
  **not Malaysia-only**: the toggle is gated by `hasTranslations`, so it appears
  for ANY country, region or area carrying Chinese copy, and resets to English
  the moment the reader moves to a country without it. Country view pins its regions (Penang and Johor render as
  dimmed *Soon* pins and disabled chips); picking a region swaps the map to
  that region's geometry with its areas pinned; picking an area opens the
  story panel **and lands on the street map** — a `Streets | Illustrated`
  control on the map card flips between the two, and it exists only at the
  area level. A country whose single region is already built (Hong Kong)
  skips the region level; one whose only region is still `soon` does not.

## Five-day trainees (2026-09-02)
`CoursesController::areaGuideState()` also locks the guide for a **five-day trainee** until their
M card is complete (`TrainingAccess::verdict($user, 'area_guide')`) — `areaGuide.locked` plus
`reason / day / cardHref`, rendered by `AreaGuideComingSoon.vue`, Mapbox token, stories and
narration withheld. Checked only when the global `area_guide_locked` flag is not already showing;
non-trainees and admins are untouched.

## Content integrity is tested on both sides

Phase 1 adds [AreaGuideRegistryTest](/tests/Unit/AreaGuide/AreaGuideRegistryTest.php)
for separate site/shared connections, import and registry behavior,
[AreaGuideRegistryConsumersTest](/tests/Unit/AreaGuide/AreaGuideRegistryConsumersTest.php)
for locked/unavailable content and capability boundaries, and
[AreaGuideContentTest](/tests/Feature/Manage/AreaGuideContentTest.php) for admin
authoring. Frontend registry tests cover authoritative empty responses, dynamic
countries and links. The legacy content checks below remain import-baseline
checks; they do not validate every later admin edit. Automated tests do not
constitute browser or GPU acceptance for the new map configuration.

Hand-edited prose and hand-typed coordinates fail SILENTLY on screen: a
swapped lat/lng puts a landmark in the sea, a missing Chinese story renders a
blank panel to the readers it was written for, an unknown badge tone renders
an unstyled pill.

- Malaysia's copy → [AreaGuideContentTest.php](/tests/Feature/Main/Portal/AreaGuide/AreaGuideContentTest.php)
  (phpunit): unique keys, both languages complete, story paragraphs long
  enough to be real, known badge tones, every area inside the Klang Valley,
  usable zoom, every painted state named in both languages with a hex colour,
  **the registry drift guard**, the road's Analyze-stage facts (Cochrane +
  Maluri exist with one badge, TRX carries the Monash campus), and that the
  hub ships both languages plus the narration index.
- Malaysia's registry + Hong Kong + UAE → [areaGuide.test.js](/resources/js/utils/areaGuide/areaGuide.test.js)
  (vitest): keys, coordinates landing on their own map, highlights naming
  lands that exist; the prose checks for the two countries that still carry
  prose. [roadAreas.test.js](/resources/js/utils/areaGuide/roadAreas.test.js)
  pins the registry side of the road's deep links.

## Related files

- Catalogue reader: [CatalogueMapLayer.vue](/resources/js/Components/AreaGuide/CatalogueMapLayer.vue),
  [ProjectDrawer.vue](/resources/js/Components/AreaGuide/ProjectDrawer.vue),
  [AssetViewer.vue](/resources/js/Components/AreaGuide/AssetViewer.vue),
  [shared Project Detail contract](/docs/modules_handbook/shared/project-detail/readMe.md),
  [AreaGuideMapController.php](/app/Http/Controllers/Main/Portal/AreaGuideMapController.php).
- Reader integration tests: [AreaGuideMapIntegration.test.js](/resources/js/Components/AreaGuide/AreaGuideMapIntegration.test.js),
  [CountryMap.test.js](/resources/js/Components/AreaGuide/CountryMap.test.js).

**Backend**
- [CoursesController.php](/app/Http/Controllers/Main/Portal/CoursesController.php) — the Learning Hub's `index()` hosts the guide: authorized full `countries`, compatible `malaysia`, `gameAreas`, `chatAreas`, narration, source/unavailable state and existing locks.
- [AreaGuideRegistry.php](/src/AreaGuide/Services/AreaGuideRegistry.php) · [config/area_guide_content.php](/config/area_guide_content.php) · [HandleInertiaRequests.php](/app/Http/Middleware/HandleInertiaRequests.php) — active content source and scoped lightweight selector props; full ownership/schema reference in the [registry handbook](/docs/modules_handbook/shared/project-catalogue/area-guide-registry/readMe.md).
- [AreaGuideController.php](/app/Http/Controllers/Main/Portal/AreaGuideController.php) — `narration()` (a fresh signed URL per play, now per CHAPTER), `chat()` and `activity()` (the trail beacon), all behind `AreaGuideViewerAccess::allowsGuide()`.
- [ActivityRequest.php](/app/Http/Requests/Main/AreaGuide/ActivityRequest.php) — `ACTS`, the five named acts a reader may report. The whole fence around the one trail endpoint a reader can write through.
- [AreaGuideMediaController.php](/app/Http/Controllers/Main/Portal/AreaGuideMediaController.php) — `chapterImage()`, serving a chapter picture by AREA + CHAPTER + INDEX so a reader never handles an avatar uuid, and `tabImage()`, serving a picture embedded in a custom building's tab (see *Pictures in a building's tabs* below).
- Avatar library (admin side): [AreaGuideAvatarsController.php](/app/Http/Controllers/Manage/AreaGuide/AreaGuideAvatarsController.php) · [AreaGuideAvatar.php](/src/AreaGuide/AreaGuideAvatar.php) · [AreaGuideAvatarRepository.php](/src/AreaGuide/Repositories/AreaGuideAvatarRepository.php) · [AreaGuideAvatarPresenter.php](/src/AreaGuide/Services/AreaGuideAvatarPresenter.php) — one image uploaded once, used by a video marker's face and by a story chapter's pictures. Schema in the [registry handbook](/docs/modules_handbook/shared/project-catalogue/area-guide-registry/readMe.md).
- [StripAreaGuideAreaCopy.php](/app/Console/Commands/StripAreaGuideAreaCopy.php) — `area-guide:strip-area-copy`, which clears the retired `tagline` / `facts` / `video` / `video_media` out of stored rows. **Owed on production.** *(Its old `index()` and the `LockAreaGuide` middleware went when the guide became a tab, 2026-08-27 — `LockAreaGuide` no longer exists, so nothing should describe it as the current lock; the class returned with the immersive guide for these two endpoints.)*
- [AreaGuideViewerAccess.php](/src/AreaGuide/Support/AreaGuideViewerAccess.php) — the tab's reader gates as ONE server-side check. `allowsGuide()` for guide content (narration, chat, walk stations and visits, the Area Tutorial player); `allows()` = that plus the catalogue-map capability, for the map, asset and media routes.
- [MalaysianGuideAreas.php](/src/AreaGuide/Support/MalaysianGuideAreas.php) — the one definition of "the areas the guide's spoken parts serve": Malaysia's areas in LIVE regions. Read by `chatAreas`, `ChatRequest`, the narration route and `area-guide:narrate`.
- [StationQuestion.php](/src/AreaGuide/Support/StationQuestion.php) — the server's copy of the walk question's decision; see the [walkable session](/docs/modules_handbook/main/area-guide/walkable-session/readMe.md).
- [AreaGuideContentUnavailable.php](/src/AreaGuide/Support/AreaGuideContentUnavailable.php) — a `LogicException` the LEGACY source now throws for a missing, unreadable or malformed file, an unsupported version or an unmapped country code, so every consumer's existing `QueryException|LogicException` catch degrades the guide to "unavailable" instead of 500ing the whole Learning Hub. Adding a country to `legacy-countries.json` therefore means adding its ISO code to `COUNTRY_CODES` as well.
- [config/area_guide.php](/config/area_guide.php) — legacy/import Malaysia copy, EN + ZH, plus the state name/colour table.
- [ChatRequest.php](/app/Http/Requests/Main/AreaGuide/ChatRequest.php) — the area key is validated against the active registry's Malaysian areas, so an unknown key never reaches the provider.
- [AreaNarration.php](/app/Support/AreaNarration.php) — where the audio lives and how the tab finds it · [RenderAreaNarration.php](/app/Console/Commands/RenderAreaNarration.php) — `area-guide:narrate`.
- [resources/prompts/area_guide_chat.md](/resources/prompts/area_guide_chat.md) — the prompt body · registered in [config/ai_prompts.php](/config/ai_prompts.php) as `AiRequest::PROMPT_AREA_GUIDE_CHAT`.

**Frontend (Vue)**
- [AreaGuidePanel.vue](/resources/js/Components/AreaGuide/AreaGuidePanel.vue) — the guide itself: country tabs, language toggle, deep link + URL sync, then MalaysiaGuide or the illustrated three-level flow · [AreaGuideComingSoon.vue](/resources/js/Components/AreaGuide/AreaGuideComingSoon.vue) — member coming-soon panel · [AreaGuideLockBanner.vue](/resources/js/Components/AreaGuide/AreaGuideLockBanner.vue) — admin banner · all mounted by [Lms/Index.vue](/resources/js/Pages/Main/Portal/Lms/Index.vue)'s `#tab-area-guide`.
- [MalaysiaGuide.vue](/resources/js/Components/AreaGuide/MalaysiaGuide.vue) — Malaysia: the map filling the left column, the STORY filling the right one (pictures, prose, rail, voice), drives and chat below it, the walk view · [MalaysiaMap.vue](/resources/js/Components/AreaGuide/MalaysiaMap.vue) — the one continuous map, plus the traced boundary's three layers and `flyToChapter` · [MapLookPad.vue](/resources/js/Components/AreaGuide/MapLookPad.vue) — the game-pad that turns and tilts the 3D view · [AreaNarration.vue](/resources/js/Components/AreaGuide/AreaNarration.vue) — the voice: one audio per CHAPTER, a track, a status line and the `ended` that turns the page (+ its own [AreaNarration.test.js](/resources/js/Components/AreaGuide/AreaNarration.test.js), the first suite to test it rather than mock it) · [AreaChat.vue](/resources/js/Components/AreaGuide/AreaChat.vue) — the assistant panel.
- [composables/useStoryChapters.js](/resources/js/composables/useStoryChapters.js) — which chapter is current, by nearest-to-the-middle · [composables/useAreaGuideActivity.js](/resources/js/composables/useAreaGuideActivity.js) — the activity beacon.

**Pictures in a building's tabs (2026-09-19, user).** A custom building had
nowhere to put a photograph: `area_guide_buildings` has no cover column, and
`RichTextHtml` deleted `<img>` outright. The user chose to open the tab bodies
rather than add a cover field, so a tab may now carry pictures between its
paragraphs — but on a short leash, because an `<img>` is not an ordinary
element. **Its `src` is fetched by every reader's browser, with their cookies
and their IP**, so an arbitrary one is a tracking pixel at best and, pointed at a
third party, is our readers' attention handed to someone we never chose.

- **The source is MATCHED, not sanitised.** `RichTextHtml::IMAGE_SRC` is one
  exact path — `/property/academy/area-guide/tab-images/{uuid}` — and an `<img>`
  that misses it is **deleted**, not unwrapped (there is nothing inside it to
  keep). Only `src` and `alt` survive; `loading="lazy"` and `decoding="async"`
  are added.
- **The pictures come from the avatar library**, the same one the story chapters
  draw on. There is no second uploader.
- ⚠️ **The reader's route is a different door from the admin's.**
  `manage.area-guide.avatars.image` is gated on an ADMIN — point a tab at it and
  every member sees a broken picture. `tabImage()` is the member's door, and its
  fence is that **an avatar is served only while a live building tab still names
  it**, so the uuid a reader can read out of the markup buys them the picture
  already on their screen and nothing else.
- ⚠️ **Two things that must stay in step**: `IMAGE_SRC` and the route's path
  (change one and the other stops agreeing — a broken picture, or every picture
  deleted on the next save), and `sanitize()`'s emptiness check, which now asks
  whether an `<img>` survived. Testing `textContent` alone silently threw away a
  tab whose body was a photograph and nothing else.

**THE AREA'S OWN VIDEO BOX IS RETIRED (2026-09-19, user: "please drop/delete all
on the ground video of the area").** For one day an area carried a fixed "ON THE
GROUND" player beside its story — a link an admin pasted or a file they uploaded,
with three players (YouTube / Vimeo / file) behind one mute-versus-narration
contract. It is gone: the component, the field, its endpoint, its request class
and its tests. `AreaVideo.vue`, `AreaVideoVimeo.vue`, `AreaVideoFile.vue`,
`AreaVideoField.vue` and `SaveAreaVideoRequest.php` no longer exist.

**Why, and what replaced it.** A box that always sits in the same corner can only
say "somewhere in this area, someone filmed something". The MAP already answers
the better question: a video now hangs on a **marker at the place it was shot**,
carries an **avatar image** as its face, and plays over the map in `AssetViewer`
when the reader clicks that face. One video, in the one place it belongs — and
the same asset a panorama's video marker opens, so there is a single video path
through the whole guide rather than a second one beside the story.

⚠️ **Three things worth knowing about the retirement itself**, because they are
the kind that bite on deploy:

- ⚠️ **`VideoLink` was DELETED with the area video, and this handbook wrongly
  said it survived.** It was restored on 2026-09-19 when map videos gained a
  pasted-link option (below), together with `YouTubeLink`,
  [utils/areaGuide/videoLink.js](/resources/js/utils/areaGuide/videoLink.js) and
  both suites' tests. The stored form is unchanged and is now used by
  `area_guide_assets.video_link`: a bare eleven-character value is YouTube,
  `vimeo:<id>` is Vimeo, and `vimeo:<id>:<hash>` is an **unlisted** Vimeo video
  whose embed 404s without that hash.
- **Rows still hold the retired keys.** `profile.video` / `profile.video_media`
  survive on areas saved before this. `SaveAreaGuideRequest::RETIRED_PROFILE_KEYS`
  + its `prepareForValidation()` strip them on the way in, because Laravel's
  `array:` rule fails the **whole attribute** on one unlisted key — without that,
  every area with a stored video became unsavable from its own editor (a real 422
  this caused). `php artisan area-guide:strip-area-copy --apply` clears them for
  good; run it on production.
- **Vimeo's player takes `url`, not `{id, h}`.** An unlisted video needs
  `url: https://vimeo.com/<id>/<hash>`; the separate `h` *option* is
  version-dependent and fails **silently** — a black box, no console error. This
  cost a session's debugging and applies to the marker player just the same.
- [AreaMap3d.vue](/resources/js/Components/AreaGuide/AreaMap3d.vue) — the illustrated three.js scene (HK / UAE country + region) · [AreaStreetMap.vue](/resources/js/Components/AreaGuide/AreaStreetMap.vue) — the real pitched Mapbox street map (HK / UAE area level, and every walk).
- [composables/useAreaWalk.js](/resources/js/composables/useAreaWalk.js) — the walkable session's state, shared by both guides.
- [utils/mapboxLoader.js](/resources/js/utils/mapboxLoader.js) — shared CDN loader; this module added its optional `onError` argument.
- Runtime content: [useAreaGuideRegistry.js](/resources/js/composables/useAreaGuideRegistry.js) · [registry.js](/resources/js/utils/areaGuide/registry.js) · [areaOptions.js](/resources/js/utils/areaGuide/areaOptions.js) — authoritative props, geometry/translation hydration and selector helpers.
- Legacy/import content: [malaysia.js](/resources/js/utils/areaGuide/malaysia.js) · [hongKong.js](/resources/js/utils/areaGuide/hongKong.js) · [uae.js](/resources/js/utils/areaGuide/uae.js), assembled by [countries.js](/resources/js/utils/areaGuide/countries.js) (+ shared `BADGE_TONES`); [legacy-countries.json](/resources/data/area-guide/legacy-countries.json) is the versioned server import snapshot.
- Geometry: [geoMalaysia.js](/resources/js/utils/areaGuide/geoMalaysia.js) · [geoHongKong.js](/resources/js/utils/areaGuide/geoHongKong.js) · [geoUae.js](/resources/js/utils/areaGuide/geoUae.js) — merged by [geo.js](/resources/js/utils/areaGuide/geo.js).
- Tests (vitest): [areaGuide.test.js](/resources/js/utils/areaGuide/areaGuide.test.js) · [roadAreas.test.js](/resources/js/utils/areaGuide/roadAreas.test.js) · [AreaGuideUrl.test.js](/resources/js/Components/AreaGuide/AreaGuideUrl.test.js) — the URL names the open area on both guides, drops it on the way back out, reopens what a copied link names, never pushes, and the panel says "temporarily unavailable" when the server could not read the guide · [AreaGuideWalk.test.js](/resources/js/Components/AreaGuide/AreaGuideWalk.test.js) · both panel suites mount Malaysia with [malaysiaGuide.fixture.js](/resources/js/Components/AreaGuide/malaysiaGuide.fixture.js).
- Tests added 2026-09-14 (vitest): [AreaGuideLocale.test.js](/resources/js/Components/AreaGuide/AreaGuideLocale.test.js) — a change of language is a change of WORDS, not of place: the toggle falls back to English on a country without Chinese copy, and the reader stays in the walk across a toggle · [AreaGuideMapCamera.test.js](/resources/js/Components/AreaGuide/AreaGuideMapCamera.test.js) — the camera through the REAL `AreaGuidePanel → MalaysiaGuide → MalaysiaMap` chain with only Mapbox stubbed, because the camera bugs live in what the panel does in ANSWER to a click · [IndexAreaGuide.test.js](/resources/js/Pages/Main/Portal/Lms/IndexAreaGuide.test.js) — the host's wiring: the `unavailable` flag reaching the panel, and the tab's own query params being dropped on the way out · [CountryMap.test.js](/resources/js/Components/AreaGuide/CountryMap.test.js) also gained the region-flight, deep-link-to-region, panel-hiding and give-up cases.
- Nav: no entry of its own — reached through "Learning Hub" in [resources/js/Layouts/AppLayout.vue](/resources/js/Layouts/AppLayout.vue).

**Migrations** — [2026_09_13_160000_create_area_guide_registry.php](/database/migrations/catalogue/2026_09_13_160000_create_area_guide_registry.php) creates the shared country/region/area hierarchy. Narration remains site-local `media` rows; the [walkable session](/docs/modules_handbook/main/area-guide/walkable-session/readMe.md) retains its local `area_guide_visits` migration. Deploy/import sequencing belongs to the registry handbook.

**Seeders** — none.

**Routes**
- [routes/main.php](/routes/main.php) — the guide rides `GET /property/academy` (`main.portal.courses.index`). `GET /area-guide` survives as a **redirect** to `/property/academy?tab=area-guide` (still named `main.portal.area-guide.index`), and `GET /area-guide/{area}` (`main.portal.area-guide.area`) is the short URL for ONE location. The Malaysian guide's four endpoints sit with the walkable session's, under the academy prefix and `feature:area-guide`: `GET property/academy/area-guide/narration/{area}/{locale}/{chapter}` (`main.portal.area-guide.narration`, locale constrained to `en|zh` — **one audio per chapter since 2026-09-19**, so the old whole-area URL is gone), `GET property/academy/area-guide/areas/{area}/chapters/{chapter}/images/{index}` (`main.portal.area-guide.chapter-image` — a chapter picture BY POSITION, never by avatar uuid), `POST property/academy/area-guide/chat` (`main.portal.area-guide.chat`, `throttle:12,1`) and `POST property/academy/area-guide/activity` (`main.portal.area-guide.activity`, `throttle:60,1` — the activity beacon; see the trail bullet above). All inside the portal group (`auth`, `main`, `contact.verified`).

**Tests**
- [AreaGuideTest.php](/tests/Feature/Main/Portal/AreaGuide/AreaGuideTest.php) · [AreaGuideLockdownTest.php](/tests/Feature/Main/Portal/AreaGuide/AreaGuideLockdownTest.php) (the lock withholds the stories **and the Area Tutorial cards**) · [AreaGuideContentTest.php](/tests/Feature/Main/Portal/AreaGuide/AreaGuideContentTest.php) · [AreaGuideChatTest.php](/tests/Feature/Main/Portal/AreaGuide/AreaGuideChatTest.php) · [AreaGuideGameTest.php](/tests/Feature/Main/Portal/AreaGuide/AreaGuideGameTest.php) (+ shared [AreaGuideTestCase.php](/tests/Feature/Main/Portal/AreaGuide/AreaGuideTestCase.php)) · the tab pair in [InvestorPortalShellTest.php](/tests/Feature/Main/Portal/InvestorPortalShellTest.php).
- Added 2026-09-14: [AreaGuideEndpointGateTest.php](/tests/Feature/Main/Portal/AreaGuide/AreaGuideEndpointGateTest.php) — all four reader endpoints against a locked member, an admin, an admin previewing the lock, an enrolled day-1 trainee and an open member, with a provider spy proving a refused reader spends **zero** AI calls · [AreaNarrationTest.php](/tests/Feature/Main/Portal/AreaGuide/AreaNarrationTest.php) — the script really is tagline + story (asserted against the config copy, so the docblock and the code cannot drift), and a `soon` area 404s on the route and renders nothing until `--include-soon` · [AreaGuideLockedConfigTest.php](/tests/Unit/AreaGuide/AreaGuideLockedConfigTest.php) — `AREA_GUIDE_LOCKED` across `$_SERVER`, `$_ENV` and `getenv`, including that garbage fails CLOSED.
- [AreaGuideReaderImagesTest.php](/tests/Feature/Main/Portal/AreaGuide/AreaGuideReaderImagesTest.php) — the two routes that hand a READER a picture. ⚠️ **Neither had a test before 2026-09-19, and that is how `chapterImage()` shipped referencing `AreaGuideViewerAccess` with no `use` statement for it** — a fatal on the first request, invisible because the page only ever built the URL and nothing ever fetched one. Also pins that an avatar no live tab names is a 404 however good the uuid, and that a stale `?v=` is refused rather than served out of a year-long browser cache. ⚠️ A fixture here needs `AreaGuideMedia`, **not** `Media`: the guide's files live in their own `area_guide_media` table on the catalogue connection.
- Added 2026-09-19: [AreaGuideActivityTest.php](/tests/Feature/Main/Portal/AreaGuide/AreaGuideActivityTest.php) — the beacon's fence (each act its own type under the Area Guide category, one deepening row per story, an arbitrary act name refused, an unknown area accepted-and-dropped, a signed-out and a locked-out reader writing nothing) · vitest [AreaGuideStory.test.js](/resources/js/Components/AreaGuide/AreaGuideStory.test.js) — nearest-to-the-middle, the cameraless chapter, the last-chapter edge case, the rail jumping by scrolling, the voice advancing and stopping at the end, a chapter with no rendered audio falling silent, and the chapter pictures cross-fading · [MapLookPad.test.js](/resources/js/Components/AreaGuide/MapLookPad.test.js) — which caught two real bugs of its own (a rAF timestamp of 0 read as "no previous frame", and the loop re-arming after `stop()`).
- **Known coverage gaps.** `AreaGuideEndpointGateTest` exercises two of the three legs of `allowsGuide()` (the temporary lock and the trainee lock); the FeatureAccess leg is covered for the tab elsewhere and by the routes' own `feature:` middleware, but not on the four endpoints.

## Working in parallel on legacy content and geometry

Database content is edited through the admin registry with revision checks. The
file ownership rules below apply to retained legacy/import source and renderer
geometry, which remain versioned code.

Malaysia's writer owns `config/area_guide.php` + the registry lines in
`malaysia.js` + `geoMalaysia.js`. The UAE author owns `uae.js` + `geoUae.js`,
the Hong Kong author owns `hongKong.js` + `geoHongKong.js` — and nobody else
touches those. The shared files (`countries.js`, `geo.js`, `AreaGuidePanel.vue`,
`MalaysiaGuide.vue`, `MalaysiaMap.vue`, `AreaMap3d.vue`, `useAreaWalk.js`)
only change when a country is added or the guide itself gains a feature — give
those to ONE person. Run `php artisan test --filter=AreaGuide` and
`npx vitest run resources/js/utils/areaGuide/ resources/js/Components/AreaGuide/`
before pushing content edits.

**After editing Malaysian copy, re-render its narration** — the audio is not
regenerated automatically, so the voice keeps reading the old story until
`php artisan area-guide:narrate --area=<key> --force` is run. Cochrane, Maluri
and Old Klang Road have never been rendered: run it once for them.

## Known gaps

- The database registry and admin editor are implemented — and so, **as of
  2026-09-13/14, are the things this list used to defer**: published project
  markers and their clustering, the reusable project drawer, uploaded building
  models, panorama authoring and viewing, and location video uploads all ship on
  both the reader and the admin side, each with a vitest suite (see *How it
  works — catalogue map reader* above, which describes them working). What is
  genuinely outstanding is rollout, not build: production `ffprobe`, a
  representative real model / panorama / video, browser and device acceptance,
  and the cutover of the shared master — tracked in the
  [catalogue map requirements checklist](/docs/modules_handbook/main/area-guide/catalogue-map-requirements.md).
- Narration audio remains site-local and is not regenerated after a registry edit. Shared audio ownership and replacement/retry orchestration are not part of Phase 1. **Nothing detects stale audio after a copy edit** — the voice keeps reading the old story until the area is re-rendered with `--force`. Since 2026-09-19 that is one file **per chapter**, so every area rendered before then is invisible to the reader until it is re-rendered: `php artisan area-guide:narrate --force` is owed anywhere the old audio existed, **production included**.
- **The walk badge is not proof that anyone walked.** Posting a forged verdict is closed, but the right/wrong flag is a sticky OR, so enumerating a station's two or three options still lands the right one. See the [walkable session](/docs/modules_handbook/main/area-guide/walkable-session/readMe.md)'s own gaps.
- Hong Kong and the UAE are still on the illustrated map with no narration, no chatbot and no Chinese. They are the next migration, not a permanent second design.
- Marker videos are third-party (YouTube / Vimeo), which makes them the one
  thing here that can rot without anyone touching the repo: a deleted or privated
  video shows the host's own unavailable panel on our page with nothing failing
  where we would see it. Nothing checks them; re-check by hand.
- The three areas that arrived from master during the merge (Cochrane, Maluri,
  Old Klang Road) and TRX's Monash paragraph were **translated to Chinese by
  the merge, not by a writer** — read them before the tab is un-hidden.
- Switching country unmounts MalaysiaGuide (`v-if`), so Malaysia → Hong Kong →
  Malaysia is two Mapbox map loads. `v-show` would need MalaysiaMap to resize
  itself on return; not done while the tab is hidden anyway.
- The imported Penang and Johor entries are `soon` shells. Opening them needs authored areas/copy and an intentional map profile; reproducing the illustrated district treatment additionally needs versioned geometry in [geoMalaysia.js](/resources/js/utils/areaGuide/geoMalaysia.js). Database content edits do not require duplicate entries in the legacy files.
- State flags are a flag-derived TINT plus the state name, not the flag image. `fill-pattern` tiles in screen space and re-tiles as the reader zooms; on an extruded slab it also wraps the side walls. The upgrade path, if literal flags are wanted, is one `image` source per state clipped by a mask polygon.
- **Chapter pictures exist, but nothing decides what may go in them.** A chapter
  takes up to six images from the avatar library (2026-09-19); what is still
  undecided is the LICENSING policy — own shots / Wikimedia with credits / AI
  illustration — and nothing in the uploader asks or records it. Settle that
  before an area is published with photographs somebody else took.
- **The activity trail records acts, never time** (the user's choice,
  2026-09-19). "Which places did this lead look at" is answerable; "how long did
  they spend" is not, and no amount of reading the rows will produce it. Adding
  duration means a heartbeat and its own volume review.
- Chat history is the reader's own earlier QUESTIONS only. An `assistant` turn
  from the browser is the caller writing in the model's voice, so it is refused
  by the Form Request and dropped by the controller; the sum of history is
  capped at 4,000 characters, the per-turn cap being the Form Request's.
- The chatbot's refusal rules live in the EDITABLE prompt body, so an admin
  who removes the price / yield / MM2H clause in Manage → AI Prompts turns them
  off. `AreaGuideChatTest` asserts the FILE still names all six terms, which
  catches a bad commit and cannot catch a bad admin edit.
- The chatbot cannot see our projects. Feeding nearby listings needs project geo, a radius query and a price policy; the refusal boundary is what makes v1 safe without it.
- Area icons on the illustrated guide are emoji placeholders pending a consistent AI-illustrated set.

**See also:** [Walkable Session](/docs/modules_handbook/main/area-guide/walkable-session/readMe.md) · [Area Tutorials](/docs/modules_handbook/manage/area-tutorials/readMe.md) · [Analyze Property](/docs/modules_handbook/main/analyze-property/readMe.md) — the sibling portal module whose lockdown pattern this copies · [AI Integration](/docs/modules_handbook/shared/ai/readMe.md) — `AiClient`, prompt keys, key-usage policy · [Media](/docs/modules_handbook/shared/media/readMe.md) — where the narration is stored.
