diff --git a/src/components/chat/ChatComposer.tsx b/src/components/chat/ChatComposer.tsx index 40b9a62..2356e1d 100644 --- a/src/components/chat/ChatComposer.tsx +++ b/src/components/chat/ChatComposer.tsx @@ -1,7 +1,13 @@ -import { FolderIcon, GitBranchIcon, PaperclipIcon } from "lucide-react"; +import { AArrowDownIcon, AArrowUpIcon, FolderIcon, GitBranchIcon, PaperclipIcon } from "lucide-react"; import type { ComponentProps } from "react"; import { personOf, sourceAuthor, threadBranch, type PromptDef } from "~/content/prompts"; import { isWorking, nextPrompt, type GameState, type ThreadState } from "~/game/store"; +import { + COMPOSER_FONT_MAX, + COMPOSER_FONT_MIN, + stepComposerFont, + useComposerFont, +} from "~/lib/composerFont"; import { cn } from "~/lib/utils"; import { Button } from "../ui/button"; import { ComposerControl, ComposerPermissionsChip, ProviderModelPicker } from "./ComposerControls"; @@ -53,8 +59,13 @@ function Main({ className, ...props }: ComponentProps<"div">) { function GhostEditor({ draft, target, locked }: { draft: string; target: string; locked: boolean }) { const typed = Array.from(draft); const goal = Array.from(target); + const fontPx = useComposerFont(); return ( -
+
{typed.map((ch, index) => { const ok = goal[index] === ch; @@ -113,6 +124,38 @@ function PromptKindLabel({ thread, prompt }: { thread: ThreadState; prompt: Prom ); } +function FontSizeControl() { + const fontPx = useComposerFont(); + return ( +
+ + +
+ ); +} + function SendButton({ enabled }: { enabled: boolean }) { return ( @@ -237,6 +280,7 @@ export function ChatComposer({ game, thread }: { game: GameState; thread: Thread data-chat-composer-controls="footer" className="relative flex min-w-0 flex-1 items-center justify-end gap-0.5" > +
{working ? : 0} />} diff --git a/src/lib/composerFont.ts b/src/lib/composerFont.ts new file mode 100644 index 0000000..bb3d23f --- /dev/null +++ b/src/lib/composerFont.ts @@ -0,0 +1,47 @@ +import { useSyncExternalStore } from "react"; + +export const COMPOSER_FONT_DEFAULT = 20; +export const COMPOSER_FONT_MIN = 14; +export const COMPOSER_FONT_MAX = 32; +export const COMPOSER_FONT_STEP = 2; +const STORAGE_KEY = "t3type.composerFontPx"; + +export function clampComposerFont(px: number): number { + if (!Number.isFinite(px)) return COMPOSER_FONT_DEFAULT; + return Math.min(COMPOSER_FONT_MAX, Math.max(COMPOSER_FONT_MIN, Math.round(px))); +} + +function readStored(): number { + try { + const raw = localStorage.getItem(STORAGE_KEY); + return raw === null ? COMPOSER_FONT_DEFAULT : clampComposerFont(Number(raw)); + } catch { + return COMPOSER_FONT_DEFAULT; + } +} + +let current = typeof window === "undefined" ? COMPOSER_FONT_DEFAULT : readStored(); +const listeners = new Set<() => void>(); + +export function stepComposerFont(direction: 1 | -1) { + const next = clampComposerFont(current + direction * COMPOSER_FONT_STEP); + if (next === current) return; + current = next; + try { + localStorage.setItem(STORAGE_KEY, String(next)); + } catch {} + listeners.forEach((listener) => listener()); +} + +function subscribe(listener: () => void) { + listeners.add(listener); + return () => listeners.delete(listener); +} + +export function useComposerFont(): number { + return useSyncExternalStore( + subscribe, + () => current, + () => COMPOSER_FONT_DEFAULT, + ); +} diff --git a/tests/lib.test.ts b/tests/lib.test.ts index 685e184..ff06bf7 100644 --- a/tests/lib.test.ts +++ b/tests/lib.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import { compactSidebarTimeLabel, formatRelativeElapsed } from "~/lib/time"; +import { clampComposerFont } from "~/lib/composerFont"; import { cn } from "~/lib/utils"; describe("compactSidebarTimeLabel", () => { @@ -19,3 +20,12 @@ describe("cn", () => { expect(cn("text-2xs", "text-sm")).toBe("text-sm"); }); }); + +describe("clampComposerFont", () => { + it("keeps sizes inside 14 to 32 and falls back to 20 for junk", () => { + expect(clampComposerFont(8)).toBe(14); + expect(clampComposerFont(40)).toBe(32); + expect(clampComposerFont(22.4)).toBe(22); + expect(clampComposerFont(Number("abc"))).toBe(20); + }); +}); diff --git a/tests/play.mjs b/tests/play.mjs index cf160ff..7601510 100644 --- a/tests/play.mjs +++ b/tests/play.mjs @@ -205,11 +205,32 @@ async function checkPerPromptWpmInLight(browser) { await page.close(); } +async function checkComposerFontSize(browser) { + const { page, errors } = await openPage(browser, "dark", "speed=4"); + const editorPx = () => + page.locator('[data-testid="composer-editor"]').evaluate((el) => parseFloat(getComputedStyle(el).fontSize)); + check((await editorPx()) === 20, `prompt text defaults to 20px (got ${await editorPx()})`); + await page.getByRole("button", { name: "Larger prompt text" }).click(); + await page.getByRole("button", { name: "Larger prompt text" }).click(); + check((await editorPx()) === 24, `two clicks on larger give 24px (got ${await editorPx()})`); + await page.keyboard.type("ab"); + check(activeOf(await state(page)).draft === "ab", "typing still reaches the composer after clicking the size buttons"); + await page.reload(); + await page.waitForFunction(() => window.__game !== undefined); + check((await editorPx()) === 24, `size survives a reload (got ${await editorPx()})`); + await page.screenshot({ path: join(shots, "dark-font-24.png") }); + for (let i = 0; i < 20; i += 1) await page.getByRole("button", { name: "Smaller prompt text" }).click({ force: true }); + check((await editorPx()) === 14, `size stops at 14px (got ${await editorPx()})`); + check(errors.length === 0, `no console errors in font size run${errors.length ? `: ${errors.join(" | ")}` : ""}`); + await page.close(); +} + const browser = await chromium.launch(); try { await playFullSessionInDark(browser); await checkAutoSettle(browser); await checkPerPromptWpmInLight(browser); + await checkComposerFontSize(browser); } finally { await browser.close(); server?.kill();