Pivot to text-first column-stack workspace + merge-with-review across AI artifacts

Workspace
- Pivot from "set of open panes" to a Finder-style miller column stack:
  TopBar / LeftSidebar / [section → entity → entity ...] / pinned text editor.
  openPanesStore is now an ordered Column[] with pushFrom / closeFrom /
  setStack; only one top-level section is rooted at a time.
- New entity column panes: Term, Block, Association, Constraint,
  Requirement, Finding. Click-through navigation truncates deeper
  columns automatically.
- LeftSidebar surfaces a pending-count chip per section (single-glance
  navigation cue) and spins its analyze ↻ via SVG Spinner whenever the
  LLM is working — including server-initiated runs caught by the runs
  poll, not just user-triggered ones.

Analyze pipeline + persistence
- Unified `concepts` pass (taxonomy + glossary in one LLM call) replaces
  the two-pass setup. Server still accepts ?section=taxonomy|glossary
  and normalizes them for back-compat.
- model / requirements / detection (assumptions, risks, inconsistencies)
  + cross-layer validation rules (X1–X4: stale term link, unlinked
  formalism, undefined linked term, prose-only term).
- Persistence: NarrativeDocument, ModelSnapshot, ChangelogEntry,
  TaxonomyTerm, RequirementEntry, Finding, AnalysisRun. Re-runs MERGE
  instead of replace: gentle update on existing items, suggested on new,
  deprecated on missing — same idiom for every artifact kind. User pins
  preserve "kept" decisions across re-analyses.
- Migrations: pivot_text_first, add_requirement_linked_term,
  term_review_state, review_state_for_reqs_and_findings,
  add_term_definition_pinned.

Concept ↔ ontology integration
- linkedTermId on Block / Association / Constraint / Requirement.
  PromoteToolbar lets the user formalize a concept inline: + Block /
  + Association / + Constraint / + Requirement, all routed through
  applyOps so undo/redo and SSE work for free.
- decideElement op for in-canvas keep/discard on review-pending model
  elements.

User-authored definitions
- TermColumn definition is click-to-edit. Save (Cmd-Enter / blur),
  Cancel (Esc), Reset to AI suggestion when pinned.
- definitionPinned flag on TaxonomyTerm: future Analyze runs leave the
  user's text alone. setTermDefinition repo function + POST
  /api/projects/[id]/terms/[termId]/definition endpoint.
- mergeTaxonomySuggestion + applyGlossaryDefinitions both pin-aware.

UX/UI
- StatusChip: single component for all state idioms (suggested,
  deprecated, accepted, dismissed, resolved, severity, validation code,
  confidence, warn). Replaces 5+ ad-hoc badge classes.
- PaneControls (PaneViewTabs + PaneFilterChip): separates view-mode
  toggles from filter chips so toggling Pending no longer flips you off
  the current view.
- PaneEmpty: unified empty-state with title + hint + action.
- PaneDrawer: collapsible groups for Pending / Discarded review; cards
  group as Kept (top) → Pending (bottom drawer) → Discarded (Findings
  only, hidden when empty). Restore action recovers dismissed/resolved
  findings.
- ConceptCard unifies Tree and A–Z views in Concepts; only Tree parents
  carry the chevron (no empty placeholder offset).
- Type + spacing tokens (--text-xs..xl, --space-1..6, --lh-tight/ui/
  prose, --radius-*) replace every ad-hoc value.
- Buttons standardized to body sans 500 (was a mishmash of mono / display).
- Card shells unified across Concepts / Requirements / Findings.

Cleanup
- Removed: LeftRail, FindingsPanel, IssuesPanel, SocratesDock,
  ProposalCard, SlashMenu, SlashExtension, slashSuggestion,
  CanvasHeader, TaxonomyPane, GlossaryPane, TermDetail (popover; now
  TermColumn).
- Section ids in openPanesStore: dropped taxonomy/glossary, added
  concepts. localStorage migration runs on hydrate.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-01 00:12:06 +02:00
parent 4e725c0b2b
commit b55425cc68
88 changed files with 10855 additions and 1768 deletions

View File

@@ -0,0 +1,33 @@
// Analyze endpoint. POST with optional ?section= to run one section, or no
// query param (or ?section=all) to run the full pipeline.
//
// Legacy section names "taxonomy" and "glossary" are accepted and mapped to
// the unified "concepts" pass (T3 of the integration plan). This keeps older
// clients and any external scripts working through one release.
import { NextResponse } from "next/server";
import { runAnalyze, type RunSection } from "../../../../../lib/llm/analyze/runAll";
import { normalizeAnalysisSection } from "../../../../../lib/db/repo";
interface RouteContext {
params: Promise<{ projectId: string }>;
}
export async function POST(req: Request, ctx: RouteContext) {
const { projectId } = await ctx.params;
const url = new URL(req.url);
const sectionRaw = (url.searchParams.get("section") ?? "all").toLowerCase();
let section: RunSection;
try {
section = normalizeAnalysisSection(sectionRaw) as RunSection;
} catch {
return NextResponse.json({ error: `Invalid section: ${sectionRaw}` }, { status: 400 });
}
try {
const result = await runAnalyze(projectId, section);
return NextResponse.json(result);
} catch (err) {
console.error("[analyze] route failed:", err);
return NextResponse.json({ error: (err as Error).message }, { status: 500 });
}
}

View File

@@ -0,0 +1,31 @@
// Narrative document save endpoint. The TipTap editor PUTs the current
// ProseMirror JSON here on debounce. Source of truth after the pivot.
import { NextResponse } from "next/server";
import { loadDocument, saveDocument } from "../../../../../lib/db/repo";
interface RouteContext {
params: Promise<{ projectId: string }>;
}
export async function GET(_req: Request, ctx: RouteContext) {
const { projectId } = await ctx.params;
const doc = await loadDocument(projectId);
if (!doc) return NextResponse.json({ doc: null, version: 0 });
return NextResponse.json({ doc: doc.doc, version: doc.version, updatedAt: doc.updatedAt });
}
export async function PUT(req: Request, ctx: RouteContext) {
const { projectId } = await ctx.params;
let body: unknown;
try {
body = await req.json();
} catch {
return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
}
if (!body || typeof body !== "object" || !("doc" in body)) {
return NextResponse.json({ error: "Missing `doc` field" }, { status: 400 });
}
const stored = await saveDocument(projectId, (body as { doc: unknown }).doc);
return NextResponse.json({ version: stored.version, updatedAt: stored.updatedAt });
}

View File

@@ -0,0 +1,51 @@
// Per-finding keep/discard/resolve/restore decision.
//
// Body: { decision: "keep" | "discard" | "resolve" | "restore" }
// keep → status="accepted" (visible). If was "deprecated", also pinned.
// discard → status="dismissed". Persists; future detections skip it.
// resolve → status="resolved". Like discard but signals "I fixed it."
// restore → status="accepted", pinned cleared. Recovers a dismissed/
// resolved finding from the Discarded drawer.
import { NextResponse } from "next/server";
import {
setFindingDecision,
restoreFinding,
type FindingDecision,
} from "../../../../../../../lib/db/repo";
interface RouteContext {
params: Promise<{ projectId: string; findingId: string }>;
}
export async function POST(req: Request, ctx: RouteContext) {
const { findingId } = await ctx.params;
let body: { decision?: string };
try {
body = await req.json();
} catch {
return NextResponse.json({ error: "invalid json body" }, { status: 400 });
}
const decision = body.decision;
if (
decision !== "keep" &&
decision !== "discard" &&
decision !== "resolve" &&
decision !== "restore"
) {
return NextResponse.json(
{ error: "decision must be 'keep', 'discard', 'resolve', or 'restore'" },
{ status: 400 }
);
}
try {
const finding =
decision === "restore"
? await restoreFinding(findingId)
: await setFindingDecision(findingId, decision as FindingDecision);
return NextResponse.json({ finding });
} catch (err) {
console.error("[finding/decision] failed:", err);
return NextResponse.json({ error: (err as Error).message }, { status: 500 });
}
}

View File

