| 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 | |
| 20 | const webRoot = new URL("../../", import.meta.url); |
| 21 | |
| 22 | function webText(path: string): string { |
| 23 | return readFileSync(new URL(path, webRoot), "utf8"); |
| 24 | } |
| 25 | |
| 26 | /** Rough Latin/CJK advance at the nav's 0.78rem body size (16px root). */ |
| 27 | function advance(text: string, fontRem: number): number { |
| 28 | let px = 0; |
| 29 | for (const ch of text) { |
| 30 | const code = ch.codePointAt(0) ?? 0; |
| 31 | const wide = code > 0x2e80 || (code >= 0x1100 && code <= 0x11ff); |
| 32 | px += (wide ? 1.05 : 0.58) * fontRem * 16; |
| 33 | } |
| 34 | return px; |
| 35 | } |
| 36 | |
| 37 | describe("localized chrome keeps a clickable home control", () => { |
| 38 | const css = webText("app/globals.css"); |
| 39 | const theme = webText("components/theme-toggle.tsx"); |
| 40 | |
| 41 | it("keeps the wordmark and locale switcher from shrinking to zero", () => { |
| 42 | // The floor is `min-width`, not a zero shrink factor: flexbox will not |
| 43 | // take an item below its `min-width`, so the box can still give space |
| 44 | // back when the row is over budget. |
| 45 | expect(css).toMatch(/\.paper-wordmark\s*\{[\s\S]*?flex:\s*0 1 auto;/); |
| 46 | expect(css).toMatch(/\.paper-wordmark\s*\{[\s\S]*?min-width:\s*8\.75rem;/); |
| 47 | expect(css).toMatch( |
| 48 | /\.site-nav-actions select\s*\{\s*width:\s*6\.75rem;\s*max-width:\s*6\.75rem;/, |
| 49 | ); |
| 50 | expect(theme).toContain("hidden 2xl:inline"); |
| 51 | expect(theme).not.toContain("hidden sm:inline"); |
| 52 | }); |
| 53 | |
| 54 | it("fits every locale's desktop strip inside the 76rem container", () => { |
| 55 | // --container: min(100% - 2rem, 76rem). The desktop strip is the 76rem |
| 56 | // cap (1216px). Wordmark 8.75rem, select 6.75rem, remaining actions |
| 57 | // measured on the 1536 deployed pass, minus the 124px the unbounded |
| 58 | // select used to steal and the theme word that no longer shows below 2xl. |
| 59 | const container = 76 * 16; |
| 60 | const wordmarkMin = 8.75 * 16; |
| 61 | const gaps = 1.5 * 16 * 2; |
| 62 | const select = 6.75 * 16; |
| 63 | const actionsBesidesSelect = 542 - 232; |
| 64 | const actionBudget = select + actionsBesidesSelect; |
| 65 | |
| 66 | for (const locale of locales) { |
| 67 | const links = navLinks(locale, getChrome(locale)); |
| 68 | const navWidth = |
| 69 | links.reduce((sum, link) => sum + Math.max(advance(link.label, 0.78), 23), 0) + |
| 70 | 20 * Math.max(links.length - 1, 0); |
| 71 | const used = wordmarkMin + navWidth + actionBudget + gaps; |
| 72 | expect(used, `${locale} strip ${Math.round(used)}px`).toBeLessThanOrEqual(container); |
| 73 | for (const link of links) { |
| 74 | expect(link.href.startsWith(`/${locale}/`), `${locale} ${link.href}`).toBe(true); |
| 75 | } |
| 76 | } |
| 77 | }); |
| 78 | |
| 79 | it("fits the compact docs strip inside a 375px viewport", () => { |
| 80 | // --container is min(100% - 2rem, 76rem) → 343px at 375. Below 520px the |
| 81 | // strip is wordmark + [theme, select, menu]: the desktop nav is |
| 82 | // display:none, the wordmark tag and install CTA are hidden, and the |
| 83 | // GitHub/Discord links went at 900px. Widths are the CSS boxes — border |
| 84 | // plus padding plus a 1em glyph for the theme control, which is |
| 85 | // glyph-only below 2xl. |
| 86 | const container = 375 - 2 * 16; |
| 87 | const innerGap = 0.5 * 16; |
| 88 | const actionGap = 0.35 * 16; |
| 89 | const themeToggle = 16 + 2 * 0.375 * 16 + 2; |
| 90 | const select = 6.75 * 16; |
| 91 | const menuToggle = 2.25 * 16; |
| 92 | const actions = themeToggle + actionGap + select + actionGap + menuToggle; |
| 93 | |
| 94 | // At the width its own content asks for, the row does not fit. Docs |
| 95 | // routes are the tight case because only they carry the theme control. |
| 96 | expect(9.75 * 16 + innerGap + actions).toBeGreaterThan(container); |
| 97 | |
| 98 | // It fits because the wordmark gives space back down to its floor. With |
| 99 | // `flex-shrink: 0` the overflow landed on the menu toggle instead, and |
| 100 | // `overflow-x: clip` on <body> then ate the edge of it. |
| 101 | expect(8.75 * 16 + innerGap + actions).toBeLessThanOrEqual(container); |
| 102 | expect(css).toMatch(/\.paper-wordmark\s*\{[\s\S]*?flex:\s*0 1 auto;/); |
| 103 | expect(css).toMatch( |
| 104 | /@media \(max-width: 520px\)[\s\S]*?\.paper-wordmark\s*\{\s*max-width:\s*9\.75rem;/, |
| 105 | ); |
| 106 | }); |
| 107 | |
| 108 | it("keeps locale-switch and docs-theme activation on the shared path helpers", () => { |
| 109 | expect(replacePathLocale("/pt-BR/docs/guide", "ja")).toBe("/ja/docs/guide"); |
| 110 | expect(replacePathLocale("/de", "zh")).toBe("/zh"); |
| 111 | expect(isDocsPath("/pt-BR/docs/guide")).toBe(true); |
| 112 | expect(isDocsPath("/id/install")).toBe(false); |
| 113 | expect(webText("components/locale-switcher.tsx")).toContain("replacePathLocale"); |
| 114 | expect(theme).toContain("isDocsPath(pathname)"); |
| 115 | }); |
| 116 | }); |
| 117 |