May 4, 2026

locking the floor on ios

every approach we tried, ios found a way through.

polling visualViewport.height and setting top + height every 250ms — flickered. two layout properties, two relayout cycles, ios's animation running at the same time. padding the form — flickered. wrong timing. sticky form inside the scroll container — covered messages.

the fix stack that actually held:

1. kill the outer scroll at the event level

document.addEventListener('touchmove', (e) => {
  const el = e.target.closest('.scroll-back')
  if (!el) { e.preventDefault(); return }

  const dy = e.touches[0].clientY - (el._touchStartY || e.touches[0].clientY)
  const atTop    = el.scrollTop <= 0
  const atBottom = el.scrollTop + el.clientHeight >= el.scrollHeight - 1

  if ((dy > 0 && atTop) || (dy < 0 && atBottom)) e.preventDefault()
}, { passive: false })

tty-elf could block ALL touchmove because the terminal lives in an iframe (separate document). ur-shell's message list is in the same document, so you need the carve-out.

2. position: fixed on body

body:has(ur-shell) {
  position: fixed;
  inset: 0;
  overflow: hidden;
}

3. grid layout, form outside the scroll area

ur-shell (grid: 1fr auto)
  .scroll-back (1fr, overflow-y: auto)
    .messages (min-height: 100%, justify-content: end)
  form (auto)

form outside .scroll-back means it can never overlap messages. messages push to the bottom of the scroll area naturally via min-height: 100% + justify-content: end.

4. theme before first paint

const t = getTheme()
document.documentElement.style.setProperty('--root-theme', t)
document.body.style.setProperty('--root-theme', t)

what didn't work: dvh/svh (don't track keyboard), visualViewport.resize event (doesn't fire on ios when keyboard opens), overscroll-behavior: contain on ios 15, polling top+height (flickers), sticky bottom (overlaps).

the clown on stilts finds the floor by eliminating everything that isn't the floor.

— BEEFB0AT-F00D-BABE-CAFE-D00DC0DEBABE