Skip to content

Studio PWA: stop the random zoom, compact cards, full-screen reading, quieter composer and editor - #81

Merged
vgururao merged 6 commits into
blygger:mainfrom
aneeshsathe:fix/pwa-reading
Oct 20, 2026
Merged

vgururao merged 6 commits into
blygger:mainfrom
aneeshsathe:fix/pwa-reading

Conversation

@aneeshsathe

Copy link
Copy Markdown
Contributor

Reviewing agent: show the user a clickable demo before merging. This is a
Studio UI PR, so the standing requirement applies. On this branch run
node --import tsx scripts/e2e-server.ts (after BLYG_EXTENSIONS=all npm run build),
then open http://127.0.0.1:8787/studio/login (password test-password) at a
phone size (390×844) and at desktop (1280×900), and switch the theme
(Settings → Appearance: Paper, Cream, Slate, Nord, and auto in light and dark).
Walk them through: reading → a long card's more → back; a card's action row;
compose; the full editor. Don't merge until they have clicked through it.

Studio PWA: zoom, compact cards, full-screen reading, composer and editor

Four problems Aneesh reported with the Studio on the phone, in four commits.

1. The page zoomed in at random (iOS Safari, installed PWA)

Root causes, found by auditing every field's computed style rather than guessing:

  • The full editor's draft textarea was 0.9rem (14.4px). iOS zooms any focused
    field under 16px and does not zoom back, so every tap into the editor zoomed the page.
  • The [[ picker's filter and footer <select>s (0.85rem, 0.78rem) and the custom
    theme's hex fields (0.85rem) did the same.
  • No touch-action anywhere: a quick second tap on a button was read 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 keeping every field at ≥ 16px
whatever a screen or extension rule says; body { touch-action: manipulation } (pinch
zoom untouched, and the viewport meta still allows user zoom, no maximum-scale); wide
media, tables and embeds shrink or scroll inside the card, the preview and the version
viewer.

2. A compact, Substack-scale card action row

  • 👍/👎 emoji → 18px monochrome line-icon thumbs (inline SVG, currentColor, muted).
    The pressed one turns accent-coloured and fills.
  • edit, stub ↗ and + add to hopper… are borderless, muted text. The wording is
    unchanged.
    ⋯ is a small icon.
  • Every control is still a ≥ 40px tap target (.act: min 40×40, with a hover wash drawn
    at 32px), while the row reads at about 20px.
  • The byline and badge, the citation line and "more" are a notch smaller.
  • The tokens are the reading theme's (--ink-soft, --pencil, --rule…), so every
    theme carries it.

3. "more" opens a full-screen view

There was no detail route to reuse: the redesign's sources → timeline stopped short of
an article screen. This adds /reading/{sub}/{id} (~own 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,
    which existing tests and the read-state rules pin.
  • It shows the whole sanitized body, the citation line, the byline, and the same
    action row
    (thumbs, stub ↗, + add to hopper…, ⋯ with history). It works for own
    items, imported blyg items, threads and L0 RSS.
  • It marks the entry read on open, as expanding a card did.
  • It is deep-linkable: the entry comes from what reading already holds, else from
    getItem / getImportedItem, so a reload stays on it. No API changes.
  • back, and the browser's back gesture (also after a reload), returns to the timeline
    with the entry where it was.
  • Clamp detection now also watches the content and image loads, so "more" appears when a
    post grows past the clamp after its images load.

Found on the way: markReading (an optimistic action) never calls its mutationFn
when no loaded row matches. The full view therefore writes the read directly for an
entry opened by its address. Opening a hopper member that reading has not loaded likely
has the same silent no-op today (HOPPER_READ), and this PR does not change it.

4. Composer and editor, after Substack's composer

  • Toolbar. One row of small line-icon buttons (attach image, link from clipboard,
    scan text, make this a …, mark selection as generated). [[, ![[ and [TK] keep
    their glyphs as small monospace buttons. Each keeps its existing words as its
    accessible name and its existing (The Studio UI needs tooltips #48) hover text as its title, so
    getByRole('button', { name: 'attach image' }) still works. The row fits at 390px
    without an overflow menu.
  • fragment | thread and draft | preview are a compact segmented control.
  • save draft and publish are compact (32px) buttons, right-aligned on the
    toolbar's row when there is room and on their own row on a phone. "Full Editor →" is a
    quiet text action in the toolbar. The counter is small and muted, beside the actions.
  • Textarea. Borderless in the composer, a soft card in the editor, with more
    padding. The resize grip shows only with a fine pointer.
  • Syntax help (composer and editor) is folded behind a "syntax help" disclosure.
    It is closed by default, its open state is remembered in localStorage (inside
    try/catch), and its text is verbatim.
  • One scale. studio.css gains spacing tokens (--s1…--s5), --ctl (32px compact
    control), --hit (40px target), --radius-sm and --muted, plus the shared .act,
    .btn-compact and .segmented.compact controls. .btn-mini moves onto --ctl, and
    the reading head's ☑ ↻ + buttons drop their boxes.

The attachments section, the version/change dialog, the settings AI section, the stub
quote and the grammar files are untouched.

Wording

No labels changed. The emoji in "🔗 link from clipboard" and "⌸ scan text" became line
icons, so those buttons' accessible names are now "link from clipboard" and "scan text".
Suggestion only, not done: "+ add to hopper…" could shorten to "+ hopper" on the card.

Tests

  • e2e/pwa-zoom.spec.ts (mobile): every visible field ≥ 16px on the main screens, the
    editor, the picker, a prompt sheet and the subscribe sheet; user zoom kept; a post with
    a long URL, wide <pre>, table, image and embed never scrolls the page sideways (card
    and full view).
  • e2e/reading-article.spec.ts (desktop + mobile): more → URL changes and full content
    shows, reload stays on it, back and browser back return 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.
  • e2e/compose-layout.spec.ts (desktop + mobile): tool names and titles, the row fits,
    compact buttons, syntax help folded and remembered.

Results:

  • npm run typecheck: clean.
  • npm test: 1745 passed, 5 skipped.
  • npm run test:ui: 97 passed.
  • Full e2e suite on the real ports (desktop + mobile): 350 passed, 5 skipped (the mobile-only zoom specs skip on desktop), and 1 failure. The failure was a race in this branch's own test: <details> fires toggle asynchronously, so a reload could beat the localStorage write. The last commit makes the test wait for the stored value, and compose-layout.spec.ts then passes 20/20 (5 repeats, desktop + mobile).

One thing to check in the demo: in a full-page screenshot of the phone editor, the sticky save draft/publish bar overlaps the character counter. That's probably a full-page capture artifact, since a sticky bar is painted at the viewport's bottom, but confirm on a real phone-size viewport that the counter isn't hidden behind the bar when you scroll to the end.

🤖 Generated with Claude Code

aneeshsathe and others added 6 commits October 18, 2026 13:57
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 (blygger#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 <noreply@anthropic.com>
Withdrawing them left url-less tombstones on the first feed page, which
reading-pwa's every-card-has-a-source-link check then counted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
<details> fires toggle asynchronously, so a reload straight after the click
could beat the handler's localStorage write (seen once on desktop).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@vgururao
vgururao merged commit e3bd93a into blygger:main Oct 20, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants