Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
22fde5b
feat: publish the build version on /api/config
thalida Aug 8, 2026
8c2a0a0
fix: don't prettier-format manifest.generated.ts
thalida Aug 8, 2026
3d75e12
feat: merge the reset gem into the project chip as a switcher
thalida Aug 8, 2026
dda9468
fix: reword stale .btn-chip comment for the flex-row header
thalida Aug 8, 2026
ec5037a
feat: add a version, repo, and creator credit line to the footer
thalida Aug 8, 2026
133d564
Fix code review findings on the header/footer simplification
thalida Aug 8, 2026
b2aaa5a
Brighten the footer credit links so they read as links
thalida Aug 8, 2026
4602f6d
Correct the gem-sizing comment's specificity claim
thalida Aug 8, 2026
b7ccf93
Give the gem its own breathing room in the project chip
thalida Aug 9, 2026
4e86eca
Rework the landing layout for small screens
thalida Aug 9, 2026
207523b
Give every over-the-scene panel the same glass
thalida Aug 9, 2026
74f5697
Settle the glass fill at 82%
thalida Aug 9, 2026
34bc75f
Order the stacked-layout overrides after the rules they override
thalida Aug 9, 2026
d176311
Give each corner of the chrome one job
thalida Aug 9, 2026
b1da541
Rebuild the hover tooltip as three lines that cannot overflow
thalida Aug 9, 2026
7933181
Frost the tooltip, and say "directory" before counting one
thalida Aug 9, 2026
4884829
Let glass borders take their cast from what is behind them
thalida Aug 9, 2026
415bd54
Tint every border and track with the chosen surface
thalida Aug 9, 2026
da8f2c0
Settle the glass fill at 90%
thalida Aug 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions api/models/responses.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ class HealthResponse(BaseModel):
class ConfigResponse(BaseModel):
allowLocalRepos: bool
maxBatchPaths: int
version: str


class CommitDetailResponse(BaseModel):
Expand Down
2 changes: 2 additions & 0 deletions api/routers/meta.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

from fastapi import APIRouter

from api import __version__
from api.config import MAX_BATCH_PATHS, local_repos_allowed
from api.models.responses import ConfigResponse, HealthResponse

Expand All @@ -22,4 +23,5 @@ def config() -> ConfigResponse:
return ConfigResponse(
allowLocalRepos=local_repos_allowed(),
maxBatchPaths=MAX_BATCH_PATHS,
version=__version__,
)
6 changes: 4 additions & 2 deletions api/tests/test_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -169,8 +169,10 @@ def test_health_and_config(self) -> None:

self.assertEqual(HealthResponse(ok=True).model_dump(), {"ok": True})
self.assertEqual(
ConfigResponse(allowLocalRepos=False, maxBatchPaths=64).model_dump(),
{"allowLocalRepos": False, "maxBatchPaths": 64},
ConfigResponse(
allowLocalRepos=False, maxBatchPaths=64, version="1.2.3"
).model_dump(),
{"allowLocalRepos": False, "maxBatchPaths": 64, "version": "1.2.3"},
)

def test_sse_event_serialization(self) -> None:
Expand Down
16 changes: 16 additions & 0 deletions api/tests/test_server_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,18 +20,24 @@ def client(tmp_path: Path) -> TestClient:


def test_config_enabled(client: TestClient, monkeypatch) -> None:
from api import __version__

monkeypatch.setenv("CODECITY_ALLOW_LOCAL_REPOS", "1")
assert client.get("/api/config").json() == {
"allowLocalRepos": True,
"maxBatchPaths": MAX_BATCH_PATHS,
"version": __version__,
}


def test_config_disabled(client: TestClient, monkeypatch) -> None:
from api import __version__

monkeypatch.delenv("CODECITY_ALLOW_LOCAL_REPOS", raising=False)
assert client.get("/api/config").json() == {
"allowLocalRepos": False,
"maxBatchPaths": MAX_BATCH_PATHS,
"version": __version__,
}


Expand All @@ -41,3 +47,13 @@ def test_config_publishes_the_cap_the_batch_routes_enforce() -> None:
from api.routers import file as file_router

assert file_router.MAX_BATCH_PATHS == MAX_BATCH_PATHS


