Skip to content

Make the editor usable with a finger, not just reachable by one - #139

Draft
kmatzen wants to merge 1 commit into
worktree-mobile-usability-auditfrom
mobile/touch-usability
Draft

Make the editor usable with a finger, not just reachable by one#139
kmatzen wants to merge 1 commit into
worktree-mobile-usability-auditfrom
mobile/touch-usability

Conversation

@kmatzen

@kmatzen kmatzen commented Aug 3, 2026

Copy link
Copy Markdown
Owner

Fixes the mobile audit findings. Stacked on #125 (the audit itself) — review that first, or read this diff against worktree-mobile-usability-audit.

Closes #126, #127, #128, #129, #130, #131, #132, #133, #134, #135, #136. Partially addresses #137.

The approach

Touch sizing is gated on @media (pointer: coarse), not on a width breakpoint. What matters is whether a fingertip is doing the pointing — a touchscreen laptop should get bigger targets, a narrow desktop window should not — and it leaves desktop density untouched by construction. .tap / .tap-h / .tap-w are opt-in per site rather than a blanket button { min-height }, because several of these buttons sit in fixed-height rows where growing the button breaks the row instead of the row growing with it.

What changed

# Fix
126 Coarse-pointer sizing across toolbar, viewport tools, tree rows and actions, property controls, palette tabs, number input. The 18px number input is now 44px.
127 MobileEmptyState in the viewport with the two ways in as buttons — instead of a black void.
128 Two-tap move mode for reparenting, alongside the existing drag-and-drop. HTML5 DnD never fires from touch, so this was the only way to restructure a tree on a phone. Works with mouse and keyboard too.
129 16px inputs on coarse pointers; inputMode="decimal" + enterKeyHint on NumberInput (stays type="text" so 10*2+5 still parses).
130 Chat drawer gets a close button, top-11 to match the toolbar, and useKeyboardInset lifts it clear of the iOS keyboard.
131 viewport-fit=cover, safe-area helpers, 100dvh, overscroll-behavior, tap-highlight, text-size-adjust, user-select.
132 Tap slop is pointer-type aware (4px mouse / 10px touch — fingers wander 5–10px, so real taps were being discarded as orbits); gizmo scales up on coarse pointers.
133 Sheet opens at 55%, has a close button, recomputes snap points on resize/rotation.
134 Duplicate NODE TREE header removed; Escape closes both mobile containers.
135 Export resolution and share-copy confirmation reach the mobile overflow menu.
136 e2e/mobile.spec.ts + a mobile Playwright project on iPhone 13.
137 Layout switch moved 768 → 1024, so iPad portrait gets a full-width viewport and drawers instead of a ~200px column.

The test that matters

The sweep asserting nothing on screen is under 44px is deliberately not a set of per-button assertions — individual sizes get adjusted and those assertions rot, whereas a sweep fails on the next control someone adds at desktop density. It already earned its keep: it caught a 42px input, because the row's 1px border sits outside its content box so an input stretched to h-full lands two pixels short.

Verification

  • npm test — 503 passed
  • npx playwright test (serial, as CI runs it) — 81 passed across chromium, mobile, golden
  • Re-shot on emulated iPhone 13 / iPad Mini / landscape to confirm visually

Note: running the three projects with local parallel workers produces spurious failures — the config already documents WebGL oversubscription on multi-worker runs, and adding a third project makes it worse. Serial is clean.

Deliberately not done

  • Copy/paste still has no mobile control. It needs a paste-target concept that does not exist yet; worth designing rather than bolting on.
  • Landscape phones still get the portrait arrangement. Toolbars wrap now so nothing overflows, but 342px of height wants its own layout. Left open in iPad portrait lands on the desktop layout with a 200px viewport; landscape phone reuses portrait #137.
  • Opening the sheet at 55% covers more of the model than 33% did, and the camera does not frame against the visible area. Dragging down still works; camera-inset framing is a follow-up.

🤖 Generated with Claude Code

The mobile layout already reflowed correctly — drawers, a bottom sheet, a
breakpoint. What it did not do was change the controls inside that layout,
so a phone got desktop density: an 18px number input, 32x24 navigation
buttons, and 28px viewport tools. Measured in docs/mobile-audit.

Sizing is gated on `pointer: coarse`, not on width. What matters is whether a
fingertip is doing the pointing — a touchscreen laptop should get the bigger
targets and a narrow desktop window should not — and it leaves desktop
density untouched by construction. `.tap` is opt-in per site rather than a
blanket `button { min-height }`, because several of these buttons live in
fixed-height rows where growing the button breaks the row instead of the row
growing with it.

Three things were not size problems and needed real work:

Reparenting was impossible on a phone. The tree drags with HTML5 DnD, which
is never generated from touch input, so restructuring a tree could only be
done by deleting and rebuilding it. Move mode picks a node up and places it
with a second tap; it works the same with a mouse or a keyboard, and it lives
in its own store so a half-finished move cannot reach a project file or an
undo step.

