/* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ /** * Variant B — "Stage" * * Cinematic, dark-by-default, demo-driven. The point of this variant is to * make people feel the agent driving the model. So the hero is an animated * IFC wireframe that progressively colorises itself as a fake transcript * scrolls underneath ("agent: viewer_isolate IfcWall …"). Big confident * type, generous breathing room, and recipes shown as a horizontally * scrolling carousel of stylised agent conversations. * * Typography: Instrument Serif (italic, for the flex character) carries * display + numerals. Bricolage Grotesque (variable) does the body work. * JetBrains Mono for code. The chartreuse accent (#d6ff3f) is a nod to * construction-safety hi-vis — distinctive on a black field, never seen on * a generic SaaS landing. * * The variant explicitly forces dark on its own subtree without flipping * the global .dark class, so the rest of the SPA isn’t affected when the * user navigates away. */ import { type CSSProperties, type ReactNode, useEffect, useMemo, useRef, useState, } from 'react'; import { ArrowDown, ArrowLeft, ArrowUpRight, Check, ChevronRight, Copy, Play, Sparkles, Sun, Terminal, } from 'lucide-react'; import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog'; import { cn } from '@/lib/utils'; import { HeroScene, HERO_STEPS, HERO_STEP_MS, type HeroStep } from './HeroScene'; import { CATALOG, CATEGORY_BLURBS, CATEGORY_ORDER, CLIENTS, EXAMPLES, FAMILY_ACCENT, MCP_VERSION, RECIPES, catalogStats, exampleCall, makeConfigSnippet, makeDeepLink, paramsFor, toolsByCategory, type ParamRow, } from './data'; import type { CatalogTool, McpClient, McpClientId, ToolCategory } from './types'; import { scrollToAnchor, useCopyToClipboard, useDocumentMeta, useFonts } from './use-mcp-page'; const NIGHT = '#0a0a0c'; const NIGHT_2 = '#121215'; const PAPER = '#ede4d3'; const PAPER_DIM = '#9c9486'; const ACCENT = '#d6ff3f'; // hi-vis chartreuse const ACCENT_2 = '#ff5cdc'; // magenta for hover/active const RULE = 'rgba(237, 228, 211, 0.10)'; const stage: CSSProperties = { background: NIGHT, color: PAPER, fontFamily: '"Bricolage Grotesque", "Inter Tight", system-ui, sans-serif', fontFeatureSettings: '"ss01" 1, "ss02" 1, "cv11" 1', }; const display: CSSProperties = { fontFamily: '"Instrument Serif", "Newsreader", Georgia, serif', fontWeight: 400, fontStyle: 'normal', }; const mono: CSSProperties = { fontFamily: '"JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, monospace', }; export function McpLanding(): ReactNode { useFonts( 'https://fonts.googleapis.com/css2?family=Instrument+Serif:ital@0;1&family=Bricolage+Grotesque:opsz,wght@12..96,300;12..96,400;12..96,500;12..96,600;12..96,700&family=JetBrains+Mono:wght@400;500;600&display=swap', ); useDocumentMeta('@ifc-lite/mcp — drive an IFC from any LLM', NIGHT); return (
); } // ── backdrop ──────────────────────────────────────────────────────────────── function BackdropGrain(): ReactNode { // SVG fractal noise gives the dark field a subtle grain — keeps the page // from looking like flat black, especially on OLEDs. return ( ); } // ── top bar ───────────────────────────────────────────────────────────────── function TopBar(): ReactNode { return (
{/* Brand also acts as the back-to-viewer affordance, but the Viewer link in the nav makes that explicit so it doesn't rely on users guessing. */} ifc-lite / mcp · {MCP_VERSION}
Playground
); } // ── hero ──────────────────────────────────────────────────────────────────── function Hero(): ReactNode { const stats = useMemo(() => catalogStats(), []); return (
new · @ifc-lite/mcp v{MCP_VERSION}

Drive a building.
From a chat.

