Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions docs/DECISIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -3408,3 +3408,64 @@ steal a tab, and reloading an unpacked extension fires that event every time.
mid-sentence into its own line — the identical mistake `.step b` had made and
which I had already fixed. An unscoped element selector inside a component is
the shape of the bug; both are now `> b`.

---

## 2026-08-10 — The spaces topbar has TWO fold tiers, and the touch gutter lives in a margin

Two mobile defects, both measured on the shipped shell at a 390×844 viewport
with a coarse pointer, neither of them a regression — they had been there since
the surfaces shipped.

**The bar folds twice, and it starts at the drawer breakpoint.** `.sp-bar` laid
out 467px wide inside 390 and Save's right edge landed at x = 426: the primary
action, 36px off the screen. The existing fold (six secondary actions → the ⋯
menu) was not enough, because what survives it is still eleven controls' worth
of 40px touch targets. So there are two tiers now:

- **≤820px** — the DRAWER breakpoint, not 720. The secondary row folds into ⋯
and labelled controls drop their words. It was 720, and at 768 (an iPad in
portrait) the save caret still ended 27px off the screen; 721–820 is exactly
the band where the page list is already an overlay competing for width, so
one number now means one thing.
- **≤600px** — a phone. It also gives up the wordmark (About is the first item
in ⋯), the undo/redo pair (added to ⋯ with their shortcuts and their disabled
state) and the save caret (each of its four items is in ⋯ or in About). Save
itself never moves, at any width.

The status span leaves the flow on a phone. It is `white-space: nowrap`, so a
long message ("Reading view — press Esc or the eye to edit" measures ~250px)
would have pushed Save back off the screen for as long as it was up — and
`status()` never cleared its text, only faded it, so the width it claimed was
permanent for the session. It is cleared after the fade now, and overlaid on the
title strip below 600px.

`isPhone()` in editor.ts duplicates the 600px number, because the ⋯ menu must
not offer Undo while Undo is also sitting in the bar. The model rig pins both
numbers and the agreement between them.

