Skip to content

Commit 7d8cb45

Browse files
committed
feat(site): fix mobile overflow & nav drawer after soft-nav, redesign courses grid, and add Playwright E2E suite
1 parent 8b10057 commit 7d8cb45

15 files changed

Lines changed: 1266 additions & 650 deletions

‎.gitignore‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,3 +44,9 @@ package-lock.json
4444

4545
# agents teamwork
4646
.agents/
47+
48+
# playwright
49+
test-results/
50+
playwright-report/
51+
blob-report/
52+
playwright/.cache/

‎apps/site/astro.config.mjs‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ export default defineConfig({
4444
// Images are Sanity CDN URLs already transformed by @sanity/image-url;
4545
// do not route them through Cloudflare Images.
4646
imageService: "passthrough",
47-
platformProxy: { enabled: true },
47+
remoteBindings: false,
4848
}),
4949
integrations: [react()],
5050
// Replaces next/font/google. The CSS variable names match the ones
@@ -99,5 +99,8 @@ export default defineConfig({
9999
vite: {
100100
plugins: [tailwindcss(), rawFonts([".ttf", ".otf"])],
101101
assetsInclude: ["**/*.wasm"],
102+
optimizeDeps: {
103+
include: ["astro/assets/services/noop"],
104+
},
102105
},
103106
});

‎apps/site/package.json‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,35 +15,40 @@
1515
"lint": "biome check .",
1616
"format": "biome format . --write",
1717
"cf-typegen": "CI=true wrangler types",
18-
"test:e2e": "node tests/run-e2e.mjs"
18+
"test:e2e": "node tests/run-e2e.mjs",
19+
"test:ui": "playwright test",
20+
"test:ui:update": "playwright test --update-snapshots"
1921
},
2022
"dependencies": {
2123
"@ai-sdk/mcp": "^2.0.72",
2224
"@astrojs/cloudflare": "^14.2.1",
2325
"@astrojs/react": "^6.0.2",
2426
"@better-auth/mcp": "^1.7.7",
2527
"@better-auth/oauth-provider": "^1.7.7",
26-
"@openrouter/ai-sdk-provider": "^3.1.0",
2728
"@portabletext/to-html": "^6.0.0",
2829
"@portabletext/types": "^4.0.2",
2930
"@sanity/client": "^7.26.2",
3031
"@sanity/image-url": "^2.1.1",
3132
"@sanity/visual-editing-standalone": "^2.0.1",
3233
"@tailwindcss/vite": "^4.2.1",
34+
"@types/three": "^0.187.0",
3335
"ai": "^7.0.135",
3436
"astro": "^7.2.2",
3537
"astro-portabletext": "^0.13.0",
3638
"better-auth": "^1.7.7",
3739
"groq": "^6.9.2",
40+
"gsap": "^3.15.1",
3841
"react": "^19.2.6",
3942
"react-dom": "^19.2.6",
4043
"tailwindcss": "^4.2.1",
44+
"three": "^0.187.0",
4145
"workers-ai-provider": "^4.0.0",
4246
"workers-og": "^0.0.27"
4347
},
4448
"devDependencies": {
4549
"@astrojs/check": "^0.9.10",
4650
"@biomejs/biome": "2.2.4",
51+
"@playwright/test": "^1.65.0",
4752
"@tailwindcss/typography": "^0.5.19",
4853
"@types/node": "^24.12.4",
4954
"typescript": "^5.9",

‎apps/site/playwright.config.ts‎

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import { defineConfig, devices } from "@playwright/test";
2+
3+
const PORT = Number(process.env.PLAYWRIGHT_PORT || 4329);
4+
const BASE_URL = process.env.PLAYWRIGHT_BASE_URL || `http://localhost:${PORT}`;
5+
6+
export default defineConfig({
7+
testDir: "./tests/e2e",
8+
timeout: 45_000,
9+
expect: {
10+
timeout: 10_000,
11+
},
12+
fullyParallel: true,
13+
forbidOnly: Boolean(process.env.CI),
14+
retries: process.env.CI ? 1 : 0,
15+
reporter: [["list"]],
16+
use: {
17+
baseURL: BASE_URL,
18+
trace: "retain-on-failure",
19+
},
20+
projects: [
21+
{
22+
name: "mobile-portrait",
23+
use: {
24+
...devices["iPhone 14"],
25+
browserName: "chromium",
26+
viewport: { width: 390, height: 844 },
27+
isMobile: true,
28+
hasTouch: true,
29+
},
30+
},
31+
{
32+
name: "mobile-landscape",
33+
use: {
34+
...devices["iPhone 14 landscape"],
35+
browserName: "chromium",
36+
viewport: { width: 844, height: 390 },
37+
isMobile: true,
38+
hasTouch: true,
39+
},
40+
},
41+
{
42+
name: "desktop",
43+
use: {
44+
...devices["Desktop Chrome"],
45+
browserName: "chromium",
46+
viewport: { width: 1280, height: 800 },
47+
},
48+
},
49+
],
50+
webServer: process.env.PLAYWRIGHT_BASE_URL
51+
? undefined
52+
: {
53+
command: `ASTRO_DEV_BACKGROUND=1 pnpm exec astro dev --port ${PORT} --ignore-lock`,
54+
url: BASE_URL,
55+
reuseExistingServer: !process.env.CI,
56+
timeout: 90_000,
57+
},
58+
});

‎apps/site/src/components/ChatModal.astro‎

Lines changed: 59 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -407,11 +407,36 @@ import AjAnimatedMascot from "@/components/AjAnimatedMascot.astro";
407407
curCanonical.setAttribute("href", newCanonical);
408408
}
409409

