Give the shell one header, a skip link, and a cookie-aware first paint. #73
+12
-10
@@ -14,10 +14,10 @@ the file tree without affecting URLs.
|
||||
|
||||
| Group | URL surface | Audience | Chrome |
|
||||
|---|---|---|---|
|
||||
| `app/(marketing)/` | `/`, `/learn`, `/blog`, `/templates`, future public pages | Public, indexable | `Top` (via root) + marketing `<Footer />` |
|
||||
| `app/(app)/` | `/create/*`, `/login`, `/profile`, future signed-in surfaces | Authenticated product | `Top` (via root) — no footer except **`/profile`** (see `profile/layout.tsx`) |
|
||||
| `app/(admin)/` | `/monitor`, future ops dashboards | Operators | `Top` (via root) — no footer |
|
||||
| `app/(dev)/` | `/components-preview`, future dev previews | Local dev (NODE_ENV gated) | `Top` (via root) — no footer |
|
||||
| `app/(marketing)/` | `/`, `/learn`, `/blog`, `/templates`, future public pages | Public, indexable | `SkipToContent` + `ConditionalNavigation` + marketing `<Footer />` |
|
||||
| `app/(app)/` | `/create/*`, `/login`, `/profile`, future signed-in surfaces | Authenticated product | `SkipToContent` + `ConditionalNavigation` — no footer except **`/profile`** (see `profile/layout.tsx`) |
|
||||
| `app/(admin)/` | `/monitor`, future ops dashboards | Operators | `SkipToContent` + `ConditionalNavigation` — no footer |
|
||||
| `app/(dev)/` | `/components-preview`, future dev previews | Local dev (NODE_ENV gated) | `Top` omitted — no footer |
|
||||
| `app/(marketing-case-study)/` | `/use-cases/[slug]/rule` | Public case-study demos | Chromeless (no global `Top`; see `navigationChromelessPath.ts`) |
|
||||
| `app/api/` | API routes | n/a | n/a |
|
||||
|
||||
@@ -28,14 +28,16 @@ the folder next to `(marketing)/`.
|
||||
## Layout responsibilities
|
||||
|
||||
- **`app/layout.tsx`** — `<html>`, `<body>`, providers (`MessagesProvider`,
|
||||
`AuthModalProvider`), fonts, and `ConditionalNavigation`. Renders
|
||||
`AuthModalProvider` are per group), fonts. Renders
|
||||
`{children}` directly inside the flex column. **Does not** render
|
||||
`<main>` — each group layout owns that.
|
||||
- **`app/(marketing)/layout.tsx`** — wraps with `<main className="flex-1">`
|
||||
and appends the public `<Footer />`.
|
||||
- **`app/(app)/layout.tsx`** / **`(admin)/layout.tsx`** / **`(dev)/layout.tsx`** —
|
||||
wrap with `<main className="flex-1">`. No footer by default; **`app/(app)/profile/layout.tsx`**
|
||||
`<main>` or the header — each group layout owns that.
|
||||
- **`app/(marketing)/layout.tsx`** — skip-to-content, `ConditionalNavigation`
|
||||
(SSR session), `<main id="main-content">`, public `<Footer />`.
|
||||
- **`app/(app)/layout.tsx`** / **`(admin)/layout.tsx`** —
|
||||
skip-to-content, `ConditionalNavigation`, `<main id="main-content">`.
|
||||
No footer by default; **`app/(app)/profile/layout.tsx`**
|
||||
appends the marketing `<Footer />` for `/profile` only.
|
||||
- **`app/(dev)/layout.tsx`** — `<main id="main-content">` only.
|
||||
- **Nested layouts** (e.g. `(app)/create/layout.tsx`) compose feature-specific
|
||||
chrome inside the group's `<main>` — never render `<html>`, `<body>`,
|
||||
`<main>`, or providers.
|
||||
|
||||
+10
-1
@@ -1,9 +1,11 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Suspense, type ReactNode } from "react";
|
||||
import ConditionalNavigation from "../components/navigation/ConditionalNavigation";
|
||||
import SkipToContent from "../components/navigation/SkipToContent";
|
||||
import { MessagesProvider } from "../contexts/MessagesContext";
|
||||
import { AuthModalProvider } from "../contexts/AuthModalContext";
|
||||
import messages from "../../messages/en/index";
|
||||
import { MAIN_CONTENT_ID } from "../../lib/mainContent";
|
||||
import { NO_INDEX_ROBOTS } from "../../lib/siteMetadata";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
@@ -20,10 +22,17 @@ export default function AdminLayout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<MessagesProvider messages={messages}>
|
||||
<AuthModalProvider>
|
||||
<SkipToContent />
|
||||
<Suspense fallback={null}>
|
||||
<ConditionalNavigation />
|
||||
</Suspense>
|
||||
<main className="flex-1">{children}</main>
|
||||
<main
|
||||
id={MAIN_CONTENT_ID}
|
||||
tabIndex={-1}
|
||||
className="flex-1 outline-none"
|
||||
>
|
||||
{children}
|
||||
</main>
|
||||
</AuthModalProvider>
|
||||
</MessagesProvider>
|
||||
);
|
||||
|
||||
+10
-1
@@ -1,9 +1,11 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Suspense, type ReactNode } from "react";
|
||||
import ConditionalNavigation from "../components/navigation/ConditionalNavigation";
|
||||
import SkipToContent from "../components/navigation/SkipToContent";
|
||||
import { MessagesProvider } from "../contexts/MessagesContext";
|
||||
import { AuthModalProvider } from "../contexts/AuthModalContext";
|
||||
import messages from "../../messages/en/index";
|
||||
import { MAIN_CONTENT_ID } from "../../lib/mainContent";
|
||||
import { NO_INDEX_ROBOTS } from "../../lib/siteMetadata";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
@@ -27,10 +29,17 @@ export default function AppLayout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<MessagesProvider messages={messages}>
|
||||
<AuthModalProvider>
|
||||
<SkipToContent />
|
||||
<Suspense fallback={null}>
|
||||
<ConditionalNavigation />
|
||||
</Suspense>
|
||||
<main className="flex-1">{children}</main>
|
||||
<main
|
||||
id={MAIN_CONTENT_ID}
|
||||
tabIndex={-1}
|
||||
className="flex-1 outline-none"
|
||||
>
|
||||
{children}
|
||||
</main>
|
||||
</AuthModalProvider>
|
||||
</MessagesProvider>
|
||||
);
|
||||
|
||||
@@ -4,6 +4,7 @@ import { notFound } from "next/navigation";
|
||||
import { MessagesProvider } from "../contexts/MessagesContext";
|
||||
import { AuthModalProvider } from "../contexts/AuthModalContext";
|
||||
import messages from "../../messages/en/index";
|
||||
import { MAIN_CONTENT_ID } from "../../lib/mainContent";
|
||||
import { NO_INDEX_ROBOTS } from "../../lib/siteMetadata";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
@@ -18,7 +19,13 @@ export default function DevLayout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<MessagesProvider messages={messages}>
|
||||
<AuthModalProvider>
|
||||
<main className="flex-1">{children}</main>
|
||||
<main
|
||||
id={MAIN_CONTENT_ID}
|
||||
tabIndex={-1}
|
||||
className="flex-1 outline-none"
|
||||
>
|
||||
{children}
|
||||
</main>
|
||||
</AuthModalProvider>
|
||||
</MessagesProvider>
|
||||
);
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import dynamic from "next/dynamic";
|
||||
import { Suspense, type ReactNode } from "react";
|
||||
import MarketingNavigation from "../components/navigation/MarketingNavigation";
|
||||
import ConditionalNavigation from "../components/navigation/ConditionalNavigation";
|
||||
import SkipToContent from "../components/navigation/SkipToContent";
|
||||
import { MessagesProvider } from "../contexts/MessagesContext";
|
||||
import { AuthModalProvider } from "../contexts/AuthModalContext";
|
||||
import { MAIN_CONTENT_ID } from "../../lib/mainContent";
|
||||
import marketingMessages from "../../messages/en/marketing";
|
||||
|
||||
// Site footer is part of the public marketing chrome only — not rendered for
|
||||
@@ -20,14 +22,21 @@ export default function MarketingLayout({ children }: { children: ReactNode }) {
|
||||
<MessagesProvider messages={marketingMessages}>
|
||||
<AuthModalProvider>
|
||||
{/*
|
||||
* MarketingNavigation reads `usePathname()` to decide chromeless paths
|
||||
* (uncached data under `cacheComponents`). Suspense lets the static
|
||||
* shell prerender; the nav streams in with the correct visibility.
|
||||
* Same session-aware shell as `(app)` / `(admin)`: `ConditionalNavigation`
|
||||
* reads the cookie behind Suspense so the static page shell can prerender
|
||||
* while the header streams signed-in vs signed-out correctly.
|
||||
*/}
|
||||
<SkipToContent />
|
||||
<Suspense fallback={null}>
|
||||
<MarketingNavigation />
|
||||
<ConditionalNavigation />
|
||||
</Suspense>
|
||||
<main className="flex-1">{children}</main>
|
||||
<main
|
||||
id={MAIN_CONTENT_ID}
|
||||
tabIndex={-1}
|
||||
className="flex-1 outline-none"
|
||||
>
|
||||
{children}
|
||||
</main>
|
||||
<Footer />
|
||||
</AuthModalProvider>
|
||||
</MessagesProvider>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { MessagesProvider } from "../contexts/MessagesContext";
|
||||
import { AuthModalProvider } from "../contexts/AuthModalContext";
|
||||
import { MAIN_CONTENT_ID } from "../../lib/mainContent";
|
||||
import marketingMessages from "../../messages/en/marketing";
|
||||
|
||||
/** Full-viewport case-study surfaces (completed rule demos) — no marketing footer. */
|
||||
@@ -12,7 +13,11 @@ export default function MarketingCaseStudyLayout({
|
||||
return (
|
||||
<MessagesProvider messages={marketingMessages}>
|
||||
<AuthModalProvider>
|
||||
<main className="flex h-dvh min-h-0 flex-col overflow-hidden">
|
||||
<main
|
||||
id={MAIN_CONTENT_ID}
|
||||
tabIndex={-1}
|
||||
className="flex h-dvh min-h-0 flex-col overflow-hidden outline-none"
|
||||
>
|
||||
{children}
|
||||
</main>
|
||||
</AuthModalProvider>
|
||||
|
||||
@@ -95,7 +95,7 @@ const Logo = memo<LogoProps>(
|
||||
const wordmarkVisibilityClass =
|
||||
size === "topNavFolderTop" || size === "topNavHeader"
|
||||
? wordmark
|
||||
? "hidden sm:block"
|
||||
? "hidden md:block"
|
||||
: "hidden"
|
||||
: wordmark
|
||||
? ""
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { memo } from "react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { isChromelessNavigationPath } from "../../../lib/navigationChromelessPath";
|
||||
import TopWithPathname from "./Top/TopWithPathname";
|
||||
|
||||
/**
|
||||
* Marketing-only navigation. Skips the server-side `getNavAuthSignedIn()` call
|
||||
* so marketing pages can render statically (no `force-dynamic`); `TopWithPathname`
|
||||
* fetches `/api/auth/session` on mount and updates the header from "Log in" to
|
||||
* "Profile" when the user is signed in. Brief mismatch is acceptable here —
|
||||
* `(app)` / `(admin)` keep the server-rendered nav.
|
||||
*/
|
||||
const MarketingNavigation = memo(() => {
|
||||
const pathname = usePathname();
|
||||
|
||||
if (isChromelessNavigationPath(pathname)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <TopWithPathname initialSignedIn={false} />;
|
||||
});
|
||||
|
||||
MarketingNavigation.displayName = "MarketingNavigation";
|
||||
|
||||
export default MarketingNavigation;
|
||||
@@ -0,0 +1,14 @@
|
||||
import header from "../../../messages/en/components/header.json";
|
||||
import { MAIN_CONTENT_ID } from "../../../lib/mainContent";
|
||||
|
||||
/**
|
||||
* First focusable control in the shell. Revealed on focus so keyboard users
|
||||
* can bypass the header and land on the group `<main>`.
|
||||
*/
|
||||
export default function SkipToContent() {
|
||||
return (
|
||||
<a href={`#${MAIN_CONTENT_ID}`} className="skip-to-content text-medium-label">
|
||||
{header.skipToContent}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
@@ -15,18 +15,7 @@ import Avatar from "../../asset/Avatar";
|
||||
import { getAssetPath, ASSETS } from "../../../../lib/assetUtils";
|
||||
import { prepareFreshCreateFlowEntrySync } from "../../../(app)/create/utils/prepareFreshCreateFlowEntry";
|
||||
import { TopView } from "./Top.view";
|
||||
import type { TopProps, NavSize } from "./Top.types";
|
||||
|
||||
type MenuClusterSize = "X Small" | "Small" | "Medium" | "Large" | "X Large";
|
||||
|
||||
/** Map responsive `NavSize` breakpoints to Figma menu item sizes (shared by nav links + login). */
|
||||
const NAV_SIZE_TO_MENU_ITEM_SIZE: Record<NavSize, MenuClusterSize> = {
|
||||
xsmall: "X Small",
|
||||
homeMd: "Medium",
|
||||
large: "Large",
|
||||
homeXlarge: "X Large",
|
||||
xlarge: "X Large",
|
||||
};
|
||||
import type { TopProps } from "./Top.types";
|
||||
|
||||
export const avatarImageSources = [
|
||||
getAssetPath(ASSETS.AVATAR_3),
|
||||
@@ -40,6 +29,16 @@ export const avatarImages = avatarImageSources.map((src, index) => ({
|
||||
alt: `Avatar ${3 - index}`,
|
||||
}));
|
||||
|
||||
/** Padding/type that tracks lg/xl Menu sizes without cloning the items per breakpoint. */
|
||||
const NAV_ITEM_RESPONSIVE_CLASS =
|
||||
"lg:px-[var(--spacing-scale-016)] lg:py-[var(--spacing-scale-016)] lg:h-[44px] lg:text-medium-label xl:text-x-large-label";
|
||||
|
||||
const FOLDER_NAV_ITEM_RESPONSIVE_CLASS =
|
||||
"md:px-[var(--spacing-scale-008)] md:py-[var(--spacing-scale-008)] md:h-[32px] md:text-x-small-label lg:px-[var(--spacing-scale-016)] lg:py-[var(--spacing-scale-016)] lg:h-[44px] lg:text-medium-label xl:text-x-large-label";
|
||||
|
||||
const CREATE_RULE_RESPONSIVE_CLASS =
|
||||
"lg:p-[var(--spacing-scale-012)] lg:gap-[var(--spacing-scale-006)] lg:text-medium-label xl:p-[var(--spacing-scale-016)] xl:gap-[var(--spacing-scale-008)] xl:text-x-large-label";
|
||||
|
||||
const TopContainer = memo<TopProps>(
|
||||
({ folderTop = false, loggedIn = false, profile = false, logIn = true }) => {
|
||||
const pathname = usePathname();
|
||||
@@ -61,7 +60,6 @@ const TopContainer = memo<TopProps>(
|
||||
router.push("/create/informational");
|
||||
}, [loggedIn, router]);
|
||||
|
||||
// Schema markup for site navigation
|
||||
const schemaData = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "WebSite",
|
||||
@@ -77,32 +75,32 @@ const TopContainer = memo<TopProps>(
|
||||
},
|
||||
};
|
||||
|
||||
// Logo size based on folderTop prop
|
||||
const logoSize = folderTop ? "topNavFolderTop" : "topNavHeader";
|
||||
|
||||
// Navigation items with translations
|
||||
const navigationItems = [
|
||||
{ href: "/use-cases", text: t("navigation.useCases"), extraPadding: true },
|
||||
{ href: "/learn", text: t("navigation.learn") },
|
||||
{ href: "/about", text: t("navigation.about") },
|
||||
];
|
||||
|
||||
const renderNavigationItems = (size: NavSize) => {
|
||||
const renderNavigationItems = () => {
|
||||
const mode = folderTop ? "inverse" : "default";
|
||||
const sizeClass = folderTop
|
||||
? FOLDER_NAV_ITEM_RESPONSIVE_CLASS
|
||||
: NAV_ITEM_RESPONSIVE_CLASS;
|
||||
|
||||
return navigationItems.map((item, index) => {
|
||||
const itemSize = NAV_SIZE_TO_MENU_ITEM_SIZE[size];
|
||||
|
||||
return navigationItems.map((item) => {
|
||||
const isUseCases = item.extraPadding === true;
|
||||
|
||||
return (
|
||||
<MenuItem
|
||||
key={index}
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
size={itemSize}
|
||||
size="X Small"
|
||||
mode={mode}
|
||||
state={pathname === item.href ? "selected" : "default"}
|
||||
reducedPadding={isUseCases}
|
||||
className={sizeClass}
|
||||
ariaLabel={t("ariaLabels.navigateToPage").replace(
|
||||
"{text}",
|
||||
item.text,
|
||||
@@ -114,34 +112,7 @@ const TopContainer = memo<TopProps>(
|
||||
});
|
||||
};
|
||||
|
||||
const renderAvatarGroup = (
|
||||
containerSize: "small" | "medium" | "large" | "xlarge",
|
||||
avatarSize: "small" | "medium" | "large" | "xlarge",
|
||||
) => {
|
||||
return (
|
||||
<AvatarContainer size={containerSize}>
|
||||
{avatarImageSources.map((src, index) => (
|
||||
<Avatar
|
||||
key={index}
|
||||
src={src}
|
||||
alt={tTopNav(`avatarAlts.${3 - index}`)}
|
||||
size={avatarSize}
|
||||
/>
|
||||
))}
|
||||
</AvatarContainer>
|
||||
);
|
||||
};
|
||||
|
||||
const renderLoginButton = (size: NavSize) => {
|
||||
const itemSize = NAV_SIZE_TO_MENU_ITEM_SIZE[size];
|
||||
|
||||
// Determine mode based on folderTop and breakpoint size
|
||||
// folderTop: inverse mode (black text) for smallest breakpoints (xsmall/home)
|
||||
// folderTop: default mode (yellow text) for 640px+ breakpoints (homeMd/large/homeXlarge/xlarge)
|
||||
// false folderTop: always default mode (yellow text on dark background)
|
||||
const isSmallBreakpoint = size === "xsmall";
|
||||
const mode = folderTop && isSmallBreakpoint ? "inverse" : "default";
|
||||
|
||||
const renderLoginButton = () => {
|
||||
const label = loggedIn ? t("buttons.profile") : t("buttons.logIn");
|
||||
const ariaLabel = loggedIn
|
||||
? t("ariaLabels.goToProfile")
|
||||
@@ -154,9 +125,10 @@ const TopContainer = memo<TopProps>(
|
||||
return (
|
||||
<MenuItem
|
||||
href="/profile"
|
||||
size={itemSize}
|
||||
mode={mode}
|
||||
size="X Small"
|
||||
mode="default"
|
||||
state={navSelected ? "selected" : "default"}
|
||||
className={NAV_ITEM_RESPONSIVE_CLASS}
|
||||
ariaLabel={ariaLabel}
|
||||
>
|
||||
{label}
|
||||
@@ -174,9 +146,10 @@ const TopContainer = memo<TopProps>(
|
||||
})
|
||||
}
|
||||
href="/login"
|
||||
size={itemSize}
|
||||
mode={mode}
|
||||
size="X Small"
|
||||
mode="default"
|
||||
state={navSelected ? "selected" : "default"}
|
||||
className={NAV_ITEM_RESPONSIVE_CLASS}
|
||||
ariaLabel={ariaLabel}
|
||||
>
|
||||
{label}
|
||||
@@ -184,20 +157,30 @@ const TopContainer = memo<TopProps>(
|
||||
);
|
||||
};
|
||||
|
||||
const renderCreateRuleButton = (
|
||||
buttonSize: "xsmall" | "small" | "medium" | "large" | "xlarge",
|
||||
containerSize: "small" | "medium" | "large" | "xlarge",
|
||||
avatarSize: "small" | "medium" | "large" | "xlarge",
|
||||
) => {
|
||||
const renderCreateRuleButton = () => {
|
||||
return (
|
||||
<Button
|
||||
size={buttonSize}
|
||||
size="xsmall"
|
||||
buttonType="filled"
|
||||
palette="inverse"
|
||||
onClick={handleCreateRuleClick}
|
||||
ariaLabel={t("ariaLabels.createNewRule")}
|
||||
className={CREATE_RULE_RESPONSIVE_CLASS}
|
||||
>
|
||||
{renderAvatarGroup(containerSize, avatarSize)}
|
||||
<AvatarContainer
|
||||
size="small"
|
||||
className="lg:-space-x-[var(--spacing-scale-010)] xl:-space-x-[13px]"
|
||||
>
|
||||
{avatarImageSources.map((src, index) => (
|
||||
<Avatar
|
||||
key={src}
|
||||
src={src}
|
||||
alt={tTopNav(`avatarAlts.${3 - index}`)}
|
||||
size="small"
|
||||
className="lg:h-[var(--spacing-scale-024)] lg:w-[var(--spacing-scale-024)] xl:h-[var(--spacing-scale-032)] xl:w-[var(--spacing-scale-032)]"
|
||||
/>
|
||||
))}
|
||||
</AvatarContainer>
|
||||
<span>{t("buttons.createRule")}</span>
|
||||
</Button>
|
||||
);
|
||||
|
||||
@@ -9,14 +9,6 @@ export interface TopProps {
|
||||
logIn?: boolean;
|
||||
}
|
||||
|
||||
/** Breakpoint slot passed from {@link Top.view} into nav render helpers. */
|
||||
export type NavSize =
|
||||
| "xsmall"
|
||||
| "homeMd"
|
||||
| "large"
|
||||
| "homeXlarge"
|
||||
| "xlarge";
|
||||
|
||||
export interface TopViewProps {
|
||||
folderTop: boolean;
|
||||
loggedIn: boolean;
|
||||
@@ -35,11 +27,7 @@ export interface TopViewProps {
|
||||
};
|
||||
};
|
||||
logoSize: "topNavFolderTop" | "topNavHeader";
|
||||
renderNavigationItems: (_size: NavSize) => React.ReactNode;
|
||||
renderLoginButton: (_size: NavSize) => React.ReactNode;
|
||||
renderCreateRuleButton: (
|
||||
_buttonSize: "xsmall" | "small" | "medium" | "large" | "xlarge",
|
||||
_containerSize: "small" | "medium" | "large" | "xlarge",
|
||||
_avatarSize: "small" | "medium" | "large" | "xlarge",
|
||||
) => React.ReactNode;
|
||||
renderNavigationItems: () => React.ReactNode;
|
||||
renderLoginButton: () => React.ReactNode;
|
||||
renderCreateRuleButton: () => React.ReactNode;
|
||||
}
|
||||
|
||||
@@ -22,7 +22,12 @@ function TopView({
|
||||
}: TopViewProps) {
|
||||
const t = useTranslation(folderTop ? "homeHeader" : "header");
|
||||
|
||||
// Render folderTop variant (HomeHeader style)
|
||||
const loginControl = logIn ? (
|
||||
<Menu size="X Small" className="lg:gap-[var(--spacing-scale-012)]">
|
||||
{renderLoginButton()}
|
||||
</Menu>
|
||||
) : null;
|
||||
|
||||
if (folderTop) {
|
||||
return (
|
||||
<>
|
||||
@@ -32,116 +37,62 @@ function TopView({
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(schemaData) }}
|
||||
/>
|
||||
<header
|
||||
className="w-full bg-transparent overflow-hidden"
|
||||
className="w-full overflow-hidden bg-transparent"
|
||||
role="banner"
|
||||
aria-label={t("ariaLabels.homePageNavigationHeader")}
|
||||
>
|
||||
<nav
|
||||
className="relative flex items-center justify-between mx-auto h-[50px] sm:h-[62px] md:h-[68px] lg:h-[68px] xl:h-[88px] pl-[var(--spacing-scale-008)] pr-[var(--spacing-scale-016)] pt-[var(--spacing-scale-010)] sm:px-[var(--spacing-scale-010)] sm:pr-[var(--spacing-scale-020)] sm:pt-[var(--spacing-scale-010)] md:px-[var(--spacing-scale-016)] md:pr-[var(--spacing-scale-032)] md:pt-[var(--spacing-scale-016)] lg:pl-[var(--spacing-scale-024)] lg:pt-[var(--spacing-scale-016)] lg:pr-[var(--spacing-scale-056)] xl:pl-[var(--spacing-scale-048)] xl:pt-[var(--spacing-scale-024)] xl:pr-[var(--spacing-scale-056)]"
|
||||
className="relative mx-auto flex h-[50px] items-end justify-between gap-[var(--spacing-scale-008)] pl-[var(--spacing-scale-008)] pr-[var(--spacing-scale-016)] pt-[var(--spacing-scale-010)] sm:h-[62px] sm:px-[var(--spacing-scale-010)] sm:pr-[var(--spacing-scale-020)] sm:pt-[var(--spacing-scale-010)] md:h-[68px] md:px-[var(--spacing-scale-016)] md:pr-[var(--spacing-scale-032)] md:pt-[var(--spacing-scale-016)] lg:h-[68px] lg:pl-[var(--spacing-scale-024)] lg:pr-[var(--spacing-scale-056)] lg:pt-[var(--spacing-scale-016)] xl:h-[88px] xl:pl-[var(--spacing-scale-048)] xl:pr-[var(--spacing-scale-056)] xl:pt-[var(--spacing-scale-024)]"
|
||||
role="navigation"
|
||||
aria-label={t("ariaLabels.mainNavigation")}
|
||||
>
|
||||
{/* Header Tab - Yellow tab container with decorative Union images */}
|
||||
<div className="HeaderTab header-breakpoint-transition relative bg-[var(--color-surface-inverse-brand-primary)] rounded-tl-[var(--radius-measures-radius-medium)] rounded-tr-[var(--radius-measures-radius-medium)] sm:rounded-t-[var(--radius-measures-radius-xlarge)] md:rounded-t-[var(--radius-measures-radius-xlarge)] lg:rounded-t-[var(--radius-measures-radius-xlarge)] xl:rounded-t-[var(--radius-measures-radius-xlarge)] pl-[var(--spacing-scale-012)] pr-[var(--spacing-scale-048)] h-[var(--spacing-scale-040)] sm:pl-[var(--spacing-scale-012)] sm:h-[52px] sm:pr-[var(--spacing-scale-006)] md:h-[52px] md:pl-[var(--spacing-scale-024)] md:pr-[var(--spacing-scale-012)] lg:h-[52px] lg:pl-[var(--spacing-scale-024)] lg:pr-[var(--spacing-scale-048)] xl:h-[64px] xl:pl-[var(--spacing-scale-032)] xl:pr-[var(--spacing-scale-120)] md:gap-[var(--spacing-scale-032)] flex-1 min-w-0 min-w-[197px] sm:min-w-0 sm:mr-[var(--spacing-scale-008)] md:mr-[185px] lg:mr-[var(--spacing-scale-024)] xl:mr-[var(--spacing-scale-032)] flex items-center self-end">
|
||||
{/* Logo - Consistent left positioning within HeaderTab */}
|
||||
<div className="HeaderTab header-breakpoint-transition relative flex h-[var(--spacing-scale-040)] min-w-0 flex-1 items-center self-end rounded-tl-[var(--radius-measures-radius-medium)] rounded-tr-[var(--radius-measures-radius-medium)] bg-[var(--color-surface-inverse-brand-primary)] pl-[var(--spacing-scale-012)] pr-[var(--spacing-scale-012)] sm:mr-[var(--spacing-scale-008)] sm:h-[52px] sm:rounded-t-[var(--radius-measures-radius-xlarge)] sm:pr-[var(--spacing-scale-006)] md:h-[52px] md:rounded-t-[var(--radius-measures-radius-xlarge)] md:pl-[var(--spacing-scale-024)] md:pr-[var(--spacing-scale-012)] lg:h-[52px] lg:rounded-t-[var(--radius-measures-radius-xlarge)] lg:pl-[var(--spacing-scale-024)] lg:pr-[var(--spacing-scale-048)] xl:h-[64px] xl:rounded-t-[var(--radius-measures-radius-xlarge)] xl:pl-[var(--spacing-scale-032)] xl:pr-[var(--spacing-scale-120)]">
|
||||
<div className="shrink-0">
|
||||
<Logo
|
||||
size={logoSize}
|
||||
wordmark
|
||||
palette={folderTop ? "inverse" : "default"}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* XSmall menu — positioned next to logo */}
|
||||
<div className="block sm:hidden -me-[2px]">
|
||||
<Menu size="X Small">
|
||||
{renderNavigationItems("xsmall")}
|
||||
{logIn && renderLoginButton("xsmall")}
|
||||
<div className="ml-auto shrink-0" data-top="nav">
|
||||
<Menu
|
||||
size="X Small"
|
||||
className="md:gap-[var(--spacing-scale-004)] lg:gap-[var(--spacing-scale-012)]"
|
||||
>
|
||||
{renderNavigationItems()}
|
||||
</Menu>
|
||||
</div>
|
||||
|
||||
{/* Decorative Union images for tab appearance */}
|
||||
{/* eslint-disable-next-line @next/next/no-img-element -- decorative SVG, not content */}
|
||||
<img
|
||||
src={getAssetPath(ASSETS.UNION_XSM)}
|
||||
alt=""
|
||||
role="presentation"
|
||||
className="absolute -bottom-[3px] -right-[52px] w-[61px] h-[24px] sm:w-[61px] sm:h-[31.5px] sm:hidden -z-10"
|
||||
className="absolute -bottom-[3px] -right-[52px] -z-10 h-[24px] w-[61px] sm:hidden sm:h-[31.5px] sm:w-[61px]"
|
||||
/>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element -- decorative SVG */}
|
||||
<img
|
||||
src={getAssetPath(ASSETS.UNION_SM_MD_LG)}
|
||||
alt=""
|
||||
role="presentation"
|
||||
className="absolute -bottom-[3.7px] -right-[53px] w-[61px] h-[24px] sm:w-[61px] sm:h-[31.5px] hidden sm:block xl:hidden -z-10"
|
||||
className="absolute -bottom-[3.7px] -right-[53px] -z-10 hidden h-[24px] w-[61px] sm:block sm:h-[31.5px] sm:w-[61px] xl:hidden"
|
||||
/>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element -- decorative SVG */}
|
||||
<img
|
||||
src={getAssetPath(ASSETS.UNION_XLG)}
|
||||
alt=""
|
||||
role="presentation"
|
||||
className="absolute -bottom-[6px] -right-[94px] w-[105px] h-[53px] hidden xl:block -z-10"
|
||||
className="absolute -bottom-[6px] -right-[94px] -z-10 hidden h-[53px] w-[105px] xl:block"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Navigation Links - Centered in header for SM and up */}
|
||||
<div className="absolute left-1/2 transform -translate-x-1/2 hidden sm:block">
|
||||
{/* 430-639px (sm: breakpoint): Menu X Small */}
|
||||
<div className="hidden sm:block md:hidden">
|
||||
<Menu size="X Small">
|
||||
{renderNavigationItems("xsmall")}
|
||||
{logIn && renderLoginButton("xsmall")}
|
||||
</Menu>
|
||||
</div>
|
||||
|
||||
{/* 640-1023px (md: breakpoint): Menu Small */}
|
||||
<div className="hidden md:block lg:hidden">
|
||||
<Menu size="Small">
|
||||
{renderNavigationItems("homeMd")}
|
||||
</Menu>
|
||||
</div>
|
||||
|
||||
{/* 1024-1440px (lg: breakpoint): Menu Large */}
|
||||
<div className="hidden lg:block xl:hidden">
|
||||
<Menu size="Large">{renderNavigationItems("large")}</Menu>
|
||||
</div>
|
||||
|
||||
{/* 1440px+ (xl: breakpoint): Menu X Large */}
|
||||
<div className="hidden xl:block">
|
||||
<Menu size="X Large">
|
||||
{renderNavigationItems("homeXlarge")}
|
||||
</Menu>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Authentication Elements - Consistent right alignment outside HeaderTab */}
|
||||
<div className="flex items-center">
|
||||
{/* XSmall and Small breakpoints - create rule button outside HeaderTab */}
|
||||
<div className="block md:hidden">
|
||||
{renderCreateRuleButton("xsmall", "small", "small")}
|
||||
</div>
|
||||
|
||||
{/* Medium breakpoint - login outside HeaderTab, create rule outside */}
|
||||
<div className="hidden md:block lg:hidden absolute right-[var(--spacing-measures-spacing-016)]">
|
||||
<div className="flex items-center gap-[var(--spacing-scale-010)]">
|
||||
{logIn && renderLoginButton("homeMd")}
|
||||
{renderCreateRuleButton("small", "medium", "medium")}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Large breakpoint */}
|
||||
<div className="hidden lg:flex xl:hidden items-center">
|
||||
<div className="flex items-center gap-[var(--spacing-scale-004)]">
|
||||
{logIn && renderLoginButton("large")}
|
||||
{renderCreateRuleButton("large", "large", "large")}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* XLarge breakpoint */}
|
||||
<div className="hidden xl:flex items-center">
|
||||
<div className="flex items-center gap-[var(--spacing-scale-004)]">
|
||||
{logIn && renderLoginButton("homeXlarge")}
|
||||
{renderCreateRuleButton("xlarge", "xlarge", "xlarge")}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="flex shrink-0 items-center gap-[var(--spacing-scale-004)] self-center md:gap-[var(--spacing-scale-010)]"
|
||||
data-top="auth"
|
||||
>
|
||||
{loginControl}
|
||||
{renderCreateRuleButton()}
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
@@ -151,11 +102,10 @@ function TopView({
|
||||
|
||||
/**
|
||||
* Standard marketing / app top nav.
|
||||
* Figma: "Navigation / Top" (Community-Rule-System, node 22078-808559) — horizontal
|
||||
* padding, logo ~200px left, menu cluster centered in the bar (`left-1/2` + translate),
|
||||
* log in + create rule on the right. Breakpoints and Menu sizes unchanged from prior map.
|
||||
* Figma: "Navigation / Top" (Community-Rule-System, node 22078-808559).
|
||||
* Three columns so the logo cannot paint over the nav cluster (no absolute
|
||||
* overlay, no reserved 200px hit box below `lg`).
|
||||
*/
|
||||
// Render standard variant (Header style)
|
||||
return (
|
||||
<>
|
||||
<script
|
||||
@@ -168,19 +118,11 @@ function TopView({
|
||||
aria-label={t("ariaLabels.mainNavigationHeader")}
|
||||
>
|
||||
<nav
|
||||
className="relative flex w-full items-center
|
||||
px-[var(--spacing-scale-016)]
|
||||
py-[var(--spacing-scale-008)]
|
||||
sm:px-[var(--spacing-measures-spacing-016)]
|
||||
lg:px-[var(--spacing-measures-spacing-64,64px)]
|
||||
lg:py-[var(--spacing-scale-016)]"
|
||||
className="grid w-full grid-cols-[auto_minmax(0,1fr)_auto] items-center px-[var(--spacing-scale-016)] py-[var(--spacing-scale-008)] sm:px-[var(--spacing-measures-spacing-016)] lg:px-[var(--spacing-measures-spacing-64,64px)] lg:py-[var(--spacing-scale-016)]"
|
||||
role="navigation"
|
||||
aria-label={t("ariaLabels.mainNavigation")}
|
||||
>
|
||||
<div
|
||||
className="relative z-20 min-w-0 shrink-0 sm:w-[200px] sm:max-w-[200px] sm:shrink-0"
|
||||
data-top="logo"
|
||||
>
|
||||
<div className="min-w-0 shrink-0 lg:w-[200px] lg:max-w-[200px]" data-top="logo">
|
||||
<Logo
|
||||
size={logoSize}
|
||||
wordmark
|
||||
@@ -188,100 +130,24 @@ function TopView({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* XSmall: nav + login in flow (flex-1) — same as before */}
|
||||
<div
|
||||
className="flex min-w-0 flex-1 items-center justify-end sm:hidden"
|
||||
data-top="nav-xs-flow"
|
||||
className="flex min-w-0 items-center justify-end md:justify-center"
|
||||
data-top="nav"
|
||||
>
|
||||
<div className="block" data-testid="nav-xs">
|
||||
<Menu size="X Small">
|
||||
{renderNavigationItems("xsmall")}
|
||||
{logIn && renderLoginButton("xsmall")}
|
||||
<Menu
|
||||
size="X Small"
|
||||
className="min-w-0 lg:gap-[var(--spacing-scale-012)]"
|
||||
>
|
||||
{renderNavigationItems()}
|
||||
</Menu>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* sm+ — Figma: nav cluster centered in bar (not between logo and actions) */}
|
||||
<div
|
||||
className="pointer-events-none hidden sm:absolute sm:left-1/2 sm:top-1/2 sm:z-10 sm:flex sm:-translate-x-1/2 sm:-translate-y-1/2 sm:items-center sm:justify-center"
|
||||
data-top="nav-center"
|
||||
className="flex shrink-0 items-center gap-[var(--spacing-scale-004)] md:gap-[var(--spacing-measures-spacing-010)] lg:gap-[var(--spacing-measures-spacing-004)]"
|
||||
data-top="auth"
|
||||
>
|
||||
<div
|
||||
className="pointer-events-auto hidden sm:flex md:hidden"
|
||||
data-testid="nav-sm"
|
||||
>
|
||||
<Menu size="X Small">
|
||||
{renderNavigationItems("xsmall")}
|
||||
{logIn && renderLoginButton("xsmall")}
|
||||
</Menu>
|
||||
</div>
|
||||
<div
|
||||
className="pointer-events-auto hidden md:flex lg:hidden"
|
||||
data-testid="nav-md"
|
||||
>
|
||||
<Menu size="X Small">
|
||||
{renderNavigationItems("xsmall")}
|
||||
</Menu>
|
||||
</div>
|
||||
<div
|
||||
className="pointer-events-auto hidden lg:flex xl:hidden"
|
||||
data-testid="nav-lg"
|
||||
>
|
||||
<Menu size="Large">{renderNavigationItems("large")}</Menu>
|
||||
</div>
|
||||
<div
|
||||
className="pointer-events-auto hidden xl:flex"
|
||||
data-testid="nav-xl"
|
||||
>
|
||||
<Menu size="X Large">
|
||||
{renderNavigationItems("xlarge")}
|
||||
</Menu>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Authentication Elements - Consistent right alignment across all breakpoints */}
|
||||
<div className="relative z-20 ml-auto flex shrink-0 items-center">
|
||||
{/* XSmall breakpoint - Only Create Rule button */}
|
||||
<div className="block sm:hidden shrink-0" data-testid="auth-xs">
|
||||
{renderCreateRuleButton("xsmall", "small", "small")}
|
||||
</div>
|
||||
|
||||
{/* Small breakpoint - Only Create Rule button */}
|
||||
<div className="hidden sm:block md:hidden" data-testid="auth-sm">
|
||||
<div className="flex items-center gap-[var(--spacing-scale-004)]">
|
||||
{renderCreateRuleButton("xsmall", "small", "small")}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Medium breakpoint */}
|
||||
<div className="hidden md:block lg:hidden" data-testid="auth-md">
|
||||
<div className="flex items-center gap-[var(--spacing-measures-spacing-010)]">
|
||||
<Menu size="Small">
|
||||
{logIn && renderLoginButton("xsmall")}
|
||||
</Menu>
|
||||
{renderCreateRuleButton("xsmall", "medium", "medium")}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Large breakpoint */}
|
||||
<div className="hidden lg:block xl:hidden" data-testid="auth-lg">
|
||||
<div className="flex items-center gap-[var(--spacing-measures-spacing-004)]">
|
||||
<Menu size="Large">
|
||||
{logIn && renderLoginButton("large")}
|
||||
</Menu>
|
||||
{renderCreateRuleButton("large", "xlarge", "xlarge")}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* XLarge breakpoint */}
|
||||
<div className="hidden xl:block" data-testid="auth-xl">
|
||||
<div className="flex items-center gap-[var(--spacing-measures-spacing-004)]">
|
||||
<Menu size="X Large">
|
||||
{logIn && renderLoginButton("xlarge")}
|
||||
</Menu>
|
||||
{renderCreateRuleButton("xlarge", "xlarge", "xlarge")}
|
||||
</div>
|
||||
</div>
|
||||
{loginControl}
|
||||
{renderCreateRuleButton()}
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
export { default } from "./Top.container";
|
||||
export type { TopProps, NavSize } from "./Top.types";
|
||||
export type { TopProps } from "./Top.types";
|
||||
export { avatarImages } from "./Top.container";
|
||||
|
||||
@@ -58,3 +58,26 @@ body {
|
||||
background-color: black;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* Skip link: parked above the viewport until focused. */
|
||||
.skip-to-content {
|
||||
position: absolute;
|
||||
left: var(--spacing-scale-016);
|
||||
top: var(--spacing-scale-016);
|
||||
z-index: 100;
|
||||
padding: var(--spacing-scale-012) var(--spacing-scale-016);
|
||||
border-radius: var(--radius-measures-radius-full);
|
||||
background-color: var(--color-surface-inverse-primary);
|
||||
color: var(--color-content-inverse-primary);
|
||||
text-decoration: none;
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
box-shadow:
|
||||
0 0 0 2px var(--color-border-default-primary),
|
||||
0 0 0 4px var(--color-border-invert-primary);
|
||||
transform: translateY(-200%);
|
||||
}
|
||||
.skip-to-content:focus,
|
||||
.skip-to-content:focus-visible {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
+3
-4
@@ -6,10 +6,9 @@ import { ASSETS, getAssetPath } from "../lib/assetUtils";
|
||||
import { PRODUCTION_SITE_ORIGIN } from "../lib/siteMetadata";
|
||||
import "./globals.css";
|
||||
|
||||
// `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.
|
||||
// Session chrome (`ConditionalNavigation`) lives in `(marketing)`, `(app)`, and
|
||||
// `(admin)` layouts, behind `<Suspense>`, so the static shell can prerender while
|
||||
// the header streams with the request cookie.
|
||||
//
|
||||
// MessagesProvider + AuthModalProvider are mounted per route group (Phase 4b):
|
||||
// `(marketing)` gets a trimmed slice without `create.*` (~41 KB gzipped saved
|
||||
|
||||
@@ -107,7 +107,7 @@ Inventory aligns with [**CR-104**](https://linear.app/community-rule/issue/CR-10
|
||||
| **Link** matrix | **`Link/`** | Next.js **`Link`** wrapper + Figma “Link, CTA” styling; used in nav and content (e.g. **`Rule`**). |
|
||||
| Create-flow top chrome (often **Utility** in Figma) | **`CreateFlowTopNav/`** | Wizard header; **`CreateFlowLayoutClient`**. |
|
||||
| Create-flow bottom chrome (often **Utility** in Figma) | **`CreateFlowFooter/`** | Wizard footer + **`ProportionBar`**; **`CreateFlowLayoutClient`**. |
|
||||
| App shell (not a DS atom) | **`ConditionalNavigation.tsx`**, **`ConditionalNavigationClient.tsx`** | Server: session for first paint. Client: hide global **`Top`** on **`/create/*`** and **`/login`**; else **`TopWithPathname`**. **Tolerated `usePathname()`** — no new pathname-conditional chrome (**`routes.mdc`**). |
|
||||
| App shell (not a DS atom) | **`ConditionalNavigation.tsx`**, **`ConditionalNavigationClient.tsx`**, **`SkipToContent.tsx`** | Server: session for first paint. Client: hide global **`Top`** on **`/create/*`** and **`/login`**; else **`TopWithPathname`**. Skip link in group layouts targets **`#main-content`**. **Tolerated `usePathname()`** — no new pathname-conditional chrome (**`routes.mdc`**). |
|
||||
|
||||
**Also under Utility in Figma:** **`CreateFlowTopNav`** / **`CreateFlowFooter`** are filed under Utility but **canonical code** is here with **`Top`** / **`Footer`** (see **Utility conventions**).
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ as follow-up work (see "Outcome" sections below).
|
||||
|
||||
| Flag | Recommendation | Status |
|
||||
| --- | --- | --- |
|
||||
| `cacheComponents` (PPR successor) | **Ship** | **Shipped.** `force-dynamic` removed from `(app)` and `(admin)` layouts; `<ConditionalNavigation />` (and `<MarketingNavigation />`) wrapped in `<Suspense fallback={null}>`. `(app)`/`(admin)` routes are now `◐ Partial Prerender` instead of `ƒ Dynamic`. `/` static shell dropped from 45 KB → 11.7 KB gzipped. |
|
||||
| `cacheComponents` (PPR successor) | **Ship** | **Shipped.** `force-dynamic` removed from `(app)` and `(admin)` layouts; `<ConditionalNavigation />` wrapped in `<Suspense fallback={null}>` in `(marketing)`, `(app)`, and `(admin)`. `(app)`/`(admin)` routes are now `◐ Partial Prerender` instead of `ƒ Dynamic`. `/` static shell dropped from 45 KB → 11.7 KB gzipped. |
|
||||
| React Compiler | **Ship (annotation mode)** | **Shipped (plumbing only).** `babel-plugin-react-compiler` + `eslint-plugin-react-compiler` installed. `reactCompiler: { compilationMode: "annotation" }` enabled in `next.config.mjs`. ESLint rule wired in at "warn" — found 31 latent warnings across 8 files (none introduced by this change). Migrating containers to `"use memo"` is a future task. |
|
||||
|
||||
Both flags now ship in `main`. The findings below describe what changed in
|
||||
@@ -76,9 +76,9 @@ requires expressing that dynamism via `<Suspense>` boundaries plus
|
||||
2. Wrapped `<ConditionalNavigation />` (server component reading
|
||||
`getNavAuthSignedIn()` → `cookies()`) in `<Suspense fallback={null}>` in
|
||||
both layouts.
|
||||
3. Same change for `<MarketingNavigation />` in
|
||||
3. Same Suspense wrap for `<ConditionalNavigation />` in
|
||||
[app/(marketing)/layout.tsx](../../app/(marketing)/layout.tsx) — the
|
||||
marketing nav reads `usePathname()` (uncached per request) and would
|
||||
nav reads `usePathname()` (uncached per request) and would
|
||||
otherwise block the static shell at routes like `/rules/[id]`.
|
||||
4. Enabled `experimental.cacheComponents: true` in
|
||||
[next.config.mjs](../../next.config.mjs).
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
/** DOM id of the group-layout `<main>` landmark; skip-to-content targets this. */
|
||||
export const MAIN_CONTENT_ID = "main-content";
|
||||
@@ -4,6 +4,7 @@
|
||||
"learn": "Learn",
|
||||
"about": "About"
|
||||
},
|
||||
"skipToContent": "Skip to content",
|
||||
"buttons": {
|
||||
"logIn": "Log in",
|
||||
"profile": "Profile",
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import SkipToContent from "../../app/components/navigation/SkipToContent";
|
||||
import { MAIN_CONTENT_ID } from "../../lib/mainContent";
|
||||
import { renderWithProviders as render, screen } from "../utils/test-utils";
|
||||
|
||||
describe("SkipToContent", () => {
|
||||
it("targets the main landmark", () => {
|
||||
render(<SkipToContent />);
|
||||
const link = screen.getByRole("link", { name: "Skip to content" });
|
||||
expect(link).toHaveAttribute("href", `#${MAIN_CONTENT_ID}`);
|
||||
expect(link).toHaveClass("skip-to-content");
|
||||
});
|
||||
});
|
||||
@@ -85,9 +85,7 @@ describe('Top "Create rule" button', () => {
|
||||
|
||||
renderWithProviders(<Top folderTop={false} />);
|
||||
|
||||
// Top renders the Create Rule button at three breakpoints (xs/sm/md);
|
||||
// any of them clicking the same handler is the point.
|
||||
const [btn] = screen.getAllByRole("button", {
|
||||
const btn = screen.getByRole("button", {
|
||||
name: /create a new rule/i,
|
||||
});
|
||||
await userEvent.click(btn);
|
||||
@@ -112,7 +110,7 @@ describe('Top "Create rule" button', () => {
|
||||
it("uses filled invert for Create rule", () => {
|
||||
for (const folderTop of [false, true]) {
|
||||
const { unmount } = renderWithProviders(<Top folderTop={folderTop} />);
|
||||
const [button] = screen.getAllByRole("button", {
|
||||
const button = screen.getByRole("button", {
|
||||
name: /create a new rule/i,
|
||||
});
|
||||
expect(button.className).toContain(
|
||||
@@ -125,3 +123,56 @@ describe('Top "Create rule" button', () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("Top header chrome", () => {
|
||||
it("renders each nav control once, including display:none copies", () => {
|
||||
for (const folderTop of [false, true]) {
|
||||
const { unmount } = renderWithProviders(
|
||||
<Top folderTop={folderTop} loggedIn />,
|
||||
);
|
||||
expect(
|
||||
screen.getAllByRole("menuitem", {
|
||||
name: /navigate to use cases page/i,
|
||||
hidden: true,
|
||||
}),
|
||||
).toHaveLength(1);
|
||||
expect(
|
||||
screen.getAllByRole("menuitem", {
|
||||
name: /navigate to learn page/i,
|
||||
hidden: true,
|
||||
}),
|
||||
).toHaveLength(1);
|
||||
expect(
|
||||
screen.getAllByRole("menuitem", {
|
||||
name: /go to your profile/i,
|
||||
hidden: true,
|
||||
}),
|
||||
).toHaveLength(1);
|
||||
expect(
|
||||
screen.getAllByRole("button", {
|
||||
name: /create a new rule/i,
|
||||
hidden: true,
|
||||
}),
|
||||
).toHaveLength(1);
|
||||
unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it("shows Profile instead of Log in when signed in", () => {
|
||||
renderWithProviders(<Top folderTop={false} loggedIn />);
|
||||
expect(
|
||||
screen.getByRole("menuitem", { name: /go to your profile/i }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole("button", { name: /log in to your account/i }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps the standard logo and nav in separate columns", () => {
|
||||
renderWithProviders(<Top folderTop={false} loggedIn />);
|
||||
const logo = document.querySelector("[data-top='logo']");
|
||||
const nav = document.querySelector("[data-top='nav']");
|
||||
expect(logo?.className).not.toMatch(/\bz-20\b/);
|
||||
expect(nav?.className).not.toMatch(/\babsolute\b/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import TopWithPathname from "../../app/components/navigation/Top/TopWithPathname";
|
||||
import { renderWithProviders, screen } from "../utils/test-utils";
|
||||
|
||||
const { pushMock } = vi.hoisted(() => ({ pushMock: vi.fn() }));
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({
|
||||
push: pushMock,
|
||||
replace: vi.fn(),
|
||||
prefetch: vi.fn(),
|
||||
back: vi.fn(),
|
||||
forward: vi.fn(),
|
||||
refresh: vi.fn(),
|
||||
}),
|
||||
usePathname: () => "/learn",
|
||||
}));
|
||||
|
||||
vi.mock("../../lib/create/api", () => ({
|
||||
fetchAuthSession: () => new Promise(() => {}),
|
||||
}));
|
||||
|
||||
describe("TopWithPathname", () => {
|
||||
it("renders Profile from the server session before /api/auth/session resolves", () => {
|
||||
renderWithProviders(<TopWithPathname initialSignedIn />);
|
||||
expect(
|
||||
screen.getByRole("menuitem", { name: /go to your profile/i }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole("button", { name: /log in to your account/i }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user