@@ -0,0 +1,81 @@
// Per-finding contextual Socrates thread.
// GET → loads existing messages.
// POST → sends a user turn; if the thread is empty, primes it with the
// finding text so Socrates' opening turn engages with the finding.
import { NextResponse } from "next/server";
import { prisma } from "../../../../../../../lib/db/client";
import { loadProject } from "../../../../../../../lib/db/repo";
import { validate } from "../../../../../../../lib/sysml/validate";
import {
getOrCreateAnchoredThread,
listAnchoredThreadMessages,
sendUserTurn,
} from "../../../../../../../lib/llm/socrates";
interface RouteContext {
params: Promise<{ projectId: string; findingId: string }>;
}
export async function GET(_req: Request, ctx: RouteContext) {
const { projectId, findingId } = await ctx.params;
const finding = await prisma.finding.findUnique({ where: { id: findingId } });
if (!finding || finding.projectId !== projectId) {
return NextResponse.json({ error: "finding not found" }, { status: 404 });
}
const threadId = await getOrCreateAnchoredThread(projectId, `finding:${findingId}`, finding.text);
const messages = await listAnchoredThreadMessages(threadId);
return NextResponse.json({ threadId, finding: { id: finding.id, text: finding.text, kind: finding.kind }, messages });
}
export async function POST(req: Request, ctx: RouteContext) {
const { projectId, findingId } = await ctx.params;
let body: { text?: string } = {};
try {
body = await req.json();
} catch {
return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
}
const finding = await prisma.finding.findUnique({ where: { id: findingId } });
if (!finding || finding.projectId !== projectId) {
return NextResponse.json({ error: "finding not found" }, { status: 404 });
}
const threadId = await getOrCreateAnchoredThread(projectId, `finding:${findingId}`, finding.text);
// If the thread is empty, prime with the finding text as the opening user
// turn so Socrates engages with the right thing.
const existing = await listAnchoredThreadMessages(threadId);
let userText = (body.text ?? "").trim();
if (existing.length === 0 && !userText) {
userText = `Discuss this ${finding.kind}: "${finding.text}"`;
}
const { model } = await loadProject(projectId);
const issues = validate(model);
const result = await sendUserTurn({ threadId, model, issues, userText });
return NextResponse.json({ threadId, ...result });
}
export async function PATCH(req: Request, ctx: RouteContext) {
// Resolve / dismiss the finding from the contextual thread.
const { projectId, findingId } = await ctx.params;
let body: { status?: string } = {};
try {
body = await req.json();
} catch {
return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
}
const status = body.status;
if (status !== "resolved" && status !== "dismissed" && status !== "open") {
return NextResponse.json({ error: "invalid status" }, { status: 400 });
}
const finding = await prisma.finding.findUnique({ where: { id: findingId } });
if (!finding || finding.projectId !== projectId) {
return NextResponse.json({ error: "finding not found" }, { status: 404 });
}
await prisma.finding.update({ where: { id: findingId }, data: { status } });
return NextResponse.json({ ok: true, status });
}

View File

