@@ -0,0 +1,281 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// kit:custom-ui the HTML document and the app header, around every page
|
||||
// The HTML document every page is rendered into: the brand layer on <html>, an organization's brand on
|
||||
// <body>, and, on a page outside the app frame, the header with the app's name and logo. It takes its brand
|
||||
// as a value so a test can render it with a customer's brand and prove what the page says; the root route
|
||||
// (src/routes/__root.tsx) supplies the request's facts from its loader (locale, session, CSRF token, labels:
|
||||
// `ShellData`, F10).
|
||||
//
|
||||
// The brand layer (`brand.style`: the roles the app's colours derive, light and dark, and its `--brand-*` tokens) is
|
||||
// a stylesheet in <head> whose rule (`:root:root`) outranks the kit's theme in either mode, so the first paint already
|
||||
// carries it: a branded app never paints the ConusAI palette first. When the platform gives the organization's theme,
|
||||
// that theme is the one source of the colours and the brand layer keeps only its `--brand-*` tokens (`__root.tsx`), so
|
||||
// the theme's own dark roles show; before, an inline brand colour on <html> beat them. It is computed on the server,
|
||||
// so the browser's bundle never imports the brand package. An
|
||||
// organization's colours go on <body> and not on an element below it: a menu and the phone's sheet are portals
|
||||
// into <body>, and they keep its colours that way.
|
||||
|
||||
import type { AuthBrand } from "@app/config"
|
||||
import type { UiLabels } from "@conusai-kit/ui/primitives"
|
||||
import type { CSSProperties, ReactElement, ReactNode } from "react"
|
||||
import { type BrowserObserveSettings, observeMeta } from "../observe/browser"
|
||||
import type { BoundaryStrings } from "./boundary"
|
||||
import type { HelpLabels } from "./help"
|
||||
import type { Theme } from "./preference-cookies"
|
||||
import { type PrivacyChoiceMessages, PrivacyChoices, PrivacyFooter } from "./privacy-choices"
|
||||
|
||||
/**
|
||||
* The brand layer as a rule on the root, above the kit's theme (`:root` and `.light`) whatever order the stylesheets
|
||||
* load in. Its values were checked when the brand was defined (colours and lengths only); a `<` is dropped all the
|
||||
* same, so nothing in it can close the element.
|
||||
*/
|
||||
export function brandLayerCss(style: Readonly<Record<string, string>>): string {
|
||||
const declarations = Object.entries(style).map(([name, value]) => `${name}:${value}`)
|
||||
return declarations.length === 0 ? "" : `:root:root{${declarations.join(";").replaceAll("<", "")}}`
|
||||
}
|
||||
|
||||
/** The brand without its colour roles, for a page the platform's organization theme colours: its `--brand-*` tokens. */
|
||||
export function brandTokensOnly(style: Readonly<Record<string, string>>): Record<string, string> {
|
||||
return Object.fromEntries(Object.entries(style).filter(([name]) => name.startsWith("--brand-")))
|
||||
}
|
||||
|
||||
/** The app's own share image (src/routes/api.brand.og.ts): public, because a link preview is fetched without a session. */
|
||||
export const APP_SHARE_IMAGE = "/api/brand/og"
|
||||
|
||||
/** What the document needs of the brand: its names, its logos and the token style the server computed. */
|
||||
export interface ShellBrand {
|
||||
readonly name: string
|
||||
readonly shortName: string
|
||||
readonly logo: { readonly light: string; readonly dark: string; readonly favicon: string }
|
||||
/** `brandTokenStyle(brand)` of `@app/config`, computed on the server. */
|
||||
readonly style: Readonly<Record<string, string>>
|
||||
}
|
||||
|
||||
/** What a page says about itself to a browser tab, a share card and a search result. */
|
||||
export interface BrandMetadata {
|
||||
readonly title: string
|
||||
readonly description: string
|
||||
readonly applicationName: string
|
||||
readonly icons: { readonly icon: string }
|
||||
readonly openGraph: {
|
||||
readonly siteName: string
|
||||
readonly title: string
|
||||
readonly type: "website"
|
||||
readonly images: readonly {
|
||||
readonly url: string
|
||||
readonly width: number
|
||||
readonly height: number
|
||||
readonly alt: string
|
||||
}[]
|
||||
}
|
||||
readonly twitter: { readonly card: "summary_large_image"; readonly title: string; readonly images: readonly string[] }
|
||||
}
|
||||
|
||||
/**
|
||||
* The brand's own name, and nothing else. `image` is the share image of a page; an organization's pages pass
|
||||
* their own.
|
||||
*/
|
||||
export function brandMetadata(
|
||||
brand: Pick<ShellBrand, "name" | "logo">,
|
||||
image: string = APP_SHARE_IMAGE,
|
||||
): BrandMetadata {
|
||||
return {
|
||||
title: brand.name,
|
||||
description: brand.name,
|
||||
applicationName: brand.name,
|
||||
icons: { icon: brand.logo.favicon },
|
||||
openGraph: {
|
||||
siteName: brand.name,
|
||||
title: brand.name,
|
||||
type: "website",
|
||||
images: [{ url: image, width: 1200, height: 630, alt: brand.name }],
|
||||
},
|
||||
twitter: { card: "summary_large_image", title: brand.name, images: [image] },
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `brandMetadata` as a route's `head()`: the meta tags and the icon link. `base` is the deployment's public
|
||||
* URL, so a link preview gets an absolute image address.
|
||||
*/
|
||||
export function brandHead(
|
||||
metadata: BrandMetadata,
|
||||
base?: string,
|
||||
): {
|
||||
meta: Record<string, string>[]
|
||||
links: Record<string, string>[]
|
||||
} {
|
||||
const absolute = (url: string): string => (base ? new URL(url, base).href : url)
|
||||
const image = metadata.openGraph.images[0]
|
||||
return {
|
||||
meta: [
|
||||
{ charSet: "utf-8" },
|
||||
{ name: "viewport", content: "width=device-width, initial-scale=1" },
|
||||
{ title: metadata.title },
|
||||
{ name: "description", content: metadata.description },
|
||||
{ name: "application-name", content: metadata.applicationName },
|
||||
{ property: "og:site_name", content: metadata.openGraph.siteName },
|
||||
{ property: "og:title", content: metadata.openGraph.title },
|
||||
{ property: "og:type", content: metadata.openGraph.type },
|
||||
...(image
|
||||
? [
|
||||
{ property: "og:image", content: absolute(image.url) },
|
||||
{ property: "og:image:width", content: String(image.width) },
|
||||
{ property: "og:image:height", content: String(image.height) },
|
||||
{ property: "og:image:alt", content: image.alt },
|
||||
]
|
||||
: []),
|
||||
{ name: "twitter:card", content: metadata.twitter.card },
|
||||
{ name: "twitter:title", content: metadata.twitter.title },
|
||||
...metadata.twitter.images.map((url) => ({ name: "twitter:image", content: absolute(url) })),
|
||||
],
|
||||
links: [{ rel: "icon", href: metadata.icons.icon }],
|
||||
}
|
||||
}
|
||||
|
||||
/** The labels the shell renders, translated by the root loader (src/server/page.ts `shellData`). */
|
||||
export interface ShellLabels {
|
||||
readonly signIn: string
|
||||
readonly signOut: string
|
||||
readonly account: string
|
||||
readonly failed: string
|
||||
readonly language: string
|
||||
readonly languageEn: string
|
||||
readonly languageLt: string
|
||||
readonly theme: string
|
||||
readonly themeDark: string
|
||||
readonly themeLight: string
|
||||
readonly themeSystem: string
|
||||
readonly switchToLight: string
|
||||
readonly switchToDark: string
|
||||
readonly loading: string
|
||||
readonly notFoundTitle: string
|
||||
readonly notFoundBody: string
|
||||
readonly forbiddenTitle: string
|
||||
readonly forbiddenBody: string
|
||||
}
|
||||
|
||||
/** The root route's loader data: everything the document and the page states render, as plain values. */
|
||||
export interface ShellData {
|
||||
readonly brand: ShellBrand
|
||||
/** The way in, in the page's language (`authBrand` of `config/src/brand.ts`). */
|
||||
readonly auth: AuthBrand
|
||||
/** The deployment's public URL: what makes a share image's address absolute for a link preview. */
|
||||
readonly publicUrl: string
|
||||
readonly locale: string
|
||||
readonly theme: Theme
|
||||
/** The frame's layout as this browser left it (`app_sidebar`, `app_panel_width`). */
|
||||
readonly sidebarOpen: boolean
|
||||
readonly panelWidth?: number
|
||||
readonly signedIn: boolean
|
||||
readonly csrf?: string
|
||||
readonly observe?: BrowserObserveSettings
|
||||
readonly nonce?: string
|
||||
/** The organization theme's stylesheet (BK12), when the platform gives one. */
|
||||
readonly organizationTheme?: string
|
||||
readonly accountHref: string
|
||||
readonly labels: ShellLabels
|
||||
/** The strings the kit's primitives and patterns render themselves, in the page's language. */
|
||||
readonly ui: UiLabels
|
||||
readonly help: HelpLabels
|
||||
readonly privacy: PrivacyChoiceMessages
|
||||
readonly boundary: BoundaryStrings
|
||||
}
|
||||
|
||||
/** The class `system` means on this device, set before anything paints. The server cannot know it. */
|
||||
const SYSTEM_THEME_SCRIPT = `document.documentElement.classList.toggle("light",matchMedia("(prefers-color-scheme: light)").matches)`
|
||||
|
||||
export interface DocumentProps {
|
||||
readonly brand: ShellBrand
|
||||
readonly locale: string
|
||||
/** `light` puts the theme class on the root, the one element that carries it (spec §10.3). */
|
||||
readonly theme: Theme
|
||||
readonly signedIn: boolean
|
||||
readonly csrf?: string
|
||||
/** Set when the deployment turned observability on: the browser starts its SDK from this (src/observe). */
|
||||
readonly observe?: BrowserObserveSettings
|
||||
/** The request's script nonce: `system` needs a script in the head. */
|
||||
readonly nonce?: string
|
||||
readonly signIn: string
|
||||
/** The sign-out control, already rendered with its label. */
|
||||
readonly signOut: ReactNode
|
||||
/** The theme and language controls, already rendered with their labels. */
|
||||
readonly controls: ReactNode
|
||||
readonly privacy: PrivacyChoiceMessages
|
||||
/** The organization whose page this is: its colours, and the mark a test finds it by. */
|
||||
readonly tenant?: { readonly style: Readonly<Record<string, string>>; readonly mark?: string }
|
||||
/** The router's `<HeadContent />`: title, meta and the stylesheet links. */
|
||||
readonly head?: ReactNode
|
||||
/** The router's `<Scripts />`, last in the body. */
|
||||
readonly scripts?: ReactNode
|
||||
readonly children: ReactNode
|
||||
}
|
||||
|
||||
export function Document({
|
||||
brand,
|
||||
locale,
|
||||
theme,
|
||||
signedIn,
|
||||
csrf,
|
||||
observe,
|
||||
nonce,
|
||||
signIn,
|
||||
signOut,
|
||||
controls,
|
||||
privacy,
|
||||
tenant,
|
||||
head,
|
||||
scripts,
|
||||
children,
|
||||
}: DocumentProps): ReactElement {
|
||||
return (
|
||||
<html
|
||||
lang={locale}
|
||||
className={theme === "light" ? "light" : undefined}
|
||||
data-brand={brand.shortName}
|
||||
suppressHydrationWarning={theme === "system"}
|
||||
>
|
||||
<head>
|
||||
{theme === "system" ? (
|
||||
<script
|
||||
{...(nonce ? { nonce } : {})}
|
||||
suppressHydrationWarning
|
||||
dangerouslySetInnerHTML={{ __html: SYSTEM_THEME_SCRIPT }}
|
||||
/>
|
||||
) : null}
|
||||
{head}
|
||||
<style
|
||||
data-brand-layer="app"
|
||||
{...(nonce ? { nonce } : {})}
|
||||
dangerouslySetInnerHTML={{ __html: brandLayerCss(brand.style) }}
|
||||
/>
|
||||
{csrf ? <meta name="csrf-token" content={csrf} /> : null}
|
||||
{observe ? <meta {...observeMeta(observe)} /> : null}
|
||||
</head>
|
||||
<body {...(tenant?.mark ? { "data-tenant-brand": tenant.mark } : {})} style={tenant?.style as CSSProperties}>
|
||||
<PrivacyChoices
|
||||
messages={privacy}
|
||||
footer={
|
||||
<div data-slot="document-footer">
|
||||
<PrivacyFooter />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<header className="app-shell-header">
|
||||
<a href="/" className="app-brand-link">
|
||||
<img className="app-logo app-logo-dark" src={brand.logo.dark} alt="" />
|
||||
<img className="app-logo app-logo-light" src={brand.logo.light} alt="" />
|
||||
{brand.name}
|
||||
</a>
|
||||
<div className="app-shell-controls">
|
||||
{controls}
|
||||
{signedIn ? signOut : <a href="/auth/sign-in">{signIn}</a>}
|
||||
</div>
|
||||
</header>
|
||||
{children}
|
||||
</PrivacyChoices>
|
||||
{scripts}
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user