@ifc-lite/viewer 1.19.0 → 1.19.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/.turbo/turbo-build.log +15 -14
  2. package/.turbo/turbo-typecheck.log +1 -1
  3. package/CHANGELOG.md +8 -0
  4. package/dist/assets/basketViewActivator-CA2CTcVo.js +71 -0
  5. package/dist/assets/{bcf-DOG9_WPX.js → bcf-4K724hw0.js} +18 -18
  6. package/dist/assets/{exporters-BraHBeoi.js → exporters-xbXqEDlO.js} +53 -46
  7. package/dist/assets/ids-2WdONLlu.js +2033 -0
  8. package/dist/assets/index-BXeEKqJG.css +1 -0
  9. package/dist/assets/{index-BOi3BuUI.js → index-D8Epw-e7.js} +48072 -30928
  10. package/dist/assets/{native-bridge-CpBeOPQa.js → native-bridge-DKmx1z95.js} +2 -2
  11. package/dist/assets/{sandbox-Baez7n-t.js → sandbox-tccwm5Bo.js} +547 -529
  12. package/dist/assets/{server-client-BB6cMAXE.js → server-client-LoWPK1N2.js} +1 -1
  13. package/dist/assets/three-CDRZThFA.js +4057 -0
  14. package/dist/assets/{wasm-bridge-CAYCUHbE.js → wasm-bridge-BsJGgPMs.js} +1 -1
  15. package/dist/index.html +8 -7
  16. package/dist/samples/building-architecture.ifc +453 -0
  17. package/dist/samples/hello-wall.ifc +1054 -0
  18. package/dist/samples/infra-bridge.ifc +962 -0
  19. package/package.json +7 -2
  20. package/public/samples/building-architecture.ifc +453 -0
  21. package/public/samples/hello-wall.ifc +1054 -0
  22. package/public/samples/infra-bridge.ifc +962 -0
  23. package/src/App.tsx +37 -3
  24. package/src/components/mcp/HeroScene.tsx +876 -0
  25. package/src/components/mcp/McpLanding.tsx +1318 -0
  26. package/src/components/mcp/McpPlayground.tsx +524 -0
  27. package/src/components/mcp/PlaygroundChat.tsx +1097 -0
  28. package/src/components/mcp/PlaygroundViewer.tsx +815 -0
  29. package/src/components/mcp/README.md +171 -0
  30. package/src/components/mcp/data.ts +659 -0
  31. package/src/components/mcp/playground-dispatcher.ts +1649 -0
  32. package/src/components/mcp/playground-files.ts +107 -0
  33. package/src/components/mcp/playground-uploads.ts +122 -0
  34. package/src/components/mcp/types.ts +65 -0
  35. package/src/components/mcp/use-mcp-page.ts +109 -0
  36. package/src/components/viewer/MainToolbar.tsx +19 -0
  37. package/src/components/viewer/ViewportContainer.tsx +35 -4
  38. package/src/generated/mcp-catalog.json +82 -0
  39. package/vite.config.ts +6 -0
  40. package/dist/assets/basketViewActivator-RZy5c3Td.js +0 -1
  41. package/dist/assets/ids-DQ5jY0E8.js +0 -1
  42. package/dist/assets/index-0XpVr_S5.css +0 -1