def test_config_reports_the_running_package_version(client: TestClient) -> None:
"""The footer shows which build is running, so /api/config carries the
package version rather than the client guessing from a bundled constant."""
from api import __version__

body = client.get("/api/config").json()
assert body["version"] == __version__
assert body["version"]
8 changes: 7 additions & 1 deletion api/tests/test_server_health.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,14 +34,20 @@ def test_config_default_disabled(
# The conftest sets CODECITY_ALLOW_LOCAL_REPOS=1 session-wide for
# tests that exercise local scan paths. Override it here to verify the
# default-disabled state that the real endpoint exposes.
from api import __version__

monkeypatch.delenv("CODECITY_ALLOW_LOCAL_REPOS", raising=False)
static = tmp_path / "static"
static.mkdir()
(static / "index.html").write_text("<html/>")
app = create_app(static_dir=static)
r = TestClient(app).get("/api/config")
assert r.status_code == 200
assert r.json() == {"allowLocalRepos": False, "maxBatchPaths": MAX_BATCH_PATHS}
assert r.json() == {
"allowLocalRepos": False,
"maxBatchPaths": MAX_BATCH_PATHS,
"version": __version__,
}


def test_root_serves_index(client: TestClient) -> None:
Expand Down
5 changes: 4 additions & 1 deletion app/src/api/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,12 @@ import type { components } from '@/types/manifest.generated';
export type ServerConfig = components['schemas']['ConfigResponse'];

// Pre-boot defaults. maxBatchPaths guesses low: too high silently truncates a
// batch's tail, too low only costs an extra request.
// batch's tail, too low only costs an extra request. `version` matches the
// backend's own metadata-lookup fallback.
export const DEFAULT_SERVER_CONFIG: ServerConfig = {
allowLocalRepos: false,
maxBatchPaths: 16,
version: '0.0.0+unknown',
};

let _cached: Promise<ServerConfig> | null = null;
Expand All @@ -38,6 +40,7 @@ export async function fetchServerConfig(): Promise<ServerConfig> {
...(typeof body.maxBatchPaths === 'number' && body.maxBatchPaths > 0
? { maxBatchPaths: body.maxBatchPaths }
: {}),
...(typeof body.version === 'string' && body.version ? { version: body.version } : {}),
};
} catch (_) {
return DEFAULT_SERVER_CONFIG;
Expand Down
10 changes: 5 additions & 5 deletions app/src/city/interaction/inputHandlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import { MODAL_OPEN } from '@/state/stores/ui';
import { NodeKind } from '@/types';
import { scrubbedStatsFor } from '@/state/stores/presentPaths';
import type { PickTarget } from '@/types';
import { formatHoverTooltip, isDeletedTarget } from './tooltipText';
import { hoverTooltipContent, type TooltipContent } from './tooltipText';
import type { createPicker } from './picker';
import type { createCameraRig } from '../render/cameraRig';
import type { CityState } from '../state';
Expand All @@ -41,7 +41,7 @@ export function createInputHandlers({
rig: ReturnType<typeof createCameraRig>;
renderer: THREE.WebGLRenderer;
cityState: CityState;
showTooltip: (text: string, x: number, y: number, deleted?: boolean) => void;
showTooltip: (content: TooltipContent, x: number, y: number) => void;
hideTooltip: () => void;
onResize: () => void;
/** Reset-view action triggered by R / gem-click. Does NOT rebuild the
Expand Down Expand Up @@ -112,9 +112,9 @@ export function createInputHandlers({
newHover?.kind === NodeKind.File && newHover.file?.path != null
? (scrubbedStatsFor(newHover.file.path)?.lines ?? null)
: null;
const tooltipText = formatHoverTooltip(newHover, rootName, scrubLines);
const tooltipText = hoverTooltipContent(newHover, rootName, scrubLines);
if (tooltipText) {
showTooltip(tooltipText, e.clientX, e.clientY, isDeletedTarget(newHover));
showTooltip(tooltipText, e.clientX, e.clientY);
canvas.style.cursor = 'pointer';
} else {
hideTooltip();
Expand Down Expand Up @@ -148,7 +148,7 @@ export function createInputHandlers({
}
if (hit.object.userData.type === NodeKind.Gem) {
picker.setSelection(null);
// Gem click = reset view, same as the R key and the header gem button.
// Gem click = reset view, same as the R key.
onResetView();
return;
}
Expand Down
56 changes: 40 additions & 16 deletions app/src/city/interaction/tooltip.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,16 @@
// city/interaction/tooltip.ts — Tiny floating label that follows the cursor
// on hover. Shown when the user is hovering a building/street; hidden
// otherwise. Inspired by Cities: Skylines / SimCity — every interactive
// object has a brief name label so the city feels alive without forcing
// a sidebar open.
// city/interaction/tooltip.ts — Tiny floating card that follows the cursor on
// hover. Shown when the user is hovering a building/street/commit; hidden
// otherwise. Inspired by Cities: Skylines / SimCity — every interactive object
// has a brief label so the city feels alive without forcing a sidebar open.
//
// The imperative showTooltip/moveTooltip/hideTooltip API is kept for the
// still-vanilla scene code (picker, inputHandlers) that calls it directly.
// A Preact Tooltip component is not meaningful here because the tooltip is
// driven by Three.js pointer events rather than React-tree state — the
// imperative API IS the right interface for this component.

import type { TooltipContent } from './tooltipText';

// Tooltip placement — fixed, not user-tunable.
const TOOLTIP_OFFSET_PX = 14;
const TOOLTIP_VIEWPORT_MARGIN_PX = 4;
Expand All @@ -21,31 +22,46 @@ function _ensure(): HTMLElement {
if (_el && _el.isConnected) return _el;
_el = document.createElement('div');
_el.id = 'hover-tooltip';
_el.className = 'card-tooltip';
_el.className = 'card-tooltip surface-glass';
_el.style.display = 'none';
document.body.appendChild(_el);
return _el;
}

// showTooltip(text, x, y, deleted?) — show with the given text, positioned near
// cursor (with auto-clamp to viewport so it doesn't bleed off-screen). When
// `deleted`, lead with a red "deleted" badge so a ruin reads at a glance.
export function showTooltip(text: string, x: number, y: number, deleted = false): void {
function _line(cls: string, text: string): HTMLElement {
const line = document.createElement('div');
line.className = cls;
line.textContent = text;
return line;
}

/**
* Show the tooltip near the cursor, clamped so it can't leave the viewport.
* Three stacked lines: identity, location, stats.
*/
export function showTooltip(content: TooltipContent, x: number, y: number): void {
const el = _ensure();
if (deleted) {
el.textContent = '';
el.textContent = '';

const title = _line('tooltip-title', content.title);
if (content.deleted) {
const badge = document.createElement('span');
badge.className = 'tooltip-deleted';
badge.textContent = 'deleted';
el.append(badge, document.createTextNode(` · ${text}`));
} else {
el.textContent = text;
title.prepend(badge, document.createTextNode(' '));
}
el.append(title);

if (content.path) el.append(_line('tooltip-path', content.path));
if (content.stats.length > 0) {
el.append(_line('tooltip-stats', content.stats.join(' · ')));
}

el.style.display = 'block';
moveTooltip(x, y);
}

// moveTooltip(x, y) — reposition without changing text. Cheap; safe to call
// moveTooltip(x, y) — reposition without changing content. Cheap; safe to call
// on every pointermove.
export function moveTooltip(x: number, y: number): void {
if (!_el) return;
Expand All @@ -55,10 +71,18 @@ export function moveTooltip(x: number, y: number): void {
const h = _el.offsetHeight;
const vw = window.innerWidth;
const vh = window.innerHeight;

// Prefer below-right of the cursor, flipping to the other side when that
// would overflow. The final clamp matters independently: a card wider than
// the space on either side overflows whichever way it is flipped, and
// flipping alone would push it off the opposite edge.
let px = x + OFFSET;
let py = y + OFFSET;
if (px + w + MARGIN > vw) px = x - OFFSET - w;
if (py + h + MARGIN > vh) py = y - OFFSET - h;
px = Math.min(Math.max(px, MARGIN), Math.max(MARGIN, vw - w - MARGIN));
py = Math.min(Math.max(py, MARGIN), Math.max(MARGIN, vh - h - MARGIN));

_el.style.left = `${px}px`;
_el.style.top = `${py}px`;
}
Expand Down
Loading