A phone's first run was a blank screen. The "No model yet" copy existed, but
inside the tree drawer — visible on desktop, where the tree is always on
screen, and behind an icon everywhere else.

iPad portrait sat exactly on the desktop side of a 768px breakpoint: two
sidebars and a ~200px viewport, on a touch device. The layout switch moves to
1024, which is the first width where both sidebars and a usable viewport fit.

e2e/mobile.spec.ts is the part that keeps this from rotting. The sweep
asserting nothing on screen is under 44px is deliberately not a set of
per-button assertions — individual sizes get adjusted and those rot, whereas
a sweep fails on the next control someone adds at desktop density. It caught
a 42px input here: the row's border sits outside its content box, so an input
stretched to h-full lands two pixels short.

Closes #126, #127, #128, #129, #130, #131, #132, #133, #134, #135, #136
Refs #137

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@kmatzen kmatzen left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed this against origin/main and it's solid overall — breakpoints (md:lg:, isMobile() 768→1024) are consistent across Toolbar/NodeTreePanel/PropertyPanel/ChatDrawer/BottomSheet/MobilePanel, viewport-fit=cover is in index.html, pb-safe/pl-safe/pr-safe are defined, useKeyboardInset cleans up its listeners with correct offset math, moveNode guards against cycles, and the BottomSheet snap-point recompute on resize looks right.

One correctness bug I'd fix before merge:

The #129 iOS-zoom fix doesn't cover three of the controls it's meant to fix

src/index.css (the @media (pointer: coarse) block):

input, select, textarea,
input[type="text"], input[type="number"], input[type="password"] {
  font-size: 16px;
}

The comment above it says this has to "beat the utility classes on the inputs themselves." The enumerated input[type="..."] selectors do have the specificity for that (0,1,1 beats a text-sm/text-[12px] Tailwind class at 0,1,0). But the bare input, select, textarea selectors are only 0,0,1 and lose to any text-* class — Tailwind isn't in important mode here. So any control that (a) has no type attribute, or (b) is a <select>, keeps its Tailwind text size and still triggers iOS Safari's zoom-on-focus.

Three always-visible mobile controls hit this:

  • src/components/toolbar/Toolbar.tsx:151 — project-name <input> has no type + text-sm → stays 14px. Always on screen on mobile (not lg:hidden). This is audit §3's "Project name — 14px" item.
  • src/components/chat/ChatDrawer.tsx:131 — chat composer <input> has no type + text-sm → stays 14px. Audit §3's "Chat input — 14px".
  • src/components/toolbar/Toolbar.tsx:259 — export-resolution <select> + text-[12px] → stays 12px (only the bare select selector can match a <select> at all).

Only NumberInput is actually rescued, since it carries an explicit type="text".

This also looks like it would fail the PR's own new e2e/mobile.spec.ts — "text entry does not zoom the page" scans every visible input/select/textarea for font-size < 16, and the project-name input is on screen throughout that test.

Smallest fix I can see: add type="text" to the two type-less <input>s so they hit the high-specificity rule, and give the base rule enough specificity to also catch <select> — e.g. input:not([type]), input[type="text"], input[type="number"], input[type="password"], select, textarea { font-size: 16px; } — or just drop the text-sm/text-[12px] classes from these three controls.

(Minor, possibly intentional: MobileEmptyState.tsx:32 is absolute inset-0 z-10 with no pointer-events-none, so while it's shown it sits over the ViewportToolbar buttons. Flagging for awareness only, since there's no model to act on at that point anyway.)


Generated by Claude Code

@kmatzen kmatzen left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correction to my earlier review comment: the CSS-specificity bug I reported for the project-name input, chat composer, and export <select> doesn't actually exist. I got the cascade wrong — sorry for the noise.

I analyzed the @media (pointer: coarse) { input, select, textarea, ... } rule as if it were competing with .text-sm under plain CSS specificity rules. It isn't. This project imports Tailwind v4 (@import "tailwindcss" in index.css), which expands to @layer theme, base, components, utilities; and puts every utility class — .text-sm included — inside @layer utilities. The coarse-pointer rule in index.css is not inside any @layer. Per the CSS cascade-layers spec, an unlayered rule always beats a layered one, regardless of specificity — so the existing rule already wins outright, and the specificity concern I raised doesn't apply.

I confirmed this two ways before retracting:

  • Ran the PR's own e2e/mobile.spec.ts "text entry does not zoom the page" test against the unmodified branch (no fix applied) — it passes.
  • Dumped getComputedStyle(...).fontSize for the project-name input directly in a real page load: 16px, not the 14px my specificity analysis predicted.

No action needed on this point — the existing rule is correct as written. The other notes in my first comment (breakpoint consistency, safe-area CSS, useKeyboardInset, etc. all checking out; the minor MobileEmptyState z-index observation) stand.


Generated by Claude Code

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.

1 participant