diff --git a/app/globals.css b/app/globals.css index 7c824ce..dd14142 100644 --- a/app/globals.css +++ b/app/globals.css @@ -3,59 +3,113 @@ @custom-variant dark (&:is(.dark *)); /* - * Design tokens — the single place to restyle the app. - * (The owner has a specific visual design planned; it lands here and in - * component edits, not in structural changes.) + * THE SKIN — wave 5. Design tokens, the single place to restyle the app. + * + * Mathematical soundness, per the owner's brief: + * - Tailwind's built-in 0.25rem (4px) spacing scale IS the rhythm — every + * padding/margin/gap in the app uses whole units of a 4px base. + * - The type scale below is a declared modular ladder (each --text-* token + * carries its own line-height); components never invent pixel sizes. + * - Radii ladder is 4 / 8 / 12 / 16px multiples of the 4px base. + * - Emphasis colors live in tokens (--lh-ink-strong / --lh-ink-italic) so a + * future settings UI reassigns them (configurable per the owner). */ +/* Palette (owner's hexes): main #141414, raised #1C1C1C, edges #434343. + Mono chrome = JetBrains Mono; prose = Source Sans 3; titles = CMU Serif. */ :root { - --radius: 0.625rem; - --background: oklch(1 0 0); - --foreground: oklch(0.145 0 0); - --card: oklch(1 0 0); - --card-foreground: oklch(0.145 0 0); - --popover: oklch(1 0 0); - --popover-foreground: oklch(0.145 0 0); - --primary: oklch(0.205 0 0); - --primary-foreground: oklch(0.985 0 0); - --secondary: oklch(0.97 0 0); - --secondary-foreground: oklch(0.205 0 0); - --muted: oklch(0.97 0 0); - --muted-foreground: oklch(0.556 0 0); - --accent: oklch(0.97 0 0); - --accent-foreground: oklch(0.205 0 0); - --destructive: oklch(0.577 0.245 27.325); - --border: oklch(0.922 0 0); - --input: oklch(0.922 0 0); - --ring: oklch(0.708 0 0); + --radius: 8px; + --background: #141414; + --foreground: #e6e6e6; + --card: #1c1c1c; + --card-foreground: #e6e6e6; + --popover: #1c1c1c; + --popover-foreground: #e6e6e6; + --primary: #e6e6e6; + --primary-foreground: #141414; + --secondary: #1c1c1c; + --secondary-foreground: #e6e6e6; + --muted: #1c1c1c; + --muted-foreground: #8d8d8d; + --accent: #2a2a2a; + --accent-foreground: #e6e6e6; + --destructive: oklch(0.704 0.191 22.216); + --border: #434343; + --input: #434343; + --ring: #8d8d8d; + /* Emphasis coloring in teaching prose — ready for the settings UI. */ + --lh-ink-strong: #c96b4a; + --lh-ink-italic: #6fa9d6; + --lh-prose-size: 16.5px; + --lh-prose-leading: 1.5; } +/* The app is always dark (); mirror the same values so + the block stays coherent under Tailwind's variant. */ .dark { - --background: oklch(0.145 0 0); - --foreground: oklch(0.985 0 0); - --card: oklch(0.205 0 0); - --card-foreground: oklch(0.985 0 0); - --popover: oklch(0.205 0 0); - --popover-foreground: oklch(0.985 0 0); - --primary: oklch(0.922 0 0); - --primary-foreground: oklch(0.205 0 0); - --secondary: oklch(0.269 0 0); - --secondary-foreground: oklch(0.985 0 0); - --muted: oklch(0.269 0 0); - --muted-foreground: oklch(0.708 0 0); - --accent: oklch(0.269 0 0); - --accent-foreground: oklch(0.985 0 0); + --background: #141414; + --foreground: #e6e6e6; + --card: #1c1c1c; + --card-foreground: #e6e6e6; + --popover: #1c1c1c; + --popover-foreground: #e6e6e6; + --primary: #e6e6e6; + --primary-foreground: #141414; + --secondary: #1c1c1c; + --secondary-foreground: #e6e6e6; + --muted: #1c1c1c; + --muted-foreground: #8d8d8d; + --accent: #2a2a2a; + --accent-foreground: #e6e6e6; --destructive: oklch(0.704 0.191 22.216); - --border: oklch(1 0 0 / 10%); - --input: oklch(1 0 0 / 15%); - --ring: oklch(0.556 0 0); + --border: #434343; + --input: #434343; + --ring: #8d8d8d; + --lh-ink-strong: #c96b4a; + --lh-ink-italic: #6fa9d6; + --lh-prose-size: 16.5px; + --lh-prose-leading: 1.5; +} + +/* CMU Serif bold (titles) — bundled local file, no CDN dependency. */ +@font-face { + font-family: "lh-cmu"; + src: url("/fonts/cmunbx.ttf") format("truetype"); + font-weight: 700; + font-style: normal; + font-display: swap; } @theme inline { - --radius-sm: calc(var(--radius) - 4px); - --radius-md: calc(var(--radius) - 2px); - --radius-lg: var(--radius); - --radius-xl: calc(var(--radius) + 4px); + /* Fonts: chrome = JetBrains Mono, prose = Source Sans 3, titles = CMU + Serif bold. The JetBrains/SourceSans variables attach in app/layout.tsx. */ + --font-ui: var(--font-jetbrains), ui-monospace, SFMono-Regular, monospace; + --font-prose: var(--font-sans3), ui-sans-serif, system-ui, sans-serif; + --font-title: "lh-cmu", serif; + /* The modular type ladder — each size carries its own line-height. + Component sizes must pull from this ladder. */ + --text-xxs: 10px; + --text-xxs--line-height: 1.4; + --text-xs: 11px; + --text-xs--line-height: 1.4; + --text-sm: 12.5px; + --text-sm--line-height: 1.4; + --text-base: 14px; + --text-base--line-height: 1.45; + --text-lg: 16px; + --text-lg--line-height: 1.45; + --text-xl: 20px; + --text-xl--line-height: 1.4; + --text-2xl: 25px; + --text-2xl--line-height: 1.3; + --text-3xl: 32px; + --text-3xl--line-height: 1.25; + /* Radii ladder — 4/8/12/16 multiples of the 4px base. */ + --radius-sm: 4px; + --radius-md: 8px; + --radius-lg: 12px; + --radius-xl: 16px; + --radius: 8px; --color-background: var(--background); --color-foreground: var(--foreground); --color-card: var(--card); @@ -83,6 +137,25 @@ body { background: var(--color-background); color: var(--color-foreground); + font-family: var(--font-ui); +} + +/* Teaching prose (the markdown renderer's container gets .lh-prose): + Source Sans 3 with the size/leading tokens; emphasis color bound to + the settings-ready tokens. */ +.lh-prose { + font-family: var(--font-prose); + font-size: var(--lh-prose-size); + line-height: var(--lh-prose-leading); +} + +.lh-prose :where(strong, b) { + color: var(--lh-ink-strong); + font-weight: 700; +} + +.lh-prose :where(em, i) { + color: var(--lh-ink-italic); } /* Thin dark native scrollbar (app-wide): dark thumb, transparent track. */ diff --git a/app/layout.tsx b/app/layout.tsx index d108f76..2a19db7 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,9 +1,15 @@ import type { Metadata } from "next"; +import { JetBrains_Mono, Source_Sans_3 } from "next/font/google"; import "katex/dist/katex.min.css"; import "./globals.css"; import { AppShell, SidebarProvider } from "@/components/sidebar"; import { ActivityDock, ActivityProvider } from "@/components/activity-dock"; +// Chrome = JetBrains Mono, teaching prose = Source Sans 3, titles = CMU +// Serif (the bundled lh-cmu @font-face in globals.css — no CDN). +const ui = JetBrains_Mono({ subsets: ["latin"], variable: "--font-jetbrains" }); +const prose = Source_Sans_3({ subsets: ["latin"], variable: "--font-sans3" }); + export const metadata: Metadata = { title: "Learning Harness", description: "A teacher agent with a memory — sessions, quizzes, and a knowledge graph of what you know.", @@ -11,7 +17,7 @@ export const metadata: Metadata = { export default function RootLayout({ children }: { children: React.ReactNode }) { return ( - + diff --git a/components/activity-dock.tsx b/components/activity-dock.tsx index cdf349d..3eab877 100644 --- a/components/activity-dock.tsx +++ b/components/activity-dock.tsx @@ -294,7 +294,7 @@ export function ActivityDock({ feed: feedProp }: { feed?: ActivityFeed }) { narrow && "max-h-[60dvh] overflow-y-auto", )} > -
+
Background activity diff --git a/components/ai-elements/message.tsx b/components/ai-elements/message.tsx index 9cb668f..18c823d 100644 --- a/components/ai-elements/message.tsx +++ b/components/ai-elements/message.tsx @@ -384,7 +384,9 @@ export const MessageResponse = memo( return ( *:first-child]:mt-0 [&>*:last-child]:mb-0", + // Source Sans 3 + emphasis-color tokens on every markdown surface + // (labels stay JetBrains Mono). Unlayered plain CSS wins over text-sm. + "lh-prose size-full [&>*:first-child]:mt-0 [&>*:last-child]:mb-0", className )} mermaid={mermaidOptions} diff --git a/components/answered-card.tsx b/components/answered-card.tsx index d444290..112097b 100644 --- a/components/answered-card.tsx +++ b/components/answered-card.tsx @@ -1,5 +1,6 @@ "use client"; +import { GlyphMark } from "@/components/ui/glyph-mark"; import { cn } from "@/lib/utils"; import { MessageResponse } from "@/components/ai-elements/message"; import type { QuestionAnswerData } from "@/src/core"; @@ -50,7 +51,7 @@ export function AnsweredCard({ answer }: { answer: QuestionAnswerData }) {
{isQuiz && ( - - {sel && key && "✓"} - {sel && !key && "✗"} - {!sel && key && "✓"} + // Zapf-Dingbats-derived SVG outlines — the exact LaTeX \ding{51}/ + // \ding{55} hand-drawn shapes (owner, round 6); see ui/glyph-mark. + + {/* Owner round 8: a touch smaller than the text (size-4→size-3.5, + ≈14px vs the 16.5px row) while centered. */} + {(key || sel) && } )} @@ -76,7 +88,7 @@ export function AnsweredCard({ answer }: { answer: QuestionAnswerData }) { {answer.other && (
“{answer.other}”
@@ -84,22 +96,27 @@ export function AnsweredCard({ answer }: { answer: QuestionAnswerData }) {
)} - {answer.text &&
“{answer.text}”
} + {answer.text &&
“{answer.text}”
} {answer.dontKnow && isQuiz &&
Answered “I don't know”.
} {answer.note && (
- + Your thinking - {answer.note} + {answer.note}
)} + {/* px-3→removed: card p-4 + box px-3 double-inset sat the "Why" label deeper + than the option box edges (owner, round 3). Sides flush with everything + else; py-2 stays for vertical air. */} {isQuiz && answer.explanation && ( -
+
Why: - {answer.explanation} + {/* The markdown wrapper must go truly inline — a block

after the + "Why:" label created the oversized leading gap (owner's fix list). */} + {answer.explanation}

)}
diff --git a/components/artifacts/steer-card.tsx b/components/artifacts/steer-card.tsx index 62e9415..ffa50c7 100644 --- a/components/artifacts/steer-card.tsx +++ b/components/artifacts/steer-card.tsx @@ -81,8 +81,8 @@ function ResearchCompleteBadge({ result }: { result: ResearchResult }) { Research complete {result.sources.length > 0 && · {result.sources.length} sources} - view brief - hide brief + view brief + hide brief
diff --git a/components/chat.tsx b/components/chat.tsx index 2f55fd9..71495e6 100644 --- a/components/chat.tsx +++ b/components/chat.tsx @@ -32,7 +32,14 @@ import { PhaseChip, PhaseDivider, planNodeCount } from "@/components/phase-divid import { findPendingToolCall, phaseAt3, phaseTimeline } from "@/src/core"; /** One width for everything: transcript, cards, and the bottom blob share this column. */ -const COLUMN = "mx-auto w-full max-w-3xl px-4"; +/** + * Width tokens (wave-5 reskin, owner decision): the teaching PROSE spans the + * full session width with a thin px-6 margin; the composer/question DOCK is + * deliberately narrower (~46rem centered) per the reference. Same ELEMENT, + * same place — width polish only. + */ +const COLUMN = "mx-auto w-full max-w-none px-6"; +const DOCK = "mx-auto w-full max-w-[46rem] px-4"; /** * Server-sync channel for artifact renderers: the CENTRAL jobs poll in Chat @@ -457,10 +464,16 @@ export function Chat({ {/* h-10, not py-2: the toggle (a size-9 icon button) renders only while the sidebar is CLOSED — an auto-height row would collapse when it disappears and slide the title + chip up. */} -
+
-
{title}
+
{title}
+ {/* Soft top fade (owner): the scroller edge under the header eases + in exactly like the dock gradient at the bottom. */} +
@@ -523,7 +536,7 @@ export function Chat({ {m.role === "user" ? ( // User text renders plain: the model's LaTeX pipeline is not // applied to learner-authored text ($ stays $, no surprises). -
+
{m.parts.filter((p) => p.type === "text").map((p) => p.text).join("\n")}
) : ( @@ -813,7 +826,7 @@ function DockOverlay({
-
+
{pending ? ( diff --git a/components/composer.tsx b/components/composer.tsx index 2192d9b..f2e7420 100644 --- a/components/composer.tsx +++ b/components/composer.tsx @@ -59,7 +59,7 @@ export function Composer({ placeholder="Ask, answer, or steer the lesson…" autoFocus={autoFocus} rows={1} - className="field-sizing-content max-h-48 min-h-10 flex-1 resize-none bg-transparent px-2.5 py-2 text-sm outline-none placeholder:text-muted-foreground" + className="field-sizing-content lh-prose max-h-48 min-h-10 flex-1 resize-none bg-transparent px-2.5 py-2 outline-none placeholder:text-muted-foreground" /> {verdict && !busy ? ( // The plan verdict (docs/probe-teach-split.md §4): accept @@ -70,14 +70,14 @@ export function Composer({ diff --git a/components/home.tsx b/components/home.tsx index 510e7f7..a5d1846 100644 --- a/components/home.tsx +++ b/components/home.tsx @@ -5,6 +5,8 @@ import { useRouter } from "next/navigation"; import { CornerDownLeftIcon } from "lucide-react"; import { GraphView } from "@/components/graph-view"; import { SidebarToggle } from "@/components/sidebar"; +import { StatsBar } from "@/components/stats-bar"; +import type { ChatSettings, TurnUsage } from "@/src/core"; /** * Home — the knowledge graph is the main view (placeholder until the graph @@ -15,6 +17,10 @@ export function Home() { const router = useRouter(); const [topic, setTopic] = useState(""); const [busy, setBusy] = useState(false); + // The model+thinking selection made pre-chat — lifted from the StatsBar's + // ModelControl, carried to the chat POST then PUT-on-create (the model + // picker defers its save when chatId is empty; see components/model-picker). + const [settings, setSettings] = useState(undefined); const start = async () => { const t = topic.trim(); @@ -27,6 +33,14 @@ export function Home() { body: JSON.stringify({ title: t }), }); const { id } = (await res.json()) as { id: string }; + if (settings) { + // Best-effort persist — the selection was made pre-chat. + await fetch(`/api/chats/${id}/settings`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(settings), + }).catch(() => {}); + } router.push(`/chat/${id}?prompt=${encodeURIComponent(t)}`); } catch { setBusy(false); @@ -46,7 +60,9 @@ export function Home() { {/* New session — same floating blob as the chat composer. */}
-
+ {/* Matches chat.tsx's DOCK geometry (max-w-[46rem] px-4) so nothing + shifts when a session starts (owner). */} +
{ @@ -66,7 +82,7 @@ export function Home() { placeholder="What do you want to learn?" autoFocus rows={1} - className="field-sizing-content max-h-48 min-h-10 flex-1 resize-none bg-transparent px-2.5 py-2 text-sm outline-none placeholder:text-muted-foreground" + className="field-sizing-content lh-prose max-h-48 min-h-10 flex-1 resize-none bg-transparent px-2.5 py-2 outline-none placeholder:text-muted-foreground" />
+ {/* Same as the chat view's under-composer stats bar (owner) — no + session yet, so money/context fold as "—"; the model + + thinking control still works (deferred save). */} +
diff --git a/components/model-picker.tsx b/components/model-picker.tsx index 89adc43..48c0c8a 100644 --- a/components/model-picker.tsx +++ b/components/model-picker.tsx @@ -117,6 +117,13 @@ export function ModelControl({ const save = useCallback( async (next: ChatSettings) => { setError(null); + // Home view (pre-chat): defer — the selection rides along on the + // chat POST and is PUT after creation (onSaved is still lifted). + if (!chatId) { + onSaved(next); + setOpen(null); + return; + } try { const res = await fetch(`/api/chats/${chatId}/settings`, { method: "PUT", @@ -160,7 +167,7 @@ export function ModelControl({ className="flex items-center gap-1.5 rounded-full border border-border/60 bg-card/70 px-2.5 py-1 transition-colors hover:border-border hover:bg-muted" > {effectiveId ? prettyModel(effectiveId) : "—"} - ▾ + ▾ · {open === "model" && ( @@ -191,7 +198,7 @@ export function ModelControl({ value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Search models…" - className="mb-1.5 w-full rounded-lg border bg-background px-2 py-1.5 text-foreground outline-none focus:ring-1 focus:ring-ring" + className="lh-prose mb-1.5 w-full rounded-lg border bg-background px-2 py-1.5 text-foreground outline-none focus:ring-1 focus:ring-ring" /> {error &&
{error}
} {!catalog && ( @@ -234,7 +241,7 @@ export function ModelControl({ ))} -
+
Applies to the next turn · background agents stay config-pinned
@@ -266,7 +273,7 @@ export function ModelControl({ ))} {error &&
{error}
} -
+
Levels this model actually supports
diff --git a/components/phase-divider.tsx b/components/phase-divider.tsx index 0e9697b..e3ac854 100644 --- a/components/phase-divider.tsx +++ b/components/phase-divider.tsx @@ -36,7 +36,7 @@ export function PhaseChip({ phase }: { phase: Phase3 }) { data-testid="phase-chip" data-phase={phase} title={`Session phase: ${CHIP_LABEL[phase]}`} - className="flex shrink-0 items-center gap-1.5 rounded-full border border-border/80 px-2 py-0.5 text-[11px] font-medium uppercase tracking-wide text-muted-foreground" + className="flex shrink-0 items-center gap-1.5 rounded-full border border-border/80 px-2 py-0.5 text-xs font-medium uppercase tracking-wide text-muted-foreground" > {CHIP_LABEL[phase]} @@ -71,7 +71,7 @@ export function PhaseDivider({ phase, nodeCount }: { phase: Phase3; nodeCount?: diff --git a/components/plan-block.tsx b/components/plan-block.tsx index db859fb..77db98b 100644 --- a/components/plan-block.tsx +++ b/components/plan-block.tsx @@ -473,7 +473,7 @@ export function PlanPopup({ messages, onClose }: { messages: readonly PopupMessa />
- Drag to pan · scroll to zoom + Drag to pan · scroll to zoom
); @@ -328,11 +332,23 @@ function QuestionDockForm({ } aria-pressed={sel.dontKnow} className={cn( - "rounded-lg border border-dashed px-3 py-2 text-left text-sm text-muted-foreground transition-colors", - sel.dontKnow && "border-primary text-foreground", + // Option-row layout + radio circle (owner); the dashed + // outline stays — this is the deliberate non-answer. + "flex items-start gap-3 rounded-lg border border-dashed px-3 py-2 text-left text-sm transition-colors", + sel.dontKnow ? "border-primary text-foreground" : "text-muted-foreground", )} > - I don't know + + {sel.dontKnow && } + + I don't know )} @@ -357,7 +373,7 @@ function QuestionDockForm({ )} > {sel.otherSelected && ( - + {qd.mode === "multi-select" ? "✓" : ""} )} @@ -371,7 +387,7 @@ function QuestionDockForm({ setSel((prev) => (prev.otherSelected ? prev : toggleOther(selectMode, prev))); }} placeholder="Type your own answer…" - className="w-full min-w-0 flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground" + className="lh-prose w-full min-w-0 flex-1 bg-transparent outline-none placeholder:text-muted-foreground" onClick={(e) => e.stopPropagation()} />
@@ -396,7 +412,7 @@ function QuestionDockForm({ data-testid={qd.mode === "text" ? "answer-field" : "thinking-field"} className="rounded-xl border border-border/70 bg-background/60 transition-colors focus-within:border-primary/50" > -
+
{qd.mode === "text" ? "Your answer" : "Your thinking"}
{qd.mode === "text" ? ( @@ -413,7 +429,7 @@ function QuestionDockForm({ }} placeholder="Type your answer…" rows={1} - className="field-sizing-content max-h-48 min-h-10 w-full resize-none bg-transparent px-2.5 py-2 text-sm outline-none placeholder:text-muted-foreground" + className="field-sizing-content lh-prose max-h-48 min-h-10 w-full resize-none bg-transparent px-2.5 py-2 outline-none placeholder:text-muted-foreground" /> ) : (