feat(world): react entry — unified shell, globe island, variant tabs, markets panel

This commit is contained in:
zeekay
2026-07-23 20:51:33 -07:00
parent 000019d5fd
commit a8bf4df2d9
9 changed files with 428 additions and 0 deletions
+19
View File
@@ -0,0 +1,19 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Hanzo World — React + GUI (foundation)</title>
<meta
name="description"
content="Hanzo World — React 19 + @hanzo/gui foundation preview. The shipping surface remains the vanilla app at index.html."
/>
<meta name="theme-color" content="#000000" />
<meta name="robots" content="noindex" />
<link rel="icon" type="image/svg+xml" href="/favico/hanzo-favicon.svg" />
</head>
<body>
<div id="react-root"></div>
<script type="module" src="/src/react/main.tsx"></script>
</body>
</html>
+57
View File
@@ -0,0 +1,57 @@
import { useState, useCallback } from 'react';
import { YStack, XStack } from '@hanzo/gui';
import { HanzoAppHeader } from '@hanzogui/shell';
import { getSiteVariant, setSiteVariantRuntime } from '@/config/variant';
import { GlobeIsland } from './components/GlobeIsland';
import { VariantTabs } from './components/VariantTabs';
import { MarketsPanel } from './components/MarketsPanel';
import { AccountControl } from './components/AccountControl';
/**
* The React + @hanzo/gui foundation for world.hanzo.ai.
*
* Architecture proven end-to-end here:
* 1. Unified signed-in shell — HanzoAppHeader(productId="world") from
* @hanzogui/shell, themed by @hanzo/brand tokens (monochrome, accent #fff).
* 2. The deck.gl globe as a React island (GlobeIsland) wrapping the EXISTING
* MapContainer — not a rewrite.
* 3. Variant tabs + one real ported panel (MarketsPanel, reusing the live
* markets service) as proof the panel pattern moves cleanly onto @hanzo/gui.
*/
export function App(): React.JSX.Element {
const [variant, setVariant] = useState<string>(() => getSiteVariant());
// One switch path: canonicalize + persist through the config layer, then reflect
// it in React state and the shareable URL. Mirrors the vanilla in-place switch.
const handleSelect = useCallback((id: string) => {
const applied = setSiteVariantRuntime(id);
if (!applied) return;
setVariant(applied);
const url = new URL(window.location.href);
url.searchParams.set('variant', applied);
window.history.replaceState(null, '', url.toString());
}, []);
return (
<YStack flex={1} height="100%" backgroundColor="#000">
<HanzoAppHeader
productId="world"
org={{ id: 'hanzo', label: 'Hanzo' }}
search={{ placeholder: 'Search or ask Hanzo…' }}
account={<AccountControl />}
/>
<XStack px="$3" py="$2" ai="center" jc="flex-start" gap="$3" zIndex={10}>
<VariantTabs active={variant} onSelect={handleSelect} />
</XStack>
{/* Stage: the globe fills the viewport; panels float over it. */}
<YStack flex={1} position="relative" overflow="hidden">
<GlobeIsland variant={variant} />
<YStack position="absolute" top="$3" right="$3" gap="$3" zIndex={20}>
<MarketsPanel />
</YStack>
</YStack>
</YStack>
);
}
+31
View File
@@ -0,0 +1,31 @@
import { XStack, SizableText } from '@hanzo/gui';
/**
* AccountControl — the far-right account affordance handed to HanzoAppHeader's
* `account` slot. A minimal monogram for the foundation slice; the real IAM-bound
* avatar (shell `UserAvatar` / `useTenantAuth` against hanzo.id) lands in a later
* round when auth is wired into the React surface.
*/
export function AccountControl(): React.JSX.Element {
return (
<XStack
tag="button"
focusable
cursor="pointer"
width={32}
height={32}
borderRadius={999}
ai="center"
jc="center"
borderWidth={1}
borderColor="rgba(255,255,255,0.2)"
backgroundColor="rgba(255,255,255,0.06)"
hoverStyle={{ backgroundColor: 'rgba(255,255,255,0.12)' }}
aria-label="Account"
>
<SizableText size="$2" color="$color12">
H
</SizableText>
</XStack>
);
}
+69
View File
@@ -0,0 +1,69 @@
import { useEffect, useRef } from 'react';
import { variantConfig } from '@/config';
import type { MapContainer as MapContainerType } from '@/components/MapContainer';
/**
* GlobeIsland — the deck.gl globe as a React island.
*
* This does NOT rewrite the globe. It owns a plain DOM host <div> and, on mount,
* instantiates the EXISTING `MapContainer` (which delegates to DeckGLMap on
* desktop / the D3-SVG fallback on mobile, exactly as the vanilla app does). React
* owns the lifecycle (mount / variant-driven layer swap / unmount); the globe
* engine stays imperative and untouched behind that host node. With no mapbox
* token locally it renders the dotted-fallback "cybermap" globe — expected.
*
* The heavy map chunk (~2.7 MB mapbox-gl + deck.gl) is dynamically imported, so
* it never blocks first paint — mirroring the vanilla `App.mountMap()`.
*/
export function GlobeIsland({ variant }: { variant: string }): React.JSX.Element {
const hostRef = useRef<HTMLDivElement>(null);
const mapRef = useRef<MapContainerType | null>(null);
// Mount the globe once. React StrictMode double-invokes effects in dev; the
// `cancelled` guard + teardown makes the instantiate/destroy pair idempotent so
// we never leak two WebGL contexts.
useEffect(() => {
let cancelled = false;
const host = hostRef.current;
if (!host) return;
void (async () => {
const { MapContainer } = await import('@/components/MapContainer');
if (cancelled || mapRef.current) return;
mapRef.current = new MapContainer(host, {
zoom: 1.35,
pan: { x: 0, y: 0 },
view: 'global',
// Reuse the canonical per-variant layer defaults — one source of truth in
// the config layer, no hand-rolled layer object here.
layers: variantConfig(variant).DEFAULT_MAP_LAYERS,
timeRange: '7d',
mode: '3d',
});
})();
return () => {
cancelled = true;
mapRef.current?.destroy();
mapRef.current = null;
};
// Mount-only: the variant-driven layer swap is handled by the effect below so
// switching a tab never tears down / cold-starts the WebGL context.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Variant change → swap layers in place (keep-alive), the same decomplected
// `setLayers` path the vanilla in-place switch uses.
useEffect(() => {
mapRef.current?.setLayers(variantConfig(variant).DEFAULT_MAP_LAYERS);
}, [variant]);
return (
<div
ref={hostRef}
id="mapContainer"
aria-label="Interactive world globe"
style={{ position: 'absolute', inset: 0, width: '100%', height: '100%' }}
/>
);
}
+102
View File
@@ -0,0 +1,102 @@
import { useEffect, useState } from 'react';
import { YStack, XStack, SizableText } from '@hanzo/gui';
import { fetchMultipleStocks } from '@/services/markets';
import { MARKET_SYMBOLS } from '@/config';
import { formatPrice, formatChange } from '@/utils';
import type { MarketData } from '@/types';
import { PanelCard } from './PanelCard';
/**
* MarketsPanel — the vanilla `MarketPanel` (markets) ported to React as proof the
* panel pattern moves cleanly onto @hanzo/gui.
*
* It REUSES the existing data + formatting layer verbatim — `fetchMultipleStocks`
* (the same Finnhub/Yahoo service the vanilla panel is fed by, streaming partial
* batches via `onBatch`) and `formatPrice` / `formatChange` — and only re-expresses
* the row markup with Tamagui primitives. No data logic is rewritten; the port is
* purely the view.
*/
export function MarketsPanel(): React.JSX.Element {
const [rows, setRows] = useState<MarketData[]>([]);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
const load = async (): Promise<void> => {
try {
const result = await fetchMultipleStocks(MARKET_SYMBOLS, {
onBatch: (partial) => {
if (!cancelled) setRows([...partial]);
},
});
if (cancelled) return;
setRows(result.data);
if (result.data.length === 0) {
setError(
result.skipped
? 'FINNHUB_API_KEY not configured — add in Settings'
: 'Market data unavailable',
);
} else {
setError(null);
}
} catch {
if (!cancelled) setError('Market data unavailable');
}
};
void load();
// Live surface: refresh on the same cadence spirit as the vanilla poller.
const id = window.setInterval(() => void load(), 60_000);
return () => {
cancelled = true;
window.clearInterval(id);
};
}, []);
return (
<PanelCard title="Markets">
{error && rows.length === 0 ? (
<SizableText size="$2" color="$color9">
{error}
</SizableText>
) : rows.length === 0 ? (
<SizableText size="$2" color="$color9">
Loading
</SizableText>
) : (
<YStack gap="$1.5">
{rows.map((stock) => (
<MarketRow key={stock.symbol} stock={stock} />
))}
</YStack>
)}
</PanelCard>
);
}
function MarketRow({ stock }: { stock: MarketData }): React.JSX.Element {
const change = stock.change ?? 0;
const changeColor = change >= 0 ? '#22c55e' : '#ef4444';
return (
<XStack jc="space-between" ai="center" py="$1">
<YStack>
<SizableText size="$3" color="$color12">
{stock.name}
</SizableText>
<SizableText size="$1" color="$color9">
{stock.display}
</SizableText>
</YStack>
<XStack gap="$3" ai="center">
<SizableText size="$3" color="$color12">
{stock.price != null ? formatPrice(stock.price) : '—'}
</SizableText>
<SizableText size="$2" color={changeColor} style={{ minWidth: 64, textAlign: 'right' }}>
{stock.change != null ? formatChange(stock.change) : '—'}
</SizableText>
</XStack>
</XStack>
);
}
+44
View File
@@ -0,0 +1,44 @@
import { YStack, XStack, SizableText } from '@hanzo/gui';
/**
* PanelCard — the shared React panel chrome (floating glass card + title bar) that
* every ported panel renders into, the @hanzo/gui analogue of the vanilla `Panel`
* base. One place owns the panel frame so individual panels carry only their body.
*/
export function PanelCard({
title,
children,
}: {
title: string;
children: React.ReactNode;
}): React.JSX.Element {
return (
<YStack
width={340}
maxHeight="70vh"
borderRadius="$4"
borderWidth={1}
borderColor="rgba(255,255,255,0.12)"
backgroundColor="rgba(12,12,14,0.82)"
overflow="hidden"
style={{ backdropFilter: 'blur(12px)' }}
>
<XStack
px="$3"
py="$2.5"
ai="center"
jc="space-between"
borderBottomWidth={1}
borderColor="rgba(255,255,255,0.10)"
>
<SizableText size="$2" color="$color11" style={{ textTransform: 'uppercase', letterSpacing: 1 }}>
{title}
</SizableText>
<XStack width={6} height={6} borderRadius={999} backgroundColor="#fff" opacity={0.7} />
</XStack>
<YStack px="$3" py="$2.5" overflow="scroll">
{children}
</YStack>
</YStack>
);
}
+60
View File
@@ -0,0 +1,60 @@
import { XStack, SizableText } from '@hanzo/gui';
import { VARIANT_TABS } from '../variants';
/**
* VariantTabs — the header view switcher (Cloud · AI · Crypto · Finance · Tech ·
* World), ported to React. Presentation only: it renders the canonical
* `VARIANT_TABS` and reports the picked id upward; the actual switch (canonical
* aliasing + persistence) stays owned by `@/config/variant`
* (setSiteVariantRuntime), called once by the parent — one switch path.
*/
export function VariantTabs({
active,
onSelect,
}: {
active: string;
onSelect: (id: string) => void;
}): React.JSX.Element {
return (
<XStack
gap="$1"
ai="center"
role="tablist"
aria-label="View switcher"
borderRadius="$10"
borderWidth={1}
borderColor="rgba(255,255,255,0.12)"
p="$1"
>
{VARIANT_TABS.map((tab) => {
const on = tab.id === active;
return (
<XStack
key={tab.id}
role="tab"
aria-selected={on}
tag="button"
focusable
cursor="pointer"
ai="center"
gap="$1.5"
px="$2.5"
py="$1.5"
borderRadius="$8"
backgroundColor={on ? 'rgba(255,255,255,0.14)' : 'transparent'}
hoverStyle={{ backgroundColor: 'rgba(255,255,255,0.08)' }}
pressStyle={{ backgroundColor: 'rgba(255,255,255,0.18)' }}
onPress={() => onSelect(tab.id)}
>
<SizableText size="$2" aria-hidden>
{tab.icon}
</SizableText>
<SizableText size="$2" color={on ? '$color12' : '$color10'}>
{tab.label}
</SizableText>
</XStack>
);
})}
</XStack>
);
}
+27
View File
@@ -0,0 +1,27 @@
import './theme.css';
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { GuiProvider } from '@hanzo/gui';
import guiConfig from './gui.config';
import { App } from './App';
/**
* React 19 entry for the world.hanzo.ai rewrite. This is a SECOND, isolated entry
* (index.react.html → here) that runs alongside the shipping vanilla app — the
* vanilla surface at index.html stays intact and deployable while the React +
* @hanzo/gui foundation is built out behind ?react / the dedicated build.
*
* GuiProvider is the Tamagui runtime provider (from @hanzo/gui) fed the ONE config
* (gui.config.ts). Everything below it can use @hanzogui/* primitives and the
* unified shell.
*/
const host = document.getElementById('react-root');
if (!host) throw new Error('[world/react] #react-root mount node missing');
createRoot(host).render(
<StrictMode>
<GuiProvider config={guiConfig} defaultTheme="dark">
<App />
</GuiProvider>
</StrictMode>,
);
+19
View File
@@ -0,0 +1,19 @@
// Canonical variant set for the React surface, mirroring the vanilla header
// switcher (Cloud · AI · Crypto · Finance · Tech · World). The variant IDS and
// aliasing stay owned by @/config/variant (getSiteVariant / setSiteVariantRuntime)
// — this only carries the presentation (label + icon + order) for the React tabs,
// so the switch logic remains one source of truth in the config layer.
export interface VariantTab {
id: string;
label: string;
icon: string;
}
export const VARIANT_TABS: readonly VariantTab[] = [
{ id: 'cloud', label: 'Cloud', icon: '☁️' },
{ id: 'ai', label: 'AI', icon: '🤖' },
{ id: 'crypto', label: 'Crypto', icon: '₿' },
{ id: 'finance', label: 'Finance', icon: '📈' },
{ id: 'tech', label: 'Tech', icon: '💻' },
{ id: 'full', label: 'World', icon: '🌍' },
] as const;