Skip to content

Commit 25fbb03

Browse files
committed
turn CyberCity into OpenWorld: fast travel between regions, low-poly bright world
The 3D area is now OpenWorld at /openworld, and it's navigated by warping rather than only by panning a single city. Fast travel. Every district on the master town plan becomes a named region with a one-line pitch and a door into the 2D PortOS page it visualizes — Memory Quarter → /brain/inbox, Sprint Yard → /devtools/jira, Data Harbor → /data. M (or the HUD button) opens a searchable panel beside a plate of the world; picking a region flies the orbital camera, and warps the player rig too when you're on foot. Geography is read from cityPlan's PARCELS rather than re-declared, so moving a district moves its warp marker with it. The destination lives in the URL (/openworld/region/:regionId), which makes a warp shareable, bookmarkable, and reachable from ⌘K and voice — navManifest.test.js scrapes the registry and fails if a region's path, label, or aliases drift from its nav command. World style. A new `worldStyle` setting picks the art direction from a data table (WORLD_STYLE_DEFS): 'vibes', the default, is a bright low-poly world — teal-to-warm sky, meadow ground, flat-shaded matte facades, warm sun — and 'cyber' restores the original neon night exactly. Most of the look rides on each style's time-of-day presets, since every surface that already lerped night→day follows daylightFactor for free; the neon-only layers (galaxy spheremap, starfield, data rain, embers, volumetric cones, neon signage, CRT overlay) don't mount at all under 'vibes' rather than being faded per frame. Keeping 'cyber' selectable keeps that whole layer exercised by the suite instead of bit-rotting behind an unused default. Because 'vibes' is daytime at both times of day, the per-building window texture would have been rasterized for every app and bound to nothing — it's now gated on the same condition the material uses. /city and everything under it redirects, so bookmarks, pinned sidebar rows, stored ⌘K history, and peers' deep links keep working. The nav command ids stay `nav.cybercity` (opaque, persisted in palette history) and the ~150 city* module names stay put; that sweep is #4571. Remaining staged work: #4572 (low-poly geometry), #4573 (in-world warp pads), #4574 (touch-sized fast-travel map).
1 parent 96a1a6f commit 25fbb03

35 files changed

Lines changed: 1602 additions & 158 deletions

