Skip to content

Commit 6bd9119

Browse files
committed
spaces: the drags, the pinch and the menus a phone could not reach
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.
1 parent 0100083 commit 6bd9119

7 files changed

Lines changed: 509 additions & 3 deletions

File tree

‎spaces/CHANGELOG.md‎

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -477,6 +477,47 @@ Versions follow `0.MINOR.PATCH` while pre-1.0.
477477
2026, from the same file. `bento.journal()` opens today's for an agent, and
478478
`bento.journal('2026-08-06')` any day's.
479479

480+
- **A finger can do the four things only a mouse could.** Reordering a block,
481+
nesting a page in the tree, moving an issue card between columns and moving a
482+
card on a canvas were all mouse-only: the first three are HTML5
483+
drag-and-drop, which never fires from a touch, and the fourth listened for
484+
`mousedown`. Two had a fallback (Move up / Move down in the block menu; tap a
485+
card's status chip) and **the page tree had none** — nesting a page was
486+
impossible on a phone by any route. Press and hold now starts the same drag,
487+
and holding a card at the edge of a board or a list scrolls it along, since at
488+
390px only one of six columns is on screen at a time.
489+
490+
A finger that MOVES is still scrolling. Nothing is captured until the press
491+
has been held still, so a swipe that starts on a card scrolls the page exactly
492+
as it did before.
493+
494+
- **Pinch to zoom the graph.** One finger already panned it, but zoom was on the
495+
scroll wheel alone — so on a phone the one view whose whole point is a crowded
496+
picture could be shoved around and never scaled. Two fingers zoom about the
497+
point between them and pan at the same time; the second finger also ends the
498+
one-finger drag it interrupts, so the two gestures no longer fight.
499+
500+
- **A menu taller than the window has items nobody can reach.** Measured on a
501+
390×800 phone: Insert laid out 19 items 1000px tall, putting Table, Link to
502+
the web, Image and Video or audio 253px below the screen with no gesture that
503+
reaches them — the menu is positioned inside a fixed bar, so the page cannot
504+
scroll to them. ⋯ lost its last five the same way. Both scroll now, and this
505+
was never only a phone bug: on an 860px laptop window the last Insert item was
506+
off the bottom too.
507+
508+
- **A sideways swipe stops at the edge of what it is scrolling.** A board or a
509+
wide table that runs out of content handed the rest of the gesture to the
510+
browser, which on a phone is the back-navigation swipe. It ends where the
511+
board does.
512+
513+
- **A wide page stops giving away a third of a phone.** "Wide" is 80% of the
514+
window, which is a sensible proportion on a desktop and 283px on a 390px
515+
phone — with the block gutter's 26px that left a 257px column inside a 390px
516+
screen. It takes the whole width below 850px and is unchanged above it
517+
(measured at 1400px: 872px before and after). And the sharing button, alone
518+
among the toolbar's controls, was 35×29 rather than the 40×40 every other one
519+
gets on a touch screen.
520+
480521
## [0.1.0] — 2026-08-03
481522

482523
First release.

‎spaces/src/canvas.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ import { descendantsOf, newBlock } from './model.ts'
6262
import { sanitizeInline } from './sanitize.ts'
6363
import { t } from './i18n.ts'
6464
import { ICONS } from './icons.ts'
65+
import { touchDragAsMouse } from './touch.ts'
6566

6667
/** The surface's shape, width ÷ height. Wide, because the first thing anyone
6768
* draws here is a row of steps. */
@@ -428,6 +429,11 @@ function wireCard(card: HTMLElement, body: HTMLElement, hooks: CanvasHooks): voi
428429
card.appendChild(grip)
429430

430431
grip.addEventListener('mousedown', (down) => startDrag(down, card, body, id, hooks))
432+
// AND A FINGER DRIVES THE SAME GESTURE. The mouse choice above is right and
433+
// none of its reasoning is about input hardware, so a touch is replayed as
434+
// the mouse stream it already handles rather than forked into a second drag
435+
// with its own clamps and its own commit. See touch.ts.
436+
touchDragAsMouse(grip)
431437

432438
// THE KEYBOARD MOVES IT TOO. A surface reachable only by mouse is a surface
433439
// half this app's readers cannot use, and the same clamp and the same commit

‎spaces/src/editor.ts‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import { syncNoticeText } from './syncnotice.ts'
1919
import { Store } from './store'
2020
import { renderPage, toneLabel, paintCode } from './render'
2121
import { wireCanvas, placeNewCard } from './canvas.ts'
22+
import { enableTouchDrag } from './touch.ts'
2223
import { CODE_LANGS, langLabel, normLang } from './highlight'
2324
import { canonicalize, escText, sanitizeInline, textOf } from './sanitize'
2425
import { FormatBar } from './formatbar'
@@ -535,6 +536,13 @@ export class Editor {
535536
}, true)
536537
}
537538

