| 1 | /** |
| 2 | * Interaction contract for #5290 — clickable chrome on non-English routes. |
| 3 | * |
| 4 | * Reproduction (Chromium, codewhale.net, 1536×900): `.paper-wordmark` was |
| 5 | * 0×42px on `/de` and `/pt-BR`, 35×42px on `/id`, and 98×42px on |
| 6 | * `/de/docs/guide` at 1280. The 2xl companion labels plus an unbounded |
| 7 | * locale <select> ate the 76rem strip; `overflow-x: clip` then left the |
| 8 | * home control with no hit target. English still had a 216px wordmark. |
| 9 | * |
| 10 | * This file pins the layout and route-handler decisions that keep a |
| 11 | * usable hit target, for every routed locale, without a browser. |
| 12 | */ |
| 13 | import { readFileSync } from "node:fs"; |
| 14 | import { describe, expect, it } from "vitest"; |
| 15 | import { locales } from "./config"; |
| 16 | import { getChrome } from "./dictionaries"; |
| 17 | import { navLinks } from "./links"; |
| 18 | import { isDocsPath, replacePathLocale } from "./path"; |
| 19 | import { siteCss } from "../site-css"; |
| 20 | |
| 21 | const webRoot = new URL("../../", import.meta.url); |
| 22 | |
| 23 | function webText(path: string): string { |
| 24 | return readFileSync(new URL(path, webRoot), "utf8"); |
| 25 | } |
| 26 | |
| 27 | /** Rough Latin/CJK advance at the nav's 0.78rem body size (16px root). */ |
| 28 | function advance(text: string, fontRem: number): number { |
| 29 | let px = 0; |
| 30 | for (const ch of text) { |
| 31 | const code = ch.codePointAt(0) ?? 0; |
| 32 | const wide = code > 0x2e80 || (code >= 0x1100 && code <= 0x11ff); |
| 33 | px += (wide ? 1.05 : 0.58) * fontRem * 16; |
| 34 | } |
| 35 | return px; |
| 36 | } |
| 37 | |
| 38 | describe("localized chrome keeps a clickable home control", () => { |
| 39 | const css = siteCss(); |
| 40 | const theme = webText("components/theme-toggle.tsx"); |
| 41 | const locale = webText("components/locale-switcher.tsx"); |
| 42 | |
| 43 | // Quiet icon controls are 2.5rem squares 2px apart (GPUI rail metrics). |
| 44 | const icon = 2.5 * 16; |
| 45 | const iconGap = 2; |
| 46 | |
| 47 | it("keeps the wordmark from shrinking to zero and the controls icon-only", () => { |
| 48 | // The floor is `min-width`, not a zero shrink factor: flexbox will not |
| 49 | // take an item below its `min-width`, so the box can still give space |
| 50 | // back when the row is over budget. |
| 51 | expect(css).toMatch(/\.paper-wordmark\s*\{[\s\S]*?flex:\s*0 1 auto;/); |
| 52 | expect(css).toMatch(/\.paper-wordmark\s*\{[\s\S]*?min-width:\s*8\.75rem;/); |
| 53 | expect(css).toMatch(/\.nav-icon-button\s*\{[\s\S]*?width:\s*2\.5rem;\s*height:\s*2\.5rem;/); |
| 54 | expect(css).toMatch(/\.site-nav-actions\s*\{[\s\S]*?gap:\s*2px;/); |
| 55 | // The theme and locale controls carry their state in the accessible |
| 56 | // name, not in visible text, so no translated word can widen the strip. |
| 57 | // The native <select> (an unbounded one once ate 124px, #5290) sits |
| 58 | // transparent over the globe glyph and is clipped to the icon box. |
| 59 | expect(theme).not.toMatch(/\{labels\[shown\]\}\s*</); |
| 60 | expect(locale).toContain('className="nav-icon-button nav-locale"'); |
| 61 | expect(css).toMatch(/\.nav-locale select\s*\{[\s\S]*?inset:\s*0;[\s\S]*?opacity:\s*0;/); |
| 62 | }); |
| 63 | |
| 64 | it("fits every locale's desktop strip at the lg floor", () => { |
| 65 | // At lg (1024px) --container is min(100% - 32px, 76rem) → 992px. The |
| 66 | // strip is wordmark + links + [theme, locale, GitHub, Sign in, Install]. |
| 67 | const container = 1024 - 32; |
| 68 | const wordmarkMin = 8.75 * 16; |
| 69 | const gaps = 1.5 * 16 * 2; |
| 70 | for (const code of locales) { |
| 71 | const chrome = getChrome(code); |
| 72 | const links = navLinks(code, chrome); |
| 73 | const navWidth = |
| 74 | links.reduce((sum, link) => sum + Math.max(advance(link.label, 0.78), 23), 0) + |
| 75 | 20 * Math.max(links.length - 1, 0); |
| 76 | const signIn = 0.5 * 16 + 1.5 * 16 + advance(chrome.authSignIn, 0.8125); |
| 77 | const install = 0.25 * 16 + 1.7 * 16 + advance(chrome.installCta, 0.8125); |
| 78 | const actions = 3 * icon + 2 * iconGap + signIn + install; |
| 79 | const used = wordmarkMin + navWidth + actions + gaps; |
| 80 | expect(used, `${code} strip ${Math.round(used)}px`).toBeLessThanOrEqual(container); |
| 81 | for (const link of links) { |
| 82 | expect(link.href.startsWith(`/${code}/`), `${code} ${link.href}`).toBe(true); |
| 83 | } |
| 84 | } |
| 85 | }); |
| 86 | |
| 87 | it("fits the compact nav strip inside a 375px viewport", () => { |
| 88 | // --container is min(100% - 2rem, 76rem) → 343px at 375. Below lg the |
| 89 | // strip is wordmark + [theme, locale, menu]: the desktop nav, Sign in and |
| 90 | // Install are display:none, and the GitHub icon goes at 900px. |
| 91 | const container = 375 - 2 * 16; |
| 92 | const innerGap = 0.5 * 16; |
| 93 | const actions = 3 * icon + 2 * iconGap; |
| 94 | // Even at the wordmark's phone cap the row fits without shrinking it. |
| 95 | expect(9.75 * 16 + innerGap + actions).toBeLessThanOrEqual(container); |
| 96 | expect(css).toMatch(/\.paper-wordmark\s*\{[\s\S]*?flex:\s*0 1 auto;/); |
| 97 | expect(css).toMatch( |
| 98 | /@media \(max-width: 520px\)[\s\S]*?\.paper-wordmark\s*\{\s*max-width:\s*9\.75rem;/, |
| 99 | ); |
| 100 | }); |
| 101 | |
| 102 | it("keeps locale switching on the shared path helpers and the theme control on every page", () => { |
| 103 | expect(replacePathLocale("/pt-BR/docs/guide", "ja")).toBe("/ja/docs/guide"); |
| 104 | expect(replacePathLocale("/de", "zh")).toBe("/zh"); |
| 105 | expect(isDocsPath("/pt-BR/docs/guide")).toBe(true); |
| 106 | expect(isDocsPath("/id/install")).toBe(false); |
| 107 | expect(webText("components/locale-switcher.tsx")).toContain("replacePathLocale"); |
| 108 | expect(theme).not.toContain("isDocsPath"); |
| 109 | }); |
| 110 | }); |
| 111 |