- ) : null}
-
- {/* Loading state */}
- {imageLoading && !imageError && (
-
- )}
-
- {/* Error state - show initials */}
- {imageError && (
+ ) : (
diff --git a/app/components/sections/RuleStack/RuleStack.container.tsx b/app/components/sections/RuleStack/RuleStack.container.tsx
index 03cca01..0fbf074 100644
--- a/app/components/sections/RuleStack/RuleStack.container.tsx
+++ b/app/components/sections/RuleStack/RuleStack.container.tsx
@@ -8,7 +8,7 @@ import { memo, useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { useTranslation } from "../../../contexts/MessagesContext";
import { logger } from "../../../../lib/logger";
-import { prepareFreshCreateFlowEntry } from "../../../(app)/create/utils/prepareFreshCreateFlowEntry";
+import { prepareFreshCreateFlowEntrySync } from "../../../(app)/create/utils/prepareFreshCreateFlowEntry";
import {
fetchTemplates,
isTemplatesFetchAborted,
@@ -99,10 +99,8 @@ const RuleStackContainer = memo
(
logger.debug(`${slug} template clicked`);
// Marketing home “Popular templates”: same fresh start as Top “Create rule”
// (local + server draft when sync) so stale state cannot break template apply.
- void (async () => {
- await prepareFreshCreateFlowEntry();
- router.push(`/create/review-template/${encodeURIComponent(slug)}`);
- })();
+ prepareFreshCreateFlowEntrySync();
+ router.push(`/create/review-template/${encodeURIComponent(slug)}`);
};
return (
diff --git a/app/layout.tsx b/app/layout.tsx
index 9fa8c7f..05cc618 100644
--- a/app/layout.tsx
+++ b/app/layout.tsx
@@ -6,10 +6,11 @@ import { MessagesProvider } from "./contexts/MessagesContext";
import messages from "../messages/en/index";
import { ASSETS, getAssetPath } from "../lib/assetUtils";
import "./globals.css";
-import ConditionalNavigation from "./components/navigation/ConditionalNavigation";
-/** Header reads `cr_session` via Server Components; must not use prerendered guest HTML. */
-export const dynamic = "force-dynamic";
+// `force-dynamic` is now scoped to `(app)/layout.tsx` and `(admin)/layout.tsx`
+// (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.
const inter = Inter({
subsets: ["latin"],
@@ -34,7 +35,9 @@ const spaceGrotesk = Space_Grotesk({
weight: ["400", "500", "700"],
variable: "--font-space-grotesk",
display: "swap",
- preload: true,
+ // Below-the-fold (subtitle in `ContentLockup` only). Skipping preload keeps
+ // the marketing critical-path bytes for Inter + Bricolage.
+ preload: false,
fallback: ["system-ui", "arial"],
});
@@ -116,15 +119,32 @@ export default function RootLayout({ children }: { children: ReactNode }) {
return (
+
+
+
+
+
-
-
- {children}
-
+ {children}
diff --git a/messages/en/pages/useCases.json b/messages/en/pages/useCases.json
index 50b4338..1cc1ea8 100644
--- a/messages/en/pages/useCases.json
+++ b/messages/en/pages/useCases.json
@@ -49,7 +49,7 @@
"tripleStep": {
"heading": "Get recommendations that will make organizing easier",
"ctaText": "Create Rule",
- "ctaHref": "/create",
+ "ctaHref": "/create/informational",
"steps": [
{
"title": "Get your stakeholders together",
@@ -68,7 +68,7 @@
"tripleTextBlock": {
"title": "Why Horizontal groups need CommunityRule",
"ctaText": "Setup your community",
- "ctaHref": "/create",
+ "ctaHref": "/create/informational",
"columns": [
{
"title": "Share Leadership and Prevent Burnout",
diff --git a/next.config.mjs b/next.config.mjs
index d5caa5c..a2600c4 100644
--- a/next.config.mjs
+++ b/next.config.mjs
@@ -29,7 +29,7 @@ const nextConfig = {
// Image optimization
images: {
formats: ["image/webp", "image/avif"],
- minimumCacheTTL: 60,
+ minimumCacheTTL: 31536000,
dangerouslyAllowSVG: true,
contentSecurityPolicy: "default-src 'self'; script-src 'none'; sandbox;",
remotePatterns: [
@@ -70,6 +70,18 @@ const nextConfig = {
},
],
},
+ {
+ // Long-cache static marketing art (avatars, vectors, case-study, etc.)
+ // since the file content is hashed into the URL by Next at request time
+ // through the image optimizer for raster, and changes require a deploy.
+ source: "/assets/:path*\\.(svg|png|webp|avif|jpg|jpeg)",
+ headers: [
+ {
+ key: "Cache-Control",
+ value: "public, max-age=31536000, immutable",
+ },
+ ],
+ },
];
},
webpack(config, { dev, isServer }) {
diff --git a/tests/components/Top.test.tsx b/tests/components/Top.test.tsx
index dd3f75b..a47e7ca 100644
--- a/tests/components/Top.test.tsx
+++ b/tests/components/Top.test.tsx
@@ -73,7 +73,7 @@ describe('Top "Create rule" button', () => {
* in-flight anonymous draft so the wizard always starts fresh. See
* handleCreateRuleClick in Top.container.tsx for the contract.
*/
- it("clears anonymous draft + core-value-details localStorage before routing to /create", async () => {
+ it("clears anonymous draft + core-value-details localStorage before routing to /create/informational", async () => {
window.localStorage.setItem(
CREATE_FLOW_ANONYMOUS_KEY,
JSON.stringify({ title: "Stale community" }),
@@ -93,7 +93,7 @@ describe('Top "Create rule" button', () => {
await userEvent.click(btn);
await waitFor(() => {
- expect(pushMock).toHaveBeenCalledWith("/create");
+ expect(pushMock).toHaveBeenCalledWith("/create/informational");
});
expect(window.localStorage.getItem(CREATE_FLOW_ANONYMOUS_KEY)).toBeNull();
expect(
diff --git a/tests/components/cards/CaseStudy.test.tsx b/tests/components/cards/CaseStudy.test.tsx
index 522fc12..7524f53 100644
--- a/tests/components/cards/CaseStudy.test.tsx
+++ b/tests/components/cards/CaseStudy.test.tsx
@@ -10,22 +10,24 @@ describe("CaseStudy", () => {
expect(container.querySelector('[data-figma-node="21993-32352"]')).toBeTruthy();
});
- it("renders built-in raster when visual is omitted (neutral)", () => {
+ it("renders built-in art when visual is omitted (neutral)", () => {
render(
,
);
expect(
screen.getByRole("img", { name: "Food Not Bombs logo" }),
- ).toHaveAttribute("src");
+ ).toBeTruthy();
});
it("uses Mutual Aid vector on lavender surface", () => {
const { container } = render(
,
);
- expect(container.querySelector("img")?.getAttribute("src")).toContain(
- "case-study-mutual-aid.svg",
- );
+ expect(
+ container.querySelector("[data-case-study-art]")?.getAttribute(
+ "data-case-study-art",
+ ),
+ ).toBe("case-study-mutual-aid");
});
});
diff --git a/tests/unit/Layout.test.jsx b/tests/unit/Layout.test.jsx
index 8a5f4a5..e252a44 100644
--- a/tests/unit/Layout.test.jsx
+++ b/tests/unit/Layout.test.jsx
@@ -138,10 +138,13 @@ describe("Group layouts (chrome composition)", () => {
test("AppLayout wraps children in with no footer", () => {
const tree = AppLayout({ children: app-child
});
- expect(tree.type).toBe("main");
- expect(tree.props.className).toContain("flex-1");
+ const main = findDescendant(
+ tree,
+ (n) => n?.type === "main" && n.props?.className?.includes("flex-1"),
+ );
+ expect(main).toBeTruthy();
expect(
- findDescendant(tree, (n) => typeof n === "string" && n.includes("app-child")),
+ findDescendant(main, (n) => typeof n === "string" && n.includes("app-child")),
).toBeTruthy();
});
});
diff --git a/vitest.config.mjs b/vitest.config.mjs
index 9944ad7..cc19fdd 100644
--- a/vitest.config.mjs
+++ b/vitest.config.mjs
@@ -13,6 +13,33 @@ export default defineConfig({
}
},
},
+ // Stub .svg imports as inert React components so SVGR-style imports
+ // (`import Foo from "./foo.svg"; `) work without webpack/turbopack.
+ // `enforce: "pre"` so we run before Vite's default asset handler that would
+ // otherwise return the URL string.
+ {
+ name: "svg-mock",
+ enforce: "pre",
+ async resolveId(source, importer) {
+ if (!source.endsWith(".svg")) return null;
+ const resolved = await this.resolve(source, importer, {
+ skipSelf: true,
+ });
+ if (!resolved) return null;
+ return { ...resolved, id: `${resolved.id}?svg-mock` };
+ },
+ load(id) {
+ if (id.endsWith(".svg?svg-mock")) {
+ return `import * as React from "react";
+const SvgMock = React.forwardRef(function SvgMock(props, ref) {
+ return React.createElement("svg", { ref, ...props });
+});
+export default SvgMock;
+export const ReactComponent = SvgMock;
+`;
+ }
+ },
+ },
],
esbuild: {
target: "node18",