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
95 changes: 81 additions & 14 deletions slides/src/editor/editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -312,7 +312,12 @@ export class Editor {
const moreMenu = div('ed-menu')
const moreD = div('ed-dropdown ed-phone-only')
moreD.append(
btn('<b>⋯</b>', t('More'), () => moreD.classList.toggle('open'), t('More actions')),
btn('<b>⋯</b>', t('More'), () => {
// Fill BEFORE opening: the save-as list reflects live state (is this
// file encrypted?) and must be current the moment it becomes visible.
if (!moreD.classList.contains('open')) this.fillPhoneSaveAs(moreMenu, moreD)
moreD.classList.toggle('open')
}, t('More actions')),
moreMenu)
const slidesB = btn(ICONS.panelLeft, t('Slides'), () => this.togglePanel('left'), t('Slides — show or hide the slide list'))
slidesB.classList.add('ed-phone-only')
Expand Down Expand Up @@ -559,17 +564,29 @@ export class Editor {
// so the menu's label rule has nothing to reveal and they would sit in
// ⋯ as mystery glyphs. Borrow the tooltip, minus its shortcut: "Redo
// (⇧⌘Z)" -> "Redo". No new strings, and desktop is untouched.
if (!b.querySelector('span') && b.title) {
// A demoted DROPDOWN is a wrapper, so label its TRIGGER and ask the
// trigger alone whether it already has one. Asking the wrapper always
// answers yes — it contains the menu it hides, and that menu is full of
// spans. Language lost its label to exactly that: it sat in ⋯ as a bare
// globe while everything around it was captioned.
const face = b.classList.contains('ed-dropdown')
? (b.firstElementChild as HTMLElement | null)
: b
if (face && !face.querySelector('span') && face.title) {
const lab = document.createElement('span')
lab.dataset.phoneLabel = '1'
lab.textContent = b.title.split('(')[0].trim()
b.appendChild(lab)
// "Redo (⇧⌘Z)" -> "Redo"; "Not sharing yet — click…" -> "Not sharing yet"
lab.textContent = face.title.split('(')[0].split('—')[0].trim()
face.appendChild(lab)
}
p.moreMenu.appendChild(b)
}
} else if (!fresh) {
while (p.insertMenu.firstChild) p.insert.appendChild(p.insertMenu.firstChild)
for (const lab of p.moreMenu.querySelectorAll('[data-phone-label]')) lab.remove()
// The save-as rows are a phone-only copy; on a wide screen the split
// button's caret is back and owns that list again.
for (const row of p.moreMenu.querySelectorAll('[data-phone-saveas]')) row.remove()
// back to their original homes, in their original order
for (const b of p.demote) {
if (b === p.demote[0]) p.history.appendChild(b)
Expand Down Expand Up @@ -600,20 +617,47 @@ export class Editor {
if (wrap.classList.contains('open')) rebuild()
}, t('Save as… — copy, new deck, password'))
trigger.classList.add('ed-split-caret')
const rebuild = () => {
menu.textContent = ''
this.buildSaveAsItems(menu, () => wrap.classList.remove('open'))
}
wrap.append(trigger, menu)
document.addEventListener('pointerdown', (ev) => {
if (!wrap.contains(ev.target as Node)) wrap.classList.remove('open')
})
return wrap
}

/**
* The Save-as list, built into `into`.
*
* Rebuilt on every open because it reflects live state: an encrypted file
* offers Change/Remove password where a plain one offers Encrypt.
*
* It takes a container so ONE list can serve two homes — the desktop split
* button's dropdown, and the ⋯ menu on a phone, where the caret that opens
* this list does not fit beside a 44px Save button. `mark` tags what it
* creates so the phone copy can be torn down again without disturbing the
* real toolbar buttons parked in that same menu.
*/
private buildSaveAsItems(into: HTMLElement, close: () => void, mark = false) {
const tag = <T extends HTMLElement>(el: T): T => {
if (mark) el.dataset.phoneSaveas = '1'
return el
}
const item = (icon: string, label: string, title: string, onClick: () => void) => {
const b = document.createElement('button')
b.className = 'ed-btn'
if (icon) b.innerHTML = icon
b.appendChild(Object.assign(document.createElement('span'), { textContent: label }))
b.title = title
b.addEventListener('click', () => {
wrap.classList.remove('open')
close()
onClick()
})
menu.appendChild(b)
into.appendChild(tag(b))
}
const rebuild = () => {
menu.textContent = ''
{
// FILE operations only — everything that goes to OTHER PEOPLE lives in
// the Share panel (one mental model: Save = for me, Share = for others).
item(ICONS.copy, t('Save a copy…'),
Expand All @@ -640,7 +684,7 @@ export class Editor {
}
// the document AS DATA — history and the AI/JSON round-trip live with
// the other file operations now (they were buried in the About dialog)
menu.appendChild(div('ed-menu-sep'))
into.appendChild(tag(div('ed-menu-sep')))
item(ICONS.history, t('Version history…'),
t('Restore an earlier auto-saved version of this deck (kept locally in this browser).'),
() => void this.openVersionHistory())
Expand All @@ -654,11 +698,34 @@ export class Editor {
t('Replace every slide with one blank slide. Keeps the deck’s theme, name and live session — ⌘Z undoes.'),
() => this.startFromScratch())
}
wrap.append(trigger, menu)
document.addEventListener('pointerdown', (ev) => {
if (!wrap.contains(ev.target as Node)) wrap.classList.remove('open')
})
return wrap
}

/**
* Put the save-as list at the bottom of ⋯ on a phone.
*
* The split button's caret is hidden there — it does not fit beside a 44px
* Save target — which left Save a copy, Duplicate as new deck, every password
* action, Version history and the whole JSON round-trip with NO route on a
* phone at all. They are file operations, so ⋯ ("everything occasional") is
* where they belong rather than a second nested dropdown, which on glass is
* a worse answer than a long list.
*
* Rebuilt on each open (the list is state-dependent) and torn down BY TAG:
* the buttons sharing this menu are the real toolbar nodes on loan from the
* bar, and clearing the container would destroy them.
*/
private fillPhoneSaveAs(menu: HTMLElement, wrap: HTMLElement) {
for (const stale of Array.from(menu.querySelectorAll('[data-phone-saveas]'))) stale.remove()
if (!this.phoneChromeOn) return
// `el.dataset.x = …`, never Object.assign(el, {dataset}) — dataset is a
// getter-only accessor, so assigning it wholesale THROWS. It type-checks
// either way, and the throw here landed before the menu's own toggle, so
// the symptom was ⋯ refusing to open at all rather than anything about
// save-as.
const sep = div('ed-menu-sep')
sep.dataset.phoneSaveas = '1'
menu.appendChild(sep)
this.buildSaveAsItems(menu, () => wrap.classList.remove('open'), true)
}

/**
Expand Down
29 changes: 25 additions & 4 deletions slides/src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -185,7 +185,12 @@ a.ed-btn { text-decoration: none; } /* the update block's "What's new" link is
z-index: 50;
min-width: 150px;
}
.ed-dropdown.open .ed-menu { display: flex; }
/* DIRECT child, not any descendant. Phone chrome demotes whole dropdown
widgets (Share, Language) into the ⋯ menu, so a descendant selector opened
their menus too — ⋯ came up with the entire language list already unrolled
inside it. Every .ed-menu is appended straight onto its own .ed-dropdown, so
scoping to `>` is exact. */
.ed-dropdown.open > .ed-menu { display: flex; }
.ed-menu .ed-btn { justify-content: flex-start; width: 100%; }

/* ————— main layout ————— */
Expand Down Expand Up @@ -1880,10 +1885,26 @@ body.ed-col-resizing { cursor: col-resize; user-select: none; }
.ed-main { position: relative; }
/* the 16px resizer chevrons are replaced by real buttons in the bar */
.ed-panel-toggle { display: none; }
/* Save stays one tap; its save-AS caret does not fit and is the least-used
half. KNOWN GAP: copy / new deck / template / password are unreachable on
a phone until they move into ⋯. */
/* Save stays one tap; its save-AS caret does not fit beside a 44px target
and is the least-used half. The list it opens is not lost — editor.ts
appends it to the bottom of ⋯ while phone chrome is on. */
.ed-topbar .ed-split-caret { display: none; }
/* ⋯ now carries the demoted buttons AND that list, so it can outgrow the
screen in BOTH directions.
Down: without a bound it runs off the bottom, and a dropdown is absolutely
positioned, so nothing else can scroll it back into reach.
Sideways: these menus open from buttons in the right half of a 402px bar,
and a start-anchored menu grows AWAY from the screen — ⋯ sits at x≈374, so
even the old six short rows were partly cut off. Anchoring to the end grows
them inward instead. */
.ed-topbar .ed-menu {
inset-inline-start: auto;
inset-inline-end: 0;
max-width: calc(100vw - 16px);
max-height: calc(100dvh - 96px);
overflow-y: auto;
-webkit-overflow-scrolling: touch;
}
.ed-title { min-width: 0; }
}

Expand Down
106 changes: 77 additions & 29 deletions tray/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -179,7 +179,26 @@ and the native file layer are not.
`BentoTray.xcodeproj` is generated, never committed — a `.pbxproj` in git is a
merge-conflict magnet.

## State: scaffold, not shippable
### Signing

The **simulator needs none** — it signs ad-hoc, which is why a plain `xcodegen
&& xcodebuild` has always just worked. A **real device needs a team**:

```sh
BENTO_TEAM_ID=ABCDE12345 xcodegen # then build to the device
```

The ID comes from the environment at generation time and is never written to a
tracked file. A Team ID identifies a person or company, and the `.xcodeproj`
that carries it is generated and gitignored, so nothing personal is committed.
Leave it unset and `DEVELOPMENT_TEAM` is simply absent — simulator builds are
unaffected.

Find it in **Xcode ▸ Settings ▸ Accounts**, or developer.apple.com ▸ Membership.
A free Apple ID signs for your own devices on a **7-day** profile that must then
be re-signed; TestFlight and the App Store need the paid programme.

## State: runs, unsigned, untested on hardware

Verified — the save contract, exercised against the **real** Bento build in a
browser with the native side emulated (`begin`/`write` over the same protocol):
Expand All @@ -190,23 +209,37 @@ browser with the native side emulated (`begin`/`write` over the same protocol):
- "Save a copy…" prompts for a destination and leaves the open document
untouched

Also verified — the Swift **typechecks against the real iOS 26.5 simulator SDK**
(`swiftc -typecheck -sdk $(xcrun --sdk iphonesimulator --show-sdk-path)`), so
UIKit, WebKit, `UIDocument`, `WKURLSchemeHandler` and every protocol conformance
resolve. That is full semantic analysis, not a syntax pass.
Since then it has been **built, installed and driven** on the iPhone 17 Pro Max
and iPad Pro 11" simulators: documents create, open, edit and save; the scheme
handler serves bytes; the exit returns to the browser; the app icon renders on
the home screen. Presentation geometry was measured from the framebuffer rather
than eyeballed — 16:9 to four decimal places, symmetric letterboxing, on both
devices and both orientations.

Not verified — **the app has never been linked, launched or run.** Typechecking
stops before codegen and linking, and nothing has exercised the bridge on an
actual device or simulator. Runtime behaviour — the scheme handler serving
bytes, security-scoped access, the save round trip reaching disk — is still
unproven.
Still not verified — **anything on real hardware.** Everything above is the
simulator, which does not exercise signing, provisioning, device performance, or
the file providers (iCloud Drive, Dropbox) that make open-in-place interesting.
Also untested: the share-sheet and AirDrop routes into the app.

### Getting back out

The editor is presented inside a `UINavigationController` with a **Documents**
button and the file's name. That bar is not decoration: presented full screen
with no chrome, a document was a ONE-WAY TRIP — full-screen modals have no
interactive dismiss, so force-quitting the app was the only exit.
A document opens full screen with **no native bar at all**, and the way back is
a small floating chevron in the bottom-left corner. Something has to be there:
full-screen modals have no interactive dismiss, so with no chrome a document
was a ONE-WAY TRIP and force-quitting the app was the only exit.

The nav bar it replaced is gone in BOTH orientations. The document already has
its own toolbar, so a native bar above it was a second row of chrome competing
with the first, spending 44pt of a screen that has none to spare. (Its
`hidesBarsWhenVerticallyCompact` auto-hide was tried first and simply does not
fire for a modally-presented navigation controller.)

The chevron fades to near-transparent after a few seconds and returns on any
touch — including a swipe, which is the gesture that matters, since a presenter
advancing slides never taps. Once element fullscreen was declined (below) the
host lost its only signal for "a show is running", and guessing what the
document is doing is the one thing this app refuses to do; getting out of the
way when unused is right for presenting and harmless while editing.

The host has to supply this itself. It cannot ask the page for a close button
without assuming what the page is, which is the one thing this app does not do.
Expand All @@ -218,21 +251,36 @@ on the failure path and leaked once per document opened. The scope is dropped
only after close completes; dropping it earlier can fail the final write for a
file outside the container.

The nav bar does NOT intrude on a slideshow, because presenting takes real
element fullscreen — see below. It is chrome for editing only.

It also **auto-hides in landscape** (`hidesBarsWhenVerticallyCompact`). A phone
in landscape is only ~390pt tall, so 44pt is over a tenth of the height, on the
very axis a 16:9 canvas needs most. When the bar is away, a small floating
chevron stands in, pinned to the **safe-area inset** — on a notched iPhone held
sideways that gutter is dead space no content can occupy, so the exit costs
nothing there.

Verified in landscape: the bar is gone, the chevron is present, and tapping it
returns to the browser. `simctl` cannot rotate a device and its screenshots do
not report orientation, so this was tested by having the app rotate ITSELF via a
temporary launch-argument hook (`requestGeometryUpdate`) — worth remembering as
the way to test orientation-dependent behaviour here.
### Element fullscreen is DECLINED, on every device

`WKWebView` offers it as an opt-in that mobile Safari never gives a page, so it
looked like free capability. It is not. WebKit's fullscreen view brings its own
close button that no public API can hide, restyle or move, and it insets the
content — so a 16:9 deck letterboxed asymmetrically and the foreign ✕ spilled
off the band onto the slide. On iPad it did not even hide the status bar, which
is the one thing fullscreen is for.

Declining costs nothing, because the host hands the page the whole screen
anyway: the status bar is hidden on iPad (where nothing else keeps the page off
the screen — there is no sensor housing to reserve a band for) and the web view
is inset by exactly `view.safeAreaInsets.top`, which reports the housing on
iPhone portrait and 0 everywhere else. The deck then fills the view edge to
edge, letterboxes evenly, and wears its OWN chrome. A page refused fullscreen
is not broken — that is the path it takes in mobile Safari.

Measured from the framebuffer, presenting the starter deck:

| | bands | result |
|---|---|---|
| iPhone landscape | 261 / 261 | aspect 1.7773 |
| iPad portrait | 741 / 741 | aspect 1.7783 |
| iPad landscape | 153 / 153 | 1362px = 1210pt × 9/16 |

Orientation testing note: `simctl` cannot rotate a device, and driving the
Simulator's own rotate command is unreliable when more than one simulator is
open (the keystroke goes to whichever window has focus). Forcing
`supportedInterfaceOrientations` on the presented controller is the dependable
way to land a specific orientation for a measurement.

### Platform notes worth keeping

Expand Down
15 changes: 13 additions & 2 deletions tray/ios/Resources/bridge.js
Original file line number Diff line number Diff line change
Expand Up @@ -91,8 +91,19 @@
}
return {
async write(data) {
// the params form: {type:'write'|'seek'|'truncate', data, position, size}
if (data && typeof data === 'object' && typeof data.type === 'string') {
// The params form is {type:'write'|'seek'|'truncate', data, position, size}.
//
// A BLOB IS ALSO AN OBJECT WITH A STRING `type` — its MIME type —
// so it must be excluded explicitly and the three type values
// matched exactly. Testing only `typeof data.type === 'string'`
// made `new Blob([html], {type: 'text/html'})` parse as params
// whose `.data` is undefined, so asText() returned '' and the
// document was written EMPTY. That is precisely the blob
// kernel/src/save.ts writes, so every real save through this
// polyfill truncated the user's file to zero bytes.
const params = data && typeof data === 'object' && !(data instanceof Blob) &&
(data.type === 'write' || data.type === 'seek' || data.type === 'truncate')
if (params) {
if (data.type === 'seek') { pos = data.position || 0; return }
if (data.type === 'truncate') { buf = buf.slice(0, data.size || 0); if (pos > buf.length) pos = buf.length; return }
if (typeof data.position === 'number') pos = data.position
Expand Down
16 changes: 16 additions & 0 deletions tray/ios/project.yml
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,22 @@ targets:
PRODUCT_BUNDLE_IDENTIFIER: page.bento.tray
TARGETED_DEVICE_FAMILY: "1,2" # iPhone and iPad
SWIFT_VERSION: "5.0"
# Signing. The SIMULATOR needs none of this — it signs ad-hoc, which is
# why every build so far has just worked. A real device needs a team.
#
# The team comes from the ENVIRONMENT at generation time, never from
# this file: a Team ID identifies a person or company, and the .xcodeproj
# that carries it is generated and gitignored, so nothing personal is
# committed. Unset, it expands to empty and simulator builds are
# unaffected.
#
# BENTO_TEAM_ID=ABCDE12345 xcodegen # then build to a device
#
# Find the ID in Xcode > Settings > Accounts (or developer.apple.com >
# Membership). A free Apple ID signs for your own devices on a 7-day
# profile; TestFlight and the App Store need the paid programme.
CODE_SIGN_STYLE: Automatic
DEVELOPMENT_TEAM: ${BENTO_TEAM_ID}
# Assets.xcassets is picked up by the source glob above; this names the
# icon set inside it. Without it the catalog still compiles and the app
# still ships — with no icon at all, silently.
Expand Down
Loading