mirror of
https://github.com/multica-ai/multica.git
synced 2026-08-05 09:30:05 +02:00
* fix(typography): load real Inter italic on web and desktop (MUL-5449) Neither platform loaded an Inter italic face: next/font defaults to `style: ["normal"]`, and desktop imported `@fontsource-variable/inter` without `wght-italic.css`. Every `italic` in the product was therefore browser-synthesized oblique — the ~20 semantic UI labels (chat empty states, model-picker's "Managed by runtime", dashboard/squad placeholders) plus all markdown <em> and blockquotes, which are user content. Load the real face on both, mirroring how Source Serif 4 is already wired. Also set `font-synthesis: style` on html, which forbids weight synthesis while leaving style synthesis on. Weight synthesis is pure loss now that every loaded face has real weights, and it is actively harmful on the CJK tail of --font-sans: `font-bold` against PingFang SC (which stops at Semibold) makes Chromium smear a fake 700 that closes up the counters of dense Han glyphs. Style synthesis stays on deliberately. `auto` only synthesizes when no real italic matches, so the newly loaded Inter Italic already wins for Latin. The only stacks still synthesizing are the two that ship no italic at all — CJK fallbacks and Geist Mono — where `font-style: italic` has no other visual carrier; a blanket `font-synthesis: none` would silently flatten every Chinese <em>, every editor italic mark and every hljs comment to upright. Co-authored-by: multica-agent <github@multica.ai> * fix(typography): give desktop the variable Geist Mono (MUL-5449) Web gets a variable Geist Mono from next/font (`font-weight: 100 900`, verified in the emitted CSS), but desktop loaded only the discrete 400 and 700 cuts. Any weight in between silently snapped to the nearest one desktop had, so the same shared component rendered at two different weights on the two platforms: - `font-mono font-medium` (500) fell back to 400 in chart.tsx, webhook-event-filter-section.tsx and keyboard-shortcuts-tab.tsx. - Inline <code> in rendered markdown has no explicit weight, so inside a <strong>, heading or <th> — all `font-semibold` — it inherits 600 and resolved up to 700. Loading `@fontsource/geist-mono/500.css` would have fixed only the first of those. Switching to `@fontsource-variable/geist-mono` covers 100-900 in one file and closes the whole class, matching how Inter and Source Serif 4 are already wired on desktop. The latin subset is also smaller than the two static cuts it replaces: 22.6 KB vs 14.4 + 14.9 KB. Co-authored-by: multica-agent <github@multica.ai> * docs(typography): correct stale Geist Mono note in font-synthesis comment The comment was written when desktop still loaded discrete 400/700 cuts. The following commit swapped desktop to @fontsource-variable/geist-mono, so Geist Mono is now variable on both platforms and the "ships discrete cuts" clause was false. Also name landing's 400-only Instrument Serif so the "every face we load has real weights" claim is complete. Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: Lambda <lambda@multica.ai> Co-authored-by: multica-agent <github@multica.ai>
166 lines
6.0 KiB
TypeScript
166 lines
6.0 KiB
TypeScript
import type { Metadata, Viewport } from "next";
|
|
import Script from "next/script";
|
|
import { Inter, Geist_Mono, Source_Serif_4 } from "next/font/google";
|
|
import { ThemeProvider } from "@/components/theme-provider";
|
|
import { Toaster } from "@multica/ui/components/ui/sonner";
|
|
import { cn } from "@multica/ui/lib/utils";
|
|
import { WebProviders } from "@/components/web-providers";
|
|
import type { SupportedLocale } from "@multica/core/i18n";
|
|
import { RESOURCES } from "@multica/views/locales";
|
|
import { getRequestLocale } from "@/lib/request-locale";
|
|
import {
|
|
resolveBrowserApiBaseUrl,
|
|
resolveBrowserWsUrl,
|
|
} from "@/config/runtime-urls";
|
|
import "./globals.css";
|
|
|
|
// Inter is the Latin UI face. next/font produces a hashed family (`__Inter_xxx`)
|
|
// plus a synthetic size-adjusted fallback face to prevent FOUT layout shift —
|
|
// both are exposed under the `--font-inter` CSS variable.
|
|
//
|
|
// The full `--font-sans` stack (Inter + the per-locale CJK fallback chain) is
|
|
// assembled in static CSS in ./globals.css, not here: it must be overridable per
|
|
// `<html lang>` (Japanese Kanji are Han ideographs and need a Japanese-first CJK
|
|
// stack), and a hashed family name can only be referenced from CSS via a variable.
|
|
// Keeping the CJK chain in CSS also keeps it CSP-safe and in sync with the desktop
|
|
// app, which defines the same chain in apps/desktop/src/renderer/src/globals.css.
|
|
//
|
|
// Italic is loaded explicitly: `style` defaults to `["normal"]`, and without a real
|
|
// italic face the ~20 semantic italic labels (chat empty states, model-picker's
|
|
// "Managed by runtime", dashboard/squad placeholders) plus every markdown <em> and
|
|
// blockquote rendered as browser-synthesized oblique. Keep in sync with desktop's
|
|
// `@fontsource-variable/inter/wght-italic.css` import.
|
|
const inter = Inter({
|
|
subsets: ["latin"],
|
|
style: ["normal", "italic"],
|
|
variable: "--font-inter",
|
|
});
|
|
// Mono font has no explicit CJK fallback: CJK chars in code blocks are inherently
|
|
// non-aligned with a mono grid (Chinese is proportional), so listing CJK fonts
|
|
// here would falsely signal alignment guarantees. Browser default fallback handles
|
|
// the rare mixed case correctly.
|
|
const geistMono = Geist_Mono({
|
|
subsets: ["latin"],
|
|
variable: "--font-mono",
|
|
fallback: ["ui-monospace", "SFMono-Regular", "Menlo", "Consolas", "monospace"],
|
|
});
|
|
// Editorial serif used for onboarding headlines. Italic support for h1 em
|
|
// accents (e.g. "...on one shared board."). Only loaded on routes that
|
|
// render the font; layout-shift-prevention handled by next/font's synthetic
|
|
// fallback metrics, same as Inter.
|
|
const sourceSerif = Source_Serif_4({
|
|
subsets: ["latin"],
|
|
style: ["normal", "italic"],
|
|
variable: "--font-serif",
|
|
fallback: [
|
|
"ui-serif",
|
|
"Iowan Old Style",
|
|
"Apple Garamond",
|
|
"Baskerville",
|
|
"Times New Roman",
|
|
"serif",
|
|
],
|
|
});
|
|
|
|
export const viewport: Viewport = {
|
|
width: "device-width",
|
|
initialScale: 1,
|
|
themeColor: [
|
|
{ media: "(prefers-color-scheme: light)", color: "#ffffff" },
|
|
{ media: "(prefers-color-scheme: dark)", color: "#05070b" },
|
|
],
|
|
};
|
|
|
|
export const metadata: Metadata = {
|
|
metadataBase: new URL("https://www.multica.ai"),
|
|
title: {
|
|
default: "Multica — Project Management for Human + Agent Teams",
|
|
template: "%s | Multica",
|
|
},
|
|
description:
|
|
"Open-source platform that turns coding agents into real teammates. Assign tasks, track progress, compound skills.",
|
|
icons: {
|
|
icon: [{ url: "/favicon.svg", type: "image/svg+xml" }],
|
|
shortcut: ["/favicon.svg"],
|
|
},
|
|
openGraph: {
|
|
type: "website",
|
|
siteName: "Multica",
|
|
locale: "en_US",
|
|
},
|
|
twitter: {
|
|
card: "summary_large_image",
|
|
site: "@multica_hq",
|
|
creator: "@multica_hq",
|
|
},
|
|
alternates: {
|
|
canonical: "/",
|
|
},
|
|
robots: {
|
|
index: true,
|
|
follow: true,
|
|
},
|
|
};
|
|
|
|
// HTML lang attribute uses BCP-47 region tags that screen readers and font
|
|
// stacks recognize widely. i18next keeps `zh-Hans` as its internal locale
|
|
// (script subtag is what we actually translate against), but the html element
|
|
// expects a region-flavoured tag for accessibility tooling and CJK fallback.
|
|
const HTML_LANG: Record<SupportedLocale, string> = {
|
|
en: "en",
|
|
"zh-Hans": "zh-CN",
|
|
ko: "ko-KR",
|
|
ja: "ja-JP",
|
|
};
|
|
|
|
export default async function RootLayout({
|
|
children,
|
|
}: {
|
|
children: React.ReactNode;
|
|
}) {
|
|
const locale = await getRequestLocale();
|
|
const resources = { [locale]: RESOURCES[locale] };
|
|
const apiBaseUrl = resolveBrowserApiBaseUrl(process.env);
|
|
const wsUrl = resolveBrowserWsUrl(process.env);
|
|
|
|
return (
|
|
<html
|
|
lang={HTML_LANG[locale]}
|
|
suppressHydrationWarning
|
|
className={cn("antialiased font-sans h-full", inter.variable, geistMono.variable, sourceSerif.variable)}
|
|
>
|
|
<body className="h-full overflow-hidden">
|
|
{/*
|
|
react-grab: dev-only element inspector. Hold ⌘C (Mac) / Ctrl+C and click
|
|
any element to copy its source path + line + component stack for pasting
|
|
to an AI. Opt-in per developer: only loads when VITE_REACT_GRAB is set in
|
|
a local, gitignored apps/web/.env.local — it never activates for anyone
|
|
else. Both guards are read server-side, so the <Script> is omitted from
|
|
the HTML entirely unless you opted in. The VITE_ prefix is shared with the
|
|
desktop renderer (apps/desktop/src/renderer/src/main.tsx), where Vite only
|
|
exposes VITE_-prefixed vars to client code, so one var name covers both
|
|
apps. See https://www.react-grab.com/
|
|
*/}
|
|
{process.env.NODE_ENV === "development" && process.env.VITE_REACT_GRAB && (
|
|
<Script
|
|
src="//unpkg.com/react-grab/dist/index.global.js"
|
|
crossOrigin="anonymous"
|
|
strategy="beforeInteractive"
|
|
/>
|
|
)}
|
|
<ThemeProvider>
|
|
<WebProviders
|
|
locale={locale}
|
|
resources={resources}
|
|
apiBaseUrl={apiBaseUrl}
|
|
wsUrl={wsUrl}
|
|
>
|
|
{children}
|
|
</WebProviders>
|
|
<Toaster />
|
|
</ThemeProvider>
|
|
</body>
|
|
</html>
|
|
);
|
|
}
|