diff --git a/README.md b/README.md index b2abae0..73cb0fe 100644 --- a/README.md +++ b/README.md @@ -156,7 +156,7 @@ data/ chats/ · graph/ (six ledgers + positions/source **The knowledge graph (two channels, one writer).** The write channel: the extraction pipeline — extractor (LLM: sees only the session, rendered as readable markdown by the shared `core/session-markdown.ts` engine — propositions as true statements, `follows`/`supports` edges with verbatim evidence; fully trusted, graph-blind) → auditor (LLM, an agentic loop that never sees the source: explores the existing graph through `find` / `list_sessions` / `list_propositions` tools, stamps each proposition with an identity verdict — link to an existing node (equivalence bias: paraphrases merge) or create — and may draw bridging edges between existing nodes and the new batch) → merger (`mergeFindings`, deterministic: applies the verdicts, link-appends phrasings/provenance, resolves and dedupes edges, checkpoints coverage) → layout settle. No quiz outcomes or understanding assessments live in the graph (2026-09-27 cleanup); practice (`/practice`) consumes the graph as feedback-only problem sets and writes nothing back. Triggered when a chat's **uncovered tokens** (message-view fingerprints, `coverage.jsonl`) cross `graph.extractionChunkTokens` (a settings knob, default 4096, floor 500 — read fresh per turn); failure checkpoints nothing, the next trigger re-runs. The same pipeline serves the **notes front door** (`POST /api/graph/import`: any `*.md`/`*.txt` becomes a source; re-imports are idempotent by path hash). The read channel: `GET /api/graph` derives the snapshot from the ledgers — a pure read; the home view is a hand-rolled Canvas 2D renderer (one rAF loop, dirty-flag idle, d3-zoom camera, zoom-gated labels, cluster hulls) over a d3-force layout, with server-persisted render settings and drag-committed positions. Clusters are **birth-session groupings**: a node joins the cluster of the session that birthed it (a chat, or an imported notes file as a file-kind session), derived at read — no embedding pass, no naming pass. The teacher reaches the graph on its own terms through the gated prober tools (`list_sessions` / `list_propositions`, the `agent.graphTools` setting) — there is no automatic context injection. -**Practice (problem sets).** The graph's `follows`-edges become reasoning chains; the `/practice` creation dialog picks a birth-session cluster (the graph view's own groups) and freezes its propositions + chains into a self-contained `ProblemSet`. Three named-level axes steer generation (ladder rung 0–4, cue distance 0–2, Theory|Application — never a number to interpolate; defaults Direct exercise · Adjacent context · Theory), plus an optional free-form note the agents weigh but need not follow. A partitioner agent covers every chain exactly once, one generator per partition writes problems against the owner's problem-engineering framework (commit-before-pose: statement + solution + rubric together), and grading is **strengthening, not assessment** — one attempt per problem with its own submit button, instant solution/rubric reveal, then a score 0–10 with brief reasoning landing live; nothing ever written back to the graph. Runs are registry-backed background jobs on the activity dock (no steer, never wakes the teacher); sets live in `data/practice`, crash-swept on boot. +**Practice (problem sets).** The graph's `follows`-edges become reasoning chains; the `/practice` creation dialog picks a birth-session cluster (the graph view's own groups) and freezes its propositions + chains into a self-contained `ProblemSet`. Three named-level axes steer generation (ladder rung 0–4, cue distance 0–2, Theory|Application — never a number to interpolate; defaults Direct exercise · Adjacent context · Theory), plus an optional free-form note the agents weigh but need not follow. Creation has two entries: the cluster picker on `/practice`, or the graph view's chain table — every reasoning chain in one selectable overlay whose pinned preview shows the exact `N propositions → M chains` the agents will receive — handing a free-form node selection to the same dialog. A partitioner agent covers every chain exactly once, one generator per partition writes problems against the owner's problem-engineering framework (commit-before-pose: statement + solution + rubric together), and grading is **strengthening, not assessment** — one attempt per problem with its own submit button, instant solution/rubric reveal, then a score 0–10 with brief reasoning landing live; nothing ever written back to the graph. Runs are registry-backed background jobs on the activity dock (no steer, never wakes the teacher); sets live in `data/practice`, crash-swept on boot. **Dictation.** Optional speech-to-text: the server mints short-lived Deepgram tokens; audio streams browser→Deepgram over WebSocket, never through the server. diff --git a/components/chain-table.tsx b/components/chain-table.tsx new file mode 100644 index 0000000..7f20f64 --- /dev/null +++ b/components/chain-table.tsx @@ -0,0 +1,519 @@ +"use client"; + +/** + * THE CHAIN TABLE (chains overlay, owner-approved plan 2026-09-29): every + * reasoning chain of the graph in one selectable table — the graph view's + * practice entry for FREE-FORM selections, complementing the cluster + * picker (which stays the /practice page's only creation mode). Read-only + * toward the graph: it renders the snapshot it is handed, selects node + * ids, and hands the selection to the practice dialog's CUSTOM mode. + * + * MOUNT IDIOM (the session-overlay precedent, session-overlay.tsx): the + * overlay is ABSOLUTE INSIDE the graph area (never a body portal) — + * inset-0, z-40, bg-background/80 + backdrop blur — so the app chrome + * (drag bar, sidebar) stays sharp and reachable; Esc and backdrop clicks + * dismiss. + * + * STRUCTURE: a fuzzy search box (names, aliases, statements — core's + * fuzzyScore) at the very top filters the chain list only; the SELECTED + * section is pinned beneath it, never filtered — N propositions → M + * chains via chainsForSelection over the selection (the agents' ACTUAL + * input: a selected premise without its conclusion shows as the singleton + * it becomes). Chain rows (the core formatter's name line) expand to + * indented member proposition rows; each member expands its full + * statement inline. Columns: checkbox | chain/proposition tree | birth + * session(s) | created. NO understanding-levels column (explicitly not + * built — no dead markup). + * + * Named neutrally (ChainTable, not Practice*) — the table is a + * graph-surface; only the Generate handoff knows about practice. + */ + +import { useEffect, useMemo, useState } from "react"; +import { ListTreeIcon, SearchIcon, XIcon } from "lucide-react"; +import { chainsForSelection, formatChainLine, fuzzyScore } from "@/src/core"; +import type { GraphEdge, GraphNode } from "@/src/core"; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@/components/ui/collapsible"; +import { PracticeDialog } from "@/components/practice-dialog"; + +/** The grid every row shares: checkbox | tree | birth | date. */ +const ROW_COLS = "grid-cols-[1.5rem_minmax(0,1fr)_9rem_5.5rem]"; + +/** A node's birth-session label, mirroring the clusters' honesty join: + * the first surviving provenance point's chatId → chat title / notes + * file path, null when unnameable (rendered muted "unlabeled", never + * guessed). Inlined here (one line) because session-clusters.ts is not + * barrel-exported — the graph stream owns it; the shape is contract- + * pinned (provenance[0] is the birth by the getGraph fold order). */ +function birthLabelOf( + node: Pick, + sessions: Record, + fileSources: Record, +): string | null { + const birth = node.provenance[0]?.chatId ?? null; + if (birth === null) return null; + return sessions[birth] ?? fileSources[birth] ?? null; +} + +/** One chain's birth labels — unique over its members, sorted by session + * id (deterministic); a member with no nameable birth contributes a + * muted "unlabeled" entry (honest, per the clusters law). */ +function chainBirthLabels( + chain: { premises: string[]; conclusion: string }, + byId: Map, + sessions: Record, + fileSources: Record, +): string[] { + const labels = new Map(); + for (const id of [...chain.premises, chain.conclusion]) { + const node = byId.get(id); + if (!node) continue; + const birth = node.provenance[0]?.chatId ?? null; + if (birth === null) { + labels.set("\0unlabeled", "unlabeled"); + continue; + } + labels.set(birth, sessions[birth] ?? fileSources[birth] ?? "unlabeled"); + } + return [...labels.entries()] + .sort((a, b) => (a[0] < b[0] ? -1 : 1)) + .map(([, label]) => label); +} + +/** The checkbox — checked/indeterminate (a chain with SOME members + * selected); clicks stop at the checkbox so they never toggle the + * surrounding Collapsible row. */ +function RowCheckbox({ + checked, + indeterminate = false, + onToggle, + testid, + label, +}: { + checked: boolean; + indeterminate?: boolean; + onToggle: (next: boolean) => void; + testid: string; + label: string; +}) { + return ( + { + if (el) el.indeterminate = indeterminate; + }} + data-testid={testid} + aria-label={label} + onClick={(e) => e.stopPropagation()} + onChange={(e) => onToggle(e.target.checked)} + className="size-3.5 accent-primary" + /> + ); +} + +/** The table's pure body — exported for SSR pins (the render-suite + * discipline). All state (query, selection) arrives via props; the + * overlay wrapper below owns it. */ +export function ChainTableBody({ + nodes, + edges, + sessions, + fileSources, + query, + onQuery, + selection, + onToggleNode, + onToggleChain, + onClear, + onGenerate, + onClose, +}: { + nodes: GraphNode[]; + edges: GraphEdge[]; + sessions: Record; + fileSources: Record; + query: string; + onQuery: (v: string) => void; + selection: ReadonlySet; + onToggleNode: (id: string, next: boolean) => void; + onToggleChain: (ids: string[], next: boolean) => void; + onClear: () => void; + onGenerate: () => void; + onClose: () => void; +}) { + const byId = useMemo(() => new Map(nodes.map((n) => [n.id, n])), [nodes]); + const chains = useMemo( + () => chainsForSelection(nodes, edges), + [nodes, edges], + ); + const nameOf = (id: string) => byId.get(id)?.name ?? id; + + // The fuzzy filter: a chain is visible when ANY member matches the + // query on name, aliases, or statement; chains rank by their best + // member's score (desc), ties by chain id — deterministic. + const visibleChains = useMemo(() => { + const scored = chains.map((chain) => { + const memberIds = [...chain.premises, chain.conclusion]; + // Empty query matches everything at score 0; a non-empty query + // starts at null (no match) and takes the best member score. + let best: number | null = query.trim() === "" ? 0 : null; + for (const id of memberIds) { + const node = byId.get(id); + if (!node) continue; + const s = fuzzyScore(query, node.name, node.statement, ...node.aliases); + if (s === null) continue; + if (best === null || s > best) best = s; + } + return { chain, best }; + }); + return scored + .filter((row): row is { chain: (typeof chains)[number]; best: number } => row.best !== null) + .sort((a, b) => b.best - a.best || (a.chain.id < b.chain.id ? -1 : 1)) + .map(({ chain }) => chain); + }, [chains, byId, query]); + + // The agents' ACTUAL input: chainsForSelection over the selection — + // a selected premise without its conclusion shows as the singleton it + // becomes. This is the pinned preview, never search-filtered. + const derived = useMemo(() => { + const selectedNodes = [...selection] + .map((id) => byId.get(id)) + .filter((n): n is GraphNode => n !== undefined); + return chainsForSelection(selectedNodes, edges); + }, [selection, byId, edges]); + const propositions = useMemo(() => { + const known = new Set([...selection].filter((id) => byId.has(id))); + return known; + }, [selection, byId]); + + return ( +
+ {/* Header: title + count + close (the session-overlay chrome). */} +
+ + Chains + + {chains.length} {chains.length === 1 ? "chain" : "chains"} ·{" "} + {nodes.length} propositions + +
+ +
+ + {/* The fuzzy search — very top; filters the LIST only. */} +
+
+ + onQuery(e.target.value)} + placeholder="Search propositions…" + aria-label="Search propositions" + spellCheck={false} + className="h-8 w-full rounded-lg border bg-background pl-8 pr-3 text-xs outline-none placeholder:text-muted-foreground focus:border-ring" + /> +
+
+ + {/* The SELECTED section — pinned, always visible, never filtered. */} +
+ {propositions.size === 0 ? ( +
+ Nothing selected — check whole chains or single propositions below. +
+ ) : ( +
+
+
+ {propositions.size}{" "} + {propositions.size === 1 ? "proposition" : "propositions"} →{" "} + {derived.length} {derived.length === 1 ? "chain" : "chains"} +
+
    + {derived.map((chain) => ( +
  • + {formatChainLine( + chain, + Object.fromEntries( + [...byId.entries()].map(([id, n]) => [ + id, + { name: n.name }, + ]), + ), + )} +
  • + ))} +
+
+
+ + +
+
+ )} +
+ + {/* Column headers + the chain rows (scrollable). */} +
+
+ + Chain + Born in + Created +
+ {visibleChains.length === 0 && ( +
+ No matching chains. +
+ )} +
+ {visibleChains.map((chain, index) => { + const memberIds = [...chain.premises, chain.conclusion]; + const selectedCount = memberIds.filter((id) => + selection.has(id), + ).length; + const conclusion = byId.get(chain.conclusion); + return ( + + + 0 + } + indeterminate={ + selectedCount > 0 && selectedCount < memberIds.length + } + onToggle={(next) => onToggleChain(memberIds, next)} + testid={`chain-checkbox-${chain.id}`} + label={`Select chain ${nameOf(chain.conclusion)}`} + /> + + {formatChainLine( + chain, + Object.fromEntries( + [...byId.entries()].map(([id, n]) => [ + id, + { name: n.name }, + ]), + ), + )} + + + {chainBirthLabels(chain, byId, sessions, fileSources) + .map((label) => + label === "unlabeled" ? "unlabeled" : label, + ) + .join(" · ")} + + + {conclusion ? conclusion.createdAt.slice(0, 10) : ""} + + + +
+ {memberIds.map((id) => { + const node = byId.get(id); + if (!node) return null; + const isConclusion = id === chain.conclusion; + return ( + + + + onToggleNode(id, next)} + testid={`prop-checkbox-${id}`} + label={`Select proposition ${node.name}`} + /> + + + {isConclusion && ( + + → + + )} + {node.name} + + + + + +
+ {node.statement} +
+
+
+ ); + })} +
+
+
+ ); + })} +
+
+
+ ); +} + +/** The graph view's overlay wrapper: owns query + selection + the custom + * practice dialog; mounts ABSOLUTE INSIDE the graph area (the + * session-overlay idiom — never a body portal). Read-only toward the + * graph — selection lives in component state only. */ +export function ChainTableOverlay({ + open, + nodes, + edges, + sessions, + fileSources, + onClose, +}: { + open: boolean; + nodes: GraphNode[]; + edges: GraphEdge[]; + sessions: Record; + fileSources: Record; + onClose: () => void; +}) { + const [query, setQuery] = useState(""); + const [selection, setSelection] = useState>( + () => new Set(), + ); + const [dialogOpen, setDialogOpen] = useState(false); + + // Esc dismisses (unless the practice dialog has focus — its own Esc + // owns that layer; both listen on window, so the guard is explicit). + useEffect(() => { + if (!open || dialogOpen) return; + const onKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape") onClose(); + }; + window.addEventListener("keydown", onKeyDown); + return () => window.removeEventListener("keydown", onKeyDown); + }, [open, dialogOpen, onClose]); + + if (!open) return null; + + const byId = new Map(nodes.map((n) => [n.id, n])); + const selectedNodes = [...selection] + .map((id) => byId.get(id)) + .filter((n): n is GraphNode => n !== undefined); + const derived = chainsForSelection(selectedNodes, edges); + const nodeIds = [...selection].sort(); + + return ( + <> +
+
e.stopPropagation()} + > + + setSelection((prev) => { + const s = new Set(prev); + if (next) s.add(id); + else s.delete(id); + return s; + }) + } + onToggleChain={(ids, next) => + setSelection((prev) => { + const s = new Set(prev); + for (const id of ids) { + if (next) s.add(id); + else s.delete(id); + } + return s; + }) + } + onClear={() => setSelection(new Set())} + onGenerate={() => setDialogOpen(true)} + onClose={onClose} + /> +
+
+ {dialogOpen && ( + setDialogOpen(false)} + /> + )} + + ); +} diff --git a/components/graph-view.tsx b/components/graph-view.tsx index c1910a0..e33ba99 100644 --- a/components/graph-view.tsx +++ b/components/graph-view.tsx @@ -66,13 +66,14 @@ import { useCallback, useDeferredValue, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import Link from "next/link"; -import { FileTextIcon, Loader2Icon, SearchIcon, SettingsIcon, UploadIcon, WaypointsIcon } from "lucide-react"; +import { FileTextIcon, ListTreeIcon, Loader2Icon, SearchIcon, SettingsIcon, UploadIcon, WaypointsIcon } from "lucide-react"; import { incrementalLayout, type GraphCostSummary, type GraphNode, type GraphSnapshot } from "@/src/core"; import { GraphCanvasRenderer, type GraphRenderSettings } from "./graph-canvas.ts"; import { usePopoverDismiss } from "@/components/popover-dismiss"; import { useActivity } from "@/components/activity-dock"; import { MenuShell } from "@/components/menu-shell.tsx"; import { DeleteNodeDialog } from "@/components/delete-node-dialog.tsx"; +import { ChainTableOverlay } from "@/components/chain-table.tsx"; import { Hint } from "@/components/ui/hint"; import { ARROW_LENGTH_LADDER, @@ -860,6 +861,7 @@ export function GraphChromeRow({ onImportFiles, settings, changeSettings, + onOpenChains, }: { isEmpty: boolean; query: string; @@ -873,6 +875,9 @@ export function GraphChromeRow({ onImportFiles: (files: FileList | null) => void; settings: GraphRenderSettings; changeSettings: (update: (cur: RenderSettings) => RenderSettings) => void; + /** Opens the chain table overlay (the chains-overlay plan, + * 2026-09-29). */ + onOpenChains: () => void; }) { return ( <> @@ -902,6 +907,24 @@ export function GraphChromeRow({ )}
)} + {/* The chain table (chains overlay, owner plan 2026-09-29) — the + graph's free-form practice entry. Own container (36px band), + between the search and Import. */} + {!isEmpty && ( +
+ + + +
+ )} {/* Import notes — its own container (36px, header band); ALWAYS rendered (the empty-graph ruling above — the entry point). */}
@@ -1050,6 +1073,9 @@ export function GraphView() { // Fetch the derived graph. Re-runs when a background pipeline run // finishes (refreshTick) — its merges just landed in the ledger. const [refreshTick, setRefreshTick] = useState(0); + // The chain table overlay (chains-overlay plan, 2026-09-29) — + // component-local: a browsing surface, not graph state. + const [chainsOpen, setChainsOpen] = useState(false); useEffect(() => { let cancelled = false; fetch("/api/graph") @@ -1529,6 +1555,20 @@ export function GraphView() { }} /> + {/* The chain table overlay (chains-overlay plan, 2026-09-29): + ABSOLUTE INSIDE the graph area (the session-overlay idiom — + never a body portal) so the app chrome stays sharp and + reachable; the overlay itself is read-only toward the graph + and owns its selection + the custom practice dialog. */} + setChainsOpen(false)} + /> + {/* Legend + filters: search box and per-state toggle rows. */} {/* Top-right controls — separate floating containers (owner, graph-view round 2). Row order (2026-09-17): search, @@ -1576,6 +1616,7 @@ export function GraphView() { onImportFiles={onImportFiles} settings={settings} changeSettings={changeSettings} + onOpenChains={() => setChainsOpen(true)} />
diff --git a/components/practice-dialog.tsx b/components/practice-dialog.tsx index 1133f6d..1b76d26 100644 --- a/components/practice-dialog.tsx +++ b/components/practice-dialog.tsx @@ -33,364 +33,434 @@ import type { GraphClusterGroup, PracticeParams } from "@/src/core"; /** The creation defaults (owner, 2026-09-29): rung 2 Direct exercise, * cue 1 Adjacent context, Theory. */ export const DEFAULT_PRACTICE_PARAMS: PracticeParams = { - ladderRung: 2, - cueDistance: 1, - presentation: "theory", + ladderRung: 2, + cueDistance: 1, + presentation: "theory", }; +/** The two creation modes (chains overlay, owner plan 2026-09-29): + * "cluster" — the /practice page's picker (stays cluster-ONLY, owner + * ruling 2026-09-29); "custom" — the graph view's chain table hands in + * a free-form node selection; no picker, a selection summary instead. */ +export type PracticeDialogMode = "cluster" | "custom"; + +/** The custom mode's handoff: the flat node-id selection + the derived + * shape (chainsForSelection over the selection — the overlay computes + * it for its pinned preview and passes the same numbers here). */ +export interface PracticeSelection { + nodeIds: string[]; + propositions: number; + chains: number; +} + export interface PracticeFailure { - status: number; - error: string; - code?: string; + status: number; + error: string; + code?: string; } /** The dialog's pure body — exported for SSR pins (the delete-session / * settings body pattern). The wrapper owns state + fetch + portal. */ export function PracticeDialogBody({ - params, - onParam, - note, - onNote, - clusters, - selectedKey, - onSelect, - busy, - failure, - onOpenSettings, - onClose, - onRun, + mode = "cluster", + params, + onParam, + note, + onNote, + clusters, + selectedKey, + onSelect, + selection, + busy, + failure, + onOpenSettings, + onClose, + onRun, }: { - params: PracticeParams; - onParam: (patch: Partial) => void; - /** The learner's free-form note (owner, 2026-09-29) — optional, - * typing-only; carried to the partitioner + generators as additional - * input they weigh but need not follow. */ - note: string; - onNote: (note: string) => void; - clusters: GraphClusterGroup[]; - selectedKey: string | null; - onSelect: (key: string) => void; - busy: boolean; - failure: PracticeFailure | null; - onOpenSettings: () => void; - onClose: () => void; - onRun: () => void; + mode?: PracticeDialogMode; + params: PracticeParams; + onParam: (patch: Partial) => void; + /** The learner's free-form note (owner, 2026-09-29) — optional, + * typing-only; carried to the partitioner + generators as additional + * input they weigh but need not follow. */ + note: string; + onNote: (note: string) => void; + clusters: GraphClusterGroup[]; + selectedKey: string | null; + onSelect: (key: string) => void; + /** The custom mode's handoff (chain-table selections). */ + selection?: PracticeSelection; + busy: boolean; + failure: PracticeFailure | null; + onOpenSettings: () => void; + onClose: () => void; + onRun: () => void; }) { - const canRun = !busy && selectedKey !== null; - return ( -
e.stopPropagation()} - className="flex max-h-[85vh] w-[32rem] max-w-[calc(100vw-2rem)] flex-col rounded-xl border bg-card p-5 shadow-xl" - > -

New problem set

+ const canRun = !busy && (mode === "custom" ? (selection?.nodeIds.length ?? 0) > 0 : selectedKey !== null); + return ( +
e.stopPropagation()} + className="flex max-h-[85vh] w-[32rem] max-w-[calc(100vw-2rem)] flex-col rounded-xl border bg-card p-5 shadow-xl" + > +

New problem set

- {/* The cluster: a birth-session group of the graph, resolved to - node ids on create (graph-domain, owner 2026-09-29). */} -
- - Cluster - -
- {clusters.map((cluster) => ( - - ))} - {clusters.length === 0 && ( -
- No clusters yet — a cluster appears once a session births its - first proposition. -
- )} -
-
+ {/* The cluster picker — CLUSTER mode only (custom mode replaces + it with the selection summary below): a birth-session group of + the graph, resolved to node ids on create (graph-domain, owner + 2026-09-29). */} + {mode === "cluster" && ( +
+ + Cluster + +
+ {clusters.map((cluster) => ( + + ))} + {clusters.length === 0 && ( +
+ No clusters yet — a cluster appears once a session births its + first proposition. +
+ )} +
+
+ )} - {/* The three parameters — named levels, never a number to + {/* CUSTOM mode: no picker — the chain table already chose. The + dated default title (the pipeline's own `Practice — date`, + stamped server-side when no title is posted) + the derived + shape of the handoff. */} + {mode === "custom" && ( +
+ + Selection + +
+
Practice — {new Date().toISOString().slice(0, 10)}
+
+ {selection?.propositions ?? 0} {selection?.propositions === 1 ? "proposition" : "propositions"} →{" "} + {selection?.chains ?? 0} {selection?.chains === 1 ? "chain" : "chains"} +
+
+
+ )} + + {/* The three parameters — named levels, never a number to interpolate (owner, 2026-09-29). Stepped sliders snap to the detents; each shows the level's NAME + its one-line definition; presentation is a two-segment toggle. */} -
-
- - Ladder - 0 · 1 · 2 · 3 · 4 - - - onParam({ - ladderRung: Number( - e.target.value, - ) as PracticeParams["ladderRung"], - }) - } - className="h-1.5 w-full accent-primary" - /> - - - {LADDER_RUNGS[params.ladderRung].name} - - - {" "} - — {LADDER_RUNGS[params.ladderRung].definition} - - -
-
- - Cue distance - 0 · 1 · 2 - - - onParam({ - cueDistance: Number( - e.target.value, - ) as PracticeParams["cueDistance"], - }) - } - className="h-1.5 w-full accent-primary" - /> - - - {CUE_LEVELS[params.cueDistance].name} - - - {" "} - — {CUE_LEVELS[params.cueDistance].definition} - - -
-
- - Presentation - -
- {(["theory", "application"] as const).map((pres) => ( - - ))} -
- - {PRESENTATIONS[params.presentation].definition} - -
- {/* The learner's free-form note — the last word before create: +
+
+ + Ladder + 0 · 1 · 2 · 3 · 4 + + + onParam({ + ladderRung: Number( + e.target.value, + ) as PracticeParams["ladderRung"], + }) + } + className="h-1.5 w-full accent-primary" + /> + + + {LADDER_RUNGS[params.ladderRung].name} + + + {" "} + — {LADDER_RUNGS[params.ladderRung].definition} + + +
+
+ + Cue distance + 0 · 1 · 2 + + + onParam({ + cueDistance: Number( + e.target.value, + ) as PracticeParams["cueDistance"], + }) + } + className="h-1.5 w-full accent-primary" + /> + + + {CUE_LEVELS[params.cueDistance].name} + + + {" "} + — {CUE_LEVELS[params.cueDistance].definition} + + +
+
+ + Presentation + +
+ {(["theory", "application"] as const).map((pres) => ( + + ))} +
+ + {PRESENTATIONS[params.presentation].definition} + +
+ {/* The learner's free-form note — the last word before create: wishes for the problems, weighed by the agents, nothing owed. */} -
- - Anything else? (optional) - -