● Zion Boggan
repos/Darwin/face/src/app/page.tsx
zionboggan.com ↗
583 lines · tsx
History for this file →
1
"use client";
2
 
3
import { useEffect, useRef, useState, useCallback } from "react";
4
import dynamic from "next/dynamic";
5
import { useHealth } from "@/hooks/useHealth";
6
import { useAsk } from "@/hooks/useAsk";
7
import AgentTree from "@/components/surface/AgentTree";
8
import HitsInbox from "@/components/surface/HitsInbox";
9
import HealthPanel from "@/components/health/HealthPanel";
10
import AskBox from "@/components/surface/AskBox";
11
import OrganismBoundary from "@/components/organism/OrganismBoundary";
12
import type { AskResponse } from "@/lib/types";
13
 
14
const DarwinOrganism = dynamic(
15
  () => import("@/components/organism/DarwinOrganism"),
16
  { ssr: false, loading: () => null }
17
);
18
 
19
 
20
function Wordmark() {
21
  const ref = useRef<HTMLDivElement>(null);
22
 
23
  useEffect(() => {
24
    const el = ref.current;
25
    if (!el) return;
26
    const prefersReduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
27
    if (prefersReduced) {
28
      el.style.opacity = "1";
29
      return;
30
    }
31
    el.style.opacity = "0";
32
    el.style.transform = "translateY(28px)";
33
    requestAnimationFrame(() => {
34
      requestAnimationFrame(() => {
35
        el.style.transition = "opacity 1.1s cubic-bezier(0.16,1,0.3,1), transform 1.1s cubic-bezier(0.16,1,0.3,1)";
36
        el.style.opacity = "1";
37
        el.style.transform = "translateY(0)";
38
      });
39
    });
40
  }, []);
41
 
42
  return (
43
    <div ref={ref} className="text-center pointer-events-none select-none" style={{ opacity: 0 }}>
44
      <h1 className="font-display font-bold text-display-xl text-text-primary uppercase tracking-tight">
45
        Darwin
46
      </h1>
47
      <div className="flex items-center justify-center gap-3 mt-3">
48
        <div className="h-px w-16 bg-teal-bio opacity-40" aria-hidden="true" />
49
        <span className="font-mono text-label-md text-teal-bio uppercase tracking-widest">
50
          Second Brain
51
        </span>
52
        <div className="h-px w-16 bg-teal-bio opacity-40" aria-hidden="true" />
53
      </div>
54
      <p className="font-mono text-label-sm text-text-muted mt-3 tracking-widest uppercase opacity-60">
55
        Knowledge under natural selection
56
      </p>
57
    </div>
58
  );
59
}
60
 
61
 
62
interface NavProps {
63
  liveOk: boolean | null;
64
}
65
 
66
function Nav({ liveOk }: NavProps) {
67
  const indicatorClass =
68
    liveOk === null
69
      ? "bg-text-muted animate-pulse-slow"
70
      : liveOk
71
      ? "bg-teal-bio animate-pulse-slow"
72
      : "bg-red-500";
73
  const liveLabel = liveOk === null ? "CONNECTING" : liveOk ? "LIVE" : "DEGRADED";
74
 
75
  return (
76
    <nav
77
      className="fixed top-0 left-0 right-0 z-50 flex items-center justify-between px-4 sm:px-6 py-4"
78
      role="navigation"
79
      aria-label="Main navigation"
80
    >
81
      <span className="font-mono text-label-md text-teal-bio uppercase tracking-widest" aria-label="Darwin">
82
        DRW
83
      </span>
84
      <div className="flex items-center gap-4 sm:gap-6">
85
        <a href="/workspace" className="font-mono text-label-md text-teal-bio hover:text-text-primary transition-colors duration-200 uppercase tracking-widest border border-teal-bio/30 px-2.5 py-1" data-cursor-expand>Workspace</a>
86
        <a
87
          href="#surface"
88
          className="font-mono text-label-md text-text-muted hover:text-teal-bio transition-colors duration-200 uppercase tracking-widest hidden sm:inline"
89
          data-cursor-expand
90
        >
91
          Query
92
        </a>
93
        <a
94
          href="#health"
95
          className="font-mono text-label-md text-text-muted hover:text-teal-bio transition-colors duration-200 uppercase tracking-widest hidden sm:inline"
96
          data-cursor-expand
97
        >
98
          Health
99
        </a>
100
        <div className="flex items-center gap-1.5" aria-label={`Status: ${liveLabel}`}>
101
          <div className={`w-1.5 h-1.5 rounded-full ${indicatorClass}`} aria-hidden="true" />
102
          <span className={`font-mono text-label-sm ${liveOk === false ? "text-red-400" : "text-teal-bio"}`}>
103
            {liveLabel}
104
          </span>
105
        </div>
106
      </div>
107
    </nav>
108
  );
109
}
110
 