@@ -0,0 +1,1318 @@
1
+ /* This Source Code Form is subject to the terms of the Mozilla Public
2
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
4
+
5
+ /**
6
+ * Variant B — "Stage"
7
+ *
8
+ * Cinematic, dark-by-default, demo-driven. The point of this variant is to
9
+ * make people feel the agent driving the model. So the hero is an animated
10
+ * IFC wireframe that progressively colorises itself as a fake transcript
11
+ * scrolls underneath ("agent: viewer_isolate IfcWall …"). Big confident
12
+ * type, generous breathing room, and recipes shown as a horizontally
13
+ * scrolling carousel of stylised agent conversations.
14
+ *
15
+ * Typography: Instrument Serif (italic, for the flex character) carries
16
+ * display + numerals. Bricolage Grotesque (variable) does the body work.
17
+ * JetBrains Mono for code. The chartreuse accent (#d6ff3f) is a nod to
18
+ * construction-safety hi-vis — distinctive on a black field, never seen on
19
+ * a generic SaaS landing.
20
+ *
21
+ * The variant explicitly forces dark on its own subtree without flipping
22
+ * the global .dark class, so the rest of the SPA isn’t affected when the
23
+ * user navigates away.
24
+ */
25
+
26
+ import {
27
+ type CSSProperties,
28
+ type ReactNode,
29
+ useEffect,
30
+ useMemo,
31
+ useRef,
32
+ useState,
33
+ } from 'react';
34
+ import {
35
+ ArrowDown,
36
+ ArrowLeft,
37
+ ArrowUpRight,
38
+ Check,
39
+ ChevronRight,
40
+ Copy,
41
+ Play,
42
+ Sparkles,
43
+ Sun,
44
+ Terminal,
45
+ } from 'lucide-react';
46
+ import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog';
47
+ import { cn } from '@/lib/utils';
48
+ import { HeroScene, HERO_STEPS, HERO_STEP_MS, type HeroStep } from './HeroScene';
49
+ import {
50
+ CATALOG,
51
+ CATEGORY_BLURBS,
52
+ CATEGORY_ORDER,
53
+ CLIENTS,
54
+ EXAMPLES,
55
+ FAMILY_ACCENT,
56
+ MCP_VERSION,
57
+ RECIPES,
58
+ catalogStats,
59
+ exampleCall,
60
+ makeConfigSnippet,
61
+ makeDeepLink,
62
+ paramsFor,
63
+ toolsByCategory,
64
+ type ParamRow,
65
+ } from './data';
66
+ import type { CatalogTool, McpClient, McpClientId, ToolCategory } from './types';
67
+ import { scrollToAnchor, useCopyToClipboard, useDocumentMeta, useFonts } from './use-mcp-page';
68
+
69
+ const NIGHT = '#0a0a0c';
70
+ const NIGHT_2 = '#121215';
71
+ const PAPER = '#ede4d3';
72
+ const PAPER_DIM = '#9c9486';
73
+ const ACCENT = '#d6ff3f'; // hi-vis chartreuse
74
+ const ACCENT_2 = '#ff5cdc'; // magenta for hover/active
75
+ const RULE = 'rgba(237, 228, 211, 0.10)';
76
+
77
+ const stage: CSSProperties = {
78
+ background: NIGHT,
79
+ color: PAPER,
80
+ fontFamily: '"Bricolage Grotesque", "Inter Tight", system-ui, sans-serif',
81
+ fontFeatureSettings: '"ss01" 1, "ss02" 1, "cv11" 1',
82
+ };
83
+
84
+ const display: CSSProperties = {
85
+ fontFamily: '"Instrument Serif", "Newsreader", Georgia, serif',
86
+ fontWeight: 400,
87
+ fontStyle: 'normal',
88
+ };
89
+
90
+ const mono: CSSProperties = {
91
+ fontFamily: '"JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, monospace',
92
+ };
93
+
94
+ export function McpLanding(): ReactNode {
95
+ useFonts(
96
+ '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',
97
+ );
98
+ useDocumentMeta('@ifc-lite/mcp — drive an IFC from any LLM', NIGHT);
99
+
100
+ return (
101
+ <main style={stage} className="relative min-h-screen overflow-hidden antialiased">
102
+ <BackdropGrain />
103
+ <TopBar />
104
+ <Hero />
105
+ <FloatingScrollHint />
106
+ <InstallSection />
107
+ <RecipesSection />
108
+ <CatalogSection />
109
+ <Footer />
110
+ </main>
111
+ );
112
+ }
113
+
114
+ // ── backdrop ────────────────────────────────────────────────────────────────
115
+
116
+ function BackdropGrain(): ReactNode {
117
+ // SVG fractal noise gives the dark field a subtle grain — keeps the page
118
+ // from looking like flat black, especially on OLEDs.
119
+ return (
120
+ <svg
121
+ aria-hidden
122
+ className="pointer-events-none fixed inset-0 z-0 h-full w-full opacity-[0.08] mix-blend-overlay"
123
+ >
124
+ <filter id="g">
125
+ <feTurbulence type="fractalNoise" baseFrequency="0.85" numOctaves="2" stitchTiles="stitch" />
126
+ </filter>
127
+ <rect width="100%" height="100%" filter="url(#g)" />
128
+ </svg>
129
+ );
130
+ }
131
+
132
+ // ── top bar ─────────────────────────────────────────────────────────────────
133
+
134
+ function TopBar(): ReactNode {
135
+ return (
136
+ <div className="relative z-10 border-b" style={{ borderColor: RULE }}>
137
+ <div className="mx-auto flex max-w-[1280px] items-center justify-between px-6 py-5">
138
+ <div className="flex items-baseline gap-3">
139
+ {/* Brand also acts as the back-to-viewer affordance, but the
140
+ Viewer link in the nav makes that explicit so it doesn't
141
+ rely on users guessing. */}
142
+ <a href="/" className="text-[16px] tracking-tight" style={{ color: PAPER, fontWeight: 600 }}>
143
+ ifc-lite
144
+ </a>
145
+ <span style={{ ...mono, color: PAPER_DIM }} className="text-[10px] uppercase tracking-[0.22em]">
146
+ / mcp · {MCP_VERSION}
147
+ </span>
148
+ </div>
149
+ <nav className="hidden items-center gap-7 text-[13.5px] sm:flex" style={{ color: PAPER_DIM, fontWeight: 500 }}>
150
+ <a
151
+ href="/"
152
+ className="group inline-flex items-center gap-1 transition-colors hover:text-[var(--paper)]"
153
+ style={{ ['--paper' as never]: PAPER }}
154
+ >
155
+ <ArrowLeft size={12} className="transition-transform group-hover:-translate-x-0.5" />
156
+ Viewer
157
+ </a>
158
+ <a href="#install" className="transition-colors hover:text-[var(--paper)]" style={{ ['--paper' as never]: PAPER }}>Install</a>
159
+ <a href="#recipes" className="transition-colors hover:text-[var(--paper)]" style={{ ['--paper' as never]: PAPER }}>Recipes</a>
160
+ <a href="#tools" className="transition-colors hover:text-[var(--paper)]" style={{ ['--paper' as never]: PAPER }}>Tools</a>
161
+ </nav>
162
+ <a
163
+ href="/mcp/playground"
164
+ className="group relative inline-flex items-center gap-1.5 px-4 py-2 text-[13px] font-medium tracking-tight transition-colors"
165
+ style={{ background: ACCENT, color: NIGHT, borderRadius: 999 }}
166
+ >
167
+ <Play size={12} fill={NIGHT} />
168
+ Playground
169
+ <ArrowUpRight size={13} className="transition-transform group-hover:translate-x-0.5" />
170
+ </a>
171
+ </div>
172
+ </div>
173
+ );
174
+ }
175
+
176
+ // ── hero ────────────────────────────────────────────────────────────────────
177
+
178
+ function Hero(): ReactNode {
179
+ const stats = useMemo(() => catalogStats(), []);
180
+ return (
181
+ <section className="relative z-10 overflow-hidden">
182
+ <div className="mx-auto max-w-[1280px] px-6 pt-20 pb-32 md:pt-32 md:pb-44">
183
+ <div className="grid grid-cols-12 gap-8">
184
+ <div className="col-span-12 md:col-span-7">
185
+ <div className="mb-6 inline-flex items-center gap-2 rounded-full border px-3 py-1.5 text-[11px] uppercase tracking-[0.2em]" style={{ borderColor: RULE, color: ACCENT, ...mono }}>
186
+ <Sparkles size={12} />
187
+ new · @ifc-lite/mcp v{MCP_VERSION}
188
+ </div>
189
+ <h1
190
+ className="text-[58px] leading-[0.92] tracking-[-0.022em] md:text-[112px]"
191
+ style={{ ...display, color: PAPER }}
192
+ >
193
+ Drive a building.
194
+ <br />
195
+ <span style={{ fontStyle: 'italic', color: ACCENT }}>From a chat.</span>
196
+ </h1>
197
+ <p
198
+ className="mt-8 max-w-[34rem] text-[18px] leading-[1.55] md:text-[20px]"
199
+ style={{ color: PAPER_DIM, fontWeight: 400 }}
200
+ >
201
+ {stats.total} typed tools that let any LLM agent query, validate, mutate, and
202
+ visualise real IFC building models. The same toolkit your engineers ship with, in a
203
+ chat.
204
+ </p>
205
+ <div className="mt-10 flex flex-wrap items-center gap-3">
206
+ <a
207
+ href="/mcp/playground"
208
+ className="group relative inline-flex items-center gap-2 px-7 py-4 text-[15px] font-semibold tracking-tight"
209
+ style={{ background: ACCENT, color: NIGHT, borderRadius: 6 }}
210
+ >
211
+ <Play size={14} fill={NIGHT} />
212
+ Try in playground
213
+ <ArrowUpRight size={14} className="transition-transform group-hover:translate-x-0.5 group-hover:-translate-y-0.5" />
214
+ <span
215
+ className="absolute -bottom-1 -right-1 -z-10 h-full w-full"
216
+ style={{ background: ACCENT_2, borderRadius: 6 }}
217
+ aria-hidden
218
+ />
219
+ </a>
220
+ <button
221
+ onClick={() => scrollToAnchor('install')}
222
+ className="inline-flex items-center gap-2 px-6 py-4 text-[15px] font-medium tracking-tight transition-colors hover:bg-white/5"
223
+ style={{ border: `1px solid ${PAPER}40`, color: PAPER, borderRadius: 6 }}
224
+ >
225
+ <Terminal size={14} />
226
+ Install
227
+ </button>
228
+ </div>
229
+ <div className="mt-12 flex flex-wrap items-center gap-x-10 gap-y-4">
230
+ <Stat number={stats.total} label="typed tools" />
231
+ <Stat number={stats.categories} label="categories" />
232
+ <Stat number={5} label="MCP clients" />
233
+ <Stat number={2} label="transports" sublabel="stdio · http" />
234
+ </div>
235
+ </div>
236
+
237
+ <div className="col-span-12 md:col-span-5">
238
+ <WireframeStage />
239
+ </div>
240
+ </div>
241
+ </div>
242
+ </section>
243
+ );
244
+ }
245
+
246
+ function Stat({ number, label, sublabel }: { number: number; label: string; sublabel?: string }): ReactNode {
247
+ return (
248
+ <div className="flex items-baseline gap-2">
249
+ <span style={{ ...display, color: PAPER, fontStyle: 'italic' }} className="text-[44px] leading-none">
250
+ {number}
251
+ </span>
252
+ <div className="flex flex-col leading-tight">
253
+ <span className="text-[12px] uppercase tracking-[0.18em]" style={{ color: PAPER_DIM, fontWeight: 600 }}>
254
+ {label}
255
+ </span>
256
+ {sublabel && (
257
+ <span style={{ ...mono, color: PAPER_DIM }} className="text-[10px]">
258
+ {sublabel}
259
+ </span>
260
+ )}
261
+ </div>
262
+ </div>
263
+ );
264
+ }
265
+
266
+ /**
267
+ * Hero stage — a real Three.js building (HeroScene.tsx) driven by twelve
268
+ * distinct agent-transcript steps. Each step:
269
+ *
270
+ * • mutates the WebGL scene (colour / isolation / section / new entity / camera),
271
+ * • prints its tool-call line under the canvas,
272
+ * • optionally overlays a UI badge or panel (audit score, count histogram,
273
+ * bSDD pset list, BCF pin, describe-selection card).
274
+ *
275
+ * The 12-step loop covers 7 of the MCP categories (Discovery, Query,
276
+ * Validation, Mutation, BCF, bSDD, Viewer) so a viewer instantly sees the
277
+ * surface is much wider than "colour the walls".
278
+ */
279
+ function WireframeStage(): ReactNode {
280
+ const [step, setStep] = useState(0);
281
+ // Pin position in container-local pixels, fed by HeroScene every rAF.
282
+ const [pinFrame, setPinFrame] = useState<{ x: number; y: number; visible: boolean } | null>(null);
283
+
284
+ useEffect(() => {
285
+ const t = setTimeout(() => setStep((s) => (s + 1) % HERO_STEPS.length), HERO_STEP_MS);
286
+ return () => clearTimeout(t);
287
+ }, [step]);
288
+
289
+ const current = HERO_STEPS[step];
290
+
291
+ return (
292
+ <div
293
+ className="relative aspect-[4/5] w-full overflow-hidden rounded-lg border"
294
+ style={{ borderColor: RULE, background: NIGHT_2 }}
295
+ >
296
+ {/* faint grid behind the canvas, masked outward so the building feels lit */}
297
+ <div
298
+ className="pointer-events-none absolute inset-0 z-0"
299
+ style={{
300
+ backgroundImage: `linear-gradient(${RULE} 1px, transparent 1px), linear-gradient(90deg, ${RULE} 1px, transparent 1px)`,
301
+ backgroundSize: '24px 24px',
302
+ maskImage: 'radial-gradient(ellipse at 50% 45%, transparent 12%, black 70%)',
303
+ WebkitMaskImage: 'radial-gradient(ellipse at 50% 45%, transparent 12%, black 70%)',
304
+ }}
305
+ />
306
+ {/* WebGL canvas */}
307
+ <div className="absolute inset-0 z-10">
308
+ <HeroScene step={step} className="h-full w-full" onPinFrame={setPinFrame} />
309
+ </div>
310
+
311
+ {/* per-step overlays (audit score, count histogram, pset list, pin caption, info card) */}
312
+ <HeroOverlay step={current} pinFrame={pinFrame} />
313
+
314
+ {/* progress dots */}
315
+ <div className="absolute right-3 top-3 z-30 flex flex-col gap-1.5">
316
+ {HERO_STEPS.map((_, i) => (
317
+ <span
318
+ key={i}
319
+ className="block h-1 rounded-full transition-all"
320
+ style={{
321
+ background: i === step ? ACCENT : `${PAPER}40`,
322
+ width: i === step ? 18 : 6,
323
+ }}
324
+ />
325
+ ))}
326
+ </div>
327
+
328
+ {/* Transcript: verb as the story headline, technical line beneath.
329
+ Both sit on a thin glass strip so the building stays the hero. */}
330
+ <div
331
+ className="absolute inset-x-0 bottom-0 z-30 border-t"
332
+ style={{ borderColor: RULE, background: 'rgba(10,10,12,0.82)', backdropFilter: 'blur(10px)' }}
333
+ >
334
+ <div className="flex items-end gap-4 px-5 py-3">
335
+ <div className="min-w-0 flex-1">
336
+ <div
337
+ className="mb-0.5 flex items-center gap-2 text-[9.5px] uppercase tracking-[0.22em]"
338
+ style={{ ...mono }}
339
+ >
340
+ <span className="inline-flex items-center gap-1.5" style={{ color: ACCENT }}>
341
+ <span
342
+ className="inline-block h-1.5 w-1.5 animate-pulse rounded-full"
343
+ style={{ background: ACCENT }}
344
+ />
345
+ {current.family}
346
+ </span>
347
+ <span style={{ color: PAPER_DIM }}>· step {String(step + 1).padStart(2, '0')} / {HERO_STEPS.length}</span>
348
+ </div>
349
+ <div className="flex items-baseline gap-3">
350
+ <span
351
+ style={{ ...display, color: PAPER, fontStyle: 'italic' }}
352
+ className="truncate text-[28px] leading-none tracking-[-0.01em]"
353
+ >
354
+ {current.verb}.
355
+ </span>
356
+ <code className="truncate text-[11.5px]" style={{ ...mono, color: PAPER_DIM }}>
357
+ {current.line}
358
+ </code>
359
+ </div>
360
+ </div>
361
+ </div>
362
+ </div>
363
+ </div>
364
+ );
365
+ }
366
+
367
+ /**
368
+ * HeroOverlay — sparse, iconic UI per step. Each one shows the smallest
369
+ * possible "proof of action" so the canvas stays the hero. No prose, no
370
+ * tool names duplicated from the transcript bar — just the result.
371
+ *
372
+ * Pointer-events are off everywhere so OrbitControls never lose clicks.
373
+ */
374
+ function HeroOverlay({
375
+ step,
376
+ pinFrame,
377
+ }: {
378
+ step: HeroStep;
379
+ pinFrame: { x: number; y: number; visible: boolean } | null;
380
+ }): ReactNode {
381
+ if (!step.overlay) return null;
382
+ const o = step.overlay;
383
+
384
+ const chrome =
385
+ 'absolute z-20 rounded-md border px-3 py-2.5 backdrop-blur-md pointer-events-none';
386
+ const chromeStyle: CSSProperties = {
387
+ borderColor: RULE,
388
+ background: 'rgba(18,18,21,0.82)',
389
+ color: PAPER,
390
+ ...mono,
391
+ };
392
+
393
+ // ── Audit: huge score in display serif, single sparkline-y bar.
394
+ if (o.kind === 'audit') {
395
+ const pct = Math.max(0, Math.min(100, o.score));
396
+ return (
397
+ <div className={chrome} style={{ ...chromeStyle, left: 14, top: 14, width: 132 }}>
398
+ <div className="flex items-baseline gap-1.5">
399
+ <span style={{ ...display, color: ACCENT }} className="text-[44px] leading-none">{o.score}</span>
400
+ <span className="text-[9px] uppercase tracking-[0.2em]" style={{ color: PAPER_DIM }}>
401
+ / 100
402
+ </span>
403
+ </div>
404
+ <div className="mt-2 h-px w-full" style={{ background: `${PAPER}22` }}>
405
+ <div className="h-px transition-all" style={{ width: `${pct}%`, background: ACCENT }} />
406
+ </div>
407
+ <div className="mt-1.5 text-[9.5px] uppercase tracking-[0.2em]" style={{ color: PAPER_DIM }}>
408
+ {o.note}
409
+ </div>
410
+ </div>
411
+ );
412
+ }
413
+
414
+ // ── Counts: a tiny histogram. Tall numerals, faint type labels, a bar
415
+ // proportional to the largest row. Three rows max.
416
+ if (o.kind === 'counts') {
417
+ const max = Math.max(...o.rows.map((r) => r.n), 1);
418
+ return (
419
+ <div className={chrome} style={{ ...chromeStyle, left: 14, top: 14, width: 188 }}>
420
+ <ul className="flex flex-col gap-2">
421
+ {o.rows.map((row) => (
422
+ <li key={row.type} className="grid grid-cols-[1fr_auto] items-baseline gap-2">
423
+ <div>
424
+ <div className="text-[9px] uppercase tracking-[0.22em]" style={{ color: PAPER_DIM }}>
425
+ Ifc{row.type}
426
+ </div>
427
+ <div className="mt-1 h-[2px] w-full" style={{ background: `${PAPER}18` }}>
428
+ <div
429
+ className="h-[2px] transition-all"
430
+ style={{ width: `${(row.n / max) * 100}%`, background: ACCENT }}
431
+ />
432
+ </div>
433
+ </div>
434
+ <span style={{ ...display, color: PAPER }} className="text-[22px] leading-none">
435
+ {row.n}
436
+ </span>
437
+ </li>
438
+ ))}
439
+ </ul>
440
+ </div>
441
+ );
442
+ }
443
+
444
+ // ── Psets (bSDD): an alphanumeric data tag — Pset header rules + a few
445
+ // canonical properties with their datatypes. Reads as a real spec
446
+ // sheet, not a vague label list.
447
+ if (o.kind === 'psets') {
448
+ // Hard-coded sample property rows per Pset so the page renders even
449
+ // before the live MCP tools/call response is wired up. Order kept
450
+ // deterministic so the text doesn’t reflow between renders.
451
+ const SAMPLE_ROWS: Record<string, Array<{ k: string; v: string; t: string }>> = {
452
+ Pset_WallCommon: [
453
+ { k: 'FireRating', v: 'EI60', t: 'string' },
454
+ { k: 'IsExternal', v: 'true', t: 'boolean' },
455
+ { k: 'LoadBearing', v: 'false', t: 'boolean' },
456
+ { k: 'AcousticRating', v: 'R45', t: 'string' },
457
+ ],
458
+ Qto_WallBaseQuantities: [
459
+ { k: 'Length', v: '5.20', t: 'm' },
460
+ { k: 'Height', v: '3.00', t: 'm' },
461
+ { k: 'Volume', v: '3.74', t: 'm³' },
462
+ ],
463
+ Pset_ConcreteElementGeneral: [
464
+ { k: 'StrengthClass', v: 'C30/37', t: 'string' },
465
+ { k: 'AssemblyPlace', v: 'SITE', t: 'enum' },
466
+ ],
467
+ };
468
+
469
+ return (
470
+ <div
471
+ className={chrome}
472
+ style={{ ...chromeStyle, right: 14, top: 14, width: 280, padding: 0, overflow: 'hidden' }}
473
+ >
474
+ <header
475
+ className="flex items-baseline justify-between gap-2 px-3 py-2 border-b"
476
+ style={{ borderColor: RULE, background: 'rgba(46,95,199,0.18)' }}
477
+ >
478
+ <span className="text-[9.5px] uppercase tracking-[0.24em]" style={{ color: '#7aa2f7' }}>
479
+ bSDD · IfcWall
480
+ </span>
481
+ <span className="text-[9.5px]" style={{ color: PAPER_DIM }}>
482
+ {o.psets.length} Psets
483
+ </span>
484
+ </header>
485
+ <div className="max-h-[260px] overflow-hidden">
486
+ {o.psets.map((psetName) => {
487
+ const rows = SAMPLE_ROWS[psetName] ?? [];
488
+ return (
489
+ <div key={psetName} className="border-b last:border-b-0" style={{ borderColor: RULE }}>
490
+ <div
491
+ className="px-3 py-1.5 text-[10px] uppercase tracking-[0.18em]"
492
+ style={{ color: ACCENT, background: 'rgba(255,255,255,0.02)' }}
493
+ >
494
+ {psetName}
495
+ </div>
496
+ {rows.length > 0 ? (
497
+ <table className="w-full">
498
+ <tbody>
499
+ {rows.map((r) => (
500
+ <tr key={r.k}>
501
+ <td className="px-3 py-0.5 text-[10.5px]" style={{ color: PAPER }}>{r.k}</td>
502
+ <td className="px-2 py-0.5 text-right text-[10.5px]" style={{ color: PAPER }}>
503
+ {r.v}
504
+ </td>
505
+ <td className="px-3 py-0.5 text-right text-[9px] uppercase tracking-[0.18em]" style={{ color: PAPER_DIM }}>
506
+ {r.t}
507
+ </td>
508
+ </tr>
509
+ ))}
510
+ </tbody>
511
+ </table>
512
+ ) : (
513
+ <div className="px-3 py-1.5 text-[9.5px]" style={{ color: PAPER_DIM }}>
514
+ — schema only —
515
+ </div>
516
+ )}
517
+ </div>
518
+ );
519
+ })}
520
+ </div>
521
+ </div>
522
+ );
523
+ }
524
+
525
+ // ── BCF pin caption — the pin itself lives in WebGL (a Sprite anchored
526
+ // to the wall), so this overlay is just the small alphanumeric label
527
+ // that follows the pin's projected screen position. Hidden if we
528
+ // don't have a fresh projection yet.
529
+ if (o.kind === 'pin') {
530
+ if (!pinFrame || !pinFrame.visible) return null;
531
+ return (
532
+ <div
533
+ className={chrome}
534
+ style={{
535
+ ...chromeStyle,
536
+ left: pinFrame.x + 22,
537
+ top: pinFrame.y - 14,
538
+ borderColor: '#ff3a3a55',
539
+ padding: '4px 8px',
540
+ background: 'rgba(40,12,12,0.86)',
541
+ }}
542
+ >
543
+ <span className="text-[10.5px] tracking-[0.08em]" style={{ color: '#ffb6b6' }}>
544
+ {o.ref}
545
+ </span>
546
+ </div>
547
+ );
548
+ }
549
+
550
+ // ── Inspect card: a hairline frame, ref + at most two evidence lines.
551
+ if (o.kind === 'card') {
552
+ return (
553
+ <div className={chrome} style={{ ...chromeStyle, right: 14, bottom: 78, width: 268 }}>
554
+ <div style={{ ...display, color: PAPER }} className="text-[18px] leading-none">
555
+ {o.ref}
556
+ </div>
557
+ <div className="mt-2 h-px w-full" style={{ background: `${PAPER}22` }} />
558
+ <ul className="mt-2 flex flex-col gap-1">
559
+ {o.lines.map((line, i) => (
560
+ <li key={i} className="flex items-baseline gap-2">
561
+ <span style={{ ...mono, color: ACCENT }} className="text-[9px]">
562
+
563
+ </span>
564
+ <span className="text-[10.5px] leading-snug" style={{ color: PAPER }}>
565
+ {line}
566
+ </span>
567
+ </li>
568
+ ))}
569
+ </ul>
570
+ </div>
571
+ );
572
+ }
573
+
574
+ return null;
575
+ }
576
+
577
+ function FloatingScrollHint(): ReactNode {
578
+ return (
579
+ <button
580
+ onClick={() => scrollToAnchor('install')}
581
+ className="absolute bottom-6 left-1/2 z-10 hidden -translate-x-1/2 flex-col items-center gap-2 md:flex"
582
+ style={{ color: PAPER_DIM }}
583
+ >
584
+ <ArrowDown size={14} className="animate-bounce" />
585
+ <span style={{ ...mono }} className="text-[10px] uppercase tracking-[0.2em]">scroll</span>
586
+ </button>
587
+ );
588
+ }
589
+
590
+ // ── install ─────────────────────────────────────────────────────────────────
591
+
592
+ function InstallSection(): ReactNode {
593
+ const [openClient, setOpenClient] = useState<McpClientId | null>(null);
594
+ const primary = CLIENTS.filter((c) => c.id !== 'goose');
595
+ const goose = CLIENTS.find((c) => c.id === 'goose');
596
+
597
+ return (
598
+ <section id="install" className="relative z-10 border-t border-b py-24" style={{ borderColor: RULE }}>
599
+ <div className="mx-auto max-w-[1280px] px-6">
600
+ <SectionHeader number="01" eyebrow="Install" title="Pick your client. We brought a snippet." />
601
+
602
+ <div className="mt-12 grid grid-cols-1 gap-6 sm:grid-cols-2">
603
+ {primary.map((c, i) => (
604
+ <BigClientCard
605
+ key={c.id}
606
+ client={c}
607
+ index={i}
608
+ onOpen={() => setOpenClient(c.id)}
609
+ />
610
+ ))}
611
+ </div>
612
+
613
+ {goose && (
614
+ <button
615
+ onClick={() => setOpenClient('goose')}
616
+ className="group mt-6 flex w-full items-center justify-between gap-4 rounded-md border px-6 py-5 text-left transition-colors hover:bg-white/5"
617
+ style={{ borderColor: RULE }}
618
+ >
619
+ <div className="flex items-baseline gap-4">
620
+ <span style={{ ...mono, color: PAPER_DIM }} className="text-[10px] uppercase tracking-[0.2em]">also</span>
621
+ <span className="text-[16px] font-medium" style={{ color: PAPER }}>{goose.name}</span>
622
+ <span className="text-[13px]" style={{ color: PAPER_DIM }}>{goose.blurb}</span>
623
+ </div>
624
+ <ArrowUpRight size={16} style={{ color: PAPER_DIM }} className="transition-transform group-hover:translate-x-0.5 group-hover:-translate-y-0.5" />
625
+ </button>
626
+ )}
627
+ </div>
628
+
629
+ <Dialog open={openClient !== null} onOpenChange={(o) => !o && setOpenClient(null)}>
630
+ <DialogContent
631
+ className="max-w-2xl border-0 p-0 shadow-2xl"
632
+ style={{ background: NIGHT_2, color: PAPER, borderRadius: 12 }}
633
+ >
634
+ <DialogTitle className="sr-only">Install instructions</DialogTitle>
635
+ {openClient && <BigInstallDetail client={CLIENTS.find((c) => c.id === openClient)!} />}
636
+ </DialogContent>
637
+ </Dialog>
638
+ </section>
639
+ );
640
+ }
641
+
642
+ function BigClientCard({
643
+ client,
644
+ index,
645
+ onOpen,
646
+ }: {
647
+ client: McpClient;
648
+ index: number;
649
+ onOpen: () => void;
650
+ }): ReactNode {
651
+ return (
652
+ <button
653
+ onClick={onOpen}
654
+ className="group relative flex flex-col gap-6 overflow-hidden rounded-xl border p-7 text-left transition-all hover:-translate-y-0.5"
655
+ style={{ borderColor: RULE, background: NIGHT_2 }}
656
+ >
657
+ <div
658
+ className="absolute -right-12 -top-12 h-40 w-40 rounded-full opacity-0 blur-3xl transition-opacity group-hover:opacity-30"
659
+ style={{ background: ACCENT }}
660
+ aria-hidden
661
+ />
662
+ <div className="flex items-baseline justify-between">
663
+ <span style={{ ...mono, color: ACCENT }} className="text-[10px] uppercase tracking-[0.22em]">
664
+ 0{index + 1} / {client.deepLinkPrefix ? 'one-click' : 'paste config'}
665
+ </span>
666
+ <span style={{ ...mono, color: PAPER_DIM }} className="text-[10px]">
667
+ {client.deepLinkPrefix ?? 'manual'}
668
+ </span>
669
+ </div>
670
+ <div>
671
+ <h3
672
+ className="text-[36px] leading-[0.95] tracking-[-0.01em] transition-colors group-hover:text-[var(--accent)]"
673
+ style={{ ...display, color: PAPER, ['--accent' as never]: ACCENT }}
674
+ >
675
+ {client.name}
676
+ </h3>
677
+ <p className="mt-3 text-[14.5px] leading-[1.5]" style={{ color: PAPER_DIM }}>
678
+ {client.blurb}
679
+ </p>
680
+ </div>
681
+ <div className="flex items-center justify-between">
682
+ <code style={{ ...mono, color: PAPER_DIM }} className="text-[10.5px] truncate" title={client.configHint}>
683
+ {client.configHint.replace(/^~/, '~')}
684
+ </code>
685
+ <ArrowUpRight size={16} className="transition-transform group-hover:translate-x-1 group-hover:-translate-y-1" style={{ color: PAPER }} />
686
+ </div>
687
+ </button>
688
+ );
689
+ }
690
+
691
+ function BigInstallDetail({ client }: { client: McpClient }): ReactNode {
692
+ const { copy, copiedKey } = useCopyToClipboard();
693
+ const snippet = makeConfigSnippet(client.id);
694
+ const deepLink = makeDeepLink(client.id);
695
+ return (
696
+ <div className="flex flex-col gap-5 p-6">
697
+ <header>
698
+ <span style={{ ...mono, color: ACCENT }} className="text-[10px] uppercase tracking-[0.22em]">
699
+ install / {client.name}
700
+ </span>
701
+ <h2 style={{ ...display, color: PAPER }} className="mt-1 text-[34px] leading-[1] tracking-[-0.01em]">
702
+ {client.deepLinkPrefix ? 'One click. Or copy.' : 'Drop in. Restart.'}
703
+ </h2>
704
+ </header>
705
+ {deepLink && (
706
+ <a
707
+ href={deepLink}
708
+ className="inline-flex w-fit items-center gap-2 rounded px-4 py-2 text-[13px]"
709
+ style={{ background: ACCENT, color: NIGHT, ...mono, fontWeight: 600 }}
710
+ >
711
+ Open in {client.name} <ArrowUpRight size={13} />
712
+ </a>
713
+ )}
714
+ <div className="rounded-lg border" style={{ borderColor: RULE, background: NIGHT }}>
715
+ <div className="flex items-center justify-between border-b px-4 py-2.5" style={{ borderColor: RULE }}>
716
+ <code style={{ ...mono, color: PAPER_DIM }} className="text-[10.5px]">
717
+ {client.configHint}
718
+ </code>
719
+ <button
720
+ onClick={() => copy(snippet, `b-${client.id}`)}
721
+ className="inline-flex items-center gap-1.5 rounded px-2 py-1 text-[11px] hover:bg-white/5"
722
+ style={{ ...mono, color: copiedKey === `b-${client.id}` ? ACCENT : PAPER }}
723
+ >
724
+ {copiedKey === `b-${client.id}` ? <Check size={12} /> : <Copy size={12} />}
725
+ {copiedKey === `b-${client.id}` ? 'Copied' : 'Copy'}
726
+ </button>
727
+ </div>
728
+ <pre className="overflow-x-auto px-4 py-4 text-[12.5px] leading-[1.55]" style={{ ...mono, color: PAPER }}>
729
+ {snippet}
730
+ </pre>
731
+ </div>
732
+ </div>
733
+ );
734
+ }
735
+
736
+ // ── recipes (horizontal carousel) ───────────────────────────────────────────
737
+
738
+ function RecipesSection(): ReactNode {
739
+ const { copy, copiedKey } = useCopyToClipboard();
740
+ const scrollerRef = useRef<HTMLDivElement>(null);
741
+ const [scrollState, setScrollState] = useState({ atStart: true, atEnd: false, page: 0, pages: 1 });
742
+
743
+ // Recompute scroll state on scroll + resize. Drives the fade gradients
744
+ // and the pagination dots underneath. Pages are computed from how many
745
+ // cards actually fit in the viewport so dots and page indices stay in
746
+ // sync — when the last few cards are all visible, the last dot becomes
747
+ // (and stays) active instead of being unreachable.
748
+ useEffect(() => {
749
+ const el = scrollerRef.current;
750
+ if (!el) return;
751
+ const computeState = () => {
752
+ const max = el.scrollWidth - el.clientWidth;
753
+ const atStart = el.scrollLeft <= 4;
754
+ const atEnd = max - el.scrollLeft <= 4;
755
+ const cardWidth = 360 + 24;
756
+ const cardsPerPage = Math.max(1, Math.floor(el.clientWidth / cardWidth));
757
+ const pages = Math.max(1, Math.ceil(RECIPES.length / cardsPerPage));
758
+ const rawPage = Math.round(el.scrollLeft / (cardsPerPage * cardWidth));
759
+ const page = Math.max(0, Math.min(pages - 1, rawPage));
760
+ setScrollState({ atStart, atEnd, page, pages });
761
+ };
762
+ computeState();
763
+ el.addEventListener('scroll', computeState, { passive: true });
764
+ const ro = new ResizeObserver(computeState);
765
+ ro.observe(el);
766
+ return () => {
767
+ el.removeEventListener('scroll', computeState);
768
+ ro.disconnect();
769
+ };
770
+ }, []);
771
+
772
+ function scrollByCard(dir: -1 | 1) {
773
+ const el = scrollerRef.current;
774
+ if (!el) return;
775
+ el.scrollBy({ left: dir * (360 + 24), behavior: 'smooth' });
776
+ }
777
+
778
+ return (
779
+ <section id="recipes" className="relative z-10 border-b py-24" style={{ borderColor: RULE }}>
780
+ <div className="mx-auto max-w-[1280px] px-6">
781
+ <SectionHeader
782
+ number="02"
783
+ eyebrow="Recipes"
784
+ title="Eight things to ask, once it’s installed."
785
+ right={
786
+ <div className="flex gap-2">
787
+ <CarouselButton onClick={() => scrollByCard(-1)} dir="left" disabled={scrollState.atStart} />
788
+ <CarouselButton onClick={() => scrollByCard(1)} dir="right" disabled={scrollState.atEnd} />
789
+ </div>
790
+ }
791
+ />
792
+ </div>
793
+
794
+ {/* Full-bleed scroller wrapper so fades + spacers can sit outside the
795
+ 1280-max content column. The cards align to the same gutter as the
796
+ section header by padding the scroller with a calc() that mirrors
797
+ the centred content width. */}
798
+ <div className="relative mt-12">
799
+ <div
800
+ ref={scrollerRef}
801
+ className="flex snap-x snap-mandatory gap-6 overflow-x-auto pb-6 pt-1 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
802
+ style={{
803
+ paddingLeft: 'max(1.5rem, calc((100vw - 1280px) / 2 + 1.5rem))',
804
+ paddingRight: 'max(1.5rem, calc((100vw - 1280px) / 2 + 1.5rem))',
805
+ scrollPaddingLeft: 'max(1.5rem, calc((100vw - 1280px) / 2 + 1.5rem))',
806
+ }}
807
+ >
808
+ {RECIPES.map((recipe) => (
809
+ <article
810
+ key={recipe.id}
811
+ className="relative flex w-[360px] shrink-0 snap-start flex-col overflow-hidden rounded-xl border"
812
+ style={{ borderColor: RULE, background: NIGHT_2 }}
813
+ >
814
+ <div
815
+ className="border-b px-5 py-3"
816
+ style={{
817
+ borderColor: RULE,
818
+ background: `linear-gradient(180deg, ${FAMILY_ACCENT[recipe.family]}18 0%, transparent 100%)`,
819
+ }}
820
+ >
821
+ <span
822
+ style={{ ...mono, color: FAMILY_ACCENT[recipe.family] }}
823
+ className="text-[10px] uppercase tracking-[0.22em]"
824
+ >
825
+ / {recipe.family}
826
+ </span>
827
+ </div>
828
+
829
+ <div className="flex flex-1 flex-col gap-4 p-5">
830
+ <h3
831
+ style={{ ...display, color: PAPER }}
832
+ className="text-[26px] leading-[1.05] tracking-[-0.01em]"
833
+ >
834
+ {recipe.title}
835
+ </h3>
836
+
837
+ <div
838
+ className="rounded-md border bg-black/40 p-4 text-[12.5px] leading-[1.55]"
839
+ style={{ ...mono, borderColor: RULE, color: PAPER }}
840
+ >
841
+ <div
842
+ className="mb-2 flex items-center gap-2 text-[9.5px] uppercase tracking-[0.2em]"
843
+ style={{ color: PAPER_DIM }}
844
+ >
845
+ <span
846
+ className="inline-block h-1.5 w-1.5 rounded-full"
847
+ style={{ background: FAMILY_ACCENT[recipe.family] }}
848
+ />
849
+ user
850
+ </div>
851
+ <p style={{ color: PAPER }}>{recipe.prompt}</p>
852
+ </div>
853
+
854
+ <div className="flex items-center justify-between gap-3">
855
+ <div className="flex flex-wrap gap-1.5">
856
+ {recipe.uses.slice(0, 3).map((tool) => (
857
+ <a
858
+ key={tool}
859
+ href={`#${tool}`}
860
+ onClick={(e) => {
861
+ e.preventDefault();
862
+ scrollToAnchor(tool);
863
+ }}
864
+ style={{ ...mono, color: PAPER_DIM, borderColor: RULE }}
865
+ className="rounded-full border px-2 py-0.5 text-[10px] hover:text-white"
866
+ >
867
+ {tool}
868
+ </a>
869
+ ))}
870
+ </div>
871
+ <button
872
+ onClick={() => copy(recipe.prompt, `b-r-${recipe.id}`)}
873
+ className="inline-flex items-center gap-1 text-[11px]"
874
+ style={{ ...mono, color: copiedKey === `b-r-${recipe.id}` ? ACCENT : PAPER_DIM }}
875
+ >
876
+ {copiedKey === `b-r-${recipe.id}` ? <Check size={12} /> : <Copy size={12} />}
877
+ {copiedKey === `b-r-${recipe.id}` ? 'Copied' : 'Copy'}
878
+ </button>
879
+ </div>
880
+ </div>
881
+ </article>
882
+ ))}
883
+ </div>
884
+
885
+ {/* edge fades — purely cosmetic, must not eat clicks */}
886
+ <div
887
+ className="pointer-events-none absolute inset-y-0 left-0 w-16 transition-opacity"
888
+ style={{
889
+ background: `linear-gradient(to right, ${'rgb(10 10 12)'} 10%, transparent)`,
890
+ opacity: scrollState.atStart ? 0 : 1,
891
+ }}
892
+ aria-hidden
893
+ />
894
+ <div
895
+ className="pointer-events-none absolute inset-y-0 right-0 w-32 transition-opacity"
896
+ style={{
897
+ background: `linear-gradient(to left, ${'rgb(10 10 12)'} 10%, transparent)`,
898
+ opacity: scrollState.atEnd ? 0 : 1,
899
+ }}
900
+ aria-hidden
901
+ />
902
+ </div>
903
+
904
+ {/* pagination dots */}
905
+ <div className="mx-auto mt-2 flex max-w-[1280px] items-center justify-between gap-3 px-6">
906
+ <span style={{ ...mono, color: PAPER_DIM }} className="text-[10px] uppercase tracking-[0.2em]">
907
+ {RECIPES.length} recipes · scroll →
908
+ </span>
909
+ <div className="flex items-center gap-1.5">
910
+ {/* One dot per page (not per recipe), so as cards-per-page changes
911
+ with viewport width the active highlight remains reachable. */}
912
+ {Array.from({ length: scrollState.pages }, (_, i) => (
913
+ <span
914
+ key={i}
915
+ className="block h-1 rounded-full transition-all"
916
+ style={{
917
+ background: i === scrollState.page ? ACCENT : `${PAPER}30`,
918
+ width: i === scrollState.page ? 18 : 6,
919
+ }}
920
+ />
921
+ ))}
922
+ </div>
923
+ </div>
924
+ </section>
925
+ );
926
+ }
927
+
928
+ function CarouselButton({
929
+ onClick,
930
+ dir,
931
+ disabled,
932
+ }: {
933
+ onClick: () => void;
934
+ dir: 'left' | 'right';
935
+ disabled?: boolean;
936
+ }): ReactNode {
937
+ return (
938
+ <button
939
+ onClick={onClick}
940
+ disabled={disabled}
941
+ className="inline-flex h-9 w-9 items-center justify-center rounded-full border transition-colors hover:bg-white/5 disabled:cursor-not-allowed disabled:opacity-30 disabled:hover:bg-transparent"
942
+ style={{ borderColor: RULE, color: PAPER }}
943
+ aria-label={dir === 'left' ? 'Scroll left' : 'Scroll right'}
944
+ >
945
+ <ArrowUpRight
946
+ size={14}
947
+ style={{ transform: dir === 'left' ? 'rotate(225deg)' : 'rotate(45deg)' }}
948
+ />
949
+ </button>
950
+ );
951
+ }
952
+
953
+ // ── catalog ─────────────────────────────────────────────────────────────────
954
+
955
+ function CatalogSection(): ReactNode {
956
+ const grouped = useMemo(() => toolsByCategory(), []);
957
+ const [activeCat, setActiveCat] = useState<ToolCategory>('Viewer');
958
+
959
+ return (
960
+ <section id="tools" className="relative z-10 py-24">
961
+ <div className="mx-auto max-w-[1280px] px-6">
962
+ <SectionHeader
963
+ number="03"
964
+ eyebrow="Catalog"
965
+ title={
966
+ <>
967
+ <span>{CATALOG.tools.length}</span>{' '}
968
+ <span style={{ fontStyle: 'italic', color: ACCENT }}>typed tools.</span>{' '}
969
+ <br className="hidden sm:block" />
970
+ Everything an agent needs.
971
+ </>
972
+ }
973
+ />
974
+
975
+ <div className="mt-12 grid grid-cols-12 gap-6">
976
+ <div className="col-span-12 md:col-span-3">
977
+ <div className="md:sticky md:top-6 flex flex-row flex-wrap gap-2 md:flex-col">
978
+ {CATEGORY_ORDER.map((cat) => {
979
+ const isActive = activeCat === cat;
980
+ return (
981
+ <button
982
+ key={cat}
983
+ onClick={() => setActiveCat(cat)}
984
+ className={cn(
985
+ 'group relative flex items-center justify-between gap-2 rounded-md px-3 py-2 text-left text-[13px] transition-all',
986
+ isActive ? 'border' : 'opacity-60 hover:opacity-100',
987
+ )}
988
+ style={{
989
+ borderColor: isActive ? ACCENT : RULE,
990
+ background: isActive ? `${ACCENT}14` : 'transparent',
991
+ color: PAPER,
992
+ }}
993
+ >
994
+ <span className="flex items-baseline gap-2 font-medium">
995
+ {cat}
996
+ </span>
997
+ <span
998
+ style={{ ...mono, color: isActive ? ACCENT : PAPER_DIM }}
999
+ className="text-[10.5px]"
1000
+ >
1001
+ {(grouped.get(cat) ?? []).length.toString().padStart(2, '0')}
1002
+ </span>
1003
+ </button>
1004
+ );
1005
+ })}
1006
+ </div>
1007
+ </div>
1008
+
1009
+ <div className="col-span-12 md:col-span-9">
1010
+ <div className="rounded-xl border" style={{ borderColor: RULE, background: NIGHT_2 }}>
1011
+ <div className="border-b px-6 py-4" style={{ borderColor: RULE }}>
1012
+ <h3 style={{ ...display, color: PAPER }} className="text-[28px] leading-tight">
1013
+ {activeCat}
1014
+ </h3>
1015
+ <p className="mt-1 text-[13.5px]" style={{ color: PAPER_DIM }}>
1016
+ {CATEGORY_BLURBS[activeCat]}
1017
+ </p>
1018
+ </div>
1019
+ <ul className="divide-y" style={{ borderColor: RULE }}>
1020
+ {(grouped.get(activeCat) ?? []).map((tool) => (
1021
+ <CatalogToolRow key={tool.name} tool={tool} />
1022
+ ))}
1023
+ </ul>
1024
+ </div>
1025
+ </div>
1026
+ </div>
1027
+ </div>
1028
+ </section>
1029
+ );
1030
+ }
1031
+
1032
+ function CatalogToolRow({ tool }: { tool: CatalogTool }): ReactNode {
1033
+ const [open, setOpen] = useState(false);
1034
+ const params = useMemo(() => paramsFor(tool), [tool]);
1035
+ const example = useMemo(() => exampleCall(tool), [tool]);
1036
+ const signature = useMemo(() => buildSignature(tool.name, params), [tool.name, params]);
1037
+ return (
1038
+ <li id={tool.name} className="scroll-mt-16">
1039
+ <button
1040
+ onClick={() => setOpen((o) => !o)}
1041
+ className="grid w-full grid-cols-12 items-center gap-4 px-6 py-4 text-left transition-colors hover:bg-white/[0.025]"
1042
+ aria-expanded={open}
1043
+ >
1044
+ <div className="col-span-12 sm:col-span-4 flex items-center gap-3">
1045
+ <span style={{ ...mono, color: ACCENT }} className="text-[14px]">
1046
+ {tool.name}
1047
+ </span>
1048
+ </div>
1049
+ <div className="col-span-12 sm:col-span-7 text-[13px]" style={{ color: PAPER_DIM }}>
1050
+ {tool.description}
1051
+ </div>
1052
+ <div className="col-span-12 flex items-center justify-end gap-2 sm:col-span-1">
1053
+ <ScopePill scope={tool.scope} />
1054
+ <ChevronRight
1055
+ size={14}
1056
+ className={cn('transition-transform', open && 'rotate-90')}
1057
+ style={{ color: PAPER_DIM }}
1058
+ />
1059
+ </div>
1060
+ </button>
1061
+ {open && <CatalogToolDetail tool={tool} signature={signature} params={params} example={example} />}
1062
+ </li>
1063
+ );
1064
+ }
1065
+
1066
+ /** Pretty function-style signature for the detail header. */
1067
+ function buildSignature(name: string, params: ParamRow[]): string {
1068
+ if (params.length === 0) return `${name}()`;
1069
+ const reqd = params.filter((p) => p.required);
1070
+ if (reqd.length === 0) return `${name}({ … })`;
1071
+ return `${name}({ ${reqd.map((p) => p.name).join(', ')}${reqd.length < params.length ? ', …' : ''} })`;
1072
+ }
1073
+
1074
+ function CatalogToolDetail({
1075
+ tool,
1076
+ signature,
1077
+ params,
1078
+ example,
1079
+ }: {
1080
+ tool: CatalogTool;
1081
+ signature: string;
1082
+ params: ParamRow[];
1083
+ example: string;
1084
+ }): ReactNode {
1085
+ const { copy, copiedKey } = useCopyToClipboard();
1086
+ return (
1087
+ <div className="mx-6 mb-5 grid grid-cols-12 gap-4 rounded-md border p-4" style={{ borderColor: RULE, background: NIGHT }}>
1088
+ {/* Signature */}
1089
+ <div className="col-span-12">
1090
+ <div className="mb-1 text-[10px] uppercase tracking-[0.22em]" style={{ ...mono, color: PAPER_DIM }}>
1091
+ Signature
1092
+ </div>
1093
+ <code className="block break-all text-[13px]" style={{ ...mono, color: ACCENT }}>
1094
+ {signature}
1095
+ </code>
1096
+ <p className="mt-2 text-[13px] leading-[1.55]" style={{ color: PAPER_DIM }}>
1097
+ {tool.description}
1098
+ </p>
1099
+ </div>
1100
+
1101
+ {/* Parameter table */}
1102
+ <div className="col-span-12 lg:col-span-7">
1103
+ <div className="mb-2 text-[10px] uppercase tracking-[0.22em]" style={{ ...mono, color: PAPER_DIM }}>
1104
+ Parameters · {params.length}
1105
+ </div>
1106
+ {params.length === 0 ? (
1107
+ <p className="text-[12.5px]" style={{ color: PAPER_DIM }}>
1108
+ No parameters — call with <code style={{ ...mono }}>{`{}`}</code>.
1109
+ </p>
1110
+ ) : (
1111
+ <div className="overflow-x-auto">
1112
+ <table className="min-w-full border-collapse">
1113
+ <thead>
1114
+ <tr style={{ ...mono, color: PAPER_DIM }} className="text-[10px] uppercase tracking-[0.18em]">
1115
+ <th className="border-b py-1.5 pr-4 text-left font-normal" style={{ borderColor: RULE }}>name</th>
1116
+ <th className="border-b py-1.5 pr-4 text-left font-normal" style={{ borderColor: RULE }}>type</th>
1117
+ <th className="border-b py-1.5 pr-4 text-left font-normal" style={{ borderColor: RULE }}>req</th>
1118
+ <th className="border-b py-1.5 text-left font-normal" style={{ borderColor: RULE }}>description</th>
1119
+ </tr>
1120
+ </thead>
1121
+ <tbody>
1122
+ {params.map((p) => (
1123
+ <tr key={p.name} className="align-top">
1124
+ <td className="border-b py-2 pr-4" style={{ borderColor: RULE }}>
1125
+ <code className="text-[12.5px]" style={{ ...mono, color: PAPER }}>{p.name}</code>
1126
+ </td>
1127
+ <td className="border-b py-2 pr-4" style={{ borderColor: RULE }}>
1128
+ <code className="text-[11.5px]" style={{ ...mono, color: '#73daca' }}>{p.type}</code>
1129
+ </td>
1130
+ <td className="border-b py-2 pr-4" style={{ borderColor: RULE }}>
1131
+ {p.required ? (
1132
+ <span style={{ ...mono, color: ACCENT_2 }} className="text-[10px] uppercase tracking-[0.18em]">
1133
+ yes
1134
+ </span>
1135
+ ) : (
1136
+ <span style={{ ...mono, color: PAPER_DIM }} className="text-[10px] uppercase tracking-[0.18em]">
1137
+
1138
+ </span>
1139
+ )}
1140
+ </td>
1141
+ <td className="border-b py-2 text-[12.5px] leading-[1.45]" style={{ borderColor: RULE, color: PAPER_DIM }}>
1142
+ {p.description ?? <span className="opacity-40">—</span>}
1143
+ </td>
1144
+ </tr>
1145
+ ))}
1146
+ </tbody>
1147
+ </table>
1148
+ </div>
1149
+ )}
1150
+ </div>
1151
+
1152
+ {/* Example call */}
1153
+ <div className="col-span-12 lg:col-span-5">
1154
+ <div className="mb-2 flex items-center justify-between gap-2">
1155
+ <span className="text-[10px] uppercase tracking-[0.22em]" style={{ ...mono, color: PAPER_DIM }}>
1156
+ Example call
1157
+ </span>
1158
+ <button
1159
+ onClick={() => copy(example, `ex-${tool.name}`)}
1160
+ className="inline-flex items-center gap-1 text-[11px]"
1161
+ style={{ ...mono, color: copiedKey === `ex-${tool.name}` ? ACCENT : PAPER_DIM }}
1162
+ >
1163
+ {copiedKey === `ex-${tool.name}` ? <Check size={12} /> : <Copy size={12} />}
1164
+ {copiedKey === `ex-${tool.name}` ? 'Copied' : 'Copy JSON-RPC'}
1165
+ </button>
1166
+ </div>
1167
+ <pre
1168
+ className="overflow-x-auto rounded-md border p-3 text-[11.5px] leading-[1.55]"
1169
+ style={{ ...mono, background: '#070709', borderColor: RULE, color: PAPER }}
1170
+ >
1171
+ {example}
1172
+ </pre>
1173
+ </div>
1174
+
1175
+ {/* Footer actions */}
1176
+ <div className="col-span-12 flex flex-wrap items-center justify-between gap-3 border-t pt-3" style={{ borderColor: RULE }}>
1177
+ <a
1178
+ href={`#${tool.name}`}
1179
+ className="inline-flex items-center gap-1 text-[11px]"
1180
+ style={{ ...mono, color: PAPER_DIM }}
1181
+ onClick={(e) => {
1182
+ e.preventDefault();
1183
+ scrollToAnchor(tool.name);
1184
+ // also copy the deep link to clipboard for sharing
1185
+ const url = new URL(window.location.href);
1186
+ url.hash = tool.name;
1187
+ void navigator.clipboard?.writeText(url.toString()).catch(() => undefined);
1188
+ }}
1189
+ >
1190
+ # {tool.name} · share link
1191
+ </a>
1192
+ <a
1193
+ href={`/mcp/playground?prompt=${encodeURIComponent(`Call ${tool.name} with ${JSON.stringify(EXAMPLES[tool.name] ?? {})}`)}`}
1194
+ className="inline-flex items-center gap-1.5 rounded px-3 py-1.5 text-[11px]"
1195
+ style={{ ...mono, background: ACCENT, color: NIGHT, fontWeight: 600 }}
1196
+ >
1197
+ Try in playground <ArrowUpRight size={12} />
1198
+ </a>
1199
+ </div>
1200
+ </div>
1201
+ );
1202
+ }
1203
+
1204
+ function ScopePill({ scope }: { scope: CatalogTool['scope'] }): ReactNode {
1205
+ const colors: Record<CatalogTool['scope'], string> = {
1206
+ read: ACCENT,
1207
+ mutate: ACCENT_2,
1208
+ export: '#73daca',
1209
+ };
1210
+ return (
1211
+ <span
1212
+ style={{ ...mono, color: colors[scope], borderColor: `${colors[scope]}50` }}
1213
+ className="rounded-full border px-2 py-0.5 text-[9.5px] uppercase tracking-[0.18em]"
1214
+ >
1215
+ {scope}
1216
+ </span>
1217
+ );
1218
+ }
1219
+
1220
+ // ── footer ──────────────────────────────────────────────────────────────────
1221
+
1222
+ function Footer(): ReactNode {
1223
+ return (
1224
+ <footer className="relative z-10 border-t" style={{ borderColor: RULE }}>
1225
+ <div className="mx-auto max-w-[1280px] px-6 py-14">
1226
+ <div className="grid grid-cols-12 gap-8">
1227
+ <div className="col-span-12 md:col-span-6">
1228
+ <h3 style={{ ...display, color: PAPER }} className="text-[44px] leading-[0.95] tracking-[-0.01em]">
1229
+ Bring your model.<br />
1230
+ <span style={{ fontStyle: 'italic', color: ACCENT }}>We brought the tools.</span>
1231
+ </h3>
1232
+ <a
1233
+ href="/mcp/playground"
1234
+ className="mt-6 inline-flex items-center gap-2 px-6 py-3 text-[14px] font-semibold"
1235
+ style={{ background: ACCENT, color: NIGHT, borderRadius: 6 }}
1236
+ >
1237
+ Open the playground <ArrowUpRight size={14} />
1238
+ </a>
1239
+ </div>
1240
+ <nav className="col-span-12 grid grid-cols-3 gap-6 md:col-span-6 text-[13px]">
1241
+ <FooterCol heading="Source" links={[
1242
+ { href: 'https://github.com/louistrue/ifc-lite', label: 'GitHub' },
1243
+ { href: 'https://www.npmjs.com/package/@ifc-lite/mcp', label: 'npm' },
1244
+ ]} />
1245
+ <FooterCol heading="Docs" links={[
1246
+ { href: '/mcp/playground', label: 'Playground' },
1247
+ { href: '/', label: 'Viewer' },
1248
+ ]} />
1249
+ <FooterCol heading="Spec" links={[
1250
+ { href: 'https://modelcontextprotocol.io', label: 'MCP' },
1251
+ { href: 'https://technical.buildingsmart.org', label: 'IFC' },
1252
+ ]} />
1253
+ </nav>
1254
+ </div>
1255
+ <div className="mt-12 flex flex-wrap items-center justify-between gap-2 border-t pt-6" style={{ borderColor: RULE }}>
1256
+ <span style={{ ...mono, color: PAPER_DIM }} className="text-[10.5px]">
1257
+ ifc-lite/mcp · v{MCP_VERSION} · MPL-2.0
1258
+ </span>
1259
+ <span style={{ ...mono, color: PAPER_DIM }} className="text-[10.5px] flex items-center gap-1.5">
1260
+ <Sun size={11} />
1261
+ Dark by intent.
1262
+ </span>
1263
+ </div>
1264
+ </div>
1265
+ </footer>
1266
+ );
1267
+ }
1268
+
1269
+ function FooterCol({ heading, links }: { heading: string; links: { href: string; label: string }[] }): ReactNode {
1270
+ return (
1271
+ <div className="flex flex-col gap-2">
1272
+ <span style={{ ...mono, color: ACCENT }} className="text-[10px] uppercase tracking-[0.22em]">
1273
+ {heading}
1274
+ </span>
1275
+ {links.map((l) => (
1276
+ <a key={l.href} href={l.href} className="text-[13px] transition-colors hover:text-[var(--p)]" style={{ color: PAPER_DIM, ['--p' as never]: PAPER }}>
1277
+ {l.label}
1278
+ </a>
1279
+ ))}
1280
+ </div>
1281
+ );
1282
+ }
1283
+
1284
+ // ── shared shells ───────────────────────────────────────────────────────────
1285
+
1286
+ function SectionHeader({
1287
+ number,
1288
+ eyebrow,
1289
+ title,
1290
+ right,
1291
+ }: {
1292
+ number: string;
1293
+ eyebrow: string;
1294
+ title: ReactNode;
1295
+ right?: ReactNode;
1296
+ }): ReactNode {
1297
+ return (
1298
+ <div className="flex flex-col items-start gap-6 sm:flex-row sm:items-end sm:justify-between">
1299
+ <div>
1300
+ <div className="mb-3 flex items-baseline gap-3">
1301
+ <span style={{ ...mono, color: ACCENT }} className="text-[11px] uppercase tracking-[0.22em]">
1302
+ §{number}
1303
+ </span>
1304
+ <span style={{ ...mono, color: PAPER_DIM }} className="text-[10.5px] uppercase tracking-[0.2em]">
1305
+ {eyebrow}
1306
+ </span>
1307
+ </div>
1308
+ <h2
1309
+ style={{ ...display, color: PAPER }}
1310
+ className="max-w-[40rem] text-[44px] leading-[1.02] tracking-[-0.015em] md:text-[60px]"
1311
+ >
1312
+ {title}
1313
+ </h2>
1314
+ </div>
1315
+ {right}
1316
+ </div>
1317
+ );
1318
+ }