# Shared Area Guide Map

## What it does

The Area Guide map combines published catalogue projects with administrator-uploaded
building models, panoramas and location videos. Administrators edit inside the current
site's backend at `/manage/area-guide/map`; participating deployments read the same
authoritative content database and private file storage. A saved change is visible on
the next request or map refresh; this implementation does not push changes into an
already-open map through WebSockets.

The country/region/area hierarchy is owned by the
[Area Guide registry](/docs/modules_handbook/shared/project-catalogue/area-guide-registry/readMe.md).
The project drawer reuses the full Malaysian
[Shared Project Detail Content](/docs/modules_handbook/shared/project-detail/readMe.md).
Uploaded panoramas and the building markers placed in them are administrator-only, including
their sharing links. They are separate from the catalogue's generated
[VR360 aerial bake](/docs/modules_handbook/shared/project-catalogue/vr360/readMe.md).

This handbook describes implemented code. See the
[session handover](/docs/modules_handbook/shared/project-catalogue/area-guide-map/handover.md)
for the discussion and next-session entry point, and the
[validation record](/docs/modules_handbook/shared/project-catalogue/area-guide-map/validation.md)
for completed local browser checks and the **2026-09-14 remediation checkpoint** (the audit
fixes described throughout this file, re-verified with automated tests only). Production master
migration/write access, real cross-deployment propagation and representative-asset/physical-device
acceptance remain pending; see the
[requirements checklist](/docs/modules_handbook/main/area-guide/catalogue-map-requirements.md).

## How it works

### Ownership and identity

| Data | Owner and identity |
|---|---|
| Published projects | Existing `CatalogProject`, read through `CatalogueFederationService`; cross-site associations use project UUID (Universally Unique Identifier), never a catalogue integer ID |
| `area_guide_assets` | Shared `kind`, title, country code, `media_id`, `cover_id`, `image_version`, audience, publication, metadata and revision, plus what the content stands for: an optional project UUID, or — for a building MODEL only — a custom `building_id` instead (exactly one of the two on a model) |
| `area_guide_placements` | Shared asset ID, latitude/longitude, heading, elevation, scale and revision; placement coordinates do not edit the project's canonical coordinates |
| `area_guide_hotspots` | Building MARKERS inside a panorama (2026-09-15, replacing the traced `area_guide_polygons` outlines): shared asset ID, original-image UUID, the point's yaw/pitch, marker `size` (0.5–3), order, revision and EXACTLY ONE link: a catalogue `project_uuid` or a custom `building_id` |
| `area_guide_buildings`, `area_guide_building_tabs` | Custom (non-catalogue) buildings an admin introduces from a panorama: country code, name, category, summary, and free-form tabs of sanitised rich-text HTML; one shared record reusable across panoramas |
| `area_guide_panorama_links` | "Fly to" hotspots: source asset ID, target asset ID, the SOURCE image version the point was placed on, yaw/pitch, optional label, order, revision and `confirmed_at` (NULL while an automatic return arrow waits for an admin to confirm its position — readers never see it before then); one per destination per panorama, at most 30 |
| `area_guide_media`, `area_guide_media_cleanups` | Dedicated shared media and durable cleanup outbox, using the existing `MediaService` implementation |
| Visits, tutorial routes, users, permissions, queue infrastructure, site listing decisions | Remain on their existing site-local owners |

Shared models use `AreaGuideContentConnection`: normally `catalogue`, or the explicitly
configured isolated `area_guide_local` connection in `local` / `testing`. Production
checks that the configured catalogue host, port and database match `catalogue_master`.
A COPY-mode local catalogue is not an independent writable Area Guide registry. The
primary site's own database may be that same live endpoint; credentials may differ to
grant writes only to Area Guide tables. Request input cannot select a database or disk.

Assets, placements and hotspots (building markers) have UUIDs, soft deletion, optimistic revisions and
`actor_context` snapshots. An actor is identified by `site_key`, `user_uuid` and name;
site-local numeric blame IDs can collide across deployments. Shared media adds the
same identity under `meta.stored_by`. These snapshots identify the last changes; they
are not a complete immutable version history or a file-restore system.

### Project feed, country profiles and visibility

`AreaGuideProjects` uses `ProjectMapQueryRequest` to apply both catalogue publication
and current-site listing rules through `publiclyListed()` on each query independently.
`MarketSiteResolver` separately restricts results to globally
active countries allowed for the request hostname. The two site concepts are distinct:
`sites` owns project listing decisions; `market_sites` owns hostname-to-country rules.

- The viewport feed reads shared master and site-owned local projects, deduplicated by
  UUID with the master authoritative. A local duplicate cannot resurrect an existing
  unpublished or suppressed canonical row. `CatalogProject` has no soft-delete
  tombstone; the map does not invent one.
- Viewport project reads require valid coordinates, exclude `(0, 0)`, start at zoom 9
  and return at most 500 projects. Latitude is limited to `[-85, 85]`; longitude to
  `[-180, 180]`. Crossing the antimeridian uses an OR longitude condition. Truncation
  prompts the user to zoom in.
- Below zoom 9, and during any `q` search, the endpoint returns early with `assets: []` —
  **asset markers are withheld too**, not only projects. That early response carries
  `Cache-Control: private, no-store` like every other map, asset and media response, so a
  result listing project names is never cached by an intermediary.
- Editor name search accepts 2–120 characters, returns at most 30 results and searches
  shared projects only. Published projects without coordinates remain searchable for
  manually placed models or panorama bindings. A site-only project can appear as a map
  pin, but cannot become a shared asset's or marker's project association.
- Both `new_project` and `subsale` memberships are retained. Search/binding does not
  require one exclusive project lifecycle category.
- The asset feed queries visible placements, fetches 251 to detect truncation and
  returns at most 250 placements grouped into assets. Feed asset payloads contain those
  viewport placements; the individual asset endpoint supplies its placement list.
- Readers receive published assets only; a model also requires what it stands for to be
  there — a currently visible shared project, or (since 2026-09-14) its custom building,
  which has no draft state, so a published building model shows as soon as it is published.
  A model whose `building_id` no longer resolves (a direct database write) is hidden like a
  model with no project. Administrators may inspect drafts and models whose project is no
  longer published so they can repair or remove them. Country restrictions still apply
  to editor reads, upload, placement changes and deletion.

The Manage country picker intersects active registry countries with the hostname's
allowed markets. `AreaGuideMapCanvas` reads `profile.map` center, zoom, minimum zoom,
pitch, bearing and rectangular bounds. It uses the Mapbox Standard style, Mercator
projection and `hash: false`; country changes replace the editor map. Bounds constrain
the camera, not exact national polygons or an upload geofence. Existing bundled map
geometry remains in code; it is not copied into asset tables. With `mapEnabled`, the
member guide selects the continuous street-map component for every country and mounts
the shared catalogue layer there. `countryMapProfile()` retains the legacy Malaysian
peninsula camera and otherwise derives a starting camera from a country's own region
and area coordinates, with authored map profile values taking precedence.

`CatalogueMapLayer` paces viewport requests, aborts superseded requests and ignores late
responses. The pacing is **250 ms of quiet with a 1.5 s ceiling** (`MOVE_MAX_WAIT_MS`, measured
from the first move of a continuous run) plus a movement threshold — 2% of the view's span on
any edge, or 0.1 zoom, compared against the last *requested* viewport. The reader's narration
orbit calls `setBearing` every frame and every frame ends in a `moveend`, which a plain debounce
postponed for the whole orbit; the ceiling lets roughly one request through per 1.5 s while an
orbit runs, and the threshold stops a camera that has not really moved costing a request. A feed
error always retries on the next move.

It draws clustered project pins plus model/panorama/video markers. Errors clear stale feed data
and expose a retry. A `panelHidden` prop `v-show`s the info/toggle panel away — so its checkboxes
leave the tab order too — while the host draws something over the map; the feed, markers and
models keep running underneath. The member guide passes it whenever its walk view is up.
Accepted consequence: 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 it. An unavailable enabled content
database does not silently switch to a local writable database or bundled asset data.
The Manage map index catches database/ownership failures and returns an empty country
list with a temporary-unavailable message; it does not present the failure as an empty
new installation or switch storage connections.

### Permissions, private delivery and the drawer

All new map routes require `area_guide_content.map_enabled`. Manage access additionally
requires an administrator with `VIEW_AREA_GUIDE`; writes require `MANAGE_AREA_GUIDE`,
`source=database` and `editing_enabled=true`. Giving a sales role those permissions does
not turn it into an administrator. Booleans use `FILTER_VALIDATE_BOOLEAN`, so the
project's raw-string `env()` behavior cannot turn `false` into an enabled write gate.

