# Open work

Everything this repository has started and not finished, in one place. It was
gathered on 2026-08-31 from the "still open", "not built" and "deferred"
sections that were scattered across a dozen separate plans, and each item names
the file and the code it was read out of so it can be checked rather than
believed.

Two neighbours to this file:

- [`FUTURE-DIRECTIONS.md`](FUTURE-DIRECTIONS.md) — ideas recorded but not
  started. Nothing there is owed.
- [`CROSS-REPO-HANDOFFS.md`](CROSS-REPO-HANDOFFS.md) — work that is finished
  here and waiting on the API, the database, or R2 write credentials. Nothing
  there can be closed from this repository.

An item leaves this file when it ships, and its plan — if it had one — moves to
[`archive/`](archive/README.md).

---

## 0. Kerr black hole optics — the museum, the frame budget, and the art

[`KERR-BLACK-HOLE-OPTICS.md`](KERR-BLACK-HOLE-OPTICS.md) records what is built
and every number it was measured at. Four things are open, and the first is by
far the largest.

### The museum (the plan's M5) — in, 2026-09-07, with the caveats the plan predicted

The Kerr black hole now stands at the end of the Golden Funnel, at the
formula's origin five metres under the floor, in the owner's numbers: a* =
0.382 turning clockwise seen from above, a disk out to 60 r_g, H/r = 0.060, an
optical depth of 0.35, everything else the study page's default. One and a
half metres to the gravitational radius (2026-09-08: three times the diameter
it opened at), so the disk is 180 m across in a room 240 m wide, the shadow
15 and the horizon nearly 6.

At that size the shadow is wider than the 5 m the exhibit hangs below the
floor, so the dark region rises above the floor plane and a visitor standing
beside it sees the black disc cut into the room rather than lie under it.
Nothing solid moved: the horizon is 2.9 m and still well under. A shadow is
the set of directions whose light was captured, not a surface, and those
directions reach above the plane at every scale — it only shows as a rise once
the shadow is bigger than the drop. The orb's
vote now dresses the same hole in one of the tracer's three skins: the two
words already on the wire (`white`, `warm`) name the ice and NASA skins and
`physical` is new, so a server still on the two-word list drops a `physical`
vote and the orb's own 2.5 s fallback wears it locally until the server's copy
of `presenceProtocol.js` gains the word.

How it sits in the room (`render/kerrExhibit.ts`, `KERR_MUSEUM_MAIN_GLSL`,
`model/manifoldKerr.js`), because each of these is a choice:

- **On a sphere, not a quad.** Every fragment of a 100 r_g sphere aims a ray
  from the visitor's eye through itself; the eye becomes the tracer's ZAMO
  camera through the same `cameraFrame` the page uses, in Kerr coordinates
  one and a half metres to the unit, room y-up mapped to Kerr z-up
  right-handedly.
- **The sky is the sky, bent.** Escaping rays sample a cube map rendered from
  the hole's own centre, and what ramps between the two blend angles is THE
  DEFLECTION, not the opacity of a second picture. It began as a crossfade and
  a crossfade shows both: the same nebula twice, a few degrees apart, half
  strength each — what the owner saw and circled. A ramped deflection has one
  image everywhere. It is zero at the sphere's rim, where the sample is then
  the sky in the direction the ray was already going, so the rim is seamless
  rather than a step; it is the true deflection by the radius the integrator
  takes over, so the traced core is exact. In between the sky is under-bent by
  up to a couple of degrees, which is the price. Baked at entry, again at +6 s
  and +20 s while the room is still streaming, and whenever a ride or the
  paint is rebuilt.
- **Depth is written where the light STOPPED**, per fragment: the gas that
  absorbed it, or the hole for a ray the horizon swallowed. A ray that escaped
  did not stop — its light came from the room's far field through the cube map
  — so it is a backdrop at the far plane and every real thing in the room is
  in front of it. So a rail in front of the shadow shows, one behind it is
  hidden, and the depth buffer never has to know a curved path.
- **The far field is closed form.** Outside 46 r_g a ray that would not meet
  the disk takes the weak-field bend (4M/b)(1 + 15πM/16b), scaled by how much
  of it is still ahead of the eye, so standing near the hole and looking away
  still sees the room bent, continuously with the traced core, for a few
  multiplications. The gas sample length grows with the eye's distance
  (0.5 r_g at 26, 4 at 200) and the step budget is 700 at 1e-4.
