Skip to content

spaces: Save was off the screen, and every block paid a row for its gutter - #287

Merged
nyblnet merged 3 commits into
mainfrom
spaces-mobile
Aug 18, 2026
Merged

spaces: Save was off the screen, and every block paid a row for its gutter#287
nyblnet merged 3 commits into
mainfrom
spaces-mobile

Conversation

@nyblnet

@nyblnet nyblnet commented Aug 10, 2026

Copy link
Copy Markdown
Owner

Two pre-existing mobile layout defects in spaces/. Both measured on the
shipped shell at a 390×844 viewport with a coarse pointer; both reproduce on
older builds, so neither is a regression.

1 — the topbar overflowed and clipped the primary action

.sp-bar measured 390px wide with a scrollWidth of 467, and Save's right
edge landed at x = 426 — 36px past the screen edge, on the one control that
must never be unreachable. The existing fold (six secondary actions → ⋯) was
not enough: what survives it is still eleven controls' worth of 40px touch
targets.

Same mechanism, applied twice — no scroller, no smaller type:

  • the fold starts at the drawer breakpoint (820px) rather than 720. At 768
    (an iPad in portrait) the bar laid out 795px and the save caret ended 27px
    off the screen; 721–820 is exactly the band where the page list is already an
    overlay competing for width.
  • + Insert drops its word and keeps its icon (76.6 → 40px). The label is a
    span now rather than a bare text node, so it can be dropped at all.
  • below 600px a phone also folds away the wordmark (About is the first item
    in ⋯), the undo/redo pair (added to ⋯ carrying their shortcuts and their
    disabled state) and the save caret (all four of its items are in ⋯ or in
    About; Save a copy / Export as Markdown are appended there).
  • the status span leaves the flow on a phone, and status() clears its text
    after the fade instead of only fading it. It is nowrap — "Reading view —
    press Esc or the eye to edit" is ~250px, and once written the width was held
    for the rest of the session, which was enough on its own to push Save back off
    the screen.
viewport before (scroll/client, Save right) after
320 467 / 320, Save 425.6 320 / 320, Save 308
375 467 / 375, Save 425.6 375 / 375, Save 363
390 467 / 390, Save 425.6 390 / 390, Save 378
768 795 / 768, caret 794.6 768 / 768, caret 756
1280 1280 / 1280 1280 / 1280 — unchanged

The document title beside it goes 18px → 54 (320), 18 → 109 (375), 18 → 124
(390), 18 → 240 (768).

2 — every block got a full gutter row

The gutter stays on touch because there is no hover, but the rule that made it
visible also made it position: static — in the flow, 34px tall plus margin,
on every block. A one-line paragraph measured 68.4px, half of it
affordances.

It is absolutely positioned again, the way it is on a desktop, inside a 44px
start padding on .sp-main reserved for it: visible at rest (nothing to
discover it with on touch), out of the flow, carrying one control — the
grip, whose bottom sheet already offers "Add below", so the + was a second
button for something a thumb could already reach.

before after
one-line paragraph 68.4px 32.4px
gutter static, 354×34, in flow absolute, 34×32, at x = 4
reading column at 390 354px 328px (the width it costs)

Options weighed: shrinking it in place still leaves a row; reveal-on-tap hides
the only affordance a block has until you have already guessed it exists. The
margin is the only one that costs 0px of height and stays visible, and height
is the scarce axis on a phone.

Reachability verified by tap, not by eye: at 320/375/390 the grip is
hit-testable at its centre and opens the sheet with all six block actions
(Turn into…, Add below, Move up, Move down, Duplicate, Delete).

Verification

Measured in a real browser on the built single-file shell (a uniquely named
copy per build — that pane caches file URLs), at 320, 375, 390, 768 and 1280,
with the file path asserted in every measurement.

  • .sp-bar scrollWidth === clientWidth at all five widths
  • Save's right edge ≤ innerWidth at all five; no page-level horizontal
    overflow (documentElement.scrollWidth === innerWidth) on any page
  • no gutter goes off-screen on any of the 12 starter pages at 320
  • desktop 1280 confirmed unchanged: gutter hover-revealed at opacity 0 with
    both buttons, .sp-main padding 28px, wordmark / secondary row / Insert
    label / save caret all present, status static

Commands: tsc -b, npm run build:single, scripts/shell-gate.mjs,
test-spaces-model.ts (413/413), test-spaces-agent.ts (143/143),
test-spaces-undo.ts (18/18), test-spaces-size.mjs,
build-spaces-i18n.mjs --check.

