소스 검색

feat(ui): implement Map grouping, dependents, and weight bars; symbol tab address

Adds a new grouping system for the Map with a new grouping depth control, exposes per-module dependents (files and modules) to drive a weight bar, and renders it on each module. Introduces a MapKey to explain visuals, collapses lone root-file buckets for clearer labeling, and supports a nullable depth value to let the provider pick grouping. The Symbol tab now has its own address (#/s) when nothing is selected, and routing/top-bar logic is updated accordingly. Also updates export SVG rendering to include weight-based bars, and extends tests and docs to cover the new visuals and behavior.
Colby McHenry 1 주 전
부모
커밋
7ec9ef1818

+ 4 - 0
CHANGELOG.md

@@ -201,6 +201,10 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
 
 #### Symbols, tests and the viewer
 
+- **The Map groups a repository the way that repository is shaped.** It always drew top-level directories, so a project whose whole program lives under one `src/` opened as a picture of four boxes — `src`, `ios`, `.github`, `(root files)` — with two thirds of the code inside one of them and nothing to say about it. The Map now picks its own grouping: the shallowest one that is not a single box holding the program, so a mobile app opens on `src/app`, `src/components`, `src/api`, `ios/CaptureView` and the rest, and a project packaged as `frontend/src/…` opens on the screens, components and reducers instead of on the word `frontend`. A repository whose top-level directories really are its modules is left exactly where it was. A new **Grouping** control on the right says which one was chosen and lets you take it a level in or out, and a leaf directory is now named for itself rather than as `…/(root files)`. Each box now also says how much leans on it — how many files elsewhere reference straight into it — with a bar along its bottom edge scaled against the most depended-on box on screen, so the folder you have to be careful with is the one you can see at a glance rather than the one with the longest name. The Map also has a **Key** now, like the Screens and Steps tabs — including what the dashed maroon lines mean, which only appear once you select a module: that module reaching back UP into something that depends on it.
+
+- **The Symbol tab opens the Symbol tab.** With no symbol open and no trail to return to, clicking **Symbol** in the top bar took you to the landing page — which, on any project that has screens, is the Screens tab. So the button said Symbol and gave you somebody else's view. It now has an address of its own (`#/s`) that opens the "nothing selected" screen: the search prompt and the where-to-start list of routes, entry files and the symbols the most code depends on.
+
 - **Files under an `e2e/` directory count as tests.** Their calls no longer appear as production callers in Steps, dead-code and test badges.
 
 - **Production code under a `samples` or `examples` package path is no longer treated as test code.** A Kotlin or Java project whose package path runs through `com/google/samples/…` (Now in Android, for one) had nearly every file counted as a fixture, so the Map opened on `build-logic`, the entry points hid the app, and dead-code and test badges were wrong. Only the project layout above a `src/` folder decides now; the package path below it never does.

+ 32 - 0
__tests__/ui-export-svg.test.ts

@@ -139,6 +139,7 @@ function mod(id: string, over: Partial<WireMapModule> = {}): WireMapModule {
     test: over.test ?? false,
     facade: over.facade ?? false,
     fileList: over.fileList ?? { total: 3, shown: 3, truncated: false, items: [] },
+    dependents: over.dependents ?? { files: 0, modules: 0 },
   };
 }
 
@@ -449,6 +450,37 @@ describe('mapSvg', () => {
     expect(svg).not.toContain('>__tests__</text>');
   });
 
+  it('exports the weight bar the canvas draws, scaled the same way', () => {
+    const weighted = buildMapLayout(
+      {
+        modules: [
+          mod('src/types', { dependents: { files: 80, modules: 4 } }),
+          mod('src/db', { dependents: { files: 20, modules: 2 } }),
+          mod('src/bin'),
+        ],
+        links: [link('src/db', 'src/types', 30), link('src/bin', 'src/db', 30)],
+      },
+      { includeTests: false }
+    );
+    const svg = mapSvg(weighted);
+    const nodeOf = (id: string) => weighted.nodes.find((n) => n.id === id)!;
+    // Full bar for the heaviest, a quarter for the module a quarter as leaned
+    // on, and NO rect at all for the one nothing depends on.
+    // The export rounds to a tenth, as every coordinate in this file does.
+    const tenth = (n: number) => Math.round(n * 10) / 10;
+    const full = nodeOf('src/types');
+    const quarter = nodeOf('src/db');
+    expect(quarter.weight).toBeCloseTo(0.25, 5);
+    expect(svg).toContain(`width="${tenth(full.width)}" height="4" fill="${EXPORT_COLORS.ink}"`);
+    expect(svg).toContain(
+      `width="${tenth(quarter.width * 0.25)}" height="4" fill="${EXPORT_COLORS.ink}"`
+    );
+    expect(nodeOf('src/bin').weight).toBe(0);
+    expect(svg.match(/height="4" fill=/g)?.length).toBe(2);
+    // …and the count rides in the meta line, as on screen.
+    expect(svg).toContain('· 80 depend on it</text>');
+  });
+
   it('names the top and bottom bands', () => {
     const svg = mapSvg(layout);
     expect(svg).toContain('>entry points</text>');

+ 131 - 2
__tests__/ui-map-api.test.ts

@@ -24,7 +24,13 @@ import * as os from 'os';
 import * as path from 'path';
 import CodeGraph from '../src/index';
 import { createGraphApi, startUiServer, type GraphApi, type UiServerHandle } from '../src/ui-server';
-import { moduleIdFor, normalizeRoot, pickDefaultRoot, resetMapCache } from '../src/ui-server/api/map';
+import {
+  moduleIdFor,
+  normalizeRoot,
+  pickDefaultDepth,
+  pickDefaultRoot,
+  resetMapCache,
+} from '../src/ui-server/api/map';
 
 let server: UiServerHandle;
 let api: GraphApi;
@@ -293,6 +299,102 @@ describe('pickDefaultRoot', () => {
   });
 });
 