- **The disk turns at the study page's rate**, 40 t_g per real second, which
  is a display convention: at this scale a real t_g is five nanoseconds. The museum ran at one t_g per second at first — the
  same disk, forty times too slow, one turn at the inner edge every 66 seconds
  against the page's 1.7.
- **One ray per CSS pixel.** The sphere is drawn by its own pass into a target
  the drawing buffer's size over the pixel ratio (never below half), and a
  full-screen quad puts it back, writing the depth the pass recorded. Measured
  on the owner's machine at 2560 × 1440 (ratio 2): 3.7 ms for the room without
  the exhibit, 10.4 ms with it from the floor forty metres away, 16.6 ms twelve
  metres away with the disk filling the view; before the pass those were 25 and
  51 ms, and the gas sample length moved them by a tenth, because the cost is
  the ray count. The study page's Full tier is one ray per CSS pixel too.
- **A backdrop is baked as pure direction.** The museum re-centres the sky
  shell — and the Live Painting marks that are its children — on the eye every
  frame, which is what makes it a backdrop rather than a place. Baked from the
  hole while still centred on the eye, its stars landed at angles wrong by the
  whole eye-to-hole offset (six degrees at 26 m on a 240 m shell) while
  everything at a real place in the room was baked at its true angle, so the
  two slid against each other as the visitor walked: the owner's report of the
  background and its particles moving apart. The bake now re-centres it on the
  cube camera. Measured: baking with the visitor 85 m from where they were
  changed 161 501 pixels of the frame before, and changes none of 3 686 400
  after. A test holds the list handed to the bake equal to the list
  `setCamera` moves, so a third camera-follower cannot be forgotten.
- **Nothing of the room is in the cube map.** A map rendered from the hole's
  centre is exact for a backdrop — a thing with a direction and no position —
  and wrong for anything at a real place a few metres away, because the
  visitor is not standing at the hole. The rides were in it for a while, and
  near the shadow their wrapped arcs were the best thing in the frame; but
  once the map has to be sampled at the ray's true direction everywhere for
  the sky's sake, there is no weight left to fade a misplaced ride out with,
  and at the rim it would sit wherever it happens to lie from the hole. One or
  the other, and the sky is the one that fills the frame. The visitors are out
  too, or they would hang around the shadow as ghosts. The room composes with
  the picture in the frame instead: the composite is drawn first among the
  transparent things, and the room's glass, rails, grid and sparkles — none of
  which write depth — test against the depth it wrote, so a rail in front of
  the hole stays in front and one behind the shadow is hidden.

Still open, and named here rather than smoothed over:

- **The composite sits at -4**, between the sky's own Live Painting marks
  (-5) and the corridor's glass (-3/-2). A mark on the sky writes no depth and
  the composite writes the far plane for an escaping ray, so a mark drawn
  after it painted itself over the black hole — a scatter of black specks
  across the disk, which is what the owner found. Measured: with the composite
  at -10 the mark changed 16 135 pixels of the frame; at -4 it changes none.
- **Ten metres down, not five.** The shadow's radius is 7.7 m at this scale,
  so at the old drop the dark region rose through the floor. What has to clear
  the floor is the critical impact parameter — a shadow is a set of captured
  directions, not a surface — and the drop is now larger than it with 2.3 m to
  spare. `tests/the-manifold-room.test.js` holds that, and is the test that
  fails if the scale is raised again without the exhibit going down with it.
  The funnel went down with it, so its slice through the floor plane is wider
  (1.83 m of radius against 0.83) and the launch orb moved from 4 m to 5 to
  stay beside the point rather than against it.
- **No pad ring over the exhibit.** The room draws a gold ellipse on the floor
  at each of its four reserved attraction areas; the Golden Funnel's lay
  directly across the middle of the disk. It is skipped by position, so moving
  the ride moves the exception with it.
- **The ride's view follows the hole**, between a floor of 15 degrees below
  level and a cap of 50. A fixed tilt lost it — the centre runs from 30 to 80
  degrees below level across the descent — and following the point with
  nothing to stop it is the older failure this ride already had, where 80
  degrees is a rider looking at their own feet. Clamped, the shadow is never
  more than 30 degrees off the aim against a 65-degree frame. The horizontal
  aim moved from halfway to the axis at the same time.
- **The tour follows by construction**, and is now held to it: its
  establishing shots are framed from each ride's own sampled frames, so the
  funnel's moved down with the funnel, and a test checks that the hole is 6.9
  degrees off that shot's aim and subtends 5.7 rather than trusting the
  construction.

