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();