app/layout.tsx
Rama main · réplica de lectura del repositorio original.
import type { Metadata, Viewport } from "next";
import type { CSSProperties } from "react";
import { APP_CONTENT_HEADER_HEIGHT } from "@/lib/app-chrome-layout";
import { headers } from "next/headers";
import { Inter, Instrument_Serif } from "next/font/google";
import {
getLocale,
getMessages,
getNow,
getTimeZone,
getTranslations,
} from "next-intl/server";
import { ThemeProvider } from "@/components/theme-provider";
import { BrowserIntlProvider } from "@/components/browser-intl-provider";
import { CookieBanner } from "@/components/cookie-banner";
import { DesktopChrome } from "@/components/desktop-chrome";
import { KeyboardModality } from "@/components/keyboard-modality";
import { PostHogInit } from "@/components/posthog-init";
import { AppLayoutInitScript } from "@/components/app-layout-init-script";
import { ThemeInitScript } from "@/components/theme-init-script";
import { TooltipProvider } from "@/components/ui/tooltip";
import { publicClientMessages } from "@/lib/public-client-messages";
import { RuntimeConfigProvider } from "@/lib/runtime-config-provider";
import { getRuntimeConfig } from "@/lib/runtime-config";
import {
ACCOUNT_THEME_HEADER,
isAccountTheme,
} from "@/lib/account-theme";
import {
parseThemeAppearanceHeaders,
THEME_PRESET_HEADERS,
} from "@/lib/theme-presets";
import { ogImageUrl } from "@/lib/seo";
import { SITE_NAME, SITE_URL, SITE_VERIFICATION } from "@/lib/site";
import type { Locale } from "@/i18n/config";
import "./globals.css";
// Operator-controlled metadata and client boot settings must be read when the
// container starts, never while the image is built.
export const dynamic = "force-dynamic";
const OPEN_GRAPH_LOCALE: Record<Locale, string> = {
en: "en_US",
fr: "fr_FR",
de: "de_DE",
"pt-BR": "pt_BR",
it: "it_IT",
es: "es_ES",
};
const inter = Inter({ variable: "--font-inter", subsets: ["latin"], display: "swap" });
const instrumentSerif = Instrument_Serif({
variable: "--font-instrument-serif",
subsets: ["latin"],
weight: "400",
style: "italic",
display: "swap",
});
/* ── What lights up the safe areas ────────────────────── ───────────────────────
Without this export, Next serves its default viewport — `width=device-width,
initial-scale=1`, sans `viewport-fit`. Or `viewport-fit: auto scales the window
IN the safe zone: the page never passes under the notch or under the bar
home indicator, and therefore **all `env(safe-area-inset-*)`
are worth 0**. The filing writes seven — the mobile browsing pill and the
clearance that it reserves (`--mobile-nav-height`, app/globals.css), the FOB of
Numo, the nav marketing drawer, the
wizard panel — and not a single one measured anything.
`cover` makes the page full bleed and gives the insets their true value:
this is the line that puts all this work already written into operation. What affects a
edge of the screen must therefore, from here, carry its inset — see the block of
safe areas in app/globals.css for the components/ui surfaces.
We do not touch `maximumScale` NOR `userScalable`: restricting the zoom is a
accessibility flaw, and none of the fixes above need it.
`interactiveWidget` addresses the other half of the problem: the keyboard. By
default (`resizes-visual`), the software keyboard ONLY shrinks the window
visual — the layout viewport keeps its height, therefore a footer
anchored in `fixed` remains stuck on a bottom of the screen which is no longer visible, and
goes BEHIND the keyboard. This is the case for the two components of the product (sweater
request, agent session), the same ones that `.dock-above-nav` already goes back to
above the moving bar in app/globals.css. `resizes-content` shrinks
the layout viewport: `h-dvh` of the shell follows, and the composer goes up
with the keyboard instead of disappearing underneath.
Only Chromium (Android) honors it. iOS Safari ignores the key and scrolls the
whole document to reveal a focused field; `useMobileViewport` in
lib/use-mobile-layout.ts cancels that scroll and makes room itself (MIN-870).
`overlays-content` with the VirtualKeyboard API would only trade Android's
native resize for the same manual work, so it stays `resizes-content`. */
export const viewport: Viewport = {
width: "device-width",
initialScale: 1,
viewportFit: "cover",
interactiveWidget: "resizes-content",
};
export async function generateMetadata(): Promise<Metadata> {
const [t, locale] = await Promise.all([getTranslations("Meta"), getLocale()]);
return {
// Base of relative URLs (canonical, OpenGraph) — without it, the pages
// public declare relative `og:url`, which crawlers ignore.
metadataBase: new URL(SITE_URL),
// `default` applies to pages without their own title; `template` wraps
// per-page titles set by nested (server) layouts, e.g. "Inbox · minddy".
title: { default: SITE_NAME, template: `%s · ${SITE_NAME}` },
description: t("description"),
// Sharing faults (MIN-88). Next DERIVES `twitter:*` from `openGraph`, but
// nothing derives from what does not exist: without these defects, a page which
// does not declare its own block — the four legal pages, /login —
// left without the slightest sticker. `lib/seo.ts` replaces them page by page
// on the public roads; this is the net for everything else.
//
// The image (MIN-512): every page WITHOUT its own social block — the whole
// authenticated app, /forgot-password, /reset-password, not-found — shares
// the landing's pastel thumbnail, in the reader's language. Pages that
// declare their own `openGraph` replace this whole object (Next does not
// merge it), which is why `lib/seo.ts` re-declares the image there.
openGraph: {
type: "website",
siteName: "minddy",
locale: OPEN_GRAPH_LOCALE[locale as Locale],
url: SITE_URL,
title: "minddy",
description: t("description"),
images: [
{
url: ogImageUrl("home", locale as Locale),
width: 1200,
height: 630,
alt: SITE_NAME,
},
],
},
twitter: {
card: "summary_large_image",
title: "minddy",
description: t("description"),
images: [ogImageUrl("home", locale as Locale)],
},
// Site ownership in Google Search Console / Bing Webmaster Tools.
// Empty string keys are omitted: until token is stuck
// in `lib/site.ts`, no empty tag is served.
verification: {
...(SITE_VERIFICATION.google ? { google: SITE_VERIFICATION.google } : {}),
...(SITE_VERIFICATION.bing
? { other: { "msvalidate.01": SITE_VERIFICATION.bing } }
: {}),
},
// Renders <meta name="apple-mobile-web-app-title" content="minddy" />.
// The favicon/icon tags are auto-wired from the files in app/ (favicon.ico,
// icon.tsx, icon1.png, apple-icon.tsx). The manifest is a route handler
// (app/manifest.json/route.ts), which the file convention does not link.
appleWebApp: { title: "minddy" },
manifest: "/manifest.json",
};
}
export default async function RootLayout({
children,
}: Readonly<{ children: React.ReactNode }>) {
const [locale, messages, headerList, now, initialTimeZone] = await Promise.all([
getLocale(),
getMessages(),
headers(),
getNow(),
getTimeZone(),
]);
const runtimeConfig = getRuntimeConfig().public;
// Anonymous public pages (feedback board, shared views): the proxy poses
// this header so that they follow the system preference instead of being forced
// to dark mode like the internal app (MIN-60).
const isPublicSite = headerList.get("x-minddy-public") === "1";
// The theme saved on the ACCOUNT, asserted by the proxy for app routes
// (`lib/account-theme.ts`). It becomes the default everywhere — the
// pre-paint script and the provider both start from it, so a new device
// renders the right theme from the first pixel. Public pages stay on the
// system preference: an anonymous visitor has no account to read.
const rawAccountTheme = headerList.get(ACCOUNT_THEME_HEADER);
const accountTheme = !isPublicSite && isAccountTheme(rawAccountTheme)
? rawAccountTheme
: null;
const defaultTheme: "light" | "dark" | "system" =
isPublicSite ? "system" : accountTheme ?? "dark";
// The account's palettes (MIN-696): asserted by the proxy on app routes
// only, so their presence is what marks a signed-in document. Public pages
// and the auth screens keep the Minddy palettes.
const appearance = !isPublicSite && headerList.has(THEME_PRESET_HEADERS.light)
? parseThemeAppearanceHeaders((name) => headerList.get(name))
: null;
// This provider only sends the four namespaces of the public site to the browser
// instead of the 67 in the catalog: the messages are a component PROP
// client, therefore of the RSC inline flow, therefore 39 KB gzipped more to download
// before the LCP image (MIN-100).
//
// **Always the same, whatever the road.** This layout chose
// formerly between reduced catalog and complete catalog according to a header placed
// by proxy on marketing pages — but a shared layout is NOT
// re-rendered during customer navigation: part of the landing, the page
// `/login` inherited the four marketing namespaces and displayed
// “Auth.signIn” until the first reload. A set of messages derived from
// the request is fixed at the first document; it can therefore only depend on this
// which is true everywhere.
//
// Segments that translate elsewhere mount `FullCatalogMessages`.
const clientMessages = publicClientMessages(messages);
return (
<html
lang={locale}
suppressHydrationWarning
data-scroll-behavior="smooth"
style={{ "--app-content-header-height": `${APP_CONTENT_HEADER_HEIGHT}px` } as CSSProperties}
>
<head>
{/* Theme applied BEFORE the first paint. Injected out of React tree
(useServerInsertedHTML) : un <script> rendu par un composant fait
complaining about React 19 every time the client re-renders the root layout — see the
composant. */}
<AppLayoutInitScript />
<ThemeInitScript
defaultTheme={defaultTheme}
accountTheme={accountTheme}
appearance={appearance}
/>
</head>
<body
className={`${inter.variable} ${instrumentSerif.variable} antialiased`}
>
<ThemeProvider defaultTheme={defaultTheme}>
<TooltipProvider>
<RuntimeConfigProvider config={runtimeConfig}>
<BrowserIntlProvider
locale={locale}
messages={clientMessages}
now={now}
initialTimeZone={initialTimeZone}
>
{/* The strip by which you move the desktop app window
(MIN-292). Here, and not in a shell: this is the only position of
repository from where it covers ALL configurations. An audit in
had found five without any catch — zen mode, legal pages,
public board `/f/`, published page `/p/`, shared view `/share/`,
plus `not-found` — because the sockets lived in the header
and the sidebar, that is to say in the two pieces of furniture that these
screens do not have.
**BEFORE `{children}`, and it's not cosmetic**: Chromic
calculates draggable regions by traversing the layout tree
page in order, and a `no-drag` encountered LATER digs the
`drag` encountered before. Placed last, the tape would resume
to the buttons of the header the hole they have just dug for themselves.
Outside of the desktop app, `globals.css` leaves it in `display: none`:
it costs a browser nothing. */}
<div aria-hidden className="desktop-drag-band" />
{children}
{/* Mark the document in the desktop app (MIN-291): this is what
read the window movement zones, which do not have
other possible source than the page. Don't give anything back. */}
<DesktopChrome />
<KeyboardModality />
<CookieBanner />
{/* PostHog covers public pages and the app. The SDK loads after idle
outside the initial bundle. Device consent controls anonymous,
persistent and disabled measurement. */}
<PostHogInit />
</BrowserIntlProvider>
</RuntimeConfigProvider>
</TooltipProvider>
</ThemeProvider>
</body>
</html>
);
}