- **The third skin cannot win a vote until the server has the word.** The orb
  asks the room, and `net/presenceProtocol.js` is shared VERBATIM with the
  server in `lucas-academy-inception-space`: this repository's copy now lists
  `physical` beside `white` and `warm`, and a server still on the two-word
  list drops a `physical` vote unread. What the chooser sees is the orb's own
  fallback — after 2.5 s with no tally it wears the answer locally — so the
  skin works for them and the room never settles on it for everyone. The fix
  is one word in the API repository's copy of the same file, which this
  repository may not make.
- **A local `npm run dev` opens no socket at all**, by design
  (`net/presenceClient.ts`): a standalone museum votes alone, which is why the
  orb reads "Exploring solo, so your choice is the room's". To exercise the
  real vote, run the API repository's `npm run api` and open the museum with
  `?api=dev`, or build with `VITE_MUSEUM_API` set. The window is 7 seconds
  (`FLOOR_VOTE_SECONDS`, cut from 27 and before that 60).
- **The third-person boom is back on the aim.** It was moved onto the track on
  2026-08-24, when the owner sat against one side of their own ribbon; the aim
  was a shallow 15 degrees then, so the boom was mostly sideways and the eye
  sat 0.04 m above the ribbon — level with it, looking across. The aim now
  points at the hole and is pitched 30 to 50 degrees down, so the same offset
  is mostly upward: at least 0.94 m above the ribbon and 28 degrees of
  elevation at its shallowest. On the track the hole was simply not in frame,
  the funnel being a spiral whose direction of travel is 72 to 85 degrees away
  from it. The elevation is the test's guard against a shallower aim bringing
  the old complaint back.

- **The colour tables are float where a device can filter float.** They were
  RGBA32F with linear filtering, which WebGL2 does not guarantee: in ES 3.0 the
  32-bit float formats are renderable but not filterable, and filtering one
  needs `OES_texture_float_linear`. Desktop exposes it; iOS Safari commonly
  does not, and a texture asked to filter a format it cannot is incomplete —
  every sample black. The gas's colour is one such sample, so on an iPad the
  shadow, the lensing and the room drew and the accretion disk did not. RGBA16F
  is filterable by core everywhere WebGL2 runs and is the fallback; a device
  with the extension keeps the table it had, bit for bit. Measured on the
  owner's desktop, forcing the fallback on the same frame: of 3 686 400 pixels
  (2 275 706 lit) the mean difference is 0.015 of an 8-bit level, 11 pixels
  differ by more than one, and the worst is two.
- **The sphere's pass adapts, and cannot cost a healthy machine anything.** It
  steps down only after a sustained frame interval past 26 ms (38 fps) and back
  up below 19; a 60 Hz display reports 16.7 and a 120 Hz one 8.3, so neither
  reaches the trigger, and the owner's desktop holds one ray per CSS pixel —
  verified live at `x1.00`, 922 k rays. Nothing is sniffed: the input is the
  interval between the exhibit's own frames. `?debug&hud=1` prints the line
  that says which float, how many rays, which step and the frame time, so the
  next device report is answerable rather than a guess.
- **Measured on two machines, and a phone is still not one of them.** On the
  owner's desktop at three times the size the exhibit costs 11.9 ms from 95 m,
  16.2 from 36 and 11.0 from straight above, against 3.3 for the room alone —
  unchanged by the trebling, because the sphere's extra area takes the cheap
  analytic path. On the owner's iPad over the LAN (2026-09-08) the room runs at
  30–40 fps with a p95 of 47 ms and reads as comfortable. That is above the
  26 ms step-down, so the pass is adapting there rather than sitting at full,
  which is the arrangement working rather than a number to fix; if it is ever
  wanted smoother, the levers are `PASS_STEPS` (add a lower one), the trigger
  itself, and `quality` / `proxyRadius` in the room spec. A phone is still
  unmeasured, and so is anything with a slower GPU than an iPad's.
- **The coaster is not bent around the hole.** It is drawn as itself and
  correctly hidden where the shadow or the gas is in front of it, but it has
  no lensed image. Bringing that back means a SECOND cube map carrying the
  near geometry alone, sampled with its own weight that fades to nothing at
  the rim while the sky's stays at one — about fifty lines and six more
  megabytes. Worth doing if the wrapped arcs are wanted; they are nearly
  invisible with the gas on, which is why they were not worth an artefact.