111
 
112
function ScrollHint() {
113
  const [visible, setVisible] = useState(true);
114
 
115
  useEffect(() => {
116
    function onScroll() {
117
      if (window.scrollY > 80) setVisible(false);
118
    }
119
    window.addEventListener("scroll", onScroll, { passive: true });
120
    return () => window.removeEventListener("scroll", onScroll);
121
  }, []);
122
 
123
  return (
124
    <div
125
      className="absolute bottom-8 left-1/2 -translate-x-1/2 flex flex-col items-center gap-2 transition-opacity duration-500 pointer-events-none"
126
      style={{ opacity: visible ? 1 : 0 }}
127
      aria-hidden="true"
128
    >
129
      <span className="font-mono text-label-sm text-text-muted uppercase tracking-widest">
130
        Scroll to query
131
      </span>
132
      <div className="w-px h-8 bg-teal-bio opacity-30 animate-bounce" />
133
    </div>
134
  );
135
}
136
 
137
 
138
type MobileTab = "ask" | "hits" | "agents" | "health";
139
 
140
const MOBILE_TABS: { id: MobileTab; label: string; short: string }[] = [
141
  { id: "ask", label: "Ask", short: "ASK" },
142
  { id: "hits", label: "Hits", short: "HITS" },
143
  { id: "agents", label: "Agents", short: "AGENTS" },
144
  { id: "health", label: "Health", short: "HEALTH" },
145
];
146
 
147
interface MobileTabBarProps {
148
  active: MobileTab;
149
  onChange: (tab: MobileTab) => void;
150
  hitCount: number;
151
  healthOk: boolean | null;
152
}
153
 
154
function MobileTabBar({ active, onChange, hitCount, healthOk }: MobileTabBarProps) {
155
  return (
156
    <div
157
      className="fixed bottom-0 left-0 right-0 z-50 lg:hidden"
158
      style={{
159
        background: "rgba(5,6,7,0.97)",
160
        borderTop: "1px solid rgba(22,224,200,0.12)",
161
        paddingBottom: "env(safe-area-inset-bottom, 0px)",
162
      }}
163
      role="tablist"
164
      aria-label="Surface navigation"
165
    >
166
      <div className="grid grid-cols-5">
167
        {MOBILE_TABS.map((tab) => {
168
          const isActive = active === tab.id;
169
          return (
170
            <button
171
              key={tab.id}
172
              role="tab"
173
              aria-selected={isActive}
174
              aria-label={tab.label}
175
              onClick={() => onChange(tab.id)}
176
              className={[
177
                "flex flex-col items-center justify-center gap-1 py-3 px-1 min-h-[56px]",
178
                "font-mono text-label-sm uppercase tracking-widest transition-colors duration-200",
179
                isActive ? "text-teal-bio" : "text-text-muted",
180
              ].join(" ")}
181
              style={{ touchAction: "manipulation" }}
182
            >
183
              {}
184
              <TabIcon id={tab.id} active={isActive} />
185
              <span style={{ fontSize: "0.55rem" }}>{tab.short}</span>
186
              {}
187
              {isActive && (
188
                <span
189
                  className="absolute top-0 left-1/2 -translate-x-1/2 h-px w-8 bg-teal-bio"
190
                  aria-hidden="true"
191
                />
192
              )}
193
            </button>
194
          );
195
        })}
196
        <a
197
          href="/workspace"
198
          aria-label="Workspace"
199
          className={[
200
            "flex flex-col items-center justify-center gap-1 py-3 px-1 min-h-[56px]",
201
            "font-mono text-label-sm uppercase tracking-widest transition-colors duration-200 text-teal-bio",
202
          ].join(" ")}
203
          style={{ touchAction: "manipulation" }}
204
        >
205
          <svg width={18} height={18} viewBox="0 0 18 18" fill="none" aria-hidden="true">
206
            <rect x="2.5" y="3.5" width="13" height="11" stroke="#16e0c8" strokeWidth="1.2" />
207
            <rect x="2.5" y="3.5" width="13" height="3" fill="#16e0c8" opacity="0.5" />
208
          </svg>
209
          <span style={{ fontSize: "0.55rem" }}>WORK</span>
210
        </a>
211
      </div>
212
    </div>
213
  );
214
}
215
 
