| 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 | } |