Skip to content

Commit f701c2c

Browse files
authored
Fix mobile hamburger menu on iOS Safari (#6)
* Fix mobile hamburger menu on iOS Safari by using position:fixed scroll lock * Fix mobile hamburger menu: portal overlay to body, avoid sticky container clipping * Use partial dropdown for mobile nav instead of full-screen overlay * Fix lint: replace setState-in-effect with useSyncExternalStore for client detection * Fix e2e tests: update selectors from header-nav--open to mobile-nav-overlay--open * docs: require lint/e2e checks pre-commit, differentiate code vs content paths * Expand E2E tests from 9 to 53 with resilient selectors Replace brittle CSS class selectors with semantic ARIA/role-based equivalents across all four E2E test files. Add minimal ARIA hooks to components to enable proper selectors: aria-label on mobile nav overlay, data-testid on contributor avatars, aria-label on blog post tags, and article elements for feature cards. * Improve E2E test resilience and correctness - Fix false-positive blog card selector on homepage (was matching nav link) - GitHub stats: check Stars/Forks labels in hero region instead of any link - Feature strip: verify unique content instead of CSS class count - Meta description: require 20+ chars instead of any non-empty string - Mobile hamburger close-on-link: assert overlay not visible after click - aria-expanded test: move to mobile spec where toggle is interactive - Blog post tags: conditional check — only assert when tags div is present - Sitemap: remove hardcoded blog slugs that break on content changes * Fix hamburger close-on-click test to stay on current page Clicking 'Blog' caused navigation, making the not.toBeVisible() assertion trivially true on the destination page. Switch to clicking 'Docs' (target=_blank) which opens a new tab but keeps the test on the current page, so the assertion actually verifies the close handler fired. * Fix hydration mismatch in ImageCompare component react-compare-slider renders different inline style formats server-side (kebab-case CSS) vs client-side (camelCase JS), causing a React hydration mismatch. Dynamically import with ssr:false since the slider is purely interactive with no SSR benefit. Fixes psschwei's report on PR #6.
1 parent 649e7df commit f701c2c

14 files changed

Lines changed: 176 additions & 145 deletions

AGENTS.md

Lines changed: 28 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ This is the **Next.js website** for Mellea — the landing page and developer bl
2020
npm install
2121
npm run dev # http://localhost:4000
2222
npm run lint # ESLint
23+
npm run lint:md # Markdown lint (content files)
2324
npm run typecheck # tsc --noEmit
2425
npm run test:unit # Vitest (no browser required)
2526
npm run test:e2e # Playwright (auto-starts dev server)
@@ -61,16 +62,34 @@ Plain descriptive messages: `fix: nav link selector in E2E tests`, `feat: add ta
6162

6263
No Angular-style mandatory types required, but keep messages short and imperative.
6364

64-
## 6. Self-Review (before notifying user)
65+
## 6. Pre-commit Checklist (mandatory — do not skip)
6566

66-
1. `npm run lint` clean?
67-
2. `npm run typecheck` clean?
68-
3. `npm run test:unit` passes?
69-
4. `npm run test:e2e` passes?
70-
5. `npm run build` succeeds?
71-
6. No new `any` types introduced without justification?
72-
7. No hardcoded URLs that should be in `src/config/site.ts`?
73-
8. Added or edited Markdown with external links? CI will run lychee — broken links block deploy.
67+
Run the appropriate checks **before every commit**. CI will reject failures; fixing them after the fact wastes pipeline time.
68+
69+
### Code changes (any `.ts`, `.tsx`, `.css`, `.mjs`, or config file)
70+
71+
```bash
72+
npm run lint # must be clean
73+
npm run typecheck # must be clean
74+
npm run test:unit # must pass
75+
npm run test:e2e # must pass
76+
```
77+
78+
If you rename or remove a CSS class, check `tests/e2e/` for selectors that reference it and update them in the same commit.
79+
80+
### Content-only changes (`.md` files in `content/blogs/` only, no code touched)
81+
82+
```bash
83+
npm run lint:md # must be clean
84+
```
85+
86+
No build or E2E run required for content-only changes.
87+
88+
### Additional checks (code changes)
89+
90+
- No new `any` types without a comment explaining why
91+
- No hardcoded URLs — use `src/config/site.ts`
92+
- External links in Markdown? CI runs lychee — broken links block deploy
7493

7594
## 7. Architecture
7695

next-env.d.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
/// <reference types="next" />
22
/// <reference types="next/image-types/global" />
3-
import "./.next/dev/types/routes.d.ts";
3+
import "./.next/types/routes.d.ts";
44

55
// NOTE: This file should not be edited
66
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

next.config.mjs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
/** @type {import('next').NextConfig} */
22
const nextConfig = {
3+
allowedDevOrigins: ['192.168.100.102'],
34
output: 'export',
45
basePath: process.env.NEXT_PUBLIC_BASE_PATH || '',
56
trailingSlash: true,

src/app/blogs/[slug]/page.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ export default async function BlogPostPage({ params }: { params: Promise<{ slug:
6868
<h1 className="blog-post-title">{blog.title}</h1>
6969

7070
{blog.tags.length > 0 && (
71-
<div className="blog-post-tags">
71+
<div className="blog-post-tags" aria-label="Tags">
7272
{blog.tags.map((tag) => (
7373
<span key={tag} className="tag">{tag}</span>
7474
))}

src/app/globals.css

Lines changed: 21 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,27 @@ a {
205205
color: var(--text-primary);
206206
}
207207

208+
/* ── Mobile nav overlay (portaled to <body>, separate from desktop .header-nav) ── */
209+
.mobile-nav-overlay {
210+
display: none;
211+
}
212+
213+
.mobile-nav-overlay--open {
214+
display: flex;
215+
position: fixed;
216+
top: 48px;
217+
left: 0;
218+
right: 0;
219+
flex-direction: column;
220+
align-items: stretch;
221+
padding: 1rem 0;
222+
background: var(--bg-primary);
223+
border-top: 1px solid var(--border);
224+
border-bottom: 1px solid var(--border);
225+
overflow-y: auto;
226+
z-index: 9999;
227+
}
228+
208229
@media (max-width: 768px) {
209230
.mobile-menu-toggle {
210231
display: flex;
@@ -214,22 +235,6 @@ a {
214235

215236
.header-nav {
216237
display: none;
217-
position: fixed;
218-
top: 48px;
219-
left: 0;
220-
right: 0;
221-
bottom: 0;
222-
background: var(--bg-primary);
223-
flex-direction: column;
224-
align-items: stretch;
225-
padding: 1rem 0;
226-
z-index: 99;
227-
border-top: 1px solid var(--border);
228-
overflow-y: auto;
229-
}
230-
231-
.header-nav--open {
232-
display: flex;
233238
}
234239

235240
.nav-link {

src/app/page.tsx

Lines changed: 14 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ export default function HomePage() {
1717
return (
1818
<>
1919
{/* ── Hero ── */}
20-
<section className="hero">
20+
<section className="hero" aria-label="Hero">
2121
<div className="container">
2222
<div className="hero-inner">
2323
<div className="hero-text">
@@ -96,7 +96,7 @@ export default function HomePage() {
9696
</div>
9797

9898
<div className="feature-grid">
99-
<div className="feature-card">
99+
<article className="feature-card">
100100
{/* Python logo */}
101101
<svg className="feature-card-icon" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
102102
<path d="M11.914 0C5.82 0 6.2 2.656 6.2 2.656l.007 2.752h5.814v.826H3.9S0 5.789 0 11.969c0 6.18 3.403 5.96 3.403 5.96h2.031v-2.867s-.109-3.402 3.35-3.402h5.766s3.24.052 3.24-3.131V3.19S18.304 0 11.914 0zm-3.2 1.84a1.046 1.046 0 1 1 0 2.092 1.046 1.046 0 0 1 0-2.092z" fill="currentColor"/>
@@ -105,8 +105,8 @@ export default function HomePage() {
105105
<h3 className="feature-card-title">Python not Prose</h3>
106106
<p className="feature-card-body">The <code>@generative</code> decorator turns typed function signatures into LLM specifications. Docstrings are prompts, type hints are schemas — no templates, no parsers.</p>
107107
<Link href="https://docs.mellea.ai/concepts/generative-functions" target="_blank" className="feature-card-link">Learn more →</Link>
108-
</div>
109-
<div className="feature-card">
108+
</article>
109+
<article className="feature-card">
110110
{/* Lock / constrained */}
111111
<svg className="feature-card-icon" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
112112
<rect x="3" y="11" width="18" height="12" rx="2" stroke="currentColor" strokeWidth="1.75"/>
@@ -116,8 +116,8 @@ export default function HomePage() {
116116
<h3 className="feature-card-title">Constrained Decoding</h3>
117117
<p className="feature-card-body">Grammar-constrained generation for Ollama, vLLM, and HuggingFace. Unlike Instructor and PydanticAI, valid output is enforced at the token level — not retried into existence.</p>
118118
<Link href="https://docs.mellea.ai/how-to/enforce-structured-output" target="_blank" className="feature-card-link">Learn more →</Link>
119-
</div>
120-
<div className="feature-card">
119+
</article>
120+
<article className="feature-card">
121121
{/* Clipboard checklist */}
122122
<svg className="feature-card-icon" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
123123
<path d="M9 5H7a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2h-2" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round"/>
@@ -128,8 +128,8 @@ export default function HomePage() {
128128
<h3 className="feature-card-title">Requirements Driven</h3>
129129
<p className="feature-card-body">Declare rules — tone, length, content, custom logic — and Mellea validates every output before it leaves. Automatic retries mean bad output never reaches your users.</p>
130130
<Link href="https://docs.mellea.ai/concepts/requirements-system" target="_blank" className="feature-card-link">Learn more →</Link>
131-
</div>
132-
<div className="feature-card">
131+
</article>
132+
<article className="feature-card">
133133
{/* Shield */}
134134
<svg className="feature-card-icon" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
135135
<path d="M12 2L4 6v6c0 5.25 3.5 10.15 8 11.35C16.5 22.15 20 17.25 20 12V6l-8-4z" stroke="currentColor" strokeWidth="1.75" strokeLinejoin="round"/>
@@ -138,8 +138,8 @@ export default function HomePage() {
138138
<h3 className="feature-card-title">Predictable and Resilient</h3>
139139
<p className="feature-card-body">Need higher confidence? Switch from single-shot to majority voting or best-of-n with one parameter. No code rewrites, no new infrastructure.</p>
140140
<Link href="https://docs.mellea.ai/advanced/inference-time-scaling" target="_blank" className="feature-card-link">Learn more →</Link>
141-
</div>
142-
<div className="feature-card">
141+
</article>
142+
<article className="feature-card">
143143
{/* Plug / connector */}
144144
<svg className="feature-card-icon" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
145145
<path d="M12 22v-3" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round"/>
@@ -150,8 +150,8 @@ export default function HomePage() {
150150
<h3 className="feature-card-title">MCP Compatible</h3>
151151
<p className="feature-card-body">Expose any Mellea program as an MCP tool. The calling agent gets validated output — requirements checked, retries run — not raw LLM responses.</p>
152152
<Link href="https://docs.mellea.ai/integrations/mcp" target="_blank" className="feature-card-link">Learn more →</Link>
153-
</div>
154-
<div className="feature-card">
153+
</article>
154+
<article className="feature-card">
155155
{/* Shield with eye — safety */}
156156
<svg className="feature-card-icon" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
157157
<path d="M12 2L4 6v6c0 5.25 3.5 10.15 8 11.35C16.5 22.15 20 17.25 20 12V6l-8-4z" stroke="currentColor" strokeWidth="1.75" strokeLinejoin="round"/>
@@ -161,7 +161,7 @@ export default function HomePage() {
161161
<h3 className="feature-card-title">Safety &amp; Guardrails</h3>
162162
<p className="feature-card-body">Built-in Granite Guardian integration detects harmful outputs, hallucinations, and jailbreak attempts before they reach your users — no external service required.</p>
163163
<Link href="https://docs.mellea.ai/how-to/safety-guardrails" target="_blank" className="feature-card-link">Learn more →</Link>
164-
</div>
164+
</article>
165165
</div>
166166
</div>
167167
</section>
@@ -201,7 +201,7 @@ export default function HomePage() {
201201
</section>
202202

203203
{/* ── Vision / closing CTA ── */}
204-
<section className="section vision-section">
204+
<section className="section vision-section" aria-label="Vision">
205205
<div className="container">
206206
<div className="vision-inner">
207207
<p className="vision-text">

src/components/GitHubStats.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,7 @@ export default function GitHubStats() {
122122
</div>
123123
<div className="gh-stats-footer">
124124
{state.status === 'success' && state.data.contributorAvatars.length > 0 && (
125-
<div className="gh-avatars">
125+
<div className="gh-avatars" data-testid="contributor-avatars">
126126
{state.data.contributorAvatars.map((c) => (
127127
<a
128128
key={c.login}

src/components/Header.tsx

Lines changed: 41 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,40 @@
11
'use client';
22

3-
import { useState, useEffect } from 'react';
3+
import { useState, useSyncExternalStore } from 'react';
4+
import { createPortal } from 'react-dom';
45
import Link from 'next/link';
56
import { usePathname } from 'next/navigation';
67
import { siteConfig } from '@/config/site';
78

9+
const emptySubscribe = () => () => {};
10+
811
export default function Header() {
912
const pathname = usePathname();
1013
const [menuOpen, setMenuOpen] = useState(false);
14+
// useSyncExternalStore returns false on server, true on client — no setState-in-effect needed.
15+
const mounted = useSyncExternalStore(emptySubscribe, () => true, () => false);
1116

1217
const closeMenu = () => setMenuOpen(false);
1318

14-
// Prevent body scroll when menu is open
15-
useEffect(() => {
16-
document.body.style.overflow = menuOpen ? 'hidden' : '';
17-
return () => { document.body.style.overflow = ''; };
18-
}, [menuOpen]);
19+
const navLinks = (
20+
<>
21+
<Link href={siteConfig.docsUrl} target="_blank" rel="noopener noreferrer" className="nav-link" onClick={closeMenu}>
22+
Docs
23+
</Link>
24+
<Link href="/blogs" className={`nav-link ${pathname.startsWith('/blogs') ? 'active' : ''}`} onClick={closeMenu}>
25+
Blog
26+
</Link>
27+
<Link href={siteConfig.discussionsUrl} target="_blank" rel="noopener noreferrer" className="nav-link" onClick={closeMenu}>
28+
Community
29+
</Link>
30+
<Link href={siteConfig.githubUrl} target="_blank" rel="noopener noreferrer" className="nav-link" onClick={closeMenu}>
31+
GitHub
32+
</Link>
33+
<Link href={siteConfig.docsUrl} target="_blank" rel="noopener noreferrer" className="nav-cta" onClick={closeMenu}>
34+
Get Started →
35+
</Link>
36+
</>
37+
);
1938

2039
return (
2140
<header className="header">
@@ -43,52 +62,24 @@ export default function Header() {
4362
)}
4463
</button>
4564

46-
<nav className={`header-nav${menuOpen ? ' header-nav--open' : ''}`}>
47-
<Link
48-
href={siteConfig.docsUrl}
49-
target="_blank"
50-
rel="noopener noreferrer"
51-
className="nav-link"
52-
onClick={closeMenu}
53-
>
54-
Docs
55-
</Link>
56-
<Link
57-
href="/blogs"
58-
className={`nav-link ${pathname.startsWith('/blogs') ? 'active' : ''}`}
59-
onClick={closeMenu}
60-
>
61-
Blog
62-
</Link>
63-
<Link
64-
href={siteConfig.discussionsUrl}
65-
target="_blank"
66-
rel="noopener noreferrer"
67-
className="nav-link"
68-
onClick={closeMenu}
69-
>
70-
Community
71-
</Link>
72-
<Link
73-
href={siteConfig.githubUrl}
74-
target="_blank"
75-
rel="noopener noreferrer"
76-
className="nav-link"
77-
onClick={closeMenu}
78-
>
79-
GitHub
80-
</Link>
81-
<Link
82-
href={siteConfig.docsUrl}
83-
target="_blank"
84-
rel="noopener noreferrer"
85-
className="nav-cta"
86-
onClick={closeMenu}
87-
>
88-
Get Started →
89-
</Link>
65+
{/* Desktop nav — inline in header */}
66+
<nav className="header-nav">
67+
{navLinks}
9068
</nav>
9169
</div>
70+
71+
{/* Mobile nav overlay — portaled to <body> so position:fixed is viewport-relative,
72+
not relative to the sticky header ancestor (iOS Safari limitation). */}
73+
{mounted && createPortal(
74+
<nav
75+
className={`mobile-nav-overlay${menuOpen ? ' mobile-nav-overlay--open' : ''}`}
76+
aria-label="Mobile navigation"
77+
aria-hidden={!menuOpen}
78+
>
79+
{navLinks}
80+
</nav>,
81+
document.body
82+
)}
9283
</header>
9384
);
9485
}

src/components/ImageCompare.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,15 @@
11
'use client';
22

3+
import dynamic from 'next/dynamic';
34
import {
4-
ReactCompareSlider,
55
ReactCompareSliderImage,
66
} from 'react-compare-slider';
77

8+
const ReactCompareSlider = dynamic(
9+
() => import('react-compare-slider').then((mod) => mod.ReactCompareSlider),
10+
{ ssr: false },
11+
);
12+
813
const basePath = process.env.NEXT_PUBLIC_BASE_PATH || '';
914

1015
export default function ImageCompare() {

tests/e2e/accessibility.spec.ts

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ test('blog index has exactly one h1', async ({ page }) => {
2424
test('blog post has exactly one h1', async ({ page }) => {
2525
// Navigate to first available post
2626
await page.goto('/blogs/');
27-
const href = await page.locator('a.blog-card').first().getAttribute('href');
27+
const href = await page.getByRole('main').locator('a[href^="/blogs/"]:not([href="/blogs/"])').first().getAttribute('href');
2828
await page.goto(href!);
2929
await expect(page.locator('h1')).toHaveCount(1);
3030
});
@@ -74,8 +74,3 @@ test('code showcase uses proper ARIA roles', async ({ page }) => {
7474
await expect(page.locator('[role="tab"][aria-selected="true"]')).toHaveCount(1);
7575
});
7676

77-
test('mobile menu toggle has aria-expanded', async ({ page }) => {
78-
await page.goto('/');
79-
const toggle = page.locator('.mobile-menu-toggle');
80-
await expect(toggle).toHaveAttribute('aria-expanded', 'false');
81-
});

0 commit comments

Comments
 (0)