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,
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
View File
@@ -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">
+14 -8
View File
@@ -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>
+2 -2
View File
@@ -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>
+12 -8
View File
@@ -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>
);
+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 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;
}
+1 -1
View File
@@ -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");
});
+6 -6
View File
@@ -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");
});
});