diff --git a/docs/app/docs-infra/components/code-highlighter/demos/CodeContent.module.css b/docs/app/docs-infra/components/code-highlighter/demos/CodeContent.module.css index 0023c8da9..44bb74849 100644 --- a/docs/app/docs-infra/components/code-highlighter/demos/CodeContent.module.css +++ b/docs/app/docs-infra/components/code-highlighter/demos/CodeContent.module.css @@ -135,6 +135,27 @@ padding: 0 6px; } +/* Only strong lines bordering a regular highlight line need to stack above + it (so the regular line's extended background sits underneath the strong + line's rounded corners). */ +.codeBlock :global(.line[data-hl='strong'][data-hl-position='single']), +.codeBlock :global(.line[data-hl='strong'][data-hl-position='end']) { + position: relative; + z-index: 1; +} + +/* Visually merge a regular highlighted line into an adjacent strong block by + extending its background through the gap between lines. */ +.codeBlock :global(.line[data-hl='']:has(+ .line[data-hl='strong'])) { + padding-bottom: 6px; + margin-bottom: -6px; +} + +.codeBlock :global(.line[data-hl='strong'] + .line[data-hl='']) { + padding-top: 6px; + margin-top: -6px; +} + .codeBlock :global(.line[data-hl-position='single']) { border-radius: 8px; } diff --git a/docs/app/docs-infra/components/code-highlighter/demos/DemoContent.module.css b/docs/app/docs-infra/components/code-highlighter/demos/DemoContent.module.css index 4eea4dc41..8179475dc 100644 --- a/docs/app/docs-infra/components/code-highlighter/demos/DemoContent.module.css +++ b/docs/app/docs-infra/components/code-highlighter/demos/DemoContent.module.css @@ -128,6 +128,27 @@ padding: 0 6px; } +/* Only strong lines bordering a regular highlight line need to stack above + it (so the regular line's extended background sits underneath the strong + line's rounded corners). */ +.codeBlock :global(.line[data-hl='strong'][data-hl-position='single']), +.codeBlock :global(.line[data-hl='strong'][data-hl-position='end']) { + position: relative; + z-index: 1; +} + +/* Visually merge a regular highlighted line into an adjacent strong block by + extending its background through the gap between lines. */ +.codeBlock :global(.line[data-hl='']:has(+ .line[data-hl='strong'])) { + padding-bottom: 6px; + margin-bottom: -6px; +} + +.codeBlock :global(.line[data-hl='strong'] + .line[data-hl='']) { + padding-top: 6px; + margin-top: -6px; +} + .codeBlock :global(.line[data-hl-position='single']) { border-radius: 8px; } diff --git a/docs/app/docs-infra/pipeline/enhance-code-emphasis/demos/CollapsibleContent.module.css b/docs/app/docs-infra/pipeline/enhance-code-emphasis/demos/CollapsibleContent.module.css index dce1cc600..e97544a80 100644 --- a/docs/app/docs-infra/pipeline/enhance-code-emphasis/demos/CollapsibleContent.module.css +++ b/docs/app/docs-infra/pipeline/enhance-code-emphasis/demos/CollapsibleContent.module.css @@ -37,7 +37,7 @@ } /* Only show the toggle when the code block has collapsible frames */ -.code:has([data-collapsible]) ~ .toggle { +.code:has(> pre > code[data-collapsible]) ~ .toggle { display: block; } @@ -70,19 +70,36 @@ pre.codeBlock { --code-padding-bottom: 6px; --scrollbar-gutter-size: 15px; padding: var(--code-padding-bottom) 0; +} + +/* Only collapsible code blocks disable horizontal scroll so the fade overlay + can clip cleanly; non-collapsible blocks keep the layout's default + `overflow-x: auto`. The `>` combinator and `code` tag qualifier keep the + `:has()` scan limited to a single direct child. */ +pre.codeBlock:has(> code[data-collapsible]) { overflow-x: hidden; } /* Fade overlay at the bottom of truncated code blocks. - Uses ::after + transform so the animation is GPU-accelerated. - The pre's computed overflow clips the translated pseudo-element. */ -pre.codeBlock:has(:global(.frame[data-frame-truncated='visible'])) { - position: relative; + Anchored to the non-scrolling `.code` wrapper rather than the `pre` + itself: an absolutely positioned overlay inside the horizontally- + scrolling `pre` would scroll with its content, leaving a gap on the + right when scrolled. Anchoring to the wrapper pins `right: 0` to the + visible viewport edge. + Uses ::after + transform so the animation is GPU-accelerated. The + wrapper's `overflow-y: clip` clips the translated pseudo-element. + The explicit `> pre.codeBlock > code > .frame` child path bounds the + `:has()` scan to a fixed depth instead of walking the whole subtree. */ +pre.codeBlock:has(> code > :global(.frame[data-frame-truncated='visible'])) { padding-bottom: 0; +} + +.code:has(> pre.codeBlock > code > :global(.frame[data-frame-truncated='visible'])) { + position: relative; overflow-y: clip; } -pre.codeBlock:has(:global(.frame[data-frame-truncated='visible']))::after { +.code:has(> pre.codeBlock > code > :global(.frame[data-frame-truncated='visible']))::after { content: ''; position: absolute; bottom: 0; @@ -95,14 +112,13 @@ pre.codeBlock:has(:global(.frame[data-frame-truncated='visible']))::after { } .container:has(.checkbox:checked) - .code - pre.codeBlock:has(:global(.frame[data-frame-truncated='visible'])) { + .code:has(> pre.codeBlock > code > :global(.frame[data-frame-truncated='visible'])) + > pre.codeBlock { padding-bottom: var(--code-padding-bottom); } .container:has(.checkbox:checked) - .code - pre.codeBlock:has(:global(.frame[data-frame-truncated='visible']))::after { + .code:has(> pre.codeBlock > code > :global(.frame[data-frame-truncated='visible']))::after { transform: translateY(100%); } @@ -202,6 +218,27 @@ pre.codeBlock[data-scrollbar-gutter='expand-to'] :global(code) { padding: 0 6px; } +/* Only strong lines bordering a regular highlight line need to stack above + it (so the regular line's extended background sits underneath the strong + line's rounded corners). */ +.codeBlock :global(.line[data-hl='strong'][data-hl-position='single']), +.codeBlock :global(.line[data-hl='strong'][data-hl-position='end']) { + position: relative; + z-index: 1; +} + +/* Visually merge a regular highlighted line into an adjacent strong block by + extending its background through the gap between lines. */ +.codeBlock :global(.line[data-hl='']:has(+ .line[data-hl='strong'])) { + padding-bottom: 6px; + margin-bottom: -6px; +} + +.codeBlock :global(.line[data-hl='strong'] + .line[data-hl='']) { + padding-top: 6px; + margin-top: -6px; +} + .codeBlock :global(.line[data-hl-position='single']) { border-radius: 8px; } diff --git a/docs/app/docs-infra/pipeline/enhance-code-emphasis/demos/CollapsibleDemoContent.module.css b/docs/app/docs-infra/pipeline/enhance-code-emphasis/demos/CollapsibleDemoContent.module.css index b80cac07d..9b2b42335 100644 --- a/docs/app/docs-infra/pipeline/enhance-code-emphasis/demos/CollapsibleDemoContent.module.css +++ b/docs/app/docs-infra/pipeline/enhance-code-emphasis/demos/CollapsibleDemoContent.module.css @@ -93,7 +93,7 @@ } /* Only show the toggle when the code block has collapsible frames */ -.code:has([data-collapsible]) ~ .toggle { +.code:has(> pre > code[data-collapsible]) ~ .toggle { display: block; } @@ -126,20 +126,36 @@ pre.codeBlock { --code-padding-bottom: 6px; --scrollbar-gutter-size: 15px; padding: var(--code-padding-bottom) 0; +} + +/* Only collapsible code blocks disable horizontal scroll so the fade overlay + can clip cleanly; non-collapsible blocks keep the layout's default + `overflow-x: auto`. The `>` combinator and `code` tag qualifier keep the + `:has()` scan limited to a single direct child. */ +pre.codeBlock:has(> code[data-collapsible]) { overflow-x: hidden; } /* Fade overlay at the bottom of truncated code blocks. - Uses ::after + transform so the animation is GPU-accelerated. - The pre's computed overflow (hidden/auto) clips the translated - pseudo-element without needing an explicit overflow: clip. */ -pre.codeBlock:has(:global(.frame[data-frame-truncated='visible'])) { - position: relative; + Anchored to the non-scrolling `.code` wrapper rather than the `pre` + itself: an absolutely positioned overlay inside the horizontally- + scrolling `pre` would scroll with its content, leaving a gap on the + right when scrolled. Anchoring to the wrapper pins `right: 0` to the + visible viewport edge. + Uses ::after + transform so the animation is GPU-accelerated. The + wrapper's `overflow-y: clip` clips the translated pseudo-element. + The explicit `> pre.codeBlock > code > .frame` child path bounds the + `:has()` scan to a fixed depth instead of walking the whole subtree. */ +pre.codeBlock:has(> code > :global(.frame[data-frame-truncated='visible'])) { padding-bottom: 0; +} + +.code:has(> pre.codeBlock > code > :global(.frame[data-frame-truncated='visible'])) { + position: relative; overflow-y: clip; } -pre.codeBlock:has(:global(.frame[data-frame-truncated='visible']))::after { +.code:has(> pre.codeBlock > code > :global(.frame[data-frame-truncated='visible']))::after { content: ''; position: absolute; bottom: 0; @@ -152,14 +168,13 @@ pre.codeBlock:has(:global(.frame[data-frame-truncated='visible']))::after { } .codeSection:has(.checkbox:checked) - .code - pre.codeBlock:has(:global(.frame[data-frame-truncated='visible'])) { + .code:has(> pre.codeBlock > code > :global(.frame[data-frame-truncated='visible'])) + > pre.codeBlock { padding-bottom: var(--code-padding-bottom); } .codeSection:has(.checkbox:checked) - .code - pre.codeBlock:has(:global(.frame[data-frame-truncated='visible']))::after { + .code:has(> pre.codeBlock > code > :global(.frame[data-frame-truncated='visible']))::after { transform: translateY(100%); } @@ -267,6 +282,27 @@ pre.codeBlock[data-scrollbar-gutter='expand-to'] :global(code) { padding: 0 6px; } +/* Only strong lines bordering a regular highlight line need to stack above + it (so the regular line's extended background sits underneath the strong + line's rounded corners). */ +.codeBlock :global(.line[data-hl='strong'][data-hl-position='single']), +.codeBlock :global(.line[data-hl='strong'][data-hl-position='end']) { + position: relative; + z-index: 1; +} + +/* Visually merge a regular highlighted line into an adjacent strong block by + extending its background through the gap between lines. */ +.codeBlock :global(.line[data-hl='']:has(+ .line[data-hl='strong'])) { + padding-bottom: 6px; + margin-bottom: -6px; +} + +.codeBlock :global(.line[data-hl='strong'] + .line[data-hl='']) { + padding-top: 6px; + margin-top: -6px; +} + .codeBlock :global(.line[data-hl-position='single']) { border-radius: 8px; } diff --git a/docs/app/docs-infra/pipeline/enhance-code-emphasis/demos/IndentContent.module.css b/docs/app/docs-infra/pipeline/enhance-code-emphasis/demos/IndentContent.module.css index 9995c7e52..8ff0e59da 100644 --- a/docs/app/docs-infra/pipeline/enhance-code-emphasis/demos/IndentContent.module.css +++ b/docs/app/docs-infra/pipeline/enhance-code-emphasis/demos/IndentContent.module.css @@ -23,7 +23,7 @@ } /* Only show the toggle when the code block has collapsible frames */ -.code:has([data-collapsible]) + .toggle { +.code:has(> pre > code[data-collapsible]) + .toggle { display: block; } @@ -35,19 +35,36 @@ pre.codeBlock { margin: 0; padding: 6px 0; +} + +/* Only collapsible code blocks disable horizontal scroll so the fade overlay + can clip cleanly; non-collapsible blocks keep the layout's default + `overflow-x: auto`. The `>` combinator and `code` tag qualifier keep the + `:has()` scan limited to a single direct child. */ +pre.codeBlock:has(> code[data-collapsible]) { overflow-x: hidden; } /* Fade overlay at the bottom of truncated code blocks. - Uses ::after + transform so the animation is GPU-accelerated. - The pre's computed overflow clips the translated pseudo-element. */ -pre.codeBlock:has(:global(.frame[data-frame-truncated='visible'])) { - position: relative; + Anchored to the non-scrolling `.code` wrapper rather than the `pre` + itself: an absolutely positioned overlay inside the horizontally- + scrolling `pre` would scroll with its content, leaving a gap on the + right when scrolled. Anchoring to the wrapper pins `right: 0` to the + visible viewport edge. + Uses ::after + transform so the animation is GPU-accelerated. The + wrapper's `overflow-y: clip` clips the translated pseudo-element. + The explicit `> pre.codeBlock > code > .frame` child path bounds the + `:has()` scan to a fixed depth instead of walking the whole subtree. */ +pre.codeBlock:has(> code > :global(.frame[data-frame-truncated='visible'])) { padding-bottom: 0; +} + +.code:has(> pre.codeBlock > code > :global(.frame[data-frame-truncated='visible'])) { + position: relative; overflow-y: clip; } -pre.codeBlock:has(:global(.frame[data-frame-truncated='visible']))::after { +.code:has(> pre.codeBlock > code > :global(.frame[data-frame-truncated='visible']))::after { content: ''; position: absolute; bottom: 0; @@ -59,11 +76,14 @@ pre.codeBlock:has(:global(.frame[data-frame-truncated='visible']))::after { pointer-events: none; } -.expanded pre.codeBlock:has(:global(.frame[data-frame-truncated='visible'])) { +.expanded.code:has(> pre.codeBlock > code > :global(.frame[data-frame-truncated='visible'])) + > pre.codeBlock { padding-bottom: 6px; } -.expanded pre.codeBlock:has(:global(.frame[data-frame-truncated='visible']))::after { +.expanded.code:has( + > pre.codeBlock > code > :global(.frame[data-frame-truncated='visible']) + )::after { transform: translateY(100%); } @@ -120,6 +140,27 @@ pre.codeBlock:has(:global(.frame[data-frame-truncated='visible']))::after { padding: 0 6px; } +/* Only strong lines bordering a regular highlight line need to stack above + it (so the regular line's extended background sits underneath the strong + line's rounded corners). */ +.codeBlock :global(.line[data-hl='strong'][data-hl-position='single']), +.codeBlock :global(.line[data-hl='strong'][data-hl-position='end']) { + position: relative; + z-index: 1; +} + +/* Visually merge a regular highlighted line into an adjacent strong block by + extending its background through the gap between lines. */ +.codeBlock :global(.line[data-hl='']:has(+ .line[data-hl='strong'])) { + padding-bottom: 6px; + margin-bottom: -6px; +} + +.codeBlock :global(.line[data-hl='strong'] + .line[data-hl='']) { + padding-top: 6px; + margin-top: -6px; +} + .codeBlock :global(.line[data-hl-position='single']) { border-radius: 8px; } diff --git a/docs/app/docs-infra/pipeline/enhance-code-emphasis/demos/useScrollAnchor.ts b/docs/app/docs-infra/pipeline/enhance-code-emphasis/demos/useScrollAnchor.ts index 2fd7aa9db..09851eb10 100644 --- a/docs/app/docs-infra/pipeline/enhance-code-emphasis/demos/useScrollAnchor.ts +++ b/docs/app/docs-infra/pipeline/enhance-code-emphasis/demos/useScrollAnchor.ts @@ -28,7 +28,110 @@ function getTransitionTimeout(direction: 'collapse' | 'expand'): number { } const GUTTER_STATE_ATTRIBUTE = 'data-scrollbar-gutter'; -const gutterCleanupTimers = new WeakMap>(); +const gutterCleanupTimers = new WeakMap | Animation>(); +const gutterFlipTimers = new WeakMap>(); +const scrollbackAnimations = new WeakMap(); + +const prefersReducedMotion = () => + typeof window !== 'undefined' && + window.matchMedia?.('(prefers-reduced-motion: reduce)').matches === true; + +/** + * Schedules `callback` to run after `duration` ms on the browser's animation + * timeline (via a no-op WAAPI animation), so DevTools' animation speed slider + * scales the delay in step with CSS transitions. Falls back to `setTimeout` + * when WAAPI isn't available. + * + * Returns the `Animation` (or timer id) so callers can cancel it. Cancelling + * the returned `Animation` does NOT invoke `callback` (the rejected + * `finished` promise is swallowed), matching `clearTimeout` semantics. + */ +function scheduleOnAnimationTimeline( + target: HTMLElement, + duration: number, + callback: () => void, +): Animation | ReturnType { + if (typeof target.animate === 'function') { + const anim = target.animate([{ opacity: 1 }, { opacity: 1 }], { duration, fill: 'none' }); + anim.finished.then(callback, () => { + // Swallow rejection from `Animation.cancel()` so cancelling the schedule + // doesn't fire the cleanup callback (which would otherwise stomp on a + // freshly-registered next-state cleanup). + }); + return anim; + } + return setTimeout(callback, duration); +} + +function cancelScheduled(handle: Animation | ReturnType | undefined) { + if (handle === undefined) { + return; + } + // Guard the `instanceof` so we don't throw a `ReferenceError` in browsers + // that lack WAAPI (where `scheduleOnAnimationTimeline` falls back to + // `setTimeout` and `Animation` is undefined as a global). + if (typeof Animation !== 'undefined' && handle instanceof Animation) { + handle.cancel(); + } else { + clearTimeout(handle as ReturnType); + } +} + +/** + * Smoothly slides the `` element back to the left edge over `duration` + * ms using an ease-out cubic via the Web Animations API. + * + * Used during collapse instead of tweening `pre.scrollLeft` because the + * scrollbar-gutter animation forces `overflow-x: hidden` on the pre, which + * snaps `scrollLeft` to 0 instantly. Animating a transform on the inner + * `code` element produces the same visual effect, isn't reset by the overflow + * change, and is naturally clipped by the pre's hidden overflow. Driving it + * through `Element.animate` keeps the styles off the element's `style` + * attribute and runs on the compositor, so it doesn't fight the existing + * CSS transitions on `code` (e.g. `margin-bottom`). + * + * Honors `prefers-reduced-motion` by snapping immediately. + */ +function smoothCollapseScrollLeft(pre: HTMLElement, duration: number): Animation | null { + const startLeft = pre.scrollLeft; + if (startLeft <= 0) { + return null; + } + const code = pre.querySelector('code'); + if (!code || typeof code.animate !== 'function') { + return null; + } + + // Cancel any leftover scroll-back animation from a previous toggle so we + // don't end up with two transforms competing on the same element. + scrollbackAnimations.get(pre)?.cancel(); + scrollbackAnimations.delete(pre); + + // Reset the actual scroll position now; the WAAPI animation visually + // compensates by translating the element from `-startLeft` back to `0`. + pre.scrollLeft = 0; + + if (prefersReducedMotion() || duration <= 0) { + return null; + } + + const anim = code.animate( + [{ transform: `translateX(${-startLeft}px)` }, { transform: 'translateX(0)' }], + { + duration, + easing: 'cubic-bezier(0, 0, 0.2, 1)', + fill: 'none', + }, + ); + scrollbackAnimations.set(pre, anim); + const onSettle = () => { + if (scrollbackAnimations.get(pre) === anim) { + scrollbackAnimations.delete(pre); + } + }; + anim.finished.then(onSettle, onSettle); + return anim; +} function isElementInViewport(element: HTMLElement): boolean { const rect = element.getBoundingClientRect(); @@ -48,14 +151,28 @@ function measureScrollbarHeight(pre: HTMLElement): number { } function clearGutterState(pre: HTMLElement) { - const existingTimer = gutterCleanupTimers.get(pre); - if (existingTimer !== undefined) { - clearTimeout(existingTimer); - gutterCleanupTimers.delete(pre); + cancelScheduled(gutterCleanupTimers.get(pre)); + gutterCleanupTimers.delete(pre); + const flipTimer = gutterFlipTimers.get(pre); + if (flipTimer !== undefined) { + clearTimeout(flipTimer); + gutterFlipTimers.delete(pre); } pre.removeAttribute(GUTTER_STATE_ATTRIBUTE); } +/** + * Cancels every animation and timer associated with `pre` (scroll-back + * transform, gutter cleanup, gutter from→to flip). Used on hook unmount so + * we don't leave WAAPI animations or pending callbacks pointing at a node + * that's been removed from the document. + */ +function cancelAllForPre(pre: HTMLElement) { + scrollbackAnimations.get(pre)?.cancel(); + scrollbackAnimations.delete(pre); + clearGutterState(pre); +} + /** * Smoothly transitions the horizontal scrollbar gutter on collapse by * swapping the real scrollbar for equivalent padding-bottom, then @@ -78,16 +195,22 @@ function animateScrollbarGutter(pre: HTMLElement) { clearGutterState(pre); pre.setAttribute(GUTTER_STATE_ATTRIBUTE, 'collapse-from'); - // Move into the transition state on the next macrotask. - setTimeout(() => { + // Move into the transition state on the next macrotask. Tracked so the + // flip can be cancelled if the component unmounts before it fires. + const flipTimer = setTimeout(() => { + gutterFlipTimers.delete(pre); pre.setAttribute(GUTTER_STATE_ATTRIBUTE, 'collapse-to'); }, 0); + gutterFlipTimers.set(pre, flipTimer); + // Schedule cleanup on the animation timeline so DevTools throttling + // scales it together with the CSS `margin-bottom` transition that's + // doing the actual gutter shrink. const timeout = getTransitionTimeout('collapse'); - const cleanupTimer = setTimeout(() => { + const cleanup = scheduleOnAnimationTimeline(pre, timeout + 30, () => { clearGutterState(pre); - }, timeout + 30); - gutterCleanupTimers.set(pre, cleanupTimer); + }); + gutterCleanupTimers.set(pre, cleanup); } /** @@ -115,28 +238,57 @@ function animateScrollbarGutterExpand(pre: HTMLElement) { clearGutterState(pre); pre.setAttribute(GUTTER_STATE_ATTRIBUTE, 'expand-from'); - // Move into the transition state on the next macrotask. - setTimeout(() => { + // Move into the transition state on the next macrotask. Tracked so the + // flip can be cancelled if the component unmounts before it fires. + const flipTimer = setTimeout(() => { + gutterFlipTimers.delete(pre); pre.setAttribute(GUTTER_STATE_ATTRIBUTE, 'expand-to'); }, 0); + gutterFlipTimers.set(pre, flipTimer); + // Schedule cleanup on the animation timeline so the `overflow-x` flip back + // to `auto` lines up with the CSS `margin-bottom` and height transitions + // even when DevTools throttles animation speed. const timeout = getTransitionTimeout('expand'); - const cleanupTimer = setTimeout(() => { + const cleanup = scheduleOnAnimationTimeline(pre, timeout + 30, () => { clearGutterState(pre); - }, timeout + 30); - gutterCleanupTimers.set(pre, cleanupTimer); + }); + gutterCleanupTimers.set(pre, cleanup); } export function useScrollAnchor() { const containerRef = React.useRef(null); const toggleRef = React.useRef(null); + // Tracks the cleanup for the currently in-flight `anchorScroll` call so a + // new toggle (or unmount) can abort it cleanly instead of leaving a + // ResizeObserver and window listeners holding references to detached + // nodes. + const activeSessionCleanupRef = React.useRef<(() => void) | null>(null); + // Tracks the most recently animated `
` so unmount can cancel any
+  // running scroll-back / gutter animations on it. Captured here because
+  // `containerRef.current` may already be null by the time the effect
+  // cleanup runs.
+  const lastPreRef = React.useRef(null);
 
   // CSS `overflow-anchor: none` on hidden frames (set in CSS) nudges native
   // scroll anchoring toward the visible highlighted/focus content. In Chromium
   // and Firefox this usually handles most compensation synchronously, while the
-  // rAF loop below smooths any remaining drift so the transition appears stable
-  // and visually "fixed" to the user. In browsers without native overflow-anchor
-  // support (e.g. Safari), the rAF loop is the primary compensation mechanism.
+  // ResizeObserver below smooths any remaining drift so the transition appears
+  // stable and visually "fixed" to the user. In browsers without native
+  // overflow-anchor support (e.g. Safari), the observer is the primary
+  // compensation mechanism.
+
+  React.useEffect(() => {
+    return () => {
+      activeSessionCleanupRef.current?.();
+      activeSessionCleanupRef.current = null;
+      const pre = lastPreRef.current;
+      if (pre) {
+        cancelAllForPre(pre);
+        lastPreRef.current = null;
+      }
+    };
+  }, []);
 
   const anchorScroll = React.useCallback((direction: 'collapse' | 'expand') => {
     const container = containerRef.current;
@@ -144,6 +296,11 @@ export function useScrollAnchor() {
       return;
     }
 
+    // Abort any in-flight session before starting a new one; otherwise the
+    // previous ResizeObserver and window listeners would race with this one.
+    activeSessionCleanupRef.current?.();
+    activeSessionCleanupRef.current = null;
+
     const primaryAnchor = container.querySelector(ANCHOR_SELECTOR);
     const toggleAnchor = toggleRef.current;
 
@@ -162,11 +319,28 @@ export function useScrollAnchor() {
     // scrollbar space can appear late and look like a snap.
     const pre = container.querySelector('pre');
     if (pre) {
+      lastPreRef.current = pre;
       if (direction === 'collapse') {
+        // Smoothly return horizontal scroll to the left edge so the focused
+        // region (which usually starts at column 0) is visible after collapse,
+        // and so the fade overlay isn't masking scrolled-away content. We
+        // animate via a transform on the inner `code` element rather than
+        // tweening `pre.scrollLeft`, because the gutter animation below sets
+        // `overflow-x: hidden` which would snap `scrollLeft` to 0 instantly.
+        // Both animations start in the same frame: the scroll-back resets
+        // `scrollLeft` to 0 up front, so the gutter swap's `overflow-x`
+        // change has nothing left to snap.
+        smoothCollapseScrollLeft(pre, 300);
         animateScrollbarGutter(pre);
       }
-      if (direction === 'expand' && pre.querySelector('[data-collapsible]')) {
-        animateScrollbarGutterExpand(pre);
+      if (direction === 'expand') {
+        // Cancel any in-flight collapse scroll-back so its leftover transform
+        // can't drift the code horizontally during the expand transition.
+        scrollbackAnimations.get(pre)?.cancel();
+        scrollbackAnimations.delete(pre);
+        if (pre.querySelector('[data-collapsible]')) {
+          animateScrollbarGutterExpand(pre);
+        }
       }
     }
 
@@ -201,7 +375,11 @@ export function useScrollAnchor() {
       window.removeEventListener('touchmove', stopOnUserInteraction);
       window.removeEventListener('pointerdown', stopOnUserInteraction);
       window.removeEventListener('keydown', stopOnUserInteraction);
+      if (activeSessionCleanupRef.current === cleanup) {
+        activeSessionCleanupRef.current = null;
+      }
     }
+    activeSessionCleanupRef.current = cleanup;
 
     function stopOnUserInteraction() {
       cleanup();
diff --git a/docs/app/docs-infra/pipeline/enhance-code-emphasis/page.mdx b/docs/app/docs-infra/pipeline/enhance-code-emphasis/page.mdx
index aed60e8d4..65104771c 100644
--- a/docs/app/docs-infra/pipeline/enhance-code-emphasis/page.mdx
+++ b/docs/app/docs-infra/pipeline/enhance-code-emphasis/page.mdx
@@ -500,16 +500,25 @@ Target frame types with CSS to style highlighted frames and create collapsible c
   padding: 0 6px;
 }
 
-/* Normal frames and unfocused highlighted/focus frames are hidden by default (collapsed) */
+/* Normal frames and unfocused highlighted/focus frames are hidden by default
+   (collapsed). `visibility: hidden` removes the frames from the a11y tree,
+   focus order, and prevents `contenteditable` caret placement while collapsed;
+   its built-in transition semantics keep the element visible during the
+   collapse animation and only flip to hidden at the end. `overflow-anchor`
+   prevents the browser from anchoring scroll position to the collapsing
+   content. */
 .codeBlock .frame:not([data-frame-type]),
 .codeBlock .frame[data-frame-type='highlighted-unfocused'],
 .codeBlock .frame[data-frame-type='focus-unfocused'] {
   max-height: 0;
   overflow: hidden;
+  overflow-anchor: none;
   opacity: 0;
+  visibility: hidden;
   transition:
     max-height 0.3s cubic-bezier(0.5, 0, 0, 1),
-    opacity 0.2s ease 0.1s;
+    opacity 0.2s ease 0.1s,
+    visibility 0.3s;
 }
 
 /* When supported, use interpolate-size for smoother height animation */
@@ -523,10 +532,16 @@ Target frame types with CSS to style highlighted frames and create collapsible c
     overflow: clip;
     transition:
       height 0.3s ease,
-      opacity 0.3s ease;
+      opacity 0.3s ease,
+      visibility 0.3s;
   }
 }
 
+/* Highlighted-unfocused keeps its background visible — only animate height. */
+.codeBlock .frame[data-frame-type='highlighted-unfocused'] {
+  opacity: 1;
+}
+
 /* Padding frames appear dimmed when collapsed */
 .codeBlock .frame[data-frame-type='padding-top'],
 .codeBlock .frame[data-frame-type='padding-bottom'] {
@@ -541,9 +556,11 @@ Target frame types with CSS to style highlighted frames and create collapsible c
 .expanded .codeBlock .frame[data-frame-type='focus-unfocused'] {
   max-height: 2220px;
   opacity: 1;
+  visibility: visible;
   transition:
     max-height 1.5s cubic-bezier(0.25, 0.46, 0.45, 0.94),
-    opacity 0.15s ease;
+    opacity 0.15s ease,
+    visibility 0s;
 }
 
 @supports (interpolate-size: allow-keywords) {
@@ -555,7 +572,8 @@ Target frame types with CSS to style highlighted frames and create collapsible c
     overflow: clip;
     transition:
       height 0.3s ease,
-      opacity 0.3s ease;
+      opacity 0.3s ease,
+      visibility 0s;
   }
 }
 
@@ -597,10 +615,58 @@ When a code block has both visible frames (highlighted, focus, padding) and hidd
   display: none;
 }
 
-/* Show only when the code block has collapsible frames */
-.pre code:has([data-collapsible]) ~ .toggle {
+/* Show only when the code block has collapsible frames.
+   The `> pre > code` child path bounds the `:has()` scan to a fixed depth
+   instead of walking the whole subtree. */
+.code:has(> pre > code[data-collapsible]) ~ .toggle {
   display: block;
 }
+
+/* Only collapsible code blocks disable horizontal scroll so the fade overlay
+   below can clip cleanly; non-collapsible blocks keep the layout's default
+   `overflow-x: auto`. */
+pre.codeBlock:has(> code[data-collapsible]) {
+  overflow-x: hidden;
+}
+
+/* Restore the pre's bottom padding when expanded. */
+.expanded
+  .code:has(> pre.codeBlock > code > .frame[data-frame-truncated='visible'])
+  > pre.codeBlock {
+  padding-bottom: var(--code-padding-bottom);
+}
+
+/* Slide the fade overlay out of view when the block is expanded. */
+.expanded .code:has(> pre.codeBlock > code > .frame[data-frame-truncated='visible'])::after {
+  transform: translateY(100%);
+}
+
+/* Fade overlay for truncated code blocks. Anchor it to the non-scrolling
+  `.code` wrapper so `right: 0` stays pinned to the visible edge while the
+  `pre` scrolls horizontally. `overflow-y: clip` trims the translated
+  pseudo-element, the explicit `> pre.codeBlock > code > .frame` path keeps
+  the `:has()` selector bounded, and `padding-bottom: 0` lets the fade sit
+  flush with the last visible line. */
+pre.codeBlock:has(> code > .frame[data-frame-truncated='visible']) {
+  padding-bottom: 0;
+}
+
+.code:has(> pre.codeBlock > code > .frame[data-frame-truncated='visible']) {
+  position: relative;
+  overflow-y: clip;
+}
+
+.code:has(> pre.codeBlock > code > .frame[data-frame-truncated='visible'])::after {
+  content: '';
+  position: absolute;
+  bottom: 0;
+  left: 0;
+  right: 0;
+  height: 40px;
+  background: linear-gradient(to bottom, transparent, rgb(255 255 255 / 0.8));
+  transition: transform 0.3s ease;
+  pointer-events: none;
+}
 ```
 
 The following demo shows a code block that starts collapsed, revealing only the highlighted and padding frames. Click **Expand** to show the full source.
@@ -708,6 +774,29 @@ mark[data-hl='strong'] {
   padding: 0 6px;
 }
 
+/* Only strong lines that border a regular highlight line need to stack
+   above it, so the regular line's extended background sits underneath the
+   strong line's rounded corners. */
+.line[data-hl='strong'][data-hl-position='single'],
+.line[data-hl='strong'][data-hl-position='end'] {
+  position: relative;
+  z-index: 1;
+}
+
+/* When a regular highlight line sits directly above a strong line,
+   extend its background down through the inter-line gap so the two
+   highlight regions visually merge. */
+.line[data-hl='']:has(+ .line[data-hl='strong']) {
+  padding-bottom: 6px;
+  margin-bottom: -6px;
+}
+
+/* Mirror for a regular highlight line directly below a strong line. */
+.line[data-hl='strong'] + .line[data-hl=''] {
+  padding-top: 6px;
+  margin-top: -6px;
+}
+
 /* Single-line emphasis - rounded on all corners */
 .line[data-hl-position='single'] {
   border-radius: 8px;
diff --git a/packages/docs-infra/src/pipeline/enhanceCodeEmphasis/enhanceCodeEmphasis.test.ts b/packages/docs-infra/src/pipeline/enhanceCodeEmphasis/enhanceCodeEmphasis.test.ts
index bc8433d9d..66125448f 100644
--- a/packages/docs-infra/src/pipeline/enhanceCodeEmphasis/enhanceCodeEmphasis.test.ts
+++ b/packages/docs-infra/src/pipeline/enhanceCodeEmphasis/enhanceCodeEmphasis.test.ts
@@ -367,9 +367,10 @@ const e = 5; // @highlight`,
       // Both comments map to the same output line after the @highlight comment line is removed.
       // The frame should have data-frame-type="highlighted" from @highlight AND wrap ", 40px" from @highlight-text.
       expect(result).toContain('data-frame-type="highlighted"');
-      // The text highlight wraps ", 40px" which contains syntax-highlighted children
+      // The text highlight wraps ", 40px" which contains syntax-highlighted children.
+      // The mark gets data-hl="" because it sits inside one containing highlight range.
       expect(result).toContain(
-        ', 40px',
+        ', 40px',
       );
     });
 
@@ -690,6 +691,187 @@ const another = 99; // @highlight`,
       // @highlight-text inside 2 containing highlight ranges: mark gets data-hl="strong"
       expect(result).toContain('Title');
     });
+
+    it('should highlight text inside nested CSS @highlight ranges', async () => {
+      const result = await testEmphasis(
+        `.X {
+  /* @highlight-start */
+  &[data-starting-style],
+  &[data-ending-style] {
+    /* @highlight-start */
+    opacity: 0;
+    transform: scale(0.9); /* @highlight-text "transform" */
+    /* @highlight-end */
+  }
+  /* @highlight-end */
+}`,
+        parseSource,
+        'test.css',
+      );
+
+      // Both lines inside the inner @highlight range should be marked strong
+      // (nested inside the outer @highlight range), regardless of whether one
+      // of them carries an inline @highlight-text directive.
+      expect(result).toMatch(/data-ln="4"[^>]*data-hl="strong"/);
+      expect(result).toMatch(/data-ln="5"[^>]*data-hl="strong"/);
+      // The text highlight on the transform identifier should be present and
+      // also marked strong because it sits inside two highlight ranges.
+      expect(result).toContain('data-hl="strong">transform');
+    });
+
+    it('should mark all lines inside an inner highlight range strong when nested with @highlight-text', async () => {
+      // Same shape as the CSS regression but in JS, ensuring the inline
+      // @highlight-text directive on one of the inner lines does not block
+      // the strong promotion for the other inner lines either.
+      const result = await testEmphasis(
+        `function makeStyles() {
+  // @highlight-start
+  return {
+    // @highlight-start
+    color: 'red',
+    background: 'blue', // @highlight-text "background"
+    // @highlight-end
+  };
+  // @highlight-end
+}`,
+        parseSource,
+        'test.ts',
+      );
+
+      // Both inner lines should be strong, including the one carrying the text
+      // highlight directive.
+      expect(result).toMatch(/data-ln="3"[^>]*data-hl="strong"/);
+      expect(result).toMatch(/data-ln="4"[^>]*data-hl="strong"/);
+      expect(result).toContain('data-hl="strong">background');
+    });
+
+    it('should not promote strong on a single highlight range that contains @highlight-text', async () => {
+      // A single (non-nested) highlight range with @highlight-text inside
+      // should NOT make the line strong: the frame already conveys the
+      // highlight, and the inline mark inherits an empty data-hl="".
+      const result = await testEmphasis(
+        `function makeStyles() {
+  // @highlight-start
+  const value = 1;
+  const other = 2; // @highlight-text "other"
+  // @highlight-end
+}`,
+        parseSource,
+        'test.ts',
+      );
+
+      // Frame should be highlighted, but neither inner line should carry
+      // a line-level data-hl="strong".
+      expect(result).toContain('data-frame-type="highlighted"');
+      expect(result).not.toMatch(/data-ln="3"[^>]*data-hl="strong"/);
+      expect(result).not.toMatch(/data-ln="4"[^>]*data-hl="strong"/);
+      // The inline mark itself uses data-hl="" (single containing range).
+      expect(result).toContain('data-hl="">other');
+    });
+
+    it('should mark inner highlight strong when wrapped only by a @focus range', async () => {
+      // A @focus range is not a highlight, so a single highlight range nested
+      // inside it should NOT be promoted to strong by the focus wrapping.
+      const result = await testEmphasis(
+        `function makeStyles() {
+  // @focus-start
+  return {
+    // @highlight-start
+    color: 'red',
+    background: 'blue', // @highlight-text "background"
+    // @highlight-end
+  };
+  // @focus-end
+}`,
+        parseSource,
+        'test.ts',
+      );
+
+      // Frame is highlighted (from the inner @highlight range), focus comes
+      // from the outer @focus range.
+      expect(result).toContain('data-frame-type="highlighted"');
+      // The inner highlight range alone is NOT nested inside another highlight,
+      // so its lines should not be strong.
+      expect(result).not.toMatch(/data-ln="4"[^>]*data-hl="strong"/);
+      expect(result).not.toMatch(/data-ln="5"[^>]*data-hl="strong"/);
+      // The mark inherits data-hl="" from the single containing highlight range.
+      expect(result).toContain('data-hl="">background');
+    });
+
+    it('should mark a single @highlight line strong when wrapped in an outer @highlight range', async () => {
+      // Mixed nesting: a single-line `@highlight` directive sitting inside a
+      // multiline `@highlight-start` / `@highlight-end` range counts as a
+      // nested highlight and must be promoted to strong.
+      const result = await testEmphasis(
+        `function example() {
+  // @highlight-start
+  const outer = 1;
+  const inner = 2; // @highlight
+  const tail = 3;
+  // @highlight-end
+}`,
+        parseSource,
+        'test.ts',
+      );
+
+      // The single-line @highlight on line 3 is nested inside the outer
+      // multiline range, so it should be strong.
+      expect(result).toMatch(/data-ln="3"[^>]*data-hl="strong"/);
+      // The other lines in the outer range should NOT be strong (they're only
+      // covered by a single highlight range, which the frame already conveys).
+      expect(result).not.toMatch(/data-ln="2"[^>]*data-hl="strong"/);
+      expect(result).not.toMatch(/data-ln="4"[^>]*data-hl="strong"/);
+    });
+
+    it('should mark a strong line as data-hl-position="single" when nested in an outer range', async () => {
+      // A single-line `@highlight` on the middle of an outer multiline range
+      // should be promoted to strong AND keep its `data-hl-position="single"`
+      // so the line is styled as a standalone highlight (e.g. rounded corners)
+      // rather than a mid-range slice of the outer range.
+      const result = await testEmphasis(
+        `function example() {
+  // @highlight-start
+  const outer = 1;
+  const inner = 2; // @highlight
+  const tail = 3;
+  // @highlight-end
+}`,
+        parseSource,
+        'test.ts',
+      );
+
+      expect(result).toMatch(/data-ln="3"[^>]*data-hl="strong"[^>]*data-hl-position="single"/);
+    });
+
+    it('should mark a strong line with @highlight-text as data-hl-position="single" when nested', async () => {
+      // A line with `@highlight-text` that sits inside a single-line inner
+      // `@highlight` range, which is itself nested in an outer multiline
+      // highlight range, should be promoted to strong AND marked as
+      // `data-hl-position="single"` so the line styles as a standalone
+      // highlight rather than a mid-range slice.
+      const result = await testEmphasis(
+        `.X {
+  /* @highlight-start */
+  &[data-starting-style],
+  &[data-ending-style] {
+    opacity: 0;
+    /* @highlight-start */
+    transform: scale(0.9); /* @highlight-text "transform" */
+    /* @highlight-end */
+  }
+  /* @highlight-end */
+}`,
+        parseSource,
+        'test.css',
+      );
+
+      // The transform line is the only content line of the inner @highlight
+      // range and is wrapped by the outer @highlight range, so it must be
+      // strong AND marked as a single-line highlight.
+      expect(result).toMatch(
+        /data-hl="strong"[^>]*data-hl-position="single"[^>]*>\s*]*>transform<\/mark>/,
+      );
+    });
   });
 
   describe('edge cases', () => {
diff --git a/packages/docs-infra/src/pipeline/enhanceCodeEmphasis/enhanceCodeEmphasis.ts b/packages/docs-infra/src/pipeline/enhanceCodeEmphasis/enhanceCodeEmphasis.ts
index 4e30db91c..312fb137b 100644
--- a/packages/docs-infra/src/pipeline/enhanceCodeEmphasis/enhanceCodeEmphasis.ts
+++ b/packages/docs-infra/src/pipeline/enhanceCodeEmphasis/enhanceCodeEmphasis.ts
@@ -634,6 +634,10 @@ function calculateEmphasizedLines(
         focus: directive.focus,
         paddingFrameMaxSize: directive.paddingFrameMaxSize,
         focusFramesMaxSize: directive.focusFramesMaxSize,
+        // Treat a single `@highlight` as a containing highlight range of depth 1
+        // so that an outer multiline range wrapping this line is detected as
+        // nesting and promoted to `strong`.
+        containingRangeDepth: directive.lineHighlight ? 1 : undefined,
       });
     } else if (directive.type === 'text') {
       // Text highlight - emphasize specific text(s) within the line.
@@ -694,8 +698,13 @@ function calculateEmphasizedLines(
         const existing = emphasizedLines.get(line);
 
         // Determine position for this line in the current range
-        let position: 'start' | 'end' | undefined;
-        if (line === startLine && line !== endLine) {
+        let position: 'start' | 'end' | 'single' | undefined;
+        if (line === startLine && line === endLine) {
+          // A multiline range that resolves to a single content line (e.g.
+          // when comment-only lines are stripped) should be treated as a
+          // standalone single-line highlight.
+          position = 'single';
+        } else if (line === startLine && line !== endLine) {
           position = 'start';
         } else if (line === endLine && line !== startLine) {
           position = 'end';
@@ -707,19 +716,29 @@ function calculateEmphasizedLines(
         // merges focus into the existing entry.
         const meta: EmphasisMeta = existing
           ? {
-              // Nested highlight ranges are strong; focus+highlight overlap is not
+              // Nested highlight ranges are strong; focus+highlight overlap is not.
+              // Detect true nesting via `containingRangeDepth` rather than
+              // `existing.lineHighlight`. This works when the existing entry
+              // came from a `@highlight-text` directive on a line that's also
+              // wrapped in a highlight range — `existing.lineHighlight` is true
+              // after the first range merge, so a second wrapping range needs to
+              // see the depth to know nesting occurred.
               strong:
-                (existing.lineHighlight &&
-                  startDirective.lineHighlight &&
-                  !existing.highlightTexts) ||
+                ((existing.containingRangeDepth ?? 0) >= 1 && startDirective.lineHighlight) ||
                 existing.strong ||
                 strong,
               description: existing.description ?? (line === startLine ? description : undefined),
               // Inner range position takes precedence, but 'single' from a standalone
               // @highlight-text should be replaced by the multiline range's position.
-              // Keep 'single' from regular @highlight (no highlightTexts).
+              // Keep 'single' from a real @highlight (lineHighlight is set), even when
+              // the line also carries @highlight-text.
               position:
-                existing.position && !(existing.position === 'single' && existing.highlightTexts)
+                existing.position &&
+                !(
+                  existing.position === 'single' &&
+                  existing.highlightTexts &&
+                  !existing.lineHighlight
+                )
                   ? existing.position
                   : position,
               highlightTexts: existing.highlightTexts, // Preserve text highlights from @highlight-text
@@ -759,6 +778,9 @@ function calculateEmphasizedLines(
               paddingFrameMaxSize: startDirective.paddingFrameMaxSize,
               focusFramesMaxSize: startDirective.focusFramesMaxSize,
               propagatedOverride: true,
+              // Track depth so a wrapping outer range can detect nesting
+              // (used by the `strong` calculation above).
+              containingRangeDepth: startDirective.lineHighlight ? 1 : undefined,
             };
 
         emphasizedLines.set(line, meta);