Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions site/scripts/check-dev-sample.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -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$/)
Expand Down
8 changes: 5 additions & 3 deletions site/scripts/check-dev.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down Expand Up @@ -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`)
}
Expand All @@ -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()
}
Expand Down
158 changes: 156 additions & 2 deletions site/scripts/check-native-layout.mjs
Original file line number Diff line number Diff line change
@@ -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) => {
Expand All @@ -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')
Expand Down Expand Up @@ -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')
Expand All @@ -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) => {
Expand Down
24 changes: 21 additions & 3 deletions site/src/styles/api.css
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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;
Expand Down
Loading
Loading