"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" export interface ExxatProductLogoProps { product: Product className?: string /** Reserved for switcher chrome; suffix stays Exxat pink in all modes. */ variant?: ExxatProductLogoVariant /** Live preview or a specific custom slot — bypasses the active custom index. */ previewCustomBrand?: CustomProductBrand | null } /** * 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, }: ExxatProductLogoProps) { 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.wordmarkColor ?? config.brandColor if (variant === "sidebar") { 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, suffixColor, className, }: { config: ProductBrandConfig suffixColor: string className?: string }) { const suffix = config.suffix const words = suffix.trim().split(/\s+/) // Two-word + long enough to merit wrapping → stacked B2. Two-word but // short ("New one") still fits A comfortably and reads better unwrapped. const lockup: "A" | "B1" | "B2" = 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 } /** * 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, }) const suffixColor = config.wordmarkColor ?? config.brandColor React.useLayoutEffect(() => { const node = suffixRef.current if (!node) return const next = suffixValue if (node.textContent !== next) { node.textContent = next } }, [suffixValue]) return (