Skip to content

Commit 2d175ff

Browse files
author
徐燕来
committed
fix(landing): cover compact header transition band
1 parent 553f644 commit 2d175ff

4 files changed

Lines changed: 112 additions & 27 deletions

File tree

apps/landing-page/app/_components/header-enhancer.astro

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -195,7 +195,7 @@ const directAssets = {
195195
}
196196

197197
// Hamburger menu toggle. Active only at narrow viewports (CSS hides
198-
// the toggle button at ≥1100px). Click toggles `.is-open` on the
198+
// the toggle button at ≥1367px). Click toggles `.is-open` on the
199199
// header; outside-click, Escape, and clicking any link inside the
200200
// menu close it again. Keeps `aria-expanded` in sync.
201201
const toggle = document.querySelector('[data-nav-toggle]');

apps/landing-page/app/_components/header.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -183,7 +183,7 @@ const COMMUNITY_ENTRY_COPY = {
183183
// the runtime via `data-*` on `.nav-account` because the auth logic lives in
184184
// `header-enhancer.astro`'s `<script is:inline>` (NOT processed by Vite, so it
185185
// cannot read `import.meta.env` itself).
186-
const env = import.meta.env as Record<string, string | undefined>;
186+
const env = (import.meta.env ?? {}) as Record<string, string | undefined>;
187187
const CLOUD_API_BASE =
188188
env.PUBLIC_CLOUD_API_BASE ?? env.PUBLIC_AMR_API_BASE ?? 'https://amr-api.open-design.ai';
189189
const CLOUD_CONSOLE_URL =
@@ -346,7 +346,7 @@ export function Header({
346346
/>
347347
</a>
348348
{/*
349-
Mobile / tablet hamburger. Hidden by CSS at ≥1100px (the desktop
349+
Mobile / tablet hamburger. Hidden by CSS at ≥1367px (the desktop
350350
breakpoint where the full nav fits). At narrower widths it toggles
351351
`.is-open` on the parent <header> via a small handler in
352352
`header-enhancer.astro` — when open, the `<nav>` element below

apps/landing-page/app/globals.css

Lines changed: 13 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -684,14 +684,14 @@ body::before {
684684
gap: 24px;
685685
}
686686
/*
687-
* Desktop bar layout (≥1081px, above the hamburger breakpoint): three
687+
* Desktop bar layout (≥1367px, above the hamburger breakpoint): three
688688
* equal-flanked columns — brand logo pinned flush-left, the nav links
689689
* centred, and the action cluster (download / star / locale) flush-right.
690690
* The two 1fr side tracks are symmetric, which keeps the centre column
691691
* optically centred regardless of how wide either side gets. Mobile keeps
692692
* the base flex layout untouched.
693693
*/
694-
@media (min-width: 1081px) {
694+
@media (min-width: 1367px) {
695695
.nav-inner {
696696
display: grid;
697697
grid-template-columns: 1fr auto 1fr;
@@ -716,13 +716,6 @@ body::before {
716716
white-space: nowrap;
717717
}
718718
}
719-
/* Mid-band desktop (hamburger ends at 1081px): the longest locales need
720-
* tighter link spacing to hold a single row. The hover bridge width must
721-
* track the gap so sweeping between triggers keeps a dropdown open. */
722-
@media (min-width: 1081px) and (max-width: 1366px) {
723-
.nav-links { gap: 22px; }
724-
.nav-links li.has-dropdown:not(:last-child)::after { width: 22px; }
725-
}
726719
.brand {
727720
display: inline-flex;
728721
align-items: center;
@@ -917,7 +910,7 @@ body::before {
917910
* hides the frost behind itself, so the bar still reads as a clean bar.
918911
* Desktop only: the mobile nav is a vertical drawer, not a hover panel.
919912
*/
920-
@media (min-width: 1081px) {
913+
@media (min-width: 1367px) {
921914
.site-chrome::after {
922915
content: '';
923916
position: absolute;
@@ -1028,7 +1021,7 @@ body::before {
10281021
where a centering transform would shift them sideways. The Product li goes
10291022
static so the wide panel positions against `.container` and centers on it —
10301023
anchored to the li it overflows narrow desktops. */
1031-
@media (min-width: 1081px) {
1024+
@media (min-width: 1367px) {
10321025
.nav-links li.nav-item-mega { position: static; }
10331026
/* With the li static, absolutely-positioned descendants resolve against
10341027
`.container` (the nearest positioned ancestor), so the shared
@@ -1116,7 +1109,7 @@ a.nav-mega-col-head.is-active {
11161109
}
11171110

11181111
/*
1119-
* Hamburger toggle. Hidden at desktop widths (≥1080px). At narrower
1112+
* Hamburger toggle. Hidden at desktop widths (≥1367px). At narrower
11201113
* viewports it replaces the inline nav-links and toggles a panel
11211114
* underneath the header bar via a small handler in `header-enhancer`.
11221115
*/
@@ -5660,6 +5653,7 @@ footer .container {
56605653
/* ---------- responsive ----------
56615654
*
56625655
* Breakpoint ladder:
5656+
* 1366 — crowded translated headers use the compact hamburger drawer.
56635657
* 1280 — desktop chrome shrinks (drop side rails, slimmer container).
56645658
* 1200 — topbar mid label crowds first, hide it.
56655659
* 1080 — small desktop / large laptop. Smaller hero/section heads.
@@ -5679,25 +5673,22 @@ footer .container {
56795673
@media (max-width: 1200px) {
56805674
.topbar-inner .mid { display: none; }
56815675
}
5682-
/* nav: between 1080 and 1180 the brand tail + nav links + 2 CTAs + dot
5683-
* crowd the row. Drop the brand sub-meta first, then tighten link spacing,
5684-
* so the Star CTA never has to compress. */
5685-
@media (max-width: 1180px) {
5676+
/* The full translated navigation plus community, locale, Star, and Download
5677+
* actions does not fit safely in the condensed 1280px capsule. Keep a compact
5678+
* row through 1366px and resume the complete desktop header at 1367px. */
5679+
@media (max-width: 1366px) {
56865680
.nav-inner { gap: 18px; }
56875681
.brand-meta { display: none; }
5688-
/* Link gap for this band already comes from the 1081–1366px rule next to
5689-
the desktop grid layout (22px — translated labels need the tighter
5690-
spacing); restating a larger gap here would override it. */
56915682
}
56925683

5693-
/* nav: at ≤1080px there's no clean way to fit Product · Skills · Systems ·
5684+
/* nav: at ≤1366px there's no clean way to fit Product · Skills · Systems ·
56945685
* Templates · Craft · Blog · Contact + Download + Star + dot in one row,
56955686
* so swap to a hamburger panel. Star CTA stays visible in the bar (it's
56965687
* the social-proof CTA); Download moves into the panel. The Product
56975688
* submenu flattens from a hover-popup to an always-expanded inline list,
56985689
* since hover doesn't translate to touch.
56995690
*/
5700-
@media (max-width: 1080px) {
5691+
@media (max-width: 1366px) {
57015692
.nav-toggle { display: inline-flex; }
57025693
.brand { white-space: nowrap; }
57035694
/* Hide Download from the bar (Star stays). */
@@ -6021,7 +6012,7 @@ footer .container {
60216012
.foot-bottom { flex-direction: column; align-items: flex-start; gap: 12px; }
60226013
.foot-bottom .right { flex-wrap: wrap; gap: 12px 20px; }
60236014
/* nav — at ≤880px tighten padding; nav-links stay reachable through
6024-
* the hamburger panel introduced at ≤1080px. Brand meta and Download
6015+
* the hamburger panel introduced at ≤1366px. Brand meta and Download
60256016
* stay hidden; Star CTA still pings in the bar. */
60266017
.nav { padding: 16px 0; }
60276018
.brand-meta { display: none; }
Lines changed: 96 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,39 @@
11
import assert from 'node:assert/strict';
2-
import { readFileSync } from 'node:fs';
2+
import { existsSync, readFileSync } from 'node:fs';
33
import { test } from 'node:test';
4+
import { chromium } from 'playwright';
5+
import { createElement } from 'react';
6+
import { renderToStaticMarkup } from 'react-dom/server';
7+
8+
import { Header } from '../app/_components/header.tsx';
49

510
const headerSource = readFileSync(
611
new URL('../app/_components/header.tsx', import.meta.url),
712
'utf8',
813
);
914
const stylesSource = readFileSync(new URL('../app/globals.css', import.meta.url), 'utf8');
1015

16+
const ukrainianHeader = renderToStaticMarkup(
17+
createElement(Header, {
18+
counts: { skills: 100, systems: 10, templates: 20, craft: 5 },
19+
github: { starsLabel: '83K+' },
20+
locale: 'uk',
21+
localeSwitcher: {
22+
label: 'Змінити мову',
23+
prefix: '',
24+
shortLabel: 'UK',
25+
options: [
26+
{
27+
code: 'uk',
28+
href: '/uk/',
29+
htmlLang: 'uk',
30+
label: 'Українська',
31+
},
32+
],
33+
},
34+
}),
35+
);
36+
1137
test('community entry moves into the drawer without overflowing long localized labels', () => {
1238
assert.match(
1339
headerSource,
@@ -17,10 +43,78 @@ test('community entry moves into the drawer without overflowing long localized l
1743
assert.match(stylesSource, /\.nav-community-mobile-entry\s*\{\s*display:\s*none;/);
1844
assert.match(
1945
stylesSource,
20-
/@media \(max-width: 1080px\)[\s\S]*?\.nav-side \.nav-community-entry\s*\{\s*display:\s*none;\s*\}[\s\S]*?\.nav-links \.nav-community-mobile-entry\s*\{[^}]*display:\s*grid;/,
46+
/@media \(max-width: 1366px\)[\s\S]*?\.nav-side \.nav-community-entry\s*\{\s*display:\s*none;\s*\}[\s\S]*?\.nav-links \.nav-community-mobile-entry\s*\{[^}]*display:\s*grid;/,
2147
);
2248
assert.match(
2349
stylesSource,
2450
/\.nav-links \.nav-community-mobile-cta\s*\{[^}]*max-width:\s*100%;[^}]*white-space:\s*normal;[^}]*overflow-wrap:\s*anywhere;/s,
2551
);
2652
});
53+
54+
test('longest-label community entry stays inside the rendered header at its boundary widths', async (t) => {
55+
const localChrome = '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
56+
const browser = await chromium.launch({
57+
headless: true,
58+
...(existsSync(localChrome) ? { executablePath: localChrome } : {}),
59+
});
60+
t.after(() => browser.close());
61+
62+
const context = await browser.newContext({ viewport: { width: 1081, height: 900 } });
63+
const page = await context.newPage();
64+
await page.setContent(
65+
`<!doctype html><html lang="uk"><head><style>${stylesSource}</style></head><body><div class="site-chrome is-condensed">${ukrainianHeader}</div></body></html>`,
66+
);
67+
68+
const readLayout = async (width: number) => {
69+
await page.setViewportSize({ width, height: 900 });
70+
await page.evaluate(`document.querySelector('header.nav').classList.add('is-open')`);
71+
await page.waitForTimeout(250);
72+
return page.evaluate(`(() => {
73+
const nav = document.querySelector('header.nav');
74+
const inner = document.querySelector('.nav-inner');
75+
const toggle = document.querySelector('.nav-toggle');
76+
const desktopEntry = document.querySelector('.nav-side .nav-community-entry');
77+
const drawerEntry = document.querySelector('.nav-community-mobile-entry');
78+
if (!nav || !inner || !toggle || !desktopEntry || !drawerEntry) {
79+
throw new Error('header layout fixture is incomplete');
80+
}
81+
82+
const isVisible = (element) => {
83+
const style = getComputedStyle(element);
84+
const rect = element.getBoundingClientRect();
85+
return style.display !== 'none' && style.visibility !== 'hidden' && rect.width > 0;
86+
};
87+
const innerRect = inner.getBoundingClientRect();
88+
const rowChildren = Array.from(inner.children).filter((element) => {
89+
return isVisible(element) && getComputedStyle(element).position !== 'absolute';
90+
});
91+
const rowRects = rowChildren
92+
.map((element) => element.getBoundingClientRect())
93+
.sort((a, b) => a.left - b.left);
94+
const rowFits =
95+
rowRects.every((rect) => rect.left >= innerRect.left - 1 && rect.right <= innerRect.right + 1) &&
96+
rowRects.every((rect, index) => index === 0 || rowRects[index - 1].right <= rect.left + 1);
97+
98+
return {
99+
desktopEntryVisible: isVisible(desktopEntry),
100+
drawerEntryText: drawerEntry.innerText,
101+
drawerEntryVisible: isVisible(drawerEntry),
102+
rowFits,
103+
toggleVisible: isVisible(toggle),
104+
};
105+
})()`);
106+
};
107+
108+
const compact = await readLayout(1081);
109+
assert.equal(compact.toggleVisible, true);
110+
assert.equal(compact.desktopEntryVisible, false);
111+
assert.equal(compact.drawerEntryVisible, true);
112+
assert.match(compact.drawerEntryText, /Приєднатися до Discord/);
113+
assert.equal(compact.rowFits, true);
114+
115+
const desktop = await readLayout(1367);
116+
assert.equal(desktop.toggleVisible, false);
117+
assert.equal(desktop.desktopEntryVisible, true);
118+
assert.equal(desktop.drawerEntryVisible, false);
119+
assert.equal(desktop.rowFits, true);
120+
});

0 commit comments

Comments
 (0)