216
function TabIcon({ id, active }: { id: MobileTab; active: boolean }) {
217
  const color = active ? "#16e0c8" : "#4a525e";
218
  const w = 18;
219
  const h = 18;
220
  if (id === "ask") {
221
    return (
222
      <svg width={w} height={h} viewBox="0 0 18 18" fill="none" aria-hidden="true">
223
        <circle cx="9" cy="9" r="6.5" stroke={color} strokeWidth="1.2" />
224
        <circle cx="9" cy="9" r="2.5" fill={color} opacity={active ? 0.8 : 0.4} />
225
      </svg>
226
    );
227
  }
228
  if (id === "hits") {
229
    return (
230
      <svg width={w} height={h} viewBox="0 0 18 18" fill="none" aria-hidden="true">
231
        <rect x="3" y="5" width="12" height="1.5" fill={color} />
232
        <rect x="3" y="8.5" width="9" height="1.5" fill={color} opacity="0.7" />
233
        <rect x="3" y="12" width="6" height="1.5" fill={color} opacity="0.4" />
234
      </svg>
235
    );
236
  }
237
  if (id === "agents") {
238
    return (
239
      <svg width={w} height={h} viewBox="0 0 18 18" fill="none" aria-hidden="true">
240
        <circle cx="9" cy="4" r="2" stroke={color} strokeWidth="1.2" />
241
        <circle cx="4" cy="13" r="2" stroke={color} strokeWidth="1.2" />
242
        <circle cx="14" cy="13" r="2" stroke={color} strokeWidth="1.2" />
243
        <line x1="9" y1="6" x2="4" y2="11" stroke={color} strokeWidth="1" opacity="0.5" />
244
        <line x1="9" y1="6" x2="14" y2="11" stroke={color} strokeWidth="1" opacity="0.5" />
245
      </svg>
246
    );
247
  }
248
  return (
249
    <svg width={w} height={h} viewBox="0 0 18 18" fill="none" aria-hidden="true">
250
      <rect x="2" y="2" width="6" height="6" stroke={color} strokeWidth="1.2" />
251
      <rect x="10" y="2" width="6" height="6" stroke={color} strokeWidth="1.2" />
252
      <rect x="2" y="10" width="6" height="6" stroke={color} strokeWidth="1.2" />
253
      <rect x="10" y="10" width="6" height="6" stroke={color} strokeWidth="1.2" opacity="0.4" />
254
    </svg>
255
  );
256
}
257
 
258
 
259
interface SurfaceProps {
260
  askData: AskResponse | null;
261
  loading: boolean;
262
  error: string | null;
263
  submit: (q: string) => Promise<void>;
264
  onFocusChange: (focused: boolean) => void;
265
  healthData: ReturnType<typeof useHealth>["data"];
266
  healthLoading: boolean;
267
}
268
 
269
function DesktopSurface({ askData, loading, error, submit, onFocusChange }: SurfaceProps) {
270
  const ref = useRef<HTMLDivElement>(null);
271
  const revealed = useRef(false);
272
 
273
  useEffect(() => {
274
    const el = ref.current;
275
    if (!el) return;
276
    const prefersReduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
277
    if (prefersReduced) return;
278
 
279
    const panes = el.querySelectorAll<HTMLElement>(".surface-pane");
280
    panes.forEach((pane) => {
281
      pane.style.opacity = "0";
282
      pane.style.transform = "translateY(24px)";
283
    });
284
 
285
    const observer = new IntersectionObserver(
286
      (entries) => {
287
        if (entries[0].isIntersecting && !revealed.current) {
288
          revealed.current = true;
289
          panes.forEach((pane, i) => {
290
            setTimeout(() => {
291
              requestAnimationFrame(() => {
292
                requestAnimationFrame(() => {
293
                  pane.style.transition = "opacity 0.7s cubic-bezier(0.16,1,0.3,1), transform 0.7s cubic-bezier(0.16,1,0.3,1)";
294
                  pane.style.opacity = "1";
295
                  pane.style.transform = "translateY(0)";
296
                });
297
              });
298
            }, i * 100);
299
          });
300
        }
301
      },
302
      { threshold: 0.08 }
303
    );
304
 
305
    observer.observe(el);
306
    return () => observer.disconnect();
307
  }, []);
308
 
309
  const hits = askData?.hits ?? [];
310
 
311
  return (
312
    <section
313
      id="surface"
314
      ref={ref}
315
      aria-label="Darwin query surface"
316
      className="hidden lg:block min-h-screen py-20 px-8 relative z-10"
317
    >
318
      <div className="w-full">
319
        <div className="mb-12">
320
          <p className="font-mono text-label-sm text-text-muted uppercase tracking-widest mb-3">
321
            Surface
322
          </p>
323
          <h2 className="font-display text-display-lg font-bold text-text-primary">
324
            Neural interface
325
          </h2>
326
        </div>
327
 
328
        <div className="grid grid-cols-1 lg:grid-cols-[280px_minmax(0,1fr)_280px] gap-5 min-h-[700px]">
329
          <div className="surface-pane">
330
            <AgentTree />
331
          </div>
332
          <div className="surface-pane">
333
            <AskBox
334
              data={askData}
335
              loading={loading}
336
              error={error}
337
              onSubmit={submit}
338
              onFocusChange={onFocusChange}
339
            />
340
          </div>
341
          <div className="surface-pane">
342
            <HitsInbox hits={hits} />
343
          </div>
344
        </div>
345
      </div>
346
    </section>
347
  );
348
}
349
 
