From 4150420bf6fdd0e87b550ab088b64c62786f1143 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=99=84=EC=9A=B0?= Date: Fri, 30 Oct 2026 23:18:08 +0900 Subject: [PATCH 01/11] docs(site): redesign landing, navigation, and theme Redesign the docs site without changing its pages, routes or features. The landing keeps every section, link, translation and script (platform-detected downloads, release fetch, demo scrub); the new look is a stylesheet layer loaded after custom.css. - Hero: the dusk photo now sits under a translucent WebGL cloud layer with pointer and scroll parallax; the nav lives inside the hero card and docks as a rounded floating glass bar once the hero scrolls away. - Header: dropdowns with icon tiles, one gliding highlight per panel, Guides trimmed to six links plus an "All guides" link to the landing docs map, a full-screen mobile menu, a GitHub star button, and a circular theme transition (View Transitions, skipped when nothing would change). - Feature and download cards: borderless surfaces, one inverted focal card, a per-card glow on hover; section reveals and word-by-word headings. - Hero word roll measures each word so "Codex"/"Claude" never collide with the text around them (most visible in Korean). - Type: Wanted Sans for Hangul and Geist Mono for code, both self-hosted; heavier display headings. - Translations: Korean landing and header copy rewritten; awkward French, Chinese, Japanese, Russian and Turkish strings fixed. - Logo: new mark as SVG with a light and an inverted dark variant, plus an adaptive SVG favicon. The generated PNG/ICO favicons are unchanged. --- docs-site/astro.config.mjs | 12 +- docs-site/bun.lock | 6 + docs-site/package.json | 4 +- docs-site/public/favicon.svg | 14 + docs-site/public/grain.svg | 1 + docs-site/src/assets/logo-dark.svg | 14 + docs-site/src/assets/logo-light.svg | 14 + docs-site/src/components/CloudCanvas.astro | 316 ++++++++ docs-site/src/components/Header.astro | 707 ++++++++++++++--- docs-site/src/components/Landing.astro | 334 ++++++-- docs-site/src/styles/cloudlight.css | 867 +++++++++++++++++++++ 11 files changed, 2115 insertions(+), 174 deletions(-) create mode 100644 docs-site/public/favicon.svg create mode 100644 docs-site/public/grain.svg create mode 100644 docs-site/src/assets/logo-dark.svg create mode 100644 docs-site/src/assets/logo-light.svg create mode 100644 docs-site/src/components/CloudCanvas.astro create mode 100644 docs-site/src/styles/cloudlight.css diff --git a/docs-site/astro.config.mjs b/docs-site/astro.config.mjs index e81e8b1fb7e..e35f3189eb0 100644 --- a/docs-site/astro.config.mjs +++ b/docs-site/astro.config.mjs @@ -27,15 +27,17 @@ export default defineConfig({ "Universal provider proxy for OpenAI Codex & Claude Code — use any LLM with Codex CLI, App, SDK, and Claude Code.", tagline: "Use any LLM with OpenAI Codex and Claude Code.", logo: { - light: "./src/assets/logo-light.png", - dark: "./src/assets/logo-dark.png", + light: "./src/assets/logo-light.svg", + dark: "./src/assets/logo-dark.svg", replacesTitle: false, }, favicon: "/favicon.ico", customCss: [ "@fontsource-variable/geist", - "pretendard/dist/web/variable/pretendardvariable-dynamic-subset.css", + "@fontsource-variable/geist-mono", + "wanted-sans/fonts/webfonts/variable/split/WantedSansVariable.css", "./src/styles/custom.css", + "./src/styles/cloudlight.css", ], components: { Header: "./src/components/Header.astro", @@ -44,6 +46,10 @@ export default defineConfig({ head: [ // Google favicon guidelines: PNG at a multiple of 48px, exposed via rel="icon". { tag: "link", attrs: { rel: "icon", type: "image/png", sizes: "192x192", href: "/favicon.png" } }, + // The vector mark recolours itself for dark browser chrome; browsers that + // understand SVG icons prefer it over the generated ICO/PNG (scripts/brand-favicons.ts). + { tag: "link", attrs: { rel: "icon", type: "image/svg+xml", href: "/favicon.svg" } }, + { tag: "link", attrs: { rel: "apple-touch-icon", href: "/favicon.png" } }, { tag: "meta", attrs: { property: "og:image", content: `${SITE_URL}/og.png` } }, { tag: "meta", attrs: { property: "og:image:width", content: "1200" } }, { tag: "meta", attrs: { property: "og:image:height", content: "630" } }, diff --git a/docs-site/bun.lock b/docs-site/bun.lock index 092f7d2349d..954219debae 100644 --- a/docs-site/bun.lock +++ b/docs-site/bun.lock @@ -7,11 +7,13 @@ "dependencies": { "@astrojs/starlight": "^0.41.7", "@fontsource-variable/geist": "^5.3.0", + "@fontsource-variable/geist-mono": "5.3.0", "astro": "^7.3.3", "echarts": "^6.1.0", "pretendard": "^1.3.9", "sharp": "^0.35.4", "simple-icons": "^16.28.0", + "wanted-sans": "1.0.3", }, }, }, @@ -165,6 +167,8 @@ "@fontsource-variable/geist": ["@fontsource-variable/geist@5.3.0", "", {}, "sha512-j0m+vLQuG5XAYoHtGCVu0spvlGreR3EzpECUVzkFmI1mTVnAO38l/NEPDCFgZ177JxzYJCLSmTQibIiYPilGrA=="], + "@fontsource-variable/geist-mono": ["@fontsource-variable/geist-mono@5.3.0", "", {}, "sha512-vBbuwDEo9AkrqADMXOrlAR3DFcJi4/JxeuU43FoiQERnNwsfXNnvxvReZG02cQKmyk4DZkZdBZX3oTDvy2zBAw=="], + "@img/colour": ["@img/colour@1.1.0", "", {}, "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ=="], "@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.3.3" }, "os": "darwin", "cpu": "arm64" }, "sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA=="], @@ -927,6 +931,8 @@ "vitefu": ["vitefu@1.1.3", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["vite"] }, "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg=="], + "wanted-sans": ["wanted-sans@1.0.3", "", {}, "sha512-YU+mQRJMc97zMGFmxFjOvjdrF5Hi2zagc69bNP9n//i77Dlf9AWGfziZWYKdhQzPK5tw+e0iHcjLrZXDatxRTQ=="], + "web-namespaces": ["web-namespaces@2.0.1", "", {}, "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ=="], "xxhash-wasm": ["xxhash-wasm@1.1.0", "", {}, "sha512-147y/6YNh+tlp6nd/2pWq38i9h6mz/EuQ6njIrmW8D1BS5nCqs0P6DG+m6zTGnNz5I+uhZ0SHxBs9BsPrwcKDA=="], diff --git a/docs-site/package.json b/docs-site/package.json index 2c1f923c83d..59cdbdd766e 100644 --- a/docs-site/package.json +++ b/docs-site/package.json @@ -13,11 +13,13 @@ "dependencies": { "@astrojs/starlight": "^0.41.7", "@fontsource-variable/geist": "^5.3.0", + "@fontsource-variable/geist-mono": "5.3.0", "astro": "^7.3.3", "echarts": "^6.1.0", "pretendard": "^1.3.9", "sharp": "^0.35.4", - "simple-icons": "^16.28.0" + "simple-icons": "^16.28.0", + "wanted-sans": "1.0.3" }, "overrides": { "svgo": "4.1.0", diff --git a/docs-site/public/favicon.svg b/docs-site/public/favicon.svg new file mode 100644 index 00000000000..bd0cf20828f --- /dev/null +++ b/docs-site/public/favicon.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/docs-site/public/grain.svg b/docs-site/public/grain.svg new file mode 100644 index 00000000000..9b7a1133c22 --- /dev/null +++ b/docs-site/public/grain.svg @@ -0,0 +1 @@ + diff --git a/docs-site/src/assets/logo-dark.svg b/docs-site/src/assets/logo-dark.svg new file mode 100644 index 00000000000..5b5acb93733 --- /dev/null +++ b/docs-site/src/assets/logo-dark.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/docs-site/src/assets/logo-light.svg b/docs-site/src/assets/logo-light.svg new file mode 100644 index 00000000000..0797892fe07 --- /dev/null +++ b/docs-site/src/assets/logo-light.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/docs-site/src/components/CloudCanvas.astro b/docs-site/src/components/CloudCanvas.astro new file mode 100644 index 00000000000..4ad20a356ca --- /dev/null +++ b/docs-site/src/components/CloudCanvas.astro @@ -0,0 +1,316 @@ +--- +// Animated WebGL2 cloud sky (Cloudlight hero grammar). Renders into an +// absolutely positioned host that fills its nearest positioned ancestor. +// Without WebGL2 the host keeps its CSS sky colour, so the section still reads. +// Under reduced motion one still frame is drawn and re-drawn on theme change. +interface Props { + class?: string; + /** `sky` paints the full Cloudlight sky; `overlay` draws only the clouds, + * dusk-lit and translucent, over whatever sits behind the canvas. */ + variant?: 'sky' | 'overlay'; +} +const { class: className, variant = 'sky' } = Astro.props; +--- + + + + diff --git a/docs-site/src/components/Header.astro b/docs-site/src/components/Header.astro index 1e6c9e2d582..a7178e56134 100644 --- a/docs-site/src/components/Header.astro +++ b/docs-site/src/components/Header.astro @@ -17,7 +17,34 @@ const base = import.meta.env.BASE_URL.replace(/\/?$/, '/'); const prefix = locale ? `${base}${locale}/` : base; type NavLink = { label: string; href: string }; -type NavGroup = { label: string; links: NavLink[] }; +type NavGroup = { label: string; links: NavLink[]; more?: NavLink }; + +// Cloudlight sub-link rows: every dropdown entry gets a tinted icon tile. +// Lucide outline paths (24x24, stroke), keyed by the link's slug. +const ICONS: Record = { + 'getting-started/installation/': 'M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M7 10l5 5 5-5M12 15V3', + 'getting-started/quickstart/': 'M13 2 3 14h9l-1 8 10-12h-9l1-8Z', + 'getting-started/how-it-works/': 'M6 3v12M18 9a3 3 0 1 0 0-6 3 3 0 0 0 0 6ZM6 21a3 3 0 1 0 0-6 3 3 0 0 0 0 6ZM18 9a9 9 0 0 1-9 9', + 'getting-started/for-agents/': 'M12 8V4H8m8 0h-4M4 12a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v6a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2Zm11 3h.01M9 15h.01', + 'guides/providers/': 'M12 22v-5M9 8V2M15 8V2M6 8h12l-1 6a5 5 0 0 1-10 0Z', + 'guides/model-routing/': 'M6 19a3 3 0 1 0 0-6 3 3 0 0 0 0 6ZM18 11a3 3 0 1 0 0-6 3 3 0 0 0 0 6ZM9 16h5.5a3.5 3.5 0 0 0 0-7H15', + 'guides/combos/': 'm12 2 10 5-10 5L2 7Zm-10 10 10 5 10-5M2 17l10 5 10-5', + 'guides/codex-integration/': 'M16 18l6-6-6-6M8 6l-6 6 6 6', + 'guides/codex-app-models/': 'M3 5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2Zm6-2v18M3 9h6', + 'guides/claude-code/': 'M4 17l6-6-6-6M12 19h8', + 'guides/sidecars/': 'M11 19a8 8 0 1 0 0-16 8 8 0 0 0 0 16Zm10 2-4.35-4.35', + 'guides/image-bridge/': 'M3 5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2Zm6 6a2 2 0 1 0 0-4 2 2 0 0 0 0 4Zm12 4-3.09-3.09a2 2 0 0 0-2.82 0L6 21', + 'guides/video-bridge/': 'm16 13 5.22 3.48a.5.5 0 0 0 .78-.42V7.94a.5.5 0 0 0-.76-.43L16 10.5M4 6h10a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2Z', + 'guides/web-dashboard/': 'M3 3v16a2 2 0 0 0 2 2h16M7 16V9m5 7V5m5 11v-4', + 'guides/sub-agent-surface/': 'M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2M9 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8Zm13 10v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75', + 'reference/cli/': 'M4 17l6-6-6-6M12 19h8', + 'reference/configuration/': 'M20 7h-9M14 17H5M17 20a3 3 0 1 0 0-6 3 3 0 0 0 0 6ZM7 10a3 3 0 1 0 0-6 3 3 0 0 0 0 6Z', + 'reference/adapters/': 'M12 22v-5M9 8V2M15 8V2M6 8h12l-1 6a5 5 0 0 1-10 0Z', + 'reference/architecture/': 'M3 3h7v7H3Zm11 0h7v7h-7Zm0 11h7v7h-7ZM3 14h7v7H3Z', + 'reference/proxy-formats/': 'M8 3H7a2 2 0 0 0-2 2v5a2 2 0 0 1-2 2 2 2 0 0 1 2 2v5a2 2 0 0 0 2 2h1m8 0h1a2 2 0 0 0 2-2v-5a2 2 0 0 1 2-2 2 2 0 0 1-2-2V5a2 2 0 0 0-2-2h-1', + 'reference/management-api/': 'M2 4h20v6H2Zm0 10h20v6H2ZM6 7h.01M6 17h.01', +}; +const iconFor = (href: string) => ICONS[href.slice(prefix.length)] ?? ICONS['guides/codex-integration/']; const fr = { 'Auto': 'Automatique', @@ -25,7 +52,7 @@ const fr = { 'Dark': 'Sombre', 'Theme': 'Thème', 'Click to use the next theme': 'Cliquez pour passer au thème suivant', - 'Getting Started': 'Démarrage', + 'Getting Started': 'Premiers pas', 'Installation': 'Installation', 'Quickstart': 'Démarrage rapide', 'How It Works': 'Fonctionnement', @@ -62,8 +89,8 @@ const starCount = await getStarCount(); const starLocale = locale ?? 'en'; const githubLabel = t('GitHub', 'GitHub', 'GitHub', 'GitHub', 'GitHub', 'GitHub', 'GitHub'); const starLabel = t( - 'GitHub, {count} stars', 'GitHub, 별 {count}개', 'GitHub,{count} 个星标', - 'GitHub, {count} звёзд', 'GitHub、{count} スター', 'GitHub,{count} 個星標', 'GitHub, {count} yıldız', + 'GitHub, {count} stars', 'GitHub, 스타 {count}개', 'GitHub,{count} 个星标', + 'GitHub, звёзд: {count}', 'GitHub、{count} スター', 'GitHub,{count} 個星標', 'GitHub, {count} yıldız', ); const githubAccessibleName = starCount === null ? githubLabel : starLabel.replace('{count}', starCount.toLocaleString(starLocale)); @@ -113,7 +140,7 @@ const groups: NavGroup[] = [ { label: t('Installation', '설치', '安装', 'Установка', 'インストール', '安裝', 'Kurulum'), href: `${prefix}getting-started/installation/` }, { label: t('Quickstart', '빠른 시작', '快速开始', 'Быстрый старт', 'クイックスタート', '快速入門', 'Hızlı Başlangıç'), href: `${prefix}getting-started/quickstart/` }, { label: t('How It Works', '동작 원리', '工作原理', 'Как это работает', '仕組み', '運作原理', 'Nasıl Çalışır'), href: `${prefix}getting-started/how-it-works/` }, - { label: t('Agent Quickstart', '에이전트 퀵스타트', 'Agent 快速上手', 'Быстрый старт для агентов', 'エージェント向けクイックスタート', 'Agent 快速上手', 'Ajanlar İçin Hızlı Başlangıç'), href: `${prefix}getting-started/for-agents/` }, + { label: t('Agent Quickstart', '에이전트용 빠른 시작', 'Agent 快速上手', 'Быстрый старт для агентов', 'エージェント向けクイックスタート', 'Agent 快速上手', 'Ajanlar İçin Hızlı Başlangıç'), href: `${prefix}getting-started/for-agents/` }, ], }, { @@ -145,17 +172,29 @@ const groups: NavGroup[] = [ }, ]; +// Eleven guides made the Guides panel read like a sitemap. The panel keeps +// the six people reach for first and ends in a link to the full docs map on +// the landing page (the sidebar still lists every guide on docs pages). +const GUIDE_PICKS = ['providers', 'model-routing', 'codex-integration', 'claude-code', 'sidecars', 'web-dashboard']; +const guides = groups[1]; +guides.links = guides.links.filter((link) => GUIDE_PICKS.some((slug) => link.href.endsWith(`guides/${slug}/`))); +guides.more = { + label: t('All guides', '가이드 전체 보기', '全部指南', 'Все руководства', 'すべてのガイド', '全部指南', 'Tüm kılavuzlar'), + href: `${prefix}#docs-map`, +}; + const contributing: NavLink = { - label: t('Contributing', '기여하기', '贡献', 'Участие в проекте', 'コントリビュート', '貢獻', 'Katkıda Bulunma'), + label: t('Contributing', '기여하기', '贡献指南', 'Участие в проекте', 'コントリビュート', '貢獻指南', 'Katkıda Bulunma'), href: `${prefix}contributing/`, }; --- -
+
-
@@ -764,7 +763,7 @@ const docsMap = [ diff --git a/docs-site/src/styles/custom.css b/docs-site/src/styles/custom.css index b20fb2a3985..79c578657cf 100644 --- a/docs-site/src/styles/custom.css +++ b/docs-site/src/styles/custom.css @@ -661,6 +661,10 @@ main:has(.lp) .content-panel:first-child { padding-block: 0; } border-radius: var(--ocx-radius); border: 1px solid var(--sl-color-hairline-light); } +@media (prefers-reduced-motion: reduce), (width < 48rem) { + .lp-scrub-canvas { display: none !important; } + .lp-scrub-poster { display: block !important; } +} .lp-scrub-caption { margin: 0; color: var(--sl-color-gray-3); @@ -1144,7 +1148,7 @@ footer.sl-flex .meta { color: var(--sl-color-gray-4); } .lp-scene { view-timeline: --scene block; } .lp-scene-hero, .lp-scene-quick { height: 150dvh; } /* long travel = slow frame rate feel on the scrub */ - .lp-scene-scrub { height: 480dvh; } + .lp-scene-scrub[data-scrub-active] { height: 480dvh; } .lp-scene-inner { position: sticky; top: calc(var(--sl-nav-height) + 0.5rem); diff --git a/docs-site/src/styles/site-theme.css b/docs-site/src/styles/site-theme.css index 2878dd71346..fb6050dc076 100644 --- a/docs-site/src/styles/site-theme.css +++ b/docs-site/src/styles/site-theme.css @@ -679,6 +679,9 @@ main:has(.lp) .sl-container { max-width: none; } left 0.6s cubic-bezier(0.22, 1, 0.36, 1); } .lp-cell:hover::before { opacity: 0.55; transform: translateX(-50%) scale(1); } +@media (prefers-reduced-motion: reduce) { + .lp-cell::before, .lp-cell:hover::before { transition: none; transform: translateX(-50%); } +} :root[data-theme='light'] .lp-cell:not(.lp-cell-providers):hover::before { opacity: 0.45; } /* ---- closing CTA: GitHub star on a cloud panel -------------------------- */ diff --git a/docs-site/tests/landing-motion.mjs b/docs-site/tests/landing-motion.mjs index 29fd18abaf3..ddfc66553a5 100644 --- a/docs-site/tests/landing-motion.mjs +++ b/docs-site/tests/landing-motion.mjs @@ -38,6 +38,7 @@ const move = async (page) => { }; const assertStillPoster = async (page) => { const poster = page.locator('.lp-scrub-poster'); + await page.waitForFunction(() => getComputedStyle(document.querySelector('.lp-scrub-poster')).display !== 'none'); await poster.scrollIntoViewIfNeeded(); await page.waitForFunction(() => { const image = document.querySelector('.lp-scrub-poster'); @@ -56,7 +57,97 @@ const assertStillPoster = async (page) => { assert.equal(firstFrame.ok(), true, 'first scrub frame is served'); assert.deepEqual(bytes, await firstFrame.body(), 'poster is the first scrub frame'); }; +const assertScrubMoves = async (page) => { + const canvas = page.locator('.lp-scrub-canvas'); + await page.waitForFunction(() => getComputedStyle(document.querySelector('.lp-scrub-canvas')).display !== 'none', null, { timeout: 5000 }); + const section = page.locator('.lp-scene-scrub'); + await section.evaluate(el => scrollTo(0, el.getBoundingClientRect().top + scrollY)); + await page.waitForFunction(async () => { + const canvas = document.querySelector('.lp-scrub-canvas'); + const image = document.querySelector('.lp-scrub-poster'); + await image.decode(); + const expected = document.createElement('canvas'); + expected.width = canvas.width; expected.height = canvas.height; + expected.getContext('2d').drawImage(image, 0, 0, expected.width, expected.height); + return canvas.toDataURL() === expected.toDataURL(); + }, null, { timeout: 5000 }); + const first = await canvas.evaluate(el => el.toDataURL()); + await section.evaluate(el => scrollTo(0, el.getBoundingClientRect().top + scrollY + (el.offsetHeight - innerHeight) * 0.8)); + await page.waitForFunction(previous => document.querySelector('.lp-scrub-canvas').toDataURL() !== previous, first, { timeout: 5000 }); +}; try { + const switches = await browser.newPage({ viewport: { width: 1280, height: 800 }, reducedMotion: 'reduce' }); + await open(switches); + await assertStillPoster(switches); + await switches.emulateMedia({ reducedMotion: 'no-preference' }); + await switches.locator('.lp-scene-scrub').scrollIntoViewIfNeeded(); + await assertScrubMoves(switches); + await switches.emulateMedia({ reducedMotion: 'reduce' }); + await pause(switches, 250); + await assertStillPoster(switches); + await switches.waitForFunction(() => !document.querySelector('.lp-scene-scrub').hasAttribute('data-scrub-active'), null, { timeout: 5000 }); + await switches.emulateMedia({ reducedMotion: 'no-preference' }); + await assertScrubMoves(switches); + await switches.setViewportSize({ width: 390, height: 844 }); + await assertStillPoster(switches); + assert.equal(await switches.locator('.lp-scrub-canvas').isVisible(), false, 'mobile hides the canvas'); + await switches.waitForFunction(() => !document.querySelector('.lp-scene-scrub').hasAttribute('data-scrub-active'), null, { timeout: 5000 }); + await switches.setViewportSize({ width: 1280, height: 800 }); + await switches.locator('.lp-scene-scrub').scrollIntoViewIfNeeded(); + await assertScrubMoves(switches); + await switches.close(); + + const mobileFirst = await browser.newPage({ viewport: { width: 390, height: 844 }, reducedMotion: 'no-preference' }); + await open(mobileFirst); + await assertStillPoster(mobileFirst); + await mobileFirst.setViewportSize({ width: 1280, height: 800 }); + await mobileFirst.locator('.lp-scene-scrub').scrollIntoViewIfNeeded(); + await assertScrubMoves(mobileFirst); + await mobileFirst.close(); + + const noScript = await browser.newPage({ viewport: { width: 1280, height: 800 }, javaScriptEnabled: false }); + await open(noScript); + await assertStillPoster(noScript); + assert.ok(await noScript.locator('.lp-scene-scrub').evaluate(el => el.offsetHeight < innerHeight * 2), 'static fallback has no long empty scroll'); + await noScript.close(); + + const unsupported = await browser.newPage({ viewport: { width: 1280, height: 800 }, reducedMotion: 'no-preference' }); + await unsupported.addInitScript(() => { + const supports = CSS.supports.bind(CSS); + CSS.supports = (...args) => args[0] === 'animation-timeline: view()' ? false : supports(...args); + }); + await open(unsupported); + await assertStillPoster(unsupported); + assert.equal(await unsupported.locator('.lp-scrub-canvas').isVisible(), false, 'unsupported timeline keeps the poster'); + assert.ok(await unsupported.locator('.lp-scene-scrub').evaluate(el => el.offsetHeight < innerHeight * 2), 'unsupported timeline has no long empty scroll'); + await unsupported.close(); + + const missingFrames = await browser.newPage({ viewport: { width: 1280, height: 800 }, reducedMotion: 'no-preference' }); + await missingFrames.route('**/demo-frames/*.webp', route => /\/001\.webp$/.test(route.request().url()) ? route.continue() : route.abort()); + await open(missingFrames); + await assertStillPoster(missingFrames); + await pause(missingFrames, 500); + assert.equal(await missingFrames.locator('.lp-scrub-canvas').isVisible(), false, 'missing sequence keeps the poster'); + assert.ok(await missingFrames.locator('.lp-scene-scrub').evaluate(el => el.offsetHeight < innerHeight * 2), 'missing sequence has no long empty scroll'); + await missingFrames.close(); + + const cssPage = await browser.newPage({ viewport: { width: 390, height: 844 }, reducedMotion: 'reduce' }); + await open(cssPage); + await cssPage.locator('.lp-menu-btn').focus(); + await cssPage.keyboard.press('Enter'); + const summary = cssPage.locator('.lp-menu-group > summary').first(); + await summary.focus(); + await cssPage.keyboard.press('Enter'); + assert.equal(await summary.locator('..').getAttribute('open'), '', 'accordion opens by keyboard'); + assert.equal(await summary.locator('svg').evaluate(el => getComputedStyle(el).transitionDuration), '0s', 'accordion arrow does not animate'); + await cssPage.keyboard.press('Escape'); + const glow = await cssPage.locator('.lp-cell').first().evaluate(el => getComputedStyle(el, '::before').transitionDuration); + assert.ok(glow.split(',').every(duration => parseFloat(duration) === 0), `card glow does not animate: ${glow}`); + const card = cssPage.locator('.lp-cell').first(); + const beforeGlow = await card.evaluate(el => getComputedStyle(el, '::before').transform); + await card.hover(); + assert.equal(await card.evaluate(el => getComputedStyle(el, '::before').transform), beforeGlow, 'card glow does not scale on hover'); + await cssPage.close(); const headerPage = await browser.newPage({ viewport: { width: 1280, height: 800 }, reducedMotion: 'no-preference' }); await open(headerPage, 'domcontentloaded'); await headerPage.waitForFunction(() => document.querySelector('.github-link [data-gh-stars]')?.style.filter); From 2a767d208a832c785ec7db0df99e9b48b5cc65b5 Mon Sep 17 00:00:00 2001 From: JUN Date: Mon, 9 Nov 2026 13:33:27 +0900 Subject: [PATCH 08/11] docs-site: label the Guides overflow row for exploration The Guides dropdown's overflow row said "All guides" but points at the landing page's curated #docs-map band, which lists 12 of the 40 guide pages; the adjacent comment claimed the docs sidebar lists every guide, and it lists 34 of the 40. Relabel the row "Explore guides" in every landing locale, add the missing French translation, and correct the comment. The href, the picked routes, focus/Escape behavior and the mobile splash row are unchanged. Add a focused guard pinning the copy in all seven locales, the #docs-map target and its curated links, the existing picked routes, the French entry, and the absence of the completeness claim in the comment. Register it through a new explicit layout shard because layout.json is at its line cap. --- docs-site/src/components/Header.astro | 9 +- scripts/test-layout/layout-docs-guides.json | 3 + scripts/test-layout/layout.json | 2 +- .../docs-navigation-label.test.ts | 131 ++++++++++++++++++ tests/fixtures/test-layout-expected.json | 2 +- 5 files changed, 142 insertions(+), 5 deletions(-) create mode 100644 scripts/test-layout/layout-docs-guides.json create mode 100644 tests/ci-workflows/docs-navigation-label.test.ts diff --git a/docs-site/src/components/Header.astro b/docs-site/src/components/Header.astro index 02d69927671..6ec684c9b56 100644 --- a/docs-site/src/components/Header.astro +++ b/docs-site/src/components/Header.astro @@ -75,6 +75,7 @@ const fr = { 'Proxy API Formats': 'Formats de l’API proxy', 'Management API': 'API de gestion', 'Contributing': 'Contribuer', + 'Explore guides': 'Explorer les guides', 'Main': 'Navigation principale', 'Select language': 'Choisir la langue', 'Menu': 'Menu', @@ -173,13 +174,15 @@ const groups: NavGroup[] = [ ]; // Eleven guides made the Guides panel read like a sitemap. The panel keeps -// the six people reach for first and ends in a link to the full docs map on -// the landing page (the sidebar still lists every guide on docs pages). +// the six people reach for first and ends in a row that jumps to the curated +// docs map on the landing page. That band is a selection of guide pages, not +// an index of them, and the docs sidebar is a subset too, so the row invites +// exploration instead of claiming it lists every guide. const GUIDE_PICKS = ['providers', 'model-routing', 'codex-integration', 'claude-code', 'sidecars', 'web-dashboard']; const guides = groups[1]; guides.links = guides.links.filter((link) => GUIDE_PICKS.some((slug) => link.href.endsWith(`guides/${slug}/`))); guides.more = { - label: t('All guides', '가이드 전체 보기', '全部指南', 'Все руководства', 'すべてのガイド', '全部指南', 'Tüm kılavuzlar'), + label: t('Explore guides', '가이드 둘러보기', '浏览指南', 'Обзор руководств', 'ガイドを探る', '瀏覽指南', 'Kılavuzlara göz atın'), href: `${prefix}#docs-map`, }; diff --git a/scripts/test-layout/layout-docs-guides.json b/scripts/test-layout/layout-docs-guides.json new file mode 100644 index 00000000000..4307dad9635 --- /dev/null +++ b/scripts/test-layout/layout-docs-guides.json @@ -0,0 +1,3 @@ +{ + "docs-navigation-label.test.ts": "ci-workflows" +} diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index ebf714ed3e3..470559fe4d0 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -1,7 +1,7 @@ { "version": 1, "root": "tests", - "explicitShards": ["layout-file-identity.json", "layout-clients.json", "layout-codex-collision.json", "layout-agent-assertion.json", "layout-chat.json", "layout-replay.json", "layout-adapters.json", "layout-jev.json", "layout-workbuddy.json", "layout-combos.json", "layout-credit-renewal.json", "layout-server-inbound.json", "layout-providers.json", "layout-responses.json", "layout-exec-boundary.json", "layout-persistent-dashboard.json", "layout-start-ownership.json", "layout-config.json", "layout-kiro-usage.json", "layout-usage.json"], + "explicitShards": ["layout-file-identity.json", "layout-clients.json", "layout-codex-collision.json", "layout-agent-assertion.json", "layout-chat.json", "layout-replay.json", "layout-adapters.json", "layout-jev.json", "layout-workbuddy.json", "layout-combos.json", "layout-credit-renewal.json", "layout-server-inbound.json", "layout-providers.json", "layout-responses.json", "layout-exec-boundary.json", "layout-persistent-dashboard.json", "layout-start-ownership.json", "layout-config.json", "layout-kiro-usage.json", "layout-usage.json", "layout-docs-guides.json"], "explicit": { "kimi-monthly-quota.test.ts": "providers", "sync-fanout.test.ts": "cli", "sync-command-fanout.test.ts": "cli", "link-pinned-sync-argv.test.ts": "clients", "sync-link-status.test.ts": "cli", "sync-fanout-process.test.ts": "cli", "codex-installation-acl.test.ts": "codex-integration", "codex-installation-id.test.ts": "codex-integration", "codex-installation-wire.test.ts": "codex-integration", "codex-installation-failover.test.ts": "responses", "service-desktop-recovery.test.ts": "service", "catalog-omit-disabled-native.test.ts": "codex-integration", "provider-input-modalities.test.ts": "providers", "combo-description.test.ts": "routing", "combo-description-catalog.test.ts": "codex-integration", "codex-provider-routing.test.ts": "codex-integration", "config-native-reasoning-retention.test.ts": "config", "server-native-reasoning-retention-merge.test.ts": "server", "server-native-reasoning-retention.test.ts": "server", "config-workflow-budget.test.ts": "config", "server-workflow-budget-settings.test.ts": "server", "responses-retained-reasoning-stream-recovery.test.ts": "responses", "openai-native-hosted-search-history.test.ts": "adapters/openai", "responses-native-compact-search-history.test.ts": "responses", "config-hosted-search-projection.test.ts": "config", "gcp-credential-boundary.test.ts": "adapters/google", "gcp-sa-marker.test.ts": "adapters/google", "gcp-sa-adapter.test.ts": "adapters/google", "management-gcp-credential-input.test.ts": "server", "codex-preserve-signed-in.test.ts": "codex-integration", "cli-account-preservation.test.ts": "cli", "cli-provider-list-active.test.ts": "cli", "service-state-isolation.test.ts": "service", "compaction-history-framing.test.ts": "responses", "catalog-native-max-context.test.ts": "codex-integration", "protocol-argon-thought-boundary.test.ts": "responses", "service-log.test.ts": "service", "messages-native-empty-inline.test.ts": "claude-integration", "catalog-auto-review-override.test.ts": "codex-integration", "management-auto-review-settings.test.ts": "server", "management-auto-review-auth.test.ts": "server", "prefer-model-alias-ids.test.ts": "server", "responses-mimo-envelope.test.ts": "responses", "responses-call-id-linkage.test.ts": "responses", "bridge-terminal-refusal.test.ts": "adapters", "loop-terminal-refusal.test.ts": "images", "web-search-terminal-refusal.test.ts": "web-search", "combo-terminal-refusal.test.ts": "routing", "server-terminal-refusal-wiring.test.ts": "server", "incomplete-reason-normalization.test.ts": "responses", "advisor-retention.test.ts": "advisor", "responses-native-key-cooldown.test.ts": "responses", "responses-run-turn-key-readmission.test.ts": "responses", "qoder-spawn-readmission.test.ts": "adapters", "api-key-selection-cooldown.test.ts": "providers", "api-key-auto-pause-distribution.test.ts": "providers", "explicit-key-pause-oauth-boundary.test.ts": "providers", "server-chat-native-key-selection.test.ts": "server", "compact-key-selection.test.ts": "responses", "messages-native-key-selection.test.ts": "claude-integration", "coding-agent-windows-dispatch.test.ts": "adapters", "coding-agent-native-empty-argv.test.ts": "windows", "stop-restore-history-ownership.test.ts": "service", "cli-stop-refusal-transport.test.ts": "cli", diff --git a/tests/ci-workflows/docs-navigation-label.test.ts b/tests/ci-workflows/docs-navigation-label.test.ts new file mode 100644 index 00000000000..af9bb480230 --- /dev/null +++ b/tests/ci-workflows/docs-navigation-label.test.ts @@ -0,0 +1,131 @@ +/** + * The Guides dropdown shows six picked guides and ends in one overflow row that + * links to the landing page's `#docs-map` band. That band is a curated overview + * (12 of the 40 guide pages in the content tree), and even the docs sidebar + * lists 34 of the 40. A row promising "All guides" therefore lies about its + * destination, and the comment beside it repeated the same false claim about the + * sidebar. + * + * The landing band it targets is already titled "Explore the docs", so the row + * says the same thing: explore. This regression pins the copy in every supported + * locale, proves the link still reaches the existing map band, and keeps the + * corrected comment from drifting back to the completeness claim. + * + * Docs-only pull requests run `docs-site-build` rather than this Bun suite, so the + * Astro build remains the authority for the rendered site; this file is the + * repository-side copy contract alongside the other docs-*.test.ts guards. + */ +import { expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import { repoPath } from "../helpers/repo-root"; + +const HEADER = repoPath("docs-site/src/components/Header.astro"); +const LANDING = repoPath("docs-site/src/components/Landing.astro"); + +// Argument order of Header.astro's `t(en, ko, zh, ru, ja, zhTW, tr)` helper. +const LOCALE_ORDER = ["en", "ko", "zh-cn", "ru", "ja", "zh-tw", "tr"] as const; + +const EXPLORE_GUIDES: Record<(typeof LOCALE_ORDER)[number], string> = { + en: "Explore guides", + ko: "가이드 둘러보기", + "zh-cn": "浏览指南", + ru: "Обзор руководств", + ja: "ガイドを探る", + "zh-tw": "瀏覽指南", + tr: "Kılavuzlara göz atın", +}; + +// Wording that claims the destination is exhaustive. The destination is not. +const TOTALITY: Record<(typeof LOCALE_ORDER)[number], RegExp> = { + en: /\b(?:all|every|full|complete|entire|whole)\b/i, + ko: /전체|모든/, + "zh-cn": /全部|所有/, + ru: /все|всех|полн/i, + ja: /すべて|全て|全部/, + "zh-tw": /全部|所有/, + tr: /\btüm\b/i, +}; + +const GUIDE_PICKS = ["providers", "model-routing", "codex-integration", "claude-code", "sidecars", "web-dashboard"]; + +function source(path: string): string { + return readFileSync(path, "utf8"); +} + +function moreBlock(): string { + const header = source(HEADER); + const start = header.indexOf("guides.more = {"); + expect(start).toBeGreaterThanOrEqual(0); + const end = header.indexOf("};", start); + expect(end).toBeGreaterThan(start); + return header.slice(start, end); +} + +function labelArgs(): string[] { + const call = /label:\s*t\(([^)]*)\)/.exec(moreBlock()); + expect(call).not.toBeNull(); + return [...call![1]!.matchAll(/'([^']*)'/g)].map((match) => match[1]!); +} + +test("the Guides overflow row promises exploration, not a complete index", () => { + const args = labelArgs(); + expect(args).toHaveLength(LOCALE_ORDER.length); + const labels = Object.fromEntries(LOCALE_ORDER.map((locale, index) => [locale, args[index]!])); + expect(labels).toEqual(EXPLORE_GUIDES); + for (const locale of LOCALE_ORDER) { + expect(TOTALITY[locale].test(labels[locale]!), `${locale}: ${labels[locale]}`).toBe(false); + } +}); + +test("the overflow row keeps pointing at the landing documentation map", () => { + expect(moreBlock()).toContain("href: `${prefix}#docs-map`"); +}); + +test("the landing documentation map band still exists and keeps its curated links", () => { + const landing = source(LANDING); + expect(landing).toContain('id="docs-map"'); + const start = landing.indexOf("const docsMap = ["); + expect(start).toBeGreaterThanOrEqual(0); + const end = landing.indexOf("\n];", start); + expect(end).toBeGreaterThan(start); + const map = landing.slice(start, end); + const guideLinks = [...map.matchAll(/href: `\$\{prefix\}guides\/[^`]+`/g)]; + expect(guideLinks.length).toBeGreaterThanOrEqual(12); +}); + +test("the Guides dropdown keeps every existing picked route", () => { + const header = source(HEADER); + const picks = /const GUIDE_PICKS = \[([^\]]*)\]/.exec(header); + expect(picks).not.toBeNull(); + const slugs = [...picks![1]!.matchAll(/'([^']+)'/g)].map((match) => match[1]!); + expect(slugs).toEqual(GUIDE_PICKS); + expect(header).toContain("guides.links = guides.links.filter"); +}); + +test("French renders a real label instead of the undefined fallback", () => { + const header = source(HEADER); + const start = header.indexOf("const fr = {"); + expect(start).toBeGreaterThanOrEqual(0); + const end = header.indexOf("} as const;", start); + expect(end).toBeGreaterThan(start); + expect(header.slice(start, end)).toContain("'Explore guides': 'Explorer les guides'"); +}); + +test("the code comment stops claiming the sidebar lists every guide", () => { + const header = source(HEADER); + const picksAt = header.indexOf("const GUIDE_PICKS"); + expect(picksAt).toBeGreaterThanOrEqual(0); + // The note is a contiguous block of `//` lines directly above GUIDE_PICKS; slice + // the whole block so the assertion reads every line, not just the last one. + const lines = header.slice(0, picksAt).split("\n").slice(0, -1); + let first = lines.length; + while (first > 0 && lines[first - 1]!.trimStart().startsWith("//")) first -= 1; + const comment = lines.slice(first).join("\n"); + // The old comment asserted "the sidebar still lists every guide". The sidebar + // lists 34 of the 40 guide pages, so that assertion is the defect. Match the + // exact sentence so a rewording cannot quietly restore the completeness claim, + // and so the fix's own "instead of claiming it lists every guide" phrasing does + // not trip a blanket word match. + expect(comment).not.toContain("sidebar still lists every guide"); + expect(comment).toMatch(/curated|picked|selection|subset/i); +}); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 6240bf32f63..4049afc45cb 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -852,7 +852,7 @@ "digitalocean-scaleway-provider.test.ts": "providers", "docs-429-failover-claims.test.ts": "ci-workflows", "docs-bun-source-requirement.test.ts": "ci-workflows", "docs-developer-role-policy.test.ts": "ci-workflows", "docs-gpt61-terra.test.ts": "ci-workflows", - "docs-gui-screenshot-policy.test.ts": "ci-workflows", "docs-link-targets.test.ts": "ci-workflows", + "docs-gui-screenshot-policy.test.ts": "ci-workflows", "docs-link-targets.test.ts": "ci-workflows", "docs-navigation-label.test.ts": "ci-workflows", "docs-gjc-reasoning-export.test.ts": "ci-workflows", "docs-provider-billing-claims.test.ts": "ci-workflows", "docs-provider-discovery-limits.test.ts": "ci-workflows", "docs-provider-preset-counts.test.ts": "ci-workflows", "docs-readme-memory-inventory.test.ts": "ci-workflows", "docs-readme-translation-parity.test.ts": "ci-workflows", From cf1b8e04a02d4b6d00c3bf4b995502352c20ba80 Mon Sep 17 00:00:00 2001 From: JUN Date: Mon, 9 Nov 2026 16:55:07 +0900 Subject: [PATCH 09/11] fix(docs): keep desktop menus usable at short heights Constrain desktop dropdowns to the viewport, keep the moving highlight aligned with scrolled links, and disable navigation highlight transitions under reduced motion. Exercise keyboard reachability and rendered navigation bounds. --- docs-site/src/components/Header.astro | 9 +++- docs-site/tests/landing-motion.mjs | 60 ++++++++++++++++++++++++++- 2 files changed, 66 insertions(+), 3 deletions(-) diff --git a/docs-site/src/components/Header.astro b/docs-site/src/components/Header.astro index 4de37eb4149..561857d0a3e 100644 --- a/docs-site/src/components/Header.astro +++ b/docs-site/src/components/Header.astro @@ -720,6 +720,9 @@ const contributing: NavLink = { .nav-panel { position: relative; width: 19rem; + max-height: max(0px, calc(100dvh - var(--sl-nav-height) - 1.5rem)); + overflow-y: auto; + overscroll-behavior: contain; padding: 0.5rem; border: 1px solid color-mix(in srgb, var(--sl-color-white) 9%, transparent); border-radius: 1rem; @@ -876,6 +879,8 @@ const contributing: NavLink = { @media (prefers-reduced-motion: reduce) { .nav-link svg, .nav-drop, + .nav-hl, + .nav-top-hl, .nav-tile, .nav-arrow { transition: none !important; @@ -1228,8 +1233,8 @@ const contributing: NavLink = { const c = container.getBoundingClientRect(); const r = item.getBoundingClientRect(); if (snap) hl.setAttribute('data-snap', ''); - hl.style.setProperty('--hl-x', `${r.left - c.left}px`); - hl.style.setProperty('--hl-y', `${r.top - c.top}px`); + hl.style.setProperty('--hl-x', `${r.left - c.left + container.scrollLeft}px`); + hl.style.setProperty('--hl-y', `${r.top - c.top + container.scrollTop}px`); hl.style.setProperty('--hl-w', `${r.width}px`); hl.style.setProperty('--hl-h', `${r.height}px`); if (snap) { diff --git a/docs-site/tests/landing-motion.mjs b/docs-site/tests/landing-motion.mjs index 104dd779085..03deaf0734b 100644 --- a/docs-site/tests/landing-motion.mjs +++ b/docs-site/tests/landing-motion.mjs @@ -76,6 +76,54 @@ const assertScrubMoves = async (page) => { await page.waitForFunction(previous => document.querySelector('.lp-scrub-canvas').toDataURL() !== previous, first, { timeout: 5000 }); }; try { + // A short desktop viewport must keep the entire dropdown reachable by keyboard, + // and reduced motion must also stop the two sliding navigation highlights. + const shortNav = await browser.newPage({ viewport: { width: 1280, height: 360 }, reducedMotion: 'reduce' }); + await shortNav.route('https://api.github.com/**', route => route.abort()); + await shortNav.goto(new URL('guides/providers/', url).href, { waitUntil: 'load' }); + await shortNav.locator('[aria-controls="nav-drop-2"]').click(); + const referencePanel = shortNav.locator('#nav-drop-2 .nav-panel'); + const referenceLinks = referencePanel.locator('ul a'); + await referenceLinks.first().focus(); + for (let i = 1; i < await referenceLinks.count(); i++) await shortNav.keyboard.press('Tab'); + assert.equal(await referenceLinks.last().evaluate(el => el === document.activeElement), true, 'Tab reaches the last reference link'); + const shortNavState = await referencePanel.evaluate(panel => { + const link = document.activeElement.getBoundingClientRect(); + const rect = panel.getBoundingClientRect(); + const highlight = panel.querySelector('.nav-hl'); + const topHighlight = document.querySelector('.nav-top-hl'); + const hlRect = highlight.getBoundingClientRect(); + return { bottom: rect.bottom, viewport: innerHeight, overflow: getComputedStyle(panel).overflowY, + scrollTop: panel.scrollTop, linkTop: link.top, linkBottom: link.bottom, panelTop: rect.top, + highlightTop: hlRect.top, highlightTransitions: getComputedStyle(highlight).transitionDuration, + topTransitions: getComputedStyle(topHighlight).transitionDuration }; + }); + assert.ok(shortNavState.bottom <= shortNavState.viewport + 1, `dropdown fits viewport: ${JSON.stringify(shortNavState)}`); + assert.equal(shortNavState.overflow, 'auto', 'dropdown scrolls when its contents exceed the viewport'); + assert.ok(shortNavState.scrollTop > 0 && shortNavState.linkTop >= shortNavState.panelTop && shortNavState.linkBottom <= shortNavState.bottom, + `last keyboard-focused link is visible: ${JSON.stringify(shortNavState)}`); + assert.ok(Math.abs(shortNavState.highlightTop - shortNavState.linkTop) <= 2, `highlight follows the scrolled link: ${JSON.stringify(shortNavState)}`); + for (const transitions of [shortNavState.highlightTransitions, shortNavState.topTransitions]) { + assert.ok(transitions.split(',').every(duration => parseFloat(duration) === 0), `reduced-motion highlights do not animate: ${transitions}`); + } + await shortNav.close(); + + const animatedNav = await browser.newPage({ viewport: { width: 1280, height: 360 }, reducedMotion: 'no-preference' }); + await animatedNav.route('https://api.github.com/**', route => route.abort()); + await animatedNav.goto(new URL('guides/providers/', url).href, { waitUntil: 'load' }); + await animatedNav.locator('[aria-controls="nav-drop-2"]').click(); + const animatedPanel = animatedNav.locator('#nav-drop-2 .nav-panel'); + const animatedLinks = animatedPanel.locator('ul a'); + await animatedLinks.first().focus(); + for (let i = 1; i < await animatedLinks.count(); i++) await animatedNav.keyboard.press('Tab'); + await animatedNav.waitForFunction(() => { + const panel = document.querySelector('#nav-drop-2 .nav-panel'); + const highlight = panel.querySelector('.nav-hl').getBoundingClientRect(); + const link = document.activeElement.getBoundingClientRect(); + return panel.scrollTop > 0 && Math.abs(highlight.top - link.top) <= 2; + }, null, { timeout: 3000 }); + await animatedNav.close(); + // The desktop header must contain its controls even when translations are wide. // Cover both the landing and docs headers at the nav breakpoint and wide rail cap. for (const locale of ['', 'fr/', 'ko/', 'zh-cn/', 'zh-tw/', 'ru/', 'ja/', 'tr/']) { @@ -98,10 +146,20 @@ try { const bounds = await navPage.evaluate(() => { const bar = document.querySelector('header.header > .header'); const barRect = bar.getBoundingClientRect(); - return [...bar.children].filter(el => getComputedStyle(el).display !== 'none').map(el => { + const items = [...bar.children].filter(el => getComputedStyle(el).display !== 'none').map(el => { const rect = el.getBoundingClientRect(); return { name: el.className, left: rect.left - barRect.left, right: rect.right - barRect.right }; }); + const nav = bar.querySelector('.glass-nav'); + if (nav && getComputedStyle(nav).display !== 'none') { + const navRect = nav.getBoundingClientRect(); + for (const el of nav.querySelectorAll(':scope > ul > li > .nav-link')) { + const rect = el.getBoundingClientRect(); + items.push({ name: `nav control ${el.textContent.trim()}`, + left: rect.left - navRect.left, right: rect.right - navRect.right }); + } + } + return items; }); for (const item of bounds) { assert.ok(item.left >= -1 && item.right <= 1, From 48fd618dd3f4bdbb7c57fa0521e5d736cd49fa0f Mon Sep 17 00:00:00 2001 From: JUN Date: Mon, 9 Nov 2026 18:05:41 +0900 Subject: [PATCH 10/11] fix(docs): preserve header bounds with scoped star counts Use the component-scoped star count in browser fixtures and reduce desktop navigation padding so long translated labels remain within the header. --- docs-site/src/components/Header.astro | 3 +-- docs-site/tests/landing-motion.mjs | 16 ++++++++-------- 2 files changed, 9 insertions(+), 10 deletions(-) diff --git a/docs-site/src/components/Header.astro b/docs-site/src/components/Header.astro index 561857d0a3e..7e86a7de5dc 100644 --- a/docs-site/src/components/Header.astro +++ b/docs-site/src/components/Header.astro @@ -668,7 +668,7 @@ const contributing: NavLink = { align-items: center; gap: 0.25rem; height: 2.25rem; - padding: 0 clamp(0.3rem, 0.45vw, 0.5rem); + padding: 0 0.25rem; border: 0; border-radius: 0.75rem; background: transparent; @@ -682,7 +682,6 @@ const contributing: NavLink = { white-space: nowrap; transition: color 0.2s; } - button.nav-link { padding-inline-end: clamp(0.3rem, 0.45vw, 0.5rem); } .nav-link svg { width: 14px; height: 14px; opacity: 0.5; transition: transform 0.2s cubic-bezier(0.22, 1, 0.36, 1); } .nav-item:hover .nav-link, .nav-item:focus-within .nav-link { diff --git a/docs-site/tests/landing-motion.mjs b/docs-site/tests/landing-motion.mjs index 03deaf0734b..7e2facafeff 100644 --- a/docs-site/tests/landing-motion.mjs +++ b/docs-site/tests/landing-motion.mjs @@ -8,6 +8,12 @@ const { chromium } = await import(pathToFileURL(process.env.PLAYWRIGHT_CORE).hre const browser = await chromium.launch({ executablePath: process.env.CHROME_PATH, headless: true }); const url = process.env.DOCS_URL ?? 'http://127.0.0.1:4317/'; const pause = (page, ms = 150) => page.waitForTimeout(ms); +const withHeaderCount = html => html.replace(/]*>[^<]*<\/span>/, ''); + return withoutCount.replace('', ``); +}); const open = async (page, waitUntil = 'load') => { // Keep the counter test deterministic when GitHub is unavailable at build time. await page.route(url, async (route) => { @@ -17,10 +23,7 @@ const open = async (page, waitUntil = 'load') => { const withoutCount = link.replace(/[^<]*<\/span>/, ''); return withoutCount.replace('', '18.3k'); }); - const body = landing.replace(/]*data-gh-stars="\d+"[^>]*>[^<]*<\/span>/, ''); - return withoutCount.replace('', '18.3k'); - }); + const body = withHeaderCount(landing); await route.fulfill({ response, body }); }); await page.goto(url, { waitUntil }); @@ -135,10 +138,7 @@ try { await navPage.route(navUrl, async route => { const response = await route.fetch(); const html = await response.text(); - const body = html.replace(/]*data-gh-stars="\d+"[^>]*>[^<]*<\/span>/, ''); - return withoutCount.replace('', ''); - }); + const body = withHeaderCount(html); await route.fulfill({ response, body }); }); await navPage.goto(navUrl, { waitUntil: 'load' }); From 6333cbed91f807aa291d8e5bd4cb109625f9ad41 Mon Sep 17 00:00:00 2001 From: JUN Date: Mon, 9 Nov 2026 18:17:48 +0900 Subject: [PATCH 11/11] fix(docs): keep mobile header actions clickable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reserve space for Starlight’s fixed menu button on docs pages so it does not cover the GitHub link. Exercise pointer hit-testing with a scoped star count across mobile widths. --- docs-site/src/styles/site-theme.css | 6 ++++++ docs-site/tests/landing-motion.mjs | 23 +++++++++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/docs-site/src/styles/site-theme.css b/docs-site/src/styles/site-theme.css index fb6050dc076..6790699d547 100644 --- a/docs-site/src/styles/site-theme.css +++ b/docs-site/src/styles/site-theme.css @@ -184,6 +184,12 @@ header.header > .header { backdrop-filter: none; -webkit-backdrop-filter: none; } +/* Starlight's fixed docs menu button sits outside this bar on narrow screens. */ +@media (max-width: 49.99rem) { + html[data-has-sidebar] header.header > .header { + padding-inline-end: calc(44px + 0.5rem); + } +} /* the glass lives on a pseudo-element: backdrop-filter on the bar itself would become the containing block of the fixed full-screen menu */ header.header > .header::before { diff --git a/docs-site/tests/landing-motion.mjs b/docs-site/tests/landing-motion.mjs index 7e2facafeff..41ac5eae31a 100644 --- a/docs-site/tests/landing-motion.mjs +++ b/docs-site/tests/landing-motion.mjs @@ -79,6 +79,29 @@ const assertScrubMoves = async (page) => { await page.waitForFunction(previous => document.querySelector('.lp-scrub-canvas').toDataURL() !== previous, first, { timeout: 5000 }); }; try { + // On docs pages Starlight's fixed menu button must not cover the GitHub link. + for (const width of [320, 375, 414, 640, 768, 799]) { + const docsHeader = await browser.newPage({ viewport: { width, height: 800 }, reducedMotion: 'reduce' }); + await docsHeader.route('https://api.github.com/**', route => route.abort()); + const docsUrl = new URL('guides/providers/', url).href; + await docsHeader.route(docsUrl, async route => { + const response = await route.fetch(); + await route.fulfill({ response, body: withHeaderCount(await response.text()) }); + }); + await docsHeader.goto(docsUrl, { waitUntil: 'load' }); + const github = docsHeader.locator('.github-link'); + const hitTest = await github.evaluate(el => { + const rect = el.getBoundingClientRect(); + const hit = document.elementFromPoint(rect.left + rect.width / 2, rect.top + rect.height / 2); + const menu = document.querySelector('starlight-menu-button button').getBoundingClientRect(); + return { receivesPointer: !!hit && el.contains(hit), linkRight: rect.right, menuLeft: menu.left }; + }); + assert.equal(hitTest.receivesPointer, true, `GitHub link receives pointer input at ${width}px`); + assert.ok(hitTest.linkRight <= hitTest.menuLeft + 1, `GitHub link does not overlap the docs menu at ${width}px`); + await github.click({ trial: true, timeout: 2000 }); + await docsHeader.close(); + } + // A short desktop viewport must keep the entire dropdown reachable by keyboard, // and reduced motion must also stop the two sliding navigation highlights. const shortNav = await browser.newPage({ viewport: { width: 1280, height: 360 }, reducedMotion: 'reduce' });