"use client" /** * Exxat product logo — SVG (mark + "Exxat" letters) + HTML text (product suffix). * * Why the hybrid: * - The mark and the "Exxat" prefix are **static** and shared across every * Exxat product. Tracing them once as SVG paths means pixel-perfect * rendering, no font-loading flicker, and perfect baseline alignment * between the circular mark and the "Exxat" caps (they live in the same * `viewBox`). * - The **suffix** (One / Prism / Pulse / …) is the only variable part, so it * renders as HTML text in **IvyPresto Text SemiBold** per the official * Figma brand spec (`weight 600`, `letter-spacing -3 %`). Adding a new * product is still a one-liner in `lib/product-brand.ts` — no new path * tracing required. * * The earlier "all-HTML wordmark" approach worked but couldn't fully match the * SVG's letter spacing / weight (Inter ≠ the original traced glyphs) and made * the mark-to-Exxat alignment depend on browser font-metrics. The hybrid * removes both classes of issue. * * **Reference paths:** the mark + "Exxat" path data is the same `d=…` source * used by the historical `ExxatOneLogo` (the pre-text-route baseline). Only * the gradient colours are pulled from the brand registry so new brands * recolour the mark. */ import * as React from "react" import { cn } from "@/lib/utils" 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" } /** * The shared viewBox covers x=0..147 (mark), the original 49-unit baseline gap * (x=147..196), and x=196..514 (the "Exxat" letters). The full y=0..164 height * preserves the breathing room around the circle exactly as the brand asset * defines it — DO NOT crop or the mark stops being a round disc. */ const EXXAT_LOGO_VIEWBOX = "0 0 514 164" /** Defer `` until after mount so SSR/CSR ids match — see `ProductMark`. */ function useBrowserPaintReady() { const [ready, setReady] = React.useState(false) React.useLayoutEffect(() => { setReady(true) }, []) return ready } /** * Per-instance gradient id so multiple logos on one page don't collide. Strip * colons because Radix-style `useId()` values can contain them and SVG `id` * cannot. */ function useExxatBaseGradientId(brandId: string) { const raw = React.useId().replace(/:/g, "") return `exxat-base-${brandId.replace(/[^a-z0-9-]/gi, "")}-${raw}` } /** * Mark + "Exxat" letters as a single SVG. The "Exxat" letter paths are * authored at y=35..128 (cap top / baseline) inside the 164-unit viewBox, so * the cap mid-line sits ≈ 81.5 — naturally centred with the circular mark * whose centre is at ≈ 81.5 too. This shared centring is the whole reason for * baking both into the same SVG. * * **`omitMark`** crops the viewBox to the "Exxat" letter range only * (`196 0 318 164`) and skips the mark paths + gradient defs. Used by the * sidebar `"sidebar"` variant where a standalone `ProductMark` already * carries the round identity — rendering the full SVG alongside would paint * two marks (the round `[E]` then the SVG's own embedded mark). */ function ExxatLogoBase({ config, className, style, omitMark = false, }: { config: ProductBrandConfig className?: string style?: React.CSSProperties omitMark?: boolean }) { const ready = useBrowserPaintReady() const gradId = useExxatBaseGradientId(config.id) const [from, to] = config.markGradient ?? [config.brandColor, config.brandColor] const shadow = config.markShadow ?? config.brandColor // Letters-only crop keeps the original y range (0..164) so the cap height // scales identically to the full SVG (cap stays at ~92/164 of the rendered // height). Only the x range narrows to the "Exxat" glyph bounds (196..514). const viewBox = omitMark ? "196 0 318 164" : EXXAT_LOGO_VIEWBOX const aspectClass = omitMark ? "aspect-[318/164]" : "aspect-[514/164]" const sharedProps = { viewBox, preserveAspectRatio: "xMinYMid meet" as const, fill: "none", xmlns: "http://www.w3.org/2000/svg", "data-product-logo-base": true, "data-product-id": config.id, "data-omit-mark": omitMark ? true : undefined, "aria-hidden": true, suppressHydrationWarning: true, className: cn("block shrink-0", aspectClass, className), style, } as const if (!ready) { return } return ( {!omitMark && ( <> {/* ── Mark: outer circle, inner shadow plate, cut-out "E" strokes ── */} )} {/* ── "Exxat" letters — neutral slate on light, soft cool grey on dark ── Filled via CSS class so dark-mode + `mutedSuffix` flips can override. */} {!omitMark && ( )} ) } /** * Mark + wordmark composed inline. Sizing is height-driven via `className` * (e.g. `h-9`) — the SVG base scales with the parent height, and the suffix * text scales with the parent `text-base` (via `text-[1.8em]`) so its * cap-height matches the SVG "Exxat" cap. * * **Geometry at h-9 (36 px) parent:** * - SVG `0 0 514 164` → renders 113 × 36 px, scale 0.2195 * - Mark height: 147 × 0.2195 ≈ 32 px (matches school Avatar slot) * - "Exxat" cap height: 92 × 0.2195 ≈ 20 px (sits centred on mark midline) * - "Exxat" baseline: y=128 in viewBox → ≈ 28.1 px from SVG top * * **Suffix sizing:** `text-[1.8em]` of 16 px base → 28.8 px font, cap ≈ 20 px * (matches "Exxat" cap). `translate-y-[0.05em]` (~1.4 px down) corrects the * residual cap-midpoint offset between HTML text metrics and the SVG baseline * authored at y=128. * * **Variants:** * - `"default"` / `"mutedSuffix"` — full inline lock-up (`ExxatLogoBase` + * suffix). 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", }: 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 ( ) } /* ── 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, }: { config: ProductBrandConfig className?: string density?: "sidebar" | "utility-bar" externalMark?: 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" const suffixTypeClasses = "font-heading text-[1.55em] font-semibold tracking-[-0.03em] leading-none" return ( ) } 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 } /** * Product switcher dropdown row — round mark + suffix-only wordmark tinted with * **that row's** registry brand colour. 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). */ export function ProductSwitcherMenuRowLabel({ product, className, previewCustomBrand, }: ProductSwitcherMenuRowLabelProps) { const activeCustomProductBrand = useAppStore(s => getActiveCustomProductBrand(s)) const productBrandColors = useAppStore(s => s.productBrandColors) const effectiveCustomBrand = previewCustomBrand ?? activeCustomProductBrand const config = brandForProduct(product, effectiveCustomBrand, productBrandColors) const suffixColor = config.id === "exxat-custom" ? config.brandColor : (config.wordmarkColor ?? config.brandColor) return ( ) } /** * 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 an inline suffix field — E + Exxat SVG plus * IvyPresto suffix input at the same size as {@link ExxatProductLogo}. */ 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( "font-heading ms-[0.18em] min-w-[5ch] max-w-[min(100%,14rem)] border-0 bg-transparent p-0", "text-[1.55em] font-semibold leading-none tracking-[-0.03em] -translate-y-[3px]", "outline-none empty:before:text-current/45 empty:before:content-[attr(data-placeholder)]", "focus-visible:outline-none focus-visible:ring-0", )} /> ) }