web: text size — iMessage-sized default, settings slider, Dynamic Type on touch (#414)
* web: text size — iMessage-sized default, theme-sheet slider, Dynamic Type on touch Introduce --msg-font-size as the one text-size primitive for message bubbles + the composer. Desktop defaults to 13px (Mac iMessage's default, down from the inherited 16px browser default) with a seven-stop A…A slider in the theme sheet (lchat_text_size, local-only like the theme). Touch keeps 17px but bubbles switch to font: -apple-system-body, so iPhone/iPad honour the system Text Size (Dynamic Type); Android falls back to the 17px variable. Reply quotes track two notches under the bubble size; the composer line grows with the font past 16px. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * web: changelog — scope the iPhone/iPad claim to the web app until app builds ship Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * web: default text size 13 → 14 — the slider's middle stop, matching Messages.app Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * web: text-size ladder tops out at 16 — default one from the end (9…14, 16) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * web: 15px default — ladder 10…16, default second from top Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> ---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
5 files changed,
+129
−5
changelog/2026-07-08-text-size.md+6 −0test/ui.test.mjs+23 −0web/static/shell.js+54 −0web/static/style.css+28 −5web/static/ui.js+18 −0
diff --git a/changelog/2026-07-08-text-size.md b/changelog/2026-07-08-text-size.md| new file mode 100644 |
| index 0000000..9f3ba98 |
| --- /dev/null |
| +++ b/changelog/2026-07-08-text-size.md |
| @@ -0,0 +1,6 @@ |
| +🤖 You can now choose your message text size. On a computer, open the menu → |
| +Theme and drag the new **Text size** slider — the default is now the same |
| +comfortable size iMessage uses on the Mac. Using the web app on an iPhone or |
| +iPad? Messages now follow your system Text Size setting (Settings → Display & |
| +Brightness → Text Size), so if you use larger type, your chats do too. The |
| +iPhone and Mac apps pick this up in their next update. |
diff --git a/test/ui.test.mjs b/test/ui.test.mjs| index 5bdd2a6..ec0b418 100644 |
| --- a/test/ui.test.mjs |
| +++ b/test/ui.test.mjs |
| @@ -16,6 +16,9 @@ import { |
| DEFAULT_THEME, |
| DEFAULT_APPEARANCE, |
| sanitizeActions, |
| + normalizeTextSize, |
| + TEXT_SIZES, |
| + DEFAULT_TEXT_SIZE, |
| } from "../web/static/ui.js"; |
| let failures = 0; |
| @@ -107,6 +110,26 @@ await test("colorScheme: system maps to 'light dark' so light-dark() follows the |
| assert.equal(colorScheme("anything-else"), "light dark"); |
| }); |
| +await test("normalizeTextSize: valid stops pass through (stored strings too), junk falls back", () => { |
| + for (const ok of TEXT_SIZES) { |
| + assert.equal(normalizeTextSize(ok), ok); |
| + assert.equal(normalizeTextSize(String(ok)), ok); // localStorage returns strings |
| + } |
| + assert.equal(normalizeTextSize(15), DEFAULT_TEXT_SIZE); // not a slider stop |
| + assert.equal(normalizeTextSize("huge"), DEFAULT_TEXT_SIZE); |
| + assert.equal(normalizeTextSize(null), DEFAULT_TEXT_SIZE); |
| + assert.equal(normalizeTextSize(undefined), DEFAULT_TEXT_SIZE); |
| + assert.equal(normalizeTextSize(""), DEFAULT_TEXT_SIZE); |
| +}); |
| + |
| +await test("text size default sits one from the top of the ladder", () => { |
| + assert.equal(DEFAULT_TEXT_SIZE, 15); |
| + // one stop above the default and no more — bigger sizes read as shouting |
| + assert.equal(TEXT_SIZES[TEXT_SIZES.length - 2], DEFAULT_TEXT_SIZE); |
| + // strictly increasing — the slider steps through them by index |
| + for (let i = 1; i < TEXT_SIZES.length; i++) assert.ok(TEXT_SIZES[i] > TEXT_SIZES[i - 1]); |
| +}); |
| + |
| await test("composerToken: detects an emoji shortcode in progress", () => { |
| const t = composerToken("hey :tad", 8); |
| assert.equal(t?.kind, "emoji"); |
diff --git a/web/static/shell.js b/web/static/shell.js| index aa3ab6e..6f6d480 100644 |
| --- a/web/static/shell.js |
| +++ b/web/static/shell.js |
| @@ -31,6 +31,9 @@ import { |
| normalizeTheme, |
| normalizeAppearance, |
| colorScheme, |
| + normalizeTextSize, |
| + TEXT_SIZES, |
| + DEFAULT_TEXT_SIZE, |
| composerToken, |
| pinnedFirst, |
| ownMessage, |
| @@ -176,6 +179,27 @@ matchMedia("(prefers-color-scheme: dark)").addEventListener("change", () => { |
| }); |
| applyTheme(currentTheme(), currentAppearance()); |
| +// ---- text size ------------------------------------------------------------ |
| +// Desktop-only, like the Mac iMessage slider: the picked size drives the |
| +// --msg-font-size variable (bubbles + composer). Touch devices skip all of |
| +// this — they follow the system text size via -apple-system-body in style.css. |
| +// Local preference only, same as the theme. The native Mac app sets the same |
| +// variable from its own View-menu / Settings pref instead of this key. |
| +const TEXT_SIZE_KEY = "lchat_text_size"; |
| +const currentTextSize = () => normalizeTextSize(localStorage.getItem(TEXT_SIZE_KEY)); |
| +function applyTextSize(px) { |
| + if (isCoarse) return; |
| + // At the default, clear the override so the stylesheet's value (and any |
| + // future retune of it) rules; only a deliberate choice pins an inline size. |
| + if (px === DEFAULT_TEXT_SIZE) document.documentElement.style.removeProperty("--msg-font-size"); |
| + else document.documentElement.style.setProperty("--msg-font-size", px + "px"); |
| +} |
| +function setTextSize(px) { |
| + localStorage.setItem(TEXT_SIZE_KEY, String(px)); |
| + applyTextSize(px); |
| +} |
| +applyTextSize(currentTextSize()); |
| + |
| // ---- action chips --------------------------------------------------------- |
| // One-tap buttons a helper attaches to its reply ("Switch to dark" etc.). |
| // Consent-based by design: nothing runs until the member taps — payloads |
| @@ -291,6 +315,36 @@ function showThemePicker() { |
| } |
| card.appendChild(grid); |
| + // Text size (desktop only — touch follows the system setting): the iMessage |
| + // slider, A…A over the sizes in ui.js. Applies live so the feed behind the |
| + // sheet previews the choice. |
| + if (!isCoarse) { |
| + const tsLabel = document.createElement("label"); |
| + tsLabel.className = "field-label"; |
| + tsLabel.textContent = "Text size"; |
| + tsLabel.style.marginTop = "14px"; |
| + card.appendChild(tsLabel); |
| + |
| + const row = document.createElement("div"); |
| + row.className = "text-size-row"; |
| + const small = document.createElement("span"); |
| + small.className = "text-size-a small"; |
| + small.textContent = "A"; |
| + const slider = document.createElement("input"); |
| + slider.type = "range"; |
| + slider.min = "0"; |
| + slider.max = String(TEXT_SIZES.length - 1); |
| + slider.step = "1"; |
| + slider.value = String(TEXT_SIZES.indexOf(currentTextSize())); |
| + slider.setAttribute("aria-label", "Text size"); |
| + slider.oninput = () => setTextSize(TEXT_SIZES[+slider.value]); |
| + const big = document.createElement("span"); |
| + big.className = "text-size-a big"; |
| + big.textContent = "A"; |
| + row.append(small, slider, big); |
| + card.appendChild(row); |
| + } |
| + |
| const foot = document.createElement("div"); |
| foot.className = "sheet-foot"; |
| const done = document.createElement("button"); |
diff --git a/web/static/style.css b/web/static/style.css| index f9f00d8..c9786f8 100644 |
| --- a/web/static/style.css |
| +++ b/web/static/style.css |
| @@ -10,6 +10,13 @@ |
| :root, |
| [data-theme="litroom"] { |
| --radius: 18px; |
| + /* Message + composer text size. The desktop default is a touch over Mac |
| + iMessage's (we used to inherit the browser's 16px); the coarse-pointer |
| + block further down bumps it for touch and layers Dynamic Type on top. A |
| + member's picked size (theme sheet slider, lchat_text_size) is applied by |
| + shell.js as an inline override on <html> — desktop only; touch follows |
| + the system. */ |
| + --msg-font-size: 15px; |
| --font-display: ui-rounded, "SF Pro Rounded", "Avenir Next", -apple-system, system-ui, sans-serif; |
| /* depth + status colours are theme-agnostic */ |
| --shadow: light-dark(rgba(20, 30, 80, 0.10), rgba(0, 0, 0, 0.55)); |
| @@ -429,6 +436,7 @@ input:focus, textarea:focus { outline: 2px solid var(--accent); border-color: tr |
| padding: 9px 13px; |
| border-radius: var(--radius); |
| background: var(--bubble-them); |
| + font-size: var(--msg-font-size); |
| line-height: 1.35; |
| white-space: pre-wrap; |
| /* break long unbroken strings (e.g. a giant URL) instead of overflowing */ |
| @@ -1094,7 +1102,8 @@ input:focus, textarea:focus { outline: 2px solid var(--accent); border-color: tr |
| .msg.me .reply-quote { text-align: right; } |
| .reply-quote-name { font-size: 11px; color: var(--muted); margin-bottom: 1px; } |
| .reply-quote-text { |
| - font-size: 13px; |
| + /* two notches under the bubble text at every size (15→13, touch 17→15) */ |
| + font-size: calc(var(--msg-font-size) - 2px); |
| color: var(--muted); |
| max-width: 220px; |
| overflow: hidden; |
| @@ -1320,11 +1329,16 @@ input:focus, textarea:focus { outline: 2px solid var(--accent); border-color: tr |
| core reading + typing text up a notch so messages are comfortable. Desktop |
| (fine pointer) keeps its sizes. Composer stays ≥16px so iOS doesn't |
| auto-zoom the page when the field is focused. */ |
| - .msg .bubble { font-size: 17px; } |
| + :root { --msg-font-size: 17px; } |
| + /* iOS/iPadOS honour the system Text Size (Dynamic Type): -apple-system-body |
| + tracks it, so a member who's set large type gets large messages. Browsers |
| + without the keyword (Android) drop the whole shorthand and keep the 17px |
| + variable from the base rule; the two declarations after it restore what |
| + the shorthand resets. Higher-specificity sizes (.jumbo-emoji) still win. */ |
| + .msg .bubble { font: -apple-system-body; font-family: inherit; line-height: 1.35; } |
| .composer textarea { font-size: 17px; } |
| .thread-row-name { font-size: 17px; } |
| .thread-row-preview { font-size: 14px; } |
| - .reply-quote-text { font-size: 15px; } |
| } |
| .composer-field { |
| position: relative; |
| @@ -1336,9 +1350,12 @@ input:focus, textarea:focus { outline: 2px solid var(--accent); border-color: tr |
| resize: none; |
| max-height: 140px; |
| /* 8+8 padding + 22 line + 2 border = 40px at rest. Symmetric so the text is |
| - vertically centred in the box; right padding leaves room for the send button. */ |
| + vertically centred in the box; right padding leaves room for the send button. |
| + Type at the reading size; past 16px the 22px line would get cramped, so the |
| + line grows with the font from there (and the box grows with it). */ |
| padding: 8px 46px 8px 14px; |
| - line-height: 22px; |
| + font-size: var(--msg-font-size); |
| + line-height: max(22px, 1.35em); |
| min-height: 40px; |
| border-radius: 20px; |
| font-family: inherit; |
| @@ -1854,6 +1871,12 @@ input:focus, textarea:focus { outline: 2px solid var(--accent); border-color: tr |
| .theme-swatch:hover { background: var(--bubble-them); } |
| .theme-swatch.active { border-color: var(--accent); box-shadow: inset 0 0 0 1px var(--accent); } |
| .theme-swatch .disc { width: 26px; height: 26px; border-radius: 50%; flex: none; box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.12); } |
| +/* "Text size" slider in the theme sheet (desktop only): A ——◦—— A */ |
| +.text-size-row { display: flex; align-items: center; gap: 12px; } |
| +.text-size-row input[type="range"] { flex: 1; margin: 0; accent-color: var(--accent); } |
| +.text-size-a { color: var(--muted); font-weight: 600; line-height: 1; } |
| +.text-size-a.small { font-size: 12px; } |
| +.text-size-a.big { font-size: 20px; } |
| /* mobile: show one pane at a time, toggled by .show-thread */ |
| @media (max-width: 720px) { |
diff --git a/web/static/ui.js b/web/static/ui.js| index b6d726f..099d7b3 100644 |
| --- a/web/static/ui.js |
| +++ b/web/static/ui.js |
| @@ -26,6 +26,24 @@ export function colorScheme(appearance) { |
| return "light dark"; |
| } |
| +// ---- text size ------------------------------------------------------------ |
| +// The desktop message text size, in px — the stops of the "Text size" slider |
| +// (theme sheet), same idea as Mac iMessage's seven-notch slider. The default |
| +// sits ONE FROM THE TOP: message text has a low ceiling (16 is the only |
| +// workable bigger size — beyond that bubbles read as shouting), while the |
| +// room to taste lies below. Touch devices aren't governed by this at all |
| +// (they follow the system text size via -apple-system-body in style.css). |
| +// A purely local preference, like the theme. Applied as --msg-font-size on |
| +// <html>; the native Mac app drives the same CSS variable from its own pref. |
| +export const TEXT_SIZES = [10, 11, 12, 13, 14, 15, 16]; |
| +export const DEFAULT_TEXT_SIZE = 15; |
| + |
| +// Coerce a stored (possibly stale/garbage) value to a valid size. |
| +export function normalizeTextSize(v) { |
| + const n = Number(v); |
| + return TEXT_SIZES.includes(n) ? n : DEFAULT_TEXT_SIZE; |
| +} |
| + |
| // shouldSend decides whether a keydown in the composer should send the message. |
| // |
| // Desktop (precise pointer): Enter sends, Shift+Enter inserts a newline. |