350
 
351
interface MobileSurfaceProps {
352
  askData: AskResponse | null;
353
  loading: boolean;
354
  error: string | null;
355
  submit: (q: string) => Promise<void>;
356
  onFocusChange: (focused: boolean) => void;
357
  healthData: ReturnType<typeof useHealth>["data"];
358
  healthLoading: boolean;
359
  activeTab: MobileTab;
360
}
361
 
362
function MobileSurface({
363
  askData,
364
  loading,
365
  error,
366
  submit,
367
  onFocusChange,
368
  healthData,
369
  healthLoading,
370
  activeTab,
371
}: MobileSurfaceProps) {
372
  const hits = askData?.hits ?? [];
373
 
374
  return (
375
    <section
376
      id="surface"
377
      aria-label="Darwin query surface"
378
      className="lg:hidden relative z-10"
379
      style={{ paddingBottom: "calc(64px + env(safe-area-inset-bottom, 0px))" }}
380
    >
381
      {}
382
      <div className="px-5 pt-10 pb-6 border-b border-surface-2">
383
        <p className="font-mono text-label-sm text-text-muted uppercase tracking-widest mb-2">
384
          Surface
385
        </p>
386
        <h2 className="font-display text-display-lg font-bold text-text-primary leading-none">
387
          Neural<br />interface
388
        </h2>
389
      </div>
390
 
391
      {}
392
      <div role="tabpanel" id={`panel-${activeTab}`} aria-labelledby={`tab-${activeTab}`}>
393
        {activeTab === "ask" && (
394
          <div className="mobile-surface-pane">
395
            <AskBox
396
              data={askData}
397
              loading={loading}
398
              error={error}
399
              onSubmit={submit}
400
              onFocusChange={onFocusChange}
401
            />
402
          </div>
403
        )}
404
        {activeTab === "hits" && (
405
          <div className="mobile-surface-pane">
406
            <HitsInbox hits={hits} />
407
          </div>
408
        )}
409
        {activeTab === "agents" && (
410
          <div className="mobile-surface-pane">
411
            <AgentTree />
412
          </div>
413
        )}
414
        {activeTab === "health" && (
415
          <div className="mobile-surface-pane">
416
            {healthLoading && !healthData && (
417
              <div className="flex items-center gap-3 py-8">
418
                <div className="w-2 h-2 rounded-full bg-teal-bio animate-pulse-slow" />
419
                <span className="font-mono text-label-sm text-text-muted uppercase tracking-widest">
420
                  Loading health data...
421
                </span>
422
              </div>
423
            )}
424
            {healthData && (
425
              <>
426
                <div className="mb-6">
427
                  <p className="font-mono text-label-sm text-text-muted uppercase tracking-widest mb-2">
428
                    Anatomy
429
                  </p>
430
                  <h3 className="font-display text-display-md font-bold text-text-primary">
431
                    Organism health
432
                  </h3>
433
                  <p className="font-ui text-sm text-text-secondary mt-2">
434
                    Each data source is a living organ.
435
                  </p>
436
                </div>
437
                <HealthPanel engines={healthData.engines} ok={healthData.ok} />
438
              </>
439
            )}
440
            {!healthLoading && !healthData && (
441
              <div className="py-8">
442
                <p className="font-mono text-label-sm text-red-400 uppercase tracking-widest">
443
                  Health check failed
444
                </p>
445
              </div>
446
            )}
447
          </div>
448
        )}
449
      </div>
450
    </section>
451
  );
452
}
453
 
