"use client"
/**
* Exxat product logo — SVG mark + HTML text (`Exxat` + the product name).
*
* **One typeface for the words.** "Exxat" used to be traced IvyPresto outlines
* in the same `viewBox` as the mark, with the product name set in IvyPresto
* text beside it. Both words are now UI sans: the lock-up is read as a label
* in the sidebar, the utility bar, and the switcher, and a Bodoni-lineage
* serif at chrome size costs legibility for names as long as
* "Clinical Education". The menu rows already made this trade
* (see {@link ProductSwitcherMenuRowLabel}); the trigger now matches them.
*
* What that buys, beyond legibility: the two words share a font-size, so their
* caps line up by construction instead of by a hand-tuned nudge against a
* baseline authored at y=128 in an SVG, and neither word waits on Adobe Fonts
* to swap. The circular mark stays SVG — geometry, gradient, and cut-out "E"
* come from {@link ProductMark}, so a new brand still only needs colours.
*
* Adding a product remains a one-liner in `lib/product-brand.ts`.
*/
import * as React from "react"
import { PRODUCT_TILE_CLASS, productTileStyle } from "@/lib/product-glyph"
import { cn } from "@/lib/utils"
import { ProductTileArt } from "@/components/product-app-mark"
import { ProductMark } from "@/components/product-wordmark"
import { brandForProduct, type ProductBrandConfig } from "@/lib/product-brand"
import type { Product } from "@/contexts/product-context"
import { useAppStore, type CustomProductBrand, getActiveCustomProductBrand } from "@/stores/app-store"
import { customProductBrandConfig } from "@/lib/product-brand"
export type ExxatProductLogoVariant = "default" | "mutedSuffix" | "sidebar" | "utility-bar"
export interface ExxatProductLogoProps {
product: Product
className?: string
/** Reserved for switcher chrome; suffix fill uses `--product-wordmark-suffix`. */
variant?: ExxatProductLogoVariant
/** Live preview or a specific custom slot — bypasses the active custom index. */
previewCustomBrand?: CustomProductBrand | null
/**
* Utility bar rail — the round mark renders in its own column (aligned with
* the school selector); this instance is wordmark-only.
*/
mark?: "included" | "external"
/**
* `utility-bar` density only. Drops the wordmark from `1.2em` to `1em`, which
* at the lockup's `text-sm` host is exactly the breadcrumb's size. The compact
* shell puts the product name and the breadcrumb on one row, and a wordmark
* two steps larger than the crumb beside it reads as a heading rather than as
* the first item in a trail.
*/
compact?: boolean
}
/**
* Type for both words of the lock-up. Sized by the caller in `em` so the whole
* mark-plus-words unit scales with the host's font-size, as the SVG did.
*
* Tracking is tighter than body text but looser than the -3 % the serif asked
* for: Inter's sidebearings are already narrow, and -3 % at 25 px closed the
* gap between the doubled letters in "Exxat" and in "Assessment".
*/
const WORDMARK_TYPE_CLASS = "font-sans font-semibold tracking-[-0.02em]"
/**
* "Exxat", ahead of the product name.
*
* Neutral ink rather than the brand colour: the name carries the brand (via
* `--product-wordmark-suffix`) and the mark carries the colour, so painting all
* three the same hue flattens the lock-up into one block.
*/
function ExxatPrefix({ className }: { className?: string }) {
return (
Exxat
)
}
/**
* Mark + wordmark composed inline. Sizing is font-size-driven: the host pins
* `text-base` (16 px) and every part is expressed in `em` from there, so one
* `text-*` on the parent scales mark and words together.
*
* **Geometry at a `text-base` parent:** mark `1.55em` ≈ 25 px, both words
* `1.55em` ≈ 25 px with a ≈ 18 px cap. The two words share a font-size, so
* their caps agree without a nudge, and `items-baseline` sits them on one
* baseline with the mark centred against it.
*
* **Variants:**
* - `"default"` / `"mutedSuffix"` — full inline lock-up (mark + "Exxat" +
* name). Used by the marketing wordmark surfaces (Settings → Appearance
* rows, dropdown rows, dashboards).
* - `"sidebar"` — adaptive lock-up cascade for the sidebar product switcher.
* The mark is always rendered (round `ProductMark`); the wordmark cascades
* A → B1 → B2 based on whether the inline string fits the trigger width
* (see {@link SidebarLockup}).
*/
export function ExxatProductLogo({
product,
className,
variant = "default",
previewCustomBrand,
mark = "included",
compact = false,
}: ExxatProductLogoProps) {
const activeCustomProductBrand = useAppStore(s => getActiveCustomProductBrand(s))
const productBrandColors = useAppStore(s => s.productBrandColors)
const effectiveCustomBrand = previewCustomBrand ?? activeCustomProductBrand
const config = brandForProduct(product, effectiveCustomBrand, productBrandColors)
if (variant === "sidebar" || variant === "utility-bar") {
return (
)
}
return (
{config.suffix}
)
}
/* ── Sidebar lock-up cascade ───────────────────────────────────────────────── */
/**
* Adaptive product lock-up for the sidebar product switcher trigger and its
* dropdown rows. Three deterministic states, picked purely from the suffix
* string — no `ResizeObserver`, no font-load timing, no measurement spans.
*
* The cascade was originally width-driven, but the measurement path latched
* the wrong lockup whenever IvyPresto swapped in (Adobe Fonts via Kit fires
* `document.fonts.ready` after first paint). A deterministic rule keyed on
* `suffix.length` + word count is stable across SSR, hydration, and font
* load, and matches the small number of legal suffix shapes the brand
* config produces (built-in suffixes are short single words; custom
* suffixes are clamped to 24 chars in `customProductBrandConfig`).
*
* - **A — Full wordmark** `[E] Exxat ` — short suffixes that read
* well next to "Exxat". Mark = standalone round `ProductMark`; the
* wordmark area renders `ExxatLogoBase` in **letters-only** mode
* (`omitMark` — viewBox cropped to the "Exxat" glyph range) + suffix
* inline. Without `omitMark` the embedded SVG mark would paint a second
* round `[E]` next to `ProductMark`.
* - **B1 — Compact one-liner** `[E] ` — long single-word suffixes
* (`Assessment`, `Analytics`). "Exxat" is dropped so the suffix can stand
* alone in IvyPresto SemiBold at the same visual size as A's suffix.
* - **B2 — Stacked two-liner** `[E] Word1` / `Word2` — exactly two-word
* suffixes where the combined length warrants wrapping
* (`Exam Management`, `Field Practice`). Each word sits on its own line at
* the same IvyPresto SemiBold size as B1; the parent's `items-center`
* keeps the round `[E]` aligned to the vertical midpoint of the stack.
*
* Three-plus-word suffixes are unsupported by product policy — they fall
* through to B1 and rely on the trigger's overflow rules to clip if needed.
*/
function SidebarLockup({
config,
className,
density = "sidebar",
externalMark = false,
compact = false,
}: {
config: ProductBrandConfig
className?: string
density?: "sidebar" | "utility-bar"
externalMark?: boolean
compact?: boolean
}) {
const utilityBar = density === "utility-bar"
const suffix = config.suffix
const words = suffix.trim().split(/\s+/)
// Utility bar — suffix-only wordmark (`Clinical Education`); round mark is external.
// Sidebar keeps the deterministic A / B1 / B2 cascade for narrow triggers.
const lockup: "A" | "B1" | "B2" = utilityBar
? "B1"
: words.length === 2 && suffix.length > 10
? "B2"
: suffix.length > 8
? "B1"
: "A"
// Utility bar clips horizontally (`truncate` for long custom suffixes), and
// that same `overflow-hidden` clips vertically too, so a descender in
// "Design OS" would be sheared by a `leading-none` box. Relaxed leading plus
// symmetric padding gives it room; the flex parent keeps it optically
// centred. The sidebar lockup overflows visibly, so it keeps `leading-none`
// and needs no padding.
const wordTypeClasses = utilityBar
? cn(
WORDMARK_TYPE_CLASS,
"leading-[1.35] py-0.5",
// `1em` of the `text-sm` host below, so the name matches the breadcrumb
// exactly rather than approximately.
compact ? "text-[1em]" : "text-[1.2em]",
)
: cn(WORDMARK_TYPE_CLASS, "text-[1.55em] leading-none")
return (
{!externalMark ? (
) : null}
{lockup === "A" && (
{suffix}
)}
{lockup === "B1" && (
{suffix}
)}
{lockup === "B2" && (
// Variant 1 (under evaluation): top word smaller, bottom word
// at the same headline size as A / B1. Hierarchy reads
// "qualifier → noun" — e.g. "Exam → MANAGEMENT".
{words[0]}
{words[1]}
)}
)
}
export interface ExxatProductMarkProps {
product: Product
className?: string
cutoutColor?: string
}
export interface ProductSwitcherMenuRowLabelProps {
product: Product
className?: string
/** Live preview or a specific custom slot — bypasses the active custom index. */
previewCustomBrand?: CustomProductBrand | null
/**
* Name to print instead of the brand suffix. The suffix alone is enough
* beside a trigger that already said "Exxat", but in a list it can read as a
* fragment ("One"), so callers with a full product name pass it here.
*/
label?: string
}
/**
* Product switcher dropdown row — brand-tinted glyph tile, product name in plain
* UI text. Omits the shared "Exxat" prefix and does not inherit the active
* shell's `--product-wordmark-suffix` token (which would paint every row in the
* current product's theme colour).
*
* The glyph sits in a tile rather than loose on the row. Loose, it carried the
* same visual weight as the label beside it, so eleven rows read as one grey
* column with nothing to aim at; the tile is the same one the home page draws
* for the same product, at menu size, so a product wears one mark everywhere.
*
* The name is `text-foreground`, not the row's brand colour. Nine names in nine
* different hues read as nine links rather than one list, the tick marking the
* active row had to compete with colour that meant nothing about state, and
* several registry hues land close to the disabled-text ramp in dark mode. The
* glyph carries the brand, where colour is decoration and costs nothing if it
* is missed.
*
* The product's own mark, not `ProductMark`: every row drew the same Exxat `E`,
* so a nine-row menu gave the eye nothing to aim at and every row had to be read.
* The Exxat mark still owns the trigger beside this menu, where there is one
* product and it is the company talking. What fills the tile is
* `ProductTileArt` — a drawn mark per app, which carries recognition on its own
* so a row still reads when two accents land on the same hue or a workspace
* recolours one.
*
* Row type is the lock-up's type at menu weight. The rows led the move off
* IvyPresto (a serif at menu size cost legibility, and nine stacked read like
* signage instead of a list); the trigger above them now follows, so a product
* name has one voice from the trigger down through the list.
*/
export function ProductSwitcherMenuRowLabel({
product,
className,
previewCustomBrand,
label,
}: ProductSwitcherMenuRowLabelProps) {
const activeCustomProductBrand = useAppStore(s => getActiveCustomProductBrand(s))
const productBrandColors = useAppStore(s => s.productBrandColors)
const effectiveCustomBrand = previewCustomBrand ?? activeCustomProductBrand
const config = brandForProduct(product, effectiveCustomBrand, productBrandColors)
return (
{label ?? config.suffix}
)
}
/**
* Circular mark only — collapsed sidebar (matches Avatar 32×32). Reuses the
* generic `ProductMark` because the mark's geometry is identical across all
* Exxat products; only colours change per brand.
*/
export function ExxatProductMark({ product, className, cutoutColor, previewCustomBrand }: ExxatProductMarkProps & { previewCustomBrand?: CustomProductBrand | null }) {
const activeCustomProductBrand = useAppStore(s => getActiveCustomProductBrand(s))
const productBrandColors = useAppStore(s => s.productBrandColors)
const effectiveCustomBrand = previewCustomBrand ?? activeCustomProductBrand
const config = brandForProduct(product, effectiveCustomBrand, productBrandColors)
return
}
export interface ExxatProductWordmarkEditorProps {
previewCustomBrand: CustomProductBrand
suffixValue: string
onSuffixChange: (value: string) => void
suffixPlaceholder?: string
suffixId?: string
className?: string
}
/**
* Product-switcher wordmark with the name as an inline field — mark + "Exxat"
* plus an editable name at the same size as {@link ExxatProductLogo}, so what
* the tenant types is what the sidebar will show.
*/
export function ExxatProductWordmarkEditor({
previewCustomBrand,
suffixValue,
onSuffixChange,
suffixPlaceholder = "Product",
suffixId,
className,
}: ExxatProductWordmarkEditorProps) {
const suffixRef = React.useRef(null)
const suffix = suffixValue.trim() || suffixPlaceholder
const config = customProductBrandConfig({
...previewCustomBrand,
suffix,
})
React.useLayoutEffect(() => {
const node = suffixRef.current
if (!node) return
const next = suffixValue
if (node.textContent !== next) {
node.textContent = next
}
}, [suffixValue])
return (
{
const raw = suffixRef.current?.textContent ?? ""
const next = raw.slice(0, 24)
if (raw.length > 24 && suffixRef.current) {
suffixRef.current.textContent = next
}
onSuffixChange(next)
}}
onKeyDown={event => {
if (event.key === "Enter") {
event.preventDefault()
}
}}
className={cn(
WORDMARK_TYPE_CLASS,
"ms-[0.18em] min-w-[5ch] max-w-[min(100%,14rem)] border-0 bg-transparent p-0",
"text-[1.55em] leading-none",
"outline-none empty:before:text-current/45 empty:before:content-[attr(data-placeholder)]",
"focus-visible:outline-none focus-visible:ring-0",
)}
/>
)
}