diff --git a/apps/content/blume.config.ts b/apps/content/blume.config.ts index 5979429f9..1e63d3306 100644 --- a/apps/content/blume.config.ts +++ b/apps/content/blume.config.ts @@ -164,12 +164,12 @@ export default defineConfig({ }, }, { - // Keeps mobile twoslash popups inside the viewport (theme.css anchors - // them below the hovered token; this nudges bottom-of-screen ones up). - name: 'twoslash-mobile', + // Anchors twoslash popups next to their token and keeps them inside + // the viewport (theme.css fixes them so they escape the code scroller). + name: 'twoslash-popups', hooks: { 'astro:config:setup': ({ injectScript }) => { - const clientPath = fileURLToPath(new URL('./components/blume/twoslash-mobile.ts', import.meta.url)) + const clientPath = fileURLToPath(new URL('./components/blume/twoslash-popups.ts', import.meta.url)) injectScript('page', `import '${clientPath.replaceAll('\\', '\\\\').replaceAll('\'', '\\\'')}'`) }, }, diff --git a/apps/content/components/blume/twoslash-mobile.ts b/apps/content/components/blume/twoslash-popups.ts similarity index 53% rename from apps/content/components/blume/twoslash-mobile.ts rename to apps/content/components/blume/twoslash-popups.ts index 88994730e..d61dd2518 100644 --- a/apps/content/components/blume/twoslash-mobile.ts +++ b/apps/content/components/blume/twoslash-popups.ts @@ -1,18 +1,21 @@ -// Twoslash popups need four behaviors CSS :hover can't provide alone: +// Twoslash popups need three behaviors CSS :hover can't provide alone: // -// 1. Mobile placement — theme.css makes popups fixed and readable-width; -// this anchors each one just below its hovered or tapped token (or above -// it when the token sits near the bottom of the screen), left-aligned -// with the token as far as the viewport allows. The popup's own height -// is never measured — the available space becomes its max-height and -// longer signatures scroll internally — so late reflows of the nested -// code signature can't push it offscreen. Fixed popups don't follow the -// page, so the visible one is dismissed as soon as scrolling starts — -// composited touch scrolling moves the page before scroll events reach -// the main thread, so JS re-anchoring always trails the finger visibly. -// Placement is only written on reveal, never proactively cleared: -// clearing eagerly (outside tap, touch pointerout) made the -// still-visible popup flash at its fallback position. +// 1. Placement — theme.css makes popups fixed (so they escape the code +// block's horizontal scroller) and readable-width; this anchors each one +// just below its hovered or tapped token (or above it when the token +// sits near the bottom of the screen), left-aligned with the token as +// far as the viewport allows. The popup's own height is never measured +// — the available space becomes its max-height and longer signatures +// scroll internally — so late reflows of the nested code signature +// can't push it offscreen. Fixed popups don't follow the page: with a +// mouse the open one is re-anchored on scroll (wheel steps are small +// and the one-frame lag reads as the popup following its token), while +// touch scrolling dismisses it instead — composited touch scrolling +// moves the page before scroll events reach the main thread, so JS +// re-anchoring always trails the finger visibly. Placement is only +// written on reveal and scroll, never proactively cleared: clearing +// eagerly (outside tap, touch pointerout) made the still-visible popup +// flash at its fallback position. // // 2. Hover bridge — the popup sits GAP px away from the token, and :hover // alone closes it the instant the pointer enters that gap. The popup @@ -23,16 +26,9 @@ // crosses, at any speed — no timers. It must never reach into the // token's own line nor stretch wider than the popup, or it traps // sideways movement between neighboring tokens, holding the old popup -// open instead of switching. Desktop widths bridge with a CSS ::before -// on the popup instead (theme.css). +// open instead of switching. // -// 3. Desktop flip — desktop popups drop below the token via CSS alone, -// so one near the viewport bottom runs offscreen. CSS can't see the -// viewport, so reveal() measures and toggles FLIP_CLASS; theme.css -// mirrors the below-placement geometry above the token (popup and -// bridge both). -// -// 4. Tap pin — touch has no hover, and sticky :hover emulation on tap is +// 3. Tap pin — touch has no hover, and sticky :hover emulation on tap is // unreliable across mobile browsers. A tap pins the popup open via // OPEN_CLASS (mirrored to the :hover reveal in theme.css); tapping // anywhere else unpins it. Pins react to click, not pointerdown: a @@ -40,18 +36,12 @@ // only a completed tap produces a click — so dragging across code // doesn't spawn popups. Mouse clicks are left alone so selecting code // text doesn't pin popups. -const MOBILE = window.matchMedia('(max-width: 48rem)') const EDGE = 8 const GAP = 6 const MIN_SPACE = 160 const OPEN_CLASS = 'twoslash-open' -const BRIDGE_CLASS = 'twoslash-mobile-bridge' +const BRIDGE_CLASS = 'twoslash-bridge' const DISMISSED_CLASS = 'twoslash-dismissed' -const FLIP_CLASS = 'twoslash-flip' -// Desktop gap between token and popup (--twoslash-gap in theme.css) plus -// breathing room, so a popup that would only just graze the viewport -// bottom still flips. -const FLIP_GAP = 16 function popupFor(target: EventTarget | null): { hover: Element, popup: HTMLElement } | null { const element = target instanceof Element ? target : null @@ -80,7 +70,7 @@ function place(hover: Element, popup: HTMLElement): void { hover.classList.remove(DISMISSED_CLASS) // Width is only measurable while the popup is displayed. Reveals always - // precede placement (mouse pointerover implies :hover; taps pin before + // precede placement (pointerover implies :hover; taps pin before // placing), so an unmeasurable popup is a transient pre-reveal event — // skip it, the next event re-places. const width = popup.getBoundingClientRect().width @@ -112,48 +102,10 @@ function place(hover: Element, popup: HTMLElement): void { placed = { hover, popup } } -// The desktop flip side is decided once per open, not on every pointerover -// — pointer movement inside an open popup re-fires pointerover constantly, -// and geometry can't change while the pointer holds the popup open. -let flipDecidedFor: Element | null = null - function reveal(target: EventTarget | null): void { const found = popupFor(target) - if (!found) { - flipDecidedFor = null - return - } - if (MOBILE.matches) { + if (found) { place(found.hover, found.popup) - return - } - if (found.popup.style.top) { - // Drop placement left over from a mobile-width session so the - // absolute-positioned popup anchors normally; desktop bridges with a - // CSS ::before, so the strip goes too. - for (const prop of ['top', 'bottom', 'maxHeight', 'marginTop', 'left', 'transform'] as const) { - found.popup.style[prop] = '' - } - found.hover.querySelector(`:scope > .${BRIDGE_CLASS}`)?.remove() - placed = null - } - if (found.hover === flipDecidedFor) { - return - } - - // Flip above a token too close to the viewport bottom for the popup to - // fit under it, when above actually fits more. Height is only - // measurable while the popup is displayed (same transient pre-reveal - // case as place()); keep the previous side rather than guessing. - const height = found.popup.getBoundingClientRect().height - if (height) { - const rect = found.hover.getBoundingClientRect() - const spaceBelow = window.innerHeight - rect.bottom - EDGE - found.hover.classList.toggle( - FLIP_CLASS, - spaceBelow < height + FLIP_GAP && rect.top - EDGE > spaceBelow, - ) - flipDecidedFor = found.hover } } @@ -171,10 +123,13 @@ function setOpen(hover: Element | null): void { // Hover shows the popup via CSS :hover (the bridge keeps the chain alive // on the way in); completed taps pin it via OPEN_CLASS — see the header on // why click, not pointerdown. Pinning happens before placing so place() -// can measure the freshly displayed popup. click carries no pointerType in -// every browser, so the preceding pointerdown's type stands in for it. +// can measure the freshly displayed popup. click and scroll carry no +// pointerType, so the type of the latest pointer event stands in for it. let lastPointerType = 'mouse' -document.addEventListener('pointerover', event => reveal(event.target)) +document.addEventListener('pointerover', (event) => { + lastPointerType = event.pointerType + reveal(event.target) +}) document.addEventListener('pointerdown', (event) => { lastPointerType = event.pointerType }) @@ -189,19 +144,23 @@ document.addEventListener('click', (event) => { } }) -// Dismiss the placed popup on the first scroll (capture catches every -// scroller, including the code block's own) — see the header on why it -// can't follow instead. DISMISSED_CLASS hides the popup and bridge even -// where a sticky tap-:hover would keep the CSS reveal alive; the next -// place() lifts it. Scrolling inside the popup itself is the one scroller -// reading depends on, so it never dismisses. +// Follow (mouse) or dismiss (touch) the placed popup on scroll (capture +// catches every scroller, including the code block's own) — see the +// header on why touch can't follow. DISMISSED_CLASS hides the popup and +// bridge even where a sticky tap-:hover would keep the CSS reveal alive; +// the next place() lifts it. Scrolling inside the popup itself is the one +// scroller reading depends on, so it never dismisses. document.addEventListener('scroll', (event) => { - if (!placed || !MOBILE.matches) { + if (!placed) { return } if (event.target instanceof Node && placed.popup.contains(event.target)) { return } + if (lastPointerType === 'mouse') { + place(placed.hover, placed.popup) + return + } setOpen(null) placed.hover.classList.add(DISMISSED_CLASS) placed = null diff --git a/apps/content/docs/client/client-side.mdx b/apps/content/docs/client/client-side.mdx index d9b8b1b77..3091489d1 100644 --- a/apps/content/docs/client/client-side.mdx +++ b/apps/content/docs/client/client-side.mdx @@ -50,6 +50,10 @@ const pong = await client.ping() client.ping // ^| + +// + +// ``` ## Client Context diff --git a/apps/content/docs/contract/implementation.mdx b/apps/content/docs/contract/implementation.mdx index 45e285c5c..43e7629a7 100644 --- a/apps/content/docs/contract/implementation.mdx +++ b/apps/content/docs/contract/implementation.mdx @@ -22,6 +22,7 @@ implementer.planet.list // +// // // ``` diff --git a/apps/content/theme.css b/apps/content/theme.css index 421d8e72c..c108a802a 100644 --- a/apps/content/theme.css +++ b/apps/content/theme.css @@ -149,11 +149,11 @@ layout, stretching the page sideways and adding thousands of pixels of phantom height below the content on narrow viewports. Keep them out of layout entirely until their trigger is hovered. */ -.prose .twoslash-hover:not(:hover):not(.twoslash-open) :is(.twoslash-popup-container, .twoslash-mobile-bridge) { +.prose .twoslash-hover:not(:hover):not(.twoslash-open) :is(.twoslash-popup-container, .twoslash-bridge) { display: none; } -/* Touch has no hover: twoslash-mobile.ts pins .twoslash-open on the +/* Touch has no hover: twoslash-popups.ts pins .twoslash-open on the tapped token (cleared by tapping elsewhere). Mirror the :hover reveal the shiki stylesheet keys on (opacity 0 / pointer-events none otherwise). */ @@ -162,17 +162,76 @@ pointer-events: auto; } +/* Touch scrolling dismisses the placed popup (twoslash-popups.ts sets + .twoslash-dismissed, the next placement lifts it). display, not + opacity: the popup must also disappear in browsers whose sticky + tap-:hover would otherwise keep the reveal alive, and the bridge + must go with it so the stale fixed strip can't swallow a tap. */ +.prose .twoslash-hover.twoslash-dismissed :is(.twoslash-popup-container, .twoslash-bridge) { + display: none; +} + main#blume-content { overflow-x: clip; } +/* Blume keeps twoslash blocks `overflow: visible` so absolutely positioned + popups can escape them, which leaves long lines nowhere to go: Blume only + wraps them below 640px, so on wider screens they run straight past the + block. The popups here are fixed instead (below) and escape any scroller, + so twoslash code scrolls horizontally like every other block. Scrolling + the inner code keeps the header bar and copy button static, matching + Blume's own pattern for regular blocks, which also carry the horizontal + padding on the code element. */ +.prose pre.twoslash { + padding-left: 0; + padding-right: 0; +} + +.prose pre.twoslash > code { + display: block; + overflow-x: auto; + white-space: pre; + overflow-wrap: normal; + padding: 0 1.25rem 0.375rem; + scrollbar-color: var(--blume-border) transparent; + scrollbar-width: thin; +} + +/* Fixed positioning escapes the code scroller above, and sizes the popup + readably: anchored to a tiny inline token, an absolute popup squeezes + into an unreadable sliver over the code on narrow screens. + twoslash-popups.ts anchors it next to the token on reveal (below it, or + above when the token sits near the bottom of the screen) and bridges the + gap with a real element so the pointer can cross into the popup; the + viewport centering here is only the first-frame fallback until the + script can measure the popup's width. */ .prose .twoslash-popup-container { + position: fixed; + inset: auto auto auto 50%; + /* Replaces twoslash's transform: translateY(1.1em) — the margin below + keeps the fixed math exact — and centers horizontally. Centering + must live on transform, not the translate property: the production + build lowers translate into transform, which would survive the + script's inline override and drag the placed popup half a width + off-screen. */ + transform: translateX(-50%); + z-index: 60; + margin: 0.375rem 0 0; /* max-content, not shrink-to-fit: sized against the tiny token span, shrink-to-fit collapses a wrappable popup to its narrowest content. With the cap as max-width, only signatures that genuinely exceed it wrap, at the full cap width. */ width: max-content; - max-width: min(85vw, 34rem); + max-width: min(100vw - 1.5rem, 34rem); + /* Oversized popups scroll internally; the script tightens the cap to the + space actually left beside the token. */ + max-height: 45dvh; + overflow-y: auto; + scrollbar-color: var(--blume-border) transparent; + scrollbar-width: thin; + border-radius: 0.5rem; + box-shadow: 0 8px 30px rgb(0 0 0 / 0.25); } /* Wrap popup code: signatures emitted as one long line would otherwise @@ -182,124 +241,6 @@ main#blume-content { overflow-wrap: break-word; } -/* Desktop: push the popup down from twoslash's translateY(1.1em) (nearly - on top of the token) and bridge the resulting hover gap with a ::before - strip, so the pointer can reach the popup without leaving the :hover - target — leaving it hides the popup instantly via the display:none rule - above. Scoped to .twoslash-hover so persisted-query popups keep their - own offset; on mobile widths twoslash-mobile.ts bridges with a real - element instead (the popup scrolls internally there, which would clip a - ::before hanging outside its box). */ -@media (width > 48rem) { - .prose .twoslash-hover .twoslash-popup-container { - /* The dead gap the 2em shift leaves between the code line and the - popup (~0.54em); the bridge and flip rules below must all agree on - it, or the hover seam reopens. */ - --twoslash-gap: 0.55em; - - transform: translateY(2em); - /* Cap oversized popups; the internal scrolling lives on the children - below. */ - max-height: min(45vh, 28rem); - } - - .prose .twoslash-hover .twoslash-popup-container::before { - content: ''; - position: absolute; - /* The gap plus 1px into the line's lower half-leading so rounding - can't reopen a seam. Any taller and it overlaps the code line, - trapping sideways movement between neighboring hover tokens. */ - top: calc(-1px - var(--twoslash-gap)); - left: 0; - right: 0; - height: calc(var(--twoslash-gap) + 1px); - } - - /* Flip: twoslash-mobile.ts sets .twoslash-flip on the hover when the - popup can't fit under a token near the viewport bottom. Anchor the - popup the same gap above the token (the hover span is position: - relative) and hang the bridge off its bottom edge instead, 1px into - the line's upper half-leading — same seam logic as below. */ - .prose .twoslash-hover.twoslash-flip .twoslash-popup-container { - top: auto; - bottom: calc(100% + var(--twoslash-gap)); - transform: none; - } - - .prose .twoslash-hover.twoslash-flip .twoslash-popup-container::before { - top: auto; - bottom: calc(-1px - var(--twoslash-gap)); - } - - /* The internal scrolling for the max-height cap above. It must live on - the flex children, not the container: the container anchors two - elements hanging outside its box — the ::before hover bridge and the - popup arrow — and container-level overflow would clip both, closing - the popup the moment the pointer crosses the gap. */ - .prose .twoslash-hover .twoslash-popup-container > * { - min-height: 0; - overflow-y: auto; - scrollbar-color: var(--blume-border) transparent; - scrollbar-width: thin; - } -} - -/* On narrow screens the popup is anchored to a tiny inline token and - squeezes into an unreadable sliver over the code. Fix it and size it - readably instead — fixed positioning escapes the code scroller below. - twoslash-mobile.ts anchors it next to the token on reveal; the - viewport centering here is only the first-frame fallback until the - script can measure the popup's width. */ -@media (max-width: 48rem) { - /* Scrolling dismisses the placed popup (twoslash-mobile.ts sets - .twoslash-dismissed, the next placement lifts it). display, not - opacity: the popup must also disappear in browsers whose sticky - tap-:hover would otherwise keep the reveal alive, and the bridge - must go with it so the stale fixed strip can't swallow a tap. */ - .prose .twoslash-hover.twoslash-dismissed :is(.twoslash-popup-container, .twoslash-mobile-bridge) { - display: none; - } - - .prose .twoslash-popup-container { - position: fixed; - inset: auto auto auto 50%; - /* Replaces twoslash's transform: translateY(1.1em) — the margin below - keeps the fixed math exact — and centers horizontally. Centering - must live on transform, not the translate property: the production - build lowers translate into transform, which would survive the - script's inline override and drag the placed popup half a width - off-screen. */ - transform: translateX(-50%); - z-index: 60; - margin: 0.375rem 0 0; - max-width: calc(100vw - 1.5rem); - max-height: 45dvh; - overflow-y: auto; - border-radius: 0.5rem; - box-shadow: 0 8px 30px rgb(0 0 0 / 0.25); - } - - /* Blume wraps twoslash lines on mobile because its anchored popups - can't escape a scroll container — the fixed bottom sheet above can, - so twoslash code scrolls horizontally like every other block - (scrolling the inner code keeps the header bar and copy button - static, matching Blume's own pattern). */ - .prose pre.twoslash { - padding-left: 0; - padding-right: 0; - } - - .prose pre.twoslash > code { - display: block; - overflow-x: auto; - white-space: pre; - overflow-wrap: normal; - padding: 0 1.25rem 0.375rem; - scrollbar-color: var(--blume-border) transparent; - scrollbar-width: thin; - } -} - /* ---------------------------------------------------------------- */ /* Non-owned chrome */ /* ---------------------------------------------------------------- */