**The touch gutter is absolute, in a reserved margin — never in the flow.** The
earlier fix for "there is no hover on touch" made `.sp-gutter` `position:
static`, which bought reachability with 36px of height 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 start margin reserved
for it (26px on `.sp-page-inner`, 44px from the edge of the screen once
`.sp-main`'s own 18px is counted), visible at rest, and carrying ONE control —
the grip, whose sheet
already offers "Add below", so the + was a second button for something a thumb
could already reach. Measured after: one-line paragraph 68.4 → 32.4px; the
reading column pays 26px of width for it (354 → 328 at 390px). Both directions
of that trade are deliberate: a phone has ~800px of height and 390 of width, and
the chrome was eating the scarce one.

**Cost.** +312 bytes on the shipped shell (132,102 → 132,414 B), inside the
existing 135,168 B ceiling; no budget change.

*Amended, same day.* The margin was reserved on `.sp-main` first, which is
wrong for a reason worth writing down: `.sp-main` is chrome and follows the
INTERFACE direction, while the gutter is anchored to a block and blocks follow
the DOCUMENT's (`renderPage` puts `theme.dir` on `.sp-page-inner`). On a
document carrying `theme.dir: 'rtl'` the padding therefore went left while the
gutter went right — measured at 390px, the gutter landed at x = 378…412 and the
column scrolled to 412. It is reserved on `.sp-page-inner` now, so the two flip
together; the ltr metrics are byte-identical.
47 changes: 43 additions & 4 deletions scripts/test-spaces-model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -386,10 +386,36 @@ for (const [label, input, err] of [
ok(/secondary\.map\(/.test(ed), '…the inline row is built from that list')
ok(/for \(const a of secondary\)/.test(ed), '…and the ⋯ menu is built from the SAME list')

ok(/@media \(max-width: 720px\)/.test(css), 'there is a narrow-width breakpoint')
const narrow = css.slice(css.indexOf('@media (max-width: 720px)'))
ok(/\.sp-sec \{ display: none/.test(narrow), 'narrow hides the inline secondary row')
ok(/\.sp-more \{ display: inline-flex/.test(narrow), 'narrow reveals the ⋯ menu')
// Which TIER a rule lives in is the thing worth pinning: the numbers moved
// once already (720 → 820) because at 768 the save caret still ended 27px off
// the screen, and they will move again.
const tierOf = (sel: RegExp): number => {
const i = css.search(sel)
if (i < 0) return -1
const opener = [...css.slice(0, i).matchAll(/@media \(max-width: (\d+)px\)/g)].pop()
return opener ? Number(opener[1]) : 0
}
ok(tierOf(/\.sp-sec \{ display: none/) === 820, 'the inline secondary row folds at the drawer breakpoint')
ok(tierOf(/\.sp-more \{ display: inline-flex/) === 820, '…which is where the ⋯ menu appears')

// THE PHONE TIER. Folding the six secondary actions was not enough: measured
// on a 390×844 viewport with a coarse pointer, the bar still laid out 467px
// wide and Save's right edge landed at x = 426 — 36px off the screen. So a
// phone also gives up the wordmark, the history pair and the save caret, and
// the ⋯ menu picks them up.
ok(tierOf(/\.sp-mark \{ display: none/) === 600, 'a phone drops the wordmark (About is in ⋯)')
ok(tierOf(/\.sp-group-history \{ display: none/) === 600, '…and the history pair')
ok(tierOf(/\.sp-split \.sp-caret \{ display: none/) === 600, '…and the save caret')
// The status line is a nowrap span: left in the flow, one long message pushes
// Save back off the screen for as long as it is up.
ok(tierOf(/\.sp-status \{\n\s*position: absolute/) === 600,
'the status message is out of the flow on a phone, so it cannot move Save')
// The JS gate that decides what the ⋯ menu carries MUST agree with the CSS
// tier, or the menu offers Undo while Undo is also sitting in the bar.
const isPhone = ed.match(/isPhone\(\): boolean \{[\s\S]{0,120}?matchMedia\('\(max-width: (\d+)px\)'\)/)
ok(!!isPhone && Number(isPhone![1]) === 600, 'isPhone() uses the same breakpoint as the phone tier')
ok(/if \(this\.isPhone\(\)\)/.test(ed) && /t\('Undo \(⌘Z\)'\)/.test(ed) && /t\('Redo \(⇧⌘Z\)'\)/.test(ed),
'…and the ⋯ menu picks up undo/redo there')

// the bar must never become a scroller — that hides the same controls, just
// less honestly, and it is the fix everyone reaches for first
Expand Down Expand Up @@ -1107,6 +1133,19 @@ for (const [label, input, err] of [
const narrow = css.slice(css.indexOf('@media (max-width: 820px)'))
ok(!/\.sp-gutter \{ display: none/.test(narrow), 'the gutter is not hidden on touch')
ok(/\.sp-gutter \{ opacity: 1/.test(narrow), '…it is shown rather than hovered')
// …AND IT DOES NOT TAKE A ROW. The first fix for "no hover on touch" put the
// gutter in the flow (position: static), which cost 36px of height on EVERY
// block: measured at 390px, a one-line paragraph was 68.4px tall and half of
// that was the two affordances. It belongs in the start margin, out of flow.
ok(!/\.sp-gutter \{[^}]*position: static/.test(narrow),
'…and it is out of the flow, so a block does not pay a row for it')
// …reserved on the PAGE, not on the scroller: `.sp-main` follows the
// interface direction and a block follows the document's (theme.dir), so an
// rtl document put the padding on one side and the gutter on the other.
ok(/\.sp-page-inner \{ padding-inline-start: 26px/.test(narrow),
'the page reserves the margin the gutter sits in, on the side the blocks start')
ok(!/\.sp-main \{[^}]*padding-inline-start/.test(narrow),
'…and the scroller does not, so the two cannot disagree under rtl')
ok(/sp-sheet/.test(css) && /isDrawer\(\)/.test(ed),
'and the menu becomes a bottom sheet where a 5px anchor would be unusable')

Expand Down
19 changes: 19 additions & 0 deletions spaces/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,25 @@ Versions follow `0.MINOR.PATCH` while pre-1.0.

## [Unreleased]

- **Fixed: Save was partly off the screen on a phone.** Measured on a 390×844
viewport, the topbar laid out 467px wide inside 390 and the Save button's
right edge landed at x = 426 — 36px past the edge, on the one control that
must never be unreachable. A phone now also folds the wordmark, undo/redo and
the other-ways-to-save caret into the ⋯ menu, which already held the six
secondary actions, and the + Insert button keeps its icon without its word.
Nothing is removed — undo and redo are in ⋯ carrying their shortcuts and their
disabled state, and Save a copy / Export as Markdown join them there. The same
fold now starts at the drawer breakpoint (820px) rather than 720, because at
768 — an iPad in portrait — the save caret still ended 27px off the screen.
The bar fits exactly at 320, 375, 390 and 768px, and is unchanged at 1280.

- **Fixed: every block cost a whole row of chrome on a phone.** The +/grip
gutter is shown rather than hovered on touch (there is no hover), but it was
laid out IN the flow: a one-line paragraph measured 68.4px tall, 36px of it
affordances. The gutter moves into a reserved 44px start margin, out of the
flow, keeping the grip — whose menu already offers "Add below". A one-line
paragraph is 32.4px now; the reading column gives up 26px of width for it.

- **Callouts.** A boxed note, tip, important, warning or caution — `/callout`,
the Insert menu, or type `[!warning] ` on an empty line. Press ⏎ inside one
and the next line goes in with it; an empty line and ⌫ takes you back out.
Expand Down
59 changes: 54 additions & 5 deletions spaces/src/editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -199,9 +199,32 @@ export class Editor {
})

const more = this.dropdown('more', '', t('More'), (menu, close) => {
// On a PHONE the ⋯ menu also carries the history pair and the other ways
// to save. Measured at 390px with a coarse pointer: eleven bar controls
// wanted 467px of a 390px viewport, and Save — the one action that must
// never be off-screen — ended at x = 426. Undo/redo (84px), the wordmark
// (35px) and the save caret (48px) are what a phone gives up so that the
// document title beside them is still wide enough to read. Nothing is
// lost: they are all one tap away, here.
if (this.isPhone()) {
menu.append(this.menuItem('undo', t('Undo (⌘Z)'), '', () => {
close(); this.store.undo(); this.repaint()
}, !this.store.canUndo))
menu.append(this.menuItem('redo', t('Redo (⇧⌘Z)'), '', () => {
close(); this.store.redo(); this.repaint()
}, !this.store.canRedo))
}
for (const a of secondary) {
menu.append(this.menuItem(a.icon, a.label, a.hint, () => { close(); a.run() }))
}
if (this.isPhone()) {
menu.append(this.menuItem('copy', t('Save a copy…'), t('A second file — the original is left alone'), () => {
close(); void this.saveAs('copy')
}))
menu.append(this.menuItem('markdown', t('Export as Markdown…'), t('Every page, as one .md file'), () => {
close(); this.exportMarkdown()
}))
}
})
more.classList.add('sp-more', 'sp-dd-end')

Expand Down Expand Up @@ -321,7 +344,10 @@ export class Editor {
b.className = 'sp-btn'
b.type = 'button'
b.innerHTML = ICONS[icon]
if (label) b.append(document.createTextNode(label))
// The word is a SPAN, not a bare text node, so a narrow bar can drop it and
// keep the icon — slides' rule, and the only way to collapse a labelled
// control without also losing it.
if (label) b.append(el('span', 'sp-btnlabel', label))
b.title = tip
b.setAttribute('aria-label', tip)
b.setAttribute('aria-haspopup', 'menu')
Expand All @@ -342,10 +368,11 @@ export class Editor {
return wrap
}

private menuItem(icon: IconName, label: string, hint: string, onClick: () => void): HTMLElement {
private menuItem(icon: IconName, label: string, hint: string, onClick: () => void, off = false): HTMLElement {
const b = document.createElement('button')
b.className = 'sp-dditem'
b.className = 'sp-dditem' + (off ? ' sp-off' : '')
b.type = 'button'
if (off) b.setAttribute('aria-disabled', 'true')
b.setAttribute('role', 'menuitem')
b.innerHTML = `<span class="sp-result-ico">${ICONS[icon]}</span>` +
`<span class="sp-result-txt"><strong>${escapeHtml(label)}</strong>` +
Expand Down Expand Up @@ -438,6 +465,17 @@ export class Editor {
return window.matchMedia('(max-width: 820px)').matches
}

/**
* A PHONE, not merely a narrow window: the width below which the topbar has
* dropped the wordmark, the history pair and the save caret (styles.css, the
* "phone topbar" block). The number is duplicated between here and the
* stylesheet on purpose — the alternative is a menu that offers Undo while
* Undo is also sitting in the bar two centimetres away.
*/
private isPhone(): boolean {
return window.matchMedia('(max-width: 600px)').matches
}

/**
* Dismiss the PHONE DRAWER after navigating. On anything wider this does
* nothing, deliberately.
Expand Down Expand Up @@ -474,7 +512,16 @@ export class Editor {
this.statusEl.textContent = msg
this.statusEl.classList.add('sp-on')
clearTimeout((this.statusEl as any)._t)
;(this.statusEl as any)._t = setTimeout(() => this.statusEl.classList.remove('sp-on'), 1800)
;(this.statusEl as any)._t = setTimeout(() => {
this.statusEl.classList.remove('sp-on')
// The word must LEAVE the bar, not just fade out of it. This span is
// nowrap, so once "Edited" had been written once it held ~40px of the
// topbar for the rest of the session — and on a phone that width came
// out of the controls beside it. Cleared after the fade, never during.
setTimeout(() => {
if (!this.statusEl.classList.contains('sp-on')) this.statusEl.textContent = ''
}, 260)
}, 1800)
}

// ---- the page tree ------------------------------------------------------
Expand Down Expand Up @@ -672,7 +719,9 @@ export class Editor {
private addGutter(node: HTMLElement, blockId: string): void {
const g = el('div', 'sp-gutter')
const add = document.createElement('button')
add.className = 'sp-ghost'
// Named, because a phone drops it: there is only room for ONE control in a
// 44px margin, and "Add below" is the second item of the grip's own menu.
add.className = 'sp-ghost sp-ghost-add'
add.type = 'button'
add.innerHTML = ICONS.plus
add.title = t('Add a block below')
Expand Down
Loading
Loading