@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.
- package/.turbo/turbo-build.log +15 -14
- package/.turbo/turbo-typecheck.log +1 -1
- package/CHANGELOG.md +8 -0
- package/dist/assets/basketViewActivator-CA2CTcVo.js +71 -0
- package/dist/assets/{bcf-DOG9_WPX.js → bcf-4K724hw0.js} +18 -18
- package/dist/assets/{exporters-BraHBeoi.js → exporters-xbXqEDlO.js} +53 -46
- package/dist/assets/ids-2WdONLlu.js +2033 -0
- package/dist/assets/index-BXeEKqJG.css +1 -0
- package/dist/assets/{index-BOi3BuUI.js → index-D8Epw-e7.js} +48072 -30928
- package/dist/assets/{native-bridge-CpBeOPQa.js → native-bridge-DKmx1z95.js} +2 -2
- package/dist/assets/{sandbox-Baez7n-t.js → sandbox-tccwm5Bo.js} +547 -529
- package/dist/assets/{server-client-BB6cMAXE.js → server-client-LoWPK1N2.js} +1 -1
- package/dist/assets/three-CDRZThFA.js +4057 -0
- package/dist/assets/{wasm-bridge-CAYCUHbE.js → wasm-bridge-BsJGgPMs.js} +1 -1
- package/dist/index.html +8 -7
- package/dist/samples/building-architecture.ifc +453 -0
- package/dist/samples/hello-wall.ifc +1054 -0
- package/dist/samples/infra-bridge.ifc +962 -0
- package/package.json +7 -2
- package/public/samples/building-architecture.ifc +453 -0
- package/public/samples/hello-wall.ifc +1054 -0
- package/public/samples/infra-bridge.ifc +962 -0
- package/src/App.tsx +37 -3
- package/src/components/mcp/HeroScene.tsx +876 -0
- package/src/components/mcp/McpLanding.tsx +1318 -0
- package/src/components/mcp/McpPlayground.tsx +524 -0
- package/src/components/mcp/PlaygroundChat.tsx +1097 -0
- package/src/components/mcp/PlaygroundViewer.tsx +815 -0
- package/src/components/mcp/README.md +171 -0
- package/src/components/mcp/data.ts +659 -0
- package/src/components/mcp/playground-dispatcher.ts +1649 -0
- package/src/components/mcp/playground-files.ts +107 -0
- package/src/components/mcp/playground-uploads.ts +122 -0
- package/src/components/mcp/types.ts +65 -0
- package/src/components/mcp/use-mcp-page.ts +109 -0
- package/src/components/viewer/MainToolbar.tsx +19 -0
- package/src/components/viewer/ViewportContainer.tsx +35 -4
- package/src/generated/mcp-catalog.json +82 -0
- package/vite.config.ts +6 -0
- package/dist/assets/basketViewActivator-RZy5c3Td.js +0 -1
- package/dist/assets/ids-DQ5jY0E8.js +0 -1
- 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
|
+
}
|