Reader endpoints use `AreaGuideViewerAccess`, which has two entry points:
`allowsGuide()` is the Guide's own gate — authentication, the temporary Area Guide lockdown
(with the administrator's `?preview=locked` escape), the TrainingAccess trainee lock and
membership Function access — and `allows()` adds the catalogue-map capability
(`area_guide_content.map_enabled`) for the map, asset and media routes. Every guide reader
endpoint outside this module (narration, chat, walk stations and visits, the Area Tutorial
player) now calls `allowsGuide()` itself, so those gates are authorization rather than a
withheld page prop. Every asset JSON and file request checks access again. Panoramas always
have `audience=admin`; publishing or copying `/manage/area-guide/panoramas/{uuid}` never
makes a public link.

`AreaGuideMediaController` returns same-origin private bytes instead of exposing bucket
paths. It supports GET/HEAD, one byte range, suffix ranges and 416 for invalid ranges.
Responses include `Cache-Control: private, no-store`, `Accept-Ranges` and `nosniff`.
An optional `v=<media UUID>` must match the current file: replaced-image URLs return
404. Video uses its separate cover image and native controls with `preload="none"`.

The project JSON endpoint resolves an authorized project UUID first. Its Malaysian
adapter then uses the same prop builder and complete tabs as the standalone page,
with drawer URL state isolated and background analysis prefetch disabled. Existing
lazy tabs retain their own access and analysis behavior; opening Overview does not
start deep analysis. A published-but-unlisted project is refused here too: the shared
detail builder now enforces the site listing, not publication alone, so the drawer
payload and the standalone page answer the same question.

**Every non-Malaysian project** — not only Hong Kong and the UAE — gets HTTP 422 with a JSON
body naming where to go instead:

```json
{ "message": "This country uses its standalone project detail page.", "href": "/ae/projects/<slug>" }
```

The response carries `Cache-Control: private, no-store`. `href` is the country's standalone
project page (`/{iso2 lowercase}/projects/{slug}`) and is null only when the catalogue row has
no slug; the drawer then falls back to the `href` the map feed's project card already carries.
The country is the named constant `AreaGuideMapController::DRAWER_COUNTRY`, no longer a literal
`'MY'`. Full country adapters remain future work — the 422 exists so the drawer offers a real
destination instead of dead-ending.

**Cross-database slug collisions fail closed.** Existing detail and lazy-tab routes
resolve slugs local-first. Before building or logging a drawer view, the map endpoint
checks that this slug resolves to the same UUID it authorized. A different UUID
returns 404; the browser also refuses a mismatched detail UUID. This prevents mixed
project data but does not make that collided master project usable in the drawer.
Supporting it requires one UUID-aware adapter across the payload and every lazy tab,
not an Overview-only exception.

### Upload, preview, replacement and deletion

| Kind | Current default upload limit | Server inspection |
|---|---|---|
| Model | 51,200 KB (50 MiB, mebibytes) | One GLB (GL Transmission Format Binary) 2.0 file; valid header/chunks, scene/nodes/meshes, one embedded buffer, embedded JPEG/PNG textures, no external resource URIs, at most 10,000 nodes; supported extensions are explicitly allowlisted |
| Panorama | 51,200 KB (50 MiB) | JPEG or PNG, exactly 2:1 aspect ratio, 1,024–20,000 pixels wide — then **normalised on upload** to a JPEG no wider than `panorama_max_width` (8,192) at `panorama_quality` (82); the original is not kept (see *Panorama delivery* below) |
| Video (source) | 2,097,152 KB (2 GiB) | `ffprobe` inspection: a container ffmpeg can open, positive duration, **exactly one** video stream. That is all — what it is encoded as no longer decides whether it is accepted |
| Video cover | 10,240 KB (10 MiB) | JPEG, PNG or WebP image; required when creating a video |

### A location video is a FILE **or** a LINK (2026-09-19)

Uploading a gigabyte up a domestic connection through a PHP request fails often enough that people
stop trying (user: "for the video upload sometimes will failed"), so a video may instead carry a
pasted Vimeo or YouTube link in **`area_guide_assets.video_link`**. Exactly one of the two, never
both and never neither — `prohibits:file` refuses both in one request, and the controller clears
whichever did not arrive, which is the REPLACEMENT case `prohibits` cannot see.

- **What an administrator pastes is a whole embed BLOCK**, because that is what Vimeo's Embed
  button copies: a wrapper `<div>`, an `<iframe>`, a `<script>`. `VideoLink::fromEmbed()` reads the
  player URL out of it — entity-decoding first, because a copied embed carries `&amp;` between its
  query parameters and that would otherwise hide `h=`. It is a **string search, not an HTML parse**:
  nothing here is ever rendered, so the only question is which URL the markup names, and the URL
  still goes through the same host and path checks as a hand-typed one.
- ⚠️ **An unlisted Vimeo video's hash is part of its ADDRESS, not a setting.**
  `player.vimeo.com/video/<id>` without `?h=` is a 404 inside the player: a blank box with nothing
  in the console. The same trap in Vimeo's player.js form — where the answer is `url:` rather than a
  separate `h` option — once cost a session's debugging.
- **A paste we cannot read is refused BY NAME**, never nulled. Nulling reads as "this video has no
  link": the save succeeds, the administrator watches their paste vanish, and nothing says why.
- **A linked video is never queued** and is publishable without a file: `isConverting()` and the
  failed-conversion guard both check `video_link === null`, so an administrator whose upload failed
  and who then pasted a link is not left with a stale status they cannot clear.
- Both players — the reader's modal and the admin's full-screen page — go through one
  **`AreaGuideVideoPlayer.vue`**. They used to hold a `<video>` tag each; adding a second branch to
  each would have been four players to keep in step.

### Videos are CONVERTED now, not refused (2026-09-19)

The rule above used to demand MP4 / H.264 / AAC and hold the upload to the 80 MiB SERVED ceiling.
That put the conversion on the administrator and meant the file people actually have — a `.mov`
off a phone, 4K, over a gigabyte — could not be uploaded at all. Now:

1. **`VideoTranscode::probe()`** reads the file. Anything ffmpeg cannot open, or that has more than
   one picture track, is still refused.
2. **`VideoTranscode::isWebReady()`** decides whether it needs converting: a real MP4, H.264, AAC
   or silent, no taller than `video_height` (1080) and no bigger than `video_max_kb` (80 MiB). A
   file that passes is **stored exactly as it arrived** — re-encoding something already correct
   would cost a generation of quality for nothing.
3. Anything else is stored **under its own extension and type** (never renamed `.mp4` while still
   being QuickTime), marked `transcode_status = queued`, and **`TranscodeAreaGuideVideo`** is
   dispatched after the transaction commits.
4. The job converts to **1080p H.264 High @ 4.1 + AAC, `yuv420p`, CRF 23, `+faststart`**, stores
   the result, points the asset at it, and only then deletes the original. A failure leaves the
   source in place and the asset `failed` with a readable reason.

**An asset cannot be published while `queued`, `running` or `failed`** (`AreaGuideAssetRepository::update`).
The file on the row during a conversion is still the administrator's source, and the job is about
to swap it — publishing would put that file in front of readers and then change what they are
watching underneath them.

⚠️ **`format_name` CANNOT TELL AN MP4 FROM A `.mov`, and this has now bitten twice.** FFmpeg
demuxes MP4, QuickTime/MOV, 3GP and M4A together and reports all four as
`mov,mp4,m4a,3gp,3g2,mj2`, so `str_contains($name, 'mp4')` is true for every one of them. The
original rule solved this with the ftyp **major brand**; the first cut of `VideoTranscode` dropped
that check and judged every QuickTime upload already web-ready — which would have served readers
an unconverted `.mov`. `isWebReady()` reads `format_tags=major_brand` and treats `qt…`, `3g…`,
`m4a`/`m4b`/`m4p` **and a missing brand** as not-MP4. Failing that way costs one conversion;
failing the other way costs a reader a video that simply does not play.

⚠️ **Re-encoding is lossy, and nothing here pretends otherwise.** What makes it worth doing is
that a phone recording carries a bitrate no web player needs — 4K at 100+ Mbps for a box a few
hundred pixels tall on a map. 1080p at CRF 23 typically lands at 40–80 MiB and looks the same on
screen. What is discarded is resolution and bitrate nobody was going to see.

⚠️ **The upload path is still a plain multipart POST.** Every byte crosses the wire twice
(browser → PHP → bucket) and has to fit inside PHP's `upload_max_filesize` / `post_max_size` and
the web server's body limit. Raising `video_source_max_kb` is an infrastructure decision, not just
a number — and a resumable or direct-to-bucket upload is a separate, larger piece of work.

**Production needs `ffmpeg` as well as `ffprobe`** (`AREA_GUIDE_FFMPEG`, default `ffmpeg`), and a
**queue worker**, or nothing is ever converted and every non-MP4 video sits on `queued` for ever.

⚠️ **Widening the server is only half of it — the FILE PICKER greys out what it does not list.**
`AreaAssetForm`'s `accept` stayed `.mp4` after the server had been widened, so an administrator
could not even SELECT the `.mov` the whole feature exists to take. It now lists `video/*` plus the
common extensions. The help text quotes **`video_source`**, the upload ceiling, not `video`, which
is what a reader is finally served — quoting the served figure would tell people to keep under
80 MB when 2 GB is allowed.

⚠️ **`config:cache` after changing these values, on any host that caches config** (this app's local
setup does, deliberately). `video_source_max_kb` was added in the same change that started using
it, so on a stale cache it read NULL, `(int) NULL` is `0`, and `max:0` **refused every single video
upload** with a size message that is true of no file anyone owns. `SaveAssetRequest::FALLBACK_MAX_KB`
now floors it so the failure can never be silent again, but the cache still has to be rebuilt for
the real ceiling to apply.

Limits are server configuration in `config/area_guide_content.php`, using
`AREA_GUIDE_MODEL_MAX_KB`, `AREA_GUIDE_PANORAMA_MAX_KB`, `AREA_GUIDE_VIDEO_MAX_KB` and
`AREA_GUIDE_COVER_MAX_KB`, and passed to the form. The same file declares
`media_directory` (`AREA_GUIDE_MEDIA_DIRECTORY`, default `area-guide`) — the object-key prefix
`MediaContext::directory()` reads, previously an undeclared silent fallback — beside
`media_disk`. PHP/web-server request limits must also accommodate the upload plus cover and
multipart overhead. Codec inspection has a 25-second timeout and fails closed if `ffprobe` is
unavailable. Stored filenames are server-generated by kind and inspected extension, not copied
from client names. *(This paragraph used to end "The service does not transcode or compress
uploads." It does both now, for video — see the section above. Panoramas have always been
normalised on upload.)*

**ffprobe version floor.** The rule now asks for
`-show_entries format=format_name,duration:format_tags=major_brand:stream=codec_type,codec_name`.
An `ffprobe` that does not recognise the `format_tags` section exits non-zero, and the rule then
rejects **every** video. The section has existed since ffmpeg 1.x, so any current build is fine —
but production's `ffprobe` install is still an open runbook item (step 5), so install a current
build there.

The form supplies title, country, project UUID where relevant, publication choice and
`metadata` (`default_yaw`, `default_pitch`, `provenance`). Provenance is one of uploaded,
photograph, render or illustration; an uploaded/illustrative asset is not a claim of
surveyed accuracy. Asset kind and country cannot change during an edit.
An administrator can retain an existing unchanged project association while saving
`published=false`, even if that project is now unpublished, suppressed or removed.
This permits unpublishing/renaming or repairing the draft. Choosing a new association
or republishing still requires a currently published, site-visible shared project.

**How that refusal now surfaces.** A hidden, unpublished, site-suppressed, site-local or
wrong-country project is a **422 field error**, not a bare 404 page / Inertia error modal:
`project_uuid` → *"Choose a published project in this country."*
(`SaveAssetRequest::PROJECT_UNAVAILABLE`) on the asset form, and
`hotspots.N.project_uuid` → the SAME message (`SaveHotspotsRequest::PROJECT_UNAVAILABLE`, since
2026-09-15 an alias of `AreaGuideLinks::PROJECT_UNAVAILABLE` — see *Video points* below for the
one class both editors now check their links through) per marker row, so one bad marker no longer
404s a whole panorama save. All four causes deliberately share one message: nothing about the
project is disclosed, and nothing is written in any branch. A **country** outside this host's
markets is still a 404 (the access check, unchanged).

`AreaGuideAssetsController` streams each upload through `MediaService::storeFromPath()`
with `MediaContext::areaGuide()`, then passes explicitly mapped nested input to
`AreaGuideAssetRepository`. New assets create their first placement and start as drafts.
Replacing the main file also forces draft status, even if Publish was checked. Preview
the result and explicitly publish it in a subsequent save. Publication requires a main
file and, for videos, a cover. Cover-only replacement does not reset publication.

The replacement order is **store new → attach under revision lock → commit → delete
previous media**. An invalid upload never stores a replacement. Storage/attachment
failure or a stale revision retains the old asset and file; any newly stored row is
sent to the scoped cleanup path. Revision conflicts return validation errors requiring
reload. Deleting an asset archives its placements/markers and the asset, then removes
its files through `MediaService`; external storage cannot be rolled back with SQL. A video that
panorama markers still open is refused instead (`VIDEO_LINKED`, see *Video points* below).

### Model placement and renderer limits

One GLB can have up to 100 active placements for multiple towers; panoramas/videos have
one active location. Administrators can choose a map point or enter coordinates and
edit heading (degrees), elevation (metres above terrain) and scale (`1` = source size).
The server accepts heading `[-360, 360]`, elevation `[-1000, 5000]` metres and scale
`[0.00001, 10000]`, with the same global latitude/longitude bounds as other map input.

**Facing a model (2026-09-14).** Heading is edited three ways, all feeding the same
`previewPlacement` so the model turns live on the map before *Save placement*: the number
input; a 0–360° slider with −15° / −1° / +1° / +15° / *Turn 90°* buttons in the placement
form; and a **rotate handle** `CatalogueMapLayer` draws at the selected model's anchor in
editor mode — a dashed ring with a knob at the current heading (`knobAngle(heading, map
bearing)`), which the admin drags around the ring (or nudges with the arrow keys, Shift for
15°). While the knob is dragged the map's `dragPan` / `dragRotate` are disabled; each move
unprojects the pointer to a lng/lat and emits `rotate` with `headingBetween(anchor, point)`
(`utils/areaGuide/projectModels.js`), so the map's bearing and pitch are honoured rather than
assumed. Only heading rotates: a building stands upright, so elevation and scale stay numbers.
**Moving it (2026-09-15):** the selected model can be dragged by its own body — a press the
model layer's raycast lands on the placement being edited claims the pointer (the map's
`dragPan` / `dragRotate` pause, the click that tails the drag is swallowed), every other press
still pans the map — and the same ring carries a centre grip (`data-move-handle`, drawn above
the asset markers) for a precise handle. Both emit `move` with the unprojected `{ lng, lat }`
(8 decimals); the grip's arrow keys walk the anchor 1 m north/south/east/west (Shift: 10 m); the
placement form previews it like a typed coordinate, and the map's hint bar says so while a model
is selected. *Choose position on map* (click a spot) and the coordinate fields still work.
**Resizing it (2026-09-15):** a corner handle (`data-scale-handle`, inside the ring, south-east
of the centre on screen so it never meets the knob or the grip) scales by the pointer's distance
from the anchor relative to where the drag began — twice as far out is twice the size — and
emits `resize` (clamped 0.01–1000, four decimals); its arrow keys step 5 % (Shift 25 %), Home is
the file's own size (1). The placement form gained −10 % / −1 % / +1 % / +10 % / 1× buttons
beside the number input. The layer also emits `measure` for the selected tower — what the
loaded model measures at its previewed scale (`modelSize()`: box width × depth × height in
metres, to a tenth, carried on every `onFootprint` report) — and the form prints it ("At this
scale the model covers about 105 × 46 m and stands 176 m tall"), so a millimetre export is
obvious at a glance.
**Selecting it:** in editor mode a click on the model itself (the raycast hit, placement row
`asset_uuid` included) emits `select-asset` with the asset AND the id of the tower that was hit,
so the placement panel opens on that tower with both handles; the marker under a tower does
the same. A reader's click on a model still opens what it links (project or building drawer),
and the panel's *View project / building details* keeps the drawer one click away for editors.
`projectModels.js` imports three.js, so `CatalogueMapLayer` reaches these helpers through the
same lazy `import()` as the model layer (the reader's map must not ship three.js for a handle
it never draws); the admin map page imports `normalizeHeading` statically, since that page is
the model editor anyway. A read-only administrator gets no handle (`previewPlacement` is null).
Placement updates have their own revision check. The last active placement cannot be
removed separately: remove the content itself instead, so it cannot become an
unreachable asset with no map location. Placements never update catalogue latitude,
longitude, footprint, height or project identity.

`projectModels.js` adds a Three.js custom layer to the Mapbox WebGL (Web Graphics
Library) context. Source GLB units are metres, +Y is up and +Z faces north at heading
zero; the authored origin is the map anchor. Terrain elevation is queried when available
and otherwise treated as zero. The transform converts metres to Mercator units and
applies the authored scale, heading and elevation.

**Reconcile pacing.** `zoomend` / `moveend` bind `scheduleReconcile`, which coalesces to one
animation frame and then reconciles only when the view really moved — a zoom step of 0.05 or
more, a crossing of the zoom-15 model gate, or an edge travelling more than 2% of the span —
measured against the view *last reconciled*, so a slow orbit still accumulates to a reconcile
instead of starving. Direct `reconcile()` calls from `onAdd`, `setPlacements`, `setVisible` and
`retry` are unchanged and still immediate. `createProjectModelLayer` accepts `requestFrame` /
`cancelFrame` options so tests can drive frames deterministically, and `onRemove` cancels a
pending frame.

The renderer module/loader are loaded dynamically. Model file loading occurs only for
in-view placements at zoom 15 or higher, with at most 12 rendered placements at a time.
Its defensive client file cap is 100 MiB, while the normal server upload default is
50 MiB. Failed/unsupported models retain their clickable marker. Hidden, out-of-view,
replaced or removed entries abort downloads and dispose geometries, textures and
renderer resources without destroying Mapbox's shared graphics context. Raycasting
selects the uploaded building and ignores map drags.

**The basemap gives way under a loaded model (2026-09-15).** Mapbox Standard draws its own
extruded version of most towers, so an uploaded model used to stand inside Mapbox's. Now the
model layer reports each loaded model's ground footprint — `modelFootprint()`, the scene's
three.js box (X/Z extents in metres, `CLIP_PADDING_M` = 1 m around it) turned by the heading
and scaled exactly as `modelPlacementMatrix()` renders it, projected from the anchor in
Mercator units, closed counter-clockwise — through the `onFootprint` callback: on load, again
whenever the placement moves, turns or rescales (the editor's live preview follows the drag),
and `null` when the entry is released. `CatalogueMapLayer` feeds those polygons to ONE
`clip` layer (`area-guide-model-clips`, GL JS ≥ 3.5; we load 3.7.0) with
`clip-layer-types: ['model']` and `clip-layer-scope: ['basemap']`: the basemap's extruded
buildings and trees inside a footprint are removed, for readers and editors alike, and come
back the moment the model unloads (zoomed out below 15, hidden, out of view, failed). Labels
are not clipped, and only the Standard basemap's content is — never this overlay's pins. A
style that refuses the layer flips the panel note to the manual fallback. **The Basemap 3D
objects toggle stays** as that fallback: it hides the basemap's 3D objects as a group for this
viewer only, restores its prior value on removal, and older styles may not support it. There
is still no building reconstruction, terrain survey or exact basemap-footprint replacement —
the clip is a rectangle, so a model whose plot is much larger than its box can leave a
neighbouring sliver, and a wildly oversized box would clip its neighbours.

### Building markers inside a panorama

The panorama editor uses `BuildingPanorama` and Photo Sphere Viewer. **Traced outlines are
gone (2026-09-15, user decision):** an administrator now clicks ONE spot on the building and a
**marker** appears there — a white ring on the building with the building's name in bold
capitals above it, the way a street-view tour labels a landmark — chooses what it stands for
(a published shared project, whose search matches are listed directly under the search box and
fill the *Linked project* select, or a custom building), sets its **size** (0.5–3, a slider,
live on the draft), and saves the edited marker set with its loaded baseline. A marker is a
yaw/pitch point on the sphere (radians, not image pixels) plus that size; *Move point* re-places
it with the next click, *Change link* re-binds it, *Remove* stages a deletion. Starting yaw/pitch
can still be saved from the current view. The old outline tracing (vertices, spherical polygon
validation, overlap priority, whole-shape hit testing) was removed with its table
(`area_guide_polygons`), its rule (`SphericalPolygon`) and its unit suite; the marker keeps the
outlines' cross-site baseline protocol below unchanged.

**The panorama page is full-screen (2026-09-14).** `/manage/area-guide/panoramas/{uuid}`
renders no sidebar and no top bar — the image fills the viewport. Since 2026-09-15 the only
thing floating over it is the **View | Edit** switch (plus, when they exist, the amber
*N arrows to confirm* button and the notices a save raises): the identity card, the *← Map*
link and *Copy admin link* were removed on the user's instruction — the page opens in its own
tab from the map, so the way back is closing it, and the chrome was covering the photograph.
The stacked host (the guide's `AssetViewer` modal) keeps its hint line and its chips of the
markers on the image; full-screen, the pulsing markers say it themselves. The map page opens it in a
**new tab** (plain `<a target="_blank" rel="noopener">`, still through `withSuite()`, because an
Inertia `Link` cannot open a tab) so the map stays where it was. It opens as a **viewer**: drag
to look around, click a building marker for its project or building drawer. An administrator
with manage access switches to **Edit** (or arrives in it through `?mode=edit`, which is
what the map page's *Edit markers & links* link appends); the marker editor then floats
over the image as a sheet — along the bottom on a narrow screen, a right-hand column on a
wide one. Switching back to View is refused while markers or links are unsaved, because
`BuildingPanorama` resets itself the moment editing is withdrawn and would discard them.

**Intro (2026-09-15).** A panorama's FIRST load (the page, and the guide's viewer modal) opens
as a "little planet" — looking straight down, fisheye 2, zoom 0 — and unfolds over 2.5 s
(`inOutQuad`) into the panorama's default view: Photo Sphere Viewer's own `utils.Animation`
driving `setOption('fisheye')`, `rotate()` and `zoom()` (`INTRO_*` in `panoramaFlight.js`).
Markers and arrows are drawn once it has landed; a press or wheel during it snaps to the end.
It never runs on a flight arrival (the viewer is not rebuilt) and not under
`prefers-reduced-motion`.

**One loading state, and it is ours.** A sphere is a dozen megabytes, so something has to be on
screen while it arrives — but it used to be TWO things: Photo Sphere Viewer's grey ring AND our
own overlay, printing the same sentence twice on top of each other. The library's ring is now
hidden (a scoped `:deep(.psv-loader-container)` rule on the viewer's own element) and
`BuildingPanorama` draws the only loading state: a slowly turning wireframe sphere, *"Preparing
the 360° view…"* and a real progress bar fed by the viewer's `load-progress` event
(`loadProgress`, clamped 0–100, minimum 4 % so the bar is never an empty slot). It fades out
over 500 ms straight into the little planet. The image cannot be shown before its bytes are
there — what makes the SECOND panorama instant is the preloading below, not this screen.

**Both markers invite the click.** A building marker carries a slow radar echo around its ring
(`animate-ping`, 2.6 s) and grows 15 % on hover; a fly-to arrow sits inside a dashed orbit that
turns once every 9 s. Neither animates while it is being placed (amber tone) or, for an arrow,
while it is still an unconfirmed return arrow: those are the admin's own work, not something a
reader should be invited to follow.

**The furniture wears the brand (2026-09-15).** The chrome over a panorama used to be neutral
slate and near-black navy, which read as someone else's UI sitting on our photograph. It now uses
the theme's own two colours (`resources/css/app.css`): **brand** royal blue for anything to press
— the fly-to arrow's disc (`bg-brand-600`, was `bg-navy-900`), the map's uploaded-content markers,
the loading bar (`bg-brand-400`) and the loading sphere (`text-brand-200`) — and **navy** for the
dark surfaces the photograph shows through: the arrow's name pill, the flight badge, the mode
switch and the notices (`bg-navy-900/75–85`), the page and the loading backdrop (`bg-navy-950`).
A building marker stays WHITE on purpose: white is the other half of the mark, and it keeps the
two kinds of marker apart at a glance — blue means "another panorama", white means "this building".
Amber keeps its own meaning throughout: something the admin has not finished.

**One camera, everywhere a panorama is offered.** `utils/areaGuide/panoramaIcons.js` draws the
360° camera (`panoramaCameraSvg({ className, strokeWidth })`) and three hosts share it: the map's
panorama marker (which used to read "360°"), the fly-to arrow inside a panorama, and the badge
shown during a flight. They cannot share a Vue component — one is built from DOM nodes for a
Mapbox marker, one is an `html` string for a Photo Sphere Viewer marker, one is a template — so
the single source of truth is a markup string, interpolating nothing but the class and the stroke
width. A reader who learns the glyph on the map recognises it inside the panorama.
The gate did not move: `AreaGuideAssetsController::panorama` still requires an
administrator with `VIEW_AREA_GUIDE` and the map flag, and the page reads no chrome, so
opening it to members later is a policy change in that controller (plus a portal route),
not a redesign. `BuildingPanorama` keeps its stacked layout when `immersive` is off — the
guide's `AssetViewer` modal still uses that.

### Custom buildings (non-catalogue) inside a panorama

Not every tower in a panorama is a catalogue project — an office block, a mall, a
landmark. A marker can link a **custom building** instead: after placing it, the editor's
*Link to* control offers **Catalogue project** (the search above) or **Custom building**
(search this country's buildings by name, or *Create new building*). A building is ONE
shared record (`area_guide_buildings`, on the content connection like every other shared
row) reusable from any panorama, carrying `name`, `category` (`AreaGuideBuilding::CATEGORIES`
— office / retail / hotel / residential / mixed / landmark / transport / other), `summary`
and up to `MAX_TABS` (12) free-form tabs (`area_guide_building_tabs`: title + TipTap HTML
body). Product decisions (2026-09-14): text-only rich content in this release (no image
upload yet), free-form admin-named tabs, one reusable record, visible as soon as saved — the
panorama's own draft/publish and admin-only audience decide who sees it.

- **Every marker links exactly one thing.** `project_uuid` is nullable; `building_id`
  is the integer key because buildings and markers always share a connection.
  `SaveHotspotsRequest` rejects a row with both or neither (`hotspots.N`,
  `LINK_REQUIRED`) and a building from another country (`hotspots.N.building_uuid`).
  `replaceHotspots()` resolves `building_uuid` → id before its transaction and locks the
  linked building inside it, so a concurrent delete cannot leave a dangling link.
- **Bodies are sanitised on the way in**, never trusted on the way out.
  `Src\AreaGuide\Support\RichTextHtml::sanitize()` (DOMDocument allowlist, no library) keeps
  paragraphs, headings (h1 becomes h2), emphasis, lists, quotes, code, rules, tables and
  http/https/mailto links (forced `rel="noopener noreferrer nofollow" target="_blank"`),
  unwraps unknown tags, removes script/style/iframe/object/embed/img/svg/form controls with
  their content, and strips every other attribute. The LMS lesson editor stores its HTML
  unsanitised; that content is site-local, this is shared across sites, hence the difference.
- **JSON endpoints, deliberately.** The editor must bind the uuid of the building it just
  created, which a `back()` redirect cannot hand it, so `AreaGuideBuildingsController`
  answers JSON: `GET buildings?country=MY&q=` (name search, 30 + truncation flag, or the
  country's 30 most recently updated without `q`), `GET buildings/{uuid}` (editor payload with
  `revision`, `tabs`, `actor_context`, `hotspot_count`, `model_count`), and under `MANAGE_AREA_GUIDE`
  `POST buildings` → 201, `PUT buildings/{uuid}`, `DELETE buildings/{uuid}` (body `revision`).
  Validation is still a Form Request (`SaveBuildingRequest`, `DeleteBuildingRequest`,
  `BuildingQueryRequest`), writes still go through `AreaGuideBuildingRepository` (revision
  lock; tabs replaced as a set: known uuid updated, new created, omitted archived; delete
  refused while any marker or map model links the building). Readers fetch
  `GET /property/academy/area-guide/map/buildings/{uuid}` (`AreaGuideViewerAccess`, host
  country, no actor data). Every building response is `Cache-Control: private, no-store`.
- **Reading.** `AreaGuideAssetPresenter::hotspots()` labels each row with `kind`
  (`project` | `building`), both `project_uuid` and `building_uuid` (one null), `yaw`,
  `pitch`, `size`, the live name and, for projects only, `href`. Selecting a building marker
  emits `select-building`; the
  hosts (`Panorama.vue`, and `AreaGuidePanel` behind `AssetViewer`) open
  `Components/AreaGuide/BuildingDrawer.vue`: name, category, summary and a local tab strip
  whose active tab renders the sanitised body with `v-html` in the lesson prose style. The
  panorama page passes `buildingCategories` (the labelled constant) to the
  `BuildingFormModal` (`Partials/BuildingForm.vue` + `BuildingFormModal.vue`, the §14
  form/modal pattern, submitting JSON and mapping 422 errors onto the form).
- **A building can also stand on the map as a 3D model (2026-09-14).** A building MODEL
  asset links exactly one of a published Catalogue project (`project_uuid`, as before) or a
  custom building (`area_guide_assets.building_id`, added by the buildings migration) — a
  tower that is not a catalogue project, such as an office block, gets its GLB placed on the
  map and its content in the same building record its panorama markers use.
  `SaveAssetRequest` takes `building_uuid` (`required_without` / `prohibits`
  against `project_uuid` on a model; `prohibited` on a panorama or video; a building from
  another country is `BUILDING_UNAVAILABLE`), the repository resolves it to the id before the
  transaction and locks the building inside it (the delete protocol above), and
  `AreaGuideBuildingRepository::delete()` now refuses while any marker OR any map model uses
  the building (`LINKED`, listing both counts; the picker row carries `model_count` beside
  `hotspot_count`). The asset form's *Link to* control (Catalogue project | Custom building)
  comes BEFORE the title and searches this country's buildings or opens the same
  `BuildingFormModal` (stacked over the asset modal) to write the content on the spot; the
  asset **title follows the link** — a picked project or building, or the building just
  created, names the model — until the admin types a title of their own, and *Create new
  building* carries the title typed so far as the building's draft name (`create-building`
  `{ name }` → `BuildingFormModal` create mode seeds from `building: { name }`), so a name is
  typed once, not twice; the map page's selected-asset panel offers
  *View building details* and *Edit building content* for such a model, and the map page
  receives `buildingCategories` like the panorama page. `AreaGuideAssetPresenter::asset()`
  carries `building_uuid` + `building_name` (null for a project model). Selecting a
  building-linked model on either map — the raycast hit or its marker — emits
  `select-building` and opens `BuildingDrawer` (admin: `manage.area-guide.buildings.show`;
  reader: `map/buildings/{uuid}`) instead of the project drawer.

### Panorama delivery — why a stored panorama is 8,192 px wide, and what the browser keeps (2026-09-15)

The first production panorama was a camera original, **12,000 × 6,000 px, 37.7 MB**, and it did
not load: every byte had to travel GCS → PHP (64 KB chunks) → Apache → Cloudflare → the browser,
which then decoded 72 megapixels only to downsize them to what its GPU can hold. The viewer
gave up before the file arrived. Three things changed, and they are the answer to "how do I
make flights faster" too — the wait was never the page architecture (a flight already swaps
props under the same viewer, no reload), it was the bytes.

**1. Uploads are normalised** (`Src\AreaGuide\Support\PanoramaImage::normalise()`, called by
`AreaGuideAssetsController::save()` for the panorama kind, after `AreaGuideUpload` has accepted
the file). GD decodes the upload, scales anything wider than `area_guide_content.panorama_max_width`
(`AREA_GUIDE_PANORAMA_MAX_WIDTH`, default 8,192 — no screen or phone shows more across a sphere)
to exactly 2:1 at that width, and re-encodes as JPEG at `panorama_quality`
(`AREA_GUIDE_PANORAMA_QUALITY`, default 82): 4–6 MB instead of 38, indistinguishable on screen.
A PNG comes out as a JPEG too; EXIF/XMP is dropped (the viewer never needed it — the starting view
is ours). GD needs about 5 bytes a pixel to decode, so the helper raises `memory_limit` for the
call from the image's own dimensions (capped at 2 GB; a 20,000 × 10,000 upload needs ~1.2 GB) and
refuses, as a `file` field error, anything larger — production has 8 GB. The original is NOT
kept: markers and arrows are angles on the sphere, so a resize never moves them, and a second
copy would only double storage. `image_version` stays the stored file's uuid as before.

**2. Versioned file URLs are cacheable by the browser.** `AreaGuideMediaController::show()`
answers `Cache-Control: private, max-age=31536000, immutable` (`CACHE_VERSIONED`) whenever the
URL carries `?v=` — the media uuid, which a replacement changes, so a cached URL can never show
the wrong picture (a mismatched `v` is a 404). `private` keeps the bytes out of Cloudflare and
every shared cache; authorization still runs on every uncached request. An unversioned URL stays
`no-store`. Flying back to a panorama, or reopening the page, now costs no download.

**3. Preloading is paced.** `BuildingPanorama.preloadLinks()` fetches at most `PRELOAD_MAX` (8)
linked files ONE AT A TIME (a promise chain, each after the previous lands or fails) instead of
all at once, and the viewer's own decoded-texture cache keeps `PRELOAD_CACHE_ITEMS` (6) items
rather than one per arrow — a decoded 8,192-px panorama is ~130 MB of texture memory, and 31 of
them was a memory bomb. The HTTP cache keeps the bytes of the rest.

**Panoramas stored before this change** are normalised in place by
`php artisan area-guide:optimise-panoramas` (a dry run: each panorama's pixel size, bytes and
whether it is due — wider than the cap, or over 12 MB) then `--apply` (`--asset=<uuid>` for
one): the normalised copy is stored as new media, `AreaGuideAssetRepository::swapMedia()`
points the asset at it — the image version, the markers, the arrows and publication all stay,
the revision bumps so open editors reload the new file URL — and the old object is deleted.
One panorama at a time; a failure leaves the others untouched. Run it on the master with the
deploy user after this release lands.

**What is NOT done:** a low-resolution placeholder shown while the full image arrives, and
serving bytes straight from GCS by signed URL (which would also need bucket CORS for the
viewer's `fetch`). Both remain worth doing if 4–6 MB still feels slow on mobile.

### AI-drafted building content (2026-09-15)

Typing every tab of a custom building by hand is slow, so the building form — in both hosts,
the panorama editor and the map's asset form — has an **AI generate** button. It needs only the
name. With the category, the country and whatever the host knows about where the building is
(the panorama's title and map spot, or the spot picked on the map), `POST
/manage/area-guide/buildings/draft` (`manage.area-guide.buildings.draft`,
`AreaGuideBuildingsController::draft()`, `DraftBuildingRequest`) runs
`App\Actions\AreaGuide\DraftBuildingContent`: ONE `AiClient::chat()` under
`AiRequest::PROMPT_AREA_GUIDE_BUILDING_DRAFT` with **Google Search grounding** (Gemini's
`google_search` tool — the model reads real pages about the building before it writes) and JSON
asked for in the prompt, because grounding and a JSON mime type cannot be combined and
`AiResponse::json()` unwraps the fenced answer. The provider follows the usual resolution — the
key's admin pin on the AI Prompts page, else `AI_DEFAULT_PROVIDER` (Gemini); grounding is a
Gemini feature, and another provider simply writes from what it knows.

**What it drafts, and why.** The prompt (`resources/prompts/area_guide_building_draft.md`,
admin-editable like every key) writes for a property investor. By category it looks for what
an investor can act on: an office block's owner, completion year, net lettable area, anchor
tenants and an ESTIMATE of the daily workforce with its arithmetic shown; a mall's footfall and
catchment; a hotel's rooms and who stays; a station's ridership — and always "what it means for
property nearby", "getting there" and "what is coming". Every estimate is marked "(est.)" with
its basis; a figure that is not published is said to be not published; and the Area Guide
chat's money rule holds (no price, price per square foot, yield, rent or ownership rule — those
belong to an agent).

**The reply is shaped, never trusted.** Titles are cut to the form's 80 characters, every body
goes through `RichTextHtml::sanitize()` (the save sanitises again), empty tabs are dropped, the
summary is plain text within 500 characters, and the set stays within `MAX_TABS` with one slot
kept for a **Sources** tab the action builds from the grounding metadata (`groundingChunks`:
pages the model actually read, never URLs it typed — a "Sources" tab of the model's own is
dropped). Nothing is saved: the draft lands in the form's fields, the admin checks it, and the
ordinary save writes it. Text already in the form is replaced only after a `ConfirmModal`.

**Guard rails.** The writer's gate (`canManage` + the map flag); the country must be one of
this host's markets; 40 drafts per administrator per day (`DRAFTS_PER_DAY`, `RateLimiter`, a
429 with a message); a 120 s request timeout with no retry (`DRAFT_TIMEOUT`); a fail-soft 503
with a message when the provider is down or the reply is unreadable. Every call is logged in
`ai_requests` under its key with the building's name, category and country in `meta`. In AI
Gateway client mode the call relays to the Hub like any other. Tests:
`tests/Feature/Manage/AreaGuideBuildingDraftTest.php` and the modal's vitest cases.

### Video points — on the map and inside a panorama (2026-09-15)

An admin can now place a **video** the way they place a building marker. On the map it is the
location-video asset that already existed (`area_guide_assets.kind = 'video'`: its cover, its
`ffprobe` inspection, its byte-range streaming — nothing new); inside a panorama it is a marker
of a THIRD kind, `video`, that links such an asset (`area_guide_hotspots.video_asset_id`). The
user's brief was "same backend, different front ends", and that is the shape: ONE video record,
ONE upload path, ONE page — reached from a map pin, from a panorama marker, or from the Learning
Hub modal.

**What links what — one rule, one class.** A map asset and a panorama marker both "link" things
(a catalogue project, a custom building, now a video), and until today each editor checked its
links in its own form request and resolved them in its own repository method — the same test
written twice, with two slightly different messages. `Src\AreaGuide\Services\AreaGuideLinks` is
now the single home: `targets()` batch-loads everything a set of rows names (projects through
`AreaGuideProjects::visibleShared()`, buildings and videos by uuid, archived rows excluded — one
query per kind, so the catalogue is still read once per save), `problem()` says whether ONE row's
link is bindable (the target exists, sits in the content's country, a video is a video), and
`buildingIds()` / `videoIds()` turn uuids into ids before a repository transaction opens,
throwing `BUILDING_REMOVED` / `VIDEO_REMOVED` at a stale editor. `SaveAssetRequest`,
`SaveHotspotsRequest` and `AreaGuideAssetRepository` all call it; the constants those classes
already exposed (`PROJECT_UNAVAILABLE`, `BUILDING_UNAVAILABLE`, `BUILDING_REMOVED`) are aliases
of the service's, so the messages are now literally the same text on both sides ("Choose a
published project in this country."). What stays per editor is HOW MANY links a row may carry,
because that genuinely differs: a model links exactly one of project/building; a panorama or
video asset keeps an optional project and never a building or a video (`video_uuid` is
`prohibited` on the asset form); a marker links exactly one of project/building/video
(`LINK_REQUIRED`).

**The marker.** `SaveHotspotsRequest` accepts `hotspots.*.video_uuid`; the repository writes all
three link columns on every row, so a marker re-linked from one kind to another loses its old
link; `AreaGuideHotspot::kind()` answers video → building → project. A draft video IS linkable
(like a draft panorama destination): readers simply do not receive the marker until the video is
published, and editors get `video_published` to badge it. The presenter row carries `video_uuid`,
the video's title as `label` and — for editors only — `href`, the video page. Locking follows
`replaceLinks()`: the source panorama and every linked video are taken in ONE id-ordered
`lockForUpdate`, then the buildings, then the hotspot rows, so a marker save and a video delete
can never wait on each other in a cycle.

**One play glyph, one disc.** `utils/areaGuide/panoramaIcons.js` gained `videoPlaySvg()`
beside the camera (a stroked screen with a filled play triangle, the same drawing language) and
`discMarkerHtml()` — the brand-blue disc with the slowly turning dashed orbit and the name pill
that the fly-to arrow already drew. The video marker is that disc with the play glyph, scaled by
the marker's `size` like a building ring, with a small *Draft* tag in its pill for editors; the
arrow now draws itself through the same function. The map's video pin carries the same glyph over
its cover thumbnail. So the colour language holds: brand blue = media you can open (camera =
another panorama, play = a video), white ring = a building, amber = the admin's unfinished work.

**The video page.** `/manage/area-guide/videos/{uuid}` (`manage.area-guide.videos.show`,
`AreaGuideAssetsController::video()`, Inertia `Manage/AreaGuide/Video`) is the panorama page's
sibling: the same gate (`assertView` + the country check), full-screen navy, the video with
native controls and its cover as poster, the title, an amber *Draft · not visible to readers*
badge, nothing else. A video marker in the panorama page and the map panel's *Watch video* link
(it replaced *Preview video*) both open it in a NEW TAB — exactly what *View 360° panorama* does —
and `share_url` on a video asset now points there. The reader's Learning Hub modal has no
standalone pages yet (members are not admitted to the panorama page either), so there a video
marker plays the video in a viewer STACKED over the panorama: `AssetViewer` re-emits `open-video`
as `select-video`, and `AreaGuidePanel::openVideo()` reads the video from the same authorized
asset endpoint into a second `AssetViewer` mounted after the first. Closing it leaves the reader
in the same view — the building drawer's rule, for the same reason: swapping the video into the
panorama's own viewer would close the panorama the reader is standing in.

**Uploading from inside a panorama.** The editor's *Link to* control offers **Location video**:
search this country's videos (`GET /manage/area-guide/videos?country=&q=`,
`manage.area-guide.videos.index`, drafts included and badged, newest first when nothing is typed)
or *Upload new video*. The upload modal (`VideoUploadModal.vue`) reuses the asset form's fields
but posts with axios to the SAME `assets.store` endpoint — `SaveAssetRequest`,
`AreaGuideAssetsController::save()`, the repository, the `ffprobe` rule, all unchanged — asking
for JSON (`Accept: application/json`), so the created asset comes back in the response
(`201 { asset }`) and is bound to the marker at once. That is the reason the custom-building
endpoints are JSON too (D10): an Inertia redirect cannot hand a new uuid back to a component
mid-edit. Its map location is the panorama's own placement, so the video also appears on the map
there; move it on the map if it belongs elsewhere. The Inertia path the map page uses (redirect +
flash) is untouched — `save()` only answers JSON when asked for JSON.

**Deleting a video that markers use is refused**, the building rule applied to videos:
`AreaGuideAssetRepository::VIDEO_LINKED` ("Unlink this video first: it is used by N panorama
marker(s).") under the `asset` error key, checked under the asset's own row lock so the count
cannot go stale against a concurrent marker save. Unpublishing is allowed and simply hides the
markers from readers.

**Deferred on purpose — watch progress and activity tracking.** The user wants the system to
record how much of a video each reader watched and, more broadly, everything a reader does in
the Area Guide; the decision (2026-09-15) is to build ONE activity-tracking module later, once
the guide's functions are complete, rather than a video-only ledger now. The hook points are the
`<video>` element on the video page and in `AssetViewer` (`timeupdate` / `ended`), the marker
clicks (`open-video`, `select-project`, `select-building`, `navigate`) and the map pin
selections. Nothing is recorded today.

### "Fly to" links between panoramas

A panorama can carry arrow hotspots that lead to other panoramas, so several photographs
become one walkable tour. Product decisions (2026-09-14, refined 2026-09-15): the click FLIES
there — the camera swings toward the arrow and zooms in while the image BLURS (never a black
screen, never Inertia's progress bar), the destination loads in the SAME viewer (no page
reload) and crossfades in, facing the destination's own default starting view, while the blur
lifts and the zoom eases back out. Arrival direction is therefore the
destination's `metadata.default_yaw` / `default_pitch`, not a per-arrow setting. When A→B is
created with *Also add a return arrow* (on by default), B→A is created automatically directly
BEHIND B's default view (`AreaGuidePanoramaLink::returnYaw()` = default yaw + π, pitch
`RETURN_PITCH` = −0.15 rad) only if B has an image and no live link back to A. That position
is a guess, so the return arrow is created UNCONFIRMED (`confirmed_at` NULL) — decision
2026-09-14: an automatic point must never appear to readers until an admin has confirmed where
it sits. View mode and readers never show it; an admin opening B in edit mode sees it flagged
*Awaiting confirmation* (a dashed marker whose pill says *confirm position*, and an amber
notice above the list), keeps it with *Confirm position* or drags it with *Move point* (a move
confirms it too), and *Save panorama links* stamps `confirmed_at`. An arrow the admin placed
is confirmed the moment it is saved, and confirming is one-way. The save on A names the
panoramas where return arrows are waiting (its flash message), and B's page shows an amber
*N arrows to confirm* button in view mode that switches to edit mode.

- **Storage.** `area_guide_panorama_links` rows belong to the SOURCE image version like
  markers: replacing A's photograph archives A's outgoing arrows (the arrows INTO A stay —
  what they fly to has not changed); archiving a panorama archives its outgoing and incoming
  arrows. `AreaGuideAsset::links()` / `incomingLinks()`. `confirmed_at` is NULL only on an
  automatic return arrow nobody has confirmed yet.
- **Saving.** `PUT assets/{id}/links` (`manage.area-guide.assets.links`, `SaveLinksRequest` →
  `AreaGuideAssetRepository::replaceLinks()`) replaces the set for the current image under the
  asset revision + `image_revision` check: known ids update in place, omitted ones are
  archived, new ones created (confirmed at once). A known row sent with `confirmed: true`
  stamps `confirmed_at` if it was waiting; `false` (or absent) leaves a waiting arrow waiting
  and never un-confirms a confirmed one. The source and every destination are locked in ONE
  id-ordered query so a save linking A→B and one linking B→A cannot deadlock. A destination
  must be a live panorama the host may show and never the source itself (row errors, not a
  404). The destination picker is `GET panoramas?country=&q=` (`panoramas.index`, drafts
  included with a badge; the editor hides the asking panorama).
- **Reading.** `AreaGuideAssetPresenter::links()` returns the current image's arrows with
  `target_uuid`, `target_title`, `target_published`, `confirmed`, `yaw`/`pitch`, `label` (the
  arrow's own label or the destination title), `target_asset_url` (the asset JSON to load on
  arrival) and, for editors, `target_href` (the destination's page). Readers only receive
  arrows whose destination is published and in the host's markets AND whose position is
  confirmed; editors get every row and use `confirmed` to flag the waiting ones. The panorama
  page props and the `assets/{uuid}` JSON both carry `links`; `panoramaFlight.js`'s
  `visibleLinks()` is how the admin page's view mode hides the waiting arrows it was sent.
- **The flight lives in `BuildingPanorama.vue`** (constants in
  `utils/areaGuide/panoramaFlight.js`): `depart()` animates toward the arrow, blurs the
  viewer (`FLIGHT_BLUR_PX`, a CSS `filter` transition — no dark backdrop) and emits
  `navigate(link)`; the host fetches the destination and swaps the props; the component's
  image watcher, seeing a flight in progress, calls
  `viewer.setPanorama(url, { transition: { effect: 'fade', rotation: false } })` — Photo
  Sphere Viewer's own crossfade — instead of rebuilding the viewer, then lifts the blur and
  lands. The admin page navigates with Inertia
  `router.visit(target_href, { preserveState: true, showProgress: false })` so the URL
  follows, the viewer survives the prop swap (its `:key` no longer includes the asset uuid)
  and no progress bar crosses the top; the reader host (`AreaGuidePanel::flyToAsset`) swaps
  `selectedAsset` without unmounting `AssetViewer`. `prefers-reduced-motion` gets an instant
  swap; a destination that never arrives times out after 10 s and reports an error instead of
  leaving the blur up.
- **Preloading makes it fast (2026-09-15).** Every links row carries `target_file_url` (the
  destination's own image path, built like `file_url`); as soon as a panorama is open in view
  mode the viewer calls `viewer.textureLoader.preloadPanorama()` for each distinct linked
  image (Photo Sphere Viewer's `Cache` keeps the decoded textures; `maxItems` is raised to
  cover a panorama's 30 arrows), and the admin page also `router.prefetch()`es each
  destination's page props. The switch is then the crossfade alone. The `/file` responses
  stay `private, no-store` — the browser's HTTP cache is never relied on.

Each panorama supports at most 100 markers (`AreaGuideHotspot::MAX_HOTSPOTS`). A marker's
yaw/pitch are bounded exactly to `|yaw| <= π` and `|pitch| <= π/2` by `App\Rules\AreaGuideAngle`
— a rule object rather than a decimal literal because Laravel's `between` compares the decimal
string form of a float, so no literal can match `M_PI` at the edge (a `±3.141593` bound would
make exactly ±π un-submittable). The same rule bounds `metadata.default_yaw` / `default_pitch`.
`size` is `nullable|numeric` between `AreaGuideHotspot::SIZE_MIN` (0.5) and `SIZE_MAX` (3),
default `SIZE_DEFAULT` (1) — the marker's on-screen scale, a slider in the editor. `order`
(`hotspots.*.order => nullable|integer|between:0,1000`, `HOTSPOT_ORDER_MIN` / `MAX` in
`panoramaGeometry.js`) is the row's sort order and the marker's z-index. There is no geometry
validation beyond that any more: the spherical-polygon rule, its hemisphere/crossing-edge
checks and whole-shape hit testing left with the outlines (2026-09-15).

Saving requires both the asset revision and `image_revision` matching its original
image UUID, plus a required `known_ids` array (distinct UUIDs, at most 100). This is the
set of original server markers actually loaded into the editor, including a loaded
marker the user subsequently deleted. Newly placed client IDs are not added to the
baseline. A dirty refresh does not silently expand that baseline.

`SaveHotspotsRequest` resolves which known rows are still publicly listed on the current
site before the repository's write transaction. Under the asset revision/image lock,
`replaceHotspots()` updates submitted known editable rows in place and archives only
known editable rows omitted from the submitted set. Unknown or currently invisible
rows remain untouched, including a project which became visible after the editor
loaded. The combined count of preserved rows and submitted rows must remain at most
100. Existing IDs belonging to another asset/image or absent from the baseline are
rejected; a genuinely new client UUID can create a new server-identified row. A loaded
marker which became uneditable and is still submitted requires a reload rather than
silently accepting its edit. Client input never supplies the server's editable-ID list.

For example, if site A loads only project Q while project P is suppressed there,
saving/deleting Q preserves P's shared marker. Publication changes do not increment
the panorama revision, so the explicit baseline is needed in addition to the revision
check. This is deliberately a partial replacement of the editable loaded view, not
"archive every shared marker and recreate whatever this site returned."

Replacing a panorama image archives the old markers; stale forms cannot
reapply them to the new image. The presenter reads only current-image markers and
filters out projects no longer publicly listed for the site. Labels come from the
current catalogue project name (or the building's own name), not an unverified saved
label. No automatic building detection or guessed project matching is performed.

Each presented marker also carries **`href`** — the project's standalone page path, or null
when its country is not one of this host's markets or the row has no slug — beside the live
`label`. Neither is stored: the repository maps only id, the link, yaw, pitch, size and sort
order, and both are re-derived from the live catalogue row on every read. The save request
*accepts* the editor's `kind` / `label` / `href` echo (a client resubmits the marker object it
loaded) and the repository discards them; the `array:` key list must keep them, or every marker
save on an existing panorama would fail validation.

**Save performance.** The validator resolves the loaded baseline *and* every submitted
marker's project in one `AreaGuideProjects::visibleShared()` catalogue query, instead of one
`resolve()` per marker — a 100-row save issued roughly 300 catalogue/site queries
before. `resolve()` also skips its unused canonical-existence probe on a shared-only read. Both
are pinned by query-counting assertions, so the numbers here are measured, not estimated.

**The editor's save-outcome protocol is load-bearing.** `BuildingPanorama` never re-hydrates
from a props refresh while a save is in flight. `Panorama.vue` reports each emitted save back as
a `saveOutcome` prop (`{ sequence, saved }`) from `onSuccess` / `onError` / `onCancel` /
`onHttpException` / `onNetworkError` plus the two local refusals (no manage permission, an
image-revision mismatch); only `saved: true` replaces local markers, and the save captures the
sequence at submit time so a leftover outcome or a viewer remount cannot be mistaken for this
save's answer. The reason: Inertia awaits `setPage()` — the props swap — *before* it calls
`onError`, so "props changed after a submit" is not evidence of success. The previous heuristic
silently discarded an administrator's unsaved work whenever another administrator had
changed the rows concurrently. A refused save keeps every local item and surfaces the
concurrent change through the "Reload saved markers" banner, with Save disabled until
the administrator chooses. The links editor runs the same protocol on `linkSaveOutcome`.

The editor shows one notice, raised only when the image actually changes under an editor
holding markers or a draft, saying the markers placed on the previous image no longer apply and
must be placed again. The load-failure copy does not mention an expired viewing link —
`file_url` is an unsigned same-origin `?v=<media uuid>` path that does not expire.

### Editor recovery, selection and the drawer shell

**Revision conflicts recover instead of dead-ending.** A stale-revision validation error — on a
placement save, an add-tower POST, a delete or an asset edit — raises a dedicated amber conflict
banner that keeps the server's own message, refetches the complete asset with a forced placement
reset (a `resetAfterConflict` latch survives a viewport feed aborting that refetch) and offers an
explicit "Reload latest" control. A delete conflict also closes the confirmation modal, so no
confirmation stands against a changed target. A conflict raised while the asset modal is open
re-feeds the modal the latest record: it re-hydrates **keeping the administrator's chosen files**
but replacing their typed field values, and says so. Only the `revision` error key triggers any
of this — an ordinary field error still renders in the form and never discards the edit. Note
that "Reset preview" is not a reload: it re-picks the same in-memory row.

**Map errors are split by kind.** `AreaGuideMapCanvas` emits `error` with a second
`{ fatal, message }` argument — fatal only before load (the map never became usable), non-fatal
for post-load Mapbox tile or sprite failures. The page keeps them in separate refs: a transient
message clears on the next successful feed, refresh, selection or country change, while a fatal
one persists until the map actually loads, and the alert renders the fatal message first so it is
never masked by a later transient one.

**After creating content the picked location is cleared immediately** — removing the "Add content
here" panel that invited a duplicate upload — and the new draft is selected once the refreshed
feed contains it. The match requires an unseen UUID, the same kind, and a placement within 1e-5
degrees (the 8-decimal column) of the picked point, preferring the typed title; an older
same-kind asset merely scrolling into a later viewport is therefore never mistaken for the new
draft, and choosing anything else drops the pending match.

**Project drawer.** Descriptors carry `href` wherever the page knows it, resolved from the
caller's own descriptor, then an accumulated map-feed card map, then the viewed asset's
marker `href`; when none is known the portal endpoint's own 422 body still supplies one, so a
non-Malaysian project never dead-ends. The drawer is a real modal dialog: `role="dialog"` +
`aria-modal="true"`, a Tab / Shift+Tab wrap and a document `focusin` listener that pulls back
focus landing on the page beneath, released before focus returns to the trigger. Layering is DOM
order and the panel is teleported to `body`, so anything contained by or following the panel
counts as a higher layer and nested dialogs (the asset viewer's lightbox, a modal opened from
inside) stay reachable and own their own Escape. The converse is a real consequence on this
page: a dialog whose teleport container was created *before* the drawer is painted behind it and
cannot hold focus while the drawer is open; closing the drawer restores it. Failures are
classified rather than uniformly retried — transient (network / 5xx) is the only kind that
offers "Try again"; gone (404, UUID mismatch, missing detail URL) offers nothing; standalone
(the 422 above, an unknown adapter, a country-slug mismatch) offers an "Open project page"
new-tab link, passed through a same-origin guard so a `//host/…` value in a response body is
refused outright.

**Dependency note.** `@photo-sphere-viewer/markers-plugin` is pinned to exactly `5.11.5` while
its siblings (`core`, `compass-plugin`) use `^5.11.5`, and two three.js copies coexist — Photo
Sphere Viewer's core bundles a nested `three@0.169.0` while the plugins' bare `import … from
"three"` resolves the top-level `three@0.166.1`, with no `dedupe` in `vite.config.js`. This is
harmless for the SVG polygon/polyline/circle markers in use; it would bite on an `npm update`
that moves core to 5.12.x, or if a 3D marker type (`imageLayer` / `videoLayer`) is ever added and
mixes `Mesh` classes across versions. Deploy trap: `package.json`, `package-lock.json` **and**
`yarn.lock` must be committed together, or the deploy's install misses the plugin and the
panorama editor breaks.

### Shared media and deployment runbook

Read the [Media handbook](/docs/modules_handbook/shared/media/readMe.md#reference-usage--shared-area-guide-assets)
before changing upload or deletion. Dedicated `area_guide_media` tables are deliberately
outside the catalogue `media` mirror/copy lifecycle. Omitting `MediaContext` preserves
the original local media defaults. Cleanup jobs carry scope, database target identity
and cleanup key so equal numeric IDs cannot delete another scope's file.

A cleanup row now has **three** states — `pending`, `writing` (a stream upload owns its intent)
and `processing` (a worker or a synchronous delete owns the task while its storage delete runs
**outside** any transaction) — each leased: `media.writing_lease_minutes` (default 60) and
`media.cleanup_claim_minutes` (default 15), set by `MEDIA_WRITING_LEASE_MINUTES` and
`MEDIA_CLEANUP_CLAIM_MINUTES`. The scanner hands an expired lease of either kind back to
`pending`. The writing lease must stay well above the slowest realistic upload — video reaches
80 MiB here — because a reclaimed intent makes the finishing writer delete the object it just
uploaded. The consequence of the longer lease is latency, deliberately: with an hourly scanner,
an object left behind by a crashed upload can sit for roughly two hours before it is swept.
The Media handbook owns the full contract.

The scheduled `media:dispatch-cleanup --scope=area-guide` runs hourly with overlap
protection only when the source is database and editing is enabled. It intentionally
does not depend on the map UI flag: hiding authoring must not strand cleanup work.
The pre-existing unscoped command still scans local media only. Queue workers and the
scheduler must run on a deployment with shared media table/storage write permission.

Production setup is an operator runbook, **not evidence that production was changed**:

1. Verify every participating site's `catalogue` connection reaches the same intended
   live master as `catalogue_master`. Preserve existing project read topology and
   SELECT-only users unless this site is deliberately becoming an Area Guide writer.
2. With deployment migration credentials, apply only the named Area Guide migrations
   on that master: registry `2026_09_13_160000`, media `2026_09_13_220000` and map assets
   `2026_09_13_230000`. Do not run `migrate:fresh`, catalogue copy/mirror or the entire
   historical migration directory as an upload setup shortcut. Example commands:

   ```shell
   php artisan migrate --database=catalogue --path=database/migrations/catalogue/2026_09_13_160000_create_area_guide_registry.php --force
   php artisan migrate --database=catalogue --path=database/migrations/catalogue/2026_09_13_220000_create_area_guide_media.php --force
   php artisan migrate --database=catalogue --path=database/migrations/catalogue/2026_09_13_230000_create_area_guide_map_assets.php --force
   php artisan migrate --database=catalogue --path=database/migrations/catalogue/2026_09_14_120000_create_area_guide_buildings.php --force
   php artisan migrate --database=catalogue --path=database/migrations/catalogue/2026_09_14_140000_create_area_guide_panorama_links.php --force
   ```

3. Runtime editors need SELECT/INSERT/UPDATE on `area_guide_countries`,
   `area_guide_regions`, `area_guide_areas`, `area_guide_assets`, `area_guide_placements`,
   `area_guide_hotspots`, `area_guide_buildings`, `area_guide_building_tabs` and
   `area_guide_panorama_links`;
   `area_guide_media` and `area_guide_media_cleanups` need
   those permissions plus DELETE for successful lifecycle
   completion. Keep these grants table-scoped; no new write grant on `catalog_projects`,
   catalogue `media` or unrelated master tables is needed. Migration/schema privileges
   belong to the deployment operator, not the web credential. Read-only sites retain
   read grants and disable editing/shared cleanup scheduling.
4. Point `AREA_GUIDE_MEDIA_DISK` at a private bucket every participating site resolves
   the SAME way — the media rows are shared, so each row stores only a disk NAME and
   every site must turn that name into the same object.
   **One bucket for the whole platform (the common case): leave every `AREA_GUIDE_GCS_*`
   value empty.** `area_guide_shared` then inherits the general `GOOGLE_CLOUD_PROJECT_ID`
   / `GOOGLE_CLOUD_KEY_FILE` / `GOOGLE_CLOUD_STORAGE_BUCKET` / `GOOGLE_CLOUD_STORAGE_PATH_PREFIX`
   values, so there is one set of credentials to keep in step instead of two, and
   `AREA_GUIDE_MEDIA_DIRECTORY` (default `area-guide`) keeps shared uploads in their own
   key prefix beside the site's own `media/` objects. Keeping the ALIAS rather than setting
   `AREA_GUIDE_MEDIA_DISK=gcs` is what makes a later split cheap: if one site ever moves its
   own media to a per-site bucket, only that site's `AREA_GUIDE_GCS_*` needs filling in and
   the already-stored rows still resolve. `gcs` as the disk name is equally correct today
   and becomes wrong the moment a site's own bucket diverges.
   **Separate buckets:** fill `AREA_GUIDE_GCS_PROJECT_ID`, `AREA_GUIDE_GCS_KEY_FILE`,
   `AREA_GUIDE_GCS_BUCKET` and optional `AREA_GUIDE_GCS_PATH_PREFIX` with the shared
   bucket's values, identically on every site. A relative key-file path resolves against
   the project directory. Either way, do not repoint a site's existing `gcs` disk away from
   its own media bucket merely to share Area Guide files. Use bucket-scoped writer/read
   permissions and the existing uniform bucket access visibility handler. Public disks and
   production local-driver disks are rejected.
5. Install `ffprobe` where the PHP process can execute it and set `AREA_GUIDE_FFPROBE`
   if it is not on PATH. Configure upload/body/time limits for the published file limits,
   and verify the Mapbox token for each site's origin.
6. Install the site-local Area Guide permission migration, import the registry using
   its dry-run then `--apply` workflow, and configure `AREA_GUIDE_CONTENT_SOURCE=database`,
   `AREA_GUIDE_CONTENT_CONNECTION=catalogue`, writer `AREA_GUIDE_CONTENT_EDITING=true`
   and `AREA_GUIDE_MAP_ENABLED=true`. Keep each site's `AREA_GUIDE_SITE_KEY` stable and
   distinct. Rebuild configuration caches and restart workers according to deployment
   practice so web and queue processes share the same ownership settings.
7. Verify on two real deployments: upload/preview/publish, read the same asset after
   refresh, deny unauthorized panorama/file access, reject a stale revision, replace
   without losing the old file on failure, and drain shared cleanup. Include desktop
   and mobile model/video/panorama rendering. Local tests cannot prove these grants,
   bucket settings or cross-deployment behavior.
8. **Panoramas stored before 2026-09-15** (camera originals, up to 20,000 px / 50 MB) load too
   slowly to be usable. After that release lands, on the master with the deploy user:
   `php artisan area-guide:optimise-panoramas` (dry run) then `--apply` — see *Panorama
   delivery* above. Uploads made after the release are normalised on the way in and need nothing.

The media migration refuses rollback while media or cleanup rows remain. Do not use
schema rollback to unpublish content; disable the feature or archive through the editor.

### Getting production's media onto a development machine (2026-09-18)

The registry rows and the FILES are two separate copies, and a machine with one but not the
other looks broken in a way nothing reports. The rows come down with the tables (see the
[registry handbook](/docs/modules_handbook/shared/project-catalogue/area-guide-registry/readMe.md#working-on-the-area-guide-locally-2026-09-18));
the files live in Google Cloud Storage.

- **Two buckets:** `petav3-prod` and `petav3-dev`. `AREA_GUIDE_GCS_*` are normally empty, so
  `area_guide_shared` inherits the general `GOOGLE_CLOUD_STORAGE_BUCKET` — which means
  pointing that one line at production points the Area Guide's media there too.
- **Copy the PREFIX, never the bucket.** `area-guide/` was **11 objects, 92 MB** on
  2026-09-18; the bucket as a whole was **43,284 objects, 34 GB** (`catalogue/` alone is 26 GB
  of project imagery a developer almost never needs).
- **Copy server side.** `StorageClient` → `$object->copy($targetBucket, ['name' => $name])`
  duplicates the object inside Google; nothing crosses the developer's connection. Skip an
  object already present at the same size so an interrupted run can simply be repeated.
- ⚠️ **A wrong bucket fails SILENTLY on the server.** Signing is computed offline from the key
  and the object path, with no call to GCS, so the app mints a perfectly good URL and the
  BROWSER gets the 404. Nothing reaches any log. When models, panoramas or area videos are
  blank locally while the rest of the page is fine, suspect the bucket first — everything else
  on that page comes from the database or from YouTube.
- ⚠️ **GCS cannot tell you which mistake you made.** "The specified bucket does not exist"
  is the answer for a wrong NAME *and* for a service account without permission to see it, so
  check the bucket name and the service-account file against production together. A scoped
  account also cannot `storage.buckets.get` or `storage.buckets.list` at all, so probe with an
  OBJECT operation (`Storage::disk(...)->exists(...)`), never `bucket()->exists()`, or a
  perfectly healthy configuration reports itself as broken.
- ⚠️ **Prefer the narrowest key locally.** An account scoped to `petav3-dev` cannot reach
  production even if a bucket name is mistyped; one that can write both leaves the spelling of
  a bucket as the only thing between a local upload and production's storage.

### Current local setup and evidence

On this checkout, the inspected local configuration is `APP_ENV=local`,
`APP_URL=https://petav3.test`, site database `petav3`, database source enabled with
`area_guide_local` targeting `petav3_area_guide`, editing/map enabled, and the private
`area_guide_local` filesystem disk rooted at `storage/app/area-guide-private`.
`AREA_GUIDE_FFPROBE` points to the local WinGet-installed executable. These are local
development settings, not production defaults; existing project catalogue reads and
their read-only master credential remain separate. The disposable HTTP test database
is `petav3_area_guide_testing`.

The coordinated isolated scratch run passed the expanded map HTTP suite, now
**28 tests / 356 assertions** after the 2026-09-14 remediation (it was 22 / 287), including
country profiles, project visibility, cross-site marker baselines (polygon baselines at the time), hidden-project draft edits,
published panorama privacy, the new field errors for an unavailable project, the 422 standalone
body, the ftyp-brand container rejections, the `no-store` header on the low-zoom/search response
and two query-count assertions for the batched project resolution.
Manage asset JSON/file/cover routes require the explicit administrator view policy;
a sales role with a granted permission cannot use the reader fallback to expose editor
metadata. Read-only administrators can still preview existing content.
Existing Guide/tutorial/project-preview regressions passed 61 / 952. Media ownership,
cleanup and spherical geometry have separate passing selections. These counts overlap
with earlier runs and are not a single distinct total.
The current consolidated browser, test, build and outstanding deployment evidence is
in [the local acceptance record](validation.md), including the dated **2026-09-14 remediation
checkpoint** and its remaining open items. Synthetic browser fixtures establish
interaction behavior, not representative production-asset or two-deployment acceptance; the
remediation checkpoint itself ran automated tests only, with no browser, build, production or
real-asset acceptance.

## Reference usage

The canonical authoring path is `AreaGuideAssetsController::save()` →
`SaveAssetRequest` / `AreaGuideUpload` → explicit shared `MediaService` context →
`AreaGuideAssetRepository`. New consumers must preserve the storage/attachment order:

```php
$stored = $media->storeFromPath(null, $file->getRealPath(), [
    'context' => MediaContext::areaGuide(),
    'collection' => 'model',
    'mime' => 'model/gltf-binary',
    'name' => 'model.glb',
]);
// Attach $stored->id through the asset repository with a validated revision.
// Delete superseded media only after that transaction commits.
```

For a new map surface, mount `CatalogueMapLayer` only after the parent Mapbox map is
ready, pass the explicit country code and authorized endpoint, then handle
`select-project` and `select-asset`. The parent owns selection, drawer and camera state.
Use `AreaGuideProjects` for project visibility and the
[shared detail adapter](/docs/modules_handbook/shared/project-detail/readMe.md) for a
drawer; do not assemble a second set of project detail tabs or bypass their gates.

## Related files

**Backend**

- [AreaGuideAsset.php](/src/AreaGuide/AreaGuideAsset.php), [AreaGuidePlacement.php](/src/AreaGuide/AreaGuidePlacement.php), [AreaGuideHotspot.php](/src/AreaGuide/AreaGuideHotspot.php) (the click-placed markers: project / building / video)
- Links shared by both editors: [AreaGuideLinks.php](/src/AreaGuide/Services/AreaGuideLinks.php) — target lookup, per-row bindability, uuid → id resolution for `SaveAssetRequest`, `SaveHotspotsRequest` and the repository
- Panorama delivery: [PanoramaImage.php](/src/AreaGuide/Support/PanoramaImage.php) (upload normalisation), `AreaGuideAssetRepository::swapMedia()`, `AreaGuideMediaController::CACHE_VERSIONED`, [OptimiseAreaGuidePanoramas.php](/app/Console/Commands/OptimiseAreaGuidePanoramas.php) (`area-guide:optimise-panoramas`); tests [PanoramaImageTest.php](/tests/Unit/AreaGuide/PanoramaImageTest.php), [AreaGuidePanoramaOptimisationTest.php](/tests/Feature/Manage/AreaGuidePanoramaOptimisationTest.php)
- AI-drafted building content: [DraftBuildingContent.php](/app/Actions/AreaGuide/DraftBuildingContent.php), [DraftBuildingRequest.php](/app/Http/Requests/Manage/AreaGuide/DraftBuildingRequest.php), `AreaGuideBuildingsController::draft()`, the prompt [area_guide_building_draft.md](/resources/prompts/area_guide_building_draft.md) (registered in [config/ai_prompts.php](/config/ai_prompts.php) as `AiRequest::PROMPT_AREA_GUIDE_BUILDING_DRAFT`); tests [AreaGuideBuildingDraftTest.php](/tests/Feature/Manage/AreaGuideBuildingDraftTest.php); the button and the confirm live in [BuildingForm.vue](/resources/js/Pages/Manage/AreaGuide/Partials/BuildingForm.vue) / [BuildingFormModal.vue](/resources/js/Pages/Manage/AreaGuide/Partials/BuildingFormModal.vue); see the [AI handbook](/docs/modules_handbook/shared/ai/readMe.md) for `AiClient`, grounding and the prompt registry
- Video points: `AreaGuideAssetsController::video()` / `videos()` (the page and the picker), `AreaGuideAssetPresenter::VIDEO_PAGE`, `AreaGuideAssetRepository::VIDEO_LINKED`; frontend [Video.vue](/resources/js/Pages/Manage/AreaGuide/Video.vue), [VideoUploadModal.vue](/resources/js/Pages/Manage/AreaGuide/Partials/VideoUploadModal.vue), [panoramaIcons.js](/resources/js/utils/areaGuide/panoramaIcons.js) (+ test); tests [AreaGuideVideoMarkersTest.php](/tests/Feature/Manage/AreaGuideVideoMarkersTest.php), [Video.test.js](/resources/js/Pages/Manage/AreaGuide/Video.test.js), [VideoUploadModal.test.js](/resources/js/Pages/Manage/AreaGuide/Partials/VideoUploadModal.test.js)
- Panorama links: [AreaGuidePanoramaLink.php](/src/AreaGuide/AreaGuidePanoramaLink.php), `AreaGuideAssetRepository::replaceLinks()`, `AreaGuideAssetPresenter::links()`, [SaveLinksRequest.php](/app/Http/Requests/Manage/AreaGuide/SaveLinksRequest.php), [PanoramaQueryRequest.php](/app/Http/Requests/Manage/AreaGuide/PanoramaQueryRequest.php), migration [2026_09_14_140000_create_area_guide_panorama_links.php](/database/migrations/catalogue/2026_09_14_140000_create_area_guide_panorama_links.php); frontend [panoramaFlight.js](/resources/js/utils/areaGuide/panoramaFlight.js) (+ test); tests [AreaGuidePanoramaLinksTest.php](/tests/Feature/Manage/AreaGuidePanoramaLinksTest.php)
- Custom buildings: [AreaGuideBuilding.php](/src/AreaGuide/AreaGuideBuilding.php), [AreaGuideBuildingTab.php](/src/AreaGuide/AreaGuideBuildingTab.php), [AreaGuideBuildingRepository.php](/src/AreaGuide/Repositories/AreaGuideBuildingRepository.php), [AreaGuideBuildingPresenter.php](/src/AreaGuide/Services/AreaGuideBuildingPresenter.php), [RichTextHtml.php](/src/AreaGuide/Support/RichTextHtml.php), [AreaGuideBuildingsController.php](/app/Http/Controllers/Manage/AreaGuide/AreaGuideBuildingsController.php), [SaveBuildingRequest.php](/app/Http/Requests/Manage/AreaGuide/SaveBuildingRequest.php), [DeleteBuildingRequest.php](/app/Http/Requests/Manage/AreaGuide/DeleteBuildingRequest.php), [BuildingQueryRequest.php](/app/Http/Requests/Manage/AreaGuide/BuildingQueryRequest.php), migration [2026_09_14_120000_create_area_guide_buildings.php](/database/migrations/catalogue/2026_09_14_120000_create_area_guide_buildings.php); frontend [BuildingDrawer.vue](/resources/js/Components/AreaGuide/BuildingDrawer.vue), [BuildingForm.vue](/resources/js/Pages/Manage/AreaGuide/Partials/BuildingForm.vue), [BuildingFormModal.vue](/resources/js/Pages/Manage/AreaGuide/Partials/BuildingFormModal.vue); tests [AreaGuideBuildingsTest.php](/tests/Feature/Manage/AreaGuideBuildingsTest.php), [RichTextHtmlTest.php](/tests/Unit/AreaGuide/RichTextHtmlTest.php), [BuildingDrawer.test.js](/resources/js/Components/AreaGuide/BuildingDrawer.test.js), [BuildingFormModal.test.js](/resources/js/Pages/Manage/AreaGuide/Partials/BuildingFormModal.test.js)
- [AreaGuideAssetRepository.php](/src/AreaGuide/Repositories/AreaGuideAssetRepository.php), [AreaGuideAssetPresenter.php](/src/AreaGuide/Services/AreaGuideAssetPresenter.php), [AreaGuideProjects.php](/src/AreaGuide/Services/AreaGuideProjects.php)
- [AreaGuideAssetsController.php](/app/Http/Controllers/Manage/AreaGuide/AreaGuideAssetsController.php), [AreaGuideMapController.php](/app/Http/Controllers/Main/Portal/AreaGuideMapController.php), [AreaGuideMediaController.php](/app/Http/Controllers/Main/Portal/AreaGuideMediaController.php)
- [SaveAssetRequest.php](/app/Http/Requests/Manage/AreaGuide/SaveAssetRequest.php), [SavePlacementRequest.php](/app/Http/Requests/Manage/AreaGuide/SavePlacementRequest.php), [SaveHotspotsRequest.php](/app/Http/Requests/Manage/AreaGuide/SaveHotspotsRequest.php), [MapProjectsRequest.php](/app/Http/Requests/Main/AreaGuide/MapProjectsRequest.php), [ProjectMapQueryRequest.php](/app/Http/Requests/Main/AreaGuide/ProjectMapQueryRequest.php)
- [AreaGuideUpload.php](/app/Rules/AreaGuideUpload.php), [AreaGuideAngle.php](/app/Rules/AreaGuideAngle.php), [AreaGuideViewerAccess.php](/src/AreaGuide/Support/AreaGuideViewerAccess.php), [AreaGuideContentAccess.php](/src/AreaGuide/Support/AreaGuideContentAccess.php), [AreaGuideContentConnection.php](/src/AreaGuide/Support/AreaGuideContentConnection.php), [AreaGuideActor.php](/src/AreaGuide/Support/AreaGuideActor.php)
- [MediaContext.php](/src/Common/Support/MediaContext.php), [AreaGuideMedia.php](/src/Common/AreaGuideMedia.php), [AreaGuideMediaCleanup.php](/src/Common/AreaGuideMediaCleanup.php), [MediaService.php](/src/Common/Services/MediaService.php), [MediaRepository.php](/src/Common/Repositories/MediaRepository.php), [MediaCleanupOutbox.php](/src/Common/Services/MediaCleanupOutbox.php)
- [CleanupMediaObject.php](/app/Jobs/Media/CleanupMediaObject.php), [DispatchMediaCleanupTasks.php](/app/Console/Commands/DispatchMediaCleanupTasks.php), [Kernel.php](/app/Console/Kernel.php)

**Frontend**

- [Map.vue](/resources/js/Pages/Manage/AreaGuide/Map.vue), [Panorama.vue](/resources/js/Pages/Manage/AreaGuide/Panorama.vue), [Video.vue](/resources/js/Pages/Manage/AreaGuide/Video.vue), [AreaAssetForm.vue](/resources/js/Pages/Manage/AreaGuide/Partials/AreaAssetForm.vue), [AreaAssetFormModal.vue](/resources/js/Pages/Manage/AreaGuide/Partials/AreaAssetFormModal.vue), [VideoUploadModal.vue](/resources/js/Pages/Manage/AreaGuide/Partials/VideoUploadModal.vue)
- [AreaGuideMapCanvas.vue](/resources/js/Components/AreaGuide/AreaGuideMapCanvas.vue), [CatalogueMapLayer.vue](/resources/js/Components/AreaGuide/CatalogueMapLayer.vue), [AssetViewer.vue](/resources/js/Components/AreaGuide/AssetViewer.vue), [BuildingPanorama.vue](/resources/js/Components/AreaGuide/BuildingPanorama.vue), [ProjectDrawer.vue](/resources/js/Components/AreaGuide/ProjectDrawer.vue)
- [projectModels.js](/resources/js/utils/areaGuide/projectModels.js), [panoramaGeometry.js](/resources/js/utils/areaGuide/panoramaGeometry.js), [MalaysiaMap.vue](/resources/js/Components/AreaGuide/MalaysiaMap.vue), [AreaGuidePanel.vue](/resources/js/Components/AreaGuide/AreaGuidePanel.vue)

**Configuration, migrations and routes**

- [area_guide_content.php](/config/area_guide_content.php) (source, connection, editing/map switches, `media_disk`, `media_directory`, upload limits, `ffprobe`), [filesystems.php](/config/filesystems.php), [database.php](/config/database.php), [media.php](/config/media.php) (signed-URL TTL and the writing/claim lease minutes)
- [Registry migration](/database/migrations/catalogue/2026_09_13_160000_create_area_guide_registry.php), [Shared media migration](/database/migrations/catalogue/2026_09_13_220000_create_area_guide_media.php), [Map assets migration](/database/migrations/catalogue/2026_09_13_230000_create_area_guide_map_assets.php), [Site permission migration](/database/migrations/2026_09_13_200001_grant_area_guide_permissions.php)
- [routes/web.php](/routes/web.php) (`manage.area-guide.*`), [routes/main.php](/routes/main.php) (`main.portal.area-guide.map.*`), [Permission.php](/src/Auth/Permission.php)

**Tests and related handbooks**

- [AreaGuideMapAssetsTest.php](/tests/Feature/Manage/AreaGuideMapAssetsTest.php), [AreaGuideVideoMarkersTest.php](/tests/Feature/Manage/AreaGuideVideoMarkersTest.php), [AreaGuideMediaTest.php](/tests/Unit/Services/AreaGuideMediaTest.php), [AreaGuideAngleTest.php](/tests/Unit/AreaGuide/AreaGuideAngleTest.php), [video/container fixtures](/tests/Fixtures/area-guide/README.md)
- [projectModels.test.js](/resources/js/utils/areaGuide/projectModels.test.js), [panoramaGeometry.test.js](/resources/js/utils/areaGuide/panoramaGeometry.test.js), [BuildingPanorama.test.js](/resources/js/Components/AreaGuide/BuildingPanorama.test.js), [AreaGuideMapIntegration.test.js](/resources/js/Components/AreaGuide/AreaGuideMapIntegration.test.js), [AreaGuideMapCanvas.test.js](/resources/js/Components/AreaGuide/AreaGuideMapCanvas.test.js), [Panorama.test.js](/resources/js/Pages/Manage/AreaGuide/Panorama.test.js)
- Also on disk and previously unlisted: [Map.test.js](/resources/js/Pages/Manage/AreaGuide/Map.test.js), [AreaAssetFormModal.test.js](/resources/js/Pages/Manage/AreaGuide/Partials/AreaAssetFormModal.test.js), [AreaContentFormModal.test.js](/resources/js/Pages/Manage/AreaGuide/Partials/AreaContentFormModal.test.js), [CatalogueMapLayer.test.js](/resources/js/Components/AreaGuide/CatalogueMapLayer.test.js), [ProjectDrawer.test.js](/resources/js/Components/AreaGuide/ProjectDrawer.test.js). `resources/js/Pages/Manage/AreaGuide/Index.vue` (the registry editor page) still has no suite of its own.
- [Media](/docs/modules_handbook/shared/media/readMe.md), [Shared Project Detail](/docs/modules_handbook/shared/project-detail/readMe.md), [Area Guide registry](/docs/modules_handbook/shared/project-catalogue/area-guide-registry/readMe.md), [Area Guide](/docs/modules_handbook/main/area-guide/readMe.md), [shared-content decisions](/docs/modules_handbook/shared/project-catalogue/area-guide-shared-content-plan.md)
