Skip to content

Anchored watch popover on desktop, docked sheet fixes - #30

Open
freethinkel wants to merge 20 commits into
mainfrom
worktree/lucky-meadow-f9ac
Open

Anchored watch popover on desktop, docked sheet fixes#30
freethinkel wants to merge 20 commits into
mainfrom
worktree/lucky-meadow-f9ac

Conversation

@freethinkel

Copy link
Copy Markdown
Owner

What

  • Sheet: the sheet now rides in a full-screen dock; overflow: clip on the dialog (iOS Safari scrolls a hidden-overflow box on focus reveal, sliding the clipped tail zone into view), autofocus on the dialog so showModal() doesn't focus the close button and fight the snap scroll, and dismiss() closes outright when already at the top — a smooth scroll to 0 fires no scroll event, which left an invisible modal blocking the page.
  • Popover (new): native popover="auto" + CSS anchor positioning with a macOS-style tail pointing at the trigger. overflow: visible (the UA clips [popover] with overflow: auto), elevated surface a notch above --color-background, width as a viewport-clamped prop.
  • AdaptivePopover (new): owns the shell choice at the bottom-nav breakpoint — anchored popover on desktop, swipeable sheet on the phone — and closes the panel when a resize crosses the breakpoint instead of teleporting it between shells mid-resize.
  • Watch panel wired through AdaptivePopover: title via prop, anchor-name on the header trigger, and a guard so a click on the trigger closes the popover rather than reopening what light dismiss just closed.

Verified

  • Chrome desktop: open/close via trigger, light dismiss, Esc, tail centered on the trigger.
  • Mobile viewport: sheet with title + close button, swipe-away scroll intact.
  • Breakpoint crossing with the panel open in both directions: panel closes, page stays clickable.

🤖 Generated with Claude Code

https://claude.ai/code/session_013uXvqNHddM9rCCR3NK7Yax

freethinkel and others added 20 commits August 9, 2026 21:58
The market card used to be a door into the editor: opening one meant parsing a
megabyte of .bin before anything showed up, and flashing it to the watch meant
going through a document you never intended to edit.

/market/[id] shows the face instead — drawn for real by the editor's renderer
against the wall clock, not as a still preview — and installs it straight from
the bytes. The editor is one button among several now, and it opens at once and
loads behind its own spinner.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U7TLWwHXLcXB4tjPhc44ny
--font-mono was an alias for --font-family, so the whole UI was Geist Mono. The
places that ask for mono (sim panel, shortcuts, frame numbers) mean it; the rest
just inherited it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U7TLWwHXLcXB4tjPhc44ny
The overlay is a scroll container with two snap points — an empty screen, then
the sheet — so swiping it down is ordinary scrolling with native inertia, and
arriving back at the top is what closes it. No pointer handlers, no drag maths.
<dialog> hosts it, so the top layer, Esc and the focus trap come for free.

The trick is lifted from friendzone's drawer; the keyboard-offset half of it
isn't here, since nothing in this app types into a sheet yet.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U7TLWwHXLcXB4tjPhc44ny
It was a popover anchored to its trigger, which meant two placements, a
click-outside listener and an Escape listener — all so the same panel could hang
off the header on a desktop and off the tab bar on a phone. As a sheet it is one
shape in both places, and <dialog> already knows how to do the rest.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U7TLWwHXLcXB4tjPhc44ny
Opening parked it at the bottom snap point outright, so it was simply there,
overlay at full strength. Starting from the top snap point and scrolling to the
bottom plays the dismissal backwards, and the overlay fades along with it — its
opacity already tracks the scroll.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U7TLWwHXLcXB4tjPhc44ny
Scrolling the sheet into place can't be animated: with mandatory snapping the
browser rounds every scrollTop we set to a snap point, so a hand-written tween
jumps, and its own smooth scroll eases in — which reads as the sheet hesitating
before it appears. The entrance is a transition on the sheet instead, from the
bottom snap point where it already sits.

Dragging past that snap point used to uncover the page below the sheet's
cut-off underside. A tail below it, same background, means overscrolling just
stretches it. The tail lives in the scroll range, so the fade now measures the
travel — one screen — rather than the whole range.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U7TLWwHXLcXB4tjPhc44ny
Silk's sheets are built on the same two ideas this one is — scroll snap for the
gesture, a bleeding background so overscroll stretches rather than cuts off —
plus a third: the travel of the gesture is a value the content can read, not a
detail the component keeps to itself. Exposing it as --travel and as the
children snippet's argument means an effect elsewhere can ride the same drag.

The header went with it: a component that hard-codes a title and an icon button
isn't a sheet, it's this app's sheet. What is left imports nothing but Svelte
and falls back for every token it reads, so the file can be copied elsewhere.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U7TLWwHXLcXB4tjPhc44ny
The tail was half a screen tall, and a tail is in the flow whether we like it or
not — an out-of-flow one can't move with a bounce (fixed stays put) and clip with
overflow-clip-margin still counts toward scrollHeight in Chrome. So the drag past
the bottom wasn't a stretch at all, it was scrolling into a second screen. At
4rem it runs out after a couple of centimetres and snaps back.

The entrance no longer borrows the app's spring, which overshoots — a sheet that
bounces past the edge it just came from looks wrong. It carries the iOS sheet
ease instead, like Silk's.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U7TLWwHXLcXB4tjPhc44ny
Read the structure off silkhq.com's bottom sheet example: [spacer][sticky
panel][tail], scrollHeight 3767 against a 2942 viewport — the whole scroll range
is the height of the sheet, and there is nothing past the open position. Dragging
beyond it is a bounce, which is what a stretch is.

