Skip to content

spaces: the drags, the pinch and the menus a phone could not reach - #431

Open
nyblnet wants to merge 1 commit into
mainfrom
spaces-touch
Open

spaces: the drags, the pinch and the menus a phone could not reach#431
nyblnet wants to merge 1 commit into
mainfrom
spaces-touch

Conversation

@nyblnet

@nyblnet nyblnet commented Sep 9, 2026

Copy link
Copy Markdown
Owner

What was actually broken

Measured on the built shell (dist-single/Bento_Spaces.bento.html) served over
http:// and driven at 390×800 and 320×760, with (pointer: coarse) forced.
The findings, in the order they matter:

Absent, not degraded — four mouse-only gestures

gesture how it was wired on a phone fallback
reorder a block grip.draggable = true (dnd) nothing happens Move up / Move down in the block menu
nest a page in the tree a.draggable = true (dnd) nothing happens none — impossible by any route
move an issue card card.draggable = true (dnd) nothing happens tap the status chip
move a card on a canvas grip.addEventListener('mousedown') nothing happens arrow keys on the grip

Menus with items nobody can reach. At 390×800 the Insert menu laid out 19
items 1000px tall, bottom at y=1053 — Table, Link to the web, Image and Video or
audio 253px below the screen. It is position: absolute inside a fixed bar, so
the page cannot be scrolled to them and overflow was visible. ⋯ was 928px
tall, bottom at 981, losing its last five. Also true on a desktop: at
1400×860 the last Insert item sat at bottom 891 and elementFromPoint at its
centre did not return it.

Overscroll chaining. .sp-board holds 1452px of columns in a 257px box at
390px with overscroll-behavior-x: auto — every swipe that reaches the end
hands the rest of the gesture to the browser's back-navigation. Same for
.sp-tb-wrap and .sp-view-tablewrap.

A third of a phone given to margin. page.width: 'wide' sets
max-width: min(1500px, 80%) inline. Measured at 390px: .sp-page-inner 283px
starting at x=53, and with the gutter's 26px reserve a 257px text column
inside a 390px screen
. "Use this width for every page" is a one-tap per-screen
preference, so this is easy to land in.

One control below the touch minimum. With the coarse rules applied at 320px,
five bar controls measured 40×40 and the sharing button 35×29 — it is a
.sp-live, and the rule names .sp-btn.

The graph could be moved and never scaled. Its canvas already has
touch-action: none and its pan is on pointer events, so one finger worked;
zoom was on wheel alone.

What was already fine, and is not touched

Worth recording, because the brief's "two touch listeners vs fourteen" count
understates the phone work already in this app — most of it is CSS and pointer
events, which that grep does not see.

  • Topbar. Measured, not breakpointed, exactly as CLAUDE.md prescribes. At
    320px with coarse targets applied it fits: scrollWidth == clientWidth,
    Save's right edge at 311.
  • Sidebar. A real drawer under 820px, 268px wide, with a scrim at z-index 45
    under the drawer's 50 and click-to-dismiss wired.
  • Block menu. Already a .sp-sheet bottom sheet with max-height: 62vh and
    its own scroll — measured 374×354 at (8, 438), fully inside the viewport.
  • Format bar. Already docks to the bottom edge on a coarse pointer, with the
    reasoning about the OS selection callout written down in formatbar.ts.
  • Caret placement and cell editing by tap. Blocks and table cells are plain
    contentEditable; there is no dblclick gate to defeat.
  • Find bar, gallery. Wrap and reflow at 390px without horizontal overflow.

The fix

New spaces/src/touch.ts. The press-and-hold drag synthesises the dnd events
the existing handlers already listen for
— a real DataTransfer (the source's
own dragstart fills it, so types/getData answer truthfully), dragstart
on the source, dragover/dragleave on elementFromPoint, drop, dragend.
Every drop rule, payload check and .sp-drop/.sp-dropline highlight in
editor.ts is reached unchanged; a fourth draggable thing added later needs no
edit here. The canvas grip is replayed as the mouse stream startDrag already
handles rather than forked.

