diff --git a/e2e/compose-layout.spec.ts b/e2e/compose-layout.spec.ts new file mode 100644 index 0000000..7675c61 --- /dev/null +++ b/e2e/compose-layout.spec.ts @@ -0,0 +1,93 @@ +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(); + //
fires toggle asynchronously; reload only once the choice is stored. + const stored = () => page.evaluate(() => localStorage.getItem('studio.syntaxHelpOpen')); + await expect.poll(stored).toBe('1'); + await page.reload(); + await expect(page.locator('details.syntax-help')).toHaveAttribute('open'); + await page.locator('details.syntax-help summary').click(); + await expect.poll(stored).toBe('0'); + 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/pwa-zoom.spec.ts b/e2e/pwa-zoom.spec.ts new file mode 100644 index 0000000..0050e3f --- /dev/null +++ b/e2e/pwa-zoom.spec.ts @@ -0,0 +1,168 @@ +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) + '

' + + '
' + 'const wide = "' + 'w'.repeat(400) + '";' + '
' + + '' + Array.from({ length: 14 }, (_, i) => ``).join('') + '
column_${i}_wide_cell_content
' + + `

wide

` + + '' + + Array.from({ length: 6 }, (_, i) => `

Filler paragraph ${i} so the card clamps.

`).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({
- upload.pick()} - attachId="composer-attach" - className="composer-tools" - > - - - {/* Only fragments have a length limit; a thread shows its count alone. */} - {kind === 'thread' ? `${text.length} chars` : `${text.length} / ${MAX}`} - - + + +
+ + + {/* Only fragments have a length limit; a thread shows its count alone. */} + {kind === 'thread' ? `${text.length} chars` : `${text.length} / ${MAX}`} + + + {text.includes('[TK]') ? ( + + ) : null} + + +
+ {over ? (

Too long for a fragment ·{' '} @@ -948,36 +1024,6 @@ export function Compose() {

) : null} -
- - - {text.includes('[TK]') ? ( - - ) : null} - - -
{upload.element} {action.warning ? ( @@ -1763,7 +1809,7 @@ function Editor({ item }: { item: Detail }) { fork of {item.forked_from.id} v{item.forked_from.version}

) : null} -
+
{(['draft', 'preview'] as const).map((value) => ( ) : null}
@@ -1848,7 +1897,9 @@ function Editor({ item }: { item: Detail }) { ) : null}
- + + +
@@ -2150,16 +2201,17 @@ function Editor({ item }: { item: Detail }) { {saved ? saved : null} ) : null} @@ -305,26 +328,99 @@ function entryParts(entry: Reading) { return { imported, id, url, source }; } -/* ---------------- one entry ---------------- */ +/* ---------------- the full view's address ---------------- */ -function Entry({ +/** The `sub` segment of an own item's full view (the hoppers' own-post id). */ +const OWN_SUB = '~own'; +/** Where an entry's full view lives: /reading/{sub}/{id}, `~own` for your own. */ +export function articleParams(entry: Reading) { + return entry.own || entry.imported?.subscriptionId === OWN_SUB + ? { sub: OWN_SUB, id: entry.own?.id ?? entry.imported!.remoteId } + : { sub: entry.imported!.subscriptionId, id: entry.imported!.remoteId }; +} +/** + * The feed position to come back to. Set when an entry's full view opens, + * read once by the timeline it was opened from: the entry's key and how far + * from the top of the viewport it sat, so back lands on the same item. + */ +type ReturnPoint = { href: string; key: string; top: number; y: number }; +// Kept in sessionStorage too, so the browser's back after a reload of the +// full view still lands on the entry; memory alone when storage is refused. +const RETURN_KEY = 'studio.reading.return'; +let returnPoint: ReturnPoint | null = null; +function setReturnPoint(point: ReturnPoint | null) { + returnPoint = point; + try { + if (point) sessionStorage.setItem(RETURN_KEY, JSON.stringify(point)); + else sessionStorage.removeItem(RETURN_KEY); + } catch { + /* private mode or blocked storage: memory only */ + } +} +function takeReturnPoint(): ReturnPoint | null { + let point = returnPoint; + if (!point) + try { + point = JSON.parse(sessionStorage.getItem(RETURN_KEY) ?? 'null') as ReturnPoint | null; + } catch { + point = null; + } + if (!point || point.href !== feedHref()) return null; + setReturnPoint(null); + return point; +} +const feedHref = () => location.pathname + location.search; +/** Put the timeline back where the full view was opened from. */ +function useReturnPoint(ready: boolean) { + useEffect(() => { + if (!ready) return; + const point = takeReturnPoint(); + if (!point) return; + const restore = () => { + const element = document.querySelector(`.reading-entry[data-key="${CSS.escape(point.key)}"]`); + const y = element ? element.getBoundingClientRect().top + window.scrollY - point.top : point.y; + window.scrollTo(0, y); + }; + // After the router's own scroll handling for the navigation, and again + // once late layout (images above) has settled. + const frame = requestAnimationFrame(() => requestAnimationFrame(restore)); + const late = setTimeout(restore, 250); + return () => { + cancelAnimationFrame(frame); + clearTimeout(late); + }; + }, [ready]); +} +/** Whether this full view was opened from a timeline in this session (so back is history's back). */ +let openedFromFeed = false; +function useOpenArticle(entry: Reading) { + const navigate = useNavigate(); + return () => { + const element = document.querySelector(`.reading-entry[data-key="${CSS.escape(entry.key)}"]`); + setReturnPoint({ href: feedHref(), key: entry.key, top: element?.getBoundingClientRect().top ?? 0, y: window.scrollY }); + openedFromFeed = true; + void navigate({ to: '/reading/$sub/$id', params: articleParams(entry) }); + }; +} + +/* ---------------- an entry's actions (card and full view) ---------------- */ + +function useEntryActions({ entry, actions, votes, buckets, + opened, }: { entry: Reading; actions: Actions; votes: SignalRow[]; buckets: Hopper[]; + /** The owner followed the entry to its origin. */ + opened: () => void; }) { const { run, openDraft, navigate } = actions; const settings = useSettings(); - const readUi = useContext(ReadContext); - const status = readUi.shown ? readStatus(entry.imported) : null; - const opened = () => { - if (entry.imported) readUi.opened(entry); - }; const [historyOpen, setHistoryOpen] = useState(false); const [extensionSheet, setExtensionSheet] = useState(null); const extensions = enabledExtensions(compiledExtensions, settings?.extensions); @@ -334,7 +430,6 @@ function Entry({ vote.subscription_id === imported?.subscriptionId && vote.remote_id === imported.remoteId, ); - const date = formatDateIn(entry.displayAt, settings?.timezone || 'UTC'); const vote = (thumb: 1 | -1) => run(async () => { if (!imported) return; @@ -467,11 +562,139 @@ function Entry({ ], }); }; + return { + imported, + id, + url, + signal, + vote, + stub, + addToHopper, + openMenu, + historyOpen, + extensions, + extensionContext, + extensionSheet, + closeExtensionSheet: () => setExtensionSheet(null), + }; +} +type EntryActions = ReturnType; + +/** The byline: kind, legacy rss, source and date, plus the extensions' slot. */ +function Byline({ entry, a, children }: { entry: Reading; a: EntryActions; children?: ReactNode }) { + const settings = useSettings(); + const date = formatDateIn(entry.displayAt, settings?.timezone || 'UTC'); + return ( +

+ {children} + {entry.kind} + {entry.l0 ? legacy rss : null} + {a.imported?.subscriptionTitle || 'you'} + · {date} + +

+ ); +} +function Tomb({ entry }: { entry: Reading }) { + return entry.withdrawn ? ( +

+ withdrawn + {entry.imported?.pinnedVersionRetained != null + ? ` · retained pinned v${entry.imported.pinnedVersionRetained}` + : ''} +

+ ) : 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 ( +
+ {imported + ? ([1, -1] as const).map((thumb) => ( + + )) + : null} + + {entry.own ? ( + + edit + + ) : null} + {imported ? ( + <> + + + + ) : null} + +
+ ); +} + +/* ---------------- one entry ---------------- */ + +function Entry({ + entry, + actions, + votes, + buckets, +}: { + entry: Reading; + actions: Actions; + votes: SignalRow[]; + buckets: Hopper[]; +}) { + const readUi = useContext(ReadContext); + const status = readUi.shown ? readStatus(entry.imported) : null; + const opened = () => { + if (entry.imported) readUi.opened(entry); + }; + const a = useEntryActions({ entry, actions, votes, buckets, opened }); + const { imported, id, url, signal } = a; + const openArticle = useOpenArticle(entry); const swipe = useSwipe( imported ? { - onRight: () => void vote(1), - onLeft: () => void stub(), + onRight: () => void a.vote(1), + onLeft: () => void a.stub(), mouseFrom: '.byline, .entry-bar', mover: '.entry', } @@ -503,7 +726,7 @@ function Entry({ if ((event.target as Element).closest('.entry-title a, a.entry-src')) opened(); }} > -

+ {readUi.selecting && imported ? (

- {entry.withdrawn ? ( -

- withdrawn - {imported?.pinnedVersionRetained != null - ? ` · retained pinned v${imported.pinnedVersionRetained}` - : ''} -

- ) : null} - + + +
- {imported && !entry.l0 && historyOpen ? ( + {imported && !entry.l0 && a.historyOpen ? ( ) : null} -
- {imported - ? ([1, -1] as const).map((thumb) => ( - - )) - : null} - - {entry.own ? ( - - edit - - ) : null} - {imported ? ( - <> - - - - ) : null} - -
- setExtensionSheet(null)} /> + +
); } +/* ---------------- 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 ( + <> +
+ +
+ + {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, @@ -1383,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 ( @@ -1524,6 +1808,7 @@ function HopperTimeline({ id, offset }: { id: string; offset: number }) { ), [row, sources, kind], ); + useReturnPoint(entries.length > 0); if (!row) return ( <> diff --git a/src/ui/studio.css b/src/ui/studio.css index ae740a4..db3ac65 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); @@ -66,6 +73,9 @@ html { -webkit-text-size-adjust: 100%; } body { font-family: var(--sans); color: var(--ink); background: var(--page); -webkit-font-smoothing: antialiased; line-height: 1.5; min-height: 100vh; + /* No double-tap zoom on a quick second tap of a button (iOS waits for, and + then zooms on, it). Pinch zoom stays: user zoom is never disabled. */ + touch-action: manipulation; } h1, h2, h3, h4 { font-family: var(--sans); font-weight: 600; letter-spacing: .1px; } code, pre, kbd, .mono { font-family: var(--mono); } @@ -147,6 +157,13 @@ details.card[open] > summary { margin-bottom: 12px; } :where(input, textarea, select):focus-visible { outline: none; } :where(.field, .card, .sheet) :where(input:not([type]), input[type=text], input[type=url], input[type=search], input[type=password], textarea, select) { width: 100%; } :where(textarea) { resize: vertical; line-height: 1.55; } +/* iOS Safari zooms the page when a field under 16px takes focus, and does not + zoom back: the "page zooms in and has to be resized" report. Every field + rule above inherits 16px; this is the guard for a screen rule (or an + extension's) that shrinks one, on touch screens only. */ +@media (pointer: coarse) { + input:not([type=checkbox]):not([type=radio]):not([type=range]):not([type=color]):not([type=file]), textarea, select, [contenteditable]:not([contenteditable=false]) { font-size: max(16px, 1em) !important; } +} .check { display: flex; gap: 10px; align-items: flex-start; font-size: .9rem; margin: 6px 0 12px; } .check input { margin-top: 4px; accent-color: var(--pencil); } .row { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; } @@ -171,13 +188,45 @@ button.link { background: none; border: none; padding: 0; font: inherit; color: .btn-ghost { background: transparent; color: var(--ink-soft); border-color: var(--rule); } .btn-ghost:hover { color: var(--ink); border-color: var(--rule-strong); } .btn-danger { background: transparent; color: var(--alert); border-color: var(--alert); } -.btn-mini { padding: 6px 10px; font-size: .8rem; border-radius: 8px; } +.btn-mini { min-height: var(--ctl); padding: 0 12px; font-size: .8rem; border-radius: var(--radius-sm); } .btn:disabled { opacity: .5; cursor: not-allowed; } .btn.is-on { background: var(--pencil-bg); color: var(--pencil); border-color: var(--pencil); } .icon-btn { background: none; border: 1px solid transparent; border-radius: 8px; padding: 5px 8px; cursor: pointer; color: var(--ink-soft); font-size: .95rem; line-height: 1; } .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; }