From 53eebc199de5bfcb8948355a13b2f1abe9ebad5c Mon Sep 17 00:00:00 2001 From: Thalida Noel Date: Mon, 15 Jun 2026 23:39:30 -0400 Subject: [PATCH 01/65] feat(picker): add selectCommit for commit/tree landmark selection --- app/src/city/interaction/picker.ts | 17 +++++++++++++++++ app/src/state/stores/scene.ts | 5 +++++ app/tests/state/sceneCommands.test.ts | 11 +++++++++++ 3 files changed, 33 insertions(+) create mode 100644 app/tests/state/sceneCommands.test.ts diff --git a/app/src/city/interaction/picker.ts b/app/src/city/interaction/picker.ts index 4e92008b..faa22342 100644 --- a/app/src/city/interaction/picker.ts +++ b/app/src/city/interaction/picker.ts @@ -291,6 +291,22 @@ export function createPicker({ if (t) setSelection(t); } + // Resolve a commit by sha to its live tree target and select it. The + // symmetry partner of selectByPath for commit/tree landmarks (almanac + // rows, future commit-list clicks). No-op if trees aren't attached yet + // or the sha isn't found. + function selectByCommit(sha: string): void { + const hit = world.getTrees()?.findTreeBySha(sha) ?? null; + if (hit) { + setSelection({ + kind: NodeKind.Commit, + mesh: hit.mesh, + instanceId: hit.instanceId, + commit: hit.commit, + }); + } + } + // Resolve a path and set it as the hover target (tree-row hover → city // highlight). No-op if the path doesn't match anything. function hoverByPath(path: string): void { @@ -407,6 +423,7 @@ export function createPicker({ setSelection, clearSelection, selectByPath, + selectByCommit, hoverByPath, targetForPath, pickAt, diff --git a/app/src/state/stores/scene.ts b/app/src/state/stores/scene.ts index 4c5222f0..6314483c 100644 --- a/app/src/state/stores/scene.ts +++ b/app/src/state/stores/scene.ts @@ -20,6 +20,11 @@ export function selectPath(path: string): void { SCENE_HANDLE.peek()?.picker.selectByPath(path); } +/** Select a commit's tree by sha (almanac landmark clicks). */ +export function selectCommit(sha: string): void { + SCENE_HANDLE.peek()?.picker.selectByCommit(sha); +} + /** Hover-highlight the node at `path` (tree-row hover → city highlight). */ export function hoverPath(path: string): void { SCENE_HANDLE.peek()?.picker.hoverByPath(path); diff --git a/app/tests/state/sceneCommands.test.ts b/app/tests/state/sceneCommands.test.ts new file mode 100644 index 00000000..6a81dd9a --- /dev/null +++ b/app/tests/state/sceneCommands.test.ts @@ -0,0 +1,11 @@ +import { describe, it, expect } from 'vitest'; +import { selectCommit, focusCommit } from '@/state/stores/scene'; + +describe('scene commit commands', () => { + it('selectCommit is a no-op (no throw) before the scene boots', () => { + expect(() => selectCommit('deadbeef')).not.toThrow(); + }); + it('focusCommit is a no-op (no throw) before the scene boots', () => { + expect(() => focusCommit('deadbeef')).not.toThrow(); + }); +}); From 3cd351dae3c6f15ab252c4146745e09a01b17529 Mon Sep 17 00:00:00 2001 From: Thalida Noel Date: Mon, 15 Jun 2026 23:43:18 -0400 Subject: [PATCH 02/65] refactor(picker): tighten selectByCommit comment + list it in contract --- app/src/city/interaction/picker.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/app/src/city/interaction/picker.ts b/app/src/city/interaction/picker.ts index faa22342..78d06b98 100644 --- a/app/src/city/interaction/picker.ts +++ b/app/src/city/interaction/picker.ts @@ -12,6 +12,7 @@ // picker.setHover(target) // updates hover signal // picker.setSelection(target) // updates selection + derived selectionKey signals // picker.selectByPath(path) // tree clicks, breadcrumb segment clicks +// picker.selectByCommit(sha) // commit/tree landmark selection // picker.pickAt(x, y) // raycast against living meshes; returns null | hit // picker.interpretHit(hit) // null | { kind, mesh|sidewalk, file|street|dir } // picker.dispose() @@ -291,10 +292,8 @@ export function createPicker({ if (t) setSelection(t); } - // Resolve a commit by sha to its live tree target and select it. The - // symmetry partner of selectByPath for commit/tree landmarks (almanac - // rows, future commit-list clicks). No-op if trees aren't attached yet - // or the sha isn't found. + // Resolve a commit sha to its live tree target and select it. No-op if + // trees aren't attached yet or the sha isn't found. function selectByCommit(sha: string): void { const hit = world.getTrees()?.findTreeBySha(sha) ?? null; if (hit) { From 93c4559798533ba41743c2197621e1f58a3ce000 Mon Sep 17 00:00:00 2001 From: Thalida Noel Date: Mon, 15 Jun 2026 23:45:59 -0400 Subject: [PATCH 03/65] feat(almanac): compute overview + building superlatives Pure computeAlmanac(manifest) function with TDD: overview totals/languages from root DirNode fields, 8 building superlatives (tallest/shortest/widest/ narrowest/oldest/newest/brightest/most-faded) each with a LandmarkRef for camera fly-to. 10/10 tests pass, tsc clean. Co-Authored-By: Claude Sonnet 4.6 --- app/src/views/InfoPane/almanac.ts | 155 +++++++++++++++++++++++ app/tests/views/InfoPane/almanac.test.ts | 108 ++++++++++++++++ 2 files changed, 263 insertions(+) create mode 100644 app/src/views/InfoPane/almanac.ts create mode 100644 app/tests/views/InfoPane/almanac.test.ts diff --git a/app/src/views/InfoPane/almanac.ts b/app/src/views/InfoPane/almanac.ts new file mode 100644 index 00000000..a3133bb2 --- /dev/null +++ b/app/src/views/InfoPane/almanac.ts @@ -0,0 +1,155 @@ +// views/InfoPane/almanac.ts — pure derivation of the "World Almanac": +// repo superlatives (oldest/tallest building, ...) computed from data already +// loaded client-side. No signals, no DOM — WorldPane renders the result. +// Landmark facts carry the key needed to fly the camera there. + +import { NodeKind } from '@/types'; +import type { Manifest, DirNode, FileNode, RepoInfo, TreeNode } from '@/types'; +import { formatShortDate } from '@/utils/dates'; + +export interface LandmarkRef { + kind: 'file' | 'dir' | 'commit'; + /** file/dir → path; commit → sha. */ + id: string; +} + +export interface AlmanacFact { + label: string; + value: string; + /** Present → the row flies the camera to this landmark on click. */ + landmark?: LandmarkRef; +} + +export type AlmanacSectionKey = 'buildings' | 'streets' | 'forest' | 'fireflies'; + +export interface AlmanacSection { + key: AlmanacSectionKey; + title: string; + facts: AlmanacFact[]; +} + +export interface LanguageStat { + ext: string; + count: number; +} + +export interface AlmanacOverview { + name: string; + founded: string | null; + totals: { files: number; dirs: number; commits: number; authors: number }; + repo: RepoInfo; + languages: LanguageStat[]; +} + +export interface Almanac { + overview: AlmanacOverview; + sections: AlmanacSection[]; +} + +const MAX_LANGUAGES = 6; + +function isManifest(m: unknown): m is Manifest { + return !!m && typeof m === 'object' && 'tree' in (m as object) && !!(m as Manifest).tree; +} + +function* walk(node: TreeNode): Generator { + yield node; + if (node.type === NodeKind.Directory) { + for (const child of node.children) yield* walk(child); + } +} + +function distinctAuthors(commits: Manifest['commits']): number { + const set = new Set(); + for (const c of commits) for (const a of c.authors) set.add(a); + return set.size; +} + +function fmt(n: number): string { + return n.toLocaleString('en-US'); +} + +function formatBytes(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + const units = ['KB', 'MB', 'GB']; + let v = bytes / 1024; + let i = 0; + while (v >= 1024 && i < units.length - 1) { + v /= 1024; + i++; + } + return `${v.toFixed(v < 10 ? 1 : 0)} ${units[i]}`; +} + +/** ISO date string → epoch ms; NaN for missing/unparseable (never wins a max). */ +function dateMs(iso: string | undefined): number { + if (!iso) return NaN; + const t = Date.parse(iso); + return Number.isNaN(t) ? NaN : t; +} + +/** Pick the file maximizing `score`; ties resolve to the first seen. NaN scores + * never win, so files with missing dates are simply skipped as candidates. */ +function pickFile(files: FileNode[], score: (f: FileNode) => number): FileNode | null { + let best: FileNode | null = null; + let bestScore = -Infinity; + for (const f of files) { + const s = score(f); + if (s > bestScore) { + bestScore = s; + best = f; + } + } + return best; +} + +function fileFact(label: string, file: FileNode | null, detail: string): AlmanacFact | null { + if (!file) return null; + return { label, value: `${file.path} · ${detail}`, landmark: { kind: 'file', id: file.path } }; +} + +function buildOverview(m: Manifest): AlmanacOverview { + const root = m.tree; + return { + name: m.display_root || root.name || 'this project', + founded: m.dateRanges.createdMin ? formatShortDate(m.dateRanges.createdMin) : null, + totals: { + files: root.descendants_file_count, + dirs: root.descendants_dir_count, + commits: m.commits.length, + authors: distinctAuthors(m.commits), + }, + repo: m.repo, + languages: root.descendants_ext_breakdown + .slice(0, MAX_LANGUAGES) + .map((e) => ({ ext: e.ext, count: e.count })), + }; +} + +function buildingsSection(files: FileNode[]): AlmanacSection { + const tallest = pickFile(files, (f) => f.lines); + const shortest = pickFile(files, (f) => -f.lines); + const widest = pickFile(files, (f) => f.size); + const narrowest = pickFile(files, (f) => -f.size); + const facts = [ + fileFact('Oldest building', pickFile(files, (f) => -dateMs(f.created)), 'first raised'), + fileFact('Newest building', pickFile(files, (f) => dateMs(f.created)), 'most recently raised'), + fileFact('Tallest building', tallest, tallest ? `${fmt(tallest.lines)} lines` : ''), + fileFact('Shortest building', shortest, shortest ? `${fmt(shortest.lines)} lines` : ''), + fileFact('Widest building', widest, widest ? formatBytes(widest.size) : ''), + fileFact('Narrowest building', narrowest, narrowest ? formatBytes(narrowest.size) : ''), + fileFact('Brightest building', pickFile(files, (f) => dateMs(f.modified)), 'freshly touched'), + fileFact('Most faded building', pickFile(files, (f) => -dateMs(f.modified)), 'long untouched'), + ]; + return { key: 'buildings', title: 'Buildings', facts: facts.filter((f): f is AlmanacFact => f !== null) }; +} + +export function computeAlmanac(m: Manifest | DirNode | null | undefined): Almanac | null { + if (!isManifest(m)) return null; + const files: FileNode[] = []; + for (const node of walk(m.tree)) { + if (node.type === NodeKind.File) files.push(node); + } + if (files.length === 0) return null; + return { overview: buildOverview(m), sections: [buildingsSection(files)] }; +} diff --git a/app/tests/views/InfoPane/almanac.test.ts b/app/tests/views/InfoPane/almanac.test.ts new file mode 100644 index 00000000..f6656d03 --- /dev/null +++ b/app/tests/views/InfoPane/almanac.test.ts @@ -0,0 +1,108 @@ +import { describe, it, expect } from 'vitest'; +import { computeAlmanac } from '@/views/InfoPane/almanac'; +import { NodeKind } from '@/types'; +import type { Manifest, FileNode, DirNode } from '@/types'; + +function file(partial: Partial & { name: string; path: string }): FileNode { + return { + type: NodeKind.File, + fullPath: `/repo/${partial.path}`, + extension: '.ts', + size: 100, + lines: 10, + binary: false, + created: '2020-01-01T00:00:00Z', + modified: '2020-01-01T00:00:00Z', + ...partial, + }; +} + +function dir(name: string, path: string, children: (FileNode | DirNode)[]): DirNode { + const files = children.filter((c) => c.type === NodeKind.File).length; + const dirs = children.filter((c) => c.type === NodeKind.Directory).length; + return { + name, + type: NodeKind.Directory, + path, + fullPath: `/repo/${path}`, + children, + children_count: children.length, + children_file_count: files, + children_dir_count: dirs, + descendants_count: children.length, + descendants_file_count: files, + descendants_dir_count: dirs, + descendants_size: 0, + descendants_ext_breakdown: [{ ext: '.ts', count: files, size: 0 }], + }; +} + +function manifest(tree: DirNode, overrides: Partial = {}): Manifest { + return { + root: '/repo', + scanned_at: '2024-01-01T00:00:00Z', + signature: 's', + tree_signature: 't', + tree, + repo: { branch: 'main', remote_url: null, head_sha: null, head_subject: null, dirty: false }, + commits: [], + busyness: { avg: 1, busy: 2 }, + dateRanges: { + createdMin: '2020-01-01T00:00:00Z', + createdMax: '2023-01-01T00:00:00Z', + modifiedMin: '2020-01-01T00:00:00Z', + modifiedMax: '2023-01-01T00:00:00Z', + }, + ...overrides, + }; +} + +describe('computeAlmanac — overview + buildings', () => { + const tree = dir('repo', '', [ + file({ name: 'old.ts', path: 'old.ts', lines: 5, size: 50, created: '2020-01-01T00:00:00Z', modified: '2021-06-01T00:00:00Z' }), + file({ name: 'tall.ts', path: 'tall.ts', lines: 999, size: 80, created: '2021-01-01T00:00:00Z', modified: '2020-02-01T00:00:00Z' }), + file({ name: 'new.ts', path: 'new.ts', lines: 10, size: 4000, created: '2023-01-01T00:00:00Z', modified: '2023-01-01T00:00:00Z' }), + ]); + const a = computeAlmanac(manifest(tree)); + + it('returns null for null manifest', () => { + expect(computeAlmanac(null)).toBeNull(); + }); + it('overview totals come from the root node', () => { + expect(a!.overview.totals.files).toBe(3); + expect(a!.overview.totals.dirs).toBe(0); + }); + it('overview name + branch', () => { + expect(a!.overview.name).toBe('repo'); + expect(a!.overview.repo.branch).toBe('main'); + }); + it('languages come from root ext breakdown', () => { + expect(a!.overview.languages[0]).toEqual({ ext: '.ts', count: 3 }); + }); + + function fact(key: string, label: string) { + const section = a!.sections.find((s) => s.key === key)!; + return section.facts.find((f) => f.label === label)!; + } + + it('tallest building = most lines, clickable to its file', () => { + const f = fact('buildings', 'Tallest building'); + expect(f.value).toContain('tall.ts'); + expect(f.landmark).toEqual({ kind: 'file', id: 'tall.ts' }); + }); + it('oldest building = earliest created', () => { + expect(fact('buildings', 'Oldest building').landmark).toEqual({ kind: 'file', id: 'old.ts' }); + }); + it('newest building = latest created', () => { + expect(fact('buildings', 'Newest building').landmark).toEqual({ kind: 'file', id: 'new.ts' }); + }); + it('widest building = largest bytes', () => { + expect(fact('buildings', 'Widest building').landmark).toEqual({ kind: 'file', id: 'new.ts' }); + }); + it('brightest building = most recently modified', () => { + expect(fact('buildings', 'Brightest building').landmark).toEqual({ kind: 'file', id: 'new.ts' }); + }); + it('most faded building = longest since modified', () => { + expect(fact('buildings', 'Most faded building').landmark).toEqual({ kind: 'file', id: 'tall.ts' }); + }); +}); From d7e002e35c463d65a77b2d501b2363f5937d10f8 Mon Sep 17 00:00:00 2001 From: Thalida Noel Date: Mon, 15 Jun 2026 23:51:30 -0400 Subject: [PATCH 04/65] refactor(almanac): reuse shared formatBytes, narrow types, tighten naming Co-Authored-By: Claude Sonnet 4.6 --- app/src/views/InfoPane/almanac.ts | 25 +++++++----------------- app/tests/views/InfoPane/almanac.test.ts | 6 ++++++ 2 files changed, 13 insertions(+), 18 deletions(-) diff --git a/app/src/views/InfoPane/almanac.ts b/app/src/views/InfoPane/almanac.ts index a3133bb2..9ab51028 100644 --- a/app/src/views/InfoPane/almanac.ts +++ b/app/src/views/InfoPane/almanac.ts @@ -1,11 +1,12 @@ // views/InfoPane/almanac.ts — pure derivation of the "World Almanac": // repo superlatives (oldest/tallest building, ...) computed from data already -// loaded client-side. No signals, no DOM — WorldPane renders the result. +// loaded client-side. No signals, no DOM. // Landmark facts carry the key needed to fly the camera there. import { NodeKind } from '@/types'; import type { Manifest, DirNode, FileNode, RepoInfo, TreeNode } from '@/types'; import { formatShortDate } from '@/utils/dates'; +import { formatBytes } from '@/utils/bytes'; export interface LandmarkRef { kind: 'file' | 'dir' | 'commit'; @@ -20,7 +21,7 @@ export interface AlmanacFact { landmark?: LandmarkRef; } -export type AlmanacSectionKey = 'buildings' | 'streets' | 'forest' | 'fireflies'; +export type AlmanacSectionKey = 'buildings'; export interface AlmanacSection { key: AlmanacSectionKey; @@ -49,7 +50,7 @@ export interface Almanac { const MAX_LANGUAGES = 6; function isManifest(m: unknown): m is Manifest { - return !!m && typeof m === 'object' && 'tree' in (m as object) && !!(m as Manifest).tree; + return !!m && typeof m === 'object' && 'tree' in (m as object) && (m as Manifest).tree != null; } function* walk(node: TreeNode): Generator { @@ -65,22 +66,10 @@ function distinctAuthors(commits: Manifest['commits']): number { return set.size; } -function fmt(n: number): string { +function fmtCount(n: number): string { return n.toLocaleString('en-US'); } -function formatBytes(bytes: number): string { - if (bytes < 1024) return `${bytes} B`; - const units = ['KB', 'MB', 'GB']; - let v = bytes / 1024; - let i = 0; - while (v >= 1024 && i < units.length - 1) { - v /= 1024; - i++; - } - return `${v.toFixed(v < 10 ? 1 : 0)} ${units[i]}`; -} - /** ISO date string → epoch ms; NaN for missing/unparseable (never wins a max). */ function dateMs(iso: string | undefined): number { if (!iso) return NaN; @@ -134,8 +123,8 @@ function buildingsSection(files: FileNode[]): AlmanacSection { const facts = [ fileFact('Oldest building', pickFile(files, (f) => -dateMs(f.created)), 'first raised'), fileFact('Newest building', pickFile(files, (f) => dateMs(f.created)), 'most recently raised'), - fileFact('Tallest building', tallest, tallest ? `${fmt(tallest.lines)} lines` : ''), - fileFact('Shortest building', shortest, shortest ? `${fmt(shortest.lines)} lines` : ''), + fileFact('Tallest building', tallest, tallest ? `${fmtCount(tallest.lines)} lines` : ''), + fileFact('Shortest building', shortest, shortest ? `${fmtCount(shortest.lines)} lines` : ''), fileFact('Widest building', widest, widest ? formatBytes(widest.size) : ''), fileFact('Narrowest building', narrowest, narrowest ? formatBytes(narrowest.size) : ''), fileFact('Brightest building', pickFile(files, (f) => dateMs(f.modified)), 'freshly touched'), diff --git a/app/tests/views/InfoPane/almanac.test.ts b/app/tests/views/InfoPane/almanac.test.ts index f6656d03..56bc3c12 100644 --- a/app/tests/views/InfoPane/almanac.test.ts +++ b/app/tests/views/InfoPane/almanac.test.ts @@ -99,6 +99,12 @@ describe('computeAlmanac — overview + buildings', () => { it('widest building = largest bytes', () => { expect(fact('buildings', 'Widest building').landmark).toEqual({ kind: 'file', id: 'new.ts' }); }); + it('shortest building = fewest lines', () => { + expect(fact('buildings', 'Shortest building').landmark).toEqual({ kind: 'file', id: 'old.ts' }); + }); + it('narrowest building = smallest bytes', () => { + expect(fact('buildings', 'Narrowest building').landmark).toEqual({ kind: 'file', id: 'old.ts' }); + }); it('brightest building = most recently modified', () => { expect(fact('buildings', 'Brightest building').landmark).toEqual({ kind: 'file', id: 'new.ts' }); }); From c11edeb9779795ef7fe9e2c3f39d6b438494f585 Mon Sep 17 00:00:00 2001 From: Thalida Noel Date: Mon, 15 Jun 2026 23:55:02 -0400 Subject: [PATCH 05/65] feat(almanac): compute street, forest, and firefly facts Co-Authored-By: Claude Sonnet 4.6 --- app/src/views/InfoPane/almanac.ts | 99 +++++++++++++++++++++++- app/tests/views/InfoPane/almanac.test.ts | 46 +++++++++++ 2 files changed, 143 insertions(+), 2 deletions(-) diff --git a/app/src/views/InfoPane/almanac.ts b/app/src/views/InfoPane/almanac.ts index 9ab51028..3fbdab25 100644 --- a/app/src/views/InfoPane/almanac.ts +++ b/app/src/views/InfoPane/almanac.ts @@ -21,7 +21,7 @@ export interface AlmanacFact { landmark?: LandmarkRef; } -export type AlmanacSectionKey = 'buildings'; +export type AlmanacSectionKey = 'buildings' | 'streets' | 'forest' | 'fireflies'; export interface AlmanacSection { key: AlmanacSectionKey; @@ -133,12 +133,107 @@ function buildingsSection(files: FileNode[]): AlmanacSection { return { key: 'buildings', title: 'Buildings', facts: facts.filter((f): f is AlmanacFact => f !== null) }; } +function depth(path: string): number { + return path ? path.split('/').length : 0; +} + +function streetsSection(dirs: DirNode[]): AlmanacSection | null { + if (dirs.length === 0) return null; + let deepest = dirs[0]; + let biggest = dirs[0]; + for (const d of dirs) { + if (depth(d.path) > depth(deepest.path)) deepest = d; + if (d.descendants_file_count > biggest.descendants_file_count) biggest = d; + } + return { + key: 'streets', + title: 'Streets', + facts: [ + { label: 'Deepest alley', value: `${deepest.path} · ${depth(deepest.path)} levels deep`, landmark: { kind: 'dir', id: deepest.path } }, + { label: 'Biggest neighborhood', value: `${biggest.path} · ${fmtCount(biggest.descendants_file_count)} buildings`, landmark: { kind: 'dir', id: biggest.path } }, + ], + }; +} + +function longestStreak(dates: string[]): number { + const uniq = [...new Set(dates)].sort(); + if (uniq.length === 0) return 0; + let best = 1; + let run = 1; + const oneDay = 86_400_000; + for (let i = 1; i < uniq.length; i++) { + const prev = Date.parse(`${uniq[i - 1]}T00:00:00Z`); + const cur = Date.parse(`${uniq[i]}T00:00:00Z`); + if (cur - prev === oneDay) { + run++; + best = Math.max(best, run); + } else { + run = 1; + } + } + return best; +} + +function forestSection(commits: Manifest['commits']): AlmanacSection | null { + if (commits.length === 0) return null; + let grandest = commits[0]; + let sparsest = commits[0]; + let busiest = commits[0]; + for (const c of commits) { + if (c.files > grandest.files) grandest = c; + if (c.files < sparsest.files) sparsest = c; + if (c.same_day_total > busiest.same_day_total) busiest = c; + } + const streak = longestStreak(commits.map((c) => c.date)); + return { + key: 'forest', + title: 'Forest', + facts: [ + { label: 'Grandest canopy', value: `${grandest.sha.slice(0, 7)} · ${fmtCount(grandest.files)} files`, landmark: { kind: 'commit', id: grandest.sha } }, + { label: 'Sparsest canopy', value: `${sparsest.sha.slice(0, 7)} · ${fmtCount(sparsest.files)} file${sparsest.files === 1 ? '' : 's'}`, landmark: { kind: 'commit', id: sparsest.sha } }, + { label: 'Busiest day', value: `${formatShortDate(busiest.date)} · ${fmtCount(busiest.same_day_total)} commits` }, + { label: 'Longest streak', value: `${fmtCount(streak)} consecutive day${streak === 1 ? '' : 's'}` }, + ], + }; +} + +function firefliesSection(commits: Manifest['commits']): AlmanacSection | null { + if (commits.length === 0) return null; + const tally = new Map(); + for (const c of commits) for (const author of c.authors) tally.set(author, (tally.get(author) ?? 0) + 1); + let topAuthor = ''; + let topCount = -1; + for (const [author, count] of tally) { + if (count > topCount) { + topCount = count; + topAuthor = author; + } + } + return { + key: 'fireflies', + title: 'Fireflies', + facts: [ + { label: 'Fireflies', value: `${fmtCount(tally.size)} drift through the forest` }, + { label: 'Most prolific author', value: `${topAuthor} · ${fmtCount(topCount)} commits` }, + ], + }; +} + export function computeAlmanac(m: Manifest | DirNode | null | undefined): Almanac | null { if (!isManifest(m)) return null; const files: FileNode[] = []; + const dirs: DirNode[] = []; for (const node of walk(m.tree)) { if (node.type === NodeKind.File) files.push(node); + else if (node.type === NodeKind.Directory && node !== m.tree) dirs.push(node); } if (files.length === 0) return null; - return { overview: buildOverview(m), sections: [buildingsSection(files)] }; + const sections: AlmanacSection[] = [buildingsSection(files)]; + const streets = streetsSection(dirs); + if (streets) sections.push(streets); + const forest = forestSection(m.commits); + if (forest) sections.push(forest); + const fireflies = firefliesSection(m.commits); + if (fireflies) sections.push(fireflies); + return { overview: buildOverview(m), sections }; } diff --git a/app/tests/views/InfoPane/almanac.test.ts b/app/tests/views/InfoPane/almanac.test.ts index 56bc3c12..3af58e93 100644 --- a/app/tests/views/InfoPane/almanac.test.ts +++ b/app/tests/views/InfoPane/almanac.test.ts @@ -112,3 +112,49 @@ describe('computeAlmanac — overview + buildings', () => { expect(fact('buildings', 'Most faded building').landmark).toEqual({ kind: 'file', id: 'tall.ts' }); }); }); + +describe('computeAlmanac — streets, forest, fireflies', () => { + const deep = dir('deep', 'src/a/b', [file({ name: 'x.ts', path: 'src/a/b/x.ts' })]); + const src = { ...dir('src', 'src', [deep, file({ name: 'm.ts', path: 'src/m.ts' })]), descendants_file_count: 5 }; + const tree = dir('repo', '', [src as DirNode, file({ name: 'r.ts', path: 'r.ts' })]); + + const commits = [ + { date: '2022-01-01', files: 2, sha: 'aaa', authors: ['Ada'], subject: 'a', same_day_total: 1 }, + { date: '2022-01-02', files: 40, sha: 'bbb', authors: ['Ada', 'Bo'], subject: 'b', same_day_total: 3 }, + { date: '2022-01-03', files: 1, sha: 'ccc', authors: ['Bo'], subject: 'c', same_day_total: 3 }, + { date: '2022-02-10', files: 5, sha: 'ddd', authors: ['Ada'], subject: 'd', same_day_total: 1 }, + ]; + const a = computeAlmanac(manifest(tree, { commits }))!; + const section = (key: string) => a.sections.find((s) => s.key === key)!; + const fact = (key: string, label: string) => section(key).facts.find((f) => f.label === label)!; + + it('deepest alley = deepest directory, excluding root', () => { + expect(fact('streets', 'Deepest alley').landmark).toEqual({ kind: 'dir', id: 'src/a/b' }); + }); + it('biggest neighborhood = max descendant files, excluding root', () => { + expect(fact('streets', 'Biggest neighborhood').landmark).toEqual({ kind: 'dir', id: 'src' }); + }); + it('grandest canopy = commit touching most files', () => { + expect(fact('forest', 'Grandest canopy').landmark).toEqual({ kind: 'commit', id: 'bbb' }); + }); + it('sparsest canopy = commit touching fewest files', () => { + expect(fact('forest', 'Sparsest canopy').landmark).toEqual({ kind: 'commit', id: 'ccc' }); + }); + it('busiest day is non-landmark and names the date', () => { + const f = fact('forest', 'Busiest day'); + expect(f.landmark).toBeUndefined(); + expect(f.value).toContain('3'); + }); + it('longest streak counts consecutive days', () => { + expect(fact('forest', 'Longest streak').value).toContain('3'); + }); + it('fireflies count distinct authors and name the most prolific', () => { + expect(fact('fireflies', 'Fireflies').value).toContain('2'); + expect(fact('fireflies', 'Most prolific author').value).toContain('Ada'); + }); + it('omits forest + fireflies sections when there are no commits', () => { + const b = computeAlmanac(manifest(tree, { commits: [] }))!; + expect(b.sections.find((s) => s.key === 'forest')).toBeUndefined(); + expect(b.sections.find((s) => s.key === 'fireflies')).toBeUndefined(); + }); +}); From ed78a9f6bbd7c153cd1ec88ff3bac9364e572d2a Mon Sep 17 00:00:00 2001 From: Thalida Noel Date: Tue, 16 Jun 2026 00:00:06 -0400 Subject: [PATCH 06/65] refactor(almanac): consistent pluralization + streets-omission test Co-Authored-By: Claude Sonnet 4.6 --- app/src/views/InfoPane/almanac.ts | 24 +++++++++++++++--------- app/tests/views/InfoPane/almanac.test.ts | 5 +++++ 2 files changed, 20 insertions(+), 9 deletions(-) diff --git a/app/src/views/InfoPane/almanac.ts b/app/src/views/InfoPane/almanac.ts index 3fbdab25..fc9d1701 100644 --- a/app/src/views/InfoPane/almanac.ts +++ b/app/src/views/InfoPane/almanac.ts @@ -70,6 +70,11 @@ function fmtCount(n: number): string { return n.toLocaleString('en-US'); } +/** "3 files" / "1 file" — comma-formats the count and pluralizes the noun. */ +function pluralize(n: number, noun: string): string { + return `${fmtCount(n)} ${noun}${n === 1 ? '' : 's'}`; +} + /** ISO date string → epoch ms; NaN for missing/unparseable (never wins a max). */ function dateMs(iso: string | undefined): number { if (!iso) return NaN; @@ -123,8 +128,8 @@ function buildingsSection(files: FileNode[]): AlmanacSection { const facts = [ fileFact('Oldest building', pickFile(files, (f) => -dateMs(f.created)), 'first raised'), fileFact('Newest building', pickFile(files, (f) => dateMs(f.created)), 'most recently raised'), - fileFact('Tallest building', tallest, tallest ? `${fmtCount(tallest.lines)} lines` : ''), - fileFact('Shortest building', shortest, shortest ? `${fmtCount(shortest.lines)} lines` : ''), + fileFact('Tallest building', tallest, tallest ? pluralize(tallest.lines, 'line') : ''), + fileFact('Shortest building', shortest, shortest ? pluralize(shortest.lines, 'line') : ''), fileFact('Widest building', widest, widest ? formatBytes(widest.size) : ''), fileFact('Narrowest building', narrowest, narrowest ? formatBytes(narrowest.size) : ''), fileFact('Brightest building', pickFile(files, (f) => dateMs(f.modified)), 'freshly touched'), @@ -145,12 +150,13 @@ function streetsSection(dirs: DirNode[]): AlmanacSection | null { if (depth(d.path) > depth(deepest.path)) deepest = d; if (d.descendants_file_count > biggest.descendants_file_count) biggest = d; } + const deepestDepth = depth(deepest.path); return { key: 'streets', title: 'Streets', facts: [ - { label: 'Deepest alley', value: `${deepest.path} · ${depth(deepest.path)} levels deep`, landmark: { kind: 'dir', id: deepest.path } }, - { label: 'Biggest neighborhood', value: `${biggest.path} · ${fmtCount(biggest.descendants_file_count)} buildings`, landmark: { kind: 'dir', id: biggest.path } }, + { label: 'Deepest alley', value: `${deepest.path} · ${deepestDepth} levels deep`, landmark: { kind: 'dir', id: deepest.path } }, + { label: 'Biggest neighborhood', value: `${biggest.path} · ${pluralize(biggest.descendants_file_count, 'building')}`, landmark: { kind: 'dir', id: biggest.path } }, ], }; } @@ -189,10 +195,10 @@ function forestSection(commits: Manifest['commits']): AlmanacSection | null { key: 'forest', title: 'Forest', facts: [ - { label: 'Grandest canopy', value: `${grandest.sha.slice(0, 7)} · ${fmtCount(grandest.files)} files`, landmark: { kind: 'commit', id: grandest.sha } }, - { label: 'Sparsest canopy', value: `${sparsest.sha.slice(0, 7)} · ${fmtCount(sparsest.files)} file${sparsest.files === 1 ? '' : 's'}`, landmark: { kind: 'commit', id: sparsest.sha } }, - { label: 'Busiest day', value: `${formatShortDate(busiest.date)} · ${fmtCount(busiest.same_day_total)} commits` }, - { label: 'Longest streak', value: `${fmtCount(streak)} consecutive day${streak === 1 ? '' : 's'}` }, + { label: 'Grandest canopy', value: `${grandest.sha.slice(0, 7)} · ${pluralize(grandest.files, 'file')}`, landmark: { kind: 'commit', id: grandest.sha } }, + { label: 'Sparsest canopy', value: `${sparsest.sha.slice(0, 7)} · ${pluralize(sparsest.files, 'file')}`, landmark: { kind: 'commit', id: sparsest.sha } }, + { label: 'Busiest day', value: `${formatShortDate(busiest.date)} · ${pluralize(busiest.same_day_total, 'commit')}` }, + { label: 'Longest streak', value: pluralize(streak, 'consecutive day') }, ], }; } @@ -214,7 +220,7 @@ function firefliesSection(commits: Manifest['commits']): AlmanacSection | null { title: 'Fireflies', facts: [ { label: 'Fireflies', value: `${fmtCount(tally.size)} drift through the forest` }, - { label: 'Most prolific author', value: `${topAuthor} · ${fmtCount(topCount)} commits` }, + { label: 'Most prolific author', value: `${topAuthor} · ${pluralize(topCount, 'commit')}` }, ], }; } diff --git a/app/tests/views/InfoPane/almanac.test.ts b/app/tests/views/InfoPane/almanac.test.ts index 3af58e93..c8a624ca 100644 --- a/app/tests/views/InfoPane/almanac.test.ts +++ b/app/tests/views/InfoPane/almanac.test.ts @@ -157,4 +157,9 @@ describe('computeAlmanac — streets, forest, fireflies', () => { expect(b.sections.find((s) => s.key === 'forest')).toBeUndefined(); expect(b.sections.find((s) => s.key === 'fireflies')).toBeUndefined(); }); + it('omits streets section when there are no subdirectories', () => { + const flatTree = dir('repo', '', [file({ name: 'a.ts', path: 'a.ts' })]); + const b = computeAlmanac(manifest(flatTree, { commits }))!; + expect(b.sections.find((s) => s.key === 'streets')).toBeUndefined(); + }); }); From 68b712d743481e1e543d1994b71583d66b924a70 Mon Sep 17 00:00:00 2001 From: Thalida Noel Date: Tue, 16 Jun 2026 00:03:24 -0400 Subject: [PATCH 07/65] feat(components): add PaneTabs in-pane subtab primitive Controlled, presentational tab strip for below pane headers; arrow-key navigable. Installs @testing-library/preact as a dev dep for the test. Co-Authored-By: Claude Sonnet 4.6 --- app/package-lock.json | 1124 ++++++++++++++++++++++ app/package.json | 1 + app/src/components/PaneTabs/PaneTabs.css | 38 + app/src/components/PaneTabs/PaneTabs.tsx | 57 ++ app/tests/components/paneTabs.test.tsx | 32 + 5 files changed, 1252 insertions(+) create mode 100644 app/src/components/PaneTabs/PaneTabs.css create mode 100644 app/src/components/PaneTabs/PaneTabs.tsx create mode 100644 app/tests/components/paneTabs.test.tsx diff --git a/app/package-lock.json b/app/package-lock.json index 876651e4..c7aa2acc 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -20,6 +20,7 @@ "devDependencies": { "@eslint/js": "^10.0.1", "@preact/preset-vite": "^2.10.5", + "@testing-library/preact": "^3.2.4", "@types/node": "^25.6.0", "@types/three": "^0.184.0", "@vitest/coverage-v8": "^4.1.7", @@ -392,6 +393,16 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/template": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", @@ -1444,6 +1455,42 @@ "dev": true, "license": "MIT" }, + "node_modules/@testing-library/dom": { + "version": "8.20.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-8.20.1.tgz", + "integrity": "sha512-/DiOQ5xBxgdYRC8LNk7U+RWat0S3qRLeIw3ZIkMQ9kkVlRmwD/Eg8k8CqIpD6GW7u20JIUOfMKbxtiLutpjQ4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.1.3", + "chalk": "^4.1.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@testing-library/preact": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@testing-library/preact/-/preact-3.2.4.tgz", + "integrity": "sha512-F+kJ243LP6VmEK1M809unzTE/ijg+bsMNuiRN0JEDIJBELKKDNhdgC/WrUSZ7klwJvtlO3wQZ9ix+jhObG07Fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@testing-library/dom": "^8.11.1" + }, + "engines": { + "node": ">= 12" + }, + "peerDependencies": { + "preact": ">=10 || ^10.0.0-alpha.0 || ^10.0.0-beta.0" + } + }, "node_modules/@tweenjs/tween.js": { "version": "23.1.3", "resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz", @@ -1462,6 +1509,13 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/chai": { "version": "5.2.3", "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", @@ -1974,6 +2028,32 @@ "node": ">=6" } }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", @@ -1981,6 +2061,33 @@ "dev": true, "license": "Python-2.0" }, + "node_modules/aria-query": { + "version": "5.1.3", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.1.3.tgz", + "integrity": "sha512-R5iJ5lkuHybztUfuOAznmboyjWq8O6sqNqtK7CLOqdydi54VNbORp49mb14KbWgG1QD3JFO9hJdZ+y4KutfdOQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "deep-equal": "^2.0.5" + } + }, + "node_modules/array-buffer-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", + "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "is-array-buffer": "^3.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -2003,6 +2110,22 @@ "js-tokens": "^10.0.0" } }, + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/babel-plugin-transform-hook-names": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/babel-plugin-transform-hook-names/-/babel-plugin-transform-hook-names-1.0.2.tgz", @@ -2158,6 +2281,56 @@ "ieee754": "^1.1.13" } }, + "node_modules/call-bind": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", + "integrity": "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "get-intrinsic": "^1.3.0", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/caniuse-lite": { "version": "1.0.30001793", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001793.tgz", @@ -2204,6 +2377,23 @@ "node": ">=18" } }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, "node_modules/change-case": { "version": "5.4.4", "resolved": "https://registry.npmjs.org/change-case/-/change-case-5.4.4.tgz", @@ -2225,6 +2415,26 @@ "dev": true, "license": "(BSD-3-Clause AND Apache-2.0)" }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, "node_modules/colorette": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/colorette/-/colorette-1.4.0.tgz", @@ -2353,6 +2563,39 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/deep-equal": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-2.2.3.tgz", + "integrity": "sha512-ZIwpnevOurS8bpT4192sqAowWM76JDKSHYzMLty3BZGSswgq6pBaH3DhCSW5xVAZICZyKdOBPjwww5wfgT/6PA==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.0", + "call-bind": "^1.0.5", + "es-get-iterator": "^1.1.3", + "get-intrinsic": "^1.2.2", + "is-arguments": "^1.1.1", + "is-array-buffer": "^3.0.2", + "is-date-object": "^1.0.5", + "is-regex": "^1.1.4", + "is-shared-array-buffer": "^1.0.2", + "isarray": "^2.0.5", + "object-is": "^1.1.5", + "object-keys": "^1.1.1", + "object.assign": "^4.1.4", + "regexp.prototype.flags": "^1.5.1", + "side-channel": "^1.0.4", + "which-boxed-primitive": "^1.0.2", + "which-collection": "^1.0.1", + "which-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/deep-extend": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", @@ -2384,6 +2627,42 @@ "node": ">=0.10.0" } }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -2394,6 +2673,13 @@ "node": ">=8" } }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT" + }, "node_modules/dom-serializer": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", @@ -2466,6 +2752,21 @@ "url": "https://github.com/fb55/domutils?sponsor=1" } }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/electron-to-chromium": { "version": "1.5.364", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.364.tgz", @@ -2496,6 +2797,47 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-get-iterator": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/es-get-iterator/-/es-get-iterator-1.1.3.tgz", + "integrity": "sha512-sPZmqHBe6JIiTfN5q2pEi//TwxmAFHwj/XEuYjTuse78i8KxaqMTTzxPoFKuzRpDpTJ+0NAbpfenkmH2rePtuw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.2", + "get-intrinsic": "^1.1.3", + "has-symbols": "^1.0.3", + "is-arguments": "^1.1.1", + "is-map": "^2.0.2", + "is-set": "^2.0.2", + "is-string": "^1.0.7", + "isarray": "^2.0.5", + "stop-iteration-iterator": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/es-module-lexer": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", @@ -2503,6 +2845,19 @@ "dev": true, "license": "MIT" }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -2838,6 +3193,22 @@ "dev": true, "license": "ISC" }, + "node_modules/for-each": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/fs-constants": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", @@ -2860,6 +3231,26 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/functions-have-names": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", + "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/gensync": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", @@ -2870,6 +3261,45 @@ "node": ">=6.9.0" } }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/github-from-package": { "version": "0.0.0", "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", @@ -2903,6 +3333,32 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-bigints": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", + "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -2913,6 +3369,61 @@ "node": ">=8" } }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/he": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", @@ -3034,6 +3545,89 @@ "dev": true, "license": "ISC" }, + "node_modules/internal-slot": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", + "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "hasown": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/is-arguments": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.2.0.tgz", + "integrity": "sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-array-buffer": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", + "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-bigint": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz", + "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-bigints": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-boolean-object": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", + "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-buffer": { "version": "1.1.6", "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz", @@ -3041,6 +3635,36 @@ "dev": true, "license": "MIT" }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-date-object": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", + "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -3064,6 +3688,36 @@ "node": ">=0.10.0" } }, + "node_modules/is-map": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", + "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-number-object": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", + "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-potential-custom-element-name": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", @@ -3071,6 +3725,126 @@ "dev": true, "license": "MIT" }, + "node_modules/is-regex": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", + "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-set": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", + "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-shared-array-buffer": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz", + "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-string": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", + "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-symbol": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz", + "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-symbols": "^1.1.0", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakmap": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", + "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakset": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz", + "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true, + "license": "MIT" + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -3575,6 +4349,16 @@ "preact": "^10.27.2" } }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "bin": { + "lz-string": "bin/bin.js" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -3644,6 +4428,16 @@ "url": "https://github.com/sponsors/material-extensions" } }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/mdn-data": { "version": "2.27.1", "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", @@ -3798,6 +4592,67 @@ "url": "https://github.com/fb55/nth-check?sponsor=1" } }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-is": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/object-is/-/object-is-1.1.6.tgz", + "integrity": "sha512-F8cZ+KfGlSGi09lJT7/Nd6KJZ9ygtvYC0/UYYLI9nmQKLMnydpB9yvbv9K1uSkEu7FU9vYPmVwLg328tX+ot3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.assign": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", + "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0", + "has-symbols": "^1.1.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/obug": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", @@ -3991,6 +4846,16 @@ "node": ">=4" } }, + "node_modules/possible-typed-array-names": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", + "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/postcss": { "version": "8.5.10", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz", @@ -4114,6 +4979,34 @@ "url": "https://github.com/prettier/prettier?sponsor=1" } }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/pump": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", @@ -4166,6 +5059,13 @@ "rc": "cli.js" } }, + "node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT" + }, "node_modules/readable-stream": { "version": "3.6.2", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", @@ -4181,6 +5081,27 @@ "node": ">= 6" } }, + "node_modules/regexp.prototype.flags": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", + "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-errors": "^1.3.0", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/rename-keys": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/rename-keys/-/rename-keys-1.2.0.tgz", @@ -4256,6 +5177,24 @@ ], "license": "MIT" }, + "node_modules/safe-regex-test": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", + "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-regex": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/saxes": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", @@ -4282,6 +5221,40 @@ "node": ">=10" } }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-function-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", + "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -4305,6 +5278,82 @@ "node": ">=8" } }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/siginfo": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", @@ -4413,6 +5462,20 @@ "dev": true, "license": "MIT" }, + "node_modules/stop-iteration-iterator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", + "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "internal-slot": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/string_decoder": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", @@ -4980,6 +6043,67 @@ "node": ">= 8" } }, + "node_modules/which-boxed-primitive": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", + "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-bigint": "^1.1.0", + "is-boolean-object": "^1.2.1", + "is-number-object": "^1.1.1", + "is-string": "^1.1.1", + "is-symbol": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-collection": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz", + "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-map": "^2.0.3", + "is-set": "^2.0.3", + "is-weakmap": "^2.0.2", + "is-weakset": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-typed-array": { + "version": "1.1.22", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.22.tgz", + "integrity": "sha512-fvO4ExWMFsqyhG3AiPAObMuY1lxaqgYcxbc49CNdWDDECOJNgQyvsOWVwbZc+qf3rzRtxojBK+CMEv0Ld5CYpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "for-each": "^0.3.5", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/why-is-node-running": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", diff --git a/app/package.json b/app/package.json index 13fe3651..00fe2664 100644 --- a/app/package.json +++ b/app/package.json @@ -29,6 +29,7 @@ "devDependencies": { "@eslint/js": "^10.0.1", "@preact/preset-vite": "^2.10.5", + "@testing-library/preact": "^3.2.4", "@types/node": "^25.6.0", "@types/three": "^0.184.0", "@vitest/coverage-v8": "^4.1.7", diff --git a/app/src/components/PaneTabs/PaneTabs.css b/app/src/components/PaneTabs/PaneTabs.css new file mode 100644 index 00000000..15291688 --- /dev/null +++ b/app/src/components/PaneTabs/PaneTabs.css @@ -0,0 +1,38 @@ +.pane-tabs { + display: flex; + gap: 2px; + padding: 6px 8px; + border-bottom: 1px solid var(--cc-border-subtle); +} + +.pane-tab { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 4px 10px; + border: 0; + border-radius: var(--cc-radius-sm); + background: transparent; + color: var(--cc-text-faint); + font: inherit; + font-size: var(--cc-font-lg); + cursor: pointer; + transition: + color var(--cc-t-base), + background var(--cc-t-base); +} + +.pane-tab:hover { + background: var(--cc-overlay-bg); + color: var(--cc-text-primary); +} + +.pane-tab--active { + background: var(--cc-accent-bg); + color: var(--cc-text-primary); +} + +.pane-tab .lucide-icon { + width: 14px; + height: 14px; +} diff --git a/app/src/components/PaneTabs/PaneTabs.tsx b/app/src/components/PaneTabs/PaneTabs.tsx new file mode 100644 index 00000000..0444ff78 --- /dev/null +++ b/app/src/components/PaneTabs/PaneTabs.tsx @@ -0,0 +1,57 @@ +// components/PaneTabs/PaneTabs.tsx — horizontal segmented tabs rendered inside +// a Pane body (below the header). Controlled + presentational: the parent owns +// the active id and decides what each tab renders. Arrow-key navigable. + +import './PaneTabs.css'; +import type { LucideIcon } from 'lucide-preact'; + +export interface PaneTab { + id: string; + label: string; + icon?: LucideIcon; +} + +export interface PaneTabsProps { + tabs: PaneTab[]; + active: string; + onSelect: (id: string) => void; +} + +export function PaneTabs({ tabs, active, onSelect }: PaneTabsProps) { + const move = (dir: 1 | -1) => { + const i = tabs.findIndex((t) => t.id === active); + if (i < 0) return; + const next = (i + dir + tabs.length) % tabs.length; + onSelect(tabs[next].id); + }; + return ( +
+ {tabs.map((t) => { + const Icon = t.icon; + const selected = t.id === active; + return ( + + ); + })} +
+ ); +} diff --git a/app/tests/components/paneTabs.test.tsx b/app/tests/components/paneTabs.test.tsx new file mode 100644 index 00000000..0cee4881 --- /dev/null +++ b/app/tests/components/paneTabs.test.tsx @@ -0,0 +1,32 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { render, fireEvent, cleanup } from '@testing-library/preact'; + +afterEach(() => cleanup()); +import { PaneTabs } from '@/components/PaneTabs/PaneTabs'; + +const tabs = [ + { id: 'world', label: 'World' }, + { id: 'readme', label: 'Readme' }, +]; + +describe('PaneTabs', () => { + it('renders a tab per entry and marks the active one', () => { + const { getByRole } = render( {}} />); + expect(getByRole('tab', { name: 'World' }).getAttribute('aria-selected')).toBe('true'); + expect(getByRole('tab', { name: 'Readme' }).getAttribute('aria-selected')).toBe('false'); + }); + + it('calls onSelect with the clicked tab id', () => { + const onSelect = vi.fn(); + const { getByRole } = render(); + fireEvent.click(getByRole('tab', { name: 'Readme' })); + expect(onSelect).toHaveBeenCalledWith('readme'); + }); + + it('ArrowRight moves selection to the next tab', () => { + const onSelect = vi.fn(); + const { getByRole } = render(); + fireEvent.keyDown(getByRole('tab', { name: 'World' }), { key: 'ArrowRight' }); + expect(onSelect).toHaveBeenCalledWith('readme'); + }); +}); From ce81bb9fe9458ee4865784c54e23c65b44008368 Mon Sep 17 00:00:00 2001 From: Thalida Noel Date: Tue, 16 Jun 2026 00:05:16 -0400 Subject: [PATCH 08/65] test(panetabs): use house render pattern, drop testing-library dep Rewrites paneTabs.test.tsx to use raw preact render + flush() helper (matching the nodeIcon.test.tsx convention) and restores package.json / package-lock.json to remove the @testing-library/preact dependency added in the previous commit. Co-Authored-By: Claude Sonnet 4.6 --- app/package-lock.json | 1124 ------------------------ app/package.json | 1 - app/tests/components/paneTabs.test.tsx | 59 +- 3 files changed, 40 insertions(+), 1144 deletions(-) diff --git a/app/package-lock.json b/app/package-lock.json index c7aa2acc..876651e4 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -20,7 +20,6 @@ "devDependencies": { "@eslint/js": "^10.0.1", "@preact/preset-vite": "^2.10.5", - "@testing-library/preact": "^3.2.4", "@types/node": "^25.6.0", "@types/three": "^0.184.0", "@vitest/coverage-v8": "^4.1.7", @@ -393,16 +392,6 @@ "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/runtime": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", - "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, "node_modules/@babel/template": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", @@ -1455,42 +1444,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@testing-library/dom": { - "version": "8.20.1", - "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-8.20.1.tgz", - "integrity": "sha512-/DiOQ5xBxgdYRC8LNk7U+RWat0S3qRLeIw3ZIkMQ9kkVlRmwD/Eg8k8CqIpD6GW7u20JIUOfMKbxtiLutpjQ4g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.10.4", - "@babel/runtime": "^7.12.5", - "@types/aria-query": "^5.0.1", - "aria-query": "5.1.3", - "chalk": "^4.1.0", - "dom-accessibility-api": "^0.5.9", - "lz-string": "^1.5.0", - "pretty-format": "^27.0.2" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/@testing-library/preact": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@testing-library/preact/-/preact-3.2.4.tgz", - "integrity": "sha512-F+kJ243LP6VmEK1M809unzTE/ijg+bsMNuiRN0JEDIJBELKKDNhdgC/WrUSZ7klwJvtlO3wQZ9ix+jhObG07Fg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@testing-library/dom": "^8.11.1" - }, - "engines": { - "node": ">= 12" - }, - "peerDependencies": { - "preact": ">=10 || ^10.0.0-alpha.0 || ^10.0.0-beta.0" - } - }, "node_modules/@tweenjs/tween.js": { "version": "23.1.3", "resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz", @@ -1509,13 +1462,6 @@ "tslib": "^2.4.0" } }, - "node_modules/@types/aria-query": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", - "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/chai": { "version": "5.2.3", "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", @@ -2028,32 +1974,6 @@ "node": ">=6" } }, - "node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", @@ -2061,33 +1981,6 @@ "dev": true, "license": "Python-2.0" }, - "node_modules/aria-query": { - "version": "5.1.3", - "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.1.3.tgz", - "integrity": "sha512-R5iJ5lkuHybztUfuOAznmboyjWq8O6sqNqtK7CLOqdydi54VNbORp49mb14KbWgG1QD3JFO9hJdZ+y4KutfdOQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "deep-equal": "^2.0.5" - } - }, - "node_modules/array-buffer-byte-length": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", - "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "is-array-buffer": "^3.0.5" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -2110,22 +2003,6 @@ "js-tokens": "^10.0.0" } }, - "node_modules/available-typed-arrays": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", - "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "possible-typed-array-names": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/babel-plugin-transform-hook-names": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/babel-plugin-transform-hook-names/-/babel-plugin-transform-hook-names-1.0.2.tgz", @@ -2281,56 +2158,6 @@ "ieee754": "^1.1.13" } }, - "node_modules/call-bind": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", - "integrity": "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "es-define-property": "^1.0.1", - "get-intrinsic": "^1.3.0", - "set-function-length": "^1.2.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/call-bind-apply-helpers": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", - "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/call-bound": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", - "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "get-intrinsic": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/caniuse-lite": { "version": "1.0.30001793", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001793.tgz", @@ -2377,23 +2204,6 @@ "node": ">=18" } }, - "node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, "node_modules/change-case": { "version": "5.4.4", "resolved": "https://registry.npmjs.org/change-case/-/change-case-5.4.4.tgz", @@ -2415,26 +2225,6 @@ "dev": true, "license": "(BSD-3-Clause AND Apache-2.0)" }, - "node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, - "license": "MIT" - }, "node_modules/colorette": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/colorette/-/colorette-1.4.0.tgz", @@ -2563,39 +2353,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/deep-equal": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-2.2.3.tgz", - "integrity": "sha512-ZIwpnevOurS8bpT4192sqAowWM76JDKSHYzMLty3BZGSswgq6pBaH3DhCSW5xVAZICZyKdOBPjwww5wfgT/6PA==", - "dev": true, - "license": "MIT", - "dependencies": { - "array-buffer-byte-length": "^1.0.0", - "call-bind": "^1.0.5", - "es-get-iterator": "^1.1.3", - "get-intrinsic": "^1.2.2", - "is-arguments": "^1.1.1", - "is-array-buffer": "^3.0.2", - "is-date-object": "^1.0.5", - "is-regex": "^1.1.4", - "is-shared-array-buffer": "^1.0.2", - "isarray": "^2.0.5", - "object-is": "^1.1.5", - "object-keys": "^1.1.1", - "object.assign": "^4.1.4", - "regexp.prototype.flags": "^1.5.1", - "side-channel": "^1.0.4", - "which-boxed-primitive": "^1.0.2", - "which-collection": "^1.0.1", - "which-typed-array": "^1.1.13" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/deep-extend": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", @@ -2627,42 +2384,6 @@ "node": ">=0.10.0" } }, - "node_modules/define-data-property": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", - "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-define-property": "^1.0.0", - "es-errors": "^1.3.0", - "gopd": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/define-properties": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", - "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", - "dev": true, - "license": "MIT", - "dependencies": { - "define-data-property": "^1.0.1", - "has-property-descriptors": "^1.0.0", - "object-keys": "^1.1.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -2673,13 +2394,6 @@ "node": ">=8" } }, - "node_modules/dom-accessibility-api": { - "version": "0.5.16", - "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", - "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", - "dev": true, - "license": "MIT" - }, "node_modules/dom-serializer": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", @@ -2752,21 +2466,6 @@ "url": "https://github.com/fb55/domutils?sponsor=1" } }, - "node_modules/dunder-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", - "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.1", - "es-errors": "^1.3.0", - "gopd": "^1.2.0" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/electron-to-chromium": { "version": "1.5.364", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.364.tgz", @@ -2797,47 +2496,6 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, - "node_modules/es-define-property": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", - "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-errors": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", - "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-get-iterator": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/es-get-iterator/-/es-get-iterator-1.1.3.tgz", - "integrity": "sha512-sPZmqHBe6JIiTfN5q2pEi//TwxmAFHwj/XEuYjTuse78i8KxaqMTTzxPoFKuzRpDpTJ+0NAbpfenkmH2rePtuw==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.2", - "get-intrinsic": "^1.1.3", - "has-symbols": "^1.0.3", - "is-arguments": "^1.1.1", - "is-map": "^2.0.2", - "is-set": "^2.0.2", - "is-string": "^1.0.7", - "isarray": "^2.0.5", - "stop-iteration-iterator": "^1.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/es-module-lexer": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", @@ -2845,19 +2503,6 @@ "dev": true, "license": "MIT" }, - "node_modules/es-object-atoms": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", - "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -3193,22 +2838,6 @@ "dev": true, "license": "ISC" }, - "node_modules/for-each": { - "version": "0.3.5", - "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", - "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-callable": "^1.2.7" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/fs-constants": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", @@ -3231,26 +2860,6 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, - "node_modules/function-bind": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/functions-have-names": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", - "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/gensync": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", @@ -3261,45 +2870,6 @@ "node": ">=6.9.0" } }, - "node_modules/get-intrinsic": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", - "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "es-define-property": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.1", - "function-bind": "^1.1.2", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "has-symbols": "^1.1.0", - "hasown": "^2.0.2", - "math-intrinsics": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", - "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "dev": true, - "license": "MIT", - "dependencies": { - "dunder-proto": "^1.0.1", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/github-from-package": { "version": "0.0.0", "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", @@ -3333,32 +2903,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/gopd": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", - "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-bigints": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", - "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -3369,61 +2913,6 @@ "node": ">=8" } }, - "node_modules/has-property-descriptors": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", - "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-define-property": "^1.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-symbols": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", - "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-tostringtag": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", - "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-symbols": "^1.0.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/hasown": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", - "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", - "dev": true, - "license": "MIT", - "dependencies": { - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/he": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", @@ -3545,89 +3034,6 @@ "dev": true, "license": "ISC" }, - "node_modules/internal-slot": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", - "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "hasown": "^2.0.2", - "side-channel": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/is-arguments": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.2.0.tgz", - "integrity": "sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "has-tostringtag": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-array-buffer": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", - "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.3", - "get-intrinsic": "^1.2.6" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-bigint": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz", - "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-bigints": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-boolean-object": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", - "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "has-tostringtag": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/is-buffer": { "version": "1.1.6", "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz", @@ -3635,36 +3041,6 @@ "dev": true, "license": "MIT" }, - "node_modules/is-callable": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", - "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-date-object": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", - "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "has-tostringtag": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -3688,36 +3064,6 @@ "node": ">=0.10.0" } }, - "node_modules/is-map": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", - "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-number-object": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", - "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "has-tostringtag": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/is-potential-custom-element-name": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", @@ -3725,126 +3071,6 @@ "dev": true, "license": "MIT" }, - "node_modules/is-regex": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", - "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "gopd": "^1.2.0", - "has-tostringtag": "^1.0.2", - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-set": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", - "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-shared-array-buffer": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz", - "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-string": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", - "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "has-tostringtag": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-symbol": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz", - "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "has-symbols": "^1.1.0", - "safe-regex-test": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-weakmap": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", - "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-weakset": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz", - "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "get-intrinsic": "^1.2.6" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/isarray": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", - "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", - "dev": true, - "license": "MIT" - }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -4349,16 +3575,6 @@ "preact": "^10.27.2" } }, - "node_modules/lz-string": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", - "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", - "dev": true, - "license": "MIT", - "bin": { - "lz-string": "bin/bin.js" - } - }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -4428,16 +3644,6 @@ "url": "https://github.com/sponsors/material-extensions" } }, - "node_modules/math-intrinsics": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", - "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, "node_modules/mdn-data": { "version": "2.27.1", "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", @@ -4592,67 +3798,6 @@ "url": "https://github.com/fb55/nth-check?sponsor=1" } }, - "node_modules/object-inspect": { - "version": "1.13.4", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", - "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/object-is": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/object-is/-/object-is-1.1.6.tgz", - "integrity": "sha512-F8cZ+KfGlSGi09lJT7/Nd6KJZ9ygtvYC0/UYYLI9nmQKLMnydpB9yvbv9K1uSkEu7FU9vYPmVwLg328tX+ot3Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.7", - "define-properties": "^1.2.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/object-keys": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", - "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/object.assign": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", - "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.3", - "define-properties": "^1.2.1", - "es-object-atoms": "^1.0.0", - "has-symbols": "^1.1.0", - "object-keys": "^1.1.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/obug": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", @@ -4846,16 +3991,6 @@ "node": ">=4" } }, - "node_modules/possible-typed-array-names": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", - "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, "node_modules/postcss": { "version": "8.5.10", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz", @@ -4979,34 +4114,6 @@ "url": "https://github.com/prettier/prettier?sponsor=1" } }, - "node_modules/pretty-format": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", - "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1", - "ansi-styles": "^5.0.0", - "react-is": "^17.0.1" - }, - "engines": { - "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" - } - }, - "node_modules/pretty-format/node_modules/ansi-styles": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", - "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, "node_modules/pump": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", @@ -5059,13 +4166,6 @@ "rc": "cli.js" } }, - "node_modules/react-is": { - "version": "17.0.2", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", - "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", - "dev": true, - "license": "MIT" - }, "node_modules/readable-stream": { "version": "3.6.2", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", @@ -5081,27 +4181,6 @@ "node": ">= 6" } }, - "node_modules/regexp.prototype.flags": { - "version": "1.5.4", - "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", - "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "define-properties": "^1.2.1", - "es-errors": "^1.3.0", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "set-function-name": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/rename-keys": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/rename-keys/-/rename-keys-1.2.0.tgz", @@ -5177,24 +4256,6 @@ ], "license": "MIT" }, - "node_modules/safe-regex-test": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", - "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "is-regex": "^1.2.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/saxes": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", @@ -5221,40 +4282,6 @@ "node": ">=10" } }, - "node_modules/set-function-length": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", - "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", - "dev": true, - "license": "MIT", - "dependencies": { - "define-data-property": "^1.1.4", - "es-errors": "^1.3.0", - "function-bind": "^1.1.2", - "get-intrinsic": "^1.2.4", - "gopd": "^1.0.1", - "has-property-descriptors": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/set-function-name": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", - "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "define-data-property": "^1.1.4", - "es-errors": "^1.3.0", - "functions-have-names": "^1.2.3", - "has-property-descriptors": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -5278,82 +4305,6 @@ "node": ">=8" } }, - "node_modules/side-channel": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", - "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.4", - "side-channel-list": "^1.0.1", - "side-channel-map": "^1.0.1", - "side-channel-weakmap": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-list": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", - "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.4" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-map": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", - "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-weakmap": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", - "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3", - "side-channel-map": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/siginfo": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", @@ -5462,20 +4413,6 @@ "dev": true, "license": "MIT" }, - "node_modules/stop-iteration-iterator": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", - "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "internal-slot": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/string_decoder": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", @@ -6043,67 +4980,6 @@ "node": ">= 8" } }, - "node_modules/which-boxed-primitive": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", - "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-bigint": "^1.1.0", - "is-boolean-object": "^1.2.1", - "is-number-object": "^1.1.1", - "is-string": "^1.1.1", - "is-symbol": "^1.1.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/which-collection": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz", - "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-map": "^2.0.3", - "is-set": "^2.0.3", - "is-weakmap": "^2.0.2", - "is-weakset": "^2.0.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/which-typed-array": { - "version": "1.1.22", - "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.22.tgz", - "integrity": "sha512-fvO4ExWMFsqyhG3AiPAObMuY1lxaqgYcxbc49CNdWDDECOJNgQyvsOWVwbZc+qf3rzRtxojBK+CMEv0Ld5CYpw==", - "dev": true, - "license": "MIT", - "dependencies": { - "available-typed-arrays": "^1.0.7", - "call-bind": "^1.0.9", - "call-bound": "^1.0.4", - "for-each": "^0.3.5", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "has-tostringtag": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/why-is-node-running": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", diff --git a/app/package.json b/app/package.json index 00fe2664..13fe3651 100644 --- a/app/package.json +++ b/app/package.json @@ -29,7 +29,6 @@ "devDependencies": { "@eslint/js": "^10.0.1", "@preact/preset-vite": "^2.10.5", - "@testing-library/preact": "^3.2.4", "@types/node": "^25.6.0", "@types/three": "^0.184.0", "@vitest/coverage-v8": "^4.1.7", diff --git a/app/tests/components/paneTabs.test.tsx b/app/tests/components/paneTabs.test.tsx index 0cee4881..53123cca 100644 --- a/app/tests/components/paneTabs.test.tsx +++ b/app/tests/components/paneTabs.test.tsx @@ -1,32 +1,53 @@ -import { describe, it, expect, vi, afterEach } from 'vitest'; -import { render, fireEvent, cleanup } from '@testing-library/preact'; - -afterEach(() => cleanup()); +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { render } from 'preact'; import { PaneTabs } from '@/components/PaneTabs/PaneTabs'; - -const tabs = [ - { id: 'world', label: 'World' }, - { id: 'readme', label: 'Readme' }, -]; +import { flush } from '../_helpers/preact'; describe('PaneTabs', () => { - it('renders a tab per entry and marks the active one', () => { - const { getByRole } = render( {}} />); - expect(getByRole('tab', { name: 'World' }).getAttribute('aria-selected')).toBe('true'); - expect(getByRole('tab', { name: 'Readme' }).getAttribute('aria-selected')).toBe('false'); + let container: HTMLDivElement; + + beforeEach(() => { + container = document.createElement('div'); + document.body.appendChild(container); + }); + + afterEach(() => { + render(null, container); + container.remove(); + }); + + const tabs = [ + { id: 'world', label: 'World' }, + { id: 'readme', label: 'Readme' }, + ]; + + const tabByLabel = (label: string) => + Array.from(container.querySelectorAll('[role="tab"]')).find( + (el) => el.textContent === label, + ) as HTMLButtonElement; + + it('renders a tab per entry and marks the active one', async () => { + render( {}} />, container); + await flush(); + expect(tabByLabel('World').getAttribute('aria-selected')).toBe('true'); + expect(tabByLabel('Readme').getAttribute('aria-selected')).toBe('false'); }); - it('calls onSelect with the clicked tab id', () => { + it('calls onSelect with the clicked tab id', async () => { const onSelect = vi.fn(); - const { getByRole } = render(); - fireEvent.click(getByRole('tab', { name: 'Readme' })); + render(, container); + await flush(); + tabByLabel('Readme').click(); expect(onSelect).toHaveBeenCalledWith('readme'); }); - it('ArrowRight moves selection to the next tab', () => { + it('ArrowRight moves selection to the next tab', async () => { const onSelect = vi.fn(); - const { getByRole } = render(); - fireEvent.keyDown(getByRole('tab', { name: 'World' }), { key: 'ArrowRight' }); + render(, container); + await flush(); + tabByLabel('World').dispatchEvent( + new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true }), + ); expect(onSelect).toHaveBeenCalledWith('readme'); }); }); From 2ff241191ded22075234a512ee4146bd72281b3f Mon Sep 17 00:00:00 2001 From: Thalida Noel Date: Tue, 16 Jun 2026 00:07:47 -0400 Subject: [PATCH 09/65] test(panetabs): cover ArrowLeft + wrap-around navigation --- app/tests/components/paneTabs.test.tsx | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/app/tests/components/paneTabs.test.tsx b/app/tests/components/paneTabs.test.tsx index 53123cca..dc21e14d 100644 --- a/app/tests/components/paneTabs.test.tsx +++ b/app/tests/components/paneTabs.test.tsx @@ -50,4 +50,24 @@ describe('PaneTabs', () => { ); expect(onSelect).toHaveBeenCalledWith('readme'); }); + + it('ArrowLeft wraps from the first tab to the last', async () => { + const onSelect = vi.fn(); + render(, container); + await flush(); + tabByLabel('World').dispatchEvent( + new KeyboardEvent('keydown', { key: 'ArrowLeft', bubbles: true }), + ); + expect(onSelect).toHaveBeenCalledWith('readme'); + }); + + it('ArrowRight wraps from the last tab to the first', async () => { + const onSelect = vi.fn(); + render(, container); + await flush(); + tabByLabel('Readme').dispatchEvent( + new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true }), + ); + expect(onSelect).toHaveBeenCalledWith('world'); + }); }); From 8a244643199f64123511d930d69cc3d3d306e7d8 Mon Sep 17 00:00:00 2001 From: Thalida Noel Date: Tue, 16 Jun 2026 00:09:52 -0400 Subject: [PATCH 10/65] refactor(info): extract ReadmePane body from InfoPane MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move README fetch/render logic verbatim into ReadmePane (body-only, no Pane chrome). InfoPane delegates to it — rendered DOM is identical. Co-Authored-By: Claude Sonnet 4.6 --- app/src/views/InfoPane/InfoPane.tsx | 133 +---------------------- app/src/views/InfoPane/ReadmePane.tsx | 146 ++++++++++++++++++++++++++ 2 files changed, 150 insertions(+), 129 deletions(-) create mode 100644 app/src/views/InfoPane/ReadmePane.tsx diff --git a/app/src/views/InfoPane/InfoPane.tsx b/app/src/views/InfoPane/InfoPane.tsx index d112ed01..d6080bc2 100644 --- a/app/src/views/InfoPane/InfoPane.tsx +++ b/app/src/views/InfoPane/InfoPane.tsx @@ -5,145 +5,20 @@ // edit to the README on disk shows up here without a page reload. import './InfoPane.css'; -import { useState, useEffect } from 'preact/hooks'; -import { effect } from '@preact/signals'; import type { Signal } from '@preact/signals'; -import { fetchFileText } from '@/api/file'; -import { BookOpen, FileWarning, FolderOpen } from 'lucide-preact'; -import { Marked } from 'marked'; -import { NodeKind } from '@/types'; -import type { DirNode, FileNode, Manifest } from '@/types'; -import { Pane, PaneEmpty } from '@/components/Pane'; -import { isEmptyManifest } from '@/utils/manifest'; -import { resolveReadmeAssetUrl, rewriteHtmlImageUrls } from '@/utils/readmeAssets'; - -/** - * Render README markdown to HTML, rewriting relative image refs to route - * through /api/file (resolved against the README's directory) so they load - * instead of 404ing against the app origin. The href is mutated on the image - * token before rendering, so marked's default renderer still handles all HTML - * escaping. - */ -function _renderReadme(text: string, readmeFullPath: string): string { - const md = new Marked(); - md.use({ - walkTokens(token) { - if (token.type === 'image') { - token.href = resolveReadmeAssetUrl(token.href, readmeFullPath); - } else if (token.type === 'html') { - // READMEs often use raw (for width/align) rather than - // markdown ![](…); those arrive as html tokens, not image tokens. - token.text = rewriteHtmlImageUrls(token.text, readmeFullPath); - } - }, - }); - return md.parse(text) as string; -} - -// Match README, README.md, readme.markdown, README.txt — any file whose -// stem (case-insensitive) is "readme". GitHub/VSCode use the same rule. -const README_BASE_NAME = 'readme'; - -function _findRootReadme(manifest: Manifest | DirNode | null): FileNode | null { - if (!manifest) return null; - const tree = - 'tree' in manifest && (manifest as Manifest).tree - ? (manifest as Manifest).tree - : (manifest as DirNode); - if (!tree || !('children' in tree) || !tree.children) return null; - for (let i = 0; i < tree.children.length; i++) { - const c = tree.children[i]; - if (c.type !== NodeKind.File) continue; - const name = (c.name || '').toLowerCase(); - if (name === README_BASE_NAME || name.indexOf(`${README_BASE_NAME}.`) === 0) return c; - } - return null; -} - -// ── State shape for Preact component ───────────────────────────────────────── - -// Discriminant for the README panel body state. Enum (not an inline string -// union) to match the NodeKind discriminant pattern used elsewhere. -export enum InfoBodyKind { - NoProject = 'no-project', - NoReadme = 'no-readme', - Loading = 'loading', - Markdown = 'markdown', - Error = 'error', -} - -type InfoBodyState = - | { kind: InfoBodyKind.NoProject } - | { kind: InfoBodyKind.NoReadme } - | { kind: InfoBodyKind.Loading } - | { kind: InfoBodyKind.Markdown; html: string } - | { kind: InfoBodyKind.Error; message: string }; +import type { DirNode, Manifest } from '@/types'; +import { Pane } from '@/components/Pane'; +import { ReadmePane } from './ReadmePane'; export interface InfoPaneProps { manifest: Signal; onClose?: () => void; } -// ── Preact component ───────────────────────────────────────────────────────── - export function InfoPane({ manifest, onClose }: InfoPaneProps) { - const [body, setBody] = useState({ kind: InfoBodyKind.NoProject }); - - useEffect(() => { - let cancelled = false; - - const doFetch = (m: Manifest | DirNode | { tree?: unknown; [k: string]: unknown } | null) => { - if (isEmptyManifest(m)) { - setBody({ kind: InfoBodyKind.NoProject }); - return; - } - const readme = _findRootReadme(m as Manifest | DirNode | null); - if (!readme || !readme.fullPath) { - setBody({ kind: InfoBodyKind.NoReadme }); - return; - } - setBody({ kind: InfoBodyKind.Loading }); - const readmePath = readme.fullPath; - fetchFileText(readmePath) - .then((text) => { - if (!cancelled) - setBody({ kind: InfoBodyKind.Markdown, html: _renderReadme(text, readmePath) }); - }) - .catch((err) => { - if (!cancelled) - setBody({ kind: InfoBodyKind.Error, message: (err && err.message) || 'Unknown error' }); - }); - }; - - // effect() fires once immediately + on every manifest change. - const unsub = effect(() => { - doFetch(manifest.value); - }); - - return () => { - cancelled = true; - unsub(); - }; - }, [manifest]); - return ( - {body.kind === InfoBodyKind.NoProject && ( - - )} - {body.kind === InfoBodyKind.NoReadme && ( - - )} - {body.kind === InfoBodyKind.Error && ( - - )} - {body.kind === InfoBodyKind.Markdown && ( -
- )} + ); } diff --git a/app/src/views/InfoPane/ReadmePane.tsx b/app/src/views/InfoPane/ReadmePane.tsx new file mode 100644 index 00000000..2d83c286 --- /dev/null +++ b/app/src/views/InfoPane/ReadmePane.tsx @@ -0,0 +1,146 @@ +// views/InfoPane/ReadmePane.tsx — renders the body of the README panel: +// finds the root README in the manifest, fetches + renders it as markdown, +// and shows appropriate empty states. No wrapper — InfoPane owns the chrome. + +import './InfoPane.css'; +import { useState, useEffect } from 'preact/hooks'; +import { effect } from '@preact/signals'; +import type { Signal } from '@preact/signals'; +import { fetchFileText } from '@/api/file'; +import { BookOpen, FileWarning, FolderOpen } from 'lucide-preact'; +import { Marked } from 'marked'; +import { NodeKind } from '@/types'; +import type { DirNode, FileNode, Manifest } from '@/types'; +import { PaneEmpty } from '@/components/Pane'; +import { isEmptyManifest } from '@/utils/manifest'; +import { resolveReadmeAssetUrl, rewriteHtmlImageUrls } from '@/utils/readmeAssets'; + +/** + * Render README markdown to HTML, rewriting relative image refs to route + * through /api/file (resolved against the README's directory) so they load + * instead of 404ing against the app origin. The href is mutated on the image + * token before rendering, so marked's default renderer still handles all HTML + * escaping. + */ +function _renderReadme(text: string, readmeFullPath: string): string { + const md = new Marked(); + md.use({ + walkTokens(token) { + if (token.type === 'image') { + token.href = resolveReadmeAssetUrl(token.href, readmeFullPath); + } else if (token.type === 'html') { + // READMEs often use raw (for width/align) rather than + // markdown ![](…); those arrive as html tokens, not image tokens. + token.text = rewriteHtmlImageUrls(token.text, readmeFullPath); + } + }, + }); + return md.parse(text) as string; +} + +// Match README, README.md, readme.markdown, README.txt — any file whose +// stem (case-insensitive) is "readme". GitHub/VSCode use the same rule. +const README_BASE_NAME = 'readme'; + +function _findRootReadme(manifest: Manifest | DirNode | null): FileNode | null { + if (!manifest) return null; + const tree = + 'tree' in manifest && (manifest as Manifest).tree + ? (manifest as Manifest).tree + : (manifest as DirNode); + if (!tree || !('children' in tree) || !tree.children) return null; + for (let i = 0; i < tree.children.length; i++) { + const c = tree.children[i]; + if (c.type !== NodeKind.File) continue; + const name = (c.name || '').toLowerCase(); + if (name === README_BASE_NAME || name.indexOf(`${README_BASE_NAME}.`) === 0) return c; + } + return null; +} + +// ── State shape ─────────────────────────────────────────────────────────────── + +// Discriminant for the README panel body state. Enum (not an inline string +// union) to match the NodeKind discriminant pattern used elsewhere. +export enum InfoBodyKind { + NoProject = 'no-project', + NoReadme = 'no-readme', + Loading = 'loading', + Markdown = 'markdown', + Error = 'error', +} + +type InfoBodyState = + | { kind: InfoBodyKind.NoProject } + | { kind: InfoBodyKind.NoReadme } + | { kind: InfoBodyKind.Loading } + | { kind: InfoBodyKind.Markdown; html: string } + | { kind: InfoBodyKind.Error; message: string }; + +export interface ReadmePaneProps { + manifest: Signal; +} + +// ── Preact component ───────────────────────────────────────────────────────── + +export function ReadmePane({ manifest }: ReadmePaneProps) { + const [body, setBody] = useState({ kind: InfoBodyKind.NoProject }); + + useEffect(() => { + let cancelled = false; + + const doFetch = (m: Manifest | DirNode | { tree?: unknown; [k: string]: unknown } | null) => { + if (isEmptyManifest(m)) { + setBody({ kind: InfoBodyKind.NoProject }); + return; + } + const readme = _findRootReadme(m as Manifest | DirNode | null); + if (!readme || !readme.fullPath) { + setBody({ kind: InfoBodyKind.NoReadme }); + return; + } + setBody({ kind: InfoBodyKind.Loading }); + const readmePath = readme.fullPath; + fetchFileText(readmePath) + .then((text) => { + if (!cancelled) + setBody({ kind: InfoBodyKind.Markdown, html: _renderReadme(text, readmePath) }); + }) + .catch((err) => { + if (!cancelled) + setBody({ kind: InfoBodyKind.Error, message: (err && err.message) || 'Unknown error' }); + }); + }; + + // effect() fires once immediately + on every manifest change. + const unsub = effect(() => { + doFetch(manifest.value); + }); + + return () => { + cancelled = true; + unsub(); + }; + }, [manifest]); + + return ( + <> + {body.kind === InfoBodyKind.NoProject && ( + + )} + {body.kind === InfoBodyKind.NoReadme && ( + + )} + {body.kind === InfoBodyKind.Error && ( + + )} + {body.kind === InfoBodyKind.Markdown && ( +
+ )} + + ); +} From 4b54ad3b11ee466ea30072ab161701dd4d22baaa Mon Sep 17 00:00:00 2001 From: Thalida Noel Date: Tue, 16 Jun 2026 00:18:52 -0400 Subject: [PATCH 11/65] feat(info): add WorldPane almanac view with clickable landmarks Renders the World Almanac (overview + superlative sections) in a body-only pane. Landmark rows are buttons that call selectPath/focusPath (file/dir) or selectCommit/focusCommit (commit) to fly the camera. Two unit tests (empty state + landmark click) pass with TDD flow. Co-Authored-By: Claude Sonnet 4.6 --- app/src/views/InfoPane/WorldPane.css | 123 ++++++++++++++++++++ app/src/views/InfoPane/WorldPane.tsx | 96 +++++++++++++++ app/tests/views/InfoPane/worldPane.test.tsx | 63 ++++++++++ 3 files changed, 282 insertions(+) create mode 100644 app/src/views/InfoPane/WorldPane.css create mode 100644 app/src/views/InfoPane/WorldPane.tsx create mode 100644 app/tests/views/InfoPane/worldPane.test.tsx diff --git a/app/src/views/InfoPane/WorldPane.css b/app/src/views/InfoPane/WorldPane.css new file mode 100644 index 00000000..c9a1136b --- /dev/null +++ b/app/src/views/InfoPane/WorldPane.css @@ -0,0 +1,123 @@ +.almanac { + padding: 12px 14px; + display: flex; + flex-direction: column; + gap: 18px; +} + +.almanac-name { + margin: 0 0 4px; + font-size: var(--cc-font-xl); + color: var(--cc-text-primary); +} + +.almanac-blurb { + margin: 0 0 10px; + color: var(--cc-text-muted); + line-height: 1.5; + font-size: var(--cc-font-lg); +} + +.almanac-meta { + margin: 0; + display: flex; + flex-direction: column; + gap: 4px; +} + +.almanac-meta div { + display: flex; + gap: 8px; + font-size: var(--cc-font-lg); +} + +.almanac-meta dt { + color: var(--cc-text-muted); + min-width: 56px; +} + +.almanac-meta dd { + margin: 0; + color: var(--cc-text-secondary); + overflow: hidden; + text-overflow: ellipsis; +} + +.almanac-meta a { + color: var(--cc-accent-light); + text-decoration: none; +} + +.almanac-meta a:hover { + text-decoration: underline; +} + +.almanac-languages { + list-style: none; + margin: 10px 0 0; + padding: 0; + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.almanac-languages li { + font-size: var(--cc-font-lg); + background: var(--cc-accent-bg); + border-radius: var(--cc-radius-sm); + padding: 2px 6px; + color: var(--cc-text-secondary); +} + +.almanac-lang-ext { + font-family: var(--cc-font-mono); + color: var(--cc-text-primary); +} + +.almanac-section-title { + margin: 0 0 6px; + font-size: var(--cc-font-md); + font-weight: var(--cc-fw-semibold); + text-transform: uppercase; + letter-spacing: var(--cc-track-label); + color: var(--cc-text-muted); +} + +.almanac-fact { + display: flex; + justify-content: space-between; + gap: 10px; + width: 100%; + padding: 6px 8px; + border-radius: var(--cc-radius-sm); + font-size: var(--cc-font-lg); + text-align: left; +} + +.almanac-fact-label { + color: var(--cc-text-muted); + white-space: nowrap; +} + +.almanac-fact-value { + color: var(--cc-text-secondary); + overflow: hidden; + text-overflow: ellipsis; +} + +button.almanac-fact--landmark { + border: 0; + background: transparent; + font: inherit; + cursor: pointer; + transition: background var(--cc-t-fast); +} + +button.almanac-fact--landmark:hover { + background: var(--cc-overlay-bg); +} + +button.almanac-fact--landmark:hover .almanac-fact-label, +button.almanac-fact--landmark:hover .almanac-fact-value { + color: var(--cc-text-primary); +} diff --git a/app/src/views/InfoPane/WorldPane.tsx b/app/src/views/InfoPane/WorldPane.tsx new file mode 100644 index 00000000..7e245ef0 --- /dev/null +++ b/app/src/views/InfoPane/WorldPane.tsx @@ -0,0 +1,96 @@ +// views/InfoPane/WorldPane.tsx — the "World" subtab: a travel-guide of repo +// superlatives derived by computeAlmanac. Landmark rows fly the camera to that +// building/tree. Body-only — InfoPane owns the Pane chrome. + +import './WorldPane.css'; +import { useMemo } from 'preact/hooks'; +import type { Signal } from '@preact/signals'; +import { FolderOpen } from 'lucide-preact'; +import type { DirNode, Manifest } from '@/types'; +import { PaneEmpty } from '@/components/Pane'; +import { selectPath, focusPath, selectCommit, focusCommit } from '@/state/stores/scene'; +import { computeAlmanac } from './almanac'; +import type { AlmanacFact, LandmarkRef } from './almanac'; + +function visit(landmark: LandmarkRef): void { + if (landmark.kind === 'commit') { + selectCommit(landmark.id); + focusCommit(landmark.id); + } else { + selectPath(landmark.id); + focusPath(landmark.id); + } +} + +function FactRow({ fact }: { fact: AlmanacFact }) { + if (fact.landmark) { + const landmark = fact.landmark; + return ( + + ); + } + return ( +
+ {fact.label} + {fact.value} +
+ ); +} + +export interface WorldPaneProps { + manifest: Signal; +} + +export function WorldPane({ manifest }: WorldPaneProps) { + const current = manifest.value; + const almanac = useMemo(() => computeAlmanac(current as Manifest | DirNode | null), [current]); + + if (!almanac) { + return ; + } + + const { overview, sections } = almanac; + return ( +
+
+

{overview.name}

+

+ {overview.founded ? `Founded ${overview.founded}, ` : ''} + this city of {overview.totals.files.toLocaleString('en-US')} buildings sprawls across{' '} + {overview.totals.dirs.toLocaleString('en-US')} districts + {overview.totals.authors > 0 + ? `, tended by ${overview.totals.authors.toLocaleString('en-US')} fireflies` + : ''} + . +

+
+ {overview.repo.branch && ( +
Branch
{overview.repo.branch}
+ )} + {overview.repo.remote_url && ( + + )} + {overview.repo.head_subject && ( +
Latest
{overview.repo.head_subject}{overview.repo.dirty ? ' (uncommitted changes)' : ''}
+ )} +
+ {overview.languages.length > 0 && ( +
    + {overview.languages.map((l) => ( +
  • {l.ext} {l.count.toLocaleString('en-US')}
  • + ))} +
+ )} +
+ {sections.map((s) => ( +
+

{s.title}

+ {s.facts.map((f) => )} +
+ ))} +
+ ); +} diff --git a/app/tests/views/InfoPane/worldPane.test.tsx b/app/tests/views/InfoPane/worldPane.test.tsx new file mode 100644 index 00000000..0d0ce7e5 --- /dev/null +++ b/app/tests/views/InfoPane/worldPane.test.tsx @@ -0,0 +1,63 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { signal } from '@preact/signals'; +import { render } from 'preact'; +import { flush } from '../../_helpers/preact'; + +const { selectPath, focusPath, selectCommit, focusCommit } = vi.hoisted(() => ({ + selectPath: vi.fn(), + focusPath: vi.fn(), + selectCommit: vi.fn(), + focusCommit: vi.fn(), +})); +vi.mock('@/state/stores/scene', () => ({ selectPath, focusPath, selectCommit, focusCommit })); + +import { WorldPane } from '@/views/InfoPane/WorldPane'; +import { NodeKind } from '@/types'; +import type { Manifest } from '@/types'; + +const tree = { + name: 'repo', type: NodeKind.Directory, path: '', fullPath: '/repo', + children: [ + { name: 'a.ts', type: NodeKind.File, path: 'a.ts', fullPath: '/repo/a.ts', extension: '.ts', size: 10, lines: 3, binary: false, created: '2020-01-01T00:00:00Z', modified: '2020-01-01T00:00:00Z' }, + ], + children_count: 1, children_file_count: 1, children_dir_count: 0, + descendants_count: 1, descendants_file_count: 1, descendants_dir_count: 0, + descendants_size: 10, descendants_ext_breakdown: [{ ext: '.ts', count: 1, size: 10 }], +}; +const manifest: Manifest = { + root: '/repo', scanned_at: '2024-01-01T00:00:00Z', signature: 's', tree_signature: 't', + tree: tree as unknown as Manifest['tree'], + repo: { branch: 'main', remote_url: null, head_sha: null, head_subject: null, dirty: false }, + commits: [], busyness: { avg: 1, busy: 2 }, + dateRanges: { createdMin: '2020-01-01T00:00:00Z', createdMax: '2020-01-01T00:00:00Z', modifiedMin: '2020-01-01T00:00:00Z', modifiedMax: '2020-01-01T00:00:00Z' }, +}; + +describe('WorldPane', () => { + let container: HTMLDivElement; + beforeEach(() => { + container = document.createElement('div'); + document.body.appendChild(container); + selectPath.mockClear(); focusPath.mockClear(); selectCommit.mockClear(); focusCommit.mockClear(); + }); + afterEach(() => { render(null, container); container.remove(); }); + + it('renders the empty state when there is no project', async () => { + const sig = signal(null); + render(, container); + await flush(); + expect(container.textContent).toContain('No project loaded'); + }); + + it('clicking a building landmark selects + focuses its file', async () => { + const sig = signal(manifest); + render(, container); + await flush(); + const btn = Array.from(container.querySelectorAll('button')).find( + (b) => b.textContent?.includes('Tallest building'), + ) as HTMLButtonElement; + expect(btn).toBeTruthy(); + btn.click(); + expect(selectPath).toHaveBeenCalledWith('a.ts'); + expect(focusPath).toHaveBeenCalledWith('a.ts'); + }); +}); From 28b28a47da39d25854704bf6a9aee6d9afdf0114 Mon Sep 17 00:00:00 2001 From: Thalida Noel Date: Tue, 16 Jun 2026 00:22:43 -0400 Subject: [PATCH 12/65] refactor(worldpane): reuse fmtCount, index keys, cover commit + non-landmark rows Co-Authored-By: Claude Sonnet 4.6 --- app/src/views/InfoPane/WorldPane.tsx | 12 +++---- app/src/views/InfoPane/almanac.ts | 2 +- app/tests/views/InfoPane/worldPane.test.tsx | 36 +++++++++++++++++++++ 3 files changed, 43 insertions(+), 7 deletions(-) diff --git a/app/src/views/InfoPane/WorldPane.tsx b/app/src/views/InfoPane/WorldPane.tsx index 7e245ef0..9ad782f6 100644 --- a/app/src/views/InfoPane/WorldPane.tsx +++ b/app/src/views/InfoPane/WorldPane.tsx @@ -9,7 +9,7 @@ import { FolderOpen } from 'lucide-preact'; import type { DirNode, Manifest } from '@/types'; import { PaneEmpty } from '@/components/Pane'; import { selectPath, focusPath, selectCommit, focusCommit } from '@/state/stores/scene'; -import { computeAlmanac } from './almanac'; +import { computeAlmanac, fmtCount } from './almanac'; import type { AlmanacFact, LandmarkRef } from './almanac'; function visit(landmark: LandmarkRef): void { @@ -59,10 +59,10 @@ export function WorldPane({ manifest }: WorldPaneProps) {

{overview.name}

{overview.founded ? `Founded ${overview.founded}, ` : ''} - this city of {overview.totals.files.toLocaleString('en-US')} buildings sprawls across{' '} - {overview.totals.dirs.toLocaleString('en-US')} districts + this city of {fmtCount(overview.totals.files)} buildings sprawls across{' '} + {fmtCount(overview.totals.dirs)} districts {overview.totals.authors > 0 - ? `, tended by ${overview.totals.authors.toLocaleString('en-US')} fireflies` + ? `, tended by ${fmtCount(overview.totals.authors)} fireflies` : ''} .

@@ -80,7 +80,7 @@ export function WorldPane({ manifest }: WorldPaneProps) { {overview.languages.length > 0 && (
    {overview.languages.map((l) => ( -
  • {l.ext} {l.count.toLocaleString('en-US')}
  • +
  • {l.ext} {fmtCount(l.count)}
  • ))}
)} @@ -88,7 +88,7 @@ export function WorldPane({ manifest }: WorldPaneProps) { {sections.map((s) => (

{s.title}

- {s.facts.map((f) => )} + {s.facts.map((f, i) => )}
))} diff --git a/app/src/views/InfoPane/almanac.ts b/app/src/views/InfoPane/almanac.ts index fc9d1701..d793e182 100644 --- a/app/src/views/InfoPane/almanac.ts +++ b/app/src/views/InfoPane/almanac.ts @@ -66,7 +66,7 @@ function distinctAuthors(commits: Manifest['commits']): number { return set.size; } -function fmtCount(n: number): string { +export function fmtCount(n: number): string { return n.toLocaleString('en-US'); } diff --git a/app/tests/views/InfoPane/worldPane.test.tsx b/app/tests/views/InfoPane/worldPane.test.tsx index 0d0ce7e5..3e3a885f 100644 --- a/app/tests/views/InfoPane/worldPane.test.tsx +++ b/app/tests/views/InfoPane/worldPane.test.tsx @@ -60,4 +60,40 @@ describe('WorldPane', () => { expect(selectPath).toHaveBeenCalledWith('a.ts'); expect(focusPath).toHaveBeenCalledWith('a.ts'); }); + + it('clicking a commit landmark selects + focuses the commit', async () => { + const withCommits: Manifest = { + ...manifest, + commits: [ + { date: '2022-01-01', files: 9, sha: 'abc1234', authors: ['Ada'], subject: 'x', same_day_total: 1 }, + ], + }; + const sig = signal(withCommits); + render(, container); + await flush(); + const btn = Array.from(container.querySelectorAll('button')).find( + (b) => b.textContent?.includes('Grandest canopy'), + ) as HTMLButtonElement; + expect(btn).toBeTruthy(); + btn.click(); + expect(selectCommit).toHaveBeenCalledWith('abc1234'); + expect(focusCommit).toHaveBeenCalledWith('abc1234'); + }); + + it('renders non-landmark facts as non-button rows', async () => { + const withCommits: Manifest = { + ...manifest, + commits: [ + { date: '2022-01-01', files: 9, sha: 'abc1234', authors: ['Ada'], subject: 'x', same_day_total: 1 }, + ], + }; + const sig = signal(withCommits); + render(, container); + await flush(); + const row = Array.from(container.querySelectorAll('.almanac-fact')).find( + (el) => el.textContent?.includes('Busiest day'), + ) as HTMLElement; + expect(row).toBeTruthy(); + expect(row.tagName).toBe('DIV'); + }); }); From b52e274a849d98cd72cf52f00634e86d6272b223 Mon Sep 17 00:00:00 2001 From: Thalida Noel Date: Tue, 16 Jun 2026 00:28:12 -0400 Subject: [PATCH 13/65] feat(info): split Info tab into World | Readme subtabs Rewrites InfoPane as a PaneTabs shell hosting World (almanac, default) and Readme subtabs; body-only panes delegate chrome to the parent. Co-Authored-By: Claude Sonnet 4.6 --- app/src/views/InfoPane/InfoPane.tsx | 27 +++++++++++----- app/tests/views/InfoPane/infoPane.test.tsx | 36 ++++++++++++++++++++++ 2 files changed, 56 insertions(+), 7 deletions(-) create mode 100644 app/tests/views/InfoPane/infoPane.test.tsx diff --git a/app/src/views/InfoPane/InfoPane.tsx b/app/src/views/InfoPane/InfoPane.tsx index d6080bc2..04dca4b1 100644 --- a/app/src/views/InfoPane/InfoPane.tsx +++ b/app/src/views/InfoPane/InfoPane.tsx @@ -1,24 +1,37 @@ -// views/InfoPane.tsx — "Info" tab in the left sidebar. Shows the -// rendered markdown of the project's root README (if any) — README.md, -// README.markdown, README, etc. Re-fetches and re-renders whenever the -// manifest is re-applied (which happens on live-update polling), so an -// edit to the README on disk shows up here without a page reload. +// views/InfoPane/InfoPane.tsx — the "Info" tab shell. Hosts two subtabs: +// World (the almanac, default) and Readme (the rendered root README). Owns the +// Pane chrome + active-subtab state; the subtab bodies render themselves. import './InfoPane.css'; +import { useState } from 'preact/hooks'; import type { Signal } from '@preact/signals'; +import { Globe, BookOpen } from 'lucide-preact'; import type { DirNode, Manifest } from '@/types'; import { Pane } from '@/components/Pane'; +import { PaneTabs } from '@/components/PaneTabs/PaneTabs'; +import { WorldPane } from './WorldPane'; import { ReadmePane } from './ReadmePane'; +type InfoTab = 'world' | 'readme'; + +const INFO_TABS = [ + { id: 'world', label: 'World', icon: Globe }, + { id: 'readme', label: 'Readme', icon: BookOpen }, +]; + export interface InfoPaneProps { manifest: Signal; onClose?: () => void; } export function InfoPane({ manifest, onClose }: InfoPaneProps) { + const [tab, setTab] = useState('world'); return ( - - + + setTab(id as InfoTab)} /> +
+ {tab === 'world' ? : } +
); } diff --git a/app/tests/views/InfoPane/infoPane.test.tsx b/app/tests/views/InfoPane/infoPane.test.tsx new file mode 100644 index 00000000..5c2cd59f --- /dev/null +++ b/app/tests/views/InfoPane/infoPane.test.tsx @@ -0,0 +1,36 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { signal } from '@preact/signals'; +import { render } from 'preact'; +import { InfoPane } from '@/views/InfoPane/InfoPane'; +import { flush } from '../../_helpers/preact'; + +describe('InfoPane shell', () => { + let container: HTMLDivElement; + beforeEach(() => { + container = document.createElement('div'); + document.body.appendChild(container); + }); + afterEach(() => { render(null, container); container.remove(); }); + + const tabByLabel = (label: string) => + Array.from(container.querySelectorAll('[role="tab"]')).find( + (el) => el.textContent === label, + ) as HTMLButtonElement; + + it('defaults to the World subtab', async () => { + const sig = signal(null); + render(, container); + await flush(); + expect(tabByLabel('World').getAttribute('aria-selected')).toBe('true'); + expect(tabByLabel('Readme').getAttribute('aria-selected')).toBe('false'); + }); + + it('switches to Readme when its tab is clicked', async () => { + const sig = signal(null); + render(, container); + await flush(); + tabByLabel('Readme').click(); + await flush(); + expect(tabByLabel('Readme').getAttribute('aria-selected')).toBe('true'); + }); +}); From b818eda21c4d2f848bce301141cc4f01cccd1def Mon Sep 17 00:00:00 2001 From: Thalida Noel Date: Tue, 16 Jun 2026 00:44:26 -0400 Subject: [PATCH 14/65] feat(worldpane): clean stacked almanac layout, dates, forest gating MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Restructure facts into primary (identifier, truncates) + secondary (metric), rendered as stacked rows (label over value) instead of justified pairs — long paths/shas truncate from the left so filenames stay visible. - Show the actual created/edited date on the oldest/newest/brightest/faded rows. - Gate the Forest section on TREES.ENABLED; show a help note when off, so its canopy landmarks can't be dead-clicked with the Trees layer hidden. - Fix self-referential --cc-font-mono token (was var(--cc-font-mono)) with a real monospace stack — affected mono text app-wide. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/src/styles/tokens.css | 3 +- app/src/views/InfoPane/WorldPane.css | 50 ++++++++++++++++----- app/src/views/InfoPane/WorldPane.tsx | 39 +++++++++++++--- app/src/views/InfoPane/almanac.ts | 41 ++++++++++------- app/tests/views/InfoPane/almanac.test.ts | 15 ++++--- app/tests/views/InfoPane/worldPane.test.tsx | 32 +++++++++++++ 6 files changed, 142 insertions(+), 38 deletions(-) diff --git a/app/src/styles/tokens.css b/app/src/styles/tokens.css index e179ddbb..726e5323 100644 --- a/app/src/styles/tokens.css +++ b/app/src/styles/tokens.css @@ -147,7 +147,8 @@ /* ── SCALES — Font families ───────────────────────────────────────────── */ --cc-font-sans: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif; - --cc-font-mono: var(--cc-font-mono); + --cc-font-mono: + ui-monospace, 'SF Mono', 'JetBrains Mono', Menlo, Consolas, 'Liberation Mono', monospace; /* ── SCALES — Letter-spacing (tracking) ──────────────────────────────── */ --cc-track-label: 0.08em; /* uppercase section labels */ diff --git a/app/src/views/InfoPane/WorldPane.css b/app/src/views/InfoPane/WorldPane.css index c9a1136b..cd01dc28 100644 --- a/app/src/views/InfoPane/WorldPane.css +++ b/app/src/views/InfoPane/WorldPane.css @@ -83,28 +83,61 @@ color: var(--cc-text-muted); } +.almanac-section-note { + margin: 0; + padding: 6px 8px; + color: var(--cc-text-muted); + font-size: var(--cc-font-lg); + font-style: italic; +} + .almanac-fact { display: flex; - justify-content: space-between; - gap: 10px; + flex-direction: column; + gap: 1px; width: 100%; padding: 6px 8px; border-radius: var(--cc-radius-sm); - font-size: var(--cc-font-lg); text-align: left; } .almanac-fact-label { + font-size: var(--cc-font-md); color: var(--cc-text-muted); - white-space: nowrap; } -.almanac-fact-value { - color: var(--cc-text-secondary); +.almanac-fact-body { + display: flex; + align-items: baseline; + gap: 8px; + min-width: 0; +} + +.almanac-fact-primary { + flex: 1 1 auto; + min-width: 0; + color: var(--cc-text-primary); + font-size: var(--cc-font-lg); + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +/* Paths/shas read left-to-right but matter most at the END (filename, short + * sha), so truncate from the left — the ellipsis leads and the tail stays. */ +.almanac-fact-primary--mono { + font-family: var(--cc-font-mono); + direction: rtl; + text-align: left; +} + +.almanac-fact-secondary { + flex: 0 0 auto; + color: var(--cc-text-muted); + font-size: var(--cc-font-md); + white-space: nowrap; +} + button.almanac-fact--landmark { border: 0; background: transparent; @@ -116,8 +149,3 @@ button.almanac-fact--landmark { button.almanac-fact--landmark:hover { background: var(--cc-overlay-bg); } - -button.almanac-fact--landmark:hover .almanac-fact-label, -button.almanac-fact--landmark:hover .almanac-fact-value { - color: var(--cc-text-primary); -} diff --git a/app/src/views/InfoPane/WorldPane.tsx b/app/src/views/InfoPane/WorldPane.tsx index 9ad782f6..ca834fe5 100644 --- a/app/src/views/InfoPane/WorldPane.tsx +++ b/app/src/views/InfoPane/WorldPane.tsx @@ -9,6 +9,7 @@ import { FolderOpen } from 'lucide-preact'; import type { DirNode, Manifest } from '@/types'; import { PaneEmpty } from '@/components/Pane'; import { selectPath, focusPath, selectCommit, focusCommit } from '@/state/stores/scene'; +import { TREES } from '@/state/stores/settings/trees'; import { computeAlmanac, fmtCount } from './almanac'; import type { AlmanacFact, LandmarkRef } from './almanac'; @@ -22,20 +23,39 @@ function visit(landmark: LandmarkRef): void { } } +function FactBody({ fact }: { fact: AlmanacFact }) { + return ( + <> + {fact.label} + + + {fact.primary} + + {fact.secondary && {fact.secondary}} + + + ); +} + function FactRow({ fact }: { fact: AlmanacFact }) { if (fact.landmark) { const landmark = fact.landmark; return ( - ); } return (
- {fact.label} - {fact.value} +
); } @@ -53,6 +73,9 @@ export function WorldPane({ manifest }: WorldPaneProps) { } const { overview, sections } = almanac; + // Forest landmarks (canopies) fly the camera to a tree; when the Trees layer + // is off those targets don't exist, so gate the section's contents on it. + const treesEnabled = TREES.value.ENABLED; return (
@@ -88,7 +111,11 @@ export function WorldPane({ manifest }: WorldPaneProps) { {sections.map((s) => (

{s.title}

- {s.facts.map((f, i) => )} + {s.key === 'forest' && !treesEnabled ? ( +

Enable the Trees layer in Settings to explore the forest.

+ ) : ( + s.facts.map((f, i) => ) + )}
))}
diff --git a/app/src/views/InfoPane/almanac.ts b/app/src/views/InfoPane/almanac.ts index d793e182..e2ae0eda 100644 --- a/app/src/views/InfoPane/almanac.ts +++ b/app/src/views/InfoPane/almanac.ts @@ -16,7 +16,12 @@ export interface LandmarkRef { export interface AlmanacFact { label: string; - value: string; + /** Headline value — a path, sha, name, date, or count. Truncates when long. */ + primary: string; + /** Muted metric shown beside the primary (e.g. "1,843 lines", "Created Apr 18, 2026"). */ + secondary?: string; + /** Render the primary in mono with left-truncation (paths and shas). */ + mono?: boolean; /** Present → the row flies the camera to this landmark on click. */ landmark?: LandmarkRef; } @@ -97,9 +102,9 @@ function pickFile(files: FileNode[], score: (f: FileNode) => number): FileNode | return best; } -function fileFact(label: string, file: FileNode | null, detail: string): AlmanacFact | null { +function fileFact(label: string, file: FileNode | null, secondary: string): AlmanacFact | null { if (!file) return null; - return { label, value: `${file.path} · ${detail}`, landmark: { kind: 'file', id: file.path } }; + return { label, primary: file.path, secondary, mono: true, landmark: { kind: 'file', id: file.path } }; } function buildOverview(m: Manifest): AlmanacOverview { @@ -121,19 +126,25 @@ function buildOverview(m: Manifest): AlmanacOverview { } function buildingsSection(files: FileNode[]): AlmanacSection { + const oldest = pickFile(files, (f) => -dateMs(f.created)); + const newest = pickFile(files, (f) => dateMs(f.created)); const tallest = pickFile(files, (f) => f.lines); const shortest = pickFile(files, (f) => -f.lines); const widest = pickFile(files, (f) => f.size); const narrowest = pickFile(files, (f) => -f.size); + const brightest = pickFile(files, (f) => dateMs(f.modified)); + const faded = pickFile(files, (f) => -dateMs(f.modified)); + const created = (f: FileNode | null) => (f ? `Created ${formatShortDate(f.created)}` : ''); + const edited = (f: FileNode | null) => (f ? `Edited ${formatShortDate(f.modified)}` : ''); const facts = [ - fileFact('Oldest building', pickFile(files, (f) => -dateMs(f.created)), 'first raised'), - fileFact('Newest building', pickFile(files, (f) => dateMs(f.created)), 'most recently raised'), + fileFact('Oldest building', oldest, created(oldest)), + fileFact('Newest building', newest, created(newest)), fileFact('Tallest building', tallest, tallest ? pluralize(tallest.lines, 'line') : ''), fileFact('Shortest building', shortest, shortest ? pluralize(shortest.lines, 'line') : ''), fileFact('Widest building', widest, widest ? formatBytes(widest.size) : ''), fileFact('Narrowest building', narrowest, narrowest ? formatBytes(narrowest.size) : ''), - fileFact('Brightest building', pickFile(files, (f) => dateMs(f.modified)), 'freshly touched'), - fileFact('Most faded building', pickFile(files, (f) => -dateMs(f.modified)), 'long untouched'), + fileFact('Brightest building', brightest, edited(brightest)), + fileFact('Most faded building', faded, edited(faded)), ]; return { key: 'buildings', title: 'Buildings', facts: facts.filter((f): f is AlmanacFact => f !== null) }; } @@ -155,8 +166,8 @@ function streetsSection(dirs: DirNode[]): AlmanacSection | null { key: 'streets', title: 'Streets', facts: [ - { label: 'Deepest alley', value: `${deepest.path} · ${deepestDepth} levels deep`, landmark: { kind: 'dir', id: deepest.path } }, - { label: 'Biggest neighborhood', value: `${biggest.path} · ${pluralize(biggest.descendants_file_count, 'building')}`, landmark: { kind: 'dir', id: biggest.path } }, + { label: 'Deepest alley', primary: deepest.path, secondary: `${deepestDepth} levels deep`, mono: true, landmark: { kind: 'dir', id: deepest.path } }, + { label: 'Biggest neighborhood', primary: biggest.path, secondary: pluralize(biggest.descendants_file_count, 'building'), mono: true, landmark: { kind: 'dir', id: biggest.path } }, ], }; } @@ -195,10 +206,10 @@ function forestSection(commits: Manifest['commits']): AlmanacSection | null { key: 'forest', title: 'Forest', facts: [ - { label: 'Grandest canopy', value: `${grandest.sha.slice(0, 7)} · ${pluralize(grandest.files, 'file')}`, landmark: { kind: 'commit', id: grandest.sha } }, - { label: 'Sparsest canopy', value: `${sparsest.sha.slice(0, 7)} · ${pluralize(sparsest.files, 'file')}`, landmark: { kind: 'commit', id: sparsest.sha } }, - { label: 'Busiest day', value: `${formatShortDate(busiest.date)} · ${pluralize(busiest.same_day_total, 'commit')}` }, - { label: 'Longest streak', value: pluralize(streak, 'consecutive day') }, + { label: 'Grandest canopy', primary: grandest.sha.slice(0, 7), secondary: pluralize(grandest.files, 'file'), mono: true, landmark: { kind: 'commit', id: grandest.sha } }, + { label: 'Sparsest canopy', primary: sparsest.sha.slice(0, 7), secondary: pluralize(sparsest.files, 'file'), mono: true, landmark: { kind: 'commit', id: sparsest.sha } }, + { label: 'Busiest day', primary: formatShortDate(busiest.date), secondary: pluralize(busiest.same_day_total, 'commit') }, + { label: 'Longest streak', primary: pluralize(streak, 'consecutive day') }, ], }; } @@ -219,8 +230,8 @@ function firefliesSection(commits: Manifest['commits']): AlmanacSection | null { key: 'fireflies', title: 'Fireflies', facts: [ - { label: 'Fireflies', value: `${fmtCount(tally.size)} drift through the forest` }, - { label: 'Most prolific author', value: `${topAuthor} · ${pluralize(topCount, 'commit')}` }, + { label: 'Fireflies', primary: pluralize(tally.size, 'author') }, + { label: 'Most prolific author', primary: topAuthor, secondary: pluralize(topCount, 'commit') }, ], }; } diff --git a/app/tests/views/InfoPane/almanac.test.ts b/app/tests/views/InfoPane/almanac.test.ts index c8a624ca..b1900a12 100644 --- a/app/tests/views/InfoPane/almanac.test.ts +++ b/app/tests/views/InfoPane/almanac.test.ts @@ -87,9 +87,14 @@ describe('computeAlmanac — overview + buildings', () => { it('tallest building = most lines, clickable to its file', () => { const f = fact('buildings', 'Tallest building'); - expect(f.value).toContain('tall.ts'); + expect(f.primary).toBe('tall.ts'); + expect(f.secondary).toContain('999'); expect(f.landmark).toEqual({ kind: 'file', id: 'tall.ts' }); }); + it('date superlatives show the date in the secondary', () => { + expect(fact('buildings', 'Oldest building').secondary).toMatch(/Created/); + expect(fact('buildings', 'Brightest building').secondary).toMatch(/Edited/); + }); it('oldest building = earliest created', () => { expect(fact('buildings', 'Oldest building').landmark).toEqual({ kind: 'file', id: 'old.ts' }); }); @@ -143,14 +148,14 @@ describe('computeAlmanac — streets, forest, fireflies', () => { it('busiest day is non-landmark and names the date', () => { const f = fact('forest', 'Busiest day'); expect(f.landmark).toBeUndefined(); - expect(f.value).toContain('3'); + expect(f.secondary).toContain('3'); }); it('longest streak counts consecutive days', () => { - expect(fact('forest', 'Longest streak').value).toContain('3'); + expect(fact('forest', 'Longest streak').primary).toContain('3'); }); it('fireflies count distinct authors and name the most prolific', () => { - expect(fact('fireflies', 'Fireflies').value).toContain('2'); - expect(fact('fireflies', 'Most prolific author').value).toContain('Ada'); + expect(fact('fireflies', 'Fireflies').primary).toContain('2'); + expect(fact('fireflies', 'Most prolific author').primary).toContain('Ada'); }); it('omits forest + fireflies sections when there are no commits', () => { const b = computeAlmanac(manifest(tree, { commits: [] }))!; diff --git a/app/tests/views/InfoPane/worldPane.test.tsx b/app/tests/views/InfoPane/worldPane.test.tsx index 3e3a885f..735a890c 100644 --- a/app/tests/views/InfoPane/worldPane.test.tsx +++ b/app/tests/views/InfoPane/worldPane.test.tsx @@ -11,6 +11,17 @@ const { selectPath, focusPath, selectCommit, focusCommit } = vi.hoisted(() => ({ })); vi.mock('@/state/stores/scene', () => ({ selectPath, focusPath, selectCommit, focusCommit })); +// Mutable stand-in for the TREES settings signal so we can toggle the Trees +// layer per test (WorldPane gates the Forest section on TREES.value.ENABLED). +const treesState = vi.hoisted(() => ({ ENABLED: true })); +vi.mock('@/state/stores/settings/trees', () => ({ + TREES: { + get value() { + return treesState; + }, + }, +})); + import { WorldPane } from '@/views/InfoPane/WorldPane'; import { NodeKind } from '@/types'; import type { Manifest } from '@/types'; @@ -38,6 +49,7 @@ describe('WorldPane', () => { container = document.createElement('div'); document.body.appendChild(container); selectPath.mockClear(); focusPath.mockClear(); selectCommit.mockClear(); focusCommit.mockClear(); + treesState.ENABLED = true; }); afterEach(() => { render(null, container); container.remove(); }); @@ -96,4 +108,24 @@ describe('WorldPane', () => { expect(row).toBeTruthy(); expect(row.tagName).toBe('DIV'); }); + + it('gates the Forest section when the Trees layer is disabled', async () => { + treesState.ENABLED = false; + const withCommits: Manifest = { + ...manifest, + commits: [ + { date: '2022-01-01', files: 9, sha: 'abc1234', authors: ['Ada'], subject: 'x', same_day_total: 1 }, + ], + }; + const sig = signal(withCommits); + render(, container); + await flush(); + // Section header still shows, but canopy buttons are replaced by a note. + expect(container.textContent).toContain('Forest'); + expect(container.textContent).toContain('Enable the Trees layer'); + const canopyBtn = Array.from(container.querySelectorAll('button')).find( + (b) => b.textContent?.includes('Grandest canopy'), + ); + expect(canopyBtn).toBeUndefined(); + }); }); From 5dd3217403ea563c2257734da426da35c50f6f6d Mon Sep 17 00:00:00 2001 From: Thalida Noel Date: Tue, 16 Jun 2026 01:02:26 -0400 Subject: [PATCH 15/65] =?UTF-8?q?feat(worldpane):=20polish=20almanac=20per?= =?UTF-8?q?=20review=20=E2=80=94=20tags,=20focus=20icons,=20dedupe?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Intro: - Title uses labelFromSource (owner/repo) instead of the full URL, which is still shown once as the clickable Remote link — no more duplication. - Language breakdown uses the shared ExtensionBadge (file-type colors), matching the header + file pane instead of plain chips. - Latest row is now short-sha + subject, clickable to the commit on the remote. - Bigger, less-muted blurb so it reads as a summary, not help text. Interactions: - Replace whole-row click with an explicit Focus icon button per landmark row — clearer affordance, and non-landmark rows have none. - Fix path truncation: split dir/filename (filename stays whole) instead of the direction:rtl hack that reordered leading dots (.github → trailing dot bug). Buildings: - Exclude media/binary files from the line-based Tallest/Shortest picks (they're sized by aspect, not lines, and a 0-line image was winning Shortest). - Rename Brightest/Most faded → Freshest/Stalest (clearer; secondary shows the edit date). Co-Authored-By: Claude Opus 4.8 (1M context) --- app/src/views/InfoPane/WorldPane.css | 88 +++++++++------ app/src/views/InfoPane/WorldPane.tsx | 118 ++++++++++++++------ app/src/views/InfoPane/almanac.ts | 22 ++-- app/tests/views/InfoPane/almanac.test.ts | 24 +++- app/tests/views/InfoPane/worldPane.test.tsx | 33 +++--- 5 files changed, 186 insertions(+), 99 deletions(-) diff --git a/app/src/views/InfoPane/WorldPane.css b/app/src/views/InfoPane/WorldPane.css index cd01dc28..7ae0e671 100644 --- a/app/src/views/InfoPane/WorldPane.css +++ b/app/src/views/InfoPane/WorldPane.css @@ -2,20 +2,22 @@ padding: 12px 14px; display: flex; flex-direction: column; - gap: 18px; + gap: 16px; } .almanac-name { - margin: 0 0 4px; - font-size: var(--cc-font-xl); + margin: 0 0 6px; + font-size: var(--cc-font-2xl); + font-weight: var(--cc-fw-semibold); color: var(--cc-text-primary); + word-break: break-word; } .almanac-blurb { - margin: 0 0 10px; - color: var(--cc-text-muted); + margin: 0 0 12px; + color: var(--cc-text-secondary); line-height: 1.5; - font-size: var(--cc-font-lg); + font-size: var(--cc-font-xl); } .almanac-meta { @@ -29,18 +31,22 @@ display: flex; gap: 8px; font-size: var(--cc-font-lg); + min-width: 0; } .almanac-meta dt { + flex: 0 0 auto; color: var(--cc-text-muted); - min-width: 56px; + min-width: 52px; } .almanac-meta dd { margin: 0; + min-width: 0; color: var(--cc-text-secondary); overflow: hidden; text-overflow: ellipsis; + white-space: nowrap; } .almanac-meta a { @@ -52,26 +58,29 @@ text-decoration: underline; } +.almanac-sha { + font-family: var(--cc-font-mono); + color: var(--cc-text-primary); +} + .almanac-languages { list-style: none; margin: 10px 0 0; padding: 0; display: flex; flex-wrap: wrap; - gap: 6px; + gap: 8px; } -.almanac-languages li { - font-size: var(--cc-font-lg); - background: var(--cc-accent-bg); - border-radius: var(--cc-radius-sm); - padding: 2px 6px; - color: var(--cc-text-secondary); +.almanac-language { + display: inline-flex; + align-items: center; + gap: 4px; } -.almanac-lang-ext { - font-family: var(--cc-font-mono); - color: var(--cc-text-primary); +.almanac-language-count { + font-size: var(--cc-font-md); + color: var(--cc-text-muted); } .almanac-section-title { @@ -93,12 +102,19 @@ .almanac-fact { display: flex; - flex-direction: column; - gap: 1px; + align-items: center; + gap: 8px; width: 100%; padding: 6px 8px; border-radius: var(--cc-radius-sm); - text-align: left; +} + +.almanac-fact-text { + flex: 1 1 auto; + min-width: 0; + display: flex; + flex-direction: column; + gap: 1px; } .almanac-fact-label { @@ -123,12 +139,24 @@ text-overflow: ellipsis; } -/* Paths/shas read left-to-right but matter most at the END (filename, short - * sha), so truncate from the left — the ellipsis leads and the tail stays. */ .almanac-fact-primary--mono { + display: flex; + min-width: 0; font-family: var(--cc-font-mono); - direction: rtl; - text-align: left; +} + +/* Directory truncates from the right; the filename always stays whole. */ +.almanac-path-dir { + flex: 0 1 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.almanac-path-base { + flex: 0 0 auto; + white-space: nowrap; } .almanac-fact-secondary { @@ -138,14 +166,6 @@ white-space: nowrap; } -button.almanac-fact--landmark { - border: 0; - background: transparent; - font: inherit; - cursor: pointer; - transition: background var(--cc-t-fast); -} - -button.almanac-fact--landmark:hover { - background: var(--cc-overlay-bg); +.almanac-fact-focus { + flex: 0 0 auto; } diff --git a/app/src/views/InfoPane/WorldPane.tsx b/app/src/views/InfoPane/WorldPane.tsx index ca834fe5..0d477480 100644 --- a/app/src/views/InfoPane/WorldPane.tsx +++ b/app/src/views/InfoPane/WorldPane.tsx @@ -1,15 +1,19 @@ // views/InfoPane/WorldPane.tsx — the "World" subtab: a travel-guide of repo -// superlatives derived by computeAlmanac. Landmark rows fly the camera to that -// building/tree. Body-only — InfoPane owns the Pane chrome. +// superlatives derived by computeAlmanac. A focus button on each landmark row +// flies the camera to that building/tree. Body-only — InfoPane owns the chrome. import './WorldPane.css'; import { useMemo } from 'preact/hooks'; import type { Signal } from '@preact/signals'; -import { FolderOpen } from 'lucide-preact'; +import { FolderOpen, Focus } from 'lucide-preact'; import type { DirNode, Manifest } from '@/types'; import { PaneEmpty } from '@/components/Pane'; +import { ExtensionBadge } from '@/components/Badge/Badge'; import { selectPath, focusPath, selectCommit, focusCommit } from '@/state/stores/scene'; import { TREES } from '@/state/stores/settings/trees'; +import { BUILDINGS } from '@/state/stores/settings/buildings'; +import { STREETS } from '@/state/stores/settings/streets'; +import { commitUrl } from '@/utils/commit'; import { computeAlmanac, fmtCount } from './almanac'; import type { AlmanacFact, LandmarkRef } from './almanac'; @@ -23,39 +27,44 @@ function visit(landmark: LandmarkRef): void { } } -function FactBody({ fact }: { fact: AlmanacFact }) { +/** Path values keep the filename fully visible and truncate the directory from + * the right; shas / dates / names (no slash) render as-is. */ +function PrimaryValue({ fact }: { fact: AlmanacFact }) { + if (!fact.mono) return {fact.primary}; + const slash = fact.primary.lastIndexOf('/'); + if (slash < 0) { + return {fact.primary}; + } return ( - <> - {fact.label} - - - {fact.primary} - - {fact.secondary && {fact.secondary}} - - + + {fact.primary.slice(0, slash + 1)} + {fact.primary.slice(slash + 1)} + ); } function FactRow({ fact }: { fact: AlmanacFact }) { - if (fact.landmark) { - const landmark = fact.landmark; - return ( - - ); - } + const landmark = fact.landmark; return (
- +
+ {fact.label} + + + {fact.secondary && {fact.secondary}} + +
+ {landmark && ( + + )}
); } @@ -73,9 +82,14 @@ export function WorldPane({ manifest }: WorldPaneProps) { } const { overview, sections } = almanac; + const { repo } = overview; // Forest landmarks (canopies) fly the camera to a tree; when the Trees layer // is off those targets don't exist, so gate the section's contents on it. const treesEnabled = TREES.value.ENABLED; + const huePalette = BUILDINGS.value.HUE_EXT_MAP; + const asphaltColor = STREETS.value.ASPHALT_COLOR; + const latestUrl = repo.remote_url && repo.head_sha ? commitUrl(repo.remote_url, repo.head_sha) : null; + return (
@@ -90,20 +104,52 @@ export function WorldPane({ manifest }: WorldPaneProps) { .

- {overview.repo.branch && ( -
Branch
{overview.repo.branch}
+ {repo.branch && ( +
+
Branch
+
{repo.branch}
+
)} - {overview.repo.remote_url && ( - + {repo.remote_url && ( +
+
Remote
+
+ + {repo.remote_url} + +
+
)} - {overview.repo.head_subject && ( -
Latest
{overview.repo.head_subject}{overview.repo.dirty ? ' (uncommitted changes)' : ''}
+ {repo.head_sha && repo.head_subject && ( +
+
Latest
+
+ {latestUrl ? ( + + {repo.head_sha.slice(0, 7)} {repo.head_subject} + + ) : ( + <> + {repo.head_sha.slice(0, 7)} {repo.head_subject} + + )} + {repo.dirty ? ' (uncommitted changes)' : ''} +
+
)}
{overview.languages.length > 0 && (
    {overview.languages.map((l) => ( -
  • {l.ext} {fmtCount(l.count)}
  • +
  • + + {fmtCount(l.count)} +
  • ))}
)} diff --git a/app/src/views/InfoPane/almanac.ts b/app/src/views/InfoPane/almanac.ts index e2ae0eda..986c78f2 100644 --- a/app/src/views/InfoPane/almanac.ts +++ b/app/src/views/InfoPane/almanac.ts @@ -7,6 +7,7 @@ import { NodeKind } from '@/types'; import type { Manifest, DirNode, FileNode, RepoInfo, TreeNode } from '@/types'; import { formatShortDate } from '@/utils/dates'; import { formatBytes } from '@/utils/bytes'; +import { labelFromSource } from '@/utils/sources'; export interface LandmarkRef { kind: 'file' | 'dir' | 'commit'; @@ -110,7 +111,9 @@ function fileFact(label: string, file: FileNode | null, secondary: string): Alma function buildOverview(m: Manifest): AlmanacOverview { const root = m.tree; return { - name: m.display_root || root.name || 'this project', + // Prefer a concise "owner/repo" (or folder basename) over the raw URL — + // the full remote URL still appears, clickable, in the meta list. + name: labelFromSource(m.repo.remote_url ?? m.display_root ?? root.name) ?? root.name ?? 'this project', founded: m.dateRanges.createdMin ? formatShortDate(m.dateRanges.createdMin) : null, totals: { files: root.descendants_file_count, @@ -126,14 +129,19 @@ function buildOverview(m: Manifest): AlmanacOverview { } function buildingsSection(files: FileNode[]): AlmanacSection { + // Height encodes line count, which only drives code buildings — media files + // (images/videos) are sized by aspect, not lines, so they'd otherwise win + // "shortest" with their 0 lines. Restrict the line-based picks to text files; + // byte-based (widest/narrowest) and date-based picks span every building. + const textFiles = files.filter((f) => !f.mediaKind && !f.binary); const oldest = pickFile(files, (f) => -dateMs(f.created)); const newest = pickFile(files, (f) => dateMs(f.created)); - const tallest = pickFile(files, (f) => f.lines); - const shortest = pickFile(files, (f) => -f.lines); + const tallest = pickFile(textFiles, (f) => f.lines); + const shortest = pickFile(textFiles, (f) => -f.lines); const widest = pickFile(files, (f) => f.size); const narrowest = pickFile(files, (f) => -f.size); - const brightest = pickFile(files, (f) => dateMs(f.modified)); - const faded = pickFile(files, (f) => -dateMs(f.modified)); + const freshest = pickFile(files, (f) => dateMs(f.modified)); + const stalest = pickFile(files, (f) => -dateMs(f.modified)); const created = (f: FileNode | null) => (f ? `Created ${formatShortDate(f.created)}` : ''); const edited = (f: FileNode | null) => (f ? `Edited ${formatShortDate(f.modified)}` : ''); const facts = [ @@ -143,8 +151,8 @@ function buildingsSection(files: FileNode[]): AlmanacSection { fileFact('Shortest building', shortest, shortest ? pluralize(shortest.lines, 'line') : ''), fileFact('Widest building', widest, widest ? formatBytes(widest.size) : ''), fileFact('Narrowest building', narrowest, narrowest ? formatBytes(narrowest.size) : ''), - fileFact('Brightest building', brightest, edited(brightest)), - fileFact('Most faded building', faded, edited(faded)), + fileFact('Freshest building', freshest, edited(freshest)), + fileFact('Stalest building', stalest, edited(stalest)), ]; return { key: 'buildings', title: 'Buildings', facts: facts.filter((f): f is AlmanacFact => f !== null) }; } diff --git a/app/tests/views/InfoPane/almanac.test.ts b/app/tests/views/InfoPane/almanac.test.ts index b1900a12..c1584db8 100644 --- a/app/tests/views/InfoPane/almanac.test.ts +++ b/app/tests/views/InfoPane/almanac.test.ts @@ -93,7 +93,7 @@ describe('computeAlmanac — overview + buildings', () => { }); it('date superlatives show the date in the secondary', () => { expect(fact('buildings', 'Oldest building').secondary).toMatch(/Created/); - expect(fact('buildings', 'Brightest building').secondary).toMatch(/Edited/); + expect(fact('buildings', 'Freshest building').secondary).toMatch(/Edited/); }); it('oldest building = earliest created', () => { expect(fact('buildings', 'Oldest building').landmark).toEqual({ kind: 'file', id: 'old.ts' }); @@ -110,11 +110,23 @@ describe('computeAlmanac — overview + buildings', () => { it('narrowest building = smallest bytes', () => { expect(fact('buildings', 'Narrowest building').landmark).toEqual({ kind: 'file', id: 'old.ts' }); }); - it('brightest building = most recently modified', () => { - expect(fact('buildings', 'Brightest building').landmark).toEqual({ kind: 'file', id: 'new.ts' }); - }); - it('most faded building = longest since modified', () => { - expect(fact('buildings', 'Most faded building').landmark).toEqual({ kind: 'file', id: 'tall.ts' }); + it('freshest building = most recently modified', () => { + expect(fact('buildings', 'Freshest building').landmark).toEqual({ kind: 'file', id: 'new.ts' }); + }); + it('stalest building = longest since modified', () => { + expect(fact('buildings', 'Stalest building').landmark).toEqual({ kind: 'file', id: 'tall.ts' }); + }); + it('excludes media files from the line-based superlatives', () => { + const withMedia = dir('repo', '', [ + file({ name: 'code.ts', path: 'code.ts', lines: 40, size: 400 }), + file({ name: 'pic.png', path: 'pic.png', lines: 0, size: 9000, mediaKind: 'image' }), + ]); + const m = computeAlmanac(manifest(withMedia))!; + const b = m.sections.find((s) => s.key === 'buildings')!; + // The 0-line media file must not win "Shortest building". + expect(b.facts.find((f) => f.label === 'Shortest building')!.landmark).toEqual({ kind: 'file', id: 'code.ts' }); + // But it can still be the widest by bytes. + expect(b.facts.find((f) => f.label === 'Widest building')!.landmark).toEqual({ kind: 'file', id: 'pic.png' }); }); }); diff --git a/app/tests/views/InfoPane/worldPane.test.tsx b/app/tests/views/InfoPane/worldPane.test.tsx index 735a890c..50c0cee7 100644 --- a/app/tests/views/InfoPane/worldPane.test.tsx +++ b/app/tests/views/InfoPane/worldPane.test.tsx @@ -60,15 +60,15 @@ describe('WorldPane', () => { expect(container.textContent).toContain('No project loaded'); }); - it('clicking a building landmark selects + focuses its file', async () => { + it('clicking a building landmark focus button selects + focuses its file', async () => { const sig = signal(manifest); render(, container); await flush(); - const btn = Array.from(container.querySelectorAll('button')).find( - (b) => b.textContent?.includes('Tallest building'), - ) as HTMLButtonElement; - expect(btn).toBeTruthy(); - btn.click(); + const row = Array.from(container.querySelectorAll('.almanac-fact')).find( + (el) => el.textContent?.includes('Tallest building'), + ) as HTMLElement; + expect(row).toBeTruthy(); + row.querySelector('button')!.click(); expect(selectPath).toHaveBeenCalledWith('a.ts'); expect(focusPath).toHaveBeenCalledWith('a.ts'); }); @@ -83,11 +83,11 @@ describe('WorldPane', () => { const sig = signal(withCommits); render(, container); await flush(); - const btn = Array.from(container.querySelectorAll('button')).find( - (b) => b.textContent?.includes('Grandest canopy'), - ) as HTMLButtonElement; - expect(btn).toBeTruthy(); - btn.click(); + const row = Array.from(container.querySelectorAll('.almanac-fact')).find( + (el) => el.textContent?.includes('Grandest canopy'), + ) as HTMLElement; + expect(row).toBeTruthy(); + row.querySelector('button')!.click(); expect(selectCommit).toHaveBeenCalledWith('abc1234'); expect(focusCommit).toHaveBeenCalledWith('abc1234'); }); @@ -106,7 +106,8 @@ describe('WorldPane', () => { (el) => el.textContent?.includes('Busiest day'), ) as HTMLElement; expect(row).toBeTruthy(); - expect(row.tagName).toBe('DIV'); + // Non-landmark rows carry no focus button. + expect(row.querySelector('button')).toBeNull(); }); it('gates the Forest section when the Trees layer is disabled', async () => { @@ -120,12 +121,12 @@ describe('WorldPane', () => { const sig = signal(withCommits); render(, container); await flush(); - // Section header still shows, but canopy buttons are replaced by a note. + // Section header still shows, but canopy rows are replaced by a note. expect(container.textContent).toContain('Forest'); expect(container.textContent).toContain('Enable the Trees layer'); - const canopyBtn = Array.from(container.querySelectorAll('button')).find( - (b) => b.textContent?.includes('Grandest canopy'), + const canopyRow = Array.from(container.querySelectorAll('.almanac-fact')).find( + (el) => el.textContent?.includes('Grandest canopy'), ); - expect(canopyBtn).toBeUndefined(); + expect(canopyRow).toBeUndefined(); }); }); From 1c1f68384cf058e731550eaa09f895dacd7f092b Mon Sep 17 00:00:00 2001 From: Thalida Noel Date: Fri, 19 Jun 2026 18:54:31 -0400 Subject: [PATCH 16/65] feat(almanac): split media into its own Billboards section MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Media files render as billboards (image/video ad panels) sized by aspect, not lines — fundamentally different from code buildings. Give them their own section with media-appropriate superlatives: count, largest by bytes, and highest resolution (when pixel dimensions are known). Buildings is now code-only; media no longer competes in any building superlative. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/src/views/InfoPane/almanac.ts | 43 ++++++++++++++++++++---- app/tests/views/InfoPane/almanac.test.ts | 20 +++++++---- 2 files changed, 49 insertions(+), 14 deletions(-) diff --git a/app/src/views/InfoPane/almanac.ts b/app/src/views/InfoPane/almanac.ts index 986c78f2..00c340fc 100644 --- a/app/src/views/InfoPane/almanac.ts +++ b/app/src/views/InfoPane/almanac.ts @@ -27,7 +27,7 @@ export interface AlmanacFact { landmark?: LandmarkRef; } -export type AlmanacSectionKey = 'buildings' | 'streets' | 'forest' | 'fireflies'; +export type AlmanacSectionKey = 'buildings' | 'media' | 'streets' | 'forest' | 'fireflies'; export interface AlmanacSection { key: AlmanacSectionKey; @@ -129,11 +129,10 @@ function buildOverview(m: Manifest): AlmanacOverview { } function buildingsSection(files: FileNode[]): AlmanacSection { - // Height encodes line count, which only drives code buildings — media files - // (images/videos) are sized by aspect, not lines, so they'd otherwise win - // "shortest" with their 0 lines. Restrict the line-based picks to text files; - // byte-based (widest/narrowest) and date-based picks span every building. - const textFiles = files.filter((f) => !f.mediaKind && !f.binary); + // `files` is code buildings only — media gets its own Billboards section. + // Height encodes line count, but binary files report 0 lines, so restrict the + // line-based picks to text files; byte/date picks span every code building. + const textFiles = files.filter((f) => !f.binary); const oldest = pickFile(files, (f) => -dateMs(f.created)); const newest = pickFile(files, (f) => dateMs(f.created)); const tallest = pickFile(textFiles, (f) => f.lines); @@ -157,6 +156,31 @@ function buildingsSection(files: FileNode[]): AlmanacSection { return { key: 'buildings', title: 'Buildings', facts: facts.filter((f): f is AlmanacFact => f !== null) }; } +/** Pixel area of a media file, or NaN when its dimensions are unknown. */ +function pixels(f: FileNode): number { + return f.media_width && f.media_height ? f.media_width * f.media_height : NaN; +} + +function mediaSection(media: FileNode[]): AlmanacSection | null { + // Media files render as billboards (image/video ad panels) sized by aspect, + // not lines — a separate class of building with its own superlatives. + if (media.length === 0) return null; + const largest = pickFile(media, (f) => f.size); + const sharpest = pickFile(media, pixels); + const facts = [ + { label: 'Billboards', primary: pluralize(media.length, 'billboard') } as AlmanacFact, + fileFact('Largest billboard', largest, largest ? formatBytes(largest.size) : ''), + fileFact( + 'Highest resolution', + sharpest, + sharpest && sharpest.media_width && sharpest.media_height + ? `${fmtCount(sharpest.media_width)} × ${fmtCount(sharpest.media_height)}` + : '', + ), + ]; + return { key: 'media', title: 'Billboards', facts: facts.filter((f): f is AlmanacFact => f !== null) }; +} + function depth(path: string): number { return path ? path.split('/').length : 0; } @@ -253,7 +277,12 @@ export function computeAlmanac(m: Manifest | DirNode | null | undefined): Almana else if (node.type === NodeKind.Directory && node !== m.tree) dirs.push(node); } if (files.length === 0) return null; - const sections: AlmanacSection[] = [buildingsSection(files)]; + const sections: AlmanacSection[] = []; + const buildingFiles = files.filter((f) => f.mediaKind == null); + const mediaFiles = files.filter((f) => f.mediaKind != null); + if (buildingFiles.length > 0) sections.push(buildingsSection(buildingFiles)); + const media = mediaSection(mediaFiles); + if (media) sections.push(media); const streets = streetsSection(dirs); if (streets) sections.push(streets); const forest = forestSection(m.commits); diff --git a/app/tests/views/InfoPane/almanac.test.ts b/app/tests/views/InfoPane/almanac.test.ts index c1584db8..bac0c5a0 100644 --- a/app/tests/views/InfoPane/almanac.test.ts +++ b/app/tests/views/InfoPane/almanac.test.ts @@ -116,17 +116,23 @@ describe('computeAlmanac — overview + buildings', () => { it('stalest building = longest since modified', () => { expect(fact('buildings', 'Stalest building').landmark).toEqual({ kind: 'file', id: 'tall.ts' }); }); - it('excludes media files from the line-based superlatives', () => { + it('splits media into its own Billboards section', () => { const withMedia = dir('repo', '', [ file({ name: 'code.ts', path: 'code.ts', lines: 40, size: 400 }), - file({ name: 'pic.png', path: 'pic.png', lines: 0, size: 9000, mediaKind: 'image' }), + file({ name: 'pic.png', path: 'pic.png', lines: 0, size: 9000, mediaKind: 'image', media_width: 1920, media_height: 1080 }), ]); const m = computeAlmanac(manifest(withMedia))!; - const b = m.sections.find((s) => s.key === 'buildings')!; - // The 0-line media file must not win "Shortest building". - expect(b.facts.find((f) => f.label === 'Shortest building')!.landmark).toEqual({ kind: 'file', id: 'code.ts' }); - // But it can still be the widest by bytes. - expect(b.facts.find((f) => f.label === 'Widest building')!.landmark).toEqual({ kind: 'file', id: 'pic.png' }); + const buildings = m.sections.find((s) => s.key === 'buildings')!; + const media = m.sections.find((s) => s.key === 'media')!; + // Media never appears as a building superlative (not even Widest by bytes). + expect(buildings.facts.every((f) => f.landmark?.id !== 'pic.png')).toBe(true); + expect(buildings.facts.find((f) => f.label === 'Widest building')!.landmark).toEqual({ kind: 'file', id: 'code.ts' }); + // It's the largest, highest-resolution billboard instead. + expect(media.facts.find((f) => f.label === 'Largest billboard')!.landmark).toEqual({ kind: 'file', id: 'pic.png' }); + expect(media.facts.find((f) => f.label === 'Highest resolution')!.secondary).toContain('1,920'); + }); + it('omits the Billboards section when there is no media', () => { + expect(a!.sections.find((s) => s.key === 'media')).toBeUndefined(); }); }); From 619b609b00294652883114ad1b8d37eeaccc0698 Mon Sep 17 00:00:00 2001 From: Thalida Noel Date: Fri, 19 Jun 2026 18:56:28 -0400 Subject: [PATCH 17/65] style(worldpane): wrap each language badge + count in a bordered chip The free-floating counts read as unrelated to their badge; group each extension badge with its count inside a subtle bordered pill so each language reads as one unit. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/src/views/InfoPane/WorldPane.css | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/app/src/views/InfoPane/WorldPane.css b/app/src/views/InfoPane/WorldPane.css index 7ae0e671..dbedf565 100644 --- a/app/src/views/InfoPane/WorldPane.css +++ b/app/src/views/InfoPane/WorldPane.css @@ -69,18 +69,24 @@ padding: 0; display: flex; flex-wrap: wrap; - gap: 8px; + gap: 6px; } +/* Each language is a single bordered chip: the colored extension badge and its + * file count read as one unit instead of free-floating numbers. */ .almanac-language { display: inline-flex; align-items: center; - gap: 4px; + gap: 5px; + padding: 2px 7px 2px 3px; + border: 1px solid var(--cc-border-subtle); + border-radius: var(--cc-radius-md); + background: var(--cc-overlay-bg); } .almanac-language-count { font-size: var(--cc-font-md); - color: var(--cc-text-muted); + color: var(--cc-text-secondary); } .almanac-section-title { From dc2708ca5576485f4637ff18db7d7a7d955529e5 Mon Sep 17 00:00:00 2001 From: Thalida Noel Date: Fri, 19 Jun 2026 18:59:58 -0400 Subject: [PATCH 18/65] feat(info): rename World subtab to Overview; fix almanac row spacing - Rename the Overview subtab (was 'World', which read as the 3D world view in an app that is literally a 3D world). - Align fact rows flush with their section title (drop the row inset that made them look indented/nested). - Move the focus button onto the value line so it balances against the primary text instead of centering on the whole two-line block. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/src/views/InfoPane/InfoPane.tsx | 2 +- app/src/views/InfoPane/WorldPane.css | 21 +++++--------- app/src/views/InfoPane/WorldPane.tsx | 32 ++++++++++------------ app/tests/views/InfoPane/infoPane.test.tsx | 4 +-- 4 files changed, 25 insertions(+), 34 deletions(-) diff --git a/app/src/views/InfoPane/InfoPane.tsx b/app/src/views/InfoPane/InfoPane.tsx index 04dca4b1..68bf92b3 100644 --- a/app/src/views/InfoPane/InfoPane.tsx +++ b/app/src/views/InfoPane/InfoPane.tsx @@ -15,7 +15,7 @@ import { ReadmePane } from './ReadmePane'; type InfoTab = 'world' | 'readme'; const INFO_TABS = [ - { id: 'world', label: 'World', icon: Globe }, + { id: 'world', label: 'Overview', icon: Globe }, { id: 'readme', label: 'Readme', icon: BookOpen }, ]; diff --git a/app/src/views/InfoPane/WorldPane.css b/app/src/views/InfoPane/WorldPane.css index dbedf565..2db605c7 100644 --- a/app/src/views/InfoPane/WorldPane.css +++ b/app/src/views/InfoPane/WorldPane.css @@ -100,27 +100,20 @@ .almanac-section-note { margin: 0; - padding: 6px 8px; + padding: 6px 0; color: var(--cc-text-muted); font-size: var(--cc-font-lg); font-style: italic; } +/* Rows sit flush with the section title (no inset); the focus button lives on + * the value line so it balances against the primary text, not the whole row. */ .almanac-fact { - display: flex; - align-items: center; - gap: 8px; - width: 100%; - padding: 6px 8px; - border-radius: var(--cc-radius-sm); -} - -.almanac-fact-text { - flex: 1 1 auto; - min-width: 0; display: flex; flex-direction: column; - gap: 1px; + gap: 2px; + width: 100%; + padding: 5px 0; } .almanac-fact-label { @@ -130,7 +123,7 @@ .almanac-fact-body { display: flex; - align-items: baseline; + align-items: center; gap: 8px; min-width: 0; } diff --git a/app/src/views/InfoPane/WorldPane.tsx b/app/src/views/InfoPane/WorldPane.tsx index 0d477480..ccd806d5 100644 --- a/app/src/views/InfoPane/WorldPane.tsx +++ b/app/src/views/InfoPane/WorldPane.tsx @@ -47,24 +47,22 @@ function FactRow({ fact }: { fact: AlmanacFact }) { const landmark = fact.landmark; return (
-
- {fact.label} - - - {fact.secondary && {fact.secondary}} - + {fact.label} +
+ + {fact.secondary && {fact.secondary}} + {landmark && ( + + )}
- {landmark && ( - - )}
); } diff --git a/app/tests/views/InfoPane/infoPane.test.tsx b/app/tests/views/InfoPane/infoPane.test.tsx index 5c2cd59f..f5b9004a 100644 --- a/app/tests/views/InfoPane/infoPane.test.tsx +++ b/app/tests/views/InfoPane/infoPane.test.tsx @@ -17,11 +17,11 @@ describe('InfoPane shell', () => { (el) => el.textContent === label, ) as HTMLButtonElement; - it('defaults to the World subtab', async () => { + it('defaults to the Overview subtab', async () => { const sig = signal(null); render(, container); await flush(); - expect(tabByLabel('World').getAttribute('aria-selected')).toBe('true'); + expect(tabByLabel('Overview').getAttribute('aria-selected')).toBe('true'); expect(tabByLabel('Readme').getAttribute('aria-selected')).toBe('false'); }); From 844ef5e612fa76739d3c9e495ab8a55311ff7364 Mon Sep 17 00:00:00 2001 From: Thalida Noel Date: Fri, 19 Jun 2026 19:04:10 -0400 Subject: [PATCH 19/65] =?UTF-8?q?feat(almanac):=20reorder=20building=20fac?= =?UTF-8?q?ts=20=E2=80=94=20date=20pairs=20first=20(newest=20before=20olde?= =?UTF-8?q?st)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Group the created/edited date superlatives together at the top (Newest, Oldest, Freshest, Stalest), with newest before oldest, then the size-based picks. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/src/views/InfoPane/almanac.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/src/views/InfoPane/almanac.ts b/app/src/views/InfoPane/almanac.ts index 00c340fc..7279bf99 100644 --- a/app/src/views/InfoPane/almanac.ts +++ b/app/src/views/InfoPane/almanac.ts @@ -144,14 +144,14 @@ function buildingsSection(files: FileNode[]): AlmanacSection { const created = (f: FileNode | null) => (f ? `Created ${formatShortDate(f.created)}` : ''); const edited = (f: FileNode | null) => (f ? `Edited ${formatShortDate(f.modified)}` : ''); const facts = [ - fileFact('Oldest building', oldest, created(oldest)), fileFact('Newest building', newest, created(newest)), + fileFact('Oldest building', oldest, created(oldest)), + fileFact('Freshest building', freshest, edited(freshest)), + fileFact('Stalest building', stalest, edited(stalest)), fileFact('Tallest building', tallest, tallest ? pluralize(tallest.lines, 'line') : ''), fileFact('Shortest building', shortest, shortest ? pluralize(shortest.lines, 'line') : ''), fileFact('Widest building', widest, widest ? formatBytes(widest.size) : ''), fileFact('Narrowest building', narrowest, narrowest ? formatBytes(narrowest.size) : ''), - fileFact('Freshest building', freshest, edited(freshest)), - fileFact('Stalest building', stalest, edited(stalest)), ]; return { key: 'buildings', title: 'Buildings', facts: facts.filter((f): f is AlmanacFact => f !== null) }; } From 7e5fc7cff5c2bc6854bea9cbd1c7726bd23f07d3 Mon Sep 17 00:00:00 2001 From: Thalida Noel Date: Fri, 19 Jun 2026 19:22:50 -0400 Subject: [PATCH 20/65] test(worldpane): regression tests for manifest reactivity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Guard that the Overview re-renders when the MANIFEST signal changes — both directly and through the InfoPane shell (whose parent does not re-render) — i.e. live-update polls are reflected without a manual refresh. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/tests/views/InfoPane/worldPane.test.tsx | 29 +++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/app/tests/views/InfoPane/worldPane.test.tsx b/app/tests/views/InfoPane/worldPane.test.tsx index 50c0cee7..0bd3c672 100644 --- a/app/tests/views/InfoPane/worldPane.test.tsx +++ b/app/tests/views/InfoPane/worldPane.test.tsx @@ -23,6 +23,7 @@ vi.mock('@/state/stores/settings/trees', () => ({ })); import { WorldPane } from '@/views/InfoPane/WorldPane'; +import { InfoPane } from '@/views/InfoPane/InfoPane'; import { NodeKind } from '@/types'; import type { Manifest } from '@/types'; @@ -60,6 +61,34 @@ describe('WorldPane', () => { expect(container.textContent).toContain('No project loaded'); }); + it('updates when the manifest signal changes (live update)', async () => { + const sig = signal(manifest); + render(, container); + await flush(); + expect(container.textContent).toContain('1 buildings'); + + sig.value = { + ...manifest, + tree: { ...tree, descendants_file_count: 2 } as unknown as Manifest['tree'], + }; + await flush(); + expect(container.textContent).toContain('2 buildings'); + }); + + it('updates through the InfoPane shell when MANIFEST changes (parent does not re-render)', async () => { + const sig = signal(manifest); + render(, container); + await flush(); + expect(container.textContent).toContain('1 buildings'); + + sig.value = { + ...manifest, + tree: { ...tree, descendants_file_count: 2 } as unknown as Manifest['tree'], + }; + await flush(); + expect(container.textContent).toContain('2 buildings'); + }); + it('clicking a building landmark focus button selects + focuses its file', async () => { const sig = signal(manifest); render(, container); From 841e9d6e0ac642a58f6bd8d6826ce79461057d2f Mon Sep 17 00:00:00 2001 From: Thalida Noel Date: Fri, 19 Jun 2026 19:24:31 -0400 Subject: [PATCH 21/65] feat(worldpane): add a hover tooltip to every almanac row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each superlative row gets a title explaining what it means and its in-world encoding. The date rows clarify that 'modified' is the last-commit date — an uncommitted working-tree edit won't change Freshest/Stalest until committed. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/src/views/InfoPane/WorldPane.tsx | 2 +- app/src/views/InfoPane/almanac.ts | 30 ++++++++++++++++++++++++ app/tests/views/InfoPane/almanac.test.ts | 4 ++++ 3 files changed, 35 insertions(+), 1 deletion(-) diff --git a/app/src/views/InfoPane/WorldPane.tsx b/app/src/views/InfoPane/WorldPane.tsx index ccd806d5..d5140472 100644 --- a/app/src/views/InfoPane/WorldPane.tsx +++ b/app/src/views/InfoPane/WorldPane.tsx @@ -46,7 +46,7 @@ function PrimaryValue({ fact }: { fact: AlmanacFact }) { function FactRow({ fact }: { fact: AlmanacFact }) { const landmark = fact.landmark; return ( -
+
{fact.label}
diff --git a/app/src/views/InfoPane/almanac.ts b/app/src/views/InfoPane/almanac.ts index 7279bf99..b8c35e0d 100644 --- a/app/src/views/InfoPane/almanac.ts +++ b/app/src/views/InfoPane/almanac.ts @@ -23,6 +23,8 @@ export interface AlmanacFact { secondary?: string; /** Render the primary in mono with left-truncation (paths and shas). */ mono?: boolean; + /** Hover tooltip explaining what the superlative means + its in-world encoding. */ + tip?: string; /** Present → the row flies the camera to this landmark on click. */ landmark?: LandmarkRef; } @@ -268,6 +270,33 @@ function firefliesSection(commits: Manifest['commits']): AlmanacSection | null { }; } +// Hover copy per superlative, keyed by label. Clarifies the in-world encoding +// and — for the date facts — that "modified" is the last-commit date, so an +// uncommitted working-tree edit doesn't change them. +const FACT_TIPS: Record = { + 'Newest building': 'Most recently created file, by git history.', + 'Oldest building': 'Earliest-created file — the city’s founding structure.', + 'Freshest building': + 'File with the newest commit. Edits only count once committed — this is the date that drives a building’s brightness.', + 'Stalest building': 'File whose last commit is the oldest — the dimmest building.', + 'Tallest building': 'File with the most lines; line count sets a building’s height.', + 'Shortest building': 'File with the fewest lines.', + 'Widest building': 'Largest file by bytes; file size sets a building’s footprint.', + 'Narrowest building': 'Smallest file by bytes.', + Billboards: 'Image & video files — they render as billboard panels sized by aspect.', + 'Largest billboard': 'Biggest media file by bytes.', + 'Highest resolution': 'Media file with the most pixels.', + 'Deepest alley': 'Most deeply nested directory.', + 'Biggest neighborhood': + 'Directory holding the most files (excluding the repo root); sets a street’s width.', + 'Grandest canopy': 'Commit that changed the most files — the widest tree.', + 'Sparsest canopy': 'Commit that changed the fewest files.', + 'Busiest day': 'Calendar day with the most commits.', + 'Longest streak': 'Longest run of consecutive days with commits.', + Fireflies: 'Distinct commit authors; each is a uniquely colored firefly.', + 'Most prolific author': 'Author with the most commits — the largest firefly.', +}; + export function computeAlmanac(m: Manifest | DirNode | null | undefined): Almanac | null { if (!isManifest(m)) return null; const files: FileNode[] = []; @@ -289,5 +318,6 @@ export function computeAlmanac(m: Manifest | DirNode | null | undefined): Almana if (forest) sections.push(forest); const fireflies = firefliesSection(m.commits); if (fireflies) sections.push(fireflies); + for (const section of sections) for (const fact of section.facts) fact.tip = FACT_TIPS[fact.label]; return { overview: buildOverview(m), sections }; } diff --git a/app/tests/views/InfoPane/almanac.test.ts b/app/tests/views/InfoPane/almanac.test.ts index bac0c5a0..16d092e7 100644 --- a/app/tests/views/InfoPane/almanac.test.ts +++ b/app/tests/views/InfoPane/almanac.test.ts @@ -130,6 +130,7 @@ describe('computeAlmanac — overview + buildings', () => { // It's the largest, highest-resolution billboard instead. expect(media.facts.find((f) => f.label === 'Largest billboard')!.landmark).toEqual({ kind: 'file', id: 'pic.png' }); expect(media.facts.find((f) => f.label === 'Highest resolution')!.secondary).toContain('1,920'); + expect(media.facts.every((f) => f.tip)).toBe(true); }); it('omits the Billboards section when there is no media', () => { expect(a!.sections.find((s) => s.key === 'media')).toBeUndefined(); @@ -175,6 +176,9 @@ describe('computeAlmanac — streets, forest, fireflies', () => { expect(fact('fireflies', 'Fireflies').primary).toContain('2'); expect(fact('fireflies', 'Most prolific author').primary).toContain('Ada'); }); + it('attaches a tooltip to every fact', () => { + for (const s of a.sections) for (const f of s.facts) expect(f.tip, `${s.key}/${f.label}`).toBeTruthy(); + }); it('omits forest + fireflies sections when there are no commits', () => { const b = computeAlmanac(manifest(tree, { commits: [] }))!; expect(b.sections.find((s) => s.key === 'forest')).toBeUndefined(); From 0667f97dc365b5b12f27b040e6c35bb39da4dba5 Mon Sep 17 00:00:00 2001 From: Thalida Noel Date: Fri, 19 Jun 2026 19:29:24 -0400 Subject: [PATCH 22/65] style: apply Prettier formatting to almanac + worldpane files Co-Authored-By: Claude Opus 4.8 (1M context) --- app/src/views/InfoPane/WorldPane.tsx | 11 ++- app/src/views/InfoPane/almanac.ts | 77 +++++++++++++--- app/tests/components/paneTabs.test.tsx | 8 +- app/tests/views/InfoPane/almanac.test.ts | 69 ++++++++++++--- app/tests/views/InfoPane/infoPane.test.tsx | 7 +- app/tests/views/InfoPane/worldPane.test.tsx | 97 ++++++++++++++++----- 6 files changed, 216 insertions(+), 53 deletions(-) diff --git a/app/src/views/InfoPane/WorldPane.tsx b/app/src/views/InfoPane/WorldPane.tsx index d5140472..5068ec20 100644 --- a/app/src/views/InfoPane/WorldPane.tsx +++ b/app/src/views/InfoPane/WorldPane.tsx @@ -76,7 +76,9 @@ export function WorldPane({ manifest }: WorldPaneProps) { const almanac = useMemo(() => computeAlmanac(current as Manifest | DirNode | null), [current]); if (!almanac) { - return ; + return ( + + ); } const { overview, sections } = almanac; @@ -86,7 +88,8 @@ export function WorldPane({ manifest }: WorldPaneProps) { const treesEnabled = TREES.value.ENABLED; const huePalette = BUILDINGS.value.HUE_EXT_MAP; const asphaltColor = STREETS.value.ASPHALT_COLOR; - const latestUrl = repo.remote_url && repo.head_sha ? commitUrl(repo.remote_url, repo.head_sha) : null; + const latestUrl = + repo.remote_url && repo.head_sha ? commitUrl(repo.remote_url, repo.head_sha) : null; return (
@@ -156,7 +159,9 @@ export function WorldPane({ manifest }: WorldPaneProps) {

{s.title}

{s.key === 'forest' && !treesEnabled ? ( -

Enable the Trees layer in Settings to explore the forest.

+

+ Enable the Trees layer in Settings to explore the forest. +

) : ( s.facts.map((f, i) => ) )} diff --git a/app/src/views/InfoPane/almanac.ts b/app/src/views/InfoPane/almanac.ts index b8c35e0d..7855b98e 100644 --- a/app/src/views/InfoPane/almanac.ts +++ b/app/src/views/InfoPane/almanac.ts @@ -107,7 +107,13 @@ function pickFile(files: FileNode[], score: (f: FileNode) => number): FileNode | function fileFact(label: string, file: FileNode | null, secondary: string): AlmanacFact | null { if (!file) return null; - return { label, primary: file.path, secondary, mono: true, landmark: { kind: 'file', id: file.path } }; + return { + label, + primary: file.path, + secondary, + mono: true, + landmark: { kind: 'file', id: file.path }, + }; } function buildOverview(m: Manifest): AlmanacOverview { @@ -115,7 +121,10 @@ function buildOverview(m: Manifest): AlmanacOverview { return { // Prefer a concise "owner/repo" (or folder basename) over the raw URL — // the full remote URL still appears, clickable, in the meta list. - name: labelFromSource(m.repo.remote_url ?? m.display_root ?? root.name) ?? root.name ?? 'this project', + name: + labelFromSource(m.repo.remote_url ?? m.display_root ?? root.name) ?? + root.name ?? + 'this project', founded: m.dateRanges.createdMin ? formatShortDate(m.dateRanges.createdMin) : null, totals: { files: root.descendants_file_count, @@ -155,7 +164,11 @@ function buildingsSection(files: FileNode[]): AlmanacSection { fileFact('Widest building', widest, widest ? formatBytes(widest.size) : ''), fileFact('Narrowest building', narrowest, narrowest ? formatBytes(narrowest.size) : ''), ]; - return { key: 'buildings', title: 'Buildings', facts: facts.filter((f): f is AlmanacFact => f !== null) }; + return { + key: 'buildings', + title: 'Buildings', + facts: facts.filter((f): f is AlmanacFact => f !== null), + }; } /** Pixel area of a media file, or NaN when its dimensions are unknown. */ @@ -177,10 +190,14 @@ function mediaSection(media: FileNode[]): AlmanacSection | null { sharpest, sharpest && sharpest.media_width && sharpest.media_height ? `${fmtCount(sharpest.media_width)} × ${fmtCount(sharpest.media_height)}` - : '', + : '' ), ]; - return { key: 'media', title: 'Billboards', facts: facts.filter((f): f is AlmanacFact => f !== null) }; + return { + key: 'media', + title: 'Billboards', + facts: facts.filter((f): f is AlmanacFact => f !== null), + }; } function depth(path: string): number { @@ -200,8 +217,20 @@ function streetsSection(dirs: DirNode[]): AlmanacSection | null { key: 'streets', title: 'Streets', facts: [ - { label: 'Deepest alley', primary: deepest.path, secondary: `${deepestDepth} levels deep`, mono: true, landmark: { kind: 'dir', id: deepest.path } }, - { label: 'Biggest neighborhood', primary: biggest.path, secondary: pluralize(biggest.descendants_file_count, 'building'), mono: true, landmark: { kind: 'dir', id: biggest.path } }, + { + label: 'Deepest alley', + primary: deepest.path, + secondary: `${deepestDepth} levels deep`, + mono: true, + landmark: { kind: 'dir', id: deepest.path }, + }, + { + label: 'Biggest neighborhood', + primary: biggest.path, + secondary: pluralize(biggest.descendants_file_count, 'building'), + mono: true, + landmark: { kind: 'dir', id: biggest.path }, + }, ], }; } @@ -240,9 +269,25 @@ function forestSection(commits: Manifest['commits']): AlmanacSection | null { key: 'forest', title: 'Forest', facts: [ - { label: 'Grandest canopy', primary: grandest.sha.slice(0, 7), secondary: pluralize(grandest.files, 'file'), mono: true, landmark: { kind: 'commit', id: grandest.sha } }, - { label: 'Sparsest canopy', primary: sparsest.sha.slice(0, 7), secondary: pluralize(sparsest.files, 'file'), mono: true, landmark: { kind: 'commit', id: sparsest.sha } }, - { label: 'Busiest day', primary: formatShortDate(busiest.date), secondary: pluralize(busiest.same_day_total, 'commit') }, + { + label: 'Grandest canopy', + primary: grandest.sha.slice(0, 7), + secondary: pluralize(grandest.files, 'file'), + mono: true, + landmark: { kind: 'commit', id: grandest.sha }, + }, + { + label: 'Sparsest canopy', + primary: sparsest.sha.slice(0, 7), + secondary: pluralize(sparsest.files, 'file'), + mono: true, + landmark: { kind: 'commit', id: sparsest.sha }, + }, + { + label: 'Busiest day', + primary: formatShortDate(busiest.date), + secondary: pluralize(busiest.same_day_total, 'commit'), + }, { label: 'Longest streak', primary: pluralize(streak, 'consecutive day') }, ], }; @@ -251,7 +296,8 @@ function forestSection(commits: Manifest['commits']): AlmanacSection | null { function firefliesSection(commits: Manifest['commits']): AlmanacSection | null { if (commits.length === 0) return null; const tally = new Map(); - for (const c of commits) for (const author of c.authors) tally.set(author, (tally.get(author) ?? 0) + 1); + for (const c of commits) + for (const author of c.authors) tally.set(author, (tally.get(author) ?? 0) + 1); let topAuthor = ''; let topCount = -1; for (const [author, count] of tally) { @@ -265,7 +311,11 @@ function firefliesSection(commits: Manifest['commits']): AlmanacSection | null { title: 'Fireflies', facts: [ { label: 'Fireflies', primary: pluralize(tally.size, 'author') }, - { label: 'Most prolific author', primary: topAuthor, secondary: pluralize(topCount, 'commit') }, + { + label: 'Most prolific author', + primary: topAuthor, + secondary: pluralize(topCount, 'commit'), + }, ], }; } @@ -318,6 +368,7 @@ export function computeAlmanac(m: Manifest | DirNode | null | undefined): Almana if (forest) sections.push(forest); const fireflies = firefliesSection(m.commits); if (fireflies) sections.push(fireflies); - for (const section of sections) for (const fact of section.facts) fact.tip = FACT_TIPS[fact.label]; + for (const section of sections) + for (const fact of section.facts) fact.tip = FACT_TIPS[fact.label]; return { overview: buildOverview(m), sections }; } diff --git a/app/tests/components/paneTabs.test.tsx b/app/tests/components/paneTabs.test.tsx index dc21e14d..d531a439 100644 --- a/app/tests/components/paneTabs.test.tsx +++ b/app/tests/components/paneTabs.test.tsx @@ -23,7 +23,7 @@ describe('PaneTabs', () => { const tabByLabel = (label: string) => Array.from(container.querySelectorAll('[role="tab"]')).find( - (el) => el.textContent === label, + (el) => el.textContent === label ) as HTMLButtonElement; it('renders a tab per entry and marks the active one', async () => { @@ -46,7 +46,7 @@ describe('PaneTabs', () => { render(, container); await flush(); tabByLabel('World').dispatchEvent( - new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true }), + new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true }) ); expect(onSelect).toHaveBeenCalledWith('readme'); }); @@ -56,7 +56,7 @@ describe('PaneTabs', () => { render(, container); await flush(); tabByLabel('World').dispatchEvent( - new KeyboardEvent('keydown', { key: 'ArrowLeft', bubbles: true }), + new KeyboardEvent('keydown', { key: 'ArrowLeft', bubbles: true }) ); expect(onSelect).toHaveBeenCalledWith('readme'); }); @@ -66,7 +66,7 @@ describe('PaneTabs', () => { render(, container); await flush(); tabByLabel('Readme').dispatchEvent( - new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true }), + new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true }) ); expect(onSelect).toHaveBeenCalledWith('world'); }); diff --git a/app/tests/views/InfoPane/almanac.test.ts b/app/tests/views/InfoPane/almanac.test.ts index 16d092e7..166ec4f4 100644 --- a/app/tests/views/InfoPane/almanac.test.ts +++ b/app/tests/views/InfoPane/almanac.test.ts @@ -59,9 +59,30 @@ function manifest(tree: DirNode, overrides: Partial = {}): Manifest { describe('computeAlmanac — overview + buildings', () => { const tree = dir('repo', '', [ - file({ name: 'old.ts', path: 'old.ts', lines: 5, size: 50, created: '2020-01-01T00:00:00Z', modified: '2021-06-01T00:00:00Z' }), - file({ name: 'tall.ts', path: 'tall.ts', lines: 999, size: 80, created: '2021-01-01T00:00:00Z', modified: '2020-02-01T00:00:00Z' }), - file({ name: 'new.ts', path: 'new.ts', lines: 10, size: 4000, created: '2023-01-01T00:00:00Z', modified: '2023-01-01T00:00:00Z' }), + file({ + name: 'old.ts', + path: 'old.ts', + lines: 5, + size: 50, + created: '2020-01-01T00:00:00Z', + modified: '2021-06-01T00:00:00Z', + }), + file({ + name: 'tall.ts', + path: 'tall.ts', + lines: 999, + size: 80, + created: '2021-01-01T00:00:00Z', + modified: '2020-02-01T00:00:00Z', + }), + file({ + name: 'new.ts', + path: 'new.ts', + lines: 10, + size: 4000, + created: '2023-01-01T00:00:00Z', + modified: '2023-01-01T00:00:00Z', + }), ]); const a = computeAlmanac(manifest(tree)); @@ -108,7 +129,10 @@ describe('computeAlmanac — overview + buildings', () => { expect(fact('buildings', 'Shortest building').landmark).toEqual({ kind: 'file', id: 'old.ts' }); }); it('narrowest building = smallest bytes', () => { - expect(fact('buildings', 'Narrowest building').landmark).toEqual({ kind: 'file', id: 'old.ts' }); + expect(fact('buildings', 'Narrowest building').landmark).toEqual({ + kind: 'file', + id: 'old.ts', + }); }); it('freshest building = most recently modified', () => { expect(fact('buildings', 'Freshest building').landmark).toEqual({ kind: 'file', id: 'new.ts' }); @@ -119,16 +143,30 @@ describe('computeAlmanac — overview + buildings', () => { it('splits media into its own Billboards section', () => { const withMedia = dir('repo', '', [ file({ name: 'code.ts', path: 'code.ts', lines: 40, size: 400 }), - file({ name: 'pic.png', path: 'pic.png', lines: 0, size: 9000, mediaKind: 'image', media_width: 1920, media_height: 1080 }), + file({ + name: 'pic.png', + path: 'pic.png', + lines: 0, + size: 9000, + mediaKind: 'image', + media_width: 1920, + media_height: 1080, + }), ]); const m = computeAlmanac(manifest(withMedia))!; const buildings = m.sections.find((s) => s.key === 'buildings')!; const media = m.sections.find((s) => s.key === 'media')!; // Media never appears as a building superlative (not even Widest by bytes). expect(buildings.facts.every((f) => f.landmark?.id !== 'pic.png')).toBe(true); - expect(buildings.facts.find((f) => f.label === 'Widest building')!.landmark).toEqual({ kind: 'file', id: 'code.ts' }); + expect(buildings.facts.find((f) => f.label === 'Widest building')!.landmark).toEqual({ + kind: 'file', + id: 'code.ts', + }); // It's the largest, highest-resolution billboard instead. - expect(media.facts.find((f) => f.label === 'Largest billboard')!.landmark).toEqual({ kind: 'file', id: 'pic.png' }); + expect(media.facts.find((f) => f.label === 'Largest billboard')!.landmark).toEqual({ + kind: 'file', + id: 'pic.png', + }); expect(media.facts.find((f) => f.label === 'Highest resolution')!.secondary).toContain('1,920'); expect(media.facts.every((f) => f.tip)).toBe(true); }); @@ -139,12 +177,22 @@ describe('computeAlmanac — overview + buildings', () => { describe('computeAlmanac — streets, forest, fireflies', () => { const deep = dir('deep', 'src/a/b', [file({ name: 'x.ts', path: 'src/a/b/x.ts' })]); - const src = { ...dir('src', 'src', [deep, file({ name: 'm.ts', path: 'src/m.ts' })]), descendants_file_count: 5 }; + const src = { + ...dir('src', 'src', [deep, file({ name: 'm.ts', path: 'src/m.ts' })]), + descendants_file_count: 5, + }; const tree = dir('repo', '', [src as DirNode, file({ name: 'r.ts', path: 'r.ts' })]); const commits = [ { date: '2022-01-01', files: 2, sha: 'aaa', authors: ['Ada'], subject: 'a', same_day_total: 1 }, - { date: '2022-01-02', files: 40, sha: 'bbb', authors: ['Ada', 'Bo'], subject: 'b', same_day_total: 3 }, + { + date: '2022-01-02', + files: 40, + sha: 'bbb', + authors: ['Ada', 'Bo'], + subject: 'b', + same_day_total: 3, + }, { date: '2022-01-03', files: 1, sha: 'ccc', authors: ['Bo'], subject: 'c', same_day_total: 3 }, { date: '2022-02-10', files: 5, sha: 'ddd', authors: ['Ada'], subject: 'd', same_day_total: 1 }, ]; @@ -177,7 +225,8 @@ describe('computeAlmanac — streets, forest, fireflies', () => { expect(fact('fireflies', 'Most prolific author').primary).toContain('Ada'); }); it('attaches a tooltip to every fact', () => { - for (const s of a.sections) for (const f of s.facts) expect(f.tip, `${s.key}/${f.label}`).toBeTruthy(); + for (const s of a.sections) + for (const f of s.facts) expect(f.tip, `${s.key}/${f.label}`).toBeTruthy(); }); it('omits forest + fireflies sections when there are no commits', () => { const b = computeAlmanac(manifest(tree, { commits: [] }))!; diff --git a/app/tests/views/InfoPane/infoPane.test.tsx b/app/tests/views/InfoPane/infoPane.test.tsx index f5b9004a..a4ffc1f2 100644 --- a/app/tests/views/InfoPane/infoPane.test.tsx +++ b/app/tests/views/InfoPane/infoPane.test.tsx @@ -10,11 +10,14 @@ describe('InfoPane shell', () => { container = document.createElement('div'); document.body.appendChild(container); }); - afterEach(() => { render(null, container); container.remove(); }); + afterEach(() => { + render(null, container); + container.remove(); + }); const tabByLabel = (label: string) => Array.from(container.querySelectorAll('[role="tab"]')).find( - (el) => el.textContent === label, + (el) => el.textContent === label ) as HTMLButtonElement; it('defaults to the Overview subtab', async () => { diff --git a/app/tests/views/InfoPane/worldPane.test.tsx b/app/tests/views/InfoPane/worldPane.test.tsx index 0bd3c672..e0ca5b20 100644 --- a/app/tests/views/InfoPane/worldPane.test.tsx +++ b/app/tests/views/InfoPane/worldPane.test.tsx @@ -28,20 +28,48 @@ import { NodeKind } from '@/types'; import type { Manifest } from '@/types'; const tree = { - name: 'repo', type: NodeKind.Directory, path: '', fullPath: '/repo', + name: 'repo', + type: NodeKind.Directory, + path: '', + fullPath: '/repo', children: [ - { name: 'a.ts', type: NodeKind.File, path: 'a.ts', fullPath: '/repo/a.ts', extension: '.ts', size: 10, lines: 3, binary: false, created: '2020-01-01T00:00:00Z', modified: '2020-01-01T00:00:00Z' }, + { + name: 'a.ts', + type: NodeKind.File, + path: 'a.ts', + fullPath: '/repo/a.ts', + extension: '.ts', + size: 10, + lines: 3, + binary: false, + created: '2020-01-01T00:00:00Z', + modified: '2020-01-01T00:00:00Z', + }, ], - children_count: 1, children_file_count: 1, children_dir_count: 0, - descendants_count: 1, descendants_file_count: 1, descendants_dir_count: 0, - descendants_size: 10, descendants_ext_breakdown: [{ ext: '.ts', count: 1, size: 10 }], + children_count: 1, + children_file_count: 1, + children_dir_count: 0, + descendants_count: 1, + descendants_file_count: 1, + descendants_dir_count: 0, + descendants_size: 10, + descendants_ext_breakdown: [{ ext: '.ts', count: 1, size: 10 }], }; const manifest: Manifest = { - root: '/repo', scanned_at: '2024-01-01T00:00:00Z', signature: 's', tree_signature: 't', + root: '/repo', + scanned_at: '2024-01-01T00:00:00Z', + signature: 's', + tree_signature: 't', tree: tree as unknown as Manifest['tree'], repo: { branch: 'main', remote_url: null, head_sha: null, head_subject: null, dirty: false }, - commits: [], busyness: { avg: 1, busy: 2 }, - dateRanges: { createdMin: '2020-01-01T00:00:00Z', createdMax: '2020-01-01T00:00:00Z', modifiedMin: '2020-01-01T00:00:00Z', modifiedMax: '2020-01-01T00:00:00Z' }, + commits: [], + busyness: { avg: 1, busy: 2 }, + dateRanges: { + createdMin: '2020-01-01T00:00:00Z', + createdMax: '2020-01-01T00:00:00Z', + modifiedMin: '2020-01-01T00:00:00Z', + modifiedMax: '2020-01-01T00:00:00Z', + }, }; describe('WorldPane', () => { @@ -49,10 +77,16 @@ describe('WorldPane', () => { beforeEach(() => { container = document.createElement('div'); document.body.appendChild(container); - selectPath.mockClear(); focusPath.mockClear(); selectCommit.mockClear(); focusCommit.mockClear(); + selectPath.mockClear(); + focusPath.mockClear(); + selectCommit.mockClear(); + focusCommit.mockClear(); treesState.ENABLED = true; }); - afterEach(() => { render(null, container); container.remove(); }); + afterEach(() => { + render(null, container); + container.remove(); + }); it('renders the empty state when there is no project', async () => { const sig = signal(null); @@ -93,8 +127,8 @@ describe('WorldPane', () => { const sig = signal(manifest); render(, container); await flush(); - const row = Array.from(container.querySelectorAll('.almanac-fact')).find( - (el) => el.textContent?.includes('Tallest building'), + const row = Array.from(container.querySelectorAll('.almanac-fact')).find((el) => + el.textContent?.includes('Tallest building') ) as HTMLElement; expect(row).toBeTruthy(); row.querySelector('button')!.click(); @@ -106,14 +140,21 @@ describe('WorldPane', () => { const withCommits: Manifest = { ...manifest, commits: [ - { date: '2022-01-01', files: 9, sha: 'abc1234', authors: ['Ada'], subject: 'x', same_day_total: 1 }, + { + date: '2022-01-01', + files: 9, + sha: 'abc1234', + authors: ['Ada'], + subject: 'x', + same_day_total: 1, + }, ], }; const sig = signal(withCommits); render(, container); await flush(); - const row = Array.from(container.querySelectorAll('.almanac-fact')).find( - (el) => el.textContent?.includes('Grandest canopy'), + const row = Array.from(container.querySelectorAll('.almanac-fact')).find((el) => + el.textContent?.includes('Grandest canopy') ) as HTMLElement; expect(row).toBeTruthy(); row.querySelector('button')!.click(); @@ -125,14 +166,21 @@ describe('WorldPane', () => { const withCommits: Manifest = { ...manifest, commits: [ - { date: '2022-01-01', files: 9, sha: 'abc1234', authors: ['Ada'], subject: 'x', same_day_total: 1 }, + { + date: '2022-01-01', + files: 9, + sha: 'abc1234', + authors: ['Ada'], + subject: 'x', + same_day_total: 1, + }, ], }; const sig = signal(withCommits); render(, container); await flush(); - const row = Array.from(container.querySelectorAll('.almanac-fact')).find( - (el) => el.textContent?.includes('Busiest day'), + const row = Array.from(container.querySelectorAll('.almanac-fact')).find((el) => + el.textContent?.includes('Busiest day') ) as HTMLElement; expect(row).toBeTruthy(); // Non-landmark rows carry no focus button. @@ -144,7 +192,14 @@ describe('WorldPane', () => { const withCommits: Manifest = { ...manifest, commits: [ - { date: '2022-01-01', files: 9, sha: 'abc1234', authors: ['Ada'], subject: 'x', same_day_total: 1 }, + { + date: '2022-01-01', + files: 9, + sha: 'abc1234', + authors: ['Ada'], + subject: 'x', + same_day_total: 1, + }, ], }; const sig = signal(withCommits); @@ -153,8 +208,8 @@ describe('WorldPane', () => { // Section header still shows, but canopy rows are replaced by a note. expect(container.textContent).toContain('Forest'); expect(container.textContent).toContain('Enable the Trees layer'); - const canopyRow = Array.from(container.querySelectorAll('.almanac-fact')).find( - (el) => el.textContent?.includes('Grandest canopy'), + const canopyRow = Array.from(container.querySelectorAll('.almanac-fact')).find((el) => + el.textContent?.includes('Grandest canopy') ); expect(canopyRow).toBeUndefined(); }); From 539e3a60e2522a6edcd55742e7ea4794a91254a1 Mon Sep 17 00:00:00 2001 From: Thalida Noel Date: Fri, 19 Jun 2026 20:10:29 -0400 Subject: [PATCH 23/65] refactor(info): address PR review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove PaneTabs arrow-key navigation (+ its tests). - Extract generic helpers to utils: dateMs → utils/dates; formatCount + pluralize → new utils/format.ts (was fmtCount/pluralize in almanac). - LandmarkRef.kind reuses the NodeKind enum instead of a bare string union. - Set each fact's tooltip inline at creation; drop the label-keyed FACT_TIPS map. - computeAlmanac always emits every section; empty ones carry a note empty-state (incl. the Trees-disabled Forest notice, now produced by compute, not the view). Takes treesEnabled as an arg. - ExtensionBadge reads BUILDINGS/STREETS itself; dropped huePalette/asphaltColor props from all callers (PathBreadcrumbs, StreetPane, FilePreviewPane, Overview). - Rename WorldPane → OverviewPane (file/component/css/test) to match the tab. - InfoPane: InfoTab enum + a data-driven tabs array (id/label/icon/Component) rendered generically, replacing hardcoded id strings + the ternary. - Simplify ReadmePane._findRootReadme (drop the unreachable bare-DirNode branch). Co-Authored-By: Claude Opus 4.8 (1M context) --- app/src/components/Badge/Badge.tsx | 10 +- app/src/components/PaneTabs/PaneTabs.tsx | 17 +- .../PathBreadcrumbs/PathBreadcrumbs.tsx | 12 +- app/src/utils/dates.ts | 8 + app/src/utils/format.ts | 11 + .../views/FilePreviewPane/FilePreviewPane.tsx | 11 +- app/src/views/InfoPane/InfoPane.tsx | 36 ++- .../{WorldPane.css => OverviewPane.css} | 0 .../{WorldPane.tsx => OverviewPane.tsx} | 57 ++-- app/src/views/InfoPane/ReadmePane.tsx | 10 +- app/src/views/InfoPane/almanac.ts | 245 +++++++++++------- app/src/views/StreetPane/StreetPane.tsx | 23 +- app/tests/components/badge.test.tsx | 60 +++-- app/tests/components/paneTabs.test.tsx | 30 --- app/tests/views/InfoPane/almanac.test.ts | 38 ++- ...rldPane.test.tsx => overviewPane.test.tsx} | 18 +- 16 files changed, 317 insertions(+), 269 deletions(-) create mode 100644 app/src/utils/format.ts rename app/src/views/InfoPane/{WorldPane.css => OverviewPane.css} (100%) rename app/src/views/InfoPane/{WorldPane.tsx => OverviewPane.tsx} (74%) rename app/tests/views/InfoPane/{worldPane.test.tsx => overviewPane.test.tsx} (91%) diff --git a/app/src/components/Badge/Badge.tsx b/app/src/components/Badge/Badge.tsx index 55ba1f9c..c1807ecf 100644 --- a/app/src/components/Badge/Badge.tsx +++ b/app/src/components/Badge/Badge.tsx @@ -14,6 +14,8 @@ import './Badge.css'; import { getHue } from '@/city/components/buildings/color'; import { parseHex, hslToRgb, pickContrastingText } from '@/utils/colors'; +import { BUILDINGS } from '@/state/stores/settings/buildings'; +import { STREETS } from '@/state/stores/settings/streets'; // Badge color palette defaults. The file badge's CSS rule paints the background // with `hsl(var(--badge-hue), 60%, 35%)` — the saturation/lightness defaults @@ -28,8 +30,6 @@ const DEFAULT_FILE_BADGE_LIGHTNESS = 0.35; export interface ExtensionBadgeProps { extension: string | null | undefined; isDir: boolean; - huePalette: Record; - asphaltColor: string; /** Label color used on bright backgrounds. */ textDark?: string; /** Label color used on dark backgrounds. */ @@ -45,13 +45,15 @@ export interface ExtensionBadgeProps { export function ExtensionBadge({ extension, isDir, - huePalette, - asphaltColor, textDark = DEFAULT_TEXT_DARK, textLight = DEFAULT_TEXT_LIGHT, fileBadgeSaturation = DEFAULT_FILE_BADGE_SATURATION, fileBadgeLightness = DEFAULT_FILE_BADGE_LIGHTNESS, }: ExtensionBadgeProps) { + // Read the live theme directly so callers don't have to thread these through — + // the city's extension→hue palette and the dir badge's asphalt color. + const huePalette = BUILDINGS.value.HUE_EXT_MAP; + const asphaltColor = STREETS.value.ASPHALT_COLOR; const contrastingText = (rgb: [number, number, number] | null): string => pickContrastingText(rgb, textDark, textLight); diff --git a/app/src/components/PaneTabs/PaneTabs.tsx b/app/src/components/PaneTabs/PaneTabs.tsx index 0444ff78..c97c84c4 100644 --- a/app/src/components/PaneTabs/PaneTabs.tsx +++ b/app/src/components/PaneTabs/PaneTabs.tsx @@ -1,6 +1,6 @@ // components/PaneTabs/PaneTabs.tsx — horizontal segmented tabs rendered inside // a Pane body (below the header). Controlled + presentational: the parent owns -// the active id and decides what each tab renders. Arrow-key navigable. +// the active id and decides what each tab renders. import './PaneTabs.css'; import type { LucideIcon } from 'lucide-preact'; @@ -18,12 +18,6 @@ export interface PaneTabsProps { } export function PaneTabs({ tabs, active, onSelect }: PaneTabsProps) { - const move = (dir: 1 | -1) => { - const i = tabs.findIndex((t) => t.id === active); - if (i < 0) return; - const next = (i + dir + tabs.length) % tabs.length; - onSelect(tabs[next].id); - }; return (
{tabs.map((t) => { @@ -37,15 +31,6 @@ export function PaneTabs({ tabs, active, onSelect }: PaneTabsProps) { aria-selected={selected ? 'true' : 'false'} class={selected ? 'pane-tab pane-tab--active' : 'pane-tab'} onClick={() => onSelect(t.id)} - onKeyDown={(e) => { - if (e.key === 'ArrowRight') { - e.preventDefault(); - move(1); - } else if (e.key === 'ArrowLeft') { - e.preventDefault(); - move(-1); - } - }} > {Icon &&
{sections.map((s) => (
-

{s.title}

+

+ {s.title} +

{s.facts.length > 0 ? ( s.facts.map((f, i) => ) ) : ( diff --git a/app/src/views/InfoPane/almanac.ts b/app/src/views/InfoPane/almanac.ts index fd2fe306..97cb2e56 100644 --- a/app/src/views/InfoPane/almanac.ts +++ b/app/src/views/InfoPane/almanac.ts @@ -1,10 +1,19 @@ // views/InfoPane/almanac.ts — pure derivation of the Overview tab's almanac: -// repo superlatives mapped from manifest.stats (server-computed leaders). -// No signals, no DOM, no tree walk. -// Landmark facts carry the key needed to fly the camera there. +// repo superlatives mapped from manifest.stats (server-computed leaders). No +// signals, no DOM, no tree walk — this file decides WHAT to show; the UI (how +// to render it) lives in OverviewPane.tsx. Landmark facts carry the key needed +// to fly the camera there. import { NodeKind } from '@/types'; -import type { Manifest, DirNode, RepoInfo, FileLeader, DirLeader, CommitLeader } from '@/types'; +import type { + Manifest, + DirNode, + RepoInfo, + RepoStats, + FileLeader, + DirLeader, + CommitLeader, +} from '@/types'; import { formatShortDate } from '@/utils/dates'; import { formatBytes } from '@/utils/bytes'; import { formatCount, pluralize } from '@/utils/format'; @@ -24,11 +33,11 @@ export interface AlmanacFact { primary: string; /** Muted metric shown beside the primary (e.g. "1,843 lines", "Created Apr 18, 2026"). */ secondary?: string; - /** Render the primary in mono with left-truncation (paths and shas). */ - mono?: boolean; /** Hover tooltip explaining what the superlative means + its in-world encoding. */ tip: string; - /** Present → the row flies the camera to this landmark on click. */ + /** Present → the primary is a code identifier (a path or sha): rendered + * monospace + left-truncated, and the row flies the camera to this landmark + * on click. Absent → a plain count/date/name summary fact. */ landmark?: LandmarkRef; } @@ -37,6 +46,8 @@ export type AlmanacSectionKey = 'buildings' | 'media' | 'streets' | 'forest' | ' export interface AlmanacSection { key: AlmanacSectionKey; title: string; + /** One-line "what is this layer" blurb, shown as the section header tooltip. */ + tip: string; facts: AlmanacFact[]; /** Shown in place of facts when the section has none — an empty state or a * gated notice (e.g. the Trees layer is off). */ @@ -63,56 +74,88 @@ export interface Almanac { const MAX_LANGUAGES = 6; +// What each world layer encodes — surfaced as the section header tooltips. +const SECTION_TIPS: Record = { + buildings: + 'Every code file is a building — height from line count, footprint from byte size, brightness from how recently it changed.', + media: 'Image & video files render as billboard panels, sized by aspect ratio instead of lines.', + streets: 'Directories are streets; the more files a directory holds, the wider its road.', + forest: + 'Each commit plants a tree — older commits grow taller, bigger commits grow wider canopies.', + fireflies: + 'Each distinct commit author is a uniquely colored firefly orbiting the trees they touched.', +}; + function isManifest(m: unknown): m is Manifest { return !!m && typeof m === 'object' && 'tree' in (m as object) && (m as Manifest).tree != null; } -function fileFact( - label: string, - leader: FileLeader | null, - secondary: (l: FileLeader) => string, - tip: string -): AlmanacFact | null { - if (!leader) return null; +// ---- fact builders ------------------------------------------------------ +// Landmark facts (file / dir / commit) carry a path or sha primary and a +// landmark key, so the row is clickable. Each returns null when its leader is +// absent, so an empty pool collapses the section to its note. Summary facts +// (statFact) are a plain count/date/name with no landmark. + +function fileFact(o: { + label: string; + leader: FileLeader | null; + secondary: (l: FileLeader) => string; + tip: string; +}): AlmanacFact | null { + if (!o.leader) return null; return { - label, - primary: leader.path, - secondary: secondary(leader), - mono: true, - tip, - landmark: { kind: NodeKind.File, id: leader.path }, + label: o.label, + primary: o.leader.path, + secondary: o.secondary(o.leader), + tip: o.tip, + landmark: { kind: NodeKind.File, id: o.leader.path }, }; } -function dirFact( - label: string, - leader: DirLeader | null, - secondary: string, - tip: string -): AlmanacFact | null { - if (!leader) return null; +function dirFact(o: { + label: string; + leader: DirLeader | null; + secondary: (l: DirLeader) => string; + tip: string; +}): AlmanacFact | null { + if (!o.leader) return null; return { - label, - primary: leader.path, - secondary, - mono: true, - tip, - landmark: { kind: NodeKind.Directory, id: leader.path }, + label: o.label, + primary: o.leader.path, + secondary: o.secondary(o.leader), + tip: o.tip, + landmark: { kind: NodeKind.Directory, id: o.leader.path }, }; } -function commitFact(label: string, leader: CommitLeader | null, tip: string): AlmanacFact | null { - if (!leader) return null; +function commitFact(o: { + label: string; + leader: CommitLeader | null; + tip: string; +}): AlmanacFact | null { + if (!o.leader) return null; return { - label, - primary: leader.sha.slice(0, 7), - secondary: pluralize(leader.files, 'file'), - mono: true, - tip, - landmark: { kind: NodeKind.Commit, id: leader.sha }, + label: o.label, + primary: o.leader.sha.slice(0, 7), + secondary: pluralize(o.leader.files, 'file'), + tip: o.tip, + landmark: { kind: NodeKind.Commit, id: o.leader.sha }, }; } +function statFact(o: { + label: string; + primary: string; + secondary?: string; + tip: string; +}): AlmanacFact { + return { label: o.label, primary: o.primary, secondary: o.secondary, tip: o.tip }; +} + +function compact(facts: (AlmanacFact | null)[]): AlmanacFact[] { + return facts.filter((f): f is AlmanacFact => f !== null); +} + function buildOverview(m: Manifest): AlmanacOverview { const root = m.tree; return { @@ -136,192 +179,190 @@ function buildOverview(m: Manifest): AlmanacOverview { }; } -function buildingsSection(m: Manifest): AlmanacSection { - const s = m.stats; - const facts = [ - fileFact( - 'Newest building', - s.newestCreatedFile, - (l) => `Created ${formatShortDate(l.created)}`, - 'Most recently created file, by git history.' - ), - fileFact( - 'Oldest building', - s.oldestCreatedFile, - (l) => `Created ${formatShortDate(l.created)}`, - "Earliest-created file — the city's founding structure." - ), - fileFact( - 'Freshest building', - s.newestModifiedFile, - (l) => `Edited ${formatShortDate(l.modified)}`, - "File with the newest commit. Edits only count once committed — this is the date that drives a building's brightness." - ), - fileFact( - 'Stalest building', - s.oldestModifiedFile, - (l) => `Edited ${formatShortDate(l.modified)}`, - 'File whose last commit is the oldest — the dimmest building.' - ), - fileFact( - 'Tallest building', - s.maxLinesFile, - (l) => pluralize(l.lines, 'line'), - "File with the most lines; line count sets a building's height." - ), - fileFact( - 'Shortest building', - s.minLinesFile, - (l) => pluralize(l.lines, 'line'), - 'File with the fewest lines.' - ), - fileFact( - 'Widest building', - s.maxBytesFile, - (l) => formatBytes(l.bytes), - "Largest file by bytes; file size sets a building's footprint." - ), - fileFact( - 'Narrowest building', - s.minBytesFile, - (l) => formatBytes(l.bytes), - 'Smallest file by bytes.' - ), - ].filter((f): f is AlmanacFact => f !== null); - - if (facts.length === 0) { - return { key: 'buildings', title: 'Buildings', facts: [], note: 'No code files yet.' }; - } - return { key: 'buildings', title: 'Buildings', facts }; +function buildingsSection(s: RepoStats): AlmanacSection { + const facts = compact([ + fileFact({ + label: 'Newest building', + leader: s.newestCreatedFile, + secondary: (l) => `Created ${formatShortDate(l.created)}`, + tip: 'Most recently created file, by git history.', + }), + fileFact({ + label: 'Oldest building', + leader: s.oldestCreatedFile, + secondary: (l) => `Created ${formatShortDate(l.created)}`, + tip: "Earliest-created file — the city's founding structure.", + }), + fileFact({ + label: 'Freshest building', + leader: s.newestModifiedFile, + secondary: (l) => `Edited ${formatShortDate(l.modified)}`, + tip: "File with the newest commit. Edits only count once committed — this is the date that drives a building's brightness.", + }), + fileFact({ + label: 'Stalest building', + leader: s.oldestModifiedFile, + secondary: (l) => `Edited ${formatShortDate(l.modified)}`, + tip: 'File whose last commit is the oldest — the dimmest building.', + }), + fileFact({ + label: 'Tallest building', + leader: s.maxLinesFile, + secondary: (l) => pluralize(l.lines, 'line'), + tip: "File with the most lines; line count sets a building's height.", + }), + fileFact({ + label: 'Shortest building', + leader: s.minLinesFile, + secondary: (l) => pluralize(l.lines, 'line'), + tip: 'File with the fewest lines.', + }), + fileFact({ + label: 'Widest building', + leader: s.maxBytesFile, + secondary: (l) => formatBytes(l.bytes), + tip: "Largest file by bytes; file size sets a building's footprint.", + }), + fileFact({ + label: 'Narrowest building', + leader: s.minBytesFile, + secondary: (l) => formatBytes(l.bytes), + tip: 'Smallest file by bytes.', + }), + ]); + return { + key: 'buildings', + title: 'Buildings', + tip: SECTION_TIPS.buildings, + facts, + note: facts.length ? undefined : 'No code files yet.', + }; } -function mediaSection(m: Manifest): AlmanacSection { +function mediaSection(s: RepoStats): AlmanacSection { // Media files render as billboards (image/video ad panels) sized by aspect, // not lines — a separate class of building with its own superlatives. - const s = m.stats; if (s.mediaCount === 0) { - return { key: 'media', title: 'Billboards', facts: [], note: 'No images or videos.' }; + return { + key: 'media', + title: 'Billboards', + tip: SECTION_TIPS.media, + facts: [], + note: 'No images or videos.', + }; } - const facts = [ - { + const sharp = s.maxMediaPixelsFile; + const facts = compact([ + statFact({ label: 'Billboards', primary: pluralize(s.mediaCount, 'billboard'), tip: 'Image & video files — they render as billboard panels sized by aspect.', - } as AlmanacFact, - fileFact( - 'Largest billboard', - s.maxMediaBytesFile, - (l) => formatBytes(l.bytes), - 'Biggest media file by bytes.' - ), - s.maxMediaPixelsFile?.media_width && s.maxMediaPixelsFile?.media_height - ? fileFact( - 'Highest resolution', - s.maxMediaPixelsFile, - (l) => `${formatCount(l.media_width!)} × ${formatCount(l.media_height!)}`, - 'Media file with the most pixels.' - ) + }), + fileFact({ + label: 'Largest billboard', + leader: s.maxMediaBytesFile, + secondary: (l) => formatBytes(l.bytes), + tip: 'Biggest media file by bytes.', + }), + sharp?.media_width && sharp?.media_height + ? fileFact({ + label: 'Highest resolution', + leader: sharp, + secondary: (l) => `${formatCount(l.media_width!)} × ${formatCount(l.media_height!)}`, + tip: 'Media file with the most pixels.', + }) : null, - ].filter((f): f is AlmanacFact => f !== null); - - return { key: 'media', title: 'Billboards', facts }; + ]); + return { key: 'media', title: 'Billboards', tip: SECTION_TIPS.media, facts }; } -function streetsSection(m: Manifest): AlmanacSection { - const s = m.stats; - const facts = [ - dirFact( - 'Deepest alley', - s.maxDepthDir, - s.maxDepthDir ? `${s.maxDepthDir.depth} levels deep` : '', - 'Most deeply nested directory.' - ), - dirFact( - 'Biggest neighborhood', - s.maxFilesPerDir, - s.maxFilesPerDir ? pluralize(s.maxFilesPerDir.file_count, 'building') : '', - "Directory holding the most files (excluding the repo root); sets a street's width." - ), - ].filter((f): f is AlmanacFact => f !== null); - - if (facts.length === 0) { - return { - key: 'streets', - title: 'Streets', - facts: [], - note: 'Everything lives at the root — no sub-directories.', - }; - } - return { key: 'streets', title: 'Streets', facts }; +function streetsSection(s: RepoStats): AlmanacSection { + const facts = compact([ + dirFact({ + label: 'Deepest alley', + leader: s.maxDepthDir, + secondary: (l) => `${l.depth} levels deep`, + tip: 'Most deeply nested directory.', + }), + dirFact({ + label: 'Biggest neighborhood', + leader: s.maxFilesPerDir, + secondary: (l) => pluralize(l.file_count, 'building'), + tip: "Directory holding the most files (excluding the repo root); sets a street's width.", + }), + ]); + return { + key: 'streets', + title: 'Streets', + tip: SECTION_TIPS.streets, + facts, + note: facts.length ? undefined : 'Everything lives at the root — no sub-directories.', + }; } function forestSection(m: Manifest, treesEnabled: boolean): AlmanacSection { + const base = { key: 'forest', title: 'Forest', tip: SECTION_TIPS.forest } as const; // Canopies fly the camera to a tree; with the Trees layer off those targets // don't exist, so the notice lives here (not the view) like any empty state. if (!treesEnabled) { return { - key: 'forest', - title: 'Forest', + ...base, facts: [], note: 'Enable the Trees layer in Settings to explore the forest.', }; } - const s = m.stats; if (m.commits.length === 0) { - return { key: 'forest', title: 'Forest', facts: [], note: 'No commits yet.' }; + return { ...base, facts: [], note: 'No commits yet.' }; } - const facts = [ - commitFact( - 'Grandest canopy', - s.maxFilesPerCommit, - 'Commit that changed the most files — the widest tree.' - ), - commitFact('Sparsest canopy', s.minFilesPerCommit, 'Commit that changed the fewest files.'), + const s = m.stats; + const facts = compact([ + commitFact({ + label: 'Grandest canopy', + leader: s.maxFilesPerCommit, + tip: 'Commit that changed the most files — the widest tree.', + }), + commitFact({ + label: 'Sparsest canopy', + leader: s.minFilesPerCommit, + tip: 'Commit that changed the fewest files.', + }), s.maxCommitsPerDay - ? ({ + ? statFact({ label: 'Busiest day', primary: formatShortDate(s.maxCommitsPerDay.date), secondary: pluralize(s.maxCommitsPerDay.count, 'commit'), tip: 'Calendar day with the most commits.', - } as AlmanacFact) + }) : null, - { + statFact({ label: 'Longest streak', primary: pluralize(s.maxCommitStreakDays, 'consecutive day'), tip: 'Longest run of consecutive days with commits.', - } as AlmanacFact, - ].filter((f): f is AlmanacFact => f !== null); - - return { key: 'forest', title: 'Forest', facts }; + }), + ]); + return { ...base, facts }; } -function firefliesSection(m: Manifest): AlmanacSection { - const s = m.stats; +function firefliesSection(s: RepoStats): AlmanacSection { + const base = { key: 'fireflies', title: 'Fireflies', tip: SECTION_TIPS.fireflies } as const; if (s.authors.length === 0) { - return { - key: 'fireflies', - title: 'Fireflies', - facts: [], - note: 'No commits yet — no fireflies.', - }; + return { ...base, facts: [], note: 'No commits yet — no fireflies.' }; } - // authors is pre-sorted descending by commits from the backend. - const top = s.authors[0]; + const top = s.authors[0]; // pre-sorted descending by commits from the backend return { - key: 'fireflies', - title: 'Fireflies', + ...base, facts: [ - { + statFact({ label: 'Fireflies', primary: pluralize(s.authors.length, 'author'), tip: 'Distinct commit authors; each is a uniquely colored firefly.', - }, - { + }), + statFact({ label: 'Most prolific author', primary: top.name, secondary: pluralize(top.commits, 'commit'), tip: 'Author with the most commits — the largest firefly.', - }, + }), ], }; } @@ -338,12 +379,13 @@ export function computeAlmanac( ): Almanac | null { if (!isManifest(m)) return null; if (!m.stats) return null; + const s = m.stats; const sections: AlmanacSection[] = [ - buildingsSection(m), - mediaSection(m), - streetsSection(m), + buildingsSection(s), + mediaSection(s), + streetsSection(s), forestSection(m, treesEnabled), - firefliesSection(m), + firefliesSection(s), ]; return { overview: buildOverview(m), sections }; } From 534216ce7c988f2deb70302d32aa058209bbcfdd Mon Sep 17 00:00:00 2001 From: Thalida Noel Date: Fri, 19 Jun 2026 23:42:00 -0400 Subject: [PATCH 44/65] refactor(info): render active.Component directly, drop the Body local PR review (InfoPane.tsx:49): a member-expression component reference works in JSX, so the intermediate `const Body = active.Component` is unnecessary. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/src/views/InfoPane/InfoPane.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/app/src/views/InfoPane/InfoPane.tsx b/app/src/views/InfoPane/InfoPane.tsx index 34d81b58..ede8626d 100644 --- a/app/src/views/InfoPane/InfoPane.tsx +++ b/app/src/views/InfoPane/InfoPane.tsx @@ -41,12 +41,11 @@ export interface InfoPaneProps { export function InfoPane({ manifest, onClose }: InfoPaneProps) { const [tab, setTab] = useState(InfoTab.Overview); const active = INFO_TABS.find((t) => t.id === tab) ?? INFO_TABS[0]; - const Body = active.Component; return ( setTab(id as InfoTab)} />
- +
); From 6bdb90881ad8001d4b4c17d5a99286b0e92c872f Mon Sep 17 00:00:00 2001 From: Thalida Noel Date: Sat, 20 Jun 2026 01:28:43 -0400 Subject: [PATCH 45/65] perf(media): batch billboard image fetches via POST /api/files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A media-heavy repo (Infisical: ~2.6k images) fired one GET /api/file per billboard, throttled to 4 at a time by the GPU semaphore — thousands of serial round-trips that exhaust the browser's HTTP/1.1 connection pool. Add POST /api/files: trust-checks each path exactly like GET /api/file and returns {path: {mime, b64}} for many small images in one round trip (images only, ≤8MB; videos still stream their poster). The frontend mediaBatch.ts coalesces image paths requested at scene build into batches of 32, feeds the bytes to the existing decode path via object URLs, and falls back to the individual GET for anything the batch omits. The fetch is no longer slot-gated (only the decode + GPU upload is), so the coalescing window sees every billboard at once: ~2.6k requests → ~82 batched requests. Co-Authored-By: Claude Opus 4.8 (1M context) --- api/models/responses.py | 8 ++ api/routers/file.py | 50 ++++++++- api/tests/test_server_file.py | 36 +++++++ app/src/city/components/buildings/adPanels.ts | 102 ++++++++++++------ .../city/components/buildings/mediaBatch.ts | 85 +++++++++++++++ app/src/types/manifest.generated.ts | 79 ++++++++++++++ .../components/buildings/mediaBatch.test.ts | 78 ++++++++++++++ 7 files changed, 401 insertions(+), 37 deletions(-) create mode 100644 app/src/city/components/buildings/mediaBatch.ts create mode 100644 app/tests/city/components/buildings/mediaBatch.test.ts diff --git a/api/models/responses.py b/api/models/responses.py index 9025e054..6cd6c0ed 100644 --- a/api/models/responses.py +++ b/api/models/responses.py @@ -15,6 +15,14 @@ class FileTooLargeResponse(BaseModel): limit: int +class FileBatchEntry(BaseModel): + """One image in a POST /api/files batch response: its content-type and + base64-encoded bytes, keyed by request path in the response map.""" + + mime: str + b64: str + + class HealthResponse(BaseModel): ok: bool diff --git a/api/routers/file.py b/api/routers/file.py index b3056a29..0da3b934 100644 --- a/api/routers/file.py +++ b/api/routers/file.py @@ -1,20 +1,37 @@ -"""GET /api/file — serve a file from disk, restricted to scanned roots.""" +"""GET /api/file — serve a file from disk, restricted to scanned roots. + +POST /api/files is the batch sibling used by the scene's media-texture loader: +one round trip for many small images instead of one request per billboard. +""" from __future__ import annotations +import base64 import mimetypes from pathlib import Path from fastapi import APIRouter, HTTPException, Query, Response from fastapi.responses import JSONResponse +from pydantic import BaseModel from api.config import MAX_FILE_BYTES -from api.models.responses import FileTooLargeResponse +from api.models.responses import FileBatchEntry, FileTooLargeResponse from api.security import NoRootsRegisteredError, OutsideRootError, TRUST from api.services.media import is_media router = APIRouter(prefix="/api", tags=["file"]) +# Caps for POST /api/files. The client chunks its requests, but a server-side +# bound keeps any single response from ballooning: at most this many paths, and +# only images up to this size are base64-inlined — anything larger or non-image +# is omitted so the client falls back to the streaming GET /api/file path. +_MAX_BATCH_PATHS = 64 +_MAX_BATCH_FILE_BYTES = 8 * 1024 * 1024 + + +class FileBatchRequest(BaseModel): + paths: list[str] + @router.get("/file") def get_file( @@ -58,3 +75,32 @@ def get_file( # Non-media (code, configs, extensionless) → text/plain so the preview # renders the bytes as code; GZipMiddleware compresses it (text gzips well). return Response(content=body, media_type="text/plain; charset=utf-8") + + +@router.post("/files") +def get_files(req: FileBatchRequest) -> dict[str, FileBatchEntry]: + """Batch image fetch — return {path: {mime, b64}} for many small images in + one round trip. The scene loads one billboard texture per media file; firing + a separate GET per file exhausts the browser's HTTP/1.1 connection pool on + media-heavy repos, so the loader coalesces image paths into POST batches. + + Each path is trust-checked exactly like GET /api/file. Paths that are out of + root, missing, non-image, or larger than _MAX_BATCH_FILE_BYTES are silently + omitted; the client falls back to the streaming GET for those. Videos are + never batched (they stream their poster frame), so this is images only. + """ + out: dict[str, FileBatchEntry] = {} + for path in req.paths[:_MAX_BATCH_PATHS]: + try: + target = TRUST.assert_inside(Path(path)) + except (NoRootsRegisteredError, OutsideRootError, OSError, RuntimeError): + continue + if not target.is_file() or target.stat().st_size > _MAX_BATCH_FILE_BYTES: + continue + guessed, _ = mimetypes.guess_type(str(target)) + if not guessed or not guessed.startswith("image/"): + continue + out[path] = FileBatchEntry( + mime=guessed, b64=base64.b64encode(target.read_bytes()).decode() + ) + return out diff --git a/api/tests/test_server_file.py b/api/tests/test_server_file.py index cc79c95b..46851b19 100644 --- a/api/tests/test_server_file.py +++ b/api/tests/test_server_file.py @@ -16,6 +16,7 @@ def project(tmp_path: Path) -> Path: p = tmp_path / "repo" (p / "src").mkdir(parents=True) (p / "src" / "a.txt").write_text("hello") + (p / "src" / "pic.png").write_bytes(b"\x89PNG\r\n\x1a\nDATA") return p @@ -54,3 +55,38 @@ def test_file_outside_root_403( def test_file_missing_param_400(client: TestClient) -> None: r = client.get("/api/file") assert r.status_code in (400, 422) + + +def test_files_batch_returns_images_only( + client: TestClient, project: Path, tmp_path: Path +) -> None: + import base64 + + TRUST.register(project) + outside = tmp_path / "secret.png" + outside.write_bytes(b"nope") + pic = str(project / "src" / "pic.png") + r = client.post( + "/api/files", + json={ + "paths": [ + pic, + str(project / "src" / "a.txt"), # non-image → omitted + str(outside), # out of root → omitted + str(project / "src" / "missing.png"), # missing → omitted + ] + }, + ) + assert r.status_code == 200 + body = r.json() + assert set(body.keys()) == {pic} # only the in-root image survives + assert body[pic]["mime"] == "image/png" + assert base64.b64decode(body[pic]["b64"]) == b"\x89PNG\r\n\x1a\nDATA" + + +def test_files_batch_no_root_omits_all(client: TestClient, project: Path) -> None: + # No TRUST.register → every path is out-of-root → empty map (still 200, so a + # cold client can batch-request without first racing the manifest). + r = client.post("/api/files", json={"paths": [str(project / "src" / "pic.png")]}) + assert r.status_code == 200 + assert r.json() == {} diff --git a/app/src/city/components/buildings/adPanels.ts b/app/src/city/components/buildings/adPanels.ts index 61d38c7e..58f6591a 100644 --- a/app/src/city/components/buildings/adPanels.ts +++ b/app/src/city/components/buildings/adPanels.ts @@ -18,6 +18,7 @@ import { AD_ERROR_COLOR } from '@/constants/buildings'; import { RENDER_ORDERS } from '@/city/types/renderOrders'; import { mediaKindOf, MediaKind } from '@/city/utils/mediaKind'; import { AdPanelTextureArray, MAX_PAGES as AD_PANEL_MAX_PAGES } from './adPanelTextureArray'; +import { fetchMediaBlob } from './mediaBatch'; import type { Building } from '@/types/index'; import adPanelVertSrc from './adPanel.vert.glsl?raw'; @@ -489,11 +490,19 @@ function _releaseSlot(): void { } /** - * Trigger async load of a media building's image/video and upload to the - * given InstancedAdPanels instance once ready. URL scheme: - * `/api/file?path=`. Funneled through a concurrency - * semaphore so a media-heavy repo doesn't overwhelm the browser's - * connection pool or GPU upload queue (see file header comment). + * Trigger async load of a media building's image/video and upload to the given + * InstancedAdPanels instance once ready. + * + * Images: bytes come from the batched POST /api/files endpoint (see + * mediaBatch.ts) — the network fetch is coalesced across all media buildings, + * not slot-gated, so a media-heavy repo doesn't fire thousands of singleton + * GETs. Only the decode + GPU upload is funneled through the concurrency + * semaphore (paces texSubImage3D uploads + keeps the main thread responsive). + * A path the batch omits (too large / non-image) falls back to the streaming + * GET /api/file?path=… . + * + * Videos: never batched (we only need the first frame, and
+ ); + } + return ( + + ); +} + +/** A section's facts: bound min↔max duos under a dimension label, solo facts + * plain. The accent that binds a duo comes from the section's data-section. */ +function SectionBody({ facts }: { facts: AlmanacFact[] }) { + return ( + <> + {groupFacts(facts).map((g, i) => + g.dimension ? ( +
+ {g.dimension} + {g.facts.map((f, j) => ( + + ))} +
+ ) : ( + + ) + )} + ); } @@ -152,18 +207,22 @@ export function OverviewPane({ manifest }: OverviewPaneProps) { )} - {sections.map((s) => ( -
-

- {s.title} -

- {s.facts.length > 0 ? ( - s.facts.map((f, i) => ) - ) : ( -

{s.note}

- )} -
- ))} + {sections.map((s) => { + const Icon = SECTION_ICON[s.key]; + return ( +
+

+

+ {s.facts.length > 0 ? ( + + ) : ( +

{s.note}

+ )} +
+ ); + })} ); } diff --git a/app/src/views/InfoPane/almanac.ts b/app/src/views/InfoPane/almanac.ts index 97cb2e56..a5b3c5ec 100644 --- a/app/src/views/InfoPane/almanac.ts +++ b/app/src/views/InfoPane/almanac.ts @@ -39,6 +39,9 @@ export interface AlmanacFact { * monospace + left-truncated, and the row flies the camera to this landmark * on click. Absent → a plain count/date/name summary fact. */ landmark?: LandmarkRef; + /** Dimension name. Consecutive facts sharing it render as one bound min↔max + * duo (e.g. "Height" over Shortest + Tallest) instead of two loose rows. */ + group?: string; } export type AlmanacSectionKey = 'buildings' | 'media' | 'streets' | 'forest' | 'fireflies'; @@ -101,6 +104,7 @@ function fileFact(o: { leader: FileLeader | null; secondary: (l: FileLeader) => string; tip: string; + group?: string; }): AlmanacFact | null { if (!o.leader) return null; return { @@ -108,6 +112,7 @@ function fileFact(o: { primary: o.leader.path, secondary: o.secondary(o.leader), tip: o.tip, + group: o.group, landmark: { kind: NodeKind.File, id: o.leader.path }, }; } @@ -132,6 +137,7 @@ function commitFact(o: { label: string; leader: CommitLeader | null; tip: string; + group?: string; }): AlmanacFact | null { if (!o.leader) return null; return { @@ -139,6 +145,7 @@ function commitFact(o: { primary: o.leader.sha.slice(0, 7), secondary: pluralize(o.leader.files, 'file'), tip: o.tip, + group: o.group, landmark: { kind: NodeKind.Commit, id: o.leader.sha }, }; } @@ -180,55 +187,67 @@ function buildOverview(m: Manifest): AlmanacOverview { } function buildingsSection(s: RepoStats): AlmanacSection { + // Four min↔max duos — the dimension (Age / Last touched / Height / Footprint) + // carries the noun, so the endpoint labels stay terse and the metric column + // shows the bare value. Pair members must stay adjacent (the view groups + // consecutive same-`group` facts). const facts = compact([ fileFact({ - label: 'Newest building', - leader: s.newestCreatedFile, - secondary: (l) => `Created ${formatShortDate(l.created)}`, - tip: 'Most recently created file, by git history.', - }), - fileFact({ - label: 'Oldest building', + group: 'Age', + label: 'Oldest', leader: s.oldestCreatedFile, - secondary: (l) => `Created ${formatShortDate(l.created)}`, - tip: "Earliest-created file — the city's founding structure.", + secondary: (l) => formatShortDate(l.created), + tip: "Earliest-created file, by git history — the city's founding structure.", }), fileFact({ - label: 'Freshest building', - leader: s.newestModifiedFile, - secondary: (l) => `Edited ${formatShortDate(l.modified)}`, - tip: "File with the newest commit. Edits only count once committed — this is the date that drives a building's brightness.", + group: 'Age', + label: 'Newest', + leader: s.newestCreatedFile, + secondary: (l) => formatShortDate(l.created), + tip: 'Most recently created file, by git history.', }), fileFact({ - label: 'Stalest building', + group: 'Last touched', + label: 'Stalest', leader: s.oldestModifiedFile, - secondary: (l) => `Edited ${formatShortDate(l.modified)}`, + secondary: (l) => formatShortDate(l.modified), tip: 'File whose last commit is the oldest — the dimmest building.', }), fileFact({ - label: 'Tallest building', - leader: s.maxLinesFile, - secondary: (l) => pluralize(l.lines, 'line'), - tip: "File with the most lines; line count sets a building's height.", + group: 'Last touched', + label: 'Freshest', + leader: s.newestModifiedFile, + secondary: (l) => formatShortDate(l.modified), + tip: "File with the newest commit — the date that drives a building's brightness.", }), fileFact({ - label: 'Shortest building', + group: 'Height', + label: 'Shortest', leader: s.minLinesFile, secondary: (l) => pluralize(l.lines, 'line'), tip: 'File with the fewest lines.', }), fileFact({ - label: 'Widest building', - leader: s.maxBytesFile, - secondary: (l) => formatBytes(l.bytes), - tip: "Largest file by bytes; file size sets a building's footprint.", + group: 'Height', + label: 'Tallest', + leader: s.maxLinesFile, + secondary: (l) => pluralize(l.lines, 'line'), + tip: "File with the most lines; line count sets a building's height.", }), fileFact({ - label: 'Narrowest building', + group: 'Footprint', + label: 'Narrowest', leader: s.minBytesFile, secondary: (l) => formatBytes(l.bytes), tip: 'Smallest file by bytes.', }), + fileFact({ + group: 'Footprint', + label: 'Widest', + leader: s.maxBytesFile, + secondary: (l) => formatBytes(l.bytes), + tip: "Largest file by bytes; file size sets a building's footprint.", + }), ]); return { key: 'buildings', @@ -317,14 +336,16 @@ function forestSection(m: Manifest, treesEnabled: boolean): AlmanacSection { const s = m.stats; const facts = compact([ commitFact({ - label: 'Grandest canopy', - leader: s.maxFilesPerCommit, - tip: 'Commit that changed the most files — the widest tree.', + group: 'Canopy', + label: 'Sparsest', + leader: s.minFilesPerCommit, + tip: 'Commit that changed the fewest files — the smallest tree.', }), commitFact({ - label: 'Sparsest canopy', - leader: s.minFilesPerCommit, - tip: 'Commit that changed the fewest files.', + group: 'Canopy', + label: 'Grandest', + leader: s.maxFilesPerCommit, + tip: 'Commit that changed the most files — the widest tree.', }), s.maxCommitsPerDay ? statFact({ diff --git a/app/tests/views/InfoPane/almanac.test.ts b/app/tests/views/InfoPane/almanac.test.ts index 416c74c5..a9f672b3 100644 --- a/app/tests/views/InfoPane/almanac.test.ts +++ b/app/tests/views/InfoPane/almanac.test.ts @@ -145,38 +145,45 @@ describe('computeAlmanac — overview + buildings', () => { } it('tallest building = most lines, clickable to its file', () => { - const f = fact('buildings', 'Tallest building'); + const f = fact('buildings', 'Tallest'); expect(f.primary).toBe('tall.ts'); expect(f.secondary).toContain('999'); expect(f.landmark).toEqual({ kind: 'file', id: 'tall.ts' }); }); - it('date superlatives show the date in the secondary', () => { - expect(fact('buildings', 'Oldest building').secondary).toMatch(/Created/); - expect(fact('buildings', 'Freshest building').secondary).toMatch(/Edited/); + it('pairs min/max facts under a shared dimension', () => { + expect(fact('buildings', 'Tallest').group).toBe('Height'); + expect(fact('buildings', 'Shortest').group).toBe('Height'); + expect(fact('buildings', 'Oldest').group).toBe('Age'); + }); + it('date superlatives show a formatted date in the secondary', () => { + // The dimension (Age / Last touched) carries created-vs-modified, so the + // metric is the bare formatted date. (TZ-agnostic: just assert the shape.) + expect(fact('buildings', 'Oldest').secondary).toMatch(/\w{3} \d{1,2}, \d{4}/); + expect(fact('buildings', 'Freshest').secondary).toMatch(/\w{3} \d{1,2}, \d{4}/); }); it('oldest building = earliest created', () => { - expect(fact('buildings', 'Oldest building').landmark).toEqual({ kind: 'file', id: 'old.ts' }); + expect(fact('buildings', 'Oldest').landmark).toEqual({ kind: 'file', id: 'old.ts' }); }); it('newest building = latest created', () => { - expect(fact('buildings', 'Newest building').landmark).toEqual({ kind: 'file', id: 'new.ts' }); + expect(fact('buildings', 'Newest').landmark).toEqual({ kind: 'file', id: 'new.ts' }); }); it('widest building = largest bytes', () => { - expect(fact('buildings', 'Widest building').landmark).toEqual({ kind: 'file', id: 'new.ts' }); + expect(fact('buildings', 'Widest').landmark).toEqual({ kind: 'file', id: 'new.ts' }); }); it('shortest building = fewest lines', () => { - expect(fact('buildings', 'Shortest building').landmark).toEqual({ kind: 'file', id: 'old.ts' }); + expect(fact('buildings', 'Shortest').landmark).toEqual({ kind: 'file', id: 'old.ts' }); }); it('narrowest building = smallest bytes', () => { - expect(fact('buildings', 'Narrowest building').landmark).toEqual({ + expect(fact('buildings', 'Narrowest').landmark).toEqual({ kind: 'file', id: 'old.ts', }); }); it('freshest building = most recently modified', () => { - expect(fact('buildings', 'Freshest building').landmark).toEqual({ kind: 'file', id: 'new.ts' }); + expect(fact('buildings', 'Freshest').landmark).toEqual({ kind: 'file', id: 'new.ts' }); }); it('stalest building = longest since modified', () => { - expect(fact('buildings', 'Stalest building').landmark).toEqual({ kind: 'file', id: 'tall.ts' }); + expect(fact('buildings', 'Stalest').landmark).toEqual({ kind: 'file', id: 'tall.ts' }); }); it('splits media into its own Billboards section', () => { const withMedia = dir('repo', '', [ @@ -208,7 +215,7 @@ describe('computeAlmanac — overview + buildings', () => { const media = m.sections.find((s) => s.key === 'media')!; // Media never appears as a building superlative (not even Widest by bytes). expect(buildings.facts.every((f) => f.landmark?.id !== 'pic.png')).toBe(true); - expect(buildings.facts.find((f) => f.label === 'Widest building')!.landmark).toEqual({ + expect(buildings.facts.find((f) => f.label === 'Widest')!.landmark).toEqual({ kind: 'file', id: 'code.ts', }); @@ -284,10 +291,10 @@ describe('computeAlmanac — streets, forest, fireflies', () => { }); }); it('grandest canopy = commit touching most files', () => { - expect(fact('forest', 'Grandest canopy').landmark).toEqual({ kind: 'commit', id: 'bbb' }); + expect(fact('forest', 'Grandest').landmark).toEqual({ kind: 'commit', id: 'bbb' }); }); it('sparsest canopy = commit touching fewest files', () => { - expect(fact('forest', 'Sparsest canopy').landmark).toEqual({ kind: 'commit', id: 'ccc' }); + expect(fact('forest', 'Sparsest').landmark).toEqual({ kind: 'commit', id: 'ccc' }); }); it('busiest day is non-landmark and names the date', () => { const f = fact('forest', 'Busiest day'); diff --git a/app/tests/views/InfoPane/overviewPane.test.tsx b/app/tests/views/InfoPane/overviewPane.test.tsx index ac71dc62..539c4f13 100644 --- a/app/tests/views/InfoPane/overviewPane.test.tsx +++ b/app/tests/views/InfoPane/overviewPane.test.tsx @@ -144,10 +144,10 @@ describe('OverviewPane', () => { render(, container); await flush(); const row = Array.from(container.querySelectorAll('.almanac-fact')).find((el) => - el.textContent?.includes('Tallest building') + el.textContent?.includes('Tallest') ) as HTMLElement; expect(row).toBeTruthy(); - row.querySelector('button')!.click(); + (row as HTMLElement).click(); expect(selectPath).toHaveBeenCalledWith('a.ts'); expect(focusPath).toHaveBeenCalledWith('a.ts'); }); @@ -171,10 +171,10 @@ describe('OverviewPane', () => { render(, container); await flush(); const row = Array.from(container.querySelectorAll('.almanac-fact')).find((el) => - el.textContent?.includes('Grandest canopy') + el.textContent?.includes('Grandest') ) as HTMLElement; expect(row).toBeTruthy(); - row.querySelector('button')!.click(); + (row as HTMLElement).click(); expect(selectCommit).toHaveBeenCalledWith('abc1234'); expect(focusCommit).toHaveBeenCalledWith('abc1234'); }); @@ -228,7 +228,7 @@ describe('OverviewPane', () => { expect(container.textContent).toContain('Forest'); expect(container.textContent).toContain('Enable the Trees layer'); const canopyRow = Array.from(container.querySelectorAll('.almanac-fact')).find((el) => - el.textContent?.includes('Grandest canopy') + el.textContent?.includes('Grandest') ); expect(canopyRow).toBeUndefined(); }); From f5421e9d136261bc21b0aac2c9f36966cef8ecae Mon Sep 17 00:00:00 2001 From: Thalida Noel Date: Sun, 21 Jun 2026 18:58:50 -0400 Subject: [PATCH 48/65] style(overview): drop the duo left-border accent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per feedback — the dimension label alone groups the pair; the accent bar read as clutter. Rows now align flush under the label. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/src/views/InfoPane/OverviewPane.css | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/app/src/views/InfoPane/OverviewPane.css b/app/src/views/InfoPane/OverviewPane.css index aa4f97c2..76e21791 100644 --- a/app/src/views/InfoPane/OverviewPane.css +++ b/app/src/views/InfoPane/OverviewPane.css @@ -89,11 +89,9 @@ color: var(--cc-text-secondary); } -/* Each section carries its world-layer accent (icon tint + duo binding); the - * soft variant is derived so there's one source of truth per section. */ +/* Each section carries its world-layer accent (icon + dimension labels). */ .almanac-section { --sec-accent: var(--cc-text-secondary); - --sec-accent-soft: color-mix(in oklch, var(--sec-accent) 30%, transparent); } .almanac-section[data-section='buildings'] { --sec-accent: #7aa2f7; @@ -143,12 +141,10 @@ font-style: italic; } -/* A min↔max duo: two endpoints bound by the section accent so the relationship - * (not eight loose rows) is the unit you read. */ +/* A min↔max duo: two endpoints under a shared dimension label so the + * relationship (not eight loose rows) is the unit you read. */ .almanac-duo { margin: 2px 0 6px; - padding-left: 9px; - border-left: 2px solid var(--sec-accent-soft); } .almanac-duo-dim { From 18e5bfc4d180ba53f00e39395cd3c72f055908d0 Mon Sep 17 00:00:00 2001 From: Thalida Noel Date: Sun, 21 Jun 2026 19:13:06 -0400 Subject: [PATCH 49/65] feat(overview): section summaries + a header on every fact group MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Give each section the same opening rhythm: a summary line under the header (count + one aggregate), then every fact under a dimension label. - Overviews: Buildings '{n} buildings · ~{avg} lines each', Billboards '{n} billboards', Streets '{n} streets · ~{avg} files each', Forest '{n} trees · since {year}', Fireflies '{n} fireflies · ~{avg} commits each'. The per-section count facts (Billboards/Fireflies) fold into this line. - Every fact now sits under a dimension header — the loose singles get grouped too: Forest → Activity (Busiest day / Longest streak), Streets → Standouts (Deepest / Biggest), Billboards → Standouts (Largest / Sharpest), Fireflies → Top contributor (Most active). - Backend: add totalLines + codeBytes (non-media sums) to power the buildings average; the rest derive from existing counts. Co-Authored-By: Claude Opus 4.8 (1M context) --- api/models/manifest.py | 2 + api/services/manifest_types.py | 2 + api/services/stats.py | 5 + api/tests/services/test_stats.py | 3 + api/tests/test_models.py | 2 + app/src/constants/manifest.ts | 2 + app/src/types/manifest.generated.ts | 4 + app/src/types/manifest.ts | 4 + app/src/views/InfoPane/OverviewPane.css | 8 ++ app/src/views/InfoPane/OverviewPane.tsx | 5 +- app/src/views/InfoPane/almanac.ts | 117 +++++++++++++++-------- app/tests/_helpers/statsFixtures.ts | 2 + app/tests/views/InfoPane/almanac.test.ts | 19 ++-- 13 files changed, 128 insertions(+), 47 deletions(-) diff --git a/api/models/manifest.py b/api/models/manifest.py index cfb66046..56dc85dc 100644 --- a/api/models/manifest.py +++ b/api/models/manifest.py @@ -199,6 +199,8 @@ class RepoStats(BaseModel): maxMediaBytesFile: Optional[FileLeader] maxMediaPixelsFile: Optional[FileLeader] mediaCount: int + totalLines: int + codeBytes: int maxDepthDir: Optional[DirLeader] maxFilesPerDir: Optional[DirLeader] maxFilesPerCommit: Optional[CommitLeader] diff --git a/api/services/manifest_types.py b/api/services/manifest_types.py index 72c3688a..d05eb2df 100644 --- a/api/services/manifest_types.py +++ b/api/services/manifest_types.py @@ -246,6 +246,8 @@ class RepoStats(TypedDict): maxMediaBytesFile: FileLeader | None maxMediaPixelsFile: FileLeader | None mediaCount: int + totalLines: int + codeBytes: int maxDepthDir: DirLeader | None maxFilesPerDir: DirLeader | None maxFilesPerCommit: CommitLeader | None diff --git a/api/services/stats.py b/api/services/stats.py index 597c1397..85e775de 100644 --- a/api/services/stats.py +++ b/api/services/stats.py @@ -98,6 +98,7 @@ def compute_repo_stats(tree: DirNode, commits: list[CommitEntry]) -> RepoStats: # strict > / <), instead of a separate scan per superlative. line_min = line_max = byte_min = byte_max = None # non-zero ranges, all files media_count = 0 + total_lines = code_bytes = 0 # sums over non-media files (for overview averages) oldest = newest = freshest = stalest = None # non-media, by created/modified tallest = shortest = None # text (non-media, non-binary, lines>0), by lines widest = narrowest = None # non-media, by bytes @@ -119,6 +120,8 @@ def compute_repo_stats(tree: DirNode, commits: list[CommitEntry]) -> RepoStats: if px is not None and (sharpest_px is None or px > sharpest_px): sharpest_px, sharpest_media = px, f continue + total_lines += lines + code_bytes += size if oldest is None or f["created"] < oldest["created"]: oldest = f if newest is None or f["created"] > newest["created"]: @@ -209,6 +212,8 @@ def _commit_leader(c: Optional[CommitEntry]) -> CommitLeader | None: "maxMediaBytesFile": _file_leader(largest_media), "maxMediaPixelsFile": _file_leader(sharpest_media), "mediaCount": media_count, + "totalLines": total_lines, + "codeBytes": code_bytes, "maxDepthDir": _dir_leader(deepest), "maxFilesPerDir": _dir_leader(biggest), "maxFilesPerCommit": _commit_leader(grandest), diff --git a/api/tests/services/test_stats.py b/api/tests/services/test_stats.py index b166e22a..f74642c9 100644 --- a/api/tests/services/test_stats.py +++ b/api/tests/services/test_stats.py @@ -86,6 +86,9 @@ def test_file_leaders_partition_media_and_text(): assert s["lineCountRange"] == {"min": 40, "max": 40} # only a.ts has lines>0 assert s["byteSizeRange"] == {"min": 400, "max": 9000} # a.ts .. pic.png(media) assert s["mediaCount"] == 1 + # Sums are over non-media files only (a.ts + __init__.py; pic.png excluded). + assert s["totalLines"] == 40 + assert s["codeBytes"] == 400 def test_dir_leaders_exclude_root(): diff --git a/api/tests/test_models.py b/api/tests/test_models.py index 585ef887..4c6e8ab2 100644 --- a/api/tests/test_models.py +++ b/api/tests/test_models.py @@ -124,6 +124,8 @@ def test_manifest_excludes_none_optional_keys(self) -> None: "maxMediaBytesFile": None, "maxMediaPixelsFile": None, "mediaCount": 0, + "totalLines": 0, + "codeBytes": 0, "maxDepthDir": None, "maxFilesPerDir": None, "maxFilesPerCommit": None, diff --git a/app/src/constants/manifest.ts b/app/src/constants/manifest.ts index 898be5cf..83eb2a5b 100644 --- a/app/src/constants/manifest.ts +++ b/app/src/constants/manifest.ts @@ -19,6 +19,8 @@ export const EMPTY_REPO_STATS: RepoStats = { maxMediaBytesFile: null, maxMediaPixelsFile: null, mediaCount: 0, + totalLines: 0, + codeBytes: 0, maxDepthDir: null, maxFilesPerDir: null, maxFilesPerCommit: null, diff --git a/app/src/types/manifest.generated.ts b/app/src/types/manifest.generated.ts index c6e664a5..e90d86c7 100644 --- a/app/src/types/manifest.generated.ts +++ b/app/src/types/manifest.generated.ts @@ -496,6 +496,10 @@ export interface components { maxMediaPixelsFile: components["schemas"]["FileLeader"] | null; /** Mediacount */ mediaCount: number; + /** Totallines */ + totalLines: number; + /** Codebytes */ + codeBytes: number; maxDepthDir: components["schemas"]["DirLeader"] | null; maxFilesPerDir: components["schemas"]["DirLeader"] | null; maxFilesPerCommit: components["schemas"]["CommitLeader"] | null; diff --git a/app/src/types/manifest.ts b/app/src/types/manifest.ts index 71d8868e..0de09e71 100644 --- a/app/src/types/manifest.ts +++ b/app/src/types/manifest.ts @@ -254,6 +254,10 @@ export interface RepoStats { maxMediaBytesFile: FileLeader | null; maxMediaPixelsFile: FileLeader | null; mediaCount: number; + /** Sum of lines over non-media files (for the buildings overview average). */ + totalLines: number; + /** Sum of bytes over non-media files. */ + codeBytes: number; maxDepthDir: DirLeader | null; maxFilesPerDir: DirLeader | null; maxFilesPerCommit: CommitLeader | null; diff --git a/app/src/views/InfoPane/OverviewPane.css b/app/src/views/InfoPane/OverviewPane.css index 76e21791..e8667d7c 100644 --- a/app/src/views/InfoPane/OverviewPane.css +++ b/app/src/views/InfoPane/OverviewPane.css @@ -133,6 +133,14 @@ color: var(--sec-accent); } +/* Summary line under each header — count + one aggregate, same rhythm in every + * section. Sits between the title and the first dimension group. */ +.almanac-section-overview { + margin: -2px 0 8px 6px; + color: var(--cc-text-secondary); + font-size: var(--cc-font-lg); +} + .almanac-section-note { margin: 0; padding: 2px 0 4px; diff --git a/app/src/views/InfoPane/OverviewPane.tsx b/app/src/views/InfoPane/OverviewPane.tsx index 0a6ec2b7..cd0ff227 100644 --- a/app/src/views/InfoPane/OverviewPane.tsx +++ b/app/src/views/InfoPane/OverviewPane.tsx @@ -216,7 +216,10 @@ export function OverviewPane({ manifest }: OverviewPaneProps) { {s.title} {s.facts.length > 0 ? ( - + <> +

{s.overview}

+ + ) : (

{s.note}

)} diff --git a/app/src/views/InfoPane/almanac.ts b/app/src/views/InfoPane/almanac.ts index a5b3c5ec..7c9cf6ab 100644 --- a/app/src/views/InfoPane/almanac.ts +++ b/app/src/views/InfoPane/almanac.ts @@ -5,15 +5,7 @@ // to fly the camera there. import { NodeKind } from '@/types'; -import type { - Manifest, - DirNode, - RepoInfo, - RepoStats, - FileLeader, - DirLeader, - CommitLeader, -} from '@/types'; +import type { Manifest, DirNode, RepoInfo, FileLeader, DirLeader, CommitLeader } from '@/types'; import { formatShortDate } from '@/utils/dates'; import { formatBytes } from '@/utils/bytes'; import { formatCount, pluralize } from '@/utils/format'; @@ -51,6 +43,9 @@ export interface AlmanacSection { title: string; /** One-line "what is this layer" blurb, shown as the section header tooltip. */ tip: string; + /** Summary line under the header — a count + one aggregate ("315 fireflies · + * ~40 commits each"). Gives every section the same opening rhythm. */ + overview: string; facts: AlmanacFact[]; /** Shown in place of facts when the section has none — an empty state or a * gated notice (e.g. the Trees layer is off). */ @@ -122,6 +117,7 @@ function dirFact(o: { leader: DirLeader | null; secondary: (l: DirLeader) => string; tip: string; + group?: string; }): AlmanacFact | null { if (!o.leader) return null; return { @@ -129,6 +125,7 @@ function dirFact(o: { primary: o.leader.path, secondary: o.secondary(o.leader), tip: o.tip, + group: o.group, landmark: { kind: NodeKind.Directory, id: o.leader.path }, }; } @@ -155,14 +152,27 @@ function statFact(o: { primary: string; secondary?: string; tip: string; + group?: string; }): AlmanacFact { - return { label: o.label, primary: o.primary, secondary: o.secondary, tip: o.tip }; + return { + label: o.label, + primary: o.primary, + secondary: o.secondary, + tip: o.tip, + group: o.group, + }; } function compact(facts: (AlmanacFact | null)[]): AlmanacFact[] { return facts.filter((f): f is AlmanacFact => f !== null); } +/** Rounded "X per Y" for an overview average, or null when there's nothing to + * divide by (so the caller can drop the trailing "· ~N each" clause). */ +function perEach(total: number, n: number): number | null { + return n > 0 ? Math.round(total / n) : null; +} + function buildOverview(m: Manifest): AlmanacOverview { const root = m.tree; return { @@ -186,7 +196,8 @@ function buildOverview(m: Manifest): AlmanacOverview { }; } -function buildingsSection(s: RepoStats): AlmanacSection { +function buildingsSection(m: Manifest): AlmanacSection { + const s = m.stats; // Four min↔max duos — the dimension (Age / Last touched / Height / Footprint) // carries the noun, so the endpoint labels stay terse and the metric column // shows the bare value. Pair members must stay adjacent (the view groups @@ -249,62 +260,77 @@ function buildingsSection(s: RepoStats): AlmanacSection { tip: "Largest file by bytes; file size sets a building's footprint.", }), ]); + // Buildings = non-media files; media render as billboards in their own section. + const count = Math.max(0, m.tree.descendants_file_count - s.mediaCount); + const avgLines = perEach(s.totalLines, count); + const overview = + pluralize(count, 'building') + + (avgLines !== null ? ` · ~${formatCount(avgLines)} lines each` : ''); return { key: 'buildings', title: 'Buildings', tip: SECTION_TIPS.buildings, + overview, facts, note: facts.length ? undefined : 'No code files yet.', }; } -function mediaSection(s: RepoStats): AlmanacSection { +function mediaSection(m: Manifest): AlmanacSection { // Media files render as billboards (image/video ad panels) sized by aspect, // not lines — a separate class of building with its own superlatives. + const s = m.stats; + const overview = pluralize(s.mediaCount, 'billboard'); if (s.mediaCount === 0) { return { key: 'media', title: 'Billboards', tip: SECTION_TIPS.media, + overview, facts: [], note: 'No images or videos.', }; } const sharp = s.maxMediaPixelsFile; const facts = compact([ - statFact({ - label: 'Billboards', - primary: pluralize(s.mediaCount, 'billboard'), - tip: 'Image & video files — they render as billboard panels sized by aspect.', - }), fileFact({ - label: 'Largest billboard', + group: 'Standouts', + label: 'Largest', leader: s.maxMediaBytesFile, secondary: (l) => formatBytes(l.bytes), tip: 'Biggest media file by bytes.', }), sharp?.media_width && sharp?.media_height ? fileFact({ - label: 'Highest resolution', + group: 'Standouts', + label: 'Sharpest', leader: sharp, secondary: (l) => `${formatCount(l.media_width!)} × ${formatCount(l.media_height!)}`, tip: 'Media file with the most pixels.', }) : null, ]); - return { key: 'media', title: 'Billboards', tip: SECTION_TIPS.media, facts }; + return { key: 'media', title: 'Billboards', tip: SECTION_TIPS.media, overview, facts }; } -function streetsSection(s: RepoStats): AlmanacSection { +function streetsSection(m: Manifest): AlmanacSection { + const s = m.stats; + const dirs = m.tree.descendants_dir_count; + const avgFiles = perEach(m.tree.descendants_file_count, dirs); + const overview = + pluralize(dirs, 'street') + + (avgFiles !== null ? ` · ~${formatCount(avgFiles)} files each` : ''); const facts = compact([ dirFact({ - label: 'Deepest alley', + group: 'Standouts', + label: 'Deepest', leader: s.maxDepthDir, secondary: (l) => `${l.depth} levels deep`, tip: 'Most deeply nested directory.', }), dirFact({ - label: 'Biggest neighborhood', + group: 'Standouts', + label: 'Biggest', leader: s.maxFilesPerDir, secondary: (l) => pluralize(l.file_count, 'building'), tip: "Directory holding the most files (excluding the repo root); sets a street's width.", @@ -314,13 +340,17 @@ function streetsSection(s: RepoStats): AlmanacSection { key: 'streets', title: 'Streets', tip: SECTION_TIPS.streets, + overview, facts, note: facts.length ? undefined : 'Everything lives at the root — no sub-directories.', }; } function forestSection(m: Manifest, treesEnabled: boolean): AlmanacSection { - const base = { key: 'forest', title: 'Forest', tip: SECTION_TIPS.forest } as const; + const trees = m.commits.length; + const since = m.stats.commitDates.oldest?.slice(0, 4) ?? null; // YYYY, TZ-safe + const overview = pluralize(trees, 'tree') + (since ? ` · since ${since}` : ''); + const base = { key: 'forest', title: 'Forest', tip: SECTION_TIPS.forest, overview } as const; // Canopies fly the camera to a tree; with the Trees layer off those targets // don't exist, so the notice lives here (not the view) like any empty state. if (!treesEnabled) { @@ -330,7 +360,7 @@ function forestSection(m: Manifest, treesEnabled: boolean): AlmanacSection { note: 'Enable the Trees layer in Settings to explore the forest.', }; } - if (m.commits.length === 0) { + if (trees === 0) { return { ...base, facts: [], note: 'No commits yet.' }; } const s = m.stats; @@ -349,6 +379,7 @@ function forestSection(m: Manifest, treesEnabled: boolean): AlmanacSection { }), s.maxCommitsPerDay ? statFact({ + group: 'Activity', label: 'Busiest day', primary: formatShortDate(s.maxCommitsPerDay.date), secondary: pluralize(s.maxCommitsPerDay.count, 'commit'), @@ -356,6 +387,7 @@ function forestSection(m: Manifest, treesEnabled: boolean): AlmanacSection { }) : null, statFact({ + group: 'Activity', label: 'Longest streak', primary: pluralize(s.maxCommitStreakDays, 'consecutive day'), tip: 'Longest run of consecutive days with commits.', @@ -364,9 +396,21 @@ function forestSection(m: Manifest, treesEnabled: boolean): AlmanacSection { return { ...base, facts }; } -function firefliesSection(s: RepoStats): AlmanacSection { - const base = { key: 'fireflies', title: 'Fireflies', tip: SECTION_TIPS.fireflies } as const; - if (s.authors.length === 0) { +function firefliesSection(m: Manifest): AlmanacSection { + const s = m.stats; + const count = s.authors.length; + const avgCommits = perEach(m.commits.length, count); + // 'firefly' is irregular, so pluralize (naive +s) won't do. + const noun = count === 1 ? 'firefly' : 'fireflies'; + const each = avgCommits !== null ? ` · ~${formatCount(avgCommits)} commits each` : ''; + const overview = `${formatCount(count)} ${noun}${each}`; + const base = { + key: 'fireflies', + title: 'Fireflies', + tip: SECTION_TIPS.fireflies, + overview, + } as const; + if (count === 0) { return { ...base, facts: [], note: 'No commits yet — no fireflies.' }; } const top = s.authors[0]; // pre-sorted descending by commits from the backend @@ -374,12 +418,8 @@ function firefliesSection(s: RepoStats): AlmanacSection { ...base, facts: [ statFact({ - label: 'Fireflies', - primary: pluralize(s.authors.length, 'author'), - tip: 'Distinct commit authors; each is a uniquely colored firefly.', - }), - statFact({ - label: 'Most prolific author', + group: 'Top contributor', + label: 'Most active', primary: top.name, secondary: pluralize(top.commits, 'commit'), tip: 'Author with the most commits — the largest firefly.', @@ -400,13 +440,12 @@ export function computeAlmanac( ): Almanac | null { if (!isManifest(m)) return null; if (!m.stats) return null; - const s = m.stats; const sections: AlmanacSection[] = [ - buildingsSection(s), - mediaSection(s), - streetsSection(s), + buildingsSection(m), + mediaSection(m), + streetsSection(m), forestSection(m, treesEnabled), - firefliesSection(s), + firefliesSection(m), ]; return { overview: buildOverview(m), sections }; } diff --git a/app/tests/_helpers/statsFixtures.ts b/app/tests/_helpers/statsFixtures.ts index c2ca51ab..357be3cc 100644 --- a/app/tests/_helpers/statsFixtures.ts +++ b/app/tests/_helpers/statsFixtures.ts @@ -87,6 +87,8 @@ export function uniformFileStats(path: string, lines: number, bytes: number): Re const l = fileLeader(path, lines, bytes); return { ...EMPTY_REPO_STATS, + totalLines: lines, + codeBytes: bytes, oldestCreatedFile: l, newestCreatedFile: l, newestModifiedFile: l, diff --git a/app/tests/views/InfoPane/almanac.test.ts b/app/tests/views/InfoPane/almanac.test.ts index a9f672b3..6837d4a1 100644 --- a/app/tests/views/InfoPane/almanac.test.ts +++ b/app/tests/views/InfoPane/almanac.test.ts @@ -220,11 +220,11 @@ describe('computeAlmanac — overview + buildings', () => { id: 'code.ts', }); // It's the largest, highest-resolution billboard instead. - expect(media.facts.find((f) => f.label === 'Largest billboard')!.landmark).toEqual({ + expect(media.facts.find((f) => f.label === 'Largest')!.landmark).toEqual({ kind: 'file', id: 'pic.png', }); - expect(media.facts.find((f) => f.label === 'Highest resolution')!.secondary).toContain('1,920'); + expect(media.facts.find((f) => f.label === 'Sharpest')!.secondary).toContain('1,920'); expect(media.facts.every((f) => f.tip)).toBe(true); }); it('keeps the Billboards section with a note when there is no media', () => { @@ -279,13 +279,13 @@ describe('computeAlmanac — streets, forest, fireflies', () => { const fact = (key: string, label: string) => section(key).facts.find((f) => f.label === label)!; it('deepest alley = deepest directory, excluding root', () => { - expect(fact('streets', 'Deepest alley').landmark).toEqual({ + expect(fact('streets', 'Deepest').landmark).toEqual({ kind: NodeKind.Directory, id: 'src/a/b', }); }); it('biggest neighborhood = max descendant files, excluding root', () => { - expect(fact('streets', 'Biggest neighborhood').landmark).toEqual({ + expect(fact('streets', 'Biggest').landmark).toEqual({ kind: NodeKind.Directory, id: 'src', }); @@ -304,9 +304,14 @@ describe('computeAlmanac — streets, forest, fireflies', () => { it('longest streak counts consecutive days', () => { expect(fact('forest', 'Longest streak').primary).toContain('3'); }); - it('fireflies count distinct authors and name the most prolific', () => { - expect(fact('fireflies', 'Fireflies').primary).toContain('2'); - expect(fact('fireflies', 'Most prolific author').primary).toContain('Ada'); + it('fireflies count distinct authors (overview) and name the most active', () => { + expect(section('fireflies').overview).toContain('2'); // 2 authors + expect(fact('fireflies', 'Most active').primary).toContain('Ada'); + }); + it('every section opens with an overview summary', () => { + expect(section('streets').overview).toMatch(/street/); + expect(section('forest').overview).toMatch(/tree/); + expect(section('fireflies').overview).toMatch(/firefl/); }); it('attaches a tooltip to every fact', () => { for (const s of a.sections) From 32fb86f865e42aa346535c8f8d28f01004df55b4 Mon Sep 17 00:00:00 2001 From: Thalida Noel Date: Sun, 21 Jun 2026 19:38:38 -0400 Subject: [PATCH 50/65] =?UTF-8?q?fix(overview):=20polish=20pass=20?= =?UTF-8?q?=E2=80=94=20alignment,=20visible=20affordance,=20NaN,=20balance?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address defects I shouldn't have shipped, plus the requested content: UI/UX (matched to the app's existing conventions, not reinvented): - Focus glyph: was baseline-misaligned, hidden until hover, and tinted per section. Now centered (row is align-items:center), always visible, and one consistent interactive color (faint→primary, like .btn-icon) in every section — section accent stays for identity, not interaction. - Fixed 76px label column so values align row-to-row; one value text size. - Balanced the spacing around section dividers; consistent left rail. Bug: - '~NaN lines each' — totalLines needs the cache schema bumped (v10) so the live manifest re-scans; perEach also guards a non-finite total. Content / balance: - Forest overview is a duration ('… · 2 years of history') not 'since 2024'. - Billboards now pairs Size (Smallest/Largest) + Resolution (Lowest/Highest) — adds min media leaders to the backend stats. Fireflies pairs Least/Most active. Every section now reads as labeled duos. Co-Authored-By: Claude Opus 4.8 (1M context) --- api/models/manifest.py | 2 + api/services/cache.py | 4 +- api/services/manifest_types.py | 2 + api/services/stats.py | 16 ++- api/tests/services/test_stats.py | 10 ++ api/tests/test_models.py | 2 + app/src/constants/manifest.ts | 2 + app/src/types/manifest.generated.ts | 2 + app/src/types/manifest.ts | 2 + app/src/utils/dates.ts | 15 +++ app/src/views/InfoPane/OverviewPane.css | 46 ++++---- app/src/views/InfoPane/almanac.ts | 105 +++++++++++++----- app/tests/views/InfoPane/almanac.test.ts | 50 ++++++++- .../views/InfoPane/overviewPane.test.tsx | 2 +- 14 files changed, 201 insertions(+), 59 deletions(-) diff --git a/api/models/manifest.py b/api/models/manifest.py index 56dc85dc..c97cdbcf 100644 --- a/api/models/manifest.py +++ b/api/models/manifest.py @@ -197,7 +197,9 @@ class RepoStats(BaseModel): maxBytesFile: Optional[FileLeader] minBytesFile: Optional[FileLeader] maxMediaBytesFile: Optional[FileLeader] + minMediaBytesFile: Optional[FileLeader] maxMediaPixelsFile: Optional[FileLeader] + minMediaPixelsFile: Optional[FileLeader] mediaCount: int totalLines: int codeBytes: int diff --git a/api/services/cache.py b/api/services/cache.py index 3582a3d3..34530e3e 100644 --- a/api/services/cache.py +++ b/api/services/cache.py @@ -65,7 +65,9 @@ class FileEntry(TypedDict): # treated as a miss and re-scanned. (Per-bump rationale lives in git history.) _FILE_CACHE_VERSION = 1 _GIT_HISTORY_CACHE_VERSION = 12 # v12: dates UTC-normalized (file maps + commit days) -_MANIFEST_SCHEMA_VERSION = 9 # v9: stats.commitDates (tree-age normalization range) +_MANIFEST_SCHEMA_VERSION = ( + 10 # v10: stats totals (totalLines/codeBytes) + min media leaders +) # Composite: invalidates when EITHER the manifest schema OR the git-history # shape changes. Stored as a string in the cache file's `version` field. _MANIFEST_CACHE_VERSION: str = ( diff --git a/api/services/manifest_types.py b/api/services/manifest_types.py index d05eb2df..8ef72841 100644 --- a/api/services/manifest_types.py +++ b/api/services/manifest_types.py @@ -244,7 +244,9 @@ class RepoStats(TypedDict): maxBytesFile: FileLeader | None minBytesFile: FileLeader | None maxMediaBytesFile: FileLeader | None + minMediaBytesFile: FileLeader | None maxMediaPixelsFile: FileLeader | None + minMediaPixelsFile: FileLeader | None mediaCount: int totalLines: int codeBytes: int diff --git a/api/services/stats.py b/api/services/stats.py index 85e775de..2bf93935 100644 --- a/api/services/stats.py +++ b/api/services/stats.py @@ -102,8 +102,9 @@ def compute_repo_stats(tree: DirNode, commits: list[CommitEntry]) -> RepoStats: oldest = newest = freshest = stalest = None # non-media, by created/modified tallest = shortest = None # text (non-media, non-binary, lines>0), by lines widest = narrowest = None # non-media, by bytes - largest_media = sharpest_media = None # media, by bytes / pixels - sharpest_px = None + largest_media = smallest_media = None # media, by bytes + sharpest_media = coarsest_media = None # media, by pixels (most / fewest) + sharpest_px = coarsest_px = None for f in _iter_files(tree): lines, size = f["lines"], f["size"] if lines > 0: @@ -116,9 +117,14 @@ def compute_repo_stats(tree: DirNode, commits: list[CommitEntry]) -> RepoStats: media_count += 1 if largest_media is None or size > largest_media["size"]: largest_media = f + if smallest_media is None or size < smallest_media["size"]: + smallest_media = f px = _media_pixels(f) - if px is not None and (sharpest_px is None or px > sharpest_px): - sharpest_px, sharpest_media = px, f + if px is not None: + if sharpest_px is None or px > sharpest_px: + sharpest_px, sharpest_media = px, f + if coarsest_px is None or px < coarsest_px: + coarsest_px, coarsest_media = px, f continue total_lines += lines code_bytes += size @@ -210,7 +216,9 @@ def _commit_leader(c: Optional[CommitEntry]) -> CommitLeader | None: "maxBytesFile": _file_leader(widest), "minBytesFile": _file_leader(narrowest), "maxMediaBytesFile": _file_leader(largest_media), + "minMediaBytesFile": _file_leader(smallest_media), "maxMediaPixelsFile": _file_leader(sharpest_media), + "minMediaPixelsFile": _file_leader(coarsest_media), "mediaCount": media_count, "totalLines": total_lines, "codeBytes": code_bytes, diff --git a/api/tests/services/test_stats.py b/api/tests/services/test_stats.py index f74642c9..22d02197 100644 --- a/api/tests/services/test_stats.py +++ b/api/tests/services/test_stats.py @@ -181,6 +181,16 @@ def test_media_only_repo_ranges(): assert s["byteSizeRange"] == {"min": 500, "max": 500} +def test_media_min_max_leaders(): + small = _file("small.png", lines=0, size=100, media="image", mw=10, mh=10) + big = _file("big.png", lines=0, size=9000, media="image", mw=1920, mh=1080) + s = compute_repo_stats(_dir("repo", "", [small, big]), []) + assert s["maxMediaBytesFile"]["path"] == "big.png" + assert s["minMediaBytesFile"]["path"] == "small.png" + assert s["maxMediaPixelsFile"]["path"] == "big.png" + assert s["minMediaPixelsFile"]["path"] == "small.png" + + def test_empty_files_only_ranges(): # A repo of only empty files: both ranges are the {0,0} sentinel; the # frontend's _safeRange turns these into {1,1} (no divide-by-zero). diff --git a/api/tests/test_models.py b/api/tests/test_models.py index 4c6e8ab2..3bfb1ecb 100644 --- a/api/tests/test_models.py +++ b/api/tests/test_models.py @@ -122,7 +122,9 @@ def test_manifest_excludes_none_optional_keys(self) -> None: "maxBytesFile": None, "minBytesFile": None, "maxMediaBytesFile": None, + "minMediaBytesFile": None, "maxMediaPixelsFile": None, + "minMediaPixelsFile": None, "mediaCount": 0, "totalLines": 0, "codeBytes": 0, diff --git a/app/src/constants/manifest.ts b/app/src/constants/manifest.ts index 83eb2a5b..35c709f1 100644 --- a/app/src/constants/manifest.ts +++ b/app/src/constants/manifest.ts @@ -17,7 +17,9 @@ export const EMPTY_REPO_STATS: RepoStats = { maxBytesFile: null, minBytesFile: null, maxMediaBytesFile: null, + minMediaBytesFile: null, maxMediaPixelsFile: null, + minMediaPixelsFile: null, mediaCount: 0, totalLines: 0, codeBytes: 0, diff --git a/app/src/types/manifest.generated.ts b/app/src/types/manifest.generated.ts index e90d86c7..240ab29f 100644 --- a/app/src/types/manifest.generated.ts +++ b/app/src/types/manifest.generated.ts @@ -493,7 +493,9 @@ export interface components { maxBytesFile: components["schemas"]["FileLeader"] | null; minBytesFile: components["schemas"]["FileLeader"] | null; maxMediaBytesFile: components["schemas"]["FileLeader"] | null; + minMediaBytesFile: components["schemas"]["FileLeader"] | null; maxMediaPixelsFile: components["schemas"]["FileLeader"] | null; + minMediaPixelsFile: components["schemas"]["FileLeader"] | null; /** Mediacount */ mediaCount: number; /** Totallines */ diff --git a/app/src/types/manifest.ts b/app/src/types/manifest.ts index 0de09e71..33309d8f 100644 --- a/app/src/types/manifest.ts +++ b/app/src/types/manifest.ts @@ -252,7 +252,9 @@ export interface RepoStats { maxBytesFile: FileLeader | null; minBytesFile: FileLeader | null; maxMediaBytesFile: FileLeader | null; + minMediaBytesFile: FileLeader | null; maxMediaPixelsFile: FileLeader | null; + minMediaPixelsFile: FileLeader | null; mediaCount: number; /** Sum of lines over non-media files (for the buildings overview average). */ totalLines: number; diff --git a/app/src/utils/dates.ts b/app/src/utils/dates.ts index df25254a..a88a897f 100644 --- a/app/src/utils/dates.ts +++ b/app/src/utils/dates.ts @@ -108,6 +108,21 @@ export function formatRelativeAgeShort(thenMs: number, nowMs: number): string { return `${Math.floor(diff / MS_YEAR)}y ago`; } +/** Coarse human duration between two dates, one unit only ("2 years", + * "5 months", "3 weeks", "4 days"). Deterministic (no "now"), so it's safe for + * spans/ages in pure view-models. Returns '' if either date is unparseable. */ +export function humanSpan(fromISO: string, toISO: string): string { + const a = parseLocalDate(fromISO); + const b = parseLocalDate(toISO); + if (!a || !b) return ''; + const diff = Math.max(0, b.getTime() - a.getTime()); + const unit = (n: number, u: string) => `${n} ${u}${n === 1 ? '' : 's'}`; + if (diff >= MS_YEAR) return unit(Math.round(diff / MS_YEAR), 'year'); + if (diff >= MS_MONTH) return unit(Math.round(diff / MS_MONTH), 'month'); + if (diff >= 7 * MS_DAY) return unit(Math.round(diff / (7 * MS_DAY)), 'week'); + return unit(Math.max(1, Math.round(diff / MS_DAY)), 'day'); +} + /** ISO date string → epoch ms; NaN for missing/unparseable (so it never wins a * max() comparison). */ export function dateMs(iso: string | null | undefined): number { diff --git a/app/src/views/InfoPane/OverviewPane.css b/app/src/views/InfoPane/OverviewPane.css index e8667d7c..c3c1b232 100644 --- a/app/src/views/InfoPane/OverviewPane.css +++ b/app/src/views/InfoPane/OverviewPane.css @@ -2,7 +2,8 @@ padding: 12px 14px; display: flex; flex-direction: column; - gap: 16px; + /* 14px here + 14px section padding-top = balanced space around the divider. */ + gap: 14px; } .almanac-name { @@ -134,9 +135,9 @@ } /* Summary line under each header — count + one aggregate, same rhythm in every - * section. Sits between the title and the first dimension group. */ + * section. Left-aligned with the fact-row text below it. */ .almanac-section-overview { - margin: -2px 0 8px 6px; + margin: -2px 0 8px 8px; color: var(--cc-text-secondary); font-size: var(--cc-font-lg); } @@ -157,21 +158,22 @@ .almanac-duo-dim { display: block; - margin: 0 0 1px 6px; + margin: 0 0 1px 8px; font-size: var(--cc-font-sm); text-transform: uppercase; letter-spacing: var(--cc-track-label); color: var(--sec-accent); } -/* One fact row — label · value · right-aligned metric on a single line. A - * landmark row is a reset + )} {repo.remote_url && ( @@ -179,40 +246,27 @@ export function OverviewPane({ manifest }: OverviewPaneProps) { )} - {repo.head_sha && repo.head_subject && ( -
-
Latest
-
- {latestUrl ? ( - - {repo.head_sha.slice(0, 7)} {repo.head_subject} - - ) : ( - <> - {repo.head_sha.slice(0, 7)} {repo.head_subject} - - )} -
-
- )} {overview.languages.length > 0 && ( -
    - {overview.languages.map((l) => ( -
  • - - {formatCount(l.count)} -
  • - ))} - {overview.moreLanguages > 0 && ( -
  • - +{formatCount(overview.moreLanguages)} more -
  • - )} -
+ <> + +
    + {overview.languages.map((l) => ( +
  • + + {formatCount(l.count)} +
  • + ))} + {overview.moreLanguages > 0 && ( +
  • + +{formatCount(overview.moreLanguages)} more +
  • + )} +
+ )} {sections.map((s) => { diff --git a/app/src/views/InfoPane/almanac.ts b/app/src/views/InfoPane/almanac.ts index 4fae377a..72660a91 100644 --- a/app/src/views/InfoPane/almanac.ts +++ b/app/src/views/InfoPane/almanac.ts @@ -10,6 +10,7 @@ import { formatShortDate, humanSpan } from '@/utils/dates'; import { formatBytes } from '@/utils/bytes'; import { formatCount, pluralize } from '@/utils/format'; import { labelFromSource } from '@/utils/sources'; +import { languageLabelForExt } from '@/utils/syntaxLanguages'; export type LandmarkKind = NodeKind.File | NodeKind.Directory | NodeKind.Commit; @@ -59,7 +60,17 @@ export interface LanguageStat { export interface AlmanacOverview { name: string; + /** Founding date as a short calendar string ("Mar 23, 2024"), or null. */ founded: string | null; + /** Raw founding timestamp (earliest file creation) — lets the view derive a + * live "N-year-old" age against the current clock. Null when undated. */ + foundedISO: string | null; + /** Newest commit date — the "Latest" row's relative-age anchor. Null when + * there are no commits. */ + latestDate: string | null; + /** Friendly name of the dominant language ("TypeScript"), for the flavor + * blurb. Null when the top file type has no nameable language ("(none)"). */ + topLanguage: string | null; totals: { files: number; dirs: number; commits: number; authors: number }; repo: RepoInfo; languages: LanguageStat[]; @@ -192,6 +203,10 @@ function buildOverview(m: Manifest): AlmanacOverview { root.name ?? 'this project', founded: m.dateRanges.minCreated ? formatShortDate(m.dateRanges.minCreated) : null, + foundedISO: m.dateRanges.minCreated ?? null, + latestDate: m.stats.commitDates.newest ?? null, + // Dominant file type → a nameable language for the flavor blurb. + topLanguage: exts.length ? languageLabelForExt(exts[0].ext) : null, totals: { files: root.descendants_file_count, dirs: root.descendants_dir_count, diff --git a/app/tests/utils/dates.test.ts b/app/tests/utils/dates.test.ts index 2ca5d048..ce9fee18 100644 --- a/app/tests/utils/dates.test.ts +++ b/app/tests/utils/dates.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { formatRelativeAge } from '@/utils/dates'; +import { formatRelativeAge, humanAge } from '@/utils/dates'; const NOW = new Date('2026-05-24T12:00:00Z'); @@ -41,3 +41,19 @@ describe('formatRelativeAge', () => { expect(formatRelativeAge('2026-05-25T00:00:00Z', NOW)).toBe('just now'); }); }); + +describe('humanAge', () => { + const TO = '2026-05-24'; + + it('renders a singular-unit adjective phrase', () => { + expect(humanAge('2024-05-24', TO)).toBe('2-year-old'); + expect(humanAge('2025-05-24', TO)).toBe('1-year-old'); + expect(humanAge('2026-03-24', TO)).toBe('2-month-old'); + expect(humanAge('2026-05-03', TO)).toBe('3-week-old'); + expect(humanAge('2026-05-20', TO)).toBe('4-day-old'); + }); + + it('returns empty string for an unparseable date', () => { + expect(humanAge('not-a-date', TO)).toBe(''); + }); +}); diff --git a/app/tests/utils/syntaxLanguages.test.ts b/app/tests/utils/syntaxLanguages.test.ts new file mode 100644 index 00000000..b008c91f --- /dev/null +++ b/app/tests/utils/syntaxLanguages.test.ts @@ -0,0 +1,22 @@ +import { describe, it, expect } from 'vitest'; +import { languageLabelForExt } from '@/utils/syntaxLanguages'; + +describe('languageLabelForExt', () => { + it('maps a known extension to its display label', () => { + expect(languageLabelForExt('.ts')).toBe('TypeScript'); + expect(languageLabelForExt('.py')).toBe('Python'); + }); + + it('is case-insensitive on the extension', () => { + expect(languageLabelForExt('.TS')).toBe('TypeScript'); + }); + + it('falls back to the uppercased extension when no language matches', () => { + expect(languageLabelForExt('.exr')).toBe('EXR'); + }); + + it('returns null for an empty extension or the "(none)" sentinel', () => { + expect(languageLabelForExt('')).toBeNull(); + expect(languageLabelForExt('(none)')).toBeNull(); + }); +}); diff --git a/app/tests/views/InfoPane/almanac.test.ts b/app/tests/views/InfoPane/almanac.test.ts index 887fea41..2c86f6a8 100644 --- a/app/tests/views/InfoPane/almanac.test.ts +++ b/app/tests/views/InfoPane/almanac.test.ts @@ -138,6 +138,26 @@ describe('computeAlmanac — overview + buildings', () => { it('languages come from root ext breakdown', () => { expect(a!.overview.languages[0]).toEqual({ ext: '.ts', count: 3 }); }); + it('exposes founding timestamp + dominant language for the flavor blurb', () => { + expect(a!.overview.foundedISO).toBe('2020-01-01T00:00:00Z'); + expect(a!.overview.topLanguage).toBe('TypeScript'); + }); + it('latestDate is the newest commit date (null when no commits)', () => { + expect(a!.overview.latestDate).toBeNull(); + const withDates = computeAlmanac( + manifest(tree, { + stats: { ...buildingsStats, commitDates: { oldest: '2020-01-01', newest: '2023-09-12' } }, + }) + )!; + expect(withDates.overview.latestDate).toBe('2023-09-12'); + }); + it('topLanguage is null when the dominant file type has no nameable language', () => { + const t = { + ...dir('repo', '', []), + descendants_ext_breakdown: [{ ext: '(none)', count: 4, size: 0 }], + } as DirNode; + expect(computeAlmanac(manifest(t))!.overview.topLanguage).toBeNull(); + }); it('summarizes file types beyond the top languages as "+N more"', () => { const exts = Array.from({ length: 9 }, (_, i) => ({ ext: `.t${i}`, diff --git a/app/tests/views/InfoPane/overviewPane.test.tsx b/app/tests/views/InfoPane/overviewPane.test.tsx index 80b3ead7..36b3515e 100644 --- a/app/tests/views/InfoPane/overviewPane.test.tsx +++ b/app/tests/views/InfoPane/overviewPane.test.tsx @@ -115,7 +115,7 @@ describe('OverviewPane', () => { const sig = signal(manifest); render(, container); await flush(); - expect(container.textContent).toContain('1 buildings'); + expect(container.textContent).toContain('1 building '); sig.value = { ...manifest, @@ -129,7 +129,7 @@ describe('OverviewPane', () => { const sig = signal(manifest); render(, container); await flush(); - expect(container.textContent).toContain('1 buildings'); + expect(container.textContent).toContain('1 building '); sig.value = { ...manifest, @@ -205,6 +205,44 @@ describe('OverviewPane', () => { expect(row.querySelector('button')).toBeNull(); }); + it('renders a flavor blurb (age + dominant language), not raw counts', async () => { + const sig = signal(manifest); + render(, container); + await flush(); + const blurb = container.querySelector('.almanac-blurb'); + expect(blurb).toBeTruthy(); + // "A city, mostly TypeScript." — age is live, so match the shape. + expect(blurb!.textContent).toMatch(/^A .+ city, mostly TypeScript\.$/); + // The old counts-blurb is gone (and with it the "1 fireflies" plural bug). + expect(container.textContent).not.toContain('sprawls across'); + }); + + it('renders a language composition bar mirroring the legend', async () => { + const sig = signal(manifest); + render(, container); + await flush(); + expect(container.querySelector('.almanac-langbar')).toBeTruthy(); + expect(container.querySelectorAll('.almanac-langbar-seg').length).toBeGreaterThan(0); + }); + + it('the Latest row flies the camera to the head commit and shows the branch chip', async () => { + const withHead: Manifest = { + ...manifest, + repo: { ...manifest.repo, head_sha: 'deadbeefcafe', head_subject: 'Fix the thing' }, + stats: { ...singleFileStats, commitDates: { oldest: '2020-01-01', newest: '2024-03-10' } }, + }; + const sig = signal(withHead); + render(, container); + await flush(); + expect(container.querySelector('.almanac-branch')?.textContent).toBe('main'); + const latest = container.querySelector('.almanac-latest') as HTMLElement; + expect(latest).toBeTruthy(); + expect(latest.textContent).toContain('deadbee'); + latest.click(); + expect(selectCommit).toHaveBeenCalledWith('deadbeefcafe'); + expect(focusCommit).toHaveBeenCalledWith('deadbeefcafe'); + }); + it('gates the Forest section when the Trees layer is disabled', async () => { treesState.ENABLED = false; const withCommits: Manifest = { From 03077bade888a32eceb7f176cfe79d62fc9c2fca Mon Sep 17 00:00:00 2001 From: Thalida Noel Date: Fri, 26 Jun 2026 00:11:23 -0400 Subject: [PATCH 59/65] feat(almanac): make Latest static when the Trees layer is off MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit focusTree/selectByCommit no-op when the commit's tree doesn't exist, so a clickable Latest button would be a dead end with the Trees layer disabled. Render it as a plain row in that case — mirroring how the Forest section gates its commit landmark rows. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/src/views/InfoPane/OverviewPane.css | 9 +++- app/src/views/InfoPane/OverviewPane.tsx | 50 ++++++++++++------- .../views/InfoPane/overviewPane.test.tsx | 16 ++++++ 3 files changed, 55 insertions(+), 20 deletions(-) diff --git a/app/src/views/InfoPane/OverviewPane.css b/app/src/views/InfoPane/OverviewPane.css index e45d0cfd..8e2c19fd 100644 --- a/app/src/views/InfoPane/OverviewPane.css +++ b/app/src/views/InfoPane/OverviewPane.css @@ -90,12 +90,17 @@ font: inherit; color: inherit; text-align: left; +} + +/* Clickable only with the Trees layer on (there's a tree to fly to). When off, + * the same markup renders as a plain
— no cursor, no hover affordance. */ +button.almanac-latest { cursor: pointer; transition: background-color 0.12s ease; } -.almanac-latest:hover, -.almanac-latest:focus-visible { +button.almanac-latest:hover, +button.almanac-latest:focus-visible { background: var(--cc-overlay-bg); outline: none; } diff --git a/app/src/views/InfoPane/OverviewPane.tsx b/app/src/views/InfoPane/OverviewPane.tsx index 2168416c..2f72f858 100644 --- a/app/src/views/InfoPane/OverviewPane.tsx +++ b/app/src/views/InfoPane/OverviewPane.tsx @@ -198,6 +198,23 @@ export function OverviewPane({ manifest }: OverviewPaneProps) { ? { sha: repo.head_sha.slice(0, 7), full: repo.head_sha, subject: repo.head_subject } : null; const latestAgo = overview.latestDate ? formatRelativeAge(overview.latestDate) : null; + // The Latest row only flies the camera when the Trees layer is on — without it + // the commit's tree doesn't exist, so a clickable button would be a dead end + // (the Forest section gates its commit rows the same way). + const latestBody = head && ( + <> + + {head.sha} + {head.subject} + + {(latestAgo || repo.branch) && ( + + {latestAgo && {latestAgo}} + {repo.branch && {repo.branch}} + + )} + + ); return (
@@ -215,24 +232,21 @@ export function OverviewPane({ manifest }: OverviewPaneProps) {
Latest
- + {treesEnabled ? ( + + ) : ( +
+ {latestBody} +
+ )}
)} diff --git a/app/tests/views/InfoPane/overviewPane.test.tsx b/app/tests/views/InfoPane/overviewPane.test.tsx index 36b3515e..1e01eccf 100644 --- a/app/tests/views/InfoPane/overviewPane.test.tsx +++ b/app/tests/views/InfoPane/overviewPane.test.tsx @@ -243,6 +243,22 @@ describe('OverviewPane', () => { expect(focusCommit).toHaveBeenCalledWith('deadbeefcafe'); }); + it('renders Latest as a static (non-button) row when the Trees layer is off', async () => { + treesState.ENABLED = false; + const withHead: Manifest = { + ...manifest, + repo: { ...manifest.repo, head_sha: 'deadbeefcafe', head_subject: 'Fix the thing' }, + stats: { ...singleFileStats, commitDates: { oldest: '2020-01-01', newest: '2024-03-10' } }, + }; + const sig = signal(withHead); + render(, container); + await flush(); + const latest = container.querySelector('.almanac-latest') as HTMLElement; + expect(latest).toBeTruthy(); + expect(latest.tagName).toBe('DIV'); // not a button — no dead-end click + expect(latest.textContent).toContain('deadbee'); + }); + it('gates the Forest section when the Trees layer is disabled', async () => { treesState.ENABLED = false; const withCommits: Manifest = { From 77bae587551e31163c25f720701f23f34fc27884 Mon Sep 17 00:00:00 2001 From: Thalida Noel Date: Fri, 26 Jun 2026 00:17:04 -0400 Subject: [PATCH 60/65] feat(almanac): title each language-bar segment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each composition-bar segment now carries a tooltip ("TypeScript · 31 files (40%)") so a bare colored sliver is identifiable on hover, and the trailing tail names how many files across how many other types it folds in. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/src/views/InfoPane/OverviewPane.tsx | 49 +++++++++++++------ .../views/InfoPane/overviewPane.test.tsx | 7 ++- 2 files changed, 39 insertions(+), 17 deletions(-) diff --git a/app/src/views/InfoPane/OverviewPane.tsx b/app/src/views/InfoPane/OverviewPane.tsx index 2f72f858..06cdad93 100644 --- a/app/src/views/InfoPane/OverviewPane.tsx +++ b/app/src/views/InfoPane/OverviewPane.tsx @@ -18,7 +18,8 @@ import { TREES } from '@/state/stores/settings/trees'; import { BUILDINGS } from '@/state/stores/settings/buildings'; import { getHue } from '@/city/components/buildings/color'; import { humanAge, formatRelativeAge } from '@/utils/dates'; -import { formatCount } from '@/utils/format'; +import { languageLabelForExt } from '@/utils/syntaxLanguages'; +import { formatCount, pluralize } from '@/utils/format'; import { computeAlmanac } from './almanac'; import type { AlmanacFact, AlmanacSectionKey, LandmarkRef, LanguageStat } from './almanac'; @@ -140,27 +141,41 @@ function flavorBlurb(age: string, language: string | null): string { * file count, painted with the same extension→hue the city and the legend chips * use), plus a neutral tail for everything past the top few. The chips below it * read as its legend. */ -function LanguageBar({ languages, moreFiles }: { languages: LanguageStat[]; moreFiles: number }) { +function LanguageBar({ + languages, + moreLanguages, + moreFiles, +}: { + languages: LanguageStat[]; + moreLanguages: number; + moreFiles: number; +}) { const palette = BUILDINGS.value.HUE_EXT_MAP; const total = languages.reduce((sum, l) => sum + l.count, 0) + moreFiles; if (total <= 0) return null; + const share = (n: number) => `${Math.round((n / total) * 100)}%`; return ( @@ -263,7 +278,11 @@ export function OverviewPane({ manifest }: OverviewPaneProps) { {overview.languages.length > 0 && ( <> - +
    {overview.languages.map((l) => (
  • diff --git a/app/tests/views/InfoPane/overviewPane.test.tsx b/app/tests/views/InfoPane/overviewPane.test.tsx index 1e01eccf..be5b48f7 100644 --- a/app/tests/views/InfoPane/overviewPane.test.tsx +++ b/app/tests/views/InfoPane/overviewPane.test.tsx @@ -217,12 +217,15 @@ describe('OverviewPane', () => { expect(container.textContent).not.toContain('sprawls across'); }); - it('renders a language composition bar mirroring the legend', async () => { + it('renders a language composition bar mirroring the legend, each segment titled', async () => { const sig = signal(manifest); render(, container); await flush(); expect(container.querySelector('.almanac-langbar')).toBeTruthy(); - expect(container.querySelectorAll('.almanac-langbar-seg').length).toBeGreaterThan(0); + const segs = Array.from(container.querySelectorAll('.almanac-langbar-seg')); + expect(segs.length).toBeGreaterThan(0); + // The lone .ts segment is named, counted, and shows its share on hover. + expect(segs[0].getAttribute('title')).toBe('TypeScript · 1 file (100%)'); }); it('the Latest row flies the camera to the head commit and shows the branch chip', async () => { From 01d46c002789a229f9276b7e31b929a91b1dbcf4 Mon Sep 17 00:00:00 2001 From: Thalida Noel Date: Fri, 26 Jun 2026 00:17:38 -0400 Subject: [PATCH 61/65] fix(almanac): stretch meta values to fill the right column MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The meta dd cells sized to their content, so the Latest button (width:100%) only spanned its text, not the full column. Give dd flex:1 so it fills the row past the fixed label column — the button's click/hover area now spans the whole right column and long values truncate at its edge. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/src/views/InfoPane/OverviewPane.css | 3 +++ 1 file changed, 3 insertions(+) diff --git a/app/src/views/InfoPane/OverviewPane.css b/app/src/views/InfoPane/OverviewPane.css index 8e2c19fd..7beb0590 100644 --- a/app/src/views/InfoPane/OverviewPane.css +++ b/app/src/views/InfoPane/OverviewPane.css @@ -43,6 +43,9 @@ } .almanac-meta dd { + /* Fill the rest of the row past the fixed label column, so the Latest cell's + * button spans the full right column (and long values truncate at its edge). */ + flex: 1 1 0; margin: 0; min-width: 0; color: var(--cc-text-secondary); From e42e8ac06c3a9b78b49ccc0fd700bf7b3c3a031d Mon Sep 17 00:00:00 2001 From: Thalida Noel Date: Fri, 26 Jun 2026 00:21:29 -0400 Subject: [PATCH 62/65] feat(almanac): give the blurb a middle-ground (add scale + fix article) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pure age+language line read too terse. Weave the building count back in — "An 8-year-old city of 312 buildings, mostly Python." — for enough texture to set the scene without the per-layer counts (those live in each section). Also fixes the article: humanAge yields "8-year-old", so the old "A " prefix produced the ungrammatical "A 8-year-old". articleFor picks A/An by the spoken sound of the leading number. Building count is non-media files, matching the Buildings section. Each clause drops out when its input is absent. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/src/views/InfoPane/OverviewPane.tsx | 29 +++++++++++++------ app/src/views/InfoPane/almanac.ts | 4 +++ app/tests/views/InfoPane/almanac.test.ts | 7 +++++ .../views/InfoPane/overviewPane.test.tsx | 26 +++++++++++++++-- 4 files changed, 54 insertions(+), 12 deletions(-) diff --git a/app/src/views/InfoPane/OverviewPane.tsx b/app/src/views/InfoPane/OverviewPane.tsx index 06cdad93..9679daf7 100644 --- a/app/src/views/InfoPane/OverviewPane.tsx +++ b/app/src/views/InfoPane/OverviewPane.tsx @@ -127,14 +127,25 @@ function SectionBody({ facts }: { facts: AlmanacFact[] }) { ); } -/** A flavor one-liner from the city's age + dominant language — pure character, - * no raw counts (those live in each section's overview line). Empty when the - * repo has neither a founding date nor a nameable top language. */ -function flavorBlurb(age: string, language: string | null): string { - if (age && language) return `A ${age} city, mostly ${language}.`; - if (age) return `A ${age} city.`; - if (language) return `A city built mostly in ${language}.`; - return ''; +/** "A"/"An" for an age phrase ("8-year-old" → "An"), keyed on the spoken sound + * of its leading number. Vowel-sound starts in the realistic age range: 8, 11, + * 18, and the eighties. */ +function articleFor(age: string): 'A' | 'An' { + const n = parseInt(age, 10); + return n === 8 || n === 11 || n === 18 || (n >= 80 && n <= 89) ? 'An' : 'A'; +} + +/** A flavor one-liner weaving the city's age, scale, and dominant language into + * one sentence ("An 8-year-old city of 312 buildings, mostly Python.") — enough + * texture to set the scene, without the per-layer counts (those live in each + * section's overview). Each clause drops out when its input is absent; empty + * when the repo has nothing to say. */ +export function flavorBlurb(age: string, buildings: number, language: string | null): string { + if (!age && !buildings && !language) return ''; + let s = age ? `${articleFor(age)} ${age} city` : 'A city'; + if (buildings > 0) s += ` of ${pluralize(buildings, 'building')}`; + if (language) s += `, mostly ${language}`; + return `${s}.`; } /** GitHub-style stacked composition bar: one segment per top language (width ∝ @@ -206,7 +217,7 @@ export function OverviewPane({ manifest }: OverviewPaneProps) { const { repo } = overview; // Live age against the current clock ("2-year-old") for the flavor blurb. const age = overview.foundedISO ? humanAge(overview.foundedISO, new Date().toISOString()) : ''; - const blurb = flavorBlurb(age, overview.topLanguage); + const blurb = flavorBlurb(age, overview.buildings, overview.topLanguage); // The HEAD commit, if any — the "Latest" row flies the camera to its tree. const head = repo.head_sha && repo.head_subject diff --git a/app/src/views/InfoPane/almanac.ts b/app/src/views/InfoPane/almanac.ts index 72660a91..4fb340d7 100644 --- a/app/src/views/InfoPane/almanac.ts +++ b/app/src/views/InfoPane/almanac.ts @@ -71,6 +71,9 @@ export interface AlmanacOverview { /** Friendly name of the dominant language ("TypeScript"), for the flavor * blurb. Null when the top file type has no nameable language ("(none)"). */ topLanguage: string | null; + /** Building count for the blurb — non-media files, matching the Buildings + * section (media render as billboards, not buildings). */ + buildings: number; totals: { files: number; dirs: number; commits: number; authors: number }; repo: RepoInfo; languages: LanguageStat[]; @@ -207,6 +210,7 @@ function buildOverview(m: Manifest): AlmanacOverview { latestDate: m.stats.commitDates.newest ?? null, // Dominant file type → a nameable language for the flavor blurb. topLanguage: exts.length ? languageLabelForExt(exts[0].ext) : null, + buildings: Math.max(0, root.descendants_file_count - m.stats.mediaCount), totals: { files: root.descendants_file_count, dirs: root.descendants_dir_count, diff --git a/app/tests/views/InfoPane/almanac.test.ts b/app/tests/views/InfoPane/almanac.test.ts index 2c86f6a8..a8edff25 100644 --- a/app/tests/views/InfoPane/almanac.test.ts +++ b/app/tests/views/InfoPane/almanac.test.ts @@ -142,6 +142,13 @@ describe('computeAlmanac — overview + buildings', () => { expect(a!.overview.foundedISO).toBe('2020-01-01T00:00:00Z'); expect(a!.overview.topLanguage).toBe('TypeScript'); }); + it('buildings count excludes media (matches the Buildings section)', () => { + expect(a!.overview.buildings).toBe(3); // 3 files, no media + const withMedia = computeAlmanac( + manifest(tree, { stats: { ...buildingsStats, mediaCount: 1 } }) + )!; + expect(withMedia.overview.buildings).toBe(2); // 3 files − 1 billboard + }); it('latestDate is the newest commit date (null when no commits)', () => { expect(a!.overview.latestDate).toBeNull(); const withDates = computeAlmanac( diff --git a/app/tests/views/InfoPane/overviewPane.test.tsx b/app/tests/views/InfoPane/overviewPane.test.tsx index be5b48f7..8b6c0e8a 100644 --- a/app/tests/views/InfoPane/overviewPane.test.tsx +++ b/app/tests/views/InfoPane/overviewPane.test.tsx @@ -22,7 +22,7 @@ vi.mock('@/state/stores/settings/trees', () => ({ }, })); -import { OverviewPane } from '@/views/InfoPane/OverviewPane'; +import { OverviewPane, flavorBlurb } from '@/views/InfoPane/OverviewPane'; import { InfoPane } from '@/views/InfoPane/InfoPane'; import { NodeKind } from '@/types'; import type { Manifest } from '@/types'; @@ -211,12 +211,32 @@ describe('OverviewPane', () => { await flush(); const blurb = container.querySelector('.almanac-blurb'); expect(blurb).toBeTruthy(); - // "A city, mostly TypeScript." — age is live, so match the shape. - expect(blurb!.textContent).toMatch(/^A .+ city, mostly TypeScript\.$/); + // "An? city of 1 building, mostly TypeScript." — age is live (depends + // on the clock), so match the shape rather than an exact age. + expect(blurb!.textContent).toMatch(/^An? .+ city of 1 building, mostly TypeScript\.$/); // The old counts-blurb is gone (and with it the "1 fireflies" plural bug). expect(container.textContent).not.toContain('sprawls across'); }); + it('flavorBlurb weaves age + scale + language with a correct article', () => { + expect(flavorBlurb('8-year-old', 312, 'Python')).toBe( + 'An 8-year-old city of 312 buildings, mostly Python.' + ); + expect(flavorBlurb('2-year-old', 312, 'Python')).toBe( + 'A 2-year-old city of 312 buildings, mostly Python.' + ); + expect(flavorBlurb('11-month-old', 5, 'Go')).toBe( + 'An 11-month-old city of 5 buildings, mostly Go.' + ); + // Singular building, and clauses drop out when their input is absent. + expect(flavorBlurb('1-year-old', 1, 'CSS')).toBe( + 'A 1-year-old city of 1 building, mostly CSS.' + ); + expect(flavorBlurb('', 312, 'Python')).toBe('A city of 312 buildings, mostly Python.'); + expect(flavorBlurb('5-day-old', 0, null)).toBe('A 5-day-old city.'); + expect(flavorBlurb('', 0, null)).toBe(''); + }); + it('renders a language composition bar mirroring the legend, each segment titled', async () => { const sig = signal(manifest); render(, container); From 90157fdc5b86a1a88486a7f20436590c42da2960 Mon Sep 17 00:00:00 2001 From: Thalida Noel Date: Fri, 26 Jun 2026 00:24:53 -0400 Subject: [PATCH 63/65] fix(almanac): make Latest informational with the sha as a real link MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The whole-row camera-fly button was an invisible link — no affordance that it was clickable. Make the Latest row plain informational text and turn the commit hash into the one explicit link (accent-colored, hover underline, matching the Remote link), pointing at the commit on the remote. Plain text when there's no remote. Drops the camera-fly and its Trees-layer gating. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/src/views/InfoPane/OverviewPane.css | 49 +++++--------- app/src/views/InfoPane/OverviewPane.tsx | 66 +++++++++---------- .../views/InfoPane/overviewPane.test.tsx | 35 +++++----- 3 files changed, 68 insertions(+), 82 deletions(-) diff --git a/app/src/views/InfoPane/OverviewPane.css b/app/src/views/InfoPane/OverviewPane.css index 7beb0590..9e5a5703 100644 --- a/app/src/views/InfoPane/OverviewPane.css +++ b/app/src/views/InfoPane/OverviewPane.css @@ -63,49 +63,36 @@ text-decoration: underline; } -.almanac-sha { +/* The sha as plain text (no remote) vs. as the one link in the row (accent + + * hover underline, matching the Remote link below). Both stay monospace and + * never shrink. */ +.almanac-sha, +.almanac-sha-link { flex: 0 0 auto; font-family: var(--cc-font-mono); +} + +.almanac-sha { color: var(--cc-text-primary); } -/* The Latest cell holds an interactive two-line button, so it must not clip the - * button's hover bleed (the other cells stay clipped for URL truncation). */ -.almanac-latest-cell { - overflow: visible; +.almanac-sha-link { + color: var(--cc-accent-light); + text-decoration: none; +} + +.almanac-sha-link:hover, +.almanac-sha-link:focus-visible { + text-decoration: underline; } -/* Latest commit → a button that flies the camera to its tree. Two lines: sha + - * subject on top, relative age + branch chip below. Negative margins bleed the - * hover highlight into the gutter while the sha stays flush with the other - * values — the same pattern the fact rows use. */ +/* Latest commit — an informational two-line row: sha + subject on top, relative + * age + branch chip below. */ .almanac-latest { display: flex; flex-direction: column; gap: 2px; - width: 100%; min-width: 0; - margin: -4px -6px; - padding: 4px 6px; - border: none; - border-radius: var(--cc-radius-md); - background: none; - font: inherit; - color: inherit; - text-align: left; -} - -/* Clickable only with the Trees layer on (there's a tree to fly to). When off, - * the same markup renders as a plain
    — no cursor, no hover affordance. */ -button.almanac-latest { - cursor: pointer; - transition: background-color 0.12s ease; -} - -button.almanac-latest:hover, -button.almanac-latest:focus-visible { - background: var(--cc-overlay-bg); - outline: none; } .almanac-latest-head { diff --git a/app/src/views/InfoPane/OverviewPane.tsx b/app/src/views/InfoPane/OverviewPane.tsx index 9679daf7..df1cc98a 100644 --- a/app/src/views/InfoPane/OverviewPane.tsx +++ b/app/src/views/InfoPane/OverviewPane.tsx @@ -18,6 +18,7 @@ import { TREES } from '@/state/stores/settings/trees'; import { BUILDINGS } from '@/state/stores/settings/buildings'; import { getHue } from '@/city/components/buildings/color'; import { humanAge, formatRelativeAge } from '@/utils/dates'; +import { commitUrl } from '@/utils/commit'; import { languageLabelForExt } from '@/utils/syntaxLanguages'; import { formatCount, pluralize } from '@/utils/format'; import { computeAlmanac } from './almanac'; @@ -218,29 +219,15 @@ export function OverviewPane({ manifest }: OverviewPaneProps) { // Live age against the current clock ("2-year-old") for the flavor blurb. const age = overview.foundedISO ? humanAge(overview.foundedISO, new Date().toISOString()) : ''; const blurb = flavorBlurb(age, overview.buildings, overview.topLanguage); - // The HEAD commit, if any — the "Latest" row flies the camera to its tree. + // The HEAD commit, if any — an informational row; the sha is the one link, + // pointing at the commit on the remote (when there is one). const head = repo.head_sha && repo.head_subject - ? { sha: repo.head_sha.slice(0, 7), full: repo.head_sha, subject: repo.head_subject } + ? { sha: repo.head_sha.slice(0, 7), subject: repo.head_subject } : null; const latestAgo = overview.latestDate ? formatRelativeAge(overview.latestDate) : null; - // The Latest row only flies the camera when the Trees layer is on — without it - // the commit's tree doesn't exist, so a clickable button would be a dead end - // (the Forest section gates its commit rows the same way). - const latestBody = head && ( - <> - - {head.sha} - {head.subject} - - {(latestAgo || repo.branch) && ( - - {latestAgo && {latestAgo}} - {repo.branch && {repo.branch}} - - )} - - ); + const latestUrl = + repo.remote_url && repo.head_sha ? commitUrl(repo.remote_url, repo.head_sha) : null; return (
    @@ -257,22 +244,31 @@ export function OverviewPane({ manifest }: OverviewPaneProps) { {head && (
    Latest
    -
    - {treesEnabled ? ( - - ) : ( -
    - {latestBody} -
    - )} +
    +
    + + {latestUrl ? ( + + {head.sha} + + ) : ( + {head.sha} + )} + {head.subject} + + {(latestAgo || repo.branch) && ( + + {latestAgo && {latestAgo}} + {repo.branch && {repo.branch}} + + )} +
    )} diff --git a/app/tests/views/InfoPane/overviewPane.test.tsx b/app/tests/views/InfoPane/overviewPane.test.tsx index 8b6c0e8a..b2464c27 100644 --- a/app/tests/views/InfoPane/overviewPane.test.tsx +++ b/app/tests/views/InfoPane/overviewPane.test.tsx @@ -248,38 +248,41 @@ describe('OverviewPane', () => { expect(segs[0].getAttribute('title')).toBe('TypeScript · 1 file (100%)'); }); - it('the Latest row flies the camera to the head commit and shows the branch chip', async () => { + it('Latest is an informational row; the commit hash links to the remote', async () => { const withHead: Manifest = { ...manifest, - repo: { ...manifest.repo, head_sha: 'deadbeefcafe', head_subject: 'Fix the thing' }, + repo: { + ...manifest.repo, + remote_url: 'https://github.com/o/r', + head_sha: 'deadbeefcafe', + head_subject: 'Fix the thing', + }, stats: { ...singleFileStats, commitDates: { oldest: '2020-01-01', newest: '2024-03-10' } }, }; const sig = signal(withHead); render(, container); await flush(); - expect(container.querySelector('.almanac-branch')?.textContent).toBe('main'); + // The row is not a button — no invisible whole-row link. const latest = container.querySelector('.almanac-latest') as HTMLElement; - expect(latest).toBeTruthy(); - expect(latest.textContent).toContain('deadbee'); - latest.click(); - expect(selectCommit).toHaveBeenCalledWith('deadbeefcafe'); - expect(focusCommit).toHaveBeenCalledWith('deadbeefcafe'); + expect(latest.tagName).toBe('DIV'); + expect(latest.querySelector('button')).toBeNull(); + // The commit hash is the one link, pointing at the commit on the remote. + const link = latest.querySelector('a.almanac-sha-link') as HTMLAnchorElement; + expect(link.textContent).toBe('deadbee'); + expect(link.getAttribute('href')).toBe('https://github.com/o/r/commit/deadbeefcafe'); + expect(container.querySelector('.almanac-branch')?.textContent).toBe('main'); }); - it('renders Latest as a static (non-button) row when the Trees layer is off', async () => { - treesState.ENABLED = false; + it('renders the commit hash as plain text (no link) when there is no remote', async () => { const withHead: Manifest = { ...manifest, - repo: { ...manifest.repo, head_sha: 'deadbeefcafe', head_subject: 'Fix the thing' }, - stats: { ...singleFileStats, commitDates: { oldest: '2020-01-01', newest: '2024-03-10' } }, + repo: { ...manifest.repo, remote_url: null, head_sha: 'deadbeefcafe', head_subject: 'x' }, }; const sig = signal(withHead); render(, container); await flush(); - const latest = container.querySelector('.almanac-latest') as HTMLElement; - expect(latest).toBeTruthy(); - expect(latest.tagName).toBe('DIV'); // not a button — no dead-end click - expect(latest.textContent).toContain('deadbee'); + expect(container.querySelector('a.almanac-sha-link')).toBeNull(); + expect(container.querySelector('.almanac-sha')?.textContent).toBe('deadbee'); }); it('gates the Forest section when the Trees layer is disabled', async () => { From 8ebade1edb152d6155f5169657f0ff0b50480a59 Mon Sep 17 00:00:00 2001 From: Thalida Noel Date: Fri, 26 Jun 2026 00:34:44 -0400 Subject: [PATCH 64/65] feat(sidebar): Info tab leads the rail and opens on world load MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move Info to the top of the left activity bar and make it the default tab, so a freshly-loaded world greets you with its almanac. A useSignalEffect on CURRENT_SOURCE re-opens Info on every committed load (cold-boot ?src= and user source switches both write it; live-reloads don't, so a manual tab change persists between loads). The panel's collapsed state is untouched — this only sets which tab is active. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/src/constants/ui.ts | 4 ++- app/src/layout/LeftSidebar/LeftSidebar.tsx | 10 +++++- app/tests/layout/leftSidebar.test.tsx | 40 ++++++++++++++++------ 3 files changed, 42 insertions(+), 12 deletions(-) diff --git a/app/src/constants/ui.ts b/app/src/constants/ui.ts index 2815320b..f052b584 100644 --- a/app/src/constants/ui.ts +++ b/app/src/constants/ui.ts @@ -29,8 +29,10 @@ export interface ActivityBarTab { } export const ACTIVITY_BAR_TABS: readonly ActivityBarTab[] = [ + // Info leads: the almanac is the first thing a freshly-loaded world greets you + // with (see LeftSidebar's default tab + on-load switch). + { id: SidebarTab.Info, icon: Info, title: 'Info' }, { id: SidebarTab.Tree, icon: FolderTree, title: 'Tree' }, { id: SidebarTab.Search, icon: Search, title: 'Search' }, - { id: SidebarTab.Info, icon: Info, title: 'Info' }, { id: SidebarTab.Controls, icon: Settings2, title: 'Settings', placement: TabPlacement.Bottom }, ] as const; diff --git a/app/src/layout/LeftSidebar/LeftSidebar.tsx b/app/src/layout/LeftSidebar/LeftSidebar.tsx index 915eb7e7..0d296d72 100644 --- a/app/src/layout/LeftSidebar/LeftSidebar.tsx +++ b/app/src/layout/LeftSidebar/LeftSidebar.tsx @@ -32,6 +32,7 @@ import { runStemDiagnostic, } from '@/state/stores/scene'; import { MANIFEST } from '@/state/stores/manifest'; +import { CURRENT_SOURCE } from '@/state/stores/source'; import { isEmptyManifest } from '@/utils/manifest'; import { TreePane } from '@/views/TreePane/TreePane'; import { InfoPane } from '@/views/InfoPane/InfoPane'; @@ -100,7 +101,7 @@ function ActivityBar({ activeTab, collapsed, onIconClick }: ActivityBarProps) { // ── Main component ─────────────────────────────────────────────────── export function LeftSidebar() { - const activeTab = useSignal(SidebarTab.Tree); + const activeTab = useSignal(SidebarTab.Info); const collapsed = useSignal(LEFT_SIDEBAR_COLLAPSED.value); // Tree selection + hover paths, derived from picker signals. @@ -136,6 +137,13 @@ export function LeftSidebar() { LEFT_SIDEBAR_COLLAPSED.value = collapsed.value; }); + // Open to Info whenever a world commits — cold-boot ?src= and a user source + // switch both write CURRENT_SOURCE; live-reloads don't, so this fires once per + // real load and won't fight a manual tab change between loads. + useSignalEffect(() => { + if (CURRENT_SOURCE.value) activeTab.value = SidebarTab.Info; + }); + // Auto-collapse when the manifest has no content (cold-boot empty state). // The activity bar stays visible but the panel is hidden. const manifestIsEmpty = useComputed(() => isEmptyManifest(MANIFEST.value)); diff --git a/app/tests/layout/leftSidebar.test.tsx b/app/tests/layout/leftSidebar.test.tsx index eacee9f5..bc771d47 100644 --- a/app/tests/layout/leftSidebar.test.tsx +++ b/app/tests/layout/leftSidebar.test.tsx @@ -3,8 +3,9 @@ import { render } from 'preact'; import { LeftSidebar } from '@/layout/LeftSidebar/LeftSidebar'; import { SCENE_HANDLE } from '@/state/stores/scene'; import { setManifest } from '@/state/stores/manifest'; +import { CURRENT_SOURCE } from '@/state/stores/source'; import { EMPTY_MANIFEST } from '@/constants/manifest'; -import { flush } from '../_helpers/preact'; +import { flush, drainAsync } from '../_helpers/preact'; const TEST_TREE = { name: 'project', @@ -53,14 +54,15 @@ describe('LeftSidebar', () => { document.body.removeChild(container); SCENE_HANDLE.value = null; setManifest(EMPTY_MANIFEST as never); + CURRENT_SOURCE.value = null; }); it('mounts an activity bar with one icon per tab', () => { const icons = container.querySelectorAll('.activity-bar .activity-bar-icon'); expect(icons.length).toBe(4); - expect(icons[0].dataset.tab).toBe('tree'); - expect(icons[1].dataset.tab).toBe('search'); - expect(icons[2].dataset.tab).toBe('info'); + expect(icons[0].dataset.tab).toBe('info'); + expect(icons[1].dataset.tab).toBe('tree'); + expect(icons[2].dataset.tab).toBe('search'); expect(icons[3].dataset.tab).toBe('controls'); }); @@ -71,18 +73,19 @@ describe('LeftSidebar', () => { const bottom = container.querySelectorAll( '.activity-bar-bottom .activity-bar-icon' ); - expect(Array.from(top).map((b) => b.dataset.tab)).toEqual(['tree', 'search', 'info']); + expect(Array.from(top).map((b) => b.dataset.tab)).toEqual(['info', 'tree', 'search']); expect(Array.from(bottom).map((b) => b.dataset.tab)).toEqual(['controls']); }); - it('shows the tree pane by default and does not render the controls pane', () => { + it('shows the info pane by default and does not render the controls pane', () => { // In the Preact port, hidden tabs aren't rendered at all (rather // than mounted with display:none) — the activeTab signal drives a - // conditional render. - expect(container.querySelector('.tree-pane')).not.toBeNull(); + // conditional render. Info is the default tab so a loaded world opens + // straight to its almanac. + expect(container.querySelector('.info-pane')).not.toBeNull(); expect(container.querySelector('.controls-pane')).toBeNull(); expect( - container.querySelector('.activity-bar-icon[data-tab="tree"]')!.classList.contains('active') + container.querySelector('.activity-bar-icon[data-tab="info"]')!.classList.contains('active') ).toBe(true); }); @@ -93,7 +96,7 @@ describe('LeftSidebar', () => { controlsBtn.click(); await flush(); - expect(container.querySelector('.tree-pane')).toBeNull(); + expect(container.querySelector('.info-pane')).toBeNull(); expect(container.querySelector('.controls-pane')).not.toBeNull(); expect( container @@ -101,4 +104,21 @@ describe('LeftSidebar', () => { .classList.contains('active') ).toBe(true); }); + + it('reopens the Info tab when a new world loads', async () => { + // Switch away from Info. + container.querySelector('.activity-bar-icon[data-tab="tree"]')!.click(); + await flush(); + expect(container.querySelector('.tree-pane')).not.toBeNull(); + // A world commits (cold-boot ?src= or a user switch both write CURRENT_SOURCE) + // → snap back to the almanac. + CURRENT_SOURCE.value = { src: 'github.com/o/r' }; + // Two-hop settle: CURRENT_SOURCE → effect → activeTab → re-render. + await drainAsync(); + expect( + container.querySelector('.activity-bar-icon[data-tab="info"]')!.classList.contains('active') + ).toBe(true); + expect(container.querySelector('.info-pane')).not.toBeNull(); + expect(container.querySelector('.tree-pane')).toBeNull(); + }); }); From 1adf3ed3c7c65fd336dab165f1ff360517110bec Mon Sep 17 00:00:00 2001 From: Thalida Noel Date: Fri, 26 Jun 2026 00:43:20 -0400 Subject: [PATCH 65/65] refactor(sidebar): hoist the default tab into a UI constant Add DEFAULT_SIDEBAR_TAB to constants/ui.ts (next to ACTIVITY_BAR_TABS) and use it for both the initial activeTab and the on-load switch, instead of repeating SidebarTab.Info at each site. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/src/constants/ui.ts | 6 +++++- app/src/layout/LeftSidebar/LeftSidebar.tsx | 6 +++--- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/app/src/constants/ui.ts b/app/src/constants/ui.ts index f052b584..5a092083 100644 --- a/app/src/constants/ui.ts +++ b/app/src/constants/ui.ts @@ -30,9 +30,13 @@ export interface ActivityBarTab { export const ACTIVITY_BAR_TABS: readonly ActivityBarTab[] = [ // Info leads: the almanac is the first thing a freshly-loaded world greets you - // with (see LeftSidebar's default tab + on-load switch). + // with (see DEFAULT_SIDEBAR_TAB + LeftSidebar's on-load switch). { id: SidebarTab.Info, icon: Info, title: 'Info' }, { id: SidebarTab.Tree, icon: FolderTree, title: 'Tree' }, { id: SidebarTab.Search, icon: Search, title: 'Search' }, { id: SidebarTab.Controls, icon: Settings2, title: 'Settings', placement: TabPlacement.Bottom }, ] as const; + +/** The left sidebar's default active tab — the one shown on first paint and + * re-opened whenever a world loads. Info (the almanac) leads the rail. */ +export const DEFAULT_SIDEBAR_TAB: SidebarTab = SidebarTab.Info; diff --git a/app/src/layout/LeftSidebar/LeftSidebar.tsx b/app/src/layout/LeftSidebar/LeftSidebar.tsx index 0d296d72..ba7363ec 100644 --- a/app/src/layout/LeftSidebar/LeftSidebar.tsx +++ b/app/src/layout/LeftSidebar/LeftSidebar.tsx @@ -17,7 +17,7 @@ import './LeftSidebar.css'; import { useComputed, useSignal, useSignalEffect } from '@preact/signals'; -import { ACTIVITY_BAR_TABS, TabPlacement } from '@/constants/ui'; +import { ACTIVITY_BAR_TABS, DEFAULT_SIDEBAR_TAB, TabPlacement } from '@/constants/ui'; import { PERSISTED_KEYS } from '@/constants/storage'; import { SidebarTab, NodeKind } from '@/types'; import type { PickTarget, TreeNode } from '@/types'; @@ -101,7 +101,7 @@ function ActivityBar({ activeTab, collapsed, onIconClick }: ActivityBarProps) { // ── Main component ─────────────────────────────────────────────────── export function LeftSidebar() { - const activeTab = useSignal(SidebarTab.Info); + const activeTab = useSignal(DEFAULT_SIDEBAR_TAB); const collapsed = useSignal(LEFT_SIDEBAR_COLLAPSED.value); // Tree selection + hover paths, derived from picker signals. @@ -141,7 +141,7 @@ export function LeftSidebar() { // switch both write CURRENT_SOURCE; live-reloads don't, so this fires once per // real load and won't fight a manual tab change between loads. useSignalEffect(() => { - if (CURRENT_SOURCE.value) activeTab.value = SidebarTab.Info; + if (CURRENT_SOURCE.value) activeTab.value = DEFAULT_SIDEBAR_TAB; }); // Auto-collapse when the manifest has no content (cold-boot empty state).