410-
// Update header nav active states
411-
const newHeaderNav = doc.querySelector("#site-header nav");
412-
const curHeaderNav = document.querySelector("#site-header nav");
413-
if (newHeaderNav && curHeaderNav) {
414-
curHeaderNav.innerHTML = newHeaderNav.innerHTML;
410+
// Update header nav & mobile active pillar states
411+
const newHeader = doc.getElementById("site-header");
412+
const curHeader = document.getElementById("site-header");
413+
if (newHeader && curHeader) {
414+
const newHeaderNav = newHeader.querySelector("nav");
415+
const curHeaderNav = curHeader.querySelector("nav");
416+
if (newHeaderNav && curHeaderNav) {
417+
curHeaderNav.innerHTML = newHeaderNav.innerHTML;
418+
}
419+
const newBrandLink = newHeader.querySelector('a[href="/"]');
420+
const curBrandLink = curHeader.querySelector('a[href="/"]');
421+
if (newBrandLink && curBrandLink) {
422+
curBrandLink.innerHTML = newBrandLink.innerHTML;
423+
}
424+
const newMobileSlot = newHeader.querySelector("#mobile-active-pillar-slot");
425+
const curMobileSlot = curHeader.querySelector("#mobile-active-pillar-slot");
426+
if (newMobileSlot && curMobileSlot) {
427+
curMobileSlot.innerHTML = newMobileSlot.innerHTML;
428+
}
429+
}
430+
const newDrawer = doc.getElementById("nav-drawer");
431+
const curDrawer = document.getElementById("nav-drawer");
432+
if (newDrawer && curDrawer) {
433+
const newDrawerNavs = newDrawer.querySelectorAll("nav");
434+
const curDrawerNavs = curDrawer.querySelectorAll("nav");
435+
newDrawerNavs.forEach((navEl, idx) => {
436+
if (curDrawerNavs[idx]) {
437+
curDrawerNavs[idx].innerHTML = navEl.innerHTML;
438+
}
439+
});
415440
}
416441

417442
// Close SearchModal or mobile nav drawer if open
@@ -420,9 +445,15 @@ import AjAnimatedMascot from "@/components/AjAnimatedMascot.astro";
420445
searchDialog.close();
421446
document.body.style.overflow = "";
422447
}
423-
const mobileNav = document.getElementById("nav-drawer");
424-
if (mobileNav && !mobileNav.hidden) {
425-
mobileNav.hidden = true;
448+
const mobileNav = document.getElementById("nav-drawer") as HTMLDialogElement | null;
449+
if (mobileNav) {
450+
mobileNav.hidden = false;
451+
mobileNav.removeAttribute("hidden");
452+
if (mobileNav.open && typeof mobileNav.close === "function") {
453+
mobileNav.close();
454+
}
455+
mobileNav.removeAttribute("open");
456+
document.getElementById("nav-drawer-toggle")?.setAttribute("aria-expanded", "false");
426457
document.body.style.overflow = "";
427458
}
428459

@@ -453,8 +484,9 @@ import AjAnimatedMascot from "@/components/AjAnimatedMascot.astro";
453484
// Re-ensure drawer stays open if saved open
454485
syncDrawerFromStorage();
455486

456-
// Notify page widgets (e.g. TranscriptModal, Recently Viewed tracker)
487+
// Notify page widgets (e.g. TranscriptModal, Recently Viewed tracker, Courses/Topics filters)
457488
window.dispatchEvent(new CustomEvent("cc:page-load"));
489+
document.dispatchEvent(new CustomEvent("astro:page-load"));
458490
return true;
459491
} catch (err: unknown) {
460492
if (err instanceof Error && err.name === "AbortError") {
@@ -720,17 +752,23 @@ import AjAnimatedMascot from "@/components/AjAnimatedMascot.astro";
720752
}
721753

722754
const internalLinkRegex =
723-
/\[([^\]]+)\]\(((\/(?:podcast|post)\/[a-zA-Z0-9_\-.]+))\)/gi;
755+
/\[([^\]]+)\]\(((\/(?:podcast|post|course)\/[a-zA-Z0-9_\-.\/]+))\)/gi;
724756
const internalLinks: Array<{
725757
title: string;
726758
url: string;
727-
type: "podcast" | "post";
759+
type: "podcast" | "post" | "course" | "lesson";
728760
}> = [];
729761
let intMatch: RegExpExecArray | null;
730762
while ((intMatch = internalLinkRegex.exec(cleanedText)) !== null) {
731763
const linkUrl = intMatch[2];
732764
const linkTitle = intMatch[1];
733-
const type = linkUrl.startsWith("/podcast") ? "podcast" : "post";
765+
const type = linkUrl.startsWith("/podcast")
766+
? "podcast"
767+
: linkUrl.startsWith("/course") && linkUrl.includes("/lesson/")
768+
? "lesson"
769+
: linkUrl.startsWith("/course")
770+
? "course"
771+
: "post";
734772
if (!internalLinks.some((l) => l.url === linkUrl)) {
735773
internalLinks.push({ title: linkTitle, url: linkUrl, type });
736774
}
@@ -832,12 +870,20 @@ import AjAnimatedMascot from "@/components/AjAnimatedMascot.astro";
832870
}
833871
}
834872