{stats.total} typed tools that let any LLM agent query, validate, mutate, and visualise real IFC building models. The same toolkit your engineers ship with, in a chat.

Try in playground
); } function Stat({ number, label, sublabel }: { number: number; label: string; sublabel?: string }): ReactNode { return (
{number}
{label} {sublabel && ( {sublabel} )}
); } /** * Hero stage — a real Three.js building (HeroScene.tsx) driven by twelve * distinct agent-transcript steps. Each step: * * • mutates the WebGL scene (colour / isolation / section / new entity / camera), * • prints its tool-call line under the canvas, * • optionally overlays a UI badge or panel (audit score, count histogram, * bSDD pset list, BCF pin, describe-selection card). * * The 12-step loop covers 7 of the MCP categories (Discovery, Query, * Validation, Mutation, BCF, bSDD, Viewer) so a viewer instantly sees the * surface is much wider than "colour the walls". */ function WireframeStage(): ReactNode { const [step, setStep] = useState(0); // Pin position in container-local pixels, fed by HeroScene every rAF. const [pinFrame, setPinFrame] = useState<{ x: number; y: number; visible: boolean } | null>(null); useEffect(() => { const t = setTimeout(() => setStep((s) => (s + 1) % HERO_STEPS.length), HERO_STEP_MS); return () => clearTimeout(t); }, [step]); const current = HERO_STEPS[step]; return (
{/* faint grid behind the canvas, masked outward so the building feels lit */}
{/* WebGL canvas */}
{/* per-step overlays (audit score, count histogram, pset list, pin caption, info card) */} {/* progress dots */}
{HERO_STEPS.map((_, i) => ( ))}
{/* Transcript: verb as the story headline, technical line beneath. Both sit on a thin glass strip so the building stays the hero. */}
{current.family} · step {String(step + 1).padStart(2, '0')} / {HERO_STEPS.length}
{current.verb}. {current.line}
); } /** * HeroOverlay — sparse, iconic UI per step. Each one shows the smallest * possible "proof of action" so the canvas stays the hero. No prose, no * tool names duplicated from the transcript bar — just the result. * * Pointer-events are off everywhere so OrbitControls never lose clicks. */ function HeroOverlay({ step, pinFrame, }: { step: HeroStep; pinFrame: { x: number; y: number; visible: boolean } | null; }): ReactNode { if (!step.overlay) return null; const o = step.overlay; const chrome = 'absolute z-20 rounded-md border px-3 py-2.5 backdrop-blur-md pointer-events-none'; const chromeStyle: CSSProperties = { borderColor: RULE, background: 'rgba(18,18,21,0.82)', color: PAPER, ...mono, }; // ── Audit: huge score in display serif, single sparkline-y bar. if (o.kind === 'audit') { const pct = Math.max(0, Math.min(100, o.score)); return (
{o.score} / 100
{o.note}
); } // ── Counts: a tiny histogram. Tall numerals, faint type labels, a bar // proportional to the largest row. Three rows max. if (o.kind === 'counts') { const max = Math.max(...o.rows.map((r) => r.n), 1); return (
    {o.rows.map((row) => (
  • Ifc{row.type}
    {row.n}
  • ))}
); } // ── Psets (bSDD): an alphanumeric data tag — Pset header rules + a few // canonical properties with their datatypes. Reads as a real spec // sheet, not a vague label list. if (o.kind === 'psets') { // Hard-coded sample property rows per Pset so the page renders even // before the live MCP tools/call response is wired up. Order kept // deterministic so the text doesn’t reflow between renders. const SAMPLE_ROWS: Record> = { Pset_WallCommon: [ { k: 'FireRating', v: 'EI60', t: 'string' }, { k: 'IsExternal', v: 'true', t: 'boolean' }, { k: 'LoadBearing', v: 'false', t: 'boolean' }, { k: 'AcousticRating', v: 'R45', t: 'string' }, ], Qto_WallBaseQuantities: [ { k: 'Length', v: '5.20', t: 'm' }, { k: 'Height', v: '3.00', t: 'm' }, { k: 'Volume', v: '3.74', t: 'm³' }, ], Pset_ConcreteElementGeneral: [ { k: 'StrengthClass', v: 'C30/37', t: 'string' }, { k: 'AssemblyPlace', v: 'SITE', t: 'enum' }, ], }; return (
bSDD · IfcWall {o.psets.length} Psets
{o.psets.map((psetName) => { const rows = SAMPLE_ROWS[psetName] ?? []; return (
{psetName}
{rows.length > 0 ? ( {rows.map((r) => ( ))}
{r.k} {r.v} {r.t}
) : (
— schema only —
)}
); })}
); } // ── BCF pin caption — the pin itself lives in WebGL (a Sprite anchored // to the wall), so this overlay is just the small alphanumeric label // that follows the pin's projected screen position. Hidden if we // don't have a fresh projection yet. if (o.kind === 'pin') { if (!pinFrame || !pinFrame.visible) return null; return (
{o.ref}
); } // ── Inspect card: a hairline frame, ref + at most two evidence lines. if (o.kind === 'card') { return (
{o.ref}
    {o.lines.map((line, i) => (
  • {line}
  • ))}
); } return null; } function FloatingScrollHint(): ReactNode { return ( ); } // ── install ───────────────────────────────────────────────────────────────── function InstallSection(): ReactNode { const [openClient, setOpenClient] = useState(null); const primary = CLIENTS.filter((c) => c.id !== 'goose'); const goose = CLIENTS.find((c) => c.id === 'goose'); return (
{primary.map((c, i) => ( setOpenClient(c.id)} /> ))}
{goose && ( )}
!o && setOpenClient(null)}> Install instructions {openClient && c.id === openClient)!} />}
); } function BigClientCard({ client, index, onOpen, }: { client: McpClient; index: number; onOpen: () => void; }): ReactNode { return ( ); } function BigInstallDetail({ client }: { client: McpClient }): ReactNode { const { copy, copiedKey } = useCopyToClipboard(); const snippet = makeConfigSnippet(client.id); const deepLink = makeDeepLink(client.id); return (
install / {client.name}

{client.deepLinkPrefix ? 'One click. Or copy.' : 'Drop in. Restart.'}

{deepLink && ( Open in {client.name} )}
{client.configHint}
          {snippet}
        
); } // ── recipes (horizontal carousel) ─────────────────────────────────────────── function RecipesSection(): ReactNode { const { copy, copiedKey } = useCopyToClipboard(); const scrollerRef = useRef(null); const [scrollState, setScrollState] = useState({ atStart: true, atEnd: false, page: 0, pages: 1 }); // Recompute scroll state on scroll + resize. Drives the fade gradients // and the pagination dots underneath. Pages are computed from how many // cards actually fit in the viewport so dots and page indices stay in // sync — when the last few cards are all visible, the last dot becomes // (and stays) active instead of being unreachable. useEffect(() => { const el = scrollerRef.current; if (!el) return; const computeState = () => { const max = el.scrollWidth - el.clientWidth; const atStart = el.scrollLeft <= 4; const atEnd = max - el.scrollLeft <= 4; const cardWidth = 360 + 24; const cardsPerPage = Math.max(1, Math.floor(el.clientWidth / cardWidth)); const pages = Math.max(1, Math.ceil(RECIPES.length / cardsPerPage)); const rawPage = Math.round(el.scrollLeft / (cardsPerPage * cardWidth)); const page = Math.max(0, Math.min(pages - 1, rawPage)); setScrollState({ atStart, atEnd, page, pages }); }; computeState(); el.addEventListener('scroll', computeState, { passive: true }); const ro = new ResizeObserver(computeState); ro.observe(el); return () => { el.removeEventListener('scroll', computeState); ro.disconnect(); }; }, []); function scrollByCard(dir: -1 | 1) { const el = scrollerRef.current; if (!el) return; el.scrollBy({ left: dir * (360 + 24), behavior: 'smooth' }); } return (
scrollByCard(-1)} dir="left" disabled={scrollState.atStart} /> scrollByCard(1)} dir="right" disabled={scrollState.atEnd} />
} />
{/* Full-bleed scroller wrapper so fades + spacers can sit outside the 1280-max content column. The cards align to the same gutter as the section header by padding the scroller with a calc() that mirrors the centred content width. */}
{RECIPES.map((recipe) => (
/ {recipe.family}

{recipe.title}

user

{recipe.prompt}

))}
{/* edge fades — purely cosmetic, must not eat clicks */}
{/* pagination dots */}
{RECIPES.length} recipes · scroll →
{/* One dot per page (not per recipe), so as cards-per-page changes with viewport width the active highlight remains reachable. */} {Array.from({ length: scrollState.pages }, (_, i) => ( ))}
); } function CarouselButton({ onClick, dir, disabled, }: { onClick: () => void; dir: 'left' | 'right'; disabled?: boolean; }): ReactNode { return ( ); } // ── catalog ───────────────────────────────────────────────────────────────── function CatalogSection(): ReactNode { const grouped = useMemo(() => toolsByCategory(), []); const [activeCat, setActiveCat] = useState('Viewer'); return (
{CATALOG.tools.length}{' '} typed tools.{' '}
Everything an agent needs. } />
{CATEGORY_ORDER.map((cat) => { const isActive = activeCat === cat; return ( ); })}