- **A ride behind the hole but outside the shadow is not hidden.** An escaping
  ray writes the far plane, so nothing occludes it there. The alternative —
  writing the closest approach — is what put a black disc across the coaster
  in the first place, since the funnel's turns above the hole sit at exactly
  that distance.
- **The blend annulus.** Between 2.3° and 5° of bend (23–50 m from the axis in
  projection) the room as drawn and its lensed image both show, misregistered
  by up to 5° for far walls; a soft ghost rather than a step, and the two
  angles are the knobs.
- **Not seen on a phone**, and the ride's last metres through the ergosphere
  have been reasoned about (the horizon flag paints black) rather than watched.

### The ride falls — a capture orbit, through the horizon, 2026-09-09

The Golden Funnel ends in a near-critical capture orbit watched from inside
it (`KERR-BLACK-HOLE-OPTICS.md` §9b). The whole coaster stands 25 m above the
hole, the ride runs to the spiral's point (0.6 m from the axis, 21 r_g from
the hole), the camera is pushed in to the eye a turn and a half before the
end and rides the last turns from it, and is thrown ALONG THE TRACK at 0.3221 c — the last speed this side of the
separatrix, E = 1.0049, nearly polar — whirls about two turns between 5 and
4 r_g, falls, crosses the horizon and stops at 0.6 r₊, over 45 s of playback
on a four-stretch schedule that shows the far part quickest and the crossing
slowest, with the gas on the camera's clock and the aim riding the shadow's
rim once the shadow is too big to look at; then three seconds of black, and
the room. (A glowing line along the curve was tried and removed at the
owner's word.) Recorded uncut from the museum's own canvas:
`assets/kerr-plunge/museum-capture.mp4`.

Open:

- **Nothing has been measured playing the 45 s interactively.** The status
  line during the stepped recording read 12–22 ms through the fall and 27 ms at
  the horizon (the pass adapted to 0.75 of its rays) against 12–14 ms on the
  track, on the owner's desktop through a hidden pane; not an interactive
  frame time. The plan's targets (desktop p95 ≤ 33.3 ms, mobile
  ≤ 50, three consecutive plays) are unmeasured on every device.
- **Inside the horizon the dark half of the sky is dark by budget**: the rays
  that never cross r₊ spend their steps approaching it from inside. Right for a
  hole that formed from a collapse, and a numerical outcome rather than a
  modelled one.
- **The disk is one colour.** With the physical (Planck) skin the gas near its
  peak is white-hot and the display transform desaturates it; the Doppler
  asymmetry is there as brightness, not as colour. Not touched, by instruction.
- **The event boundaries were checked at 60 and 30 Hz**, not at 15 or 120, and
  not with a frame delta that crosses both the hand-over and the release at
  once — the code handles that in order and the pure-function tests hold the
  timing, but it has not been watched.

---

## 1. Discovery — the three requirements that are still unmet

[`DISCOVERY-SEO-GEO.md`](DISCOVERY-SEO-GEO.md) lists these under "Still open".
All three were re-checked against the build on 2026-08-31 and all three are
still true.

### R4 — the preview image is shared, not per room

**Half done, 2026-08-31.** The gap was total: no `og:image` existed anywhere,
so no page had a link-preview thumbnail — and the landing page was worse than
bare, because `twitter:card = summary_large_image` promises a big-picture card
and there was no picture, so it rendered as a text box.

There is now one card for the whole site: `public/og-cover-v1.jpg`, 1200 x 630,
a crop of the museum's own constellation portal.
`src/inception/discovery/socialCard.js` owns it, both generated templates call
it, `index.html` repeats it under test, and the build refuses if the file is
missing from `dist/`.

What is still owed is what R4 actually asks for: **a curated screenshot per
room**, rendered headlessly at build time — "a room, not a logo", because the
card beside an answer about *Van Gogh House* should show Van Gogh House. The
seam is ready for it: give `socialCard.js` a per-page lookup and let the shared
card stay the fallback for pages that are not a room.

Two things to know before building it. Headless capture of this museum means
booting WebGL, waiting for a room's textures to upload (see
[`PERFORMANCE.md`](PERFORMANCE.md) — arrival is not instant) and reading the
canvas back, which needs `preserveDrawingBuffer` or a capture inside the render
loop. And a scraper caches a card **by URL**, so a new or changed image ships
under a new `-vN` name rather than replacing bytes at the old one.

### R5 — no `hreflang` pair

There is no `hreflang` in `src/`, `scripts/` or `index.html`. The policy is EN +
ZH, both indexable, paired. The blocker named in the requirement still stands:
the room documents carry no translated title, so there is nothing to build a ZH
twin out of yet. That is the first piece of work, and it is a content change to
`content/inception/rooms/*.room.json`, not a renderer change.

