# AGENTS.md

## Overview

`demiurge/public/www` is a Next.js 16 monorepo: a markdown document/post platform plus an embedded Three.js isometric 3D engine with Ammo.js physics. It runs on the shared VMAX backend and publishes to VMAX.ai.

## Documentation map: READ the matching doc BEFORE editing a subsystem

The detail lives in per-subsystem docs, not here. Each doc is the **contract** for its area. Do not edit a subsystem without reading its doc first. Sub-`AGENTS.md` files nest; always read the **deepest** one that owns your file.

| Read this | Covers | Before you... |
|---|---|---|
| [`demiurge/public/www/app/AGENTS.md`](demiurge/public/www/app/AGENTS.md) | App Router front door: route map, Next.js conventions, how a post URL becomes a page | touch any route, page, layout, or the SSR pipeline |
| ↳ [`demiurge/public/www/app/-/AGENTS.md`](demiurge/public/www/app/-/AGENTS.md) | the `app/-/` standalone reference and test routes | add or use a test/debug/reference route |
| [`demiurge/public/www/three-isometric-engine/AGENTS.md`](demiurge/public/www/three-isometric-engine/AGENTS.md) | the 3D engine: scene architecture, flat-sea and seafloor placement rules, mount manifest, per-frame loop, shadow scheduling, shaders, physics | touch anything under `three-isometric-engine/` |
| ↳ [`demiurge/public/www/three-isometric-engine/world-entities/AGENTS.md`](demiurge/public/www/three-isometric-engine/world-entities/AGENTS.md) | per-entity contracts (publishes/subscribes, manifest gate, quirks) | edit or add an `Entity` |
| ↳ [`demiurge/public/www/three-isometric-engine/world-meshes/AGENTS.md`](demiurge/public/www/three-isometric-engine/world-meshes/AGENTS.md) | per-factory reference | edit or add a mesh factory |
| ↳ [`demiurge/public/www/three-isometric-engine/agent-simulation/AGENTS.md`](demiurge/public/www/three-isometric-engine/agent-simulation/AGENTS.md) | flag cloth, boat-placement geometry (water-sdf, offshore band), c-space grid | touch flag cloth, boat placement, or wayfinding geometry |
| [`demiurge/public/www/next-vmax/AGENTS.md`](demiurge/public/www/next-vmax/AGENTS.md) | `next-vmax` component invariants not in its skill doc | edit `next-vmax/` |
| ↳ [`demiurge/public/www/next-vmax/islands/AGENTS.md`](demiurge/public/www/next-vmax/islands/AGENTS.md) + [`demiurge/public/www/next-vmax/islands/island-scenes.ts`](demiurge/public/www/next-vmax/islands/island-scenes.ts) | The preset catalog ([`/glossary#preset`](/glossary#preset)); `IslandStage` instances any row. Browse at `/-/vmax-tools-template/islands/{key}` | build, change, or verify any island / structure / scene |
| [`demiurge/public/www/next-vmax-tools/AGENTS.md`](demiurge/public/www/next-vmax-tools/AGENTS.md) | tools-shell invariants not in its skill doc | edit `next-vmax-tools/` |
| [`demiurge/public/www/document-system-common/AGENTS.md`](demiurge/public/www/document-system-common/AGENTS.md) | shared non-React document layer | edit `document-system-common/` |
| [`demiurge/public/www/document-system-components/AGENTS.md`](demiurge/public/www/document-system-components/AGENTS.md) | markdown-post React UI plus the six overlay shaders | edit `document-system-components/` |
| [`demiurge/public/www/next-slate-markdown/AGENTS.md`](demiurge/public/www/next-slate-markdown/AGENTS.md) | Slate.js editor, API client, public-page chrome | edit `next-slate-markdown/` |
| [`skills/post-permissions-and-viewing/SKILL.md`](skills/post-permissions-and-viewing/SKILL.md) | Sign-in, draft review, author/admin writes, publication, team URLs, and partner domain onboarding across the website and sibling API | diagnose or change post permissions, add or revoke partner access |
| [`demiurge/public/www/public/SKILL.md`](demiurge/public/www/public/SKILL.md) | VMAX.ai posting API, Slate JSON serialization, common-mistakes checklist | run any posting workflow |
| [`demiurge/public/www/ALGORITHMS.md`](demiurge/public/www/ALGORITHMS.md) | every algorithm with code demos, plus section 24, the standing performance evaluation ledger (cost models, backtests, adopted and rejected alternatives). **Jump to its Table of Contents; do not read end-to-end** (143 KB) | implement or modify an algorithm; before proposing a faster one, read its ledger row |
| [`demiurge/public/www/skills/`](demiurge/public/www/skills) `*/SKILL.md` | task workflows (new island/structure/agent, the structure builder and its uuid identity model, shader and TypeScript perf discipline, LaTeX to VMAX, [`use-campaign-api`](demiurge/public/www/skills/use-campaign-api/SKILL.md), the ONE source of truth for the Campaign product and API, [`create-vmax-presentation`](demiurge/public/www/skills/create-vmax-presentation/SKILL.md), the ONE source of truth for the presentation Markdown dialect, deck conventions, and the handoff an agent with a user's API key follows to create a presentation, [`use-slides-api`](demiurge/public/www/skills/use-slides-api/SKILL.md), the ONE source of truth for the Slides application, its compiler, and the internet.dev slides API client, [`write-help-from-seth`](demiurge/public/www/skills/write-help-from-seth/SKILL.md), the voice and grounding standard for every `/help` card, and [`create-wood-block-simulation-illustration`](demiurge/public/www/skills/create-wood-block-simulation-illustration/SKILL.md), which turns a paper's diagram into a SandWoodBlock scene and red brief on `/-/sample`, linked from `.claude/skills/` so it runs as `/create-wood-block-simulation-illustration`) | invoke that workflow; read the one skill fully first |

## Top-level directories

| Directory | Purpose |
|---|---|
| `app/` | App Router pages plus the `app/-/` reference/test routes. See [`demiurge/public/www/app/AGENTS.md`](demiurge/public/www/app/AGENTS.md). |
| `common/` | Repo-wide shared utilities (`derive-world-mode.ts`, `theme-colors.ts`, `attach-theme-observer.ts`, `use-viewport.ts`, `use-mounted.ts`, `scene-modal-bridge.ts`, `navigation-links.ts`, `home-post-descriptions.ts`, `research-copy.ts`, `campaign-copy.ts`, `slides-copy.ts`, `page-return.ts`, `speech-voices.ts`). Invariants below. |
| `components/` | Top-level React components: page chrome, the modal system, sign-in, the home marketing stack, the brand mark, `ForestCacheWarmer`, `RootLayoutEffects`, `Providers`. Invariants below; page-level contracts in `app/AGENTS.md`. |
| `next-vmax/` | Application-component library for `app/-/` routes, including `ModalAuthentication` (the non-dismissable `/sign-in` Google-auth gate), `islands/` (preset catalog, `IslandStage`, `WorldStage`), `graphs/`. Contracts: [`demiurge/public/www/skills/create-old-vmax-application-ui/SKILL.md`](demiurge/public/www/skills/create-old-vmax-application-ui/SKILL.md) (deprecated, still the reference). |
| `next-vmax-tools/` | The `/-/vmax-tools-template` tools shell and `Form*` body kit (alias `@nextjs-vmax-tools/*`), a reference app to clone for any internal tool. `AppContainer` ([`/glossary#app-container`](/glossary#app-container)) wraps every page; `tools-pages.tsx` is the `TOOLS_PAGES` registry both routes read. See [`demiurge/public/www/skills/create-vmax-tools-page/SKILL.md`](demiurge/public/www/skills/create-vmax-tools-page/SKILL.md). |
| `document-system-common/` | Shared non-React document layer, including `shader-context` (shared WebGL2 manager). See its `AGENTS.md`. |
| `document-system-components/` | React UI for markdown posts plus the six overlay shaders. See its `AGENTS.md`. |
| `next-slate-markdown/` | Slate.js editor, API client, public-page chrome. Posts persist as markdown. See its `AGENTS.md`. |
| `three-isometric-engine/` | Three.js engine with physics, shaders, scenes. See its `AGENTS.md` and [`demiurge/public/www/skills/fast-threejs-shaders-check/SKILL.md`](demiurge/public/www/skills/fast-threejs-shaders-check/SKILL.md). |
| `modules/` | Tiny shared modules and vendored `minisearch.js` + `minisearch.d.ts` (imported as `@modules/minisearch`). |
| `pages/` | Legacy Pages Router; one endpoint (`oauth.tsx`). |
| `scripts/` | Build/utility scripts (`npm run script`). |
| `public/` | Static assets including `SKILL.md`, mirrored at `/skill` and `https://vmax.ai/SKILLS`. |
| `skills/` | Project-local agent skills. |

### Cross-file invariants in `common/` and `components/`

- **All internal navigation is a HARD load: never soft-navigate.** No `next/link`, no `useRouter`/`router.push`/`router.replace`: hrefs render plain `<a>` and programmatic navigation assigns `window.location.href`. Pages stack full-viewport fixed overlays (scene backdrops, the persistent `ModalRenderer`/`SceneModalBridge` in root `Providers`), so a client-side transition would carry the previous page's overlays onto the next page.
  - Same-page URL cleanup that must NOT reload uses `window.history.replaceState`, which is not a navigation.
- **`Providers` seeds `activeConfigs` WITHOUT reading `window.matchMedia` in the `useState` initializer** so server and client agree on the first render; the mount `useEffect` reconciles `prefers-color-scheme`. `useViewportMode` / `useViewportSize` (`common/use-viewport.ts`, `MOBILE_BREAKPOINT` 768) follow the same rule.
- **No clock is read during render: every counter waits for the browser to mount.** `common/use-mounted.ts` `useMounted()` is `false` on the server and the first client render and `true` after the mount effect; `useMountedAtMilliseconds()` is `0` until then and the mount time after. A `useState(() => Date.now())` initializer or a module-level `Date.now()` runs on the server and hydrates against a different clock, so an elapsed label rendered from it mismatches or shows the server's uptime. Every elapsed counter (`next-vmax-tools/FormCampaignElapsedLine` `useElapsedMilliseconds`) reads `0` until mounted and `FormTemplateCampaign` stamps its refine fixture through `useMountedAtMilliseconds`; the braille loaders start at frame `0` on both sides and advance only in their effects, which is the same rule by construction.
  - `useHydrated()` (same file) is the gate for a value that exists only on the client and may change before a component hydrates: it is `useSyncExternalStore` with a server snapshot of `false`, so it reads `false` on the server and in every hydration pass (including a deferred `<React.Activity>` section hydrating after its provider changed) and `true` from the first render of anything mounted after load. Prefer it to `useMounted` when a late client mount must render the value in its first commit; `useMounted` always costs one commit.
- **`common/derive-world-mode.ts` `sceneNeedsPhysics(props)` is the ONE reader of which scene flags load Ammo** (`?debug=true`, `hasPropel` on any scene including the inline `/campaign` whose hwacha shatters trebuchets, then `hasDistantIslands`, a dormant non-desert volcano with its quarry, the `large-mountain-quarry` showcase, `hasCrystalAgents`, `lumberjacks`, the `desert-motte-airship` showcase, the `sodium-reactor` showcase whose batteries and ship break into debris; every other inline scene is off). `GLComponent` evaluates it once; a new destructible or agent gate is added there and to the literal table in `common/derive-world-mode.test.ts`, never as a second check in an entity (`three-isometric-engine/AGENTS.md`, "Physics").
- `common/scene-frame.ts` owns the engine-to-HUD frame subscription. `GLComponent` publishes after its projections and shader overlays; `useSceneControlPosition` consumes the latest coordinates without a separate animation loop or per-frame React state. The control behavior contract lives in `app/AGENTS.md`.
- `requestSceneModal(key)` (`common/scene-modal-bridge.ts`) fires `SCENE_MODAL_EVENT` on `window`; `SceneModalBridge` (mounted once in `Providers`, deduped) opens the registered modal (`sign-in` → `ModalAuthentication`, `campaign-access` → `FormCampaignAccess`). It is the only way a Three.js scene element, or a plain function outside React, opens a React modal it cannot reach through hooks.
- **`attachThemeObserver` stores its MutationObserver on `mesh.userData.themeObserver`**: `three-isometric-engine/common/three/scene-teardown.ts` disconnects observers under that key on unmount. Renaming the key leaks observers.
- **`DOCUMENT_NAVIGATION_SECTIONS` (`common/navigation-links.ts`) is the shared site-menu registry** for `DocumentHeading`, `NavigationDropDownButton`, and `Logo`: `vmax.ai`, `team`, then `social`, with entries alphabetized within each section so every page presents the same hierarchy.
- **`NavigationMenuItem.themeAction` and `visibility` are serializable tokens.** `NavigationMenuTrigger` calls `resolveNavigationItems` when the menu opens, so the `vmax` cookie is read in the browser and each opening reflects the current session. Campaign links to `CAMPAIGNS_URL` only when signed in; Sign in appears only when signed out. Toggle Theme reads the current body class on activation and uses `Utilities.setTheme`, preserving the independent font classes.
- **`TEAM_MENU_ITEMS` derives from `SOCIAL_LINKS` without reordering it**, because the engine uses the roster's indices for scene placement. New chess-island members append before Jimmy Lee, whose final roster slot is reserved for the Designer island. The alphabetized projection carries each person's LinkedIn link and the existing `unavatar.io/x/{handle}` avatar source; `TEAM_NAVIGATION_SECTION` supplies both the site menu and the home Team popover so names, portraits, and links cannot drift. Members without a personal X account, including Albert Luo, use `https://x.com/VmaxAI` and its avatar; their names retain their personal LinkedIn destination. A shared X URL is not a unique person key.
- **`CAMPAIGNS_URL` and `CAMPAIGN_BLUEPRINT_URL` remain in `common/navigation-links.ts`**, because `tools-pages.tsx` imports this module and re-exports them as `CAMPAIGNS_HREF` and `CAMPAIGN_BLUEPRINT_HREF`. A component outside the tools shell imports a URL from `navigation-links`, never from `tools-pages`: the registry statically imports every tools page body (KaTeX through `FormEquation`, the Slate editor and `marked` through `FormClientExperience`, the island stages), so one constant pulls all of it into that page's JavaScript. The site menu carries no template or debug-route links.
- **`common/scene-debug-flags.ts` is the ONE reader of the rendering-debug URL flags** (`isBareScene()` for `?bare=true`, `isPerfScene()` for `?perf=true`, `isFreezeScene()` for `?freeze=true`; the last two are engine-only and documented in `three-isometric-engine/AGENTS.md`, "`?perf=true`"). **`isBareScene()` is the ONE reader of the `?bare=true` flag**, shared across the React/engine boundary: `GLComponent` threads it into the scene manifest, and `VmaxAppHome` checks it inside its mount EFFECT (never during render, same rule as `Providers` and `matchMedia`) to suppress the `MARKETING_PANELS` stack. **Only the marketing panels go**: the engine's own projected tags are part of the scene under inspection, so the flag hides page chrome over the world and nothing belonging to the world. Contract: [`demiurge/public/www/three-isometric-engine/AGENTS.md`](demiurge/public/www/three-isometric-engine/AGENTS.md).
- **`home-post-descriptions.ts` resolves each blurb from `PUBLISHED_POSTS` by HREF, never by index.** `PUBLISHED_POSTS` is newest-first while `GLComponent` owns the fixed `post_number` → tag mapping (1 → Populora, 2 → UNIX CTF, 3 → PROPEL, 4 → Modal Coder). Keys are local post_numbers, not post UUIDs, so copy edits never touch an id.
- **`PUBLISHED_POSTS` (`navigation-links.ts`) is a CURATION list, not a content source.** Only its `href` is authoritative; titles, dates, and abstracts are fetched live through `Server.getResearchPosts()` (see `next-slate-markdown/AGENTS.md`), so a copy edit on VMAX.ai reaches every surface without a deploy. **Curation is not publication:** a curated post appears ONLY when the viewer can read it (`canReadPost`: published to everyone; a team post also to a verified viewer with the `canPreviewTeamPosts` flag, its owner, and public-access staff). An entry is never rendered from its checked-in title and abstract, which is how the unpublished modal-sdk post once appeared on `/research` and in every Research sidebar.
  - `/research` is the one place the feed is enumerated; the home page only links to it (`RESEARCH_URL`, `CAREERS_URL`).
  - `STATIC_RESEARCH_POSTS` is the checked-in `ResearchPost` projection: the API-outage fallback and the data `FormResearch` renders on `/-/vmax-tools-template/app-interface`. It lives in the tools registry so the row stays synchronously readable.
  - `getHomePostDescriptions` keeps its `PUBLISHED_POSTS` default because `FormIslandScene` is a client component calling it with no argument.
  - `publishedPostSlug(href)` is the shared href-to-slug reduction. `MODAL_CODER_URL` is curated for the chess-island article sign; its fallback date stays empty until supplied by the live post. Its checked-in description, grounded in the article copy, keeps the scene card present when the public metadata is unavailable; `getHomePostDescriptions` prefers non-empty live descriptions and resolves the fallback by href.
  - `components/VmaxAppPaperPostItem` and `components/DocumentLayout` have no caller.
- **`authorSocialLink(name)` (`navigation-links.ts`) must stay narrow.** `SOCIAL_LINKS` holds display names while `data.authors` carries publication forms (middle names, initials, extended surnames such as `Matthew Daborn-Sargent`), so `authorSocialKeys` reduces both sides to `first token + last token` (lowercased, periods/apostrophes stripped) and, for a hyphenated surname, also `first token + final hyphen segment`. A miss returns `null` (no glyph) rather than mislinking a real person; widening the match is not a safe cleanup.
- **`getCSSVariableColor` (`theme-colors.ts`) resolves through a temporary DOM element and `getComputedStyle(...).color`** so oklch/hsl/rgb values all normalise to `rgb(...)`; `THREE.Color` cannot parse oklch.
- **`theme-*` and `font-*` body classes are independent axes.** `setTheme` swaps only the theme class (a wholesale `body.className` assignment wipes the font class); `toggleFontClassName` keeps `font-*` classes mutually exclusive.
- **`ForestCacheWarmer` must stay mounted in root `app/layout.tsx`** so the forest-template cache is warm on every entry path, including direct navigation to `/[username]/[slug]`, and `createNeutralForest()` skips the bake. The warmup is idempotent.
- **Nothing in the root layout or root `not-found.tsx` statically imports Three.js, the engine, or the tools registry.** Both are part of EVERY route's module tree, and Next serializes the root not-found element into every page's payload, so their client imports ship to every page, `/use-campaign` and the home page included. `ForestCacheWarmer` imports `create-neutral-forest` with a dynamic `import()` inside its idle callback (the same module instance the engine uses, so the cache is shared); `PlayerAgentActionBar` loads `DockPreviewCanvas` with `React.lazy` (`app/AGENTS.md`); `app/not-found.tsx` renders only `NotFoundScreen` (`app/AGENTS.md`, route map). With the three in place `/use-campaign` loads 20 script chunks, 1.5 MB raw and 460 KB gzipped, against 36 chunks, 3.8 MB and 1.1 MB with the not-found tree and the two root imports static (`ALGORITHMS.md` 24o). A new root-layout or not-found import that reaches `three`, `@engine/*`, `tools-pages`, or `AppContainer` reopens that cost on every page.
- **`OutsideElementEvent` listens on `document`, not the overlay.** The modal overlay is `pointer-events: none` so a scene behind stays interactive; do not add an overlay `onClick` or re-enable overlay pointer events to "fix" dismissal.

#### Navigation popovers

- **`DropdownMenu` applies `next-vmax/Tooltip.module.css`'s `.tooltip` class directly**, so HelpTip's four gradient hairlines, square corners and theme-specific shadows stay shared. `DropdownMenu.module.css` owns the section hierarchy and the inner scroller, with a pinned 12px menu grid and 16px desktop base type so body-portalled menus do not inherit the larger public-post scale.
- **`DropdownMenu.module.css` steps each text section down at `max-width: 768px`**, matching `MOBILE_BREAKPOINT`: primary links 16px to 14px, team names in `.secondary` 14px to 12px, and team portraits 20px to 18px. Primary links use `--tooltip-font-size` and `--tooltip-line-height` so shared tooltip breakpoints cannot resize them again; social SVGs keep their explicit 20px size.
- **`DropdownMenu.module.css` sizes rows from the type, with no row minimum.** Each item pads a quarter grid above and below and half a grid at the sides, so a primary row is 28px, a team row 26px around its 20px portrait, and the rows shrink with the mobile type. The scroller inset and each side of a section divider are one 12px menu grid at every width, and the portrait sits nine pixels from its name; social controls keep their explicit 44px square.
- **`DropdownMenu.module.css` uses `.root.root` for `pointer-events: auto`**, giving the interactive menu higher specificity than the shared `.tooltip` rule's `pointer-events: none`. A single-class override depends on CSS chunk order and can make every menu link ignore pointer input.
- **`DropdownMenu` uses `FormHintAvatar.module.css`'s `.avatar` for team portrait frames**, so their gradient hairlines, 1px inset, and theme-specific shadows match Seth. The menu's `.avatar.avatar` pins a 20px square, 18px on mobile, independently of stylesheet order; the inner `.portrait` keeps the image and its fallback inside the frame.
- **`DOCUMENT_NAVIGATION_SECTIONS` and `TEAM_NAVIGATION_SECTION` set `hideHeading` on every section**, so the logo menu and home Team popover show their contents without visible section titles. `DropdownMenu` retains each section's `aria-label` so the groups remain named for assistive technology.
- **`DropdownMenu` items draw no focus state.** `.item:focus-visible` sets `outline: none` and only pointer hover tints the row, so the menu's first entry is never framed when the popup opens.
- **`DropdownMenuTrigger` owns a native button and a labelled, nonmodal dialog**, so Enter/Space activates the trigger, Tab follows native links and buttons, and Escape returns focus without closing an underlying modal. Arrow keys, Home, and End move through menu entries; an outside pointer press or focus leaving the popup dismisses it.
- **`DropdownMenuTrigger` sizes the popup before calling `Position.calculate` with zero scroll offsets**, because its portal uses fixed viewport coordinates. The larger vertical space around the trigger limits the inner scroller within the viewport height, and a 12px viewport margin keeps long team menus reachable on narrow or short screens.
- **`DropdownMenuTrigger` observes the trigger and popup with `ResizeObserver`, and only the body's class attribute with `MutationObserver`**, so image sizing and theme changes reposition the card without observing every moving scene node. Both observers and all listeners are removed on close.
- **`NavigationMenuTrigger` preserves explicit `navigationItems` as a complete replacement**, so a scene reference can supply its own links. `customNavigationItems` append inside the primary site section while the shared team and social sections stay present.

#### Modal system (`ModalContext`, `ModalRenderer`)

- `useModals()` returns `open`, `update(key, props)`, `close(key?)`, `closeAll()`; `close()` with no key closes the top card.
- **`update(key, props)` replaces an open card's props in place and returns `false` when no card holds that key.** A card's props are snapshotted at `open`, so a modal that follows a request (`next-vmax-tools/FormCampaignLaunchModal`) is advanced by the caller through `update` rather than closed and reopened, which would remount it into a new window slot. The presence check reads `modalStackRef` (mirrored from state every render), so a caller learns the viewer already closed the card and can raise the result another way; never reopen a fresh card to show a later phase.
- **A modal that owns a close control closes ITSELF through `useCloseSelf`** (key from `ModalInstanceProvider`, which `ModalRenderer` wraps around every modal): in windowed mode several cards are live at once, so a bare `close()` hits the wrong one.
- Items are keyed `${modal.key}-${viewportMode}` and `positions`/`frontKey` reset on the change, so crossing `MOBILE_BREAKPOINT` remounts every modal into a clean layout instead of stranding windows at stale coordinates.
- **`ModalComponent.placement = 'bottom-left-stack'` anchors the whole stack at the bottom-left on EVERY viewport**, used by the home panels and `ModalAuthentication`. It applies only when every open modal asks for it; a mixed stack falls back to the layouts below. Each card is `left`/`bottom` at `ANCHORED_MARGIN` (24px, 16px mobile), `min(480px, 100vw - margins)` wide on desktop and fluid on mobile (`100vw` minus the two 16px gutters, below `MOBILE_BREAKPOINT`) (`--panel-width: 100%` of that wrapper), with `--panel-max-height` leaving room for every card's `STACK_OFFSET` peek. Cards behind the front one use the stack's offset and blur, only the front card is interactive, and outside clicks dismiss nothing (each card closes through its own control). **The cards share ONE grid cell** (`.anchoredStack`, `display: grid`, every `.anchored` item at `grid-area: 1 / 1`, flex column with its card at `flex: 1`), so the row is as tall as the tallest card and every card stretches to it: equal heights from layout alone, with no measuring. The container carries the position, width, and max height; items carry only the stack offset and blur. A lone card (`/sign-in`) is simply its own height.
- **Mobile is a STACK**: lower cards offset by `STACK_OFFSET` and blurred by `STACK_BLUR` per level; only the top card is interactive (`.interactive` re-enables `pointer-events`), and outside-click dismissal attaches to the top card alone so one click peels one card.
- **Desktop is draggable WINDOWS.** A window sizes to its content (`--panel-max-height: none`, `--panel-overflow: visible`); layouts use heights measured by a `ResizeObserver` keyed on `data-modal-key`, `WINDOW_ASSUMED_HEIGHT` being only the first-paint placeholder. Clamping to a computed cell height makes cards grow their own scrollbars.
- **`ModalComponent.preferredWidth` opts a modal into a wider packed window.** `ModalRenderer` records the preference in the sticky slot roster and includes it when choosing column count and each card's width, capped by the viewport. A roster with an explicit preference uses `packWindows` instead of the hero composition; closed slots retain their width until the stack empties, so closing a card does not rearrange survivors. Ordinary modals keep the 460px cap; `CampaignAppInspection` requests 920px. The wrapper width, `--panel-width`, and drag clamp all use the same resolved width.
- **A drag writes the DOM, not React state.** `handlePointerMove` clamps through `clampWindowPosition` (`components/modal-window-drag.ts`, the one home of `WINDOW_KEEP_VISIBLE`) into a reused scratch record, writes `left` / `top` on the dragged element's style directly, and keeps the live point on `dragRef`; `setPositions` runs once on `pointerup` / `pointercancel` with that point, and `frontKey` still updates on `pointerdown`. A React commit that lands mid-drag (a `ResizeObserver` height) reads the live point for the dragged key, so it never snaps the card back to its origin. `modal-window-drag.test.ts` pins the clamp against the inline expressions over random viewports and deltas.
- **Outside-click dismissal is OFF in windowed mode**: a click on one window would close another. Windows close only through their own control.
- `composeWindowsAroundFocus` runs first; `packWindows` (shortest-column pack, `WINDOW_MIN_CELL` sets column count) takes over when the stack was never a composition (fewer than two cards ever slotted, e.g. a lone `ModalAuthentication`) or the viewport cannot fit two bands plus the minimum corridor.
- `composeWindowsAroundFocus` exists because the home page mounts four marketing panels over a 3D hero whose centre island is the subject; a plain pack reads as a grid over it.
  - Each modal key gets a STICKY slot the first time it is seen (`slotIndexByKey` in `slotIndexRef`, cleared only when the stack empties); slot parity picks the LEFT or RIGHT band. A live-index split renumbers survivors on close and swaps bands; the clear-on-empty stops a reopened stack from continuing the numbering.
  - **Both layout functions read the slot ROSTER (`slotRoster.rosterKeys`), never live `orderedKeys`, and `heights` is never pruned for a closed key.** The closed card's slot, height, and band space stay reserved, so a close moves nothing and survivors' placements are byte-identical until a resize. Composing over live keys re-centres the band (`placeBand` sums `stackHeight`), shifts later cards by `cardHeight + WINDOW_GAP`, drags `inward` (derived from `top`) horizontally, and drops a lone survivor into the pack.
  - The bands hold a clear corridor (`FOCUS_CORRIDOR_RATIO` of width, clamped by `FOCUS_CORRIDOR_MIN`/`FOCUS_CORRIDOR_MAX`) so the scene centre is never covered; they centre on `BAND_ANCHOR_RATIO`, stagger by `BAND_STAGGER`, and each card tucks inward by `FOCUS_ARC_DEPTH` scaled by its distance from `FOCUS_CENTER_RATIO` (falloff `FOCUS_SPAN_RATIO`), which is where the hero island renders under `WorldStage`'s `HOME_CAMERA_Y_OFFSET` (0.22 of canvas height).
  - `WINDOW_WIDTH_STEP` narrows each successive card off its sticky slot, floored at `WINDOW_MIN_WIDTH`, so `--panel-width` is per card and a card keeps the width it opened with.
- Drag raises via `frontKey`; `NON_DRAGGABLE` skips drags starting on a control; the clamp keeps `WINDOW_KEEP_VISIBLE` of the card's OWN width (from `dragRef`) on-screen.
- **Placement is written as `--panel-width`/`--panel-max-height` AND as an inline `width` on the `.windowed` wrapper.** The wrapper is `position: absolute` with `left`; without a pinned width a `width: 100%` card (`FormCard`, `FormModal`, `ModalAuthentication`) shrinks to `viewport - left`, height feeds x, x feeds width, width re-wraps copy, and the `ResizeObserver` `setHeights` loop throws `Maximum update depth exceeded`.

#### Home marketing stack and brand mark

Component-level contracts (`VmaxFlag`, `VmaxSpellTile`, `PanelShell` slots, `RootLayoutEffects`) live in `app/AGENTS.md`. Cross-file rules:

- `VmaxAppHome` opens the `marketingPanels(signedIn)` stack on mount (no Campaign waitlist card for a signed-in viewer); the top-left `DocumentHeading` mark (`onShowOverview`) is the ONE control that reopens it, and `hideMenu` hides it while a panel is open. There is no "The World" card: closing the stack is the explore action.
- `PanelShell` closes BY KEY through `useCloseSelf`. Its card is opaque (`app/AGENTS.md`).
- `PanelShell` switches OFF its `GlassButton` children's `backdrop-filter` via `--glass-button-backdrop`: over an opaque card an inner blur samples only the card's own fill (see `next-vmax/AGENTS.md`).
- `PanelShell.module.css` owns `.appIcon`, `.waitlist`, `.action` so the 80px icon box cannot drift out of the tile's whole-pixel guarantee in one caller.
- `VmaxAppIcon` is the ONE composition of `VmaxSpellTile` + `VmaxFlag`, taking a `grid`, so the 80px home icon and the sidebar's `SPELL_GRID_APP_ICON` 24px header cannot drift apart. `VmaxPresentationsIcon` is shared by the tools product entry and the presentations create row; its square glass and book-mark contract lives in `app/AGENTS.md`.
- `components/spell-tile-artwork.ts` is React-free; it dithers through the engine's shared `BAYER_4X4` via the grid-independent `DITHER_CELL` divisor (2px stipple at every size) and `components/spell-tile-bake-queue.ts` bakes the ember in the `components/spell-tile-worker.ts` Web Worker, streaming four-frame chunks, with `createBudgetPump` idle slices as the fallback, so the bake never blocks hydration, scene boot, or a page's animation frames. Output is ONE SVG strip driven by a `steps()` animation: no canvas, no WebGL, no per-frame script.
- `SignInScreen` is the shared surface for `/sign-in` and `/admin` (`VmaxAppNavigation` + `VmaxAppIsometricBackdrop` with `island="propel"` and `signInVariant`, plus `SignInModalLauncher`). Naming is sign-in / sign-up, never "login".
- `VmaxWaitlist` posts `HTTP.joinWaitlist({ email })` for the `vmax.ai` domain and shows a uniform confirmation. Its submit is a primary `FormLiquidButton` (`next-vmax-tools/AGENTS.md`).
- `common/campaign-copy.ts` is the single source of the Campaign description, `CAMPAIGN_GENERATED_PROMPT`, `CAMPAIGN_PROMPT_PLACEHOLDER` (`FormCampaignComposer` and `FormClientExperience`), and `COMPOSER_MINIMUM_PROMPT_LENGTH` (24) + `COMPOSER_SHORT_PROMPT_MESSAGE` (`FormCampaignApplication` and `FormCampaignTemplate`) so live and fixture controllers cannot drift. The floor is the composer's rule, not the API's, and the message says so.
  - **`CAMPAIGN_START_LABEL` (`Start a new campaign`) names the creation action in `FormCampaignSidebar`.** The tools rail's `CAMPAIGNS_SIDEBAR_GROUP` and site menu use `Campaign` for the product destination, so product navigation and campaign creation keep distinct labels.
- `common/research-copy.ts` holds the research/home intro paragraphs; `firstSentences` derives the `/research` meta description from the first.
- **`common/page-return.ts` `onPageReturn(handler)` is the ONE "the viewer is back" signal**: `visibilitychange` to visible, window `focus`, a bfcache `pageshow`, and `online`, coalesced within `RETURN_COALESCE_MS` (500) and silent while `document.visibilityState` is `hidden`; it returns the unsubscribe. A polling surface subscribes it to resync at once instead of waiting out a timer a background tab suspended (`/campaigns` is the caller, `next-vmax-tools/AGENTS.md`). `RootLayoutEffects`' `pageshow` reload is a different contract (a restored page reloads whole) and stays where it is.

## Cross-cutting rules

### Change discipline

- Treat the files you read in the current run as the evidence base. If the evidence does not identify a requirement, state the assumption or ask before changing behavior.
- Preserve existing contracts before chasing a local fix. When behavior changes, update the canonical source, the verification path, and affected agent-facing docs together.
- Expand through the repo's extension points: registries, manifests, entities, shared predicates, helpers, skills. Do not hard-code one-off branches.
- Keep temporary diagnostics, captures, audit outputs, and source backups in the operating system's temporary directory. Do not leave scratch files or directories in this project. Required assets belong in their canonical source or public asset directories.
- `npm run check:workspace` rejects recognized scratch directories, logs, profiles, compiler caches outside `.next`, and before/after source snapshots, including ignored files. It runs before `npm test` and `npm run build`; it reports paths without deleting anything. Normal dependency/build caches and Finder metadata are excluded.
- An ignore rule does not remove files already tracked. When removing accidental diagnostics, keep their deletions in the change submitted to CI; do not format disposable scripts into permanent source or weaken lint rules to retain them.

### Node and scripts

Node `>=18` (`package.json`). Test runner is vitest.

| Command | Purpose |
|---|---|
| `npm run dev` | Dev server on port 10003 |
| `npm run check` | TypeScript plus workspace-artifact checks |
| `npm run check:workspace` | Read-only check for leftover diagnostic files |
| `npm run build` | Production build |
| `npm start` | Production server on port 10003 |
| `npm test` | Run tests once |
| `npm run test:watch` | Test watch mode |
| `npm run script` | Run one-off `ts-node` scripts in `scripts/` |

### Validation before handoff

- Run `npm run check` and the existing tests relevant to the change. A clean TypeScript result does not establish formatting, Python lint, or workspace cleanliness.
- The CI lint contract is [`.pre-commit-config.yaml`](../../.pre-commit-config.yaml). From this directory, run `pre-commit run --config ../../.pre-commit-config.yaml --files <edited-file> ...` on the existing files changed by the task. List files explicitly; do not stage, stash, or commit as part of validation. If tooling is missing, install its environment and caches in an operating-system temporary directory.
- Re-run pre-commit after an auto-fixing hook changes files; the final run must exit zero. Text files have no trailing whitespace and end with one newline. Retained Python utilities must satisfy the configured Ruff lint and formatter, including annotations and the diagnostic-print rule. Do not suppress a hook to claim a clean result.

### TypeScript

Pure TypeScript (`strict`, `noUncheckedIndexedAccess`, `verbatimModuleSyntax`, `noUnusedParameters`, `target: "es2022"`).

- `tsconfig.json` places incremental compiler state in `.next/cache/tsconfig.tsbuildinfo`, keeping generated cache files out of the project root.
- The only `.js` files are vendored bundles paired with a `.d.ts` stub so `allowJs: false` resolves types while the bundler picks up the runtime: `three-isometric-engine/vendor/ammo.js` and `modules/minisearch.js` (verbatim npm build; vendored code is exempt from the comment and `Map` rules). `scripts/strip-glb.js`, `scripts/scrub-glb.js`, `scripts/simplify-glb.mjs`, `scripts/bake-portrait-glb.mjs` are GLB tooling (see `world-meshes/AGENTS.md`).
- **No `any` at boundaries.** Exported functions, constructors, and pub/sub channels have typed parameters; interior logic uses inference.
- **`as const`** on frozen constant objects.
- **`import type`** for type-only imports.
- **Unused parameters** are prefixed `_`; the `_` prefix is reserved for that alone.
- **Private members and module-scoped privates use a `__private` prefix** (`this.__privateHeatField`, `__privateCameraTarget`). See [`demiurge/public/www/skills/fast-typescript-check/SKILL.md`](demiurge/public/www/skills/fast-typescript-check/SKILL.md).
- **No `Map` in hot paths.** Use `Record<string, T>`; `Map` only for object-reference keys.
- **`typeof` checks against globals** are hoisted to module constants (`const HAS_WINDOW = typeof window !== 'undefined'`).
- **Barrel exports** exist at each engine subsystem root; import from the barrel, not deep paths. One recorded exception: `three-isometric-engine/world-entities/index.ts` registers idle entities through `() => import('@engine/world-entities/<Entity>')` deep paths so each entity is its own chunk, and re-exports no entity class; `GLComponent` types its entity fields with `import type` from the same deep paths.
- **Non-null assertions (`!`)** are used in hot-path engine loops where indices are loop-bounded. Do not add runtime null guards there.
- **Root `types.d.ts` globally re-declares Next.js's generated `LayoutProps`** because the Next.js 16 type generator's layout types fail to compile without it. Remove only after confirming an upgrade fixes the generator.

### File naming convention

File names use **hyphen-case** (`shader-context.ts`), with two PascalCase exceptions: React components (`AvatarSocialTag.tsx`) and class-per-file modules whose primary export is a single class (`WorldContext.ts`, `GLComponent.ts`, `*Entity.ts`). camelCase file names are not permitted.

### No comments: knowledge lives in AGENTS.md

**Comments are banned everywhere in this repository.** No `//` line comments, no `/* */` block comments, no `NOTE(@handle)` annotations: in TypeScript, TSX, GLSL, CSS, tests, configs, and scripts alike. Identifier names carry the meaning (see "Identifier naming" below); if code reads unclearly, expand the name or restructure the code; never annotate it. This is permanent policy, not a per-PR preference.

The only permitted exceptions are machine-read directives where technically required: `@ts-expect-error`, `@ts-ignore`, `eslint-disable`, `/// <reference>`. Nothing else survives review.

Knowledge that would once have been a comment (a constraint, a tradeoff, a non-obvious ordering, a workaround, a gotcha) belongs in the deepest `AGENTS.md` that owns the file, as a note that cites the exact function/class/symbol name so a reader can find it from the code (`grep` the symbol, read the doc). If a fact is not worth an AGENTS.md note, it is not worth keeping at all.

`next-vmax/islands/AGENTS.md` and `world-entities/AGENTS.md` are the reference for a good note: every claim traces to live code, names match the source exactly, nothing is restated that a reader could infer.

Documentation prose contains no em-dashes, in AGENTS.md files, SKILL.md files, and prose strings in source alike: use a comma, colon, semicolon, parentheses, or a second sentence. Literal em-dash glyphs that are rendered content (the `PostByline` split, the campaign metric placeholder value) are content, not punctuation, and stay.

### Describe the current state only

- **AGENTS.md notes describe what IS, never what was.** No "previously / used to / now / replaced / the maintainer asked" narrative. Git holds the history.
- **Every note is: symbol name + invariant + why.** A note with no why and no rule is inventory and is deleted.
- **No prop-list or interface restatements.** The TypeScript says what a component takes; the doc says what must stay true and why.
- **One fact per bullet.** No multi-KB paragraphs; split into sub-bullets.

### Terms are defined once, in `/glossary`

- **`common/glossary.ts` is the one definition of every term the workspace uses** (`GLOSSARY_TERMS`: slug, term, definition, related; one flat alphabetical list, ONE meaning per word, never two entries or two senses for the same word). `/glossary` (`app/glossary/page.tsx` → `next-vmax-tools/FormGlossary`) renders it; `/-/vmax-tools-template/glossary` previews the same body.
- **A new term is added to `GLOSSARY_TERMS` first, then used.** An AGENTS.md, SKILL.md, hint, or UI string may name a term and say how it applies here, but must not restate its definition; link `demiurge/public/www/common/glossary.ts` or `/glossary#{slug}` instead. If a definition changes, it changes there and nowhere else.
- **Every help tip links its terms INLINE, automatically.** `next-vmax/SpokenText` with `linkGlossary` wraps the first occurrence (per paragraph) of any glossary term name in the text, plurals included, in an `<a href="/glossary#{slug}">`; `Tooltip` paragraphs, the glossary definitions, and the sign-in copy all render through it. Nothing is declared per hint: if a word in a hint should link and does not, the word is not in `GLOSSARY_TERMS` yet, so add the term rather than a manual link.
- **`/help` is where a surface is EXPLAINED; docs do not re-explain it.** Every help tip is one `CampaignHint` in `next-vmax-tools/campaign-hints.tsx`, rendered on `/help` (and beside its `?` in the product). An AGENTS.md or SKILL.md that wants to say what a campaign surface is, what its numbers mean, or how to read it links `/help#{surface-anchor}` (`helpAnchor(surface)` in `common/help-copy.ts`) instead of writing the paragraph again. Docs hold invariants and file contracts; `/glossary` holds meanings; `/help` holds explanations.
- **New vocabulary updates both, in this order:** the term goes into `GLOSSARY_TERMS` (so it links everywhere), then, if a surface needs explaining, its `CampaignHint` is added to `CAMPAIGN_HINTS` (so it appears on `/help`), and only then may a doc mention it, by link. A skill that introduces a term or a surface without those two edits is incomplete.
- `glossaryHref(slug)` / `glossaryTerm(slug)` throw on an unknown slug, and `common/glossary.test.ts` pins unique slugs, resolvable `related` links, non-empty groups, and the speech word indexes, so a typo fails at test time, not on a reader's screen.

### Identifier naming: spell names out in full

Spell every local variable and parameter out in full: `context` not `ctx`, `deltaX`/`deltaZ` not `dx`/`dz`, `event` not `e`, `element` not `el`, `callback` not `cb`, `index` not `i` (`innerIndex` or a domain name for nested loops). The reader never decodes an abbreviation.

This governs names you own: locals and parameters. Properties, methods, exported symbols, type fields, JSON manifest keys, pub/sub channel names, React props, THREE `userData` keys, GLSL identifiers (`iTime`, `uColor`, `vUv`), and CSS variables are contracts; renaming one is an API change, not a readability cleanup.

**Deliberate exceptions (do NOT "fix" these to longer names):**
- **Domain-canonical single letters:** `x`/`y`/`z`, `u`/`v`, `r`/`g`/`b`/`a` ONLY as colour channels, generic type parameters (`<T>`).
- **`props` and `params`** stay as canonical React/JS idioms.
- **Not variables at all:** `vi.fn()`, CSS `calc(...)` in style strings, the `src` attribute/prop, string literals, and any identifier inside a GLSL/shader template literal (shaders are excluded wholesale).
- **Type-position contracts:** a function-TYPE signature's parameter name (`decode?: (str: string) => string`) is a declared shape, not a binding you own.

### Never separate metadata with middle dots

Bylines, metadata, counts, dates, statuses, and captions never join their parts with ` · ` (or any dot separator). Put each part on its own line instead: a list row's description is newline-separated (`SidebarItem` draws each line of `description` as its own ellipsised row), a status is two lines, a size sits under its file name. The maintainer has asked for this many times; treat a new ` · ` in user-visible text as a bug. Older Campaign surfaces still carry some and are being removed.

### Brand name casing

Branding writes **Vmax** (capital `V`, lowercase `max`, one word) in prose, UI copy, and PascalCase identifiers and file names (`VmaxAppPanels`, `Icon.VmaxLogo`). Do NOT write `VMax` or `VMAX…` inside a mixed-case identifier.

All-caps **`VMAX`** survives only where the whole token is uppercase or it is a product/wordmark reference: the `VMAX` backend, `VMAX.ai` / `vmax.ai`, the wordmark SVG, `SCREAMING_SNAKE_CASE` constants (`VMAX_SVG_PATHS`), the `{ VMAX: 'vmax' }` affiliation key. The lowercase `next-vmax*` folders and `@nextjs-vmax*` aliases stay lowercase.

### Code correctness > tests passing

`vitest.config.ts` carries `passWithNoTests`, so a green run with zero matching tests is possible. A "pinned by `X.test.ts`" reference in a doc names an invariant that must hold; if the file is absent it is a re-pinning checklist item, not evidence.

**A green `npm test` is not a signal that a feature works.** It only signals that the asserted contracts hold under the harness. Visual correctness, physics plausibility, emergent behavior, and cross-subsystem interaction are out of scope for vitest.

Before reporting a feature complete:

1. For UI / 3D / shader / physics work: run `npm run dev`, load the scene, **look at the result**. A passing suite with a broken visual is a false green.
2. Assert invariants (mass conservation, bounded spread, byte-identical non-mutation), not that a function was called. "Runs without throwing" is not a test.
3. When you weaken a spec's behavioral assertion to an API-shape assertion, say so explicitly.

**Agents (including Claude) are demonstrably weaker at writing tests than implementation.** Failure modes: asserting intermediate state instead of observable behavior; synthetic inputs that bypass the real bug class; declaring success on "tests pass" without running the feature. Check delegated tests against the spec's intent. A test that passes on the first run without iteration is a yellow flag.

**Do not write tests the type system already proves.** A `strict` compiler with `noUncheckedIndexedAccess` is the first test suite. Delete tests whose only claim is a tautology: default field values of a fresh object, math identities independent of the code under test, shape/length/"returns an array" assertions on a typed return. Keep every test asserting *behavior the types cannot*: invariants, gating and ordering, geometry/placement, regressions. When unsure, keep it: the bar for deletion is "the compiler already fails if this is false."

### Debug visibility rules (apply to every subsystem)

Full contract in [`demiurge/public/www/three-isometric-engine/AGENTS.md`](demiurge/public/www/three-isometric-engine/AGENTS.md) ("Barrier visibility invariant", "One-classification-of-truth invariant").

1. **Every barrier renders red.** Hard barriers (physics bodies, planner stamps, predicate gates) AND soft cost-based barriers (RVO penalties, halos, dominating taxes) MUST draw a red debug primitive. If you cannot draw one, do not stamp it as a barrier. Colours: red `0xff3030` = physical barrier; yellow `0xfff066` = wayfinding destination / stake marker; green = wayfinding line.
2. **Visible matches math.** Every debug overlay derives from the EXACT constants the runtime predicate uses; no visual-only scale factors. Document any clamp / projection in the owning `AGENTS.md`, citing the rendering function.
3. **One classification of truth.** When one physical concept (sailability, walkability, flammability) lives in several structures, every representation MUST agree. Multi-class enums NEVER collapse with `> 0` shortcuts; every consumer lists every class or imports the canonical predicate.

### Never hand-write a `-webkit-` pair in CSS

Lightning CSS (the Next.js 16 minifier) auto-prefixes from browserslist. Write the standard property alone and it emits `-webkit-backdrop-filter` plus `backdrop-filter`. Write BOTH by hand and it collapses to the `-webkit-` form only, so the effect is missing in Firefox.

- `components/PanelShell.module.css` carries no hand-written prefix and is the reference.
- `next-vmax/GlassButton.module.css` and `next-slate-markdown/components/Button.module.css` still carry hand-written `-webkit-backdrop-filter` lines, so their blur does not render in Firefox. Deleting them is the fix but turns the filter ON for Firefox users: a visual change, not a cleanup.

### Path aliases (`tsconfig.json`)

`@root/*` → `./*`, `@common/*`, `@components/*`, `@modules/*`, `@pages/*`, `@engine/*` → `./three-isometric-engine/*`, `@document-system-common/*`, `@document-system-components/*`, `@nextjs-slate/*` → `./next-slate-markdown/*`, `@nextjs-vmax/*` → `./next-vmax/*`, `@nextjs-vmax-tools/*` → `./next-vmax-tools/*`.

### Sub-AGENTS files apply hierarchically

Each top-level sub-file owns its subsystem in full; `three-isometric-engine/` nests further into `world-entities/AGENTS.md` and `world-meshes/AGENTS.md`. **Do not duplicate sub-file content into a parent.** Reference the deepest file that owns the detail.

## Engine data flow

### Entity data-flow diagram

```
Document (Slate.js markdown)
  ↓ deriveWorldMode / deriveWorldType
World Manifest (JSON)
  ↓ mount-world.ts (entity registry + DAG scheduler)
Entities (build → idle bake → update loop)
  ├── OceanBoardEntity → the unified FLAT FFT sea on every non-inline scene; wake drops + movement ripples
  ├── SeafloorEntity → the giant seabed disc under the home archipelago (hasDistantIslands)
  ├── OceanSurfaceEntity → inline FFT ocean plane (decorativeScene) → publishWaterSurface
  ├── IslandTerrainEntity → publishLandMass, publishFloorDist
  ├── CastleEntity → publishLandmarks, publishZones
  ├── ForestEntity → publishForestLeaves, onHeatField
  ├── FleetEntity → the ONE offshore rowboat fleet on the centre island
  ├── GrassBladesEntity → onHeatField, onFloorDist
  ├── VolcanoEntity → publishVolcanoGeometry
  ├── MarbleLavaSystem → publishHeatField
  ├── SmokeFieldEntity → onHeatField, onForestLeaves
  ├── PalisadeTowerEntity → centre-island watchtower + road + recruitment pole
  ├── MotteBaileyCastleEntity → home-page ringwork castle hub
  ├── ShowcaseEntity → one distant-island feature on the centre island (islands routes)
  ├── RocketLauncherEntity → "PROPEL" mode: hwacha + village + trebuchet row
  ├── CrystalAgentsEntity → "CRYSTAL" mode: chessboard plinth
  ├── CrystalChessEntity → the CrystalChess showcase: complete white and black chess armies
  ├── LumberjackEntity → "Mode:Lumberjack(n)": n lumberjacks fell the forest via onStructure
  ├── SodiumReactorEntity → the SodiumReactor showcase: two stone reactor plants on two islands, the stone pipe between their batteries, a dock on each island, and "Mode:PipeStrike" (onFleetBoats, forestZones, publishHeatField tone 'wither')
  ├── DistantIslandsEntity → home-page ring of distant islands; owns iFloorDistB–K
  ├── AirshipEntity → propeller galleon (`home` / `showcase` missions)
  ├── PlayerAgentEntity → the selectable, click-to-walk blue agents: three on the centre ice island, one at the CTF landing, and one at each quarry (the volcano operator also appears on its standalone scene); jump gaps, drown in the moat, solid to walls, boats, trees, and each other
  ├── LogStackEntity → three 25-log triangular woodpiles on the home ice island (hasDistantIslands)
  ├── QuarryEntity → standalone mountain or dormant-volcano quarry, timber extraction machine, standard stone pile, and nearby-agent action
  ├── StoneStackEntity → one 3x6x3 concrete-block stack beside the log yard (hasDistantIslands)
  ├── BuildManagerEntity → player-driven structure builder (place/confirm/cancel ghost → a dock, a log stack, a large ship, or a trebuchet raised section by section) on the home island (hasDistantIslands)
  ├── PartDebrisSystem → part-level destruction runtime (`ctx.explodeParts`; mounted on every manifest, idle until a blast)
  └── StructureSolidityEntity → static Bullet compounds for every `ctx.structureParts` record (agents, boats, and debris collide with them)
```

Each entity's full contract lives in [`demiurge/public/www/three-isometric-engine/world-entities/AGENTS.md`](demiurge/public/www/three-isometric-engine/world-entities/AGENTS.md); the one-liners above are an index, not the spec.

### How mounting and data flow work

- `mount-world.ts` is an idle DAG scheduler: entities declare `deps` in the manifest JSON, every entry whose deps are satisfied launches in parallel, entries with no deps start immediately.
- Cross-entity data flows through `WorldContext` publish/subscribe pairs that replay on subscribe.
- A new scene mode is a new JSON manifest plus a factory entry, with zero edits inside `firstRender`.

### Scene composition is shared

`common/derive-world-mode.ts` also owns `postScenePresetFor`, the ordered post-to-preset selector, and `textTriggersThreeMileIsland`, the exact-sentence test behind its first rule. `Document` reads the returned row from `ISLAND_PRESETS`, so post overrides and reference islands share their scene flags; matching and precedence are documented in `document-system-components/AGENTS.md`.

`common/derive-world-mode.ts` is the single source of truth for scene mode and world type; the SSR preload (`app/[username]/[slug]/(document)/page.tsx`), `document-system-components/Document.tsx`, and `GLComponent` all read it.

- `deriveWorldMode(events)` returns `{ mode: 'ctf' | 'wander' | 'island', isCTF }` from the byline `EVENT` column; new code reads `mode`, not `isCTF`.
- `deriveWorldType({ imageCount, isDesert })` returns `'ICE' | 'GRASS' | 'DESERT'`.
- **CTF takes precedence over wander (`!isCTF && WANDER_RE.test(...)`)**: a `CTF | Playtest` post can contain a stray "wander" in prose and the agent system must win. Keep the ordering.

## Publishing to VMAX.ai

Before any posting workflow, read [`demiurge/public/www/public/SKILL.md`](demiurge/public/www/public/SKILL.md).

- Posts persist as markdown text (`SAVED_AS_MARKDOWN: true`, post type `NEXT_SLATE_WORLD_MARKDOWN`).
- `::isometric(0)` is auto-prepended by the slug page template.
- `source` field is `'vmax.ai'`; presigned-URL upload domain is `vmax.ai`.

## Agent discovery routes

- `/research` is the public research index (see `app/AGENTS.md`); a normal page, but in `sitemap.xml`
- `/agents` serves this file as `text/markdown`
- `/skill` serves `public/SKILL.md`; `/skill/convert` serves `skills/tex-or-pdf-to-vmax-markdown/SKILL.md`; `/skill/template` serves `skills/create-vmax-tools-page/SKILL.md`; `/skill/slides` serves `skills/create-vmax-presentation/SKILL.md`; `/skill/permissions` serves `skills/post-permissions-and-viewing/SKILL.md`
- `/glossary` is the term reference for readers and agents alike (see "Terms are defined once" above); `/help` is every help tip (`CAMPAIGN_HINTS`) as the card its surface opens; `/-/vmax-tools-template/{glossary,help}` are their tools-page previews
- `/llms.txt` is a machine-readable summary
- `/sitemap.xml` includes `/research`, `/use-campaign`, `/glossary`, `/help`, `/agents`, `/skill`, `/skill/convert`, `/skill/template`, `/skill/slides`, `/skill/permissions`, `/llms.txt`
- `/robots.txt` allows all paths except `/api/`

`DOCUMENT_NAVIGATION_SECTIONS` links the public Glossary and Help pages, and excludes the agent-discovery and template routes so site navigation stays focused on the product. `SHARED_SIDEBAR_GROUPS` keeps template review pages in its `Debug Paper` and `Debug Vmax Tools` groups.

<!-- BEGIN:nextjs-agent-rules -->

# This is NOT the Next.js you know

This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices.

This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean.

<!-- END:nextjs-agent-rules -->