‎client/src/App.jsx‎

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ const AgentsPage = lazyWithReload(() => import('./pages/DevTools').then(m => ({
2424
const DataDog = lazyWithReload(() => import('./pages/DataDog'));
2525
const FlowsDoc = lazyWithReload(() => import('./pages/FlowsDoc'));
2626
const GitHub = lazyWithReload(() => import('./pages/GitHub'));
27-
const CyberCity = lazyWithReload(() => import('./pages/CyberCity'));
27+
const OpenWorld = lazyWithReload(() => import('./pages/OpenWorld'));
2828
const AppDetail = lazyWithReload(() => import('./pages/AppDetail'));
2929
const FeatureAgents = lazyWithReload(() => import('./pages/FeatureAgents'));
3030
const FeatureAgentDetail = lazyWithReload(() => import('./pages/FeatureAgentDetail'));
@@ -195,6 +195,10 @@ const MEDIA_3D_PREFIX = /^\/media\/3d/;
195195
// is what a bookmark, a typed URL, or a half-remembered path lands on — alias it
196196
// rather than letting the catch-all swallow it (issue #3793).
197197
const ANNOTATE_PREFIX = /^\/annotate/;
198+
// CyberCity became OpenWorld. Bookmarks, pinned sidebar entries, stored palette
199+
// history, and peers' deep links all still say /city — alias the whole prefix
200+
// (including /city/settings and /city/apps/:appId) rather than 404ing them.
201+
const CITY_PREFIX = /^\/city/;
198202

199203
// Normalize a tab-less /creative-director/:id URL to its overview tab while
200204
// preserving any query string + hash. A bare `<Navigate to="overview">` would
@@ -342,9 +346,12 @@ export default function App() {
342346
<Route path="jira" element={<Navigate to="/devtools/jira" replace />} />
343347
<Route path="devtools/jira" element={<Jira />} />
344348
<Route path="devtools/jira/reports" element={<JiraReports />} />
345-
<Route path="city" element={<CyberCity />} />
346-
<Route path="city/settings" element={<CyberCity />} />
347-
<Route path="city/apps/:appId" element={<CyberCity />} />
349+
<Route path="openworld" element={<OpenWorld />} />
350+
<Route path="openworld/settings" element={<OpenWorld />} />
351+
<Route path="openworld/apps/:appId" element={<OpenWorld />} />
352+
<Route path="openworld/region/:regionId" element={<OpenWorld />} />
353+
<Route path="city" element={<PrefixRedirect from={CITY_PREFIX} to="/openworld" />} />
354+
<Route path="city/*" element={<PrefixRedirect from={CITY_PREFIX} to="/openworld" />} />
348355
<Route path="data" element={<DataManager />} />
349356
<Route path="character" element={<CharacterSheet />} />
350357
<Route path="ask" element={<Ask />} />

‎client/src/CLAUDE.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@ These apply to React/Vite client code. Universal constraints (functional program
1010
- **Above the fold** - keep actionable content and info above the fold and design pages for maximum information and access without scrolling
1111
- **No hardcoded localhost** - use `window.location.hostname` for URLs; app accessed via Tailscale remotely
1212
- **Chrome floating over media uses `.port-media-overlay`** - a label, status bar, badge, or control panel sitting on top of an image, a video, or a 3D canvas takes its surface and ink from the theme (`.port-media-overlay`, or `.port-media-overlay-strong` for a small label directly on photography; `.port-media-overlay-item` for its buttons, `.port-media-overlay-divider` for its separators — all defined in `index.css`, which carries the mechanism and the rule for picking between this, `.always-dark`, and the City HUD remap). **Don't write `bg-black/NN` + `text-white`/`text-gray-N00` for this** — day mode remaps the text to the theme's dark ink while the black scrim stays black, so the label renders dark-on-black. Older overlays outside the 3D/Three.js pages still carry that idiom (migration tracked in #3796), so match the new convention rather than the nearest neighbor.
13-
- **Alphabetical navigation** - sidebar nav items in `Layout.jsx` are alphabetically ordered after the Dashboard+CyberCity top section and separator; children within collapsible sections are also alphabetical
13+
- **Alphabetical navigation** - sidebar nav items in `Layout.jsx` are alphabetically ordered after the Dashboard+OpenWorld top section and separator; children within collapsible sections are also alphabetical
14+
- **OpenWorld's `city*` prefix is legacy, not a naming convention to follow.** The 3D world was renamed CyberCity → OpenWorld at the route/label level; the ~150 `city*` files (`components/city/`, `utils/city*.js`, `hooks/useCity*.js`) and the `.cybercity-themed` CSS scope did NOT move, and the `nav.cybercity` command ids are deliberately frozen (they're opaque and persisted in ⌘K history). **Name new files in that tree `openWorld*` / `OpenWorld*`** — matching the nearest neighbor would half-rename the directory by accretion, which is worse than either end state. The mechanical sweep is tracked in #4571.
1415
- **Reactive UI updates** - after mutations (delete, create, update), update local state directly instead of refetching the entire list from the server. Use `setState(prev => prev.filter(...))` or similar patterns for immediate feedback
1516

1617
## API errors and save gating

‎client/src/components/Layout.jsx‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ import {
4343
SquareTerminal,
4444
Globe,
4545
Newspaper,
46-
Building2,
46+
Globe2,
4747
Ticket,
4848
Network,
4949
Flame,
@@ -144,7 +144,7 @@ import * as api from '../services/api';
144144
const navItems = [
145145
{ to: '/', label: 'Dashboard', icon: Home, single: true },
146146
{ to: '/review', label: 'Review Hub', icon: ClipboardList, single: true },
147-
{ to: '/city', label: 'City', icon: Building2, single: true },
147+
{ to: '/openworld', label: 'OpenWorld', icon: Globe2, single: true },
148148
{ separator: true },
149149
{ label: 'Apps', icon: Package, dynamic: 'apps', defaultTo: '/apps', children: [] },
150150
{
@@ -545,7 +545,7 @@ const FULL_WIDTH_PATH_PREFIXES = [
545545
'/writers-room',
546546
'/agents',
547547
'/shell/',
548-
'/city',
548+
'/openworld',
549549
'/timeline/',
550550
// Every SongBook route — index (/songbook), import (/songbook/import),
551551
// and viewer (/songbook/:id) — is full-bleed and owns its own scroll

‎client/src/components/Layout.test.jsx‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import * as api from '../services/api';
66

77
// This suite locks the *integration* path that SingleNavRow.test.jsx can't reach:
88
// pinning a top-level `single: true` row (Dashboard `/`, Review Hub `/review`,
9-
// City `/city`, Goals `/goals/list`) must make it render in the sidebar Pinned
9+
// OpenWorld `/openworld`, Goals `/goals/list`) must make it render in the sidebar Pinned
1010
// section. That resolution lives in Layout itself — `navEntryByPath` indexes
1111
// `item.single` leaves, `resolveNavEntry` maps a stored path to a row, and
1212
// `useNavWorkingSet` feeds the Pinned-section render. We exercise the real
@@ -107,13 +107,13 @@ describe('Layout — pinned single nav rows', () => {
107107
});
108108

109109
it('resolves every top-level single row by path into the Pinned section', async () => {
110-
localStorage.setItem(PINNED_KEY, JSON.stringify(['/', '/review', '/city', '/goals/list']));
110+
localStorage.setItem(PINNED_KEY, JSON.stringify(['/', '/review', '/openworld', '/goals/list']));
111111
await renderLayout();
112112

113113
const pinned = pinnedSection();
114114
expect(within(pinned).getByRole('link', { name: /Dashboard/i })).toHaveAttribute('href', '/');
115115
expect(within(pinned).getByRole('link', { name: /Review Hub/i })).toHaveAttribute('href', '/review');
116-
expect(within(pinned).getByRole('link', { name: /City/i })).toHaveAttribute('href', '/city');
116+
expect(within(pinned).getByRole('link', { name: /OpenWorld/i })).toHaveAttribute('href', '/openworld');
117117
expect(within(pinned).getByRole('link', { name: /Goals/i })).toHaveAttribute('href', '/goals/list');
118118
});
119119

@@ -127,12 +127,12 @@ describe('Layout — pinned single nav rows', () => {
127127
// null and is dropped by resolveNavEntry; a known path beside it still renders.
128128
// Asserting the survivor (not just absence) proves the filter, not merely that
129129
// the section failed to render.
130-
localStorage.setItem(PINNED_KEY, JSON.stringify(['/this/path/does/not/exist', '/city']));
130+
localStorage.setItem(PINNED_KEY, JSON.stringify(['/this/path/does/not/exist', '/openworld']));
131131
await renderLayout();
132132

133133
const pinned = pinnedSection();
134134
expect(pinned).toBeTruthy();
135-
expect(within(pinned).getByRole('link', { name: /City/i })).toHaveAttribute('href', '/city');
135+
expect(within(pinned).getByRole('link', { name: /OpenWorld/i })).toHaveAttribute('href', '/openworld');
136136
// The unknown path contributes no row.
137137
expect(within(pinned).getAllByRole('link')).toHaveLength(1);
138138
});

‎client/src/components/city/Building.jsx‎

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -416,7 +416,9 @@ export default function Building({ app, position, agentCount, onClick, playSfx,
416416
const displayedColorRef = useRef(null);
417417
const targetColorRef = useRef(null);
418418

419-
const { getBuildingColor, getAccentColor, tintStructure } = useCityPalette();
419+
// `surface` carries the world style's finish (flat shading + matte under the low-poly
420+
// style); spread last on the facade material so it wins over the cyber defaults.
421+
const { getBuildingColor, getAccentColor, tintStructure, surface } = useCityPalette();
420422
const height = getBuildingHeight(app);
421423
const edgeColor = getBuildingColor(app.overallStatus, app.archived);
422424
const accentColor = getAccentColor(app);
@@ -464,17 +466,21 @@ export default function Building({ app, position, agentCount, onClick, playSfx,
464466
);
465467
useEffect(() => () => haloEdgesGeom.dispose(), [haloEdgesGeom]);
466468

467-
// Window texture with pixel art icon
469+
// Window texture with pixel art icon. Built ONLY when the facade will actually bind it:
470+
// the daytime material drops `map`/`emissiveMap` entirely, and the Vibes world style is
471+
// daytime at both times of day — so without this gate every install on the default style
472+
// would rasterize a full canvas per building (nested fillRect loops, ~1–2k 2D ops each)
473+
// at scene mount and again on every theme-accent change, and bind none of them.
468474
const windowTexture = useMemo(
469-
() => createWindowTexture(accentColor, width, height, seed, tintStructure),
470-
[accentColor, width, height, seed, tintStructure]
475+
() => (daytime ? null : createWindowTexture(accentColor, width, height, seed, tintStructure)),
476+
[daytime, accentColor, width, height, seed, tintStructure]
471477
);
472478

473479
// R3F does NOT dispose a CanvasTexture handed in via `map`/`emissiveMap` — it
474480
// only frees objects it owns on unmount. Since the city no longer remounts on
475481
// theme switch (issue-1064), each recolor recreates this texture and would
476482
// strand the previous one in VRAM per building. Dispose it on replace + unmount.
477-
useEffect(() => () => windowTexture.dispose(), [windowTexture]);
483+
useEffect(() => () => windowTexture?.dispose(), [windowTexture]);
478484

479485
// Format name for building face
480486
const displayName = useMemo(() => {
@@ -561,10 +567,11 @@ export default function Building({ app, position, agentCount, onClick, playSfx,
561567
color={daytime ? bodyColor : '#ffffff'}
562568
emissive={edgeColor}
563569
emissiveIntensity={0.7 * neonBrightness * dimMul * (1 - dayMix * 0.88)}
564-
map={daytime ? undefined : windowTexture}
565-
emissiveMap={daytime ? undefined : windowTexture}
570+
map={windowTexture || undefined}
571+
emissiveMap={windowTexture || undefined}
566572
roughness={daytime ? 0.9 : 0.78}
567573
metalness={daytime ? 0.05 : 0.08}
574+
{...surface}
568575
transparent
569576
opacity={(app.archived ? 0.78 : 1) * dimMul}
570577
/>

‎client/src/components/city/CityFocusCamera.jsx‎

Lines changed: 24 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,17 @@
1-
import { useRef, useEffect } from 'react';
1+
import { useRef, useEffect, useMemo } from 'react';
22
import { useThree, useFrame } from '@react-three/fiber';
33
import * as THREE from 'three';
44
import { smoothstep } from '../../utils/easing';
5-
import { computeFocusCamera } from '../../utils/cityFocusCamera';
5+
import { computeFocusCamera, computeRegionCamera } from '../../utils/cityFocusCamera';
66

7-
// In-canvas camera controller for CyberCity's building focus mode (issue #2593). When the
8-
// `/city/apps/:appId` route resolves to a placed building, it flies the orbital camera (and the
9-
// OrbitControls target) to frame that borough; when focus clears it flies back to the overview.
7+
// In-canvas camera controller for OpenWorld's two URL-addressed camera targets: a single
8+
// building (`/openworld/apps/:appId`, issue #2593) and a whole fast-travel region
9+
// (`/openworld/region/:regionId`). Either one flies the orbital camera (and the OrbitControls
10+
// target) to frame its subject; when both clear, it flies back to the overview.
11+
//
12+
// A building focus wins over a region when both are somehow set — the two live on separate
13+
// routes, so that only happens transiently mid-navigation, and framing the tighter subject is
14+
// the less jarring resolution.
1015
//
1116
// Staleness / unmount safety: all motion runs inside useFrame, which is inherently frame-gated —
1217
// there is NO setTimeout, so a stale deferred emit is impossible. Retargeting to a newly-selected
@@ -25,11 +30,17 @@ const deriveLookAt = (camera) => {
2530
return camera.position.clone().add(dir.multiplyScalar(10));
2631
};
2732

28-
export default function CityFocusCamera({ focusedAppId, positions, orbitRef, active = true, hudSafe }) {
33+
export default function CityFocusCamera({ focusedAppId, focusedRegion, positions, orbitRef, active = true, hudSafe }) {
2934
const { camera, size } = useThree();
30-
// `null` = overview (no fly needed on a plain /city mount). A transition into/out of focus flips
35+
// `null` = overview (no fly needed on a plain /openworld mount). A transition into/out of focus flips
3136
// this and starts a fly.
3237
const currentKeyRef = useRef(null);
38+
// The region fly's identity, built once per target change rather than inside useFrame —
39+
// the live loop runs ~60×/s and must not allocate a string per frame.
40+
const regionKey = useMemo(
41+
() => (focusedRegion?.anchor ? `region:${focusedRegion.id}` : null),
42+
[focusedRegion?.anchor, focusedRegion?.id],
43+
);
3344
const animRef = useRef(null);
3445
const controlsWasEnabledRef = useRef(true);
3546

@@ -46,7 +57,9 @@ export default function CityFocusCamera({ focusedAppId, positions, orbitRef, act
4657
const pos = wantFocus ? positions?.get?.(focusedAppId) : null;
4758
// Focus wanted but the layout position isn't ready yet → hold and retry next frame.
4859
if (wantFocus && !pos) return;
49-
const key = wantFocus ? focusedAppId : null;
60+
// Regions come straight from the static plan, so there is no equivalent wait.
61+
const wantRegion = !wantFocus && active && regionKey !== null;
62+
const key = wantFocus ? focusedAppId : wantRegion ? regionKey : null;
5063

5164
if (key !== currentKeyRef.current) {
5265
currentKeyRef.current = key;
@@ -64,7 +77,9 @@ export default function CityFocusCamera({ focusedAppId, positions, orbitRef, act
6477
} else {
6578
const aspect = size.height > 0 ? size.width / size.height : 1;
6679
const fovDeg = camera.isPerspectiveCamera ? camera.fov : undefined;
67-
const framed = computeFocusCamera({ building: pos, aspect, fovDeg, hudSafe });
80+
const framed = wantFocus
81+
? computeFocusCamera({ building: pos, aspect, fovDeg, hudSafe })
82+
: computeRegionCamera({ region: focusedRegion, aspect, fovDeg, hudSafe });
6883
endPos = new THREE.Vector3(...framed.position);
6984
endTarget = new THREE.Vector3(...framed.target);
7085
}

‎client/src/components/city/CityHud.jsx‎

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ function ControlsHint({ visible }) {
4848
<div>WASD: MOVE · Q/E: DOWN/UP</div>
4949
<div>SHIFT: SPRINT</div>
5050
<div>F: INTERACT WITH BUILDING</div>
51+
<div>M: FAST TRAVEL</div>
5152
<div>TAB: FLY OUT</div>
5253
</div>
5354
</div>
@@ -69,7 +70,7 @@ function Crosshair() {
6970
);
7071
}
7172

72-
export default function CityHud({ cosStatus, cosAgents, agentMap, eventLogs, connected, apps, reviewCounts, instances, productivityData, systemHealth, notificationCounts, character, filter, onFilterChange, onJumpToFirst, matchCount, onToggleExploration, explorationMode, onSelectApp, onEnterPhotoMode, onEnterPlayback, focusedAppId, focusedApp, focusNotFound, focusAgents, onCloseFocus, onOpenApp }) {
73+
export default function CityHud({ cosStatus, cosAgents, agentMap, eventLogs, connected, apps, reviewCounts, instances, productivityData, systemHealth, notificationCounts, character, filter, onFilterChange, onJumpToFirst, matchCount, onToggleExploration, explorationMode, onSelectApp, onEnterPhotoMode, onEnterPlayback, focusedAppId, focusedApp, focusNotFound, focusAgents, onCloseFocus, onOpenApp, onOpenFastTravel, activeRegion }) {
7374
const isFocused = Boolean(focusedApp || focusNotFound);
7475
const navigate = useNavigate();
7576
const location = useLocation();
@@ -267,6 +268,24 @@ export default function CityHud({ cosStatus, cosAgents, agentMap, eventLogs, con
267268
</div>
268269
</div>
269270

271+
{/* Fast travel (M) — warp to any named region of the world */}
272+
{onOpenFastTravel && (
273+
<button
274+
onClick={onOpenFastTravel}
275+
className="pointer-events-auto mb-2 relative bg-black/85 backdrop-blur-sm border border-cyan-500/30 rounded-lg px-3 py-2 hover:border-cyan-400/60 hover:bg-cyan-500/10 transition-all w-full"
276+
title="Fast travel to a region (M)"
277+
>
278+
<HudCorner position="tl" />
279+
<HudCorner position="br" />
280+
<div className="font-pixel text-[10px] text-cyan-400 tracking-wider truncate" style={{ textShadow: '0 0 6px rgba(6,182,212,0.4)' }}>
281+
[ FAST TRAVEL ]
282+
</div>
283+
<div className="font-pixel text-[7px] text-cyan-500/40 tracking-wide mt-0.5 text-center truncate">
284+
{activeRegion ? activeRegion.label.toUpperCase() : '(M)'}
285+
</div>
286+
</button>
287+
)}
288+
270289
{/* Drop In / Fly Out button */}
271290
<button
272291
onClick={onToggleExploration}
@@ -319,10 +338,10 @@ export default function CityHud({ cosStatus, cosAgents, agentMap, eventLogs, con
319338
)}
320339

321340
<button
322-
onClick={() => navigate(location.pathname === '/city/settings' ? `/city${location.search}` : `/city/settings${location.search}`)}
341+
onClick={() => navigate(location.pathname === '/openworld/settings' ? `/openworld${location.search}` : `/openworld/settings${location.search}`)}
323342
className="pointer-events-auto mb-2 relative bg-black/85 backdrop-blur-sm border border-cyan-500/30 rounded-lg w-10 h-10 flex items-center justify-center hover:border-cyan-400/60 hover:bg-cyan-500/10 transition-all group"
324343
title="Settings"
325-
aria-label="City settings"
344+
aria-label="OpenWorld settings"
326345
>
327346
<HudCorner position="tl" />
328347
<HudCorner position="br" />
@@ -369,6 +388,7 @@ export default function CityHud({ cosStatus, cosAgents, agentMap, eventLogs, con
369388
focusAgents={focusAgents}
370389
onCloseFocus={onCloseFocus}
371390
onOpenApp={onOpenApp}
391+
onOpenFastTravel={onOpenFastTravel}
372392
/>
373393
)}
374394

0 commit comments

Comments
 (0)