diff --git a/app/(admin)/layout.tsx b/app/(admin)/layout.tsx
index 6f7f23d..08dceda 100644
--- a/app/(admin)/layout.tsx
+++ b/app/(admin)/layout.tsx
@@ -1,5 +1,8 @@
import type { ReactNode } from "react";
import ConditionalNavigation from "../components/navigation/ConditionalNavigation";
+import { MessagesProvider } from "../contexts/MessagesContext";
+import { AuthModalProvider } from "../contexts/AuthModalContext";
+import messages from "../../messages/en/index";
// Reads the session for admin chrome (matches the HttpOnly cookie on first
// HTML response). Scoped here so `(marketing)` can render statically.
@@ -9,9 +12,11 @@ export const dynamic = "force-dynamic";
// public marketing footer. Auth/access is enforced upstream.
export default function AdminLayout({ children }: { children: ReactNode }) {
return (
- <>
-
- {children}
- >
+
+
+
+ {children}
+
+
);
}
diff --git a/app/(app)/layout.tsx b/app/(app)/layout.tsx
index 2218eaf..11e8278 100644
--- a/app/(app)/layout.tsx
+++ b/app/(app)/layout.tsx
@@ -1,5 +1,8 @@
import type { ReactNode } from "react";
import ConditionalNavigation from "../components/navigation/ConditionalNavigation";
+import { MessagesProvider } from "../contexts/MessagesContext";
+import { AuthModalProvider } from "../contexts/AuthModalContext";
+import messages from "../../messages/en/index";
// Reads `cr_session` via Server Components on every navigation so the header
// matches the HttpOnly cookie on the first HTML response (no "Log in" flash
@@ -12,9 +15,11 @@ export const dynamic = "force-dynamic";
// CreateFlow) is composed in nested layouts.
export default function AppLayout({ children }: { children: ReactNode }) {
return (
- <>
-
- {children}
- >
+
+
+
+ {children}
+
+
);
}
diff --git a/app/(dev)/layout.tsx b/app/(dev)/layout.tsx
index e67fa09..699928e 100644
--- a/app/(dev)/layout.tsx
+++ b/app/(dev)/layout.tsx
@@ -1,10 +1,19 @@
import type { ReactNode } from "react";
import { notFound } from "next/navigation";
+import { MessagesProvider } from "../contexts/MessagesContext";
+import { AuthModalProvider } from "../contexts/AuthModalContext";
+import messages from "../../messages/en/index";
// Development-only previews (e.g. `/components-preview`) — no public chrome.
export default function DevLayout({ children }: { children: ReactNode }) {
if (process.env.NODE_ENV === "production") {
notFound();
}
- return {children} ;
+ return (
+
+
+ {children}
+
+
+ );
}
diff --git a/app/(marketing)/layout.tsx b/app/(marketing)/layout.tsx
index 188695d..6155b79 100644
--- a/app/(marketing)/layout.tsx
+++ b/app/(marketing)/layout.tsx
@@ -1,6 +1,9 @@
import dynamic from "next/dynamic";
import type { ReactNode } from "react";
import MarketingNavigation from "../components/navigation/MarketingNavigation";
+import { MessagesProvider } from "../contexts/MessagesContext";
+import { AuthModalProvider } from "../contexts/AuthModalContext";
+import marketingMessages from "../../messages/en/marketing";
// Site footer is part of the public marketing chrome only — not rendered for
// signed-in product surfaces, admin dashboards, or dev previews. See
@@ -14,10 +17,12 @@ const Footer = dynamic(() => import("../components/navigation/Footer"), {
export default function MarketingLayout({ children }: { children: ReactNode }) {
return (
- <>
-
- {children}
-
- >
+
+
+
+ {children}
+
+
+
);
}
diff --git a/app/(marketing)/learn/page.tsx b/app/(marketing)/learn/page.tsx
index 3671bfe..38ada81 100644
--- a/app/(marketing)/learn/page.tsx
+++ b/app/(marketing)/learn/page.tsx
@@ -29,22 +29,18 @@ export default function LearnPage() {
-
+ {/*
+ * Single responsive render: ContentThumbnailTemplate variant="responsive"
+ * uses
to swap horizontal/vertical art at smd (530px). The
+ * container switches from a vertical flex stack (
{allPosts.map((post) => (
- ))}
-
-
-
- {allPosts.map((post) => (
-
))}
diff --git a/app/(marketing-case-study)/layout.tsx b/app/(marketing-case-study)/layout.tsx
index 8e8d60a..3b43e9a 100644
--- a/app/(marketing-case-study)/layout.tsx
+++ b/app/(marketing-case-study)/layout.tsx
@@ -1,4 +1,7 @@
import type { ReactNode } from "react";
+import { MessagesProvider } from "../contexts/MessagesContext";
+import { AuthModalProvider } from "../contexts/AuthModalContext";
+import marketingMessages from "../../messages/en/marketing";
/** Full-viewport case-study surfaces (completed rule demos) — no marketing footer. */
export default function MarketingCaseStudyLayout({
@@ -7,8 +10,12 @@ export default function MarketingCaseStudyLayout({
children: ReactNode;
}) {
return (
-
- {children}
-
+
+
+
+ {children}
+
+
+
);
}
diff --git a/app/components/content/ContentThumbnailTemplate/ContentThumbnailTemplate.container.tsx b/app/components/content/ContentThumbnailTemplate/ContentThumbnailTemplate.container.tsx
index 648b1c3..f99057b 100644
--- a/app/components/content/ContentThumbnailTemplate/ContentThumbnailTemplate.container.tsx
+++ b/app/components/content/ContentThumbnailTemplate/ContentThumbnailTemplate.container.tsx
@@ -23,14 +23,13 @@ const ContentThumbnailTemplateContainer = memo
(
}) => {
const variant = variantProp;
const sizing = sizingProp;
- // Get article-specific background image from frontmatter
const getBackgroundImage = (
post: ContentThumbnailTemplateProps["post"],
- variant: "vertical" | "horizontal",
+ orientation: "vertical" | "horizontal",
): string => {
if (post.frontmatter?.thumbnail) {
const imageName =
- variant === "vertical"
+ orientation === "vertical"
? post.frontmatter.thumbnail.vertical
: post.frontmatter.thumbnail.horizontal;
@@ -47,12 +46,21 @@ const ContentThumbnailTemplateContainer = memo(
? slug
: contentCatalogSlugForFallback(slug);
- return variant === "vertical"
+ return orientation === "vertical"
? contentBlogVerticalPath(resolvedSlug)
: contentBlogHorizontalPath(resolvedSlug);
};
- const backgroundImage = getBackgroundImage(post, variant);
+ // For "responsive", emit both orientations so the source can
+ // swap at smd without a second card in the DOM.
+ const backgroundImage =
+ variant === "responsive"
+ ? getBackgroundImage(post, "horizontal")
+ : getBackgroundImage(post, variant);
+ const backgroundImageSmd =
+ variant === "responsive"
+ ? getBackgroundImage(post, "vertical")
+ : undefined;
return (
(
variant={variant}
sizing={sizing}
backgroundImage={backgroundImage}
+ backgroundImageSmd={backgroundImageSmd}
/>
);
},
diff --git a/app/components/content/ContentThumbnailTemplate/ContentThumbnailTemplate.types.ts b/app/components/content/ContentThumbnailTemplate/ContentThumbnailTemplate.types.ts
index a7cf2d8..af871b2 100644
--- a/app/components/content/ContentThumbnailTemplate/ContentThumbnailTemplate.types.ts
+++ b/app/components/content/ContentThumbnailTemplate/ContentThumbnailTemplate.types.ts
@@ -1,6 +1,9 @@
import type { BlogPost } from "../../../../lib/content";
-export type ContentThumbnailTemplateVariantValue = "vertical" | "horizontal";
+export type ContentThumbnailTemplateVariantValue =
+ | "vertical"
+ | "horizontal"
+ | "responsive";
export type ContentThumbnailTemplateSizingValue = "fluid" | "fixed";
@@ -8,7 +11,8 @@ export interface ContentThumbnailTemplateProps {
post: BlogPost;
className?: string;
/**
- * Content thumbnail variant.
+ * vertical | horizontal — single layout. responsive — horizontal at .
*/
variant?: ContentThumbnailTemplateVariantValue;
/**
@@ -21,7 +25,9 @@ export interface ContentThumbnailTemplateProps {
export interface ContentThumbnailTemplateViewProps {
post: BlogPost;
className: string;
- variant: "vertical" | "horizontal";
+ variant: ContentThumbnailTemplateVariantValue;
sizing: ContentThumbnailTemplateSizingValue;
backgroundImage: string;
+ /** Wide-viewport image source for variant="responsive" (≥smd). */
+ backgroundImageSmd?: string;
}
diff --git a/app/components/content/ContentThumbnailTemplate/ContentThumbnailTemplate.view.tsx b/app/components/content/ContentThumbnailTemplate/ContentThumbnailTemplate.view.tsx
index 755ccd3..6bd3929 100644
--- a/app/components/content/ContentThumbnailTemplate/ContentThumbnailTemplate.view.tsx
+++ b/app/components/content/ContentThumbnailTemplate/ContentThumbnailTemplate.view.tsx
@@ -9,7 +9,41 @@ function ContentThumbnailTemplateView({
variant,
sizing,
backgroundImage,
+ backgroundImageSmd,
}: ContentThumbnailTemplateViewProps) {
+ if (variant === "responsive") {
+ // Single card; swaps the orientation-specific image at smd
+ // (530px), aspect-ratio and content positioning switch via Tailwind.
+ return (
+
+
+
+
+ {backgroundImageSmd ? (
+
+ ) : null}
+ {/* eslint-disable-next-line @next/next/no-img-element */}
+
+
+
+
+
+
+
+
+ );
+ }
+
if (variant === "vertical") {
if (sizing === "fixed") {
return (
diff --git a/app/components/sections/HeroBanner/HeroBanner.tsx b/app/components/sections/HeroBanner/HeroBanner.tsx
index 4030f5f..d8c8779 100644
--- a/app/components/sections/HeroBanner/HeroBanner.tsx
+++ b/app/components/sections/HeroBanner/HeroBanner.tsx
@@ -5,11 +5,20 @@
*/
import { memo } from "react";
+import Image from "next/image";
import { useTranslation } from "../../../contexts/MessagesContext";
import ContentLockup from "../../type/ContentLockup";
import HeroDecor from "./HeroDecor";
import { ASSETS, getAssetPath } from "../../../../lib/assetUtils";
+/**
+ * Intrinsic dimensions of `public/assets/marketing/hero-image.png` (2560×1600,
+ * 16:10). Passed to `next/image` to reserve aspect ratio + drive responsive
+ * srcset generation. Actual rendered size is governed by `sizes`.
+ */
+const HERO_IMAGE_WIDTH = 2560;
+const HERO_IMAGE_HEIGHT = 1600;
+
interface HeroBannerProps {
title?: string;
subtitle?: string;
@@ -50,13 +59,14 @@ const HeroBanner = memo(
{/* Hero Image Container */}
- {/* eslint-disable-next-line @next/next/no-img-element -- dynamic path from getAssetPath */}
-
diff --git a/app/layout.tsx b/app/layout.tsx
index 05cc618..a1eac4e 100644
--- a/app/layout.tsx
+++ b/app/layout.tsx
@@ -1,8 +1,6 @@
import { Inter, Bricolage_Grotesque, Space_Grotesk } from "next/font/google";
import type { Metadata, Viewport } from "next";
import type { ReactNode } from "react";
-import { AuthModalProvider } from "./contexts/AuthModalContext";
-import { MessagesProvider } from "./contexts/MessagesContext";
import messages from "../messages/en/index";
import { ASSETS, getAssetPath } from "../lib/assetUtils";
import "./globals.css";
@@ -11,6 +9,11 @@ import "./globals.css";
// (the only groups that read the session via `ConditionalNavigation`). Marketing
// renders a client-side `MarketingNavigation` so its HTML can be statically
// optimized — TTFB drops to CDN speed for guests.
+//
+// MessagesProvider + AuthModalProvider are mounted per route group (Phase 4b):
+// `(marketing)` gets a trimmed slice without `create.*` (~41 KB gzipped saved
+// per static page); `(app)`/`(admin)`/`(dev)` get the full tree. See
+// `messages/en/marketing.ts` and `docs/perf/next16-eval.md`.
const inter = Inter({
subsets: ["latin"],
@@ -142,11 +145,7 @@ export default function RootLayout({ children }: { children: ReactNode }) {
-
-
- {children}
-
-
+ {children}