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) + '

' + + '
' + '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 table and the code scroll inside their own boxes. + for (const selector of ['table', 'pre']) { + const box = await entry.locator(selector).boundingBox(); + expect(box!.x + box!.width, selector).toBeLessThanOrEqual(page.viewportSize()!.width); + } +}); diff --git a/src/ui/authoring.css b/src/ui/authoring.css index 713e782..6228ebc 100644 --- a/src/ui/authoring.css +++ b/src/ui/authoring.css @@ -97,7 +97,7 @@ } .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); } +.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); } .ed-foot .counter { margin-left: 0; color: inherit; } .ed-foot a { color: var(--page-ink); } @@ -106,6 +106,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); } diff --git a/src/ui/picker.css b/src/ui/picker.css index 6768ea7..61fac7d 100644 --- a/src/ui/picker.css +++ b/src/ui/picker.css @@ -27,7 +27,7 @@ .picker-query { margin: 0; font-size: .82rem; color: var(--ink-soft); } .picker-filters { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; } .picker select { width: auto; max-width: 100%; } -.picker-filters select { font-size: .85rem; padding: 4px 6px; } +.picker-filters select { font-size: 1rem; padding: 4px 6px; } .picker-seg { display: inline-flex; border: 1px solid var(--rule-strong); border-radius: 8px; overflow: hidden; } .picker-seg label { position: relative; } .picker-seg input { position: absolute; opacity: 0; inset: 0; margin: 0; cursor: pointer; } @@ -46,4 +46,4 @@ .picker-foot { display: flex; flex-wrap: wrap; gap: 6px 12px; align-items: center; justify-content: space-between; font-size: .78rem; color: var(--ink-soft); border-top: 1px solid var(--rule); padding-top: 8px; } .picker-foot .hint { margin: 0; } @media (hover: none) { .picker-foot { display: none; } } -.picker-foot select { font-size: .78rem; padding: 2px 4px; } +.picker-foot select { font-size: 1rem; padding: 2px 4px; } diff --git a/src/ui/reading.css b/src/ui/reading.css index 3721793..ef6bea8 100644 --- a/src/ui/reading.css +++ b/src/ui/reading.css @@ -70,7 +70,14 @@ .entry-body blockquote { margin: 10px 0; padding: 8px 12px; border-left: 3px solid var(--pencil); background: var(--sunk); border-radius: 0 8px 8px 0; font-size: .95em; } .entry-body blockquote.blyg-partial { border-left-style: dashed; } .entry-body blockquote > :last-child { margin-bottom: 0; } -.entry-body pre { overflow-x: auto; background: var(--sunk); border-radius: 8px; padding: 8px 10px; font-size: .85rem; } +.entry-body pre { overflow-x: auto; max-width: 100%; background: var(--sunk); border-radius: 8px; padding: 8px 10px; font-size: .85rem; } +/* Imported HTML carries fixed-width media, tables and embeds. They shrink or + scroll inside the card; none may widen the page (a wider page is laid out + zoomed out on iOS, which reads as the studio zooming at random). */ +.entry-body :is(video, iframe, embed, object, svg, canvas) { max-width: 100%; height: auto; } +.entry-body table { display: block; max-width: 100%; overflow-x: auto; border-collapse: collapse; } +.entry-body :is(td, th) { border: 1px solid var(--rule); padding: 4px 8px; } +.entry-body a { overflow-wrap: anywhere; } .entry-body.clamped { max-height: 9.5em; overflow: hidden; position: relative; } .entry-body.clamped.overflowing::after { content: ""; position: absolute; left: 0; right: 0; bottom: 0; height: 3em; background: linear-gradient(transparent, var(--card)); pointer-events: none; } .entry-src { display: block; margin: 2px 0 8px; font-size: .78rem; color: var(--ink-soft); text-decoration: none; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } @@ -215,7 +222,7 @@ .theme-colour input[type="color"] { width: 44px; height: 44px; padding: 0; border: 1px solid var(--rule); border-radius: 8px; background: none; flex: none; cursor: pointer; } .theme-colour-text { display: grid; gap: 3px; flex: 1; min-width: 0; } .theme-colour-text label { font-size: .85rem; } -.theme-colour-text input { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .85rem; } +.theme-colour-text input { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 1rem; } .theme-colour-text input[aria-invalid="true"] { border-color: var(--alert); } .theme-preview { border-radius: 10px; padding: 18px; border: 1px solid var(--rule); } .theme-preview-sheet { max-width: 30rem; margin: 0 auto; padding: 14px 18px; border-radius: 6px; border: 1px solid; font-size: .92rem; line-height: 1.5; } diff --git a/src/ui/studio.css b/src/ui/studio.css index ae740a4..b92c21e 100644 --- a/src/ui/studio.css +++ b/src/ui/studio.css @@ -66,6 +66,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 +150,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; } From 644ad76de8113c259740f0808cbe6fbdf7c40c19 Mon Sep 17 00:00:00 2001 From: Aneesh Sathe Date: Sun, 18 Oct 2026 14:12:54 -0700 Subject: [PATCH 2/6] Reading cards: a compact, Substack-scale action row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bottom row was ~48px of emoji thumbs and boxed buttons. Now: - thumbs are 18px monochrome line icons (inline SVG, currentColor, muted); the pressed one takes the accent and fills; - edit, stub ↗ and + add to hopper… are borderless muted text, wording unchanged; ⋯ is a small icon; - every control keeps a 40px tap target (.act: min-height/min-width 40, the hover wash drawn at 32px by a ::before) while the row reads ~20px; - the byline, badge, citation line and "more" are a notch smaller. The row and its handlers move out of Entry into useEntryActions and EntryBar so the full view (next commit) can carry the same row. Shared tokens land in studio.css: a spacing scale, --ctl/--hit, --radius-sm, --muted, and the .act / .btn-compact / .segmented.compact controls. Co-Authored-By: Claude Opus 5.5 --- src/ui/icons.tsx | 99 ++++++++++++++++ src/ui/reading.css | 22 ++-- src/ui/reading.tsx | 289 ++++++++++++++++++++++++++++----------------- src/ui/studio.css | 39 ++++++ 4 files changed, 331 insertions(+), 118 deletions(-) create mode 100644 src/ui/icons.tsx diff --git a/src/ui/icons.tsx b/src/ui/icons.tsx new file mode 100644 index 0000000..8e752d9 --- /dev/null +++ b/src/ui/icons.tsx @@ -0,0 +1,99 @@ +/* + * The studio's line icons: 24-unit grid, 1.75 stroke, `currentColor`, so a + * button's colour (muted at rest, the accent when on) is the icon's colour. + * Decorative only (aria-hidden): every button that shows one keeps its + * existing words as its accessible name and its title. + */ +import type { ReactNode, SVGProps } from 'react'; + +function Svg({ children, ...props }: SVGProps & { children: ReactNode }) { + return ( + + ); +} +export const ThumbUp = () => ( + + + +); +export const ThumbDown = () => ( + + + +); +export const More = () => ( + + + +); +export const Image = () => ( + + + + + +); +export const LinkIcon = () => ( + + + + +); +export const Scan = () => ( + + + + +); +export const Sparkle = () => ( + + + +); +export const Swap = () => ( + + + +); +export const Expand = () => ( + + + +); +export const Back = () => ( + + + +); +export const Reply = () => ( + + + + +); +export const Tray = () => ( + + + + +); +export const Pencil = () => ( + + + +); diff --git a/src/ui/reading.css b/src/ui/reading.css index ef6bea8..7a358e7 100644 --- a/src/ui/reading.css +++ b/src/ui/reading.css @@ -55,8 +55,9 @@ /* ---------- ENTRIES ---------- */ .entry { background: var(--card); color: var(--ink); border: 1px solid var(--rule); border-radius: var(--radius); box-shadow: var(--shadow); margin-bottom: 12px; overflow: hidden; overflow-wrap: anywhere; } -.entry-in { padding: 13px 14px 4px; } -.entry .byline { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; font-size: .76rem; color: var(--ink-soft); margin: 0 0 6px; } +.entry-in { padding: var(--s3) 14px 0; } +.entry .byline { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; font-size: .74rem; color: var(--muted); margin: 0 0 var(--s1); line-height: 1.4; } +.entry .byline .badge { font-size: .6rem; padding: 1px 6px; } .entry .byline .src { font-weight: 600; color: var(--ink); } .entry .byline a.src { text-decoration: none; } .entry-title { font-family: var(--sans); font-weight: 600; font-size: 1.08rem; margin: 2px 0 6px; line-height: 1.3; } @@ -80,12 +81,19 @@ .entry-body a { overflow-wrap: anywhere; } .entry-body.clamped { max-height: 9.5em; overflow: hidden; position: relative; } .entry-body.clamped.overflowing::after { content: ""; position: absolute; left: 0; right: 0; bottom: 0; height: 3em; background: linear-gradient(transparent, var(--card)); pointer-events: none; } -.entry-src { display: block; margin: 2px 0 8px; font-size: .78rem; color: var(--ink-soft); text-decoration: none; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.entry-src { display: block; margin: 0 0 var(--s1); font-size: .74rem; color: var(--muted); text-decoration: none; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .entry-src:hover { color: var(--pencil); text-decoration: underline; } -.more { background: none; border: none; border-radius: 0; color: var(--pencil); font-size: .82rem; font-weight: 600; padding: 0 0 8px; cursor: pointer; min-height: 0; } -.entry-bar { display: flex; align-items: center; gap: 4px; padding: 6px 8px 8px; border-top: 1px solid var(--rule); flex-wrap: wrap; } -.entry-bar .btn-mini { padding: 6px 9px; } -.entry-bar .more-btn { font-size: 1.15rem; } +/* "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; } +/* 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. */ +.entry-bar { display: flex; align-items: center; gap: 0; padding: 0 6px 2px; flex-wrap: wrap; } +.entry-bar .act { padding: 0 7px; } +.entry-bar .act.icon-only { padding: 0; } +.entry-bar .thumb .ico { width: 18px; height: 18px; } + .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 ce8b1e4..b127576 100644 --- a/src/ui/reading.tsx +++ b/src/ui/reading.tsx @@ -63,6 +63,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 './reading.css'; import type { CachedResponse } from './revision-query.ts'; @@ -216,27 +217,46 @@ const ReadContext = createContext({ /* ---------------- entry body ---------------- */ +/** + * The entry's opening heading (or an L0 entry's lone title link) becomes its + * title; the rest is the body. Shared by the card and the full view. + */ +function splitTitle(html: string, l0: boolean) { + const parsed = new DOMParser().parseFromString(html, 'text/html'); + const first = parsed.body.firstElementChild; + let title: string | undefined; + if ( + first && + (/^H[1-6]$/.test(first.tagName) || + (l0 && + first.tagName === 'P' && + first.children.length === 1 && + first.firstElementChild?.tagName === 'A')) + ) { + title = first.textContent || undefined; + first.remove(); + } + return { title, html: parsed.body.innerHTML }; +} +function EntryTitle({ title, url, as: Tag = 'h3', className = 'entry-title' }: { title?: string; url?: string; as?: 'h2' | 'h3'; className?: string }) { + if (!title) return null; + return ( + + {url ? ( + + {title} + + ) : ( + title + )} + + ); +} function Body({ html, url, l0, onOpen }: { html: string; url?: string; l0: boolean; onOpen?: () => void }) { const [expanded, setExpanded] = useState(false), [overflow, setOverflow] = useState(false); const body = useRef(null); - const content = useMemo(() => { - const parsed = new DOMParser().parseFromString(html, 'text/html'); - const first = parsed.body.firstElementChild; - let title: string | undefined; - if ( - first && - (/^H[1-6]$/.test(first.tagName) || - (l0 && - first.tagName === 'P' && - first.children.length === 1 && - first.firstElementChild?.tagName === 'A')) - ) { - title = first.textContent || undefined; - first.remove(); - } - return { title, html: parsed.body.innerHTML }; - }, [html, l0]); + const content = useMemo(() => splitTitle(html, l0), [html, l0]); useEffect(() => { const element = body.current; if (!element) return; @@ -249,17 +269,7 @@ function Body({ html, url, l0, onOpen }: { html: string; url?: string; l0: boole }, [content.html]); return ( <> - {content.title ? ( -

- {url ? ( - - {content.title} - - ) : ( - content.title - )} -

- ) : null} +
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 +342,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 +474,138 @@ 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 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 +637,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)} /> + + ); 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 ? ( - ) : null} @@ -315,6 +328,81 @@ function entryParts(entry: Reading) { return { imported, id, url, source }; } +/* ---------------- the full view's address ---------------- */ + +/** 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({ @@ -601,6 +689,7 @@ function Entry({ }; const a = useEntryActions({ entry, actions, votes, buckets, opened }); const { imported, id, url, signal } = a; + const openArticle = useOpenArticle(entry); const swipe = useSwipe( imported ? { @@ -658,7 +747,7 @@ function Entry({ ) : null} - +
{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 ( + <> +
+ +
+ + {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({
- 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}