ui: consistency sweep — the 1ch rail geometry, Submit thinking, no auto-caps
Owner round 4 (2026-10-05):
- THE RAIL IS THE MISSING CHARACTER: rail + padding = exactly 1ch of
the mono heading font. 'YOUR THINKING' (13ch) + rail == 'AGENT
THINKING' (14ch) flush — the chevrons line up vertically, no fixed
columns. The YOU heading joins the rail (label inside, like the
thinking rows) — one quote geometry everywhere.
- HEADING REGISTER: YOUR THINKING / AGENT THINKING / YOU all use the
card eyebrows' small mono caps (text-xs = the 11px rung, font-medium
uppercase tracking-wide) — the app chrome is JetBrains Mono.
- SUBMIT THINKING: the practice button is now literally the question
dock's Button (h-9, px-3.5) in one p-1 row with the hint left
('↵ submits · ⇧↵ newline') — the keyboard hints are back. Corner
radii concentric in both docks: shell 16 − 6 = field 10 − 4 = button 6
(rounded-[10px] fields, rounded-[6px] buttons — the old rounded-xl/12
was the 'too large' culprit).
- MODEL NAMES: prettyModel no longer auto-capitalizes — names render
exactly as their providers write them ('glm 5.3 flash').
- Quiz explanations pulled closer (mt-3 → mt-2).
This commit is contained in:
@@ -150,7 +150,7 @@ export function AnsweredCard({ answer }: { answer: QuestionAnswerData }) {
|
||||
verdict shows, but the explanation sits collapsed — viewable,
|
||||
never center stage. The teaching that follows resolves the
|
||||
prediction; THAT is the real explanation. */
|
||||
<div className="mt-3" data-testid="tucked-explanation">
|
||||
<div className="mt-2" data-testid="tucked-explanation">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setWhyOpen((v) => !v)}
|
||||
@@ -168,7 +168,7 @@ export function AnsweredCard({ answer }: { answer: QuestionAnswerData }) {
|
||||
) : (
|
||||
isQuiz &&
|
||||
answer.explanation && (
|
||||
<div className="mt-3 rounded-lg bg-muted py-2 text-sm text-foreground/50">
|
||||
<div className="mt-2 rounded-lg bg-muted py-2 text-sm text-foreground/50">
|
||||
<span className="font-medium text-muted-foreground">Why: </span>
|
||||
{/* The markdown wrapper must go truly inline — a block <p> after the
|
||||
"Why:" label created the oversized leading gap (owner's fix list). */}
|
||||
|
||||
+6
-5
@@ -1259,14 +1259,15 @@ export function Chat({
|
||||
User text renders plain — the model's LaTeX pipeline is not
|
||||
applied to learner-authored text ($ stays $, no surprises).
|
||||
Rendered WITHOUT the Message wrapper — that is the bubble. */
|
||||
<div data-testid="user-message" className="my-1 w-full">
|
||||
<div className="mb-0.5 text-xs font-medium uppercase tracking-wide text-muted-foreground">You</div>
|
||||
<div className="border-l-2 border-muted-foreground/30 pl-3 text-foreground">
|
||||
<div className="lh-prose whitespace-pre-wrap">
|
||||
<div data-testid="user-message" className="my-1 w-full border-l-2 border-muted-foreground/30 pl-[calc(1ch-2px)] font-ui text-xs">
|
||||
{/* The rail GROUPS (owner round 4, 2026-10-05): the YOU heading
|
||||
sits INSIDE the rail, same mono-xs register and same 1ch
|
||||
rail as the thinking rows — one quote geometry. */}
|
||||
<div className="font-medium uppercase tracking-wide text-muted-foreground">You</div>
|
||||
<div className="lh-prose whitespace-pre-wrap text-foreground">
|
||||
{m.parts.filter((p) => p.type === "text").map((p) => p.text).join("\n")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Message from="assistant" className="max-w-full">
|
||||
<MessageContent className="w-full">
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { ChevronRightIcon } from "lucide-react";
|
||||
import { MessageResponse } from "@/components/ai-elements/message";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { joinDictation, useDictationTarget } from "@/components/dictation";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
@@ -102,7 +103,7 @@ export function PracticeDock({ problemCallId, input, expanded, onExpand, onSubmi
|
||||
Shift+Enter makes a newline — less to decide about. */}
|
||||
<div
|
||||
data-testid="practice-thinking-field"
|
||||
className="m-1.5 rounded-xl border border-border/70 bg-background/60 transition-colors focus-within:border-primary/50"
|
||||
className="m-1.5 rounded-[10px] border border-border/70 bg-background/60 transition-colors focus-within:border-primary/50"
|
||||
>
|
||||
<div className="px-2.5 pt-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
YOUR THINKING
|
||||
@@ -135,18 +136,23 @@ export function PracticeDock({ problemCallId, input, expanded, onExpand, onSubmi
|
||||
"field-sizing-content lh-input max-h-64 min-h-16 w-full resize-none bg-transparent px-2.5 pb-2 outline-none placeholder:text-muted-foreground",
|
||||
)}
|
||||
/>
|
||||
{/* The one Send — docked inside the field like the composer's,
|
||||
right-aligned. */}
|
||||
<div className="flex items-center justify-end px-1.5 pb-1.5">
|
||||
<button
|
||||
{/* The submit row (owner round 4, 2026-10-05): IDENTICAL to the
|
||||
question dock's — hint left, the SAME Button (h-9, px-3.5),
|
||||
one p-1 row. Corner radii concentric: shell 16 − m-1.5 (6) =
|
||||
field 10 − p-1 (4) = button 6. */}
|
||||
<div className="flex items-center justify-between gap-2 p-1">
|
||||
<span aria-hidden className="pl-1.5 text-xs text-muted-foreground">
|
||||
↵ submits · ⇧↵ newline
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
data-testid="practice-send"
|
||||
disabled={thinking.trim().length === 0}
|
||||
onClick={() => submit()}
|
||||
className="rounded-lg bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 disabled:pointer-events-none disabled:opacity-40"
|
||||
className="ml-auto h-9 rounded-[6px] px-3.5 text-sm font-medium"
|
||||
>
|
||||
Send
|
||||
</button>
|
||||
Submit thinking
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -769,7 +769,7 @@ function QuestionDockForm({
|
||||
>
|
||||
<div
|
||||
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"
|
||||
className="rounded-[10px] border border-border/70 bg-background/60 transition-colors focus-within:border-primary/50"
|
||||
>
|
||||
<div className="px-2.5 pt-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
{qd.mode === "text" ? "Your answer" : "YOUR THINKING"}
|
||||
@@ -846,7 +846,7 @@ function QuestionDockForm({
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={!canSubmit}
|
||||
className="ml-auto h-9 rounded-[10px] px-3.5 text-sm font-medium"
|
||||
className="ml-auto h-9 rounded-[6px] px-3.5 text-sm font-medium"
|
||||
>
|
||||
Submit answer
|
||||
</Button>
|
||||
|
||||
@@ -31,27 +31,31 @@ export function ThinkingBlock({
|
||||
}) {
|
||||
const [open, setOpen] = useState(false); // arrives collapsed, always
|
||||
return (
|
||||
/* The rail GROUPS (owner correction, 2026-10-05): the label sits
|
||||
INSIDE the rail — the block reads as one quote, ownership clear
|
||||
from the rail alone, collapsed or expanded. Same shape as the
|
||||
user text ("You") row. */
|
||||
/* The rail GROUPS and the rail IS THE MISSING CHARACTER (owner
|
||||
rounds, 2026-10-05): the heading sits INSIDE the rail, and the
|
||||
rail's full width (border + padding) is exactly ONE CHARACTER of
|
||||
the mono heading font — 'YOUR THINKING' is one char shorter than
|
||||
'AGENT THINKING', so with the user row offset by the rail and the
|
||||
agent row flush, the chevrons line up vertically. The heading
|
||||
register is the card eyebrows' small mono caps (text-xs = the
|
||||
11px rung). */
|
||||
<div
|
||||
data-testid={testId}
|
||||
data-commit={dataCommit === undefined ? undefined : dataCommit ? "true" : "false"}
|
||||
className="lh-card-enter my-1 w-full border-l-2 border-muted-foreground/30 pl-3"
|
||||
className="lh-card-enter my-1 w-full border-l-2 border-muted-foreground/30 pl-[calc(1ch-2px)] font-ui text-xs"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
data-testid={testId ? `${testId}-trigger` : undefined}
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
aria-expanded={open}
|
||||
className="flex w-full items-center gap-2 text-muted-foreground text-sm transition-colors hover:text-foreground"
|
||||
className="flex w-full items-center gap-2 font-medium uppercase tracking-wide text-muted-foreground transition-colors hover:text-foreground"
|
||||
>
|
||||
{label}
|
||||
<ChevronDownIcon className={cn("size-4 transition-transform", open ? "rotate-180" : "rotate-0")} />
|
||||
<ChevronDownIcon className={cn("size-4 shrink-0 transition-transform", open ? "rotate-180" : "rotate-0")} />
|
||||
</button>
|
||||
{open && (
|
||||
<span className="lh-prose mt-1.5 block whitespace-pre-wrap italic text-foreground/50">{text}</span>
|
||||
<span className="lh-prose mt-1.5 block whitespace-pre-wrap text-[15px] italic text-foreground/50">{text}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -39,6 +39,8 @@ export function prettyModel(id: string, qualified = false): string {
|
||||
const short = bare.includes("/") ? bare.slice(bare.lastIndexOf("/") + 1) : bare;
|
||||
const parts = short.split("-");
|
||||
const trimmed = /^\d+$/.test(parts[parts.length - 1] ?? "") ? parts.slice(0, -1) : parts;
|
||||
const pretty = trimmed.map((p) => p.charAt(0).toUpperCase() + p.slice(1)).join(" ");
|
||||
// No auto-capitalization (owner round 4, 2026-10-05): model names
|
||||
// render exactly as their providers write them.
|
||||
const pretty = trimmed.join(" ");
|
||||
return qualified && provider ? `${pretty} · ${provider}` : pretty;
|
||||
}
|
||||
@@ -155,7 +155,7 @@ describe("AgentSection (thin SSR)", () => {
|
||||
);
|
||||
// The trigger shows the stored id even with no catalog loaded (a
|
||||
// trigger reading "Default" over a live override would lie).
|
||||
assert.ok(html.includes("Glm 4.7 Flash"), "the override id is visible (prettyModel form)");
|
||||
assert.ok(html.includes("glm 4.7 flash"), "the override id is visible (prettyModel form, no auto-caps)");
|
||||
assert.ok(tagOf(html, "toggle-tool-demo").includes('aria-checked="false"'), "demo off");
|
||||
assert.ok(tagOf(html, "toggle-tool-visual").includes('aria-checked="true"'), "visual still on");
|
||||
});
|
||||
|
||||
@@ -77,20 +77,20 @@ describe("stats bar", () => {
|
||||
assert.match(html, /data-testid="stats-context">11k</);
|
||||
});
|
||||
|
||||
it("prettyModel strips vendor + date suffix, capitalizes", () => {
|
||||
assert.equal(prettyModel("z-ai/glm-5.3-flash"), "Glm 5.3 Flash");
|
||||
assert.equal(prettyModel("qwen/qwen3.8-max-0902"), "Qwen3.8 Max");
|
||||
it("prettyModel strips vendor + date suffix (no auto-caps)", () => {
|
||||
assert.equal(prettyModel("z-ai/glm-5.3-flash"), "glm 5.3 flash"); // no auto-caps (owner round 4)
|
||||
assert.equal(prettyModel("qwen/qwen3.8-max-0902"), "qwen3.8 max");
|
||||
});
|
||||
|
||||
it("provider-qualified stamps (usage contract, model-slots wave)", () => {
|
||||
// qualified: the provider rides as a quiet suffix — federation makes
|
||||
// the same model via two providers different bookkeeping
|
||||
assert.equal(prettyModel("openrouter:z-ai/glm-5.3-flash", true), "Glm 5.3 Flash · openrouter");
|
||||
assert.equal(prettyModel("openrouter:z-ai/glm-5.3-flash", true), "glm 5.3 flash · openrouter");
|
||||
assert.equal(prettyModel("openai:gpt-5.2", true), "Gpt 5.2 · openai");
|
||||
// unqualified on a qualified stamp: provider silently dropped (old shape)
|
||||
assert.equal(prettyModel("openrouter:z-ai/glm-5.3-flash"), "Glm 5.3 Flash");
|
||||
assert.equal(prettyModel("openrouter:z-ai/glm-5.3-flash"), "glm 5.3 flash");
|
||||
// qualified on a bare id: no suffix invented
|
||||
assert.equal(prettyModel("z-ai/glm-5.3-flash", true), "Glm 5.3 Flash");
|
||||
assert.equal(prettyModel("z-ai/glm-5.3-flash", true), "glm 5.3 flash");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user