`).join('');
+/** Serve the feed with one more legacy-RSS entry at the top: the wide post. */
+async function injectWidePost(page: Page) {
+ await page.route(/\/api\/reading(\?|$)/, async (route) => {
+ const response = await route.fetch();
+ const page0 = await response.json();
+ if (page0.offset === 0) {
+ const at = '2026-10-17T00:00:00Z';
+ page0.items.unshift({
+ key: 'imported:["parity-rss","wide-fixture"]', source: 'imported', kind: 'fragment', withdrawn: false, l0: true, contentHtml: WIDE_HTML, displayAt: at,
+ imported: { subscriptionId: 'parity-rss', subscriptionTitle: 'Legacy source', remoteId: 'wide-fixture', kind: 'fragment', withdrawn: false, l0: true, updated: at, observedAt: at, contentHtml: WIDE_HTML, pinnedVersionRetained: null, sourceUrl: 'https://legacy.example/' + 'very-long-path-segment/'.repeat(12), version: 1, readVersion: 1 },
+ });
+ }
+ await route.fulfill({ response, json: page0 });
+ });
+}
+
+test('reading a post with wide content never scrolls the page sideways', async ({ page }) => {
+ await login(page);
+ await injectWidePost(page);
+ await page.goto('/studio/reading');
+ const entry = page.locator('.reading-entry').filter({ hasText: 'Wide fixture post' });
+ 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 ['.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..1043a33
--- /dev/null
+++ b/e2e/reading-article.spec.ts
@@ -0,0 +1,165 @@
+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;
+}
+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()}`];
+ // Left published, as other suites leave theirs: withdrawing them would put
+ // url-less tombstones in the feed that reading-pwa's every-card check counts.
+ const ids = await longThreads(page, tags);
+ {
+ 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]}$`));
+ }
+});
+
+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$/);
+ // 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();
+ }
+});
+
+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/authoring.css b/src/ui/authoring.css
index 713e782..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: .9rem; 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); }
@@ -106,6 +124,10 @@
.preview-pane h1 { font-size: 1.35rem; margin: 0 0 10px; }
.preview-pane p { margin: 0 0 12px; }
.preview img { max-width: 100%; border-radius: 8px; }
+/* Wide content scrolls inside its box, never widens the page (iOS lays a wider
+ page out zoomed out, which reads as a random zoom). */
+.preview :is(img, video, iframe, embed, object, svg, canvas), #h-viewer-body :is(video, iframe, embed, object, svg, canvas) { max-width: 100%; height: auto; }
+.preview :is(pre, table), #h-viewer-body :is(pre, table) { display: block; max-width: 100%; overflow-x: auto; }
.preview blockquote.blyg-transclusion { margin: 1rem 0; padding: 8px 12px; border-left: 3px solid var(--pencil); background: var(--sunk); border-radius: 0 8px 8px 0; font-size: .95em; }
.preview blockquote.blyg-transclusion p { margin: 0 0 .25rem; }
.preview .provenance { font-size: .75rem; color: var(--ink-soft); }
@@ -149,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) ---------- */
@@ -164,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({