Size: 132,102 → 132,414 B (+312), inside the existing 135,168 B ceiling at
98.0%. No budget change, and no new user-visible strings — undo/redo reuse the
existing Undo (⌘Z) / Redo (⇧⌘Z) keys and the folded save items reuse the
caret menu's, so all eight catalogs are already complete (--check passes).

Rigs updated so this cannot quietly come back: test-spaces-model.ts now
pins which tier each fold lives in (rather than a hardcoded 720), that
isPhone() agrees with the stylesheet's phone breakpoint, and that the touch
gutter is never put back in the flow.

Platform invariants: none touched — no format, splice, collab or kernel
change. docs/DECISIONS.md records the two breakpoint tiers and the touch
gutter rule.

Not done

  • The 34×32 grip is under the 44px touch-target guidance. A 44px control needs
    a 48px margin, which is 5% of a 390px screen taken from the prose; 34px is
    the compromise, and the sheet it opens is full-size.
  • Field/property blocks (.sp-props) keep their gutters, which now overlay a
    neighbouring chip slightly — exactly what they already do on a desktop on
    hover. Suppressing them there would remove the only way to reorder or delete
    a field on a phone, so it is left alone.
  • The status message overlays the title strip below 600px rather than becoming
    a toast. A toast is a bigger surface than this PR should introduce.

…utter

Two mobile layout defects, both measured on the shipped shell at 390×844 with
a coarse pointer, neither of them a regression — each had been there since the
surface it belongs to shipped.

THE TOPBAR OVERFLOWED AND CLIPPED THE PRIMARY ACTION.
`.sp-bar` measured 390px wide with a scrollWidth of 467, and the Save button's
right edge landed at x = 426 — 36px past the edge of the screen, on the one
control that must never be unreachable. The existing fold (the six secondary
actions into ⋯) was not enough: what survives it is still eleven controls'
worth of 40px touch targets.

The fix is the same mechanism, applied twice, and never a scroller:

  · the fold now starts at the DRAWER breakpoint (820px) rather than 720.
    Measured at 768 — an iPad in portrait — the bar laid out 795px and the save
    caret ended 27px off the screen. 721–820 is exactly the band where the page
    list is already an overlay competing for width.
  · + Insert drops its word and keeps its icon (76.6px → 40px). The word is a
    span now, not a bare text node, so it can be dropped at all.
  · below 600px a phone also folds away the wordmark (About is the first item
    in ⋯), the undo/redo pair (added to ⋯, carrying their shortcuts and their
    disabled state) and the save caret (all four of its items are in ⋯ or in
    About, and Save a copy / Export as Markdown are appended there).
  · the status span leaves the flow on a phone, and status() clears its text
    after the fade instead of only fading it. It is nowrap: "Reading view —
    press Esc or the eye to edit" is ~250px, and once written the width was
    held for the rest of the session.

    bar scrollWidth vs clientWidth, and Save's right edge:
      320   before 467 / 320, Save 425.6   after 320 / 320, Save 308
      375   before 467 / 375, Save 425.6   after 375 / 375, Save 363
      390   before 467 / 390, Save 425.6   after 390 / 390, Save 378
      768   before 795 / 768, caret 794.6  after 768 / 768, caret 756
     1280   before 1280 / 1280             after 1280 / 1280 (unchanged)

    The document title beside it goes 18px → 54 (320), 18 → 109 (375),
    18 → 124 (390), 18 → 240 (768).

EVERY BLOCK GOT A FULL GUTTER ROW.
The gutter stays on touch because there is no hover, but the rule that made it
visible also made it `position: static` — in the flow, 34px tall plus margin,
on every block. A one-line paragraph measured 68.4px, half of it affordances.

It is absolutely positioned again, the way it is on a desktop, inside a 44px
start padding on `.sp-main` reserved for it: visible at rest, out of the flow,
and carrying ONE control — the grip, whose bottom sheet already offers "Add
below", so the + was a second button for something a thumb could already
reach. Verified by tap: the grip is hit-testable at 320/375/390 and opens the
sheet with all six block actions.

  one-line paragraph   68.4px → 32.4px
  reading column        354px → 328px at 390 (the width it costs)
  gutter                static, 354×34, in flow → absolute, 34×32, at x = 4

Desktop is untouched: at 1280 the gutter is still hover-revealed with both
buttons, `.sp-main` still pads 28px, and the bar still carries the wordmark,
the secondary row, the Insert label and the save caret.

Shell 132,102 → 132,414 B, inside the existing 135,168 B ceiling (98.0%); no
budget change. Rigs updated to pin which TIER each fold lives in, that
isPhone() agrees with the stylesheet, and that the touch gutter is never put
back in the flow.
Follow-up to the previous commit, found by measuring an rtl DOCUMENT rather
than an rtl interface.

