From 208651249cb801932708e9981b73ad55c84f330d Mon Sep 17 00:00:00 2001
From: aphrody-dev
Date: Mon, 12 Oct 2026 08:23:52 +0200
Subject: [PATCH] chore: migrate package sources from Aphrody
---
README.md | 125 +++++++-
bin/m3-next.ts | 24 ++
build.config.ts | 14 +
examples/app/.gitignore | 4 +
examples/app/AGENTS.md | 9 +
examples/app/CLAUDE.md | 1 +
examples/app/README.md | 23 ++
examples/app/app/api/health/route.ts | 4 +
examples/app/app/components/notifier.tsx | 46 +++
examples/app/app/components/page.tsx | 224 ++++++++++++++
examples/app/app/globals.css | 9 +
examples/app/app/layout.tsx | 40 +++
examples/app/app/nav.tsx | 32 ++
examples/app/app/page.tsx | 44 +++
examples/app/app/theme-toggle.tsx | 14 +
examples/app/app/theme/page.tsx | 27 ++
examples/app/next.config.ts | 15 +
examples/app/package.json | 39 +++
examples/app/postcss.config.mjs | 1 +
examples/app/proxy.ts | 8 +
examples/app/test/example.test.ts | 57 ++++
examples/app/tsconfig.json | 34 +++
examples/tauri/.gitignore | 4 +
examples/tauri/README.md | 14 +
examples/tauri/app/about/page.tsx | 13 +
examples/tauri/app/globals.css | 11 +
examples/tauri/app/layout.tsx | 12 +
examples/tauri/app/page.tsx | 20 ++
examples/tauri/app/theme-toggle.tsx | 13 +
examples/tauri/next.config.ts | 16 +
examples/tauri/package.json | 37 +++
examples/tauri/postcss.config.mjs | 1 +
examples/tauri/test/example.test.ts | 41 +++
examples/tauri/tsconfig.json | 34 +++
m3.css | 23 ++
package.json | 94 ++++++
scripts/browser-check.ts | 173 +++++++++++
scripts/bundle-report.ts | 347 +++++++++++++++++++++
scripts/yolo-example.ts | 331 +++++++++++++++++++++
src/client.tsx | 240 +++++++++++++++
src/constants.ts | 33 ++
src/crawl.ts | 237 +++++++++++++++
src/index.ts | 333 +++++++++++++++++++++
src/server.tsx | 364 +++++++++++++++++++++++
src/theme-proxy.ts | 58 ++++
test-dom/client.test.tsx | 207 +++++++++++++
test/config.test.ts | 207 +++++++++++++
test/cookie-contract.test.ts | 32 ++
test/crawl.test.ts | 135 +++++++++
test/m3-next.test.tsx | 184 ++++++++++++
test/theme-proxy.test.ts | 48 +++
tsconfig.build.json | 8 +
tsconfig.json | 10 +
53 files changed, 4072 insertions(+), 2 deletions(-)
create mode 100644 bin/m3-next.ts
create mode 100644 build.config.ts
create mode 100644 examples/app/.gitignore
create mode 100644 examples/app/AGENTS.md
create mode 100644 examples/app/CLAUDE.md
create mode 100644 examples/app/README.md
create mode 100644 examples/app/app/api/health/route.ts
create mode 100644 examples/app/app/components/notifier.tsx
create mode 100644 examples/app/app/components/page.tsx
create mode 100644 examples/app/app/globals.css
create mode 100644 examples/app/app/layout.tsx
create mode 100644 examples/app/app/nav.tsx
create mode 100644 examples/app/app/page.tsx
create mode 100644 examples/app/app/theme-toggle.tsx
create mode 100644 examples/app/app/theme/page.tsx
create mode 100644 examples/app/next.config.ts
create mode 100644 examples/app/package.json
create mode 100644 examples/app/postcss.config.mjs
create mode 100644 examples/app/proxy.ts
create mode 100644 examples/app/test/example.test.ts
create mode 100644 examples/app/tsconfig.json
create mode 100644 examples/tauri/.gitignore
create mode 100644 examples/tauri/README.md
create mode 100644 examples/tauri/app/about/page.tsx
create mode 100644 examples/tauri/app/globals.css
create mode 100644 examples/tauri/app/layout.tsx
create mode 100644 examples/tauri/app/page.tsx
create mode 100644 examples/tauri/app/theme-toggle.tsx
create mode 100644 examples/tauri/next.config.ts
create mode 100644 examples/tauri/package.json
create mode 100644 examples/tauri/postcss.config.mjs
create mode 100644 examples/tauri/test/example.test.ts
create mode 100644 examples/tauri/tsconfig.json
create mode 100644 m3.css
create mode 100644 package.json
create mode 100644 scripts/browser-check.ts
create mode 100644 scripts/bundle-report.ts
create mode 100644 scripts/yolo-example.ts
create mode 100644 src/client.tsx
create mode 100644 src/constants.ts
create mode 100644 src/crawl.ts
create mode 100644 src/index.ts
create mode 100644 src/server.tsx
create mode 100644 src/theme-proxy.ts
create mode 100644 test-dom/client.test.tsx
create mode 100644 test/config.test.ts
create mode 100644 test/cookie-contract.test.ts
create mode 100644 test/crawl.test.ts
create mode 100644 test/m3-next.test.tsx
create mode 100644 test/theme-proxy.test.ts
create mode 100644 tsconfig.build.json
create mode 100644 tsconfig.json
diff --git a/README.md b/README.md
index 953cd86..ada9bbf 100644
--- a/README.md
+++ b/README.md
@@ -1,2 +1,123 @@
-# aphrody-m3-next
-Aphrody package @aphrody/m3-next
+
+
+# `@aphrody/m3-next`
+
+Next.js 16 for the M3 stack: App Router, Cache Components, `proxy.ts`, React Compiler, typed routes, Tailwind v4 and Base UI,
+run on Bun with no Node. `examples/app` is a working app (build and serve through yolo), `examples/tauri` the static export a
+Tauri webview loads.
+
+| Import | Runtime | Contents |
+| ------------------------- | ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `@aphrody/m3-next` | any (config, server, client, edge) | `withM3`, metadata and viewport helpers, scheme helpers, `colorSchemeScript`, `m3TypefaceCss`, crawler files (sitemap, robots, llms.txt, JSON-LD) |
+| `@aphrody/m3-next/server` | Server Components | `M3Html`, `M3Root` (class, `color-scheme`, inline scheme, typefaces, pre-paint script), `readM3ThemeMode`, `m3Css` |
+| `@aphrody/m3-next/client` | `'use client'` | `M3ThemeProvider`, `M3NextThemeProvider`, `useM3Theme`, `useM3ThemeToggle` |
+| `@aphrody/m3-next/proxy` | `proxy.ts` | `createM3Proxy`: every page request carries a valid theme cookie |
+| `@aphrody/m3-next/m3.css` | CSS | tokens, expressive tokens, colour roles and the Tailwind preset (`@aphrody/m3-tailwind`) |
+| `m3-next` (bin) | Bun | runs the app's Next on Bun: `m3-next dev \| build \| start` (in the Aphrody checkout: `yolo m3 next`) |
+
+## Set up
+
+```ts
+// next.config.ts
+import { withM3 } from "@aphrody/m3-next";
+export default withM3({}, { reactCompiler: true, cacheComponents: true, typedRoutes: true });
+```
+
+`withM3` transpiles the M3 packages (including `@aphrody/m3-baseui`, which ships source), keeps `@aphrody/bun` external on the
+server, rewrites barrel imports and keeps strict mode on. Options never override a key the config sets.
+
+```css
+/* app/globals.css */
+@import "tailwindcss";
+@import "@aphrody/m3-next/m3.css";
+@import "@aphrody/m3-baseui/m3-baseui.css"; /* or css/core.css and the sheets you use */
+```
+
+```tsx
+// app/layout.tsx
+import { M3Root } from "@aphrody/m3-next/server";
+export default function RootLayout({ children }: { children: React.ReactNode }) {
+ return {children} ;
+}
+```
+
+```ts
+// proxy.ts (Next 16's name for middleware.ts)
+import { createM3Proxy } from "@aphrody/m3-next/proxy";
+export const proxy = createM3Proxy();
+// `matcher` is parsed at compile time, so it must be a literal. Pages only:
+export const config = { matcher: ["/((?!api|_next/static|_next/image|.*\\..*).*)"] };
+```
+
+## Theme, SSR and Cache Components
+
+- `M3Root` renders `` with the mode class, `color-scheme` and the scheme's colour roles inline, so the first byte is themed.
+- With `readCookie={false}` (or `mode="system"`) the shell is static: Cache Components and a static export need that. The inline
+ pre-paint script reads the `m3-theme` cookie in the browser and sets `.dark` / `.light` before first paint, so a saved
+ preference never flashes. Without `readCookie={false}` the layout reads the cookie on the server (dynamic).
+- A Server Component that reads the cookie (`readM3ThemeMode()`) belongs behind ``; `proxy.ts` guarantees the cookie
+ exists on the first request, so the server and the client agree from the first byte (`examples/app/app/theme`).
+- The client provider is a few hundred bytes (cookie, `.dark`, `.light`, `data-m3-theme`, `color-scheme`). The Material You
+ engine (material-color-utilities, about 90 KB) is a separate chunk, loaded only when the seed, variant or contrast differ from the
+ scheme the server inlined. It replaces the `@aphrody/m3-theme/react` provider: the client entry is 94 KB before and 3.3 KB now
+ (`docs/reports/M3-NEXT-BUNDLE.md`).
+
+## Fonts
+
+`next/font` needs literal arguments in the module that calls it, so a library cannot load fonts for you. Load them in
+`layout.tsx` and hand the CSS variable to `M3Root`, which points the M3 reference typefaces at it:
+
+```tsx
+const roboto = localFont({
+ src: "./RobotoFlex.woff2",
+ variable: "--font-roboto-flex",
+ weight: "100 1000",
+});
+ ;
+```
+
+`@aphrody/m3-fonts/fonts.css` is the alternative: `@font-face` rules for the M3 typefaces, imported from the global stylesheet.
+
+## Tauri static export
+
+`withM3(config, { tauri: true })` (or `"auto"`: only when the Tauri CLI runs the build) sets `output: "export"`, unoptimized
+images, trailing slashes, `cacheComponents: false` and `NEXT_PUBLIC_M3_STATIC=1`, so `M3Root` stops reading the cookie.
+`TAURI_DEV_HOST` adds the dev `assetPrefix` a phone needs. A static export has no `proxy.ts`, route handlers (unless
+`force-static`) or server cookies: the preference lives in the cookie the client writes. Point Tauri's `frontendDist` at `../out`.
+`examples/tauri` imports only the sheets of the three components it renders.
+
+## On Bun, no Node
+
+`bun --bun next build` runs Next itself on Bun, but Turbopack spawns `node` for its PostCSS and loader workers and fails when that
+is not Bun (`node: --bun is not allowed in NODE_OPTIONS`). `m3-next` puts a `node` that is Bun first on PATH, so the whole
+build, workers included, runs on one runtime and Turbopack stays the bundler (no `--webpack` fallback: `examples/app`, cold, back to back on one machine: 4.5 s with
+Turbopack, 15.3 s with `--webpack`). `m3-next dev`, `build` and `start` pass their arguments to `next`.
+
+### Moved to aphrody-labs/bun
+
+The runner (`bunNodeShim`, `nextBin`, `nextCommand`) and its tests now live in `@aphrody/next-bun`
+(`packages/bun-next/lib/run.js` and `test/integration/next-bun/` of aphrody-labs/bun, also `next-bun dev|build|start`).
+`bin/m3-next.ts` re-exports them and keeps the `m3-next` usage. The dependency is the published
+`@aphrody/next-bun` (npm, exact pin `0.2.0-aphrody.1`).
+
+A file named `proxy.ts` anywhere in a transpiled package is treated as the app's proxy by Next: that is why this package's helper
+lives in `theme-proxy.ts` (exported as `@aphrody/m3-next/proxy`).
+
+## Examples and checks
+
+```sh
+bun scripts/yolo-example.ts check # yolo web build, yolo web dev, next start, yolo tauri build; curl every route
+bun scripts/yolo-example.ts bench # cold and warm yolo web build, three runs each
+bun scripts/bundle-report.ts --examples # bundle sizes (docs/reports/M3-NEXT-BUNDLE.md)
+bun run test # unit tests, then the DOM tests (happy-dom preload)
+```
+
+`yolo web build|dev` runs `bun run build|dev` in `$YOLO_HOME/m3/packages/app-ui`; `scripts/yolo-example.ts` points `YOLO_HOME` at a temporary
+directory whose `m3/packages/app-ui` is the example (a symlink under the temp dir), so nothing is written into yolo or this repository.
+Migrating an existing app to this stack: `@aphrody/m3-next-migrate` (`next-migrate codemod all `).
diff --git a/bin/m3-next.ts b/bin/m3-next.ts
new file mode 100644
index 0000000..5d24859
--- /dev/null
+++ b/bin/m3-next.ts
@@ -0,0 +1,24 @@
+#!/usr/bin/env bun
+// SPDX-License-Identifier: Apache-2.0
+// Run Next.js 16 on Bun with no Node on the machine:
+//
+// m3-next dev | build | start [next arguments] (in the Aphrody checkout: yolo m3 next ...)
+//
+// The runner lives in @aphrody/next-bun (aphrody-labs/bun, packages/bun-next): this Bun runs the app's `next` with a
+// `node` that is Bun first on PATH, so Turbopack workers run on Bun too.
+import { bunExecutable, bunNodeShim, nextBin, nextCommand, runNext } from "@aphrody/next-bun/run";
+
+export { bunExecutable, bunNodeShim, nextBin, nextCommand };
+
+/** `m3-next ` (`yolo m3 next `): runs the app's Next and resolves to its exit status. */
+export async function runM3Next(argv: string[]): Promise {
+ if (argv.length === 0 || argv[0] === "-h" || argv[0] === "--help") {
+ console.log(
+ "usage: m3-next [next arguments]\nRuns the app's Next.js on Bun, Turbopack workers included.",
+ );
+ return argv.length === 0 ? 2 : 0;
+ }
+ return await runNext(argv);
+}
+
+if (import.meta.main) process.exit(await runM3Next(process.argv.slice(2)));
diff --git a/build.config.ts b/build.config.ts
new file mode 100644
index 0000000..470d0ba
--- /dev/null
+++ b/build.config.ts
@@ -0,0 +1,14 @@
+// SPDX-License-Identifier: Apache-2.0
+import { m3Package } from "@aphrody/m3-config/bun-build";
+
+// Four entries with four runtimes: `index` (config, metadata and scheme helpers, safe anywhere),
+// `server` (React Server Components, node:fs, next/headers), `client` (the 'use client'
+// boundary) and `theme-proxy` (the proxy.ts helper, next/server only; its file is not named proxy.ts because Next scans for that name). No code splitting: server.js reaches client.js through the package name
+// (`@aphrody/m3-next/client`, external), so the client directive stays on a module of its own.
+export default m3Package({
+ entry: ["src/index.ts", "src/server.tsx", "src/client.tsx", "src/theme-proxy.ts"],
+ target: "node",
+ splitting: false,
+ useClient: ["client.js"],
+ external: ["next", "next/*", "node:*"],
+});
diff --git a/examples/app/.gitignore b/examples/app/.gitignore
new file mode 100644
index 0000000..fa8bdda
--- /dev/null
+++ b/examples/app/.gitignore
@@ -0,0 +1,4 @@
+.next/
+out/
+next-env.d.ts
+*.tsbuildinfo
diff --git a/examples/app/AGENTS.md b/examples/app/AGENTS.md
new file mode 100644
index 0000000..643577d
--- /dev/null
+++ b/examples/app/AGENTS.md
@@ -0,0 +1,9 @@
+
+
+# This is NOT the Next.js you know
+
+This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices.
+
+This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean.
+
+
diff --git a/examples/app/CLAUDE.md b/examples/app/CLAUDE.md
new file mode 100644
index 0000000..43c994c
--- /dev/null
+++ b/examples/app/CLAUDE.md
@@ -0,0 +1 @@
+@AGENTS.md
diff --git a/examples/app/README.md b/examples/app/README.md
new file mode 100644
index 0000000..202c64c
--- /dev/null
+++ b/examples/app/README.md
@@ -0,0 +1,23 @@
+
+
+# `@aphrody/m3-next-example`
+
+A Next.js 16 app on the M3 stack: App Router, Cache Components (`use cache`, a static shell with a dynamic `/theme` behind
+``), `proxy.ts`, React Compiler, typed routes, Tailwind v4 (`@aphrody/m3-tailwind`), Base UI wrappers
+(`@aphrody/m3-baseui`) rendered from a Server Component, `next/font/local` mapped onto the M3 typefaces, and a route handler.
+
+```sh
+bun run build # next build, on Bun (Turbopack workers included): `yolo m3 next build`
+bun run start # next start
+bun run dev # next dev
+bun run test # configuration and files, without starting Next
+```
+
+Through yolo (`yolo web build|dev` runs these scripts), with curl on every route, and cold and warm build times:
+
+```sh
+bun ../../scripts/yolo-example.ts check
+bun ../../scripts/yolo-example.ts bench
+```
+
+The static export for Tauri is `../tauri`. The `AGENTS.md` and `CLAUDE.md` here are written by `next dev`.
diff --git a/examples/app/app/api/health/route.ts b/examples/app/app/api/health/route.ts
new file mode 100644
index 0000000..a5e2d3b
--- /dev/null
+++ b/examples/app/app/api/health/route.ts
@@ -0,0 +1,4 @@
+// SPDX-License-Identifier: Apache-2.0
+export function GET() {
+ return Response.json({ ok: true, runtime: process.versions.bun ? "bun" : "node" });
+}
diff --git a/examples/app/app/components/notifier.tsx b/examples/app/app/components/notifier.tsx
new file mode 100644
index 0000000..5f22534
--- /dev/null
+++ b/examples/app/app/components/notifier.tsx
@@ -0,0 +1,46 @@
+// SPDX-License-Identifier: Apache-2.0
+"use client";
+import { M3Button, M3Toast } from "@aphrody/m3-baseui";
+import { useState } from "react";
+
+function Toasts() {
+ const { toasts } = M3Toast.useToastManager();
+ return toasts.map((toast) => (
+
+
+
+
+
+ ×
+
+ ));
+}
+
+function Trigger() {
+ const manager = M3Toast.useToastManager();
+ // The React Compiler memoises this component and its handler; there is no useCallback or useMemo.
+ const [count, setCount] = useState(0);
+ const notify = () => {
+ setCount(count + 1);
+ manager.add({ title: "Saved", description: `Toast number ${count + 1}` });
+ };
+ return (
+
+ Show a toast ({count})
+
+ );
+}
+
+/** Client Component: a toast provider, its viewport and the button that adds a toast. */
+export function Notifier() {
+ return (
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/examples/app/app/components/page.tsx b/examples/app/app/components/page.tsx
new file mode 100644
index 0000000..d7a06f8
--- /dev/null
+++ b/examples/app/app/components/page.tsx
@@ -0,0 +1,224 @@
+// SPDX-License-Identifier: Apache-2.0
+// A Server Component that renders Base UI parts styled with M3: the wrappers are 'use client' modules, so a server page
+// composes them with plain props (render elements, items, values) and ships only their interactive code.
+import {
+ M3Accordion,
+ M3Avatar,
+ M3Button,
+ M3Checkbox,
+ M3Dialog,
+ M3Field,
+ M3Input,
+ M3Menu,
+ M3Meter,
+ M3NumberField,
+ M3Popover,
+ M3Progress,
+ M3Radio,
+ M3RadioGroup,
+ M3Select,
+ M3Separator,
+ M3Slider,
+ M3Switch,
+ M3Tabs,
+ M3Toggle,
+ M3ToggleGroup,
+ M3Tooltip,
+} from "@aphrody/m3-baseui";
+import { Nav } from "../nav";
+import { Notifier } from "./notifier";
+
+const fruit = [
+ { label: "Apple", value: "apple" },
+ { label: "Banana", value: "banana" },
+ { label: "Cherry", value: "cherry" },
+];
+
+function Section({ title, children }: { title: string; children: React.ReactNode }) {
+ return (
+
+ );
+}
+
+export default function ComponentsPage() {
+ return (
+ <>
+
+
+ Base UI, styled by M3
+
+
+ Filled
+ Tonal
+ Outlined
+ Text
+ Elevated
+ Disabled
+
+
+
+
+
+
+
+
+
+
+
+ Bold
+ Italic
+
+
+
+
+
+ Name
+
+ As on your passport.
+
+
+
+
+ -
+
+ +
+
+
+
+
+
+
+
+
+ ▾
+
+
+
+
+
+ {fruit.map((item) => (
+
+ ✓
+ {item.label}
+
+ ))}
+
+
+
+
+
+
+
+ }>Menu
+
+
+
+ Cut
+ Copy
+
+ Paste
+
+
+
+
+
+
+ }>Popover
+
+
+
+ Rich content
+ Anchored to its trigger.
+
+
+
+
+
+
+
+ }>Tooltip
+
+
+ Plain tooltip
+
+
+
+
+
+
+ }>Open dialog
+
+
+
+ Discard draft?
+
+ The draft is deleted and cannot be restored.
+
+
+ }>Cancel
+ }>Discard
+
+
+
+
+
+
+
+
+
+ One
+ Two
+
+
+ First panel
+ Second panel
+
+
+
+
+ What is Cache Components?
+
+
+ `use cache`, `cacheLife` and `cacheTag`, with a static shell.
+
+
+
+
+
+
+
+ Volume
+
+
+
+
+
+
+
+
+
+ Upload
+
+
+
+
+
+
+ Disk
+
+
+
+
+
+ AL
+
+
+
+
+
+ >
+ );
+}
diff --git a/examples/app/app/globals.css b/examples/app/app/globals.css
new file mode 100644
index 0000000..aa09b4c
--- /dev/null
+++ b/examples/app/app/globals.css
@@ -0,0 +1,9 @@
+@import "tailwindcss";
+@import "@aphrody/m3-next/m3.css";
+@import "@aphrody/m3-baseui/m3-baseui.css";
+
+body {
+ margin: 0;
+ background: var(--md-sys-color-surface);
+ color: var(--md-sys-color-on-surface);
+}
diff --git a/examples/app/app/layout.tsx b/examples/app/app/layout.tsx
new file mode 100644
index 0000000..42bfe69
--- /dev/null
+++ b/examples/app/app/layout.tsx
@@ -0,0 +1,40 @@
+// SPDX-License-Identifier: Apache-2.0
+import { generateM3Metadata, generateM3Viewport } from "@aphrody/m3-next";
+import { M3Root } from "@aphrody/m3-next/server";
+import type { Metadata, Viewport } from "next";
+import localFont from "next/font/local";
+import type { ReactNode } from "react";
+import "./globals.css";
+
+// next/font needs literal arguments in the module that calls it, so the font is loaded here and handed to
+// M3Root, which points the M3 reference typefaces at it.
+const roboto = localFont({
+ src: "../../../../m3-fonts/fonts/web/RobotoFlex-latin.woff2",
+ variable: "--font-roboto-flex",
+ weight: "100 1000",
+ display: "swap",
+});
+
+export const metadata: Metadata = generateM3Metadata({
+ name: "M3 on Next 16",
+ description: "Base UI, Tailwind v4 and Material 3 on Next.js 16, built and served on Bun.",
+});
+export const viewport: Viewport = generateM3Viewport();
+
+export default function RootLayout({ children }: { children: ReactNode }) {
+ return (
+ // Cache Components: the shell is static. The theme preference is read in the browser (pre-paint script and client
+ // provider), and /theme reads the cookie on the server inside .
+
+ {children}
+
+ );
+}
diff --git a/examples/app/app/nav.tsx b/examples/app/app/nav.tsx
new file mode 100644
index 0000000..1a513ef
--- /dev/null
+++ b/examples/app/app/nav.tsx
@@ -0,0 +1,32 @@
+// SPDX-License-Identifier: Apache-2.0
+import Link from "next/link";
+import { ThemeToggle } from "./theme-toggle";
+
+/** Server Component: typed `href`s (typedRoutes) and the client toggle. */
+export function Nav() {
+ return (
+
+
+ Home
+
+
+ Components
+
+
+ Theme
+
+
+
+
+
+ );
+}
diff --git a/examples/app/app/page.tsx b/examples/app/app/page.tsx
new file mode 100644
index 0000000..a8b0ec9
--- /dev/null
+++ b/examples/app/app/page.tsx
@@ -0,0 +1,44 @@
+// SPDX-License-Identifier: Apache-2.0
+import { M3Button } from "@aphrody/m3-baseui";
+import { cacheLife, cacheTag } from "next/cache";
+import Link from "next/link";
+import { Nav } from "./nav";
+
+/** Cache Components: computed once at build and revalidated hourly (or by `revalidateTag("stamp")`). */
+async function stamp() {
+ "use cache";
+ cacheLife("hours");
+ cacheTag("stamp");
+ const runtime = process.versions.bun ? `Bun ${process.versions.bun}` : `Node ${process.version}`;
+ return { at: new Date().toISOString(), runtime };
+}
+
+export default async function Home() {
+ const { at, runtime } = await stamp();
+ return (
+ <>
+
+
+ Material 3 on Next.js 16
+
+ App Router, Cache Components, proxy.ts, React Compiler and typed routes, with Base UI
+ parts styled by M3 tokens through a Tailwind v4 preset.
+
+
+ Cached on the server
+
+ Rendered {at} on {runtime}.
+
+
+
+ } nativeButton={false}>
+ Components
+
+ } nativeButton={false}>
+ /api/health
+
+
+
+ >
+ );
+}
diff --git a/examples/app/app/theme-toggle.tsx b/examples/app/app/theme-toggle.tsx
new file mode 100644
index 0000000..ac7782d
--- /dev/null
+++ b/examples/app/app/theme-toggle.tsx
@@ -0,0 +1,14 @@
+// SPDX-License-Identifier: Apache-2.0
+"use client";
+import { M3Button } from "@aphrody/m3-baseui";
+import { useM3ThemeToggle } from "@aphrody/m3-next/client";
+
+/** Client Component compiled by the React Compiler (no useCallback or useMemo needed). */
+export function ThemeToggle() {
+ const { mode, resolved, toggle } = useM3ThemeToggle();
+ return (
+
+ Theme: {mode} ({resolved})
+
+ );
+}
diff --git a/examples/app/app/theme/page.tsx b/examples/app/app/theme/page.tsx
new file mode 100644
index 0000000..0a0abd5
--- /dev/null
+++ b/examples/app/app/theme/page.tsx
@@ -0,0 +1,27 @@
+// SPDX-License-Identifier: Apache-2.0
+import { readM3ThemeMode } from "@aphrody/m3-next/server";
+import { Suspense } from "react";
+import { Nav } from "../nav";
+
+/** Reads the request cookie: dynamic, so it sits behind while the shell stays static. */
+async function ServerMode() {
+ const mode = await readM3ThemeMode();
+ return The server saw the preference: {mode}
;
+}
+
+export default function ThemePage() {
+ return (
+ <>
+
+
+ Theme
+
+ proxy.ts writes the cookie on the first visit; the toggle in the bar changes it.
+
+ Reading the cookie...
}>
+
+
+
+ >
+ );
+}
diff --git a/examples/app/next.config.ts b/examples/app/next.config.ts
new file mode 100644
index 0000000..5fa0769
--- /dev/null
+++ b/examples/app/next.config.ts
@@ -0,0 +1,15 @@
+// SPDX-License-Identifier: Apache-2.0
+import { withM3 } from "@aphrody/m3-next";
+import { resolve } from "node:path";
+
+export default withM3(
+ { poweredByHeader: false },
+ {
+ // Next 16: React Compiler and Cache Components are top-level keys; Turbopack is the default bundler.
+ reactCompiler: true,
+ cacheComponents: true,
+ typedRoutes: true,
+ // The workspace packages live outside this app: Turbopack's root is the repository root.
+ turbopackRoot: resolve(process.cwd(), "../../../.."),
+ },
+);
diff --git a/examples/app/package.json b/examples/app/package.json
new file mode 100644
index 0000000..cafadc6
--- /dev/null
+++ b/examples/app/package.json
@@ -0,0 +1,39 @@
+{
+ "name": "@aphrody/m3-next-example",
+ "version": "3.3.6",
+ "private": true,
+ "description": "Sample Next.js 16 app on the M3 stack: App Router, Cache Components, proxy.ts, React Compiler, typed routes, Tailwind v4 and Base UI, built and served on Bun through yolo.",
+ "license": "Apache-2.0",
+ "type": "module",
+ "publishConfig": {
+ "access": "public"
+ },
+ "scripts": {
+ "dev": "bun ../../../../../packages/engine/yolo/src/index.ts m3 next dev",
+ "build": "bun ../../../../../packages/engine/yolo/src/index.ts m3 next build",
+ "start": "bun ../../../../../packages/engine/yolo/src/index.ts m3 next start",
+ "typecheck": "bun check -p tsconfig.json --noEmit",
+ "test": "bun test ./test/"
+ },
+ "dependencies": {
+ "@aphrody/m3-baseui": "workspace:*",
+ "@aphrody/m3-next": "workspace:*",
+ "@aphrody/m3-tailwind": "workspace:*",
+ "@aphrody/m3-tokens": "workspace:*",
+ "next": "catalog:",
+ "react": "catalog:",
+ "react-dom": "catalog:"
+ },
+ "devDependencies": {
+ "@tailwindcss/postcss": "catalog:",
+ "@types/bun": "catalog:",
+ "@types/react": "catalog:",
+ "@types/react-dom": "catalog:",
+ "babel-plugin-react-compiler": "catalog:",
+ "tailwindcss": "catalog:",
+ "typescript": "catalog:"
+ },
+ "engines": {
+ "bun": ">=1.4.2"
+ }
+}
diff --git a/examples/app/postcss.config.mjs b/examples/app/postcss.config.mjs
new file mode 100644
index 0000000..a9b5c43
--- /dev/null
+++ b/examples/app/postcss.config.mjs
@@ -0,0 +1 @@
+export default { plugins: { "@tailwindcss/postcss": {} } };
diff --git a/examples/app/proxy.ts b/examples/app/proxy.ts
new file mode 100644
index 0000000..99af4eb
--- /dev/null
+++ b/examples/app/proxy.ts
@@ -0,0 +1,8 @@
+// SPDX-License-Identifier: Apache-2.0
+// Next 16's proxy.ts (it replaces middleware.ts): every page request carries a valid theme cookie, so the
+// Server Component on /theme and the client provider agree from the first byte.
+import { createM3Proxy } from "@aphrody/m3-next/proxy";
+
+export const proxy = createM3Proxy();
+// `matcher` is parsed at compile time: it must be a literal. Pages only: not /api, Next's static and image routes, or files.
+export const config = { matcher: ["/((?!api|_next/static|_next/image|.*\\..*).*)"] };
diff --git a/examples/app/test/example.test.ts b/examples/app/test/example.test.ts
new file mode 100644
index 0000000..36e1af3
--- /dev/null
+++ b/examples/app/test/example.test.ts
@@ -0,0 +1,57 @@
+// SPDX-License-Identifier: Apache-2.0
+// The example's configuration and files, without starting Next: `scripts/yolo-example.ts` builds and serves it.
+import { beforeAll, describe, expect, test } from "bun:test";
+import { existsSync } from "node:fs";
+import { join } from "node:path";
+import { NextRequest } from "next/server";
+
+const root = join(import.meta.dir, "..");
+const m3Next = join(root, "../..");
+
+beforeAll(() => {
+ // `@aphrody/m3-next` resolves to its build output: make it when a fresh checkout has none.
+ if (!existsSync(join(m3Next, "dist/index.js"))) {
+ const build = Bun.spawnSync(
+ ["bun", join(m3Next, "../../scripts/build.ts"), "build", "--only", "@aphrody/m3-next"],
+ { windowsHide: true, cwd: m3Next },
+ );
+ if (build.exitCode !== 0) throw new Error(build.stderr.toString());
+ }
+});
+
+describe("examples/app", () => {
+ test("next.config.ts: the Next 16 stack, with the monorepo root for Turbopack", async () => {
+ const config = (await import("../next.config")).default;
+ expect(config.cacheComponents).toBe(true);
+ expect(config.reactCompiler).toBe(true);
+ expect(config.typedRoutes).toBe(true);
+ expect(config.output).toBeUndefined();
+ expect(config.transpilePackages).toContain("@aphrody/m3-baseui");
+ expect(config.turbopack?.root).toBe(join(root, "../../../.."));
+ });
+
+ test("proxy.ts: a function and a literal page matcher; a first visit gets the theme cookie", async () => {
+ const { proxy, config } = await import("../proxy");
+ expect(typeof proxy).toBe("function");
+ expect(config.matcher).toHaveLength(1);
+ const response = proxy(new NextRequest("http://localhost/theme"));
+ expect(response.cookies.get("m3-theme")?.value).toBe("system");
+ });
+
+ test("the health route answers", async () => {
+ const { GET } = await import("../app/api/health/route");
+ expect(await GET().json()).toMatchObject({ ok: true });
+ });
+
+ test("every route has its file", () => {
+ for (const file of [
+ "app/layout.tsx",
+ "app/page.tsx",
+ "app/components/page.tsx",
+ "app/theme/page.tsx",
+ "app/api/health/route.ts",
+ "app/globals.css",
+ ])
+ expect(existsSync(join(root, file))).toBe(true);
+ });
+});
diff --git a/examples/app/tsconfig.json b/examples/app/tsconfig.json
new file mode 100644
index 0000000..6923ca3
--- /dev/null
+++ b/examples/app/tsconfig.json
@@ -0,0 +1,34 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "lib": ["DOM", "DOM.Iterable", "ES2022"],
+ "module": "ESNext",
+ "moduleResolution": "Bundler",
+ "jsx": "react-jsx",
+ "strict": true,
+ "noEmit": true,
+ "skipLibCheck": true,
+ "allowJs": false,
+ "isolatedModules": true,
+ "incremental": true,
+ "resolveJsonModule": true,
+ "esModuleInterop": true,
+ "plugins": [
+ {
+ "name": "next"
+ }
+ ],
+ "paths": {
+ "@/*": ["./*"]
+ },
+ "types": ["bun"]
+ },
+ "include": [
+ "next-env.d.ts",
+ "**/*.ts",
+ "**/*.tsx",
+ ".next/types/**/*.ts",
+ ".next/dev/types/**/*.ts"
+ ],
+ "exclude": ["node_modules"]
+}
diff --git a/examples/tauri/.gitignore b/examples/tauri/.gitignore
new file mode 100644
index 0000000..fa8bdda
--- /dev/null
+++ b/examples/tauri/.gitignore
@@ -0,0 +1,4 @@
+.next/
+out/
+next-env.d.ts
+*.tsbuildinfo
diff --git a/examples/tauri/README.md b/examples/tauri/README.md
new file mode 100644
index 0000000..8ff83fe
--- /dev/null
+++ b/examples/tauri/README.md
@@ -0,0 +1,14 @@
+
+
+# `@aphrody/m3-next-tauri-example`
+
+The static export a Tauri webview loads: `withM3(config, { tauri: true })` sets `output: "export"`, unoptimized images, trailing
+slashes and `NEXT_PUBLIC_M3_STATIC=1`, so `M3Root` renders a static shell and the pre-paint script applies the saved theme from
+the cookie the client writes. No `proxy.ts`, no route handlers, no server cookies. Only the sheets of the components in use are
+imported (`core`, `button`, `switch`).
+
+```sh
+bun run build # writes out/; point Tauri's frontendDist at ../out
+bun run test
+bun ../../scripts/yolo-example.ts export # yolo tauri build, then checks out/index.html
+```
diff --git a/examples/tauri/app/about/page.tsx b/examples/tauri/app/about/page.tsx
new file mode 100644
index 0000000..8ee93c3
--- /dev/null
+++ b/examples/tauri/app/about/page.tsx
@@ -0,0 +1,13 @@
+// SPDX-License-Identifier: Apache-2.0
+import Link from "next/link";
+
+export default function About() {
+ return (
+
+ About
+
+ Back
+
+
+ );
+}
diff --git a/examples/tauri/app/globals.css b/examples/tauri/app/globals.css
new file mode 100644
index 0000000..74070d5
--- /dev/null
+++ b/examples/tauri/app/globals.css
@@ -0,0 +1,11 @@
+@import "tailwindcss";
+@import "@aphrody/m3-next/m3.css";
+@import "@aphrody/m3-baseui/css/core.css";
+@import "@aphrody/m3-baseui/css/button.css";
+@import "@aphrody/m3-baseui/css/switch.css";
+
+body {
+ margin: 0;
+ background: var(--md-sys-color-surface);
+ color: var(--md-sys-color-on-surface);
+}
diff --git a/examples/tauri/app/layout.tsx b/examples/tauri/app/layout.tsx
new file mode 100644
index 0000000..81e765c
--- /dev/null
+++ b/examples/tauri/app/layout.tsx
@@ -0,0 +1,12 @@
+// SPDX-License-Identifier: Apache-2.0
+import { M3Root } from "@aphrody/m3-next/server";
+import type { ReactNode } from "react";
+import "./globals.css";
+
+export const metadata = { title: "M3 for Tauri" };
+
+export default function RootLayout({ children }: { children: ReactNode }) {
+ // No `mode` and no `readCookie`: with `tauri: true` the shell is static and the pre-paint script applies the saved
+ // preference from the cookie the client provider writes.
+ return {children} ;
+}
diff --git a/examples/tauri/app/page.tsx b/examples/tauri/app/page.tsx
new file mode 100644
index 0000000..671715c
--- /dev/null
+++ b/examples/tauri/app/page.tsx
@@ -0,0 +1,20 @@
+// SPDX-License-Identifier: Apache-2.0
+import { M3Button, M3Switch } from "@aphrody/m3-baseui";
+import Link from "next/link";
+import { ThemeToggle } from "./theme-toggle";
+
+export default function Home() {
+ return (
+
+ Static export for Tauri
+
+ Only the sheets of the components used on the page are imported (core, button, switch).
+
+
+
+ } nativeButton={false}>
+ About
+
+
+ );
+}
diff --git a/examples/tauri/app/theme-toggle.tsx b/examples/tauri/app/theme-toggle.tsx
new file mode 100644
index 0000000..10b0a3e
--- /dev/null
+++ b/examples/tauri/app/theme-toggle.tsx
@@ -0,0 +1,13 @@
+// SPDX-License-Identifier: Apache-2.0
+"use client";
+import { M3Button } from "@aphrody/m3-baseui";
+import { useM3ThemeToggle } from "@aphrody/m3-next/client";
+
+export function ThemeToggle() {
+ const { mode, toggle } = useM3ThemeToggle();
+ return (
+
+ Theme: {mode}
+
+ );
+}
diff --git a/examples/tauri/next.config.ts b/examples/tauri/next.config.ts
new file mode 100644
index 0000000..c0415e7
--- /dev/null
+++ b/examples/tauri/next.config.ts
@@ -0,0 +1,16 @@
+// SPDX-License-Identifier: Apache-2.0
+import { withM3 } from "@aphrody/m3-next";
+import { resolve } from "node:path";
+
+// `tauri: true` is the static export a Tauri webview loads: `output: "export"`, unoptimized images, trailing slashes,
+// no Cache Components, and `M3Root` stops reading the request cookie (there is no request). With `tauri: "auto"` the
+// same config also serves `next dev` from the browser and exports only when the Tauri CLI runs the build.
+export default withM3(
+ {},
+ {
+ typedRoutes: true,
+ reactCompiler: false,
+ tauri: true,
+ turbopackRoot: resolve(process.cwd(), "../../../.."),
+ },
+);
diff --git a/examples/tauri/package.json b/examples/tauri/package.json
new file mode 100644
index 0000000..e43224b
--- /dev/null
+++ b/examples/tauri/package.json
@@ -0,0 +1,37 @@
+{
+ "name": "@aphrody/m3-next-tauri-example",
+ "version": "3.3.6",
+ "private": true,
+ "description": "Static export of a Next.js 16 app for a Tauri webview: no server, no proxy, no cookies read on the server; the theme preference lives in the browser.",
+ "license": "Apache-2.0",
+ "type": "module",
+ "publishConfig": {
+ "access": "public"
+ },
+ "scripts": {
+ "dev": "bun ../../../../../packages/engine/yolo/src/index.ts m3 next dev",
+ "build": "bun ../../../../../packages/engine/yolo/src/index.ts m3 next build",
+ "typecheck": "bun check -p tsconfig.json --noEmit",
+ "test": "bun test ./test/"
+ },
+ "dependencies": {
+ "@aphrody/m3-baseui": "workspace:*",
+ "@aphrody/m3-next": "workspace:*",
+ "@aphrody/m3-tailwind": "workspace:*",
+ "@aphrody/m3-tokens": "workspace:*",
+ "next": "catalog:",
+ "react": "catalog:",
+ "react-dom": "catalog:"
+ },
+ "devDependencies": {
+ "@tailwindcss/postcss": "catalog:",
+ "@types/bun": "catalog:",
+ "@types/react": "catalog:",
+ "@types/react-dom": "catalog:",
+ "tailwindcss": "catalog:",
+ "typescript": "catalog:"
+ },
+ "engines": {
+ "bun": ">=1.4.2"
+ }
+}
diff --git a/examples/tauri/postcss.config.mjs b/examples/tauri/postcss.config.mjs
new file mode 100644
index 0000000..a9b5c43
--- /dev/null
+++ b/examples/tauri/postcss.config.mjs
@@ -0,0 +1 @@
+export default { plugins: { "@tailwindcss/postcss": {} } };
diff --git a/examples/tauri/test/example.test.ts b/examples/tauri/test/example.test.ts
new file mode 100644
index 0000000..89b3199
--- /dev/null
+++ b/examples/tauri/test/example.test.ts
@@ -0,0 +1,41 @@
+// SPDX-License-Identifier: Apache-2.0
+// The static export's configuration and files, without starting Next: `scripts/yolo-example.ts export` builds it.
+import { beforeAll, describe, expect, test } from "bun:test";
+import { existsSync, readFileSync } from "node:fs";
+import { join } from "node:path";
+
+const root = join(import.meta.dir, "..");
+const m3Next = join(root, "../..");
+
+beforeAll(() => {
+ if (!existsSync(join(m3Next, "dist/index.js"))) {
+ const build = Bun.spawnSync(
+ ["bun", join(m3Next, "../../scripts/build.ts"), "build", "--only", "@aphrody/m3-next"],
+ { windowsHide: true, cwd: m3Next },
+ );
+ if (build.exitCode !== 0) throw new Error(build.stderr.toString());
+ }
+});
+
+describe("examples/tauri", () => {
+ test("next.config.ts is a static export for a webview", async () => {
+ const config = (await import("../next.config")).default;
+ expect(config.output).toBe("export");
+ expect(config.trailingSlash).toBe(true);
+ expect(config.images).toEqual({ unoptimized: true });
+ expect(config.cacheComponents).toBe(false);
+ expect(config.env).toEqual({ NEXT_PUBLIC_M3_STATIC: "1" });
+ });
+
+ test("there is no proxy.ts and no route handler: an export has no server", () => {
+ expect(existsSync(join(root, "proxy.ts"))).toBe(false);
+ expect(existsSync(join(root, "app/api"))).toBe(false);
+ });
+
+ test("the page imports the sheets of the components it uses, not the whole library", () => {
+ const css = readFileSync(join(root, "app/globals.css"), "utf8");
+ expect(css).toContain("@aphrody/m3-baseui/css/core.css");
+ expect(css).toContain("@aphrody/m3-baseui/css/button.css");
+ expect(css).not.toContain("m3-baseui.css");
+ });
+});
diff --git a/examples/tauri/tsconfig.json b/examples/tauri/tsconfig.json
new file mode 100644
index 0000000..6923ca3
--- /dev/null
+++ b/examples/tauri/tsconfig.json
@@ -0,0 +1,34 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "lib": ["DOM", "DOM.Iterable", "ES2022"],
+ "module": "ESNext",
+ "moduleResolution": "Bundler",
+ "jsx": "react-jsx",
+ "strict": true,
+ "noEmit": true,
+ "skipLibCheck": true,
+ "allowJs": false,
+ "isolatedModules": true,
+ "incremental": true,
+ "resolveJsonModule": true,
+ "esModuleInterop": true,
+ "plugins": [
+ {
+ "name": "next"
+ }
+ ],
+ "paths": {
+ "@/*": ["./*"]
+ },
+ "types": ["bun"]
+ },
+ "include": [
+ "next-env.d.ts",
+ "**/*.ts",
+ "**/*.tsx",
+ ".next/types/**/*.ts",
+ ".next/dev/types/**/*.ts"
+ ],
+ "exclude": ["node_modules"]
+}
diff --git a/m3.css b/m3.css
new file mode 100644
index 0000000..c4d01a5
--- /dev/null
+++ b/m3.css
@@ -0,0 +1,23 @@
+/* SPDX-License-Identifier: Apache-2.0 */
+/*
+ * @aphrody/m3-next/m3.css: the Material 3 sheets for a Next.js + Tailwind v4 app.
+ * Import it from the app's global stylesheet, right after Tailwind:
+ *
+ * @import "tailwindcss";
+ * @import "@aphrody/m3-next/m3.css";
+ *
+ * - m3-tokens.css and expressive.css: shape, typescale (with the emphasized styles), motion (with the Expressive
+ * springs), elevation and state tokens as runtime vars;
+ * - m3-theme tokens.css: the M3 colour roles (light, dark via prefers-color-scheme and .dark) plus the
+ * shadcn/ui and Tailwind aliases; m3-theme tailwind.css keeps the shadcn utility names (bg-card, text-muted-foreground);
+ * - m3-tailwind preset.css: every M3 role, shape, type style, elevation, motion and window class as a Tailwind v4
+ * utility or variant (bg-surface-container, text-label-large, shape-large-top, state-layer, medium:, m3-open:, dark:, ...).
+ *
+ * `dark:` follows the class M3Html puts on (cookie, or system resolved before paint), or the OS preference
+ * unless `.light` forces light, so `dark:` utilities and the M3 roles always agree.
+ */
+@import "@aphrody/m3-tokens/m3-tokens.css";
+@import "@aphrody/m3-tokens/expressive.css";
+@import "@aphrody/m3-theme/tokens.css";
+@import "@aphrody/m3-theme/tailwind.css";
+@import "@aphrody/m3-tailwind/preset.css";
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..5c857e7
--- /dev/null
+++ b/package.json
@@ -0,0 +1,94 @@
+{
+ "name": "@aphrody/m3-next",
+ "version": "1.4.4",
+ "description": "Next.js 16 App Router integration for the @aphrody Material Design 3 layer: withM3 config (React Compiler, Cache Components, typed routes, Tauri static export), M3Html/M3Root server components, a light 'use client' theme provider, a proxy.ts helper, metadata/viewport helpers, crawler files and the m3-next runner (Next on Bun, no Node; from @aphrody/next-bun).",
+ "keywords": [
+ "material-3",
+ "md3",
+ "next",
+ "nextjs",
+ "react-server-components",
+ "tailwind"
+ ],
+ "homepage": "https://github.com/aphrody-labs/aphrody-m3/tree/main/packages/m3-next#readme",
+ "license": "Apache-2.0",
+ "author": "aphrody-code",
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/aphrody-labs/aphrody-m3.git",
+ "directory": "packages/m3-next"
+ },
+ "bin": {
+ "m3-next": "./bin/m3-next.ts"
+ },
+ "files": [
+ "dist",
+ "bin",
+ "m3.css"
+ ],
+ "type": "module",
+ "sideEffects": [
+ "*.css"
+ ],
+ "main": "./dist/index.js",
+ "module": "./dist/index.js",
+ "types": "./dist/index.d.ts",
+ "exports": {
+ ".": {
+ "types": "./dist/index.d.ts",
+ "default": "./dist/index.js"
+ },
+ "./server": {
+ "types": "./dist/server.d.ts",
+ "default": "./dist/server.js"
+ },
+ "./client": {
+ "types": "./dist/client.d.ts",
+ "default": "./dist/client.js"
+ },
+ "./proxy": {
+ "types": "./dist/theme-proxy.d.ts",
+ "default": "./dist/theme-proxy.js"
+ },
+ "./m3.css": "./m3.css",
+ "./package.json": "./package.json"
+ },
+ "publishConfig": {
+ "access": "public"
+ },
+ "scripts": {
+ "build": "bun ../../scripts/build.ts build --only $npm_package_name",
+ "typecheck": "bun check -p tsconfig.json --noEmit",
+ "test": "bun test ./test/ && bun test --preload @aphrody/m3-config/test/dom ./test-dom/"
+ },
+ "dependencies": {
+ "@aphrody/bun": "^3.3.0",
+ "@aphrody/m3-tailwind": "workspace:*",
+ "@aphrody/m3-theme": "workspace:*",
+ "@aphrody/m3-tokens": "workspace:*",
+ "@aphrody/next-bun": "0.2.0-aphrody.1"
+ },
+ "devDependencies": {
+ "@aphrody/bun-webview-page": "0.1.1-aphrody.2",
+ "@aphrody/m3-config": "workspace:*",
+ "@types/react": "catalog:",
+ "@types/react-dom": "catalog:",
+ "next": "catalog:",
+ "react": "catalog:",
+ "react-dom": "catalog:"
+ },
+ "peerDependencies": {
+ "next": ">=16.0.0",
+ "react": ">=19.0.0",
+ "react-dom": ">=19.0.0",
+ "tailwindcss": ">=4.1.0"
+ },
+ "peerDependenciesMeta": {
+ "tailwindcss": {
+ "optional": true
+ }
+ },
+ "engines": {
+ "bun": ">=1.4.4"
+ }
+}
diff --git a/scripts/browser-check.ts b/scripts/browser-check.ts
new file mode 100644
index 0000000..b4ee493
--- /dev/null
+++ b/scripts/browser-check.ts
@@ -0,0 +1,173 @@
+#!/usr/bin/env bun
+// SPDX-License-Identifier: Apache-2.0
+// Drive the example app in headless Chromium: hydration without errors, Base UI behaviour, the M3 styles actually
+// computed, and the theme round trip (client toggle -> cookie -> server render).
+//
+// bun scripts/browser-check.ts [--base http://localhost:3218] (server running: `bun run start` in examples/app)
+// bun scripts/yolo-example.ts browser (builds with yolo, starts the server, runs this)
+//
+// The browser is Bun.WebView driven through @aphrody/bun-webview-page (Chrome, Chromium or Edge found on PATH or in
+// BUN_CHROME_PATH); no Playwright and no browser download.
+import { chromium } from "@aphrody/bun-webview-page";
+
+export interface Check {
+ name: string;
+ ok: boolean;
+ detail?: string;
+}
+
+/** Run every check against a running server; never throws on a failed check. */
+export async function browserCheck(base: string): Promise {
+ const browser = await chromium.launch({
+ headless: true,
+ args: ["--no-sandbox", "--disable-gpu", "--disable-dev-shm-usage"],
+ });
+ const checks: Check[] = [];
+ const check = (name: string, ok: boolean, detail?: string) =>
+ checks.push({ name, ok, ...(detail ? { detail } : {}) });
+ try {
+ const page = await browser.newPage();
+ const problems: string[] = [];
+ // Headless Chromium without a display never fires requestAnimationFrame here, and the actionability wait of a
+ // selector click depends on it: clicks are forced (native events at the element centre; the assertions after each
+ // click replace the visibility and hit checks).
+ page.on("console", (message) => {
+ if (message.type() === "error") problems.push(`console: ${message.text()}`);
+ });
+ page.on("pageerror", (error) => problems.push(`pageerror: ${error.message}`));
+
+ // Home: the cached stamp, the typed links, the font variable.
+ await page.goto(`${base}/`, { waitUntil: "networkidle" });
+ check(
+ "home renders the cached stamp",
+ ((await page.locator('[data-testid="stamp"]').textContent()) ?? "").startsWith("Rendered "),
+ );
+ check(
+ "html has lang, the font variable class and a colour scheme",
+ await page.evaluate(() => {
+ const html = document.documentElement;
+ return (
+ html.lang === "en" && /variable/.test(html.className) && html.style.colorScheme !== ""
+ );
+ }),
+ );
+
+ // Components: a Server Component rendered the client wrappers; they hydrate and work.
+ await page.goto(`${base}/components`, { waitUntil: "networkidle" });
+ const filled = page.locator(".m3-bui-button--filled").first();
+ check(
+ "filled button paints the primary colour",
+ await filled.evaluate((el) => {
+ const background = getComputedStyle(el).backgroundColor;
+ const primary = getComputedStyle(document.documentElement)
+ .getPropertyValue("--md-sys-color-primary")
+ .trim();
+ return background !== "rgba(0, 0, 0, 0)" && primary !== "";
+ }),
+ );
+ check(
+ "the button is a full pill (9999px radius token)",
+ await filled.evaluate((el) => parseFloat(getComputedStyle(el).borderTopLeftRadius) > 100),
+ );
+
+ await page.locator('[role="switch"].m3-bui-switch').first().click({ force: true });
+ check(
+ "a click flips the switch (Base UI data-checked)",
+ (await page.locator('[role="switch"].m3-bui-switch').first().getAttribute("data-checked")) ===
+ null,
+ );
+
+ await page.getByText("Open dialog").first().click({ force: true });
+ await page
+ .locator(".m3-bui-dialog__popup")
+ .first()
+ .waitFor({ state: "visible", timeout: 5000 });
+ check(
+ "the dialog opens with its M3 popup, backdrop and title",
+ (await page.locator(".m3-bui-dialog__backdrop").count()) === 1 &&
+ ((await page.locator(".m3-bui-dialog__title").textContent()) ?? "").startsWith("Discard"),
+ );
+ await page.keyboard.press("Escape");
+ await page.waitForTimeout(500);
+ check("Escape closes the dialog", (await page.locator(".m3-bui-dialog__popup").count()) === 0);
+
+ await page.locator(".m3-bui-select__trigger").first().click({ force: true });
+ await page
+ .locator(".m3-bui-select__popup")
+ .first()
+ .waitFor({ state: "visible", timeout: 5000 });
+ check(
+ "the select lists its items in the M3 popup",
+ (await page.locator(".m3-bui-select__item").count()) === 3,
+ );
+ await page.keyboard.press("Escape");
+
+ await page.locator('[role="tab"]').nth(1).click({ force: true });
+ check(
+ "tabs switch panels",
+ (
+ (await page.locator('[role="tabpanel"]:not([hidden])').first().textContent()) ?? ""
+ ).includes("Second"),
+ );
+
+ await page.locator('[data-testid="notify"]').click({ force: true });
+ await page.locator(".m3-bui-toast__title").first().waitFor({ state: "visible", timeout: 5000 });
+ check(
+ "the toast manager shows a snackbar",
+ ((await page.locator(".m3-bui-toast__title").first().textContent()) ?? "") === "Saved",
+ );
+
+ // Theme round trip.
+ await page.goto(`${base}/theme`, { waitUntil: "networkidle" });
+ check(
+ "the server saw the cookie proxy.ts wrote",
+ ((await page.locator('[data-testid="server-mode"]').textContent()) ?? "").endsWith("system"),
+ );
+ const toggle = page.locator('[data-testid="theme-toggle"]');
+ await toggle.click({ force: true }); // system -> light
+ await page.waitForTimeout(200);
+ const cookies = await page.context().cookies();
+ check(
+ "the toggle writes the cookie and the .light class",
+ cookies.some((c) => c.name === "m3-theme" && c.value === "light") &&
+ (await page.evaluate(() => document.documentElement.classList.contains("light"))),
+ );
+ await page.reload({ waitUntil: "networkidle" });
+ check(
+ "a reload renders the saved preference on the server",
+ ((await page.locator('[data-testid="server-mode"]').textContent()) ?? "").endsWith("light"),
+ );
+ await toggle.click({ force: true }); // light -> dark
+ await page.waitForTimeout(200);
+ check(
+ "dark sets .dark and the dark colour roles",
+ await page.evaluate(
+ () =>
+ document.documentElement.classList.contains("dark") &&
+ getComputedStyle(document.body).backgroundColor !== "rgb(255, 255, 255)",
+ ),
+ );
+
+ check(
+ "no console errors, page errors or hydration warnings",
+ problems.length === 0,
+ problems.slice(0, 3).join(" | "),
+ );
+ } finally {
+ await browser.close();
+ }
+ return checks;
+}
+
+if (import.meta.main) {
+ const args = process.argv.slice(2);
+ const base = args[args.indexOf("--base") + 1] ?? "http://localhost:3218";
+ const checks = await browserCheck(args.includes("--base") ? base : "http://localhost:3218");
+ for (const c of checks)
+ console.log(`${c.ok ? "ok " : "FAIL"} ${c.name}${c.detail ? `: ${c.detail}` : ""}`);
+ const failed = checks.filter((c) => !c.ok).length;
+ console.log(
+ failed === 0 ? `pass ${checks.length} browser checks` : `FAIL ${failed} of ${checks.length}`,
+ );
+ process.exit(failed === 0 ? 0 : 1);
+}
diff --git a/scripts/bundle-report.ts b/scripts/bundle-report.ts
new file mode 100644
index 0000000..b3fd5f3
--- /dev/null
+++ b/scripts/bundle-report.ts
@@ -0,0 +1,347 @@
+#!/usr/bin/env bun
+// SPDX-License-Identifier: Apache-2.0
+// Bundle-size report of the Base UI + Tailwind v4 + Next 16 layer (m3-baseui, m3-tailwind, m3-next).
+//
+// bun scripts/bundle-report.ts [--json] [--out file.json] [--before file.json] [--write file.md]
+//
+// Every number is measured with Bun's bundler (browser target, minified, react external) or with
+// @tailwindcss/node plus Bun's CSS minifier, so a re-run on another commit is comparable.
+// `--before` embeds the numbers of an earlier run next to the current ones; `--write` renders the Markdown report.
+/* oxlint-disable eslint/no-await-in-loop -- measurements run one after another so they never compete for the machine */
+import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
+import { join, resolve } from "node:path";
+import { gzipSync } from "node:zlib";
+
+const PACKAGES = resolve(import.meta.dir, "../..");
+
+/** `@tailwindcss/node` as m3-tailwind resolves it, so the report measures the preset against its own engine. */
+const { compile } = (await import(
+ Bun.resolveSync("@tailwindcss/node", join(PACKAGES, "m3-tailwind"))
+)) as {
+ compile(
+ css: string,
+ options: { base: string; onDependency(path: string): void },
+ ): Promise<{ build(candidates: string[]): string }>;
+};
+
+export interface Measure {
+ bytes: number;
+ gzip: number;
+}
+export type Report = Record;
+
+const gz = (text: string | Uint8Array): number => gzipSync(text, { level: 9 }).length;
+const size = (text: string): Measure => ({ bytes: Buffer.byteLength(text), gzip: gz(text) });
+
+/** Entry file of a workspace package: the `.` export, falling back to `main`. */
+function entryOf(pkg: string): string {
+ const dir = join(PACKAGES, pkg);
+ const manifest = JSON.parse(readFileSync(join(dir, "package.json"), "utf8")) as {
+ exports?: Record;
+ main?: string;
+ };
+ const root = manifest.exports?.["."];
+ const file =
+ typeof root === "string"
+ ? root
+ : typeof root === "object" && root !== null
+ ? ((root as Record)["import"] ??
+ (root as Record)["default"])
+ : manifest.main;
+ if (file === undefined) throw new Error(`${pkg}: no entry`);
+ return resolve(dir, file);
+}
+
+/** Minified browser bundle of `code` (react and react-dom stay external, as in an app that dedupes them). */
+async function bundle(
+ code: string,
+ external: string[] = ["react", "react-dom", "react/jsx-runtime"],
+): Promise {
+ const entry = join(PACKAGES, "m3-next", "__bundle_entry__.ts");
+ const result = await Bun.build({
+ entrypoints: [entry],
+ files: { [entry]: code },
+ target: "browser",
+ format: "esm",
+ minify: true,
+ external,
+ define: { "process.env.NODE_ENV": '"production"' },
+ });
+ if (!result.success) throw new Error(result.logs.map(String).join("\n"));
+ const text = await result.outputs[0]!.text();
+ return size(text);
+}
+
+/**
+ * Like {@link bundle} with code splitting: the entry chunk (what a page downloads up front) and the dynamic-import
+ * chunks together (what is fetched on demand).
+ */
+async function bundleSplit(
+ code: string,
+ external: string[],
+): Promise<{ entry: Measure; lazy: Measure }> {
+ const entry = join(PACKAGES, "m3-next", "__bundle_entry__.ts");
+ const result = await Bun.build({
+ entrypoints: [entry],
+ files: { [entry]: code },
+ target: "browser",
+ format: "esm",
+ minify: true,
+ splitting: true,
+ external,
+ define: { "process.env.NODE_ENV": '"production"' },
+ });
+ if (!result.success) throw new Error(result.logs.map(String).join("\n"));
+ let first = "";
+ let lazy = "";
+ for (const output of result.outputs) {
+ if (output.kind === "entry-point") first = await output.text();
+ else if (output.kind === "chunk") lazy += await output.text();
+ }
+ return { entry: size(first), lazy: size(lazy) };
+}
+
+/** Minified CSS through Bun's CSS pipeline. */
+async function minifyCss(css: string): Promise {
+ const entry = join(PACKAGES, "m3-next", "__bundle_entry__.css");
+ const result = await Bun.build({ entrypoints: [entry], files: { [entry]: css }, minify: true });
+ if (!result.success) throw new Error(result.logs.map(String).join("\n"));
+ return size(await result.outputs[0]!.text());
+}
+
+/** The classes of a typical M3 screen: every family of the preset appears at least once. */
+export const TYPICAL_CANDIDATES = [
+ "bg-primary",
+ "text-on-primary",
+ "bg-surface-container",
+ "text-on-surface",
+ "text-on-surface-variant",
+ "bg-secondary-container",
+ "text-on-secondary-container",
+ "bg-error",
+ "border-outline-variant",
+ "rounded-full",
+ "rounded-large",
+ "rounded-extra-large",
+ "text-label-large",
+ "text-title-medium",
+ "text-body-large",
+ "text-headline-small-emphasized",
+ "font-brand",
+ "shadow-elevation-2",
+ "ease-emphasized",
+ "duration-300",
+ "p-4",
+ "flex",
+ "gap-2",
+ "hover:bg-on-surface/8",
+ "md:grid",
+];
+
+/** The classes only the extended preset has: shape, state layer, typescale, spring motion, adaptive and Base UI variants. */
+export const M3_CANDIDATES = [
+ "shape-large-top",
+ "state-layer",
+ "state-layer-primary",
+ "typescale-label-large",
+ "motion-expressive-default-spatial",
+ "compact:hidden",
+ "h-medium:flex",
+ "m3-open:bg-primary",
+ "m3-on:text-on-primary",
+ "dark:bg-surface",
+];
+
+async function tailwindCss(): Promise<{ preset: Measure; app: Measure; m3: Measure }> {
+ const base = join(PACKAGES, "m3-tailwind");
+ const input = [
+ '@import "tailwindcss/theme.css" layer(theme);',
+ '@import "tailwindcss/utilities.css" layer(utilities);',
+ '@import "@aphrody/m3-tailwind/preset.css";',
+ ].join("\n");
+ const compiler = await compile(input, { base, onDependency() {} });
+ const app = await minifyCss(compiler.build(TYPICAL_CANDIDATES));
+ const m3 = await minifyCss(compiler.build([...TYPICAL_CANDIDATES, ...M3_CANDIDATES]));
+ const preset = await minifyCss(readFileSync(join(base, "preset.css"), "utf8"));
+ return { preset, app, m3 };
+}
+
+export async function measure(): Promise {
+ const baseui = entryOf("m3-baseui");
+ const nextClient = join(PACKAGES, "m3-next/src/client.tsx");
+ const nextIndex = join(PACKAGES, "m3-next/src/index.ts");
+ const report: Report = {};
+
+ report["baseui.js.button"] = await bundle(
+ `import { M3Button } from ${JSON.stringify(baseui)}; globalThis.x = M3Button;`,
+ );
+ report["baseui.js.button+switch+tabs"] = await bundle(
+ `import { M3Button, M3Switch, M3Tabs } from ${JSON.stringify(baseui)}; globalThis.x = [M3Button, M3Switch, M3Tabs.Root];`,
+ );
+ report["baseui.js.everything"] = await bundle(
+ `import * as all from ${JSON.stringify(baseui)}; globalThis.x = all;`,
+ );
+
+ report["baseui.js.dialog"] = await bundle(
+ `import { M3Dialog } from ${JSON.stringify(baseui)}; globalThis.x = [M3Dialog.Root, M3Dialog.Popup, M3Dialog.Title];`,
+ );
+ report["baseui.js.select"] = await bundle(
+ `import { M3Select } from ${JSON.stringify(baseui)}; globalThis.x = [M3Select.Root, M3Select.Popup, M3Select.Item];`,
+ );
+
+ const baseuiDir = join(PACKAGES, "m3-baseui");
+ report["baseui.css.all"] = await minifyCss(
+ `@import ${JSON.stringify(join(baseuiDir, "m3-baseui.css"))};`,
+ );
+ if (existsSync(join(baseuiDir, "css/button.css"))) {
+ report["baseui.css.button"] = await minifyCss(
+ `@import ${JSON.stringify(join(baseuiDir, "css/core.css"))};\n@import ${JSON.stringify(join(baseuiDir, "css/button.css"))};`,
+ );
+ }
+
+ const tw = await tailwindCss();
+ report["tailwind.preset.css"] = tw.preset;
+ report["tailwind.app.css"] = tw.app;
+ report["tailwind.app.m3.css"] = tw.m3;
+
+ const clientExternal = ["react", "react-dom", "react/jsx-runtime", "next", "next/*"];
+ const client = await bundleSplit(
+ `import * as c from ${JSON.stringify(nextClient)}; globalThis.x = c;`,
+ clientExternal,
+ );
+ report["next.client.js"] = client.entry;
+ if (client.lazy.bytes > 0) report["next.client.lazy.js"] = client.lazy;
+ report["next.index.js"] = await bundle(
+ `import * as c from ${JSON.stringify(nextIndex)}; globalThis.x = c;`,
+ ["react", "react-dom", "react/jsx-runtime", "next", "next/*"],
+ );
+ return report;
+}
+
+const LABELS: Record = {
+ "baseui.js.button": "m3-baseui JS, `M3Button` only",
+ "baseui.js.button+switch+tabs": "m3-baseui JS, Button + Switch + Tabs",
+ "baseui.js.everything": "m3-baseui JS, `import *` (whole package)",
+ "baseui.js.dialog": "m3-baseui JS, Dialog (Root, Popup, Title)",
+ "baseui.js.select": "m3-baseui JS, Select (Root, Popup, Item)",
+ "baseui.css.all": "m3-baseui CSS, every component (minified)",
+ "baseui.css.button": "m3-baseui CSS, core + Button only (minified)",
+ "tailwind.preset.css": "m3-tailwind `preset.css` (minified)",
+ "tailwind.app.css": "Tailwind app CSS, 25 typical classes (minified)",
+ "tailwind.app.m3.css": "same 25 classes plus 10 of the new utilities and variants (minified)",
+ "next.client.js": "m3-next client entry JS (theme provider; downloaded up front)",
+ "next.client.lazy.js":
+ "m3-next colour engine chunk (loaded only for a custom seed, variant or contrast)",
+ "next.index.js": "m3-next config/scheme entry JS (server and build time only)",
+};
+
+const delta = (a: number, b: number): string =>
+ a === 0 ? "n/a" : `${(((b - a) / a) * 100).toFixed(1)}%`;
+
+export interface ExampleSize {
+ js: Measure;
+ css: Measure;
+ files: number;
+}
+
+/** Client assets of a built example: `.next/static` (server app) or `out/_next/static` (static export). */
+export function exampleSizes(dir: string): ExampleSize | undefined {
+ const root = [join(dir, "out/_next/static"), join(dir, ".next/static")].find((d) =>
+ existsSync(d),
+ );
+ if (!root) return undefined;
+ let js = Buffer.alloc(0);
+ let css = Buffer.alloc(0);
+ let files = 0;
+ const walkDir = (current: string): void => {
+ for (const entry of readdirSync(current, { withFileTypes: true })) {
+ const full = join(current, entry.name);
+ if (entry.isDirectory()) walkDir(full);
+ else if (entry.name.endsWith(".js")) {
+ js = Buffer.concat([js, readFileSync(full)]);
+ files += 1;
+ } else if (entry.name.endsWith(".css")) {
+ css = Buffer.concat([css, readFileSync(full)]);
+ files += 1;
+ }
+ }
+ };
+ walkDir(root);
+ return {
+ js: { bytes: js.length, gzip: gz(js) },
+ css: { bytes: css.length, gzip: gz(css) },
+ files,
+ };
+}
+
+export function markdown(after: Report, before: Report | undefined, extra: string): string {
+ const rows = Object.keys({ ...before, ...after }).map((key) => {
+ const a = after[key] as Measure | undefined;
+ const b = before?.[key] as Measure | undefined;
+ const cell = (m: Measure | undefined): string => (m ? `${m.bytes} (${m.gzip} gz)` : "n/a");
+ return `| ${LABELS[key] ?? key} | ${cell(b)} | ${cell(a)} | ${a && b ? delta(b.bytes, a.bytes) : "n/a"} |`;
+ });
+ return `
+# M3 Next 16 stack: bundle and build numbers
+
+Generated by \`bun packages/m3-next/scripts/bundle-report.ts --before --write docs/reports/M3-NEXT-BUNDLE.md\`.
+Bytes are minified output (gzip level 9 in parentheses), Bun ${Bun.version}, browser target, \`react\` and \`react-dom\`
+external, Base UI bundled. "Before" is the tree at commit \`0c2cc62\` (the barrel with \`export * as BaseUI\`).
+
+| Measure | Before (bytes) | After (bytes) | Delta |
+| --- | --- | --- | --- |
+${rows.join("\n")}
+
+${extra}`;
+}
+
+if (import.meta.main) {
+ const args = process.argv.slice(2);
+ const flag = (name: string): string | undefined => {
+ const i = args.indexOf(name);
+ return i >= 0 ? args[i + 1] : undefined;
+ };
+ const after = await measure();
+ const out = flag("--out");
+ if (out) writeFileSync(out, JSON.stringify(after, null, 2) + "\n");
+ const beforePath = flag("--before");
+ const before = beforePath ? (JSON.parse(readFileSync(beforePath, "utf8")) as Report) : undefined;
+ const mdPath = flag("--write");
+ const extraPath = flag("--extra");
+ let extra = extraPath ? readFileSync(extraPath, "utf8") : "";
+ if (args.includes("--examples")) {
+ const examples = join(PACKAGES, "m3-next/examples");
+ const rows: string[] = [];
+ for (const name of ["app", "tauri"]) {
+ const sizes = exampleSizes(join(examples, name));
+ if (!sizes) continue;
+ const bench = flag(`--bench-${name}`);
+ const times =
+ bench && existsSync(bench)
+ ? (JSON.parse(readFileSync(bench, "utf8")) as {
+ coldMedian: number;
+ warmMedian: number;
+ cold: number[];
+ warm: number[];
+ })
+ : undefined;
+ rows.push(
+ `| ${name === "app" ? "examples/app (server: Cache Components, proxy.ts, React Compiler)" : "examples/tauri (static export)"} | ${sizes.js.bytes} (${sizes.js.gzip} gz) | ${sizes.css.bytes} (${sizes.css.gzip} gz) | ${times ? `${times.coldMedian} ms (${times.cold.join(", ")})` : "n/a"} | ${times ? `${times.warmMedian} ms (${times.warm.join(", ")})` : "n/a"} |`,
+ );
+ }
+ if (rows.length > 0)
+ extra += `## Example apps, built and measured through yolo
+
+\`yolo web build\` on Bun ${Bun.version}: Next 16.3.8 with Turbopack, Turbopack workers on Bun too (the \`m3-next\` runner puts a Bun \`node\` first on PATH). Client assets are the files under \`static/\` (JS and CSS, unminified sum of every chunk the build emits, all routes). Cold removes \`.next\` (and \`out\`); warm keeps the build cache. Times are the wall time of the yolo command, median of three (runs listed), on a shared WSL machine.
+
+| App | Client JS | Client CSS | Cold build | Warm build |
+| --- | --- | --- | --- | --- |
+${rows.join("\n")}
+`;
+ }
+ if (mdPath) {
+ writeFileSync(mdPath, markdown(after, before, extra));
+ console.log(`wrote ${mdPath}`);
+ }
+ if (args.includes("--json") || (!mdPath && !out)) console.log(JSON.stringify(after, null, 2));
+}
diff --git a/scripts/yolo-example.ts b/scripts/yolo-example.ts
new file mode 100644
index 0000000..421c437
--- /dev/null
+++ b/scripts/yolo-example.ts
@@ -0,0 +1,331 @@
+#!/usr/bin/env bun
+// SPDX-License-Identifier: Apache-2.0
+// Build, serve and check the Next 16 examples through the yolo CLI.
+//
+// bun scripts/yolo-example.ts [--example app|tauri] [--json]
+//
+// yolo's `web` verb runs `bun run build|dev` in `$YOLO_HOME/m3/packages/app-ui`. This script points YOLO_HOME at a directory
+// whose `m3/packages/app-ui` is the example (a symlink under the temp dir; nothing is written into this repository) and runs
+// `bun $YOLO_ROOT/packages/engine/yolo/src/index.ts web build|dev` (YOLO_ROOT: this Aphrody checkout by default). The example
+// scripts run `yolo m3 next`, so Next, Turbopack workers included, runs on Bun; no Node is involved.
+//
+// build yolo web build (cold: `.next` removed first)
+// serve yolo web dev (dev server, curl every route)
+// start yolo web build, then `bun run start` (production server, curl every route)
+// export yolo tauri build (the tauri example: static export in out/)
+// bench cold and warm yolo web build, three runs each
+// check build, serve, start, export in turn
+/* oxlint-disable eslint/no-await-in-loop -- polling and benchmark runs are sequential on purpose */
+import {
+ existsSync,
+ lstatSync,
+ mkdirSync,
+ readFileSync,
+ readlinkSync,
+ rmSync,
+ symlinkSync,
+ unlinkSync,
+} from "node:fs";
+import { tmpdir } from "node:os";
+import { join, resolve } from "node:path";
+
+const PKG = resolve(import.meta.dir, "..");
+export const YOLO_ROOT = process.env["YOLO_ROOT"] ?? resolve(PKG, "../../..");
+export const YOLO_CLI = join(YOLO_ROOT, "packages/engine/yolo/src/index.ts");
+
+export type Example = "app" | "tauri";
+export const exampleDir = (name: Example): string => join(PKG, "examples", name);
+
+/** Routes that must answer, with the status each returns. */
+export const ROUTES: Record = {
+ app: [
+ ["/", 200],
+ ["/components", 200],
+ ["/theme", 200],
+ ["/api/health", 200],
+ ["/missing", 404],
+ ],
+ tauri: [
+ ["/", 200],
+ ["/about/", 200],
+ ],
+};
+
+/** A YOLO_HOME whose `m3/packages/app-ui` (the `yolo web` app directory) is the example; created under the temp dir, repaired when it points elsewhere. */
+export function yoloHome(
+ example: Example,
+ root: string = join(tmpdir(), `m3-next-yolo-${process.getuid?.() ?? 0}`),
+): string {
+ const home = join(root, example);
+ const web = join(home, "m3", "packages", "app-ui");
+ mkdirSync(join(home, "m3", "packages"), { recursive: true });
+ const target = exampleDir(example);
+ try {
+ if (lstatSync(web).isSymbolicLink() && readlinkSync(web) === target) return home;
+ unlinkSync(web);
+ } catch {
+ // Absent: create it below.
+ }
+ symlinkSync(target, web);
+ return home;
+}
+
+/** The yolo invocation: `bun ` with YOLO_HOME at the example. */
+export function yoloCommand(
+ example: Example,
+ ...args: string[]
+): { cmd: string[]; env: Record } {
+ return {
+ cmd: ["bun", YOLO_CLI, ...args],
+ env: { ...process.env, YOLO_HOME: yoloHome(example), NEXT_TELEMETRY_DISABLED: "1" },
+ };
+}
+
+export interface Timed {
+ exitCode: number;
+ ms: number;
+ output: string;
+}
+
+/** Run a command to completion, capturing stdout and stderr. */
+async function run(
+ cmd: string[],
+ env: Record,
+ cwd?: string,
+): Promise {
+ const start = performance.now();
+ const proc = Bun.spawn(cmd, { windowsHide: true, env, cwd, stdout: "pipe", stderr: "pipe" });
+ const [out, err, exitCode] = await Promise.all([
+ new Response(proc.stdout).text(),
+ new Response(proc.stderr).text(),
+ proc.exited,
+ ]);
+ return { exitCode, ms: Math.round(performance.now() - start), output: out + err };
+}
+
+export async function yoloBuild(
+ example: Example,
+ verb: "web" | "tauri" = "web",
+ cold = true,
+): Promise {
+ const dir = exampleDir(example);
+ if (cold)
+ for (const out of [".next", "out"]) rmSync(join(dir, out), { recursive: true, force: true });
+ const { cmd, env } = yoloCommand(example, verb, "build");
+ return run(cmd, env);
+}
+
+/** Wait for the URL to answer, up to `timeoutMs`. */
+async function waitFor(url: string, timeoutMs: number): Promise {
+ const deadline = Date.now() + timeoutMs;
+ while (Date.now() < deadline) {
+ try {
+ if ((await fetch(url, { redirect: "manual" })).status > 0) return true;
+ } catch {
+ // Not up yet.
+ }
+ await Bun.sleep(250);
+ }
+ return false;
+}
+
+export interface RouteResult {
+ route: string;
+ expected: number;
+ status: number;
+ ok: boolean;
+}
+
+async function probe(base: string, routes: [string, number][]): Promise {
+ const out: RouteResult[] = [];
+ for (const [route, expected] of routes) {
+ const status =
+ (await fetch(`${base}${route}`, { redirect: "manual" }).catch(() => undefined))?.status ?? 0;
+ out.push({ route, expected, status, ok: status === expected });
+ }
+ return out;
+}
+
+/** Run a server in its own process group and make sure it is gone afterwards. */
+async function withServer(
+ cmd: string[],
+ env: Record,
+ cwd: string | undefined,
+ url: string,
+ body: () => Promise,
+): Promise {
+ // A leftover server on the port would answer for ours: refuse to start.
+ if (
+ await fetch(url, { redirect: "manual" }).then(
+ () => true,
+ () => false,
+ )
+ )
+ throw new Error(`${url} is already served`);
+ const proc = Bun.spawn(cmd, {
+ windowsHide: true,
+ env,
+ cwd,
+ stdout: "ignore",
+ stderr: "ignore",
+ detached: true,
+ });
+ try {
+ if (!(await waitFor(url, 90_000))) throw new Error(`${url} did not come up`);
+ return await body();
+ } finally {
+ try {
+ process.kill(-proc.pid, "SIGTERM");
+ } catch {
+ proc.kill();
+ }
+ await Promise.race([proc.exited, Bun.sleep(5000)]);
+ try {
+ process.kill(-proc.pid, "SIGKILL");
+ } catch {
+ // Already gone.
+ }
+ }
+}
+
+const PORT_DEV = "3217";
+const PORT_START = "3218";
+
+/** `yolo web dev` serves the example; curl each route. */
+export async function yoloServe(example: Example = "app"): Promise {
+ const { cmd, env } = yoloCommand(example, "web", "dev");
+ const base = `http://localhost:${PORT_DEV}`;
+ return withServer(cmd, { ...env, PORT: PORT_DEV }, undefined, `${base}/`, () =>
+ probe(base, ROUTES[example]),
+ );
+}
+
+/** `yolo web build` then the production server (`bun run start`); curl each route. */
+export async function yoloStart(
+ example: Example = "app",
+): Promise<{ build: Timed; routes: RouteResult[] }> {
+ const build = await yoloBuild(example);
+ if (build.exitCode !== 0) return { build, routes: [] };
+ const base = `http://localhost:${PORT_START}`;
+ const { env } = yoloCommand(example, "web", "build");
+ const routes = await withServer(
+ ["bun", "run", "start"],
+ { ...env, PORT: PORT_START },
+ exampleDir(example),
+ `${base}/`,
+ () => probe(base, ROUTES[example]),
+ );
+ return { build, routes };
+}
+
+/** Median of the runs. */
+const median = (values: number[]): number =>
+ // oxlint-disable-next-line unicorn/no-array-sort -- sorts a copy; ES2022 has no toSorted
+ [...values].sort((a, b) => a - b)[Math.floor(values.length / 2)]!;
+
+/** Cold (`.next` removed) and warm (`.next` kept) `yolo web build` times, `runs` each. */
+export async function yoloBench(
+ example: Example = "app",
+ runs = 3,
+): Promise<{ cold: number[]; warm: number[]; coldMedian: number; warmMedian: number }> {
+ const cold: number[] = [];
+ const warm: number[] = [];
+ for (let i = 0; i < runs; i += 1) {
+ const result = await yoloBuild(example, "web", true);
+ if (result.exitCode !== 0) throw new Error(result.output.slice(-2000));
+ cold.push(result.ms);
+ }
+ for (let i = 0; i < runs; i += 1) {
+ const result = await yoloBuild(example, "web", false);
+ if (result.exitCode !== 0) throw new Error(result.output.slice(-2000));
+ warm.push(result.ms);
+ }
+ return { cold, warm, coldMedian: median(cold), warmMedian: median(warm) };
+}
+
+function report(label: string, results: RouteResult[]): boolean {
+ for (const r of results)
+ console.log(
+ ` ${r.ok ? "ok " : "FAIL"} ${r.route.padEnd(14)} ${r.status} (expected ${r.expected})`,
+ );
+ const ok = results.length > 0 && results.every((r) => r.ok);
+ console.log(`${ok ? "pass" : "FAIL"} ${label}`);
+ return ok;
+}
+
+if (import.meta.main) {
+ const args = process.argv.slice(2);
+ const [task = "check"] = args;
+ const flag = (name: string): string | undefined => {
+ const i = args.indexOf(name);
+ return i >= 0 ? args[i + 1] : undefined;
+ };
+ const example = (flag("--example") ?? "app") as Example;
+ const json = args.includes("--json");
+ if (!existsSync(YOLO_CLI)) {
+ console.error(`yolo CLI not found at ${YOLO_CLI}; set YOLO_ROOT`);
+ process.exit(2);
+ }
+ let ok = true;
+ const doBuild = async (name: Example, verb: "web" | "tauri") => {
+ const result = await yoloBuild(name, verb);
+ console.log(
+ `${result.exitCode === 0 ? "pass" : "FAIL"} yolo ${verb} build (${name}) ${result.ms} ms`,
+ );
+ if (result.exitCode !== 0) console.log(result.output.slice(-1500));
+ return result.exitCode === 0;
+ };
+ const doExport = async () => {
+ if (!(await doBuild("tauri", "tauri"))) return false;
+ const out = join(exampleDir("tauri"), "out");
+ const html = existsSync(join(out, "index.html"))
+ ? readFileSync(join(out, "index.html"), "utf8")
+ : "";
+ const good =
+ html.includes('id="m3-scheme"') &&
+ html.includes("document.cookie.match") &&
+ existsSync(join(out, "about", "index.html"));
+ console.log(
+ `${good ? "pass" : "FAIL"} static export: out/index.html, out/about/index.html, inline scheme and pre-paint script`,
+ );
+ return good;
+ };
+ switch (task) {
+ case "build":
+ ok = await doBuild(example, "web");
+ break;
+ case "serve":
+ ok = report(`yolo web dev (${example})`, await yoloServe(example));
+ break;
+ case "start": {
+ const { build, routes } = await yoloStart(example);
+ console.log(`yolo web build (${example}) ${build.ms} ms, exit ${build.exitCode}`);
+ ok = report(`next start (${example})`, routes);
+ break;
+ }
+ case "export":
+ ok = await doExport();
+ break;
+ case "bench": {
+ const bench = await yoloBench(example);
+ console.log(
+ json
+ ? JSON.stringify(bench)
+ : `cold ${bench.cold.join(", ")} ms (median ${bench.coldMedian}); warm ${bench.warm.join(", ")} ms (median ${bench.warmMedian})`,
+ );
+ break;
+ }
+ case "check":
+ ok = (await doBuild("app", "web")) && report("yolo web dev (app)", await yoloServe("app"));
+ if (ok) {
+ const { routes } = await yoloStart("app");
+ ok = report("next start (app)", routes);
+ }
+ ok = (await doExport()) && ok;
+ break;
+ default:
+ console.error(`unknown task "${task}"`);
+ process.exit(2);
+ }
+ process.exit(ok ? 0 : 1);
+}
diff --git a/src/client.tsx b/src/client.tsx
new file mode 100644
index 0000000..1a0551e
--- /dev/null
+++ b/src/client.tsx
@@ -0,0 +1,240 @@
+// SPDX-License-Identifier: Apache-2.0
+// Client boundary of @aphrody/m3-next (the build prepends the `'use client'` directive to dist/client.js; Bun keeps a
+// source directive after the hoisted imports, where Next rejects it): the theme provider. It carries only the mode (light, dark, system), the cookie
+// bridge to the server render and the class and `color-scheme` on : a few hundred bytes. The Material You engine
+// (material-color-utilities, tens of KB) is imported on demand, and only when the seed, variant or contrast differ from
+// the scheme the server already inlined (M3Html does), so a typical page never downloads it.
+import {
+ createContext,
+ useCallback,
+ useContext,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+ useSyncExternalStore,
+ type ReactNode,
+} from "react";
+import type { SchemeVariant } from "@aphrody/m3-tokens/dynamic-color";
+import {
+ M3_BASELINE_SEED,
+ M3_THEME_COOKIE,
+ themeCookie,
+ validateThemeCookieName,
+ type M3ThemeMode,
+} from "./constants";
+
+export type { SchemeVariant };
+/** `light`, `dark`, or `system` (follow the OS). */
+export type ThemeMode = M3ThemeMode;
+
+export interface M3ThemeContextType {
+ themeMode: ThemeMode;
+ seedColor: string;
+ resolvedTheme: "light" | "dark";
+ contrastLevel: number;
+ variant: SchemeVariant;
+ setThemeMode: (mode: ThemeMode) => void;
+ setSeedColor: (seed: string) => void;
+ setContrastLevel: (level: number) => void;
+ setVariant: (variant: SchemeVariant) => void;
+}
+
+const DARK_QUERY = "(prefers-color-scheme: dark)";
+
+function subscribeToColorScheme(onChange: () => void): () => void {
+ const query = window.matchMedia(DARK_QUERY);
+ query.addEventListener("change", onChange);
+ return () => query.removeEventListener("change", onChange);
+}
+const prefersDark = (): boolean => window.matchMedia(DARK_QUERY).matches;
+const serverPrefersDark = (): boolean => false;
+
+/** The stored preference, or `undefined` when the cookie is absent or invalid. */
+function readStoredMode(cookieName: string): ThemeMode | undefined {
+ const prefix = `${validateThemeCookieName(cookieName)}=`;
+ const value = document.cookie
+ .split(/;\s*/)
+ .find((cookie) => cookie.startsWith(prefix))
+ ?.slice(prefix.length);
+ return value === "light" || value === "dark" || value === "system" ? value : undefined;
+}
+
+const M3ThemeContext = createContext(undefined);
+
+export interface M3ThemeProviderProps {
+ children?: ReactNode;
+ /** The mode the server rendered; with a static shell, the mode used until the cookie is read (default `system`). */
+ defaultThemeMode?: ThemeMode;
+ /** Source colour of the scheme in the document (default `#6750a4`). */
+ defaultSeedColor?: string;
+ defaultVariant?: SchemeVariant;
+ defaultContrastLevel?: number;
+ /**
+ * The scheme of the default seed, variant and contrast is already in the document (M3Html inlines it).
+ * Set `false` when it is not: the roles are then computed in the browser on mount.
+ */
+ schemeInlined?: boolean;
+ /** Cookie that stores the preference (default `m3-theme`). */
+ cookieName?: string;
+ /** Element that receives a custom scheme (default ``). */
+ target?: HTMLElement;
+}
+
+/**
+ * Theme state for the app. Writes the preference cookie, keeps `.dark`, `.light`, `data-m3-theme` and `color-scheme` on
+ * `` in step, and adopts a saved preference after hydration when the server could not read it (static shell).
+ */
+export function M3ThemeProvider({
+ children,
+ defaultThemeMode = "system",
+ defaultSeedColor = M3_BASELINE_SEED,
+ defaultVariant = "tonalSpot",
+ defaultContrastLevel = 0,
+ schemeInlined = true,
+ cookieName = M3_THEME_COOKIE,
+ target,
+}: M3ThemeProviderProps) {
+ const [themeMode, setThemeMode] = useState(defaultThemeMode);
+ const [seedColor, setSeedColor] = useState(defaultSeedColor);
+ const [contrastLevel, setContrastLevel] = useState(defaultContrastLevel);
+ const [variant, setVariant] = useState(defaultVariant);
+ const initial = useRef({
+ seed: defaultSeedColor,
+ variant: defaultVariant,
+ contrast: defaultContrastLevel,
+ });
+
+ const systemDark = useSyncExternalStore(subscribeToColorScheme, prefersDark, serverPrefersDark);
+ const resolvedTheme: "light" | "dark" =
+ themeMode === "system" ? (systemDark ? "dark" : "light") : themeMode;
+
+ // Cookie and . The first run adopts a saved preference the server did not see, and leaves to the
+ // pre-paint script until the adopted mode renders; later runs write the cookie and the classes.
+ const adopted = useRef(false);
+ useEffect(() => {
+ if (!adopted.current) {
+ adopted.current = true;
+ const stored = readStoredMode(cookieName);
+ if (stored !== undefined && stored !== themeMode) {
+ // oxlint-disable-next-line react/set-state-in-effect -- adoption unique au premier montage de la préférence de thème enregistrée dans le cookie (synchronisation avec un stockage externe)
+ setThemeMode(stored);
+ return;
+ }
+ }
+ // The theme cookie is the documented bridge to the server render; Cookie Store is not in every browser yet.
+ // oxlint-disable-next-line unicorn/no-document-cookie
+ document.cookie = themeCookie(themeMode, cookieName);
+ const root = document.documentElement;
+ root.classList.toggle("light", themeMode === "light");
+ root.classList.toggle("dark", resolvedTheme === "dark");
+ root.dataset["m3Theme"] = themeMode;
+ root.style.colorScheme = resolvedTheme;
+ }, [themeMode, resolvedTheme, cookieName]);
+
+ // A scheme other than the inlined one is computed on demand: the engine is a separate chunk.
+ const applied = useRef(false);
+ useEffect(() => {
+ const custom =
+ seedColor !== initial.current.seed ||
+ variant !== initial.current.variant ||
+ contrastLevel !== initial.current.contrast;
+ if (schemeInlined && !custom && !applied.current) return;
+ let cancelled = false;
+ void (async () => {
+ const { applyDynamicColor, clearDynamicColor } =
+ await import("@aphrody/m3-tokens/dynamic-color");
+ if (cancelled) return;
+ const element = target ?? document.documentElement;
+ if (schemeInlined && !custom) {
+ clearDynamicColor(element);
+ applied.current = false;
+ return;
+ }
+ applyDynamicColor(seedColor, {
+ dark: resolvedTheme === "dark",
+ variant,
+ contrastLevel,
+ target: element,
+ });
+ applied.current = true;
+ })();
+ return () => {
+ cancelled = true;
+ };
+ }, [seedColor, variant, contrastLevel, resolvedTheme, schemeInlined, target]);
+
+ const value = useMemo(
+ () => ({
+ themeMode,
+ seedColor,
+ resolvedTheme,
+ contrastLevel,
+ variant,
+ setThemeMode,
+ setSeedColor,
+ setContrastLevel,
+ setVariant,
+ }),
+ [themeMode, seedColor, resolvedTheme, contrastLevel, variant],
+ );
+ return {children} ;
+}
+
+/** The theme state and its setters; throws outside {@link M3ThemeProvider}. */
+export function useM3Theme(): M3ThemeContextType {
+ const context = useContext(M3ThemeContext);
+ if (context === undefined) throw new Error("useM3Theme must be used within an M3ThemeProvider");
+ return context;
+}
+
+export interface M3NextThemeProviderProps {
+ children?: ReactNode;
+ /** The mode the server rendered (from the cookie). */
+ initialMode?: M3ThemeMode;
+ /** Source colour (default `#6750a4`). */
+ seed?: string;
+ variant?: SchemeVariant;
+ contrast?: number;
+ cookieName?: string;
+ /** The server inlined the scheme of `seed` (default `true`; `M3Root` with `inlineScheme={false}` passes `false`). */
+ schemeInlined?: boolean;
+}
+
+/** {@link M3ThemeProvider} with the prop names of `M3Root` (`initialMode`, `seed`, `variant`, `contrast`). */
+export function M3NextThemeProvider({
+ children,
+ initialMode,
+ seed,
+ variant,
+ contrast,
+ cookieName,
+ schemeInlined,
+}: M3NextThemeProviderProps) {
+ return (
+
+ {children}
+
+ );
+}
+
+const CYCLE: Record = { light: "dark", dark: "system", system: "light" };
+
+/** The current mode, the resolved light / dark value and a `light -> dark -> system` cycle. */
+export function useM3ThemeToggle(): {
+ mode: M3ThemeMode;
+ resolved: "light" | "dark";
+ setMode: (mode: M3ThemeMode) => void;
+ toggle: () => void;
+} {
+ const { themeMode, resolvedTheme, setThemeMode } = useM3Theme();
+ const toggle = useCallback(() => setThemeMode(CYCLE[themeMode]), [setThemeMode, themeMode]);
+ return { mode: themeMode, resolved: resolvedTheme, setMode: setThemeMode, toggle };
+}
diff --git a/src/constants.ts b/src/constants.ts
new file mode 100644
index 0000000..052c9f9
--- /dev/null
+++ b/src/constants.ts
@@ -0,0 +1,33 @@
+// SPDX-License-Identifier: Apache-2.0
+// Dependency-free constants shared by every entry (the client bundle imports only these).
+
+/** Theme preference persisted in the cookie: a forced mode or the OS preference. */
+export type M3ThemeMode = "light" | "dark" | "system";
+
+/** Cookie that carries the theme preference between the client toggle and the server render. */
+export const M3_THEME_COOKIE = "m3-theme";
+/** The Material 3 baseline source colour. */
+export const M3_BASELINE_SEED = "#6750a4";
+/** One year, in seconds: the lifetime of the theme cookie. */
+export const M3_THEME_COOKIE_MAX_AGE = 60 * 60 * 24 * 365;
+
+/** Reject names that cannot be represented by a single HTTP cookie. */
+export function validateThemeCookieName(name: string): string {
+ if (!/^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/.test(name)) {
+ throw new Error("M3 theme cookie name must be an HTTP cookie token");
+ }
+ return name;
+}
+
+/** The `Set-Cookie`-style string the client toggle writes. */
+export function themeCookie(mode: M3ThemeMode, name: string = M3_THEME_COOKIE): string {
+ return `${validateThemeCookieName(name)}=${mode}; path=/; max-age=${M3_THEME_COOKIE_MAX_AGE}; samesite=lax`;
+}
+
+/** Parse a cookie value into a theme mode; anything else yields `fallback`. */
+export function parseThemeMode(
+ value: string | undefined | null,
+ fallback: M3ThemeMode = "system",
+): M3ThemeMode {
+ return value === "light" || value === "dark" || value === "system" ? value : fallback;
+}
diff --git a/src/crawl.ts b/src/crawl.ts
new file mode 100644
index 0000000..03aad9d
--- /dev/null
+++ b/src/crawl.ts
@@ -0,0 +1,237 @@
+// SPDX-License-Identifier: Apache-2.0
+// Crawler-facing files for an App Router site: sitemap.xml, robots.txt, llms.txt and a
+// script-safe JSON-LD serializer. Pure functions (no React, no Node, no Next): call them from a
+// route handler (`app/sitemap.xml/route.ts`), from a build script, or from a test.
+//
+// export const GET = () => new Response(m3SitemapXml(routes, "https://example.com"), { headers: { "content-type": "application/xml" } });
+
+const SITEMAP_NS = "http://www.sitemaps.org/schemas/sitemap/0.9";
+/** The Sitemaps protocol limit for one file (a sitemap index is needed beyond it). */
+export const M3_SITEMAP_MAX_URLS = 50_000;
+
+export type M3ChangeFrequency =
+ | "always"
+ | "hourly"
+ | "daily"
+ | "weekly"
+ | "monthly"
+ | "yearly"
+ | "never";
+
+export interface M3Route {
+ /** Path (`/docs`), or an absolute URL on the same origin. */
+ path: string;
+ /** `YYYY-MM-DD` or a full W3C datetime; a `Date` is serialized to ISO 8601. */
+ lastmod?: string | Date;
+ changefreq?: M3ChangeFrequency;
+ /** 0 to 1 (serialized with one decimal). */
+ priority?: number;
+}
+
+function xmlEscape(value: string): string {
+ return value
+ .replace(/&/g, "&")
+ .replace(//g, ">")
+ .replace(/"/g, """)
+ .replace(/'/g, "'");
+}
+
+function parseOrigin(origin: string): URL {
+ let url: URL;
+ try {
+ url = new URL(origin);
+ } catch {
+ throw new TypeError(`origin ${JSON.stringify(origin)} is not an absolute URL`);
+ }
+ if (url.protocol !== "http:" && url.protocol !== "https:")
+ throw new TypeError(`origin ${origin} must be http or https`);
+ return url;
+}
+
+/** Absolute URL of a route on `origin`; throws when the route points at another origin. */
+export function m3AbsoluteUrl(path: string, origin: string): string {
+ const base = parseOrigin(origin);
+ const url = new URL(path, base);
+ if (url.origin !== base.origin)
+ throw new RangeError(`route ${path} is not on origin ${base.origin}`);
+ url.hash = "";
+ return url.href;
+}
+
+function singleLine(value: string, what: string): string {
+ if (/[\r\n]/.test(value)) throw new TypeError(`${what} must not contain a line break`);
+ return value.trim();
+}
+
+function lastmodValue(value: string | Date): string {
+ if (value instanceof Date) {
+ if (Number.isNaN(value.getTime())) throw new RangeError("lastmod is an invalid Date");
+ return value.toISOString();
+ }
+ if (!/^\d{4}-\d{2}-\d{2}(T[\d:.]+(Z|[+-]\d{2}:\d{2}))?$/.test(value))
+ throw new RangeError(`lastmod ${JSON.stringify(value)} is not a W3C datetime`);
+ return value;
+}
+
+/**
+ * A Sitemaps 0.9 document. Routes are resolved against `origin`, de-duplicated by absolute URL
+ * (the last definition wins) and sorted by URL so the output is stable whatever the input order.
+ */
+export function m3SitemapXml(routes: readonly (string | M3Route)[], origin: string): string {
+ const entries = new Map();
+ for (const route of routes) {
+ const item = typeof route === "string" ? { path: route } : route;
+ entries.set(m3AbsoluteUrl(item.path, origin), item);
+ }
+ if (entries.size > M3_SITEMAP_MAX_URLS)
+ throw new RangeError(
+ `a sitemap holds at most ${M3_SITEMAP_MAX_URLS} URLs, got ${entries.size}`,
+ );
+ const urls = [...entries]
+ // oxlint-disable-next-line unicorn/no-array-sort -- Sort the owned copy without requiring ES2023 in consumers.
+ .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
+ .map(([loc, route]) => {
+ const lines = [` ${xmlEscape(loc)} `];
+ if (route.lastmod !== undefined)
+ lines.push(` ${lastmodValue(route.lastmod)} `);
+ if (route.changefreq !== undefined)
+ lines.push(` ${route.changefreq} `);
+ if (route.priority !== undefined) {
+ if (!(route.priority >= 0 && route.priority <= 1))
+ throw new RangeError(`priority ${route.priority} is outside 0..1`);
+ lines.push(` ${route.priority.toFixed(1)} `);
+ }
+ return ` \n${lines.join("\n")}\n `;
+ });
+ return `\n\n${urls.join("\n")}${urls.length ? "\n" : ""} \n`;
+}
+
+export interface M3RobotsRule {
+ userAgent: string | readonly string[];
+ allow?: readonly string[];
+ /** An empty string allows everything for the group. */
+ disallow?: readonly string[];
+ /** Seconds between requests (honoured by some crawlers, ignored by Google). */
+ crawlDelay?: number;
+}
+
+export interface M3RobotsOptions {
+ origin: string;
+ /** Default: every crawler may fetch everything. */
+ rules?: readonly M3RobotsRule[];
+ /** Sitemap paths or absolute URLs; default `["/sitemap.xml"]`. Pass `[]` for none. */
+ sitemaps?: readonly string[];
+}
+
+/** A robots.txt (RFC 9309) with one group per rule and absolute `Sitemap:` lines. */
+export function m3RobotsTxt(options: M3RobotsOptions): string {
+ const rules = options.rules ?? [{ userAgent: "*", allow: ["/"] }];
+ const groups = rules.map((rule) => {
+ const agents = (
+ typeof rule.userAgent === "string" ? [rule.userAgent] : [...rule.userAgent]
+ ).map((agent) => singleLine(agent, "user agent"));
+ if (agents.length === 0 || agents.some((agent) => agent === ""))
+ throw new TypeError("a robots.txt group needs at least one non-empty user agent");
+ const lines = agents.map((agent) => `User-agent: ${agent}`);
+ for (const path of rule.allow ?? []) lines.push(`Allow: ${singleLine(path, "Allow path")}`);
+ for (const path of rule.disallow ?? [])
+ lines.push(`Disallow: ${singleLine(path, "Disallow path")}`);
+ if (rule.crawlDelay !== undefined) {
+ if (!(rule.crawlDelay >= 0))
+ throw new RangeError(`crawlDelay ${rule.crawlDelay} must be >= 0`);
+ lines.push(`Crawl-delay: ${rule.crawlDelay}`);
+ }
+ return lines.join("\n");
+ });
+ const sitemaps = (options.sitemaps ?? ["/sitemap.xml"]).map(
+ (path) => `Sitemap: ${m3AbsoluteUrl(path, options.origin)}`,
+ );
+ return `${[...groups, ...(sitemaps.length ? [sitemaps.join("\n")] : [])].join("\n\n")}\n`;
+}
+
+export interface M3LlmsLink {
+ title: string;
+ /** Path on `origin`, or an absolute URL (any origin). */
+ path: string;
+ /** Short description after the link. */
+ notes?: string;
+}
+
+export interface M3LlmsSection {
+ title: string;
+ links: readonly M3LlmsLink[];
+ /** Emitted under the reserved `## Optional` heading (readers may skip it for a shorter context). */
+ optional?: boolean;
+}
+
+export interface M3LlmsOptions {
+ origin: string;
+ /** The H1: the only required part of the format. */
+ name: string;
+ /** One-paragraph summary, emitted as a blockquote. */
+ summary?: string;
+ /** Free paragraphs after the summary (no headings). */
+ details?: string | readonly string[];
+ sections?: readonly M3LlmsSection[];
+}
+
+const markdownText = (value: string) =>
+ singleLine(value, "llms.txt text").replace(/([[\]\\])/g, "\\$1");
+
+/** An `/llms.txt` following https://llmstxt.org: H1, blockquote summary, details, then `##` link lists. */
+export function m3LlmsTxt(options: M3LlmsOptions): string {
+ const name = singleLine(options.name, "name");
+ if (name === "") throw new TypeError("llms.txt needs a non-empty name");
+ const blocks = [`# ${name}`];
+ if (options.summary)
+ blocks.push(
+ options.summary
+ .split(/\r?\n/)
+ .map((line) => `> ${line.trim()}`)
+ .join("\n"),
+ );
+ const details =
+ typeof options.details === "string" ? [options.details] : [...(options.details ?? [])];
+ for (const paragraph of details) {
+ if (/^\s*#/m.test(paragraph)) throw new TypeError("llms.txt details must not contain headings");
+ blocks.push(paragraph.trim());
+ }
+ const sections = options.sections ?? [];
+ const link = (item: M3LlmsLink) => {
+ const url = /^[a-z][a-z0-9+.-]*:/i.test(item.path)
+ ? new URL(item.path).href
+ : m3AbsoluteUrl(item.path, options.origin);
+ return `- [${markdownText(item.title)}](${url})${item.notes ? `: ${singleLine(item.notes, "notes")}` : ""}`;
+ };
+ const ordered = [
+ ...sections.filter((section) => !section.optional),
+ ...sections.filter((section) => section.optional),
+ ];
+ const optional = ordered.filter((section) => section.optional);
+ for (const section of ordered.filter((s) => !s.optional))
+ blocks.push(`## ${markdownText(section.title)}\n\n${section.links.map(link).join("\n")}`);
+ if (optional.length > 0)
+ blocks.push(
+ `## Optional\n\n${optional
+ .flatMap((section) => section.links)
+ .map(link)
+ .join("\n")}`,
+ );
+ return `${blocks.join("\n\n")}\n`;
+}
+
+/**
+ * JSON for an inline `", "with space"]) {
+ expect(() => themeCookie("dark", name)).toThrow("HTTP cookie token");
+ expect(() => colorSchemeScript(name)).toThrow("HTTP cookie token");
+ }
+ });
+});
diff --git a/test/crawl.test.ts b/test/crawl.test.ts
new file mode 100644
index 0000000..1c05744
--- /dev/null
+++ b/test/crawl.test.ts
@@ -0,0 +1,135 @@
+// SPDX-License-Identifier: Apache-2.0
+import { describe, expect, test } from "bun:test";
+import {
+ M3_SITEMAP_MAX_URLS,
+ m3AbsoluteUrl,
+ m3JsonLd,
+ m3LlmsTxt,
+ m3RobotsTxt,
+ m3SitemapXml,
+} from "../src/crawl";
+
+const origin = "https://example.com";
+
+describe("m3SitemapXml", () => {
+ test("resolves, de-duplicates, sorts and escapes", () => {
+ const xml = m3SitemapXml(
+ [
+ "/b",
+ "/a?x=1&y=2",
+ { path: "/b", lastmod: "2026-10-01", changefreq: "weekly", priority: 0.8 },
+ "https://example.com/c#frag",
+ ],
+ origin,
+ );
+ expect(
+ xml.startsWith(
+ '\n',
+ ),
+ ).toBe(true);
+ const locs = [...xml.matchAll(/([^<]*)<\/loc>/g)].map((m) => m[1]);
+ expect(locs).toEqual([
+ "https://example.com/a?x=1&y=2",
+ "https://example.com/b",
+ "https://example.com/c",
+ ]);
+ expect(xml).toContain("2026-10-01 ");
+ expect(xml).toContain("0.8 ");
+ expect(m3SitemapXml(["/z", "/a"], origin)).toBe(m3SitemapXml(["/a", "/z"], origin));
+ });
+
+ test("rejects foreign origins, bad dates, bad priorities and oversized lists", () => {
+ expect(() => m3SitemapXml(["https://other.com/x"], origin)).toThrow(RangeError);
+ expect(() => m3SitemapXml([{ path: "/a", lastmod: "yesterday" }], origin)).toThrow(RangeError);
+ expect(() => m3SitemapXml([{ path: "/a", priority: 2 }], origin)).toThrow(RangeError);
+ expect(() =>
+ m3SitemapXml(
+ Array.from({ length: M3_SITEMAP_MAX_URLS + 1 }, (_, i) => `/p${i}`),
+ origin,
+ ),
+ ).toThrow(RangeError);
+ expect(() => m3AbsoluteUrl("/a", "ftp://x")).toThrow(TypeError);
+ });
+
+ test("an empty route list is still a valid urlset", () => {
+ expect(m3SitemapXml([], origin)).toContain(" {
+ test("defaults allow everything and reference the sitemap", () => {
+ expect(m3RobotsTxt({ origin })).toBe(
+ "User-agent: *\nAllow: /\n\nSitemap: https://example.com/sitemap.xml\n",
+ );
+ });
+
+ test("groups, crawl delay and custom sitemaps", () => {
+ const txt = m3RobotsTxt({
+ origin,
+ rules: [
+ { userAgent: "*", disallow: ["/private/"] },
+ { userAgent: ["GPTBot", "ClaudeBot"], disallow: ["/"], crawlDelay: 10 },
+ ],
+ sitemaps: ["/sitemap.xml", "https://example.com/news.xml"],
+ });
+ expect(txt).toContain("User-agent: *\nDisallow: /private/\n");
+ expect(txt).toContain(
+ "User-agent: GPTBot\nUser-agent: ClaudeBot\nDisallow: /\nCrawl-delay: 10",
+ );
+ expect(txt.trimEnd().endsWith("Sitemap: https://example.com/news.xml")).toBe(true);
+ expect(m3RobotsTxt({ origin, sitemaps: [] })).not.toContain("Sitemap:");
+ });
+
+ test("rejects header injection", () => {
+ expect(() =>
+ m3RobotsTxt({ origin, rules: [{ userAgent: "*", disallow: ["/a\nAllow: /"] }] }),
+ ).toThrow(TypeError);
+ expect(() => m3RobotsTxt({ origin, rules: [{ userAgent: [] }] })).toThrow(TypeError);
+ });
+});
+
+describe("m3LlmsTxt (llmstxt.org)", () => {
+ test("H1, blockquote summary, details, sections and Optional last", () => {
+ const txt = m3LlmsTxt({
+ origin,
+ name: "Example",
+ summary: "One line summary.",
+ details: ["First paragraph.", "Second paragraph."],
+ sections: [
+ { title: "Extras", links: [{ title: "Changelog", path: "/changelog" }], optional: true },
+ {
+ title: "Docs",
+ links: [
+ { title: "Guide [v2]", path: "/docs/guide", notes: "Start here" },
+ { title: "API", path: "https://api.example.org/ref" },
+ ],
+ },
+ ],
+ });
+ expect(txt).toBe(
+ [
+ "# Example",
+ "> One line summary.",
+ "First paragraph.",
+ "Second paragraph.",
+ "## Docs\n\n- [Guide \\[v2\\]](https://example.com/docs/guide): Start here\n- [API](https://api.example.org/ref)",
+ "## Optional\n\n- [Changelog](https://example.com/changelog)",
+ ].join("\n\n") + "\n",
+ );
+ });
+
+ test("requires a name and forbids headings in details", () => {
+ expect(() => m3LlmsTxt({ origin, name: " " })).toThrow(TypeError);
+ expect(() => m3LlmsTxt({ origin, name: "X", details: "# not allowed" })).toThrow(TypeError);
+ expect(m3LlmsTxt({ origin, name: "X" })).toBe("# X\n");
+ });
+});
+
+describe("m3JsonLd", () => {
+ test("escapes markup so the payload cannot close the script tag", () => {
+ const json = m3JsonLd({ name: "&", sep: "\u2028" });
+ expect(json).not.toMatch(/[<>&\u2028]/);
+ expect(JSON.parse(json)).toEqual({ name: "&", sep: "\u2028" });
+ expect(() => m3JsonLd(undefined)).toThrow(TypeError);
+ });
+});
diff --git a/test/m3-next.test.tsx b/test/m3-next.test.tsx
new file mode 100644
index 0000000..3e2aa81
--- /dev/null
+++ b/test/m3-next.test.tsx
@@ -0,0 +1,184 @@
+// SPDX-License-Identifier: Apache-2.0
+import { describe, expect, test } from "bun:test";
+import { existsSync, readFileSync } from "node:fs";
+import { createRequire } from "node:module";
+import { join } from "node:path";
+import { renderToString } from "react-dom/server";
+import { schemeFromSeed } from "@aphrody/m3-tokens/dynamic-color";
+import {
+ M3_COLOR_SCHEME_SCRIPT,
+ M3_SERVER_EXTERNAL_PACKAGES,
+ M3_TRANSPILE_PACKAGES,
+ colorSchemeFor,
+ generateM3Metadata,
+ generateM3Viewport,
+ m3SchemeStylesheet,
+ m3ThemeColors,
+ parseThemeMode,
+ rootDeclarations,
+ themeCookie,
+ withM3,
+} from "../src/index";
+import {
+ M3Document,
+ isBunRuntime,
+ m3Css,
+ m3CssResult,
+ m3FfiError,
+ m3SchemeCss,
+} from "../src/server";
+
+const pkgDir = join(import.meta.dir, "..");
+const require = createRequire(join(pkgDir, "package.json"));
+const nativeLib = ["bun_rs.dll", "libbun_rs.so", "libbun_rs.dylib"].some((name) =>
+ existsSync(join(pkgDir, "../../target/release", name)),
+);
+
+describe("withM3", () => {
+ test("adds the M3 packages, keeps user values and strict mode", () => {
+ const config = withM3({
+ transpilePackages: ["my-lib"],
+ reactStrictMode: false,
+ poweredByHeader: false,
+ });
+ expect(config.transpilePackages).toContain("my-lib");
+ for (const name of M3_TRANSPILE_PACKAGES) expect(config.transpilePackages).toContain(name);
+ expect(config.reactStrictMode).toBe(false);
+ expect(config.poweredByHeader).toBe(false);
+ expect(config.serverExternalPackages).toEqual([...M3_SERVER_EXTERNAL_PACKAGES]);
+ expect(config.experimental?.optimizePackageImports).toContain("@aphrody/m3-primitives");
+ });
+
+ test("defaults strict mode on, deduplicates and never lists a package twice", () => {
+ const config = withM3({
+ transpilePackages: ["@aphrody/m3-theme"],
+ serverExternalPackages: ["@aphrody/m3-ai", "sharp"],
+ });
+ expect(config.reactStrictMode).toBe(true);
+ expect(config.transpilePackages!.filter((name) => name === "@aphrody/m3-theme")).toHaveLength(
+ 1,
+ );
+ // A transpiled package can never be external at the same time (Next rejects the overlap).
+ expect(config.serverExternalPackages).toEqual(["sharp", "@aphrody/bun"]);
+ });
+
+ test("merges Turbopack options and the optional root", () => {
+ const config = withM3(
+ { turbopack: { resolveExtensions: [".tsx", ".ts", ".js"] } },
+ { turbopackRoot: "/repo" },
+ );
+ expect(config.turbopack).toEqual({ resolveExtensions: [".tsx", ".ts", ".js"], root: "/repo" });
+ expect(withM3({}).turbopack).toBeUndefined();
+ expect(withM3({}, { optimizePackageImports: false }).experimental).toBeUndefined();
+ });
+});
+
+describe("theme mode", () => {
+ test("parses cookie values", () => {
+ expect(parseThemeMode("dark")).toBe("dark");
+ expect(parseThemeMode("light")).toBe("light");
+ expect(parseThemeMode("bogus")).toBe("system");
+ expect(parseThemeMode(undefined, "dark")).toBe("dark");
+ expect(colorSchemeFor("system")).toBe("light dark");
+ expect(themeCookie("dark")).toBe("m3-theme=dark; path=/; max-age=31536000; samesite=lax");
+ });
+});
+
+describe("metadata and viewport", () => {
+ test("theme-color is the scheme surface in both modes", () => {
+ const colors = m3ThemeColors({ seed: "#0b57d0" });
+ expect(colors.light).toBe(schemeFromSeed("#0b57d0")["--md-sys-color-surface"]!);
+ expect(colors.dark).toBe(schemeFromSeed("#0b57d0", { dark: true })["--md-sys-color-surface"]!);
+ const viewport = generateM3Viewport({ seed: "#0b57d0" });
+ expect(viewport.colorScheme).toBe("light dark");
+ expect(viewport.themeColor).toEqual([
+ { media: "(prefers-color-scheme: light)", color: colors.light },
+ { media: "(prefers-color-scheme: dark)", color: colors.dark },
+ ]);
+ expect(generateM3Viewport({ mode: "dark" }).themeColor).toBe(m3ThemeColors().dark);
+ });
+
+ test("metadata has a title template and merges overrides", () => {
+ const metadata = generateM3Metadata({
+ name: "M3",
+ description: "d",
+ url: "https://example.test",
+ metadata: { robots: "noindex" },
+ });
+ expect(metadata.title).toEqual({ default: "M3", template: "%s · M3" });
+ expect(String(metadata.metadataBase)).toBe("https://example.test/");
+ expect(metadata.robots).toBe("noindex");
+ });
+});
+
+describe("m3Css", () => {
+ test("static sheets match the files of @aphrody/m3-theme", async () => {
+ const result = await m3CssResult({ sheet: "tailwind" }, { noFfi: true });
+ expect(result.source).toBe("static");
+ expect(result.css).toBe(
+ readFileSync(require.resolve("@aphrody/m3-theme/tailwind.css"), "utf8"),
+ );
+ expect(await m3Css({ sheet: "tokens" }, { noFfi: true })).toContain("--md-sys-color-primary:");
+ });
+
+ test("the JS scheme fallback equals material-color-utilities", async () => {
+ const result = await m3CssResult(
+ { sheet: "scheme", seed: "#b3261e", dark: true },
+ { noFfi: true },
+ );
+ expect(result.source).toBe("js");
+ expect(rootDeclarations(result.css)).toEqual(schemeFromSeed("#b3261e", { dark: true }));
+ });
+
+ test.skipIf(!isBunRuntime() || !nativeLib)(
+ "under Bun the Rust renderer agrees with the JS scheme",
+ async () => {
+ const result = await m3CssResult({ sheet: "scheme", seed: "#b3261e" });
+ expect(m3FfiError()).toBeUndefined();
+ expect(result.source).toBe("ffi");
+ const rust = rootDeclarations(result.css);
+ for (const [role, value] of Object.entries(schemeFromSeed("#b3261e")))
+ expect(rust[role]).toBe(value);
+ const tokens = await m3CssResult({ sheet: "tokens" });
+ expect(tokens.source).toBe("ffi");
+ expect(tokens.css).toContain("--md-sys-color-primary:");
+ },
+ );
+
+ test("the inline scheme sheet wires both modes to the classes and the media query", async () => {
+ const { css } = await m3SchemeCss({ seed: "#6750a4" }, { noFfi: true });
+ expect(css).toContain(":root {");
+ expect(css).toContain("@media (prefers-color-scheme: dark) {\n :root:not(.light) {");
+ expect(css).toContain(":root.dark {");
+ expect(css).toContain(":root.light {");
+ expect(css.match(/--md-sys-color-primary:/g)).toHaveLength(4);
+ expect(m3SchemeStylesheet({ "--a": "#000" }, { "--a": "#fff" })).toContain(
+ "color-scheme: dark;\n --a: #fff;",
+ );
+ });
+});
+
+describe("M3Document", () => {
+ test("a forced mode sets the class and color-scheme, without the pre-paint script", () => {
+ const html = renderToString(
+
+ hi
+ ,
+ );
+ expect(html).toContain('class="font-plain dark"');
+ expect(html).toContain("color-scheme:dark");
+ expect(html).toContain('data-m3-theme="dark"');
+ expect(html).toContain('');
+ expect(html).not.toContain("m3-color-scheme");
+ expect(html).toContain("hi");
+ });
+
+ test("system mode defers to the OS and resolves the class before paint", () => {
+ const html = renderToString( );
+ expect(html).toContain('lang="fr"');
+ expect(html).toContain("color-scheme:light dark");
+ expect(html).not.toContain("class=");
+ expect(html).toContain('