返回 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
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
117 lines TYPESCRIPT