From a80e831a3c45a11f6ea2ae6fccea6d588ac45fa8 Mon Sep 17 00:00:00 2001
From: Aneesh Sathe
Date: Sun, 18 Oct 2026 13:57:02 -0700
Subject: [PATCH 1/6] Studio PWA: stop the random zoom on iOS
Causes found, not guessed:
- The full editor's draft textarea was 0.9rem (14.4px). iOS Safari zooms
any field under 16px on focus and never zooms back, so opening the
editor and tapping into the draft zoomed the page every time.
- The [[ picker's filter and footer selects (0.85rem, 0.78rem) and the
custom theme's hex fields (0.85rem) did the same.
- No touch-action anywhere, so a quick second tap on a button was taken
as a double-tap zoom.
- Imported HTML's fixed-width tables, iframes and embeds had no max-width
rule (only img and pre did).
Fixes: those fields at 1rem; a pointer:coarse guard that keeps every
field at >= 16px whatever a screen or extension rule says; body
touch-action: manipulation (pinch zoom untouched, the viewport meta still
allows user zoom); wide media, tables and embeds shrink or scroll inside
the card, preview and version viewer.
e2e/pwa-zoom.spec.ts (mobile): every visible field >= 16px across the
main screens, the editor, the picker, a prompt sheet and the subscribe
sheet; the viewport keeps user zoom; a post with a long URL, wide pre,
table, image and embed never scrolls the page sideways.
Co-Authored-By: Claude Opus 5.5
---
e2e/pwa-zoom.spec.ts | 163 +++++++++++++++++++++++++++++++++++++++++++
src/ui/authoring.css | 6 +-
src/ui/picker.css | 4 +-
src/ui/reading.css | 11 ++-
src/ui/studio.css | 10 +++
5 files changed, 189 insertions(+), 5 deletions(-)
create mode 100644 e2e/pwa-zoom.spec.ts
diff --git a/e2e/pwa-zoom.spec.ts b/e2e/pwa-zoom.spec.ts
new file mode 100644
index 0000000..881725e
--- /dev/null
+++ b/e2e/pwa-zoom.spec.ts
@@ -0,0 +1,163 @@
+import { test, expect, type Page } from './fixture';
+
+// "The page zooms in randomly and has to be resized" (iOS Safari / the
+// installed PWA). Two causes, each pinned here at the phone viewport:
+// - a form field under 16px zooms the page when it takes focus and the page
+// does not zoom back, so every visible field on every screen and sheet is
+// asserted at ≥ 16px;
+// - content wider than the viewport makes iOS lay the page out wider and
+// show it zoomed out, so reading a post with a long URL, a wide
, a
+// wide table, a wide image and a wide embed must not scroll sideways.
+// User zoom itself stays enabled (no maximum-scale / user-scalable=no).
+
+test.beforeEach(({}, info) => test.skip(info.project.name !== 'mobile', 'phone viewport only'));
+
+async function login(page: Page) {
+ await page.goto('/studio/login');
+ await page.locator('[name=password]').fill('test-password');
+ await page.getByRole('button', { name: 'log in', exact: true }).click();
+ await expect(page.locator('#composer-text')).toBeVisible();
+}
+const api = (page: Page, method: string, path: string, body?: unknown) =>
+ page.evaluate(
+ async ({ method, path, body }) => {
+ const response = await fetch(`/api${path}`, { method, headers: { 'content-type': 'application/json' }, ...(body === undefined ? {} : { body: JSON.stringify(body) }) });
+ return { status: response.status, json: await response.json().catch(() => null) };
+ },
+ { method, path, body },
+ );
+/** Every visible text field whose computed font-size is under 16px. */
+const smallFields = (page: Page) =>
+ page.evaluate(() =>
+ [...document.querySelectorAll('input, textarea, select, [contenteditable]:not([contenteditable=false])')]
+ .filter((el) => {
+ const type = (el as HTMLInputElement).type;
+ if (['checkbox', 'radio', 'hidden', 'file', 'color', 'range', 'submit', 'button', 'reset', 'image'].includes(type)) return false;
+ const style = getComputedStyle(el);
+ const box = el.getBoundingClientRect();
+ return style.display !== 'none' && style.visibility !== 'hidden' && box.width > 0 && box.height > 0;
+ })
+ .map((el) => ({ field: `${el.tagName.toLowerCase()}${el.id ? '#' + el.id : ''}${el.className ? '.' + String(el.className).split(' ').join('.') : ''}`, size: getComputedStyle(el).fontSize }))
+ .filter((field) => parseFloat(field.size) < 16),
+ );
+async function noSmallFields(page: Page, where: string, needsField = true) {
+ // Where a screen has fields, at least one must be on screen, or the check proves nothing.
+ if (needsField) await expect(page.locator('input:visible, textarea:visible, select:visible').first(), `${where} shows a field`).toBeVisible();
+ expect(await smallFields(page), where).toEqual([]);
+}
+const noSideScroll = (page: Page) =>
+ page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth);
+
+test('the viewport keeps user zoom', async ({ page }) => {
+ await login(page);
+ const viewport = await page.locator('meta[name=viewport]').getAttribute('content');
+ expect(viewport).toContain('width=device-width');
+ expect(viewport).not.toMatch(/maximum-scale|user-scalable/);
+ // No double-tap zoom on buttons: the body's touch-action allows pan and pinch only.
+ expect(await page.evaluate(() => getComputedStyle(document.body).touchAction)).toBe('manipulation');
+});
+
+test('every field on the main screens is at least 16px', async ({ page }) => {
+ await login(page);
+ await noSmallFields(page, 'compose');
+ // The [[ picker: its search box and filters.
+ await page.locator('#composer-text').fill('[[');
+ await expect(page.locator('.picker')).toBeVisible();
+ await noSmallFields(page, 'picker');
+ await page.keyboard.press('Escape');
+ await page.locator('#composer-text').fill('');
+ for (const [path, where, needsField] of [
+ ['/studio/settings', 'settings', true],
+ ['/studio/setup', 'setup', false],
+ ['/studio/access', 'access', false],
+ ['/studio/hoppers', 'hoppers', true],
+ ['/studio/hoppers/parity-hopper', 'hopper', false],
+ ['/studio/mentions', 'mentions', false],
+ ['/studio/signals', 'signals', false],
+ ['/studio/reading?view=sources', 'sources', false],
+ ] as const) {
+ await page.goto(path);
+ await expect(page.locator('main .screen > *').first()).toBeVisible();
+ await page.waitForLoadState('networkidle');
+ await noSmallFields(page, where, needsField);
+ }
+ // Settings → appearance → Custom opens the colour editor and its hex fields.
+ await page.goto('/studio/settings');
+ const custom = page.locator('.theme-opt').filter({ hasText: /custom/i }).first();
+ if (await custom.count()) {
+ await custom.click();
+ await expect(page.locator('.theme-colour-text input').first()).toBeVisible();
+ await noSmallFields(page, 'custom theme editor');
+ await page.reload();
+ }
+});
+
+test('every field in the editor and in sheets is at least 16px', async ({ page }) => {
+ await login(page);
+ const { json } = await api(page, 'POST', '/items', { content_md: 'Zoom audit draft.', kind: 'fragment' });
+ try {
+ await page.goto(`/studio/edit/${json.id}`);
+ await expect(page.locator('#md-input')).toBeVisible();
+ await noSmallFields(page, 'editor');
+ await page.locator('#md-input').fill('Zoom audit draft. [[');
+ await expect(page.locator('.picker')).toBeVisible();
+ await noSmallFields(page, 'editor picker');
+ await page.keyboard.press('Escape');
+ await page.locator('#md-input').fill('Zoom audit draft.');
+ // A prompt sheet: link from clipboard asks for a URL when the clipboard has none.
+ await page.context().grantPermissions(['clipboard-read', 'clipboard-write']);
+ await page.evaluate(() => navigator.clipboard.writeText('not a url'));
+ await page.locator('[data-action="link-clipboard"]').click();
+ const sheet = page.getByRole('dialog', { name: 'link from clipboard' });
+ await expect(sheet.getByRole('textbox')).toBeVisible();
+ await noSmallFields(page, 'prompt sheet');
+ await sheet.locator('[data-sheet=cancel]').click();
+ // The subscribe sheet on reading.
+ await page.goto('/studio/reading');
+ await page.getByRole('button', { name: 'subscribe', exact: true }).click();
+ await expect(page.getByRole('dialog', { name: 'subscribe' }).getByRole('textbox').first()).toBeVisible();
+ await noSmallFields(page, 'subscribe sheet');
+ } finally {
+ await api(page, 'DELETE', `/items/${json.id}`);
+ }
+});
+
+const WIDE_IMAGE = `data:image/svg+xml,${encodeURIComponent('')}`;
+const WIDE_HTML =
+ '
Wide fixture post
' +
+ '
A long link: https://example.com/a/really/long/path/that/never/breaks/' + 'segment'.repeat(30) + '?query=' + 'x'.repeat(120) + '
+ ) : null;
+}
+/**
+ * The action row, the same on the card and in the full view: line-icon
+ * thumbs, then edit / stub ↗ / + add to hopper… as quiet text, then ⋯.
+ * Small to look at; every control is still a 40px target (.act).
+ */
+function EntryBar({ entry, a }: { entry: Reading; a: EntryActions }) {
+ const { imported, id, signal } = a;
+ return (
+
- setExtensionSheet(null)} />
+
+
);
diff --git a/src/ui/studio.css b/src/ui/studio.css
index b92c21e..fe6cb4c 100644
--- a/src/ui/studio.css
+++ b/src/ui/studio.css
@@ -48,6 +48,13 @@
--alert-bg: color-mix(in srgb, var(--alert) 10%, var(--card));
--pencil-bg: color-mix(in srgb, var(--pencil) 12%, var(--card));
--tk-bg: color-mix(in srgb, var(--warn) 16%, transparent);
+ /* One scale for the whole studio (spacing, compact controls, radius, muted
+ text), so composer, editor, cards and sheets read as one system. */
+ --s1: 4px; --s2: 8px; --s3: 12px; --s4: 16px; --s5: 24px;
+ --ctl: 32px; /* visual height of a compact control */
+ --hit: 40px; /* its minimum tap target */
+ --radius-sm: 8px; /* controls; --radius is cards and sheets */
+ --muted: var(--ink-soft);
/* Legacy names, still used by the screens not yet redesigned. */
--paper: var(--card);
--paper-sunk: var(--sunk);
@@ -188,6 +195,38 @@ button.link { background: none; border: none; padding: 0; font: inherit; color:
.icon-btn:hover { border-color: var(--rule); color: var(--ink); }
.icon-btn[aria-pressed=true] { background: var(--pencil-bg); color: var(--pencil); border-color: var(--pencil); }
+/* ---------- COMPACT CONTROLS ----------
+ Substack-scale: small muted glyphs and labels, no boxes. The visual is
+ --ctl (32px) or less; the tap target is --hit (40px), from min-height and
+ a ::before that draws the hover wash inside it. */
+.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; }
+.ico { width: 20px; height: 20px; flex: 0 0 auto; display: block; }
+.act { position: relative; isolation: isolate; display: inline-flex; align-items: center; justify-content: center; gap: 5px;
+ min-height: var(--hit); min-width: var(--hit); padding: 0 8px; margin: 0; border: 0; border-radius: var(--radius-sm); background: none;
+ color: var(--muted); font: inherit; font-size: .8rem; font-weight: 500; line-height: 1; cursor: pointer; white-space: nowrap; text-decoration: none; }
+.act::before { content: ""; position: absolute; inset: calc((var(--hit) - var(--ctl)) / 2) 0; border-radius: var(--radius-sm); z-index: -1; }
+.act:hover { color: var(--ink); }
+.act:hover::before { background: color-mix(in srgb, var(--ink) 7%, transparent); }
+.act:focus-visible { outline: none; }
+.act:focus-visible::before { outline: 2px solid var(--pencil); outline-offset: 0; }
+.act[aria-pressed=true] { color: var(--pencil); }
+.act[aria-pressed=true] .fillable { fill: currentColor; fill-opacity: .22; }
+.act:disabled { opacity: .45; cursor: not-allowed; }
+.act.mono { font-family: var(--mono); font-size: .8rem; font-weight: 600; letter-spacing: -.2px; }
+.act.icon-only { padding: 0; }
+/* A compact button with a face: save draft, publish. */
+.btn-compact { position: relative; min-height: var(--ctl); padding: 0 14px; font-size: .85rem; border-radius: var(--radius-sm); }
+.btn-compact::after { content: ""; position: absolute; inset: calc((var(--ctl) - var(--hit)) / 2) 0; }
+.btn-neutral { background: var(--sunk); color: var(--ink); border-color: var(--rule); }
+.btn-neutral:hover { border-color: var(--rule-strong); }
+/* A compact segmented control: fragment | thread, draft | preview. */
+.segmented.compact { display: inline-flex; gap: 2px; padding: 3px; margin: 0; background: var(--sunk); border: 1px solid var(--rule); border-radius: calc(var(--radius-sm) + 2px); overflow: visible; }
+.segmented.compact .seg { position: relative; min-height: calc(var(--ctl) - 4px); padding: 0 12px; border: 0; border-radius: var(--radius-sm); background: none;
+ color: var(--muted); font-size: .82rem; font-weight: 600; }
+.segmented.compact .seg::after { content: ""; position: absolute; inset: -6px 0; }
+.segmented.compact .seg.is-active, .segmented.compact .seg[aria-pressed=true], .segmented.compact .seg[aria-checked=true] {
+ background: var(--card); color: var(--ink); box-shadow: 0 1px 2px rgba(0,0,0,.14), 0 0 0 1px var(--rule); }
+
/* ---------- SEGMENTED / CHIPS ---------- */
.segmented { display: flex; gap: 6px; overflow-x: auto; padding-bottom: 2px; margin-bottom: 12px; scrollbar-width: none; }
.segmented::-webkit-scrollbar { display: none; }
From aa9f6a839a8ed6b2cdf4c37609c4de910faa4dbf Mon Sep 17 00:00:00 2001
From: Aneesh Sathe
Date: Sun, 18 Oct 2026 14:14:09 -0700
Subject: [PATCH 3/6] Reading: "more" opens the entry full screen
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
A long thread or RSS post was clamped in its card and "more" expanded it
in place, so long posts made the feed unwieldy. No detail route existed
to reuse (the redesign's sources → timeline stopped short of an article
screen), so this adds one: /reading/{sub}/{id}, with ~own as the sub for
your own items (the id hoppers already use for own posts).
- "more", or a tap on the clamped text, opens it; the title still goes
to the origin, as tests and the read-state rules pin.
- It renders the whole sanitized body with its citation line, the card's
byline, and the same action row (thumbs, stub ↗, + add to hopper…, ⋯
with history), for own items, imported blyg items, threads and L0.
- Opening it marks the entry read, as expanding did. A row reading holds
goes through markReading; one opened by its address (no row) is
written direct, because an optimistic action with no row to change
never calls its mutationFn.
- Deep-linkable: the entry comes from what reading holds, else from
getItem / getImportedItem, so a reload stays on it. No API changes.
- back (and the browser's back gesture) returns to the timeline with the
entry where it was: the opener records the entry's key and viewport
offset (memory + sessionStorage), the timeline restores it.
- Clamp detection also watches the content's children and image loads,
so "more" appears when a post grows past the clamp after images load.
e2e/reading-article.spec.ts: more → URL, full content, reload, back and
browser back to the same entry, tap-to-open; an imported entry by its
address with the action row and a read PUT; an L0 entry; the compact
row's wording, icons and 40px targets. Both projects.
Co-Authored-By: Claude Opus 5.5
---
e2e/pwa-zoom.spec.ts | 9 +-
e2e/reading-article.spec.ts | 169 ++++++++++++++++++++++++
src/ui/app.tsx | 21 ++-
src/ui/components.tsx | 2 +-
src/ui/reading.css | 11 ++
src/ui/reading.tsx | 248 +++++++++++++++++++++++++++++++++---
6 files changed, 441 insertions(+), 19 deletions(-)
create mode 100644 e2e/reading-article.spec.ts
diff --git a/e2e/pwa-zoom.spec.ts b/e2e/pwa-zoom.spec.ts
index 881725e..0050e3f 100644
--- a/e2e/pwa-zoom.spec.ts
+++ b/e2e/pwa-zoom.spec.ts
@@ -155,9 +155,14 @@ test('reading a post with wide content never scrolls the page sideways', async (
await expect(entry).toBeVisible();
await expect(entry.locator('img[alt=wide]')).toBeVisible();
expect(await noSideScroll(page)).toBe(true);
+ // The full view shows the whole post unclamped: still no sideways scroll.
+ await entry.getByRole('button', { name: 'more', exact: true }).click();
+ await expect(page.locator('.reading-article')).toContainText('Filler paragraph 5');
+ await expect(page.locator('.reading-article table')).toBeVisible();
+ expect(await noSideScroll(page)).toBe(true);
// The table and the code scroll inside their own boxes.
- for (const selector of ['table', 'pre']) {
- const box = await entry.locator(selector).boundingBox();
+ for (const selector of ['.reading-article table', '.reading-article pre']) {
+ const box = await page.locator(selector).boundingBox();
expect(box!.x + box!.width, selector).toBeLessThanOrEqual(page.viewportSize()!.width);
}
});
diff --git a/e2e/reading-article.spec.ts b/e2e/reading-article.spec.ts
new file mode 100644
index 0000000..2fdd79a
--- /dev/null
+++ b/e2e/reading-article.spec.ts
@@ -0,0 +1,169 @@
+import { test, expect, type Page } from './fixture';
+
+// The full view (/reading/{sub}/{id}): "more" on a card opens the whole
+// entry full screen instead of expanding it inside the feed; reload stays on
+// it; back returns to the timeline at the same entry. It carries the card's
+// action row and marks the entry read on open. Own items, imported blyg
+// items, threads and legacy RSS entries all open in it.
+const NATIVE = '00000000000000000000000001';
+async function login(page: Page) {
+ await page.goto('/studio/login');
+ await page.locator('[name=password]').fill('test-password');
+ await page.getByRole('button', { name: 'log in', exact: true }).click();
+ await expect(page.locator('#composer-text')).toBeVisible();
+}
+const api = (page: Page, method: string, path: string, body?: unknown) =>
+ page.evaluate(
+ async ({ method, path, body }) => {
+ const response = await fetch(`/api${path}`, { method, headers: { 'content-type': 'application/json' }, ...(body === undefined ? {} : { body: JSON.stringify(body) }) });
+ return { status: response.status, json: await response.json().catch(() => null) };
+ },
+ { method, path, body },
+ );
+const paragraphs = (tag: string) =>
+ Array.from({ length: 14 }, (_, i) => `${tag} paragraph ${i + 1}, long enough that the card clamps it and needs a way in to the rest.`).join('\n\n');
+/** Publish own threads long enough to clamp; returns their ids, newest last. */
+async function longThreads(page: Page, tags: string[]) {
+ const ids: string[] = [];
+ for (const tag of tags) {
+ const { json } = await api(page, 'POST', '/items', { content_md: `# ${tag} heading\n\n${paragraphs(tag)}`, kind: 'thread' });
+ expect((await api(page, 'POST', `/items/${json.id}/publish`)).status).toBe(200);
+ ids.push(json.id);
+ }
+ return ids;
+}
+async function remove(page: Page, ids: string[]) {
+ for (const id of ids) {
+ await api(page, 'POST', `/items/${id}/withdraw`, {});
+ await api(page, 'DELETE', `/items/${id}`);
+ }
+}
+const card = (page: Page, text: string) => page.locator('.reading-entry').filter({ hasText: text });
+
+test('"more" opens the full view; reload stays; back returns to the same entry', async ({ page }) => {
+ await login(page);
+ const tags = [`Alpha${Date.now()}`, `Bravo${Date.now()}`, `Charlie${Date.now()}`];
+ const ids = await longThreads(page, tags);
+ try {
+ await page.goto('/studio/reading');
+ // The oldest of the three sits below the other two: scroll it into the middle.
+ const target = card(page, `${tags[0]} heading`);
+ await expect(target).toBeVisible();
+ await target.evaluate((element) => element.scrollIntoView({ block: 'center' }));
+ await page.waitForTimeout(150);
+ const before = (await target.boundingBox())!.y;
+ const scrolled = await page.evaluate(() => window.scrollY);
+ expect(scrolled).toBeGreaterThan(0);
+ // Clamped, with a fade, and "more" offered; the end of the post is not in the card.
+ await expect(target.locator('.entry-body')).toHaveClass(/overflowing/);
+ await expect(target.getByText(`${tags[0]} paragraph 14`)).not.toBeInViewport();
+ await target.getByRole('button', { name: 'more', exact: true }).click();
+
+ await expect(page).toHaveURL(new RegExp(`/studio/reading/~own/${ids[0]}$`));
+ const article = page.locator('.reading-article');
+ await expect(article.getByRole('heading', { name: `${tags[0]} heading` })).toBeVisible();
+ await expect(article.getByText(`${tags[0]} paragraph 14`)).toBeVisible();
+ await expect(article.locator('.entry-body')).not.toHaveClass(/clamped/);
+ // The card's action row and citation line come along.
+ await expect(article.getByRole('link', { name: 'edit', exact: true })).toHaveAttribute('href', `/studio/edit/${ids[0]}`);
+ await expect(article.getByRole('button', { name: 'more actions', exact: true })).toBeVisible();
+ await expect(article.locator('.entry-src')).toHaveAttribute('href', new RegExp(`/t/${ids[0]}/$`));
+ // The reading tab stays current.
+ await expect(page.getByRole('navigation', { name: 'studio' }).locator('a[aria-current=page]')).toHaveAccessibleName('reading');
+
+ // Deep-linkable: a reload stays on the full view.
+ await page.reload();
+ await expect(article.getByText(`${tags[0]} paragraph 14`)).toBeVisible();
+ // The browser's back after that reload still lands on the entry.
+ await page.goBack();
+ await expect(page).toHaveURL(/\/studio\/reading$/);
+ await expect.poll(async () => Math.abs((await target.boundingBox())!.y - before)).toBeLessThan(40);
+ await target.getByRole('button', { name: 'more', exact: true }).click();
+ await expect(page).toHaveURL(new RegExp(`/studio/reading/~own/${ids[0]}$`));
+
+ // Back returns to the feed with the same entry where it was.
+ await page.getByRole('button', { name: 'back', exact: true }).click();
+ await expect(page).toHaveURL(/\/studio\/reading$/);
+ await expect(target).toBeInViewport();
+ await expect.poll(async () => Math.abs((await target.boundingBox())!.y - before)).toBeLessThan(40);
+
+ // The browser's own back (the phone's back gesture) does the same.
+ await target.getByRole('button', { name: 'more', exact: true }).click();
+ await expect(article.getByText(`${tags[0]} paragraph 14`)).toBeVisible();
+ await page.goBack();
+ await expect(page).toHaveURL(/\/studio\/reading$/);
+ await expect.poll(async () => Math.abs((await target.boundingBox())!.y - before)).toBeLessThan(40);
+
+ // A tap on the clamped text opens it too.
+ await target.locator('.entry-body p').first().click();
+ await expect(page).toHaveURL(new RegExp(`/studio/reading/~own/${ids[0]}$`));
+ } finally {
+ await remove(page, ids);
+ }
+});
+
+test('an imported blyg entry opens by its address with the action row, and opening marks it read', async ({ page }) => {
+ await login(page);
+ const clear = () => api(page, 'DELETE', `/reading/parity-native/${NATIVE}/read`);
+ await clear();
+ try {
+ const put = page.waitForRequest((r) => r.method() === 'PUT' && r.url().endsWith(`/api/reading/parity-native/${NATIVE}/read`));
+ await page.goto(`/studio/reading/parity-native/${NATIVE}`);
+ const article = page.locator('.reading-article');
+ await expect(article.getByRole('heading', { name: 'Native title' })).toBeVisible();
+ await expect(article).toContainText('Frozen source text.');
+ await expect(article).toContainText('Frozen quote from a prior version.');
+ await expect(article.locator('.byline')).toContainText('Native source');
+ await expect(article.locator('.entry-src')).toHaveAttribute('href', 'https://source.example/native');
+ for (const name of ['thumbs up', 'thumbs down', 'stub ↗', 'add to hopper', 'more actions'])
+ await expect(article.getByRole('button', { name, exact: true })).toBeVisible();
+ expect((await put).postDataJSON()).toEqual({ version: 1 });
+ // The ⋯ sheet carries the card's rows, history among them.
+ await article.getByRole('button', { name: 'more actions', exact: true }).click();
+ await expect(page.getByRole('dialog', { name: 'actions' }).getByRole('button', { name: 'history', exact: true })).toBeVisible();
+ await page.keyboard.press('Escape');
+ // Back with no feed behind it goes to the feed.
+ await page.getByRole('button', { name: 'back', exact: true }).click();
+ await expect(page).toHaveURL(/\/studio\/reading$/);
+ await expect(card(page, 'Native title')).toHaveAttribute('data-read', 'read');
+ } finally {
+ await clear();
+ }
+});
+
+test('a legacy RSS entry opens in the full view from its timeline', async ({ page }) => {
+ await login(page);
+ // Short entries are not clamped; the full view is still at their address.
+ await page.goto('/studio/reading?sub=parity-rss');
+ await expect(card(page, 'Legacy title').getByRole('button', { name: 'more', exact: true })).toHaveCount(0);
+ await page.goto('/studio/reading/parity-rss/l0-parity');
+ const article = page.locator('.reading-article');
+ await expect(article.getByRole('heading', { name: 'Legacy title' })).toBeVisible();
+ await expect(article).toContainText('Legacy body.');
+ await expect(article.locator('.byline')).toContainText('legacy rss');
+ await article.getByRole('button', { name: 'more actions', exact: true }).click();
+ const menu = page.getByRole('dialog', { name: 'actions' });
+ await expect(menu).toContainText('https://legacy.example/post');
+ await expect(menu.getByRole('button', { name: 'history' })).toHaveCount(0);
+});
+
+test('the card action row is compact: line-icon thumbs, quiet labels, 40px targets', async ({ page }) => {
+ await login(page);
+ await page.goto('/studio/reading?sub=parity-native');
+ const bar = card(page, 'Native title').locator('.entry-bar');
+ // The wording is unchanged.
+ await expect(bar.getByRole('button', { name: 'stub ↗', exact: true })).toHaveText('stub ↗');
+ await expect(bar.getByRole('button', { name: 'add to hopper', exact: true })).toHaveText('+ add to hopper…');
+ // Thumbs are icons now, not emoji, still named.
+ const up = bar.getByRole('button', { name: 'thumbs up', exact: true });
+ await expect(up.locator('svg')).toHaveCount(1);
+ await expect(up).not.toContainText('👍');
+ for (const button of await bar.locator('button, a').all()) {
+ const box = (await button.boundingBox())!;
+ expect(box.height).toBeGreaterThanOrEqual(40);
+ expect(box.width).toBeGreaterThanOrEqual(40);
+ // Borderless: the size is the target, not a box drawn around it.
+ expect(await button.evaluate((el) => getComputedStyle(el).borderTopWidth)).toBe('0px');
+ }
+ expect((await bar.boundingBox())!.height).toBeLessThanOrEqual(46);
+});
diff --git a/src/ui/app.tsx b/src/ui/app.tsx
index c892a64..a801084 100644
--- a/src/ui/app.tsx
+++ b/src/ui/app.tsx
@@ -12,7 +12,7 @@ import {
} from '@tanstack/react-router';
import { Layout, basepath } from './components.tsx';
import { Compose, EditorPage } from './authoring.tsx';
-import { ReadingPage } from './reading.tsx';
+import { ReadingArticle, ReadingPage } from './reading.tsx';
import { AuthorizationsPage } from './authorizations.tsx';
import { SettingsPage } from './settings.tsx';
import {
@@ -177,6 +177,24 @@ const reading = createRoute({
},
component: () => ,
});
+/**
+ * One entry full screen: /reading/{sub}/{id}, `~own` as the sub for your own
+ * items. "more" on a card opens it; reloading stays on it.
+ */
+const article = createRoute({
+ getParentRoute: () => rootRoute,
+ path: '/reading/$sub/$id',
+ loader: () =>
+ Promise.all([
+ preloadView(listViews.subscriptions),
+ preloadView(listViews.hoppers),
+ preloadView(listViews.signals),
+ ]),
+ component: () => {
+ const { sub, id } = article.useParams();
+ return ;
+ },
+});
const edit = createRoute({
getParentRoute: () => rootRoute,
path: '/edit/$id',
@@ -289,6 +307,7 @@ export const router = createRouter({
routeTree: rootRoute.addChildren([
compose,
reading,
+ article,
edit,
settings,
setup,
diff --git a/src/ui/components.tsx b/src/ui/components.tsx
index 6d31cf0..4a9fe2f 100644
--- a/src/ui/components.tsx
+++ b/src/ui/components.tsx
@@ -149,7 +149,7 @@ type Tab = (typeof tabs)[number]['label'];
export function tabFor(path: string): Tab | null {
const p = path.replace(/\/+$/, '') || '/';
if (p === '/' || p.startsWith('/edit/')) return 'compose';
- if (p === '/reading' || p === '/subs' || p === '/fork') return 'reading';
+ if (p === '/reading' || p.startsWith('/reading/') || p === '/subs' || p === '/fork') return 'reading';
if (p === '/hoppers' || p.startsWith('/hoppers/')) return 'hoppers';
if (p === '/mentions') return 'mentions';
if (p === '/updates') return 'updates';
diff --git a/src/ui/reading.css b/src/ui/reading.css
index 7a358e7..aeb1d89 100644
--- a/src/ui/reading.css
+++ b/src/ui/reading.css
@@ -86,6 +86,7 @@
/* "more": small to look at, a 40px target (the ::after). */
.more { position: relative; background: none; border: none; border-radius: 0; color: var(--pencil); font-size: .8rem; font-weight: 600; padding: 2px 0; margin: 0 0 var(--s1); cursor: pointer; min-height: 0; }
.more::after { content: ""; position: absolute; inset: -10px -12px; }
+.entry-body.clamped.overflowing { cursor: pointer; }
/* The action row (Substack-scale): small line-icon thumbs, quiet text
actions, ⋯. The controls are .act (studio.css): ~20px to the eye, 40px to
the finger. */
@@ -94,6 +95,16 @@
.entry-bar .act.icon-only { padding: 0; }
.entry-bar .thumb .ico { width: 18px; height: 18px; }
+/* ---------- THE FULL VIEW (/reading/{sub}/{id}) ---------- */
+.article-head { display: flex; align-items: center; margin: 0 0 var(--s2) -8px; }
+.back-btn { font-size: .9rem; gap: 2px; padding: 0 10px 0 4px; color: var(--page-ink-soft); }
+.back-btn:hover { color: var(--page-ink); }
+.reading-article .entry-in { padding: var(--s4) 18px var(--s1); }
+.reading-article .article-title { font-size: 1.45rem; line-height: 1.25; margin: var(--s1) 0 var(--s3); }
+.reading-article .article-body { font-size: 1.06rem; line-height: 1.65; }
+.reading-article .article-body p { margin: 0 0 var(--s3); }
+.reading-article .entry-src { white-space: normal; overflow-wrap: anywhere; margin: var(--s3) 0 var(--s2); }
+@media (min-width: 900px) { .reading-article .entry-in { padding: var(--s5) 28px var(--s2); } }
.tomb { font-size: .78rem; color: var(--alert); margin: 0 0 8px; }
/* Read state (0026): a dot on unread imported entries, a ring on ones
diff --git a/src/ui/reading.tsx b/src/ui/reading.tsx
index b127576..0b72159 100644
--- a/src/ui/reading.tsx
+++ b/src/ui/reading.tsx
@@ -31,6 +31,7 @@ import { BlyggerApi, unwrap } from '../../sdk/dist/browser.js';
import type { Lens, Reading } from './data.ts';
import { LENSES, lensKind, readingKey } from './data.ts';
import {
+ reading,
readingView,
subscriptions,
hoppers,
@@ -43,6 +44,7 @@ import {
hopperDetail,
hopperPreview,
markReading,
+ saveReadState,
} from './data.ts';
import { needsRead, readStatus, readWrites } from './read-state.ts';
import {
@@ -63,7 +65,7 @@ import { useSwipe } from './swipe.ts';
import type { EntryContext, StudioExtension } from './extension-api.ts';
import { EntryBylineSlot, EntrySheetSlot, entryActionRows, enabledExtensions, type OpenSheet } from './extensions.tsx';
import { compiledExtensions } from '../../build/extensions.ui.ts';
-import { More, ThumbDown, ThumbUp } from './icons.tsx';
+import { Back, More, ThumbDown, ThumbUp } from './icons.tsx';
import './reading.css';
import type { CachedResponse } from './revision-query.ts';
@@ -252,9 +254,13 @@ function EntryTitle({ title, url, as: Tag = 'h3', className = 'entry-title' }: {
);
}
-function Body({ html, url, l0, onOpen }: { html: string; url?: string; l0: boolean; onOpen?: () => void }) {
- const [expanded, setExpanded] = useState(false),
- [overflow, setOverflow] = useState(false);
+/**
+ * The card's body, clamped. "more" opens the entry's full view (and so does
+ * a tap on the clamped text itself); it shows whenever the text is cut, which
+ * is measured again as images inside it load and change its height.
+ */
+function Body({ html, url, l0, onMore }: { html: string; url?: string; l0: boolean; onMore: () => void }) {
+ const [overflow, setOverflow] = useState(false);
const body = useRef(null);
const content = useMemo(() => splitTitle(html, l0), [html, l0]);
useEffect(() => {
@@ -262,27 +268,34 @@ function Body({ html, url, l0, onOpen }: { html: string; url?: string; l0: boole
if (!element) return;
const measure = () =>
setOverflow(element.scrollHeight > element.clientHeight + 1);
+ // The box stops resizing once it reaches the clamp, but its content keeps
+ // growing as images load: watch both, and every image's load.
const observer = new ResizeObserver(measure);
observer.observe(element);
+ for (const child of element.children) observer.observe(child);
+ element.addEventListener('load', measure, true);
measure();
- return () => observer.disconnect();
+ return () => {
+ observer.disconnect();
+ element.removeEventListener('load', measure, true);
+ };
}, [content.html]);
return (
<>
{
+ // A tap on cut-off text is a way in too; links and selections keep theirs.
+ if (!overflow || (event.target as Element).closest('a, button')) return;
+ if (!(window.getSelection()?.isCollapsed ?? true)) return;
+ onMore();
+ }}
dangerouslySetInnerHTML={{ __html: content.html }}
/>
- {overflow && !expanded ? (
-
{imported && !entry.l0 && a.historyOpen ? (
@@ -671,6 +760,133 @@ function Entry({
);
}
+/* ---------------- the full view ---------------- */
+
+/** An own item as a reading entry, for a full view opened by its address. */
+async function ownEntry(id: string): Promise {
+ const item = await unwrap(BlyggerApi.getItem({ client, path: { id } }));
+ const withdrawn = item.status === 'withdrawn';
+ const contentHtml = withdrawn ? '' : (item.published?.content_html ?? '');
+ const kind = item.authored_kind;
+ return {
+ key: `own:${id}`,
+ source: 'own',
+ kind,
+ withdrawn,
+ l0: false,
+ contentHtml,
+ displayAt: item.published?.published_at ?? item.updated,
+ rank: 0,
+ own: { id, kind, withdrawn, updated: item.updated, contentHtml },
+ };
+}
+/** The entry an address names: from what reading already holds, else fetched. */
+function useArticleEntry(sub: string, id: string) {
+ const key = sub === OWN_SUB ? `own:${id}` : `imported:${JSON.stringify([sub, id])}`;
+ const held = () => reading.toArray.find((row) => row.key === key);
+ const [entry, setEntry] = useState(held);
+ const [error, setError] = useState();
+ const sources = useLiveQuery({ query: (q) => q.from({ source: subscriptions }) }).data;
+ useEffect(() => {
+ const row = held();
+ if (row) {
+ setEntry(row);
+ return;
+ }
+ let live = true;
+ setEntry(undefined);
+ setError(undefined);
+ (sub === OWN_SUB
+ ? ownEntry(id)
+ : unwrap(BlyggerApi.getImportedItem({ client, path: { sub, id } })).then((item) =>
+ hopperEntry(item, subscriptions.get(sub), 0),
+ )
+ ).then(
+ (loaded) => live && setEntry(loaded),
+ (failure) => live && setError(failure),
+ );
+ return () => {
+ live = false;
+ };
+ }, [key]);
+ // A fetched entry names its source once subscriptions have loaded.
+ const source = sources?.find((row) => row.id === sub);
+ const shown =
+ entry?.imported && source && entry.imported.subscriptionTitle === sub
+ ? { ...entry, imported: { ...entry.imported, subscriptionTitle: sourceName(source) } }
+ : entry;
+ return { entry: shown, error };
+}
+/**
+ * One entry, full screen (/reading/{sub}/{id}): the whole post with its
+ * citation line and the card's action row. Opening it marks it read, as
+ * expanding a card did. Back returns to the timeline at the same entry.
+ */
+export function ReadingArticle({ sub, id }: { sub: string; id: string }) {
+ useChrome({ framed: false, wide: false });
+ const actions = useReadingActions();
+ const { entry, error } = useArticleEntry(sub, id);
+ const back = () => {
+ if (openedFromFeed && window.history.length > 1) {
+ openedFromFeed = false;
+ window.history.back();
+ } else void actions.navigate({ to: '/reading' });
+ };
+ return (
+ <>
+
+
+
+ back
+
+
+
+ {entry ? (
+
+ ) : !error ? (
+
Loading…
+ ) : null}
+ >
+ );
+}
+function Article({ entry, actions }: { entry: Reading; actions: Actions }) {
+ const votes = useLiveQuery({ query: (q) => q.from({ signal: signals }) }).data ?? [];
+ const buckets = useLiveQuery({ query: (q) => q.from({ hopper: hoppers }) }).data ?? [];
+ usePoll('signals', signals.utils.refetch);
+ // Opening the full view is opening the entry: mark it read once, at the
+ // version held here, as expanding a card did (0.39.0).
+ const marked = useRef(null);
+ useEffect(() => {
+ if (marked.current === entry.key) return;
+ marked.current = entry.key;
+ if (!needsRead(readStatus(entry.imported))) return;
+ // A row reading holds is marked through it (every view updates at once);
+ // one fetched by its address has no row to change, so it is written direct.
+ const write = reading.toArray.some((row) => row.key === entry.key)
+ ? markEntries([entry], true)
+ : saveReadState(readWrites([entry], true).read, []).then(() => changed('reading'));
+ void write.catch(() => toast('Could not store the read mark.'));
+ }, [entry.key]);
+ const a = useEntryActions({ entry, actions, votes, buckets, opened: () => {} });
+ const content = useMemo(() => splitTitle(entry.contentHtml, entry.l0), [entry.contentHtml, entry.l0]);
+ return (
+
+
+
+
+
+
+
+
+ {a.imported && !entry.l0 && a.historyOpen ? (
+
+ ) : null}
+
+
+
+ );
+}
+
/** Entries and the error line above them. */
function EntryList({
entries,
@@ -1450,6 +1666,7 @@ function Timeline({ sub, offset }: { sub: string; offset: number }) {
? sourceName(selected)
: 'all';
const readable = sub !== 'own' && !isPlaceholder(lens);
+ useReturnPoint(!!entries.data?.length);
const read = useReadSelection(key, offset, entries.data ?? [], actions, title, lens);
return (
@@ -1591,6 +1808,7 @@ function HopperTimeline({ id, offset }: { id: string; offset: number }) {
),
[row, sources, kind],
);
+ useReturnPoint(entries.length > 0);
if (!row)
return (
<>
From 30714cff704ecede7f4158247d652da2d7d954a5 Mon Sep 17 00:00:00 2001
From: Aneesh Sathe
Date: Sun, 18 Oct 2026 14:45:59 -0700
Subject: [PATCH 4/6] Composer and editor: one quiet toolbar, compact controls,
one scale
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
After Substack's composer; presentation only, no behaviour changes.
- Toolbar: one row of small line-icon buttons (attach image, link from
clipboard, scan text, make this a …, mark selection as generated),
each a 40px .act target that keeps its words as its accessible name
and its existing (#48) hover text as its title. [[, ![[ and [TK] are
syntax and keep their glyphs as small monospace buttons. The row fits
at 390px in the composer and the editor, so no overflow menu.
- fragment | thread and draft | preview: a compact segmented control.
- save draft / publish: 32px compact buttons, right-aligned on the
toolbar's row where width allows, their own row on a phone (also in
the editor's action bar, whose ⋯ is now the line icon). Full Editor →
is a quiet text action; the counter is small and muted by the actions.
- Textareas: borderless in the composer, a soft card in the editor, more
padding; the resize grip only with a fine pointer.
- The syntax paragraph (composer and editor) folds behind "syntax help",
closed by default, open state in localStorage (try/catch), text
verbatim.
- One scale: .btn-mini moves onto --ctl, the reading head's ☑ ↻ + lose
their boxes; spacing, radius and muted tokens from studio.css.
The attachments section is not touched.
e2e/compose-layout.spec.ts covers names, titles, fit, compact buttons
and the remembered disclosure; reading-article checks the read on the
server instead of a feed page the entry may have left.
Co-Authored-By: Claude Opus 5.5
---
e2e/compose-layout.spec.ts | 89 +++++++++++++++++
e2e/reading-article.spec.ts | 4 +-
src/ui/authoring.css | 54 ++++++----
src/ui/authoring.tsx | 194 +++++++++++++++++++++++-------------
src/ui/reading.css | 3 +-
src/ui/studio.css | 2 +-
6 files changed, 254 insertions(+), 92 deletions(-)
create mode 100644 e2e/compose-layout.spec.ts
diff --git a/e2e/compose-layout.spec.ts b/e2e/compose-layout.spec.ts
new file mode 100644
index 0000000..3196df8
--- /dev/null
+++ b/e2e/compose-layout.spec.ts
@@ -0,0 +1,89 @@
+import { test, expect, type Page } from './fixture';
+
+// The composer and the editor after Substack's composer: one row of small
+// line-icon tools that keep their words (accessible name) and hover text
+// (title), a compact kind / pane switch, compact save draft and publish, and
+// the syntax paragraph folded behind "syntax help" (remembered per device).
+async function login(page: Page) {
+ await page.goto('/studio/login');
+ await page.locator('[name=password]').fill('test-password');
+ await page.getByRole('button', { name: 'log in', exact: true }).click();
+ await expect(page.locator('#composer-text')).toBeVisible();
+}
+const api = (page: Page, method: string, path: string, body?: unknown) =>
+ page.evaluate(
+ async ({ method, path, body }) => {
+ const response = await fetch(`/api${path}`, { method, headers: { 'content-type': 'application/json' }, ...(body === undefined ? {} : { body: JSON.stringify(body) }) });
+ return { status: response.status, json: await response.json().catch(() => null) };
+ },
+ { method, path, body },
+ );
+/** Every control in `row` sits inside the viewport and is a 40px target. */
+async function fits(page: Page, selector: string) {
+ const width = page.viewportSize()!.width;
+ const row = page.locator(selector);
+ await expect(row).toBeVisible();
+ for (const control of await row.locator('button').all()) {
+ const box = (await control.boundingBox())!;
+ expect(box.x).toBeGreaterThanOrEqual(0);
+ expect(box.x + box.width).toBeLessThanOrEqual(width);
+ expect(box.height).toBeGreaterThanOrEqual(40);
+ expect(box.width).toBeGreaterThanOrEqual(40);
+ }
+ expect(await row.evaluate((el) => el.scrollWidth <= el.clientWidth + 1)).toBe(true);
+}
+
+test('the composer: icon tools keep their names and titles, compact actions, folded syntax help', async ({ page }) => {
+ await login(page);
+ const tools = page.locator('.composer-tools');
+ for (const [name, title] of [
+ ['attach image', 'Upload an image and insert it at the cursor'],
+ ['link from clipboard', /^Read a URL from the clipboard/],
+ ['scan text', /^Scan printed text/],
+ ['[[ link an item', /^Link to an item/],
+ ['[TK] instruction', /^Write an instruction for the model/],
+ ['Full Editor →', 'Full Editor →'],
+ ] as const) {
+ const button = tools.getByRole('button', { name, exact: true });
+ await expect(button).toBeVisible();
+ await expect(button).toHaveAttribute('title', title);
+ }
+ await expect(tools.getByRole('button', { name: 'attach image', exact: true }).locator('svg')).toHaveCount(1);
+ await expect(tools.getByRole('button', { name: '[[ link an item', exact: true })).toHaveText('[[');
+ await fits(page, '.composer-tools');
+ // Compact segmented kind switch, compact buttons.
+ await expect(page.getByRole('radiogroup', { name: 'kind' })).toHaveClass(/compact/);
+ for (const id of ['#save-draft-btn', '#publish-btn']) expect((await page.locator(id).boundingBox())!.height).toBeLessThanOrEqual(36);
+ // Syntax help is folded by default, text intact, and the choice is remembered.
+ const help = page.locator('details.syntax-help');
+ await expect(help).not.toHaveAttribute('open');
+ await expect(help.getByText(/^Markdown supported\./)).toBeHidden();
+ await help.locator('summary').click();
+ await expect(help.getByText(/^Markdown supported\./)).toBeVisible();
+ await page.reload();
+ await expect(page.locator('details.syntax-help')).toHaveAttribute('open');
+ await page.locator('details.syntax-help summary').click();
+ await page.reload();
+ await expect(page.locator('details.syntax-help')).not.toHaveAttribute('open');
+ // A thread adds ![[; the row still fits.
+ await page.getByRole('radio', { name: 'thread' }).click();
+ await expect(tools.getByRole('button', { name: '![[ quote an item', exact: true })).toBeVisible();
+ await fits(page, '.composer-tools');
+});
+
+test('the editor: one row of tools that fits, its own items named as before', async ({ page }) => {
+ await login(page);
+ const { json } = await api(page, 'POST', '/items', { content_md: 'Layout draft.', kind: 'thread' });
+ try {
+ await page.goto(`/studio/edit/${json.id}`);
+ await expect(page.locator('#md-input')).toBeVisible();
+ await fits(page, '.ed-tools');
+ const tools = page.locator('.ed-tools');
+ await expect(tools.getByRole('button', { name: 'make this a fragment', exact: true })).toHaveAttribute('title', 'make this a fragment');
+ await expect(tools.getByRole('button', { name: 'mark selection as generated', exact: true })).toHaveAttribute('title', /^Wrap the selected text/);
+ await expect(page.locator('details.syntax-help')).toBeVisible();
+ for (const id of ['#save-draft-btn', '#publish-btn']) expect((await page.locator(id).boundingBox())!.height).toBeLessThanOrEqual(36);
+ } finally {
+ await api(page, 'DELETE', `/items/${json.id}`);
+ }
+});
diff --git a/e2e/reading-article.spec.ts b/e2e/reading-article.spec.ts
index 2fdd79a..d48a49d 100644
--- a/e2e/reading-article.spec.ts
+++ b/e2e/reading-article.spec.ts
@@ -125,7 +125,9 @@ test('an imported blyg entry opens by its address with the action row, and openi
// Back with no feed behind it goes to the feed.
await page.getByRole('button', { name: 'back', exact: true }).click();
await expect(page).toHaveURL(/\/studio\/reading$/);
- await expect(card(page, 'Native title')).toHaveAttribute('data-read', 'read');
+ // The read reached the server (the feed's first page may not hold this entry).
+ const { json } = await api(page, 'GET', '/reading?sub=parity-native&limit=50');
+ expect(json.items.find((e: { imported?: { remoteId: string } }) => e.imported?.remoteId === NATIVE).imported.readVersion).toBe(1);
} finally {
await clear();
}
diff --git a/src/ui/authoring.css b/src/ui/authoring.css
index 6228ebc..55553f6 100644
--- a/src/ui/authoring.css
+++ b/src/ui/authoring.css
@@ -3,26 +3,40 @@
* Builds on the vocabulary in studio.css (.card .seg .badge .dot .banner
* .btn …); only what these two screens add lives here. Sans throughout. */
-/* ---------- COMPOSER ---------- */
-.composer .segmented { margin-bottom: 8px; }
+/* ---------- COMPOSER ----------
+ After Substack's composer: a compact kind switch, a borderless text area
+ with room to breathe, and one baseline below it — small line-icon tools on
+ the left, the counter and two compact buttons on the right. On a phone the
+ tools and the actions take a row each. Controls are .act / .btn-compact /
+ .segmented.compact from studio.css. */
+.composer { padding: var(--s4) var(--s4) var(--s2); }
+.composer .segmented.compact { margin-bottom: var(--s2); }
.ta-wrap { position: relative; }
-.composer textarea { width: 100%; min-height: 128px; background: var(--card); border: none; padding: 4px 2px; box-shadow: none; resize: vertical; font-size: 1rem; }
+.composer textarea { display: block; width: 100%; min-height: 140px; background: transparent; border: none; border-radius: 0; padding: var(--s2) 2px var(--s3); box-shadow: none; resize: none; font-size: 1.05rem; line-height: 1.6; }
.composer textarea:focus { box-shadow: none; }
-.composer-tools, .ed-tools { display: flex; gap: 6px; align-items: center; }
-.composer-tools { flex-wrap: wrap; border-top: 1px dashed var(--rule); padding-top: 10px; margin-top: 6px; }
-.ed-tools { overflow-x: auto; margin: 0 0 8px; padding-bottom: 2px; scrollbar-width: none; }
-.ed-tools::-webkit-scrollbar { display: none; }
-.ed-tools .tool { flex: 0 0 auto; }
-.tool { font: inherit; font-family: var(--mono); font-size: .8rem; line-height: 1.2; padding: 6px 10px; border-radius: 8px; border: 1px solid var(--rule); background: var(--card); color: var(--ink-soft); cursor: pointer; white-space: nowrap; }
-.tool.sans { font-family: var(--sans); }
-.tool:hover { color: var(--ink); border-color: var(--rule-strong); }
-.tool:disabled { opacity: .5; cursor: not-allowed; }
-.counter { font-size: .78rem; color: var(--ink-soft); font-variant-numeric: tabular-nums; margin-left: auto; }
+.composer textarea::placeholder, .editor-pane textarea.md::placeholder { color: var(--muted); opacity: .8; }
+.composer-foot { display: flex; flex-wrap: wrap; align-items: center; column-gap: var(--s2); border-top: 1px solid var(--rule); margin: 0 calc(-1 * var(--s2)); padding: 2px 0 0; }
+.composer-tools, .ed-tools { display: flex; align-items: center; gap: 0; flex-wrap: wrap; min-width: 0; }
+.composer-tools .full-editor { font-size: .78rem; }
+.ed-tools { margin: 0 0 var(--s1) calc(-1 * var(--s2)); }
+.tool .ico { width: 19px; height: 19px; }
+.counter { font-size: .74rem; color: var(--muted); font-variant-numeric: tabular-nums; margin-left: auto; }
.counter.over { color: var(--alert); font-weight: 700; }
.over-hint { color: var(--alert); }
.over-hint button.link { color: inherit; font-weight: 600; }
.save-state { font-size: .78rem; color: var(--ok); font-weight: 600; }
-.composer-actions { display: flex; gap: 8px; align-items: center; margin-top: 12px; flex-wrap: wrap; }
+.composer-actions { display: flex; gap: var(--s2); align-items: center; flex-wrap: wrap; margin-left: auto; padding: var(--s1) var(--s2); }
+.composer-actions .counter { margin-left: 0; }
+/* The resize grip helps with a mouse; on touch it is a stray target. */
+@media (hover: hover) and (pointer: fine) { .composer textarea { resize: vertical; } }
+/* "syntax help": the long syntax paragraph, folded (open state per device). */
+.syntax-help { margin: 0 2px var(--s3); }
+.syntax-help > summary { display: inline-flex; align-items: center; gap: 4px; min-height: 32px; cursor: pointer; font-size: .8rem; color: var(--page-ink-soft); list-style: none; }
+.syntax-help > summary::-webkit-details-marker { display: none; }
+.syntax-help > summary::before { content: "▸"; font-size: .7rem; transition: transform .15s; }
+.syntax-help[open] > summary::before { transform: rotate(90deg); }
+.syntax-help > summary:hover { color: var(--page-ink); }
+.syntax-help > p { margin: var(--s1) 0 0; font-size: .82rem; }
.publish-warning { font-size: .85rem; color: var(--warn); margin: 10px 0 0; }
.filters { margin-top: 20px; }
@@ -97,8 +111,12 @@
}
.editor-grid { display: block; }
.editor-pane, .preview-pane { min-width: 0; }
-.editor-pane textarea.md { width: 100%; min-height: 46vh; font-family: var(--mono); font-size: 1rem; background: var(--card); border: 1px solid var(--rule); }
-.ed-foot { margin-top: 6px; font-size: .8rem; color: var(--page-ink-soft); }
+.editor-pane textarea.md { display: block; width: 100%; min-height: 46vh; font-family: var(--mono); font-size: 1rem; line-height: 1.6; background: var(--card); border: 1px solid transparent; border-radius: var(--radius); padding: var(--s4); box-shadow: var(--shadow); }
+.editor-pane textarea.md:focus { border-color: var(--rule-strong); box-shadow: var(--shadow); }
+@media (hover: none) { .editor-pane textarea.md { resize: none; } }
+.editor .pane-toggle { margin: 0 0 var(--s3); }
+.ed-foot { margin-top: var(--s2); font-size: .78rem; color: var(--page-ink-soft); }
+.editor-pane .syntax-help { margin-top: var(--s1); }
.ed-foot .counter { margin-left: 0; color: inherit; }
.ed-foot a { color: var(--page-ink); }
.editor-pane .hint { color: var(--page-ink-soft); }
@@ -153,7 +171,7 @@
#snapshots .q-passage-missing .q-state, #snapshots .q-unresolvable .q-state { color: var(--alert); }
#snapshots .banner { margin: 10px 0 0; }
.refresh-row { margin-top: 12px; }
-.editor .actionbar .more-btn { font-size: 1.2rem; }
+.editor .actionbar { padding-top: 6px; padding-bottom: max(6px, env(safe-area-inset-bottom)); }
/* ---------- PALETTE (the [[ / ![[ picker) ---------- */
@@ -168,7 +186,7 @@
@media (max-width: 640px) {
.tk-scope-row { flex-wrap: wrap; }
.tk-instruction { white-space: normal; overflow: visible; flex-basis: 100%; }
- .editor .actionbar .btn { padding: 10px 12px; }
+
}
.stub-help p[tabindex] { outline: none; }
.stub-help pre code { padding: 0; background: none; border: none; font-size: inherit; }
diff --git a/src/ui/authoring.tsx b/src/ui/authoring.tsx
index da07cdf..acf123b 100644
--- a/src/ui/authoring.tsx
+++ b/src/ui/authoring.tsx
@@ -45,6 +45,7 @@ import type { Resolve } from './plain-text.ts';
import { itemTitle, permalink, plainText, textWithLink } from './plain-text.ts';
import { OcrScan } from './ocr.tsx';
import { addStubQuote, passageCount, stubQuoteForm, withStubQuote } from './stub-quote.ts';
+import { Image as ImageIcon, LinkIcon, More, Scan, Sparkle, Swap } from './icons.tsx';
import './authoring.css';
type Kind = 'fragment' | 'thread';
@@ -134,6 +135,37 @@ function Help({ thread, className = 'compose-help' }: { thread?: boolean; classN
);
}
+/** Whether "syntax help" was left open (per device; false when storage is refused). */
+const SYNTAX_HELP_KEY = 'studio.syntaxHelpOpen';
+function syntaxHelpOpen() {
+ try {
+ return localStorage.getItem(SYNTAX_HELP_KEY) === '1';
+ } catch {
+ return false;
+ }
+}
+/** The syntax paragraph behind a small "syntax help" disclosure, closed until opened once. */
+function SyntaxHelp({ children }: { children: ReactNode }) {
+ const [open, setOpen] = useState(syntaxHelpOpen);
+ return (
+ {
+ const next = (event.currentTarget as HTMLDetailsElement).open;
+ setOpen(next);
+ try {
+ localStorage.setItem(SYNTAX_HELP_KEY, next ? '1' : '0');
+ } catch {
+ /* private mode: the choice lasts this page only */
+ }
+ }}
+ >
+ syntax help
+ {children}
+
+ );
+}
/**
* Put `block` on its own paragraph at [start, end) of `text`, adding only the
* blank lines the surrounding text does not already supply.
@@ -376,7 +408,12 @@ function useTextTools(
};
}
type TextTools = ReturnType;
-/** The tool row: attach image, link, scan, the bracket forms and TK, plus a screen's own. */
+/**
+ * The tool row: attach image, link, scan, the bracket forms and TK, plus a
+ * screen's own. One row of small line icons (.act, a 40px target each); each
+ * keeps its words as its accessible name and its hover text as its title.
+ * The bracket forms are syntax and show their own glyphs.
+ */
function ToolRow({
tools,
thread,
@@ -398,33 +435,36 @@ function ToolRow({
- attach image
+
+ attach image void tools.linkFromClipboard()}
>
- 🔗 link from clipboard
+
+ link from clipboard
- ⌸ scan text
+
+ scan text
{thread ? (
) : null}
compose
-
+
+
+ setPublished(undefined)} />
-
+
{(['fragment', 'thread'] as const).map((value) => (
{tools.coach}
- upload.pick()}
- attachId="composer-attach"
- className="composer-tools"
- >
- void action.run(openEditor)}
+ {/* Tools left, actions right: one row where it fits, two on a phone. */}
+
+ upload.pick()}
+ attachId="composer-attach"
+ className="composer-tools"
>
- Full Editor →
-
-
- {/* Only fragments have a length limit; a thread shows its count alone. */}
- {kind === 'thread' ? `${text.length} chars` : `${text.length} / ${MAX}`}
-
-
+ void action.run(openEditor)}
+ >
+ Full Editor →
+
+
+