From aeb3467b027955fafe27552856402f123a13dd47 Mon Sep 17 00:00:00 2001 From: Thalida Noel Date: Fri, 29 May 2026 16:34:05 -0400 Subject: [PATCH 01/27] fix(camera): include tallest building height in start framing The first-frame framing radius was a width-only proxy (rootStreet.width * 15), so cities with one very tall building pierced the top of the camera frame on load / R. Take the max of the width formula and a padded tallest-building height (getMaxBuildingHeight * 1.15) so the framed sphere always contains the tallest spire with ~15% sky headroom. Closes the TODO line: "fix start position to include tallest building". Co-Authored-By: Claude Opus 4.7 (1M context) --- TODO.md | 2 +- app/src/scene/system/cameraRig.ts | 30 +++++++++++++++++++++++------- 2 files changed, 24 insertions(+), 8 deletions(-) diff --git a/TODO.md b/TODO.md index 5f65cd74..8b2799a9 100644 --- a/TODO.md +++ b/TODO.md @@ -44,7 +44,7 @@ - [x] on focus / select look straight down at the building - [ ] add meta data about the world - [ ] add enable local env variable -- [ ] fix start position to include tallest building +- [x] fix start position to include tallest building ## Agent Prompts ToDos diff --git a/app/src/scene/system/cameraRig.ts b/app/src/scene/system/cameraRig.ts index 85ee1e58..eb8107b9 100644 --- a/app/src/scene/system/cameraRig.ts +++ b/app/src/scene/system/cameraRig.ts @@ -57,6 +57,12 @@ const STREET_FOCUS_RATIO = 1.2; const TOP_DOWN_ELEVATION_DEG = 80; const TOP_DOWN_PADDING_MULT = 2.8; +// Headroom above the tallest building's roof when sizing the initial +// framing radius. 1.15 leaves ~15% of the vertical FOV as sky above the +// tallest spire so the horizon-glow band stays visible instead of the +// roof sitting flush against the top edge of the frame. +const TALLEST_BUILDING_HEADROOM_MULT = 1.15; + export function createCameraRig({ canvas, world, @@ -165,13 +171,23 @@ export function createCameraRig({ let framingRadius: number; if (gemPos && rootStreet) { framingCenter = new THREE.Vector3(gemPos.x, 0, gemPos.z); - // Frame off the root street's WIDTH, not its length. Length is a - // proxy for "how much stuff is in the project" — for a big repo the - // root street is enormously long and framing on it is the same as - // framing the whole world. Width is bounded by STREET_TIERS (≈10–52), - // so width × 10 reliably fits the gem + the road's first stretch - // regardless of project size. - framingRadius = rootStreet.width * 15; + // Frame off the root street's WIDTH (not its length) AND the tallest + // building's height, whichever produces the larger radius. + // Width: length is a proxy for "how much stuff is in the project" — + // for a big repo the root street is enormously long and framing on + // it is the same as framing the whole world. Width is bounded by + // STREET_TIERS (≈10–52), so width × 15 reliably fits the gem + the + // road's first stretch regardless of project size. + // Height: framingCenter sits at y=0, so a sphere of radius R fits + // any point with |y| ≤ R. Without the height branch, tall buildings + // pierce the top of the frame on cities where one big file dwarfs + // the root street's width. getMaxBuildingHeight() returns 0 for + // empty cities, so the Math.max safely degrades to width-only. + const tallestH = world.getMaxBuildingHeight(); + framingRadius = Math.max( + rootStreet.width * 15, + tallestH * TALLEST_BUILDING_HEADROOM_MULT, + ); } else { // No gem (empty manifest, pre-build) — fall back to whole-world. framingCenter = worldGroundCenter; From ed9e353566b626387c850c83b5c1b83fa9019dc0 Mon Sep 17 00:00:00 2001 From: Thalida Noel Date: Fri, 29 May 2026 16:47:08 -0400 Subject: [PATCH 02/27] fix(camera): compute start framing distance geometrically + raise elevation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous attempt (Math.max on a sphere-fit radius with a 1.15 height multiplier) under-corrected. INITIAL_DISTANCE_MULT = 0.75 makes the sphere fit 25% tighter than a true bounding-sphere projection, and the camera's 14° elevation puts the building roof above the optical axis instead of at the sphere's edge. Net effect: the tallest building still poked above the top of the frame by ~15%. Replace with a two-distance fit: widthDist = (rootStreet.width * 15 / sin(halfFov)) * INITIAL_DISTANCE_MULT heightDist = tallestH * HEADROOM * cos(elev - halfFov) / sin(halfFov) framingDist = max(widthDist, heightDist) The heightDist formula is derived from the perspective projection at elevation `elev`: a point H above the target projects to screen-y = cos(elev) * H / (D - sin(elev) * H); setting that = tan(halfFov) and solving for D yields D = H * cos(elev - halfFov) / sin(halfFov) for a flush fit. HEADROOM_MULT = 1.15 scales D so the roof clears the top edge with sky above it. Also raise FRAMING_DIR_Y from 0.25 to 0.5 (~14° → ~27° elevation) so the camera takes in the skyline more top-down instead of hugging the horizon. The constant is now shared between the dir construction and the heightDist formula instead of being duplicated as a magic 0.25. Co-Authored-By: Claude Opus 4.7 (1M context) --- app/src/scene/system/cameraRig.ts | 77 ++++++++++++++++++++----------- 1 file changed, 49 insertions(+), 28 deletions(-) diff --git a/app/src/scene/system/cameraRig.ts b/app/src/scene/system/cameraRig.ts index eb8107b9..63a25574 100644 --- a/app/src/scene/system/cameraRig.ts +++ b/app/src/scene/system/cameraRig.ts @@ -57,10 +57,17 @@ const STREET_FOCUS_RATIO = 1.2; const TOP_DOWN_ELEVATION_DEG = 80; const TOP_DOWN_PADDING_MULT = 2.8; -// Headroom above the tallest building's roof when sizing the initial -// framing radius. 1.15 leaves ~15% of the vertical FOV as sky above the -// tallest spire so the horizon-glow band stays visible instead of the -// roof sitting flush against the top edge of the frame. +// y-component of the start framing direction vector (before normalization). +// 0.5 → ~27° camera elevation above the root street's long axis. The +// height-fit distance formula in _captureFraming depends on this exact +// value, so it is referenced both there and at the dir construction site +// instead of being duplicated. +const FRAMING_DIR_Y = 0.5; + +// Headroom above the tallest building's roof when fitting the start +// framing. 1.15 = distance is 15% greater than the minimum at which the +// roof would sit flush against the top edge of the vertical FOV, so the +// horizon-glow band stays visible above the tallest spire. const TALLEST_BUILDING_HEADROOM_MULT = 1.15; export function createCameraRig({ @@ -171,46 +178,60 @@ export function createCameraRig({ let framingRadius: number; if (gemPos && rootStreet) { framingCenter = new THREE.Vector3(gemPos.x, 0, gemPos.z); - // Frame off the root street's WIDTH (not its length) AND the tallest - // building's height, whichever produces the larger radius. - // Width: length is a proxy for "how much stuff is in the project" — - // for a big repo the root street is enormously long and framing on - // it is the same as framing the whole world. Width is bounded by - // STREET_TIERS (≈10–52), so width × 15 reliably fits the gem + the - // road's first stretch regardless of project size. - // Height: framingCenter sits at y=0, so a sphere of radius R fits - // any point with |y| ≤ R. Without the height branch, tall buildings - // pierce the top of the frame on cities where one big file dwarfs - // the root street's width. getMaxBuildingHeight() returns 0 for - // empty cities, so the Math.max safely degrades to width-only. - const tallestH = world.getMaxBuildingHeight(); - framingRadius = Math.max( - rootStreet.width * 15, - tallestH * TALLEST_BUILDING_HEADROOM_MULT, - ); + // Frame off the root street's WIDTH, not its length. Length is a + // proxy for "how much stuff is in the project" — for a big repo the + // root street is enormously long and framing on it is the same as + // framing the whole world. Width is bounded by STREET_TIERS (≈10–52), + // so width × 15 reliably fits the gem + the road's first stretch + // regardless of project size. + framingRadius = rootStreet.width * 15; } else { // No gem (empty manifest, pre-build) — fall back to whole-world. framingCenter = worldGroundCenter; framingRadius = worldRadius; } - const framingDist = + // Distance from width: the existing "city neighborhood readable on + // screen" framing. INITIAL_DISTANCE_MULT (<1) tightens the sphere fit + // intentionally; tuned for the typical city shape. + const widthDist = (framingRadius / Math.sin(halfFov)) * cameraControlsCfg.INITIAL_DISTANCE_MULT; + // Distance from tallest building: ensures the roof would fit the + // vertical FOV even though the tallest building can sit anywhere in + // the city — without this the camera frames width only and tall + // buildings near the gem pierce the top of the frame. Geometry: the + // framing target sits at y=0; the camera sits at elevation `elev` + // along (-cos(elev), sin(elev), 0) × D from the target (see dir + // construction below). A point H above the target projects to + // screen-y = cos(elev) × H / (D − sin(elev) × H); setting that ≤ + // tan(halfFov) and solving gives D = H × cos(elev − halfFov) / + // sin(halfFov) for a flush fit. HEADROOM_MULT scales D up so the + // roof clears the top edge with sky above it. Empty cities get + // getMaxBuildingHeight() = 0, so heightDist collapses to 0 and the + // width branch wins. + const tallestH = gemPos && rootStreet ? world.getMaxBuildingHeight() : 0; + const elevRad = Math.atan(FRAMING_DIR_Y); + const heightDist = + (tallestH * TALLEST_BUILDING_HEADROOM_MULT * Math.cos(elevRad - halfFov)) / + Math.sin(halfFov); + const framingDist = Math.max(widthDist, heightDist); // Default framing: place the camera BEHIND the gem along the root // street's long axis (the street extends in +X for X-oriented or +Z // for Y-oriented; the gem sits at the low end — see // engine.ts:createRootGem) at a low cinematic elevation. This gives // a "looking down the main road into the city" view instead of the - // previous top-down (-1, 1, 1) oblique. y=0.25 → ~14° elevation; - // wide enough to see the whole skyline, low enough that the - // horizon-glow band is visible above the buildings. Fallback - // (no gem) keeps the old high-oblique direction for completeness. + // previous top-down (-1, 1, 1) oblique. FRAMING_DIR_Y (0.5) → ~27° + // elevation; high enough to take in the skyline without the camera + // hugging the horizon, low enough that the horizon-glow band still + // reads behind the buildings. The same constant feeds the heightDist + // formula above. Fallback (no gem) keeps the old high-oblique + // direction for completeness. let dir: THREE.Vector3; if (rootStreet) { dir = rootStreet.orientation === StreetAxis.X - ? new THREE.Vector3(-1, 0.25, 0).normalize() - : new THREE.Vector3(0, 0.25, -1).normalize(); + ? new THREE.Vector3(-1, FRAMING_DIR_Y, 0).normalize() + : new THREE.Vector3(0, FRAMING_DIR_Y, -1).normalize(); } else { dir = new THREE.Vector3(-1, 1, 1).normalize(); } From dcb70ee51af099789c0894ad1e255e5032506ec6 Mon Sep 17 00:00:00 2001 From: Thalida Noel Date: Fri, 29 May 2026 16:49:45 -0400 Subject: [PATCH 03/27] =?UTF-8?q?tweak(camera):=20raise=20start=20framing?= =?UTF-8?q?=20elevation=2027=C2=B0=20=E2=86=92=2045=C2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per visual feedback, ~27° still felt too side-on. 45° (FRAMING_DIR_Y = 1.0) is an isometric-style oblique that reads the full city footprint and the skyline together. The height-fit formula adapts via cos(elev - halfFov), so this also adjusts the framing distance to keep the tallest building inside the FOV. Co-Authored-By: Claude Opus 4.7 (1M context) --- app/src/scene/system/cameraRig.ts | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/app/src/scene/system/cameraRig.ts b/app/src/scene/system/cameraRig.ts index 63a25574..d264d63d 100644 --- a/app/src/scene/system/cameraRig.ts +++ b/app/src/scene/system/cameraRig.ts @@ -58,11 +58,11 @@ const TOP_DOWN_ELEVATION_DEG = 80; const TOP_DOWN_PADDING_MULT = 2.8; // y-component of the start framing direction vector (before normalization). -// 0.5 → ~27° camera elevation above the root street's long axis. The +// 1.0 → ~45° camera elevation above the root street's long axis. The // height-fit distance formula in _captureFraming depends on this exact // value, so it is referenced both there and at the dir construction site // instead of being duplicated. -const FRAMING_DIR_Y = 0.5; +const FRAMING_DIR_Y = 1.0; // Headroom above the tallest building's roof when fitting the start // framing. 1.15 = distance is 15% greater than the minimum at which the @@ -220,12 +220,11 @@ export function createCameraRig({ // for Y-oriented; the gem sits at the low end — see // engine.ts:createRootGem) at a low cinematic elevation. This gives // a "looking down the main road into the city" view instead of the - // previous top-down (-1, 1, 1) oblique. FRAMING_DIR_Y (0.5) → ~27° - // elevation; high enough to take in the skyline without the camera - // hugging the horizon, low enough that the horizon-glow band still - // reads behind the buildings. The same constant feeds the heightDist - // formula above. Fallback (no gem) keeps the old high-oblique - // direction for completeness. + // previous top-down (-1, 1, 1) oblique. FRAMING_DIR_Y (1.0) → 45° + // elevation; an isometric-style oblique that reads the full city + // footprint and skyline together. The same constant feeds the + // heightDist formula above. Fallback (no gem) keeps the old + // high-oblique direction for completeness. let dir: THREE.Vector3; if (rootStreet) { dir = From 4cdfbd8baceab02e42c25849deb8f2bfe4aa5faf Mon Sep 17 00:00:00 2001 From: Thalida Noel Date: Fri, 29 May 2026 16:54:43 -0400 Subject: [PATCH 04/27] feat(repoLabel): full 3-axis billboard so the panel pitches with camera MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The label was a yaw-only billboard (Y-locked) — it rotated to face the camera horizontally but stayed vertical. With the start-framing elevation bumped to 45°, the user sees the panel strongly foreshortened from above. Switch to a full lookAt-based billboard: panel +Z points directly at the camera, with world up preserved so the text doesn't roll when the camera orbits sideways. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../scene/components/repoLabel/repoLabel.ts | 32 ++++++++++++------- 1 file changed, 20 insertions(+), 12 deletions(-) diff --git a/app/src/scene/components/repoLabel/repoLabel.ts b/app/src/scene/components/repoLabel/repoLabel.ts index d5a9bd69..0b29e58d 100644 --- a/app/src/scene/components/repoLabel/repoLabel.ts +++ b/app/src/scene/components/repoLabel/repoLabel.ts @@ -1,6 +1,7 @@ // scene/components/repoLabel/repoLabel.ts — Floating holographic // repo-name label. One group at the scene root, holding a vertical -// light beam from the floor + a Y-locked-billboard text panel. +// light beam from the floor + a camera-facing billboard text panel +// (full 3-axis billboard — pitches with camera elevation, not just yaw). // // Lifecycle (matches sky / island): // const label = createRepoLabel(); @@ -79,17 +80,24 @@ export interface RepoLabel { dispose(): void; } -// _faceCameraYLocked rotates `obj` so its +Z faces the camera in the XZ -// plane while staying vertical (no tilt). -function _faceCameraYLocked(obj: THREE.Object3D, camera: THREE.Camera): void { +// _faceCamera rotates `obj` so its +Z front face points at the camera, +// pitching up/down as the camera's elevation changes (a full billboard, +// not just yaw-locked). Local +Y stays aligned with world up so text +// remains upright regardless of azimuth or tilt. +const _LABEL_WORLD_UP = new THREE.Vector3(0, 1, 0); +const _scratchObjPos = new THREE.Vector3(); +const _scratchCamPos = new THREE.Vector3(); +const _scratchMat = new THREE.Matrix4(); +function _faceCamera(obj: THREE.Object3D, camera: THREE.Camera): void { obj.updateMatrixWorld(true); - const objPos = new THREE.Vector3(); - obj.getWorldPosition(objPos); - const camPos = new THREE.Vector3(); - camera.getWorldPosition(camPos); - const dx = camPos.x - objPos.x; - const dz = camPos.z - objPos.z; - obj.rotation.set(0, Math.atan2(dx, dz), 0); + obj.getWorldPosition(_scratchObjPos); + camera.getWorldPosition(_scratchCamPos); + // Matrix4.lookAt(eye, target, up) builds a basis where local +Z points + // from target toward eye — i.e. out the panel's front face toward the + // camera. The world-up arg keeps +Y aligned with world up so text + // doesn't roll when the camera orbits sideways. + _scratchMat.lookAt(_scratchCamPos, _scratchObjPos, _LABEL_WORLD_UP); + obj.quaternion.setFromRotationMatrix(_scratchMat); } export function createRepoLabel(): RepoLabel { @@ -293,7 +301,7 @@ export function createRepoLabel(): RepoLabel { const dtScaled = dtSeconds * cfg.ANIMATION_SPEED; panelMat.uniforms.uTime.value += dtScaled; if (beamMat) beamMat.uniforms.uTime.value += dtScaled; - _faceCameraYLocked(panelMesh, camera); + _faceCamera(panelMesh, camera); // Track the gem's per-frame bob — the renderLoop mutates // gemRef.position.y each frame (sin-wave around its baseY), so the // beam's foot follows the gem live. From ac8aafe43de6328037367de7353c9d3709282c86 Mon Sep 17 00:00:00 2001 From: Thalida Noel Date: Fri, 29 May 2026 18:19:09 -0400 Subject: [PATCH 05/27] feat(favicon): replace diamond with Lucide gem to match site header MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The header icon (appHeader.ts:352) is the Lucide gem rendered via CSS mask with an OKLCH yellow → electric-blue gradient (styles.css:738), while the favicon was a flat 4-sided diamond with an unrelated teal→purple gradient — the two no longer read as the same brand mark. Inline the Lucide gem SVG paths verbatim (no CDN dependency for the favicon) and stroke them with a static teal→violet gradient matching the frame the user referenced. Stroke-only, transparent fill — same visual model as how the header icon renders (mask of the strokes). Co-Authored-By: Claude Opus 4.7 (1M context) --- app/public/favicon.svg | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/app/public/favicon.svg b/app/public/favicon.svg index 68c71da0..2af8b41f 100644 --- a/app/public/favicon.svg +++ b/app/public/favicon.svg @@ -1,10 +1,13 @@ - + - + - - + + + + + From 1ad9c24079b7705c41a3079d25fda2680a3395b8 Mon Sep 17 00:00:00 2001 From: Thalida Noel Date: Fri, 29 May 2026 18:21:07 -0400 Subject: [PATCH 06/27] fix(favicon): use userSpaceOnUse so the equator line renders MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Lucide gem's equator (M2 9h20) has zero geometry height. Per the SVG spec, a linearGradient with the default gradientUnits= "objectBoundingBox" renders as "no paint" on a zero-height element, making the stroke invisible while the two diagonal paths still render fine. Switching to userSpaceOnUse with explicit viewBox-space endpoints (y=3 → y=22) means all three paths sample one gradient spanning the full gem height — the equator picks up its color from the middle of the gradient instead of being dropped. Co-Authored-By: Claude Opus 4.7 (1M context) --- app/public/favicon.svg | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/public/favicon.svg b/app/public/favicon.svg index 2af8b41f..9bd6a19c 100644 --- a/app/public/favicon.svg +++ b/app/public/favicon.svg @@ -1,6 +1,6 @@ - + From 41d27f40972a7c4f67175d9f0d4a05f88d39801a Mon Sep 17 00:00:00 2001 From: Thalida Noel Date: Fri, 29 May 2026 18:55:02 -0400 Subject: [PATCH 07/27] tweak(gem): pastel face palette + white edges MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the neon GEM_FACE_PALETTE with a softer pastel rainbow (each face hue raised in lightness, saturation lowered). EDGE_COLOR moves from violet (#7a7fff) to white so the edges read as crisp separators against the new pastel fills. GEM_GLOW.ANIMATE_COLORS defaults to true so the halo continues cycling through the new palette — the white EDGE_COLOR only affects the halo in the inactive-animation fallback path. Co-Authored-By: Claude Opus 4.7 (1M context) --- app/src/config/components/gem.ts | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/app/src/config/components/gem.ts b/app/src/config/components/gem.ts index a1a4978f..a795650c 100644 --- a/app/src/config/components/gem.ts +++ b/app/src/config/components/gem.ts @@ -45,14 +45,14 @@ export interface GemFacePaletteConfig { } export const GEM_FACE_PALETTE = map({ - FACE_1: '#ff338c', // hot pink (was [1.0, 0.2, 0.55]) - FACE_2: '#26e5ff', // cyan (was [0.15, 0.9, 1.0]) - FACE_3: '#bfff33', // chartreuse (was [0.75, 1.0, 0.2]) - FACE_4: '#9940ff', // violet (was [0.6, 0.25, 1.0]) - FACE_5: '#ff8c19', // orange (was [1.0, 0.55, 0.1]) - FACE_6: '#ff33e5', // magenta (was [1.0, 0.2, 0.9]) - FACE_7: '#26ffbf', // aqua (was [0.15, 1.0, 0.75]) - FACE_8: '#66ff4d', // lime (was [0.4, 1.0, 0.3]) + FACE_1: '#ff99c5', // pastel pink + FACE_2: '#ffc999', // pastel peach + FACE_3: '#fffc99', // pastel yellow + FACE_4: '#a5ff99', // pastel green + FACE_5: '#99fffd', // pastel cyan + FACE_6: '#99d3ff', // pastel sky + FACE_7: '#beb3ff', // pastel lavender + FACE_8: '#f099ff', // pastel orchid }); // ─── Appearance ──────────────────────────────────────────────────────────── @@ -65,7 +65,7 @@ export interface GemAppearanceConfig { } export const GEM_APPEARANCE = map({ - EDGE_COLOR: '#7a7fff', + EDGE_COLOR: '#ffffff', BODY_OPACITY: 0.75, }); From 846ad83041c13ee676f997ff3cb102880fcdecd3 Mon Sep 17 00:00:00 2001 From: Thalida Noel Date: Fri, 29 May 2026 19:04:05 -0400 Subject: [PATCH 08/27] refactor(icons): replace Lucide gem with codecity gem icon everywhere MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the Lucide gem (used in the header reset-view button + the file tree root row) with the new multicolor codecity gem. Single SVG source (app/public/gem.svg) feeds both the favicon and the header. File tree uses a monochrome outline variant (gem-simple.svg) so it follows currentColor and visually matches the other tree icons. Changes: - Rename favicon.svg → gem.svg; update index.html - Add app/public/gem-simple.svg (octahedron outline + cross facets) - Add makeGemIcon({ simple }) helper to widgets/icon.ts - appHeader: makeLucideIcon('gem') → makeGemIcon(); drop the now- unused btn-icon--rainbow class (and the OKLCH animated gradient + @keyframes that fed it) - treePane: makeLucideIcon('gem') → makeGemIcon({ simple: true }) - styles.css: add .gem-icon (background-image, preserves SVG colors); remove .btn-icon--rainbow, @property --gem-angle, @keyframes gem-shimmer; refresh .tree-root-glyph comment Co-Authored-By: Claude Opus 4.7 (1M context) --- app/index.html | 2 +- app/public/favicon.svg | 13 -------- app/public/gem-simple.svg | 5 +++ app/public/gem.svg | 6 ++++ app/src/styles.css | 52 ++++++++++++-------------------- app/src/views/panes/treePane.ts | 7 +++-- app/src/views/shell/appHeader.ts | 6 ++-- app/src/views/widgets/icon.ts | 38 +++++++++++++++++++++-- 8 files changed, 73 insertions(+), 56 deletions(-) delete mode 100644 app/public/favicon.svg create mode 100644 app/public/gem-simple.svg create mode 100644 app/public/gem.svg diff --git a/app/index.html b/app/index.html index d3463096..00c79bdc 100644 --- a/app/index.html +++ b/app/index.html @@ -6,7 +6,7 @@ - + codecity diff --git a/app/public/favicon.svg b/app/public/favicon.svg deleted file mode 100644 index 9bd6a19c..00000000 --- a/app/public/favicon.svg +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - - - - - - - diff --git a/app/public/gem-simple.svg b/app/public/gem-simple.svg new file mode 100644 index 00000000..5a19ee06 --- /dev/null +++ b/app/public/gem-simple.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/app/public/gem.svg b/app/public/gem.svg new file mode 100644 index 00000000..3e6934b2 --- /dev/null +++ b/app/public/gem.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/src/styles.css b/app/src/styles.css index 69d5568c..29481e6b 100644 --- a/app/src/styles.css +++ b/app/src/styles.css @@ -735,35 +735,21 @@ h6 { opacity: 0.7; } -/* .btn-icon--rainbow — paints the inner lucide-icon mask with a soft - pink → yellow → neon chartreuse → purple gradient instead of - currentColor. Pastel pink/yellow/purple anchors with a punchy - yellow-green in the middle for life. */ -.btn-icon--rainbow .lucide-icon { - /* Neon yellow → neon electric blue via OKLCH interpolation, with a - slowly rotating gradient angle so the gem reads as alive without - calling attention to itself. The @property declaration lets the - CSS engine tween the custom property (un-typed custom - props don't interpolate). */ - background-image: linear-gradient( - in oklch var(--gem-angle, 180deg), - oklch(0.951 0.204 107.3) 0%, - /* neon yellow */ oklch(0.531 0.261 265) 100% /* neon electric blue */ - ); - animation: gem-shimmer 12s linear infinite; -} -@property --gem-angle { - syntax: ''; - initial-value: 180deg; - inherits: false; -} -@keyframes gem-shimmer { - from { - --gem-angle: 0deg; - } - to { - --gem-angle: 360deg; - } +/* .gem-icon — the codecity gem rendered as a full-color SVG background + image (not a CSS mask), so the per-face palette colors render directly + instead of being collapsed to currentColor. Same 1em sizing as + .lucide-icon so it slots into header buttons without throwing off + alignment. The .lucide-icon mask path is the simple monochrome variant; + see makeGemIcon() in views/widgets/icon.ts. */ +.gem-icon { + display: inline-block; + width: 1em; + height: 1em; + vertical-align: -0.15em; + background-image: url(/gem.svg); + background-size: contain; + background-repeat: no-repeat; + background-position: center; } /* ── Base ───────────────────────────────────────────────────────────────────── */ @@ -2064,10 +2050,10 @@ canvas { display: none; } -/* Root glyph — Lucide gem rendered via mask-image so it picks up - * currentColor (matches the row's label color; no brand tint). Sized - * to match the 14px Material file/folder icons used on every other row - * so the icon column stays a consistent width. */ +/* Root glyph — codecity gem (simple monochrome variant) rendered via + * mask-image so it picks up currentColor (matches the row's label color; + * no brand tint). Sized to match the 14px Material file/folder icons + * used on every other row so the icon column stays a consistent width. */ .tree-root-glyph { width: 12px; height: 12px; diff --git a/app/src/views/panes/treePane.ts b/app/src/views/panes/treePane.ts index c68f2429..c17ba160 100644 --- a/app/src/views/panes/treePane.ts +++ b/app/src/views/panes/treePane.ts @@ -5,7 +5,7 @@ import { NodeKind } from '@/types'; import type { DirNode, Manifest, TreeNode } from '@/types'; -import { makeLucideIcon } from '@/views/widgets/icon.js'; +import { makeGemIcon, makeLucideIcon } from '@/views/widgets/icon.js'; import { makeFileIcon, makeFolderIcon } from '@/views/widgets/fileIcon.js'; import { buildPaneHeader } from '@/views/shell/paneHeader.js'; @@ -75,10 +75,11 @@ function _buildItem(child: TreeNode, ctx: TreeCtx, isRoot = false): HTMLLIElemen row.appendChild(chevron); // Folder glyph sits between the chevron and the label so the row // reads "[state arrow] [folder icon] [name]". The root row gets the - // brand gem (mask-image, picks up currentColor — no brand colors); + // brand gem in its simple (monochrome outline) variant so it inherits + // the row's currentColor and visually matches the other tree icons; // other folders use the name-based Material Icon Theme glyph. if (isRoot) { - row.appendChild(makeLucideIcon('gem', { class: 'tree-root-glyph' })); + row.appendChild(makeGemIcon({ simple: true, class: 'tree-root-glyph' })); } else { row.appendChild(makeFolderIcon(child as DirNode)); } diff --git a/app/src/views/shell/appHeader.ts b/app/src/views/shell/appHeader.ts index 3eef0f83..7750eed9 100644 --- a/app/src/views/shell/appHeader.ts +++ b/app/src/views/shell/appHeader.ts @@ -7,7 +7,7 @@ // When no path is selected (or only the root), #app-title is empty. import { ASPHALT, BUILDING_PALETTE } from '@/config'; -import { makeLucideIcon } from '../widgets/icon.js'; +import { makeGemIcon, makeLucideIcon } from '../widgets/icon.js'; import { makeExtensionBadge } from '../widgets/badge.js'; import { toHttpsRepoUrl } from '../widgets/displayLabel.js'; import { fitSegments } from '../widgets/pathTruncate.js'; @@ -346,10 +346,10 @@ export function initAppHeader(opts: InitAppHeaderOpts = {}) { if (onResetView) { const btn = document.createElement('button'); btn.type = 'button'; - btn.className = 'btn-icon btn-icon--no-drag btn-icon--rainbow'; + btn.className = 'btn-icon btn-icon--no-drag'; btn.title = 'Reset view (R)'; btn.setAttribute('aria-label', 'Reset view'); - btn.appendChild(makeLucideIcon('gem')); + btn.appendChild(makeGemIcon()); btn.dataset.appHeaderInjected = '1'; btn.addEventListener('click', () => onResetView()); titleEl.parentElement?.prepend(btn); diff --git a/app/src/views/widgets/icon.ts b/app/src/views/widgets/icon.ts index d526a88d..30332015 100644 --- a/app/src/views/widgets/icon.ts +++ b/app/src/views/widgets/icon.ts @@ -1,6 +1,11 @@ -// views/widgets/icon.ts — Tiny helper for inline Lucide icons. The icon is -// painted via a CSS mask so it automatically picks up the parent's -// currentColor. Sizing comes from the parent's font-size (1em × 1em). +// views/widgets/icon.ts — Tiny helpers for inline icons. +// +// makeLucideIcon — generic Lucide icon painted via CSS mask so it picks +// up currentColor (used by every monochrome glyph). +// makeGemIcon — the codecity gem (full multicolor by default; simple +// monochrome variant for inline-with-text use). +// +// Sizing comes from the parent's font-size (1em × 1em). import { LUCIDE_ICON_BASE_URL } from '@/constants'; @@ -24,3 +29,30 @@ export function makeLucideIcon(name: string, opts: IconOpts = {}): HTMLSpanEleme span.style.webkitMaskImage = url; return span; } + +interface GemIconOpts extends IconOpts { + /** Render the monochrome outline variant (mask-image, follows currentColor). + * Use this when the gem appears inline with text (e.g. tree rows). The + * default (false) renders the full multicolor SVG via background-image. */ + simple?: boolean; +} + +/** + * The codecity gem icon. Default = full multicolor (same SVG as the favicon, + * loaded from /gem.svg). Simple variant = monochrome outline, /gem-simple.svg, + * painted with currentColor like any other lucide-icon. + */ +export function makeGemIcon(opts: GemIconOpts = {}): HTMLSpanElement { + const span = document.createElement('span'); + span.setAttribute('aria-hidden', 'true'); + if (opts.title) span.title = opts.title; + if (opts.simple) { + span.className = `lucide-icon${opts.class ? ` ${opts.class}` : ''}`; + const url = 'url(/gem-simple.svg)'; + span.style.maskImage = url; + span.style.webkitMaskImage = url; + } else { + span.className = `gem-icon${opts.class ? ` ${opts.class}` : ''}`; + } + return span; +} From af8699cff660fade82c1c37765d7e2f6eb688984 Mon Sep 17 00:00:00 2001 From: Thalida Noel Date: Fri, 29 May 2026 19:13:31 -0400 Subject: [PATCH 09/27] feat(gem): grayscale-filled simple gem + README h1 mark MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Switch the tree-row gem from a currentColor mask outline to a filled grayscale variant — same octahedron geometry as the multicolor, just neutral fills (light → dark across the four facets) so it reads as the same gem in a quieter visual register. makeGemIcon now uses one class (.gem-icon) for both variants; simple just overrides the background-image URL. README h1 picks up the multicolor gem inline. README h1 inline-html lint warning (MD033) is expected and harmless — this is the standard way to put a logo in a GitHub README heading. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 2 +- app/public/gem-simple.svg | 9 +++++---- app/src/styles.css | 8 ++++---- app/src/views/widgets/icon.ts | 21 +++++++++------------ 4 files changed, 19 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index f35488b6..ca9370f7 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# codecity +# codecity Visualize any codebase as an isometric 3D city. Point it at a git repo and it walks the tree, collects file + git metadata, and renders a city in your browser. Directories become streets, files become buildings. diff --git a/app/public/gem-simple.svg b/app/public/gem-simple.svg index 5a19ee06..02aa4105 100644 --- a/app/public/gem-simple.svg +++ b/app/public/gem-simple.svg @@ -1,5 +1,6 @@ - - - - + + + + + diff --git a/app/src/styles.css b/app/src/styles.css index 29481e6b..aba2afa2 100644 --- a/app/src/styles.css +++ b/app/src/styles.css @@ -2050,10 +2050,10 @@ canvas { display: none; } -/* Root glyph — codecity gem (simple monochrome variant) rendered via - * mask-image so it picks up currentColor (matches the row's label color; - * no brand tint). Sized to match the 14px Material file/folder icons - * used on every other row so the icon column stays a consistent width. */ +/* Root glyph — codecity gem in its grayscale variant. Quieter than the + * full multicolor gem so it doesn't compete with row text. Sized to match + * the 14px Material file/folder icons used on every other row so the icon + * column stays a consistent width. */ .tree-root-glyph { width: 12px; height: 12px; diff --git a/app/src/views/widgets/icon.ts b/app/src/views/widgets/icon.ts index 30332015..8ad7981e 100644 --- a/app/src/views/widgets/icon.ts +++ b/app/src/views/widgets/icon.ts @@ -31,28 +31,25 @@ export function makeLucideIcon(name: string, opts: IconOpts = {}): HTMLSpanEleme } interface GemIconOpts extends IconOpts { - /** Render the monochrome outline variant (mask-image, follows currentColor). - * Use this when the gem appears inline with text (e.g. tree rows). The - * default (false) renders the full multicolor SVG via background-image. */ + /** Render the grayscale-filled variant for quieter contexts (e.g. tree + * rows) where the multicolor palette would compete with row text. Same + * octahedron geometry, just neutral fills instead of palette colors. */ simple?: boolean; } /** - * The codecity gem icon. Default = full multicolor (same SVG as the favicon, - * loaded from /gem.svg). Simple variant = monochrome outline, /gem-simple.svg, - * painted with currentColor like any other lucide-icon. + * The codecity gem icon, painted as a background-image SVG so the per-face + * fills render directly. Default = full multicolor (/gem.svg, same source + * as the favicon). Simple = grayscale variant (/gem-simple.svg) for inline + * use in trees/lists. */ export function makeGemIcon(opts: GemIconOpts = {}): HTMLSpanElement { const span = document.createElement('span'); + span.className = `gem-icon${opts.class ? ` ${opts.class}` : ''}`; span.setAttribute('aria-hidden', 'true'); if (opts.title) span.title = opts.title; if (opts.simple) { - span.className = `lucide-icon${opts.class ? ` ${opts.class}` : ''}`; - const url = 'url(/gem-simple.svg)'; - span.style.maskImage = url; - span.style.webkitMaskImage = url; - } else { - span.className = `gem-icon${opts.class ? ` ${opts.class}` : ''}`; + span.style.backgroundImage = 'url(/gem-simple.svg)'; } return span; } From 39d658a09e8abbce501bf9c3df506e42e0631b98 Mon Sep 17 00:00:00 2001 From: Thalida Noel Date: Fri, 29 May 2026 19:20:33 -0400 Subject: [PATCH 10/27] fix(pick-port): saved ports are sticky; strip legacy keys MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two behavior changes to bin/pick-port.py: 1. Never auto-reallocate a saved port. Previously, if the saved port was occupied (e.g. a stale Docker container still bound to it), pick-port silently picked a new free port and overwrote the saved value. That breaks bookmarked URLs and surprises the user — what they wanted was to free the original port, not move to a new one. Now the saved port is printed unconditionally; if it's busy, `docker compose up` fails at bind time with a clear "port already in use" message, which is the right cue to kill the stale process. 2. Strip unknown keys on write. .local/worktree-ports.json had legacy `vite_port` / `api_port` entries from an earlier naming scheme (renamed to `vite` long ago) that were preserved on every rewrite because the script round-tripped whatever it loaded. Now only keys in the KNOWN_KEYS whitelist ({'vite', 'run'}) get persisted — legacy entries get dropped the next time pick-port runs. Also reject unknown keys on the CLI so a typo (e.g. `just dev` is edited to call `pick-port viet`) fails fast instead of allocating an unwanted entry. Co-Authored-By: Claude Opus 4.7 (1M context) --- bin/pick-port.py | 55 ++++++++++++++++++++++++++++++------------------ 1 file changed, 35 insertions(+), 20 deletions(-) diff --git a/bin/pick-port.py b/bin/pick-port.py index cc1ca5e5..e0080e5c 100755 --- a/bin/pick-port.py +++ b/bin/pick-port.py @@ -4,30 +4,34 @@ Usage: pick-port.py Reads .local/worktree-ports.json (creating it if missing). If already -has a port AND that port is still free on the host, reuses it. Otherwise -picks a fresh free port from the OS. Writes the file back and prints the -chosen port to stdout. +has a port, prints it unconditionally — even if the host port is currently +occupied. The script does NOT silently re-allocate: a saved port is treated +as a hard commitment so bookmarked URLs (and `just dev` ergonomics) stay +stable across container restarts and Docker hangs. If the port is busy +(e.g. a stale container still bound to it), the recipe will fail when it +tries to bind, with a clear "port already in use" message — the right +remediation is to free the port, not to silently move it. -Why bind-test instead of connect-test: docker -p binding fails when ANY -process holds the port (listening or not). bind() with SO_REUSEADDR=0 -matches docker's behavior better than connect_ex(). +If has no saved port, picks a fresh free port from the OS, saves it, +and prints it. This is the only path that allocates. + +Only KNOWN_KEYS are persisted; any other keys present in the file (e.g. +legacy `vite_port`/`api_port` from earlier naming) are dropped on the next +write. This way the file converges to the current schema without manual +cleanup. + +Why bind-test instead of connect-test was used historically: docker -p +binding fails when ANY process holds the port. We no longer need this +since we don't auto-reallocate, but the helper is kept in case future +recipes want it. """ import json import pathlib import socket import sys - -def port_free(port: int) -> bool: - """Return True if we can bind to the port on all interfaces.""" - s = socket.socket() - try: - s.bind(("", port)) - except OSError: - return False - finally: - s.close() - return True +# Keys this script is willing to manage. Anything else gets dropped on write. +KNOWN_KEYS = {"vite", "run"} def pick_free_port() -> int: @@ -43,15 +47,26 @@ def main() -> int: print("usage: pick-port.py ", file=sys.stderr) return 2 key = sys.argv[1] + if key not in KNOWN_KEYS: + print( + f"error: unknown key {key!r}; expected one of {sorted(KNOWN_KEYS)}", + file=sys.stderr, + ) + return 2 p = pathlib.Path(".local/worktree-ports.json") p.parent.mkdir(exist_ok=True) - data = json.loads(p.read_text()) if p.exists() else {} + raw = json.loads(p.read_text()) if p.exists() else {} + # Drop legacy / unknown keys on read so we never persist them again. + data = {k: v for k, v in raw.items() if k in KNOWN_KEYS} - existing = data.get(key) - if existing is None or not port_free(int(existing)): + if key not in data: data[key] = pick_free_port() p.write_text(json.dumps(data)) + elif data != raw: + # Schema converged (legacy keys removed) — write the cleaned file + # even though the chosen port itself didn't change. + p.write_text(json.dumps(data)) print(data[key]) return 0 From b007d17d67741293d3daebeeeb3f74bbccef5462 Mon Sep 17 00:00:00 2001 From: Thalida Noel Date: Fri, 29 May 2026 19:23:15 -0400 Subject: [PATCH 11/27] =?UTF-8?q?tweak(gem):=202-color=20grayscale=20tree?= =?UTF-8?q?=20gem,=2012px=20=E2=86=92=2014px?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Simplify the tree-row gem from 4 distinct grays to a 2-tone split (light on top, dark on bottom) so it reads as a single shaded gem silhouette rather than four busy facets at favicon-scale. Bump .tree-root-glyph from 12px to 14px so it matches the existing 14px Material file/folder icons — the comment already claimed this size; the rule just hadn't followed. Co-Authored-By: Claude Opus 4.7 (1M context) --- app/public/gem-simple.svg | 8 ++++---- app/src/styles.css | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/app/public/gem-simple.svg b/app/public/gem-simple.svg index 02aa4105..fcfc196e 100644 --- a/app/public/gem-simple.svg +++ b/app/public/gem-simple.svg @@ -1,6 +1,6 @@ - - - - + + + + diff --git a/app/src/styles.css b/app/src/styles.css index aba2afa2..7723ea96 100644 --- a/app/src/styles.css +++ b/app/src/styles.css @@ -2055,8 +2055,8 @@ canvas { * the 14px Material file/folder icons used on every other row so the icon * column stays a consistent width. */ .tree-root-glyph { - width: 12px; - height: 12px; + width: 14px; + height: 14px; margin-right: var(--cc-space-2); vertical-align: middle; } From 6c67619f8f9b3fe11cd857ac98fe8b85d8dfa968 Mon Sep 17 00:00:00 2001 From: Thalida Noel Date: Fri, 29 May 2026 19:25:48 -0400 Subject: [PATCH 12/27] ux: loading-modal title shows current step + tree gem checkerboard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Loading overlay: Drop the redundant project name from the title. setPendingLabel already renders the project name as a header directly above the spinner, so "Loading …" was the same string repeated. Title now shows the current step in sentence form ("Resolving source…", "Cloning…", …) and updates with each setStep call. The branch parenthetical is preserved on the initial render so the user can confirm which branch is being fetched. Tree-row gem: Switch from light-top / dark-bottom to a checkerboard of the same two grays (diagonals share a tone). Reads more like a 2D mosaic facet pattern than a 3D-shaded gem; better contrast at small size. Co-Authored-By: Claude Opus 4.7 (1M context) --- app/public/gem-simple.svg | 4 +-- app/src/views/source/loadingOverlay.ts | 35 ++++++++++++++++---------- 2 files changed, 24 insertions(+), 15 deletions(-) diff --git a/app/public/gem-simple.svg b/app/public/gem-simple.svg index fcfc196e..fd53e851 100644 --- a/app/public/gem-simple.svg +++ b/app/public/gem-simple.svg @@ -1,6 +1,6 @@ - - + + diff --git a/app/src/views/source/loadingOverlay.ts b/app/src/views/source/loadingOverlay.ts index ae5948b3..4a953963 100644 --- a/app/src/views/source/loadingOverlay.ts +++ b/app/src/views/source/loadingOverlay.ts @@ -142,32 +142,41 @@ export function createLoadingOverlay(): LoadingOverlay { show({ kind, label, branch }: LoadingOverlayShowOpts) { _buildDOM(); - // Set title. - if (_titleEl) { - _titleEl.textContent = branch - ? `Loading ${label} (branch ${branch})…` - : `Loading ${label}…`; - } - // Hide git-only steps for local sources. + const initialStep: LoadingStep = kind === 'local' ? 'scanning' : 'resolving'; if (kind === 'local') { const resolvingEl = _stepEls['resolving']; const cloningEl = _stepEls['cloning']; if (resolvingEl) resolvingEl.style.display = 'none'; if (cloningEl) cloningEl.style.display = 'none'; - _applyStep('scanning'); - } else { - // Git sources: start at 'resolving'. The server emits 'cloning' - // immediately after receiving the request, so the next setStep - // call from main.ts moves us forward within milliseconds. - _applyStep('resolving'); } + _applyStep(initialStep); + + // Title shows the current step in sentence form ("Resolving source…", + // "Cloning…"), updated by setStep below. Previously it repeated the + // project name ("Loading