From ef25f84f01738d25fba0d917dbf1d46fe4625fc1 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 18 Oct 2026 06:06:22 -0500 Subject: [PATCH] fix(site[api]) Center declaration controls on the first signature line why: The badges, [source] link and permalink beside a declaration were top-aligned with its code. A formatted native signature pads its code by 0.25rem, so on a one-line declaration such as TypeScript's Server.sessions the controls sat 4.5px above the line's center. Badges aligned by their own baseline sat 0.5px high on every other header, and a heading with a taller line box drifted by more. what: - Align the row's first baselines for native headers at every width and for Python and MCP headers above the 36rem container breakpoint, so controls follow line one of a wrapped signature and ignore the code's padding; center the badge slot on the line the text controls share - Leave the vendored column layout, header height and wrapping as they were; a control group on a row of its own is not moved - Add checkDeclarationControlAlignment to check-native-layout.mjs. It loads TypeScript, Go, Python, Julia, C#, Kotlin, Scala and MCP pages at 1440, 768, 500 and 390px and requires every control beside the code to sit within 1px of the center of the first signature line, and controls that wrap onto their own rows to be level within each row. check-dev.mjs runs it with the signature layouts and the sample loop checks the API probe header - Replace the top-edge assertion in checkNativeSignatureGeometry with a center assertion, and replay the observed hosted Scala geometry as the rejected case Largest control offset from the first line's center, origin/main then this change, measured in Chromium against the dev server: - TypeScript, Go, Julia and C# formatted methods: 4.50px, then 0.00px at 1440 and 768px; TypeScript client.toString at 390px: 4.00px, then 0.00px - Python method and class, Kotlin type: 0.50px, then 0.00px - MCP tool header: 0.50px, then 0.00px; its type definition headings, whose 600-weight face makes a 25px line: 1.80px, then 0.70px - Header heights, code boxes and the stacked layouts are unchanged --- site/scripts/check-dev-sample.mjs | 4 + site/scripts/check-dev.mjs | 8 +- site/scripts/check-native-layout.mjs | 158 +++++++++++++++++++++- site/src/styles/api.css | 24 +++- site/test/native-signature-layout.test.ts | 77 ++++++++++- 5 files changed, 259 insertions(+), 12 deletions(-) diff --git a/site/scripts/check-dev-sample.mjs b/site/scripts/check-dev-sample.mjs index af5f613e..8aed48ab 100644 --- a/site/scripts/check-dev-sample.mjs +++ b/site/scripts/check-dev-sample.mjs @@ -2,6 +2,7 @@ import assert from 'node:assert/strict' import { checkHomeResponsiveLayout } from './check-home-launcher.mjs' import { checkPickerFilters } from './check-picker-filters.mjs' import { checkNavigationBeforeAnalytics } from './check-navigation.mjs' +import { checkDeclarationControlsLevel } from './check-native-layout.mjs' import { checkRequiredAppearance } from './check-page-readiness.mjs' /** A bounded rendering sample; the publication audit runs the full matrix. */ @@ -49,6 +50,9 @@ export async function checkDevSample(browser, base) { if (path === 'mcp/tools/') { assert(layout.tableColumns > 1 && layout.columns?.every((delta) => delta <= 1), `${width}: MCP table columns align`) } + if (path === 'api-example-probe/') { + await checkDeclarationControlsLevel(page.locator('dt.gp-sphinx-api-header').first(), `${path}/${width}: declaration controls`) + } } if (path.startsWith('tmux/')) { assert.match(await page.title(), /\| tmux \| libtmux\.org$/) diff --git a/site/scripts/check-dev.mjs b/site/scripts/check-dev.mjs index 901869af..1047355e 100644 --- a/site/scripts/check-dev.mjs +++ b/site/scripts/check-dev.mjs @@ -14,7 +14,7 @@ import { checkHomeTaskReset } from './check-home-task-reset.mjs' import { checkColorScheme } from './check-color-scheme.mjs' import { checkHomeExampleOptions } from './check-home-example-options.mjs' import { checkDevSample } from './check-dev-sample.mjs' -import { checkNativeSignatureGeometry, checkNativeSignatureLinks } from './check-native-layout.mjs' +import { checkDeclarationControlAlignment, checkDeclarationControlsLevel, checkNativeSignatureGeometry, checkNativeSignatureLinks } from './check-native-layout.mjs' import { checkRequiredAppearance, checkWebAnalytics, checkWebAnalyticsTransitions } from './check-page-readiness.mjs' const apiNavigationOnly = process.argv.includes('--api-navigation') @@ -382,8 +382,8 @@ async function checkSignatureLayouts(browser, base) { const code = await kotlin.locator('.gp-sphinx-api-layout-left').boundingBox() const toolbar = await kotlin.locator('.gp-sphinx-api-layout-right').boundingBox() if (width === 803 || width === 1920) { - assert(Math.abs(code.y - toolbar.y) < 2, - `${colorScheme}/${width}px: toolbar shares the declaration's first row when it fits`) + const level = await checkDeclarationControlsLevel(kotlin, `${colorScheme}/${width}px: toolbar shares the declaration's first line`) + assert.equal(level.mode, 'beside', `${colorScheme}/${width}px: toolbar shares the declaration's first row when it fits`) assert(code.x + code.width <= toolbar.x, `${colorScheme}/${width}px: declaration leaves room for its toolbar`) } @@ -397,6 +397,8 @@ async function checkSignatureLayouts(browser, base) { } } console.log('Native browse layout: compact F# modules and linked Kotlin function cards work without JavaScript') + const { measured } = await checkDeclarationControlAlignment(browser, base) + console.log(`Declaration controls: ${measured.length} sampled headers keep their controls level with the first signature line at 1440/768/500/390px`) } finally { await page.close() } diff --git a/site/scripts/check-native-layout.mjs b/site/scripts/check-native-layout.mjs index d52f0ece..e94aa395 100644 --- a/site/scripts/check-native-layout.mjs +++ b/site/scripts/check-native-layout.mjs @@ -1,5 +1,11 @@ import assert from 'node:assert/strict' +/** Controls beside a signature may sit this far from the centre of its first line. */ +const LEVEL_TOLERANCE = 1 + +/** Centres further apart than this belong to different rows of wrapped controls. */ +const STACKED_ROW_GAP = 8 + /** Measure the code width left by first-line or stacked declaration metadata. */ export async function checkNativeSignatureGeometry(header) { const geometry = await header.evaluate((element) => { @@ -18,6 +24,7 @@ export async function checkNativeSignatureGeometry(header) { bottom: pixels(style.paddingBottom) + pixels(style.borderBottomWidth), }, rowGap: pixels(style.rowGap), columnGap: pixels(style.columnGap), + lineHeight: pixels(style.lineHeight), } } const row = element.querySelector(':scope > .gp-sphinx-api-layout') @@ -63,9 +70,13 @@ export async function checkNativeSignatureGeometry(header) { const beside = controls.left >= code.right - tolerance assert(below || beside, 'Native signature code and metadata do not overlap') if (!below) { - assert(near(controls.top, code.top) && near(firstSignature.top, code.top) + assert(near(firstSignature.top, code.top) && controls.top >= code.top - tolerance && controls.bottom <= firstSignature.bottom + tolerance, - 'Native signature metadata aligns with the first line') + 'Native signature metadata stays on the first line') + assert(firstSignature.lineHeight > 0, 'Native first signature has a measurable line height') + const firstLine = content(firstSignature).top + firstSignature.lineHeight / 2 + assert(Math.abs(controls.top + controls.height / 2 - firstLine) <= LEVEL_TOLERANCE, + 'Native signature metadata centres on the first line') } const availableCodeWidth = rowContent.right - rowContent.left - (below ? 0 : controls.width + row.columnGap) assert(near(code.width, availableCodeWidth), 'Native signature uses the available code width') @@ -75,6 +86,149 @@ export async function checkNativeSignatureGeometry(header) { return { ...geometry, mode: below ? 'stacked' : 'inline', availableCodeWidth } } +/** + * Locate a declaration's first signature line and the vertical centre of each + * control in its right-hand group. The first line box starts at the content + * edge of the block that owns the first painted glyph and is one used + * line-height tall; a heading with `line-height: normal` has no declared + * height, so its first glyph's box stands in. + */ +export async function measureDeclarationControls(header) { + return header.evaluate((element) => { + const document = element.ownerDocument + const pixels = (value) => Number.parseFloat(value) + const row = element.querySelector(':scope > .gp-sphinx-api-layout') + const code = row?.querySelector(':scope > .gp-sphinx-api-layout-left') + const group = row?.querySelector(':scope > .gp-sphinx-api-layout-right') + if (!code || !group?.checkVisibility()) return null + const walker = document.createTreeWalker(code, document.defaultView.NodeFilter.SHOW_TEXT) + let glyph = null + for (let node; !glyph && (node = walker.nextNode());) { + const start = node.textContent.search(/\S/) + if (start < 0 || node.parentElement.closest('[popover]') || !node.parentElement.checkVisibility()) continue + const range = document.createRange() + range.setStart(node, start) + range.setEnd(node, start + 1) + const box = range.getBoundingClientRect() + if (box.width && box.height) glyph = { node, box } + } + if (!glyph) return null + const view = document.defaultView + let owner = glyph.node.parentElement + while (view.getComputedStyle(owner).display.startsWith('inline')) owner = owner.parentElement + const style = view.getComputedStyle(owner) + const lineHeight = pixels(style.lineHeight) + const height = Number.isFinite(lineHeight) ? lineHeight : glyph.box.height + const top = owner.getBoundingClientRect().top + pixels(style.paddingTop) + pixels(style.borderTopWidth) + const controls = [] + for (const child of group.children) { + if (!child.checkVisibility()) continue + for (const control of child.matches('.gp-sphinx-api-slot') ? child.children : [child]) { + if (!control.checkVisibility()) continue + const box = control.getBoundingClientRect() + controls.push({ name: control.textContent.trim().slice(0, 16) || control.className, centre: box.top + box.height / 2 }) + } + } + const codeBox = code.getBoundingClientRect() + const groupBox = group.getBoundingClientRect() + return { + id: element.getAttribute('data-symbol-id') ?? element.id, + mode: groupBox.top < codeBox.bottom - 1 && groupBox.bottom > codeBox.top + 1 ? 'beside' : 'stacked', + line: { top, height, centre: top + height / 2 }, controls, + } + }) +} + +/** + * Controls beside the code share the centre of its first line, however many + * lines follow. Controls that wrap below or above the code share the centre of + * whichever row of controls they sit on. + */ +export function assertDeclarationControlsLevel(measurement, label, tolerance = LEVEL_TOLERANCE) { + assert(measurement?.controls.length > 0, `${label}: declaration has code and visible controls`) + const { mode, line, controls } = measurement + assert(Number.isFinite(line.centre) && controls.every((control) => Number.isFinite(control.centre)), + `${label}: first line and controls have measurable centres`) + if (mode === 'beside') { + for (const control of controls) { + const offset = control.centre - line.centre + assert(Math.abs(offset) <= tolerance, + `${label}: control "${control.name}" centre is ${offset.toFixed(2)}px from the first signature line`) + } + return + } + // Wrapped controls form rows, and each row is level on its own. + const rows = [] + for (const { centre } of [...controls].sort((a, b) => a.centre - b.centre)) { + const row = rows.at(-1) + if (row && centre - row.at(-1) <= STACKED_ROW_GAP) row.push(centre) + else rows.push([centre]) + } + for (const row of rows) { + const spread = row.at(-1) - row[0] + assert(spread <= tolerance, `${label}: stacked controls share their row centre, spread ${spread.toFixed(2)}px`) + } +} + +export async function checkDeclarationControlsLevel(header, label) { + const measurement = await measureDeclarationControls(header) + assertDeclarationControlsLevel(measurement, label) + return measurement +} + +/** Each sample pairs a page with the declaration shape it contributes. */ +const DECLARATION_SAMPLES = [ + ['ts/latest/reference/server-server-sessions/', 'TypeScript one-line formatted method'], + ['ts/latest/reference/pane-pane-sethook/', 'TypeScript method that fits beside controls at 500px'], + ['ts/latest/reference/client-client-tostring/', 'TypeScript short method that fits beside controls at 390px'], + ['go/latest/reference/tmux-server-newsessionconnection/', 'Go multi-line method'], + ['py/latest/reference/libtmux-hooks-hooksmixin-set_hook/', 'Python wrapped method'], + ['py/latest/reference/libtmux-server/', 'Python class'], + ['julia/latest/reference/libtmux-capture_bytes/', 'Julia multi-line function'], + ['csharp/latest/reference/libtmux-tmuxbuffers-setasync/', 'C# multi-line method'], + ['kotlin/latest/reference/io-github-libtmux-kotlin-batch/', 'Kotlin type'], + ['scala/latest/reference/io-github-libtmux-scaladsl-server-windows/', 'Scala overloaded method'], + ['rs/latest/mcp/tools/get_pane_info/', 'MCP tool and its type definitions'], +] + +/** + * Sample declarations across ports at desktop and narrow widths. A header + * whose controls fall to a row of their own has no first line to follow, so + * the sample must still catch controls beside the code at every width it + * names, or the check would pass without measuring anything. + */ +export async function checkDeclarationControlAlignment(browser, base, widths = [1440, 768, 500, 390]) { + const page = await browser.newPage({ javaScriptEnabled: false }) + const beside = new Map(widths.map((width) => [width, 0])) + const measured = [] + try { + for (const [path, label] of DECLARATION_SAMPLES) { + await page.goto(`${base}/${path}`, { waitUntil: 'load' }) + await page.evaluate(() => document.fonts.ready) + const headers = page.locator('dt.gp-sphinx-api-header') + const count = Math.min(await headers.count(), 6) + assert(count > 0, `${label}: page has declaration headers`) + for (const width of widths) { + await page.setViewportSize({ width, height: 1000 }) + for (let index = 0; index < count; index++) { + const measurement = await checkDeclarationControlsLevel(headers.nth(index), `${label} #${index + 1} at ${width}px`) + if (measurement.mode === 'beside') beside.set(width, beside.get(width) + 1) + measured.push({ label, width, index, ...measurement }) + if (width === widths[0] && index === 0) { + assert.equal(measurement.mode, 'beside', `${label}: desktop controls sit beside the first line`) + } + } + } + } + for (const width of widths) { + assert(beside.get(width) > 0, `${width}px: some sampled controls sit beside a first line`) + } + } finally { + await page.close() + } + return { beside, measured } +} + /** A formatter's logical line must occupy one painted row, even when it scrolls. */ export async function checkNativeSignatureLines(header) { const signatures = await header.evaluate((element) => { diff --git a/site/src/styles/api.css b/site/src/styles/api.css index 1842e563..ba44b8c4 100644 --- a/site/src/styles/api.css +++ b/site/src/styles/api.css @@ -603,7 +603,7 @@ dl.gp-sphinx-api-container > dt.gp-sphinx-api-header:only-child { /* Keep code first; move the whole toolbar below it when both no longer fit. */ dl.gp-sphinx-api-container > dt.gp-sphinx-api-header.api-native-header > .gp-sphinx-api-layout--responsive { flex-flow: row wrap; - align-items: flex-start; + align-items: baseline; gap: 0.375rem 1rem; } dl.gp-sphinx-api-container > dt.gp-sphinx-api-header.api-native-header .gp-sphinx-api-layout-left { @@ -616,8 +616,26 @@ dl.gp-sphinx-api-container > dt.gp-sphinx-api-header.api-native-header .gp-sphin max-width: 100%; margin-left: auto; } -dl.gp-sphinx-api-container > dt.gp-sphinx-api-header .gp-sphinx-api-layout-right { - align-items: center; +/* + * Declaration controls share the vertical centre of the signature's first + * line. The row aligns the first baseline of the code with the first baseline + * of the controls, so padding on a formatted signature, a wrapped signature + * and a heading taller than the controls all leave the controls level with + * line one rather than the block's middle. Text controls share that baseline + * and badges centre on its line. A control group that wraps onto a row of its + * own has no code beside it and keeps its place. Below the container + * breakpoint the vendored column layout keeps its stretch. + */ +@container gp-sphinx-api-entry (width > 36rem) { + dl.gp-sphinx-api-container > dt.gp-sphinx-api-header:not(.api-native-header) > .gp-sphinx-api-layout--responsive { + align-items: baseline; + } +} +dl.gp-sphinx-api-container > dt.gp-sphinx-api-header > .gp-sphinx-api-layout > .gp-sphinx-api-layout-right { + align-items: baseline; +} +dl.gp-sphinx-api-container > dt.gp-sphinx-api-header > .gp-sphinx-api-layout > .gp-sphinx-api-layout-right > :is(.gp-sphinx-api-slot, .api-badge) { + align-self: center; } .api-native-signature { display: block; diff --git a/site/test/native-signature-layout.test.ts b/site/test/native-signature-layout.test.ts index d2b3535d..0e7f7f07 100644 --- a/site/test/native-signature-layout.test.ts +++ b/site/test/native-signature-layout.test.ts @@ -7,7 +7,7 @@ import * as nativeLayoutChecks from '../scripts/check-native-layout.mjs' interface Region { left: number; right: number; top: number; bottom: number; width: number; height: number - padding: string; gap: string + padding: string; gap: string; lineHeight?: number } interface Measurement { viewport: number; pageWidth: number @@ -38,7 +38,8 @@ async function runHostedGeometry(measurement: Measurement) { for (const [element, region] of regions) { if (!element) continue if (!region) { element.remove(); continue } - element.setAttribute('style', `padding:${region.padding};gap:${region.gap};border:0 solid;`) + element.setAttribute('style', `padding:${region.padding};gap:${region.gap};border:0 solid;` + + (region.lineHeight ? `line-height:${region.lineHeight}px;` : '')) const [rowGap, columnGap = rowGap] = region.gap.split(' ') const style = (element as HTMLElement).style style.rowGap = rowGap @@ -71,8 +72,24 @@ async function runHostedGeometry(measurement: Measurement) { } finally { await win.happyDOM.abort() } } +/** The recorded Scala signature is one line inside 4px of padding. */ +function observed(viewport: number): Measurement { + const measurement = structuredClone(measured.find((record) => record.viewport === viewport)!) + measurement.signatures[0].lineHeight = measurement.signatures[0].height - 8 + return measurement +} + +/** Move metadata beside the code so it centres on the first signature line. */ +function level(measurement: Measurement): Measurement { + const { left, right, signatures: [signature] } = measurement + if (left && right && signature && right.top < left.bottom) { + shift(right, 0, signature.top + Number.parseFloat(signature.padding) + signature.lineHeight! / 2 - (right.top + right.height / 2)) + } + return measurement +} + function sample(viewport: number): Measurement { - return structuredClone(measured.find((record) => record.viewport === viewport)!) + return level(observed(viewport)) } function resize(region: Region, width: number) { @@ -85,10 +102,16 @@ function shift(region: Region, dx: number, dy: number) { } describe('hosted native signature geometry', () => { - it('accepts the observed first-line metadata beside a 1440px Scala header', async () => { + it('accepts metadata centred on the first line beside a 1440px Scala header', async () => { await runHostedGeometry(sample(1440)) }) + it('rejects the observed 1440px metadata that sat on the padded signature\'s top edge', async () => { + const measurement = observed(1440) + expect(measurement.right!.top).toBe(measurement.left!.top) + await expect(runHostedGeometry(measurement)).rejects.toThrow(/centres on the first line/) + }) + it.each([768, 390])('accepts the observed full-width code above metadata at %ipx', async (width) => { await runHostedGeometry(sample(width)) }) @@ -190,6 +213,52 @@ describe('hosted native signature geometry', () => { }) }) +describe('declaration controls beside a signature', () => { + /** A 22.4px first line starting at 100px, followed by more lines of code. */ + const declaration = (mode: 'beside' | 'stacked', centres: number[]) => ({ + id: 'sample', mode, line: { top: 100, height: 22.4, centre: 111.2 }, + controls: centres.map((centre, index) => ({ name: ['method', 'async', '[source]', 'ΒΆ'][index], centre })), + }) + + it('accepts controls on the centre of the first line of a multi-line signature', () => { + nativeLayoutChecks.assertDeclarationControlsLevel(declaration('beside', [111.2, 111.2, 111.2, 111.2]), 'control') + }) + + it('accepts a centre exactly the tolerance away and rejects one beyond it', () => { + nativeLayoutChecks.assertDeclarationControlsLevel(declaration('beside', [112.2]), 'edge') + expect(() => nativeLayoutChecks.assertDeclarationControlsLevel(declaration('beside', [112.21]), 'edge')) + .toThrow(/control "method" centre is 1\.01px from the first signature line/) + }) + + it('rejects controls sitting above the centre of a padded signature line', () => { + expect(() => nativeLayoutChecks.assertDeclarationControlsLevel(declaration('beside', [106.7, 107.2]), 'ts')) + .toThrow(/ts: control "method" centre is -4\.50px from the first signature line/) + }) + + it('rejects controls centred on the middle of the whole code block', () => { + expect(() => nativeLayoutChecks.assertDeclarationControlsLevel(declaration('beside', [111.2, 245]), 'block')) + .toThrow(/control "async" centre is 133\.80px/) + }) + + it('compares stacked controls with each other, not with the code above or below', () => { + nativeLayoutChecks.assertDeclarationControlsLevel(declaration('stacked', [240.4, 240.9]), 'stacked') + expect(() => nativeLayoutChecks.assertDeclarationControlsLevel(declaration('stacked', [240.4, 243.4]), 'stacked')) + .toThrow(/stacked controls share their row centre, spread 3\.00px/) + }) + + it('compares controls that wrap onto several rows within each row', () => { + nativeLayoutChecks.assertDeclarationControlsLevel(declaration('stacked', [240.4, 240.9, 270.8, 271.3]), 'wrapped') + expect(() => nativeLayoutChecks.assertDeclarationControlsLevel(declaration('stacked', [240.4, 240.9, 270.8, 273.8]), 'wrapped')) + .toThrow(/spread 3\.00px/) + }) + + it('rejects a declaration with no measurable controls', () => { + expect(() => nativeLayoutChecks.assertDeclarationControlsLevel(null, 'missing')).toThrow(/visible controls/) + expect(() => nativeLayoutChecks.assertDeclarationControlsLevel(declaration('beside', []), 'empty')).toThrow(/visible controls/) + expect(() => nativeLayoutChecks.assertDeclarationControlsLevel(declaration('beside', [Number.NaN]), 'nan')).toThrow(/measurable centres/) + }) +}) + const lineMeasurements = JSON.parse(readFileSync(new URL('./fixtures/formatted-signature-lines.json', import.meta.url), 'utf8')) .records as { port: string; before: LineMeasurement; prePrototype: LineMeasurement }[]