539+
// A FINGER GETS THE DRAGS A MOUSE HAD. Blocks, page rows and issue cards
540+
// are all `[draggable="true"]`, and HTML5 dnd never fires from a touch —
541+
// so press-and-hold replays the same dnd events those handlers already
542+
// listen for. One call, delegated at the root, so anything draggable this
543+
// editor grows later is covered without a second edit. See touch.ts.
544+
enableTouchDrag(this.root)
545+
538546
this.paintTree()
539547
this.paintPage()
540548
this.syncHistoryButtons()

‎spaces/src/graph.ts‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@
4343
import type { SpacesDoc, SpaceIndex } from './model.ts'
4444
import { ICONS } from './icons.ts'
4545
import { t } from './i18n.ts'
46+
import { enablePinchZoom } from './touch.ts'
4647

4748
// ————— the graph itself ————————————————————————————————————————————————————
4849

@@ -764,6 +765,23 @@ export function openGraphView(opts: GraphViewOpts): GraphView {
764765
draw()
765766
}, { passive: false })
766767

768+
// ——— two fingers ———
769+
//
770+
// One finger already worked: the pointer path above is pointer events, and a
771+
// touch drives those. ZOOM did not — it was `wheel` alone, and a phone has no
772+
// wheel, so a crowded graph could be shoved around and never scaled. Fit was
773+
// the only way to change magnification and it only ever gives you one.
774+
//
775+
// The second finger also has to CANCEL the one-finger drag it interrupts, or
776+
// the pointer path keeps panning (or worse, keeps dragging a node) underneath
777+
// the pinch and the two fight over the same picture.
778+
canvas.addEventListener('touchstart', (e) => { if (e.touches.length > 1) drag = null }, { passive: true })
779+
enablePinchZoom(canvas, {
780+
get: () => ({ scale, panX, panY }),
781+
set: (s, x, y) => { scale = s; panX = x; panY = y; draw() },
782+
limits: [0.12, 5],
783+
})
784+
767785
fitBtn.addEventListener('click', () => { fit(); draw() })
768786
closeBtn.addEventListener('click', () => opts.close())
769787
back.addEventListener('click', (e) => { if (e.target === back) opts.close() })