{activeCat}

{CATEGORY_BLURBS[activeCat]}

    {(grouped.get(activeCat) ?? []).map((tool) => ( ))}
); } function CatalogToolRow({ tool }: { tool: CatalogTool }): ReactNode { const [open, setOpen] = useState(false); const params = useMemo(() => paramsFor(tool), [tool]); const example = useMemo(() => exampleCall(tool), [tool]); const signature = useMemo(() => buildSignature(tool.name, params), [tool.name, params]); return (
  • {open && }
  • ); } /** Pretty function-style signature for the detail header. */ function buildSignature(name: string, params: ParamRow[]): string { if (params.length === 0) return `${name}()`; const reqd = params.filter((p) => p.required); if (reqd.length === 0) return `${name}({ … })`; return `${name}({ ${reqd.map((p) => p.name).join(', ')}${reqd.length < params.length ? ', …' : ''} })`; } function CatalogToolDetail({ tool, signature, params, example, }: { tool: CatalogTool; signature: string; params: ParamRow[]; example: string; }): ReactNode { const { copy, copiedKey } = useCopyToClipboard(); return (
    {/* Signature */}
    Signature
    {signature}

    {tool.description}

    {/* Parameter table */}
    Parameters · {params.length}
    {params.length === 0 ? (

    No parameters — call with {`{}`}.

    ) : (
    {params.map((p) => ( ))}
    name type req description
    {p.name} {p.type} {p.required ? ( yes ) : ( )} {p.description ?? }
    )}
    {/* Example call */}
    Example call
              {example}
            
    {/* Footer actions */}
    ); } function ScopePill({ scope }: { scope: CatalogTool['scope'] }): ReactNode { const colors: Record = { read: ACCENT, mutate: ACCENT_2, export: '#73daca', }; return ( {scope} ); } // ── footer ────────────────────────────────────────────────────────────────── function Footer(): ReactNode { return (

    Bring your model.
    We brought the tools.

    Open the playground
    ifc-lite/mcp · v{MCP_VERSION} · MPL-2.0 Dark by intent.
    ); } function FooterCol({ heading, links }: { heading: string; links: { href: string; label: string }[] }): ReactNode { return (
    {heading} {links.map((l) => ( {l.label} ))}
    ); } // ── shared shells ─────────────────────────────────────────────────────────── function SectionHeader({ number, eyebrow, title, right, }: { number: string; eyebrow: string; title: ReactNode; right?: ReactNode; }): ReactNode { return (
    §{number} {eyebrow}

    {title}

    {right}
    ); }