diff --git a/e2e/graph-density.spec.ts b/e2e/graph-density.spec.ts
new file mode 100644
index 00000000..85cabe07
--- /dev/null
+++ b/e2e/graph-density.spec.ts
@@ -0,0 +1,102 @@
+import { test, expect } from '@playwright/test';
+import { navClick } from './helpers/navigation';
+
+/**
+ * Graph density spec (plans/152).
+ *
+ * Placement spreads unseeded entities over a band that grows with the library, and
+ * the canvas grows with the band. Two user-visible consequences are worth pinning
+ * in a real browser: no node may be drawn outside the canvas (where it cannot be
+ * clicked at all), and a click must select the node it landed on — a node placed
+ * closer than the click-safe distance lets its neighbour's label swallow the click
+ * and select the wrong entity (plans/148).
+ *
+ * The library is seeded through the Zustand persist envelope, so the graph holds
+ * far more entities than the authored 800×560 canvas can hold at the preferred
+ * spacing without any slow per-entity UI setup.
+ */
+
+/** Must match CURRENT_SCHEMA_VERSION in src/lib/studio/migrations.ts. */
+const SCHEMA_VERSION = 5;
+
+/** Persist key used by the Zustand store (name option in store.ts). */
+const STORE_KEY = 'do-knowledge-studio-store';
+
+/** Enough entities that the placement band outgrows the authored canvas. */
+const ENTITY_COUNT = 60;
+
+/** The entity the click test targets — any node at this scale would do. */
+const TARGET_ENTITY = `Entity ${ENTITY_COUNT - 1}`;
+
+const SEED_ENVELOPE = {
+ state: {
+ entities: Array.from({ length: ENTITY_COUNT }, (_, i) => ({
+ id: `dense-${i}`,
+ name: `Entity ${i}`,
+ type: 'note',
+ description: `Seeded entity ${i} for graph density coverage`,
+ content: '',
+ tags: ['seed'],
+ createdAt: '2025-01-01T00:00:00Z',
+ updatedAt: '2025-06-15T00:00:00Z',
+ links: [],
+ })),
+ claims: [],
+ graph: undefined,
+ mindMap: undefined,
+ links: undefined,
+ tags: undefined,
+ },
+ version: SCHEMA_VERSION,
+};
+
+test.describe('Graph density at scale', () => {
+ test.beforeEach(async ({ page }) => {
+ await page.addInitScript(
+ ({ storeKey, envelope }) => {
+ localStorage.setItem(storeKey, JSON.stringify(envelope));
+ },
+ { storeKey: STORE_KEY, envelope: SEED_ENVELOPE },
+ );
+ await page.goto('/');
+ await navClick(page, /graph/i);
+ await expect(page.getByRole('application', { name: /graph canvas/i })).toBeVisible();
+ });
+
+ test('draws every node inside the canvas', async ({ page }) => {
+ const graph = page.getByRole('img', { name: /knowledge graph/i });
+
+ const result = await graph.evaluate((svg) => {
+ const canvas = svg.getBoundingClientRect();
+ const nodes = Array.from(svg.querySelectorAll('g[role="button"]'));
+ const outside = nodes
+ .filter((node) => {
+ const box = node.getBoundingClientRect();
+ return (
+ box.left < canvas.left ||
+ box.right > canvas.right ||
+ box.top < canvas.top ||
+ box.bottom > canvas.bottom
+ );
+ })
+ .map((node) => node.getAttribute('aria-label') ?? '');
+ return { total: nodes.length, outside };
+ });
+
+ expect(result.total).toBe(ENTITY_COUNT);
+ expect(result.outside).toEqual([]);
+ });
+
+ test('selects the node the click landed on', async ({ page }) => {
+ const graph = page.getByRole('img', { name: /knowledge graph/i });
+ const target = graph.getByRole('button', { name: new RegExp(`^${TARGET_ENTITY} —`) });
+
+ await target.click();
+
+ // The clicked node reports itself as selected: a click swallowed by a
+ // neighbour's label would leave this node unselected.
+ await expect(
+ graph.getByRole('button', { name: new RegExp(`^${TARGET_ENTITY} —.*\\(selected\\)`) }),
+ ).toBeVisible();
+ });
+});
diff --git a/plans/152-graph-density-and-canvas-fit-2026-09-24.md b/plans/152-graph-density-and-canvas-fit-2026-09-24.md
new file mode 100644
index 00000000..f076c38e
--- /dev/null
+++ b/plans/152-graph-density-and-canvas-fit-2026-09-24.md
@@ -0,0 +1,118 @@
+# Plan 152 — Graph Density: the Canvas Grows With the Library (2026-09-24)
+
+**Type**: product defect + layout fix
+**Scope**: `src/lib/studio/graph-layout.ts`, `src/lib/studio/graph-viewport.ts` (new),
+`src/components/studio/views/graph-view.tsx`, `e2e/graph-density.spec.ts` (new)
+**Follows**: plans/148 §6.3 ("Graph density — the placement band is small relative to
+the seed layout, so a large library crowds. A viewport-aware band or zoom-to-fit
+would help.")
+
+## 1. Problem
+
+plans/148 placed unseeded nodes with a probe that keeps its distance from every
+already-placed node, and measured the click-safe distance from the label geometry
+(80px). But the band it probes inside is fixed — 600×400, inside the 800×560
+canvas — and once that band is full the probe falls back to "the candidate with
+the most clearance", which can be **closer than click-safe**. The wrong-entity
+selection that plans/148 fixed therefore returns as soon as the library is large
+enough to saturate the band.
+
+Measured, before any change (8 seed nodes + 40 new entities):
+
+```
+"Genrich Altshuller ↔ Entity new-8: 68.7px"
+"Local-First Software ↔ Entity new-37: 60.0px"
+"Entity new-17 ↔ Entity new-38: 45.6px"
+… 42 pairs closer than the 80px click-safe distance
+```
+
+This is not cosmetic. A pair closer than click-safe means one node's label covers
+the other's dot, SVG gives the click to the topmost element, and the user selects
+the wrong entity.
+
+## 2. Fix
+
+Two halves, and both are required — the second is what makes the first safe.
+
+### 2.1 The placement band grows with the library (`graph-layout.ts`)
+
+`placementBand(unseededCount)` starts at the authored band and doubles its area per
+tier (`√2` per axis) until it holds the library at the preferred spacing:
+
+| unseeded entities | band | capacity at preferred spacing |
+|---|---|---|
+| ≤ 6 | 600×400 (authored) | 6 |
+| 7–12 | 848×565 | 12 |
+| 13–24 | 1200×800 | 24 |
+| 25–48 | 1697×1131 | 48 |
+
+`BASE_BAND_CAPACITY = 6` is derived from the authored layout: (600×400 − 8 seed
+obstacles × ~17k px²) ÷ ~17k px², where 17k px² is one node's share of the plane
+at `PREFERRED_NODE_DISTANCE_PX` under hexagonal packing. Growth is tiered rather
+than continuous, so the canvas grows in steps instead of rescaling on every
+insert; individual positions can still shift when an entity is added, because
+placement is sequential over a deterministic id order.
+
+### 2.2 The canvas grows with the band (`graph-viewport.ts`, new)
+
+The view hard-coded `viewBox="0 0 800 560"`. Left that way, the grown band is not
+just crowded but **invisible**: the mutation check below measured 56 of 60 nodes
+drawn outside the canvas, where they cannot be clicked or panned to (the viewBox
+never grows, so panning cannot reach them either).
+
+`canvasSize(nodes)` returns the authored canvas or the smallest canvas that
+contains every node *and its label* (labels span ±66px and hang below the dot —
+the same measurement `PREFERRED_NODE_DISTANCE_PX` is built on). `canvasViewBox`
+composes that size with the existing zoom/pan state. Resetting the view (Home)
+therefore always shows the whole graph, and no new control or string is needed.
+
+The canvas is derived from `positioned` rather than `visibleNodes`: focus mode
+filters what is drawn, and the canvas must not shrink under the nodes when it
+toggles.
+
+This also covers the `circular` and `hierarchical` layouts, which place nodes on
+their own grid and already exceeded 800×560 at ~25 entities.
+
+## 3. Verification
+
+| Check | Result |
+|---|---|
+| New invariant test (8 seeds + 40 entities, every pair ≥ click-safe) | **fails before** (42 pairs, listed above) → **passes after** |
+| `pnpm exec vitest run graph-layout graph-viewport graph-view…` | 62 passed |
+| Mutation: revert the view to `0 0 800 560` | component test fails (`expected 800 to be greater than 800`); E2E reports 56 nodes outside the canvas |
+| `pnpm exec playwright test --project=chromium` | **151 passed** (2.3 m), including the two new density tests |
+| `./scripts/quality_gate.sh` | see §4 |
+
+`e2e/graph-density.spec.ts` seeds 60 entities through the Zustand persist envelope
+(the `library-virtualization.spec.ts` pattern) and asserts that every node's box
+is inside the canvas, and that clicking a node selects *that* node.
+
+## 4. Gate and CI results
+
+| Check | Result |
+|---|---|
+| `./scripts/quality_gate.sh` (full, all scopes) | **✓ All Quality Gates PASSED** — lint, typecheck, test (2635), shellcheck, `bats tests/`, link validation |
+| `pnpm exec playwright test --project=chromium` | 151 passed (2.3 m) |
+
+## 5. Trade-offs
+
+- **A large graph renders smaller.** The canvas grows and `preserveAspectRatio`
+ fits it, so a 60-entity library draws at roughly half scale. That is the
+ intended reading of "fit to content": detail comes from zooming in, and the
+ alternative is the wrong-entity click. Labels stay legible until roughly the
+ 40-entity tier.
+- **Growth is tiered, not continuous.** Crossing a tier grows the canvas once.
+ Continuous growth would rescale the graph on every insert, which is worse. It
+ does not make positions immutable within a tier: placement is sequential over a
+ deterministic id order, so inserting an entity that sorts before others can move
+ theirs (corrected after review — the first version of this note overclaimed).
+- **The seed layout never moves.** Growth extends the band right and down from
+ (100, 80), so the authored 8-node layout keeps its positions.
+
+## 6. Follow-ups
+
+1. **A viewport-aware tier size** — the band grows with the entity count, not with
+ the rendered viewport. On a large display the same library could hold the
+ preferred spacing in a shorter band.
+2. **`semantic-search.spec.ts` load sensitivity** (plans/148 §6.1) — unchanged.
+3. **ESLint 10 workaround** (plans/140 §2) — still blocked upstream.
diff --git a/src/components/studio/views/graph-view.test.tsx b/src/components/studio/views/graph-view.test.tsx
index 3d8faefe..13d08d2b 100644
--- a/src/components/studio/views/graph-view.test.tsx
+++ b/src/components/studio/views/graph-view.test.tsx
@@ -113,6 +113,7 @@ vi.mock('@/lib/studio/store', () => ({
}))
import { GraphView } from './graph-view'
+import { BASE_CANVAS_HEIGHT, BASE_CANVAS_WIDTH } from '@/lib/studio/graph-viewport'
describe('GraphView', () => {
beforeEach(() => {
@@ -263,4 +264,43 @@ describe('GraphView', () => {
expect(screen.getByText(/2 nodes · 1 edges/)).toBeDefined()
expect(screen.queryByText('Unrelated Entity')).toBeNull()
})
+
+ it('grows the canvas past the authored size so a large library stays on it', () => {
+ currentEntities = [
+ ...mockEntities,
+ ...Array.from({ length: 40 }, (_, i) => ({
+ id: `new-${i}`,
+ name: `Entity new-${i}`,
+ type: 'concept' as const,
+ description: '',
+ content: '',
+ tags: [],
+ createdAt: new Date().toISOString(),
+ updatedAt: new Date().toISOString(),
+ links: [],
+ })),
+ ]
+ render()
+
+ const svg = screen.getByRole('img', { name: /knowledge graph/i })
+ const [, , width, height] = (svg.getAttribute('viewBox') ?? '').split(' ').map(Number)
+
+ // The regression: the view hard-coded an 800×560 viewBox while placement
+ // spread nodes over a grown band, drawing most of the graph off-canvas.
+ expect(width).toBeGreaterThan(BASE_CANVAS_WIDTH)
+ expect(height).toBeGreaterThan(BASE_CANVAS_HEIGHT)
+ })
+
+ it('keeps the canvas size while focus mode filters nodes', () => {
+ currentSelectedEntityId = 'ent-1'
+ render()
+
+ const svg = screen.getByRole('img', { name: /knowledge graph/i })
+ const before = svg.getAttribute('viewBox')
+ fireEvent.click(screen.getByLabelText('Focus neighborhood'))
+
+ // Focus mode hides nodes; shrinking the canvas under the remaining ones
+ // would rescale the graph every time the toggle is used.
+ expect(svg.getAttribute('viewBox')).toBe(before)
+ })
})
diff --git a/src/components/studio/views/graph-view.tsx b/src/components/studio/views/graph-view.tsx
index 757eb76e..4ee91403 100644
--- a/src/components/studio/views/graph-view.tsx
+++ b/src/components/studio/views/graph-view.tsx
@@ -4,6 +4,7 @@ import { useStudioStore } from '@/lib/studio/store'
import { type GraphEdge, type GraphNode } from '@/lib/studio/types'
import { seedGraph } from '@/lib/studio/seed-data'
import { placeGraphNodes } from '@/lib/studio/graph-layout'
+import { canvasSize, canvasViewBox } from '@/lib/studio/graph-viewport'
import { getEntityTypeDefs, getEntityTypeMeta } from '@/lib/studio/entity-types'
import { translate as entityTypesT } from '@/lib/i18n/messages/entity-types'
import { todayStamp, downloadBlob } from './export-types'
@@ -163,6 +164,11 @@ export const GraphView = () => {
return nodes
}, [nodes, layout])
+ // The canvas grows with the placed nodes so nothing is drawn off-canvas. It is
+ // derived from `positioned` rather than `visibleNodes`: focus mode filters what
+ // is drawn, and the canvas must not shrink under the nodes when it toggles.
+ const canvas = useMemo(() => canvasSize(positioned), [positioned])
+
const visibleNodes = useMemo(() => {
if (focusMode && selectedEntityId) {
const neighbors = adjacency.get(selectedEntityId)
@@ -330,7 +336,7 @@ export const GraphView = () => {
>