返回 CodeWhale
nav-hit-target.test.ts
根目录 / web / lib / i18n / nav-hit-target.test.ts
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
111 lines TYPESCRIPT