@@ -1,27 +1,51 @@
// GET /api/projects/[id]/findings — returns the project's open findings
// POST /api/projects/[id]/findings — runs detection, persists, returns the new set
// GET /api/projects/[id]/findings — returns the project's currently visible
// findings (suggested + accepted + deprecated)
// POST /api/projects/[id]/findings — runs detection, MERGES (no wipe), returns
// the new set. Suggestions and deprecations
// await user keep/discard via the
// /findings/[findingId]/decision endpoint.
import { NextResponse } from "next/server";
import { loadProject, listOpenFindings, replaceFindings } from "../../../../../lib/db/repo";
import {
loadProject,
listVisibleFindings,
listDiscardedFindings,
mergeFindingsSuggestion,
} from "../../../../../lib/db/repo";
import { detectFindings } from "../../../../../lib/llm/detect";
export async function GET(_req: Request, { params }: { params: Promise<{ projectId: string }> }) {
function toApi(f: Awaited<ReturnType<typeof listVisibleFindings>>[number]) {
return {
id: f.id,
kind: f.kind,
text: f.text,
linkedElementIds: f.linkedElementIds,
confidence: f.confidence,
severity: f.severity,
validationCode: f.validationCode,
status: f.status,
pinned: f.pinned,
modelVersion: f.modelVersion,
provider: f.provider,
llmModel: f.llmModel,
};
}
export async function GET(req: Request, { params }: { params: Promise<{ projectId: string }> }) {
const { projectId } = await params;
const findings = await listOpenFindings(projectId);
return NextResponse.json({
findings: findings.map(f => ({
id: f.id,
kind: f.kind,
text: f.text,
linkedElementIds: f.linkedElementIds,
confidence: f.confidence,
severity: f.severity,
validationCode: f.validationCode,
modelVersion: f.modelVersion,
provider: f.provider,
llmModel: f.llmModel,
})),
});
// ?include=all merges in dismissed + resolved so the FindingsPane Discarded
// drawer can render them. Default stays visible-only for back-compat.
const url = new URL(req.url);
const include = (url.searchParams.get("include") ?? "").toLowerCase();
if (include === "all") {
const [visible, discarded] = await Promise.all([
listVisibleFindings(projectId),
listDiscardedFindings(projectId),
]);
return NextResponse.json({ findings: [...visible, ...discarded].map(toApi) });
}
const findings = await listVisibleFindings(projectId);
return NextResponse.json({ findings: findings.map(toApi) });
}
export async function POST(_req: Request, { params }: { params: Promise<{ projectId: string }> }) {
@@ -29,21 +53,16 @@ export async function POST(_req: Request, { params }: { params: Promise<{ projec
try {
const { model, version } = await loadProject(projectId);
const result = await detectFindings(model);
const stored = await replaceFindings(projectId, result.findings, version, result.provider, result.model);
const stored = await mergeFindingsSuggestion(
projectId,
result.findings,
version,
result.provider,
result.model
);
return NextResponse.json({
findings: stored.map(f => ({
id: f.id,
kind: f.kind,
text: f.text,
linkedElementIds: f.linkedElementIds,
confidence: f.confidence,
severity: f.severity,
validationCode: f.validationCode,
modelVersion: f.modelVersion,
provider: f.provider,
llmModel: f.llmModel,
})),
findings: stored.map(toApi),
meta: {
provider: result.provider,
model: result.model,

View File

@@ -0,0 +1,35 @@
// Per-requirement keep/discard decision. Mirrors the term decision endpoint
// (see /api/projects/[id]/terms/[termId]/decision).
//
// Body: { decision: "keep" | "discard" }
import { NextResponse } from "next/server";
import { setRequirementDecision, type RequirementDecision } from "../../../../../../../lib/db/repo";
interface RouteContext {
params: Promise<{ projectId: string; reqId: string }>;
}
export async function POST(req: Request, ctx: RouteContext) {
const { reqId } = await ctx.params;
let body: { decision?: string };
try {
body = await req.json();
} catch {
return NextResponse.json({ error: "invalid json body" }, { status: 400 });
}
const decision = body.decision;
if (decision !== "keep" && decision !== "discard") {
return NextResponse.json(
{ error: "decision must be 'keep' or 'discard'" },
{ status: 400 }
);
}
try {
const requirement = await setRequirementDecision(reqId, decision as RequirementDecision);
return NextResponse.json({ requirement });
} catch (err) {
console.error("[requirement/decision] failed:", err);
return NextResponse.json({ error: (err as Error).message }, { status: 500 });
}
}

View File

@@ -0,0 +1,12 @@
import { NextResponse } from "next/server";
import { listRequirements } from "../../../../../lib/db/repo";
interface RouteContext {
params: Promise<{ projectId: string }>;
}
export async function GET(_req: Request, ctx: RouteContext) {
const { projectId } = await ctx.params;
const reqs = await listRequirements(projectId);
return NextResponse.json({ requirements: reqs });
}

View File

@@ -0,0 +1,12 @@
import { NextResponse } from "next/server";
import { listLatestAnalysisRuns } from "../../../../../lib/db/repo";
interface RouteContext {
params: Promise<{ projectId: string }>;
}
export async function GET(_req: Request, ctx: RouteContext) {
const { projectId } = await ctx.params;
const runs = await listLatestAnalysisRuns(projectId);
return NextResponse.json({ runs });
}

View File

@@ -0,0 +1,12 @@
import { NextResponse } from "next/server";
import { listTerms } from "../../../../../lib/db/repo";
interface RouteContext {
params: Promise<{ projectId: string }>;
}
export async function GET(_req: Request, ctx: RouteContext) {
const { projectId } = await ctx.params;
const terms = await listTerms(projectId);
return NextResponse.json({ terms });
}

View File

@@ -0,0 +1,20 @@
import { NextResponse } from "next/server";
import { linkTermToBlock } from "../../../../../lib/db/repo";
interface RouteContext {
params: Promise<{ projectId: string }>;
}
export async function POST(req: Request, _ctx: RouteContext) {
let body: { termId?: string; blockId?: string | null } = {};
try {
body = await req.json();
} catch {
return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
}
if (!body.termId) {
return NextResponse.json({ error: "Missing termId" }, { status: 400 });
}
await linkTermToBlock(body.termId, body.blockId ?? null);
return NextResponse.json({ ok: true });
}

View File

@@ -0,0 +1,39 @@
// Per-term keep/discard decision. Drives the merge-with-review flow:
// `mergeTaxonomySuggestion` marks pending changes; this endpoint confirms or
// rejects them.
//
// Body: { decision: "keep" | "discard" }
// keep → status="accepted"; if previously "deprecated", also pinned=true
// so the next merge doesn't re-deprecate it.
// discard → row deleted.
import { NextResponse } from "next/server";
import { setTermDecision, type TermDecision } from "../../../../../../../lib/db/repo";
interface RouteContext {
params: Promise<{ projectId: string; termId: string }>;
}
export async function POST(req: Request, ctx: RouteContext) {
const { termId } = await ctx.params;
let body: { decision?: string };
try {
body = await req.json();
} catch {
return NextResponse.json({ error: "invalid json body" }, { status: 400 });
}
const decision = body.decision;
if (decision !== "keep" && decision !== "discard") {
return NextResponse.json(
{ error: "decision must be 'keep' or 'discard'" },
{ status: 400 }
);
}
try {
const term = await setTermDecision(termId, decision as TermDecision);
return NextResponse.json({ term });
} catch (err) {
console.error("[term/decision] failed:", err);
return NextResponse.json({ error: (err as Error).message }, { status: 500 });
}
}

View File

@@ -0,0 +1,35 @@
// User-authored definition for a concept term.
//
// Body: { definition: string | null }
// non-empty trimmed → set definition + pin (future Analyze leaves it alone)
// empty / null → clear definition + clear pin ("Reset to AI suggestion")
import { NextResponse } from "next/server";
import { setTermDefinition } from "../../../../../../../lib/db/repo";
interface RouteContext {
params: Promise<{ projectId: string; termId: string }>;
}
export async function POST(req: Request, ctx: RouteContext) {
const { termId } = await ctx.params;
let body: { definition?: string | null };
try {
body = await req.json();
} catch {
return NextResponse.json({ error: "invalid json body" }, { status: 400 });
}
if (body.definition !== null && typeof body.definition !== "string" && typeof body.definition !== "undefined") {
return NextResponse.json(
{ error: "definition must be a string or null" },
{ status: 400 }
);
}
try {
const term = await setTermDefinition(termId, body.definition ?? null);
return NextResponse.json({ term });
} catch (err) {
console.error("[term/definition] failed:", err);
return NextResponse.json({ error: (err as Error).message }, { status: 500 });
}
}

View File

@@ -0,0 +1,18 @@
// GET /api/projects — list projects on this server.
import { NextResponse } from "next/server";
import { listProjects } from "../../../lib/db/repo";
export async function GET() {
const projects = await listProjects();
return NextResponse.json({
projects: projects.map(p => ({
id: p.id,
name: p.name,
scope: p.scope,
tagline: p.tagline,
updatedAt: p.updatedAt.toISOString(),
latestVersion: p.latestVersion,
})),
});
}

View File

@@ -0,0 +1,51 @@
// POST /api/seed/finalize
//
// Pivot flow: turn the SeedDraft into structured prose, persist it as the
// NarrativeDocument, create an empty model snapshot, and return the new
// project id so the client can navigate to /editor/[id]. The analyze
// pipeline runs on first paint of the editor (or via Analyze All).
import { NextResponse } from "next/server";
import { createProjectFromSeed, saveDocument } from "../../../../lib/db/repo";
import { seedToProseDoc } from "../../../../lib/llm/seedToProse";
import { runAnalyze } from "../../../../lib/llm/analyze/runAll";
import type { SeedDraft } from "../../../../lib/llm/seedInterview";
import type { SysMLModel } from "../../../../lib/sysml/model";
export async function POST(req: Request) {
let body: { draft?: SeedDraft };
try {
body = await req.json();
} catch {
return NextResponse.json({ error: "invalid json body" }, { status: 400 });
}
const draft = body.draft;
if (!draft || !draft.problem || !draft.targetUser || !draft.desiredOutcome) {
return NextResponse.json(
{ error: "draft must include problem, targetUser, desiredOutcome" },
{ status: 400 }
);
}
try {
const emptyModel: SysMLModel = { blocks: [], associations: [], constraints: [], requirements: [] };
const created = await createProjectFromSeed({
name: draft.title?.trim() || "Untitled idea",
scope: "Untitled scope",
tagline: draft.problem.slice(0, 80),
model: emptyModel,
});
await saveDocument(created.projectId, seedToProseDoc(draft));
// Kick off the full Analyze pipeline in the background so the editor lands
// populated. Errors are logged but do not block the redirect — the user
// can hit Analyze All again from the TopBar if anything goes sideways.
void runAnalyze(created.projectId, "all").catch(err =>
console.error(`[seed/finalize] background analyze failed for ${created.projectId}:`, err)
);
return NextResponse.json({ projectId: created.projectId, version: created.version });
} catch (err) {
return NextResponse.json({ error: (err as Error).message }, { status: 500 });
}
}

View File

@@ -0,0 +1,44 @@
// POST /api/seed/turn
//
// Per-turn handler for the live seed interview. Stateless — client passes
// the running thread + draft + user text; we return the next assistant turn
// + an updated draft + a `ready` flag.
import { NextResponse } from "next/server";
import { interviewStep, type InterviewTurn, type SeedDraft } from "../../../../lib/llm/seedInterview";
export async function POST(req: Request) {
let body: { history?: InterviewTurn[]; userText?: string; draft?: SeedDraft };
try {
body = await req.json();
} catch {
return NextResponse.json({ error: "invalid json body" }, { status: 400 });
}
const history = Array.isArray(body.history) ? body.history : [];
const draft: SeedDraft = body.draft ?? {
title: "",
problem: "",
targetUser: "",
desiredOutcome: "",
};
const userText = body.userText ?? "";
try {
const result = await interviewStep({ history, userText, draft });
return NextResponse.json({
assistant: { text: result.text },
draft: result.draft,
confidence: result.confidence,
ready: result.ready,
meta: {
provider: result.provider,
model: result.model,
inputTokens: result.inputTokens,
outputTokens: result.outputTokens,
},
});
} catch (err) {
return NextResponse.json({ error: (err as Error).message }, { status: 500 });
}
}

View File

@@ -1,10 +1,67 @@
import { notFound } from "next/navigation";
import { prisma } from "../../../lib/db/client";
import { EditorShell } from "../../../components/editor/EditorShell";
import { aristotleFixture } from "../../../lib/fixtures/aristotle";
import { loadProject, ensureAristotleSeeded, ProjectNotFoundError } from "../../../lib/db/repo";
// M1: every projectId resolves to the Aristotle fixture.
// M4M5 wire this to a real database lookup.
// (EditorShell wraps itself in Suspense for useSearchParams; no boundary
// needed at this level.)
export default async function EditorPage(_props: { params: Promise<{ projectId: string }> }) {
return <EditorShell data={aristotleFixture} />;
// Server component: loads the latest model + project metadata. Auto-seeds the
// Aristotle demo for the literal "aristotle" id; any other unknown id 404s.
export default async function EditorPage(props: { params: Promise<{ projectId: string }> }) {
const { projectId } = await props.params;
if (projectId === "aristotle") await ensureAristotleSeeded();
let model, version;
try {
({ model, version } = await loadProject(projectId));
} catch (err) {
if (err instanceof ProjectNotFoundError) notFound();
throw err;
}
const projectRow = await prisma.project.findUnique({ where: { id: projectId } });
if (!projectRow) notFound();
// The narrative is fixture-derived for the Aristotle demo only. For any
// other project we render a near-empty fixture so the prose surface
// doesn't lie about the project's actual content. (Future polish: have
// the seed-finalize step also produce an opening narrative.)
const isAristotle = projectId === "aristotle";
const fixtureForUi = isAristotle
? aristotleFixture
: {
...aristotleFixture,
project: {
...aristotleFixture.project,
name: projectRow.name,
scope: projectRow.scope,
tagline: projectRow.tagline,
},
narrative: [
{ type: "h1" as const, text: projectRow.name },
{
type: "p" as const,
children: [
{ t: "text" as const, v: projectRow.tagline },
],
},
{ type: "h2" as const, text: "Notes" },
{
type: "p" as const,
children: [
{ t: "text" as const, v: "Start writing — slash to insert chips that reference your blocks, requirements, and constraints." },
],
},
],
socratesThread: [], // dock loads from DB anyway
};
return (
<EditorShell
data={fixtureForUi}
initialModel={model}
initialVersion={version}
projectId={projectId}
/>
);
}

View File

@@ -1,6 +1,11 @@
import Link from "next/link";
// Landing page — list existing projects + entry point to start a new one.
import Link from "next/link";
import { listProjects } from "../lib/db/repo";
export default async function HomePage() {
const projects = await listProjects();
export default function HomePage() {
return (
<div style={{ padding: "60px 80px", maxWidth: 720, margin: "0 auto", lineHeight: 1.55 }}>
<h1 style={{ fontFamily: "var(--font-display)", fontSize: 32, fontWeight: 600, marginBottom: 8 }}>
@@ -9,42 +14,108 @@ export default function HomePage() {
<p style={{ color: "var(--muted)", marginBottom: 32 }}>
Structured thinking and validation platform for product managers.
</p>
<ul style={{ listStyle: "none", padding: 0, margin: 0, display: "grid", gap: 12 }}>
<li>
<Link
href="/editor/aristotle"
style={{
display: "inline-block",
padding: "12px 18px",
border: "1px solid var(--border-strong)",
borderRadius: 6,
color: "var(--accent)",
fontFamily: "var(--font-mono)",
fontSize: 13,
textDecoration: "none",
}}
>
Open editor (Aristotle fixture)
</Link>
</li>
<li>
<Link
href="/seed"
style={{
display: "inline-block",
padding: "12px 18px",
border: "1px solid var(--border)",
borderRadius: 6,
color: "var(--muted)",
fontFamily: "var(--font-mono)",
fontSize: 13,
textDecoration: "none",
}}
>
Seed screen (coming next)
</Link>
</li>
</ul>
<div style={{ marginBottom: 32 }}>
<Link
href="/seed"
style={{
display: "inline-block",
padding: "12px 18px",
border: "1px solid var(--accent)",
background: "var(--accent)",
color: "var(--accent-on)",
borderRadius: 6,
fontFamily: "var(--font-mono)",
fontSize: 13,
textDecoration: "none",
}}
>
Start a new idea
</Link>
</div>
<div>
<h2 style={{
fontFamily: "var(--font-mono)",
fontSize: 11,
textTransform: "uppercase",
letterSpacing: "0.10em",
color: "var(--muted)",
marginBottom: 12,
}}>
{projects.length === 0 ? "no projects yet" : `${projects.length} project${projects.length === 1 ? "" : "s"}`}
</h2>
{projects.length === 0 ? (
<p style={{ color: "var(--muted)", fontStyle: "italic" }}>
Click "Start a new idea" above to begin a Socratic interview that bootstraps a fresh project.
</p>
) : (
<ul style={{ listStyle: "none", padding: 0, margin: 0, display: "grid", gap: 8 }}>
{projects.map(p => (
<li key={p.id}>
<Link
href={`/editor/${encodeURIComponent(p.id)}`}
style={{
display: "block",
padding: "12px 14px",
border: "1px solid var(--border)",
borderRadius: 6,
color: "var(--fg)",
textDecoration: "none",
background: "var(--surface)",
}}
>
<div style={{ display: "flex", alignItems: "baseline", gap: 10 }}>
<span style={{
fontFamily: "var(--font-display)",
fontWeight: 600,
fontSize: 14,
color: "var(--accent-strong)",
}}>{p.name}</span>
<span style={{
fontFamily: "var(--font-mono)",
fontSize: 10.5,
color: "var(--muted)",
}}>
{p.scope} · v{p.latestVersion}
</span>
<span style={{
marginLeft: "auto",
fontFamily: "var(--font-mono)",
fontSize: 10.5,
color: "var(--muted)",
}}>
{timeAgo(new Date(p.updatedAt))}
</span>
</div>
{p.tagline && (
<div style={{
fontSize: 12.5,
color: "var(--muted-strong)",
marginTop: 4,
lineHeight: 1.45,
}}>
{p.tagline}
</div>
)}
</Link>
</li>
))}
</ul>
)}
</div>
</div>
);
}
function timeAgo(d: Date): string {
const sec = Math.floor((Date.now() - d.getTime()) / 1000);
if (sec < 60) return `${sec}s ago`;
const min = Math.floor(sec / 60);
if (min < 60) return `${min}m ago`;
const hr = Math.floor(min / 60);
if (hr < 24) return `${hr}h ago`;
const day = Math.floor(hr / 24);
return `${day}d ago`;
}