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:
amosblomqvist
2026-10-05 04:39:47 +03:00
parent 05f083748b
commit 9b75b4f785
8 changed files with 46 additions and 33 deletions
+2 -2
View File
@@ -150,7 +150,7 @@ export function AnsweredCard({ answer }: { answer: QuestionAnswerData }) {
verdict shows, but the explanation sits collapsed — viewable, verdict shows, but the explanation sits collapsed — viewable,
never center stage. The teaching that follows resolves the never center stage. The teaching that follows resolves the
prediction; THAT is the real explanation. */ prediction; THAT is the real explanation. */
<div className="mt-3" data-testid="tucked-explanation"> <div className="mt-2" data-testid="tucked-explanation">
<button <button
type="button" type="button"
onClick={() => setWhyOpen((v) => !v)} onClick={() => setWhyOpen((v) => !v)}
@@ -168,7 +168,7 @@ export function AnsweredCard({ answer }: { answer: QuestionAnswerData }) {
) : ( ) : (
isQuiz && isQuiz &&
answer.explanation && ( 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> <span className="font-medium text-muted-foreground">Why: </span>
{/* The markdown wrapper must go truly inline — a block <p> after the {/* The markdown wrapper must go truly inline — a block <p> after the
"Why:" label created the oversized leading gap (owner's fix list). */} "Why:" label created the oversized leading gap (owner's fix list). */}
+6 -5
View File
@@ -1259,14 +1259,15 @@ export function Chat({
User text renders plain — the model's LaTeX pipeline is not User text renders plain — the model's LaTeX pipeline is not
applied to learner-authored text ($ stays $, no surprises). applied to learner-authored text ($ stays $, no surprises).
Rendered WITHOUT the Message wrapper — that is the bubble. */ Rendered WITHOUT the Message wrapper — that is the bubble. */
<div data-testid="user-message" className="my-1 w-full"> <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">
<div className="mb-0.5 text-xs font-medium uppercase tracking-wide text-muted-foreground">You</div> {/* The rail GROUPS (owner round 4, 2026-10-05): the YOU heading
<div className="border-l-2 border-muted-foreground/30 pl-3 text-foreground"> sits INSIDE the rail, same mono-xs register and same 1ch
<div className="lh-prose whitespace-pre-wrap"> 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")} {m.parts.filter((p) => p.type === "text").map((p) => p.text).join("\n")}
</div> </div>
</div> </div>
</div>
) : ( ) : (
<Message from="assistant" className="max-w-full"> <Message from="assistant" className="max-w-full">
<MessageContent className="w-full"> <MessageContent className="w-full">
+14 -8
View File
@@ -3,6 +3,7 @@
import { useCallback, useRef, useState } from "react"; import { useCallback, useRef, useState } from "react";
import { ChevronRightIcon } from "lucide-react"; import { ChevronRightIcon } from "lucide-react";
import { MessageResponse } from "@/components/ai-elements/message"; import { MessageResponse } from "@/components/ai-elements/message";
import { Button } from "@/components/ui/button";
import { joinDictation, useDictationTarget } from "@/components/dictation"; import { joinDictation, useDictationTarget } from "@/components/dictation";
import { cn } from "@/lib/utils"; 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. */} Shift+Enter makes a newline — less to decide about. */}
<div <div
data-testid="practice-thinking-field" 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"> <div className="px-2.5 pt-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
YOUR THINKING 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", "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, {/* The submit row (owner round 4, 2026-10-05): IDENTICAL to the
right-aligned. */} question dock's — hint left, the SAME Button (h-9, px-3.5),
<div className="flex items-center justify-end px-1.5 pb-1.5"> one p-1 row. Corner radii concentric: shell 16 − m-1.5 (6) =
<button 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" type="button"
data-testid="practice-send" data-testid="practice-send"
disabled={thinking.trim().length === 0} disabled={thinking.trim().length === 0}
onClick={() => submit()} 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 Submit thinking
</button> </Button>
</div> </div>
</div> </div>
</div> </div>
+2 -2
View File
@@ -769,7 +769,7 @@ function QuestionDockForm({
> >
<div <div
data-testid={qd.mode === "text" ? "answer-field" : "thinking-field"} 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"> <div className="px-2.5 pt-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
{qd.mode === "text" ? "Your answer" : "YOUR THINKING"} {qd.mode === "text" ? "Your answer" : "YOUR THINKING"}
@@ -846,7 +846,7 @@ function QuestionDockForm({
<Button <Button
type="submit" type="submit"
disabled={!canSubmit} 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 Submit answer
</Button> </Button>
+12 -8
View File
@@ -31,27 +31,31 @@ export function ThinkingBlock({
}) { }) {
const [open, setOpen] = useState(false); // arrives collapsed, always const [open, setOpen] = useState(false); // arrives collapsed, always
return ( return (
/* The rail GROUPS (owner correction, 2026-10-05): the label sits /* The rail GROUPS and the rail IS THE MISSING CHARACTER (owner
INSIDE the rail — the block reads as one quote, ownership clear rounds, 2026-10-05): the heading sits INSIDE the rail, and the
from the rail alone, collapsed or expanded. Same shape as the rail's full width (border + padding) is exactly ONE CHARACTER of
user text ("You") row. */ 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 <div
data-testid={testId} data-testid={testId}
data-commit={dataCommit === undefined ? undefined : dataCommit ? "true" : "false"} 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 <button
type="button" type="button"
data-testid={testId ? `${testId}-trigger` : undefined} data-testid={testId ? `${testId}-trigger` : undefined}
onClick={() => setOpen((v) => !v)} onClick={() => setOpen((v) => !v)}
aria-expanded={open} 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} {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> </button>
{open && ( {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> </div>
); );
+3 -1
View File
@@ -39,6 +39,8 @@ export function prettyModel(id: string, qualified = false): string {
const short = bare.includes("/") ? bare.slice(bare.lastIndexOf("/") + 1) : bare; const short = bare.includes("/") ? bare.slice(bare.lastIndexOf("/") + 1) : bare;
const parts = short.split("-"); const parts = short.split("-");
const trimmed = /^\d+$/.test(parts[parts.length - 1] ?? "") ? parts.slice(0, -1) : parts; 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; return qualified && provider ? `${pretty} · ${provider}` : pretty;
} }
+1 -1
View File
@@ -155,7 +155,7 @@ describe("AgentSection (thin SSR)", () => {
); );
// The trigger shows the stored id even with no catalog loaded (a // The trigger shows the stored id even with no catalog loaded (a
// trigger reading "Default" over a live override would lie). // 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-demo").includes('aria-checked="false"'), "demo off");
assert.ok(tagOf(html, "toggle-tool-visual").includes('aria-checked="true"'), "visual still on"); assert.ok(tagOf(html, "toggle-tool-visual").includes('aria-checked="true"'), "visual still on");
}); });
+6 -6
View File
@@ -77,20 +77,20 @@ describe("stats bar", () => {
assert.match(html, /data-testid="stats-context">11k</); assert.match(html, /data-testid="stats-context">11k</);
}); });
it("prettyModel strips vendor + date suffix, capitalizes", () => { it("prettyModel strips vendor + date suffix (no auto-caps)", () => {
assert.equal(prettyModel("z-ai/glm-5.3-flash"), "Glm 5.3 Flash"); 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"); assert.equal(prettyModel("qwen/qwen3.8-max-0902"), "qwen3.8 max");
}); });
it("provider-qualified stamps (usage contract, model-slots wave)", () => { it("provider-qualified stamps (usage contract, model-slots wave)", () => {
// qualified: the provider rides as a quiet suffix — federation makes // qualified: the provider rides as a quiet suffix — federation makes
// the same model via two providers different bookkeeping // 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"); assert.equal(prettyModel("openai:gpt-5.2", true), "Gpt 5.2 · openai");
// unqualified on a qualified stamp: provider silently dropped (old shape) // 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 // 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");
}); });
}); });