454
 
455
function DesktopHealthSection({ healthData }: { healthData: ReturnType<typeof useHealth>["data"] }) {
456
  if (!healthData) return null;
457
 
458
  return (
459
    <section
460
      id="health"
461
      aria-label="Darwin organism health"
462
      className="hidden lg:block py-16 px-6 relative z-10"
463
    >
464
      <div className="max-w-7xl mx-auto">
465
        <div className="mb-8">
466
          <p className="font-mono text-label-sm text-text-muted uppercase tracking-widest mb-2">
467
            Anatomy
468
          </p>
469
          <h2 className="font-display text-display-lg font-bold text-text-primary">
470
            Organism health
471
          </h2>
472
          <p className="font-ui text-sm text-text-secondary mt-2 max-w-md">
473
            Each data source is a living organ. Stale nodes wither in the hero above.
474
          </p>
475
        </div>
476
        <div className="max-w-md">
477
          <HealthPanel engines={healthData.engines} ok={healthData.ok} />
478
        </div>
479
      </div>
480
    </section>
481
  );
482
}
483
 
484
 
485
function Footer() {
486
  return (
487
    <footer className="py-8 px-6 border-t border-surface-2 relative z-10 hidden lg:block">
488
      <div className="max-w-7xl mx-auto flex items-center justify-between">
489
        <span className="font-mono text-label-sm text-text-muted">
490
          Darwin - CT215 homelab
491
        </span>
492
        <span className="font-mono text-label-sm text-text-muted">
493
          {new Date().getFullYear()}
494
        </span>
495
      </div>
496
    </footer>
497
  );
498
}
499
 
500
 
501
export default function Page() {
502
  const { data: healthData, loading: healthLoading } = useHealth(30_000);
503
  const { data: askData, loading, error, submit } = useAsk();
504
  const [focusMode, setFocusMode] = useState(false);
505
  const [mobileTab, setMobileTab] = useState<MobileTab>("ask");
506
 
507
  const handleSubmit = useCallback(
508
    async (q: string) => {
509
      await submit(q);
510
    },
511
    [submit]
512
  );
513
 
514
  useEffect(() => {
515
    if (askData && askData.hits.length > 0 && typeof window !== "undefined") {
516
      const isMobile = window.innerWidth < 1024;
517
      if (isMobile) {
518
        setTimeout(() => setMobileTab("hits"), 1200);
519
      }
520
    }
521
  }, [askData]);
522
 
523
  const liveOk = healthData ? healthData.ok : null;
524
 
525
  return (
526
    <main>
527
      {}
528
      <div className="aurora-blob" aria-hidden="true" />
529
 
530
      <Nav liveOk={liveOk} />
531
 
532
      {
533
}
534
      <section className="hero-section" aria-label="Darwin organism hero">
535
        <div className="hero-organism" aria-hidden="true">
536
          <OrganismBoundary>
537
            <DarwinOrganism healthData={healthData} focusMode={focusMode} />
538
          </OrganismBoundary>
539
        </div>
540
        <div className="relative z-10 flex flex-col items-center gap-4 mt-auto mb-20">
541
          <Wordmark />
542
        </div>
543
        <ScrollHint />
544
      </section>
545
 
546
      {}
547
      <DesktopSurface
548
        askData={askData}
549
        loading={loading}
550
        error={error}
551
        submit={handleSubmit}
552
        onFocusChange={setFocusMode}
553
        healthData={healthData}
554
        healthLoading={healthLoading}
555
      />
556
 
557
      {}
558
      <MobileSurface
559
        askData={askData}
560
        loading={loading}
561
        error={error}
562
        submit={handleSubmit}
563
        onFocusChange={setFocusMode}
564
        healthData={healthData}
565
        healthLoading={healthLoading}
566
        activeTab={mobileTab}
567
      />
568
 
569
      {}
570
      <DesktopHealthSection healthData={healthData} />
571
 
572
      <Footer />
573
 
574
      {}
575
      <MobileTabBar
576
        active={mobileTab}
577
        onChange={setMobileTab}
578
        hitCount={askData?.hits.length ?? 0}
579
        healthOk={liveOk}
580
      />
581
    </main>
582
  );
583
}