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,
|
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
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
@@ -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");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user