‎spaces/src/render.ts‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -954,7 +954,16 @@ export function renderPage(page: Page, doc: SpacesDoc, opts: RenderOpts = {}): H
954954
// 80% keeps a visible step at every size, and the 1500px cap keeps Wide from
955955
// becoming an unreadable line on a very large screen — at which point Full is
956956
// the thing to pick, deliberately.
957-
else if (width === 'wide') inner.style.maxWidth = 'min(1500px, 80%)'
957+
// …WITH A FLOOR, because a proportion has nothing to be a proportion OF on a
958+
// phone. 80% of a 354px page is 283px, and the 26px the gutter takes there
959+
// leaves a 257px column on a 390px screen — a third of the display given to
960+
// margin on the setting whose entire purpose is "room for a board or a
961+
// table", and reachable in one tap ("Use this width for every page" is a
962+
// per-screen preference). `max(80%, 680px)` is the same 80% wherever 80% is
963+
// at least 680px (a container of 850px and up) and the whole container below
964+
// that, since a max-width wider than the box does nothing. Measured at 390px:
965+
// column 257 → 328, the page's left margin 79 → 26.
966+
else if (width === 'wide') inner.style.maxWidth = 'min(1500px, max(80%, 680px))'
958967
else if (doc.theme.measure) {
959968
// AND THE DEFAULT ITSELF GROWS. 720px is ~88 characters at 16px, which is
960969
// already at the long end — so this does not widen the line much; what it

‎spaces/src/styles.css‎

Lines changed: 59 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -706,7 +706,10 @@ button.sp-callout-chip:hover { text-decoration: underline; text-underline-offset
706706
/* A WIDE TABLE SCROLLS. The measure belongs to the document, so a six-column
707707
table must not widen the prose column on a phone — it gets its own scroller
708708
and the paragraph above it keeps its line length. */
709-
.sp-tb-wrap { overflow-x: auto; position: relative; }
709+
/* `overscroll-behavior-x` for the reason spelled out on `.sp-board`: a
710+
sideways swipe that runs out of table must stop, not become the browser's
711+
back gesture. */
712+
.sp-tb-wrap { overflow-x: auto; overscroll-behavior-x: contain; position: relative; }
710713
.sp-tb {
711714
width: 100%; border-collapse: collapse; table-layout: fixed;
712715
font-size: 15px; line-height: 1.5;
@@ -1460,15 +1463,61 @@ button.sp-callout-chip:hover { text-decoration: underline; text-underline-offset
14601463
/* Hit targets: a 30px button is a miss on a touch screen. */
14611464
@media (pointer: coarse) {
14621465
.sp-bar .sp-btn { min-width: 40px; min-height: 40px; justify-content: center; }
1466+
/* The sharing button is a `.sp-live`, not a `.sp-btn`, so the rule above
1467+
stepped straight over it: measured at 320px with the coarse rules applied,
1468+
five bar controls came out 40×40 and this one 35×29 — the odd one out, and
1469+
the one that opens a popover. Widening it does not cost the bar its fit:
1470+
Save's right edge stays at 311 inside 320. */
1471+
.sp-bar .sp-live { min-width: 40px; min-height: 40px; justify-content: center; }
14631472
.sp-dditem { padding-block: 11px; }
14641473
}
14651474

1475+
/* WHILE A FINGER IS DRAGGING. The press that starts a touch drag is also the
1476+
press that arms text selection on iOS and Android, and a card whose title is
1477+
being selected mid-drag paints a blue smear across the gesture. touch.ts
1478+
clears the selection when the hold fires; this stops a new one starting. */
1479+
body.sp-touchdrag, body.sp-touchdrag * { -webkit-user-select: none; user-select: none; }
1480+
/* AND SCROLL SNAPPING STANDS DOWN WHILE IT LASTS. The board's
1481+
`scroll-snap-type: x proximity` is right for a thumb flicking between
1482+
columns and wrong for the 12px-per-frame creep that carries a held card
1483+
towards an off-screen column: each step lands inside the proximity of the
1484+
snap point it just left and is pulled straight back. Measured before this
1485+
rule, with the finger parked at the board's right edge for 1400ms:
1486+
scrollLeft 0 → 0. */
1487+
body.sp-touchdrag .sp-board { scroll-snap-type: none; }
1488+
14661489
/* A menu opened from the right end of the bar must open INWARD. Anchoring
14671490
every dropdown to its start edge put the ⋯ and Save menus half off-screen at
14681491
375px — the items were there, just unreadable. */
14691492
.sp-dd-end .sp-ddmenu { inset-inline-start: auto; inset-inline-end: 0; }
14701493
.sp-ddmenu { max-width: calc(100vw - 20px); }
14711494

1495+
/* A MENU TALLER THAN THE WINDOW IS A MENU WITH ITEMS NOBODY CAN REACH.
1496+
Measured on the built shell at 390×800: Insert laid out 19 items 1000px tall
1497+
with its bottom at y = 1053, so five of them — Table, Link to the web, Image,
1498+
Video or audio and the divider above them — were 253px below the viewport;
1499+
⋯ laid out 928px with its bottom at 981. The menu is `position: absolute`
1500+
inside a fixed bar, so the page cannot be scrolled to them and there is no
1501+
gesture that reaches them at all. Not a phone bug, either: at 861px of
1502+
browser height on a laptop the same five items are off the bottom.
1503+
1504+
`100dvh`, not `100vh`: on a phone `vh` is the tall viewport with the URL bar
1505+
retracted, so a cap written in `vh` still runs the menu under the chrome that
1506+
is actually on screen.
1507+
1508+
BOTH AXES ARE SPELLED OUT ON PURPOSE. Setting one to a non-`visible` value
1509+
computes the other to `auto`, and a scroll container CLIPS what is positioned
1510+
inside it — a floating child can never escape one (CLAUDE.md's ⋯/Share
1511+
popover trap, which cost slides a silently half-drawn popover). Every child
1512+
of these menus today is a `position: static` `.sp-dditem`, verified in the
1513+
built shell: 16 of 16 in ⋯, 19 of 19 in Insert. Anything demoted in here
1514+
later must be a SECTION OF THE LIST, never a popover anchored to it. */
1515+
.sp-ddmenu {
1516+
max-height: calc(100dvh - 64px);
1517+
overflow-y: auto; overflow-x: hidden;
1518+
overscroll-behavior: contain;
1519+
}
1520+
14721521
/* ————— find & replace on a small screen —————
14731522
Measured at a 575px viewport: the bar laid out 644px wide and started at
14741523
x = -87, so the Find field itself was off the left edge — the one control
@@ -1787,6 +1836,14 @@ button.sp-prop-val:hover { background: var(--surface); border-color: var(--edge)
17871836
.sp-board {
17881837
display: flex; gap: 12px; overflow-x: auto; padding-bottom: 6px;
17891838
scroll-snap-type: x proximity;
1839+
/* …AND THE SWIPE STOPS AT ITS OWN EDGE. `overflow-x: auto` keeps the board's
1840+
columns out of the document, but by default a touch scroll that reaches
1841+
the end of a scroller CHAINS to its ancestors — and on a phone the
1842+
outermost ancestor is the browser's own back-navigation gesture. Measured
1843+
at 390px: the board holds 1452px of columns in a 257px box, so a reader
1844+
swiping through five columns hits that end on nearly every gesture.
1845+
Containment ends the scroll where the board does. */
1846+
overscroll-behavior-x: contain;
17901847
}
17911848
.sp-col {
17921849
flex: 0 0 232px; min-width: 232px; scroll-snap-align: start;
@@ -1852,7 +1909,7 @@ button.sp-issue-set:hover { background: var(--chrome-2); border-color: var(--edg
18521909
the rest as data: tabular figures, a quiet rule under the head, and no
18531910
vertical lines — a grid of boxes is a spreadsheet, and this is a list of
18541911
documents that happens to have columns. */
1855-
.sp-view-tablewrap { overflow-x: auto; margin: 0 0 4px; }
1912+
.sp-view-tablewrap { overflow-x: auto; overscroll-behavior-x: contain; margin: 0 0 4px; }
18561913
.sp-view-table {
18571914
border-collapse: collapse; width: 100%; font-size: 13.5px;
18581915
font-variant-numeric: tabular-nums;

0 commit comments

Comments
 (0)