eleven / ocho Public

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>
Paul Campbell acc188ad32955954f5d606195a69e54210863fd1 parent 20201ce
5 files changed, +129 −5
  • changelog/2026-07-08-text-size.md +6 −0
  • test/ui.test.mjs +23 −0
  • web/static/shell.js +54 −0
  • web/static/style.css +28 −5
  • web/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.