+describe('pickDefaultDepth', () => {
+  /** `n` files under `dir`, each carrying `each` symbols. */
+  function spread(dir: string, n: number, each: number, test = false) {
+    return Array.from({ length: n }, (_, i) => ({
+      path: `${dir}/f${i}.ts`,
+      symbols: each,
+      test,
+    }));
+  }
+
+  it('goes deeper when one box holds the program', () => {
+    // The shape this rule exists for: a React-Native-ish repo whose whole app
+    // is under `src/`. At depth 1 the map is a box labelled `src` and nothing
+    // else — 285 files and two thirds of the symbols, unopenable.
+    const files = [
+      ...spread('src/components', 119, 12),
+      ...spread('src/app', 53, 21),
+      ...spread('src/api', 47, 7),
+      ...spread('src/utils', 24, 6),
+      ...spread('ios/CaptureView', 63, 28),
+      ...spread('ios/Camera', 3, 38),
+      ...spread('.github/workflows', 4, 0),
+    ];
+    expect(pickDefaultDepth(files, '')).toBe(2);
+  });
+
+  it('keeps a repository whose directories ARE its modules at one level', () => {
+    const files = [
+      ...spread('src/db', 8, 40),
+      ...spread('src/graph', 9, 40),
+      ...spread('src/mcp', 7, 40),
+      ...spread('src/search', 5, 40),
+      ...spread('src/sync', 4, 40),
+    ];
+    expect(pickDefaultDepth(files, 'src')).toBe(1);
+  });
+
+  it('does not open a dominant box that has nothing in it', () => {
+    // `src/core` holds most of the symbols but only three files: this is a
+    // small project honestly drawn, not a coarse grouping.
+    const files = [
+      ...spread('src/core', 3, 90),
+      ...spread('src/db', 2, 10),
+      ...spread('src/api', 2, 10),
+      { path: 'src/index.ts', symbols: 5, test: false },
+    ];
+    expect(pickDefaultDepth(files, 'src')).toBe(1);
+  });
+
+  it('keeps going while the picture is still one box', () => {
+    // `frontend/` then `frontend/src/` — two levels of packaging before the
+    // code. Neither is a map; the third level is.
+    const files = [
+      ...spread('frontend/src/screens', 15, 10),
+      ...spread('frontend/src/components', 14, 10),
+      ...spread('frontend/src/hooks', 8, 10),
+      ...spread('frontend/src/api', 6, 10),
+      ...spread('backend/app', 5, 8),
+    ];
+    expect(pickDefaultDepth(files, '')).toBe(3);
+  });
+
+  it('stops before a deeper grouping becomes a crowd', () => {
+    const files = [
+      ...spread('src/a', 30, 10),
+      ...Array.from({ length: 70 }, (_, i) => ({
+        path: `src/b/m${i}/f.ts`,
+        symbols: 1,
+        test: false,
+      })),
+    ];
+    // Depth 2 is dominated by `src/a`, but depth 3 would draw 71 boxes.
+    expect(pickDefaultDepth(files, '')).toBe(2);
+  });
+
+  it('does not chase a tree that has no more levels to give', () => {
+    const files = [
+      ...spread('src/a', 30, 10),
+      ...spread('src/b', 2, 1),
+    ];
+    expect(pickDefaultDepth(files, 'src')).toBe(1);
+  });
+
+  it('counts only the modules the map draws by default', () => {
+    // Test files are hidden unless the reader asks for them, so a depth that
+    // is only "enough boxes" once tests are counted is not enough boxes.
+    const files = [
+      ...spread('src/app', 40, 10),
+      ...spread('src/__tests__/a', 12, 10, true),
+      ...spread('src/__tests__/b', 12, 10, true),
+      ...spread('src/__tests__/c', 12, 10, true),
+    ];
+    expect(pickDefaultDepth(files, 'src')).toBe(1);
+  });
+});
+
 describe('GET /api/map', () => {
   it('is listed by the API index', async () => {
     const res = await request('/api');
@@ -407,13 +509,40 @@ describe('GET /api/map', () => {
     // down joins that level's bucket rather than being promoted to a module.
     expect(ids).toContain('src/core/passes');
     expect(ids).toContain('src/core/(root files)');
-    expect(ids).toContain('src/api/(root files)');
     expect(ids).not.toContain('src/core');
+    // …but the bucket keeps its name only because `src/core/passes` sits beside
+    // it. `src/api` has nothing below it, so its bucket IS `src/api` and saying
+    // otherwise would name a directory the repository does not have.
+    expect(ids).toContain('src/api');
+    expect(ids).not.toContain('src/api/(root files)');
 
     const slashed = await getMap('?root=src%2F&depth=2');
     expect(slashed.modules).toEqual(deep.modules);
   });
 
+  it('counts the files outside each module that reference into it', async () => {
+    const map = await getMap('?root=src&depth=1');
+    const by = new Map<string, any>(map.modules.map((m: any) => [m.id, m]));
+
+    // `src/types.ts` and `src/index.ts` are what the rest of the fixture
+    // imports, so the bucket holding types is the most depended-on box.
+    const types = by.get('src/(root files)');
+    expect(types.dependents.files).toBeGreaterThan(0);
+    expect(types.dependents.modules).toBeGreaterThan(1);
+
+    // Every count is FILES OUTSIDE the module: never more than the rest of the
+    // repository, and a module's own internal imports never inflate it.
+    const total = map.modules.reduce((sum: number, m: any) => sum + m.files, 0);
+    for (const module of map.modules) {
+      expect(module.dependents.files).toBeLessThanOrEqual(total - module.files);
+      expect(module.dependents.modules).toBeLessThanOrEqual(map.modules.length - 1);
+      // A module nothing arrives at is an island, and the two must agree —
+      // they are computed from different queries and a reader sees both.
+      const arrives = map.links.some((l: any) => l.target === module.id);
+      if (!arrives) expect(module.dependents.files).toBe(0);
+    }
+  });
+
   it('rejects an out-of-range depth as JSON, not as a crash', async () => {
     const res = await request('/api/map?depth=9');
     expect(res.status).toBe(400);

+ 47 - 0
__tests__/ui-map-model.test.ts

@@ -46,6 +46,7 @@ function mod(id: string, over: Partial<WireMapModule> = {}): WireMapModule {
     test: over.test ?? false,
     facade: over.facade ?? false,
     fileList: over.fileList ?? { total: 3, shown: 3, truncated: false, items: [] },
+    dependents: over.dependents ?? { files: 0, modules: 0 },
   };
 }
 
@@ -473,3 +474,49 @@ describe('directional ports and room', () => {
     expect(wide.layers[0]!.y - wide.layers[1]!.y).toBe(NODE_HEIGHT + 116);
   });
 });
+
+describe('how much leans on a box', () => {
+  it('scales the bar against the heaviest module DRAWN', () => {
+    const modules = [
+      mod('src/types', { dependents: { files: 90, modules: 5 } }),
+      mod('src/db', { dependents: { files: 45, modules: 3 } }),
+      mod('src/cli', { dependents: { files: 0, modules: 0 } }),
+    ];
+    const links = [link('src/db', 'src/types', 20), link('src/cli', 'src/db', 20)];
+    const layout = buildMapLayout({ modules, links }, OPTS);
+    const weightOf = (id: string) => layout.nodes.find((n) => n.id === id)!.weight;
+
+    expect(weightOf('src/types')).toBe(1);
+    expect(weightOf('src/db')).toBeCloseTo(0.5, 5);
+    // Nothing depends on the CLI, so it draws no bar at all rather than a
+    // sliver a reader would have to squint at to call empty.
+    expect(weightOf('src/cli')).toBe(0);
+  });
+
+  it('rescales when a heavier test module joins the picture', () => {
+    const modules = [
+      mod('src/types', { dependents: { files: 40, modules: 4 } }),
+      mod('src/app', { dependents: { files: 10, modules: 1 } }),
+      mod('__tests__', { test: true, dependents: { files: 80, modules: 6 } }),
+    ];
+    const links = [link('src/app', 'src/types', 20), link('__tests__', 'src/app', 20)];
+    const spec = { modules, links };
+    // Tests off: the app's own busiest box is the full bar.
+    const off = buildMapLayout(spec, { includeTests: false });
+    expect(off.nodes.find((n) => n.id === 'src/types')!.weight).toBe(1);
+    // Tests on: the scale moves, rather than leaving a bar running past a
+    // maximum the reader cannot see.
+    const on = buildMapLayout(spec, { includeTests: true });
+    expect(on.nodes.find((n) => n.id === 'src/types')!.weight).toBeCloseTo(0.5, 5);
+    expect(on.nodes.find((n) => n.id === '__tests__')!.weight).toBe(1);
+  });
+
+  it('says the count on the box, and says nothing when nothing depends on it', () => {
+    expect(moduleMetaLabel(mod('src/db', { dependents: { files: 45, modules: 3 } }))).toBe(
+      '30 symbols · 3 files · 45 depend on it'
+    );
+    expect(moduleMetaLabel(mod('src/cli'))).toBe('30 symbols · 3 files');
+    // An island's line is still the one sentence that matters about it.
+    expect(moduleMetaLabel(mod('src/cli'), true)).toBe('nothing depends on this');
+  });
+});

+ 40 - 0
__tests__/ui-package.test.ts

@@ -674,6 +674,46 @@ describe('@colbymchenry/codegraph-ui — the seams', () => {
     expect(symbolHref('function:x')).toBe('#/s/function%3Ax');
   });
 
+  it('gives the Symbol tab an address of its own when no symbol is chosen', async () => {
+    const { parseHash } = await import('../ui/src/lib/router.svelte');
+
+    // The regression this pins: the tab used to fall back to `#/`, and `#/` is
+    // the landing page — which renders the SCREENS tab on any project that has
+    // screens. Clicking Symbol landed you on somebody else's view.
+    expect(symbolHref(null)).toBe('#/s');
+    expect(parseHash('#/').route.view).toBe('home');
+
+    const empty = parseHash(symbolHref(null)).route;
+    expect(empty.view).toBe('symbol');
+    expect(empty).toMatchObject({ view: 'symbol', id: null });
+
+    // …and a chosen symbol still round-trips, id and all.
+    const chosen = parseHash(symbolHref('function:x')).route;
+    expect(chosen).toMatchObject({ view: 'symbol', id: 'function:x' });
+  });
+
+  it('sends every nav tab to its own view', async () => {
+    const { parseHash } = await import('../ui/src/lib/router.svelte');
+    const { entryHref, screensHref, stepsHref, deadHref } = await import(
+      '../ui/src/lib/navigation'
+    );
+
+    // One href per tab in the top bar, each parsed back. A tab whose link
+    // resolves to a different tab's view is the bug above, in general form.
+    const tabs: Array<[string, string]> = [
+      ['screens', screensHref()],
+      ['steps', stepsHref()],
+      ['entry', entryHref()],
+      ['map', mapHref()],
+      ['symbol', symbolHref(null)],
+      ['flow', flowHref()],
+      ['dead', deadHref()],
+    ];
+    for (const [view, href] of tabs) {
+      expect(parseHash(href).route.view, `${href} should open the ${view} view`).toBe(view);
+    }
+  });
+
   it('the default adapter is the loopback JSON API and asks for `api/...`', async () => {
     const asked: string[] = [];
     const adapter = createHttpAdapter({

+ 7 - 1
docs/design/codegraph-ui-design-spec.md

@@ -207,7 +207,13 @@ with — so the strip and the MCP answer cannot disagree.
 Grid: canvas `minmax(600px,1fr)` | side panel **320px** (`--rule-soft` left border, 14px 16px padding).
 Nodes: rect `width = max(110, label.length × 7.3 + 28)`, **height 40**, `--paper` fill, 1px `--ink` stroke (2px + `--press` fill
 when hovered/selected; `--ink-4` when dimmed; test modules dashed `4 3` in `--ink-3`), label 13px mono at (10,17), count
-"N symbols · M files" 11px `--ink-3` at (10,32). Layers: vertical gap **74px**, horizontal gap **34px**, padding 44px; entry points at the
+"N symbols · M files · R depend on it" 11px `--ink-3` at (10,32). **Weight bar:** 4px band inside the bottom edge, `--ink` at
+0.3 (0.55 hovered/selected, 0.1 dimmed or generated), `width = node.width × (R / max R drawn)` — how much of the picture
+leans on this box. `R` is `dependents.files`: files OUTSIDE the module holding a direct confident reference into one of its
+files. **Direct, not transitive** — the transitive closure was measured and saturates on any repository with a dependency
+cycle (139–282 of 377 files on a real mobile app, a flat spread that only reports cyclicity), while the direct count on the
+same repository spreads 0–127 and names the modules a reader would name by hand. Relative to the heaviest box *drawn*, so
+turning tests on rescales rather than overflowing a maximum nobody can see; a module with R=0 draws no bar at all. Layers: vertical gap **74px**, horizontal gap **34px**, padding 44px; entry points at the
 top ("entry points" label), foundations at the bottom ("foundations — depend on nothing below"); faint layer lines `--rule-faint`.
 Layout: aggregate edges by module; break 2-cycles keeping the heavier direction; longest-path layering (a module sits one layer
 above everything it depends on); barycenter ordering, 3 sweeps; single-node layers centred; ports spread along each box

+ 229 - 18
src/ui-server/api/map.ts

@@ -126,6 +126,25 @@ export interface WireMapModule {
   facade: boolean;
   /** Its files, capped — what the side panel lists when the module is selected. */
   fileList: WireList<string>;
+  /**
+   * What a change in here reaches: files OUTSIDE this module holding a direct,
+   * confident reference into one of its files, and how many modules those
+   * files span.
+   *
+   * DIRECT, deliberately. The transitive closure was measured first and it is
+   * useless on a real repository: any dependency cycle — and a mobile app had
+   * nine mutual pairs — saturates it, so every module comes out reaching
+   * nearly every file (139–282 of 377, a flat 2× spread that says nothing but
+   * "this repo has cycles"). The direct count on the same repository spreads
+   * 0–127 and names the modules a reader would name by hand: the shared types
+   * at the top, the CLI at zero.
+   *
+   * The counts are FILES, not symbols: a module is a set of files, and "94
+   * files would have to be re-read if this changed" is a claim the index can
+   * stand behind. It is a floor on blast radius, not the whole of it — a
+   * symbol-level answer for one symbol is what the Symbol view is for.
+   */
+  dependents: { files: number; modules: number };
 }
 
 export interface WireMapLink {
@@ -276,6 +295,107 @@ export function pickDefaultRoot(
   return bestSymbols * 2 > total ? best : '';
 }
 
+/**
+ * A box holding more than this share of the mapped symbols IS the program, and
+ * a map whose subject is one box has not said anything.
+ */
+const DOMINANT_SHARE = 0.4;
+
+/**
+ * …but only if there is something inside it. A dominant box of four files is a
+ * small project honestly drawn; opening it just spreads four files over four
+ * boxes. This is the line between "grouped too coarsely" and "actually small".
+ */
+const DOMINANT_MIN_FILES = 25;
+
+/** Fewer boxes than this is a list, not a picture. */
+const MIN_MODULES = 4;
+
+/** More than this and a deeper grouping has traded one unreadable map for another. */
+const MAX_MODULES = 60;
+
+/** The non-test modules a given depth would draw, and how concentrated they are. */
+function tallyModules(
+  files: ReadonlyArray<{ path: string; symbols: number; test: boolean }>,
+  root: string,
+  depth: number
+): { count: number; share: number; largestFiles: number } {
+  const byModule = new Map<string, { symbols: number; files: number }>();
+  let total = 0;
+  for (const file of files) {
+    if (file.test) continue;
+    const assigned = moduleIdFor(file.path, root, depth);
+    if (assigned === null) continue;
+    let entry = byModule.get(assigned.id);
+    if (!entry) byModule.set(assigned.id, (entry = { symbols: 0, files: 0 }));
+    entry.symbols += file.symbols;
+    entry.files += 1;
+    total += file.symbols;
+  }
+  let largest = { symbols: 0, files: 0 };
+  for (const entry of byModule.values()) {
+    if (entry.symbols > largest.symbols) largest = entry;
+  }
+  return {
+    count: byModule.size,
+    share: total === 0 ? 0 : largest.symbols / total,
+    largestFiles: largest.files,
+  };
+}
+
+/**
+ * How many segments name a module, when the reader has not said.
+ *
+ * Depth is not a property of the reader's taste, it is a property of the
+ * repository: one level under the root is the right grouping for a project
+ * whose directories ARE its modules, and the wrong one for the very common
+ * shape where every line of the program lives under a single `src/`. Drawing
+ * that project at depth 1 produces the map this rule exists to prevent — a box
+ * labelled `src`, holding two thirds of the code, with nothing to say about it.
+ *
+ * So: take the shallowest depth that is neither dominated by one box worth
+ * opening nor too small to be a picture; stop before a deeper one becomes a
+ * crowd; and never go past the last level the directory tree actually has.
+ *
+ * The walk does NOT stop at the first depth that fails to add boxes. A repo
+ * packaged as `frontend/src/...` plateaus at two boxes for two levels running
+ * before the third splits it, and a rule that gave up on the plateau would
+ * draw exactly the picture this function exists to avoid.
+ */
+export function pickDefaultDepth(
+  files: ReadonlyArray<{ path: string; symbols: number; test: boolean }>,
+  root: string
+): number {
+  // Past the deepest directory, a bigger number only renames boxes to
+  // `src/a/(root files)`. There is nothing below the leaves.
+  let deepest = DEFAULT_DEPTH;
+  for (const file of files) {
+    if (file.test) continue;
+    const path = toPosixPath(file.path);
+    if (root && !path.startsWith(`${root}/`)) continue;
+    const rel = root ? path.slice(root.length + 1) : path;
+    deepest = Math.max(deepest, rel.split('/').filter(Boolean).length - 1);
+  }
+
+  let fallback = DEFAULT_DEPTH;
+  let fallbackCount = 0;
+  for (let depth = DEFAULT_DEPTH; depth <= Math.min(MAX_DEPTH, deepest); depth += 1) {
+    const tally = tallyModules(files, root, depth);
+    if (tally.count === 0) break;
+    // Deeper only gets more crowded from here.
+    if (tally.count > MAX_MODULES) break;
+    const dominated = tally.share > DOMINANT_SHARE && tally.largestFiles >= DOMINANT_MIN_FILES;
+    if (tally.count >= MIN_MODULES && !dominated) return depth;
+    // Not a picture yet. Worth keeping only if it drew more than the last one:
+    // a deeper grouping that splits nothing is the same map with longer labels.
+    if (tally.count > fallbackCount) {
+      fallback = depth;
+      fallbackCount = tally.count;
+    }
+  }
+  return fallback;
+}
+
 // =============================================================================
 // Cache
 // =============================================================================
@@ -301,9 +421,17 @@ export function resetMapCache(): void {
 // Build
 // =============================================================================
 
-export function parseMapQuery(query: URLSearchParams): { root: string | null; depth: number } {
+/**
+ * `null` for either field means "nobody said" — the answer picks. Absence has
+ * to survive parsing: a depth defaulted to 1 here is indistinguishable from a
+ * reader who asked for 1, and {@link pickDefaultDepth} would never run.
+ */
+export function parseMapQuery(query: URLSearchParams): {
+  root: string | null;
+  depth: number | null;
+} {
   const rawDepth = query.get('depth');
-  let depth = DEFAULT_DEPTH;
+  let depth: number | null = null;
   if (rawDepth !== null && rawDepth !== '') {
     depth = Number.parseInt(rawDepth, 10);
     if (!Number.isFinite(depth) || depth < 1 || depth > MAX_DEPTH) {
@@ -314,9 +442,45 @@ export function parseMapQuery(query: URLSearchParams): { root: string | null; de
   return { root: rawRoot === null ? null : normalizeRoot(rawRoot), depth };
 }
 
+/**
+ * Rename `x/(root files)` to `x` wherever the bucket is all `x` has.
+ *
+ * The bucket earns its name only when it stands beside something: `src` holding
+ * both `src/api` and three loose files needs a box for the loose ones, and that
+ * box has to say it is not the whole of `src`. But a `backend/controllers` with
+ * no subdirectories in it is not a directory with a bucket in it — it IS the
+ * directory, and drawing it as `backend/controllers/(root files)` names a thing
+ * the repository does not have. Deeper groupings hit this constantly (every
+ * leaf directory becomes a bucket), which is what makes it worth a pass.
+ *
+ * Returns only the ids that move, so a caller can leave the rest alone.
+ */
+function collapseLoneRootFiles(ids: ReadonlySet<string>): Map<string, string> {
+  const renamed = new Map<string, string>();
+  for (const id of ids) {
+    const cut = id.lastIndexOf('/(root files)');
+    // A bucket at the very top (`(root files)`) has no directory to become.
+    if (cut <= 0 || cut + '/(root files)'.length !== id.length) continue;
+    const dir = id.slice(0, cut);
+    let alone = true;
+    for (const other of ids) {
+      // A façade counts: `src/utils` beside `src/utils/index.tsx` would read as
+      // if the box contained the file drawn next to it.
+      if (other !== id && other.startsWith(`${dir}/`)) {
+        alone = false;
+        break;
+      }
+    }
+    // `dir` can only already be a module if something lives BELOW it, which is
+    // exactly the case `alone` just ruled out — so this rename cannot collide.
+    if (alone) renamed.set(id, dir);
+  }
+  return renamed;
+}
+
 export function buildMap(cg: CodeGraph, projectRoot: string, query: URLSearchParams): WireMapPayload {
   const started = Date.now();
-  let { root: requestedRoot, depth } = parseMapQuery(query);
+  const { root: requestedRoot, depth: requestedDepth } = parseMapQuery(query);
 
   const fileRecords = cg.getFiles().map((file) => {
     const path = toPosixPath(file.path);
@@ -330,10 +494,10 @@ export function buildMap(cg: CodeGraph, projectRoot: string, query: URLSearchPar
   });
 
   const root = requestedRoot ?? pickDefaultRoot(fileRecords);
-  // Left to choose, and choosing the whole project (two substantial roots):
-  // one level deeper, so the boxes are `src/app` and `ios/CaptureView`, not
-  // `src` and `ios`.
-  if (requestedRoot === null && root === '' && !query.has('depth')) depth = 2;
+  // Root first, then depth against THAT root: how finely to cut depends on
+  // what is being cut. Choosing `src` and then asking for one level under it
+  // is the same question as choosing the whole project and asking for two.
+  const depth = requestedDepth ?? pickDefaultDepth(fileRecords, root);
   const stats = cg.getStats();
   const key = [
     projectRoot,
@@ -367,16 +531,24 @@ export function buildMap(cg: CodeGraph, projectRoot: string, query: URLSearchPar
   >();
   const moduleOfFile = new Map<string, string>();
 
+  const assigned = new Map<string, { id: string; facade: boolean }>();
   for (const file of fileRecords) {
-    const assigned = moduleIdFor(file.path, root, depth);
-    if (assigned === null) continue;
-    assignments.push({ filePath: file.path, module: assigned.id });
-    moduleOfFile.set(file.path, assigned.id);
-    let entry = modules.get(assigned.id);
+    const at = moduleIdFor(file.path, root, depth);
+    if (at !== null) assigned.set(file.path, at);
+  }
+  const renamed = collapseLoneRootFiles(new Set([...assigned.values()].map((a) => a.id)));
+
+  for (const file of fileRecords) {
+    const at = assigned.get(file.path);
+    if (at === undefined) continue;
+    const id = renamed.get(at.id) ?? at.id;
+    assignments.push({ filePath: file.path, module: id });
+    moduleOfFile.set(file.path, id);
+    let entry = modules.get(id);
     if (!entry) {
       entry = {
-        id: assigned.id,
-        facade: assigned.facade,
+        id,
+        facade: at.facade,
         files: 0,
         symbols: 0,
         testFiles: 0,
@@ -385,7 +557,7 @@ export function buildMap(cg: CodeGraph, projectRoot: string, query: URLSearchPar
         languages: new Map(),
         paths: [],
       };
-      modules.set(assigned.id, entry);
+      modules.set(id, entry);
     }
     entry.files += 1;
     entry.paths.push(file.path);
@@ -438,6 +610,12 @@ export function buildMap(cg: CodeGraph, projectRoot: string, query: URLSearchPar
     }
   }
 
+  // ONE fetch of the file edge list, read twice: the cycle finder and the
+  // dependent counts are both questions about it, and it is the expensive query
+  // on this screen.
+  const filePairs = cg.getFileDependencyPairs(UNCERTAIN_BELOW);
+  const dependents = countDependents(filePairs, moduleOfFile);
+
   const payload: WireMapPayload = {
     root,
     depth,
@@ -460,6 +638,7 @@ export function buildMap(cg: CodeGraph, projectRoot: string, query: URLSearchPar
           // draws are the same list — the count-equals-list rule.
           generatedFiles: shown.filter((path) => entry.generatedPaths.has(path)),
           fileList: wireList(shown, entry.files),
+          dependents: dependents.get(entry.id) ?? { files: 0, modules: 0 },
         };
       })
       // Sorted so two runs over one index produce byte-identical payloads —
@@ -468,7 +647,7 @@ export function buildMap(cg: CodeGraph, projectRoot: string, query: URLSearchPar
     links: [...links.values()].sort(
       (a, b) => a.source.localeCompare(b.source) || a.target.localeCompare(b.target)
     ),
-    cycles: fileCycles(cg, moduleOfFile),
+    cycles: fileCycles(filePairs, moduleOfFile),
     excluded: { uncertainEdges, confidenceBelow: UNCERTAIN_BELOW },
     index: {
       lastIndexedAt: cg.getLastIndexedAt(),
@@ -486,6 +665,38 @@ export function buildMap(cg: CodeGraph, projectRoot: string, query: URLSearchPar
   return payload;
 }
 
+/**
+ * Per module: how many files outside it reference into it, and across how many
+ * modules those files sit.
+ *
+ * One pass over the edge list. A pair whose two ends land in the same module is
+ * internal cohesion, not blast radius, and is skipped; a pair touching a file
+ * outside the chosen root has no module and is skipped too. The `Set` per
+ * module is what makes the count DISTINCT FILES rather than distinct
+ * references — twelve calls from one file are one file that has to be re-read.
+ */
+function countDependents(
+  pairs: ReadonlyArray<{ source: string; target: string }>,
+  moduleOfFile: Map<string, string>
+): Map<string, { files: number; modules: number }> {
+  const incoming = new Map<string, Set<string>>();
+  for (const pair of pairs) {
+    const from = moduleOfFile.get(pair.source);
+    const to = moduleOfFile.get(pair.target);
+    if (from === undefined || to === undefined || from === to) continue;
+    let seen = incoming.get(to);
+    if (!seen) incoming.set(to, (seen = new Set()));
+    seen.add(pair.source);
+  }
+  const out = new Map<string, { files: number; modules: number }>();
+  for (const [module, files] of incoming) {
+    const modules = new Set<string>();
+    for (const file of files) modules.add(moduleOfFile.get(file)!);
+    out.set(module, { files: files.size, modules: modules.size });
+  }
+  return out;
+}
+
 /**
  * File-level circular dependencies, as strongly connected components.
  *
@@ -496,11 +707,11 @@ export function buildMap(cg: CodeGraph, projectRoot: string, query: URLSearchPar
  * list anybody reads.
  */
 function fileCycles(
-  cg: CodeGraph,
+  pairs: ReadonlyArray<{ source: string; target: string }>,
   moduleOfFile: Map<string, string>
 ): WireMapPayload['cycles'] {
   const adjacency = new Map<string, string[]>();
-  for (const pair of cg.getFileDependencyPairs(UNCERTAIN_BELOW)) {
+  for (const pair of pairs) {
     if (!moduleOfFile.has(pair.source) || !moduleOfFile.has(pair.target)) continue;
     let out = adjacency.get(pair.source);
     if (!out) adjacency.set(pair.source, (out = []));

+ 5 - 2
ui/src/App.svelte

@@ -81,7 +81,8 @@
     const encoded = router.params.get('t');
     untrack(() => {
       trail.hydrate(encoded);
-      if (current.view === 'symbol' && trail.current?.id !== current.id) {
+      // `id: null` is the tab with nothing chosen — there is no hop to record.
+      if (current.view === 'symbol' && current.id !== null && trail.current?.id !== current.id) {
         trail.push({ id: current.id });
       }
     });
@@ -157,7 +158,7 @@
 <TopBar bind:this={topbar} project={project.name} stats={project.summary} showScreens={hasScreens} />
 <TrailBar />
 <main>
-  {#if route.view === 'symbol'}
+  {#if route.view === 'symbol' && route.id !== null}
     <SymbolView id={route.id} line={route.line} />
   {:else if route.view === 'file' && route.source}
     <FileCodeView path={route.path} line={route.line} />
@@ -175,6 +176,8 @@
   {:else if route.view === 'entry'}
     <EntryView project={project.name} />
   {:else if route.view === 'screens' || (route.view === 'home' && hasScreens)}
+    <!-- `home` renders Screens when the project has any, which is why the
+         Symbol tab needs its own `#/s` and must never fall back to `#/`. -->
     <ScreensView />
   {:else if route.view === 'steps'}
     <StepsView anchor={route.anchor} symbol={route.symbol} depth={route.depth} through={route.through} reading={route.reading} />

+ 6 - 3
ui/src/components/TopBar.svelte

@@ -20,12 +20,15 @@
   let view = $derived(router.route.view);
 
   // The Symbol tab returns you to where you were reading, not to a blank
-  // view: the current symbol if you are on one, else the trail's last hop.
+  // view: the current symbol if you are on one, else the trail's last hop —
+  // and failing both, the tab's own empty screen. NOT `#/`: the landing page
+  // renders the Screens tab on any project that has screens, so that fallback
+  // sent a reader who clicked Symbol to somebody else's view.
   let symbolTabHref = $derived.by(() => {
     const route = router.route;
-    if (route.view === 'symbol') return symbolHref(route.id);
+    if (route.view === 'symbol' && route.id !== null) return symbolHref(route.id);
     const current = trail.current;
-    return current ? symbolHref(current.id) : '#/';
+    return symbolHref(current ? current.id : null);
   });
 
   /** What `/` and Cmd-K reach — the palette owns its own keyboard. */

+ 189 - 0
ui/src/components/map/MapKey.svelte

@@ -0,0 +1,189 @@
+<script lang="ts">
+  /**
+   * The Map's key (design spec §3.6), matching the Screens and Steps views'.
+   *
+   * Each row draws the actual stroke or box rather than a word for it — a
+   * reader matches shapes. Two rows here exist because the Map hides things at
+   * rest and a picture that hides must say so: the thin links, and the dashed
+   * back-edges that appear only once a module is selected. A reader who selects
+   * `src/utils` and watches four maroon dashes appear has no way to guess what
+   * they are, and the side panel's prose is not where anyone looks for a stroke.
+   */
+
+  interface Props {
+    /** The weight below which a link waits for a selection. */
+    minWeight: number;
+    /** How many links are waiting on one right now; the row is skipped at zero. */
+    thinCount: number;
+    /** Whether the vertical order came from declared edges or from raw counts. */
+    declaredBasis: boolean;
+    open: boolean;
+    onToggle: (open: boolean) => void;
+  }
+  let { minWeight, thinCount, declaredBasis, open, onToggle }: Props = $props();
+</script>
+
+<div class="legend" class:open>
+  <button class="legend-h" onclick={() => onToggle(!open)} aria-expanded={open}>
+    Key <span class="dim">{open ? '▾' : '▸'}</span>
+  </button>
+  {#if open}
+    <div class="legend-body">
+      <div class="lrow">
+        <span class="k-box mono">src/api</span>
+        <span>A module — one directory, with the symbols and files in it</span>
+      </div>
+      <div class="lrow">
+        <span class="k-box k-weight mono">src/db</span>
+        <span>
+          The bar along the bottom is how much leans on it — files elsewhere that reference
+          straight into it, against the most depended-on box here. The count is on the box
+        </span>
+      </div>
+      <div class="lrow">
+        <svg width="44" height="12" aria-hidden="true"><path d="M2 6 H42" class="k-line" /></svg>
+        <span>
+          Depends on — the box above calls, imports, extends or names a type from the box below.
+          Thicker is more references{declaredBasis
+            ? ''
+            : '; here the layering had too few imports to trust, so it used raw counts'}
+        </span>
+      </div>
+      <div class="lrow">
+        <svg width="44" height="12" aria-hidden="true"><path d="M2 6 H42" class="k-line k-back" /></svg>
+        <span>
+          Points back up — the lighter half of a mutual dependency, or a link with no import or
+          declared type behind it. Drawn only while a module it touches is selected
+        </span>
+      </div>
+      <div class="lrow">
+        <span class="k-label">top / bottom</span>
+        <span>
+          A module sits one layer above everything it depends on, so entry points end up at the top
+          and the foundations — which depend on nothing below — at the bottom
+        </span>
+      </div>
+      <div class="lrow">
+        <span class="k-box k-sel mono">src/api</span>
+        <span>Selected: click a module to bring out its links and list its files; everything more than one hop away fades</span>
+      </div>
+      <div class="lrow">
+        <span class="k-label">nothing depends on this</span>
+        <span>No link in the index arrives here — a script, a workflow, an unreferenced corner</span>
+      </div>
+      <div class="lrow">
+        <span class="k-box k-test mono">__tests__</span>
+        <span>More than half its files are tests; off unless you turn tests on</span>
+      </div>
+      <div class="lrow">
+        <span class="k-box k-gen mono">gen</span>
+        <span>Every file in it is tool-generated — nobody wrote it and nobody edits it</span>
+      </div>
+      {#if thinCount > 0}
+        <div class="lrow">
+          <span class="k-label">{thinCount} hidden</span>
+          <span>
+            Links carrying fewer than {minWeight} references wait until you select a module they
+            touch, so a weak coincidence never draws as a dependency
+          </span>
+        </div>
+      {/if}
+    </div>
+  {/if}
+</div>
+
+<style>
+  .legend {
+    position: absolute;
+    left: 12px;
+    bottom: 12px;
+    z-index: 4;
+    max-width: 400px;
+    border: 1px solid var(--rule);
+    background: var(--paper);
+    font-size: 11.5px;
+    color: var(--ink-2);
+  }
+  .legend-h {
+    display: block;
+    width: 100%;
+    border: 0;
+    background: transparent;
+    padding: 5px 10px;
+    text-align: left;
+    color: var(--ink);
+    font: 600 12px var(--sans);
+    cursor: pointer;
+  }
+  .legend-body {
+    padding: 2px 10px 8px;
+    border-top: 1px solid var(--rule-soft);
+  }
+  .lrow {
+    display: flex;
+    align-items: center;
+    gap: 10px;
+    padding: 3px 0;
+  }
+  .lrow > :first-child {
+    flex: 0 0 52px;
+    display: inline-flex;
+    justify-content: center;
+  }
+  .k-line {
+    stroke: var(--ink);
+    stroke-opacity: 0.6;
+    stroke-width: 1.5;
+    fill: none;
+  }
+  .k-line.k-back {
+    stroke: var(--accent);
+    stroke-opacity: 0.8;
+    stroke-dasharray: 4 3;
+  }
+  .k-label {
+    font-size: 10px;
+    color: var(--ink-3);
+    text-align: center;
+    line-height: 1.2;
+  }
+  .k-box {
+    box-sizing: border-box;
+    padding: 1px 5px;
+    border: 1px solid var(--ink);
+    font-size: 10.5px;
+    color: var(--ink);
+    line-height: 14px;
+  }
+  /* The bar, drawn the way the canvas draws it: inside the bottom edge. */
+  .k-box.k-weight {
+    position: relative;
+  }
+  .k-box.k-weight::after {
+    content: '';
+    position: absolute;
+    left: 0;
+    bottom: 0;
+    width: 68%;
+    height: 4px;
+    background: var(--ink);
+    opacity: 0.3;
+  }
+  /* The same three treatments the canvas uses, at key size. */
+  .k-box.k-sel {
+    border-width: 2px;
+    background: var(--press);
+  }
+  .k-box.k-test {
+    border-style: dashed;
+    border-color: var(--ink-3);
+    color: var(--ink-3);
+  }
+  .k-box.k-gen {
+    border-color: var(--ink-4);
+    color: var(--ink-4);
+  }
+  .dim {
+    color: var(--ink-3);
+  }
+</style>

+ 55 - 0
ui/src/components/map/MapSidePanel.svelte

@@ -25,6 +25,9 @@
     files: string[];
     onToggleTests: (value: boolean) => void;
     onSelectRoot: (root: string) => void;
+    /** What the reader asked for, or `null` when the depth in `payload` was chosen for them. */
+    chosenDepth: number | null;
+    onSelectDepth: (depth: number | null) => void;
     onSelect: (id: string | null) => void;
     /** Builds the map as an SVG at a given device-pixel scale. */
     buildSvg: (scale: number) => string;
@@ -40,11 +43,31 @@
     files,
     onToggleTests,
     onSelectRoot,
+    chosenDepth,
+    onSelectDepth,
     onSelect,
     buildSvg,
     exportName,
   }: Props = $props();
 
+  /**
+   * The grouping options.
+   *
+   * The first one is the default and is not a number: the answering side reads
+   * the repository and picks the shallowest grouping that is not one box
+   * holding the whole program. The numbers below it are there for when its
+   * choice is wrong for what the reader is looking at — an escape hatch, not
+   * the thing anybody should have to reach for.
+   */
+  const DEPTHS = [1, 2, 3, 4] as const;
+
+  function depthLabel(depth: number): string {
+    return depth === 1 ? 'top-level folders' : `${depth} folders deep`;
+  }
+
+  /** An em dash the mono face has; the select is narrow enough to notice a tofu. */
+  const DASH = '\u2014';
+
   const selectedNode = $derived(
     selected === null ? null : (layout.nodes.find((n) => n.id === selected) ?? null)
   );
@@ -96,6 +119,25 @@
     </select>
   </label>
 
+  <!-- The grouping. A repository whose whole program sits under one directory
+       draws as one box at the shallowest setting, which is why the default is
+       chosen from the repository rather than fixed at 1. -->
+  <label class="field">
+    <span>Grouping</span>
+    <select
+      value={chosenDepth === null ? 'auto' : String(chosenDepth)}
+      onchange={(event) => {
+        const value = (event.currentTarget as HTMLSelectElement).value;
+        onSelectDepth(value === 'auto' ? null : Number(value));
+      }}
+    >
+      <option value="auto">automatic {DASH} {depthLabel(payload.depth)}</option>
+      {#each DEPTHS as option (option)}
+        <option value={String(option)}>{depthLabel(option)}</option>
+      {/each}
+    </select>
+  </label>
+
   <label class="toggle">
     <input
       type="checkbox"
@@ -215,6 +257,15 @@
         {/if}
       </p>
 
+      {#if (selectedModule.dependents?.files ?? 0) > 0}
+        <p class="reach">
+          <b>{plural(selectedModule.dependents.files, 'file')}</b> outside it, across
+          {plural(selectedModule.dependents.modules, 'module')}, reference straight into it — the
+          floor on what a change here has to be checked against, and the bar along the bottom of
+          the box.
+        </p>
+      {/if}
+
       {#if selectedNode?.island}
         <p class="island">
           Nothing in the index depends on this module — no import, call or reference crosses into
@@ -289,6 +340,10 @@
     font-size: 11.5px;
     margin-bottom: 8px;
   }
+  .reach {
+    font-size: 11.5px;
+    margin: 0 0 8px;
+  }
   .field {
     display: flex;
     gap: 8px;

+ 33 - 0
ui/src/components/map/ModuleNode.svelte

@@ -52,6 +52,10 @@
   aria-pressed={node.selected}
   title={`${module.id} — ${module.symbols} symbols in ${module.files} file${
     module.files === 1 ? '' : 's'
+  }${
+    (module.dependents?.files ?? 0) > 0
+      ? `. ${module.dependents.files} file${module.dependents.files === 1 ? '' : 's'} outside it, across ${module.dependents.modules} module${module.dependents.modules === 1 ? '' : 's'}, reference into it.`
+      : ''
   }${layout.island ? '. Nothing in the index depends on it.' : ''}${
     layout.generated ? '. Every file in it is tool-generated.' : ''
   }`}
@@ -61,6 +65,12 @@
   <span class="count" class:island={layout.island}
     >{moduleMetaLabel(module, layout.island)}</span
   >
+  <!-- How much leans on this box, as a share of the heaviest one drawn. Inside
+       the border rather than on it, so it reads as a level in the box and not
+       as a second, thicker edge. -->
+  {#if layout.weight > 0}
+    <span class="weight" style={`width:${(layout.weight * 100).toFixed(1)}%`}></span>
+  {/if}
 </button>
 
 {#each layout.sourceHandles as handle, i (handle)}
@@ -75,6 +85,7 @@
 
 <style>
   .mnode {
+    position: relative;
     display: flex;
     flex-direction: column;
     justify-content: center;
@@ -125,6 +136,28 @@
     outline: 2px solid var(--accent);
     outline-offset: 1px;
   }
+  /* A wash, not a rule: it is a quantity the eye should compare across boxes at
+     a glance, never a line competing with the box's own border. */
+  .weight {
+    position: absolute;
+    left: 0;
+    bottom: 0;
+    height: 4px;
+    background: var(--ink);
+    /* Dark enough to survive the fit: the map opens as far out as 0.45, where a
+       3px band at 0.18 was a rumour. Length is what carries the comparison, and
+       length cannot be read off a stroke the eye has to hunt for. */
+    opacity: 0.3;
+    pointer-events: none;
+  }
+  .mnode:hover .weight,
+  .mnode.sel .weight {
+    opacity: 0.55;
+  }
+  .mnode.dimmed .weight,
+  .mnode.gen .weight {
+    opacity: 0.1;
+  }
   .name {
     font: 500 13px var(--mono);
     line-height: 15px;

+ 14 - 1
ui/src/lib/export-svg.ts

@@ -172,6 +172,7 @@ function rect(
   h: number,
   attrs: {
     fill?: string;
+    fillOpacity?: number;
     stroke?: string;
     strokeWidth?: number;
     dash?: string;
@@ -184,6 +185,7 @@ function rect(
     `height="${round(h)}"`,
     `fill="${attrs.fill ?? 'none'}"`,
   ];
+  if (attrs.fillOpacity !== undefined) parts.push(`fill-opacity="${attrs.fillOpacity}"`);
   if (attrs.stroke) {
     parts.push(`stroke="${attrs.stroke}"`, `stroke-width="${attrs.strokeWidth ?? 1}"`);
     if (attrs.dash) parts.push(`stroke-dasharray="${attrs.dash}"`);
@@ -837,9 +839,20 @@ function mapNodeSvg(node: MapNodeLayout, selected: boolean, dimmed: boolean): st
         size: MODULE_META_SIZE,
         fill: dimmed ? EXPORT_COLORS.ink4 : EXPORT_COLORS.ink3,
       },
-      esc(truncate(moduleMetaLabel(module), room, MODULE_META_SIZE, SANS_ADVANCE))
+      // `node.island`, matching the canvas: an exported map that counts a
+      // module the screen said nothing depends on is a different picture.
+      esc(truncate(moduleMetaLabel(module, node.island), room, MODULE_META_SIZE, SANS_ADVANCE))
     )
   );
+  // The weight bar, same 3px inside the bottom edge as the canvas draws.
+  if (node.weight > 0) {
+    out.push(
+      rect(node.x, node.y + node.height - 4, node.width * node.weight, 4, {
+        fill: EXPORT_COLORS.ink,
+        fillOpacity: dimmed ? 0.1 : 0.3,
+      })
+    );
+  }
   return out.join('');
 }
 

+ 25 - 1
ui/src/lib/map-model.ts

@@ -122,7 +122,16 @@ export function moduleMetaLabel(module: WireMapModule, island = false): string {
   if (island) return 'nothing depends on this';
   const symbols = `${module.symbols} symbol${module.symbols === 1 ? '' : 's'}`;
   const files = `${module.files} file${module.files === 1 ? '' : 's'}`;
-  return `${symbols} · ${files}`;
+  // How big a change here is, said in the same breath as how big the module is.
+  // Two boxes of 20 files are not the same box when one of them is imported by
+  // ninety files and the other by two, and until this line the picture had no
+  // channel that said so — width tracked the length of the PATH.
+  // `?.` because `GraphAdapter` is a public seam: a host that assembles this
+  // payload itself and has not caught up to the field must lose the bar, not
+  // the screen. Every other read of `dependents` goes through this one.
+  const reach = module.dependents?.files ?? 0;
+  const depend = reach > 0 ? ` · ${reach} depend on it` : '';
+  return `${symbols} · ${files}${depend}`;
 }
 
 /** One port on a box's edge: the link it belongs to, and which end of it this is. */
@@ -144,6 +153,17 @@ export interface MapNodeLayout {
   island: boolean;
   /** Every file in it is tool-generated, so it draws in ink-4. */
   generated: boolean;
+  /**
+   * How much of the picture leans on this box, 0..1, as a share of the
+   * most-depended-on box DRAWN — the bar along the bottom of the node.
+   *
+   * Relative rather than absolute because there is no absolute scale a reader
+   * could calibrate against: 94 dependent files is enormous in a 377-file app
+   * and unremarkable in a monorepo. Relative to what is on screen, the longest
+   * bar always means "this is the one to be careful with, here". The absolute
+   * number is on the box beside it, so the bar never has to be trusted alone.
+   */
+  weight: number;
   layer: number;
   x: number;
   y: number;
@@ -417,6 +437,9 @@ export function buildMapLayout(
 
   const nodesById = new Map<string, MapNodeLayout>();
   const byId = new Map(modules.map((m) => [m.id, m]));
+  // The busiest box DRAWN sets the scale — so turning tests on rescales the
+  // bars rather than leaving a test module's bar overflowing a hidden maximum.
+  const heaviest = Math.max(0, ...modules.map((m) => m.dependents?.files ?? 0));
   rows.forEach((row, index) => {
     const span = rowSpans[index] ?? 0;
     const sum = rowSums[index] ?? 0;
@@ -435,6 +458,7 @@ export function buildMapLayout(
         // Every file generated, not merely some: a module with one `.pb.go` in
         // it is still a module somebody writes by hand.
         generated: module.files > 0 && module.generated === module.files,
+        weight: heaviest === 0 ? 0 : (module.dependents?.files ?? 0) / heaviest,
         layer: index,
         x,
         y,

+ 17 - 4
ui/src/lib/navigation.ts

@@ -39,7 +39,8 @@ export interface FileHrefOptions {
 
 export interface MapHrefOptions {
   root?: string | null;
-  depth?: number;
+  /** Absent or null leaves the grouping to the answering side. */
+  depth?: number | null;
   tests?: boolean;
 }
 
@@ -79,7 +80,14 @@ export interface StepsHrefOptions {
  * serve.
  */
 export interface NavigationDriver {
-  symbolHref(id: string, opts?: SymbolHrefOptions): string;
+  /**
+   * A symbol's page — or, with `null`, the Symbol tab with nothing chosen yet.
+   *
+   * The null case has to be addressable. Without it the tab had no href of its
+   * own and fell back to the landing page, which on a project that HAS screens
+   * is the Screens tab: clicking Symbol landed you on somebody else's view.
+   */
+  symbolHref(id: string | null, opts?: SymbolHrefOptions): string;
   fileHref(path: string, opts?: FileHrefOptions): string;
   mapHref(opts?: MapHrefOptions): string;
   flowHref(opts?: FlowHrefOptions): string;
@@ -116,6 +124,9 @@ export const hashNavigation: NavigationDriver = {
     const params = new URLSearchParams();
     if (opts.trail) params.set('t', opts.trail);
     if (opts.line) params.set('hl', String(opts.line));
+    // No id: the tab itself. `#/s` rather than `#/s/` so the segment filter
+    // cannot read an empty id back out of it.
+    if (!id) return `#/s${query(params)}`;
     return `#/s/${encodePath(id)}${query(params)}`;
   },
 
@@ -131,7 +142,9 @@ export const hashNavigation: NavigationDriver = {
   mapHref(opts = {}) {
     const params = new URLSearchParams();
     if (opts.root !== undefined && opts.root !== null) params.set('root', opts.root);
-    if (opts.depth && opts.depth !== 1) params.set('depth', String(opts.depth));
+    // Including 1: a reader who asked for top-level directories has said
+    // something, and dropping it would hand the choice back to the answer.
+    if (opts.depth) params.set('depth', String(opts.depth));
     if (opts.tests) params.set('tests', '1');
     return `#/map${query(params)}`;
   },
@@ -224,7 +237,7 @@ export function getNavigationDriver(): NavigationDriver {
 
 /* --------------------------- what the components actually call ----------- */
 
-export function symbolHref(id: string, opts: SymbolHrefOptions = {}): string {
+export function symbolHref(id: string | null, opts: SymbolHrefOptions = {}): string {
   return driver.symbolHref(id, opts);
 }
 

+ 2 - 0
ui/src/lib/program-model.ts

@@ -339,6 +339,7 @@ export function buildOrderModel(payload: WireStepsPayload): StepsModel | null {
       generatedFiles: [],
       facade: false,
       fileList: { total: 1, shown: 1, truncated: false, items: [step.node?.file ?? step.sub] },
+      dependents: { files: 0, modules: 0 },
     });
   }
   // Each decision is a point of its own on the canvas: a small box asking the
@@ -357,6 +358,7 @@ export function buildOrderModel(payload: WireStepsPayload): StepsModel | null {
       generatedFiles: [],
       facade: false,
       fileList: { total: 0, shown: 0, truncated: false, items: [] },
+      dependents: { files: 0, modules: 0 },
     });
   }
   const drawn = (id: string): boolean => nodes.has(id) || forks.has(id);

+ 12 - 6
ui/src/lib/router.svelte.ts

@@ -59,7 +59,8 @@ export type {
 
 export type Route =
   | { view: 'home' }
-  | { view: 'symbol'; id: string; line: number | null }
+  /** `id: null` = the Symbol tab, nothing chosen — the empty screen. */
+  | { view: 'symbol'; id: string | null; line: number | null }
   | {
       view: 'file';
       path: string;
@@ -67,7 +68,7 @@ export type Route =
       /** The whole-file source view rather than the outline (design spec §3.4). */
       source: boolean;
     }
-  | { view: 'map'; root: string | null; depth: number; tests: boolean }
+  | { view: 'map'; root: string | null; depth: number | null; tests: boolean }
   | {
       view: 'flow';
       /** "how does X reach Y" — both ends pinned. */
@@ -135,19 +136,24 @@ export function parseHash(hash: string): RouterLocation {
   let route: Route;
   if (head === undefined) {
     route = { view: 'home' };
-  } else if (head === 's' && rest.length > 0) {
-    route = { view: 'symbol', id: rest.join('/'), line };
+  } else if (head === 's') {
+    // `#/s` on its own is the tab, not a 404: nothing is chosen yet.
+    route = { view: 'symbol', id: rest.length > 0 ? rest.join('/') : null, line };
   } else if (head === 'file' && rest.length > 0) {
     route = { view: 'file', path: rest.join('/'), line, source: params.get('src') === '1' };
   } else if (head === 'map' && rest.length === 0) {
     // The map's shape travels in the URL like the trail does: a link to
-    // "src/vs at depth 2, tests on" has to reopen the same picture.
+    // "src/vs at depth 2, tests on" has to reopen the same picture. Absent, it
+    // stays absent: the answering side reads the repository and picks a depth,
+    // and a 1 defaulted in here would silently override that with the one
+    // grouping — top-level directories — that is wrong for every project whose
+    // program lives under a single `src/`.
     const root = params.get('root');
     const depth = Number.parseInt(params.get('depth') ?? '', 10);
     route = {
       view: 'map',
       root: root === null ? null : root,
-      depth: Number.isFinite(depth) && depth >= 1 && depth <= 4 ? depth : 1,
+      depth: Number.isFinite(depth) && depth >= 1 && depth <= 4 ? depth : null,
       tests: params.get('tests') === '1',
     };
   } else if (head === 'entry' && rest.length === 0) {

+ 2 - 0
ui/src/lib/screens-model.ts

@@ -398,6 +398,8 @@ function moduleFor(info: ScreenNodeInfo, symbols: number): WireMapModule {
     generatedFiles: [],
     facade: false,
     fileList: { total: 1, shown: 1, truncated: false, items: [info.screen?.file ?? info.sub] },
+    // Not the Map: a screen has no dependent count and draws no weight bar.
+    dependents: { files: 0, modules: 0 },
   };
 }
 

+ 3 - 0
ui/src/lib/steps-model.ts

@@ -385,6 +385,8 @@ export function buildStepsModel(payload: WireStepsPayload): StepsModel {
       generatedFiles: [],
       facade: false,
       fileList: { total: 1, shown: 1, truncated: false, items: [step.node?.file ?? step.sub] },
+      // Not the Map: a step has no dependent count and draws no weight bar.
+      dependents: { files: 0, modules: 0 },
     });
   }
 
@@ -716,6 +718,7 @@ function packRegions(
       module: moduleOf.get(id)!,
       island: false,
       generated: false,
+      weight: 0,
       layer: layerOf(id),
       x,
       y: yy,

+ 6 - 0
ui/src/lib/wire.ts

@@ -610,6 +610,12 @@ export interface WireMapModule {
   facade: boolean;
   /** Its files, capped — the side panel's list when the module is selected. */
   fileList: { total: number; shown: number; truncated: boolean; items: string[] };
+  /**
+   * Files OUTSIDE this module with a direct reference into it, and how many
+   * modules they span — what a change in here reaches. Direct, not transitive:
+   * a cycle saturates the transitive count and it stops discriminating.
+   */
+  dependents: { files: number; modules: number };
 }
 
 export interface WireMapLink {

+ 46 - 3
ui/src/views/MapView.svelte

@@ -18,6 +18,7 @@
   import ModuleNode from '../components/map/ModuleNode.svelte';
   import ModuleEdge from '../components/map/ModuleEdge.svelte';
   import MapSidePanel from '../components/map/MapSidePanel.svelte';
+  import MapKey from '../components/map/MapKey.svelte';
   import { exportFilename, mapSvg } from '../lib/export-svg';
   import { fetchMap, type WireMapPayload } from '../lib/api';
   import { live } from '../lib/live.svelte';
@@ -31,7 +32,8 @@
 
   interface Props {
     root: string | null;
-    depth: number;
+    /** `null` = nobody has chosen; the answer picks a grouping for this repo. */
+    depth: number | null;
     tests: boolean;
   }
 
@@ -55,6 +57,26 @@
    */
   const FIT = { fitViewOptions: { padding: 0.12, maxZoom: 1, minZoom: 0.45 } };
 
+  // The key stays open until the reader closes it; the choice survives a reload
+  // but is per browser — a preference, not a fact about the project. Same
+  // storage shape as the Screens and Steps keys.
+  const LEGEND_KEY = 'codegraph-ui:map-legend';
+  let legendOpen = $state(readLegendOpen());
+  function readLegendOpen(): boolean {
+    try {
+      return localStorage.getItem(LEGEND_KEY) !== 'closed';
+    } catch {
+      return true;
+    }
+  }
+  $effect(() => {
+    try {
+      localStorage.setItem(LEGEND_KEY, legendOpen ? 'open' : 'closed');
+    } catch {
+      // Storage refused (private mode): the key simply reopens next time.
+    }
+  });
+
   const nodeTypes = { module: ModuleNode };
   const edgeTypes = { module: ModuleEdge };
 
@@ -72,7 +94,7 @@
     const controller = new AbortController();
     loading = true;
     error = null;
-    fetchMap({ root: wantRoot, depth: wantDepth }, controller.signal)
+    fetchMap({ root: wantRoot, depth: wantDepth ?? undefined }, controller.signal)
       .then((next) => {
         payload = next;
         loading = false;
@@ -165,7 +187,16 @@
 
   function setRoot(next: string): void {
     selected = null;
-    navigate(mapHref({ root: next, depth, tests }));
+    // Deliberately dropping the depth: how finely to cut `ios` is a different
+    // question from how finely to cut the whole project, and carrying the old
+    // answer over is how a reader lands on a one-box map.
+    navigate(mapHref({ root: next, tests }));
+  }
+
+  /** `null` hands the grouping back to the answering side. */
+  function setDepth(next: number | null): void {
+    selected = null;
+    navigate(mapHref({ root, depth: next, tests }));
   }
 
   /**
@@ -191,6 +222,7 @@
     selected = null;
     navigate(mapHref({ root, depth, tests: next }));
   }
+
 </script>
 
 <div class="mapview">
@@ -256,6 +288,15 @@
         <Controls position="bottom-right" showLock={false} />
       </SvelteFlow>
 
+      <!-- The key, on the picture it explains. -->
+      <MapKey
+        minWeight={layout.minWeight}
+        thinCount={layout.edges.filter((e) => e.thin && !e.back).length}
+        declaredBasis={layout.basis.kind === 'declared'}
+        open={legendOpen}
+        onToggle={(next) => (legendOpen = next)}
+      />
+
       {#if hovered !== null}
         <div class="tip" style={`left:${hovered.x}px;top:${hovered.y}px`}>
           <div class="mono"><b>{hovered.edge.source}</b> → {hovered.edge.target}</div>
@@ -291,6 +332,8 @@
       exportName={exportFilename('map', payload.root ?? '')}
       onToggleTests={setTests}
       onSelectRoot={setRoot}
+      chosenDepth={depth}
+      onSelectDepth={setDepth}
       onSelect={(id) => (selected = id)}
     />
   {/if}