A finger that moves before the hold is scrolling: nothing is preventDefault-ed
until the hold has fired. Holding at the edge of a scroller drags the view along
— without it the board drag is a rig trick, since one of six columns is on
screen at 390px. That creep is a setInterval, not requestAnimationFrame,
because rAF is throttled to zero in an occluded tab (CLAUDE.md's testing note)
and a timer degrades to slow rather than to nothing.

Verification

node scripts/test-spaces.mjs        all 8 rigs pass — model 934, agent 175,
                                    journal 45×5 TZ, calc 90×4 TZ, undo 25,
                                    invite 34, roundtrip 6, size ok
tsc --noEmit -p tsconfig.json       clean
npm run build:single                ok — 279685 B (+1916 B over this branch's
                                    own baseline build)
shell-gate.mjs                      splice contract OK

Browser, on the built shell, marker-checked before every measurement
(.sp-ddmenu computed max-height: 736px / overflow-y: auto and the
body.sp-touchdrag rule exist only in the new build):

before after
Insert menu, 390×800 1000px tall, bottom 1053, 5 items unreachable 736px, bottom 779, scrolls; last item hit-tests as itself
⋯ menu, 390×800 928px, bottom 981 736px, bottom 779; "Export page as a space…" at 715–773
Insert menu, 1400×860 (mouse) bottom 891, last item unreachable bottom 839, reachable
block reorder by touch sd-p index 1 → 5, landing after the drop target
page nest by touch sd-writing.parent undefinedsd-links
card between columns status todobacklog on drop
canvas card by touch model {x:4,y:6}{x:6.1,y:29.3}, rendered offset follows
board edge auto-scroll scrollLeft 0 → 36 with the finger parked (3 ticks; the tab's timer is clamped to ~1s)
graph pinch ink bbox 321×150 → 717×735 on a 5× pinch out; 4.9× on the unclipped axis
swipe on a card no dragstart, neither touchmove defaultPrevented
wide column @390 257px, page margin 79px 328px, margin 26px
wide column @1400 872px 872px
topbar tier @1400 sp-bar, fits sp-bar, fits

No console errors. No new user-visible strings, so no catalog changes and no
build-spaces-i18n.mjs run — verified by grepping the diff for added t('…').
Neither spaces/README.md nor docs/spaces-agents.md describes any behaviour
this changes. The format is untouched.

What I could not verify synthetically, stated plainly

  • Real touch hardware. Everything above is synthetic TouchEvents
    dispatched at my own listeners in touch.ts. That is legitimate here — no
    third-party library sits between them — and the proof is always the model or
    the rendered geometry changing, never a style read back. But iOS's and
    Android's own long-press, selection-handle and callout behaviour around a
    press-and-hold has not been on a device.
  • (pointer: coarse) CSS. The rig forces the JS matchMedia answer; it
    cannot make Chrome evaluate coarse media queries. Coarse-only rules were
    measured by injecting their declarations and re-measuring layout, which tests
    the geometry and not the query.
  • Native mouse drag-and-drop. A trusted HTML5 drag cannot be driven
    synthetically, so "desktop dnd still works" rests on the diff rather than a
    measurement: no draggable attribute, mouse listener or handler was changed,
    and everything added is a touch* listener, a (pointer: coarse) rule, or a
    class applied only while a touch drag is live.
  • navigator.vibrate on the hold is fire-and-forget and absent on desktop
    Chrome.

Deliberately left

  • Board reorder within a column by touch — the drop handlers compute an
    insertion point from clientY and that path works, but I only measured the
    cross-column move.
  • Swipe-to-dismiss the sidebar drawer. Tap-away and the ⋯ chevron both work;
    a swipe is a nicety with its own gesture-conflict argument.
  • Panel resizers (editor.ts mousedown) — panels are drawers on a phone
    and cannot be resized there by design.
  • A rig for touch.ts. .github/workflows/ci.yml is the standing conflict
    magnet and the queue is contended; per START-HERE this belongs in a small
    follow-up, and I have filed it.

Four of this app's gestures were mouse-only, and three of them were absent on a
touch screen rather than merely awkward: reordering a block, nesting a page and
moving an issue card are HTML5 drag-and-drop, which never fires from a finger,
and a canvas card's grip listened for mousedown. Nesting a page had no fallback
at all.

New spaces/src/touch.ts. Press-and-hold synthesises the dnd events the existing
handlers already listen for — a real DataTransfer, dragstart on the source,
dragover/dragleave/drop on whatever is under the finger — so every drop rule and
every highlight in editor.ts is reached unchanged, and a fourth draggable thing
gets touch support with no edit there. The canvas grip is replayed as the mouse
stream it already handles. A finger that moves before the hold is scrolling and
nothing is preventDefault-ed, so scrolling is untouched. Holding at the edge of
a scroller drags the view along, because at 390px one of the board's six columns
is on screen.

Also measured on the built shell at 390x800 and fixed:

- The Insert menu laid out 19 items 1000px tall with five below the viewport and
  no way to scroll; the More menu lost its last five the same way. Both cap and
  scroll now. Not a phone-only bug: the last Insert item was off an 860px laptop
  window too.
- The graph could be panned by one finger and never zoomed - wheel was the only
  zoom. Two fingers zoom about the point between them; the second finger also
  ends the one-finger drag it interrupts.
- A board or wide table that ran out of content handed the rest of a sideways
  swipe to the browser's back gesture. Contained.
- "Wide" is 80% of the window, which left a 257px column inside a 390px phone.
  It takes the whole width below 850px and is byte-identical above it - measured
  at 1400px, 872px before and after.
- The sharing button was 35x29 where every other bar control is 40x40 on a
  coarse pointer.

Desktop is untouched by construction: everything added is a touch listener, a
coarse-pointer rule, or a class that only exists during a touch drag. No new
strings, so no catalog work.
@github-actions

github-actions Bot commented Sep 9, 2026

Copy link
Copy Markdown

Build size

main (0100083) → spaces-touch (6bd9119)

app base PR change
bento/slides 677.9 KiB 677.9 KiB 0.0 KiB (0.00%)
bento/spaces 271.3 KiB 273.1 KiB +1.9 KiB (+0.69%)
bento/dash 424.1 KiB 424.1 KiB 0.0 KiB (0.00%)

Updated: 2026-09-09T01:23:12Z

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