diff --git a/app/page.tsx b/app/page.tsx
index 4e89922..80b0496 100644
--- a/app/page.tsx
+++ b/app/page.tsx
@@ -1,6 +1,6 @@
import { Home } from "@/components/home";
import { readSettingsRecord } from "@/src/server";
-import { slotsConfigured } from "@/src/core";
+import { resolveSettings, slotsConfigured } from "@/src/core";
// Dynamic: the configured state is server-rendered into the page (the
// setup CTA's truth on the first frame), and every navigation re-reads
@@ -12,7 +12,11 @@ export default function HomePage() {
// has picked all three slots — the dock shows the CTA while the raw
// record's models section lacks any slot pin.
const record = readSettingsRecord();
+ // GRAPH RENDERING (owner, 2026-10-06): off = the home's main view is
+ // the chains view. Resolved through the one core resolver (missing
+ // section / absent key → factory default on); re-read per navigation.
+ const rendering = resolveSettings(record ?? null).graph.rendering;
return (
-
+
);
}
diff --git a/components/chain-table.tsx b/components/chain-table.tsx
index d1d7554..7ef8268 100644
--- a/components/chain-table.tsx
+++ b/components/chain-table.tsx
@@ -136,7 +136,10 @@ export function ChainTableBody({
onClear: () => void;
/** Opens the full-death confirm dialog (owner, 2026-09-29 night). */
onDelete: () => void;
- onClose: () => void;
+ /** Dismisses the overlay (top-bar X). ABSENT = the HOME variant
+ * (graph rendering off, owner 2026-10-06): the table is the page,
+ * there is nothing to close, and the X does not render. */
+ onClose?: () => void;
/** PRACTICE SELECT (2026-10-04): the floating bar's confirm becomes
* "Practice these" — no delete fence; the caller owns what the
* selection becomes. */
@@ -260,6 +263,7 @@ export function ChainTableBody({
className="lh-no-drag h-8 w-56 rounded-lg border bg-background pl-8 pr-3 text-xs outline-none placeholder:text-muted-foreground focus:border-ring"
/>
+ {onClose && (
+ )}
{/* The list: NO per-row lines (owner ruling) — the ONLY horizontal
diff --git a/components/chains-home.tsx b/components/chains-home.tsx
new file mode 100644
index 0000000..9622646
--- /dev/null
+++ b/components/chains-home.tsx
@@ -0,0 +1,239 @@
+"use client";
+
+/**
+ * THE CHAINS HOME (owner, 2026-10-06 — the release graph-controls round):
+ * when GRAPH RENDERING is off (graph.rendering in settings), the home's
+ * main view is NOT the canvas — it is the chain table, in flow, as the
+ * page. Same body as the overlay (ChainTableBody), same powers (query +
+ * sort, selection, the delete fence), but there is nothing to close so
+ * the top-bar X does not render (onClose absent = home variant).
+ *
+ * The composer blob persists over everything exactly as it floats over
+ * the canvas (home owns it); this view keeps clear of it with a fixed
+ * bottom band (pb-40 ≈ the composer column's height — chip row + form +
+ * stats + dock padding), so the table scrolls CLEAR of the composer
+ * instead of under it: a text table under a floating box is noise
+ * (owner-approved wording: "bottom clearance so content scrolls clear").
+ *
+ * The graph chrome's survivors: Import notes (ALWAYS — the entry point,
+ * the empty-graph law) and the cost pill, floating top-right below the
+ * table's top bar. Node search is canvas-scoped and the render-settings
+ * gear is canvas rendering — neither applies; the table's own search row
+ * covers query.
+ *
+ * The PRACTICE PICK flow mirrors the overlay's contract exactly (the
+ * caller — home.tsx — lifts the pick set; selectMode swaps the floating
+ * bar's confirm to Done). In this home the chip's empty label drops the
+ * "from the map" (there is no map); while picking, home hides the chip
+ * row so the bar (which carries count + Clear + Done) is the one voice.
+ */
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import { UploadIcon } from "lucide-react";
+import { ChainTableBody, type ChainSort } from "@/components/chain-table.tsx";
+import { DeleteNodeDialog } from "@/components/delete-node-dialog.tsx";
+import { GraphCostControl } from "@/components/graph-view.tsx";
+import { Hint } from "@/components/ui/hint";
+import type { GraphCostSummary, GraphNode, GraphSnapshot } from "@/src/core";
+
+/** The fixed bottom clearance ≈ the composer column at its tallest
+ * (chip row + form + stats + the dock's own pb-5). The canvas home lets
+ * content run under the blob; a text table scrolls clear instead. */
+const COMPOSER_CLEARANCE = "pb-40";
+
+export function ChainsHome({
+ chainSelectOpen = false,
+ practiceSelection,
+ onPracticeSelectionChange,
+ onChainSelectClose,
+}: {
+ /** The lifted pick set drives selectMode: Done (not the delete fence)
+ * confirms, and the selection is caller-owned (the chip reflects it). */
+ chainSelectOpen?: boolean;
+ practiceSelection?: ReadonlySet;
+ onPracticeSelectionChange?: (nodes: { id: string; name: string; statement: string }[]) => void;
+ onChainSelectClose?: () => void;
+}) {
+ const [snapshot, setSnapshot] = useState(null);
+ const [costs, setCosts] = useState(null);
+ const [importing, setImporting] = useState(false);
+ const fileInputRef = useRef(null);
+
+ // Table state — the overlay's own shape (query/sort/expand/selection
+ // + the delete dialog), minus Esc-to-dismiss: this is the page.
+ const [query, setQuery] = useState("");
+ const [sort, setSort] = useState({ col: "name", dir: 1 });
+ const [internalSelection, setInternalSelection] = useState>(() => new Set());
+ const [expandedClusters, setExpandedClusters] = useState>(() => new Set());
+ const [expandedChains, setExpandedChains] = useState>(() => new Set());
+ const [deleteOpen, setDeleteOpen] = useState(false);
+
+ const readGraph = useCallback(() => {
+ fetch("/api/graph")
+ .then((res) => res.json() as Promise)
+ .then(setSnapshot)
+ .catch(() => {});
+ fetch("/api/graph/costs")
+ .then((res) => res.json() as Promise)
+ .then(setCosts)
+ .catch(() => {});
+ }, []);
+ useEffect(() => {
+ readGraph();
+ }, [readGraph]);
+
+ // Notes import — the same front door as the graph home: POST awaits
+ // the whole pipeline server-side, then one refetch lands the merged
+ // graph. The button carries the busy state for the duration.
+ const onImportFiles = (files: FileList | null) => {
+ if (!files || files.length === 0) return;
+ setImporting(true);
+ const form = new FormData();
+ for (const file of Array.from(files)) form.append("files", file);
+ fetch("/api/graph/import", { method: "POST", body: form })
+ .then(async (res) => {
+ const body = (await res.json()) as { results: { filePath: string; ok: boolean }[] };
+ const failed = body.results.filter((r) => !r.ok);
+ if (failed.length > 0) console.error("notes import failed for:", failed);
+ })
+ .catch((err) => console.error("import failed:", err))
+ .finally(() => {
+ setImporting(false);
+ readGraph();
+ });
+ };
+
+ // Controlled when the caller lifts the selection (the practice pick
+ // set — home always lifts, same law as the overlay on the graph home);
+ // internal otherwise. The lifted value is RESOLVED nodes (ids →
+ // {id, name, statement}) against the live snapshot — the caller holds
+ // the pick set (count + seed), never raw ids.
+ const selection = practiceSelection ?? internalSelection;
+ const nodes = snapshot?.propositions ?? [];
+ const chains = snapshot?.chains ?? [];
+ const byId = useMemo(() => new Map(nodes.map((n) => [n.id, n])), [nodes]);
+ const applySelection = (next: ReadonlySet) => {
+ if (onPracticeSelectionChange) {
+ const picked: { id: string; name: string; statement: string }[] = [];
+ for (const id of next) {
+ const node = byId.get(id);
+ if (node) picked.push({ id: node.id, name: node.name, statement: node.statement });
+ }
+ onPracticeSelectionChange(picked);
+ } else setInternalSelection(next);
+ };
+ const selectedNodes = [...selection].map((id) => byId.get(id)).filter((n): n is GraphNode => n !== undefined);
+ const isEmpty = snapshot !== null && nodes.length === 0;
+
+ const toggleNodes = (ids: string[], next: boolean) => {
+ const s = new Set(selection);
+ for (const id of ids) {
+ if (next) s.add(id);
+ else s.delete(id);
+ }
+ applySelection(s);
+ };
+ const toggleIn = (set: ReadonlySet, id: string) => {
+ const s = new Set(set);
+ if (s.has(id)) s.delete(id);
+ else s.add(id);
+ return s;
+ };
+
+ return (
+
+ ) : (
+
+ setSort((prev) =>
+ prev.col === col ? { col, dir: prev.dir === 1 ? -1 : 1 } : { col, dir: col === "name" ? 1 : -1 },
+ )
+ }
+ selection={selection}
+ expandedClusters={expandedClusters}
+ expandedChains={expandedChains}
+ onToggleCluster={(key) => setExpandedClusters((prev) => toggleIn(prev, key))}
+ onToggleChain={(id) => setExpandedChains((prev) => toggleIn(prev, id))}
+ onToggleNodes={toggleNodes}
+ onClear={() => applySelection(new Set())}
+ onDelete={() => setDeleteOpen(true)}
+ // onClose ABSENT — the home variant: no top-bar X.
+ selectMode={chainSelectOpen}
+ onSelect={onChainSelectClose}
+ />
+ )}
+
+ {/* The chrome survivors, floating top-right BELOW the table's top
+ bar (h-15 = 60px + breathing room — owner, 2026-10-06): Import
+ ALWAYS — the entry point; the cost pill when any run exists. Node
+ search + the render gear are canvas things. */}
+ {!isEmpty && (
+
+ {costs && costs.runs.length > 0 && }
+
+
+
+
+
+
+ )}
+
+ {deleteOpen && (
+ ({ id: n.id, name: n.name }))}
+ onClose={() => setDeleteOpen(false)}
+ onDeleted={(deadIds) => {
+ // Prune the dead ids (the rest of a big selection
+ // survives), then refetch — fresh rows flow back in.
+ setDeleteOpen(false);
+ const dead = new Set(deadIds);
+ applySelection(new Set([...selection].filter((id) => !dead.has(id))));
+ readGraph();
+ }}
+ />
+ )}
+
+ );
+}
diff --git a/components/graph-view.tsx b/components/graph-view.tsx
index 88d0f26..b414b86 100644
--- a/components/graph-view.tsx
+++ b/components/graph-view.tsx
@@ -325,7 +325,9 @@ export function displayCostTotals(costs: Pick): {
* pattern as the settings gear: outside click or Escape closes. The
* panel is viewport-capped + scrollable like the coverage dropdown.
*/
-function GraphCostControl({ costs }: { costs: GraphCostSummary }) {
+/** Exported for the chains home (graph rendering off, owner
+ * 2026-10-06): the same bare cumulative-dollars pill + panel. */
+export function GraphCostControl({ costs }: { costs: GraphCostSummary }) {
const [open, setOpen] = useState(false);
// Click anywhere outside (or Escape) closes the panel — the root
// wraps button + panel, so the toggle itself keeps working.
diff --git a/components/home.tsx b/components/home.tsx
index de566d1..737516f 100644
--- a/components/home.tsx
+++ b/components/home.tsx
@@ -4,6 +4,7 @@ import { useEffect, useMemo, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { CornerDownLeftIcon, DumbbellIcon } from "lucide-react";
import { GraphView } from "@/components/graph-view";
+import { ChainsHome } from "@/components/chains-home";
import { SetupDialog } from "@/components/setup-dialog";
import { useStrongKeyless, useZeroKeys, ZeroKeysPill } from "@/components/setup-cta";
import { DictationBar, joinDictation, useDictation } from "@/components/dictation";
@@ -17,7 +18,16 @@ import type { TurnUsage } from "@/src/core";
* under the header and the new-session dock, both of which float over it
* with no gradient scrims. Sessions live in the toggleable sidebar.
*/
-export function Home({ initialConfigured }: { initialConfigured: boolean }) {
+export function Home({
+ initialConfigured,
+ rendering = true,
+}: {
+ initialConfigured: boolean;
+ /** GRAPH RENDERING off (owner, 2026-10-06): the home's main view is
+ * the chains view, not the canvas. Read server-side per navigation —
+ * changing the setting applies on the next visit home. */
+ rendering?: boolean;
+}) {
const router = useRouter();
const [topic, setTopic] = useState("");
/* The topic MIRROR ("nothing gets lost" round, 2026-10-01 — same
@@ -149,16 +159,28 @@ export function Home({ initialConfigured }: { initialConfigured: boolean }) {
never needs to receive events — the mask routes the pixels. */}
- {/* Graph — the main view, full-bleed (owner, graph-view round 1): no
- padding on any side; the canvas runs from the sidebar to the right
- edge and from the bottom to the top, under everything else. */}
+ {/* The main view — full-bleed (owner, graph-view round 1): no
+ padding on any side; it runs from the sidebar to the right edge
+ and from the bottom to the top, under everything else. With
+ rendering OFF (graph.rendering, owner 2026-10-06) it is the
+ CHAINS VIEW — the same table as the overlay, in flow; data,
+ extraction, and the pick flow are untouched. */}
- setChainSelectOpen(false)}
- />
+ {rendering ? (
+ setChainSelectOpen(false)}
+ />
+ ) : (
+ setChainSelectOpen(false)}
+ />
+ )}
{/* Header floats over the graph — pointer-events-none except the
@@ -185,7 +207,10 @@ export function Home({ initialConfigured }: { initialConfigured: boolean }) {
send consumes it). Practice mode with nothing picked
shows the pick HINT so the affordance is discoverable;
teaching mode with nothing picked renders nothing. */}
- {(practiceProps.length > 0 || mode === "practice") && (
+ {/* While PICKING in the chains home the chip row hides
+ (owner, 2026-10-06): the floating bar above the composer
+ already carries count + Clear + Done — one voice, not two. */}
+ {(practiceProps.length > 0 || mode === "practice") && !(rendering === false && chainSelectOpen) && (
{practiceProps.length > 0
? `${practiceProps.length} ${practiceProps.length === 1 ? "proposition" : "propositions"} selected`
- : "Pick propositions from the map"}
+ : rendering
+ ? "Pick propositions from the map"
+ : "Pick propositions"}
{practiceProps.length > 0 && (