Anchored watch popover on desktop, docked sheet fixes - #30
Open
freethinkel wants to merge 20 commits into
Open
Conversation
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
This reverts commit 90ef3c3.
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
overflow: clipon the dialog (iOS Safari scrolls a hidden-overflow box on focus reveal, sliding the clipped tail zone into view),autofocuson the dialog soshowModal()doesn't focus the close button and fight the snap scroll, anddismiss()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="auto"+ CSS anchor positioning with a macOS-style tail pointing at the trigger.overflow: visible(the UA clips[popover]withoverflow: auto), elevated surface a notch above--color-background, width as a viewport-clamped prop.anchor-nameon the header trigger, and a guard so a click on the trigger closes the popover rather than reopening what light dismiss just closed.Verified
🤖 Generated with Claude Code
https://claude.ai/code/session_013uXvqNHddM9rCCR3NK7Yax