Shereen Lee commited on
Commit
49edbde
·
1 Parent(s): 11fc1c9

feat: collab fixes, Cesium OOM mitigation, and frontend precompile

Browse files

Collaboration (ported from build-small-hackathon/storybook_test):
- AI + journey mutual-exclusion locks (server-authoritative), room
capacity cap, SSE reconnect hardening, live photos_state sync,
activity presence beacon, synced journey playback / darkroom develop.

Cesium memory: cap 3D-tiles cache (cacheBytes) so passed-over tiles are
evicted instead of accumulating across the flythrough, dynamic SSE to
coarsen the horizon, globe tile-cache limits, and coarsen+trim before the
end-of-journey overview (the OOM spike). Adds a webglcontextlost guard.

Frontend first-boot latency: precompile the in-browser JSX with esbuild
(build_frontend.py -> index.build.html) so we no longer ship
@babel /standalone (~0.7 MB gzip) or transpile ~7.5k lines on the client's
main thread before first paint. index.html stays the editable source;
app.py serves index.build.html when present and caches the assembled HTML.

Files changed (4) hide show
  1. app.py +267 -11
  2. build_frontend.py +98 -0
  3. index.build.html +0 -0
  4. index.html +860 -127
app.py CHANGED
@@ -2448,6 +2448,21 @@ RELAY_EVENTS = {
2448
  "photo_pinned", "photo_moved", "photo_props", "photos_reset",
2449
  # navigation that collaborators can optionally follow
2450
  "screen_changed", "variant_changed",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2451
  }
2452
 
2453
  # Types the server originates or manages itself — these must NEVER be relayed
@@ -2456,8 +2471,20 @@ NON_RELAY_EVENTS = {
2456
  "init", "me", "presence", "presence_update", "status",
2457
  "user_joined", "user_left", "rename", "error",
2458
  "join_request", "join_pending", "join_declined", "admit_join", "decline_join", "cancel_join",
 
 
 
 
 
 
 
 
2459
  }
2460
 
 
 
 
 
2461
 
2462
  # NOTE: This transport was a raw WebSocket (/ws/{room_id}). Hugging Face
2463
  # Spaces' router silently drops custom (non-Gradio) WebSocket UPGRADE requests
@@ -2515,6 +2542,114 @@ class Manager:
2515
  def __init__(self):
2516
  self.rooms = {} # room_id -> {session_id: _Client} (admitted)
2517
  self.pending = {} # room_id -> {session_id: info} (awaiting host)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2518
 
2519
  # ---- admitted participants -------------------------------------------- #
2520
  def add_client(self, room_id, client):
@@ -2609,14 +2744,22 @@ _sse_queues = {} # (room_id, session_id) -> asyncio.Queue
2609
 
2610
 
2611
  def _sse_queue(room_id, session_id):
 
 
 
 
 
 
2612
  key = (room_id, session_id)
2613
- q = _sse_queues.get(key)
2614
- if q is None:
2615
- q = asyncio.Queue()
2616
- _sse_queues[key] = q
2617
  return q
2618
 
2619
 
 
 
 
 
2620
  async def _relay_incoming(room_id, session_id, data):
2621
  """Apply one client→server event (formerly a WS receive_json). Shared by the
2622
  POST /send endpoint. `data` already has a 'type'."""
@@ -2645,6 +2788,53 @@ async def _relay_incoming(room_id, session_id, data):
2645
  pend.event.set()
2646
  return
2647
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2648
  # explicitly known (RELAY_EVENTS) or simply not a server-owned type
2649
  # (NON_RELAY_EVENTS) — this way the frontend can introduce new interaction
2650
  # events without the server silently dropping them.
@@ -2653,7 +2843,7 @@ async def _relay_incoming(room_id, session_id, data):
2653
  await manager.broadcast(room_id, data, exclude=session_id)
2654
  # cursors + transient interaction pings are too chatty to keep the room
2655
  # alive on; everything else counts as real activity.
2656
- if t not in ("cursor_position", "interaction"):
2657
  touch_room(room_id)
2658
 
2659
 
@@ -2686,6 +2876,17 @@ async def room_stream(room_id: str, request: Request):
2686
  host = manager.host_client(room_id, owner_session)
2687
  needs_approval = (not is_owner) and (host is not None)
2688
 
 
 
 
 
 
 
 
 
 
 
 
2689
  if needs_approval:
2690
  pend = _PendingJoin(info)
2691
  manager.add_pending(room_id, pend)
@@ -2705,9 +2906,18 @@ async def room_stream(room_id: str, request: Request):
2705
  client = manager.admit(room_id, info)
2706
  client.queue = q # reuse the queue the SSE stream is already draining
2707
  else:
2708
- client = _Client(info)
2709
- client.queue = q
2710
- manager.add_client(room_id, client)
 
 
 
 
 
 
 
 
 
2711
 
2712
  if client is not None:
2713
  now = time.time()
@@ -2720,6 +2930,12 @@ async def room_stream(room_id: str, request: Request):
2720
  _conn.commit()
2721
  touch_room(room_id)
2722
  await q.put({"type": "init", "you": _public(info), "participants": manager.presence(room_id)})
 
 
 
 
 
 
2723
  await manager.broadcast(room_id, {"type": "user_joined", "participant": _public(info)}, exclude=session_id)
2724
  await manager.broadcast(room_id, {"type": "presence_update", "participants": manager.presence(room_id)})
2725
 
@@ -2738,11 +2954,27 @@ async def room_stream(room_id: str, request: Request):
2738
  except asyncio.CancelledError:
2739
  pass
2740
  finally:
2741
- _sse_queues.pop((room_id, session_id), None)
2742
- if manager.get_client(room_id, session_id) is not None:
 
 
 
 
 
 
 
 
 
 
 
 
2743
  manager.disconnect(room_id, session_id)
2744
  await manager.broadcast(room_id, {"type": "user_left", "participant": _public(info)})
2745
  await manager.broadcast(room_id, {"type": "presence_update", "participants": manager.presence(room_id)})
 
 
 
 
2746
 
2747
  return StreamingResponse(event_gen(), media_type="text/event-stream", headers={
2748
  "Cache-Control": "no-cache",
@@ -2930,8 +3162,32 @@ def _inline_maplibre(html: str) -> str:
2930
  return html.replace(marker, "\n".join(parts), 1)
2931
 
2932
 
 
 
 
2933
  def _app_html() -> str:
2934
- return _inline_maplibre(_read_html("index.html"))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2935
 
2936
 
2937
  @app.get("/", response_class=HTMLResponse)
 
2448
  "photo_pinned", "photo_moved", "photo_props", "photos_reset",
2449
  # navigation that collaborators can optionally follow
2450
  "screen_changed", "variant_changed",
2451
+ # LIVE shared image library: the full photo list, relayed on every
2452
+ # add / delete / edit so both people always see the same images instantly.
2453
+ "photos_state",
2454
+ # ambient "what is my collaborator doing / where are they" presence beacon
2455
+ # (navigation, AI activity, etc.) — shown as a floating label by the cursor.
2456
+ "activity",
2457
+ # SYNCED "Play your journey" playback. Only the lock-holding driver emits
2458
+ # these; the peer applies them so both watch the SAME flythrough. The lock
2459
+ # itself (journey_acquire/release/state) is server-owned (NON_RELAY) below.
2460
+ "journey_frame", "journey_transport", "journey_start", "journey_stop",
2461
+ # darkroom develop: which prints have been revealed (shake) so the peer's
2462
+ # darkroom shows the same reveal progress and developed watercolours.
2463
+ "darkroom_reveal", "darkroom_phase", "darkroom_developed",
2464
+ # map fullscreen toggle (optional follow, same as screen/variant)
2465
+ "map_fullscreen",
2466
  }
2467
 
2468
  # Types the server originates or manages itself — these must NEVER be relayed
 
2471
  "init", "me", "presence", "presence_update", "status",
2472
  "user_joined", "user_left", "rename", "error",
2473
  "join_request", "join_pending", "join_declined", "admit_join", "decline_join", "cancel_join",
2474
+ # the AI mutual-exclusion lock is server-authoritative: clients REQUEST it,
2475
+ # the server grants/denies and broadcasts the resulting ai_state. Clients
2476
+ # may not forge ai_state directly, so it's non-relay.
2477
+ "ai_acquire", "ai_release", "ai_state",
2478
+ # the journey mutual-exclusion lock is likewise server-authoritative.
2479
+ "journey_acquire", "journey_release", "journey_refresh", "journey_state",
2480
+ # room-full notice is server-originated
2481
+ "room_full",
2482
  }
2483
 
2484
+ # A room holds at most this many simultaneous participants. The owner plus one
2485
+ # guest = 2. A third connection is rejected with a room_full notice.
2486
+ ROOM_CAPACITY = int(os.environ.get("SOZAI_ROOM_CAPACITY", "2"))
2487
+
2488
 
2489
  # NOTE: This transport was a raw WebSocket (/ws/{room_id}). Hugging Face
2490
  # Spaces' router silently drops custom (non-Gradio) WebSocket UPGRADE requests
 
2542
  def __init__(self):
2543
  self.rooms = {} # room_id -> {session_id: _Client} (admitted)
2544
  self.pending = {} # room_id -> {session_id: info} (awaiting host)
2545
+ # AI mutual-exclusion lock per room. room_id -> {"session_id","name","action","ts"}
2546
+ # or None when free. Only one participant may run AI at a time.
2547
+ self.ai_lock = {}
2548
+ # Journey mutual-exclusion lock per room. The "Play your journey"
2549
+ # flythrough (and the watercolour "develop" pass) are GPU/memory heavy;
2550
+ # letting BOTH people trigger them at once risks an out-of-memory crash.
2551
+ # So only one participant may drive a journey at a time. The driver's
2552
+ # control frames are relayed to the peer so they watch the SAME playback
2553
+ # (see the journey_* relay events) — the peer's button stays disabled
2554
+ # until the driver finishes or stops. room_id -> {session_id,name,ts}.
2555
+ self.journey_lock = {}
2556
+
2557
+ # ---- capacity --------------------------------------------------------- #
2558
+ def participant_count(self, room_id):
2559
+ """Number of DISTINCT admitted sessions (a person who reconnects with the
2560
+ same session_id counts once, so a refresh doesn't burn a seat)."""
2561
+ return len({sid for sid in self.rooms.get(room_id, {})})
2562
+
2563
+ def has_seat(self, room_id, session_id):
2564
+ """True if this session can occupy/continue a seat: either it's already
2565
+ in the room (reconnect) or the room is below capacity."""
2566
+ peers = self.rooms.get(room_id, {})
2567
+ if session_id in peers:
2568
+ return True
2569
+ return len(peers) < ROOM_CAPACITY
2570
+
2571
+ # ---- AI lock ---------------------------------------------------------- #
2572
+ AI_LOCK_TIMEOUT = 90 # seconds; a stale lock (crashed holder) auto-expires
2573
+
2574
+ def ai_state(self, room_id):
2575
+ """Current AI lock for a room as a JSON-safe dict (or {'holder': None})."""
2576
+ lock = self.ai_lock.get(room_id)
2577
+ if lock and (time.time() - lock["ts"]) > self.AI_LOCK_TIMEOUT:
2578
+ # stale — the holder went away without releasing
2579
+ self.ai_lock.pop(room_id, None)
2580
+ lock = None
2581
+ if not lock:
2582
+ return {"holder": None}
2583
+ return {"holder": {k: lock[k] for k in ("session_id", "name", "action")}}
2584
+
2585
+ def ai_acquire(self, room_id, session_id, name, action):
2586
+ """Try to take the AI lock. Returns True if acquired (or already held by
2587
+ this same session — re-acquire/refresh), False if held by someone else."""
2588
+ lock = self.ai_lock.get(room_id)
2589
+ if lock and (time.time() - lock["ts"]) > self.AI_LOCK_TIMEOUT:
2590
+ lock = None
2591
+ if lock and lock["session_id"] != session_id:
2592
+ return False
2593
+ self.ai_lock[room_id] = {
2594
+ "session_id": session_id, "name": name,
2595
+ "action": action, "ts": time.time(),
2596
+ }
2597
+ return True
2598
+
2599
+ def ai_release(self, room_id, session_id):
2600
+ """Release the lock if this session holds it."""
2601
+ lock = self.ai_lock.get(room_id)
2602
+ if lock and lock["session_id"] == session_id:
2603
+ self.ai_lock.pop(room_id, None)
2604
+ return True
2605
+ return False
2606
+
2607
+ def ai_release_all(self, room_id, session_id):
2608
+ """Drop the lock unconditionally if held by this session (used on
2609
+ disconnect so a vanished holder never blocks the room)."""
2610
+ return self.ai_release(room_id, session_id)
2611
+
2612
+ # ---- Journey lock ----------------------------------------------------- #
2613
+ # The flythrough can run for a while, so its lock lives longer than the AI
2614
+ # one. A crashed driver still auto-expires so the room never gets stuck.
2615
+ JOURNEY_LOCK_TIMEOUT = 600 # seconds
2616
+
2617
+ def journey_state(self, room_id):
2618
+ """Current journey lock for a room as a JSON-safe dict (or {'driver': None})."""
2619
+ lock = self.journey_lock.get(room_id)
2620
+ if lock and (time.time() - lock["ts"]) > self.JOURNEY_LOCK_TIMEOUT:
2621
+ self.journey_lock.pop(room_id, None)
2622
+ lock = None
2623
+ if not lock:
2624
+ return {"driver": None}
2625
+ return {"driver": {k: lock[k] for k in ("session_id", "name")}}
2626
+
2627
+ def journey_acquire(self, room_id, session_id, name):
2628
+ """Try to take the journey lock. True if acquired (or re-acquired by the
2629
+ same session), False if another participant is already driving."""
2630
+ lock = self.journey_lock.get(room_id)
2631
+ if lock and (time.time() - lock["ts"]) > self.JOURNEY_LOCK_TIMEOUT:
2632
+ lock = None
2633
+ if lock and lock["session_id"] != session_id:
2634
+ return False
2635
+ self.journey_lock[room_id] = {"session_id": session_id, "name": name, "ts": time.time()}
2636
+ return True
2637
+
2638
+ def journey_refresh(self, room_id, session_id):
2639
+ """Keep a long-running journey lock alive while the driver streams frames."""
2640
+ lock = self.journey_lock.get(room_id)
2641
+ if lock and lock["session_id"] == session_id:
2642
+ lock["ts"] = time.time()
2643
+ return True
2644
+ return False
2645
+
2646
+ def journey_release(self, room_id, session_id):
2647
+ """Release the journey lock if this session holds it."""
2648
+ lock = self.journey_lock.get(room_id)
2649
+ if lock and lock["session_id"] == session_id:
2650
+ self.journey_lock.pop(room_id, None)
2651
+ return True
2652
+ return False
2653
 
2654
  # ---- admitted participants -------------------------------------------- #
2655
  def add_client(self, room_id, client):
 
2744
 
2745
 
2746
  def _sse_queue(room_id, session_id):
2747
+ """Return a FRESH queue for a new SSE connection and mark it as the current
2748
+ one for this (room, session). If the same session reconnects (e.g. a page
2749
+ refresh opens a new stream before the old one's cleanup runs), the newest
2750
+ connection wins: the old generator detects it is no longer current and bows
2751
+ out WITHOUT evicting the live participant. This prevents a reconnect from
2752
+ dropping your own seat or AI lock."""
2753
  key = (room_id, session_id)
2754
+ q = asyncio.Queue()
2755
+ _sse_queues[key] = q
 
 
2756
  return q
2757
 
2758
 
2759
+ def _is_current_sse(room_id, session_id, q):
2760
+ return _sse_queues.get((room_id, session_id)) is q
2761
+
2762
+
2763
  async def _relay_incoming(room_id, session_id, data):
2764
  """Apply one client→server event (formerly a WS receive_json). Shared by the
2765
  POST /send endpoint. `data` already has a 'type'."""
 
2788
  pend.event.set()
2789
  return
2790
 
2791
+ # ---- AI mutual-exclusion lock ----
2792
+ # A client asks to start an AI action; the server grants it only if no one
2793
+ # else holds the lock, then broadcasts the authoritative ai_state to BOTH
2794
+ # people so the other user's AI controls disable. The requester is told
2795
+ # whether it won via a direct ai_state (with a `granted` hint).
2796
+ if t == "ai_acquire" and info is not None:
2797
+ action = str(data.get("action") or "AI")[:40]
2798
+ ok = manager.ai_acquire(room_id, session_id, info["name"], action)
2799
+ # tell the requester the outcome (granted true/false)
2800
+ await manager.send_to(room_id, session_id, {
2801
+ "type": "ai_state", "granted": ok, **manager.ai_state(room_id),
2802
+ })
2803
+ # tell everyone the new state (so the peer disables their buttons)
2804
+ await manager.broadcast(room_id, {"type": "ai_state", **manager.ai_state(room_id)}, exclude=session_id)
2805
+ touch_room(room_id)
2806
+ return
2807
+
2808
+ if t == "ai_release" and info is not None:
2809
+ manager.ai_release(room_id, session_id)
2810
+ await manager.broadcast(room_id, {"type": "ai_state", **manager.ai_state(room_id)})
2811
+ touch_room(room_id)
2812
+ return
2813
+
2814
+ # ---- Journey mutual-exclusion lock ----
2815
+ # Like the AI lock, but for the "Play your journey" flythrough / develop.
2816
+ # The requester is told whether it won; everyone is told the new driver so
2817
+ # the peer's Play button disables. The driver then streams journey_frame
2818
+ # events (relayed normally) so the peer watches the same playback.
2819
+ if t == "journey_acquire" and info is not None:
2820
+ ok = manager.journey_acquire(room_id, session_id, info["name"])
2821
+ await manager.send_to(room_id, session_id, {
2822
+ "type": "journey_state", "granted": ok, **manager.journey_state(room_id),
2823
+ })
2824
+ await manager.broadcast(room_id, {"type": "journey_state", **manager.journey_state(room_id)}, exclude=session_id)
2825
+ touch_room(room_id)
2826
+ return
2827
+
2828
+ if t == "journey_refresh" and info is not None:
2829
+ manager.journey_refresh(room_id, session_id)
2830
+ return
2831
+
2832
+ if t == "journey_release" and info is not None:
2833
+ manager.journey_release(room_id, session_id)
2834
+ await manager.broadcast(room_id, {"type": "journey_state", **manager.journey_state(room_id)})
2835
+ touch_room(room_id)
2836
+ return
2837
+
2838
  # explicitly known (RELAY_EVENTS) or simply not a server-owned type
2839
  # (NON_RELAY_EVENTS) — this way the frontend can introduce new interaction
2840
  # events without the server silently dropping them.
 
2843
  await manager.broadcast(room_id, data, exclude=session_id)
2844
  # cursors + transient interaction pings are too chatty to keep the room
2845
  # alive on; everything else counts as real activity.
2846
+ if t not in ("cursor_position", "interaction", "activity", "photos_state"):
2847
  touch_room(room_id)
2848
 
2849
 
 
2876
  host = manager.host_client(room_id, owner_session)
2877
  needs_approval = (not is_owner) and (host is not None)
2878
 
2879
+ # ---- capacity gate: at most ROOM_CAPACITY (2) people per room ----
2880
+ # The owner is never locked out of their own room. Anyone else is rejected
2881
+ # when the room is already full AND they don't already hold a seat (a plain
2882
+ # reconnect with the same session_id keeps its seat). We emit a room_full
2883
+ # notice, then close the stream so the client can show "Room is full".
2884
+ if not is_owner and not manager.has_seat(room_id, session_id):
2885
+ async def _full():
2886
+ yield f'data: {json.dumps({"type": "room_full", "capacity": ROOM_CAPACITY}, separators=(",", ":"))}\n\n'
2887
+ _sse_queues.pop((room_id, session_id), None)
2888
+ return StreamingResponse(_full(), media_type="text/event-stream", headers={"Cache-Control": "no-cache"})
2889
+
2890
  if needs_approval:
2891
  pend = _PendingJoin(info)
2892
  manager.add_pending(room_id, pend)
 
2906
  client = manager.admit(room_id, info)
2907
  client.queue = q # reuse the queue the SSE stream is already draining
2908
  else:
2909
+ # New connection OR a reconnect by the same session. If the session is
2910
+ # already seated (reconnect), keep its existing _Client but repoint its
2911
+ # queue at this new SSE stream so broadcasts reach the live connection.
2912
+ existing = manager.get_client(room_id, session_id)
2913
+ if existing is not None:
2914
+ existing.queue = q
2915
+ existing.alive = True
2916
+ client = existing
2917
+ else:
2918
+ client = _Client(info)
2919
+ client.queue = q
2920
+ manager.add_client(room_id, client)
2921
 
2922
  if client is not None:
2923
  now = time.time()
 
2930
  _conn.commit()
2931
  touch_room(room_id)
2932
  await q.put({"type": "init", "you": _public(info), "participants": manager.presence(room_id)})
2933
+ # seed the current AI-lock state so a late joiner immediately knows if
2934
+ # the other person is mid-AI-action (and disables their own AI buttons).
2935
+ await q.put({"type": "ai_state", **manager.ai_state(room_id)})
2936
+ # likewise seed the journey-lock state, so a late joiner's Play button
2937
+ # is correctly disabled if the other person is already driving a journey.
2938
+ await q.put({"type": "journey_state", **manager.journey_state(room_id)})
2939
  await manager.broadcast(room_id, {"type": "user_joined", "participant": _public(info)}, exclude=session_id)
