From 9f2512895c48838f832482cb49fb048716fe298e Mon Sep 17 00:00:00 2001 From: Dinh Le Date: Fri, 4 Sep 2026 10:15:40 +0700 Subject: [PATCH] chore(content): scroll long twoslash lines instead of overflowing the code block Blume keeps twoslash blocks overflow: visible so absolutely positioned hover popups can escape them, and only wraps long lines below 640px, so on wider screens long comment lines ran past the code frame. Popups are now fixed-positioned at every width (previously only on mobile) and placed by the client script, so twoslash code can scroll horizontally like every other block. Two completion examples gain spacer lines so their lists stay inside the new scroller. --- apps/content/blume.config.ts | 8 +- ...{twoslash-mobile.ts => twoslash-popups.ts} | 119 ++++-------- apps/content/docs/client/client-side.mdx | 4 + apps/content/docs/contract/implementation.mdx | 1 + apps/content/theme.css | 183 ++++++------------ 5 files changed, 110 insertions(+), 205 deletions(-) rename apps/content/components/blume/{twoslash-mobile.ts => twoslash-popups.ts} (53%) 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 */ /* ---------------------------------------------------------------- */