// The shared HTML document shell — ONE implementation drives both the Bun/Node // dev+prod server and the generated Workers entry, so `june build` output // renders byte-equivalent heads to `june dev`. import React from "react"; import type { Metadata } from "./route"; import type { RouterMode, SiteConfig } from "./config"; // The serializable slice of app config the document needs. The server feeds it // from AppConfig; the generated worker inlines it as literals at build time. export type DocumentConfig = { // `lang` is the document-language FLOOR: a single-locale app sets it (default // "en") without any i18n machinery. When `i18n` is configured the resolved // per-request locale (ctx.locale) overrides it on ``. site: SiteConfig; speculationRules: string | null; speculationDelivery: "inline" | "header"; viewTransitions: boolean | "instant" | number; // June's built-in zero-config defaults: the baseline reset (BASE_RESET_CSS) AND the starter look // (STARTER_CONTENT_CSS — page background, a centered 720px column, inline-code chips). The reset is // :where()-wrapped (zero-specificity); the starter look is full-specificity AND unlayered, so it // would override an app's own CSS (Tailwind utilities/typography included). Both default ON and are // dropped together when `false` — set it when your CSS already ships a reset and owns the look (e.g. // Tailwind Preflight + your own styles). Maps from JuneConfig.cssReset (auto-off when Tailwind is // detected in app/global.css). cssReset?: boolean; // Opt-in client router (resolved from config.clientRouter). When not "off" the // page is wrapped in
— the region the router swaps on soft // navigation — and that element's presence is the runtime signal the islands // bundle reads to start the router. "off"/absent → classic MPA navigation, zero // added JS. The applier ("morph" default, "flight" opt-in) rides on the same // element as data-june-router (omitted for morph → byte-identical to before). clientRouter?: RouterMode; // URL of the client islands runtime bundle. Set by the host (dev serves it, // build freezes its hashed path) when the app has islands; the document then // loads it as a deferred module so `"use client"` islands hydrate. Absent / // null → the page ships zero client JS. clientScript?: string | null; // URL of the global stylesheet. Set by the host (dev serves it, build emits it // as an asset) when `app/global.css` exists — auto-linked, no import. Absent / // null → no stylesheet. CSS is a HUMAN-surface concern; agent projections // (.md/.json/mcp) never carry it. styles?: string | null; // URL of the collected CSS-Modules stylesheet (app/**/*.module.css), when any // exist. Linked after `styles` so component-scoped rules win over the global // sheet. Same dev-stable / build-hashed split as `styles`. moduleStyles?: string | null; // WebMCP tool manifest (name/description/inputSchema) — the SAME actions the // server exposes at /mcp. When present, the document injects a tiny script // that registers each via navigator.modelContext.registerTool() so an // in-browser agent can call them (each tool's execute proxies to /mcp). webmcpTools?: Array<{ name: string; description?: string; inputSchema?: unknown }> | null; // URL of the app's AI Catalog (ARD) — set by the host when agent discovery is // on; the document advertises it with . aiCatalog?: string | null; // Public-path prefix the whole site is served under (JuneConfig.basePath), e.g. // "/openab/docs" for a GitHub Pages project subpath. When set, the framework asset // URLs below (favicon/styles/moduleStyles/clientScript) — which are root-absolute // ("/_june/…") — are prefixed so they resolve under the subpath. Empty/absent = // root deploy (unchanged). Only the static() target sets it. basePath?: string; // "https://" when the config names one: the public origin for // pages whose request can't supply it (prerendered pages render against a // placeholder host). Kept apart from site.url so a locale's own domain can // still win over it — see publicOrigin(). deployOrigin?: string; // The icon links, filled by the host from public/ (see favicon.ts resolveIcons): // primary the app's own main icon (public/favicon.svg, icon.svg, …) — // linked in place of June's letter /favicon.svg; site.icon wins // png a 32×32 PNG (Google Search and some browsers skip SVG) // appleTouch the iOS home-screen icon // manifest the web app manifest (Android "Add to Home Screen") // generated true when the set is June's own (the pipeline then serves the // generated /manifest.webmanifest) // June's generated set when the app has no icon of its own. Absent → only the // favicon link. icons?: { primary?: string; png?: string; appleTouch?: string; manifest?: string; generated?: boolean } | null; }; // June's built-in baseline CSS reset — a minimal, Tailwind-Preflight-aligned normalize, NOT a layout // opinion. Every rule is wrapped in :where() so it carries ZERO specificity: present as a safe baseline // for apps with no reset, yet trivially overridden by any stylesheet (so it never fights an app's own // styles, Tailwind Preflight included). Deliberately omits `cursor: pointer` on buttons to match // Tailwind v4 / browser-native behavior. Injected unless `cssReset: false`. export const BASE_RESET_CSS = `:where(*,::before,::after,::backdrop){box-sizing:border-box} :where(html){-webkit-text-size-adjust:100%} :where(body){margin:0} :where(img,picture,video,canvas,svg){display:block;max-width:100%} :where(button,input,select,textarea){font:inherit} :where(button){color:inherit;background:none;border:0}`; // June's zero-config "starter look": opinionated defaults — a page background, a centered 720px reading // column, and inline-code chips — that make an app shipping NO CSS of its own look decent immediately. // Unlike BASE_RESET_CSS these are full-specificity LAYOUT/LOOK opinions, not a :where() reset, so they // would fight an app that brings its own styling. CSS cascade layers don't save us here: these are // unlayered, and unlayered normal declarations outrank EVERY @layer (Tailwind utilities and the // typography plugin included) regardless of specificity. So they're injected together with the reset // and, like it, DEFERRED when the app brings its own system (cssReset === false — which auto-derives // from Tailwind detection in the host). Then Tailwind Preflight + the app's CSS own the look entirely. // The starter look's page background — also its default theme-color. export const STARTER_BACKGROUND = "#fbfbf8"; export const STARTER_CONTENT_CSS = `body{font-family:ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;background:${STARTER_BACKGROUND};color:#1d1d1f} main{width:min(720px,calc(100vw - 32px));margin:72px auto} code{background:#ecebe4;border-radius:4px;padding:2px 5px}`; // Cross-document View Transitions: same-origin MPA navigations cross-fade // (and pair with prerender: activation + smooth transition = SPA feel, no SPA). // // The browser-default cross-fade runs ~250ms. On a PRERENDERED navigation the // page is already there, so the fade isn't masking a load — it's pure tax played // AFTER the new page is ready, with a hazy double-exposure mid-cross-fade that // reads as lag. We override only animation-duration on the UA cross-fade // (author > UA, so the browser's own keyframes still drive it — the most robust // baseline) and default to a snappy 120ms: motion as polish, not manufactured // delay. prefers-reduced-motion always collapses to an instant cut. const VIEW_TRANSITION_DEFAULT_MS = 120; // Build the View Transition CSS for the resolved setting: // true → cross-fade at the default duration // number → cross-fade at that many ms (0 = instant cut) // "instant" → cross-document activation with no animation (instant cut) // false → "" (no @view-transition rule; the caller drops it entirely) export function viewTransitionCss(opt: boolean | "instant" | number): string { if (opt === false) return ""; const ms = opt === true ? VIEW_TRANSITION_DEFAULT_MS : opt === "instant" ? 0 : Math.max(0, opt); return ` @view-transition { navigation: auto; } ::view-transition-group(root), ::view-transition-old(root), ::view-transition-new(root) { animation-duration: ${ms}ms; } @media (prefers-reduced-motion: reduce) { ::view-transition-group(*), ::view-transition-old(*), ::view-transition-new(*) { animation: none !important; } }`; } export const PREFETCH_FALLBACK = `(function(){if(HTMLScriptElement.supports&&HTMLScriptElement.supports('speculationrules'))return;var seen=new Set();document.addEventListener('pointerover',function(e){var a=e.target&&e.target.closest&&e.target.closest('a[href]');if(!a)return;var u=new URL(a.href,location.href);if(u.origin!==location.origin||seen.has(u.pathname)||u.pathname===location.pathname)return;if(/\.(md|json)$/.test(u.pathname)||u.pathname==='/mcp')return;seen.add(u.pathname);var l=document.createElement('link');l.rel='prefetch';l.href=u.pathname+u.search;document.head.appendChild(l);},{passive:true});})();`; // WebMCP bridge: register each declared action via navigator.modelContext so an // in-browser agent can call it; execute() proxies to /mcp (the same dispatch the // server MCP endpoint uses). No-op when the browser lacks the API. An // AbortController lets a future SPA navigation unregister. Reads its tool list // from the adjacent