Here the second snap point was a full screen with the sheet parked in it, plus a
tail in the flow, so the range was a screen and a half: past the open position
the drag kept scrolling instead of stretching. Now the dock is exactly as tall as
the sheet and aligns to its end, and the sheet is sticky to the bottom of the
scrollport — the tail is gone, since a stuck sheet doesn't leave an edge to cover
during a bounce.

That leaves the entrance: a translation counts toward scrollable overflow, so
while it plays the range reads a sheet-height too long. Measurements wait it out.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U7TLWwHXLcXB4tjPhc44ny
Taken from silkhq.com's bottom sheet: the surface there isn't the sheet's own
background, it is an absolutely positioned layer with bottom: -300px, so pulling
the sheet up reveals more of the same surface instead of the edge it was cut off
at. The content sits over it.

Their bleed costs them no scroll — measured it: hiding the layer leaves
scrollHeight at 3767 either way — because the flow continues below their panel
with another half-screen block the bleed hides inside. A sheet flush with the
bottom of the screen has no such block, so ours is 6rem of extra range: pull, and
the surface follows down to the edge of the screen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U7TLWwHXLcXB4tjPhc44ny
A programmatic scroll may rest anywhere, snap points or no — and scrollHeight
stopped being the open position when the bleed layer moved in under it. So
opening parked the sheet 6rem over-stretched, and the first touch jolted it back
to the snap point: the "still not right" feel.

The open position is one dock height of scroll, by construction — the spacer is
one screen. Parking there and measuring travel against the same length also
retires the entrance timer: the translate still inflates scrollHeight while it
plays, but nothing reads scrollHeight anymore.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U7TLWwHXLcXB4tjPhc44ny
… open

data-silk laid Silk's whole scroller out: a spacer the height of the travel
(snap start), the sheet in a sticky panel, and a tail (snap end) — panel and
tail together exactly one viewport, so the bleeding background and the tail's
copy of the surface fit inside the flow and the range ends at the open snap
point. Nothing is scrollable past open; pulling further is the browser's own
elastic bounce.

Every bleed we tried — ::after, an absolute layer, clip-margin — extended the
range past the snap point instead, leaving 6rem of real 1:1 scroll with a hard
stop where Silk has a rubber band. A box-shadow is the one way to paint below
the sheet that costs nothing: ink overflow doesn't count toward scrollable
overflow. Same invariants as Silk's structure, none of the machinery.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U7TLWwHXLcXB4tjPhc44ny
The background moves back off the sheet onto a dedicated absolute layer — Silk's
BleedingBackground shape, and the one the travel can drive effects on. Its box
stays inside the sheet (an edge past it would extend the scroll range past the
open snap point again); the continuation below is still the shadow's, which
costs no range.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U7TLWwHXLcXB4tjPhc44ny
0.4s reads as an appearance; 1.6s on the same curve reads as an arrival.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U7TLWwHXLcXB4tjPhc44ny
Content-sized worked until the content was three lines — the watch panel opened
as a strip that read as a toast. Everything else already follows the dock's
height, so the travel and the snap point grew with it on their own.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U7TLWwHXLcXB4tjPhc44ny
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U7TLWwHXLcXB4tjPhc44ny
The piece of silkhq.com's structure the earlier ports missed: their scrollport
is taller than the screen, the dialog clips its bottom half, and only the spacer
and the tail are snap areas — the sheet takes no part in snapping. That is what
buys the real bleeding background: the room below the sheet is in the flow, so
the bleed div hanging into it extends no scroll range, sits off screen, and
rides up into view when an elastic stretch lifts the sheet.

Ported as: scroll container 100svh + 50svh (dialog clips the rest), spacer one
SCREEN tall — not one scrollport — so the travel stays the sheet's height, snap
end moved off the dock onto the tail, and the bleed back to a real overhang
instead of the box-shadow stand-in.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U7TLWwHXLcXB4tjPhc44ny
The sheet now rides in a full-screen dock (flex-end) instead of snapping
as its own box. Three fixes around it: overflow: clip on the dialog — a
hidden box is still a scroll container and iOS Safari scrolls it on focus
reveal, sliding the clipped tail zone into view; autofocus on the dialog
so showModal() doesn't focus the close button and fight the snap scroll;
and dismiss() closes outright when already at the top, where a smooth
scroll to 0 fires no scroll event and the close never came.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013uXvqNHddM9rCCR3NK7Yax
New Popover component: native popover="auto" (top layer, Esc, light
dismiss) positioned with CSS anchor positioning against the trigger's
anchor-name, with a macOS-style tail pointing at it — overflow: visible
because the UA clips [popover] with overflow: auto, and an elevated
surface a notch above --color-background so it reads against the page.
Width is a prop, clamped to the viewport.

AdaptivePopover owns the shell choice at the bottom-nav breakpoint:
popover on desktop, the swipeable sheet on the phone — and closes the
panel when a resize crosses the breakpoint, since teleporting an open
panel into the other shell mid-resize opens it with half-measured
geometry. The watch panel supplies its title through a prop; the trigger
guard keeps a click on it from reopening what light dismiss just closed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013uXvqNHddM9rCCR3NK7Yax
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