2940
  await manager.broadcast(room_id, {"type": "presence_update", "participants": manager.presence(room_id)})
2941
 
 
2954
  except asyncio.CancelledError:
2955
  pass
2956
  finally:
2957
+ # Only tear down if THIS connection is still the current one for the
2958
+ # session. If the same session reconnected (refresh), a newer stream
2959
+ # already took over — this superseded generator must NOT evict the
2960
+ # live participant, drop their seat, or free their AI lock.
2961
+ superseded = not _is_current_sse(room_id, session_id, q)
2962
+ if not superseded:
2963
+ _sse_queues.pop((room_id, session_id), None)
2964
+ if not superseded and manager.get_client(room_id, session_id) is not None:
2965
+ # if this person was holding the AI lock, free it so the room
2966
+ # isn't stuck "someone is generating…" forever after they leave.
2967
+ released = manager.ai_release_all(room_id, session_id)
2968
+ # same for the journey lock: a driver who leaves must not block
2969
+ # the peer's Play button forever.
2970
+ journey_released = manager.journey_release(room_id, session_id)
2971
  manager.disconnect(room_id, session_id)
2972
  await manager.broadcast(room_id, {"type": "user_left", "participant": _public(info)})
2973
  await manager.broadcast(room_id, {"type": "presence_update", "participants": manager.presence(room_id)})
2974
+ if released:
2975
+ await manager.broadcast(room_id, {"type": "ai_state", **manager.ai_state(room_id)})
2976
+ if journey_released:
2977
+ await manager.broadcast(room_id, {"type": "journey_state", **manager.journey_state(room_id)})
2978
 
