Compare commits

...
Author SHA1 Message Date
hanzo-dev 2f0a3a1569 chore(world): tidy theme-proof brand tokens 2026-07-07 22:08:53 -07:00
hanzo-dev 1304900e2a feat(world): embeddable OSS data-stream components in @hanzo/ui
Port the highest-value hanzoai/world panels to reusable, themeable React
components under @hanzo/ui/world (mirrors the /dash module pattern):

- NewsStream        — news/signal feed with severity rails, relative time
- MarketTicker      — quotes + inline sparkline + change (list & strip variants)
- InstabilityScore  — 0-100 risk gauge card (level, trend, driver breakdown)
- PredictionMarket  — yes/no probability bars

All pure-props with loading/error/empty states, plus /v1/world data hooks
(useWorldData + typed wrappers; native fetch, polling, abort-on-unmount).
Default look = world.hanzo.ai Geist/black-monochrome; re-skins to any host by
overriding --world-* tokens (default / light / host-brand all proven).

Registered ./world + ./world/* + ./world/tokens.css exports and tsup entries;
ships src/{dash,world}/tokens.css in the npm tarball. Build verified: tsup
bundle + tsc .d.ts emit clean, SSR render of dist asserts correct data,
Tailwind/Geist screenshots confirm the look.
2026-07-07 22:08:26 -07:00
hanzo-dev f9a0da985e feat(billing): shared CreditModal (trial + prepaid buckets, injected top-up)
Presentational, self-contained credit/top-up modal any Hanzo app
(console2, hanzo.app, billing.hanzo.ai) can mount with its own data +
handlers. All data/handlers injected via props (cents-based, commerce
balance shape); no network calls, no app coupling.

- Two distinct buckets: non-cash trial credit vs. real prepaid money,
  with a combined breakdown + explicit total.
- Welcome celebration state when a new user's trial credit just landed.
- Top-up affordance (preset amounts + custom) that calls the injected
  onTopUp(amountCents); Square/HUSD payment flow stays in the caller,
  optionally rendered via children. Reuses the existing handler-prop
  pattern (onAddFunds / SquareCardForm) rather than duplicating it.
- Reuses the package radix Dialog primitive for focus trap, escape,
  overlay + aria; styled with the billing semantic tokens.
- Exported from the billing barrel; 8 vitest cases.
2026-07-04 14:36:34 -07:00
18 changed files with 1797 additions and 0 deletions
+13
View File
@@ -20,6 +20,8 @@
"style",
"bin",
"finance",
"src/dash/tokens.css",
"src/world/tokens.css",
"README.md",
"LICENSE"
],
@@ -280,6 +282,17 @@
"import": "./dist/dash/*.mjs",
"require": "./dist/dash/*.js"
},
"./world": {
"types": "./dist/src/world/index.d.ts",
"import": "./dist/world/index.mjs",
"require": "./dist/world/index.js"
},
"./world/tokens.css": "./src/world/tokens.css",
"./world/*": {
"types": "./dist/src/world/*.d.ts",
"import": "./dist/world/*.mjs",
"require": "./dist/world/*.js"
},
"./billing": {
"types": "./dist/src/billing/index.d.ts",
"import": "./dist/billing/index.mjs",
+2
View File
@@ -0,0 +1,2 @@
# generated render-proof artifacts
.world-*
+104
View File
@@ -0,0 +1,104 @@
// Render proof for @hanzo/ui/world — SSRs the REAL compiled components
// (from dist/world) with sample data and writes a static HTML page.
// Usage: node scripts/world-proof.mjs
import React from 'react'
import { renderToStaticMarkup } from 'react-dom/server'
import { readFileSync, writeFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { dirname, join } from 'node:path'
import {
NewsStream,
MarketTicker,
InstabilityScore,
PredictionMarket,
} from '../dist/world/index.mjs'
const h = React.createElement
const __dirname = dirname(fileURLToPath(import.meta.url))
const root = join(__dirname, '..')
const now = Date.now()
const news = [
{ title: 'Central bank holds rates, signals one cut before year-end', source: 'Reuters', category: 'macro', severity: 'info', location: 'Frankfurt', timestamp: now - 3 * 60_000, url: 'https://example.com/a' },
{ title: 'Undersea cable fault disrupts regional connectivity', source: 'Bloomberg', category: 'infra', severity: 'high', location: 'Red Sea', timestamp: now - 22 * 60_000, url: 'https://example.com/b' },
{ title: 'Border clashes escalate; observers report troop movement', source: 'AP', category: 'conflict', severity: 'critical', location: 'Eastern front', timestamp: now - 61 * 60_000, url: 'https://example.com/c' },
{ title: 'Grid operator restores power to 2.1M after outage', source: 'AFP', severity: 'normal', location: 'Texas, US', timestamp: now - 5 * 3600_000, url: 'https://example.com/d' },
]
const markets = [
{ symbol: 'SPX', name: 'S&P 500', price: 5432.11, change: 0.82, sparkline: [12, 12.4, 12.1, 12.9, 13.2, 13.0, 13.6, 13.9] },
{ symbol: 'BTC', name: 'Bitcoin', price: 61230.5, change: -2.14, sparkline: [64, 63.2, 63.6, 62.1, 61.8, 62.0, 61.2, 61.2] },
{ symbol: 'VIX', name: 'Volatility', price: 14.22, change: -3.9, currency: '', sparkline: [17, 16.2, 15.8, 15.1, 14.9, 14.6, 14.3, 14.2] },
{ symbol: 'GC', name: 'Gold (front)', price: 2338.4, change: 0.31, sparkline: [23.1, 23.3, 23.2, 23.35, 23.4, 23.3, 23.36, 23.38] },
]
const prediction = [
{ title: 'Fed cuts rates by Q3 2026?', yesPrice: 63, volume: 1_240_000, url: 'https://example.com/m1' },
{ title: 'Ceasefire agreement signed this quarter?', yesPrice: 28, volume: 486_000, url: 'https://example.com/m2' },
{ title: 'Incumbent wins re-election?', yesPrice: 91, volume: 3_100_000, url: 'https://example.com/m3' },
]
const ukraine = { code: 'UA', name: 'Ukraine', score: 82, level: 'critical', trend: 'rising', change24h: 4, components: { unrest: 68, conflict: 94, security: 80, information: 72 } }
const brazil = { code: 'BR', name: 'Brazil', score: 37, trend: 'falling', change24h: -3, components: { unrest: 44, conflict: 12, security: 41, information: 39 } }
function Card({ title, children }) {
return h('div', { style: { display: 'flex', flexDirection: 'column', gap: 8 } },
h('div', { style: { fontSize: 11, letterSpacing: '0.04em', textTransform: 'uppercase', color: '#666', fontFamily: 'var(--world-font-mono)' } }, title),
children,
)
}
const body = renderToStaticMarkup(
h('div', { style: { display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(320px, 1fr))', gap: 20, alignItems: 'start' } },
h(Card, { title: '<NewsStream />' }, h(NewsStream, { title: 'Live intel', items: news, maxHeight: '360px' })),
h(Card, { title: '<MarketTicker /> — list' }, h(MarketTicker, { title: 'Markets', items: markets })),
h(Card, { title: '<MarketTicker variant="strip" />' }, h(MarketTicker, { variant: 'strip', items: markets })),
h(Card, { title: '<PredictionMarket />' }, h(PredictionMarket, { title: 'Prediction markets', items: prediction })),
h(Card, { title: '<InstabilityScore /> — critical' }, h(InstabilityScore, { data: ukraine })),
h(Card, { title: '<InstabilityScore /> — moderate' }, h(InstabilityScore, { data: brazil })),
h(Card, { title: '<NewsStream loading />' }, h(NewsStream, { title: 'Loading', items: [], loading: true })),
),
)
writeFileSync(join(root, 'scripts/.world-proof.body.html'), body)
// Assertions on the real rendered output (fail loudly if data didn't render).
const checks = [
['news title', body.includes('Undersea cable fault')],
['market price', body.includes('$61,231') && body.includes('$5,432')],
['change color up', body.includes('--world-up')],
['instability score', body.includes('>82<')],
['level pill', body.toLowerCase().includes('critical')],
['prediction yes%', body.includes('Yes 63%') || body.includes('63%')],
['mono font var', body.includes('var(--world-font-mono)')],
]
let ok = true
for (const [name, pass] of checks) {
console.log(`${pass ? 'PASS' : 'FAIL'} ${name}`)
if (!pass) ok = false
}
const tokens = readFileSync(join(root, 'src/world/tokens.css'), 'utf8')
const bodyPath = join(root, 'scripts/.world-proof.body.html')
// Compile the REAL Tailwind utilities used by the components so the page
// renders with the true Geist/black look (no hand-written CSS).
const postcss = (await import('postcss')).default
const tailwindcss = (await import('@tailwindcss/postcss')).default
const twInput = `@import "tailwindcss";\n@source "${bodyPath}";`
const tw = await postcss([tailwindcss()]).process(twInput, { from: join(root, 'scripts/.world-proof.in.css') })
const html = `<!doctype html>
<html data-theme="dark"><head><meta charset="utf-8"><title>@hanzo/ui/world — render proof</title>
<style>${tokens}
${tw.css}
body{background:var(--world-bg);color:var(--world-text);font-family:var(--world-font-sans);margin:0;padding:32px;}
h1{font-size:14px;font-weight:500;color:var(--world-text-muted);margin:0 0 24px;letter-spacing:-0.01em;}
</style></head>
<body><h1>@hanzo/ui/world — flagship components (SSR of the compiled dist bundle, real Tailwind + Geist/black tokens)</h1>
${body}</body></html>`
writeFileSync(join(root, 'scripts/.world-proof.html'), html)
console.log('wrote scripts/.world-proof.html')
console.log(ok ? '\nSSR RENDER OK' : '\nSSR RENDER FAILED')
process.exit(ok ? 0 : 1)
+80
View File
@@ -0,0 +1,80 @@
// Theming proof: the SAME compiled components re-skinned three ways, purely by
// overriding --world-* custom properties (no component/prop changes).
import React from 'react'
import { renderToStaticMarkup } from 'react-dom/server'
import { readFileSync, writeFileSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { fileURLToPath, pathToFileURL } from 'node:url'
import { chromium } from 'playwright'
import postcss from 'postcss'
import tailwindcss from '@tailwindcss/postcss'
import { NewsStream, MarketTicker, InstabilityScore } from '../dist/world/index.mjs'
const h = React.createElement
const root = join(dirname(fileURLToPath(import.meta.url)), '..')
const news = [
{ title: 'Central bank holds rates, signals one cut before year-end', source: 'Reuters', category: 'macro', severity: 'info', timestamp: Date.now() - 3 * 60_000, url: 'https://example.com/a' },
{ title: 'Border clashes escalate; observers report troop movement', source: 'AP', category: 'conflict', severity: 'critical', timestamp: Date.now() - 61 * 60_000, url: 'https://example.com/c' },
]
const markets = [
{ symbol: 'SPX', name: 'S&P 500', price: 5432.11, change: 0.82, sparkline: [12, 12.4, 12.1, 12.9, 13.2, 13.0, 13.6, 13.9] },
{ symbol: 'BTC', name: 'Bitcoin', price: 61230.5, change: -2.14, sparkline: [64, 63.2, 63.6, 62.1, 61.8, 62.0, 61.2, 61.2] },
]
const score = { code: 'UA', name: 'Ukraine', score: 82, trend: 'rising', change24h: 4, components: { unrest: 68, conflict: 94 } }
function trio() {
return h('div', { style: { display: 'flex', flexDirection: 'column', gap: 14 } },
h(NewsStream, { title: 'Live intel', items: news }),
h(MarketTicker, { title: 'Markets', items: markets }),
h(InstabilityScore, { data: score, compact: true }),
)
}
// Column 1: default (Geist/black). Column 2: light theme (data-theme=light).
// Column 3: a fictitious customer brand — override --world-* to inherit host tokens.
const brand = [
'--world-surface:#101725', '--world-bg:#0b0f1a', '--world-border:#22304a',
'--world-text:#e6edf7', '--world-text-muted:#8aa0c0', '--world-text-dim:#5b7096',
'--world-accent:#4f8cff', '--world-up:#22c55e', '--world-down:#f43f5e',
'--world-radius:0.9rem', "--world-font-sans:'Inter',system-ui,sans-serif",
].join(';')
const body = h('div', { style: { display: 'grid', gridTemplateColumns: 'repeat(3,1fr)', gap: 28 } },
h('div', null, h('div', { className: 'lbl' }, 'default — Geist / black (world.hanzo.ai)'), h('div', {}, trio())),
h('div', { 'data-theme': 'light', style: { background: 'var(--world-bg)', padding: 12, borderRadius: 12 } },
h('div', { className: 'lbl' }, 'data-theme="light" — inherits light mode'), h('div', {}, trio())),
h('div', { style: { cssText: '' } },
h('div', { className: 'lbl' }, 'host brand — override --world-*'),
h('div', { style: styleObj(brand) }, trio())),
)
function styleObj(css) {
const o = {}
for (const decl of css.split(';')) {
const [k, v] = decl.split(/:(.+)/)
if (k && v) o[k.trim()] = v.trim()
}
return o
}
const rendered = renderToStaticMarkup(body)
const bodyPath = join(root, 'scripts/.world-theme.body.html')
writeFileSync(bodyPath, rendered)
const tokens = readFileSync(join(root, 'src/world/tokens.css'), 'utf8')
const tw = await postcss([tailwindcss()]).process(`@import "tailwindcss";\n@source "${bodyPath}";`, { from: join(root, 'scripts/.world-theme.in.css') })
const html = `<!doctype html><html data-theme="dark"><head><meta charset="utf-8"><style>${tokens}\n${tw.css}
body{background:#000;color:#e8e8e8;font-family:var(--world-font-sans);margin:0;padding:28px}
.lbl{font-size:11px;text-transform:uppercase;letter-spacing:.05em;color:#777;margin-bottom:10px;font-family:var(--world-font-mono,monospace)}
[data-theme=light]{color:var(--world-text)}
</style></head><body>${rendered}</body></html>`
const htmlPath = join(root, 'scripts/.world-theme.html')
writeFileSync(htmlPath, html)
const b = await chromium.launch()
const p = await b.newPage({ viewport: { width: 1280, height: 620 }, deviceScaleFactor: 2 })
await p.goto(pathToFileURL(htmlPath).href, { waitUntil: 'networkidle' })
await p.screenshot({ path: 'scripts/.world-theme.png', fullPage: true })
await b.close()
console.log('theme proof written')
@@ -0,0 +1,81 @@
import { describe, it, expect, vi, beforeAll } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import * as React from 'react'
import { CreditModal } from './credit-modal'
// Radix Dialog relies on pointer-capture + scrollIntoView, which happy-dom lacks.
beforeAll(() => {
if (!Element.prototype.hasPointerCapture) {
Element.prototype.hasPointerCapture = () => false
Element.prototype.setPointerCapture = () => {}
Element.prototype.releasePointerCapture = () => {}
}
if (!Element.prototype.scrollIntoView) {
Element.prototype.scrollIntoView = () => {}
}
})
describe('CreditModal', () => {
it('renders nothing when closed', () => {
render(<CreditModal open={false} onClose={() => {}} />)
expect(screen.queryByText('Credits & balance')).toBeNull()
})
it('shows the trial + prepaid buckets distinctly with a clear total', () => {
render(
<CreditModal open onClose={() => {}} trialBalanceCents={500} prepaidBalanceCents={1234} />,
)
// Two distinct buckets.
expect(screen.getByText('$5.00')).toBeInTheDocument()
expect(screen.getByText('$12.34')).toBeInTheDocument()
// Combined breakdown + explicit total.
expect(screen.getByText('$5.00 trial + $12.34 credits')).toBeInTheDocument()
expect(screen.getByText('Total $17.34')).toBeInTheDocument()
})
it('shows the welcome celebration for new users', () => {
render(
<CreditModal
open
onClose={() => {}}
isNewUser
trialGrantedCents={500}
trialBalanceCents={500}
/>,
)
expect(screen.getByText("You've got $5.00 in free credits")).toBeInTheDocument()
})
it('calls onTopUp with the preset amount in cents', () => {
const onTopUp = vi.fn()
render(<CreditModal open onClose={() => {}} onTopUp={onTopUp} topUpOptions={[1000, 2500]} />)
fireEvent.click(screen.getByText('$25.00'))
expect(onTopUp).toHaveBeenCalledWith(2500)
})
it('converts a custom dollar amount to cents before calling onTopUp', () => {
const onTopUp = vi.fn()
render(<CreditModal open onClose={() => {}} onTopUp={onTopUp} topUpOptions={[]} />)
fireEvent.change(screen.getByPlaceholderText('Custom amount'), { target: { value: '7.50' } })
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
expect(onTopUp).toHaveBeenCalledWith(750)
})
it('does not call onTopUp while busy', () => {
const onTopUp = vi.fn()
render(<CreditModal open onClose={() => {}} onTopUp={onTopUp} topUpOptions={[1000]} busy />)
fireEvent.click(screen.getByText('$10.00'))
expect(onTopUp).not.toHaveBeenCalled()
})
it('hides the top-up affordance when no onTopUp is supplied', () => {
render(<CreditModal open onClose={() => {}} prepaidBalanceCents={1000} />)
expect(screen.queryByText('Add credits')).toBeNull()
})
it('surfaces an error message', () => {
render(<CreditModal open onClose={() => {}} error="Card declined" />)
expect(screen.getByRole('alert')).toHaveTextContent('Card declined')
})
})
@@ -0,0 +1,198 @@
'use client'
import * as React from 'react'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '../../../primitives/dialog'
/**
* Default preset top-up amounts, in cents ($10 / $25 / $50 / $100).
* Callers may override via `topUpOptions`.
*/
const DEFAULT_TOP_UP_OPTIONS = [1000, 2500, 5000, 10000]
export interface CreditModalProps {
/** Controls visibility. */
open: boolean
/** Fired on escape, overlay click, or the close affordance. */
onClose: () => void
/** Non-cash promotional trial credit remaining, in cents. */
trialBalanceCents?: number
/** Real (paid) prepaid credit remaining, in cents. */
prepaidBalanceCents?: number
/** Trial credit originally granted, in cents — drives the welcome copy. */
trialGrantedCents?: number
/** ISO 4217 currency code. */
currency?: string
/** Preset top-up amounts, in cents. */
topUpOptions?: number[]
/**
* Injected top-up handler. The Square/HUSD payment flow lives in the caller;
* this modal only presents amounts + buckets, then hands back the chosen cents.
* Omit to render a read-only balance view.
*/
onTopUp?: (amountCents: number) => void | Promise<void>
/** First-run celebration state (e.g. trial credit just landed). */
isNewUser?: boolean
/** Disables actions while a payment/settlement is in flight. */
busy?: boolean
/** Error to surface (e.g. a failed charge). */
error?: string | null
/**
* Optional caller-injected payment surface (e.g. <SquareCardForm/>),
* rendered below the amount picker.
*/
children?: React.ReactNode
/** Overrides the default (non-welcome) heading. */
title?: string
}
function formatCents(cents: number, currency: string) {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency,
minimumFractionDigits: 2,
}).format((cents || 0) / 100)
}
export function CreditModal({
open,
onClose,
trialBalanceCents = 0,
prepaidBalanceCents = 0,
trialGrantedCents,
currency = 'usd',
topUpOptions = DEFAULT_TOP_UP_OPTIONS,
onTopUp,
isNewUser = false,
busy = false,
error,
children,
title = 'Credits & balance',
}: CreditModalProps) {
const [custom, setCustom] = React.useState('')
const totalCents = trialBalanceCents + prepaidBalanceCents
const welcomeCents = trialGrantedCents ?? trialBalanceCents
const showWelcome = isNewUser && welcomeCents > 0
const customCents = React.useMemo(() => {
const dollars = Number.parseFloat(custom)
if (!Number.isFinite(dollars) || dollars <= 0) return 0
return Math.round(dollars * 100)
}, [custom])
const topUp = React.useCallback(
(cents: number) => {
if (busy || cents <= 0 || !onTopUp) return
void onTopUp(cents)
},
[busy, onTopUp],
)
return (
<Dialog open={open} onOpenChange={(next) => { if (!next) onClose() }}>
<DialogContent className="max-w-md border-border bg-bg-card text-text">
<DialogHeader>
<DialogTitle className="text-text">
{showWelcome
? `You've got ${formatCents(welcomeCents, currency)} in free credits`
: title}
</DialogTitle>
<DialogDescription className="text-text-muted">
{showWelcome
? 'Start building — trial credit is applied automatically before any charge.'
: 'Trial credit is applied before your prepaid balance.'}
</DialogDescription>
</DialogHeader>
{/* Two distinct buckets: promo trial credit vs. real prepaid money */}
<div className="overflow-hidden rounded-xl border border-border bg-bg">
<div className="grid grid-cols-2 divide-x divide-border">
<div className="p-4">
<div className="flex items-center gap-2">
<p className="text-xs font-semibold uppercase tracking-[0.14em] text-text-muted">Trial</p>
<span className="rounded-full bg-warning/10 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-warning">
Promo
</span>
</div>
<p className="mt-1 text-2xl font-bold text-text">{formatCents(trialBalanceCents, currency)}</p>
<p className="mt-0.5 text-xs text-text-dim">Non-cash credit</p>
</div>
<div className="p-4">
<p className="text-xs font-semibold uppercase tracking-[0.14em] text-text-muted">Prepaid</p>
<p className="mt-1 text-2xl font-bold text-text">{formatCents(prepaidBalanceCents, currency)}</p>
<p className="mt-0.5 text-xs text-text-dim">Paid credit</p>
</div>
</div>
<div className="flex flex-wrap items-center justify-between gap-2 border-t border-border px-4 py-3">
<span className="text-sm text-text-muted">
{formatCents(trialBalanceCents, currency)} trial + {formatCents(prepaidBalanceCents, currency)} credits
</span>
<span className="text-sm font-semibold text-text">Total {formatCents(totalCents, currency)}</span>
</div>
</div>
{/* Top-up affordance. The payment execution is injected via onTopUp. */}
{onTopUp && (
<div className="space-y-3">
<p className="text-sm font-medium text-text">Add credits</p>
{topUpOptions.length > 0 && (
<div className="grid grid-cols-4 gap-2">
{topUpOptions.map((cents) => (
<button
key={cents}
type="button"
disabled={busy}
onClick={() => topUp(cents)}
className="rounded-lg border border-border bg-bg px-3 py-2 text-sm font-semibold text-text transition hover:bg-bg-elevated disabled:cursor-not-allowed disabled:opacity-60"
>
{formatCents(cents, currency)}
</button>
))}
</div>
)}
<div className="flex items-center gap-2">
<div className="relative flex-1">
<span className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-sm text-text-dim">$</span>
<input
type="number"
min="0"
step="0.01"
inputMode="decimal"
value={custom}
onChange={(e) => setCustom(e.target.value)}
placeholder="Custom amount"
disabled={busy}
className="w-full rounded-lg border border-border bg-bg py-2 pl-7 pr-3 text-sm text-text placeholder:text-text-dim focus:border-brand focus:outline-none disabled:opacity-60"
/>
</div>
<button
type="button"
disabled={busy || customCents <= 0}
onClick={() => topUp(customCents)}
className="rounded-lg bg-brand px-5 py-2 text-sm font-semibold text-brand-foreground transition hover:bg-brand-hover active:scale-[0.97] disabled:cursor-not-allowed disabled:opacity-60"
>
{busy ? 'Processing…' : 'Add'}
</button>
</div>
</div>
)}
{/* Injected payment surface (e.g. <SquareCardForm/> or an HUSD flow). */}
{children}
{error && (
<p role="alert" className="text-sm text-danger">
{error}
</p>
)}
</DialogContent>
</Dialog>
)
}
+3
View File
@@ -25,6 +25,9 @@ export type { UsagePanelProps } from './usage-panel'
export { CreditsPanel } from './credits-panel'
export type { CreditsPanelProps } from './credits-panel'
export { CreditModal } from './credit-modal'
export type { CreditModalProps } from './credit-modal'
export { TransactionsPanel } from './transactions-panel'
export type { TransactionsPanelProps } from './transactions-panel'
+102
View File
@@ -0,0 +1,102 @@
# @hanzo/ui/world
Embeddable, OSS data-stream components ported from **hanzoai/world**
(world.hanzo.ai). Drop a live news feed, market ticker, instability gauge, or
prediction-market widget into any Hanzo site or customer app.
Self-contained React + TypeScript, MIT, no external runtime deps beyond the
`@hanzo/ui` peers (`react`, `react-dom`). Default look = the world.hanzo.ai
Geist/black-monochrome Vercel aesthetic; re-skins to any host theme by
overriding CSS custom properties.
```bash
npm i @hanzo/ui # published to npm as @hanzo/ui (public)
```
```tsx
import '@hanzo/ui/world/tokens.css' // once, at app root
import { NewsStream, MarketTicker } from '@hanzo/ui/world'
<NewsStream title="Live intel" items={items} maxHeight="480px" />
<MarketTicker title="Markets" items={quotes} />
```
Sub-path imports keep bundles small:
`@hanzo/ui/world/news-stream`, `/market-ticker`, `/instability-score`,
`/prediction-market`, `/hooks`, `/format`, `/types`.
## Components
| Component | Data prop | Archetype |
|---|---|---|
| `<NewsStream>` | `items: NewsStreamItem[]` | scrolling feed with severity rails |
| `<MarketTicker>` | `items: MarketQuote[]`, `variant: 'list' \| 'strip'` | numeric ticker + inline sparkline |
| `<InstabilityScore>` | `data: InstabilityScoreData` | 0100 gauge card, level + trend + drivers |
| `<PredictionMarket>` | `items: PredictionMarketItem[]` | yes/no probability bars |
Every component is **pure props** and accepts `loading` / `error` /
`emptyMessage` / `className`. Numeric fields keep `| null` as the missing-data
sentinel (components render `N/A`, never a fake `0`).
### Data: props OR the /v1/world hooks
Feed data yourself, or pull it from the Hanzo World API with the built-in hooks
(native `fetch`, polling, abort-on-unmount, no extra deps):
```tsx
import { NewsStream, useNewsStream } from '@hanzo/ui/world'
function Feed() {
const { data, loading, error } = useNewsStream({ baseUrl: 'https://api.hanzo.ai' })
return <NewsStream title="Live intel" items={data ?? []} loading={loading} error={error} />
}
```
`useWorldData<T>(path, opts)` is the generic primitive; `useNewsStream`,
`useMarketTicker`, `usePredictionMarkets`, `useInstabilityScores` are typed
wrappers over `GET {baseUrl}/v1/world/<path>`. Each unwraps `[]`,
`{ items }`, or `{ data, unavailable }` envelopes.
## Theming — default Vercel/Geist, or inherit the host
Every visible style reads a `--world-*` custom property. `tokens.css` ships the
Geist/black default and a `[data-theme="light"]` / `.light` override. To re-skin
onto a host design system, point the tokens at the host's own variables — no
component or prop changes:
```css
:root {
--world-bg: var(--background);
--world-surface: var(--card);
--world-text: var(--foreground);
--world-accent: var(--primary);
--world-font-sans: var(--font-sans);
--world-radius: 0.9rem;
}
```
Geist fonts are **not** bundled — host apps self-host `'Geist'` / `'Geist Mono'`
(the tokens fall back to `system-ui` / `SF Mono` when absent).
Proven three ways (default black / light / host brand) by the render scripts in
`scripts/world-proof.mjs` and `scripts/world-theme-proof.mjs` — they SSR the
compiled `dist` bundle, compile the real Tailwind, and screenshot it.
## Adding the next panel (mechanical fan-out)
The 4 flagship components establish the pattern. To port another world panel:
1. Add the prop type to `types.ts` (mirror the world `services/*` return shape).
2. Write `src/world/<name>.tsx` — pure props, Tailwind classes reading
`var(--world-*)`, `cn()` from `../../utils`, a `className` passthrough,
`loading`/`error`/empty states. Numerics use `[font-family:var(--world-font-mono)]`.
3. Re-export from `index.ts`; register the entry in
`tsup.config.minimal.ts` and the `./world/*` export already covers package.json.
4. Optional: add a typed hook in `hooks.ts`.
**High-value next ports** (from the upstream survey, all map-free and
single-source): FRED econ indicators (`EconomicPanel``services/fred.ts`),
the self-fetching finance trio (macro-signals / ETF-flows / stablecoin
peg-health — endpoint+component pairs), an AI-insight card, and a research feed
(arXiv / HackerNews / GitHub-trending). Reimplement independently against the
public data source — never copy AGPL upstream.
+99
View File
@@ -0,0 +1,99 @@
/**
* @hanzo/ui/world - Formatting + safety helpers
*
* Pure, dependency-free. Numeric formatting mirrors the hanzoai/world panels
* so ported components read identically to world.hanzo.ai.
*/
import type { InstabilityLevel } from './types'
/** Price with adaptive precision (sub-1 values keep more decimals). */
export function formatPrice(value: number | null | undefined, currency = '$'): string {
if (value == null || Number.isNaN(value)) return 'N/A'
const abs = Math.abs(value)
const digits = abs >= 1000 ? 0 : abs >= 1 ? 2 : abs >= 0.01 ? 4 : 6
return currency + value.toLocaleString('en-US', { minimumFractionDigits: digits, maximumFractionDigits: digits })
}
/** Signed percent change, e.g. "+1.24%" / "-0.80%". */
export function formatChange(change: number | null | undefined): string {
if (change == null || Number.isNaN(change)) return '—'
const sign = change > 0 ? '+' : ''
return `${sign}${change.toFixed(2)}%`
}
/** Compact magnitude, e.g. 1_240_000 -> "1.2M". */
export function formatCompact(value: number | null | undefined): string {
if (value == null || Number.isNaN(value)) return ''
const abs = Math.abs(value)
if (abs >= 1_000_000_000) return `${(value / 1_000_000_000).toFixed(1)}B`
if (abs >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M`
if (abs >= 1_000) return `${(value / 1_000).toFixed(0)}K`
return `${value}`
}
/** USD volume, e.g. "$1.2M". Empty string for falsy. */
export function formatVolume(volume: number | null | undefined): string {
if (!volume) return ''
return `$${formatCompact(volume)}`
}
/** Human relative time, e.g. "3m", "2h", "5d". */
export function formatRelativeTime(input: string | number | Date | undefined): string {
if (input == null) return ''
const then = input instanceof Date ? input.getTime() : new Date(input).getTime()
if (Number.isNaN(then)) return ''
const secs = Math.max(0, Math.floor((Date.now() - then) / 1000))
if (secs < 60) return `${secs}s`
const mins = Math.floor(secs / 60)
if (mins < 60) return `${mins}m`
const hours = Math.floor(mins / 60)
if (hours < 24) return `${hours}h`
const days = Math.floor(hours / 24)
if (days < 7) return `${days}d`
return new Date(then).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
}
/**
* Boundary validation: only allow http/https links. Returns undefined for
* anything else (javascript:, data:, relative, malformed) so callers render
* plain text instead of an unsafe anchor.
*/
export function safeUrl(url: string | undefined): string | undefined {
if (!url) return undefined
try {
const u = new URL(url, 'https://invalid.local')
if (u.protocol === 'http:' || u.protocol === 'https:') {
// Reject the relative-resolution sentinel origin.
if (u.hostname === 'invalid.local' && !/^https?:\/\//i.test(url)) return undefined
return u.href
}
} catch {
return undefined
}
return undefined
}
/** SVG polyline points for a sparkline within a w×h box. */
export function sparklinePoints(data: number[], w: number, h: number): string {
if (!data || data.length < 2) return ''
const min = Math.min(...data)
const max = Math.max(...data)
const range = max - min || 1
return data
.map((v, i) => {
const x = (i / (data.length - 1)) * w
const y = h - ((v - min) / range) * (h - 2) - 1
return `${x.toFixed(1)},${y.toFixed(1)}`
})
.join(' ')
}
/** Derive a severity band from a 0-100 instability score. */
export function instabilityLevel(score: number): InstabilityLevel {
if (score >= 80) return 'critical'
if (score >= 60) return 'high'
if (score >= 40) return 'elevated'
if (score >= 20) return 'normal'
return 'low'
}
+129
View File
@@ -0,0 +1,129 @@
'use client'
import * as React from 'react'
import type { PanelState, NewsStreamItem, MarketQuote, PredictionMarketItem, InstabilityScoreData } from './types'
/* ------------------------------------------------------------------ */
/* useWorldData */
/* ------------------------------------------------------------------ */
export interface UseWorldDataOptions {
/**
* API origin. Default `''` → same-origin (`/v1/world/...`). Point at
* `https://api.hanzo.ai` (or a customer gateway) to embed cross-origin.
*/
baseUrl?: string
/** Poll interval in ms. Omit / 0 to fetch once. */
refreshMs?: number
/** Query params appended to the request. */
params?: Record<string, string | number | boolean | undefined>
/** Set false to skip fetching (e.g. while a parent gate is closed). */
enabled?: boolean
/** Override fetch (SSR / auth-injecting wrappers). */
fetcher?: typeof fetch
/** Extra request headers (e.g. Authorization). */
headers?: Record<string, string>
}
/**
* Fetch + poll a `/v1/world/<path>` endpoint into a normalized PanelState.
* Accepts a bare array, `{ items: [...] }`, or `{ data: ... , unavailable? }`.
*/
export function useWorldData<T>(path: string, options: UseWorldDataOptions = {}): PanelState<T> & { refresh: () => void } {
const { baseUrl = '', refreshMs, params, enabled = true, fetcher, headers } = options
const [state, setState] = React.useState<PanelState<T>>({ data: null, loading: enabled, error: null })
// Serialize params so the effect only re-runs on real changes.
const paramsKey = params ? JSON.stringify(params) : ''
const headersKey = headers ? JSON.stringify(headers) : ''
const url = React.useMemo(() => {
const base = baseUrl.replace(/\/$/, '')
const clean = path.replace(/^\//, '')
const u = new URL(`${base}/v1/world/${clean}`, base || 'http://local.invalid')
if (params) {
for (const [k, v] of Object.entries(params)) {
if (v !== undefined) u.searchParams.set(k, String(v))
}
}
// Same-origin: emit a relative URL so it works in the browser.
return base ? u.href : `${u.pathname}${u.search}`
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [baseUrl, path, paramsKey])
const [nonce, setNonce] = React.useState(0)
const refresh = React.useCallback(() => setNonce((n) => n + 1), [])
React.useEffect(() => {
if (!enabled) {
setState({ data: null, loading: false, error: null })
return
}
const controller = new AbortController()
let active = true
const doFetch = fetcher ?? fetch
async function run() {
try {
setState((s) => ({ ...s, loading: true, error: null }))
const res = await doFetch(url, { signal: controller.signal, headers })
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const json = (await res.json()) as unknown
if (!active) return
const { data, unavailable } = normalize<T>(json)
setState({ data, loading: false, error: null, unavailable })
} catch (err) {
if (!active || controller.signal.aborted) return
setState({ data: null, loading: false, error: err instanceof Error ? err.message : 'Request failed' })
}
}
run()
const timer = refreshMs && refreshMs > 0 ? setInterval(run, refreshMs) : null
return () => {
active = false
controller.abort()
if (timer) clearInterval(timer)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [url, enabled, refreshMs, nonce, headersKey])
return { ...state, refresh }
}
/** Unwrap the common envelopes into `{ data, unavailable }`. */
function normalize<T>(json: unknown): { data: T | null; unavailable?: boolean } {
if (json && typeof json === 'object' && !Array.isArray(json)) {
const obj = json as Record<string, unknown>
const unavailable = obj.unavailable === true
if ('items' in obj) return { data: obj.items as T, unavailable }
if ('data' in obj) return { data: obj.data as T, unavailable }
return { data: json as T, unavailable }
}
return { data: json as T }
}
/* ------------------------------------------------------------------ */
/* Typed convenience hooks */
/* ------------------------------------------------------------------ */
/** News feed → `NewsStreamItem[]`. Default 60s poll. */
export function useNewsStream(options: UseWorldDataOptions = {}) {
return useWorldData<NewsStreamItem[]>('news', { refreshMs: 60_000, ...options })
}
/** Market quotes → `MarketQuote[]`. Default 30s poll. */
export function useMarketTicker(options: UseWorldDataOptions = {}) {
return useWorldData<MarketQuote[]>('markets', { refreshMs: 30_000, ...options })
}
/** Prediction markets → `PredictionMarketItem[]`. Default 60s poll. */
export function usePredictionMarkets(options: UseWorldDataOptions = {}) {
return useWorldData<PredictionMarketItem[]>('predictions', { refreshMs: 60_000, ...options })
}
/** Instability scores → `InstabilityScoreData[]`. Default 5m poll. */
export function useInstabilityScores(options: UseWorldDataOptions = {}) {
return useWorldData<InstabilityScoreData[]>('instability', { refreshMs: 300_000, ...options })
}
+63
View File
@@ -0,0 +1,63 @@
/**
* @hanzo/ui/world
*
* Reusable, embeddable data-stream components ported from hanzoai/world
* (world.hanzo.ai). Self-contained React + TS, MIT, no external deps beyond
* @hanzo/ui peers. Feed them data via props or the `useWorldData` hooks that
* hit the /v1/world API.
*
* Default look = the world.hanzo.ai Geist/black-monochrome aesthetic. Re-skin
* to any host by overriding the `--world-*` custom properties — import the
* tokens once:
*
* import '@hanzo/ui/world/tokens.css'
* import { NewsStream, MarketTicker } from '@hanzo/ui/world'
*/
// Components
export { NewsStream } from './news-stream'
export type { NewsStreamProps } from './news-stream'
export { MarketTicker } from './market-ticker'
export type { MarketTickerProps } from './market-ticker'
export { InstabilityScore } from './instability-score'
export type { InstabilityScoreProps } from './instability-score'
export { PredictionMarket } from './prediction-market'
export type { PredictionMarketProps } from './prediction-market'
// Data hooks
export {
useWorldData,
useNewsStream,
useMarketTicker,
usePredictionMarkets,
useInstabilityScores,
} from './hooks'
export type { UseWorldDataOptions } from './hooks'
// Formatting + safety helpers (reusable in host cells / custom renders)
export {
formatPrice,
formatChange,
formatCompact,
formatVolume,
formatRelativeTime,
safeUrl,
sparklinePoints,
instabilityLevel,
} from './format'
// Types
export type {
PanelState,
WorldSeverity,
NewsStreamItem,
MarketQuote,
InstabilityLevel,
InstabilityTrend,
InstabilityComponents,
InstabilityScoreData,
PredictionMarketItem,
} from './types'
+171
View File
@@ -0,0 +1,171 @@
'use client'
import * as React from 'react'
import { cn } from '../../utils'
import type { InstabilityScoreData, InstabilityLevel, InstabilityComponents } from './types'
import { instabilityLevel } from './format'
/* ------------------------------------------------------------------ */
/* Level → token + label */
/* ------------------------------------------------------------------ */
const LEVEL: Record<InstabilityLevel, { color: string; label: string }> = {
low: { color: 'var(--world-normal)', label: 'Low' },
normal: { color: 'var(--world-normal)', label: 'Moderate' },
elevated: { color: 'var(--world-elevated)', label: 'Elevated' },
high: { color: 'var(--world-high)', label: 'High' },
critical: { color: 'var(--world-critical)', label: 'Critical' },
}
const COMPONENT_LABEL: Record<keyof InstabilityComponents, string> = {
unrest: 'Unrest',
conflict: 'Conflict',
security: 'Security',
information: 'Information',
}
/* ------------------------------------------------------------------ */
/* Props */
/* ------------------------------------------------------------------ */
export interface InstabilityScoreProps {
/** Score data. `score` is required; `level` is derived if omitted. */
data: InstabilityScoreData
/** Compact layout (no component breakdown, smaller number). */
compact?: boolean
loading?: boolean
error?: string | null
onClick?: (data: InstabilityScoreData) => void
className?: string
}
/* ------------------------------------------------------------------ */
/* Component */
/* ------------------------------------------------------------------ */
/**
* Instability / risk gauge card. Renders a 0-100 composite score with a
* level band, trend, 24h delta and an optional per-driver breakdown.
* The score is computed upstream (app or /v1/world) and passed in as data.
*
* @example
* <InstabilityScore data={{ name: 'Ukraine', score: 82, trend: 'rising', change24h: 4 }} />
*/
export function InstabilityScore({ data, compact = false, loading = false, error = null, onClick, className }: InstabilityScoreProps) {
const score = Math.max(0, Math.min(100, Math.round(data.score)))
const level = data.level ?? instabilityLevel(score)
const { color, label } = LEVEL[level]
const shell = cn(
'flex flex-col gap-3 overflow-hidden rounded-[var(--world-radius)] border border-[var(--world-border)]',
'bg-[var(--world-surface)] p-3 text-[var(--world-text)] [font-family:var(--world-font-sans)]',
onClick && 'cursor-pointer transition-colors hover:bg-[var(--world-surface-hover)]',
className,
)
const content = (
<>
{error ? (
<div className="py-6 text-center text-sm text-[var(--world-text-dim)]">{error}</div>
) : loading ? (
<div className="space-y-3">
<div className="h-3 w-24 rounded bg-[var(--world-surface-active)]" />
<div className="h-8 w-16 rounded bg-[var(--world-surface-active)]" />
<div className="h-1.5 w-full rounded bg-[var(--world-surface-hover)]" />
</div>
) : (
<>
{/* Header: name + level pill */}
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2 truncate">
{data.code && (
<span className="rounded-[var(--world-radius-sm)] bg-[var(--world-surface-active)] px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-[var(--world-text-muted)] [font-family:var(--world-font-mono)]">
{data.code}
</span>
)}
{data.name && <span className="truncate text-sm font-medium text-[var(--world-text)]">{data.name}</span>}
</div>
<span
className="rounded-full px-2 py-0.5 text-[10px] font-medium uppercase tracking-wide"
style={{ color, background: 'color-mix(in srgb, ' + color + ' 14%, transparent)' }}
>
{label}
</span>
</div>
{/* Score + trend */}
<div className="flex items-end justify-between">
<div className="flex items-baseline gap-1.5">
<span
className={cn('tabular-nums leading-none [font-family:var(--world-font-mono)]', compact ? 'text-2xl' : 'text-4xl')}
style={{ color }}
>
{score}
</span>
<span className="text-xs text-[var(--world-text-dim)]">/100</span>
</div>
{(data.trend || typeof data.change24h === 'number') && <Trend trend={data.trend} change24h={data.change24h} />}
</div>
{/* Meter */}
<div className="h-1.5 w-full overflow-hidden rounded-full bg-[var(--world-surface-active)]">
<div className="h-full rounded-full transition-[width]" style={{ width: `${score}%`, background: color }} />
</div>
{/* Component breakdown */}
{!compact && data.components && <Components components={data.components} />}
</>
)}
</>
)
if (onClick && !loading && !error) {
return (
<button type="button" onClick={() => onClick(data)} className={cn(shell, 'text-left')}>
{content}
</button>
)
}
return <div className={shell}>{content}</div>
}
/* ------------------------------------------------------------------ */
/* Trend + components */
/* ------------------------------------------------------------------ */
function Trend({ trend, change24h }: { trend?: InstabilityScoreData['trend']; change24h?: number }) {
// Rising instability is worse (high), falling is improving (normal).
const color = trend === 'rising' ? 'var(--world-high)' : trend === 'falling' ? 'var(--world-normal)' : 'var(--world-text-dim)'
const arrow = trend === 'rising' ? '↑' : trend === 'falling' ? '↓' : '→'
const delta = typeof change24h === 'number' ? `${change24h > 0 ? '+' : ''}${change24h}` : null
return (
<div className="flex items-center gap-1 text-xs [font-family:var(--world-font-mono)]" style={{ color }}>
<span aria-hidden>{arrow}</span>
{delta && <span className="tabular-nums">{delta}</span>}
<span className="text-[10px] text-[var(--world-text-dim)]">24h</span>
</div>
)
}
function Components({ components }: { components: InstabilityComponents }) {
const entries = (Object.keys(COMPONENT_LABEL) as Array<keyof InstabilityComponents>)
.filter((k) => typeof components[k] === 'number')
.map((k) => [k, components[k] as number] as const)
if (entries.length === 0) return null
return (
<div className="grid grid-cols-2 gap-x-3 gap-y-1.5 border-t border-[var(--world-border)] pt-3">
{entries.map(([key, value]) => {
const v = Math.max(0, Math.min(100, Math.round(value)))
return (
<div key={key} className="flex items-center gap-2">
<span className="w-16 shrink-0 text-[10px] uppercase tracking-wide text-[var(--world-text-dim)]">{COMPONENT_LABEL[key]}</span>
<div className="h-1 flex-1 overflow-hidden rounded-full bg-[var(--world-surface-active)]">
<div className="h-full rounded-full bg-[var(--world-text-muted)]" style={{ width: `${v}%` }} />
</div>
<span className="w-6 text-right text-[10px] tabular-nums text-[var(--world-text-muted)] [font-family:var(--world-font-mono)]">{v}</span>
</div>
)
})}
</div>
)
}
+209
View File
@@ -0,0 +1,209 @@
'use client'
import * as React from 'react'
import { cn } from '../../utils'
import type { MarketQuote } from './types'
import { formatPrice, formatChange, sparklinePoints } from './format'
/* ------------------------------------------------------------------ */
/* Sparkline */
/* ------------------------------------------------------------------ */
function Sparkline({ data, change, w = 56, h = 18 }: { data?: number[]; change: number | null; w?: number; h?: number }) {
const points = data ? sparklinePoints(data, w, h) : ''
if (!points) return <span className="inline-block" style={{ width: w, height: h }} />
const color = change != null && change < 0 ? 'var(--world-down)' : 'var(--world-up)'
return (
<svg width={w} height={h} viewBox={`0 0 ${w} ${h}`} className="shrink-0" aria-hidden>
<polyline points={points} fill="none" stroke={color} strokeWidth={1.2} strokeLinecap="round" strokeLinejoin="round" />
</svg>
)
}
function changeColor(change: number | null): string {
if (change == null) return 'text-[var(--world-text-dim)]'
if (change > 0) return 'text-[var(--world-up)]'
if (change < 0) return 'text-[var(--world-down)]'
return 'text-[var(--world-text-muted)]'
}
/* ------------------------------------------------------------------ */
/* Props */
/* ------------------------------------------------------------------ */
export interface MarketTickerProps {
/** Quotes to render. */
items: MarketQuote[]
/** `list` = vertical rows; `strip` = horizontal scrolling tape. */
variant?: 'list' | 'strip'
/** Optional header label (list variant only). */
title?: string
loading?: boolean
error?: string | null
emptyMessage?: string
onItemClick?: (quote: MarketQuote) => void
/** Constrain height and scroll internally (list variant). CSS length. */
maxHeight?: string
className?: string
}
/* ------------------------------------------------------------------ */
/* Component */
/* ------------------------------------------------------------------ */
/**
* Market quotes with inline sparklines and color-coded change. Two layouts:
* a vertical `list` panel or a horizontal `strip` ticker tape.
*
* @example
* <MarketTicker title="Markets" items={quotes} />
* <MarketTicker variant="strip" items={quotes} />
*/
export function MarketTicker({
items,
variant = 'list',
title,
loading = false,
error = null,
emptyMessage = 'No market data',
onItemClick,
maxHeight,
className,
}: MarketTickerProps) {
if (variant === 'strip') {
return <MarketStrip items={items} loading={loading} error={error} onItemClick={onItemClick} className={className} />
}
return (
<div
className={cn(
'flex flex-col overflow-hidden rounded-[var(--world-radius)] border border-[var(--world-border)]',
'bg-[var(--world-surface)] text-[var(--world-text)] [font-family:var(--world-font-sans)]',
className,
)}
>
{title && (
<div className="border-b border-[var(--world-border)] px-3 py-2 text-xs font-medium text-[var(--world-text-muted)]">
{title}
</div>
)}
<div className="min-h-0 flex-1 overflow-y-auto" style={maxHeight ? { maxHeight } : undefined}>
{error ? (
<div className="px-3 py-8 text-center text-sm text-[var(--world-text-dim)]">{error}</div>
) : loading ? (
<MarketSkeleton />
) : items.length === 0 ? (
<div className="px-3 py-8 text-center text-sm text-[var(--world-text-dim)]">{emptyMessage}</div>
) : (
<ul className="divide-y divide-[var(--world-border)]">
{items.map((q, i) => (
<MarketRow key={q.symbol ?? i} quote={q} onItemClick={onItemClick} />
))}
</ul>
)}
</div>
</div>
)
}
/* ------------------------------------------------------------------ */
/* List row */
/* ------------------------------------------------------------------ */
function MarketRow({ quote, onItemClick }: { quote: MarketQuote; onItemClick?: (q: MarketQuote) => void }) {
const body = (
<div className="flex items-center gap-3 px-3 py-2.5">
<div className="min-w-0 flex-1">
<div className="truncate text-[13px] font-medium text-[var(--world-text)]">{quote.symbol}</div>
{quote.name && <div className="truncate text-[11px] text-[var(--world-text-dim)]">{quote.name}</div>}
</div>
<Sparkline data={quote.sparkline} change={quote.change} />
<div className="w-20 text-right [font-family:var(--world-font-mono)]">
<div className="text-[13px] tabular-nums text-[var(--world-text)]">{formatPrice(quote.price, quote.currency ?? '$')}</div>
<div className={cn('text-[11px] tabular-nums', changeColor(quote.change))}>{formatChange(quote.change)}</div>
</div>
</div>
)
if (onItemClick) {
return (
<li>
<button
type="button"
onClick={() => onItemClick(quote)}
className="block w-full text-left transition-colors hover:bg-[var(--world-surface-hover)]"
>
{body}
</button>
</li>
)
}
return <li>{body}</li>
}
/* ------------------------------------------------------------------ */
/* Strip (ticker tape) */
/* ------------------------------------------------------------------ */
function MarketStrip({
items,
loading,
error,
onItemClick,
className,
}: {
items: MarketQuote[]
loading: boolean
error: string | null
onItemClick?: (q: MarketQuote) => void
className?: string
}) {
return (
<div
className={cn(
'flex items-center gap-5 overflow-x-auto rounded-[var(--world-radius)] border border-[var(--world-border)]',
'bg-[var(--world-surface)] px-3 py-2 [font-family:var(--world-font-mono)]',
className,
)}
>
{error ? (
<span className="text-xs text-[var(--world-text-dim)]">{error}</span>
) : loading ? (
<span className="text-xs text-[var(--world-text-dim)]">Loading</span>
) : (
items.map((q, i) => (
<button
key={q.symbol ?? i}
type="button"
onClick={onItemClick ? () => onItemClick(q) : undefined}
className={cn('flex shrink-0 items-center gap-1.5 whitespace-nowrap text-xs', onItemClick && 'cursor-pointer')}
>
<span className="font-medium text-[var(--world-text)] [font-family:var(--world-font-sans)]">{q.symbol}</span>
<span className="tabular-nums text-[var(--world-text-muted)]">{formatPrice(q.price, q.currency ?? '$')}</span>
<span className={cn('tabular-nums', changeColor(q.change))}>{formatChange(q.change)}</span>
</button>
))
)}
</div>
)
}
function MarketSkeleton() {
return (
<ul className="divide-y divide-[var(--world-border)]">
{Array.from({ length: 5 }).map((_, i) => (
<li key={i} className="flex items-center gap-3 px-3 py-2.5">
<div className="min-w-0 flex-1 space-y-1.5">
<div className="h-3 w-16 rounded bg-[var(--world-surface-active)]" />
<div className="h-2.5 w-24 rounded bg-[var(--world-surface-hover)]" />
</div>
<div className="h-[18px] w-14 rounded bg-[var(--world-surface-hover)]" />
<div className="w-20 space-y-1.5">
<div className="ml-auto h-3 w-14 rounded bg-[var(--world-surface-active)]" />
<div className="ml-auto h-2.5 w-10 rounded bg-[var(--world-surface-hover)]" />
</div>
</li>
))}
</ul>
)
}
+186
View File
@@ -0,0 +1,186 @@
'use client'
import * as React from 'react'
import { cn } from '../../utils'
import type { NewsStreamItem, WorldSeverity } from './types'
import { formatRelativeTime, safeUrl } from './format'
/* ------------------------------------------------------------------ */
/* Severity → token */
/* ------------------------------------------------------------------ */
const SEVERITY_VAR: Record<WorldSeverity, string> = {
critical: 'var(--world-critical)',
high: 'var(--world-high)',
elevated: 'var(--world-elevated)',
normal: 'var(--world-normal)',
low: 'var(--world-normal)',
info: 'var(--world-info)',
}
/* ------------------------------------------------------------------ */
/* Props */
/* ------------------------------------------------------------------ */
export interface NewsStreamProps {
/** Items to render, newest first. */
items: NewsStreamItem[]
/** Optional header label rendered above the stream. */
title?: string
/** Show a skeleton/loading state instead of items. */
loading?: boolean
/** Error message; replaces the list when set. */
error?: string | null
/** Message shown when there are no items. */
emptyMessage?: string
/** Cap the number of rendered rows. */
maxItems?: number
/** Called on row click (in addition to following `url`). */
onItemClick?: (item: NewsStreamItem) => void
/** Constrain height and scroll internally. CSS length. */
maxHeight?: string
className?: string
}
/* ------------------------------------------------------------------ */
/* Component */
/* ------------------------------------------------------------------ */
/**
* Vertical stream of news / signal items. Pure props — feed it any array
* (from the `useNewsStream` hook, the /v1/world API, or your own source).
*
* @example
* <NewsStream title="Live intel" items={items} maxHeight="480px" />
*/
export function NewsStream({
items,
title,
loading = false,
error = null,
emptyMessage = 'No items',
maxItems,
onItemClick,
maxHeight,
className,
}: NewsStreamProps) {
const rows = maxItems ? items.slice(0, maxItems) : items
return (
<div
className={cn(
'flex flex-col overflow-hidden rounded-[var(--world-radius)] border border-[var(--world-border)]',
'bg-[var(--world-surface)] text-[var(--world-text)]',
'[font-family:var(--world-font-sans)]',
className,
)}
>
{title && (
<div className="flex items-center justify-between border-b border-[var(--world-border)] px-3 py-2">
<span className="text-xs font-medium text-[var(--world-text-muted)]">{title}</span>
{!loading && !error && (
<span className="text-[11px] tabular-nums text-[var(--world-text-dim)] [font-family:var(--world-font-mono)]">
{items.length}
</span>
)}
</div>
)}
<div className="min-h-0 flex-1 overflow-y-auto" style={maxHeight ? { maxHeight } : undefined}>
{error ? (
<div className="px-3 py-8 text-center text-sm text-[var(--world-text-dim)]">{error}</div>
) : loading ? (
<NewsStreamSkeleton />
) : rows.length === 0 ? (
<div className="px-3 py-8 text-center text-sm text-[var(--world-text-dim)]">{emptyMessage}</div>
) : (
<ul className="divide-y divide-[var(--world-border)]">
{rows.map((item, i) => (
<NewsRow key={item.id ?? item.url ?? `${item.title}-${i}`} item={item} onItemClick={onItemClick} />
))}
</ul>
)}
</div>
</div>
)
}
/* ------------------------------------------------------------------ */
/* Row */
/* ------------------------------------------------------------------ */
function NewsRow({ item, onItemClick }: { item: NewsStreamItem; onItemClick?: (item: NewsStreamItem) => void }) {
const href = safeUrl(item.url)
const rail = item.severity ? SEVERITY_VAR[item.severity] : undefined
const time = formatRelativeTime(item.timestamp)
const inner = (
<div className="flex gap-2.5 px-3 py-2.5">
<span
aria-hidden
className="mt-1 w-0.5 shrink-0 self-stretch rounded-full"
style={{ background: rail ?? 'transparent' }}
/>
<div className="min-w-0 flex-1">
<div className="text-[13px] font-normal leading-snug text-[var(--world-text)]">{item.title}</div>
<div className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-0.5 text-[11px] text-[var(--world-text-dim)] [font-family:var(--world-font-mono)]">
{item.source && <span className="text-[var(--world-text-muted)]">{item.source}</span>}
{item.location && <span>{item.location}</span>}
{item.category && (
<span className="rounded-[var(--world-radius-sm)] bg-[var(--world-surface-active)] px-1.5 py-px text-[10px] uppercase tracking-wide text-[var(--world-text-muted)]">
{item.category}
</span>
)}
{time && <span className="ml-auto tabular-nums">{time}</span>}
</div>
</div>
</div>
)
const interactive =
'cursor-pointer transition-colors hover:bg-[var(--world-surface-hover)] focus-visible:bg-[var(--world-surface-hover)] focus-visible:outline-none'
if (href) {
return (
<li>
<a
href={href}
target="_blank"
rel="noopener noreferrer"
onClick={() => onItemClick?.(item)}
className={cn('block', interactive)}
>
{inner}
</a>
</li>
)
}
if (onItemClick) {
return (
<li>
<button type="button" onClick={() => onItemClick(item)} className={cn('block w-full text-left', interactive)}>
{inner}
</button>
</li>
)
}
return <li>{inner}</li>
}
function NewsStreamSkeleton() {
return (
<ul className="divide-y divide-[var(--world-border)]">
{Array.from({ length: 6 }).map((_, i) => (
<li key={i} className="flex gap-2.5 px-3 py-2.5">
<span className="mt-1 w-0.5 shrink-0 self-stretch rounded-full bg-[var(--world-surface-active)]" />
<div className="min-w-0 flex-1 space-y-1.5">
<div className="h-3 w-4/5 rounded bg-[var(--world-surface-active)]" />
<div className="h-2.5 w-2/5 rounded bg-[var(--world-surface-hover)]" />
</div>
</li>
))}
</ul>
)
}
+138
View File
@@ -0,0 +1,138 @@
'use client'
import * as React from 'react'
import { cn } from '../../utils'
import type { PredictionMarketItem } from './types'
import { formatVolume, safeUrl } from './format'
/* ------------------------------------------------------------------ */
/* Props */
/* ------------------------------------------------------------------ */
export interface PredictionMarketProps {
/** Markets to render. */
items: PredictionMarketItem[]
/** Optional header label. */
title?: string
loading?: boolean
error?: string | null
emptyMessage?: string
maxItems?: number
/** Constrain height and scroll internally. CSS length. */
maxHeight?: string
className?: string
}
/* ------------------------------------------------------------------ */
/* Component */
/* ------------------------------------------------------------------ */
/**
* Prediction-market widget: each row shows a question and a yes/no
* probability split bar. Pure props — feed it Polymarket-shaped data
* (title / yesPrice / volume / url) from any source.
*
* @example
* <PredictionMarket title="Markets" items={[{ title: 'Rate cut by Q3?', yesPrice: 63, volume: 1_200_000, url }]} />
*/
export function PredictionMarket({
items,
title,
loading = false,
error = null,
emptyMessage = 'No markets',
maxItems,
maxHeight,
className,
}: PredictionMarketProps) {
const rows = maxItems ? items.slice(0, maxItems) : items
return (
<div
className={cn(
'flex flex-col overflow-hidden rounded-[var(--world-radius)] border border-[var(--world-border)]',
'bg-[var(--world-surface)] text-[var(--world-text)] [font-family:var(--world-font-sans)]',
className,
)}
>
{title && (
<div className="border-b border-[var(--world-border)] px-3 py-2 text-xs font-medium text-[var(--world-text-muted)]">
{title}
</div>
)}
<div className="min-h-0 flex-1 overflow-y-auto" style={maxHeight ? { maxHeight } : undefined}>
{error ? (
<div className="px-3 py-8 text-center text-sm text-[var(--world-text-dim)]">{error}</div>
) : loading ? (
<MarketSkeleton />
) : rows.length === 0 ? (
<div className="px-3 py-8 text-center text-sm text-[var(--world-text-dim)]">{emptyMessage}</div>
) : (
<ul className="divide-y divide-[var(--world-border)]">
{rows.map((m, i) => (
<PredictionRow key={m.url ?? `${m.title}-${i}`} market={m} />
))}
</ul>
)}
</div>
</div>
)
}
/* ------------------------------------------------------------------ */
/* Row */
/* ------------------------------------------------------------------ */
function PredictionRow({ market }: { market: PredictionMarketItem }) {
const yes = Math.max(0, Math.min(100, Math.round(market.yesPrice)))
const no = 100 - yes
const href = safeUrl(market.url)
const volume = formatVolume(market.volume)
return (
<li className="px-3 py-2.5">
<div className="flex items-start justify-between gap-2">
{href ? (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className="text-[13px] font-normal leading-snug text-[var(--world-text)] transition-colors hover:text-[var(--world-accent)]"
>
{market.title}
</a>
) : (
<span className="text-[13px] font-normal leading-snug text-[var(--world-text)]">{market.title}</span>
)}
{volume && (
<span className="shrink-0 text-[11px] tabular-nums text-[var(--world-text-dim)] [font-family:var(--world-font-mono)]">{volume}</span>
)}
</div>
<div className="mt-2 flex h-5 overflow-hidden rounded-[var(--world-radius-sm)] bg-[var(--world-surface-active)] text-[10px] font-medium [font-family:var(--world-font-mono)]">
<div
className="flex items-center justify-start overflow-hidden whitespace-nowrap px-1.5 text-black"
style={{ width: `${yes}%`, background: 'var(--world-up)' }}
>
{yes >= 18 && <span>Yes {yes}%</span>}
</div>
<div className="flex items-center justify-end overflow-hidden whitespace-nowrap px-1.5 text-[var(--world-text-muted)]" style={{ width: `${no}%` }}>
{no >= 18 && <span>No {no}%</span>}
</div>
</div>
</li>
)
}
function MarketSkeleton() {
return (
<ul className="divide-y divide-[var(--world-border)]">
{Array.from({ length: 4 }).map((_, i) => (
<li key={i} className="space-y-2 px-3 py-2.5">
<div className="h-3 w-3/4 rounded bg-[var(--world-surface-active)]" />
<div className="h-5 w-full rounded-[var(--world-radius-sm)] bg-[var(--world-surface-hover)]" />
</li>
))}
</ul>
)
}
+102
View File
@@ -0,0 +1,102 @@
/*
* @hanzo/ui/world - Design tokens
*
* Framework-agnostic CSS custom properties for World data components
* (NewsStream, MarketTicker, InstabilityScore, PredictionMarket, ...).
*
* DEFAULT THEME = the world.hanzo.ai look: black monochrome surfaces,
* Geist Sans (UI) + Geist Mono (data/numeric), sentence case, light weights —
* the clean Vercel/Geist aesthetic.
*
* THEMING — one way: every visible style reads a `--world-*` custom property.
* To re-skin the components on top of a host app's design system, override any
* `--world-*` property in your own `:root` (or a scoped ancestor). Point them at
* your host tokens to unify, e.g.:
*
* :root {
* --world-bg: var(--background);
* --world-surface: var(--card);
* --world-text: var(--foreground);
* --world-accent: var(--primary);
* --world-font-sans: var(--font-sans);
* }
*
* The Geist fonts are NOT bundled — host apps self-host 'Geist' / 'Geist Mono'
* (the tokens fall back to system-ui / SF Mono when the faces are absent).
*/
:root {
/* Surfaces — black monochrome (matches hanzo.ai / world.hanzo.ai) */
--world-bg: #000;
--world-surface: #0a0a0a;
--world-surface-hover: #161616;
--world-surface-active: #1c1c1c;
/* Borders — neutral monochrome grey (no tint) */
--world-border: #1e1e1e;
--world-border-strong: #3a3a3a;
/* Text */
--world-text: #e8e8e8;
--world-text-muted: #888;
--world-text-dim: #666;
--world-accent: #fff;
/* Severity — instability / threat / alert level */
--world-critical: #ff4444;
--world-high: #ff8800;
--world-elevated: #ffaa00;
--world-normal: #44aa44;
--world-info: #3b82f6;
/* Directional — price up / down, escalation */
--world-up: #44ff88;
--world-down: #ff4444;
/* Status dot */
--world-live: #44ff88;
--world-stale: #ffaa00;
/* Typography — Geist Sans for UI, Geist Mono for data/numeric */
--world-font-sans: 'Geist', system-ui, -apple-system, sans-serif;
--world-font-mono: 'Geist Mono', 'SF Mono', 'Monaco', ui-monospace, monospace;
/* Sizing */
--world-radius: 0.5rem;
--world-radius-sm: 0.375rem;
--world-transition: 150ms ease;
}
/*
* Light theme override. Applies when a host marks the tree with
* data-theme="light" or a `.light` class (matching world.hanzo.ai and the
* common next-themes conventions). Bright dark-mode accents are darkened so
* they stay legible on light surfaces.
*/
[data-theme='light'],
.light {
--world-bg: #ffffff;
--world-surface: #f8f9fa;
--world-surface-hover: #f0f0f0;
--world-surface-active: #e8e8e8;
--world-border: #e4e4e7;
--world-border-strong: #d4d4d8;
--world-text: #18181b;
--world-text-muted: #52525b;
--world-text-dim: #71717a;
--world-accent: #000;
--world-critical: #dc2626;
--world-high: #ea580c;
--world-elevated: #d97706;
--world-normal: #16a34a;
--world-info: #2563eb;
--world-up: #16a34a;
--world-down: #dc2626;
--world-live: #16a34a;
--world-stale: #d97706;
}
+107
View File
@@ -0,0 +1,107 @@
/**
* @hanzo/ui/world - Shared data types
*
* Prop shapes for the World data components. These mirror the typed data the
* hanzoai/world panels consume, so a host can feed the same payloads the
* /v1/world API returns. Numeric fields keep `| null` as the missing-data
* sentinel — components branch on null to show "N/A" rather than a fake 0.
*/
/** Standard async data envelope shared by every self-fetching component. */
export interface PanelState<T> {
data: T | null
loading: boolean
error: string | null
/** Source reported no data (endpoint reachable but empty/unavailable). */
unavailable?: boolean
}
/** Severity ramp used for alerts, threats and instability. */
export type WorldSeverity = 'critical' | 'high' | 'elevated' | 'normal' | 'low' | 'info'
/* ------------------------------------------------------------------ */
/* NewsStream */
/* ------------------------------------------------------------------ */
export interface NewsStreamItem {
/** Stable key. Falls back to `url` then title+index. */
id?: string
title: string
/** Publisher / feed name, e.g. "Reuters". */
source?: string
/** Canonical article link (http/https only — validated at render). */
url?: string
/** Publish time — Date, epoch ms, or ISO string. */
timestamp?: string | number | Date
/** Free-form category / topic label. */
category?: string
/** Highlights the item and colors its rail. */
severity?: WorldSeverity
/** Optional place label, e.g. "Kyiv, UA". */
location?: string
}
/* ------------------------------------------------------------------ */
/* MarketTicker */
/* ------------------------------------------------------------------ */
export interface MarketQuote {
/** Trading symbol, e.g. "AAPL", "BTC". */
symbol: string
/** Display name, e.g. "Apple Inc.". */
name?: string
/** Last price. `null` = unavailable. */
price: number | null
/** Percent change over the period. `null` = unavailable. */
change: number | null
/** Recent price series for the inline sparkline. */
sparkline?: number[]
/** Currency prefix, default "$". Pass "" for indices. */
currency?: string
}
/* ------------------------------------------------------------------ */
/* InstabilityScore */
/* ------------------------------------------------------------------ */
export type InstabilityLevel = 'low' | 'normal' | 'elevated' | 'high' | 'critical'
export type InstabilityTrend = 'rising' | 'stable' | 'falling'
/** Per-driver breakdown (0-100 each). Mirrors world's ComponentScores. */
export interface InstabilityComponents {
unrest?: number
conflict?: number
security?: number
information?: number
}
export interface InstabilityScoreData {
/** Composite score, 0-100. */
score: number
/** ISO country code or region key, e.g. "UA". */
code?: string
/** Display label, e.g. "Ukraine". */
name?: string
/** Severity band. Derived from `score` when omitted. */
level?: InstabilityLevel
/** Directional movement. */
trend?: InstabilityTrend
/** Change over the last 24h (score points). */
change24h?: number
/** Optional driver breakdown, rendered as mini bars. */
components?: InstabilityComponents
}
/* ------------------------------------------------------------------ */
/* PredictionMarket */
/* ------------------------------------------------------------------ */
export interface PredictionMarketItem {
title: string
/** Probability of "yes" as a percentage, 0-100. */
yesPrice: number
/** Notional traded volume (USD). */
volume?: number
/** Market link (http/https only). */
url?: string
}
+10
View File
@@ -189,6 +189,16 @@ export default defineConfig({
'dash/form': 'src/dash/form.tsx',
'dash/crud': 'src/dash/crud.tsx',
// World components (embeddable data-stream panels from world.hanzo.ai)
'world/index': 'src/world/index.ts',
'world/news-stream': 'src/world/news-stream.tsx',
'world/market-ticker': 'src/world/market-ticker.tsx',
'world/instability-score': 'src/world/instability-score.tsx',
'world/prediction-market': 'src/world/prediction-market.tsx',
'world/hooks': 'src/world/hooks.ts',
'world/format': 'src/world/format.ts',
'world/types': 'src/world/types.ts',
// Auth components (IAMLoginButton, AuthGuard)
'auth/index': 'src/auth/index.ts',