Read the web app at 16px base size on phones (#155)

Text sizes now follow a --font-scale variable that is 1 on wider screens and
8/7 below 768px, so the 14px base text reads at 16px on phones (Safari stops
zooming into fields at that size) and every other size grows by the same
ratio. One-off pixel sizes in the shell move onto the shared text tokens so
they scale too. The stage subtitle now truncates inside the screen instead
of spilling past both edges.


Claude-Session: https://claude.ai/code/session_01PFEDgbyJm12ExL3Kn4Dk8B

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
cynarAI 2026-10-05 12:42:30 +02:00 committed by GitHub
parent 404a1d1782
commit 738811dbe7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 96 additions and 32 deletions

View File

@ -0,0 +1,41 @@
import { expect, type Locator, test } from "@playwright/test";
import { captureScreenshot, completeOnboarding, signup } from "./helpers";
async function fontSize(locator: Locator) {
return locator.evaluate((element) => Number.parseFloat(getComputedStyle(element).fontSize));
}
test("phone widths read text at 16px and up while desktop keeps its sizes", async ({
page,
}, testInfo) => {
const stamp = Date.now();
await signup(page, `mobile-font-${stamp}@rakazo.test`, "password12", "Font QA");
await completeOnboarding(page);
const composer = page.getByRole("combobox", { name: /^Message/ });
await composer.fill("Please review `shared/PROJECT_CHECKPOINT_WRAPUP.md`.");
await composer.press("Enter");
const reply = page
.getByTestId("transcript")
.locator("[data-message-id]")
.filter({ hasText: "done. i handled:" });
await expect(reply).toBeVisible({ timeout: 30_000 });
const smallText = page.locator(".text-ui-body-sm").first();
const desktopSmall = await fontSize(smallText);
expect(desktopSmall).toBe(14);
await page.setViewportSize({ width: 390, height: 844 });
await expect.poll(() => fontSize(smallText)).toBeCloseTo(16, 1);
expect(await fontSize(composer)).toBeGreaterThanOrEqual(16);
await page.emulateMedia({ colorScheme: "dark" });
await expect(page.locator("html")).toHaveAttribute("data-theme", "dark");
await captureScreenshot(page, testInfo, "mobile-font-size-dark");
await page.emulateMedia({ colorScheme: "light" });
await expect(page.locator("html")).toHaveAttribute("data-theme", "light");
await captureScreenshot(page, testInfo, "mobile-font-size-light");
await page.setViewportSize({ width: 1280, height: 720 });
await expect.poll(() => fontSize(smallText)).toBe(desktopSmall);
});

View File

@ -251,7 +251,7 @@ export function OoStage({
) : null}
</>
) : (
<p className="mt-1 max-w-xl truncate text-center text-ui-caption text-muted-foreground">
<p className="mt-1 w-full max-w-xl truncate text-center text-ui-caption text-muted-foreground">
{subtitle}
</p>
)}
@ -548,7 +548,7 @@ export function OoGroupStage({
{subtitle}
</p>
) : (
<p className="mt-1 max-w-xl truncate text-center text-ui-caption text-muted-foreground">
<p className="mt-1 w-full max-w-xl truncate text-center text-ui-caption text-muted-foreground">
{subtitle}
</p>
)}

View File