`.sp-main` is chrome and follows the interface direction. The gutter is
anchored to a block, and blocks follow the DOCUMENT's direction — renderPage
puts `theme.dir` on `.sp-page-inner` and pins the outer wrapper to ltr. So
reserving the gutter's margin as `padding-inline-start` on `.sp-main` sent the
padding one way while an rtl document sent the gutter the other: measured at
390px on a doc with `theme.dir: 'rtl'`, the gutter landed at x = 378…412 —
22px past the viewport — and `.sp-main` scrolled to 412 against a 390 client
width. A horizontally scrolling document is exactly the thing that makes a
phone unusable.

Reserved on `.sp-page-inner` instead, where it flips with the blocks it is for.

  rtl at 390   gutter 378…412, main scrollWidth 412 → gutter 352…386, 390
  ltr at 390   gutter x 4, block 328 wide, paragraph 32.4px — unchanged
  ltr at 320   gutter x 4, block 258 wide — unchanged
  ltr at 375   gutter x 4, block 313 wide — unchanged
  ltr at 768   gutter x 4 → 10, block 706 → 694 (the page centres inside its
               720px measure here, so the padding lands inside the column)
  desktop 1280 untouched: no inner padding, .sp-main still 28px, gutter still
               hover-revealed with both buttons

Shell unchanged at 132,414 B. The model rig now pins WHICH element reserves the
margin, and that the scroller does not.
@nyblnet

nyblnet commented Aug 10, 2026

Copy link
Copy Markdown
Owner Author

Follow-up commit 0440a72: the gutter's reserved margin moved from .sp-main
to .sp-page-inner.

.sp-main is chrome and follows the interface direction; the gutter is
anchored to a block, and blocks follow the document's (renderPage puts
theme.dir on .sp-page-inner and pins the outer wrapper to ltr). Reserving
the margin on the scroller sent the padding one way while a document carrying
theme.dir: 'rtl' sent the gutter the other — measured at 390px, the gutter
landed at x = 378…412, 22px past the viewport, and .sp-main scrolled to 412
against a 390 client width.

before follow-up after
rtl doc @ 390 gutter 378…412, main scrollWidth 412 gutter 352…386, 390
ltr @ 320 / 375 / 390 gutter x 4; blocks 258 / 313 / 328 identical
ltr @ 768 gutter x 4, block 706 gutter x 10, block 694 (the page centres inside its 720px measure here, so the padding lands inside the column)
desktop 1280 untouched: no inner padding, .sp-main still 28px, gutter still hover-revealed with both buttons

Shell unchanged at 132,414 B. The model rig now pins which element reserves the
margin, and that the scroller does not.

@nyblnet
nyblnet merged commit 69f3a0d into main Aug 18, 2026
1 check passed
@nyblnet
nyblnet deleted the spaces-mobile branch August 18, 2026 14:26
nyblnet added a commit that referenced this pull request Aug 18, 2026
The topbar fold (#287) and the view controls both added a FIFTH parameter to
`menuItem`, and they mean different things: `off` is a command that exists but
cannot run right now — undo/redo folded into ⋯ on a phone, disabled rather than
hidden so the menu does not change shape as you edit — and `selected` is the
choice a view is currently on. Taking either side would have silently dropped
the other's five call sites into a boolean that no longer meant what they think.

They are `state: { off?, selected? }` now, defaulted to `{}`, and the five call
sites say which they mean: `{ off: !this.store.canUndo }`, `{ selected: f.key
=== now }`. A row can be neither and nothing yet is both; the object is what
stops the next fifth meaning colliding too.

Verified in the built shell rather than by typecheck alone — both meanings, in
one build: the ⋯ menu carries `Undo (⌘Z)` and `Redo (⇧⌘Z)` with `sp-off` and
`aria-disabled="true"`, and the tracker's Group menu marks `Status` with
`sp-sel` and `aria-current="true"` while `Priority` carries neither.

SIZE CEILING 140 → 144 KiB, and this commit is the one that needs it. With
magic notes, daily notes and the mobile fold already in, the shell measures
142,674 B — 99.5% of the old ceiling, 686 B of headroom. That is not enough to
ship: the payload is mostly a zlib block and zlib differs across node versions,
and a past commit measured 130,095 B locally against 131,246 B on CI. This
would have passed here and failed there.

Gates: model 436/436 · agent 143/143 · calc 90/90 · journal 45/45 · i18n
complete (238 × 8) · shell-gate OK · tsc clean.
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