### R6 — `noindex` has been removed, and the rest of R6 has not been re-checked

The requirement says `index.html` carries
`<meta name="robots" content="noindex">` and **keeps it until the public surface
exists**. That meta tag is no longer in `index.html`. Whether the public surface
now genuinely satisfies R6 — public-snapshot layer, no real names, no class
membership, no draft history — is a decision that should be recorded rather than
inferred from a deleted line. The two automated guards (no owner email, no
`password`, and a room in the sitemap only if its document says
`visibility: public`) still run and still pass; they are narrower than R6.

### Per-artwork pages

Rooms have pages; individual paintings do not, because the public-snapshot layer
that decides what an artwork may say (execution plan §2.3) is not built.

---

## 2. Standalone mode — the missing half of import/export

From [`UI-REPOSITORY-SCOPE.md`](UI-REPOSITORY-SCOPE.md), which calls standalone
mode the primary acceptance target.

There is exactly **one** file input in the whole client and it belongs to the
publish flow. Standalone therefore has export and file-keeping only. Missing:

- local `.ispack` import (the inbound importer was retired upstream);
- local GLB import for display;
- single-object GLB export.

The document is explicit that building local import is the next piece of work
and not a capability it should claim. Note the ordering rule in
[`CROSS-REPO-HANDOFFS.md`](CROSS-REPO-HANDOFFS.md) Part 2: the `.ispack`
contract belongs to Lucas Visual Art Lab and must not be re-derived here.

### Sealed-collision coverage does not travel

The `solidCollisionBox` invariant — a sealed interior must cover a measured
object's real geometry, not its centred footprint — is asserted by
`tests/inception-solid-interior.test.js` **in the source repository**, because
that test reads `server/rooms.js`. This repository has the collider fix and
`library-glb-collider.test.js`, but no coverage of sealed collision. Splitting
the server-free half of that test out is separate, unclaimed work.

---

## 3. Narration and subtitles

### The Manifold rides have no recordings

[`MANIFOLD-TOUR-NARRATION.en.md`](MANIFOLD-TOUR-NARRATION.en.md) is confirmed by
the owner and shipping as subtitles — `src/inception/tour/manifoldNarration.js`
is the runtime source of truth — but no voice exists. The subtitles stand on
their own until one does. English only, deliberately: the field for a Chinese
caption track is left out rather than left empty.

### The Van Gogh opening revision is still a proposal

[`VAN-GOGH-DEMO-NARRATION.en.md`](VAN-GOGH-DEMO-NARRATION.en.md) carries one
proposed Phase 2 opening for the first painting that is editorial until the
owner finalizes it; recorded cue `01-lines-and-people` is unchanged and stays
that way until then. `tests/demo-narration-docs.test.js` pins this: it asserts
the document still says *"This is a script proposal, not a new recording"*, so
finalizing the copy means changing that test in the same commit.

### The Chinese track is a review document, not a voice

[`VAN-GOGH-DEMO-NARRATION.zh.md`](VAN-GOGH-DEMO-NARRATION.zh.md) mirrors the
English cue ids for review only. The demo plays the English recordings; no
Chinese audio has been generated, and nothing authorizes it into the runtime.

---

## 4. The standalone demo runtime is scheduled for retirement, and still here

