Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 47 additions & 3 deletions src/components/chat/ChatComposer.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -53,16 +59,21 @@ 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 (
<div className="relative flow-root font-sans text-sm" data-testid="composer-editor-root">
<div
className="relative flow-root font-sans"
style={{ fontSize: fontPx }}
data-testid="composer-editor-root"
>
<div
role="textbox"
aria-multiline="true"
aria-label="Message"
aria-placeholder={target}
data-testid="composer-editor"
tabIndex={0}
className="composer-tiptap -m-1 block max-h-52 min-h-[calc(2lh+--spacing(2))] overflow-y-auto p-1 whitespace-pre-wrap wrap-break-word bg-transparent leading-relaxed text-foreground focus:outline-none"
className="composer-tiptap -m-1 block max-h-[8lh] min-h-[calc(2lh+--spacing(2))] overflow-y-auto p-1 whitespace-pre-wrap wrap-break-word bg-transparent leading-relaxed text-foreground focus:outline-none"
>
{typed.map((ch, index) => {
const ok = goal[index] === ch;
Expand Down Expand Up @@ -113,6 +124,38 @@ function PromptKindLabel({ thread, prompt }: { thread: ThreadState; prompt: Prom
);
}

function FontSizeControl() {
const fontPx = useComposerFont();
return (
<div className="flex shrink-0 items-center" data-composer-font-size={fontPx}>
<Button
variant="ghost"
size="icon-sm"
aria-label="Smaller prompt text"
title="Smaller prompt text"
tabIndex={-1}
disabled={fontPx <= COMPOSER_FONT_MIN}
onMouseDown={(event) => event.preventDefault()}
onClick={() => stepComposerFont(-1)}
>
<AArrowDownIcon />
</Button>
<Button
variant="ghost"
size="icon-sm"
aria-label="Larger prompt text"
title="Larger prompt text"
tabIndex={-1}
disabled={fontPx >= COMPOSER_FONT_MAX}
onMouseDown={(event) => event.preventDefault()}
onClick={() => stepComposerFont(1)}
>
<AArrowUpIcon />
</Button>
</div>
);
}

function SendButton({ enabled }: { enabled: boolean }) {
return (
<span className="inline-flex" title="Send (Enter)">
Expand Down Expand Up @@ -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"
>
<FontSizeControl />
<ProviderModelPicker thread={thread.def} />
</div>
{working ? <StopButton /> : <SendButton enabled={!locked && thread.draft.trim().length > 0} />}
Expand Down
47 changes: 47 additions & 0 deletions src/lib/composerFont.ts
Original file line number Diff line number Diff line change
@@ -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,
);
}
10 changes: 10 additions & 0 deletions tests/lib.test.ts
Original file line number Diff line number Diff line change
@@ -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", () => {
Expand All @@ -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);
});
});
21 changes: 21 additions & 0 deletions tests/play.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Loading