@ -80,7 +80,10 @@ export function ExternalConversationSettings({
return (
<div data-testid="external-conversation-settings">
<div className="mb-6">
<h2 className="truncate text-[17px] font-medium text-foreground" dir="auto">
<h2
className="truncate text-ui-reading leading-normal font-medium text-foreground"
dir="auto"
>
{conversation.displayName || t`External conversation`}
</h2>
<p className="mt-1 truncate text-ui-body-sm text-muted-foreground">

View File

@ -134,7 +134,7 @@ export function PeerMessagesOverlay({
<div className="mb-1 text-ui-body-sm text-muted-foreground/70" dir="auto">
{sent ? botName : peerBotName}
</div>
<div className="text-[14.5px] leading-[1.5] text-foreground/90" dir="auto">
<div className="text-ui-body-sm leading-[1.5] text-foreground/90" dir="auto">
<ChatMarkdown>{peerMessage.text}</ChatMarkdown>
</div>
</div>

View File

@ -558,7 +558,9 @@ export function PluginsOverlay({
{item.name[0]}
</div>
)}
<div className="truncate text-[17px] font-medium text-foreground">{item.name}</div>
<div className="truncate text-ui-reading leading-normal font-medium text-foreground">
{item.name}
</div>
</div>
<Button
type="button"

View File

@ -156,7 +156,7 @@ export function RoutineListRow({
)}
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-[14.5px] font-medium text-foreground" dir="auto">
<span className="block truncate text-ui-body-sm font-medium text-foreground" dir="auto">
{routineName(routine)}
</span>
<span className="block truncate text-ui-body-sm text-muted-foreground/80">
@ -522,7 +522,7 @@ function MessageTriggerCard({ provider, onRemove }: { provider: string; onRemove
className="text-muted-foreground size-icon-md"
aria-hidden
/>
<span className="flex-1 text-[14.5px] text-foreground">{label}</span>
<span className="flex-1 text-ui-body-sm text-foreground">{label}</span>
<Button
variant="ghost"
size="icon-xs"
@ -587,7 +587,7 @@ function InboundTriggerCard({
) : (
<Globe strokeWidth={1.6} className="text-muted-foreground size-icon-md" aria-hidden />
)}
<span className="flex-1 text-[14.5px] text-foreground">
<span className="flex-1 text-ui-body-sm text-foreground">
{kind === "github" ? <Trans>Git event</Trans> : <Trans>When a webhook fires</Trans>}
</span>
<Button

View File

@ -229,9 +229,9 @@ export function RoutineSchedule({
<div className="mt-2 rounded-xl border border-border p-3">
<div className="flex items-center gap-2.5 px-0.5">
<Clock size={17} strokeWidth={1.6} className="shrink-0 text-muted-foreground" aria-hidden />
<span className="text-[14.5px] text-foreground">{lead}</span>
<span className="text-ui-body-sm text-foreground">{lead}</span>
{detail ? (
<span className="flex-1 text-[14.5px] text-muted-foreground">{detail}</span>
<span className="flex-1 text-ui-body-sm text-muted-foreground">{detail}</span>
) : null}
</div>
<div className="mt-2.5 flex flex-wrap items-center gap-2 text-sm text-muted-foreground">

View File

@ -124,7 +124,7 @@ export function ScratchpadSection({ botId }: { botId: string }) {
/>
<div className="min-w-0 flex-1">
<div
className={`text-start text-[14.5px] ${item.status === "done" ? "text-muted-foreground/80 line-through" : "text-foreground"}`}
className={`text-start text-ui-body-sm ${item.status === "done" ? "text-muted-foreground/80 line-through" : "text-foreground"}`}
dir="auto"
>
{item.title}

View File

@ -21,7 +21,7 @@ export function WelcomePage() {
<p className="text-[27px] leading-[1.35] text-foreground/90 font-medium">
<Trans>Your specialists. One calm place to talk.</Trans>
</p>
<p className="text-[18px] leading-[1.5] text-foreground/60">
<p className="text-ui-title-sm leading-[1.5] text-foreground/60">
<Trans>Simple to start. Expert when you need it.</Trans>
</p>
</div>

View File

@ -383,7 +383,7 @@ function ChartCanvas({
return (
<div className="text-foreground/75">
{meta.title ? (
<div className="mb-1 text-[14.5px] font-semibold text-foreground">{meta.title}</div>
<div className="mb-1 text-ui-body-sm font-semibold text-foreground">{meta.title}</div>
) : null}
{meta.swatches.length > 0 ? (
<div className="mb-2 flex flex-wrap gap-x-3 gap-y-1">
@ -460,7 +460,7 @@ export function McpApprovalCard({
<span className="grid size-control-sm place-items-center rounded-lg bg-muted text-ui-label text-foreground">
M
</span>
<span className="text-[14.5px] font-medium text-foreground">
<span className="text-ui-body-sm font-medium text-foreground">
<Trans>Connect MCP server “{name}”</Trans>
</span>
</div>

View File

@ -811,7 +811,7 @@ const MessageView = memo(function MessageView({
</div>
<div className="flex items-center gap-2">
<span
className="text-[14.5px] font-semibold text-foreground tracking-tight"
className="text-ui-body-sm font-semibold text-foreground tracking-tight"
dir="auto"
>
{effectiveSpeakerName}
@ -1048,7 +1048,7 @@ const MessageView = memo(function MessageView({
</div>
<div className="mt-2 text-ui-body text-muted-foreground">{block.task}</div>
{block.progress || block.result ? (
<div className="mt-2.5 text-[14.5px] leading-[1.5] text-foreground/75">
<div className="mt-2.5 text-ui-body-sm leading-[1.5] text-foreground/75">
<ChatMarkdown streaming={running}>
{block.result || block.progress || ""}
</ChatMarkdown>
@ -1095,7 +1095,7 @@ const MessageView = memo(function MessageView({
)}
</span>
</div>
<div className="mt-2 text-[14.5px] leading-[1.5] text-foreground/75" dir="auto">
<div className="mt-2 text-ui-body-sm leading-[1.5] text-foreground/75" dir="auto">
{removed
? block.status === "archived"
? t`Archived. Chat, memory, and files kept.`
@ -1201,7 +1201,7 @@ const MessageView = memo(function MessageView({
className={
encounterMode
? "max-w-full whitespace-pre-wrap wrap-anywhere rounded-2xl bg-chat-user px-3.5 py-2 text-ui-body-sm leading-relaxed text-chat-user-foreground"
: "chat-bubble-user max-w-full whitespace-pre-wrap wrap-anywhere rounded-[24px] px-6 py-4 text-[17px] leading-[1.62] text-chat-user-foreground shadow-md"
: "chat-bubble-user max-w-full whitespace-pre-wrap wrap-anywhere rounded-[24px] px-6 py-4 text-ui-reading leading-[1.62] text-chat-user-foreground shadow-md"
}
dir="auto"
>
@ -1218,7 +1218,7 @@ const MessageView = memo(function MessageView({
className={
encounterMode
? "max-w-full space-y-2 border-s-2 border-emerald-500/35 ps-3 text-ui-body-sm leading-relaxed text-foreground/90"
: "chat-bubble-bot max-w-full space-y-3 rounded-[24px] px-6 py-4 text-[17.5px] leading-[1.85] text-foreground/95"
: "chat-bubble-bot max-w-full space-y-3 rounded-[24px] px-6 py-4 text-ui-reading leading-[1.85] text-foreground/95"
}
dir="auto"
>
@ -1330,7 +1330,7 @@ const MessageView = memo(function MessageView({
{block.state}
</span>
</div>
<div className="my-2.5 text-[14.5px] leading-[1.5] text-foreground/75">
<div className="my-2.5 text-ui-body-sm leading-[1.5] text-foreground/75">
<ChatMarkdown>{block.text}</ChatMarkdown>
</div>
</div>

View File

@ -30,7 +30,7 @@ export function UserMenu({
<span className="grid size-control-lg place-items-center rounded-full bg-accent text-ui-body-sm text-foreground/75">
{initials}
</span>
<span className="text-[14.5px] text-foreground/90">{userName}</span>
<span className="text-ui-body-sm text-foreground/90">{userName}</span>
</PopoverTrigger>
{open ? (
<PopoverContent

View File

@ -98,7 +98,7 @@ body,
/* Gemini-Grade Accessible Typography (Apple-like with Inter Variable).
Heading, code and quote colors come from the shared @rakazo/chat-ui markdown styles. */
.rk-chat-markdown {
font-size: var(--font-size-reading);
font-size: calc(var(--font-size-reading) * var(--font-scale));
line-height: var(--line-height-reading);
color: var(--foreground);
letter-spacing: -0.011em;
@ -116,7 +116,7 @@ body,
inset 0 1px 0 rgba(255, 255, 255, 0.09),
0 12px 36px rgba(0, 0, 0, 0.55);
padding: 1.25em 1.5em;
font-size: 14.5px;
font-size: calc(14.5px * var(--font-scale));
line-height: 1.65;
font-family: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
}
@ -130,7 +130,7 @@ body,
font-weight: 500;
border-radius: 6px;
padding: 0.15em 0.4em;
font-size: 14.5px;
font-size: calc(14.5px * var(--font-scale));
font-family: "JetBrains Mono", ui-monospace, SFMono-Regular, monospace;
}

View File

@ -45,20 +45,26 @@
--radius-xl: calc(var(--radius) + 4px);
--radius-2xl: calc(var(--radius) + 8px);
/* Typography scale */
--text-ui-caption: var(--font-size-caption);
/* Typography scale. Every text size follows --font-scale, which grows on phones. */
--text-xs: calc(0.75rem * var(--font-scale));
--text-sm: calc(0.875rem * var(--font-scale));
--text-base: calc(1rem * var(--font-scale));
--text-lg: calc(1.125rem * var(--font-scale));
--text-xl: calc(1.25rem * var(--font-scale));
--text-2xl: calc(1.5rem * var(--font-scale));
--text-ui-caption: calc(var(--font-size-caption) * var(--font-scale));
--text-ui-caption--line-height: var(--line-height-tight);
--text-ui-label: var(--font-size-label);
--text-ui-label: calc(var(--font-size-label) * var(--font-scale));
--text-ui-label--line-height: var(--line-height-tight);
--text-ui-body-sm: var(--font-size-body-sm);
--text-ui-body-sm: calc(var(--font-size-body-sm) * var(--font-scale));
--text-ui-body-sm--line-height: var(--line-height-body);
--text-ui-body: var(--font-size-body);
--text-ui-body: calc(var(--font-size-body) * var(--font-scale));
--text-ui-body--line-height: var(--line-height-body);
--text-ui-reading: var(--font-size-reading);
--text-ui-reading: calc(var(--font-size-reading) * var(--font-scale));
--text-ui-reading--line-height: var(--line-height-reading);
--text-ui-title-sm: var(--font-size-title-sm);
--text-ui-title-sm: calc(var(--font-size-title-sm) * var(--font-scale));
--text-ui-title-sm--line-height: var(--line-height-tight);
--text-ui-title: var(--font-size-title);
--text-ui-title: calc(var(--font-size-title) * var(--font-scale));
--text-ui-title--line-height: var(--line-height-tight);
/* Control + icon sizes (also drive h-*, size-*, gap-*, p-* utilities) */
@ -74,6 +80,18 @@
--spacing-rk-sidebar-y: var(--spacing-sidebar-row-y);
}
/* Phones read the 14px base size at 16px (Safari also stops zooming into fields at 16px);
all other sizes grow by the same ratio. */
:root {
--font-scale: 1;
}
@media (max-width: 767.98px) {
:root {
--font-scale: 1.142857;
}
}
@layer base {
* {
@apply border-border outline-ring/50;