From d1f085550b069d6af188e68eb9374f6d7731bacc Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Tue, 6 Oct 2026 03:55:14 +0300 Subject: [PATCH] =?UTF-8?q?practice:=20the=20next=20affordance=20IS=20the?= =?UTF-8?q?=20send=20button=20=E2=80=94=20wide=20green=20morph=20in=20the?= =?UTF-8?q?=20pause=20(round=2016=20refinement)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Owner correction (2026-10-06): the floating chip clipped inside the composer band. Replaced with the button morph: an EMPTY composer in the between-problems pause renders a wide green 'Next problem' button (bg-green-600 — the answered-card correct register, mono-caps label, arrow glyph); typing anything returns the regular white send; busy (steer/stop) outranks the morph. Click and Enter share the one act() lane, so the button also teaches the Enter-on-empty behavior. The wrapper div + chip are gone from chat.tsx; inputAppends widens its record param to { messages; kind? } (the practice gate reads it without a cast). --- components/chat.tsx | 57 ++++++++++++---------------------- components/composer.tsx | 49 ++++++++++++++++++++--------- src/core/input-events.ts | 7 +++-- tests/render-composer.test.tsx | 25 ++++++++++++++- 4 files changed, 83 insertions(+), 55 deletions(-) diff --git a/components/chat.tsx b/components/chat.tsx index d71189a..85e3b98 100644 --- a/components/chat.tsx +++ b/components/chat.tsx @@ -1541,43 +1541,26 @@ export function Chat({ ) } composer={ -
- {/* THE NEXT CHIP (owner round 16): the practice pause's - mouse path — hovering at the composer band's top-right, - the pick-pill chrome register. One click = the move-on - signal; gone the moment a problem is active again. */} - {nextReady && ( - - )} - -
+ } onSubmitAnswer={(output) => { if (!pending) return; diff --git a/components/composer.tsx b/components/composer.tsx index 3fb4558..c5c7db4 100644 --- a/components/composer.tsx +++ b/components/composer.tsx @@ -1,6 +1,6 @@ "use client"; -import { CornerDownLeftIcon, SquareIcon } from "lucide-react"; +import { ArrowRightIcon, CornerDownLeftIcon, SquareIcon } from "lucide-react"; import { cn } from "@/lib/utils"; import { Hint } from "@/components/ui/hint"; @@ -124,20 +124,39 @@ export function Composer({ only an empty composer morphs it to Stop. ESC keeps its confirm-popup abort in every state, so stop is never unreachable. No tooltip — the ↵ glyph is the affordance - (owner, subtlety round 2026-09-25). */} - + (owner, subtlety round 2026-09-25). + + THE NEXT MORPH (owner round 16 refinement, 2026-10-06): an + empty composer in the practice pause morphs the button into + a wide green NEXT PROBLEM — the affordance IS the button + (click and Enter share one lane), and typing anything + returns the regular send. The green is the app's correct- + answer register (answered-card's green-500/600). */} + {nextProblem && !busy && !canSend ? ( + + ) : ( + + )} ); } diff --git a/src/core/input-events.ts b/src/core/input-events.ts index 35c25e7..42d6985 100644 --- a/src/core/input-events.ts +++ b/src/core/input-events.ts @@ -194,7 +194,10 @@ export function parseChatInput(input: unknown): ChatInput { * else 400s honestly instead of silently interrupting a question or * double-answering. */ -export function inputAppends(record: { messages: LhMessage[] }, input: ChatInput): LhMessage[] { +export function inputAppends( + record: { messages: LhMessage[]; kind?: string }, + input: ChatInput, +): LhMessage[] { switch (input.type) { case "text": return [...dismissalConclusion(record), newUserMessage(input.text)]; @@ -219,7 +222,7 @@ export function inputAppends(record: { messages: LhMessage[] }, input: ChatInput // between-problems affordance — practice sessions only, nothing // active (mid-problem input rides the dock), and the client gates // make violations unreachable in practice anyway. - if ((record as { kind?: string }).kind !== "practice") { + if (record.kind !== "practice") { throw new ChatInputError("next input is a practice-session affordance"); } if (activeProblemOf(record.messages)) { diff --git a/tests/render-composer.test.tsx b/tests/render-composer.test.tsx index a42a04d..23b3714 100644 --- a/tests/render-composer.test.tsx +++ b/tests/render-composer.test.tsx @@ -13,7 +13,9 @@ import { describe, it } from "node:test"; import { renderToStaticMarkup } from "react-dom/server"; import { Composer, enterAction } from "../components/composer.tsx"; -function render(opts: { busy?: boolean; canSend?: boolean; placeholder?: string } = {}) { +function render( + opts: { busy?: boolean; canSend?: boolean; placeholder?: string; nextProblem?: boolean } = {}, +) { return renderToStaticMarkup( {}} canSend={opts.canSend ?? true} {...(opts.placeholder ? { placeholder: opts.placeholder } : {})} + {...(opts.nextProblem ? { nextProblem: true, onNext: () => {} } : {})} />, ); } @@ -58,6 +61,26 @@ describe("Composer", () => { const html = render({ canSend: false }); assert.ok(/aria-label="Send"[^>]*disabled|disabled[^>]*aria-label="Send"/.test(html), "send disabled"); }); + + it("THE NEXT MORPH (owner round 16 refinement): empty composer in the pause → wide green Next problem", () => { + const html = render({ canSend: false, nextProblem: true }); + assert.ok(html.includes('data-testid="next-problem-button"'), "the morphed button"); + assert.match(html, /Next problem/, "the label"); + assert.ok(html.includes("bg-green-600"), "green — the correct-answer register"); + assert.ok(!html.includes('aria-label="Send"'), "the square send is gone in the pause"); + }); + + it("typing anything returns the regular send button (canSend outranks the morph)", () => { + const html = render({ canSend: true, nextProblem: true }); + assert.ok(html.includes('aria-label="Send"'), "regular send while there is text"); + assert.ok(!html.includes('data-testid="next-problem-button"'), "no next morph with text typed"); + }); + + it("busy outranks the morph — stop stays reachable", () => { + const html = render({ busy: true, canSend: false, nextProblem: true }); + assert.ok(html.includes('aria-label="Stop"'), "stop wins the empty lane mid-turn"); + assert.ok(!html.includes('data-testid="next-problem-button"'), "no next morph mid-turn"); + }); }); // The Enter decision (composer steering, 2026-10-03): while a turn runs,