Issue [#12](https://github.com/yancyqin/inception-space-ui/issues/12) made the
guided demo an ordinary museum visit driven by `?room=<id>&tour=<id>`; the
result is recorded in
[`archive/ROOM-LAUNCH-AND-TOURS.md`](archive/ROOM-LAUNCH-AND-TOURS.md). The
owner cancelled the earlier plan to relocate the standalone demo to
`lucasgame-academy` (engine mismatch — that site has its own lesson-player), and
accepted that the standalone runtime is **retired rather than moved**.

It has not been retired yet. Still present and still built:

```
demo/src/main.js
demo/src/shell.js
demo/src/world/{demoWorld,scene,input,readers}.js
```

Two pieces remain, and both are gated on a human watching the in-museum tour at
real frame rates first — nobody has:

1. reduce `/demo` to a thin shell that forwards into the museum entry;
2. delete the old runtime modules above.

Do not schedule this from a browser-pane capture. The pane throttles
`requestAnimationFrame` when hidden, so tour pacing measured there is not what a
visitor sees.

---

## 5. The object library needs content, not code

[`OBJECT-LIBRARY.md`](OBJECT-LIBRARY.md) is complete end to end — ownership, R2
serving and the GLB prop renderer are all built and the museum draws from a
bucket that holds reviewed objects. What it says remains is content:

- more objects;
- the editor control for placing them per room.

The browsing half of that shipped as the object storage building and its item
bar ([`archive/OBJECT-STORAGE-BUILDING.md`](archive/OBJECT-STORAGE-BUILDING.md));
the per-room placement control did not.

### A room's door has no art and no editor control

[`archive/ROOM-DOOR-AND-TRANSIT.md`](archive/ROOM-DOOR-AND-TRANSIT.md) records
the ownership rule as accepted by the owner, and the door's own art and its
editor control as **not built**. A centre-out dissolve was tried and rejected
the same day; the door stays the plain sheet of light the document describes.
Anyone reopening this should read that rejection first.

---

## 6. `.lpr` — one guard gap, and a stale status line

[`LPR-FORMAT.md`](LPR-FORMAT.md) is the format's own contract and stays a living
document. Two things in it are owed.

### The recompile tripwire cannot see shader edits

`npm run check` recompiles every `.lpp` and compares to the committed
`.lpr.json` byte for byte, so a compiler or registry change that would alter an
existing painting fails before it can be committed. That guard is blind to GLSL:
editing the shader for `photo-nebula-breath@2` leaves every package
byte-identical, `check` stays green, and every painting using that behavior
quietly looks different. Today only the two-implementation cross-check tests
stand in the way. The document names the fix if the vocabulary grows — a
reference-render snapshot per behavior, hashing a small offscreen render — and
it has not been built.

### The status line is out of date

The document's own header still says *"Nothing here is wired into the museum
runtime — the inherited renderer still draws every room."* That is no longer
true: `src/inception/artwork/lprSurface.ts`,
`src/inception/artwork/lprMarksSurface.ts`,
`src/inception/lpr/markShader.js` and `src/inception/world/render/paintScheduler.js`
are all in the runtime, and the GPU marks path has its own section in
[`PERFORMANCE.md`](PERFORMANCE.md) dated 2026-08-17. The header needs rewriting
by someone who can state what v2 still owes.

The corpus it points at, `content/live-projects/`, **does not exist in this
repository** — the `.lpp` authoring masters stayed on the other side of the
split, and only the compiled `content/lpr/*.lpr.json` outputs are here. So
`node scripts/lpp-report.mjs` cannot be run from this checkout either.

---

## 7. Stale claims worth correcting when someone next edits these files

Small, and none of them affect the running museum — but each one will mislead
the next reader.

| Where | What it says | What is true on 2026-08-31 |
| --- | --- | --- |
| [`UI-REPOSITORY-SCOPE.md`](UI-REPOSITORY-SCOPE.md) | Matthew's L-City floor is `uploadState: pending`, its R2 URL 404s, and it costs 451,541 bytes from the static host per cold visit | The manifest has no pending rows at all; `matthew-l-city/floor-v1.jpg` carries a real `publicUrl`, hash and `distFile`. The bill it describes has been paid |
| [`DISCOVERY-SEO-GEO.md`](DISCOVERY-SEO-GEO.md) | `index.html` carries `noindex` and keeps it | The tag is gone — see §1 above |
| [`LPR-FORMAT.md`](LPR-FORMAT.md) | nothing is wired into the museum runtime | It is — see §6 above |
| [`archive/R2-STATIC-ASSET-MIGRATION-EXECUTION-PLAN.en.md`](archive/R2-STATIC-ASSET-MIGRATION-EXECUTION-PLAN.en.md) | Status: ready for implementation | Phases A–F are done; this is why it is in the archive. Phase G belongs to the other repository |

---

## 8. A prerequisite, for whenever a preview origin is wanted

Not work, but the thing that is always discovered too late. From
[`UI-REPOSITORY-SCOPE.md`](UI-REPOSITORY-SCOPE.md): a preview origin needs
**two** allowlist additions, not one.

1. the **API**'s `ALLOWED_ORIGINS`, so connected-mode QA can reach it;
2. the **R2 bucket**'s CORS allowlist, which today permits
   `https://is.lucasacademy.org`, `https://inception-space.onrender.com` and
   `http://localhost:5173` only.

Verified locally: a production build served from an unlisted origin has every R2
asset blocked by CORS, and the transit chamber then fails entry rather than
downgrading silently. Both allowlists live in the other repository.
