Merge feat/ui-polish: wave 5 — the cosmetic reskin (owner skin, fonts, dock/prose split, pre-chat model selection)
This commit is contained in:
+116
-43
@@ -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 (<html class="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. */
|
||||
|
||||
+7
-1
@@ -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 (
|
||||
<html lang="en" className="dark">
|
||||
<html lang="en" className={`dark ${ui.variable} ${prose.variable}`}>
|
||||
<body className="min-h-dvh antialiased">
|
||||
<SidebarProvider>
|
||||
<ActivityProvider>
|
||||
|
||||
@@ -294,7 +294,7 @@ export function ActivityDock({ feed: feedProp }: { feed?: ActivityFeed }) {
|
||||
narrow && "max-h-[60dvh] overflow-y-auto",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2 px-1 pb-1 text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
|
||||
<div className="flex items-center gap-2 px-1 pb-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
<ActivityIcon className="size-3.5" />
|
||||
Background activity
|
||||
<span className="ml-auto tabular-nums" data-testid="activity-total-cost" title="Total running cost (inferred)">
|
||||
|
||||
@@ -384,7 +384,9 @@ export const MessageResponse = memo(
|
||||
return (
|
||||
<Streamdown
|
||||
className={cn(
|
||||
"size-full [&>*: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}
|
||||
|
||||
@@ -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 }) {
|
||||
<div
|
||||
key={opt.index}
|
||||
className={cn(
|
||||
"flex items-start gap-2 rounded-lg border px-3 py-1.5",
|
||||
"flex items-center gap-2 rounded-lg border px-3 py-1.5",
|
||||
sel && key && "border-green-500/60 bg-green-500/10",
|
||||
sel && !key && isQuiz && "border-red-500/60 bg-red-500/10",
|
||||
sel && !isQuiz && "border-primary bg-accent",
|
||||
@@ -58,10 +59,21 @@ export function AnsweredCard({ answer }: { answer: QuestionAnswerData }) {
|
||||
)}
|
||||
>
|
||||
{isQuiz && (
|
||||
<span className="w-4 shrink-0">
|
||||
{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.
|
||||
<span
|
||||
aria-hidden
|
||||
data-mark={sel && key ? "check" : sel && !key ? "x" : !sel && key ? "check" : undefined}
|
||||
className={cn(
|
||||
"flex shrink-0 items-center",
|
||||
sel && key && "text-green-500",
|
||||
sel && !key && "text-red-500",
|
||||
!sel && key && "text-green-500",
|
||||
)}
|
||||
>
|
||||
{/* Owner round 8: a touch smaller than the text (size-4→size-3.5,
|
||||
≈14px vs the 16.5px row) while centered. */}
|
||||
{(key || sel) && <GlyphMark kind={key ? "check" : "x"} className="size-3.5" />}
|
||||
</span>
|
||||
)}
|
||||
<span className={cn(!sel && "text-muted-foreground")}>
|
||||
@@ -76,7 +88,7 @@ export function AnsweredCard({ answer }: { answer: QuestionAnswerData }) {
|
||||
{answer.other && (
|
||||
<div
|
||||
data-testid="answered-other"
|
||||
className="flex items-start gap-2 rounded-lg border border-primary bg-accent px-3 py-1.5"
|
||||
className="flex items-center gap-2 rounded-lg border border-primary bg-accent px-3 py-1.5"
|
||||
>
|
||||
<span>“{answer.other}”</span>
|
||||
</div>
|
||||
@@ -84,22 +96,27 @@ export function AnsweredCard({ answer }: { answer: QuestionAnswerData }) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{answer.text && <div className="mt-2 whitespace-pre-wrap text-sm">“{answer.text}”</div>}
|
||||
{answer.text && <div className="lh-prose mt-2 whitespace-pre-wrap">“{answer.text}”</div>}
|
||||
{answer.dontKnow && isQuiz && <div className="mt-2 text-sm text-muted-foreground">Answered “I don't know”.</div>}
|
||||
|
||||
{answer.note && (
|
||||
<div data-testid="your-thinking" className="mt-3 border-l-2 border-primary/40 pl-3">
|
||||
<span className="mb-0.5 block text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
|
||||
<span className="mb-0.5 block text-xxs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Your thinking
|
||||
</span>
|
||||
<span className="block whitespace-pre-wrap text-sm text-foreground/90">{answer.note}</span>
|
||||
<span className="lh-prose block whitespace-pre-wrap text-foreground/90">{answer.note}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 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 && (
|
||||
<div className="mt-3 rounded-lg bg-muted px-3 py-2 text-sm text-muted-foreground">
|
||||
<div className="mt-3 rounded-lg bg-muted py-2 text-sm text-muted-foreground">
|
||||
<span className="font-medium text-foreground">Why: </span>
|
||||
<MessageResponse className="inline">{answer.explanation}</MessageResponse>
|
||||
{/* The markdown wrapper must go truly inline — a block <p> after the
|
||||
"Why:" label created the oversized leading gap (owner's fix list). */}
|
||||
<MessageResponse className="inline [&_p]:inline [&_p]:m-0">{answer.explanation}</MessageResponse>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -81,8 +81,8 @@ function ResearchCompleteBadge({ result }: { result: ResearchResult }) {
|
||||
<span className="font-medium uppercase tracking-wide">Research</span>
|
||||
<span>complete</span>
|
||||
{result.sources.length > 0 && <span>· {result.sources.length} sources</span>}
|
||||
<span className="ml-1 text-[11px] text-muted-foreground/70 group-open:hidden">view brief</span>
|
||||
<span className="ml-1 hidden text-[11px] text-muted-foreground/70 group-open:inline">hide brief</span>
|
||||
<span className="ml-1 text-xs text-muted-foreground/70 group-open:hidden">view brief</span>
|
||||
<span className="ml-1 hidden text-xs text-muted-foreground/70 group-open:inline">hide brief</span>
|
||||
</summary>
|
||||
<div className="mt-2">
|
||||
<ResearchBriefCard output={result} />
|
||||
|
||||
+18
-5
@@ -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. */}
|
||||
<header className="flex h-10 items-center gap-3 px-3">
|
||||
<header className="relative flex h-10 items-center gap-3 px-3">
|
||||
<SidebarToggle />
|
||||
<div className="min-w-0 flex-1 truncate font-medium">{title}</div>
|
||||
<div className="min-w-0 flex-1 truncate font-title text-2xl tracking-tight">{title}</div>
|
||||
<PhaseChip phase={phases.at(-1) ?? "probe"} />
|
||||
{/* Soft top fade (owner): the scroller edge under the header eases
|
||||
in exactly like the dock gradient at the bottom. */}
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute inset-x-0 top-full z-10 h-5 bg-gradient-to-b from-background to-transparent"
|
||||
/>
|
||||
</header>
|
||||
|
||||
<div className="relative flex min-h-0 flex-1">
|
||||
@@ -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).
|
||||
<div className="whitespace-pre-wrap">
|
||||
<div className="lh-prose whitespace-pre-wrap">
|
||||
{m.parts.filter((p) => p.type === "text").map((p) => p.text).join("\n")}
|
||||
</div>
|
||||
) : (
|
||||
@@ -813,7 +826,7 @@ function DockOverlay({
|
||||
<div className="pointer-events-none sticky bottom-0 mt-auto">
|
||||
<div className="h-24 bg-gradient-to-t from-background via-background/85 to-transparent" />
|
||||
<div className="bg-background pb-4">
|
||||
<div className={COLUMN}>
|
||||
<div className={DOCK}>
|
||||
<div ref={shellRef} className="pointer-events-auto overflow-hidden rounded-2xl border bg-card shadow-lg">
|
||||
<div key={mode} className={mode === initialModeRef.current ? undefined : "dock-variant"}>
|
||||
{pending ? (
|
||||
|
||||
@@ -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({
|
||||
<button
|
||||
type="button"
|
||||
onClick={verdict.onAccept}
|
||||
className="flex h-[19px] items-center justify-center gap-1 rounded-md bg-emerald-600 px-3 text-[11px] font-medium text-white transition-colors hover:bg-emerald-500"
|
||||
className="flex h-[19px] items-center justify-center gap-1 rounded-md bg-emerald-600 px-3 text-xs font-medium text-white transition-colors hover:bg-emerald-500"
|
||||
>
|
||||
<CheckIcon className="size-3" /> Accept plan
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={verdict.onDecline}
|
||||
className="flex h-[19px] items-center justify-center gap-1 rounded-md bg-destructive px-3 text-[11px] font-medium text-destructive-foreground transition-colors hover:opacity-90"
|
||||
className="flex h-[19px] items-center justify-center gap-1 rounded-md bg-destructive px-3 text-xs font-medium text-destructive-foreground transition-colors hover:opacity-90"
|
||||
>
|
||||
<XIcon className="size-3" /> Decline
|
||||
</button>
|
||||
|
||||
+27
-2
@@ -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<ChatSettings | undefined>(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. */}
|
||||
<div className="pb-4">
|
||||
<div className="mx-auto w-full max-w-3xl px-4">
|
||||
{/* Matches chat.tsx's DOCK geometry (max-w-[46rem] px-4) so nothing
|
||||
shifts when a session starts (owner). */}
|
||||
<div className="mx-auto w-full max-w-[46rem] px-4">
|
||||
<form
|
||||
className="flex items-end gap-1.5 rounded-2xl border bg-card p-1.5 shadow-lg"
|
||||
onSubmit={(e) => {
|
||||
@@ -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"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
@@ -77,6 +93,15 @@ export function Home() {
|
||||
<CornerDownLeftIcon className="size-5" />
|
||||
</button>
|
||||
</form>
|
||||
{/* 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). */}
|
||||
<StatsBar
|
||||
turns={[] as TurnUsage[]}
|
||||
chatId=""
|
||||
settings={settings}
|
||||
onSettingsSaved={setSettings}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
<span className="text-card-foreground">{effectiveId ? prettyModel(effectiveId) : "—"}</span>
|
||||
<span aria-hidden className="text-[9px] text-muted-foreground">▾</span>
|
||||
<span aria-hidden className="text-xxs text-muted-foreground">▾</span>
|
||||
</button>
|
||||
<span aria-hidden>·</span>
|
||||
<button
|
||||
@@ -177,7 +184,7 @@ export function ModelControl({
|
||||
<span className="text-card-foreground">
|
||||
{effectiveLevel === undefined ? "Thinking —" : `Thinking: ${LEVEL_LABEL[effectiveLevel] ?? effectiveLevel}`}
|
||||
</span>
|
||||
<span aria-hidden className="text-[9px] text-muted-foreground">▾</span>
|
||||
<span aria-hidden className="text-xxs text-muted-foreground">▾</span>
|
||||
</button>
|
||||
|
||||
{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 && <div className="mb-1.5 text-destructive">{error}</div>}
|
||||
{!catalog && (
|
||||
@@ -234,7 +241,7 @@ export function ModelControl({
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="mt-1 border-t px-2 pt-1.5 text-[10px] text-muted-foreground">
|
||||
<div className="mt-1 border-t px-2 pt-1.5 text-xxs text-muted-foreground">
|
||||
Applies to the next turn · background agents stay config-pinned
|
||||
</div>
|
||||
</div>
|
||||
@@ -266,7 +273,7 @@ export function ModelControl({
|
||||
</button>
|
||||
))}
|
||||
{error && <div className="mt-1 text-destructive">{error}</div>}
|
||||
<div className="mt-1 border-t px-2 pt-1.5 text-[10px] text-muted-foreground">
|
||||
<div className="mt-1 border-t px-2 pt-1.5 text-xxs text-muted-foreground">
|
||||
Levels this model actually supports
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
<span className={cn("size-1.5 rounded-full", PHASE_DOT[phase])} aria-hidden />
|
||||
{CHIP_LABEL[phase]}
|
||||
@@ -71,7 +71,7 @@ export function PhaseDivider({ phase, nodeCount }: { phase: Phase3; nodeCount?:
|
||||
<span className={cn("h-0 flex-1 border-t-2 border-dotted", DIVIDER_RULE[phase])} aria-hidden />
|
||||
<span
|
||||
className={cn(
|
||||
"rounded-full border px-2.5 py-0.5 text-[11px] font-medium uppercase tracking-wide",
|
||||
"rounded-full border px-2.5 py-0.5 text-xs font-medium uppercase tracking-wide",
|
||||
DIVIDER_TEXT[phase],
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -473,7 +473,7 @@ export function PlanPopup({ messages, onClose }: { messages: readonly PopupMessa
|
||||
/>
|
||||
</div>
|
||||
<div className="pointer-events-none absolute inset-x-0 bottom-0 flex items-end justify-between p-2">
|
||||
<span className="text-[10px] text-muted-foreground/60">Drag to pan · scroll to zoom</span>
|
||||
<span className="text-xxs text-muted-foreground/60">Drag to pan · scroll to zoom</span>
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -82,7 +82,7 @@ export function PlanWindow({ messages, onOpen }: { messages: readonly PopupMessa
|
||||
title="Open the full plan"
|
||||
className="fixed bottom-3 left-3 z-30 hidden w-80 flex-col gap-1 rounded-xl border bg-card/95 p-2 text-left shadow-lg backdrop-blur sm:flex"
|
||||
>
|
||||
<div className="flex items-center gap-2 px-1 text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
|
||||
<div className="flex items-center gap-2 px-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
<WaypointsIcon className="size-3.5 text-primary" />
|
||||
The plan
|
||||
<span className="ml-auto min-w-0 truncate font-normal normal-case" title={currentTitle ?? undefined}>
|
||||
|
||||
@@ -284,7 +284,7 @@ function QuestionDockForm({
|
||||
)}
|
||||
|
||||
{qd.mode !== "text" && (
|
||||
<div className="flex flex-col gap-2 pb-1" role={qd.mode === "multi-select" ? "group" : "radiogroup"}>
|
||||
<div className="flex flex-col gap-2 pb-2" role={qd.mode === "multi-select" ? "group" : "radiogroup"}>
|
||||
{qd.options.map((opt, i) => {
|
||||
const isSel = sel.selected.has(i);
|
||||
return (
|
||||
@@ -307,14 +307,18 @@ function QuestionDockForm({
|
||||
)}
|
||||
>
|
||||
{isSel && (
|
||||
<span className={cn(qd.mode === "multi-select" ? "text-[10px]" : "size-1.5 rounded-full bg-current")}>
|
||||
<span className={cn(qd.mode === "multi-select" ? "text-xxs" : "size-1.5 rounded-full bg-current")}>
|
||||
{qd.mode === "multi-select" ? "✓" : ""}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span>
|
||||
<MessageResponse>{opt.label}</MessageResponse>
|
||||
{opt.description && <span className="block text-xs text-muted-foreground">{opt.description}</span>}
|
||||
{opt.description && (
|
||||
// Option subtext renders in Source Sans 3 (owner's mockup);
|
||||
// the "I don't know" copy stays JetBrains Mono.
|
||||
<span className="lh-prose block text-xs text-muted-foreground">{opt.description}</span>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
@@ -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
|
||||
<span
|
||||
className={cn(
|
||||
"mt-0.5 flex size-4 shrink-0 items-center justify-center rounded-full border",
|
||||
sel.dontKnow
|
||||
? "border-primary bg-primary text-primary-foreground"
|
||||
: "border-muted-foreground/40",
|
||||
)}
|
||||
>
|
||||
{sel.dontKnow && <span className="size-1.5 rounded-full bg-current" />}
|
||||
</span>
|
||||
<span>I don't know</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
@@ -357,7 +373,7 @@ function QuestionDockForm({
|
||||
)}
|
||||
>
|
||||
{sel.otherSelected && (
|
||||
<span className={cn(qd.mode === "multi-select" ? "text-[10px]" : "size-1.5 rounded-full bg-current")}>
|
||||
<span className={cn(qd.mode === "multi-select" ? "text-xxs" : "size-1.5 rounded-full bg-current")}>
|
||||
{qd.mode === "multi-select" ? "✓" : ""}
|
||||
</span>
|
||||
)}
|
||||
@@ -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()}
|
||||
/>
|
||||
</div>
|
||||
@@ -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"
|
||||
>
|
||||
<div className="px-2.5 pt-2 text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
|
||||
<div className="px-2.5 pt-2 text-xxs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
{qd.mode === "text" ? "Your answer" : "Your thinking"}
|
||||
</div>
|
||||
{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"
|
||||
/>
|
||||
) : (
|
||||
<textarea
|
||||
@@ -430,7 +446,7 @@ function QuestionDockForm({
|
||||
placeholder="Why this answer? What are you unsure about? Say as much as you like — the teacher reads all of this."
|
||||
rows={1}
|
||||
className={cn(
|
||||
"field-sizing-content max-h-48 w-full resize-none bg-transparent px-2.5 py-2 text-sm outline-none placeholder:text-muted-foreground/70",
|
||||
"field-sizing-content lh-prose max-h-48 w-full resize-none bg-transparent px-2.5 py-2 outline-none placeholder:text-muted-foreground/70",
|
||||
// Phase-scaled prominence: teaching invites depth,
|
||||
// probing stays quick.
|
||||
phase === "teach" ? "min-h-[4.5rem]" : "min-h-10",
|
||||
@@ -439,7 +455,7 @@ function QuestionDockForm({
|
||||
)}
|
||||
<div className="flex items-center justify-between gap-2 p-1">
|
||||
{qd.mode !== "text" && (
|
||||
<span aria-hidden className="pl-1.5 text-[10px] text-muted-foreground">
|
||||
<span aria-hidden className="pl-1.5 text-xxs text-muted-foreground">
|
||||
⌘/Ctrl + ↵ submits
|
||||
</span>
|
||||
)}
|
||||
|
||||
+56
-9
@@ -1,10 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useCallback, useContext, useEffect, useState } from "react";
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { PanelLeftCloseIcon, PanelLeftOpenIcon } from "lucide-react";
|
||||
import { PanelLeftCloseIcon, PanelLeftOpenIcon, PlusIcon, SearchIcon } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ChatListItem, Phase3 } from "@/src/core";
|
||||
|
||||
@@ -181,9 +182,26 @@ export function SessionRow({
|
||||
function SessionsSidebar() {
|
||||
const { open, toggle } = useSidebar();
|
||||
const [chats, setChats] = useState<ChatListItem[] | null>(null);
|
||||
const [query, setQuery] = useState("");
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
|
||||
/* New session: one POST, then hello. Search: a client-side filter over
|
||||
the poll snapshot — no server round trip. Both were visual-only in
|
||||
the wave-5 mock; wiring each is a single tiny handler. */
|
||||
const newSession = useCallback(async () => {
|
||||
const res = await fetch("/api/chats", { method: "POST", headers: { "content-type": "application/json" }, body: "{}" });
|
||||
const d = (await res.json().catch(() => null)) as { id?: string } | null;
|
||||
if (d?.id) router.push(`/chat/${d.id}`);
|
||||
}, [router]);
|
||||
|
||||
const shown = useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!chats) return chats;
|
||||
if (!q) return chats;
|
||||
return chats.filter((c) => c.title.toLowerCase().includes(q));
|
||||
}, [chats, query]);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch("/api/chats", { cache: "no-store" });
|
||||
@@ -213,22 +231,51 @@ function SessionsSidebar() {
|
||||
aria-hidden={!open}
|
||||
>
|
||||
<div className="flex h-full w-72 flex-col border-r bg-background">
|
||||
{/* LH wordmark — the owner's header; close toggle sits at the edge. */}
|
||||
<div className="flex items-center justify-between px-4 py-3">
|
||||
<Link href="/" className="font-semibold tracking-tight hover:opacity-80">
|
||||
Learning Harness
|
||||
<Link href="/" className="text-sm font-bold uppercase tracking-widest hover:opacity-80">
|
||||
LH
|
||||
</Link>
|
||||
<Button variant="ghost" size="icon" aria-label="Close sidebar" onClick={toggle}>
|
||||
<PanelLeftCloseIcon className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
data-testid="new-session"
|
||||
onClick={() => void newSession()}
|
||||
className="mx-3 mb-1 flex items-center gap-1.5 rounded-md px-2 py-1.5 text-xs uppercase tracking-wider text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
<PlusIcon className="size-3" />
|
||||
New session
|
||||
<span className="sr-only">create a new session</span>
|
||||
</button>
|
||||
|
||||
<div className="mx-3 mb-1 flex items-center gap-2">
|
||||
<span className="text-xxs uppercase tracking-widest text-muted-foreground/70">Sessions</span>
|
||||
<span className="relative flex-1">
|
||||
<Input
|
||||
data-testid="session-search"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Search..."
|
||||
aria-label="Search sessions"
|
||||
className="h-6 rounded-sm border-border bg-background pl-6 text-xs placeholder:text-muted-foreground/60"
|
||||
/>
|
||||
<SearchIcon className="pointer-events-none absolute left-1.5 top-1/2 size-3 -translate-y-1/2 text-muted-foreground/70" />
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-2">
|
||||
{chats === null ? (
|
||||
<div className="px-2 py-4 text-sm text-muted-foreground">Loading…</div>
|
||||
) : chats.length === 0 ? (
|
||||
<div className="px-2 py-4 text-sm text-muted-foreground">No sessions yet.</div>
|
||||
{shown === null ? (
|
||||
<div className="px-2 py-4 text-xs text-muted-foreground">Loading…</div>
|
||||
) : shown.length === 0 ? (
|
||||
<div className="px-2 py-4 text-xs text-muted-foreground">
|
||||
{query.trim() ? "No sessions match." : "No sessions yet."}
|
||||
</div>
|
||||
) : (
|
||||
chats.map((c) => (
|
||||
shown.map((c) => (
|
||||
<SessionRow
|
||||
key={c.id}
|
||||
chat={c}
|
||||
|
||||
@@ -48,7 +48,7 @@ export function StatsBar({
|
||||
// (so its transparent gradient area never blocks scrolling) — this row
|
||||
// must re-enable events or every control on it is dead (the model-
|
||||
// control wave caught this live: buttons rendered, clicks never landed).
|
||||
className="pointer-events-auto relative flex items-center justify-between gap-3 px-3 pt-1.5 text-[11px] text-muted-foreground"
|
||||
className="pointer-events-auto relative flex items-center justify-between gap-3 px-3 pt-1.5 text-xs text-muted-foreground"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
@@ -79,7 +79,7 @@ export function StatsBar({
|
||||
data-testid="stats-detail"
|
||||
className="absolute bottom-full left-0 z-40 mb-1.5 w-72 rounded-xl border bg-card p-3 text-xs shadow-xl"
|
||||
>
|
||||
<div className="mb-2 text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
|
||||
<div className="mb-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Session stats
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 text-muted-foreground">
|
||||
@@ -102,7 +102,7 @@ export function StatsBar({
|
||||
</div>
|
||||
{turns.length > 0 && (
|
||||
<>
|
||||
<div className="mb-1 mt-2.5 border-t pt-2 text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
|
||||
<div className="mb-1 mt-2.5 border-t pt-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Turns
|
||||
</div>
|
||||
<ul className="flex max-h-56 flex-col gap-1 overflow-y-auto">
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* GYpH-true ✓/✗ marks (owner's "old LaTeX style" round 6) — the real
|
||||
* Computer-Modern-adjacent hand-drawn shapes extracted as vector outlines
|
||||
* from the system Zapf Dingbats font (the exact font LaTeX pifont uses for
|
||||
* \ding{51} and \ding{55}), baked in as static SVG geometry (the KaTeX
|
||||
* pattern: glyph outlines are embedded, never rendered as font text).
|
||||
*
|
||||
* Bounds carry the ink box so each figure scales to its own geometry
|
||||
* (viewBox = minX -yMax w h with a y-flip on the path — SVG y grows
|
||||
* downward, font y grows upward).
|
||||
*/
|
||||
|
||||
export type MarkKind = "check" | "x";
|
||||
|
||||
const GLYPHS: Record<MarkKind, { d: string; x: number; y: number; w: number; h: number }> = {
|
||||
// ✓ U+2713 (LaTeX \ding{51}) — bounds (78, -20, 1470, 1444)
|
||||
check: {
|
||||
x: 78,
|
||||
y: -1444,
|
||||
w: 1392,
|
||||
h: 1464,
|
||||
d: "M1470 1409Q1211 1120 984.5 811.0Q758 502 568 177Q554 153 533 110Q468 -20 348 -20Q297 -20 264.0 -5.0Q231 10 203 47Q180 79 160.0 130.5Q140 182 113 282Q111 288 109 297Q78 408 78 436Q78 493 144.5 540.5Q211 588 276 588Q297 588 310.0 575.5Q323 563 340 522Q350 497 365 454Q399 358 422 358Q442 358 580 577Q712 786 807 928Q940 1127 1009.5 1215.5Q1079 1304 1135 1350Q1184 1390 1261.0 1413.0Q1338 1436 1456 1444Z",
|
||||
},
|
||||
// ✗ U+2717 BALLOT X (LaTeX \ding{55} — the owner's correct xmark; the
|
||||
// previous embedded text was ✕ U+2715/\ding{53} by mistake) — bounds
|
||||
// (0, -145, 1169, 1346)
|
||||
x: {
|
||||
x: 0,
|
||||
y: -1346,
|
||||
w: 1169,
|
||||
h: 1491,
|
||||
d: "M1169 1266Q1065 1134 964.5 1004.0Q864 874 766 743Q818 637 872.0 521.5Q926 406 983 276L954 252L956 231Q947 222 936.5 214.5Q926 207 913 201L924 168Q913 148 896.0 135.5Q879 123 859 119Q838 120 769.5 217.5Q701 315 588 504Q481 357 380.5 201.0Q280 45 186 -121Q162 -133 136.0 -139.0Q110 -145 82 -145H70Q41 -118 23.5 -87.5Q6 -57 6 -39Q6 -29 9.0 -15.0Q12 -1 18 20L0 53Q36 144 152.5 309.5Q269 475 469 723Q429 829 388.5 944.0Q348 1059 299 1206Q312 1234 327.0 1251.0Q342 1268 354 1268Q362 1268 370.0 1263.5Q378 1259 387 1249Q393 1255 401.0 1258.5Q409 1262 418 1262H426Q433 1272 441.0 1279.0Q449 1286 459 1292H461Q490 1292 571 1120Q620 1016 653 950Q712 1018 805 1129Q983 1341 1001 1341Q1012 1341 1029.5 1331.5Q1047 1322 1071 1303L1116 1346Q1132 1329 1145.5 1309.0Q1159 1289 1169 1266Z",
|
||||
},
|
||||
};
|
||||
|
||||
export function GlyphMark({ kind, className }: { kind: MarkKind; className?: string }) {
|
||||
const g = GLYPHS[kind];
|
||||
return (
|
||||
<svg
|
||||
aria-hidden
|
||||
className={className}
|
||||
viewBox={`${g.x} ${g.y} ${g.w} ${g.h}`}
|
||||
fill="currentColor"
|
||||
>
|
||||
<path d={g.d} transform="scale(1,-1)" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
Copyright (C) Authors of original metafont fonts:
|
||||
Donald Ervin Knuth (cm, concrete fonts)
|
||||
1995, 1996, 1997 J"org Knappen, 1990, 1992 Norbert Schwarz (ec fonts)
|
||||
1992-2006 A.Khodulev, O.Lapko, A.Berdnikov, V.Volovich (lh fonts)
|
||||
1997-2005 Claudio Beccari (cb greek fonts)
|
||||
2002 FUKUI Rei (tipa fonts)
|
||||
2003-2005 Han The Thanh (Vietnamese fonts)
|
||||
1996-2005 Walter Schmidt (cmbright fonts)
|
||||
|
||||
Copyright (C) 2003-2009, Andrey V. Panov (panov@canopus.iacp.dvo.ru),
|
||||
with Reserved Font Family Name "Computer Modern Unicode fonts".
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
http://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
Binary file not shown.
@@ -168,7 +168,8 @@ describe("render smoke tests", () => {
|
||||
const html = renderToStaticMarkup(<AnsweredCard answer={answer} />);
|
||||
assert.match(html, /incorrect/);
|
||||
assert.match(html, /innermost/, "explanation renders post-answer");
|
||||
assert.ok(html.includes("✓") && html.includes("✗"));
|
||||
// Glyph-shaped SVG marks (owner preference) — pinned via data-mark.
|
||||
assert.ok(html.includes('data-mark="check"') && html.includes('data-mark="x"'));
|
||||
});
|
||||
|
||||
it("AnsweredCard renders the learner's own Other answer AS an option row", () => {
|
||||
@@ -213,7 +214,7 @@ describe("render smoke tests", () => {
|
||||
};
|
||||
const answer = buildAnswer(qd, { values: ["Somewhat"] });
|
||||
const html = renderToStaticMarkup(<AnsweredCard answer={answer} />);
|
||||
assert.ok(!html.includes("incorrect") && !html.includes("✗"), "ask answers never get quiz grading UI");
|
||||
assert.ok(!html.includes("incorrect") && !html.includes('data-mark="x"'), "ask answers never get quiz grading UI");
|
||||
assert.match(html, /Somewhat/);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user