From c15677ec8eb1b8cecccf647c336348f4ff8395f7 Mon Sep 17 00:00:00 2001 From: Jan Calanog Date: Thu, 12 Nov 2026 17:34:06 +0100 Subject: [PATCH 1/9] Make long API example lines easy to scroll sideways with a mouse A global `* { scrollbar-width: thin }` in `styles.css` turns off Chrome's `::-webkit-scrollbar` styling. On macOS the API code cards fell back to an overlay bar that shows only while scrolling, so a mouse user had no bar to drag. The cards now opt back in with a 14px slot whose thumb shows on card hover and fills the slot under the pointer. The line-number gutter is sticky, so the numbers stay put while the code scrolls. Co-Authored-By: Claude Opus 5.5 --- .../Assets/api-docs.css | 73 ++++++++++++++----- .../Assets/code-block.css | 21 +++--- 2 files changed, 65 insertions(+), 29 deletions(-) diff --git a/src/Elastic.Documentation.Site/Assets/api-docs.css b/src/Elastic.Documentation.Site/Assets/api-docs.css index 1ae975b2ed..422ced8ef2 100644 --- a/src/Elastic.Documentation.Site/Assets/api-docs.css +++ b/src/Elastic.Documentation.Site/Assets/api-docs.css @@ -2641,33 +2641,68 @@ a.docs-reference-btn::after { box-shadow: none; } -/* Thin scrollbar thumb on the dark surface. */ -.api-code-card .api-code-sample-body, -.api-code-card .example-block-body { - scrollbar-width: thin; - scrollbar-color: var(--api-code-scroll) transparent; -} +/* Chrome draws ::-webkit-scrollbar only while scrollbar-width and scrollbar-color are auto; styles.css sets both on *. */ +.api-code-card + :is( + .api-code-carousel-card-body, + .api-code-sample-body, + .example-block-body + ) { + scrollbar-width: auto; + scrollbar-color: auto; -.api-code-card .api-code-sample-body::-webkit-scrollbar, -.api-code-card .example-block-body::-webkit-scrollbar { - width: 8px; - height: 8px; -} + @supports not selector(::-webkit-scrollbar) { + scrollbar-width: thin; + scrollbar-color: var(--api-code-scroll) transparent; + } -.api-code-card .api-code-sample-body::-webkit-scrollbar-track, -.api-code-card .example-block-body::-webkit-scrollbar-track { - background: transparent; + &::-webkit-scrollbar { + width: 14px; + height: 14px; + } + + &::-webkit-scrollbar-track, + &::-webkit-scrollbar-corner { + background: transparent; + } + + /* A 6px thumb in a 14px slot that fills it under the pointer: a drag target that never shifts the code. + Shown while the card is hovered or focused, always on touch screens. */ + &::-webkit-scrollbar-thumb { + background-color: var(--api-code-scroll); + background-clip: padding-box; + border: 4px solid transparent; + border-radius: 9999px; + + @media (hover: hover) { + background-color: transparent; + } + } } -.api-code-card .api-code-sample-body::-webkit-scrollbar-thumb, -.api-code-card .example-block-body::-webkit-scrollbar-thumb { +.api-code-card:is(:hover, :focus-within) + :is( + .api-code-carousel-card-body, + .api-code-sample-body, + .example-block-body + )::-webkit-scrollbar-thumb { background-color: var(--api-code-scroll); - border-radius: 9999px; } -.api-code-card .api-code-sample-body::-webkit-scrollbar-thumb:hover, -.api-code-card .example-block-body::-webkit-scrollbar-thumb:hover { +.api-code-card + :is( + .api-code-carousel-card-body, + .api-code-sample-body, + .example-block-body + )::-webkit-scrollbar-thumb:hover, +.api-code-card + :is( + .api-code-carousel-card-body, + .api-code-sample-body, + .example-block-body + )::-webkit-scrollbar-thumb:active { background-color: var(--api-code-scroll-hover); + border-width: 2px; } .example-block { diff --git a/src/Elastic.Documentation.Site/Assets/code-block.css b/src/Elastic.Documentation.Site/Assets/code-block.css index c6ec418a7c..c507cbb241 100644 --- a/src/Elastic.Documentation.Site/Assets/code-block.css +++ b/src/Elastic.Documentation.Site/Assets/code-block.css @@ -206,31 +206,29 @@ } /* Line-number gutter (rendered by _ApiCodeBlock.cshtml): a sibling element that is excluded from - selection and copy. The wrapper owns the surface so the gutter sits on the same fill. */ + selection and copy. The wrapper owns the surface so the gutter sits on the same fill. + As wide as its longest line, so long lines scroll in the card and the sticky gutter stays put the whole way. */ .highlight .code-lines { display: flex; align-items: flex-start; - min-width: 0; + width: max-content; + min-width: 100%; background-color: var(--code-bg); border-radius: var(--code-radius); - /* width:max-content keeps long lines from wrapping; the card scrolls instead. */ > pre { display: block; margin: 0; flex: 1 0 auto; - width: max-content; - min-width: 0; code { display: block; - width: max-content; - min-width: 0; max-height: none; overflow: visible; background-color: transparent; border-radius: 0; - padding-left: 0.5rem !important; + /* The gap after the numbers is the gutter's, so it holds while the code scrolls. */ + padding-left: 0 !important; white-space: pre; word-break: normal; overflow-wrap: normal; @@ -238,18 +236,21 @@ } } + /* Sticky, and opaque so code scrolls under it. */ .highlight .code-line-gutter { + position: sticky; + left: 0; flex: 0 0 auto; margin: 0; border: 0; - background: transparent; + background-color: var(--code-bg); user-select: none; -webkit-user-select: none; pointer-events: none; text-align: right; font-variant-numeric: tabular-nums; white-space: pre; - padding: var(--code-pad) 0.5rem var(--code-pad) 1rem; + padding: var(--code-pad) 1rem; color: var(--code-gutter); font-family: var(--code-font); font-size: var(--code-size); From 85ac62ab6fac12836b4bb4ececb1d1c104f10c18 Mon Sep 17 00:00:00 2001 From: Jan Calanog Date: Thu, 12 Nov 2026 21:41:51 +0100 Subject: [PATCH 2/9] Drop the stale fill note from the code-lines comment The gutter now paints its own background, so it no longer relies on the wrapper's fill. Co-Authored-By: Claude Opus 5.5 --- src/Elastic.Documentation.Site/Assets/code-block.css | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/Elastic.Documentation.Site/Assets/code-block.css b/src/Elastic.Documentation.Site/Assets/code-block.css index c507cbb241..fade9d3873 100644 --- a/src/Elastic.Documentation.Site/Assets/code-block.css +++ b/src/Elastic.Documentation.Site/Assets/code-block.css @@ -205,9 +205,8 @@ } } - /* Line-number gutter (rendered by _ApiCodeBlock.cshtml): a sibling element that is excluded from - selection and copy. The wrapper owns the surface so the gutter sits on the same fill. - As wide as its longest line, so long lines scroll in the card and the sticky gutter stays put the whole way. */ + /* Line-number gutter (rendered by _ApiCodeBlock.cshtml): a sibling excluded from selection and copy. + As wide as its longest line, so long lines scroll in the card while the sticky gutter stays put. */ .highlight .code-lines { display: flex; align-items: flex-start; From 799bbac251e5838d4b5ead6f126f6ae758e27c60 Mon Sep 17 00:00:00 2001 From: Jan Calanog Date: Thu, 12 Nov 2026 21:49:21 +0100 Subject: [PATCH 3/9] Fit the API response example to the rail so its scrollbar stays in view The response height was the window minus a 26rem guess at everything above it. On pages where the request, chips or captions are taller than the guess, the response ran past the bottom of the sticky rail, and the rail clipped its horizontal scrollbar. Every box from the rail down to the response body now shrinks, so the response fills exactly what is left. A floor of four lines, or the whole response when shorter, keeps it readable; below that floor the rail scrolls as before. Co-Authored-By: Claude Opus 5.5 --- .../Assets/api-docs.css | 33 +++++++++++++++---- 1 file changed, 27 insertions(+), 6 deletions(-) diff --git a/src/Elastic.Documentation.Site/Assets/api-docs.css b/src/Elastic.Documentation.Site/Assets/api-docs.css index 422ced8ef2..a8863c748c 100644 --- a/src/Elastic.Documentation.Site/Assets/api-docs.css +++ b/src/Elastic.Documentation.Site/Assets/api-docs.css @@ -4029,14 +4029,35 @@ html:has(.api-page-shell) { overflow: auto; } -/* Beside the article the rail is sticky and as tall as the window, so the response fills what is left under - the request instead of stopping at 12 lines. 26rem is the rail's padding, Copy page and the request - above the response. The 12-line cap stays as the minimum. */ +/* Beside the article the rail is sticky and as tall as the window, and the response fills what is left under + the request. Every box from the rail down to the response body may shrink, so the body scrolls inside the + window whatever sits above it, and its scrollbar stays in view. */ @media (min-width: 1024px) { + #api-examples-panel, + #api-examples-panel .api-examples { + display: flex; + flex-direction: column; + } + + #api-examples-panel .api-examples-panel-nav, + #api-examples-panel .api-examples, + #api-examples-panel .api-examples-scenario-panel:not([hidden]), #api-examples-panel .example-block-body { - max-height: max( - var(--api-response-cap), - calc(100dvh - var(--offset-top, 0px) - 26rem) + flex: 0 1 auto; + min-height: 0; + } + + #api-examples-panel .example-block-body { + max-height: none; + } + + /* A floor of four lines, or the whole response when it is shorter; below it the rail scrolls instead. + flex-basis: content keeps the preferred size at the content, so the height only sets the automatic + minimum: min(content, height). The height counts the 48px header and the 14px scrollbar slot. */ + #api-examples-panel .example-block--response { + flex: 0 1 content; + height: calc( + 48px + 4 * var(--code-size) * 1.5 + var(--code-pad) * 2 + 14px ); } } From 6a69a4869a14669ab748583f19a4fb64365d2045 Mon Sep 17 00:00:00 2001 From: Jan Calanog Date: Thu, 12 Nov 2026 22:30:40 +0100 Subject: [PATCH 4/9] Keep the response's generated-sample note in view while the code scrolls The note is sticky, but in a response it sits in .example-media-body, which was only as wide as the visible code, and a sticky box cannot leave its parent. Scrolling a wide response sideways carried the note off with the code. The media body now spans the full code width, and its non-code children size to the response panel, which is exactly the visible width, so the note stays at the bottom right like the request note does. Co-Authored-By: Claude Opus 5.5 --- .../Assets/api-docs.css | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/src/Elastic.Documentation.Site/Assets/api-docs.css b/src/Elastic.Documentation.Site/Assets/api-docs.css index a8863c748c..0b133c6af3 100644 --- a/src/Elastic.Documentation.Site/Assets/api-docs.css +++ b/src/Elastic.Documentation.Site/Assets/api-docs.css @@ -3988,6 +3988,27 @@ html:has(.api-page-shell) { -webkit-user-select: none; } +/* In a response the note sits in .example-media-body, and a sticky box cannot leave its parent: the media body + spans the full code width so the note can stay put, and everything in it that is not code (the note, a caption, + a media header with its copy button) keeps to the visible width and to the left. The panel is the container + because, as a block inside the scroll body, it is exactly the visible width; 100cqi of the scroll body itself + would include its vertical scrollbar. */ +#api-examples-panel .example-response-panel { + container-type: inline-size; +} + +#api-examples-panel .example-media-body { + width: max-content; + min-width: 100%; + + > :not(.notranslate) { + position: sticky; + left: 0; + box-sizing: border-box; + width: 100cqi; + } +} + /* The short line carries the full explanation in a tooltip; the dotted underline says there is one. */ .api-code-carousel-card-note > span { cursor: help; From f75c1654bd9aff1a13c1ac07c15b7f13e99ffdad Mon Sep 17 00:00:00 2001 From: Jan Calanog Date: Fri, 13 Nov 2026 09:14:12 +0100 Subject: [PATCH 5/9] fix: count the horizontal scrollbar in the carousel strip height (per review by Libra) scrollHeight leaves out the 14px horizontal scrollbar a wide line adds, so a short wide sample came up 14px short and scrolled vertically by the bar's height. Co-Authored-By: Claude Opus 5.5 --- .../Assets/api-examples-carousel.test.ts | 22 +++++++++++++++++++ .../Assets/api-examples-carousel.ts | 9 +++++--- 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/src/Elastic.Documentation.Site/Assets/api-examples-carousel.test.ts b/src/Elastic.Documentation.Site/Assets/api-examples-carousel.test.ts index cae60cac1a..6cc4196c7a 100644 --- a/src/Elastic.Documentation.Site/Assets/api-examples-carousel.test.ts +++ b/src/Elastic.Documentation.Site/Assets/api-examples-carousel.test.ts @@ -363,6 +363,28 @@ describe('API examples carousel', () => { dialog.dispatchEvent(new Event('close')) }) + it('sizes the strip to a short wide sample including its horizontal scrollbar', () => { + const strip = document.querySelector( + '[data-scenario="term"] [data-carousel-strip]' + )! + const body = strip.querySelector( + '[data-lang="Console"] > div' + )! + body.className = 'api-code-carousel-card-body' + body.style.maxHeight = '300px' + const metrics = { + scrollHeight: 102, + clientHeight: 102, + offsetHeight: 116, + } + for (const [key, value] of Object.entries(metrics)) + Object.defineProperty(body, key, { configurable: true, value }) + + initApiExamples() + + expect(strip.style.getPropertyValue('--api-strip-height')).toBe('118px') + }) + it('steps with the arrows and disables them at the ends', () => { initApiExamples() const prev = document.querySelector( diff --git a/src/Elastic.Documentation.Site/Assets/api-examples-carousel.ts b/src/Elastic.Documentation.Site/Assets/api-examples-carousel.ts index 7b701b6016..10050f3d73 100644 --- a/src/Elastic.Documentation.Site/Assets/api-examples-carousel.ts +++ b/src/Elastic.Documentation.Site/Assets/api-examples-carousel.ts @@ -83,9 +83,12 @@ function syncStripHeight(carousel: HTMLElement) { ) if (!strip || !card || !body) return const cap = parseFloat(getComputedStyle(body).maxHeight) - const code = Number.isFinite(cap) - ? Math.min(body.scrollHeight, cap) - : body.scrollHeight + // scrollHeight leaves out the horizontal scrollbar a wide line adds; without it a short sample scrolls by the bar's height. + const scrollbar = body.offsetHeight - body.clientHeight + const code = + (Number.isFinite(cap) + ? Math.min(body.scrollHeight, cap) + : body.scrollHeight) + scrollbar const header = card.querySelector('header')?.offsetHeight ?? 0 // The card's border and the strip's own padding, or the card comes up short and its code scrolls. const stripStyle = getComputedStyle(strip) From ea7fa5985859ad91eae26f820f705e4f69ce1f29 Mon Sep 17 00:00:00 2001 From: Jan Calanog Date: Fri, 13 Nov 2026 09:39:54 +0100 Subject: [PATCH 6/9] fix: keep the preview dialog's line numbers pinned past 100 characters (per review by Libra) A fixed width: 100ch on the preview's code ended .code-lines at 100ch, so on a longer line the sticky gutter slid out of view. A min-width keeps the 100-character dialog for shorter samples and lets the dialog grow with a longer line up to the window. Co-Authored-By: Claude Opus 5.5 --- src/Elastic.Documentation.Site/Assets/api-docs.css | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/Elastic.Documentation.Site/Assets/api-docs.css b/src/Elastic.Documentation.Site/Assets/api-docs.css index 30da2eceb1..6ad42a9896 100644 --- a/src/Elastic.Documentation.Site/Assets/api-docs.css +++ b/src/Elastic.Documentation.Site/Assets/api-docs.css @@ -4235,10 +4235,12 @@ html:has(.api-page-shell) { overflow: auto; } -/* 100 characters of code; the dialog fits around it (gutter and padding add themselves) and longer lines scroll sideways. */ +/* At least 100 characters of code; the dialog fits around it (gutter and padding add themselves), grows with a longer + line up to the window and scrolls past that. A minimum, not a width: a fixed width would end .code-lines at 100ch, + and the sticky line numbers could not follow a longer line. */ .api-code-preview-body .highlight pre code { box-sizing: content-box; - width: 100ch; + min-width: 100ch; } @media (max-width: 1023px) { From 192cc7715e57b8609af125bf1fc0a0c05894d31f Mon Sep 17 00:00:00 2001 From: Jan Calanog Date: Fri, 13 Nov 2026 10:37:16 +0100 Subject: [PATCH 7/9] test: drag the API example scrollbars in Chrome (per review by Libra) The carousel tests run in jsdom, so nothing caught an invisible scrollbar or line numbers that scroll away. Two tests open a Cloud Connect operation in headless Chrome, drag the request's and the response's scrollbar, and check that the code scrolled, the language stayed, and the line numbers and the generated-sample note stayed in place. Chrome is started without --hide-scrollbars, which Browser.LaunchAsync always passes. Co-Authored-By: Claude Opus 5.5 --- .../Areas/ApiExampleScrollingTests.cs | 228 ++++++++++++++++++ 1 file changed, 228 insertions(+) create mode 100644 tests/Elastic.Documentation.Journeys.Tests/Areas/ApiExampleScrollingTests.cs diff --git a/tests/Elastic.Documentation.Journeys.Tests/Areas/ApiExampleScrollingTests.cs b/tests/Elastic.Documentation.Journeys.Tests/Areas/ApiExampleScrollingTests.cs new file mode 100644 index 0000000000..dd9f8db959 --- /dev/null +++ b/tests/Elastic.Documentation.Journeys.Tests/Areas/ApiExampleScrollingTests.cs @@ -0,0 +1,228 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +using System.Diagnostics; +using System.Globalization; +using System.Text.Json; +using AwesomeAssertions; +using Elastic.Documentation.Journeys.Tests.Targets; +using Nullean.Cdp; +using Nullean.Snapshots; + +namespace Elastic.Documentation.Journeys.Tests.Areas; + +/// +/// Long lines in the API examples rail scroll sideways under a scrollbar a mouse can drag, while the line numbers and the +/// "Generated from the API spec" note stay in place. Only real layout shows this, so it runs in Chrome: a journey file has +/// no way to drag a scrollbar or compare two positions. The Cloud Connect spec renders in under a second, and this +/// operation has a wide generated sample in both its request and its response. +/// +[ClassDataSource(Shared = SharedType.PerTestSession)] +[NotInParallel] +public class ApiExampleScrollingTests(JourneyTarget target) +{ + private const string Operation = "api/doc/cloud-connect/operation/operation-patchcluster"; + private const string Scenario = ".api-examples-scenario-panel:not([hidden])"; + + [Test] + public async Task Request_DraggingTheScrollbar_ScrollsTheCodeAndKeepsLineNumbersAndNoteInPlace() + { + var report = await DragScrollbarAsync($"{Scenario} .api-code-carousel-card.is-active .api-code-carousel-card-body").ConfigureAwait( + false + ); + + _ = report.Should().MatchInlineSnapshot( + """ + overflows sideways: True + scrollbar slot: 14px + scrolled after the drag: True + language before and after: curl, curl + line numbers from the left edge: 0px + note from the right edge: 0px + note from the bottom edge: 0px + """ + ); + } + + [Test] + public async Task Response_DraggingTheScrollbar_ScrollsTheCodeAndKeepsLineNumbersAndNoteInPlace() + { + var report = await DragScrollbarAsync($"{Scenario} .example-block--response .example-block-body").ConfigureAwait(false); + + _ = report.Should().MatchInlineSnapshot( + """ + overflows sideways: True + scrollbar slot: 14px + scrolled after the drag: True + language before and after: curl, curl + line numbers from the left edge: 0px + note from the right edge: 0px + note from the bottom edge: 0px + """ + ); + } + + private async Task DragScrollbarAsync(string body) + { + if (target.Name != "isolated") + Skip.Test($"The API pages these tests open are part of the isolated site, not '{target.Name}'."); + + var url = new Uri(target.BaseUrl, Operation); + await WaitForPageAsync(url).ConfigureAwait(false); + + await using var chrome = await ChromeWithScrollbars.StartAsync().ConfigureAwait(false); + await using var context = await chrome.Browser.NewContextAsync().ConfigureAwait(false); + var page = await context.NewPageAsync().ConfigureAwait(false); + await page.SetViewportAsync(1280, 900, 1).ConfigureAwait(false); + await page.GotoAsync(url.ToString()).ConfigureAwait(false); + await page.WaitForLoadStateAsync("networkidle").ConfigureAwait(false); + + var before = await MeasureAsync(page, body).ConfigureAwait(false); + // Grab the thumb near the left end of the bar, inside the bottom scrollbar slot, and pull it right. + var x = before.GetProperty("left").GetDouble() + 30; + var y = before.GetProperty("bottom").GetDouble() - (before.GetProperty("slot").GetDouble() / 2); + await MouseAsync(page, "mouseMoved", (x, y), pressed: false).ConfigureAwait(false); + await MouseAsync(page, "mousePressed", (x, y), pressed: true).ConfigureAwait(false); + for (var step = 1; step <= 10; step++) + await MouseAsync(page, "mouseMoved", (x + (step * 15), y), pressed: true).ConfigureAwait(false); + await MouseAsync(page, "mouseReleased", (x + 150, y), pressed: false).ConfigureAwait(false); + var after = await MeasureAsync(page, body).ConfigureAwait(false); + + return string.Join( + '\n', + $"overflows sideways: {before.GetProperty("overflows").GetBoolean()}", + $"scrollbar slot: {before.GetProperty("slot").GetInt32()}px", + $"scrolled after the drag: {after.GetProperty("scrollLeft").GetInt32() > 0}", + $"language before and after: {before.GetProperty("language").GetString()}, {after.GetProperty("language").GetString()}", + $"line numbers from the left edge: {after.GetProperty("gutter").GetInt32()}px", + $"note from the right edge: {after.GetProperty("noteRight").GetInt32()}px", + $"note from the bottom edge: {after.GetProperty("noteBottom").GetInt32()}px" + ); + } + + // Edges are the scroll body's visible area: inside its border, short of its scrollbars. + private static async Task MeasureAsync(Page page, string body) => + await page.EvaluateAsync( + $$""" + (() => { + const body = document.querySelector({{JsonSerializer.Serialize(body)}}); + const box = body.getBoundingClientRect(); + const left = box.left + body.clientLeft, top = box.top + body.clientTop; + const gutter = body.querySelector('.code-line-gutter').getBoundingClientRect(); + const note = body.querySelector('.api-code-carousel-card-note').getBoundingClientRect(); + return { + language: document.querySelector('{{Scenario}} .api-code-carousel-card.is-active').dataset.lang, + overflows: body.scrollWidth > body.clientWidth, + slot: body.offsetHeight - body.clientHeight, + scrollLeft: body.scrollLeft, + left: box.left, + bottom: box.bottom, + gutter: Math.round(gutter.left - left), + noteRight: Math.round(left + body.clientWidth - note.right), + noteBottom: Math.round(top + body.clientHeight - note.bottom), + }; + })() + """ + ).ConfigureAwait(false); + + private static Task MouseAsync(Page page, string type, (double X, double Y) at, bool pressed) => + page.SendAsync("Input.dispatchMouseEvent", writer => + { + writer.WriteString("type", type); + writer.WriteNumber("x", at.X); + writer.WriteNumber("y", at.Y); + writer.WriteString("button", type == "mouseMoved" && !pressed ? "none" : "left"); + writer.WriteNumber("buttons", pressed ? 1 : 0); + writer.WriteNumber("clickCount", 1); + }); + + // The site serves an API's pages once its spec is rendered, which can finish after the landing page is up. + private static async Task WaitForPageAsync(Uri url) + { + using var http = new HttpClient { Timeout = TimeSpan.FromMinutes(5) }; + var deadline = DateTime.UtcNow.AddMinutes(5); + while (DateTime.UtcNow < deadline) + { + using var response = await http.GetAsync(url).ConfigureAwait(false); + if (response.IsSuccessStatusCode) + return; + await Task.Delay(500).ConfigureAwait(false); + } + throw new TimeoutException(string.Create(CultureInfo.InvariantCulture, $"{url} did not render within 5 minutes.")); + } +} + +/// +/// Headless Chrome as starts it, but without --hide-scrollbars: that flag hides +/// the scrollbar these tests drag, and extra arguments cannot take it back off. +/// +internal sealed class ChromeWithScrollbars : IAsyncDisposable +{ + private readonly Process _process; + private readonly string _profile; + + private ChromeWithScrollbars(Process process, string profile, Browser browser) + { + _process = process; + _profile = profile; + Browser = browser; + } + + public Browser Browser { get; } + + public static async Task StartAsync() + { + var executable = new ChromeLocator().Locate().Path ?? throw new InvalidOperationException("Chrome was not found."); + var profile = Directory.CreateTempSubdirectory("api-scrolling-").FullName; + var info = new ProcessStartInfo(executable) + { + ArgumentList = { + "--headless=new", + "--remote-debugging-port=0", + $"--user-data-dir={profile}", + "--no-first-run", + "--no-default-browser-check" + }, + RedirectStandardError = true + }; + var process = Process.Start(info) ?? throw new InvalidOperationException($"{executable} did not start."); + var socket = await DevToolsSocketAsync(profile).ConfigureAwait(false); + var browser = await Browser.ConnectAsync(socket).ConfigureAwait(false); + return new ChromeWithScrollbars(process, profile, browser); + } + + public async ValueTask DisposeAsync() + { + await Browser.DisposeAsync().ConfigureAwait(false); + if (!_process.HasExited) + { + _process.Kill(entireProcessTree: true); + await _process.WaitForExitAsync().ConfigureAwait(false); + } + _process.Dispose(); + try + { + Directory.Delete(_profile, recursive: true); + } + catch (IOException) + { + // Chrome can hold a file in the profile for a moment after it exits; the temp folder is cleaned up later. + } + } + + // Chrome writes the port on the first line of DevToolsActivePort and the browser's socket path on the second. + private static async Task DevToolsSocketAsync(string profile) + { + var file = Path.Combine(profile, "DevToolsActivePort"); + var deadline = DateTime.UtcNow.AddSeconds(30); + while (DateTime.UtcNow < deadline) + { + var lines = File.Exists(file) ? await File.ReadAllLinesAsync(file).ConfigureAwait(false) : []; + if (lines is [var port, var path, ..] && path.Length > 0) + return new Uri($"ws://127.0.0.1:{port}{path}"); + await Task.Delay(100).ConfigureAwait(false); + } + throw new TimeoutException("Chrome did not open its DevTools port within 30 seconds."); + } +} From 237d26889d9321dcf78ca4080ee7420e6130eb52 Mon Sep 17 00:00:00 2001 From: Jan Calanog Date: Fri, 13 Nov 2026 11:14:42 +0100 Subject: [PATCH 8/9] Run the API example scrolling tests in their own journeys shard The journeys workflow fails when a test class is in no shard. ApiExampleScrollingTests waits for the isolated site to render its API specs, so it gets its own job instead of slowing the site shard. Co-Authored-By: Claude Opus 5.5 --- tests/Elastic.Documentation.Journeys.Tests/shards.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/Elastic.Documentation.Journeys.Tests/shards.json b/tests/Elastic.Documentation.Journeys.Tests/shards.json index 722645af73..cc18b5e982 100644 --- a/tests/Elastic.Documentation.Journeys.Tests/shards.json +++ b/tests/Elastic.Documentation.Journeys.Tests/shards.json @@ -1,6 +1,7 @@ { "shards": [ { "name": "site", "classes": ["A11yJourneys", "ChromeJourneys", "ContentJourneys", "FeedbackJourneys", "LandingJourneys", "SynthJourneys", "NavigationJourneys", "PageActionsJourneys"] }, - { "name": "http-and-tool", "classes": ["DocsApiHttp", "DocsJourneysCliTests", "DocsJourneysContextTests", "DocsJourneysEmbeddedTests", "DocsJourneysExportConformanceTests", "DocsJourneysExportTests", "MarkdownAlternatesHttp", "McpHttp"] } + { "name": "http-and-tool", "classes": ["DocsApiHttp", "DocsJourneysCliTests", "DocsJourneysContextTests", "DocsJourneysEmbeddedTests", "DocsJourneysExportConformanceTests", "DocsJourneysExportTests", "MarkdownAlternatesHttp", "McpHttp"] }, + { "name": "api-examples", "classes": ["ApiExampleScrollingTests"] } ] } From 0a702ccddc0a605a82e1f33a8aaa6e0e8a9dd1fc Mon Sep 17 00:00:00 2001 From: Jan Calanog Date: Fri, 13 Nov 2026 11:57:43 +0100 Subject: [PATCH 9/9] Use the site's code scrollbar in API examples, like Markdown code blocks The custom 14px bar answered a misdiagnosis: headless Chrome draws overlay scrollbars, which hid the bar in every probe. With classic scrollbars, main already shows the API cards' bar now that the strip and the response count it. The cards now take the site-wide thin bar Markdown code blocks use, which also drops the old thumb that was nearly invisible on the dark surface. The browser test scrolls with a sideways wheel instead of dragging the bar, so it needs no Chrome of its own. Co-Authored-By: Claude Opus 5.5 --- .../Assets/api-docs.css | 65 -------- .../Areas/ApiExampleScrollingTests.cs | 142 ++++-------------- 2 files changed, 27 insertions(+), 180 deletions(-) diff --git a/src/Elastic.Documentation.Site/Assets/api-docs.css b/src/Elastic.Documentation.Site/Assets/api-docs.css index 6ad42a9896..f4c748b0db 100644 --- a/src/Elastic.Documentation.Site/Assets/api-docs.css +++ b/src/Elastic.Documentation.Site/Assets/api-docs.css @@ -2637,7 +2637,6 @@ a.docs-reference-btn::after { --api-code-muted: var(--code-muted); --api-code-accent: var(--color-blue-elastic-50); --api-code-scroll: var(--code-border); - --api-code-scroll-hover: var(--color-grey-100); overflow: visible; position: relative; margin-bottom: 24px; @@ -2648,70 +2647,6 @@ a.docs-reference-btn::after { box-shadow: none; } -/* Chrome draws ::-webkit-scrollbar only while scrollbar-width and scrollbar-color are auto; styles.css sets both on *. */ -.api-code-card - :is( - .api-code-carousel-card-body, - .api-code-sample-body, - .example-block-body - ) { - scrollbar-width: auto; - scrollbar-color: auto; - - @supports not selector(::-webkit-scrollbar) { - scrollbar-width: thin; - scrollbar-color: var(--api-code-scroll) transparent; - } - - &::-webkit-scrollbar { - width: 14px; - height: 14px; - } - - &::-webkit-scrollbar-track, - &::-webkit-scrollbar-corner { - background: transparent; - } - - /* A 6px thumb in a 14px slot that fills it under the pointer: a drag target that never shifts the code. - Shown while the card is hovered or focused, always on touch screens. */ - &::-webkit-scrollbar-thumb { - background-color: var(--api-code-scroll); - background-clip: padding-box; - border: 4px solid transparent; - border-radius: 9999px; - - @media (hover: hover) { - background-color: transparent; - } - } -} - -.api-code-card:is(:hover, :focus-within) - :is( - .api-code-carousel-card-body, - .api-code-sample-body, - .example-block-body - )::-webkit-scrollbar-thumb { - background-color: var(--api-code-scroll); -} - -.api-code-card - :is( - .api-code-carousel-card-body, - .api-code-sample-body, - .example-block-body - )::-webkit-scrollbar-thumb:hover, -.api-code-card - :is( - .api-code-carousel-card-body, - .api-code-sample-body, - .example-block-body - )::-webkit-scrollbar-thumb:active { - background-color: var(--api-code-scroll-hover); - border-width: 2px; -} - .example-block { position: relative; } diff --git a/tests/Elastic.Documentation.Journeys.Tests/Areas/ApiExampleScrollingTests.cs b/tests/Elastic.Documentation.Journeys.Tests/Areas/ApiExampleScrollingTests.cs index dd9f8db959..b3d5853785 100644 --- a/tests/Elastic.Documentation.Journeys.Tests/Areas/ApiExampleScrollingTests.cs +++ b/tests/Elastic.Documentation.Journeys.Tests/Areas/ApiExampleScrollingTests.cs @@ -2,7 +2,6 @@ // Elasticsearch B.V licenses this file to you under the Apache 2.0 License. // See the LICENSE file in the project root for more information -using System.Diagnostics; using System.Globalization; using System.Text.Json; using AwesomeAssertions; @@ -13,10 +12,10 @@ namespace Elastic.Documentation.Journeys.Tests.Areas; /// -/// Long lines in the API examples rail scroll sideways under a scrollbar a mouse can drag, while the line numbers and the -/// "Generated from the API spec" note stay in place. Only real layout shows this, so it runs in Chrome: a journey file has -/// no way to drag a scrollbar or compare two positions. The Cloud Connect spec renders in under a second, and this -/// operation has a wide generated sample in both its request and its response. +/// Long lines in the API examples rail scroll sideways while the line numbers and the "Generated from the API spec" note +/// stay in place. Only real layout shows this, so it runs in Chrome: a journey file has no way to compare two positions. +/// The Cloud Connect spec renders in under a second, and this operation has a wide generated sample in both its request +/// and its response. /// [ClassDataSource(Shared = SharedType.PerTestSession)] [NotInParallel] @@ -26,17 +25,16 @@ public class ApiExampleScrollingTests(JourneyTarget target) private const string Scenario = ".api-examples-scenario-panel:not([hidden])"; [Test] - public async Task Request_DraggingTheScrollbar_ScrollsTheCodeAndKeepsLineNumbersAndNoteInPlace() + public async Task Request_ScrollingSideways_KeepsLineNumbersAndNoteInPlace() { - var report = await DragScrollbarAsync($"{Scenario} .api-code-carousel-card.is-active .api-code-carousel-card-body").ConfigureAwait( + var report = await ScrollSidewaysAsync($"{Scenario} .api-code-carousel-card.is-active .api-code-carousel-card-body").ConfigureAwait( false ); _ = report.Should().MatchInlineSnapshot( """ overflows sideways: True - scrollbar slot: 14px - scrolled after the drag: True + scrolled sideways: True language before and after: curl, curl line numbers from the left edge: 0px note from the right edge: 0px @@ -46,15 +44,14 @@ public async Task Request_DraggingTheScrollbar_ScrollsTheCodeAndKeepsLineNumbers } [Test] - public async Task Response_DraggingTheScrollbar_ScrollsTheCodeAndKeepsLineNumbersAndNoteInPlace() + public async Task Response_ScrollingSideways_KeepsLineNumbersAndNoteInPlace() { - var report = await DragScrollbarAsync($"{Scenario} .example-block--response .example-block-body").ConfigureAwait(false); + var report = await ScrollSidewaysAsync($"{Scenario} .example-block--response .example-block-body").ConfigureAwait(false); _ = report.Should().MatchInlineSnapshot( """ overflows sideways: True - scrollbar slot: 14px - scrolled after the drag: True + scrolled sideways: True language before and after: curl, curl line numbers from the left edge: 0px note from the right edge: 0px @@ -63,7 +60,7 @@ public async Task Response_DraggingTheScrollbar_ScrollsTheCodeAndKeepsLineNumber ); } - private async Task DragScrollbarAsync(string body) + private async Task ScrollSidewaysAsync(string body) { if (target.Name != "isolated") Skip.Test($"The API pages these tests open are part of the isolated site, not '{target.Name}'."); @@ -71,29 +68,30 @@ private async Task DragScrollbarAsync(string body) var url = new Uri(target.BaseUrl, Operation); await WaitForPageAsync(url).ConfigureAwait(false); - await using var chrome = await ChromeWithScrollbars.StartAsync().ConfigureAwait(false); - await using var context = await chrome.Browser.NewContextAsync().ConfigureAwait(false); + await using var browser = await Browser.LaunchAsync(new BrowserLaunchOptions()).ConfigureAwait(false); + await using var context = await browser.NewContextAsync().ConfigureAwait(false); var page = await context.NewPageAsync().ConfigureAwait(false); await page.SetViewportAsync(1280, 900, 1).ConfigureAwait(false); await page.GotoAsync(url.ToString()).ConfigureAwait(false); await page.WaitForLoadStateAsync("networkidle").ConfigureAwait(false); var before = await MeasureAsync(page, body).ConfigureAwait(false); - // Grab the thumb near the left end of the bar, inside the bottom scrollbar slot, and pull it right. - var x = before.GetProperty("left").GetDouble() + 30; - var y = before.GetProperty("bottom").GetDouble() - (before.GetProperty("slot").GetDouble() / 2); - await MouseAsync(page, "mouseMoved", (x, y), pressed: false).ConfigureAwait(false); - await MouseAsync(page, "mousePressed", (x, y), pressed: true).ConfigureAwait(false); - for (var step = 1; step <= 10; step++) - await MouseAsync(page, "mouseMoved", (x + (step * 15), y), pressed: true).ConfigureAwait(false); - await MouseAsync(page, "mouseReleased", (x + 150, y), pressed: false).ConfigureAwait(false); + // A sideways wheel over the code, as a trackpad swipe or shift+wheel sends it. + await page.SendAsync("Input.dispatchMouseEvent", writer => + { + writer.WriteString("type", "mouseWheel"); + writer.WriteNumber("x", before.GetProperty("centerX").GetDouble()); + writer.WriteNumber("y", before.GetProperty("centerY").GetDouble()); + writer.WriteNumber("deltaX", 200); + writer.WriteNumber("deltaY", 0); + }).ConfigureAwait(false); + await Task.Delay(300).ConfigureAwait(false); var after = await MeasureAsync(page, body).ConfigureAwait(false); return string.Join( '\n', $"overflows sideways: {before.GetProperty("overflows").GetBoolean()}", - $"scrollbar slot: {before.GetProperty("slot").GetInt32()}px", - $"scrolled after the drag: {after.GetProperty("scrollLeft").GetInt32() > 0}", + $"scrolled sideways: {after.GetProperty("scrollLeft").GetInt32() > 0}", $"language before and after: {before.GetProperty("language").GetString()}, {after.GetProperty("language").GetString()}", $"line numbers from the left edge: {after.GetProperty("gutter").GetInt32()}px", $"note from the right edge: {after.GetProperty("noteRight").GetInt32()}px", @@ -114,10 +112,9 @@ await page.EvaluateAsync( return { language: document.querySelector('{{Scenario}} .api-code-carousel-card.is-active').dataset.lang, overflows: body.scrollWidth > body.clientWidth, - slot: body.offsetHeight - body.clientHeight, scrollLeft: body.scrollLeft, - left: box.left, - bottom: box.bottom, + centerX: left + body.clientWidth / 2, + centerY: top + body.clientHeight / 2, gutter: Math.round(gutter.left - left), noteRight: Math.round(left + body.clientWidth - note.right), noteBottom: Math.round(top + body.clientHeight - note.bottom), @@ -126,18 +123,7 @@ await page.EvaluateAsync( """ ).ConfigureAwait(false); - private static Task MouseAsync(Page page, string type, (double X, double Y) at, bool pressed) => - page.SendAsync("Input.dispatchMouseEvent", writer => - { - writer.WriteString("type", type); - writer.WriteNumber("x", at.X); - writer.WriteNumber("y", at.Y); - writer.WriteString("button", type == "mouseMoved" && !pressed ? "none" : "left"); - writer.WriteNumber("buttons", pressed ? 1 : 0); - writer.WriteNumber("clickCount", 1); - }); - - // The site serves an API's pages once its spec is rendered, which can finish after the landing page is up. + // The site serves an API's pages once its spec is rendered; on the isolated site that waits for every spec, Elasticsearch's too. private static async Task WaitForPageAsync(Uri url) { using var http = new HttpClient { Timeout = TimeSpan.FromMinutes(5) }; @@ -152,77 +138,3 @@ private static async Task WaitForPageAsync(Uri url) throw new TimeoutException(string.Create(CultureInfo.InvariantCulture, $"{url} did not render within 5 minutes.")); } } - -/// -/// Headless Chrome as starts it, but without --hide-scrollbars: that flag hides -/// the scrollbar these tests drag, and extra arguments cannot take it back off. -/// -internal sealed class ChromeWithScrollbars : IAsyncDisposable -{ - private readonly Process _process; - private readonly string _profile; - - private ChromeWithScrollbars(Process process, string profile, Browser browser) - { - _process = process; - _profile = profile; - Browser = browser; - } - - public Browser Browser { get; } - - public static async Task StartAsync() - { - var executable = new ChromeLocator().Locate().Path ?? throw new InvalidOperationException("Chrome was not found."); - var profile = Directory.CreateTempSubdirectory("api-scrolling-").FullName; - var info = new ProcessStartInfo(executable) - { - ArgumentList = { - "--headless=new", - "--remote-debugging-port=0", - $"--user-data-dir={profile}", - "--no-first-run", - "--no-default-browser-check" - }, - RedirectStandardError = true - }; - var process = Process.Start(info) ?? throw new InvalidOperationException($"{executable} did not start."); - var socket = await DevToolsSocketAsync(profile).ConfigureAwait(false); - var browser = await Browser.ConnectAsync(socket).ConfigureAwait(false); - return new ChromeWithScrollbars(process, profile, browser); - } - - public async ValueTask DisposeAsync() - { - await Browser.DisposeAsync().ConfigureAwait(false); - if (!_process.HasExited) - { - _process.Kill(entireProcessTree: true); - await _process.WaitForExitAsync().ConfigureAwait(false); - } - _process.Dispose(); - try - { - Directory.Delete(_profile, recursive: true); - } - catch (IOException) - { - // Chrome can hold a file in the profile for a moment after it exits; the temp folder is cleaned up later. - } - } - - // Chrome writes the port on the first line of DevToolsActivePort and the browser's socket path on the second. - private static async Task DevToolsSocketAsync(string profile) - { - var file = Path.Combine(profile, "DevToolsActivePort"); - var deadline = DateTime.UtcNow.AddSeconds(30); - while (DateTime.UtcNow < deadline) - { - var lines = File.Exists(file) ? await File.ReadAllLinesAsync(file).ConfigureAwait(false) : []; - if (lines is [var port, var path, ..] && path.Length > 0) - return new Uri($"ws://127.0.0.1:{port}{path}"); - await Task.Delay(100).ConfigureAwait(false); - } - throw new TimeoutException("Chrome did not open its DevTools port within 30 seconds."); - } -}