+ 0 ? 'xl:hidden' : 'lg:hidden']} data-pagefind-ignore="all">
+
Browse documentation
@@ -367,7 +367,10 @@ const breadcrumbs = withSidebarBreadcrumbs(requestedBreadcrumbs ?? (product && p
* page that could not be scrolled and no visible control to release it.
*/
const desktop = window.matchMedia('(min-width: 1024px)')
- desktop.addEventListener('change', (e) => e.matches && close(), { signal })
+ desktop.addEventListener('change', (e) => {
+ // Contents can stay open until 80rem and still owns its scroll lock.
+ if (e.matches && sidebar && !sidebar.classList.contains('-translate-x-full')) close()
+ }, { signal })
if (desktop.matches) close()
}
@@ -394,15 +397,7 @@ const breadcrumbs = withSidebarBreadcrumbs(requestedBreadcrumbs ?? (product && p
.product-hero { display: block; }
.product-hero img { margin-bottom: 1rem; }
}
- /*
- * The breakpoint is stated here, not left to `lg:hidden` on the element.
- *
- * Astro scopes this rule to `.mobile-toolbar[data-astro-cid-…]`, which is
- * specificity (0,2,0) against Tailwind's `.lg\:hidden` at (0,1,0) — so the
- * utility never won and the toolbar rendered at every width. A hamburger
- * and a contents button sat above the three-column desktop layout on all
- * 394 pages that use this shell.
- */
+ /* Scoped display rules use the same breakpoints as the replacement columns. */
.mobile-toolbar {
position: sticky;
top: 0;
@@ -415,7 +410,12 @@ const breadcrumbs = withSidebarBreadcrumbs(requestedBreadcrumbs ?? (product && p
color: var(--text-color);
padding: 0.5rem 1rem;
}
- @media (min-width: 1024px) {
+ @media (min-width: 64rem) {
+ .mobile-toolbar:not(.mobile-toolbar--contents) {
+ display: none;
+ }
+ }
+ @media (min-width: 80rem) {
.mobile-toolbar {
display: none;
}
diff --git a/site/src/lib/api-glossary.ts b/site/src/lib/api-glossary.ts
index f416fdecb..864e77c22 100644
--- a/site/src/lib/api-glossary.ts
+++ b/site/src/lib/api-glossary.ts
@@ -269,6 +269,14 @@ export function apiGlossary(contextPath?: string) {
}
}
+/** Page contents follow the same filtered native groups and term order. */
+export function apiGlossaryHeadings(contextPath?: string) {
+ return apiGlossary(contextPath).groups.flatMap((group) => [
+ { depth: 2, slug: group.id, text: group.title },
+ ...group.terms.map((term) => ({ depth: 3, slug: term.id, text: term.id })),
+ ])
+}
+
/** Unknown labels remain plain text rather than linking to an absent definition. */
export function apiGlossaryHref(term: string, contextPath?: string): string | undefined {
if (!terms.has(term)) return undefined
diff --git a/site/src/styles/api-headings.css b/site/src/styles/api-headings.css
new file mode 100644
index 000000000..7fcdd5f50
--- /dev/null
+++ b/site/src/styles/api-headings.css
@@ -0,0 +1,17 @@
+/* Shared API and glossary permalinks stay beside the heading label. */
+.api-heading-link {
+ flex: none;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ min-inline-size: 1.5rem;
+ min-block-size: 1.5rem;
+ margin-inline-start: 0.25rem;
+ padding: 0.2rem 0.35rem;
+ color: var(--color-muted);
+ font-size: 0.875rem;
+ font-weight: 400;
+ text-decoration: none;
+}
+.api-heading-link:hover { color: var(--color-link); }
+.api-heading-link:focus-visible { outline: 2px solid var(--focus-ring-color); outline-offset: 2px; }
diff --git a/site/src/styles/api.css b/site/src/styles/api.css
index 89ea16be5..2e0727ece 100644
--- a/site/src/styles/api.css
+++ b/site/src/styles/api.css
@@ -667,30 +667,33 @@ dl.gp-sphinx-api-container > dt.gp-sphinx-api-header .gp-sphinx-api-layout-right
overflow-wrap: anywhere;
}
.api-reference-layout { max-width: 108rem; margin-inline: auto; }
-html:has(.api-reference-layout--symbol) {
+html:has(:is(.api-reference-layout--symbol, .api-reference-layout--glossary)) {
--api-header-height: 0px;
--api-sections-height: 0px;
--api-reading-top: calc(var(--api-header-height) + var(--api-sections-height) + 0.75rem);
scroll-padding-top: var(--api-reading-top);
}
-html:has(.api-reference-layout--symbol):has(.site-header :focus) { scroll-padding-top: 0; }
+html:has(:is(.api-reference-layout--symbol, .api-reference-layout--glossary)):has(.site-header :focus) { scroll-padding-top: 0; }
/* Sticky controls need measured clearance when text wraps or grows. */
-html:has(.api-reference-layout--symbol):not([data-api-sections-measured]) .site-header {
+html:has(:is(.api-reference-layout--symbol, .api-reference-layout--glossary)):not([data-api-sections-measured]) .site-header {
position: static;
}
@media (min-width: 56rem) {
.api-reference-layout:has(> .api-nav-column) { grid-template-columns: 16rem minmax(0, 1fr); }
- .api-reference-layout--symbol .api-nav {
+ :is(.api-reference-layout--symbol, .api-reference-layout--glossary) .api-nav {
top: var(--api-header-height);
height: calc(100dvh - var(--api-header-height));
}
}
.api-page-contents { display: none; }
+.api-glossary-contents { margin-block: 1.25rem; }
+.api-glossary-contents > summary { cursor: pointer; padding-block: 0.375rem; font-size: 0.875rem; }
+.api-glossary-contents > summary:focus-visible { outline: 2px solid var(--focus-ring-color); outline-offset: 2px; }
@media (min-width: 85rem) {
- .api-reference-layout--symbol:has(> .api-nav-column) {
+ :is(.api-reference-layout--symbol, .api-reference-layout--glossary):has(> .api-nav-column) {
grid-template-columns: 16rem minmax(0, 1fr) 14rem;
}
- .api-reference-layout--symbol > .api-reference-content {
+ :is(.api-reference-layout--symbol, .api-reference-layout--glossary) > .api-reference-content {
padding-inline: 2.25rem;
padding-bottom: 3rem;
}
@@ -699,6 +702,7 @@ html:has(.api-reference-layout--symbol):not([data-api-sections-measured]) .site-
min-width: 0;
padding: 4.5rem 0.5rem 2rem 0;
}
+ .api-glossary-contents { display: none; }
.api-page-contents > aside {
position: sticky;
top: calc(var(--api-header-height) + 1rem);
@@ -716,8 +720,8 @@ html:has(.api-reference-layout--symbol):not([data-api-sections-measured]) .site-
letter-spacing: 0.045em;
margin-bottom: 1rem;
}
-.api-page-contents ul { list-style: none; margin: 0; padding: 0; }
-.api-page-contents a {
+:is(.api-page-contents, .api-glossary-contents) ul { list-style: none; margin: 0; padding: 0; }
+:is(.api-page-contents, .api-glossary-contents) a {
display: flex;
gap: 0.5rem;
padding: 0.4rem 0.35rem;
@@ -728,8 +732,8 @@ html:has(.api-reference-layout--symbol):not([data-api-sections-measured]) .site-
overflow-wrap: anywhere;
text-decoration: none;
}
-.api-page-contents a:hover { color: var(--color-link); }
-.api-page-contents a[aria-current="location"] {
+:is(.api-page-contents, .api-glossary-contents) a:hover { color: var(--color-link); }
+:is(.api-page-contents, .api-glossary-contents) a[aria-current="location"] {
border-left-color: var(--color-link);
color: var(--color-link);
font-weight: 600;
@@ -738,10 +742,11 @@ html:has(.api-reference-layout--symbol):not([data-api-sections-measured]) .site-
.api-related { margin-top: 2rem; }
.api-related code { font-size: inherit; }
.api-related a { align-items: center; }
-.api-page-contents a:focus-visible {
+:is(.api-page-contents, .api-glossary-contents) a:focus-visible {
outline: 2px solid var(--focus-ring-color);
outline-offset: -2px;
}
+.api-glossary-contents a { font-size: 0.875rem; }
.api-reference-heading {
margin-block: 1rem 1.25rem;
}
@@ -782,22 +787,6 @@ html:has(.api-reference-layout--symbol):not([data-api-sections-measured]) .site-
}
.page-toolbar-controls--reference select { max-width: 11rem; }
.api-section-heading { display: flex; align-items: baseline; gap: 0.375rem; }
-.api-heading-link {
- flex: none;
- display: inline-flex;
- align-items: center;
- justify-content: center;
- min-inline-size: 1.5rem;
- min-block-size: 1.5rem;
- margin-inline-start: 0.25rem;
- padding: 0.2rem 0.35rem;
- color: var(--color-muted);
- font-size: 0.875rem;
- font-weight: 400;
- text-decoration: none;
-}
-.api-heading-link:hover { color: var(--color-link); }
-.api-heading-link:focus-visible { outline: 2px solid var(--focus-ring-color); outline-offset: 2px; }
.api-related-types { margin-top: 2rem; }
.api-related-cards { display: grid; gap: 0.75rem; margin-top: 0.75rem; }
.api-related-card { padding: 0.875rem 1rem; border: 1px solid var(--color-edge); border-radius: 0.4rem; }
@@ -934,12 +923,12 @@ html[data-api-sections-measured] .api-section-links {
}
.api-source-details { margin-block: 1rem 1.5rem; font-size: 0.875rem; }
.api-source-details > summary { cursor: pointer; color: var(--color-muted); }
-.api-reference-layout--symbol .api-reference-content [id] {
+:is(.api-reference-layout--symbol, .api-reference-layout--glossary) .api-reference-content [id] {
scroll-margin-top: 0;
}
@media print {
- .api-page-contents, .api-section-links { display: none; }
- .api-reference-layout--symbol { display: block; }
+ .api-page-contents, .api-glossary-contents, .api-section-links { display: none; }
+ :is(.api-reference-layout--symbol, .api-reference-layout--glossary) { display: block; }
}
@container gp-sphinx-api-entry (max-width: 36rem) {
diff --git a/site/src/styles/global.css b/site/src/styles/global.css
index df9716cbb..56abe6814 100644
--- a/site/src/styles/global.css
+++ b/site/src/styles/global.css
@@ -8,6 +8,7 @@
@import "./gp-sphinx-tokens.css";
@import "./brand.css";
@import "./api-kinds.css";
+@import "./api-headings.css";
@import "./flags.css" layer(components);
@import "./image-loading.css" layer(components);
/* Unlayered, like the widgets, so prose margins cannot outrank it. */
diff --git a/site/test/api-glossary-navigation.test.ts b/site/test/api-glossary-navigation.test.ts
new file mode 100644
index 000000000..f413c40ea
--- /dev/null
+++ b/site/test/api-glossary-navigation.test.ts
@@ -0,0 +1,382 @@
+import { afterEach, expect, it, vi } from 'vitest'
+vi.mock('astro:content', () => ({ getCollection: vi.fn(async () => []) }))
+import { readFileSync, realpathSync } from 'node:fs'
+import { createRequire } from 'node:module'
+import { pathToFileURL } from 'node:url'
+import { experimental_AstroContainer } from 'astro/container'
+import { Window, type Document, type Element, type HTMLElement } from 'happy-dom'
+import ts from 'typescript'
+import { compile as compileTailwind } from 'tailwindcss'
+import * as model from '@libtmux/api-model'
+import * as glossary from '../src/lib/api-glossary'
+import { NATIVE_API_GLOSSARIES } from '../src/lib/api-glossary-native'
+import * as models from '../src/lib/api-models'
+import * as sections from '../src/lib/api-sections'
+import * as relationships from '../src/lib/api-relationships'
+import * as tree from '../src/lib/api-tree'
+import * as search from '../src/lib/api-search'
+import * as treeState from '../src/components/api/api-tree-state'
+import * as labels from '../src/lib/api-labels'
+import * as wordBreak from '../src/lib/word-break'
+import * as ports from '../src/lib/ports'
+import * as products from '../src/lib/product-api'
+import * as versions from '../src/lib/versions'
+import * as roots from '../src/lib/site-root'
+import * as sidebar from '../src/lib/sidebar'
+import * as documentation from '../src/lib/documentation-navigation'
+import * as pagePorts from '../src/lib/page-ports-for'
+import * as markdown from '../src/lib/markdown-twins'
+import * as manual from '../src/lib/tmux-manual-data'
+import * as reference from '../src/lib/tmux-reference'
+import * as locales from '../src/i18n/locales'
+import * as resolve from '../src/i18n/resolve'
+import * as contribute from '../src/i18n/contribute'
+import * as ui from '../src/i18n/ui'
+import * as constants from '../src/constants'
+import * as toc from '../src/utils/generateToC'
+import * as branding from '../src/lib/branding'
+import mentions from '../src/data/mentions.json'
+
+const windows: Window[] = []
+afterEach(async () => {
+ for (const win of windows.splice(0)) await win.happyDOM.abort()
+ vi.unstubAllGlobals()
+ vi.unstubAllEnvs()
+})
+
+/** Keep the edited templates, native catalogs, API tree and both contents renderers real. */
+async function components() {
+ const modules: Record = {
+ '@libtmux/api-model': model,
+ ...Object.fromEntries(Object.entries({
+ 'api-glossary': glossary, 'api-models': models, 'api-sections': sections,
+ 'api-relationships': relationships, 'api-tree': tree, 'api-search': search,
+ 'api-labels': labels, 'word-break': wordBreak, ports, 'product-api': products,
+ versions, 'site-root': roots, sidebar, 'documentation-navigation': documentation,
+ 'page-ports-for': pagePorts, 'markdown-twins': markdown, 'tmux-manual-data': manual, 'tmux-reference': reference,
+ branding,
+ }).map(([name, exports]) => [new URL(`../src/lib/${name}.ts`, import.meta.url).href, exports])),
+ ...Object.fromEntries(Object.entries({ locales, resolve, contribute, ui }).map(([name, exports]) =>
+ [new URL(`../src/i18n/${name}.ts`, import.meta.url).href, exports])),
+ [new URL('../src/components/api/api-tree-state.ts', import.meta.url).href]: treeState,
+ [new URL('../src/constants.ts', import.meta.url).href]: constants,
+ [new URL('../src/utils/generateToC.ts', import.meta.url).href]: toc,
+ [new URL('../src/data/mentions.json', import.meta.url).href]: { default: mentions },
+ }
+ vi.stubGlobal('__glossaryNavigationModules', modules)
+ const astroRequire = createRequire(realpathSync(new URL('../node_modules/astro/package.json', import.meta.url)))
+ const { transform } = await import(astroRequire.resolve('@astrojs/compiler-rs'))
+ const runtime = pathToFileURL(astroRequire.resolve('astro/compiler-runtime')).href
+ const realTemplates = new Set([
+ 'api/ApiReference.astro', 'api/ApiGlossary.astro', 'api/ApiGlossaryContent.astro',
+ 'api/ApiPageContents.astro', 'api/ApiTree.astro', 'api/ApiTreeItems.astro',
+ 'api/ApiBadge.astro', 'api/SymbolName.astro', 'TableOfContents/TableOfContents.astro',
+ 'TableOfContents/TableOfContentsList.astro', 'MobileToC.astro',
+ 'docs/DocumentationContext.astro', 'docs/DocumentationNavigationButtons.astro',
+ ].map((name) => new URL(`../src/components/${name}`, import.meta.url).href))
+ realTemplates.add(new URL('../src/layouts/DocsLayout.astro', import.meta.url).href)
+ const compiled = new Map()
+ const styles: string[] = []
+ // Collection loading and outer chrome are outside this regression. No glossary or TOC stub.
+ const shell = (await transform('', {
+ filename: 'glossary-test-chrome.astro', internalURL: 'astro/compiler-runtime', resultScopedSlot: true,
+ resolvePath: (specifier: string) => specifier,
+ })).code.replaceAll('"astro/compiler-runtime"', JSON.stringify(runtime))
+ const chrome = `data:text/javascript;base64,${Buffer.from(shell).toString('base64')}`
+
+ async function compile(file: URL): Promise {
+ if (compiled.has(file.href)) return compiled.get(file.href)!
+ const source = readFileSync(file, 'utf8')
+ .replace(/