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 }[]