2979
  return StreamingResponse(event_gen(), media_type="text/event-stream", headers={
2980
  "Cache-Control": "no-cache",
 
3162
  return html.replace(marker, "\n".join(parts), 1)
3163
 
3164
 
3165
+ _APP_HTML_CACHE: str | None = None
3166
+
3167
+
3168
  def _app_html() -> str:
3169
+ """Assembled app HTML, built once and cached in memory (the file doesn't
3170
+ change at runtime, so re-reading + re-inlining ~500 KB per request is waste).
3171
+
3172
+ Prefers `index.build.html` — the precompiled output of build_frontend.py,
3173
+ which ships plain JS instead of making the browser download @babel/standalone
3174
+ and transpile the whole app on first paint. Falls back to `index.html` (the
3175
+ editable source, which still works via the in-browser Babel fallback) when
3176
+ no build exists, e.g. during local development."""
3177
+ global _APP_HTML_CACHE
3178
+ if _APP_HTML_CACHE is None:
3179
+ built = os.path.join(BASE, "index.build.html")
3180
+ if os.path.exists(built):
3181
+ if os.path.getmtime(os.path.join(BASE, "index.html")) > os.path.getmtime(built):
3182
+ print("[frontend] WARNING: index.html is newer than index.build.html — "
3183
+ "rerun `python build_frontend.py` before deploying.")
3184
+ name = "index.build.html"
3185
+ else:
3186
+ print("[frontend] no index.build.html — serving un-precompiled index.html "
3187
+ "(slow first boot). Run `python build_frontend.py`.")
3188
+ name = "index.html"
3189
+ _APP_HTML_CACHE = _inline_maplibre(_read_html(name))
3190
+ return _APP_HTML_CACHE
3191
 
3192
 
3193
  @app.get("/", response_class=HTMLResponse)
build_frontend.py ADDED
@@ -0,0 +1,98 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Precompile the in-browser JSX so first boot doesn't ship @babel/standalone
3
+ (~0.7 MB gzip) and transpile ~7.5k lines of JSX on the client's main thread
4
+ before anything paints.
5
+
6
+ `index.html` stays the editable source: it still works opened standalone via
7
+ the in-browser Babel fallback. This script reads it and writes
8
+ `index.build.html` with:
9
+ * the `<script type="text/babel">` block transpiled to plain JS (esbuild),
10
+ * the `@babel/standalone` CDN <script> removed.
11
+
12
+ The server (app.py) serves `index.build.html` when present, else falls back to
13
+ `index.html`. So the deploy flow is:
14
+
15
+ python build_frontend.py # before `git push` to the Space
16
+
17
+ Requires `npx` (esbuild is fetched on demand) or a local `esbuild` on PATH.
18
+ """
19
+ import os
20
+ import re
21
+ import shutil
22
+ import subprocess
23
+ import sys
24
+ import tempfile
25
+
26
+ BASE = os.path.dirname(os.path.abspath(__file__))
27
+ SRC = os.path.join(BASE, "index.html")
28
+ OUT = os.path.join(BASE, "index.build.html")
29
+ ESBUILD_VERSION = "0.21.5"
30
+
31
+ # Match the SINGLE in-browser Babel block. The block contains no nested
32
+ # "</script>" (verified), so the non-greedy match captures all of it.
33
+ BABEL_BLOCK_RE = re.compile(r'<script type="text/babel"[^>]*>(.*?)</script>', re.S)
34
+ BABEL_CDN_RE = re.compile(r'[ \t]*<script[^>]*@babel/standalone[^>]*></script>\s*\n', re.S)
35
+
36
+
37
+ def _esbuild_cmd():
38
+ """Prefer a local esbuild; otherwise go through npx (downloads on demand)."""
39
+ if shutil.which("esbuild"):
40
+ return ["esbuild"]
41
+ if shutil.which("npx"):
42
+ return ["npx", "--yes", f"esbuild@{ESBUILD_VERSION}"]
43
+ sys.exit("error: need `esbuild` or `npx` on PATH to precompile the frontend.")
44
+
45
+
46
+ def transpile(jsx: str) -> str:
47
+ with tempfile.TemporaryDirectory() as td:
48
+ inp = os.path.join(td, "app.jsx")
49
+ outp = os.path.join(td, "app.js")
50
+ with open(inp, "w", encoding="utf-8") as fh:
51
+ fh.write(jsx)
52
+ # Classic JSX runtime against the global React UMD (no auto-import),
53
+ # matching the current `data-presets="react"` behaviour. es2020 keeps
54
+ # broad browser support without down-levelling more than Babel did.
55
+ cmd = _esbuild_cmd() + [
56
+ inp,
57
+ "--jsx-factory=React.createElement",
58
+ "--jsx-fragment=React.Fragment",
59
+ "--target=es2020",
60
+ "--charset=utf8",
61
+ f"--outfile={outp}",
62
+ ]
63
+ res = subprocess.run(cmd, capture_output=True, text=True)
64
+ if res.returncode != 0:
65
+ sys.stderr.write(res.stderr)
66
+ sys.exit("error: esbuild failed to transpile the JSX (see above).")
67
+ with open(outp, "r", encoding="utf-8") as fh:
68
+ return fh.read()
69
+
70
+
71
+ def main() -> None:
72
+ with open(SRC, "r", encoding="utf-8") as fh:
73
+ html = fh.read()
74
+
75
+ m = BABEL_BLOCK_RE.search(html)
76
+ if not m:
77
+ sys.exit('error: no <script type="text/babel"> block found in index.html')
78
+
79
+ js = transpile(m.group(1))
80
+ # Guard against any "</script>" inside string/template literals closing our tag early.
81
+ js = js.replace("</script", "<\\/script")
82
+
83
+ html = html[: m.start()] + "<script>\n" + js + "\n</script>" + html[m.end():]
84
+ html, n = BABEL_CDN_RE.subn("", html)
85
+ if n == 0:
86
+ print("warning: @babel/standalone CDN <script> not found to remove", file=sys.stderr)
87
+
88
+ header = "<!-- GENERATED by build_frontend.py from index.html — do not edit; edit index.html and rerun. -->\n"
89
+ with open(OUT, "w", encoding="utf-8") as fh:
90
+ fh.write(header + html)
91
+
92
+ print(f"wrote {os.path.relpath(OUT, BASE)} "
93
+ f"({len(html) / 1024:.0f} KB, JSX → {len(js) / 1024:.0f} KB plain JS, "
94
+ f"removed {n} babel CDN tag)")
95
+
96
+
97
+ if __name__ == "__main__":
98
+ main()
index.build.html ADDED
The diff for this file is too large to render. See raw diff
 
index.html CHANGED
@@ -3,7 +3,7 @@
3
  <head>
4
  <meta charset="UTF-8" />
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
- <title>Sozai · Add to Album</title>
7
  <link rel="icon" type="image/svg+xml" href="/assets/icon.svg" />
8
 
9
  <link rel="preconnect" href="https://fonts.googleapis.com" />
@@ -109,6 +109,21 @@
109
  * { @apply border-border outline-ring/50; }
110
  body { @apply bg-background text-foreground; }
111
  html { @apply font-sans; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
112
  }
113
  </style>
114
 
@@ -1195,6 +1210,62 @@
1195
  };
1196
  })();
1197
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1198
  // Adapt a SozaiPhotos item to the shape that the map-view components expect.
1199
  // x/y are deterministic synthetic percentages used for scrapbook pin positions.
1200
  function storeToMoment(p, i) {
@@ -1246,6 +1317,177 @@
1246
  useEffect(() => Collab.on("me", setM), []);
1247
  return m;
1248
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1249
  function initials(name) {
1250
  const parts = (name || "Guest").trim().split(/\s+/);
1251
  return ((parts[0]?.[0] || "G") + (parts[1]?.[0] || "")).toUpperCase();
@@ -1277,6 +1519,28 @@
1277
  );
1278
  }
1279
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1280
  // ----------------------------------------------------------------- //
1281
  // Collaboration bar (share code + link, presence, name, chat toggle)
1282
  // ----------------------------------------------------------------- //
@@ -1355,6 +1619,8 @@
1355
  </div>
1356
 
1357
  <div className="flex items-center gap-3">
 
 
1358
  {/* presence */}
1359
  <div className="flex items-center gap-1.5">
1360
  <Users className="size-4 text-primary" />
@@ -1652,7 +1918,10 @@
1652
  }
1653
  return (
1654
  <div className="flex flex-col items-center gap-4">
1655
- <div className="relative w-full max-w-xs">
 
 
 
1656
  <div className="absolute inset-0 translate-x-3 translate-y-3 rotate-2 rounded-md border-2 border-primary/30 bg-card" />
1657
  <div className="absolute inset-0 translate-x-1.5 translate-y-1.5 rotate-1 rounded-md border-2 border-primary/40 bg-card" />
1658
  {/* keyed by index so the slide animation re-triggers on each browse;
@@ -1804,7 +2073,7 @@
1804
  <button type="submit" disabled={busy} className="shrink-0 rounded-md border-2 border-primary bg-primary px-2.5 py-1.5 font-mono text-[11px] uppercase text-primary-foreground disabled:opacity-50">{busy ? "…" : "Go"}</button>
1805
  </form>
1806
  <div ref={elRef} className="h-40 w-full overflow-hidden rounded-md border-2 border-primary/40" />
1807
- <p className="mt-1.5 font-mono text-[10px] leading-snug text-muted-foreground">Drag the pin or tap the map this is your photo's general area.</p>
1808
  <div className="mt-2 flex items-center gap-2">
1809
  <span className="font-mono text-[10px] uppercase tracking-wide text-primary">Radius</span>
1810
  <input type="range" min="100" max="5000" step="100" value={rad} onChange={(e) => onRad(parseInt(e.target.value, 10))} className="min-w-0 flex-1 accent-primary" />
@@ -1837,7 +2106,7 @@
1837
  // ----------------------------------------------------------------- //
1838
  // components/details-form.tsx
1839
  // ----------------------------------------------------------------- //
1840
- function DetailsForm({ title, onTitleChange, caption, onCaptionChange, date, onDateChange, time, onTimeChange, lat, lon, locationName, radius, onLocationChange, tags, onTagsChange, onSubmit, onAutoCaption, autoBusy, autoStage, autoError, captionPrompt, onCaptionPromptChange, captionModel, onAutoTitle, titleBusy, titleStage, titleError, titlePrompt, onTitlePromptChange, onAutoTags, tagsBusy, tagsStage, tagsError, tagsPrompt, onTagsPromptChange, onCancelGen }) {
1841
  const CancelGen = () => (
1842
  <button type="button" onClick={onCancelGen}
1843
  className="relative shrink-0 rounded-full border-2 border-primary/40 bg-card/85 px-2.5 py-0.5 font-mono text-[10px] uppercase tracking-wide text-primary transition-colors hover:bg-accent hover:text-foreground">
@@ -1852,6 +2121,13 @@
1852
  const DEFAULT_TAGS_HINT = "List 3 to 6 short, comma-separated tags for this photo.";
1853
  return (
1854
  <div className="flex flex-col gap-6">
 
 
 
 
 
 
 
1855
  {/* title */}
1856
  <div className="space-y-2">
1857
  <div className="flex items-center justify-between gap-2">
@@ -1865,9 +2141,10 @@
1865
  showTitlePrompt ? "border-primary bg-accent text-foreground" : "border-primary/60 text-primary hover:bg-accent")}>
1866
  <Settings className="size-3.5" /> Prompt
1867
  </button>
1868
- <button type="button" onClick={onAutoTitle} disabled={titleBusy}
1869
- className="flex items-center gap-1.5 rounded-md border-2 border-primary/60 px-2.5 py-1 font-mono text-[11px] uppercase tracking-wide text-primary transition-colors hover:bg-accent disabled:opacity-60">
1870
- <Wand2 className={cn("size-3.5", titleBusy && "animate-pulse")} /> {titleBusy ? "Naming…" : "Auto-generate"}
 
1871
  </button>
1872
  </div>
1873
  </div>
@@ -1937,9 +2214,10 @@
1937
  showPrompt ? "border-primary bg-accent text-foreground" : "border-primary/60 text-primary hover:bg-accent")}>
1938
  <Settings className="size-3.5" /> Prompt
1939
  </button>
1940
- <button type="button" onClick={onAutoCaption} disabled={autoBusy}
1941
- className="flex items-center gap-1.5 rounded-md border-2 border-primary/60 px-2.5 py-1 font-mono text-[11px] uppercase tracking-wide text-primary transition-colors hover:bg-accent disabled:opacity-60">
1942
- <Wand2 className={cn("size-3.5", autoBusy && "animate-pulse")} /> {autoBusy ? "Developing…" : "Auto-generate"}
 
1943
  </button>
1944
  </div>
1945
  </div>
@@ -2095,12 +2373,22 @@
2095
  const [tagsBusy, setTagsBusy] = useState(false);
2096
  const [tagsError, setTagsError] = useState(null);
2097
  const [tagsStage, setTagsStage] = useState("");
 
2098
 
2099
- // remote field edits from collaborators - apply by photo index onto the
2100
- // single stackPhotos source of truth.
 
 
2101
  useEffect(() => Collab.on("object_updated", (m) => {
2102
  if (typeof m.index !== "number" || !m.kind) return;
2103
- setStackPhotos((prev) => prev.map((p, i) => (i === m.index ? { ...p, [m.kind]: m.value } : p)));
 
 
 
 
 
 
 
2104
  }), []);
2105
 
2106
  // follow a collaborator to the photo they switch to (when follow is on)
@@ -2130,23 +2418,15 @@
2130
  const date = (photo && photo.date != null && photo.date !== "") ? photo.date : DEFAULT_DATE;
2131
  const time = (photo && photo.time != null && photo.time !== "") ? photo.time : DEFAULT_TIME;
2132
 
2133
- // Seed the shared photo store with the album photos + their fields so the
2134
- // Map of My Time draft tray can pick and pin them (persisted).
2135
- useEffect(() => {
2136
- stackPhotos.forEach((p) => {
2137
- window.SozaiPhotos.upsert(p.id, {
2138
- src: p.src, alt: p.alt || "",
2139
- title: p.title || "", caption: p.caption || "",
2140
- date: p.date || "", time: p.time || "",
2141
- });
2142
- });
2143
- }, []); // eslint-disable-line
2144
 
2145
- // Edit a field on the current photo: updates the single source of truth,
2146
- // broadcasts to collaborators, and mirrors to the shared store.
 
 
2147
  function makeFieldSetter(kind) {
2148
  return (value) => {
2149
- setStackPhotos((prev) => prev.map((p, i) => (i === safeIndex ? { ...p, [kind]: value } : p)));
2150
  Collab.send("object_updated", { kind, index: safeIndex, value });
2151
  const pid = photo && photo.id;
2152
  if (pid) window.SozaiPhotos.upsert(pid, { [kind]: value });
@@ -2162,17 +2442,15 @@
2162
  const locationName = (photo && photo.locationName) || "";
2163
  const radius = (photo && photo.radius != null) ? photo.radius : null;
2164
  // A location edit touches several fields at once (lat/lon/name/radius +
2165
- // the pinned flag) - mirror the whole patch to the photo and shared store.
2166
  const setLocation = (patch) => {
2167
- setStackPhotos((prev) => prev.map((p, i) => (i === safeIndex ? { ...p, ...patch } : p)));
2168
  const pid = photo && photo.id;
2169
  if (pid) window.SozaiPhotos.upsert(pid, patch);
2170
  Collab.send("object_updated", { kind: "location", index: safeIndex, value: patch });
2171
  };
2172
  // reposition the focal point of the current photo (object-position %),
2173
- // saved on the photo + shared store so it persists.
2174
  function onReposition(fx, fy) {
2175
- setStackPhotos((prev) => prev.map((p, i) => (i === safeIndex ? { ...p, focusX: fx, focusY: fy } : p)));
2176
  const pid = photo && photo.id;
2177
  if (pid) window.SozaiPhotos.upsert(pid, { focusX: fx, focusY: fy });
2178
  }
@@ -2218,14 +2496,13 @@
2218
  };
2219
  const id = window.SozaiPhotos.add(newPhoto);
2220
  newPhoto.id = id;
2221
- setStackPhotos((prev) => [...prev, newPhoto]);
2222
  added++;
2223
  // resolve a readable "general area" for the EXIF GPS, in the background
2224
  if (exif.lat != null) {
2225
  reverseGeocode(exif.lat, exif.lon).then((nm) => {
2226
  if (!nm) return;
2227
  window.SozaiPhotos.upsert(id, { locationName: nm });
2228
- setStackPhotos((prev) => prev.map((p) => (p.id === id ? { ...p, locationName: nm } : p)));
2229
  });
2230
  }
2231
  }
@@ -2244,9 +2521,7 @@
2244
  title: s.title || "", caption: s.caption || "",
2245
  date: DEFAULT_DATE, time: DEFAULT_TIME,
2246
  };
2247
- const id = window.SozaiPhotos.add(newPhoto);
2248
- newPhoto.id = id;
2249
- setStackPhotos((prev) => [...prev, newPhoto]);
2250
  setPhotoIndex(base);
2251
  }
2252
  // "Try a sample" — load the whole built-in photo set at once (with their
@@ -2263,7 +2538,7 @@
2263
  date: p.date, time: p.time, lat: p.lat, lon: p.lon,
2264
  pinned: p.lat != null && p.lon != null,
2265
  }));
2266
- setStackPhotos(loaded);
2267
  setPhotoIndex(0);
2268
  }
2269
 
@@ -2303,7 +2578,7 @@
2303
  if (typeof idx !== "number") idx = safeIndex;
2304
  const removedId = stackPhotos[idx] && stackPhotos[idx].id;
2305
  if (removedId) window.SozaiPhotos.remove(removedId);
2306
- setStackPhotos((prev) => prev.filter((_, i) => i !== idx));
2307
  setPhotoIndex((prev) => {
2308
  const next = prev > idx ? prev - 1 : prev;
2309
  return Math.max(0, next);
@@ -2325,6 +2600,7 @@
2325
  setAutoBusy(false); setAutoStage("");
2326
  setTitleBusy(false); setTitleStage("");
2327
  setTagsBusy(false); setTagsStage("");
 
2328
  }
2329
 
2330
  function changePhoto(delta) {
@@ -2361,6 +2637,9 @@
2361
  async function handleAutoCaption() {
2362
  if (autoBusy) return;
2363
  if (!photo) return;
 
 
 
2364
  if (window.sozaiPing) window.sozaiPing("✨ auto-generating caption");
2365
  setAutoBusy(true); setAutoError(null);
2366
  const token = { cancelled: false, timers: [] };
@@ -2381,6 +2660,7 @@
2381
  if (token.cancelled) return; // cancelled mid-flight: drop it
2382
  fn(); setAutoBusy(false); setAutoStage("");
2383
  if (genRef.current === token) genRef.current = null;
 
2384
  }, wait);
2385
  token.timers.push(t);
2386
  };
@@ -2406,6 +2686,9 @@
2406
  async function handleAutoTitle() {
2407
  if (titleBusy) return;
2408
  if (!photo) return;
 
 
 
2409
  if (window.sozaiPing) window.sozaiPing("✨ auto-generating title");
2410
  setTitleBusy(true); setTitleError(null);
2411
  const token = { cancelled: false, timers: [] };
@@ -2425,6 +2708,7 @@
2425
  if (token.cancelled) return;
2426
  fn(); setTitleBusy(false); setTitleStage("");
2427
  if (genRef.current === token) genRef.current = null;
 
2428
  }, wait);
2429
  token.timers.push(t);
2430
  };
@@ -2477,6 +2761,9 @@
2477
  async function handleAutoTags() {
2478
  if (tagsBusy) return;
2479
  if (!photo) return;
 
 
 
2480
  if (window.sozaiPing) window.sozaiPing("🏷️ auto-generating tags");
2481
  setTagsBusy(true); setTagsError(null);
2482
  const token = { cancelled: false, timers: [] };
@@ -2496,6 +2783,7 @@
2496
  if (token.cancelled) return;
2497
  fn(); setTagsBusy(false); setTagsStage("");
2498
  if (genRef.current === token) genRef.current = null;
 
2499
  }, wait);
2500
  token.timers.push(t);
2501
  };
@@ -2550,10 +2838,9 @@
2550
  return;
2551
  }
2552
  setValidationError(null);
2553
- // Push the album photos into the shared store so the MAP (and any live
2554
- // room) actually receives them. stackPhotos and SozaiPhotos are separate,
2555
- // and joining a room clears the store's seed without this the next page
2556
- // shows "0 photos". GPS photos auto-pin onto the globe like the seed does.
2557
  stackPhotos.forEach((p) => window.SozaiPhotos.upsert(p.id, {
2558
  src: p.src, alt: p.alt || "",
2559
  title: p.title || p.defaultTitle || "",
@@ -2583,7 +2870,7 @@
2583
  return (
2584
  <section className="w-full max-w-6xl rounded-xl border-2 border-primary bg-card shadow-lg">
2585
  <header className="flex items-center justify-between gap-4 border-b-2 border-dashed border-[#f5edd8]/30 bg-[#13294b] px-5 py-4 sm:px-7">
2586
- <div className="flex items-center gap-2.5"><h1 className="font-display text-2xl text-[#f5edd8]">Develop Photos</h1></div>
2587
  <div className="flex items-center gap-2">
2588
  <button type="button" onClick={() => onOpenSettings && onOpenSettings()} aria-label="Settings" className="flex size-9 items-center justify-center rounded-md border-2 border-[#f5edd8]/50 text-[#f5edd8] transition-colors hover:bg-white/10"><Settings className="size-4" /></button>
2589
  </div>
@@ -2642,17 +2929,18 @@
2642
  titlePrompt={settings.titlePrompt || ""} onTitlePromptChange={setTitlePrompt}
2643
  onAutoTags={handleAutoTags} tagsBusy={tagsBusy} tagsStage={tagsStage} tagsError={tagsError}
2644
  tagsPrompt={settings.tagsPrompt || ""} onTagsPromptChange={(v) => update({ tagsPrompt: v })}
2645
- onCancelGen={cancelGeneration} />
 
2646
 
2647
  {/* choose how the Map of My Time will look once developed */}
2648
  <MapStylePicker variant={mapVariant} onChange={setMapVariant} />
2649
 
2650
  {validationError ? <p role="alert" className="rounded-md border-2 border-[oklch(0.55_0.18_25)]/50 bg-[oklch(0.55_0.18_25)]/10 px-3 py-2 text-center font-mono text-[11px] uppercase tracking-wide text-[oklch(0.5_0.18_25)]">{validationError}</p> : null}
2651
  {confirmation ? <p role="status" className="rounded-md border-2 border-primary/40 bg-accent px-3 py-2 text-center text-sm text-accent-foreground">{confirmation}</p> : null}
2652
- {/* develop = advance to the darkroom, so an arrow rather than a plus */}
2653
  <button type="button" onClick={handleSubmit}
2654
  className="flex w-full items-center justify-center gap-2 rounded-md border-2 border-primary bg-primary px-4 py-3 font-mono text-sm font-semibold uppercase tracking-wide text-primary-foreground transition-colors hover:bg-primary/90">
2655
- Develop Photos <ArrowRight className="size-4" />
2656
  </button>
2657
  </div>
2658
  </div>
@@ -2701,18 +2989,23 @@
2701
  // components/map/moment-card.tsx
2702
  // ----------------------------------------------------------------- //
2703
  function MomentCard({ moment, selected, onSelect, rotate = "" }) {
 
 
 
 
 
2704
  return (
2705
  <button type="button" onClick={() => onSelect && onSelect(moment.id)} style={{ left: `${moment.x}%`, top: `${moment.y}%` }}
2706
- className={cn("group absolute z-10 w-44 -translate-x-1/2 -translate-y-1/2 rounded-sm border-2 border-primary bg-card p-2 text-left shadow-md transition-transform hover:z-20 hover:scale-105 sm:w-52", rotate, selected && "z-20 ring-4 ring-primary/30")}>
2707
  <span aria-hidden="true" className="absolute -top-2.5 left-1/2 h-4 w-12 -translate-x-1/2 -rotate-2 rounded-sm bg-primary/15" />
2708
- <div className="relative aspect-square w-full overflow-hidden rounded-sm border border-primary/30"><Img src={moment.src} alt={moment.alt} fill sizes="208px" className="object-cover [filter:saturate(0.85)_contrast(1.05)]" /></div>
2709
  <div className="px-0.5 pb-0.5 pt-2">
2710
  <div className="flex items-start justify-between gap-1">
2711
- <h3 className="font-serif text-xs font-semibold leading-tight text-foreground">{moment.title}</h3>
2712
  {moment.favorite ? <Star className="mt-0.5 size-3.5 shrink-0 fill-primary text-primary" /> : null}
2713
  </div>
2714
- <p className="mt-1 font-hand text-[12px] leading-snug text-muted-foreground">{moment.caption}</p>
2715
- <p className="mt-1.5 font-mono text-[10px] text-primary/70">{moment.date}</p>
2716
  </div>
2717
  </button>
2718
  );
@@ -3292,6 +3585,7 @@
3292
  function MicButton({ field, onText, title }) {
3293
  const { settings, update } = useSettings();
3294
  const asrCfg = useAsrConfig();
 
3295
  const [state, setState] = useState("idle"); // idle | recording | working | error
3296
  const [lang, setLang] = useState("");
3297
  const [note, setNote] = useState("");
@@ -3306,12 +3600,16 @@
3306
 
3307
  async function start() {
3308
  setNote(""); setLang("");
 
3309
  if (asrCfg && asrCfg.osSupported === false) {
3310
  setState("error"); setNote("Nemotron is not supported on this OS."); return;
3311
  }
3312
  if (!navigator.mediaDevices || typeof MediaRecorder === "undefined") {
3313
  setState("error"); setNote("Recording not supported here."); return;
3314
  }
 
 
 
3315
  try {
3316
  const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
3317
  streamRef.current = stream;
@@ -3326,6 +3624,7 @@
3326
  if (window.sozaiPing) window.sozaiPing("🎤 recording " + field);
3327
  } catch (e) {
3328
  setState("error"); setNote("Microphone blocked.");
 
3329
  }
3330
  }
3331
  function stop() {
@@ -3356,20 +3655,23 @@
3356
  }
3357
  } catch (e) {
3358
  setState("error"); setNote("Transcription failed.");
 
 
3359
  }
3360
  }
3361
 
3362
  const recording = state === "recording";
3363
  const working = state === "working";
3364
  const osBlocked = asrCfg && asrCfg.osSupported === false;
 
3365
  return (
3366
  <span className="relative inline-flex items-center gap-1.5">
3367
  <button type="button"
3368
  onClick={() => (recording ? stop() : start())}
3369
- disabled={working || osBlocked}
3370
  aria-pressed={recording}
3371
- title={osBlocked ? "Nemotron is not supported on this OS" : (title || ("Dictate " + field))}
3372
- className={cn("flex size-7 items-center justify-center rounded-full border-2 transition-colors disabled:opacity-60",
3373
  recording ? "border-[oklch(0.55_0.2_25)] bg-[oklch(0.55_0.2_25)] text-white animate-pulse"
3374
  : "border-primary/60 text-primary hover:bg-accent")}>
3375
  {working ? <Loader2 className="size-3.5 animate-spin" /> : recording ? <Square className="size-3" /> : <Mic className="size-3.5" />}
@@ -4037,6 +4339,18 @@
4037
  ];
4038
  const [transport, setTransport] = useState("car");
4039
  const transportMarkerRef = useRef(null);
 
 
 
 
 
 
 
 
 
 
 
 
4040
  // recording the journey to a downloadable clip (item 5)
4041
  const [recording, setRecording] = useState(false);
4042
  const recorderRef = useRef(null);
@@ -4206,7 +4520,7 @@
4206
  el.className = "sozai-polaroid";
4207
  el.style.setProperty("--rot", (p.rot || 0) + "deg");
4208
  el.innerHTML =
4209
- '<img src="' + p.src + '" alt="">' +
4210
  (p.caption ? '<div class="cap">' + p.caption.replace(/</g, "&lt;") + '</div>' : '<div class="cap">&nbsp;</div>');
4211
  const del = document.createElement("button");
4212
  del.className = "pdel"; del.type = "button"; del.title = "Remove"; del.textContent = "×";
@@ -4245,7 +4559,7 @@
4245
  const meta = p.date ? _dmy(p.date) : "";
4246
  const objPos = (p.focusX != null ? p.focusX : 50) + "% " + (p.focusY != null ? p.focusY : 50) + "%";
4247
  el.innerHTML =
4248
- '<img src="' + p.src + '" alt="" style="object-position:' + objPos + '">' +
4249
  '<div class="cap">' + ((p.caption || p.title || "&nbsp;").replace(/</g, "&lt;")) +
4250
  (meta ? '<span class="sozai-pin-when">' + meta.replace(/</g, "&lt;") + '</span>' : '') +
4251
  '</div>';
@@ -4292,7 +4606,7 @@
4292
  marker.on("dragend", () => wrap.classList.remove("is-dragging"));
4293
  storeMarkersRef.current[p.id] = { marker, el: wrap };
4294
  });
4295
- }, [pinnedStore.map((p) => p.id + ":" + p.lon + "," + p.lat + ":" + p.caption + ":" + (p.rot || 0) + ":" + (p.scale || 1) + ":" + (p.focusX != null ? p.focusX : 50) + "," + (p.focusY != null ? p.focusY : 50)).join("|"), ready, styleEpoch]);
4296
 
4297
  // highlight the active stop during a flythrough
4298
  useEffect(() => {
@@ -4331,7 +4645,22 @@
4331
  }, 420));
4332
  return () => { cancelAnimationFrame(raf); ts.forEach(clearTimeout); };
4333
  }, [isFullscreen]);
4334
- function toggleFullscreen() { setIsFullscreen((v) => !v); }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4335
 
4336
  async function searchCity() {
4337
  const q = place.trim();
@@ -4415,12 +4744,21 @@
4415
  }
4416
 
4417
  // ---- flythrough ("Map of My Time" video) ---- //
4418
- function stopFly() {
 
4419
  flyAbortRef.current.stop = true;
 
4420
  setFlying(false);
4421
  setFlyIdx(-1);
4422
  if (transportMarkerRef.current) { try { transportMarkerRef.current.remove(); } catch (e) {} transportMarkerRef.current = null; }
4423
  stopRecording();
 
 
 
 
 
 
 
4424
  }
4425
 
4426
  // move the transport emoji marker to a lng/lat (creating it if needed)
@@ -4465,12 +4803,31 @@
4465
 
4466
  async function playFly(opts) {
4467
  opts = opts || {};
 
4468
  const map = mapRef.current;
4469
  if (!map || flyStops.length === 0) return;
 
 
 
 
 
 
 
 
 
 
 
 
 
4470
  flyAbortRef.current = { stop: false };
4471
  const token = flyAbortRef.current;
4472
  setFlying(true);
4473
- if (opts.record) { const ok = startRecording(); setRecording(ok); }
 
 
 
 
 
4474
 
4475
  // draw the route as a line under the journey
4476
  const routeCoords = flyStops.map((s) => [s.lon, s.lat]);
@@ -4486,7 +4843,7 @@
4486
 
4487
  const spd = (TRANSPORTS.find((t) => t.id === transport) || TRANSPORTS[0]).speed;
4488
  // start at the first stop
4489
- setFlyIdx(0);
4490
  setTransportAt(flyStops[0].lon, flyStops[0].lat);
4491
  map.flyTo({ center: [flyStops[0].lon, flyStops[0].lat], zoom: 15, pitch: 50, duration: 1400, essential: true });
4492
  await new Promise((r) => setTimeout(r, 1500));
@@ -4513,7 +4870,7 @@
4513
  requestAnimationFrame(step);
4514
  });
4515
  if (token.stop) break;
4516
- setFlyIdx(i);
4517
  map.flyTo({ center: [b.lon, b.lat], zoom: 15.5, pitch: 55, duration: 1100, essential: true });
4518
  await new Promise((r) => setTimeout(r, 1300)); // dwell to show the photo
4519
  }
@@ -4529,7 +4886,38 @@
4529
  stopRecording();
4530
  setFlying(false);
4531
  setFlyIdx(-1);
 
 
 
 
 
 
4532
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4533
  useEffect(() => () => { flyAbortRef.current.stop = true; stopRecording(); }, []);
4534
 
4535
  if (libMissing) {
@@ -4598,8 +4986,8 @@
4598
  </span>
4599
  <div className="flex flex-wrap items-center gap-1.5">
4600
  {TRANSPORTS.map((t) => (
4601
- <button key={t.id} type="button" onClick={() => setTransport(t.id)} aria-pressed={transport === t.id} title={t.label}
4602
- disabled={flying}
4603
  className={cn("flex items-center gap-1 rounded-full border-2 px-2 py-1 font-mono text-[11px] uppercase tracking-wide transition-colors disabled:opacity-50",
4604
  transport === t.id ? "border-primary bg-accent text-foreground" : "border-primary/40 text-muted-foreground hover:bg-accent/50")}>
4605
  <span aria-hidden="true">{t.emoji}</span>{t.label}
@@ -4607,11 +4995,13 @@
4607
  ))}
4608
  </div>
4609
  <button type="button" onClick={() => (flying ? stopFly() : playFly({ record: false }))}
4610
- className="flex items-center gap-1.5 rounded-md border-2 border-primary bg-primary px-3 py-1.5 font-mono text-[11px] font-semibold uppercase tracking-wide text-primary-foreground transition-colors hover:bg-primary/90">
4611
- {flying ? <X className="size-3.5" /> : <FastForward className="size-3.5" />} {flying ? "Stop" : "Play"}
 
 
4612
  </button>
4613
- <button type="button" onClick={() => (flying ? stopFly() : playFly({ record: true }))} disabled={flying}
4614
- title="Play the journey and save it as a video clip"
4615
  className="flex items-center gap-1.5 rounded-md border-2 border-primary/60 px-3 py-1.5 font-mono text-[11px] uppercase tracking-wide text-primary transition-colors hover:bg-accent disabled:opacity-50">
4616
  <span className={cn("size-2.5 rounded-full", recording ? "animate-pulse bg-[oklch(0.55_0.2_25)]" : "bg-primary")} /> {recording ? "Recording…" : "Record clip"}
4617
  </button>
@@ -4621,6 +5011,9 @@
4621
  <Download className="size-3.5" /> Download clip
4622
  </a>
4623
  ) : null}
 
 
 
4624
  </div>
4625
  ) : null}
4626
 
@@ -4670,10 +5063,11 @@
4670
  {/* flythrough control (only when there are pinned album photos) */}
4671
  {flyStops.length > 0 ? (
4672
  <button type="button" onClick={() => (flying ? stopFly() : playFly())}
4673
- title={flying ? "Stop the flythrough" : "Play Your Journey"}
4674
- className="absolute right-2 top-2 z-10 flex items-center gap-1.5 rounded-md border-2 border-primary bg-card/90 px-2.5 py-2 font-mono text-[10px] font-semibold uppercase tracking-wide text-primary shadow-sm transition-colors hover:bg-accent">
 
4675
  {flying ? <X className="size-4" /> : <FastForward className="size-4" />}
4676
- {flying ? "Stop" : "Play journey"}
4677
  </button>
4678
  ) : null}
4679
 
@@ -4907,6 +5301,16 @@
4907
  ];
4908
  const [expanded, setExpanded] = useState(false); // in-page immersive view
4909
  const [dropArmed, setDropArmed] = useState(false); // drag-from-tray hint
 
 
 
 
 
 
 
 
 
 
4910
  const [armedId, setArmedId] = useState(null); // tap-to-pin armed photo
4911
 
4912
  // tap-to-pin: arm a photo, then the next globe click pins it
@@ -4962,6 +5366,26 @@
4962
  });
4963
  viewerRef.current = viewer;
4964
  try { viewer.clock.shouldAnimate = false; } catch (e) {}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4965
  viewer.scene.camera.setView({
4966
  destination: Cesium.Cartesian3.fromDegrees(-123.1207, 49.2827, 1200),
4967
  orientation: { heading: Cesium.Math.toRadians(10), pitch: Cesium.Math.toRadians(-25) },
@@ -5004,6 +5428,24 @@
5004
  if (cancelled) { viewer.destroy(); return; }
5005
  viewer.scene.primitives.add(tileset);
5006
  tilesetRef.current = tileset;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5007
  tileset.style = buildCesiumStyle(Cesium, "watercolor", colorway);
5008
  } catch (e) { console.warn("[cesium] OSM buildings failed", e); }
5009
  if (!cancelled) setStatus("ready");
@@ -5089,7 +5531,7 @@
5089
  });
5090
  entitiesRef.current.push(ent);
5091
  });
5092
- }, [pinnedStore.map((p) => p.id + ":" + p.lon + "," + p.lat + ":" + p.caption).join("|"), status]);
5093
 
5094
  // tap-to-pin + drag-to-move on the globe.
5095
  const armedRef = useRef(null);
@@ -5158,8 +5600,10 @@
5158
  if (viewer && vehicleRef.current) { try { viewer.entities.remove(vehicleRef.current); } catch (e) {} }
5159
  vehicleRef.current = null;
5160
  }
5161
- function stopFly() {
 
5162
  flyAbortRef.current.stop = true;
 
5163
  setFlying(false); setFlyIdx(-1);
5164
  const viewer = viewerRef.current;
5165
  if (viewer) {
@@ -5169,10 +5613,24 @@
5169
  try { if (viewer.entities.getById("sozai-route")) viewer.entities.removeById("sozai-route"); } catch (e) {}
5170
  }
5171
  removeVehicle();
 
 
 
 
5172
  }
5173
- async function playFly() {
 
 
5174
  const viewer = viewerRef.current;
5175
  if (!viewer || !window.Cesium || flyStops.length < 1) return;
 
 
 
 
 
 
 
 
5176
  const Cesium = window.Cesium;
5177
  flyAbortRef.current = { stop: false };
5178
  const token = flyAbortRef.current;
@@ -5275,7 +5733,9 @@
5275
  }
5276
  } catch (e) {}
5277
  if (nextStop < stopTimes.length && t >= stopTimes[nextStop] - 0.2) {
5278
- setFlyIdx(nextStop); nextStop++;
 
 
5279
  }
5280
  });
5281
  clockTickRef.current = () => { try { onTick(); } catch (e) {} };
@@ -5293,17 +5753,49 @@
5293
  if (clockTickRef.current) { clockTickRef.current(); clockTickRef.current = null; }
5294
  viewer.clock.shouldAnimate = false;
5295
  if (!token.stop && flyStops.length) {
 
 
 
 
 
 
5296
  const lons = flyStops.map((s) => s.lon), lats = flyStops.map((s) => s.lat);
5297
  const cx = (Math.min(...lons) + Math.max(...lons)) / 2, cy = (Math.min(...lats) + Math.max(...lats)) / 2;
5298
- viewer.scene.camera.flyTo({ destination: Cesium.Cartesian3.fromDegrees(cx, cy - 0.02, 2600),
5299
  orientation: { heading: 0, pitch: Cesium.Math.toRadians(-45) }, duration: 2.4 });
5300
  await new Promise((r) => setTimeout(r, 2400));
 
5301
  }
5302
  removeVehicle();
5303
  try { if (viewer.entities.getById("sozai-route")) viewer.entities.removeById("sozai-route"); } catch (e) {}
5304
  setFlying(false); setFlyIdx(-1);
 
 
 
 
 
5305
  }
5306
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5307
  if (status === "no-token") {
5308
  return (
5309
  <div className="rounded-lg border-2 border-primary/40 bg-accent/30 p-6 text-center">
@@ -5377,13 +5869,16 @@
5377
  </span>
5378
  <div className="flex flex-wrap items-center gap-1.5">
5379
  {CS_TRANSPORTS.map((t) => (
5380
- <button key={t.id} type="button" onClick={() => setTransport(t.id)} aria-pressed={transport === t.id} title={t.label} disabled={flying}
5381
  className={cn("flex items-center gap-1 rounded-full border-2 px-2 py-1 font-mono text-[11px] uppercase tracking-wide transition-colors disabled:opacity-50",
5382
  transport === t.id ? "border-primary bg-accent text-foreground" : "border-primary/40 text-muted-foreground hover:bg-accent/50")}>
5383
  <span aria-hidden="true">{t.emoji}</span>{t.label}
5384
  </button>
5385
  ))}
5386
  </div>
 
 
 
5387
  </div>
5388
  ) : null}
5389
 
@@ -5428,8 +5923,10 @@
5428
  ) : null}
5429
  {flyStops.length > 0 && status === "ready" ? (
5430
  <button type="button" onClick={() => (flying ? stopFly() : playFly())}
5431
- className="absolute right-2 top-2 z-10 flex items-center gap-1.5 rounded-md border-2 border-primary bg-card/90 px-2.5 py-2 font-mono text-[10px] font-semibold uppercase tracking-wide text-primary shadow-sm transition-colors hover:bg-accent">
5432
- {flying ? <X className="size-4" /> : <FastForward className="size-4" />} {flying ? "Stop" : "Play journey"}
 
 
5433
  </button>
5434
  ) : null}
5435
  {flying && flyStops[flyIdx] ? (
@@ -6195,7 +6692,7 @@
6195
  <div className="relative w-full overflow-hidden rounded-lg border-2 border-primary/40 bg-[oklch(0.95_0.018_85)]">
6196
  <img src="/assets/photos/scrapbook-map.png" alt="" aria-hidden="true" className="pointer-events-none absolute inset-0 h-full w-full object-cover opacity-60 mix-blend-multiply" />
6197
  <RouteLines moments={liveMoments} />
6198
- <div className="relative h-[460px] w-full sm:h-[520px]">
6199
  {liveMoments.map((m, i) => <MomentCard key={m.id} moment={m} rotate={rotations[i % rotations.length]} selected={selectedId === m.id} onSelect={setSelectedId} />)}
6200
  </div>
6201
  </div>
@@ -6521,10 +7018,10 @@
6521
 
6522
  // baths kept from the zip (colors + labels), seen from above
6523
  const DR_BATHS = [
6524
- { id: "developer", label: "Developer", color: "oklch(0.38 0.11 52)", edge: "oklch(0.24 0.08 50)" },
6525
- { id: "stop", label: "Stop Bath", color: "oklch(0.72 0.16 82)", edge: "oklch(0.54 0.13 80)" },
6526
- { id: "fixer", label: "Fixer", color: "oklch(0.44 0.05 225)", edge: "oklch(0.30 0.04 225)" },
6527
- { id: "wash", label: "Wash", color: "oklch(0.54 0.06 212)", edge: "oklch(0.40 0.04 212)" },
6528
  ];
6529
  // top-down layout - columns are now driven by flex, not % coordinates
6530
 
@@ -6777,7 +7274,8 @@
6777
  // ---- polaroid print card (shared by both bath phases) --------------- //
6778
  function PrintCard({ sheet }) {
6779
  const bf = drBathFilter(sheet.level);
6780
- const filt = sheet.submerged ? ("url(#dr-ripple) " + bf) : bf;
 
6781
  return (
6782
  <div className="w-full" style={{
6783
  transform: "rotate(" + sheet.rot + "deg) scale(" + sheet.scale + ")",
@@ -6885,7 +7383,7 @@
6885
  style={{ background: "radial-gradient(120% 100% at 50% 0%, oklch(0.20 0.04 55), oklch(0.10 0.02 55) 70%)",
6886
  boxShadow: "inset 0 0 70px rgba(0,0,0,0.55)" }}>
6887
 
6888
- <div className="flex items-end gap-3 px-4 pb-4 pt-10">
6889
  {DR_BATHS.map((b, i) => {
6890
  const aboveCards = sheets.filter((s) => s.col === i && s.above);
6891
  const subCards = sheets.filter((s) => s.col === i && !s.above);
@@ -6906,11 +7404,11 @@
6906
  style={{ aspectRatio: "1 / 1.5", background: "oklch(0.12 0.02 55)", boxShadow: "0 6px 16px rgba(0,0,0,0.5)" }}>
6907
  <div className="relative h-full w-full overflow-hidden rounded-[6px]"
6908
  style={{ background: "radial-gradient(120% 120% at 50% 0%, " + b.color + ", " + b.edge + ")" }}>
6909
- <WaterRippleCanvas baseColor={[0.5, 0.48, 0.44]} scale={5} illumination={0.55}
6910
- rippleStyle={{ mixBlendMode: "overlay", opacity: 0.16, zIndex: 1 }} />
6911
- <div className="absolute inset-0" style={{ filter: "url(#dr-ripple)",
6912
- background: "linear-gradient(180deg, rgba(255,255,255,0.20), rgba(255,255,255,0) 45%)", zIndex: 2 }} />
6913
- <div className="absolute inset-0" style={{ boxShadow: "inset 0 0 24px rgba(0,0,0,0.5)", zIndex: 3 }} />
6914
  {/* submerged card lives inside its tray */}
6915
  {subCards.map((s) => (
6916
  <div key={s.id} className="absolute" style={{ inset: "10%", zIndex: 4 + s.z }}>
@@ -6959,25 +7457,65 @@
6959
  }
6960
 
6961
  // ---- phase 2: hanging print, shake to reveal (ported) -------------- //
6962
- const DR_SHAKES_TO_REVEAL = 4;
6963
- function HangingPrint({ photo, index, swayDelay, onRevealed, widthClass }) {
6964
  const [progress, setProgress] = useState(0);
6965
  const [revealed, setRevealed] = useState(false);
6966
  const [dragging, setDragging] = useState(false);
6967
  const [tilt, setTilt] = useState(0);
6968
- const dragState = useRef({ active: false, lastX: 0, lastDir: 0, offset: 0 });
6969
  const progressRef = useRef(0);
 
 
6970
 
6971
  useEffect(() => { if (revealed) onRevealed(photo.id); }, [revealed, onRevealed, photo.id]);
6972
 
 
 
 
 
 
 
 
 
6973
  // Kick off the watercolour "develop" (flux.2-klein img2img) the moment the
6974
  // print is hung, so the result is ready by the time it's shaken in. The
6975
  // shake then reveals the DEVELOPED image; if the endpoint is unavailable
6976
  // or fails, we fall back to the original photo (no regression).
 
 
 
 
 
 
 
6977
  const [developedSrc, setDevelopedSrc] = useState(null);
6978
  const [devStatus, setDevStatus] = useState("queued"); // queued|developing|done|base
6979
  const developRef = useRef(false);
 
 
 
 
 
 
 
6980
  useEffect(() => {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6981
  if (developRef.current || !photo.src) return;
6982
  developRef.current = true;
6983
  let alive = true;
@@ -7011,9 +7549,13 @@
7011
  if (j && j.available && j.image) {
7012
  if (alive) {
7013
  setDevelopedSrc(j.image); setDevStatus("done");
 
 
 
7014
  // write the DEVELOPED watercolour + its pin back into the shared
7015
  // store so the map shows the transformed photo, auto-pinned at
7016
- // its GPS location (falls through to the room sync too).
 
7017
  try {
7018
  window.SozaiPhotos.upsert(photo.id, {
7019
  src: j.image,
@@ -7028,47 +7570,72 @@
7028
  return;
7029
  }
7030
  if (j && j.available === false) { // feature genuinely off
7031
- if (alive) setDevStatus("base"); return;
 
7032
  }
7033
  }
7034
  } catch (e) { console.warn("[develop] attempt", attempt, "failed", e); }
7035
  await sleep(5000);
7036
  }
7037
- if (alive) setDevStatus("base"); // gave up → keep the original
7038
  })();
7039
  return () => { alive = false; };
7040
- }, [photo.src, photo.alt, index]);
7041
-
7042
- const registerShake = useCallback(() => {
7043
- const next = Math.min(1, progressRef.current + 1 / DR_SHAKES_TO_REVEAL);
 
 
 
 
 
7044
  progressRef.current = next; setProgress(next);
7045
- if (next >= 1) setRevealed(true);
7046
- }, []);
 
 
 
 
7047
 
7048
  // No shaking until the watercolour has actually developed (or the develop
7049
  // feature is off / gave up, where "base" means we reveal the original).
7050
  const canShake = devStatus === "done" || devStatus === "base";
 
 
 
 
 
 
 
 
 
7051
  function onPointerDown(e) {
7052
  if (revealed || !canShake) return;
7053
  try { e.currentTarget.setPointerCapture(e.pointerId); } catch (err) {}
7054
- dragState.current = { active: true, lastX: e.clientX, lastDir: 0, offset: 0 };
7055
  setDragging(true);
7056
  }
7057
  function onPointerMove(e) {
7058
  const s = dragState.current;
7059
  if (!s.active || revealed || !canShake) return;
7060
  const dx = e.clientX - s.lastX;
7061
- if (Math.abs(dx) < 4) return;
7062
- const dir = dx > 0 ? 1 : -1;
7063
- if (s.lastDir !== 0 && dir !== s.lastDir) registerShake();
7064
- s.lastDir = dir; s.lastX = e.clientX;
 
7065
  s.offset = Math.max(-26, Math.min(26, s.offset + dx));
7066
- setTilt(s.offset);
 
 
 
7067
  }
7068
  function endDrag(e) {
7069
  dragState.current.active = false; setDragging(false); setTilt(0);
 
7070
  try { e.currentTarget.releasePointerCapture(e.pointerId); } catch (err) {}
7071
  }
 
7072
 
7073
  const blurPx = (1 - progress) * 14;
7074
  const grayscale = (1 - progress) * 100;
@@ -7082,6 +7649,9 @@
7082
  className={cn("group relative -mt-1.5 select-none rounded-sm bg-[oklch(0.99_0.01_85)] p-3 pb-16 shadow-2xl",
7083
  dragging ? "" : "dr-sway", revealed ? "cursor-default" : (canShake ? "cursor-grab active:cursor-grabbing" : "cursor-progress"))}
7084
  style={{ animationDelay: swayDelay + "s",
 
 
 
7085
  transform: dragging ? "rotate(" + (tilt * 0.3) + "deg) translateX(" + (tilt * 0.4) + "px)" : undefined,
7086
  transition: dragging ? "none" : "transform 0.4s ease" }}
7087
  onPointerDown={onPointerDown} onPointerMove={onPointerMove} onPointerUp={endDrag} onPointerCancel={endDrag}>
@@ -7123,11 +7693,65 @@
7123
  const [muted, setMuted] = useState(false);
7124
  const sfx = useWaterSfx(muted);
7125
  const allRevealed = revealed.size === list.length;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7126
 
7127
  const handleRevealed = useCallback((id) => {
7128
  setRevealed((prev) => { const next = new Set(prev); next.add(id); return next; });
7129
  }, []);
7130
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7131
  return (
7132
  <div className="fixed inset-0 z-50 flex flex-col overflow-hidden bg-[oklch(0.12_0.022_55)]">
7133
  <style dangerouslySetInnerHTML={{ __html:
@@ -7174,11 +7798,12 @@
7174
  </button>
7175
  </div>
7176
 
7177
- {/* centred content */}
7178
- <div className="relative flex flex-1 flex-col items-center justify-center gap-6 px-5 pb-10 sm:px-8">
 
7179
  <div className="text-center">
7180
- <h1 className="font-display text-5xl leading-none text-[oklch(0.92_0.04_78)] sm:text-6xl">The Darkroom</h1>
7181
- <p className="mt-3 font-serif italic text-xl text-[oklch(0.70_0.07_65)]">
7182
  {phase === "baths"
7183
  ? "developing your photos"
7184
  : allRevealed ? "your photos are ready"
@@ -7187,7 +7812,7 @@
7187
  </div>
7188
 
7189
  {phase === "baths" ? (
7190
- <TopDownBaths photos={list} sfx={sfx} onComplete={() => setPhase("hang")} />
7191
  ) : (
7192
  <div className="flex w-full flex-col items-center">
7193
  <div className="relative w-full">
@@ -7200,7 +7825,7 @@
7200
  : list.length <= 4 ? "w-60 sm:w-72"
7201
  : "w-52 sm:w-60";
7202
  return (
7203
- <HangingPrint key={photo.id} photo={photo} index={i} swayDelay={i * 0.4} onRevealed={handleRevealed} widthClass={heroW} />
7204
  );
7205
  })}
7206
  </ul>
@@ -7228,7 +7853,22 @@
7228
  function Page({ onOpenSettings, onOpenPets, onOpenTrace, screen, setScreen, mapVariant, setMapVariant }) {
7229
  // Each photo carries its own title/caption/date/time - a single source of
7230
  // truth so the fields can never desync from the photo list.
7231
- const [stackPhotos, setStackPhotos] = useState(() => []); // default: empty
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7232
  const developPhotos = stackPhotos.map((p) => ({ id: p.id, src: p.src, alt: p.alt, defaultCaption: p.defaultCaption,
7233
  lat: p.lat, lon: p.lon, date: p.date, time: p.time, title: p.title, caption: p.caption }));
7234
  if (screen === "add")
@@ -7242,6 +7882,51 @@
7242
  return <MapOfMyTime onBack={() => setScreen("add")} onOpenSettings={onOpenSettings} onOpenPets={onOpenPets} variant={mapVariant} onChangeVariant={setMapVariant} />;
7243
  }
7244
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7245
  function Root() {
7246
  const [chatOpen, setChatOpen] = useState(false);
7247
  const [unread, setUnread] = useState(0);
@@ -7252,14 +7937,23 @@
7252
  });
7253
  const [settings, setSettings] = useState(loadSettings);
7254
 
 
 
 
 
 
 
 
 
7255
  useEffect(() => { Collab.connect(); }, []);
7256
  useEffect(() => {
7257
  // load saved scrapbook for this room (if any) and start auto-saving
7258
  RoomPersistence.init();
 
7259
  const onHide = () => { try { RoomPersistence.flush(); } catch (e) {} };
7260
  window.addEventListener("beforeunload", onHide);
7261
  document.addEventListener("visibilitychange", () => { if (document.visibilityState === "hidden") onHide(); });
7262
- return () => { window.removeEventListener("beforeunload", onHide); RoomPersistence.stop(); };
7263
  }, []);
7264
  useEffect(() => { applySettings(settings); }, []); // eslint-disable-line
7265
  useEffect(() => {
@@ -7284,16 +7978,20 @@
7284
  }),
7285
  Collab.on("join_pending", () => {
7286
  window.__sozaiJoinPending = true;
7287
- ToastStore.push({ kind: "info", sticky: true, id: "join-wait",
7288
- text: "Waiting for the host to let you in…" });
7289
  }),
7290
  Collab.on("join_declined", () => {
7291
  ToastStore.dismiss("join-wait");
7292
- ToastStore.push({ kind: "info", ttl: 8000,
7293
- text: "The host didn't admit you to this room." });
 
 
 
 
 
7294
  }),
7295
- // once we're fully in (init arrives), clear any waiting toast
7296
- Collab.on("me", () => { ToastStore.dismiss("join-wait"); window.__sozaiJoinPending = false; }),
7297
  ];
7298
  return () => offs.forEach((off) => off && off());
7299
  }, []);
@@ -7315,26 +8013,61 @@
7315
  const [followPeer, setFollowPeer] = useState(true);
7316
  const followRef = useRef(followPeer);
7317
  useEffect(() => { followRef.current = followPeer; window.__sozaiFollow = followPeer; }, [followPeer]);
 
 
 
 
 
 
7318
  useEffect(() => {
7319
  const offs = [
7320
- Collab.on("screen_changed", (m) => { if (followRef.current && m && m.screen) setScreen(m.screen); }),
 
 
 
 
7321
  Collab.on("variant_changed", (m) => { if (followRef.current && m && m.variant) setMapVariant(m.variant); }),
7322
  ];
7323
  return () => offs.forEach((off) => off && off());
7324
  }, []);
7325
- // broadcast our own screen/variant changes for peers who are following
7326
- useEffect(() => { Collab.send("screen_changed", { screen }); }, [screen]);
 
 
 
 
 
7327
  useEffect(() => { Collab.send("variant_changed", { variant: mapVariant }); }, [mapVariant]);
7328
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7329
  const update = (patch) => setSettings((prev) => ({ ...prev, ...patch }));
7330
  const openPets = () => setScreen("pets");
7331
  const openTrace = () => setScreen("trace");
7332
 
 
 
 
 
 
 
7333
  return (
7334
  <SettingsContext.Provider value={{ settings, update }}>
7335
  <div className="flex min-h-screen flex-col">
7336
  <CollabBar onToggleChat={() => { setChatOpen((v) => !v); setUnread(0); }} unread={unread} onOpenPets={openPets} onOpenTrace={openTrace} />
7337
- <main className="flex flex-1 items-center justify-center bg-background p-4 sm:p-8">
7338
  <Page onOpenSettings={() => setSettingsOpen(true)} onOpenPets={openPets} onOpenTrace={openTrace}
7339
  screen={screen} setScreen={setScreen} mapVariant={mapVariant} setMapVariant={setMapVariant} />
7340
  </main>
 
3
  <head>
4
  <meta charset="UTF-8" />
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>Sozai · Upload Photos</title>
7
  <link rel="icon" type="image/svg+xml" href="/assets/icon.svg" />
8
 
9
  <link rel="preconnect" href="https://fonts.googleapis.com" />
 
109
  * { @apply border-border outline-ring/50; }
110
  body { @apply bg-background text-foreground; }
111
  html { @apply font-sans; }
112
+ /* mobile: never let a stray wide element cause a horizontal scrollbar,
113
+ and use momentum scrolling + a sane tap target / text size. */
114
+ html, body {
115
+ max-width: 100%;
116
+ overflow-x: hidden;
117
+ -webkit-text-size-adjust: 100%;
118
+ scroll-behavior: smooth;
119
+ }
120
+ body { -webkit-overflow-scrolling: touch; overscroll-behavior-y: none; }
121
+ /* media + canvases should never exceed their column on small screens */
122
+ img, video, canvas, svg { max-width: 100%; }
123
+ }
124
+ /* any element that scrolls internally gets momentum + smoothness on iOS */
125
+ .overflow-x-auto, .overflow-y-auto, .overflow-auto, [class*="overflow-"] {
126
+ -webkit-overflow-scrolling: touch;
127
  }
128
  </style>
129
 
 
1210
  };
1211
  })();
1212
 
1213
+ // ----------------------------------------------------------------- //
1214
+ // LIVE shared image library. //
1215
+ // RoomPersistence above is the DURABLE layer (server JSON, debounced, //
1216
+ // seeds late joiners). PhotoSync is the LIVE layer: it relays the whole //
1217
+ // photo list over SSE on every local change so BOTH people see the same //
1218
+ // images the instant one of them uploads, deletes, or edits — captions //
1219
+ // and titles included, on every keystroke. //
1220
+ // //
1221
+ // Echo guard: when we APPLY a list received from the peer, we set //
1222
+ // __sozaiApplyingRemote so the resulting store change doesn't bounce a //
1223
+ // photos_state straight back (which would loop forever). //
1224
+ // ----------------------------------------------------------------- //
1225
+ window.__sozaiApplyingRemote = false;
1226
+ const PhotoSync = (function () {
1227
+ let unsub = null;
1228
+ let offRecv = null;
1229
+ let lastSent = ""; // de-dupe identical payloads (e.g. no-op re-renders)
1230
+
1231
+ function broadcast(items) {
1232
+ if (window.__sozaiApplyingRemote) return; // don't echo remote applies
1233
+ if (window.__sozaiSuppressSave) return; // bulk load in progress
1234
+ if (!Collab.isCollab()) return;
1235
+ let payload;
1236
+ try { payload = JSON.stringify(items); } catch (e) { return; }
1237
+ if (payload === lastSent) return; // nothing actually changed
1238
+ lastSent = payload;
1239
+ // send the full list every time. Photos are data-URLs, so this is the
1240
+ // simplest correct model for a 2-person room: last-write-wins, always
1241
+ // converging on an identical array. Sent on EVERY change (keystroke).
1242
+ Collab.send("photos_state", { photos: items });
1243
+ }
1244
+
1245
+ function apply(items) {
1246
+ if (!Array.isArray(items)) return;
1247
+ // remember what we're applying so our own broadcast() suppresses it,
1248
+ // and so we don't re-send it as if it were a local edit.
1249
+ lastSent = (function () { try { return JSON.stringify(items); } catch (e) { return ""; } })();
1250
+ window.__sozaiApplyingRemote = true;
1251
+ try { window.SozaiPhotos.replaceAll(items); }
1252
+ finally { window.__sozaiApplyingRemote = false; }
1253
+ }
1254
+
1255
+ return {
1256
+ init() {
1257
+ if (!Collab.isCollab()) return;
1258
+ // local change → broadcast to the peer
1259
+ unsub = window.SozaiPhotos.subscribe((items) => broadcast(items));
1260
+ // peer change → apply locally
1261
+ offRecv = Collab.on("photos_state", (m) => {
1262
+ if (m && Array.isArray(m.photos)) apply(m.photos);
1263
+ });
1264
+ },
1265
+ stop() { if (unsub) unsub(); if (offRecv) offRecv(); },
1266
+ };
1267
+ })();
1268
+
1269
  // Adapt a SozaiPhotos item to the shape that the map-view components expect.
1270
  // x/y are deterministic synthetic percentages used for scrapbook pin positions.
1271
  function storeToMoment(p, i) {
 
1317
  useEffect(() => Collab.on("me", setM), []);
1318
  return m;
1319
  }
1320
+
1321
+ // ----------------------------------------------------------------- //
1322
+ // AI mutual-exclusion lock (client side of the server's ai_state). //
1323
+ // Only one collaborator may run AI (auto-caption / title / tags / voice //
1324
+ // transcription) at a time. When one starts, the server grants them the //
1325
+ // lock and broadcasts ai_state; the other person's AI controls disable //
1326
+ // and show "<name> is generating…". Solo mode: always free, never locks. //
1327
+ // ----------------------------------------------------------------- //
1328
+ window.SozaiAI = (function () {
1329
+ let holder = null; // { session_id, name, action } or null
1330
+ let listeners = [];
1331
+ let pendingResolve = null; // resolver for an in-flight acquire()
1332
+ function emit() { listeners.forEach((fn) => { try { fn(holder); } catch (e) {} }); }
1333
+ if (typeof window !== "undefined") {
1334
+ Collab.on("ai_state", (m) => {
1335
+ holder = (m && m.holder) ? m.holder : null;
1336
+ // if this message answers OUR acquire request, resolve the promise
1337
+ if (pendingResolve) {
1338
+ const granted = !!(m && m.granted);
1339
+ const r = pendingResolve; pendingResolve = null;
1340
+ r(granted);
1341
+ }
1342
+ emit();
1343
+ });
1344
+ }
1345
+ return {
1346
+ get() { return holder; },
1347
+ subscribe(fn) { listeners.push(fn); return () => { listeners = listeners.filter((f) => f !== fn); }; },
1348
+ heldByOther(mySession) { return !!holder && holder.session_id !== mySession; },
1349
+ // Try to take the lock for `action`. Resolves true if granted.
1350
+ // Solo (not in a room): instantly true, nothing to coordinate.
1351
+ acquire(action) {
1352
+ if (!Collab.isCollab()) return Promise.resolve(true);
1353
+ return new Promise((resolve) => {
1354
+ if (pendingResolve) { const r = pendingResolve; pendingResolve = null; r(false); }
1355
+ pendingResolve = resolve;
1356
+ Collab.send("ai_acquire", { action: String(action || "AI").slice(0, 40) });
1357
+ // safety: if the server never answers, don't hang forever
1358
+ setTimeout(() => { if (pendingResolve === resolve) { pendingResolve = null; resolve(false); } }, 6000);
1359
+ });
1360
+ },
1361
+ release() {
1362
+ if (!Collab.isCollab()) return;
1363
+ Collab.send("ai_release", {});
1364
+ },
1365
+ };
1366
+ })();
1367
+
1368
+ // React hook: live AI-lock holder + whether THIS user's AI controls should
1369
+ // be disabled (because the peer holds the lock).
1370
+ function useAILock() {
1371
+ const me = useMe();
1372
+ const [holder, setHolder] = useState(window.SozaiAI.get());
1373
+ useEffect(() => window.SozaiAI.subscribe(setHolder), []);
1374
+ const mySession = (me && me.session_id) || Collab.sessionId();
1375
+ const lockedByOther = !!holder && holder.session_id !== mySession;
1376
+ return {
1377
+ holder,
1378
+ lockedByOther,
1379
+ lockLabel: lockedByOther ? `${holder.name || "Someone"} is ${holder.action || "using AI"}…` : null,
1380
+ acquire: (action) => window.SozaiAI.acquire(action),
1381
+ release: () => window.SozaiAI.release(),
1382
+ };
1383
+ }
1384
+
1385
+ // ----------------------------------------------------------------- //
1386
+ // Journey mutual-exclusion lock (client side of the server's //
1387
+ // journey_state). The "Play your journey" flythrough + watercolour //
1388
+ // "develop" are GPU/memory heavy, so only ONE person may drive at a //
1389
+ // time (prevents OOM). The driver streams journey_frame events so the //
1390
+ // peer watches the SAME playback; the peer's Play button stays //
1391
+ // disabled until the driver finishes or stops. Solo: always free. //
1392
+ // ----------------------------------------------------------------- //
1393
+ window.SozaiJourney = (function () {
1394
+ let driver = null; // { session_id, name } or null
1395
+ let listeners = [];
1396
+ let pendingResolve = null;
1397
+ let refreshTimer = null;
1398
+ function emit() { listeners.forEach((fn) => { try { fn(driver); } catch (e) {} }); }
1399
+ if (typeof window !== "undefined") {
1400
+ Collab.on("journey_state", (m) => {
1401
+ driver = (m && m.driver) ? m.driver : null;
1402
+ if (pendingResolve) {
1403
+ const granted = !!(m && m.granted);
1404
+ const r = pendingResolve; pendingResolve = null;
1405
+ r(granted);
1406
+ }
1407
+ emit();
1408
+ });
1409
+ }
1410
+ function startRefresh() {
1411
+ stopRefresh();
1412
+ // keep the long-running lock alive while we drive the playback
1413
+ refreshTimer = setInterval(() => { if (Collab.isCollab()) Collab.send("journey_refresh", {}); }, 30000);
1414
+ }
1415
+ function stopRefresh() { if (refreshTimer) { clearInterval(refreshTimer); refreshTimer = null; } }
1416
+ return {
1417
+ get() { return driver; },
1418
+ subscribe(fn) { listeners.push(fn); return () => { listeners = listeners.filter((f) => f !== fn); }; },
1419
+ drivenByOther(mySession) { return !!driver && driver.session_id !== mySession; },
1420
+ acquire() {
1421
+ if (!Collab.isCollab()) return Promise.resolve(true);
1422
+ return new Promise((resolve) => {
1423
+ if (pendingResolve) { const r = pendingResolve; pendingResolve = null; r(false); }
1424
+ pendingResolve = resolve;
1425
+ Collab.send("journey_acquire", {});
1426
+ setTimeout(() => { if (pendingResolve === resolve) { pendingResolve = null; resolve(false); } }, 6000);
1427
+ });
1428
+ },
1429
+ beginRefresh() { startRefresh(); },
1430
+ release() {
1431
+ stopRefresh();
1432
+ if (!Collab.isCollab()) return;
1433
+ Collab.send("journey_release", {});
1434
+ },
1435
+ // relay one playback control frame to the peer (driver only)
1436
+ frame(payload) { if (Collab.isCollab()) Collab.send("journey_frame", payload || {}); },
1437
+ };
1438
+ })();
1439
+
1440
+ // React hook: live journey driver + whether THIS user's Play button should
1441
+ // be disabled (because the peer is already driving a journey).
1442
+ function useJourneyLock() {
1443
+ const me = useMe();
1444
+ const [driver, setDriver] = useState(window.SozaiJourney.get());
1445
+ useEffect(() => window.SozaiJourney.subscribe(setDriver), []);
1446
+ const mySession = (me && me.session_id) || Collab.sessionId();
1447
+ const drivenByMe = !!driver && driver.session_id === mySession;
1448
+ const drivenByOther = !!driver && driver.session_id !== mySession;
1449
+ return {
1450
+ driver,
1451
+ drivenByMe,
1452
+ drivenByOther,
1453
+ driverLabel: drivenByOther ? `${driver.name || "Someone"} is playing the journey…` : null,
1454
+ acquire: () => window.SozaiJourney.acquire(),
1455
+ release: () => window.SozaiJourney.release(),
1456
+ beginRefresh: () => window.SozaiJourney.beginRefresh(),
1457
+ frame: (p) => window.SozaiJourney.frame(p),
1458
+ };
1459
+ }
1460
+
1461
+ // React hook: the peer's most recent ambient activity (#4) — "where are
1462
+ // they / what are they doing". Combines navigation beacons (activity) with
1463
+ // the AI-lock state so AI work always shows even mid-action. Returns the
1464
+ // freshest peer activity { name, color, label } or null.
1465
+ function usePeerActivity() {
1466
+ const me = useMe();
1467
+ const [act, setAct] = useState(null);
1468
+ const mySession = (me && me.session_id) || Collab.sessionId();
1469
+ useEffect(() => {
1470
+ const offAct = Collab.on("activity", (m) => {
1471
+ if (!m || !m.from || m.from.session_id === mySession) return;
1472
+ setAct({ name: m.from.name, color: m.from.color, label: m.label || "", t: Date.now() });
1473
+ });
1474
+ // a peer leaving clears their activity
1475
+ const offLeft = Collab.on("user_left", () => setAct(null));
1476
+ return () => { offAct && offAct(); offLeft && offLeft(); };
1477
+ }, [mySession]);
1478
+ // AI activity takes precedence and is derived from the authoritative lock
1479
+ const ai = useAILock();
1480
+ const journey = useJourneyLock();
1481
+ if (ai.holder && ai.holder.session_id !== mySession) {
1482
+ return { name: ai.holder.name, color: (act && act.color) || "#7c5cff", label: ai.holder.action || "using AI", ai: true };
1483
+ }
1484
+ // a peer driving the journey is shown the same way (so you always SEE it)
1485
+ if (journey.drivenByOther) {
1486
+ return { name: journey.driver.name, color: (act && act.color) || "#7c5cff", label: "playing the journey", ai: true };
1487
+ }
1488
+ return act;
1489
+ }
1490
+
1491
  function initials(name) {
1492
  const parts = (name || "Guest").trim().split(/\s+/);
1493
  return ((parts[0]?.[0] || "G") + (parts[1]?.[0] || "")).toUpperCase();
 
1519
  );
1520
  }
1521
 
1522
+ // ----------------------------------------------------------------- //
1523
+ // Peer activity pill (#4): a compact, live "Alex is exploring the map" //
1524
+ // indicator. Shows the collaborator's current screen, or their AI action //
1525
+ // when they're running AI. Hidden in solo mode or when nothing is known. //
1526
+ // ----------------------------------------------------------------- //
1527
+ function PeerActivityPill() {
1528
+ const act = usePeerActivity();
1529
+ if (!Collab.isCollab() || !act || !act.label) return null;
1530
+ const color = act.color || "#7c5cff";
1531
+ return (
1532
+ <span className="hidden items-center gap-1.5 rounded-full border-2 px-2.5 py-1 font-mono text-[10px] uppercase tracking-wide sm:inline-flex"
1533
+ style={{ borderColor: color, color }}
1534
+ title={`${act.name || "Collaborator"} is ${act.label}`}>
1535
+ <span className="relative flex size-2">
1536
+ <span className="absolute inline-flex size-full animate-ping rounded-full opacity-60" style={{ background: color }} />
1537
+ <span className="relative inline-flex size-2 rounded-full" style={{ background: color }} />
1538
+ </span>
1539
+ <span className="max-w-[14rem] truncate">{act.name || "Collaborator"} · {act.label}</span>
1540
+ </span>
1541
+ );
1542
+ }
1543
+
1544
  // ----------------------------------------------------------------- //
1545
  // Collaboration bar (share code + link, presence, name, chat toggle)
1546
  // ----------------------------------------------------------------- //
 
1619
  </div>
1620
 
1621
  <div className="flex items-center gap-3">
1622
+ {/* what the other person is doing right now (#4) */}
1623
+ <PeerActivityPill />
1624
  {/* presence */}
1625
  <div className="flex items-center gap-1.5">
1626
  <Users className="size-4 text-primary" />
 
1918
  }
1919
  return (
1920
  <div className="flex flex-col items-center gap-4">
1921
+ {/* the backing cards lean down-right (translate-x), which visually
1922
+ pulls the stack off-centre; nudge the whole stack left by half that
1923
+ offset so the polaroid sits centred, especially on mobile. */}
1924
+ <div className="relative w-full max-w-xs" style={{ marginRight: "0.375rem", marginLeft: "-0.375rem" }}>
1925
  <div className="absolute inset-0 translate-x-3 translate-y-3 rotate-2 rounded-md border-2 border-primary/30 bg-card" />
1926
  <div className="absolute inset-0 translate-x-1.5 translate-y-1.5 rotate-1 rounded-md border-2 border-primary/40 bg-card" />
1927
  {/* keyed by index so the slide animation re-triggers on each browse;
 
2073
  <button type="submit" disabled={busy} className="shrink-0 rounded-md border-2 border-primary bg-primary px-2.5 py-1.5 font-mono text-[11px] uppercase text-primary-foreground disabled:opacity-50">{busy ? "…" : "Go"}</button>
2074
  </form>
2075
  <div ref={elRef} className="h-40 w-full overflow-hidden rounded-md border-2 border-primary/40" />
2076
+ <p className="mt-1.5 font-mono text-[10px] leading-snug text-muted-foreground">Drag the pin or tap the map to set your photo's general area.</p>
2077
  <div className="mt-2 flex items-center gap-2">
2078
  <span className="font-mono text-[10px] uppercase tracking-wide text-primary">Radius</span>
2079
  <input type="range" min="100" max="5000" step="100" value={rad} onChange={(e) => onRad(parseInt(e.target.value, 10))} className="min-w-0 flex-1 accent-primary" />
 
2106
  // ----------------------------------------------------------------- //
2107
  // components/details-form.tsx
2108
  // ----------------------------------------------------------------- //
2109
+ function DetailsForm({ title, onTitleChange, caption, onCaptionChange, date, onDateChange, time, onTimeChange, lat, lon, locationName, radius, onLocationChange, tags, onTagsChange, onSubmit, onAutoCaption, autoBusy, autoStage, autoError, captionPrompt, onCaptionPromptChange, captionModel, onAutoTitle, titleBusy, titleStage, titleError, titlePrompt, onTitlePromptChange, onAutoTags, tagsBusy, tagsStage, tagsError, tagsPrompt, onTagsPromptChange, onCancelGen, aiLockedByOther, aiLockLabel }) {
2110
  const CancelGen = () => (
2111
  <button type="button" onClick={onCancelGen}
2112
  className="relative shrink-0 rounded-full border-2 border-primary/40 bg-card/85 px-2.5 py-0.5 font-mono text-[10px] uppercase tracking-wide text-primary transition-colors hover:bg-accent hover:text-foreground">
 
2121
  const DEFAULT_TAGS_HINT = "List 3 to 6 short, comma-separated tags for this photo.";
2122
  return (
2123
  <div className="flex flex-col gap-6">
2124
+ {/* peer is running AI → explain why the AI buttons are disabled */}
2125
+ {aiLockedByOther ? (
2126
+ <div role="status" className="flex items-center gap-2 rounded-md border-2 border-primary/40 bg-accent px-3 py-2 text-sm text-accent-foreground">
2127
+ <Wand2 className="size-4 shrink-0 animate-pulse text-primary" />
2128
+ <span>{aiLockLabel || "Your collaborator is using AI…"} You can use AI again when they're done.</span>
2129
+ </div>
2130
+ ) : null}
2131
  {/* title */}
2132
  <div className="space-y-2">
2133
  <div className="flex items-center justify-between gap-2">
 
2141
  showTitlePrompt ? "border-primary bg-accent text-foreground" : "border-primary/60 text-primary hover:bg-accent")}>
2142
  <Settings className="size-3.5" /> Prompt
2143
  </button>
2144
+ <button type="button" onClick={onAutoTitle} disabled={titleBusy || aiLockedByOther}
2145
+ title={aiLockedByOther ? aiLockLabel : undefined}
2146
+ className="flex items-center gap-1.5 rounded-md border-2 border-primary/60 px-2.5 py-1 font-mono text-[11px] uppercase tracking-wide text-primary transition-colors hover:bg-accent disabled:opacity-60 disabled:cursor-not-allowed">
2147
+ <Wand2 className={cn("size-3.5", titleBusy && "animate-pulse")} /> {titleBusy ? "Naming…" : (aiLockedByOther ? "AI busy" : "Auto-generate")}
2148
  </button>
2149
  </div>
2150
  </div>
 
2214
  showPrompt ? "border-primary bg-accent text-foreground" : "border-primary/60 text-primary hover:bg-accent")}>
2215
  <Settings className="size-3.5" /> Prompt
2216
  </button>
2217
+ <button type="button" onClick={onAutoCaption} disabled={autoBusy || aiLockedByOther}
2218
+ title={aiLockedByOther ? aiLockLabel : undefined}
2219
+ className="flex items-center gap-1.5 rounded-md border-2 border-primary/60 px-2.5 py-1 font-mono text-[11px] uppercase tracking-wide text-primary transition-colors hover:bg-accent disabled:opacity-60 disabled:cursor-not-allowed">
2220
+ <Wand2 className={cn("size-3.5", autoBusy && "animate-pulse")} /> {autoBusy ? "Developing…" : (aiLockedByOther ? "AI busy" : "Auto-generate")}
2221
  </button>
2222
  </div>
2223
  </div>
 
2373
  const [tagsBusy, setTagsBusy] = useState(false);
2374
  const [tagsError, setTagsError] = useState(null);
2375
  const [tagsStage, setTagsStage] = useState("");
2376
+ const aiLock = useAILock(); // server-coordinated AI mutual exclusion
2377
 
2378
+ // Remote field edits from collaborators. The full photo list also syncs
2379
+ // via photos_state, but object_updated gives a snappier single-field
2380
+ // update; we apply it to the shared store (by resolving index→id) so it
2381
+ // converges with photos_state instead of fighting a private copy.
2382
  useEffect(() => Collab.on("object_updated", (m) => {
2383
  if (typeof m.index !== "number" || !m.kind) return;
2384
+ if (window.__sozaiApplyingRemote) return; // avoid echo while applying a full list
2385
+ const target = window.SozaiPhotos.all()[m.index];
2386
+ if (!target) return;
2387
+ if (m.kind === "location" && m.value && typeof m.value === "object") {
2388
+ window.SozaiPhotos.upsert(target.id, m.value);
2389
+ } else {
2390
+ window.SozaiPhotos.upsert(target.id, { [m.kind]: m.value });
2391
+ }
2392
  }), []);
2393
 
2394
  // follow a collaborator to the photo they switch to (when follow is on)
 
2418
  const date = (photo && photo.date != null && photo.date !== "") ? photo.date : DEFAULT_DATE;
2419
  const time = (photo && photo.time != null && photo.time !== "") ? photo.time : DEFAULT_TIME;
2420
 
2421
+ // (stackPhotos is now a live view of the shared SozaiPhotos store, so
2422
+ // there's nothing to "seed" the store IS the source of truth.)
 
 
 
 
 
 
 
 
 
2423
 
2424
+ // Edit a field on the current photo. With stackPhotos now mirroring the
2425
+ // shared store (see Page), we write ONCE to the store; the subscription
2426
+ // re-renders us and PhotoSync relays the change to the peer. We still send
2427
+ // object_updated for the lightweight per-field "X is typing" live echo.
2428
  function makeFieldSetter(kind) {
2429
  return (value) => {
 
2430
  Collab.send("object_updated", { kind, index: safeIndex, value });
2431
  const pid = photo && photo.id;
2432
  if (pid) window.SozaiPhotos.upsert(pid, { [kind]: value });
 
2442
  const locationName = (photo && photo.locationName) || "";
2443
  const radius = (photo && photo.radius != null) ? photo.radius : null;
2444
  // A location edit touches several fields at once (lat/lon/name/radius +
2445
+ // the pinned flag) - write the whole patch to the shared store.
2446
  const setLocation = (patch) => {
 
2447
  const pid = photo && photo.id;
2448
  if (pid) window.SozaiPhotos.upsert(pid, patch);
2449
  Collab.send("object_updated", { kind: "location", index: safeIndex, value: patch });
2450
  };
2451
  // reposition the focal point of the current photo (object-position %),
2452
+ // saved on the shared store so it persists.
2453
  function onReposition(fx, fy) {
 
2454
  const pid = photo && photo.id;
2455
  if (pid) window.SozaiPhotos.upsert(pid, { focusX: fx, focusY: fy });
2456
  }
 
2496
  };
2497
  const id = window.SozaiPhotos.add(newPhoto);
2498
  newPhoto.id = id;
2499
+ // (store subscription re-renders stackPhotos; no separate copy)
2500
  added++;
2501
  // resolve a readable "general area" for the EXIF GPS, in the background
2502
  if (exif.lat != null) {
2503
  reverseGeocode(exif.lat, exif.lon).then((nm) => {
2504
  if (!nm) return;
2505
  window.SozaiPhotos.upsert(id, { locationName: nm });
 
2506
  });
2507
  }
2508
  }
 
2521
  title: s.title || "", caption: s.caption || "",
2522
  date: DEFAULT_DATE, time: DEFAULT_TIME,
2523
  };
2524
+ window.SozaiPhotos.add(newPhoto);
 
 
2525
  setPhotoIndex(base);
2526
  }
2527
  // "Try a sample" — load the whole built-in photo set at once (with their
 
2538
  date: p.date, time: p.time, lat: p.lat, lon: p.lon,
2539
  pinned: p.lat != null && p.lon != null,
2540
  }));
2541
+ // store subscription re-renders stackPhotos
2542
  setPhotoIndex(0);
2543
  }
2544
 
 
2578
  if (typeof idx !== "number") idx = safeIndex;
2579
  const removedId = stackPhotos[idx] && stackPhotos[idx].id;
2580
  if (removedId) window.SozaiPhotos.remove(removedId);
2581
+ // store subscription re-renders stackPhotos (and PhotoSync relays it)
2582
  setPhotoIndex((prev) => {
2583
  const next = prev > idx ? prev - 1 : prev;
2584
  return Math.max(0, next);
 
2600
  setAutoBusy(false); setAutoStage("");
2601
  setTitleBusy(false); setTitleStage("");
2602
  setTagsBusy(false); setTagsStage("");
2603
+ aiLock.release(); // cancelling ends the AI action → free the shared lock
2604
  }
2605
 
2606
  function changePhoto(delta) {
 
2637
  async function handleAutoCaption() {
2638
  if (autoBusy) return;
2639
  if (!photo) return;
2640
+ if (aiLock.lockedByOther) return; // peer holds the AI lock
2641
+ const got = await aiLock.acquire("generating a caption");
2642
+ if (!got) return; // peer grabbed it first
2643
  if (window.sozaiPing) window.sozaiPing("✨ auto-generating caption");
2644
  setAutoBusy(true); setAutoError(null);
2645
  const token = { cancelled: false, timers: [] };
 
2660
  if (token.cancelled) return; // cancelled mid-flight: drop it
2661
  fn(); setAutoBusy(false); setAutoStage("");
2662
  if (genRef.current === token) genRef.current = null;
2663
+ aiLock.release(); // free the shared AI lock
2664
  }, wait);
2665
  token.timers.push(t);
2666
  };
 
2686
  async function handleAutoTitle() {
2687
  if (titleBusy) return;
2688
  if (!photo) return;
2689
+ if (aiLock.lockedByOther) return;
2690
+ const got = await aiLock.acquire("generating a title");
2691
+ if (!got) return;
2692
  if (window.sozaiPing) window.sozaiPing("✨ auto-generating title");
2693
  setTitleBusy(true); setTitleError(null);
2694
  const token = { cancelled: false, timers: [] };
 
2708
  if (token.cancelled) return;
2709
  fn(); setTitleBusy(false); setTitleStage("");
2710
  if (genRef.current === token) genRef.current = null;
2711
+ aiLock.release(); // free the shared AI lock
2712
  }, wait);
2713
  token.timers.push(t);
2714
  };
 
2761
  async function handleAutoTags() {
2762
  if (tagsBusy) return;
2763
  if (!photo) return;
2764
+ if (aiLock.lockedByOther) return;
2765
+ const got = await aiLock.acquire("generating tags");
2766
+ if (!got) return;
2767
  if (window.sozaiPing) window.sozaiPing("🏷️ auto-generating tags");
2768
  setTagsBusy(true); setTagsError(null);
2769
  const token = { cancelled: false, timers: [] };
 
2783
  if (token.cancelled) return;
2784
  fn(); setTagsBusy(false); setTagsStage("");
2785
  if (genRef.current === token) genRef.current = null;
2786
+ aiLock.release(); // free the shared AI lock
2787
  }, wait);
2788
  token.timers.push(t);
2789
  };
 
2838
  return;
2839
  }
2840
  setValidationError(null);
2841
+ // stackPhotos is now a live view of the shared store, so these are
2842
+ // self-writes that simply ensure defaults (title/caption/pin) are
2843
+ // materialised before we advance to the map. Harmless + idempotent.
 
2844
  stackPhotos.forEach((p) => window.SozaiPhotos.upsert(p.id, {
2845
  src: p.src, alt: p.alt || "",
2846
  title: p.title || p.defaultTitle || "",
 
2870
  return (
2871
  <section className="w-full max-w-6xl rounded-xl border-2 border-primary bg-card shadow-lg">
2872
  <header className="flex items-center justify-between gap-4 border-b-2 border-dashed border-[#f5edd8]/30 bg-[#13294b] px-5 py-4 sm:px-7">
2873
+ <div className="flex items-center gap-2.5"><h1 className="font-display text-2xl text-[#f5edd8]">Upload Photos</h1></div>
2874
  <div className="flex items-center gap-2">
2875
  <button type="button" onClick={() => onOpenSettings && onOpenSettings()} aria-label="Settings" className="flex size-9 items-center justify-center rounded-md border-2 border-[#f5edd8]/50 text-[#f5edd8] transition-colors hover:bg-white/10"><Settings className="size-4" /></button>
2876
  </div>
 
2929
  titlePrompt={settings.titlePrompt || ""} onTitlePromptChange={setTitlePrompt}
2930
  onAutoTags={handleAutoTags} tagsBusy={tagsBusy} tagsStage={tagsStage} tagsError={tagsError}
2931
  tagsPrompt={settings.tagsPrompt || ""} onTagsPromptChange={(v) => update({ tagsPrompt: v })}
2932
+ onCancelGen={cancelGeneration}
2933
+ aiLockedByOther={aiLock.lockedByOther} aiLockLabel={aiLock.lockLabel} />
2934
 
2935
  {/* choose how the Map of My Time will look once developed */}
2936
  <MapStylePicker variant={mapVariant} onChange={setMapVariant} />
2937
 
2938
  {validationError ? <p role="alert" className="rounded-md border-2 border-[oklch(0.55_0.18_25)]/50 bg-[oklch(0.55_0.18_25)]/10 px-3 py-2 text-center font-mono text-[11px] uppercase tracking-wide text-[oklch(0.5_0.18_25)]">{validationError}</p> : null}
2939
  {confirmation ? <p role="status" className="rounded-md border-2 border-primary/40 bg-accent px-3 py-2 text-center text-sm text-accent-foreground">{confirmation}</p> : null}
2940
+ {/* advance to the darkroom */}
2941
  <button type="button" onClick={handleSubmit}
2942
  className="flex w-full items-center justify-center gap-2 rounded-md border-2 border-primary bg-primary px-4 py-3 font-mono text-sm font-semibold uppercase tracking-wide text-primary-foreground transition-colors hover:bg-primary/90">
2943
+ Upload Photos <ArrowRight className="size-4" />
2944
  </button>
2945
  </div>
2946
  </div>
 
2989
  // components/map/moment-card.tsx
2990
  // ----------------------------------------------------------------- //
2991
  function MomentCard({ moment, selected, onSelect, rotate = "" }) {
2992
+ // We cap albums at a handful of photos, so the scrapbook cards can afford
2993
+ // to be bigger — large enough that the title + caption fit inside the
2994
+ // frame. Text is line-clamped so it can never spill past the card edge,
2995
+ // and the date + time always show at the bottom.
2996
+ const timeLabel = [moment.date, moment.time].filter(Boolean).join(" · ");
2997
  return (
2998
  <button type="button" onClick={() => onSelect && onSelect(moment.id)} style={{ left: `${moment.x}%`, top: `${moment.y}%` }}
2999
+ className={cn("group absolute z-10 w-52 max-w-[78vw] -translate-x-1/2 -translate-y-1/2 rounded-sm border-2 border-primary bg-card p-2.5 text-left shadow-md transition-transform hover:z-20 hover:scale-105 sm:w-60", rotate, selected && "z-20 ring-4 ring-primary/30")}>
3000
  <span aria-hidden="true" className="absolute -top-2.5 left-1/2 h-4 w-12 -translate-x-1/2 -rotate-2 rounded-sm bg-primary/15" />
3001
+ <div className="relative aspect-square w-full overflow-hidden rounded-sm border border-primary/30"><Img src={moment.src} alt={moment.alt} fill sizes="240px" className="object-cover [filter:saturate(0.85)_contrast(1.05)]" /></div>
3002
  <div className="px-0.5 pb-0.5 pt-2">
3003
  <div className="flex items-start justify-between gap-1">
3004
+ <h3 className="line-clamp-2 break-words font-serif text-sm font-semibold leading-tight text-foreground">{moment.title}</h3>
3005
  {moment.favorite ? <Star className="mt-0.5 size-3.5 shrink-0 fill-primary text-primary" /> : null}
3006
  </div>
3007
+ {moment.caption ? <p className="mt-1 line-clamp-2 break-words font-hand text-[13px] leading-snug text-muted-foreground">{moment.caption}</p> : null}
3008
+ {timeLabel ? <p className="mt-1.5 font-mono text-[10px] text-primary/70">{timeLabel}</p> : null}
3009
  </div>
3010
  </button>
3011
  );
 
3585
  function MicButton({ field, onText, title }) {
3586
  const { settings, update } = useSettings();
3587
  const asrCfg = useAsrConfig();
3588
+ const aiLock = useAILock(); // voice transcription is AI → same lock
3589
  const [state, setState] = useState("idle"); // idle | recording | working | error
3590
  const [lang, setLang] = useState("");
3591
  const [note, setNote] = useState("");
 
3600
 
3601
  async function start() {
3602
  setNote(""); setLang("");
3603
+ if (aiLock.lockedByOther) return; // peer is using AI
3604
  if (asrCfg && asrCfg.osSupported === false) {
3605
  setState("error"); setNote("Nemotron is not supported on this OS."); return;
3606
  }
3607
  if (!navigator.mediaDevices || typeof MediaRecorder === "undefined") {
3608
  setState("error"); setNote("Recording not supported here."); return;
3609
  }
3610
+ // take the AI lock for the whole record→transcribe cycle
3611
+ const got = await aiLock.acquire("dictating");
3612
+ if (!got) { setNote("Collaborator is using AI."); return; }
3613
  try {
3614
  const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
3615
  streamRef.current = stream;
 
3624
  if (window.sozaiPing) window.sozaiPing("🎤 recording " + field);
3625
  } catch (e) {
3626
  setState("error"); setNote("Microphone blocked.");
3627
+ aiLock.release(); // never acquired audio → free the lock
3628
  }
3629
  }
3630
  function stop() {
 
3655
  }
3656
  } catch (e) {
3657
  setState("error"); setNote("Transcription failed.");
3658
+ } finally {
3659
+ aiLock.release(); // always free the lock once the cycle ends
3660
  }
3661
  }
3662
 
3663
  const recording = state === "recording";
3664
  const working = state === "working";
3665
  const osBlocked = asrCfg && asrCfg.osSupported === false;
3666
+ const micDisabled = working || osBlocked || (aiLock.lockedByOther && !recording);
3667
  return (
3668
  <span className="relative inline-flex items-center gap-1.5">
3669
  <button type="button"
3670
  onClick={() => (recording ? stop() : start())}
3671
+ disabled={micDisabled}
3672
  aria-pressed={recording}
3673
+ title={osBlocked ? "Nemotron is not supported on this OS" : (aiLock.lockedByOther ? aiLock.lockLabel : (title || ("Dictate " + field)))}
3674
+ className={cn("flex size-7 items-center justify-center rounded-full border-2 transition-colors disabled:opacity-60 disabled:cursor-not-allowed",
3675
  recording ? "border-[oklch(0.55_0.2_25)] bg-[oklch(0.55_0.2_25)] text-white animate-pulse"
3676
  : "border-primary/60 text-primary hover:bg-accent")}>
3677
  {working ? <Loader2 className="size-3.5 animate-spin" /> : recording ? <Square className="size-3" /> : <Mic className="size-3.5" />}
 
4339
  ];
4340
  const [transport, setTransport] = useState("car");
4341
  const transportMarkerRef = useRef(null);
4342
+
4343
+ // #3/#5: journey playback is GPU/CPU heavy and must be driven by ONE
4344
+ // person; the peer watches the SAME flythrough. The driver holds the
4345
+ // journey lock and broadcasts journey_start/frame/stop; the viewer mirrors
4346
+ // it. Transport choice is also synced (journey_transport).
4347
+ const journey = useJourneyLock();
4348
+ const viewerFlyRef = useRef({ active: false, idx: -1 });
4349
+ // pick a transport — synced to the peer so the journey looks the same
4350
+ const chooseTransport = useCallback((id) => {
4351
+ setTransport(id);
4352
+ try { Collab.send("journey_transport", { transport: id }); } catch (e) {}
4353
+ }, []);
4354
  // recording the journey to a downloadable clip (item 5)
4355
  const [recording, setRecording] = useState(false);
4356
  const recorderRef = useRef(null);
 
4520
  el.className = "sozai-polaroid";
4521
  el.style.setProperty("--rot", (p.rot || 0) + "deg");
4522
  el.innerHTML =
4523
+ '<img src="' + (p.src || "/assets/placeholder.svg").replace(/"/g, "&quot;") + '" alt="" onerror="this.onerror=null;this.src=\'/assets/placeholder.svg\'">' +
4524
  (p.caption ? '<div class="cap">' + p.caption.replace(/</g, "&lt;") + '</div>' : '<div class="cap">&nbsp;</div>');
4525
  const del = document.createElement("button");
4526
  del.className = "pdel"; del.type = "button"; del.title = "Remove"; del.textContent = "×";
 
4559
  const meta = p.date ? _dmy(p.date) : "";
4560
  const objPos = (p.focusX != null ? p.focusX : 50) + "% " + (p.focusY != null ? p.focusY : 50) + "%";
4561
  el.innerHTML =
4562
+ '<img src="' + (p.src || "/assets/placeholder.svg").replace(/"/g, "&quot;") + '" alt="" style="object-position:' + objPos + '" onerror="this.onerror=null;this.src=\'/assets/placeholder.svg\'">' +
4563
  '<div class="cap">' + ((p.caption || p.title || "&nbsp;").replace(/</g, "&lt;")) +
4564
  (meta ? '<span class="sozai-pin-when">' + meta.replace(/</g, "&lt;") + '</span>' : '') +
4565
  '</div>';
 
4606
  marker.on("dragend", () => wrap.classList.remove("is-dragging"));
4607
  storeMarkersRef.current[p.id] = { marker, el: wrap };
4608
  });
4609
+ }, [pinnedStore.map((p) => p.id + ":" + p.lon + "," + p.lat + ":" + p.caption + ":" + (p.rot || 0) + ":" + (p.scale || 1) + ":" + (p.focusX != null ? p.focusX : 50) + "," + (p.focusY != null ? p.focusY : 50) + ":" + (p.src || "").slice(0, 64)).join("|"), ready, styleEpoch]);
4610
 
4611
  // highlight the active stop during a flythrough
4612
  useEffect(() => {
 
4645
  }, 420));
4646
  return () => { cancelAnimationFrame(raf); ts.forEach(clearTimeout); };
4647
  }, [isFullscreen]);
4648
+ function toggleFullscreen() {
4649
+ setIsFullscreen((v) => {
4650
+ const next = !v;
4651
+ // #5: mirror the fullscreen state to the peer (optional follow). Only
4652
+ // applied by the receiver when "Following" is on, same as screen view.
4653
+ try { Collab.send("map_fullscreen", { full: next }); } catch (e) {}
4654
+ return next;
4655
+ });
4656
+ }
4657
+ // receive a peer's fullscreen toggle (respecting the follow preference)
4658
+ useEffect(() => {
4659
+ const off = Collab.on("map_fullscreen", (m) => {
4660
+ if (window.__sozaiFollow !== false && m && typeof m.full === "boolean") setIsFullscreen(m.full);
4661
+ });
4662
+ return () => off && off();
4663
+ }, []);
4664
 
4665
  async function searchCity() {
4666
  const q = place.trim();
 
4744
  }
4745
 
4746
  // ---- flythrough ("Map of My Time" video) ---- //
4747
+ function stopFly(opts) {
4748
+ opts = opts || {};
4749
  flyAbortRef.current.stop = true;
4750
+ viewerFlyRef.current.active = false;
4751
  setFlying(false);
4752
  setFlyIdx(-1);
4753
  if (transportMarkerRef.current) { try { transportMarkerRef.current.remove(); } catch (e) {} transportMarkerRef.current = null; }
4754
  stopRecording();
4755
+ // the driver tells the peer to stop too, and frees the lock so either
4756
+ // person can start the next journey. (A viewer stopping locally does NOT
4757
+ // broadcast — it just leaves the shared playback on its own screen.)
4758
+ if (!opts.fromRemote && journey.drivenByMe) {
4759
+ try { Collab.send("journey_stop", {}); } catch (e) {}
4760
+ journey.release();
4761
+ }
4762
  }
4763
 
4764
  // move the transport emoji marker to a lng/lat (creating it if needed)
 
4803
 
4804
  async function playFly(opts) {
4805
  opts = opts || {};
4806
+ const asViewer = !!opts.asViewer; // following the peer's journey
4807
  const map = mapRef.current;
4808
  if (!map || flyStops.length === 0) return;
4809
+
4810
+ // #3: in a room, the journey must be driven by ONE person. A normal
4811
+ // (non-viewer) start acquires the lock; if the peer already holds it we
4812
+ // bail (the button is disabled anyway, this is just belt-and-braces).
4813
+ if (!asViewer && Collab.isCollab()) {
4814
+ const got = await journey.acquire();
4815
+ if (!got) return;
4816
+ journey.beginRefresh();
4817
+ // tell the peer to start watching the same journey
4818
+ try { Collab.send("journey_start", { transport, record: false }); } catch (e) {}
4819
+ }
4820
+ if (asViewer) viewerFlyRef.current = { active: true, idx: -1 };
4821
+
4822
  flyAbortRef.current = { stop: false };
4823
  const token = flyAbortRef.current;
4824
  setFlying(true);
4825
+ // recording stays per-user and only on the driver's explicit request
4826
+ if (opts.record && !asViewer) { const ok = startRecording(); setRecording(ok); }
4827
+
4828
+ // the driver beacons which stop is showing, so the viewer's photo
4829
+ // overlay stays aligned even if animation timing drifts slightly.
4830
+ const beacon = (idx) => { if (!asViewer && journey.drivenByMe) { try { journey.frame({ idx }); } catch (e) {} } };
4831
 
4832
  // draw the route as a line under the journey
4833
  const routeCoords = flyStops.map((s) => [s.lon, s.lat]);
 
4843
 
4844
  const spd = (TRANSPORTS.find((t) => t.id === transport) || TRANSPORTS[0]).speed;
4845
  // start at the first stop
4846
+ setFlyIdx(0); beacon(0);
4847
  setTransportAt(flyStops[0].lon, flyStops[0].lat);
4848
  map.flyTo({ center: [flyStops[0].lon, flyStops[0].lat], zoom: 15, pitch: 50, duration: 1400, essential: true });
4849
  await new Promise((r) => setTimeout(r, 1500));
 
4870
  requestAnimationFrame(step);
4871
  });
4872
  if (token.stop) break;
4873
+ setFlyIdx(i); beacon(i);
4874
  map.flyTo({ center: [b.lon, b.lat], zoom: 15.5, pitch: 55, duration: 1100, essential: true });
4875
  await new Promise((r) => setTimeout(r, 1300)); // dwell to show the photo
4876
  }
 
4886
  stopRecording();
4887
  setFlying(false);
4888
  setFlyIdx(-1);
4889
+ viewerFlyRef.current.active = false;
4890
+ // driver finished naturally → tell the peer + free the lock
4891
+ if (!asViewer && !token.stop && journey.drivenByMe) {
4892
+ try { Collab.send("journey_stop", {}); } catch (e) {}
4893
+ journey.release();
4894
+ }
4895
  }
4896
+
4897
+ // viewer side: follow the peer's journey. journey_start runs the same
4898
+ // local flythrough (stops + transport already synced); journey_frame keeps
4899
+ // the photo index aligned; journey_stop ends it.
4900
+ useEffect(() => {
4901
+ const offs = [
4902
+ Collab.on("journey_transport", (m) => { if (m && m.transport) setTransport(m.transport); }),
4903
+ Collab.on("journey_start", (m) => {
4904
+ if (m && m.transport) setTransport(m.transport);
4905
+ // run on the next tick so the transport state is applied first
4906
+ setTimeout(() => { playFly({ asViewer: true }); }, 60);
4907
+ }),
4908
+ // safety net: if the driver disappears (disconnect) mid-flight, the
4909
+ // server broadcasts journey_state with no driver — stop our viewer run
4910
+ // so we don't keep flying a journey nobody is driving.
4911
+ Collab.on("journey_state", (m) => {
4912
+ if (viewerFlyRef.current.active && m && !m.driver) stopFly({ fromRemote: true });
4913
+ }),
4914
+ Collab.on("journey_frame", (m) => {
4915
+ if (m && typeof m.idx === "number" && viewerFlyRef.current.active) setFlyIdx(m.idx);
4916
+ }),
4917
+ Collab.on("journey_stop", () => { stopFly({ fromRemote: true }); }),
4918
+ ];
4919
+ return () => offs.forEach((off) => off && off());
4920
+ }, [flyStops.length]); // eslint-disable-line
4921
  useEffect(() => () => { flyAbortRef.current.stop = true; stopRecording(); }, []);
4922
 
4923
  if (libMissing) {
 
4986
  </span>
4987
  <div className="flex flex-wrap items-center gap-1.5">
4988
  {TRANSPORTS.map((t) => (
4989
+ <button key={t.id} type="button" onClick={() => chooseTransport(t.id)} aria-pressed={transport === t.id} title={t.label}
4990
+ disabled={flying || journey.drivenByOther}
4991
  className={cn("flex items-center gap-1 rounded-full border-2 px-2 py-1 font-mono text-[11px] uppercase tracking-wide transition-colors disabled:opacity-50",
4992
  transport === t.id ? "border-primary bg-accent text-foreground" : "border-primary/40 text-muted-foreground hover:bg-accent/50")}>
4993
  <span aria-hidden="true">{t.emoji}</span>{t.label}
 
4995
  ))}
4996
  </div>
4997
  <button type="button" onClick={() => (flying ? stopFly() : playFly({ record: false }))}
4998
+ disabled={!flying && journey.drivenByOther}
4999
+ title={journey.drivenByOther ? journey.driverLabel : undefined}
5000
+ className="flex items-center gap-1.5 rounded-md border-2 border-primary bg-primary px-3 py-1.5 font-mono text-[11px] font-semibold uppercase tracking-wide text-primary-foreground transition-colors hover:bg-primary/90 disabled:opacity-50">
5001
+ {flying ? <X className="size-3.5" /> : <FastForward className="size-3.5" />} {flying ? "Stop" : (journey.drivenByOther ? "Playing…" : "Play")}
5002
  </button>
5003
+ <button type="button" onClick={() => (flying ? stopFly() : playFly({ record: true }))} disabled={flying || journey.drivenByOther}
5004
+ title={journey.drivenByOther ? journey.driverLabel : "Play the journey and save it as a video clip"}
5005
  className="flex items-center gap-1.5 rounded-md border-2 border-primary/60 px-3 py-1.5 font-mono text-[11px] uppercase tracking-wide text-primary transition-colors hover:bg-accent disabled:opacity-50">
5006
  <span className={cn("size-2.5 rounded-full", recording ? "animate-pulse bg-[oklch(0.55_0.2_25)]" : "bg-primary")} /> {recording ? "Recording…" : "Record clip"}
5007
  </button>
 
5011
  <Download className="size-3.5" /> Download clip
5012
  </a>
5013
  ) : null}
5014
+ {journey.drivenByOther ? (
5015
+ <span className="font-mono text-[10px] uppercase tracking-wide text-primary">{journey.driverLabel}</span>
5016
+ ) : null}
5017
  </div>
5018
  ) : null}
5019
 
 
5063
  {/* flythrough control (only when there are pinned album photos) */}
5064
  {flyStops.length > 0 ? (
5065
  <button type="button" onClick={() => (flying ? stopFly() : playFly())}
5066
+ disabled={!flying && journey.drivenByOther}
5067
+ title={journey.drivenByOther ? journey.driverLabel : (flying ? "Stop the flythrough" : "Play Your Journey")}
5068
+ className="absolute right-2 top-2 z-10 flex items-center gap-1.5 rounded-md border-2 border-primary bg-card/90 px-2.5 py-2 font-mono text-[10px] font-semibold uppercase tracking-wide text-primary shadow-sm transition-colors hover:bg-accent disabled:opacity-50">
5069
  {flying ? <X className="size-4" /> : <FastForward className="size-4" />}
5070
+ {flying ? "Stop" : (journey.drivenByOther ? "Playing…" : "Play journey")}
5071
  </button>
5072
  ) : null}
5073
 
 
5301
  ];
5302
  const [expanded, setExpanded] = useState(false); // in-page immersive view
5303
  const [dropArmed, setDropArmed] = useState(false); // drag-from-tray hint
5304
+
5305
+ // #3/#5: same journey lock + sync as the watercolor map. The Cesium globe
5306
+ // (photorealistic 3D tiles) is the heaviest path, so the one-driver gate
5307
+ // matters most here. transport choice is synced; the viewer follows.
5308
+ const journey = useJourneyLock();
5309
+ const viewerFlyRef = useRef({ active: false });
5310
+ const chooseTransport = useCallback((id) => {
5311
+ setTransport(id);
5312
+ try { Collab.send("journey_transport", { transport: id }); } catch (e) {}
5313
+ }, []);
5314
  const [armedId, setArmedId] = useState(null); // tap-to-pin armed photo
5315
 
5316
  // tap-to-pin: arm a photo, then the next globe click pins it
 
5366
  });
5367
  viewerRef.current = viewer;
5368
  try { viewer.clock.shouldAnimate = false; } catch (e) {}
5369
+ // ---- memory budget --------------------------------------------- //
5370
+ // The flythrough sweeps the camera across the whole route; with the
5371
+ // default (effectively unbounded) caches Cesium keeps every terrain
5372
+ // and building tile it ever loads, so GPU memory grows the entire
5373
+ // journey and OOMs the tab near the end. Cap the terrain cache and
5374
+ // coarsen distant terrain so passed-over tiles are evicted.
5375
+ try {
5376
+ viewer.scene.globe.tileCacheSize = 60; // default 100
5377
+ viewer.scene.globe.maximumScreenSpaceError = 3; // default 2 (coarser = fewer tiles)
5378
+ viewer.scene.globe.preloadSiblings = false;
5379
+ viewer.scene.fog.enabled = true; // fog culls distant tiles → less to load
5380
+ } catch (e) {}
5381
+ // Graceful path if the GPU context is still lost despite the caps:
5382
+ // show an error instead of a silent white freeze.
5383
+ try {
5384
+ viewer.scene.canvas.addEventListener("webglcontextlost", (ev) => {
5385
+ ev.preventDefault();
5386
+ if (!cancelled) { setStatus("error"); setErrMsg("The 3D view ran out of GPU memory. Reload to try again."); }
5387
+ }, false);
5388
+ } catch (e) {}
5389
  viewer.scene.camera.setView({
5390
  destination: Cesium.Cartesian3.fromDegrees(-123.1207, 49.2827, 1200),
5391
  orientation: { heading: Cesium.Math.toRadians(10), pitch: Cesium.Math.toRadians(-25) },
 
5428
  if (cancelled) { viewer.destroy(); return; }
5429
  viewer.scene.primitives.add(tileset);
5430
  tilesetRef.current = tileset;
5431
+ // ---- 3D-tiles memory cap (the OSM buildings are the heaviest
5432
+ // consumer and the real OOM source) -------------------------- //
5433
+ // cacheBytes is a HARD ceiling: once exceeded, tiles outside the
5434
+ // view (e.g. everywhere the flythrough already passed) are
5435
+ // unloaded instead of accumulating. dynamicScreenSpaceError makes
5436
+ // tiles toward the horizon much coarser — the forward-looking
5437
+ // chase camera otherwise floods memory with full-detail distance.
5438
+ try {
5439
+ tileset.cacheBytes = 256 * 1024 * 1024; // 256 MB hard cap
5440
+ tileset.maximumCacheOverflowBytes = 64 * 1024 * 1024; // small transient overflow only
5441
+ tileset.maximumScreenSpaceError = 16; // keep near-camera crisp
5442
+ tileset.dynamicScreenSpaceError = true;
5443
+ tileset.dynamicScreenSpaceErrorDensity = 0.0045;
5444
+ tileset.dynamicScreenSpaceErrorFactor = 4.0;
5445
+ tileset.dynamicScreenSpaceErrorHeightFalloff = 0.25;
5446
+ tileset.preloadWhenHidden = false;
5447
+ tileset.preloadFlightDestinations = false; // don't pre-load the overview's tiles in a burst
5448
+ } catch (e) {}
5449
  tileset.style = buildCesiumStyle(Cesium, "watercolor", colorway);
5450
  } catch (e) { console.warn("[cesium] OSM buildings failed", e); }
5451
  if (!cancelled) setStatus("ready");
 
5531
  });
5532
  entitiesRef.current.push(ent);
5533
  });
5534
+ }, [pinnedStore.map((p) => p.id + ":" + p.lon + "," + p.lat + ":" + p.caption + ":" + (p.src || "").slice(0, 64)).join("|"), status]);
5535
 
5536
  // tap-to-pin + drag-to-move on the globe.
5537
  const armedRef = useRef(null);
 
5600
  if (viewer && vehicleRef.current) { try { viewer.entities.remove(vehicleRef.current); } catch (e) {} }
5601
  vehicleRef.current = null;
5602
  }
5603
+ function stopFly(opts) {
5604
+ opts = opts || {};
5605
  flyAbortRef.current.stop = true;
5606
+ viewerFlyRef.current.active = false;
5607
  setFlying(false); setFlyIdx(-1);
5608
  const viewer = viewerRef.current;
5609
  if (viewer) {
 
5613
  try { if (viewer.entities.getById("sozai-route")) viewer.entities.removeById("sozai-route"); } catch (e) {}
5614
  }
5615
  removeVehicle();
5616
+ if (!opts.fromRemote && journey.drivenByMe) {
5617
+ try { Collab.send("journey_stop", {}); } catch (e) {}
5618
+ journey.release();
5619
+ }
5620
  }
5621
+ async function playFly(opts) {
5622
+ opts = opts || {};
5623
+ const asViewer = !!opts.asViewer;
5624
  const viewer = viewerRef.current;
5625
  if (!viewer || !window.Cesium || flyStops.length < 1) return;
5626
+ // #3: one-driver gate (the 3D globe is the heaviest path → highest OOM risk)
5627
+ if (!asViewer && Collab.isCollab()) {
5628
+ const got = await journey.acquire();
5629
+ if (!got) return;
5630
+ journey.beginRefresh();
5631
+ try { Collab.send("journey_start", { transport }); } catch (e) {}
5632
+ }
5633
+ if (asViewer) viewerFlyRef.current = { active: true };
5634
  const Cesium = window.Cesium;
5635
  flyAbortRef.current = { stop: false };
5636
  const token = flyAbortRef.current;
 
5733
  }
5734
  } catch (e) {}
5735
  if (nextStop < stopTimes.length && t >= stopTimes[nextStop] - 0.2) {
5736
+ setFlyIdx(nextStop);
5737
+ if (!asViewer && journey.drivenByMe) { try { journey.frame({ idx: nextStop }); } catch (e) {} }
5738
+ nextStop++;
5739
  }
5740
  });
5741
  clockTickRef.current = () => { try { onTick(); } catch (e) {} };
 
5753
  if (clockTickRef.current) { clockTickRef.current(); clockTickRef.current = null; }
5754
  viewer.clock.shouldAnimate = false;
5755
  if (!token.stop && flyStops.length) {
5756
+ // Pulling back to show the WHOLE route is the single biggest tile
5757
+ // spike (every stop's buildings become visible at once). Coarsen the
5758
+ // 3D tiles hard and trim the cache BEFORE the wide flyTo so it can't
5759
+ // OOM, then restore detail once the camera has settled.
5760
+ const ts0 = tilesetRef.current;
5761
+ try { if (ts0) { ts0.maximumScreenSpaceError = 48; ts0.trimLoadedTiles(); } } catch (e) {}
5762
  const lons = flyStops.map((s) => s.lon), lats = flyStops.map((s) => s.lat);
5763
  const cx = (Math.min(...lons) + Math.max(...lons)) / 2, cy = (Math.min(...lats) + Math.max(...lats)) / 2;
5764
+ viewer.scene.camera.flyTo({ destination: Cesium.Cartesian3.fromDegrees(cx, cy - 0.02, 2200),
5765
  orientation: { heading: 0, pitch: Cesium.Math.toRadians(-45) }, duration: 2.4 });
5766
  await new Promise((r) => setTimeout(r, 2400));
5767
+ try { if (ts0) ts0.maximumScreenSpaceError = 16; } catch (e) {} // restore near-camera detail
5768
  }
5769
  removeVehicle();
5770
  try { if (viewer.entities.getById("sozai-route")) viewer.entities.removeById("sozai-route"); } catch (e) {}
5771
  setFlying(false); setFlyIdx(-1);
5772
+ viewerFlyRef.current.active = false;
5773
+ if (!asViewer && !token.stop && journey.drivenByMe) {
5774
+ try { Collab.send("journey_stop", {}); } catch (e) {}
5775
+ journey.release();
5776
+ }
5777
  }
5778
 
5779
+ // viewer side: follow the peer's 3D journey (stops + transport synced).
5780
+ useEffect(() => {
5781
+ const offs = [
5782
+ Collab.on("journey_transport", (m) => { if (m && m.transport) setTransport(m.transport); }),
5783
+ Collab.on("journey_start", (m) => {
5784
+ if (m && m.transport) setTransport(m.transport);
5785
+ setTimeout(() => playFly({ asViewer: true }), 60);
5786
+ }),
5787
+ Collab.on("journey_frame", (m) => {
5788
+ if (m && typeof m.idx === "number" && viewerFlyRef.current.active) setFlyIdx(m.idx);
5789
+ }),
5790
+ Collab.on("journey_stop", () => { stopFly({ fromRemote: true }); }),
5791
+ // driver disconnected mid-flight → stop following
5792
+ Collab.on("journey_state", (m) => {
5793
+ if (viewerFlyRef.current.active && m && !m.driver) stopFly({ fromRemote: true });
5794
+ }),
5795
+ ];
5796
+ return () => offs.forEach((off) => off && off());
5797
+ }, [flyStops.length]); // eslint-disable-line
5798
+
5799
  if (status === "no-token") {
5800
  return (
5801
  <div className="rounded-lg border-2 border-primary/40 bg-accent/30 p-6 text-center">
 
5869
  </span>
5870
  <div className="flex flex-wrap items-center gap-1.5">
5871
  {CS_TRANSPORTS.map((t) => (
5872
+ <button key={t.id} type="button" onClick={() => chooseTransport(t.id)} aria-pressed={transport === t.id} title={t.label} disabled={flying || journey.drivenByOther}
5873
  className={cn("flex items-center gap-1 rounded-full border-2 px-2 py-1 font-mono text-[11px] uppercase tracking-wide transition-colors disabled:opacity-50",
5874
  transport === t.id ? "border-primary bg-accent text-foreground" : "border-primary/40 text-muted-foreground hover:bg-accent/50")}>
5875
  <span aria-hidden="true">{t.emoji}</span>{t.label}
5876
  </button>
5877
  ))}
5878
  </div>
5879
+ {journey.drivenByOther ? (
5880
+ <span className="font-mono text-[10px] uppercase tracking-wide text-primary">{journey.driverLabel}</span>
5881
+ ) : null}
5882
  </div>
5883
  ) : null}
5884
 
 
5923
  ) : null}
5924
  {flyStops.length > 0 && status === "ready" ? (
5925
  <button type="button" onClick={() => (flying ? stopFly() : playFly())}
5926
+ disabled={!flying && journey.drivenByOther}
5927
+ title={journey.drivenByOther ? journey.driverLabel : undefined}
5928
+ className="absolute right-2 top-2 z-10 flex items-center gap-1.5 rounded-md border-2 border-primary bg-card/90 px-2.5 py-2 font-mono text-[10px] font-semibold uppercase tracking-wide text-primary shadow-sm transition-colors hover:bg-accent disabled:opacity-50">
5929
+ {flying ? <X className="size-4" /> : <FastForward className="size-4" />} {flying ? "Stop" : (journey.drivenByOther ? "Playing…" : "Play journey")}
5930
  </button>
5931
  ) : null}
5932
  {flying && flyStops[flyIdx] ? (
 
6692
  <div className="relative w-full overflow-hidden rounded-lg border-2 border-primary/40 bg-[oklch(0.95_0.018_85)]">
6693
  <img src="/assets/photos/scrapbook-map.png" alt="" aria-hidden="true" className="pointer-events-none absolute inset-0 h-full w-full object-cover opacity-60 mix-blend-multiply" />
6694
  <RouteLines moments={liveMoments} />
6695
+ <div className="relative h-[540px] w-full sm:h-[560px]">
6696
  {liveMoments.map((m, i) => <MomentCard key={m.id} moment={m} rotate={rotations[i % rotations.length]} selected={selectedId === m.id} onSelect={setSelectedId} />)}
6697
  </div>
6698
  </div>
 
7018
 
7019
  // baths kept from the zip (colors + labels), seen from above
7020
  const DR_BATHS = [
7021
+ { id: "developer", label: "Developer", color: "oklch(0.45 0.14 150)", edge: "oklch(0.32 0.1 150)" },
7022
+ { id: "stop", label: "Stop Bath", color: "oklch(0.5 0.12 90)", edge: "oklch(0.36 0.09 90)" },
7023
+ { id: "fixer", label: "Fixer", color: "oklch(0.5 0.1 250)", edge: "oklch(0.36 0.08 250)" },
7024
+ { id: "wash", label: "Wash", color: "oklch(0.55 0.08 220)", edge: "oklch(0.4 0.06 220)" },
7025
  ];
7026
  // top-down layout - columns are now driven by flex, not % coordinates
7027
 
 
7274
  // ---- polaroid print card (shared by both bath phases) --------------- //
7275
  function PrintCard({ sheet }) {
7276
  const bf = drBathFilter(sheet.level);
7277
+ // (no animated SVG ripple filter it flashed/repainted badly on mobile)
7278
+ const filt = bf;
7279
  return (
7280
  <div className="w-full" style={{
7281
  transform: "rotate(" + sheet.rot + "deg) scale(" + sheet.scale + ")",
 
7383
  style={{ background: "radial-gradient(120% 100% at 50% 0%, oklch(0.20 0.04 55), oklch(0.10 0.02 55) 70%)",
7384
  boxShadow: "inset 0 0 70px rgba(0,0,0,0.55)" }}>
7385
 
7386
+ <div className="flex items-end gap-1.5 px-2 pb-3 pt-8 sm:gap-3 sm:px-4 sm:pb-4 sm:pt-10">
7387
  {DR_BATHS.map((b, i) => {
7388
  const aboveCards = sheets.filter((s) => s.col === i && s.above);
7389
  const subCards = sheets.filter((s) => s.col === i && !s.above);
 
7404
  style={{ aspectRatio: "1 / 1.5", background: "oklch(0.12 0.02 55)", boxShadow: "0 6px 16px rgba(0,0,0,0.5)" }}>
7405
  <div className="relative h-full w-full overflow-hidden rounded-[6px]"
7406
  style={{ background: "radial-gradient(120% 120% at 50% 0%, " + b.color + ", " + b.edge + ")" }}>
7407
+ {/* a single STILL highlight (no animated turbulence / WebGL
7408
+ shimmer those flashed green/orange on some phones) */}
7409
+ <div className="pointer-events-none absolute inset-0" style={{
7410
+ background: "linear-gradient(180deg, rgba(255,255,255,0.16), rgba(255,255,255,0) 45%)", zIndex: 2 }} />
7411
+ <div className="pointer-events-none absolute inset-0" style={{ boxShadow: "inset 0 0 24px rgba(0,0,0,0.5)", zIndex: 3 }} />
7412
  {/* submerged card lives inside its tray */}
7413
  {subCards.map((s) => (
7414
  <div key={s.id} className="absolute" style={{ inset: "10%", zIndex: 4 + s.z }}>
 
7457
  }
7458
 
7459
  // ---- phase 2: hanging print, shake to reveal (ported) -------------- //
7460
+ // (shake reveal is now distance-based; see HangingPrint.addTravel)
7461
+ function HangingPrint({ photo, index, swayDelay, onRevealed, widthClass, isDeveloper, onDeveloped }) {
7462
  const [progress, setProgress] = useState(0);
7463
  const [revealed, setRevealed] = useState(false);
7464
  const [dragging, setDragging] = useState(false);
7465
  const [tilt, setTilt] = useState(0);
7466
+ const dragState = useRef({ active: false, lastX: 0, lastY: 0, offset: 0 });
7467
  const progressRef = useRef(0);
7468
+ const revealedRef = useRef(false);
7469
+ useEffect(() => { revealedRef.current = revealed; }, [revealed]);
7470
 
7471
  useEffect(() => { if (revealed) onRevealed(photo.id); }, [revealed, onRevealed, photo.id]);
7472
 
7473
+ // #5: when the peer reveals this print, reveal it here too (mirror the shake)
7474
+ useEffect(() => {
7475
+ const off = Collab.on("darkroom_reveal", (m) => {
7476
+ if (m && m.id === photo.id) { progressRef.current = 1; setProgress(1); revealedRef.current = true; setRevealed(true); }
7477
+ });
7478
+ return () => off && off();
7479
+ }, [photo.id]);
7480
+
7481
  // Kick off the watercolour "develop" (flux.2-klein img2img) the moment the
7482
  // print is hung, so the result is ready by the time it's shaken in. The
7483
  // shake then reveals the DEVELOPED image; if the endpoint is unavailable
7484
  // or fails, we fall back to the original photo (no regression).
7485
+ //
7486
+ // #3 (OOM): the FLUX develop is GPU-heavy. In a room, only ONE person —
7487
+ // the develop-lock holder (isDeveloper) — actually calls /api/img2img. The
7488
+ // developed watercolour is written to the shared SozaiPhotos store, so it
7489
+ // syncs to the peer automatically; the non-developer simply WAITS for that
7490
+ // synced result (photo.src flips to a fresh data URL) instead of hitting
7491
+ // the GPU a second time. Solo: isDeveloper is always true.
7492
  const [developedSrc, setDevelopedSrc] = useState(null);
7493
  const [devStatus, setDevStatus] = useState("queued"); // queued|developing|done|base
7494
  const developRef = useRef(false);
7495
+ const initialSrcRef = useRef(photo.src);
7496
+
7497
+ // Non-developer path: watch for the developed image to arrive via sync.
7498
+ // When the store's src for this photo changes from what we started with,
7499
+ // the watercolour has synced in — treat it as developed + shakeable. Also
7500
+ // listen for the developer's explicit outcome (covers the "develop feature
7501
+ // is off → keep the original" case, where no new src is ever written).
7502
  useEffect(() => {
7503
+ if (isDeveloper) return;
7504
+ setDevStatus((s) => (s === "queued" ? "developing" : s));
7505
+ if (photo.src && photo.src !== initialSrcRef.current) {
7506
+ setDevelopedSrc(photo.src);
7507
+ setDevStatus("done");
7508
+ }
7509
+ const off = Collab.on("darkroom_developed", (m) => {
7510
+ if (!m || m.id !== photo.id) return;
7511
+ if (m.status === "base") setDevStatus("base");
7512
+ else if (m.status === "done") setDevStatus((s) => (s === "done" ? s : "developing"));
7513
+ });
7514
+ return () => off && off();
7515
+ }, [isDeveloper, photo.src, photo.id]);
7516
+
7517
+ useEffect(() => {
7518
+ if (!isDeveloper) return; // only the developer hits the GPU
7519
  if (developRef.current || !photo.src) return;
7520
  developRef.current = true;
7521
  let alive = true;
 
7549
  if (j && j.available && j.image) {
7550
  if (alive) {
7551
  setDevelopedSrc(j.image); setDevStatus("done");
7552
+ // tell the peer this print finished developing (the image
7553
+ // itself syncs via the store; this covers reveal-gating)
7554
+ try { Collab.send("darkroom_developed", { id: photo.id, status: "done" }); } catch (e) {}
7555
  // write the DEVELOPED watercolour + its pin back into the shared
7556
  // store so the map shows the transformed photo, auto-pinned at
7557
+ // its GPS location. This also SYNCS the watercolour to the peer
7558
+ // (PhotoSync), which is how the non-developer gets the result.
7559
  try {
7560
  window.SozaiPhotos.upsert(photo.id, {
7561
  src: j.image,
 
7570
  return;
7571
  }
7572
  if (j && j.available === false) { // feature genuinely off
7573
+ if (alive) { setDevStatus("base"); try { Collab.send("darkroom_developed", { id: photo.id, status: "base" }); } catch (e) {} }
7574
+ return;
7575
  }
7576
  }
7577
  } catch (e) { console.warn("[develop] attempt", attempt, "failed", e); }
7578
  await sleep(5000);
7579
  }
7580
+ if (alive) { setDevStatus("base"); try { Collab.send("darkroom_developed", { id: photo.id, status: "base" }); } catch (e) {} } // gave up → keep the original
7581
  })();
7582
  return () => { alive = false; };
7583
+ }, [isDeveloper, photo.src, photo.alt, index]);
7584
+
7585
+ // Progress is driven by TOTAL travel distance (sum of |movement|), not by
7586
+ // counting precise left-right-left reversals. Reversals are hard to land
7587
+ // with a thumb and made the shake feel broken on phones; accumulating
7588
+ // distance means any vigorous wiggle reliably develops the print.
7589
+ const SHAKE_DISTANCE_TO_REVEAL = 520; // px of total travel
7590
+ const addTravel = useCallback((dist) => {
7591
+ const next = Math.min(1, progressRef.current + dist / SHAKE_DISTANCE_TO_REVEAL);
7592
  progressRef.current = next; setProgress(next);
7593
+ if (next >= 1 && !revealedRef.current) {
7594
+ revealedRef.current = true;
7595
+ setRevealed(true);
7596
+ try { Collab.send("darkroom_reveal", { id: photo.id }); } catch (e) {}
7597
+ }
7598
+ }, [photo.id]);
7599
 
7600
  // No shaking until the watercolour has actually developed (or the develop
7601
  // feature is off / gave up, where "base" means we reveal the original).
7602
  const canShake = devStatus === "done" || devStatus === "base";
7603
+ // Tell the parent the moment GPU work for this print is finished (done or
7604
+ // base), so it can release the develop lock instantly — the shake/reveal
7605
+ // phase is pure CSS and needs no lock.
7606
+ const reportedRef = useRef(false);
7607
+ useEffect(() => {
7608
+ if (canShake && !reportedRef.current) { reportedRef.current = true; onDeveloped && onDeveloped(photo.id); }
7609
+ }, [canShake, photo.id, onDeveloped]);
7610
+
7611
+ const tiltRafRef = useRef(0);
7612
  function onPointerDown(e) {
7613
  if (revealed || !canShake) return;
7614
  try { e.currentTarget.setPointerCapture(e.pointerId); } catch (err) {}
7615
+ dragState.current = { active: true, lastX: e.clientX, lastY: e.clientY, offset: 0 };
7616
  setDragging(true);
7617
  }
7618
  function onPointerMove(e) {
7619
  const s = dragState.current;
7620
  if (!s.active || revealed || !canShake) return;
7621
  const dx = e.clientX - s.lastX;
7622
+ const dy = e.clientY - s.lastY;
7623
+ const dist = Math.hypot(dx, dy);
7624
+ if (dist < 2) return; // ignore micro-jitter
7625
+ addTravel(dist); // any movement counts toward reveal
7626
+ s.lastX = e.clientX; s.lastY = e.clientY;
7627
  s.offset = Math.max(-26, Math.min(26, s.offset + dx));
7628
+ // throttle tilt updates to one per frame so fast shakes stay smooth
7629
+ if (!tiltRafRef.current) {
7630
+ tiltRafRef.current = requestAnimationFrame(() => { tiltRafRef.current = 0; setTilt(dragState.current.offset); });
7631
+ }
7632
  }
7633
  function endDrag(e) {
7634
  dragState.current.active = false; setDragging(false); setTilt(0);
7635
+ if (tiltRafRef.current) { cancelAnimationFrame(tiltRafRef.current); tiltRafRef.current = 0; }
7636
  try { e.currentTarget.releasePointerCapture(e.pointerId); } catch (err) {}
7637
  }
7638
+ useEffect(() => () => { if (tiltRafRef.current) cancelAnimationFrame(tiltRafRef.current); }, []);
7639
 
7640
  const blurPx = (1 - progress) * 14;
7641
  const grayscale = (1 - progress) * 100;
 
7649
  className={cn("group relative -mt-1.5 select-none rounded-sm bg-[oklch(0.99_0.01_85)] p-3 pb-16 shadow-2xl",
7650
  dragging ? "" : "dr-sway", revealed ? "cursor-default" : (canShake ? "cursor-grab active:cursor-grabbing" : "cursor-progress"))}
7651
  style={{ animationDelay: swayDelay + "s",
7652
+ // touch-action none while shakeable so the phone doesn't scroll the
7653
+ // page out from under the shake gesture (the main cause of jitter)
7654
+ touchAction: (canShake && !revealed) ? "none" : "auto",
7655
  transform: dragging ? "rotate(" + (tilt * 0.3) + "deg) translateX(" + (tilt * 0.4) + "px)" : undefined,
7656
  transition: dragging ? "none" : "transform 0.4s ease" }}
7657
  onPointerDown={onPointerDown} onPointerMove={onPointerMove} onPointerUp={endDrag} onPointerCancel={endDrag}>
 
7693
  const [muted, setMuted] = useState(false);
7694
  const sfx = useWaterSfx(muted);
7695
  const allRevealed = revealed.size === list.length;
7696
+ const journey = useJourneyLock(); // reuse the journey lock for the GPU-heavy develop
7697
+
7698
+ // #3: only ONE person runs the FLUX develop (the lock holder). Solo always
7699
+ // wins the lock. The other person is a viewer: they don't hit the GPU and
7700
+ // instead receive each developed watercolour via the shared store. We grab
7701
+ // the lock on mount and — per the requested behaviour — RELEASE IT THE
7702
+ // INSTANT all GPU work is finished (the shake/reveal phase is pure CSS and
7703
+ // needs no lock), rather than holding it until the darkroom closes.
7704
+ const [isDeveloper, setIsDeveloper] = useState(!Collab.isCollab());
7705
+ const heldRef = useRef(false); // do we currently hold the lock?
7706
+ const developedRef = useRef(new Set()); // ids whose GPU work has finished
7707
+ const releaseLock = useCallback(() => {
7708
+ if (heldRef.current) { heldRef.current = false; journey.release(); }
7709
+ }, []); // eslint-disable-line
7710
+ useEffect(() => {
7711
+ let alive = true;
7712
+ (async () => {
7713
+ if (!Collab.isCollab()) { setIsDeveloper(true); return; }
7714
+ const got = await journey.acquire();
7715
+ if (!alive) { if (got) journey.release(); return; }
7716
+ setIsDeveloper(got);
7717
+ if (got) { heldRef.current = true; journey.beginRefresh(); }
7718
+ })();
7719
+ // safety: always release on unmount in case develop never completed
7720
+ return () => { alive = false; releaseLock(); };
7721
+ }, []); // eslint-disable-line
7722
+
7723
+ // each print reports when its GPU work is done; once every print is
7724
+ // finished, free the lock immediately so the peer can start their journey.
7725
+ const onPrintDeveloped = useCallback((id) => {
7726
+ developedRef.current.add(id);
7727
+ if (list.length > 0 && developedRef.current.size >= list.length) releaseLock();
7728
+ }, [list.length, releaseLock]);
7729
 
7730
  const handleRevealed = useCallback((id) => {
7731
  setRevealed((prev) => { const next = new Set(prev); next.add(id); return next; });
7732
  }, []);
7733
 
7734
+ // #5: sync the darkroom phase (baths → hang) so both move together. The
7735
+ // developer drives the phase; the viewer follows. Either receiving a phase
7736
+ // event applies it locally.
7737
+ useEffect(() => {
7738
+ const off = Collab.on("darkroom_phase", (m) => { if (m && m.phase) setPhase(m.phase); });
7739
+ return () => off && off();
7740
+ }, []);
7741
+ const goPhase = useCallback((p) => {
7742
+ setPhase(p);
7743
+ try { Collab.send("darkroom_phase", { phase: p }); } catch (e) {}
7744
+ }, []);
7745
+
7746
+ // #5: mirror peer reveals at the parent too (so the X/Y "done" counter and
7747
+ // the "View your map" gate advance for both people).
7748
+ useEffect(() => {
7749
+ const off = Collab.on("darkroom_reveal", (m) => {
7750
+ if (m && m.id) handleRevealed(m.id);
7751
+ });
7752
+ return () => off && off();
7753
+ }, [handleRevealed]);
7754
+
7755
  return (
7756
  <div className="fixed inset-0 z-50 flex flex-col overflow-hidden bg-[oklch(0.12_0.022_55)]">
7757
  <style dangerouslySetInnerHTML={{ __html:
 
7798
  </button>
7799
  </div>
7800
 
7801
+ {/* centred content (scrollable on small screens so tall print rows
7802
+ never get clipped by the fixed overlay) */}
7803
+ <div className="relative flex flex-1 flex-col items-center justify-start gap-6 overflow-y-auto px-5 pb-10 pt-2 sm:justify-center sm:px-8">
7804
  <div className="text-center">
7805
+ <h1 className="font-display text-4xl leading-none text-[oklch(0.92_0.04_78)] sm:text-6xl">The Darkroom</h1>
7806
+ <p className="mt-3 font-serif italic text-lg text-[oklch(0.70_0.07_65)] sm:text-xl">
7807
  {phase === "baths"
7808
  ? "developing your photos"
7809
  : allRevealed ? "your photos are ready"
 
7812
  </div>
7813
 
7814
  {phase === "baths" ? (
7815
+ <TopDownBaths photos={list} sfx={sfx} onComplete={() => goPhase("hang")} />
7816
  ) : (
7817
  <div className="flex w-full flex-col items-center">
7818
  <div className="relative w-full">
 
7825
  : list.length <= 4 ? "w-60 sm:w-72"
7826
  : "w-52 sm:w-60";
7827
  return (
7828
+ <HangingPrint key={photo.id} photo={photo} index={i} swayDelay={i * 0.4} onRevealed={handleRevealed} widthClass={heroW} isDeveloper={isDeveloper} onDeveloped={onPrintDeveloped} />
7829
  );
7830
  })}
7831
  </ul>
 
7853
  function Page({ onOpenSettings, onOpenPets, onOpenTrace, screen, setScreen, mapVariant, setMapVariant }) {
7854
  // Each photo carries its own title/caption/date/time - a single source of
7855
  // truth so the fields can never desync from the photo list.
7856
+ //
7857
+ // Upload sync: stackPhotos MUST be a live view of the shared SozaiPhotos
7858
+ // store, not a private copy. The store is what PhotoSync relays to/from
7859
+ // the peer (photos_state). Subscribing here means a peer's upload / edit /
7860
+ // delete — which lands in the store via replaceAll — immediately shows up,
7861
+ // and our own edits get broadcast. setStackPhotos is a thin wrapper that
7862
+ // funnels every local mutation back into the store, so there is exactly
7863
+ // ONE source of truth and uploads always sync both ways.
7864
+ const [stackPhotos, setStackPhotosRaw] = useState(() => window.SozaiPhotos.all());
7865
+ useEffect(() => window.SozaiPhotos.subscribe((items) => setStackPhotosRaw(items.slice())), []);
7866
+ const setStackPhotos = useCallback((next) => {
7867
+ const current = window.SozaiPhotos.all();
7868
+ const resolved = typeof next === "function" ? next(current) : next;
7869
+ if (!Array.isArray(resolved)) return;
7870
+ window.SozaiPhotos.replaceAll(resolved);
7871
+ }, []);
7872
  const developPhotos = stackPhotos.map((p) => ({ id: p.id, src: p.src, alt: p.alt, defaultCaption: p.defaultCaption,
7873
  lat: p.lat, lon: p.lon, date: p.date, time: p.time, title: p.title, caption: p.caption }));
7874
  if (screen === "add")
 
7882
  return <MapOfMyTime onBack={() => setScreen("add")} onOpenSettings={onOpenSettings} onOpenPets={onOpenPets} variant={mapVariant} onChangeVariant={setMapVariant} />;
7883
  }
7884
 
7885
+ // ----------------------------------------------------------------- //
7886
+ // Join gate (#4): a full-screen loading / status screen shown while a //
7887
+ // joiner is connecting or awaiting host approval, so they never see or //
7888
+ // interact with the room before they're actually admitted. //
7889
+ // ----------------------------------------------------------------- //
7890
+ function JoinGate({ state }) {
7891
+ const joining = state === "joining";
7892
+ const pending = state === "pending";
7893
+ const declined = state === "declined";
7894
+ const full = state === "full";
7895
+ const title = joining ? "Joining the room…"
7896
+ : pending ? "Waiting for the host…"
7897
+ : declined ? "Not admitted"
7898
+ : "This room is full";
7899
+ const blurb = joining ? "Connecting you to the shared album."
7900
+ : pending ? "The host needs to let you in. Hang tight; this screen updates the moment they do."
7901
+ : declined ? "The host didn't admit you to this room."
7902
+ : "This room is at its 2-person limit. Ask them to make space, or start your own room.";
7903
+ const spinning = joining || pending;
7904
+ return (
7905
+ <div className="fixed inset-0 z-[200] flex items-center justify-center bg-background p-6">
7906
+ <div className="w-full max-w-sm rounded-lg border-2 border-primary/40 bg-card p-7 text-center shadow-lg">
7907
+ <div className="mx-auto flex size-12 items-center justify-center rounded-full border-2 border-primary/50 text-primary">
7908
+ {spinning ? <Loader2 className="size-6 animate-spin" />
7909
+ : declined ? <X className="size-6" /> : <Users className="size-6" />}
7910
+ </div>
7911
+ <h1 className="mt-4 font-display text-2xl text-foreground">{title}</h1>
7912
+ <p className="mt-2 text-sm leading-relaxed text-muted-foreground">{blurb}</p>
7913
+ {(declined || full) ? (
7914
+ <a href="/"
7915
+ className="mt-5 inline-flex items-center justify-center rounded-md border-2 border-primary bg-primary px-5 py-2.5 font-mono text-xs uppercase tracking-wide text-primary-foreground transition-colors hover:bg-primary/90">
7916
+ Back to start
7917
+ </a>
7918
+ ) : (
7919
+ <div className="mt-5 flex items-center justify-center gap-1" aria-hidden="true">
7920
+ <span className="size-1.5 rounded-full bg-primary" style={{ animation: "sozai-bounce 1s infinite", animationDelay: "0ms" }} />
7921
+ <span className="size-1.5 rounded-full bg-primary" style={{ animation: "sozai-bounce 1s infinite", animationDelay: "150ms" }} />
7922
+ <span className="size-1.5 rounded-full bg-primary" style={{ animation: "sozai-bounce 1s infinite", animationDelay: "300ms" }} />
7923
+ </div>
7924
+ )}
7925
+ </div>
7926
+ </div>
7927
+ );
7928
+ }
7929
+
7930
  function Root() {
7931
  const [chatOpen, setChatOpen] = useState(false);
7932
  const [unread, setUnread] = useState(0);
 
7937
  });
7938
  const [settings, setSettings] = useState(loadSettings);
7939
 
7940
+ // ---- join gate (#4) ----------------------------------------------- //
7941
+ // When joining a room we must NOT show the live app until we're actually
7942
+ // admitted (init arrives). Otherwise the joiner sees/can touch the room
7943
+ // while still "pending". joinState: "joining" until init; then "in".
7944
+ // "pending" = waiting for host approval; "declined"/"full" = terminal.
7945
+ // Solo (no /room/ path) is "in" immediately — nothing to wait for.
7946
+ const [joinState, setJoinState] = useState(() => (Collab.isCollab() ? "joining" : "in"));
7947
+
7948
  useEffect(() => { Collab.connect(); }, []);
7949
  useEffect(() => {
7950
  // load saved scrapbook for this room (if any) and start auto-saving
7951
  RoomPersistence.init();
7952
+ PhotoSync.init(); // live image relay (instant peer sync)
7953
  const onHide = () => { try { RoomPersistence.flush(); } catch (e) {} };
7954
  window.addEventListener("beforeunload", onHide);
7955
  document.addEventListener("visibilitychange", () => { if (document.visibilityState === "hidden") onHide(); });
7956
+ return () => { window.removeEventListener("beforeunload", onHide); RoomPersistence.stop(); PhotoSync.stop(); };
7957
  }, []);
7958
  useEffect(() => { applySettings(settings); }, []); // eslint-disable-line
7959
  useEffect(() => {
 
7978
  }),
7979
  Collab.on("join_pending", () => {
7980
  window.__sozaiJoinPending = true;
7981
+ setJoinState("pending");
 
7982
  }),
7983
  Collab.on("join_declined", () => {
7984
  ToastStore.dismiss("join-wait");
7985
+ setJoinState("declined");
7986
+ }),
7987
+ // room is at the 2-person cap → explain and offer to go home
7988
+ Collab.on("room_full", (m) => {
7989
+ ToastStore.dismiss("join-wait");
7990
+ window.__sozaiRoomFull = true;
7991
+ setJoinState("full");
7992
  }),
7993
+ // once we're fully in (init arrives), reveal the app
7994
+ Collab.on("me", () => { ToastStore.dismiss("join-wait"); window.__sozaiJoinPending = false; setJoinState("in"); }),
7995
  ];
7996
  return () => offs.forEach((off) => off && off());
7997
  }, []);
 
8013
  const [followPeer, setFollowPeer] = useState(true);
8014
  const followRef = useRef(followPeer);
8015
  useEffect(() => { followRef.current = followPeer; window.__sozaiFollow = followPeer; }, [followPeer]);
8016
+ // Screens that are PERSONAL — a collaborator entering them should not drag
8017
+ // the other person along. The cat picker ("pets") is explicitly personal:
8018
+ // each person chooses their own cat independently. "trace" (debug/LLM
8019
+ // trace) is likewise private. We neither follow INTO these nor broadcast
8020
+ // our own entry into them.
8021
+ const PRIVATE_SCREENS = React.useRef(new Set(["pets", "trace"])).current;
8022
  useEffect(() => {
8023
  const offs = [
8024
+ Collab.on("screen_changed", (m) => {
8025
+ if (!followRef.current || !m || !m.screen) return;
8026
+ if (PRIVATE_SCREENS.has(m.screen)) return; // don't follow into a private screen
8027
+ setScreen(m.screen);
8028
+ }),
8029
  Collab.on("variant_changed", (m) => { if (followRef.current && m && m.variant) setMapVariant(m.variant); }),
8030
  ];
8031
  return () => offs.forEach((off) => off && off());
8032
  }, []);
8033
+ // broadcast our own screen/variant changes for peers who are following
8034
+ // but never announce entry into a private screen (cat picker, trace), so
8035
+ // the other person isn't yanked away while we pick a cat.
8036
+ useEffect(() => {
8037
+ if (PRIVATE_SCREENS.has(screen)) return;
8038
+ Collab.send("screen_changed", { screen });
8039
+ }, [screen]);
8040
  useEffect(() => { Collab.send("variant_changed", { variant: mapVariant }); }, [mapVariant]);
8041
 
8042
+ // Ambient activity beacon (#4): tell the peer WHERE we are / what we're
8043
+ // doing, as a friendly label — even for private screens like the cat
8044
+ // picker (we announce it without dragging them there). This drives the
8045
+ // "Alex is browsing the map" presence indicator in the collab bar.
8046
+ const SCREEN_ACTIVITY = {
8047
+ add: "adding photos", darkroom: "in the darkroom",
8048
+ map: "exploring the Map of My Time", pets: "choosing a cat",
8049
+ trace: "viewing the AI trace",
8050
+ };
8051
+ useEffect(() => {
8052
+ const label = SCREEN_ACTIVITY[screen] || ("on the " + screen + " screen");
8053
+ Collab.send("activity", { label, screen });
8054
+ }, [screen]);
8055
+
8056
  const update = (patch) => setSettings((prev) => ({ ...prev, ...patch }));
8057
  const openPets = () => setScreen("pets");
8058
  const openTrace = () => setScreen("trace");
8059
 
8060
+ // #4: while joining / pending / declined / full, show ONLY the gate — the
8061
+ // joiner must never see or touch the room until they're actually admitted.
8062
+ if (joinState !== "in") {
8063
+ return <JoinGate state={joinState} />;
8064
+ }
8065
+
8066
  return (
8067
  <SettingsContext.Provider value={{ settings, update }}>
8068
  <div className="flex min-h-screen flex-col">
8069
  <CollabBar onToggleChat={() => { setChatOpen((v) => !v); setUnread(0); }} unread={unread} onOpenPets={openPets} onOpenTrace={openTrace} />
8070
+ <main className="flex flex-1 items-start justify-center overflow-y-auto bg-background p-4 sm:items-center sm:p-8">
8071
  <Page onOpenSettings={() => setSettingsOpen(true)} onOpenPets={openPets} onOpenTrace={openTrace}
8072
  screen={screen} setScreen={setScreen} mapVariant={mapVariant} setMapVariant={setMapVariant} />
8073
  </main>