873+
const typeBadgeLabel =
874+
link.type === "podcast"
875+
? "Podcast Episode"
876+
: link.type === "course"
877+
? "Course"
878+
: link.type === "lesson"
879+
? "Lesson"
880+
: "Blog Post";
835881
cardsHtml += `
836882
<div class="flex flex-col p-2.5 rounded-xl border border-border bg-background/70 hover:border-primary/50 transition-all text-left">
837883
<a href="${link.url}" class="aj-internal-nav group flex items-center justify-between gap-2.5 no-underline">
838884
<div class="min-w-0">
839885
<div class="text-xs font-semibold text-foreground group-hover:text-primary transition-colors truncate">${escapeHtml(link.title)}</div>
840-
<div class="text-[10px] font-mono text-muted-foreground uppercase tracking-wider">${isPodcast ? "Podcast Episode" : "Blog Post"}</div>
886+
<div class="text-[10px] font-mono text-muted-foreground uppercase tracking-wider">${typeBadgeLabel}</div>
841887
</div>
842888
<span class="shrink-0 text-xs font-medium text-primary">Open →</span>
843889
</a>

‎apps/site/src/components/Header.astro‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -524,6 +524,8 @@ const secondaryMobileLinks = [
524524
if (!drawer) {
525525
return;
526526
}
527+
drawer.hidden = false;
528+
drawer.removeAttribute("hidden");
527529
if (open) {
528530
if (!drawer.open) {
529531
if (typeof drawer.showModal === "function") {

‎apps/site/src/components/InfiniteContentGrid.astro‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -630,6 +630,7 @@ const effectivePlaceholder =
630630
document.addEventListener("DOMContentLoaded", initInfiniteFeeds);
631631
window.addEventListener("load", initInfiniteFeeds);
632632
document.addEventListener("astro:page-load", initInfiniteFeeds);
633+
window.addEventListener("cc:page-load", initInfiniteFeeds);
633634
}
634635

635636
initInfiniteFeeds();

‎apps/site/src/components/TopicFilterStrip.astro‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -167,7 +167,7 @@ const isExtraActiveTopic = Boolean(
167167
data-topic-popover-panel
168168
role="dialog"
169169
aria-label="Search and select a topic or vendor tag"
170-
class="hidden absolute left-0 top-full z-40 mt-2 w-[min(92vw,34rem)] rounded-3xl border-2 border-[#2A1155] bg-[#FAF9FC] p-4 shadow-sketch dark:border-white/25 dark:bg-[#131B2E]"
170+
class="hidden fixed inset-x-3 top-20 z-50 rounded-3xl border-2 border-[#2A1155] bg-[#FAF9FC] p-4 shadow-sketch dark:border-white/25 dark:bg-[#131B2E] sm:absolute sm:inset-x-auto sm:left-0 sm:top-full sm:mt-2 sm:w-[min(92vw,34rem)]"
171171
>
172172
{/* Search Input + Close Button */}
173173
<div class="flex items-center gap-2">
@@ -464,6 +464,7 @@ const isExtraActiveTopic = Boolean(
464464
document.addEventListener("DOMContentLoaded", initTopicFilterStrips);
465465
window.addEventListener("load", initTopicFilterStrips);
466466
document.addEventListener("astro:page-load", initTopicFilterStrips);
467+
window.addEventListener("cc:page-load", initTopicFilterStrips);
467468
initTopicFilterStrips();
468469
})();
469470
</script>

‎apps/site/src/pages/course/[slug].astro‎

Lines changed: 36 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -196,36 +196,30 @@ const sanitizedContent = (course.content ?? []).filter((block) => {
196196
)
197197
}
198198

199-
{
200-
sanitizedContent.length > 0 && (
201-
<div class="my-8">
202-
<PortableTextContent
203-
value={sanitizedContent}
204-
class="mx-auto prose-violet lg:prose-xl lg:max-w-4xl"
205-
/>
206-
</div>
207-
)
208-
}
209-
210199
{
211200
sections.length > 0 && (
212-
<section class="my-12">
213-
<h2 class="mb-6 font-display text-3xl font-black text-[#2A1155] dark:text-white">
214-
course lessons<span class="text-[#7C3AED] dark:text-[#C084FC]">.</span>
215-
</h2>
216-
<div class="grid gap-6">
201+
<section id="course-lessons-section" class="my-10">
202+
<div class="mb-6 flex flex-wrap items-end justify-between gap-3">
203+
<h2 class="font-display text-3xl font-black text-[#2A1155] dark:text-white">
204+
course lessons<span class="text-[#7C3AED] dark:text-[#C084FC]">.</span>
205+
</h2>
206+
<span class="font-mono text-xs font-bold text-[#2A1155]/70 dark:text-slate-400">
207+
{sections.length} {sections.length === 1 ? "Section" : "Sections"} &middot; {allLessons.length} {allLessons.length === 1 ? "Lesson" : "Lessons"}
208+
</span>
209+
</div>
210+
<div class="grid grid-cols-1 min-w-0 gap-6">
217211
{sections.map((sec, sIdx) => (
218-
<div class="duo-card hand-border p-6">
219-
<div class="mb-4 flex items-center gap-3">
220-
<span class="inline-flex size-9 items-center justify-center rounded-full border-2 border-[#2A1155] bg-[#7C3AED] font-display font-black text-white shadow-sketch-sm dark:border-white/50">
212+
<div class="duo-card hand-border-rect min-w-0 max-w-full overflow-hidden p-4 sm:p-6">
213+
<div class="mb-4 flex min-w-0 items-center gap-3">
214+
<span class="inline-flex size-9 shrink-0 items-center justify-center rounded-full border-2 border-[#2A1155] bg-[#7C3AED] font-display font-black text-white shadow-sketch-sm dark:border-white/50">
221215
{sIdx + 1}
222216
</span>
223-
<h3 class="font-display text-2xl font-black text-[#2A1155] dark:text-white">
217+
<h3 class="min-w-0 font-display text-xl font-black text-[#2A1155] dark:text-white sm:text-2xl">
224218
{sec?.title || `Section ${sIdx + 1}`}
225219
</h3>
226220
</div>
227221
{sec?.lesson && sec.lesson.length > 0 && (
228-
<ul class="grid gap-3 sm:grid-cols-2">
222+
<ul class="grid grid-cols-1 min-w-0 gap-3 sm:grid-cols-2">
229223
{sec.lesson.map((lesson, lIdx) => {
230224
const lessonHref = lesson?.slug
231225
? `/course/${slug}/lesson/${lesson.slug}`
@@ -234,18 +228,18 @@ const sanitizedContent = (course.content ?? []).filter((block) => {
234228
lesson?.youtube || lesson?.videoCloudinary?.asset?._ref,
235229
);
236230
return (
237-
<li>
231+
<li class="min-w-0">
238232
{lessonHref ? (
239233
<a
240234
href={lessonHref}
241-
class="group flex h-full flex-col justify-between gap-3 rounded-xl border-2 border-[#2A1155] bg-[#FAF9FC] p-4 shadow-sketch-sm transition hover:-translate-y-0.5 hover:border-[#7C3AED] hover:bg-[#F3E8FF]/60 dark:border-white/20 dark:bg-[#0B1326] dark:hover:border-[#C084FC] dark:hover:bg-[#131B2E]"
235+
class="group flex h-full min-w-0 max-w-full flex-col justify-between gap-3 overflow-hidden rounded-xl border-2 border-[#2A1155] bg-[#FAF9FC] p-4 shadow-sketch-sm transition hover:-translate-y-0.5 hover:border-[#7C3AED] hover:bg-[#F3E8FF]/60 dark:border-white/20 dark:bg-[#0B1326] dark:hover:border-[#C084FC] dark:hover:bg-[#131B2E]"
242236
>
243-
<div class="flex items-start gap-3">
237+
<div class="flex min-w-0 items-start gap-3">
244238
<span class="mt-0.5 inline-flex shrink-0 items-center justify-center rounded-md border border-[#7C3AED]/40 bg-[#EDE9FE] px-2 py-0.5 font-mono text-xs font-extrabold text-[#7C3AED] dark:border-purple-400/40 dark:bg-purple-950/60 dark:text-[#C084FC]">
245239
{sIdx + 1}.{lIdx + 1}
246240
</span>
247241
<div class="min-w-0 flex-1">
248-
<span class="block font-bold text-[#2A1155] transition group-hover:text-[#7C3AED] dark:text-slate-100 dark:group-hover:text-[#C084FC]">
242+
<span class="block break-words font-bold text-[#2A1155] transition group-hover:text-[#7C3AED] dark:text-slate-100 dark:group-hover:text-[#C084FC]">
249243
{lesson?.title || `Lesson ${lIdx + 1}`}
250244
</span>
251245
{lesson?.excerpt && (
@@ -275,11 +269,11 @@ const sanitizedContent = (course.content ?? []).filter((block) => {
275269
</div>
276270
</a>
277271
) : (
278-
<div class="flex items-start gap-3 rounded-xl border-2 border-[#2A1155] bg-[#FAF9FC] p-4 shadow-sketch-sm dark:border-white/20 dark:bg-[#0B1326]">
279-
<span class="font-mono text-xs font-extrabold text-[#7C3AED] dark:text-[#C084FC]">
272+
<div class="flex min-w-0 items-start gap-3 rounded-xl border-2 border-[#2A1155] bg-[#FAF9FC] p-4 shadow-sketch-sm dark:border-white/20 dark:bg-[#0B1326]">
273+
<span class="shrink-0 font-mono text-xs font-extrabold text-[#7C3AED] dark:text-[#C084FC]">
280274
{sIdx + 1}.{lIdx + 1}
281275
</span>
282-
<span class="font-bold text-[#2A1155] dark:text-slate-100">
276+
<span class="min-w-0 break-words font-bold text-[#2A1155] dark:text-slate-100">
283277
{lesson?.title || `Lesson ${lIdx + 1}`}
284278
</span>
285279
</div>
@@ -296,6 +290,20 @@ const sanitizedContent = (course.content ?? []).filter((block) => {
296290
)
297291
}
298292

293+
{
294+
sanitizedContent.length > 0 && (
295+
<section id="course-overview-section" class="my-12">
296+
<h2 class="mb-6 font-display text-3xl font-black text-[#2A1155] dark:text-white">
297+
course overview<span class="text-[#7C3AED] dark:text-[#C084FC]">.</span>
298+
</h2>
299+
<PortableTextContent
300+
value={sanitizedContent}
301+
class="mx-auto prose-violet lg:prose-xl lg:max-w-4xl"
302+
/>
303+
</section>
304+
)
305+
}
306+
299307
<ExcalidrawBlueprintCard
300308
title={course.title}
301309
kind="course"

0 commit comments

Comments
 (0)