Compare commits
22
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1f0e77c3f7 | ||
|
|
6343c3e384 | ||
|
|
031a3e105e | ||
|
|
7885a8e77f | ||
|
|
4a7e50c207 | ||
|
|
2d9f3d445b | ||
|
|
7cbb8b8e16 | ||
|
|
65a530e5a4 | ||
|
|
cf38fa82cf | ||
|
|
d0d2520121 | ||
|
|
5f915401cb | ||
|
|
ec81483941 | ||
|
|
f87f01dac3 | ||
|
|
b2ab3d6499 | ||
|
|
c97e62192a | ||
|
|
c91a55053e | ||
|
|
faa26ad205 | ||
|
|
faae394d2c | ||
|
|
9f513ad63c | ||
|
|
958824f746 | ||
|
|
451c57df28 | ||
|
|
26ce908779 |
@@ -0,0 +1,103 @@
|
||||
# Hanzo Design System — Canonical Tokens
|
||||
|
||||
`@hanzo/ui` is the single source of truth for the shared Hanzo product look across
|
||||
the **Tailwind** apps (hanzo.chat, hanzo.app, hanzo console, commerce, hanzo-desktop).
|
||||
Change a value here; apps converge on it. This file is that source of truth for the
|
||||
three things that must read as **one product**: typography, the sidebar/panel system,
|
||||
and the dark-black palette.
|
||||
|
||||
> Two design systems, do not confuse them: **`@hanzo/ui`** = shadcn/ui + Tailwind +
|
||||
> Radix (this repo). **`@hanzo/gui`** = the Tamagui/RN system (hanzo console). They
|
||||
> share these *token values* (fonts, dark palette, the sidebar glyph), not code.
|
||||
|
||||
---
|
||||
|
||||
## 1. Typography — Basel Grotesk + Geist Mono
|
||||
|
||||
| Role | Family | Notes |
|
||||
|------|--------|-------|
|
||||
| UI / body / display / heading (`sans`) | **Basel Grotesk** | Self-hosted. Book = weight **400**, Medium = weight **500**. |
|
||||
| code / data / mono (`mono`) | **Geist Mono** | `next/font/google` (`Geist_Mono`) or the geist CDN. |
|
||||
| Arabic / Hebrew (`--font-ar` / `--font-he`) | unchanged | i18n only — keep. |
|
||||
|
||||
**Dropped as defaults:** Geist Sans, DM Sans, Figtree, Inter, PT Sans, Roboto Mono.
|
||||
|
||||
Basel is a **licensed, non-Google** face — **self-host** the woff2/woff, do NOT use
|
||||
`next/font/google` for it. Canonical files (mirror lux.exchange):
|
||||
`Basel-Grotesk-Book.woff2/.woff` (400), `Basel-Grotesk-Medium.woff2/.woff` (500).
|
||||
|
||||
`@font-face` (weights 400/500, `font-display: swap`, `font-style: normal`):
|
||||
|
||||
```css
|
||||
@font-face {
|
||||
font-family: 'Basel';
|
||||
font-style: normal;
|
||||
font-weight: 400; /* Book; 500 = Medium */
|
||||
font-display: swap;
|
||||
src: url('.../Basel-Grotesk-Book.woff2') format('woff2'),
|
||||
url('.../Basel-Grotesk-Book.woff') format('woff');
|
||||
}
|
||||
```
|
||||
|
||||
Per-app adoption (converge the value, keep each app's own mechanism):
|
||||
- **@hanzo/ui / Next apps** → `next/font/local` for Basel (`--font-basel-sans`) +
|
||||
`next/font/google` `Geist_Mono` (`--font-geist-mono`). See `app/lib/fonts.ts`;
|
||||
tailwind `sans → var(--font-basel-sans)`, `mono → var(--font-geist-mono)`.
|
||||
- **Vite + Tailwind apps** (chat, launcher, desktop) → self-host Basel `@font-face`
|
||||
+ geist-mono CDN import; tailwind `fontFamily.sans = ['Basel', …]`,
|
||||
`mono = ['Geist Mono', …]`.
|
||||
- **Tamagui (console)** → Basel `@font-face` in globals + override the Tamagui
|
||||
`body`/`heading` font `family` to Basel; Geist Mono for `code`/`pre`.
|
||||
|
||||
---
|
||||
|
||||
## 2. Sidebar toggle icon — lucide `PanelLeft`
|
||||
|
||||
One glyph everywhere: lucide **`PanelLeft`** (the shadcn `SidebarTrigger` default).
|
||||
Where a directional open/close affordance is wanted, use the pair
|
||||
**`PanelLeftClose`** (expanded) / **`PanelLeft`** (collapsed). Never a hamburger,
|
||||
a magnifier, a directional arrow, or a bespoke panel SVG for the sidebar toggle.
|
||||
|
||||
- Icon: stroke-2, ~16–20px, `currentColor`.
|
||||
- Button: ghost/outline, square (`size-7`/`h-6 w-6`), subtle hover.
|
||||
|
||||
---
|
||||
|
||||
## 3. Sidebar + panels
|
||||
|
||||
| Spec | Value |
|
||||
|------|-------|
|
||||
| Sidebar width (expanded) | **16rem / 256px** (`SIDEBAR_WIDTH`) |
|
||||
| Sidebar width (collapsed / icon rail) | **3rem / 48px** (apps vary 48–70px) |
|
||||
| Sidebar / panel surface | resting **#0a0a0a** over the true-black page |
|
||||
| Border / separation | `border-border` — subtle white-alpha ~10% in dark (`border-r` / `border-l`) |
|
||||
| Item hover | subtle `white/5` |
|
||||
| Item active | monochrome `white/10` — **no colored accent** (the house style is monochrome) |
|
||||
| Right panel rail | `border-l border-border`, same surface, collapsible |
|
||||
|
||||
Prefer the `@hanzo/ui` `Sidebar` primitive (`pkgs/ui/primitives/sidebar.tsx`,
|
||||
`SidebarTrigger` → `PanelLeft`) where the app can consume it; otherwise match these
|
||||
classes/tokens.
|
||||
|
||||
---
|
||||
|
||||
## 4. Dark-black palette (true-black OLED)
|
||||
|
||||
The house dark theme is a **true-black** canvas (matches hanzo.ai marketing +
|
||||
hanzo.chat OLED), with a shallow surface-depth ladder for cards/panels and quiet
|
||||
hairline borders — never harsh pure-white on pure-black.
|
||||
|
||||
| Token | Value | Use |
|
||||
|-------|-------|-----|
|
||||
| Page background | **#000000** (`oklch(0 0 0)`) | body / canvas |
|
||||
| Surface / sidebar / panel (resting) | **#0a0a0a** | sidebars, panels, cards |
|
||||
| Press | **#050505** | pressed surface |
|
||||
| Elevated / hover | **#171717** | hover, raised card |
|
||||
| Border / divider | **rgba(255,255,255,0.10)** (≈ `#171717` opaque on black) | hairlines |
|
||||
| Foreground (primary text) | near-white **#ededf1** (`oklch(0.985)`) — not pure `#fff` | body text |
|
||||
| Muted / secondary text | `white/70` (≈ `#a1a1aa`) | secondary |
|
||||
|
||||
Keep each app's theme-token engine (CSS vars / Tailwind tokens / Tamagui `$color*`);
|
||||
converge the **values/usage** to the table above, don't rip the engine.
|
||||
|
||||
Reference: `pkgs/ui/style/hanzo-default-colors.css` (`.dark` / `.hanzo-ui-dark-theme`).
|
||||
Binary file not shown.
Binary file not shown.
+28
-7
@@ -165,22 +165,42 @@ export const AI_TOKEN_ABI = [
|
||||
// Contract addresses by chain ID
|
||||
export const CONTRACT_ADDRESSES: Record<
|
||||
number,
|
||||
{ registry: `0x${string}`; token: `0x${string}` }
|
||||
{
|
||||
registry: `0x${string}`;
|
||||
token: `0x${string}`;
|
||||
chainConfig?: `0x${string}`;
|
||||
husd?: `0x${string}`;
|
||||
faucet?: `0x${string}`;
|
||||
}
|
||||
> = {
|
||||
// Local Testnet (31337)
|
||||
31337: {
|
||||
registry: "0x9fE46736679d2D9a65F0992F2272dE9f3c7fa6e0",
|
||||
token: "0x5FbDB2315678afecb367f032d93F642f64180aa3",
|
||||
},
|
||||
// Hanzo Mainnet (36963)
|
||||
// Hanzo Mainnet (36963) — sovereign L1, deployed 2026-06
|
||||
36963: {
|
||||
registry: "0x0000000000000000000000000000000000000000", // Deploy and update
|
||||
token: "0x0000000000000000000000000000000000000000", // Deploy and update
|
||||
registry: "0xf3df584A4a996b5D215E740B2240886d42C7307a",
|
||||
token: "0x799586e3637E68250449e840F22F8a1a01d6E934", // AIToken (AI)
|
||||
chainConfig: "0x25C806e07bA1c7B5c3495a8C57E6b8fd346092E1",
|
||||
husd: "0xe9e32EF8aaECB68794Da3E1E9191b0a64CeC2c83", // HUSD (LUSD)
|
||||
faucet: "0xd27d8049A575A63b54aAbbC4C41dBa5963cedF56",
|
||||
},
|
||||
// Hanzo Testnet (36962)
|
||||
// Hanzo Testnet (36962) — sovereign L1, deployed 2026-06
|
||||
36962: {
|
||||
registry: "0x0000000000000000000000000000000000000000", // Deploy and update
|
||||
token: "0x0000000000000000000000000000000000000000", // Deploy and update
|
||||
registry: "0x6EA9D7C669DAC51830219ff5d4391872a25AB147",
|
||||
token: "0x9Adf4583DDB3aFF5fA08a6788fc203e9d9908F4F", // AIToken (AI)
|
||||
chainConfig: "0x6162A52F71a1C8F0F1F86FE8D17d6DDedaEdaC3c",
|
||||
husd: "0xc57b7eCE2Ce2E74ef3Bc08Cfd5f5Fb41B6Ad4D66", // HUSD (LUSD)
|
||||
faucet: "0x88810C4F376aF0018641e98Fcc06f0b7Ba529937",
|
||||
},
|
||||
// Hanzo Devnet (36964) — sovereign L1, deployed 2026-06
|
||||
36964: {
|
||||
registry: "0xDeA8179dEc51eA55E03fbe257d51c6d0f5908E3F",
|
||||
token: "0x486809dD1bac9A17f18a1a640cdEf014C7DD809a", // AIToken (AI)
|
||||
chainConfig: "0xf911b6e4952781949Db84B478582Ca05817fECB4",
|
||||
husd: "0xBf92c933774daDF112159Be4b29e6BDc3ffAa2B1", // HUSD (LUSD)
|
||||
faucet: "0x0B2B0BF9f423C03151480e1bB63caCd4cB3B6343",
|
||||
},
|
||||
// Lux Mainnet (96369)
|
||||
96369: {
|
||||
@@ -208,6 +228,7 @@ export const NETWORK_NAMES: Record<number, string> = {
|
||||
31337: "localhost",
|
||||
36963: "hanzo",
|
||||
36962: "hanzo-testnet",
|
||||
36964: "hanzo-devnet",
|
||||
96369: "lux",
|
||||
96368: "lux-testnet",
|
||||
200200: "zoo",
|
||||
|
||||
+13
-2
@@ -1,6 +1,17 @@
|
||||
import { GeistMono } from "geist/font/mono"
|
||||
import { GeistSans } from "geist/font/sans"
|
||||
import localFont from "next/font/local"
|
||||
|
||||
export const fontSans = GeistSans
|
||||
// Canonical Hanzo typography (single source of truth; see DESIGN.md):
|
||||
// - UI / body / display / heading -> Basel Grotesk (self-hosted, Book 400 + Medium 500)
|
||||
// - code / data / mono -> Geist Mono
|
||||
// Basel replaces Geist Sans / DM Sans / Figtree / Inter as the default sans.
|
||||
export const fontSans = localFont({
|
||||
src: [
|
||||
{ path: "../fonts/Basel-Grotesk-Book.woff2", weight: "400", style: "normal" },
|
||||
{ path: "../fonts/Basel-Grotesk-Medium.woff2", weight: "500", style: "normal" },
|
||||
],
|
||||
variable: "--font-basel-sans",
|
||||
display: "swap",
|
||||
})
|
||||
|
||||
export const fontMono = GeistMono
|
||||
|
||||
@@ -23,7 +23,7 @@ const config = {
|
||||
},
|
||||
extend: {
|
||||
fontFamily: {
|
||||
sans: ["var(--font-geist-sans)", ...fontFamily.sans],
|
||||
sans: ["var(--font-basel-sans)", ...fontFamily.sans],
|
||||
mono: ["var(--font-geist-mono)", ...fontFamily.mono],
|
||||
},
|
||||
colors: {
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
# Build artifacts — declarations are emitted by `pnpm run build` at pack time.
|
||||
types/
|
||||
*.tsbuildinfo
|
||||
# Standalone install (this package is not in the root pnpm workspace).
|
||||
node_modules/
|
||||
pnpm-lock.yaml
|
||||
+2
-3
@@ -1,8 +1,7 @@
|
||||
# Keep the published tarball lean — ship src, not tests or dev type-check config.
|
||||
# Keep the published tarball lean — ship src + compiled types, not tests or dev config.
|
||||
**/*.test.ts
|
||||
**/*.test.tsx
|
||||
tsconfig.json
|
||||
tsconfig.build.json
|
||||
vitest.config.ts
|
||||
gui.config.ts
|
||||
gui.d.ts
|
||||
node_modules
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
// The @hanzo/gui v5 config this package is authored against. `createGui` with the
|
||||
// shared `@hanzogui/config/v5` default enables the shorthand style props
|
||||
// (bg/px/py/items/justify/rounded/…) the components use; `Conf` feeds the type
|
||||
// augmentation in `gui.d.ts` so `tsc --noEmit` type-checks the shorthands exactly
|
||||
// as the consuming app (console) does. Not shipped — dev/type-check only.
|
||||
import { defaultConfig } from '@hanzogui/config/v5'
|
||||
import { createGui } from '@hanzo/gui'
|
||||
|
||||
export const config = createGui(defaultConfig)
|
||||
|
||||
export default config
|
||||
|
||||
export type Conf = typeof config
|
||||
Vendored
-15
@@ -1,15 +0,0 @@
|
||||
/**
|
||||
* Registers our Gui config with the type system so shorthand style props
|
||||
* (tokens, themes, bg/px/py/items/justify etc.) type-check exactly as they do in
|
||||
* the consuming app. GuiCustomConfig is declared in @hanzogui/web and flows
|
||||
* through @hanzo/gui. Dev/type-check only — never shipped.
|
||||
*/
|
||||
import type { Conf } from './gui.config'
|
||||
|
||||
declare module '@hanzogui/web' {
|
||||
interface GuiCustomConfig extends Conf {}
|
||||
}
|
||||
|
||||
declare module '@hanzogui/core' {
|
||||
interface GuiCustomConfig extends Conf {}
|
||||
}
|
||||
+13
-7
@@ -1,38 +1,42 @@
|
||||
{
|
||||
"name": "@hanzo/data",
|
||||
"version": "1.2.0",
|
||||
"version": "1.2.1",
|
||||
"type": "module",
|
||||
"description": "Hanzo Data — cross-platform, metadata-driven data-app components (typed fields, record table, kanban board, record detail, saved views) on @hanzo/gui. The universal object/field/record/view layer that powers any Base-backed CRM, CMS, or commerce app on web, native (iOS), and desktop. Airtable/Twenty-class polish, clean-room.",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./src/index.ts",
|
||||
"types": "./types/index.d.ts",
|
||||
"default": "./src/index.ts"
|
||||
},
|
||||
"./table/logic": {
|
||||
"types": "./src/table/logic.ts",
|
||||
"types": "./types/table/logic.d.ts",
|
||||
"default": "./src/table/logic.ts"
|
||||
},
|
||||
"./board/logic": {
|
||||
"types": "./src/board/logic.ts",
|
||||
"types": "./types/board/logic.d.ts",
|
||||
"default": "./src/board/logic.ts"
|
||||
},
|
||||
"./view/logic": {
|
||||
"types": "./src/view/logic.ts",
|
||||
"types": "./types/view/logic.d.ts",
|
||||
"default": "./src/view/logic.ts"
|
||||
},
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"main": "./src/index.ts",
|
||||
"module": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
"types": "./types/index.d.ts",
|
||||
"sideEffects": [
|
||||
"**/registerDefaults.ts",
|
||||
"**/index.ts"
|
||||
],
|
||||
"files": [
|
||||
"src"
|
||||
"src",
|
||||
"types"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.build.json",
|
||||
"clean": "rm -rf types",
|
||||
"prepack": "pnpm run build",
|
||||
"tc": "tsc --noEmit",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
@@ -45,7 +49,9 @@
|
||||
"devDependencies": {
|
||||
"@hanzo/gui": "7.3.0",
|
||||
"@hanzogui/config": "7.3.0",
|
||||
"@hanzogui/core": "7.3.0",
|
||||
"@hanzogui/lucide-icons-2": "7.3.0",
|
||||
"@hanzogui/web": "7.3.0",
|
||||
"@types/react": "^19.1.10",
|
||||
"react": "^19.0.0",
|
||||
"typescript": "^5.7.2",
|
||||
|
||||
Vendored
+27
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* Registers this package's @hanzo/gui config with the type system so the
|
||||
* shorthand style props (bg / px / py / items / justify / gap / rounded …) that
|
||||
* the components author against resolve to their concrete types DURING THIS
|
||||
* PACKAGE'S OWN COMPILATION.
|
||||
*
|
||||
* Because the resolution happens here, the emitted `.d.ts` (see
|
||||
* `tsconfig.build.json`) bakes in the concrete prop types — a consumer type-checks
|
||||
* against the compiled declarations and never has to re-declare this augmentation
|
||||
* (nor descend into our `.tsx` internals). Ambient + type-only; never emitted.
|
||||
*
|
||||
* `GuiCustomConfig` is the extension point declared by @hanzogui/web and re-declared
|
||||
* by @hanzogui/core; `InferGuiConfig<typeof defaultConfig>` (the return type of
|
||||
* `createGui(defaultConfig)`) is the exact config the components are written for.
|
||||
*/
|
||||
import type { createGui } from '@hanzo/gui'
|
||||
import type { defaultConfig } from '@hanzogui/config/v5'
|
||||
|
||||
type Conf = ReturnType<typeof createGui<typeof defaultConfig>>
|
||||
|
||||
declare module '@hanzogui/web' {
|
||||
interface GuiCustomConfig extends Conf {}
|
||||
}
|
||||
|
||||
declare module '@hanzogui/core' {
|
||||
interface GuiCustomConfig extends Conf {}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"noEmit": false,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"emitDeclarationOnly": true,
|
||||
"outDir": "./types",
|
||||
"rootDir": "./src"
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["node_modules", "types", "**/*.test.ts", "**/*.test.tsx"]
|
||||
}
|
||||
@@ -14,6 +14,6 @@
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"types": ["react"]
|
||||
},
|
||||
"include": ["gui.config.ts", "gui.d.ts", "src"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
"include": ["src"],
|
||||
"exclude": ["node_modules", "dist", "types"]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
# Build artifacts — declarations are emitted by `pnpm run build` at pack time.
|
||||
types/
|
||||
*.tsbuildinfo
|
||||
# Standalone install (this package is not in the root pnpm workspace).
|
||||
node_modules/
|
||||
pnpm-lock.yaml
|
||||
+2
-3
@@ -1,8 +1,7 @@
|
||||
# Keep the published tarball lean — ship src, not tests or dev type-check config.
|
||||
# Keep the published tarball lean — ship src + compiled types, not tests or dev config.
|
||||
**/*.test.ts
|
||||
**/*.test.tsx
|
||||
tsconfig.json
|
||||
tsconfig.build.json
|
||||
vitest.config.ts
|
||||
gui.config.ts
|
||||
gui.d.ts
|
||||
node_modules
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
// The @hanzo/gui v5 config this package is authored against. `createGui` with the
|
||||
// shared `@hanzogui/config/v5` default enables the shorthand style props
|
||||
// (bg/px/py/items/justify/rounded/…) the components use; `Conf` feeds the type
|
||||
// augmentation in `gui.d.ts` so `tsc --noEmit` type-checks the shorthands exactly
|
||||
// as the consuming app (console) does. Not shipped — dev/type-check only.
|
||||
import { defaultConfig } from '@hanzogui/config/v5'
|
||||
import { createGui } from '@hanzo/gui'
|
||||
|
||||
export const config = createGui(defaultConfig)
|
||||
|
||||
export default config
|
||||
|
||||
export type Conf = typeof config
|
||||
Vendored
-15
@@ -1,15 +0,0 @@
|
||||
/**
|
||||
* Registers our Gui config with the type system so shorthand style props
|
||||
* (tokens, themes, bg/px/py/items/justify etc.) type-check exactly as they do in
|
||||
* the consuming app. GuiCustomConfig is declared in @hanzogui/web and flows
|
||||
* through @hanzo/gui. Dev/type-check only — never shipped.
|
||||
*/
|
||||
import type { Conf } from './gui.config'
|
||||
|
||||
declare module '@hanzogui/web' {
|
||||
interface GuiCustomConfig extends Conf {}
|
||||
}
|
||||
|
||||
declare module '@hanzogui/core' {
|
||||
interface GuiCustomConfig extends Conf {}
|
||||
}
|
||||
+16
-9
@@ -1,23 +1,23 @@
|
||||
{
|
||||
"name": "@hanzo/ui",
|
||||
"version": "8.0.0",
|
||||
"version": "8.0.1",
|
||||
"type": "module",
|
||||
"description": "Hanzo UI — the one cross-platform component library on @hanzo/gui. The product/app layer (charts, metrics, page headers, status tags, rich empty states, combobox, slide-over, toasts, drag-reorder, labeled field rows, provider/product marks) + the metadata-driven record layer (@hanzo/data: RecordsView, DataTable, board, typed field editors) + the calm dark-first tokens and motion. Presentational, host-agnostic (data/effects injected), clean-room. Web + native (iOS) + desktop.",
|
||||
"description": "Hanzo UI \u2014 the one cross-platform component library on @hanzo/gui. The product/app layer (charts, metrics, page headers, status tags, rich empty states, combobox, slide-over, toasts, drag-reorder, labeled field rows, provider/product marks) + the metadata-driven record layer (@hanzo/data: RecordsView, DataTable, board, typed field editors) + the calm dark-first tokens and motion. Presentational, host-agnostic (data/effects injected), clean-room. Web + native (iOS) + desktop.",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./src/index.ts",
|
||||
"types": "./types/index.d.ts",
|
||||
"default": "./src/index.ts"
|
||||
},
|
||||
"./product": {
|
||||
"types": "./src/product/index.ts",
|
||||
"types": "./types/product/index.d.ts",
|
||||
"default": "./src/product/index.ts"
|
||||
},
|
||||
"./data": {
|
||||
"types": "./src/data.ts",
|
||||
"types": "./types/data.d.ts",
|
||||
"default": "./src/data.ts"
|
||||
},
|
||||
"./primitives/bases/data": {
|
||||
"types": "./src/primitives/bases/data/index.ts",
|
||||
"types": "./types/primitives/bases/data/index.d.ts",
|
||||
"default": "./src/primitives/bases/data/index.ts"
|
||||
},
|
||||
"./styles/motion.css": "./src/styles/hanzo-motion.css",
|
||||
@@ -25,14 +25,18 @@
|
||||
},
|
||||
"main": "./src/index.ts",
|
||||
"module": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
"types": "./types/index.d.ts",
|
||||
"sideEffects": [
|
||||
"**/*.css"
|
||||
],
|
||||
"files": [
|
||||
"src"
|
||||
"src",
|
||||
"types"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.build.json",
|
||||
"clean": "rm -rf types",
|
||||
"prepack": "pnpm run build",
|
||||
"tc": "tsc --noEmit",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
@@ -44,12 +48,15 @@
|
||||
"react": ">=19"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@hanzo/data": "1.2.0",
|
||||
"@hanzo/data": "1.2.1",
|
||||
"@hanzo/gui": "7.3.0",
|
||||
"@hanzogui/config": "7.3.0",
|
||||
"@hanzogui/core": "7.3.0",
|
||||
"@hanzogui/lucide-icons-2": "7.3.0",
|
||||
"@hanzogui/next-theme": "7.3.0",
|
||||
"@hanzogui/web": "7.3.0",
|
||||
"@types/react": "^19.1.10",
|
||||
"@types/react-dom": "^19.1.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"typescript": "^5.7.2",
|
||||
|
||||
Vendored
+27
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* Registers this package's @hanzo/gui config with the type system so the
|
||||
* shorthand style props (bg / px / py / items / justify / gap / rounded …) that
|
||||
* the components author against resolve to their concrete types DURING THIS
|
||||
* PACKAGE'S OWN COMPILATION.
|
||||
*
|
||||
* Because the resolution happens here, the emitted `.d.ts` (see
|
||||
* `tsconfig.build.json`) bakes in the concrete prop types — a consumer type-checks
|
||||
* against the compiled declarations and never has to re-declare this augmentation
|
||||
* (nor descend into our `.tsx` internals). Ambient + type-only; never emitted.
|
||||
*
|
||||
* `GuiCustomConfig` is the extension point declared by @hanzogui/web and re-declared
|
||||
* by @hanzogui/core; `InferGuiConfig<typeof defaultConfig>` (the return type of
|
||||
* `createGui(defaultConfig)`) is the exact config the components are written for.
|
||||
*/
|
||||
import type { createGui } from '@hanzo/gui'
|
||||
import type { defaultConfig } from '@hanzogui/config/v5'
|
||||
|
||||
type Conf = ReturnType<typeof createGui<typeof defaultConfig>>
|
||||
|
||||
declare module '@hanzogui/web' {
|
||||
interface GuiCustomConfig extends Conf {}
|
||||
}
|
||||
|
||||
declare module '@hanzogui/core' {
|
||||
interface GuiCustomConfig extends Conf {}
|
||||
}
|
||||
@@ -55,3 +55,6 @@ export * from './StatusTag'
|
||||
export * from './ThemeToggle'
|
||||
export * from './Toast'
|
||||
export * from './color'
|
||||
|
||||
// Usage — the shared AI-quota surface (meter bar → provider card → dashboard).
|
||||
export * from './usage'
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Usage dashboard — the ONE shared AI-usage screen every Hanzo app (console,
|
||||
* desktop, app, chat) renders: a summary header of totals (spend / tokens /
|
||||
* requests) as `Metric` tiles, above a responsive grid of `UsageProviderCard`s.
|
||||
*
|
||||
* Presentational + honest: totals are pre-summed by the caller and formatted here
|
||||
* (an absent total is an em-dash, never fabricated); the grid simply lays out the
|
||||
* providers it is given. @hanzo/gui primitives only — web + native + desktop.
|
||||
*/
|
||||
import { XStack, YStack } from '@hanzo/gui'
|
||||
import { Activity, Coins, DollarSign } from '@hanzogui/lucide-icons-2'
|
||||
|
||||
import { MetricCard } from '../Metric'
|
||||
import { UsageProviderCard, type UsageProviderCardProps, formatUsageCount, formatUsageCurrency } from './UsageProviderCard'
|
||||
|
||||
export function UsageDashboard({
|
||||
providers,
|
||||
totals,
|
||||
}: {
|
||||
providers: UsageProviderCardProps[]
|
||||
totals?: { spendUSD?: number; tokens?: number; requests?: number }
|
||||
}) {
|
||||
return (
|
||||
<YStack gap="$4">
|
||||
{totals ? (
|
||||
<XStack gap="$3" flexWrap="wrap">
|
||||
<MetricCard
|
||||
icon={<DollarSign size={16} color="$color11" />}
|
||||
label="Total spend"
|
||||
value={totals.spendUSD != null ? formatUsageCurrency(totals.spendUSD, 'USD') : '—'}
|
||||
/>
|
||||
<MetricCard
|
||||
icon={<Coins size={16} color="$color11" />}
|
||||
label="Tokens"
|
||||
value={totals.tokens != null ? formatUsageCount(totals.tokens) : '—'}
|
||||
/>
|
||||
<MetricCard
|
||||
icon={<Activity size={16} color="$color11" />}
|
||||
label="Requests"
|
||||
value={totals.requests != null ? formatUsageCount(totals.requests) : '—'}
|
||||
/>
|
||||
</XStack>
|
||||
) : null}
|
||||
|
||||
<XStack gap="$3" flexWrap="wrap">
|
||||
{providers.map((p) => (
|
||||
<UsageProviderCard key={p.name} {...p} />
|
||||
))}
|
||||
</XStack>
|
||||
</YStack>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Usage meter — the ONE labeled rate-limit bar every Hanzo app renders for an AI
|
||||
* quota (session / weekly / a custom window). A thin track + tone-by-value fill
|
||||
* (green calm → amber → red hot, the same `utilColor` semantics as the console's
|
||||
* `UtilBar`), a "% left" read-out, and an honest reset countdown ("resets in 2h
|
||||
* 14m") computed from a real `resetsAt` — never a fabricated number.
|
||||
*
|
||||
* Built only from @hanzo/gui primitives (the nested-YStack track/fill is the house
|
||||
* bar idiom from `Charts.BarRows`), so it themes to the shell and works web +
|
||||
* native + desktop. No DOM APIs: `Date` only.
|
||||
*/
|
||||
import { Text, XStack, YStack } from '@hanzo/gui'
|
||||
|
||||
import { utilColor } from '../Metric'
|
||||
|
||||
/** A single quota window: how much is spent (0–100) and when it refills. */
|
||||
export type UsageWindow = {
|
||||
usedPercent: number
|
||||
/** ISO string or Date the window resets — drives the honest countdown. */
|
||||
resetsAt?: string | Date
|
||||
}
|
||||
|
||||
/** "resets in 2h 14m" from a real reset time, or null when there is none/past. */
|
||||
export function resetCountdown(resetsAt?: string | Date): string | null {
|
||||
if (resetsAt == null) return null
|
||||
const t = typeof resetsAt === 'string' ? Date.parse(resetsAt) : resetsAt.getTime()
|
||||
if (!Number.isFinite(t)) return null
|
||||
const mins = Math.floor((t - Date.now()) / 60000)
|
||||
if (mins <= 0) return 'resets now'
|
||||
const d = Math.floor(mins / 1440)
|
||||
const h = Math.floor((mins % 1440) / 60)
|
||||
const m = mins % 60
|
||||
if (d > 0) return `resets in ${d}d ${h}h`
|
||||
if (h > 0) return `resets in ${h}h ${m}m`
|
||||
return `resets in ${m}m`
|
||||
}
|
||||
|
||||
export function UsageMeter({
|
||||
label,
|
||||
usedPercent,
|
||||
resetsAt,
|
||||
compact,
|
||||
}: {
|
||||
label: string
|
||||
usedPercent: number
|
||||
resetsAt?: string | Date
|
||||
compact?: boolean
|
||||
}) {
|
||||
const used = Math.max(0, Math.min(100, usedPercent))
|
||||
const left = Math.round(100 - used)
|
||||
const reset = resetCountdown(resetsAt)
|
||||
const h = compact ? 6 : 8
|
||||
return (
|
||||
<YStack gap={compact ? '$1' : '$1.5'}>
|
||||
<XStack items="center" justify="space-between" gap="$2">
|
||||
<Text fontSize="$2" color="$color11" numberOfLines={1} flex={1}>
|
||||
{label}
|
||||
</Text>
|
||||
<Text fontSize="$2" color="$color12" fontWeight="600">
|
||||
{left}% left
|
||||
</Text>
|
||||
</XStack>
|
||||
<YStack height={h} bg="$color3" rounded="$2" overflow="hidden">
|
||||
<YStack height={h} width={`${Math.max(2, used)}%`} bg={utilColor(used) as never} rounded="$2" />
|
||||
</YStack>
|
||||
{reset && !compact ? (
|
||||
<Text fontSize="$1" color="$color10">
|
||||
{reset}
|
||||
</Text>
|
||||
) : null}
|
||||
</YStack>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Usage provider card — one AI provider's quota at a glance, mirroring the Codex
|
||||
* menu card: a session bar, a weekly bar, any extra windows, credits/spend, and a
|
||||
* used-% history Sparkline. Composes `UsageMeter` rows + the canonical
|
||||
* `Charts.Sparkline` + `Metric` idioms; nothing is fabricated (a missing window
|
||||
* renders no row, a <2-point history renders no spark, `error`/`sourceLabel`/
|
||||
* `updatedAt` surface honest provenance).
|
||||
*
|
||||
* @hanzo/gui primitives only — themes to the shell, works web + native + desktop.
|
||||
*/
|
||||
import { Card, Text, XStack, YStack } from '@hanzo/gui'
|
||||
|
||||
import { Sparkline } from '../Charts'
|
||||
import { UsageMeter, type UsageWindow } from './UsageMeter'
|
||||
|
||||
/** Format a monetary amount; falls back to "12.34 USD" where Intl currency is absent. */
|
||||
export function formatUsageCurrency(value: number, currencyCode: string): string {
|
||||
try {
|
||||
return new Intl.NumberFormat(undefined, { style: 'currency', currency: currencyCode, maximumFractionDigits: 2 }).format(value)
|
||||
} catch {
|
||||
return `${value.toFixed(2)} ${currencyCode}`
|
||||
}
|
||||
}
|
||||
|
||||
/** Compact count (1.2K / 3.4M / 5.6B) for token/request totals. */
|
||||
export function formatUsageCount(n: number): string {
|
||||
if (!Number.isFinite(n)) return '—'
|
||||
const abs = Math.abs(n)
|
||||
if (abs >= 1e9) return `${(n / 1e9).toFixed(1)}B`
|
||||
if (abs >= 1e6) return `${(n / 1e6).toFixed(1)}M`
|
||||
if (abs >= 1e3) return `${(n / 1e3).toFixed(1)}K`
|
||||
return String(Math.round(n))
|
||||
}
|
||||
|
||||
/** "updated 2m ago" from a real timestamp, or null when there is none. */
|
||||
function updatedLabel(t?: string | Date): string | null {
|
||||
if (t == null) return null
|
||||
const ms = typeof t === 'string' ? Date.parse(t) : t.getTime()
|
||||
if (!Number.isFinite(ms)) return null
|
||||
const mins = Math.floor((Date.now() - ms) / 60000)
|
||||
if (mins < 1) return 'updated just now'
|
||||
if (mins < 60) return `updated ${mins}m ago`
|
||||
const h = Math.floor(mins / 60)
|
||||
if (h < 24) return `updated ${h}h ago`
|
||||
return `updated ${Math.floor(h / 24)}d ago`
|
||||
}
|
||||
|
||||
export type UsageProviderCardProps = {
|
||||
name: string
|
||||
/** Accent dot color (hex/rgb) — the provider's brand mark. */
|
||||
color?: string
|
||||
/** Plan/tier badge (e.g. "Pro", "Team"). */
|
||||
plan?: string
|
||||
session?: UsageWindow
|
||||
weekly?: UsageWindow
|
||||
/** Additional named windows (e.g. per-model or per-tool quotas). */
|
||||
extras?: Array<{ id: string; title: string; usedPercent: number; resetsAt?: string | Date }>
|
||||
spend?: { used: number; limit?: number; currencyCode: string }
|
||||
/** Used-% samples over time — drawn with the canonical Sparkline. */
|
||||
history?: number[]
|
||||
/** An honest load/refresh error for this provider. */
|
||||
error?: string
|
||||
/** Where the numbers came from (e.g. "OAuth", "API key"). */
|
||||
sourceLabel?: string
|
||||
updatedAt?: string | Date
|
||||
}
|
||||
|
||||
export function UsageProviderCard({
|
||||
name,
|
||||
color,
|
||||
plan,
|
||||
session,
|
||||
weekly,
|
||||
extras,
|
||||
spend,
|
||||
history,
|
||||
error,
|
||||
sourceLabel,
|
||||
updatedAt,
|
||||
}: UsageProviderCardProps) {
|
||||
const updated = updatedLabel(updatedAt)
|
||||
return (
|
||||
<Card p="$4" gap="$3" borderWidth={1} borderColor="$borderColor" flex={1} minW={260}>
|
||||
<XStack items="center" justify="space-between" gap="$2">
|
||||
<XStack items="center" gap="$2" flex={1}>
|
||||
{color ? <YStack width={10} height={10} rounded="$2" bg={color as never} /> : null}
|
||||
<Text fontSize="$4" fontWeight="800" color="$color12" numberOfLines={1}>
|
||||
{name}
|
||||
</Text>
|
||||
</XStack>
|
||||
{plan ? (
|
||||
<Text fontSize="$1" px="$2" py="$1" rounded="$2" bg="$color3" color="$color11">
|
||||
{plan}
|
||||
</Text>
|
||||
) : null}
|
||||
</XStack>
|
||||
|
||||
{error ? (
|
||||
<Text fontSize="$2" color="#e5534b">
|
||||
{error}
|
||||
</Text>
|
||||
) : null}
|
||||
|
||||
{session ? <UsageMeter label="Session" usedPercent={session.usedPercent} resetsAt={session.resetsAt} /> : null}
|
||||
{weekly ? <UsageMeter label="Weekly" usedPercent={weekly.usedPercent} resetsAt={weekly.resetsAt} /> : null}
|
||||
{extras?.map((e) => (
|
||||
<UsageMeter key={e.id} label={e.title} usedPercent={e.usedPercent} resetsAt={e.resetsAt} />
|
||||
))}
|
||||
|
||||
{spend ? (
|
||||
spend.limit != null ? (
|
||||
<UsageMeter
|
||||
label={`Spend · ${formatUsageCurrency(spend.used, spend.currencyCode)} / ${formatUsageCurrency(spend.limit, spend.currencyCode)}`}
|
||||
usedPercent={spend.limit > 0 ? (spend.used / spend.limit) * 100 : 0}
|
||||
compact
|
||||
/>
|
||||
) : (
|
||||
<XStack items="center" justify="space-between" gap="$2">
|
||||
<Text fontSize="$2" color="$color11">
|
||||
Spend
|
||||
</Text>
|
||||
<Text fontSize="$2" color="$color12" fontWeight="600">
|
||||
{formatUsageCurrency(spend.used, spend.currencyCode)}
|
||||
</Text>
|
||||
</XStack>
|
||||
)
|
||||
) : null}
|
||||
|
||||
{history && history.length >= 2 ? (
|
||||
<XStack justify="flex-end">
|
||||
<Sparkline values={history} />
|
||||
</XStack>
|
||||
) : null}
|
||||
|
||||
{sourceLabel || updated ? (
|
||||
<XStack items="center" justify="space-between" gap="$2">
|
||||
{sourceLabel ? (
|
||||
<Text fontSize="$1" color="$color10" numberOfLines={1}>
|
||||
{sourceLabel}
|
||||
</Text>
|
||||
) : null}
|
||||
{updated ? (
|
||||
<Text fontSize="$1" color="$color10">
|
||||
{updated}
|
||||
</Text>
|
||||
) : null}
|
||||
</XStack>
|
||||
) : null}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
// @hanzo/ui/product/usage — the shared AI-usage surface (the ONE way every Hanzo
|
||||
// app renders quota bars, per-provider cards, and the usage dashboard).
|
||||
//
|
||||
// UsageMeter (labeled rate-limit bar) → UsageProviderCard (one provider) →
|
||||
// UsageDashboard (totals header + provider grid). Presentational, host-agnostic,
|
||||
// cross-platform on @hanzo/gui.
|
||||
|
||||
export * from './UsageMeter'
|
||||
export * from './UsageProviderCard'
|
||||
export * from './UsageDashboard'
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"noEmit": false,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"emitDeclarationOnly": true,
|
||||
"outDir": "./types",
|
||||
"rootDir": "./src"
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["node_modules", "types", "**/*.test.ts", "**/*.test.tsx"]
|
||||
}
|
||||
+16
-5
@@ -1,7 +1,11 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"lib": [
|
||||
"ES2022",
|
||||
"DOM",
|
||||
"DOM.Iterable"
|
||||
],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"jsx": "react-jsx",
|
||||
@@ -12,8 +16,15 @@
|
||||
"skipLibCheck": true,
|
||||
"resolveJsonModule": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"types": ["react"]
|
||||
"types": [
|
||||
"react"
|
||||
]
|
||||
},
|
||||
"include": ["gui.config.ts", "gui.d.ts", "src"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
"dist"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
# @hanzo/dashboard
|
||||
|
||||
The reusable **dashboard layer on [`@hanzo/gui`](https://github.com/hanzoai/gui)**.
|
||||
A set of composable, honest, reduced-motion-guarded dashboard primitives extracted
|
||||
from the Hanzo Cloud Console so every Hanzo app — console, admin surfaces,
|
||||
hanzo.team, desktop dashboards — builds the **same** dashboard system instead of
|
||||
re-implementing it.
|
||||
|
||||
Everything here is **pure**: data comes in via props or a loader you supply, actions
|
||||
are callbacks, and `@hanzo/gui` is a peer dependency (this layer sits _on_ it, never
|
||||
forks it). No app-specific API imports.
|
||||
|
||||
```bash
|
||||
pnpm add @hanzo/dashboard @hanzo/gui @hanzogui/lucide-icons-2
|
||||
```
|
||||
|
||||
```tsx
|
||||
import '@hanzo/dashboard/dashboard.css' // once — ships the motion keyframes
|
||||
import { Overview, Kpi, Sparkline, Landing, Pipeline } from '@hanzo/dashboard'
|
||||
```
|
||||
|
||||
> `dashboard.css` carries the loading shimmer, live-feed pulse, fade-up entrance, and
|
||||
> the deploy-pipeline animations — **every one reduced-motion-guarded** (→ static).
|
||||
> The count-up + live sparkline motion is driven in JS and gated on
|
||||
> `prefers-reduced-motion` in the hooks.
|
||||
|
||||
---
|
||||
|
||||
## Charts — the ONE way to draw a trend / series / share / distribution
|
||||
|
||||
Monochrome SVG (dependency-free) that themes to the shell. Honest by construction: a
|
||||
`Sparkline` with < 2 points renders nothing; `Donut`/`Bars` with no positive value
|
||||
render an em-dash. Callers pass REAL series — these never fabricate data.
|
||||
|
||||
| Export | What it draws |
|
||||
| --- | --- |
|
||||
| `Sparkline` | A fixed-size mini trend (for stat tiles). |
|
||||
| `Line` | A value over time (line + hover tooltip). |
|
||||
| `Columns` | A value over time (vertical bars + hover). |
|
||||
| `Donut` | Categorical share (optional legend, center slot). |
|
||||
| `Bars` | A ranked distribution (horizontal, token-native). |
|
||||
| `useContainerWidth` | Measure a container for responsive SVG. |
|
||||
| `CHART_PALETTE`, `CHART_OTHER` | The categorical hues. |
|
||||
| types `ChartPoint`, `Slice` | Data shapes. |
|
||||
|
||||
```tsx
|
||||
<Line data={[{ label: 'Mon', value: 12 }, { label: 'Tue', value: 30 }]} formatValue={(v) => `${v}`} />
|
||||
<Donut slices={[{ label: 'zen', value: 60 }, { label: 'gpt', value: 40 }]} legend />
|
||||
<Sparkline values={[3, 5, 4, 9, 12]} />
|
||||
```
|
||||
|
||||
## Motion — pure math + rAF/interval hooks
|
||||
|
||||
Unit-testable pure functions plus the thin browser hooks that drive them; every hook
|
||||
self-cleans on unmount (no leaked frames/timers).
|
||||
|
||||
- Pure: `easeOutCubic`, `countUpValue`, `progress`, `pushSample`, `shouldTick`, `effectiveInterval`.
|
||||
- Hooks: `useCountUp`, `usePoll`, `useReducedMotion`, `usePageHidden`.
|
||||
|
||||
```tsx
|
||||
const value = useCountUp(target, /* enabled */ true) // snaps to target under reduced motion
|
||||
const { tick, bump } = usePoll(15_000) // paused when interval ≤ 0
|
||||
```
|
||||
|
||||
## Overview — the videogame-like living dashboard
|
||||
|
||||
Declare a product's tiles + a REAL data loader; `Overview` renders + animates the
|
||||
board and keeps it alive (one throttled poll loop, hidden-tab-paused, range selector,
|
||||
count-up on change). A product is added by writing a config, never overview UI.
|
||||
|
||||
```tsx
|
||||
import { Overview, type OverviewConfig } from '@hanzo/dashboard'
|
||||
import { Cpu } from '@hanzogui/lucide-icons-2'
|
||||
|
||||
const config: OverviewConfig = {
|
||||
id: 'gpus', title: 'GPUs',
|
||||
live: { pollMs: 15_000, countUp: true },
|
||||
rows: [
|
||||
[{ tile: 'metric', key: 'active', label: 'Active', icon: Cpu }],
|
||||
[{ tile: 'timeseries', key: 'usage', title: 'Usage over time' },
|
||||
{ tile: 'distribution', key: 'byModel', title: 'By model' }],
|
||||
[{ tile: 'activity' }, { tile: 'health' }],
|
||||
],
|
||||
load: async ({ range }) => fromMyApi(await MyApi.overview(range)), // → OverviewData
|
||||
}
|
||||
|
||||
<Overview config={config} isGlobalAdmin={amAdmin} />
|
||||
```
|
||||
|
||||
The loader returns the normalized **`OverviewData`** (`kpi` / `series` / `distribution`
|
||||
/ `activity` / `alerts` / `health` maps). A missing slice renders an honest empty tile
|
||||
— you can over-declare tiles a slow backend hasn't filled yet. First load shows a
|
||||
spinner; a failure shows a retry card; a background refetch never blanks a board that
|
||||
already has real data.
|
||||
|
||||
### Composable primitives (use without the driver)
|
||||
|
||||
The tiles are also standalone, direct-props components — the valuable, reusable core:
|
||||
|
||||
| Export | Props (data in, view out) |
|
||||
| --- | --- |
|
||||
| `Kpi` | `label`, `value` (null → "—"), `unit`, `deltaPct`, `series`, `icon`, `color`, `caption`, `animate`, `loading` |
|
||||
| `Feed` | `items: OverviewEvent[]`, `title`, `empty`, `loading` (virtualizes past a viewport) |
|
||||
| `Board` | `items: OverviewHealth[]`, `title`, `empty`, `loading` (health tally + dots) |
|
||||
| `Tile` | Render one declared tile spec against `OverviewData`. |
|
||||
| `Panel`, `SkeletonBar`, `EmptyPanel`, `PanelSpinner`, `LiveDot` | Shared chrome. |
|
||||
|
||||
```tsx
|
||||
<Kpi label="Requests" value={32_412} unit="count" deltaPct={12} series={[10,14,22,31]} icon={Zap} />
|
||||
```
|
||||
|
||||
Plus the pure tile logic (`formatMetric`, `deltaOf`, `hasTrend`, `mergeActivity`,
|
||||
`windowRows`, `statusColor`/`healthColor`/`severityColor`, `selectKpi`/`selectSeries`/
|
||||
`selectDistribution`, …) and the `OverviewConfig` / `OverviewData` / tile types.
|
||||
|
||||
## Landing — the polished product landing kit
|
||||
|
||||
Compose a `LandingConfig` and render `<Landing>` to get a hero + live count-up metrics
|
||||
row + an interactive code-sample block + a resources rail. Parts are exported too
|
||||
(`Hero`, `Metrics`, `Samples`, `Rail`).
|
||||
|
||||
```tsx
|
||||
import { Landing, type LandingConfig } from '@hanzo/dashboard'
|
||||
|
||||
const config: LandingConfig = {
|
||||
productId: 'embeddings', title: 'Embeddings',
|
||||
tagline: 'Vector search over your data.',
|
||||
brandName: 'Hanzo Cloud', docsUrl: 'https://docs.hanzo.ai', docsProduct: 'embeddings',
|
||||
primary: { label: 'Create collection', onPress: onCreate },
|
||||
loadMetrics: async () => [{ key: 'vectors', label: 'Vectors', value: 1_240, series: [...] }],
|
||||
samples: [{ lang: 'curl', label: 'cURL', code: 'curl https://api.hanzo.ai/v1/embeddings ...' }],
|
||||
}
|
||||
|
||||
<Landing config={config}>{/* the product's own real content */}</Landing>
|
||||
```
|
||||
|
||||
All links (docs / quickstart / examples / API / support) derive from `config.docsUrl`,
|
||||
so a Lux/Zoo console links to ITS OWN surfaces. Brand + accent come from the config
|
||||
(`brandName`, `accent`) — no hardcoded brand. Pure link helpers are exported
|
||||
(`apexFromDocs`, `apiBaseFromDocs`, `landingDocsUrl`, `standardResources`, `supportMailto`).
|
||||
|
||||
## Pipeline — the deploy-stage line
|
||||
|
||||
A small, presentational animated pipeline (Queued → Building → Deploying → Live, or any
|
||||
custom stages). Driven by a `stages` prop; derive it from a real status string with the
|
||||
pure `pipelineModel`, and poll to pass fresh stages.
|
||||
|
||||
```tsx
|
||||
import { Pipeline, pipelineModel } from '@hanzo/dashboard'
|
||||
|
||||
const model = pipelineModel(app.status, furthestReached) // furthest tracked across polls
|
||||
<Pipeline stages={model.stages} label={model.label} />
|
||||
```
|
||||
|
||||
Pure helpers: `pipelineModel`, `pipelinePhase`, `stageIndex`, `isTerminalPhase`,
|
||||
`pipelineTone`, `PIPELINE_STAGES`, and the `PipelineStage`/`PipelineModel`/`StageState`
|
||||
types. Completed stages fill with a check, the current stage pulses, the active leg
|
||||
flows, a failure turns the reached stage red — all reduced-motion-guarded.
|
||||
|
||||
---
|
||||
|
||||
## Provenance — how each export maps back to the console
|
||||
|
||||
Extracted (and generalized: no `~/lib/*` / `~/config` imports) from
|
||||
`~/work/hanzo/console2`:
|
||||
|
||||
| `@hanzo/dashboard` | console2 source |
|
||||
| --- | --- |
|
||||
| `charts/Charts` (`Sparkline`/`Line`/`Columns`/`Donut`/`Bars`) | `src/components/ui/Charts.tsx` (`Sparkline`/`LineChart`/`BarChart`/`Donut`/`BarRows`) |
|
||||
| `motion/motion` + `motion/hooks` | `src/components/products/overview/living/{motion,hooks}.ts` |
|
||||
| `overview/{config,logic}` | `.../overview/living/{config,logic}.ts` |
|
||||
| `overview/primitives` (`Kpi`/`Feed`/`Board`) + `overview/tiles` (`Tile`) | `.../overview/living/tiles.tsx` (the tile views) |
|
||||
| `overview/Overview` | `.../overview/living/LivingOverview.tsx` |
|
||||
| `landing/*` (`Landing`/`Hero`/`Metrics`/`Samples`/`Rail`) | `src/components/products/landing/*` (`ProductLanding`/`LandingHero`/`LandingMetrics`/`CodeSamples`/`ResourceRail`) |
|
||||
| `pipeline/{pipeline,Pipeline}` | `src/components/products/paas/{railway.ts,RailwayDeploy.tsx}` |
|
||||
|
||||
### Generalizations made during extraction
|
||||
|
||||
- **Icons** are consumer-supplied (`IconComponent` = a `size`/`color` icon), replacing
|
||||
the console's `ProductIcon` from its registry.
|
||||
- **`Overview`** drops `~/lib/api` `ApiError`, `useIsGlobalAdmin`, `PageHeader`,
|
||||
`FadeIn`, and `States` — `isGlobalAdmin` is now a prop and the header/fade/error
|
||||
chrome is inlined (self-contained).
|
||||
- **Landing** takes brand/docs/support/discord/accent from `LandingConfig` (was
|
||||
`~/config`). `LandingHero` inlines the hero graphic + accent button (was
|
||||
`../inference/parts`).
|
||||
- **Pipeline** takes a `stages` prop and drops the console's `PaasApi.getApp`
|
||||
self-poll — the consumer polls and passes fresh stages.
|
||||
- Naming is de-branded and single-word: `LivingOverview` → `Overview`,
|
||||
`RailwayDeploy` → `Pipeline`, `ProductLanding` → `Landing`, `LineChart`/`BarChart`/
|
||||
`BarRows` → `Line`/`Columns`/`Bars`. CSS classes `hz-rail-*` → `hz-pipe-*`.
|
||||
|
||||
---
|
||||
|
||||
## Follow-up: swapping console2 onto this package (a later pass)
|
||||
|
||||
console2 is **untouched** this pass. To consume `@hanzo/dashboard` back in console2,
|
||||
per component:
|
||||
|
||||
1. **Charts** — delete `src/components/ui/Charts.tsx`; re-export from the package
|
||||
(`export { Sparkline, Line as LineChart, Columns as BarChart, Bars as BarRows, Donut, useContainerWidth, CHART_PALETTE, CHART_OTHER } from '@hanzo/dashboard'`)
|
||||
or update call sites to the new names. `~/components/products/inference/parts`,
|
||||
`landing/*`, and `overview/living/tiles` import `Charts` — the alias keeps them working.
|
||||
2. **Motion** — replace `overview/living/{motion,hooks}.ts` with re-exports from
|
||||
`@hanzo/dashboard`; `RailwayDeploy` + `LandingMetrics` import these hooks.
|
||||
3. **Overview** — replace `overview/living/{config,logic,tiles,LivingOverview}` with
|
||||
thin wrappers over the package. The console's `LivingOverviewModule` keeps its
|
||||
registry-router wrapper and passes `isGlobalAdmin={useIsGlobalAdmin()}` +
|
||||
maps `ApiError` into the driver's caught error. `PageHeader`/`FadeIn`/`States`
|
||||
stay in console (they're used elsewhere) — the driver now inlines its own.
|
||||
4. **Landing** — replace `products/landing/*` with the package; the console builds a
|
||||
`LandingConfig` with `docsUrl: config.docsUrl`, `brandName: config.brandName`,
|
||||
`discordUrl: 'https://discord.gg/hanzo'`. `LandingHero`'s console-specific
|
||||
`HeroGraphic`/`AccentButton` (from `inference/parts`) are now inlined in the package.
|
||||
5. **Pipeline** — swap `paas/RailwayDeploy.tsx` for `<Pipeline>`; the console owns the
|
||||
`PaasApi.getApp` poll (`usePoll` + `PaasApi`) and the monotonic `furthest` state,
|
||||
calling `pipelineModel(status, furthest)` and passing `.stages` + `.label`.
|
||||
|
||||
Once swapped, the console2 tests that already cover the pure logic
|
||||
(`motion.test.ts`, `logic.test.ts`, `railway.test.ts`, `landing/logic.test.ts`) move
|
||||
here (or re-run against the re-exports) so coverage follows the code.
|
||||
|
||||
## Honest about what didn't extract cleanly
|
||||
|
||||
- The console's **`config.ts` registry** (`LivingOverviewConfig.load` adapters:
|
||||
`fromCloudUsage`, `fromAdminOverview`, `fromFunctions`, `healthFromApps`) is
|
||||
**product/API-specific** and stays in console2 — it's the data half. This package is
|
||||
the view half; the `OverviewData` contract is the seam between them.
|
||||
- Console tile icons come from the console's product registry; here they're plain
|
||||
`IconComponent` props (the consumer supplies them).
|
||||
- The `Pipeline` self-poll was PaaS-coupled and is intentionally dropped — the small,
|
||||
minor pipeline primitive is presentational only.
|
||||
|
||||
## Build
|
||||
|
||||
```bash
|
||||
pnpm typecheck # tsc --noEmit (strict) — clean
|
||||
pnpm build # tsc → dist (proof; the package ships src)
|
||||
```
|
||||
|
||||
The package **ships `src`** (source-only, like `@hanzo/data`); the consumer's bundler
|
||||
transpiles it, and the `@hanzo/gui` shorthand style props type-check against the real
|
||||
token unions via `gui.config.ts` + `gui.d.ts`.
|
||||
|
||||
License: BSD-3-Clause · Hanzo AI, Inc.
|
||||
@@ -0,0 +1,17 @@
|
||||
// Gui config for @hanzo/dashboard typechecking.
|
||||
//
|
||||
// The canonical v5 default config — the same shape Hanzo's admin SPAs use, so
|
||||
// the shorthand style props (`bg`, `p`, `items`, `justify`, `rounded`, …) the
|
||||
// dashboard components use typecheck against the real Gui token unions.
|
||||
// Consumers supply their own config + theme at runtime; this exists so the
|
||||
// library type-checks standalone. The config-type registration lives in
|
||||
// `gui.d.ts` (augmenting `@hanzogui/web`), matching the @hanzo/data pattern.
|
||||
|
||||
import { defaultConfig } from '@hanzogui/config/v5'
|
||||
import { createGui } from '@hanzo/gui'
|
||||
|
||||
export const config = createGui(defaultConfig)
|
||||
|
||||
export default config
|
||||
|
||||
export type Conf = typeof config
|
||||
Vendored
+15
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* Registers our Gui config with the type system so component style props
|
||||
* (tokens, themes, shorthands) are typed. `GuiCustomConfig` is declared in
|
||||
* `@hanzogui/web` and re-exported across the Gui packages; augmenting it there
|
||||
* flows the types through every `@hanzo/gui` component.
|
||||
*/
|
||||
import type { Conf } from './gui.config'
|
||||
|
||||
declare module '@hanzogui/web' {
|
||||
interface GuiCustomConfig extends Conf {}
|
||||
}
|
||||
|
||||
declare module '@hanzogui/core' {
|
||||
interface GuiCustomConfig extends Conf {}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
{
|
||||
"name": "@hanzo/dashboard",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"description": "Hanzo Dashboard — the reusable dashboard layer on @hanzo/gui: monochrome SVG charts, a videogame-like LivingOverview (count-up KPIs, live sparklines, streaming feed, health), the ProductLanding kit, and a RailwayPipeline deploy animation. Pure (data via props/loaders), typed, reduced-motion-guarded. Extracted from the Hanzo Cloud Console so every Hanzo app composes ONE dashboard system.",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./src/index.ts",
|
||||
"default": "./src/index.ts"
|
||||
},
|
||||
"./dashboard.css": "./src/dashboard.css"
|
||||
},
|
||||
"main": "./src/index.ts",
|
||||
"module": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
"sideEffects": [
|
||||
"*.css"
|
||||
],
|
||||
"files": [
|
||||
"src"
|
||||
],
|
||||
"scripts": {
|
||||
"tc": "tsc --noEmit",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"build": "tsc --outDir dist --declaration --emitDeclarationOnly false --module ESNext --moduleResolution bundler --jsx react-jsx --esModuleInterop true --skipLibCheck true --sourceMap true",
|
||||
"test": "bun test"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@hanzo/gui": ">=7.2.2",
|
||||
"@hanzogui/lucide-icons-2": ">=7.2.2",
|
||||
"react": ">=19"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@hanzo/gui": "7.3.0",
|
||||
"@hanzogui/config": "7.3.0",
|
||||
"@hanzogui/core": "7.3.0",
|
||||
"@hanzogui/lucide-icons-2": "7.3.0",
|
||||
"@hanzogui/web": "7.3.0",
|
||||
"@types/react": "^19.1.10",
|
||||
"react": "^19.0.0",
|
||||
"typescript": "^5.7.2"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"license": "BSD-3-Clause",
|
||||
"author": "Hanzo AI <dev@hanzo.ai>",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/hanzoai/ui.git",
|
||||
"directory": "pkgs/dashboard"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,470 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Charts — the ONE way to draw a trend, a series, a share, or a distribution.
|
||||
* Dependency-free: inline SVG for the data-dense temporal charts (Sparkline /
|
||||
* Line / Columns) and @hanzo/gui primitives for the token-native ones
|
||||
* (Bars, and the Donut's optional legend), so everything themes to the shell.
|
||||
*
|
||||
* Color is data-driven, not a mode flag: a `Slice` is drawn in its own `color`
|
||||
* when it has one (categorical/polychrome — e.g. spend-by-model) and in a
|
||||
* monochrome opacity ramp off the foreground token when it doesn't (on-brand —
|
||||
* e.g. collections-by-model). One Donut serves both; a consumer that needs a
|
||||
* richer legend (amounts, click targets) leaves `legend` off and composes its own.
|
||||
*
|
||||
* Honest by construction: a Sparkline with fewer than two points renders nothing
|
||||
* (no invented trend); Donut / Bars with no positive value render an em-dash.
|
||||
* Callers pass REAL series — these never fabricate data.
|
||||
*
|
||||
* SVG marks can't read Tamagui tokens, so concrete colors for SVG come from the
|
||||
* live theme (`useTheme().<token>.get()` → `var(--token)` on web, stays reactive).
|
||||
*/
|
||||
import { useEffect, useRef, useState, type ReactNode } from 'react'
|
||||
import { Text, XStack, YStack, useTheme } from '@hanzo/gui'
|
||||
|
||||
/** Distinct hues for categorical series (e.g. spend-by-model donut), dark-tuned. */
|
||||
export const CHART_PALETTE = [
|
||||
'#7c5cff',
|
||||
'#23c562',
|
||||
'#ff9f45',
|
||||
'#3aa0ff',
|
||||
'#ff5d8f',
|
||||
'#16c0c8',
|
||||
'#c084fc',
|
||||
'#f4c245',
|
||||
]
|
||||
export const CHART_OTHER = '#64748b'
|
||||
|
||||
const ACCENT = CHART_PALETTE[0]
|
||||
const GRID = 'rgba(148,163,184,0.16)'
|
||||
const AXIS = 'rgba(148,163,184,0.85)'
|
||||
|
||||
/** A point on a temporal/sequential axis (Line / Columns). */
|
||||
export type ChartPoint = { label: string; value: number }
|
||||
|
||||
/** A labelled magnitude in a categorical chart (Donut / Bars). */
|
||||
export type Slice = { label: string; value: number; color?: string; hint?: string }
|
||||
|
||||
type Themed = ReturnType<typeof useTheme>
|
||||
|
||||
/** Read a concrete color for a theme token (for SVG), with a safe fallback. */
|
||||
const tone = (theme: Themed, key: string, fallback: string): string => {
|
||||
const v = (theme as unknown as Record<string, { get?: () => unknown } | undefined>)[key]
|
||||
const got = v?.get?.()
|
||||
return typeof got === 'string' ? got : fallback
|
||||
}
|
||||
|
||||
/** Opacity ramp so N monochrome slices/areas stay visually distinct. */
|
||||
const rampOpacity = (i: number, n: number): number => (n <= 1 ? 0.9 : 0.95 - (i / (n - 1)) * 0.62)
|
||||
|
||||
export function useContainerWidth() {
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
const [w, setW] = useState(0)
|
||||
useEffect(() => {
|
||||
const el = ref.current
|
||||
if (!el) return
|
||||
const measure = () => setW(el.clientWidth)
|
||||
measure()
|
||||
const ro = new ResizeObserver(measure)
|
||||
ro.observe(el)
|
||||
return () => ro.disconnect()
|
||||
}, [])
|
||||
return { ref, w }
|
||||
}
|
||||
|
||||
// ── Sparkline (fixed size, for metric cards) ─────────────────────────────────
|
||||
|
||||
export function Sparkline({
|
||||
values,
|
||||
width = 120,
|
||||
height = 36,
|
||||
color,
|
||||
}: {
|
||||
values: number[]
|
||||
width?: number
|
||||
height?: number
|
||||
color?: string
|
||||
}) {
|
||||
const theme = useTheme()
|
||||
const clean = (values ?? []).filter((v) => Number.isFinite(v))
|
||||
if (clean.length < 2) return null
|
||||
const stroke = color ?? tone(theme, 'color11', 'currentColor')
|
||||
const max = Math.max(...clean)
|
||||
const min = Math.min(...clean)
|
||||
const span = max - min || 1
|
||||
const n = clean.length
|
||||
const px = (i: number) => (i / (n - 1)) * (width - 2) + 1
|
||||
const py = (v: number) => height - 2 - ((v - min) / span) * (height - 4)
|
||||
const line = clean.map((v, i) => `${i === 0 ? 'M' : 'L'}${px(i).toFixed(1)},${py(v).toFixed(1)}`).join(' ')
|
||||
const area = `${line} L${(width - 1).toFixed(1)},${height} L1,${height} Z`
|
||||
return (
|
||||
<svg width={width} height={height} aria-hidden style={{ display: 'block' }}>
|
||||
<path d={area} fill={stroke} fillOpacity={0.12} />
|
||||
<path d={line} fill="none" stroke={stroke} strokeWidth={1.5} strokeLinejoin="round" strokeLinecap="round" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Shared hover read-out (Line / Columns) ─────────────────────────────
|
||||
|
||||
function Tooltip({ x, height, label, value }: { x: number; height: number; label: string; value: string }) {
|
||||
// Keep the box on-screen near the guide line.
|
||||
const left = Math.max(0, x - 60)
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left,
|
||||
top: 0,
|
||||
height,
|
||||
pointerEvents: 'none',
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
background: 'rgba(15,17,21,0.94)',
|
||||
border: '1px solid rgba(148,163,184,0.25)',
|
||||
borderRadius: 8,
|
||||
padding: '6px 9px',
|
||||
fontSize: 11,
|
||||
lineHeight: 1.35,
|
||||
color: '#e2e8f0',
|
||||
whiteSpace: 'nowrap',
|
||||
boxShadow: '0 4px 16px rgba(0,0,0,0.4)',
|
||||
}}
|
||||
>
|
||||
<div style={{ color: AXIS }}>{label}</div>
|
||||
<div style={{ fontWeight: 700 }}>{value}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function nearestIndex(clientX: number, rect: DOMRect, padL: number, innerW: number, n: number): number {
|
||||
if (n <= 1) return 0
|
||||
const mx = clientX - rect.left - padL
|
||||
const i = Math.round((mx / (innerW || 1)) * (n - 1))
|
||||
return Math.max(0, Math.min(n - 1, i))
|
||||
}
|
||||
|
||||
/** Pick a few evenly-spaced x labels (first/mid/last) to avoid crowding. */
|
||||
function axisTicks(data: ChartPoint[]): { i: number; label: string; anchor: 'start' | 'middle' | 'end' }[] {
|
||||
const n = data.length
|
||||
if (n === 0) return []
|
||||
if (n === 1) return [{ i: 0, label: data[0].label, anchor: 'middle' }]
|
||||
const mid = Math.floor((n - 1) / 2)
|
||||
const out: { i: number; label: string; anchor: 'start' | 'middle' | 'end' }[] = [
|
||||
{ i: 0, label: data[0].label, anchor: 'start' },
|
||||
{ i: n - 1, label: data[n - 1].label, anchor: 'end' },
|
||||
]
|
||||
if (n >= 5) out.splice(1, 0, { i: mid, label: data[mid].label, anchor: 'middle' })
|
||||
return out
|
||||
}
|
||||
|
||||
// ── Line chart (a value over time) ───────────────────────────────────────────
|
||||
|
||||
export function Line({
|
||||
data,
|
||||
height = 210,
|
||||
color = ACCENT,
|
||||
formatValue = (v) => String(v),
|
||||
}: {
|
||||
data: ChartPoint[]
|
||||
height?: number
|
||||
color?: string
|
||||
formatValue?: (v: number) => string
|
||||
}) {
|
||||
const { ref, w } = useContainerWidth()
|
||||
const [hover, setHover] = useState<number | null>(null)
|
||||
const padL = 10
|
||||
const padR = 10
|
||||
const padT = 12
|
||||
const padB = 24
|
||||
const n = data.length
|
||||
const innerW = Math.max(1, w - padL - padR)
|
||||
const innerH = Math.max(1, height - padT - padB)
|
||||
const max = Math.max(...data.map((d) => d.value), 1)
|
||||
const x = (i: number) => padL + (n <= 1 ? innerW / 2 : (i / (n - 1)) * innerW)
|
||||
const y = (v: number) => padT + innerH - (v / max) * innerH
|
||||
|
||||
const line = data.map((d, i) => `${i === 0 ? 'M' : 'L'}${x(i).toFixed(1)},${y(d.value).toFixed(1)}`).join(' ')
|
||||
const area = n >= 2 ? `${line} L${x(n - 1).toFixed(1)},${(padT + innerH).toFixed(1)} L${x(0).toFixed(1)},${(padT + innerH).toFixed(1)} Z` : ''
|
||||
const ticks = axisTicks(data)
|
||||
|
||||
return (
|
||||
<div ref={ref} style={{ position: 'relative', width: '100%' }}>
|
||||
{w > 0 ? (
|
||||
<svg
|
||||
width={w}
|
||||
height={height}
|
||||
style={{ display: 'block' }}
|
||||
onMouseMove={(e) => setHover(nearestIndex(e.clientX, e.currentTarget.getBoundingClientRect(), padL, innerW, n))}
|
||||
onMouseLeave={() => setHover(null)}
|
||||
>
|
||||
{[0, 0.5, 1].map((g) => (
|
||||
<line key={g} x1={padL} x2={w - padR} y1={padT + innerH * g} y2={padT + innerH * g} stroke={GRID} strokeWidth={1} />
|
||||
))}
|
||||
{area ? <path d={area} fill={color} fillOpacity={0.1} /> : null}
|
||||
{n >= 2 ? <path d={line} fill="none" stroke={color} strokeWidth={2} strokeLinejoin="round" strokeLinecap="round" /> : null}
|
||||
{hover != null && data[hover] ? (
|
||||
<>
|
||||
<line x1={x(hover)} x2={x(hover)} y1={padT} y2={padT + innerH} stroke={AXIS} strokeWidth={1} strokeDasharray="3 3" />
|
||||
<circle cx={x(hover)} cy={y(data[hover].value)} r={4} fill={color} stroke="#0f1115" strokeWidth={2} />
|
||||
</>
|
||||
) : null}
|
||||
{ticks.map((t) => (
|
||||
<text key={t.i} x={x(t.i)} y={height - 7} fontSize={10} fill={AXIS} textAnchor={t.anchor}>
|
||||
{t.label}
|
||||
</text>
|
||||
))}
|
||||
</svg>
|
||||
) : null}
|
||||
{hover != null && data[hover] ? (
|
||||
<Tooltip x={x(hover)} height={height} label={data[hover].label} value={formatValue(data[hover].value)} />
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Columns (a value over time, vertical bars) ───────────────────────────────
|
||||
|
||||
export function Columns({
|
||||
data,
|
||||
height = 210,
|
||||
color = ACCENT,
|
||||
formatValue = (v) => String(v),
|
||||
}: {
|
||||
data: ChartPoint[]
|
||||
height?: number
|
||||
color?: string
|
||||
formatValue?: (v: number) => string
|
||||
}) {
|
||||
const { ref, w } = useContainerWidth()
|
||||
const [hover, setHover] = useState<number | null>(null)
|
||||
const padL = 10
|
||||
const padR = 10
|
||||
const padT = 12
|
||||
const padB = 24
|
||||
const n = data.length
|
||||
const innerW = Math.max(1, w - padL - padR)
|
||||
const innerH = Math.max(1, height - padT - padB)
|
||||
const max = Math.max(...data.map((d) => d.value), 1)
|
||||
const slot = innerW / Math.max(1, n)
|
||||
const barW = Math.max(1, Math.min(slot * 0.62, 36))
|
||||
const cx = (i: number) => padL + slot * i + slot / 2
|
||||
const ticks = axisTicks(data)
|
||||
|
||||
return (
|
||||
<div ref={ref} style={{ position: 'relative', width: '100%' }}>
|
||||
{w > 0 ? (
|
||||
<svg
|
||||
width={w}
|
||||
height={height}
|
||||
style={{ display: 'block' }}
|
||||
onMouseMove={(e) => {
|
||||
const rect = e.currentTarget.getBoundingClientRect()
|
||||
const i = Math.floor((e.clientX - rect.left - padL) / (slot || 1))
|
||||
setHover(Math.max(0, Math.min(n - 1, i)))
|
||||
}}
|
||||
onMouseLeave={() => setHover(null)}
|
||||
>
|
||||
{[0, 0.5, 1].map((g) => (
|
||||
<line key={g} x1={padL} x2={w - padR} y1={padT + innerH * g} y2={padT + innerH * g} stroke={GRID} strokeWidth={1} />
|
||||
))}
|
||||
{data.map((d, i) => {
|
||||
const h = (d.value / max) * innerH
|
||||
return (
|
||||
<rect
|
||||
key={i}
|
||||
x={cx(i) - barW / 2}
|
||||
y={padT + innerH - h}
|
||||
width={barW}
|
||||
height={Math.max(0, h)}
|
||||
rx={Math.min(3, barW / 2)}
|
||||
fill={color}
|
||||
fillOpacity={hover === i ? 1 : 0.78}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
{ticks.map((t) => (
|
||||
<text key={t.i} x={cx(t.i)} y={height - 7} fontSize={10} fill={AXIS} textAnchor="middle">
|
||||
{t.label}
|
||||
</text>
|
||||
))}
|
||||
</svg>
|
||||
) : null}
|
||||
{hover != null && data[hover] ? (
|
||||
<Tooltip x={cx(hover)} height={height} label={data[hover].label} value={formatValue(data[hover].value)} />
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Donut (categorical share) ────────────────────────────────────────────────
|
||||
|
||||
export function Donut({
|
||||
slices,
|
||||
size = 160,
|
||||
thickness = 22,
|
||||
center,
|
||||
legend = false,
|
||||
}: {
|
||||
slices: Slice[]
|
||||
size?: number
|
||||
thickness?: number
|
||||
/** Rendered, centered, inside the ring (a number, a label+sub, anything). */
|
||||
center?: ReactNode
|
||||
/**
|
||||
* Render a built-in label/percentage legend beside the ring. Off by default so
|
||||
* a consumer can compose a richer legend (amounts, click targets) itself.
|
||||
*/
|
||||
legend?: boolean
|
||||
}) {
|
||||
const theme = useTheme()
|
||||
const fg = tone(theme, 'color12', 'currentColor')
|
||||
const positive = slices.filter((s) => s.value > 0)
|
||||
const total = positive.reduce((sum, s) => sum + s.value, 0)
|
||||
const r = size / 2
|
||||
const rInner = r - thickness
|
||||
const mid = (r + rInner) / 2
|
||||
|
||||
if (total <= 0) {
|
||||
return (
|
||||
<XStack items="center" justify="center" height={size}>
|
||||
<Text fontSize="$6" color="$color10">
|
||||
—
|
||||
</Text>
|
||||
</XStack>
|
||||
)
|
||||
}
|
||||
|
||||
const ring = (
|
||||
<div style={{ position: 'relative', width: size, height: size }}>
|
||||
<svg width={size} height={size} style={{ display: 'block' }}>
|
||||
{positive.length === 1 ? (
|
||||
<circle
|
||||
cx={r}
|
||||
cy={r}
|
||||
r={mid}
|
||||
fill="none"
|
||||
stroke={positive[0].color ?? fg}
|
||||
strokeOpacity={positive[0].color ? 1 : 0.9}
|
||||
strokeWidth={thickness}
|
||||
/>
|
||||
) : (
|
||||
renderArcs(positive, total, r, rInner, fg)
|
||||
)}
|
||||
</svg>
|
||||
{center ? (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
textAlign: 'center',
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
>
|
||||
{center}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
|
||||
if (!legend) return ring
|
||||
|
||||
return (
|
||||
<XStack gap="$4" items="center" flexWrap="wrap">
|
||||
{ring}
|
||||
<YStack gap="$1.5" flex={1} minW={150}>
|
||||
{positive.map((s, i) => (
|
||||
<XStack key={s.label} gap="$2" items="center">
|
||||
<div
|
||||
style={{
|
||||
width: 10,
|
||||
height: 10,
|
||||
borderRadius: 2,
|
||||
backgroundColor: s.color ?? AXIS,
|
||||
opacity: s.color ? 1 : rampOpacity(i, positive.length),
|
||||
}}
|
||||
/>
|
||||
<Text fontSize="$2" color="$color11" flex={1} numberOfLines={1}>
|
||||
{s.label}
|
||||
</Text>
|
||||
<Text fontSize="$2" color="$color12" fontWeight="600">
|
||||
{Math.round((s.value / total) * 100)}%
|
||||
</Text>
|
||||
</XStack>
|
||||
))}
|
||||
</YStack>
|
||||
</XStack>
|
||||
)
|
||||
}
|
||||
|
||||
function renderArcs(slices: Slice[], total: number, rOuter: number, rInner: number, fg: string): ReactNode {
|
||||
let a0 = -Math.PI / 2
|
||||
const paths: ReactNode[] = []
|
||||
slices.forEach((s, idx) => {
|
||||
const frac = Math.max(0, s.value) / total
|
||||
if (frac <= 0) return
|
||||
const a1 = a0 + frac * Math.PI * 2
|
||||
paths.push(
|
||||
<path
|
||||
key={s.label || idx}
|
||||
d={annularArc(rOuter, rOuter, rOuter, rInner, a0, Math.min(a1, a0 + Math.PI * 1.9999))}
|
||||
fill={s.color ?? fg}
|
||||
fillOpacity={s.color ? 1 : rampOpacity(idx, slices.length)}
|
||||
/>,
|
||||
)
|
||||
a0 = a1
|
||||
})
|
||||
return <>{paths}</>
|
||||
}
|
||||
|
||||
function polar(cx: number, cy: number, rad: number, a: number): [number, number] {
|
||||
return [cx + rad * Math.cos(a), cy + rad * Math.sin(a)]
|
||||
}
|
||||
|
||||
function annularArc(cx: number, cy: number, rOuter: number, rInner: number, a0: number, a1: number): string {
|
||||
const large = a1 - a0 > Math.PI ? 1 : 0
|
||||
const [x0, y0] = polar(cx, cy, rOuter, a0)
|
||||
const [x1, y1] = polar(cx, cy, rOuter, a1)
|
||||
const [x2, y2] = polar(cx, cy, rInner, a1)
|
||||
const [x3, y3] = polar(cx, cy, rInner, a0)
|
||||
return `M${x0},${y0} A${rOuter},${rOuter} 0 ${large} 1 ${x1},${y1} L${x2},${y2} A${rInner},${rInner} 0 ${large} 0 ${x3},${y3} Z`
|
||||
}
|
||||
|
||||
// ── Bars (a ranked distribution, horizontal, token-native) ───────────────────
|
||||
|
||||
export function Bars({ bars }: { bars: Slice[] }) {
|
||||
const max = Math.max(0, ...bars.map((b) => b.value))
|
||||
if (max <= 0) {
|
||||
return (
|
||||
<Text fontSize="$3" color="$color10">
|
||||
—
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<YStack gap="$2.5">
|
||||
{bars.map((b) => (
|
||||
<XStack key={b.label} gap="$3" items="center">
|
||||
<Text width={64} fontSize="$2" color="$color11" text="right">
|
||||
{b.label}
|
||||
</Text>
|
||||
<YStack flex={1} height={10} bg="$color3" rounded="$2" overflow="hidden">
|
||||
<YStack height={10} width={`${Math.max(2, (b.value / max) * 100)}%`} bg="$color9" rounded="$2" />
|
||||
</YStack>
|
||||
<Text width={56} fontSize="$2" color="$color12" fontWeight="600">
|
||||
{b.value.toLocaleString()}
|
||||
</Text>
|
||||
</XStack>
|
||||
))}
|
||||
</YStack>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
/**
|
||||
* @hanzo/dashboard motion + numeric-face stylesheet.
|
||||
*
|
||||
* Import ONCE in a consumer app (e.g. `import '@hanzo/dashboard/dashboard.css'`).
|
||||
* It ships the CSS the dashboard layer's JS-driven motion pairs with: the loading
|
||||
* shimmer, the live-feed pulse, the fade-up entrance, the deploy-Pipeline track
|
||||
* animations, and the tabular-numeral face for metric values.
|
||||
*
|
||||
* Every animation here is reduced-motion-guarded (→ static) — the count-up + live
|
||||
* sparkline are driven in JS and gated by `prefers-reduced-motion` in the hooks;
|
||||
* these are the pure-CSS bits. One place defines the easing; components apply the
|
||||
* classes. Extracted verbatim from the Hanzo Cloud Console so the "videogame-like"
|
||||
* feel is identical across apps.
|
||||
*/
|
||||
|
||||
/* Tabular numerals — metric values, prices, counts and IDs align on a fixed
|
||||
advance width so columns of numbers read cleanly (the dashboard-grade detail). */
|
||||
.hz-tnum {
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-feature-settings: 'tnum' 1;
|
||||
}
|
||||
|
||||
/* Motion — a single fade-up entrance (~0.4s ease-out, small upward travel,
|
||||
staggered by the consumer). One place defines it; <FadeIn> applies the class. */
|
||||
@keyframes hz-fade-up {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.hz-fade-up {
|
||||
animation: hz-fade-up 0.4s cubic-bezier(0.16, 1, 0.3, 1) both;
|
||||
will-change: transform, opacity;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.hz-fade-up {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Overview motion — the "videogame-like" live dashboard. The count-up +
|
||||
live sparkline are driven in JS (rAF, gated by prefers-reduced-motion in the
|
||||
hooks); these are the pure-CSS bits: a loading shimmer, a live-feed pulse, and
|
||||
a brief highlight when a tile's number changes. */
|
||||
|
||||
/* Skeleton shimmer — an honest "loading", never fabricated content. */
|
||||
@keyframes hz-shimmer {
|
||||
0% {
|
||||
background-position: -160px 0;
|
||||
}
|
||||
100% {
|
||||
background-position: 160px 0;
|
||||
}
|
||||
}
|
||||
|
||||
.hz-skeleton {
|
||||
background-color: var(--color3, rgba(148, 163, 184, 0.14));
|
||||
background-image: linear-gradient(
|
||||
90deg,
|
||||
transparent 0%,
|
||||
var(--color4, rgba(148, 163, 184, 0.22)) 50%,
|
||||
transparent 100%
|
||||
);
|
||||
background-size: 160px 100%;
|
||||
background-repeat: no-repeat;
|
||||
animation: hz-shimmer 1.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* Live-feed pulse — the "Live" dot on a streaming panel. */
|
||||
@keyframes hz-pulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
opacity: 0.45;
|
||||
transform: scale(0.82);
|
||||
}
|
||||
}
|
||||
|
||||
/* Entrance for a freshly-arrived activity row (staggerless — one row at a time). */
|
||||
@keyframes hz-row-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-6px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.hz-row-in {
|
||||
animation: hz-row-in 0.35s cubic-bezier(0.16, 1, 0.3, 1) both;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.hz-skeleton {
|
||||
animation: none;
|
||||
}
|
||||
.hz-row-in {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Pipeline — the deploy-stage line. A smooth flowing gradient marches along the
|
||||
active leg of the track (stroke-dashoffset), a soft halo pulses out from the
|
||||
current stage, and the status dot breathes. All reduced-motion-guarded (→
|
||||
static). One place defines the easing; Pipeline applies the classes. */
|
||||
@keyframes hz-pipe-flow {
|
||||
to {
|
||||
stroke-dashoffset: -28;
|
||||
}
|
||||
}
|
||||
.hz-pipe-flow {
|
||||
stroke-dasharray: 5 9;
|
||||
animation: hz-pipe-flow 0.85s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes hz-pipe-pulse {
|
||||
0% {
|
||||
transform: scale(1);
|
||||
opacity: 0.34;
|
||||
}
|
||||
70% {
|
||||
transform: scale(2.1);
|
||||
opacity: 0;
|
||||
}
|
||||
100% {
|
||||
transform: scale(2.1);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
.hz-pipe-pulse {
|
||||
transform-box: fill-box;
|
||||
transform-origin: center;
|
||||
animation: hz-pipe-pulse 1.7s ease-out infinite;
|
||||
}
|
||||
|
||||
.hz-pipe-dot {
|
||||
animation: hz-pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.hz-pipe-flow,
|
||||
.hz-pipe-pulse,
|
||||
.hz-pipe-dot {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
// @hanzo/dashboard — the reusable dashboard layer on @hanzo/gui.
|
||||
//
|
||||
// Composable dashboard primitives extracted from the Hanzo Cloud Console so every
|
||||
// Hanzo app builds the SAME dashboard system: monochrome SVG charts, a live
|
||||
// count-up KPI + streaming-feed + health Overview, the Landing kit, and a deploy
|
||||
// Pipeline. Each is pure (data via props / a loader), typed, reduced-motion-guarded.
|
||||
//
|
||||
// import '@hanzo/dashboard/dashboard.css' // once, for the motion keyframes
|
||||
// import { Overview, Kpi, Sparkline, Landing, Pipeline } from '@hanzo/dashboard'
|
||||
|
||||
// Shared
|
||||
export type { IconComponent } from './types'
|
||||
|
||||
// ── Charts (the ONE way to draw a trend / series / share / distribution) ─────
|
||||
export {
|
||||
Sparkline,
|
||||
Line,
|
||||
Columns,
|
||||
Donut,
|
||||
Bars,
|
||||
useContainerWidth,
|
||||
CHART_PALETTE,
|
||||
CHART_OTHER,
|
||||
type ChartPoint,
|
||||
type Slice,
|
||||
} from './charts/Charts'
|
||||
|
||||
// ── Motion (pure math + rAF/interval hooks) ──────────────────────────────────
|
||||
export { easeOutCubic, countUpValue, progress, pushSample, shouldTick, effectiveInterval } from './motion/motion'
|
||||
export { useReducedMotion, usePageHidden, useCountUp, usePoll } from './motion/hooks'
|
||||
|
||||
// ── Overview: the videogame-like dashboard ────────────────────────────
|
||||
export { Overview } from './overview/Overview'
|
||||
export { Tile } from './overview/tiles'
|
||||
export { Kpi, Feed, Board, Panel, SkeletonBar, EmptyPanel, PanelSpinner, LiveDot } from './overview/primitives'
|
||||
export type { KpiProps, FeedProps, BoardProps } from './overview/primitives'
|
||||
export {
|
||||
formatMetric,
|
||||
metricTarget,
|
||||
deltaOf,
|
||||
hasTrend,
|
||||
statusColor,
|
||||
healthColor,
|
||||
severityColor,
|
||||
worstHealth,
|
||||
healthTally,
|
||||
selectKpi,
|
||||
selectSeries,
|
||||
selectDistribution,
|
||||
distributionTotal,
|
||||
mergeActivity,
|
||||
windowRows,
|
||||
ago,
|
||||
isSkeleton,
|
||||
OK,
|
||||
WARN,
|
||||
BAD,
|
||||
MUTED,
|
||||
type Loadable,
|
||||
} from './overview/logic'
|
||||
export type {
|
||||
OverviewConfig,
|
||||
OverviewContext,
|
||||
OverviewData,
|
||||
OverviewRange,
|
||||
OverviewTile,
|
||||
MetricTile,
|
||||
TimeseriesTile,
|
||||
DistributionTile,
|
||||
ActivityTile,
|
||||
AlertsTile,
|
||||
HealthTile,
|
||||
MetricUnit,
|
||||
LiveConfig,
|
||||
LoadOverview,
|
||||
OverviewKpi,
|
||||
OverviewSeries,
|
||||
OverviewSlice,
|
||||
OverviewPoint,
|
||||
OverviewEvent,
|
||||
OverviewAlert,
|
||||
OverviewHealth,
|
||||
} from './overview/config'
|
||||
|
||||
// ── Landing kit (Hero / Metrics / Samples / Rail) ────────────────────────────
|
||||
export { Landing } from './landing/Landing'
|
||||
export { Hero } from './landing/Hero'
|
||||
export { Metrics } from './landing/Metrics'
|
||||
export { Samples } from './landing/Samples'
|
||||
export { Rail } from './landing/Rail'
|
||||
export { LandingCard, ActionRow, DeltaChip, openExternal } from './landing/parts'
|
||||
export { apexFromDocs, apiBaseFromDocs, landingDocsUrl, standardResources, supportMailto, type StandardResources } from './landing/logic'
|
||||
export type {
|
||||
LandingConfig,
|
||||
LandingAction,
|
||||
LandingMetric,
|
||||
ResourceLink,
|
||||
CodeSample,
|
||||
CodeLang,
|
||||
} from './landing/types'
|
||||
|
||||
// ── Pipeline (deploy-stage line) ─────────────────────────────────────────────
|
||||
export { Pipeline, type PipelineProps } from './pipeline/Pipeline'
|
||||
export {
|
||||
PIPELINE_STAGES,
|
||||
pipelinePhase,
|
||||
stageIndex,
|
||||
isTerminalPhase,
|
||||
pipelineModel,
|
||||
pipelineTone,
|
||||
type PipelinePhase,
|
||||
type PipelineStage,
|
||||
type PipelineStageName,
|
||||
type PipelineModel,
|
||||
type StageState,
|
||||
} from './pipeline/pipeline'
|
||||
@@ -0,0 +1,115 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Hero — the clean product hero (kicker + title + one-line what-it-is + primary
|
||||
* CTA): monochrome-tasteful with a functional accent + a pure-SVG hero graphic.
|
||||
* White-labels by brand (`config.brandName`) and accent (`config.accent`).
|
||||
*/
|
||||
import type { ComponentProps } from 'react'
|
||||
import { Button, Card, Text, XStack, YStack } from '@hanzo/gui'
|
||||
|
||||
import { CHART_PALETTE } from '../charts/Charts'
|
||||
import { openExternal } from './parts'
|
||||
import type { LandingAction, LandingConfig } from './types'
|
||||
|
||||
/** A hex color at an alpha, as an `rgba()`. */
|
||||
function hex(h: string, a: number): string {
|
||||
const n = h.replace('#', '')
|
||||
const r = parseInt(n.slice(0, 2), 16)
|
||||
const g = parseInt(n.slice(2, 4), 16)
|
||||
const b = parseInt(n.slice(4, 6), 16)
|
||||
return `rgba(${r}, ${g}, ${b}, ${a})`
|
||||
}
|
||||
|
||||
const act = (a: LandingAction) => (a.href ? openExternal(a.href) : a.onPress?.())
|
||||
|
||||
export function Hero({ config: c }: { config: LandingConfig }) {
|
||||
const Icon = c.icon
|
||||
const accent = c.accent ?? CHART_PALETTE[0]
|
||||
return (
|
||||
<Card
|
||||
borderWidth={1}
|
||||
borderColor="$borderColor"
|
||||
rounded="$6"
|
||||
p="$5"
|
||||
overflow="hidden"
|
||||
style={{ background: `linear-gradient(115deg, ${hex(accent, 0.16)} 0%, ${hex(accent, 0.04)} 48%, rgba(0,0,0,0) 78%)` }}
|
||||
>
|
||||
<XStack items="center" gap="$4" flexWrap="wrap">
|
||||
<YStack flex={1} minW={280} gap="$2.5">
|
||||
{Icon || c.brandName ? (
|
||||
<XStack items="center" gap="$2">
|
||||
{Icon ? <Icon size={16} color={accent} /> : null}
|
||||
{c.brandName ? (
|
||||
<Text fontSize="$1" color="$color11" fontWeight="700" style={{ letterSpacing: 0.4, textTransform: 'uppercase' }}>
|
||||
{c.brandName}
|
||||
</Text>
|
||||
) : null}
|
||||
</XStack>
|
||||
) : null}
|
||||
<Text fontSize="$8" fontWeight="900" color="$color12">
|
||||
{c.title}
|
||||
</Text>
|
||||
<Text fontSize="$3" color="$color11" maxW={620}>
|
||||
{c.tagline}
|
||||
</Text>
|
||||
{c.primary || c.secondary ? (
|
||||
<XStack gap="$2" pt="$2" flexWrap="wrap" items="center">
|
||||
{c.primary ? (
|
||||
<AccentButton accent={accent} icon={c.primary.icon} onPress={() => act(c.primary as LandingAction)}>
|
||||
{c.primary.label}
|
||||
</AccentButton>
|
||||
) : null}
|
||||
{c.secondary ? (
|
||||
<Button icon={c.secondary.icon} onPress={() => act(c.secondary as LandingAction)}>
|
||||
{c.secondary.label}
|
||||
</Button>
|
||||
) : null}
|
||||
</XStack>
|
||||
) : null}
|
||||
</YStack>
|
||||
<YStack items="center" justify="center" minW={170}>
|
||||
<HeroGraphic size={170} accent={accent} />
|
||||
</YStack>
|
||||
</XStack>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
/** The one high-emphasis accent action (a filled button on the brand accent). */
|
||||
function AccentButton({ accent, children, ...props }: { accent: string } & ComponentProps<typeof Button>) {
|
||||
return (
|
||||
<Button {...props} borderWidth={1} style={{ backgroundColor: accent, borderColor: accent, color: '#fff' }} hoverStyle={{ opacity: 0.92 }} pressStyle={{ opacity: 0.85 }}>
|
||||
{children}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
/** A pure-SVG hero motif — a glowing isometric "serving unit" cube. */
|
||||
function HeroGraphic({ size = 170, accent = CHART_PALETTE[0] }: { size?: number; accent?: string }) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 200 200" aria-hidden style={{ display: 'block', maxWidth: '100%' }}>
|
||||
<defs>
|
||||
<radialGradient id="hz-hero-glow" cx="50%" cy="45%" r="60%">
|
||||
<stop offset="0%" stopColor={accent} stopOpacity="0.55" />
|
||||
<stop offset="55%" stopColor={accent} stopOpacity="0.14" />
|
||||
<stop offset="100%" stopColor={accent} stopOpacity="0" />
|
||||
</radialGradient>
|
||||
<linearGradient id="hz-hero-cube" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" stopColor="#a78bfa" />
|
||||
<stop offset="100%" stopColor={accent} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<circle cx="100" cy="96" r="92" fill="url(#hz-hero-glow)" />
|
||||
{[74, 56, 40].map((r, i) => (
|
||||
<circle key={r} cx="100" cy="96" r={r} fill="none" stroke={accent} strokeOpacity={0.18 + i * 0.12} strokeWidth={1.5} />
|
||||
))}
|
||||
<g transform="translate(100 96)">
|
||||
<polygon points="0,-34 30,-17 30,17 0,34 -30,17 -30,-17" fill="url(#hz-hero-cube)" fillOpacity="0.9" />
|
||||
<polygon points="0,-34 30,-17 0,0 -30,-17" fill="#c4b5fd" fillOpacity="0.85" />
|
||||
<polygon points="0,0 30,-17 30,17 0,34" fill={accent} fillOpacity="0.7" />
|
||||
<polygon points="0,0 -30,-17 -30,17 0,34" fill="#6d28d9" fillOpacity="0.7" />
|
||||
</g>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Landing — the reusable polished landing/overview any product composes from a
|
||||
* small `LandingConfig`: a clean hero, a live count-up metrics row, an interactive
|
||||
* code-sample block (curl/python/ts/js + copy + Run), and a resources rail
|
||||
* (docs/quickstart/examples/code/support). DRY — one landing system across products.
|
||||
*
|
||||
* Layout: hero on top, then a two-column body (main content + resource rail) that
|
||||
* wraps to a single column on narrow viewports. `children` is the product's own
|
||||
* REAL content, rendered below the code block so a product keeps everything it
|
||||
* already had — this is polish, not a rewrite.
|
||||
*/
|
||||
import { useRef, type ReactNode } from 'react'
|
||||
import { XStack, YStack } from '@hanzo/gui'
|
||||
|
||||
import { Hero } from './Hero'
|
||||
import { Metrics } from './Metrics'
|
||||
import { Samples } from './Samples'
|
||||
import { Rail } from './Rail'
|
||||
import type { LandingConfig } from './types'
|
||||
|
||||
export function Landing({ config, children }: { config: LandingConfig; children?: ReactNode }) {
|
||||
const codeRef = useRef<HTMLDivElement>(null)
|
||||
const hasCode = Boolean(config.samples && config.samples.length)
|
||||
const scrollToCode = () => codeRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||
|
||||
return (
|
||||
<YStack gap="$4">
|
||||
<Hero config={config} />
|
||||
|
||||
<XStack gap="$4" flexWrap="wrap" items="flex-start">
|
||||
<YStack flex={2} minW={360} gap="$4">
|
||||
<Metrics config={config} />
|
||||
{hasCode ? (
|
||||
<div ref={codeRef} style={{ width: '100%' }}>
|
||||
<Samples samples={config.samples as NonNullable<LandingConfig['samples']>} run={config.run} />
|
||||
</div>
|
||||
) : null}
|
||||
{children}
|
||||
</YStack>
|
||||
|
||||
<Rail config={config} onViewCode={hasCode ? scrollToCode : undefined} />
|
||||
</XStack>
|
||||
</YStack>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Metrics — the live KPI row for a landing. Count-up values + real sparklines +
|
||||
* honest deltas, reusing the motion system (`useCountUp`/`usePoll`/
|
||||
* `useReducedMotion`) and the shared `Sparkline`. Two modes:
|
||||
* - controlled: the parent passes `config.metrics` (it owns the data / reload);
|
||||
* - self-loading: `config.loadMetrics` (+ optional `pollMs`) fetches + polls here.
|
||||
* Honest throughout: a null value renders "—", a background poll failure keeps the
|
||||
* last real data, and a first-load failure shows a quiet note (metrics are
|
||||
* supplementary — never a full error card).
|
||||
*/
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Card, Text, XStack, YStack } from '@hanzo/gui'
|
||||
|
||||
import { CHART_PALETTE, Sparkline } from '../charts/Charts'
|
||||
import { useCountUp, usePoll, useReducedMotion } from '../motion/hooks'
|
||||
import { DeltaChip } from './parts'
|
||||
import type { LandingConfig, LandingMetric } from './types'
|
||||
|
||||
export function Metrics({ config }: { config: LandingConfig }) {
|
||||
const reduced = useReducedMotion()
|
||||
const controlled = config.metrics !== undefined
|
||||
const load = config.loadMetrics
|
||||
const accent = config.accent ?? CHART_PALETTE[0]
|
||||
|
||||
const [metrics, setMetrics] = useState<LandingMetric[] | null>(config.metrics ?? null)
|
||||
const [failed, setFailed] = useState(false)
|
||||
const hasData = useRef(false)
|
||||
hasData.current = metrics !== null
|
||||
|
||||
const { tick } = usePoll(!controlled && load && config.pollMs ? config.pollMs : 0)
|
||||
|
||||
// Controlled: mirror the parent-provided metrics.
|
||||
useEffect(() => {
|
||||
if (controlled) setMetrics(config.metrics ?? null)
|
||||
}, [controlled, config.metrics])
|
||||
|
||||
// Self-loading: fetch (and re-fetch on each poll tick). Keep last-good on error.
|
||||
useEffect(() => {
|
||||
if (controlled || !load) return
|
||||
let cancelled = false
|
||||
void load()
|
||||
.then((m) => {
|
||||
if (!cancelled) {
|
||||
setMetrics(m)
|
||||
setFailed(false)
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled && !hasData.current) setFailed(true)
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [controlled, load, tick])
|
||||
|
||||
if (!metrics) {
|
||||
if (failed) return <MetricsNote text="Live metrics aren’t connected for your organization yet — nothing is fabricated." />
|
||||
if (!controlled && load) return <MetricsSkeleton />
|
||||
return null
|
||||
}
|
||||
if (metrics.length === 0) return null
|
||||
|
||||
return (
|
||||
<XStack flexWrap="wrap" gap="$3">
|
||||
{metrics.map((m) => (
|
||||
<MetricCard key={m.key} metric={m} reduced={reduced} accent={accent} />
|
||||
))}
|
||||
</XStack>
|
||||
)
|
||||
}
|
||||
|
||||
function MetricCard({ metric, reduced, accent }: { metric: LandingMetric; reduced: boolean; accent: string }) {
|
||||
const finite = metric.value != null && Number.isFinite(metric.value)
|
||||
const target = finite ? (metric.value as number) : 0
|
||||
|
||||
// Count up from zero on first mount (then re-animate on every value change). Seeding
|
||||
// via an after-mount effect means `useCountUp` animates 0 → value instead of snapping.
|
||||
const [seeded, setSeeded] = useState(false)
|
||||
useEffect(() => {
|
||||
setSeeded(true)
|
||||
}, [])
|
||||
const shown = useCountUp(seeded ? target : 0, !reduced && finite)
|
||||
const fmt = metric.format ?? ((n: number) => Math.round(n).toLocaleString())
|
||||
|
||||
return (
|
||||
<Card p="$3.5" gap="$2" borderWidth={1} borderColor="$borderColor" bg="$color2" flex={1} minW={168}>
|
||||
<XStack items="center" justify="space-between" gap="$2">
|
||||
<Text fontSize="$2" color="$color10" numberOfLines={1}>
|
||||
{metric.label}
|
||||
</Text>
|
||||
{metric.icon ?? null}
|
||||
</XStack>
|
||||
<Text fontSize="$8" fontWeight="900" color={finite ? '$color12' : '$color10'} numberOfLines={1}>
|
||||
{finite ? fmt(shown) : '—'}
|
||||
</Text>
|
||||
<XStack items="center" justify="space-between" gap="$2" minH={22}>
|
||||
{metric.deltaPct !== undefined ? <DeltaChip pct={metric.deltaPct ?? null} /> : <YStack />}
|
||||
{metric.series && metric.series.filter((v) => Number.isFinite(v)).length >= 2 ? (
|
||||
<Sparkline values={metric.series} width={72} height={22} color={accent} />
|
||||
) : metric.hint ? (
|
||||
<Text fontSize="$1" color="$color9" numberOfLines={1}>
|
||||
{metric.hint}
|
||||
</Text>
|
||||
) : null}
|
||||
</XStack>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function MetricsSkeleton() {
|
||||
return (
|
||||
<XStack flexWrap="wrap" gap="$3">
|
||||
{[0, 1, 2, 3].map((i) => (
|
||||
<Card key={i} p="$3.5" gap="$2.5" borderWidth={1} borderColor="$borderColor" bg="$color2" flex={1} minW={168}>
|
||||
<div className="hz-skeleton" style={{ height: 10, width: '55%', borderRadius: 4 }} />
|
||||
<div className="hz-skeleton" style={{ height: 26, width: '72%', borderRadius: 6 }} />
|
||||
<div className="hz-skeleton" style={{ height: 10, width: '40%', borderRadius: 4 }} />
|
||||
</Card>
|
||||
))}
|
||||
</XStack>
|
||||
)
|
||||
}
|
||||
|
||||
function MetricsNote({ text }: { text: string }) {
|
||||
return (
|
||||
<Card p="$3.5" borderWidth={1} borderColor="$borderColor" bg="$color2">
|
||||
<Text fontSize="$2" color="$color10">
|
||||
{text}
|
||||
</Text>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Rail — the right rail of a product landing (Documentation, Quick Start, Examples,
|
||||
* Code samples, API Reference, Quick Actions, Support). Every row is a REAL link
|
||||
* (docs derived from `config.docsUrl`) or an in-console action — no dead rows.
|
||||
* "Code samples" scrolls to the interactive block; Community/Support are opt-in
|
||||
* (`config.discordUrl` / `config.supportEmail`), never a hardcoded brand link.
|
||||
*/
|
||||
import { YStack } from '@hanzo/gui'
|
||||
import { BookOpen, Boxes, Code2, LifeBuoy, MessageSquare, Rocket, Zap } from '@hanzogui/lucide-icons-2'
|
||||
|
||||
import { ActionRow, LandingCard, openExternal } from './parts'
|
||||
import { apexFromDocs, standardResources } from './logic'
|
||||
import type { LandingAction, LandingConfig } from './types'
|
||||
|
||||
const runAction = (a: LandingAction) => (a.href ? openExternal(a.href) : a.onPress?.())
|
||||
|
||||
export function Rail({ config, onViewCode }: { config: LandingConfig; onViewCode?: () => void }) {
|
||||
const docs = config.docsUrl
|
||||
const std = standardResources(docs, config.docsProduct)
|
||||
const apex = apexFromDocs(docs)
|
||||
const supportAddr = config.supportEmail ?? `support@${apex}`
|
||||
const support = `mailto:${supportAddr}`
|
||||
|
||||
return (
|
||||
<YStack gap="$4" flex={1} minW={280}>
|
||||
<LandingCard title="Resources" p="$3">
|
||||
<YStack gap="$0.5">
|
||||
<ActionRow icon={<BookOpen size={15} />} label="Documentation" sub="Guides & concepts" onPress={() => openExternal(std.docs)} />
|
||||
<ActionRow icon={<Rocket size={15} />} label="Quick Start" sub="Ship in minutes" onPress={() => openExternal(std.quickstart)} />
|
||||
<ActionRow icon={<Boxes size={15} />} label="Examples" sub="Sample projects" onPress={() => openExternal(std.examples)} />
|
||||
{onViewCode ? <ActionRow icon={<Code2 size={15} />} label="Code samples" sub="Copy a real API call" onPress={onViewCode} /> : null}
|
||||
<ActionRow icon={<Zap size={15} />} label="API Reference" sub={`api.${apex}`} onPress={() => openExternal(std.api)} />
|
||||
{config.resources?.map((r) => (
|
||||
<ActionRow key={r.id} icon={r.icon} label={r.label} sub={r.sub} onPress={() => (r.href ? openExternal(r.href) : r.onPress?.())} />
|
||||
))}
|
||||
</YStack>
|
||||
</LandingCard>
|
||||
|
||||
{config.actions?.length ? (
|
||||
<LandingCard title="Quick Actions" p="$3">
|
||||
<YStack gap="$0.5">
|
||||
{config.actions.map((a) => (
|
||||
<ActionRow key={a.label} icon={a.icon} label={a.label} onPress={() => runAction(a)} />
|
||||
))}
|
||||
</YStack>
|
||||
</LandingCard>
|
||||
) : null}
|
||||
|
||||
<LandingCard title="Need help?" p="$3">
|
||||
<YStack gap="$0.5">
|
||||
{config.discordUrl ? (
|
||||
<ActionRow icon={<MessageSquare size={15} />} label="Community" sub="Join the chat" onPress={() => openExternal(config.discordUrl as string)} />
|
||||
) : null}
|
||||
<ActionRow icon={<LifeBuoy size={15} />} label="Contact Support" sub={supportAddr} onPress={() => openExternal(support)} />
|
||||
</YStack>
|
||||
</LandingCard>
|
||||
</YStack>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Samples — the interactive Quick Start / code block. Tab-switch between the REAL
|
||||
* API call in curl / Python / TS / JS, copy the current sample, and (optionally)
|
||||
* Run / Try in Playground. Not a screenshot — real, copyable, tab-switchable code.
|
||||
* The block scrolls horizontally inside its own container so a wide line never
|
||||
* scrolls the page.
|
||||
*/
|
||||
import { useState } from 'react'
|
||||
import { Button, Text, XStack, YStack } from '@hanzo/gui'
|
||||
import { Check, Copy, Play } from '@hanzogui/lucide-icons-2'
|
||||
|
||||
import { LandingCard, openExternal } from './parts'
|
||||
import type { CodeSample, LandingAction } from './types'
|
||||
|
||||
export function Samples({ samples, run, title = 'Quick start' }: { samples: CodeSample[]; run?: LandingAction; title?: string }) {
|
||||
const [active, setActive] = useState(0)
|
||||
const [copied, setCopied] = useState(false)
|
||||
const idx = Math.min(active, samples.length - 1)
|
||||
const sample = samples[idx]
|
||||
|
||||
const copy = async () => {
|
||||
if (typeof navigator === 'undefined' || !navigator.clipboard) return
|
||||
try {
|
||||
await navigator.clipboard.writeText(sample.code)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 1400)
|
||||
} catch {
|
||||
/* clipboard denied — no-op (the code is still visible + selectable) */
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<LandingCard
|
||||
title={title}
|
||||
actions={
|
||||
run ? (
|
||||
<Button size="$2" icon={<Play size={14} />} onPress={() => (run.href ? openExternal(run.href) : run.onPress?.())}>
|
||||
{run.label}
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
<XStack items="center" justify="space-between" gap="$2" flexWrap="wrap">
|
||||
<XStack gap="$1" flexWrap="wrap" items="center">
|
||||
{samples.map((s, i) => {
|
||||
const on = i === idx
|
||||
return (
|
||||
<Button key={s.lang} size="$2" bg={on ? '$color5' : 'transparent'} borderWidth={1} borderColor={on ? '$color7' : '$borderColor'} onPress={() => setActive(i)}>
|
||||
<Text fontSize="$2" fontWeight={on ? '700' : '500'} color={on ? '$color12' : '$color11'}>
|
||||
{s.label}
|
||||
</Text>
|
||||
</Button>
|
||||
)
|
||||
})}
|
||||
</XStack>
|
||||
<Button size="$2" chromeless icon={copied ? <Check size={14} color="#23c562" /> : <Copy size={14} />} onPress={copy}>
|
||||
{copied ? 'Copied' : 'Copy'}
|
||||
</Button>
|
||||
</XStack>
|
||||
|
||||
<YStack bg="$color1" borderWidth={1} borderColor="$borderColor" rounded="$4" p="$3" style={{ overflowX: 'auto' }}>
|
||||
<Text
|
||||
color="$color11"
|
||||
style={{
|
||||
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace',
|
||||
whiteSpace: 'pre',
|
||||
fontSize: 12,
|
||||
lineHeight: 1.6,
|
||||
}}
|
||||
>
|
||||
{sample.code}
|
||||
</Text>
|
||||
</YStack>
|
||||
</LandingCard>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* Landing — PURE link helpers (no UI, no I/O), unit-testable. Builds the real docs
|
||||
* / API / support links a landing rail and code samples point at, derived from the
|
||||
* brand docs host so a Lux/Zoo console links to ITS OWN surfaces (never hardcoded).
|
||||
*/
|
||||
|
||||
/** Apex domain from a docs URL (https://docs.hanzo.ai → hanzo.ai). */
|
||||
export function apexFromDocs(docsUrl: string): string {
|
||||
return docsUrl
|
||||
.replace(/^https?:\/\//, '')
|
||||
.replace(/\/.*$/, '')
|
||||
.replace(/^docs\./, '')
|
||||
.trim()
|
||||
}
|
||||
|
||||
/** The brand API base for code samples, e.g. https://api.hanzo.ai/v1. */
|
||||
export function apiBaseFromDocs(docsUrl: string): string {
|
||||
return `https://api.${apexFromDocs(docsUrl)}/v1`
|
||||
}
|
||||
|
||||
/** A docs URL for a product, with an optional sub-path. */
|
||||
export function landingDocsUrl(docsUrl: string, product: string, sub?: string): string {
|
||||
const base = `${docsUrl.replace(/\/+$/, '')}/${product}`
|
||||
return sub ? `${base}/${sub}` : base
|
||||
}
|
||||
|
||||
/** The brand support mailbox derived from the docs host (docs.hanzo.ai → support@hanzo.ai). */
|
||||
export function supportMailto(docsUrl: string): string {
|
||||
return `mailto:support@${apexFromDocs(docsUrl)}`
|
||||
}
|
||||
|
||||
/** The standard doc links every product's resource rail shows. */
|
||||
export interface StandardResources {
|
||||
docs: string
|
||||
quickstart: string
|
||||
examples: string
|
||||
api: string
|
||||
}
|
||||
|
||||
export function standardResources(docsUrl: string, product: string): StandardResources {
|
||||
return {
|
||||
docs: landingDocsUrl(docsUrl, product),
|
||||
quickstart: landingDocsUrl(docsUrl, product, 'quickstart'),
|
||||
examples: landingDocsUrl(docsUrl, product, 'examples'),
|
||||
api: landingDocsUrl(docsUrl, product, 'api-reference'),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Landing UI atoms — the shared card / action-row / delta-chip language, in a
|
||||
* dark-card idiom. Strictly @hanzo/gui v5 shorthands.
|
||||
*/
|
||||
import type { ComponentProps, ReactNode } from 'react'
|
||||
import { Card, Text, XStack, YStack } from '@hanzo/gui'
|
||||
import { TrendingDown, TrendingUp } from '@hanzogui/lucide-icons-2'
|
||||
|
||||
/** Open an external URL in a new tab (SSR-guarded). */
|
||||
export function openExternal(href: string): void {
|
||||
if (typeof window !== 'undefined') window.open(href, '_blank', 'noopener')
|
||||
}
|
||||
|
||||
/** A titled dark card — the ONE section container across the landing. */
|
||||
export function LandingCard({
|
||||
title,
|
||||
subtitle,
|
||||
actions,
|
||||
children,
|
||||
p = '$4',
|
||||
...rest
|
||||
}: {
|
||||
title?: ReactNode
|
||||
subtitle?: ReactNode
|
||||
actions?: ReactNode
|
||||
children: ReactNode
|
||||
p?: ComponentProps<typeof Card>['p']
|
||||
} & Omit<ComponentProps<typeof Card>, 'children' | 'title'>) {
|
||||
return (
|
||||
<Card borderWidth={1} borderColor="$borderColor" bg="$color2" rounded="$5" p={p} gap="$3.5" {...rest}>
|
||||
{title || actions ? (
|
||||
<XStack justify="space-between" items="center" gap="$3" flexWrap="wrap">
|
||||
<YStack gap="$0.5" flex={1} minW={120}>
|
||||
{title ? (
|
||||
<Text fontSize="$5" fontWeight="800" color="$color12">
|
||||
{title}
|
||||
</Text>
|
||||
) : null}
|
||||
{subtitle ? (
|
||||
<Text fontSize="$2" color="$color10">
|
||||
{subtitle}
|
||||
</Text>
|
||||
) : null}
|
||||
</YStack>
|
||||
{actions ? (
|
||||
<XStack gap="$2" items="center">
|
||||
{actions}
|
||||
</XStack>
|
||||
) : null}
|
||||
</XStack>
|
||||
) : null}
|
||||
{children}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
/** One tappable action row (icon + label + optional sub-line). */
|
||||
export function ActionRow({
|
||||
icon,
|
||||
label,
|
||||
sub,
|
||||
onPress,
|
||||
}: {
|
||||
icon?: ReactNode
|
||||
label: string
|
||||
sub?: string
|
||||
onPress: () => void
|
||||
}) {
|
||||
return (
|
||||
<XStack items="center" gap="$3" py="$2" px="$2" rounded="$3" cursor="pointer" hoverStyle={{ bg: '$color3' }} onPress={onPress}>
|
||||
<YStack width={30} height={30} items="center" justify="center" rounded="$3" bg="$color3">
|
||||
{icon}
|
||||
</YStack>
|
||||
<YStack flex={1} minW={0} gap="$0.5">
|
||||
<Text fontSize="$3" color="$color12" numberOfLines={1}>
|
||||
{label}
|
||||
</Text>
|
||||
{sub ? (
|
||||
<Text fontSize="$1" color="$color10" numberOfLines={1}>
|
||||
{sub}
|
||||
</Text>
|
||||
) : null}
|
||||
</YStack>
|
||||
<Text fontSize="$4" color="$color9">
|
||||
›
|
||||
</Text>
|
||||
</XStack>
|
||||
)
|
||||
}
|
||||
|
||||
/** An ↑/↓ delta chip. Up→green, down→red, null→muted em-dash. Direction only (honest). */
|
||||
export function DeltaChip({ pct }: { pct: number | null }) {
|
||||
if (pct == null || !Number.isFinite(pct)) {
|
||||
return (
|
||||
<Text fontSize="$1" color="$color10">
|
||||
—
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
const rounded = Math.round(pct)
|
||||
const up = rounded >= 0
|
||||
const color = up ? '#23c562' : '#ff5d8f'
|
||||
const Icon = up ? TrendingUp : TrendingDown
|
||||
const sign = rounded > 0 ? '+' : rounded < 0 ? '−' : ''
|
||||
return (
|
||||
<XStack items="center" gap="$1">
|
||||
<Icon size={13} color={color} />
|
||||
<Text fontSize="$1" fontWeight="700" style={{ color }}>
|
||||
{sign}
|
||||
{Math.abs(rounded)}%
|
||||
</Text>
|
||||
</XStack>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* Landing — the shared config contract every product supplies to get a polished
|
||||
* landing/overview: a hero, live count-up metrics, an interactive code-sample
|
||||
* block, and a resources rail. One landing system, DRY across products.
|
||||
*
|
||||
* Types only — erased at build; the pure link logic lives in `logic.ts`.
|
||||
*/
|
||||
import type { ReactElement, ReactNode } from 'react'
|
||||
|
||||
import type { IconComponent } from '../types'
|
||||
|
||||
/** Languages the interactive code block offers. */
|
||||
export type CodeLang = 'curl' | 'python' | 'ts' | 'js'
|
||||
|
||||
/** One copyable code sample — the REAL API call for the product. */
|
||||
export interface CodeSample {
|
||||
lang: CodeLang
|
||||
/** Tab label, e.g. "cURL", "Python", "TypeScript", "JavaScript". */
|
||||
label: string
|
||||
code: string
|
||||
}
|
||||
|
||||
/** A call-to-action — an in-app handler OR an external link. `icon` is a rendered
|
||||
* element so it satisfies the Button `icon` prop. */
|
||||
export interface LandingAction {
|
||||
label: string
|
||||
onPress?: () => void
|
||||
href?: string
|
||||
icon?: ReactElement
|
||||
}
|
||||
|
||||
/** One headline metric — a real value (null → honest "—"), optional trend + delta. */
|
||||
export interface LandingMetric {
|
||||
key: string
|
||||
label: string
|
||||
/** Current value, or null when the backend doesn't report it (renders "—"). */
|
||||
value: number | null
|
||||
/** Formats the (count-up) value; defaults to a localized integer. */
|
||||
format?: (n: number) => string
|
||||
/** Real time series for the sparkline (≥2 points, else omitted — never faked). */
|
||||
series?: number[]
|
||||
/** Fractional/percent change vs the prior window; null → honest em-dash. */
|
||||
deltaPct?: number | null
|
||||
icon?: ReactNode
|
||||
/** A small honest caption shown when there is no series (e.g. "Awaiting metering"). */
|
||||
hint?: string
|
||||
}
|
||||
|
||||
/** A resource rail row (Documentation / Quick Start / Examples / Support …). */
|
||||
export interface ResourceLink {
|
||||
id: string
|
||||
label: string
|
||||
sub?: string
|
||||
href?: string
|
||||
onPress?: () => void
|
||||
icon?: ReactNode
|
||||
}
|
||||
|
||||
/**
|
||||
* The full landing config a product composes. `metrics` (controlled by the parent)
|
||||
* OR `loadMetrics` (self-loading + polling) drives the live KPI row; supply one.
|
||||
*/
|
||||
export interface LandingConfig {
|
||||
productId: string
|
||||
title: string
|
||||
/** One-line "what it is". */
|
||||
tagline: string
|
||||
icon?: IconComponent
|
||||
/** Docs host base the rail/samples derive links from (e.g. 'https://docs.hanzo.ai'). */
|
||||
docsUrl: string
|
||||
/** Docs path segment under the docs host (e.g. 'embeddings'). */
|
||||
docsProduct: string
|
||||
/** Brand name shown in the hero kicker (omitted when unset). */
|
||||
brandName?: string
|
||||
/** Accent color for the hero + sparklines (defaults to the palette head). */
|
||||
accent?: string
|
||||
/** Community link for the resource rail (row omitted when unset). */
|
||||
discordUrl?: string
|
||||
/** Support email override (defaults to `support@<docs apex>`). */
|
||||
supportEmail?: string
|
||||
/** The hero's primary + optional secondary CTA. */
|
||||
primary?: LandingAction
|
||||
secondary?: LandingAction
|
||||
/** Controlled metrics (parent owns the data). */
|
||||
metrics?: LandingMetric[]
|
||||
/** Self-loading metrics (real data / honest gaps) — used with `pollMs`. */
|
||||
loadMetrics?: () => Promise<LandingMetric[]>
|
||||
/** Poll interval for `loadMetrics` in ms (0/undefined → load once, no poll). */
|
||||
pollMs?: number
|
||||
/** Interactive, copyable code samples (curl/python/ts/js). */
|
||||
samples?: CodeSample[]
|
||||
/** A "Run"/"Try in Playground" affordance for the code block. */
|
||||
run?: LandingAction
|
||||
/** Quick-action rows appended to the resource rail. */
|
||||
actions?: LandingAction[]
|
||||
/** Extra resource rows appended after the standard ones. */
|
||||
resources?: ResourceLink[]
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Overview React hooks — the thin rAF/interval drivers on top of the pure
|
||||
* `motion.ts` math. Each hook owns exactly one browser concern (animation frames,
|
||||
* the poll clock, reduced-motion, tab visibility) and cleans itself up on unmount
|
||||
* (no leaked intervals/frames).
|
||||
*/
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
|
||||
import { countUpValue, progress } from './motion'
|
||||
|
||||
/** `true` when the user asked for reduced motion — every animation short-circuits to its end state. */
|
||||
export function useReducedMotion(): boolean {
|
||||
const [reduced, setReduced] = useState(false)
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined' || !window.matchMedia) return
|
||||
const mq = window.matchMedia('(prefers-reduced-motion: reduce)')
|
||||
const on = () => setReduced(mq.matches)
|
||||
on()
|
||||
// addEventListener is the modern API; older Safari uses addListener.
|
||||
if (mq.addEventListener) mq.addEventListener('change', on)
|
||||
else mq.addListener(on)
|
||||
return () => {
|
||||
if (mq.removeEventListener) mq.removeEventListener('change', on)
|
||||
else mq.removeListener(on)
|
||||
}
|
||||
}, [])
|
||||
return reduced
|
||||
}
|
||||
|
||||
/** `true` when the document is hidden (background tab) — the poll clock pauses. */
|
||||
export function usePageHidden(): boolean {
|
||||
const [hidden, setHidden] = useState(false)
|
||||
useEffect(() => {
|
||||
if (typeof document === 'undefined') return
|
||||
const on = () => setHidden(document.visibilityState === 'hidden')
|
||||
on()
|
||||
document.addEventListener('visibilitychange', on)
|
||||
return () => document.removeEventListener('visibilitychange', on)
|
||||
}, [])
|
||||
return hidden
|
||||
}
|
||||
|
||||
/**
|
||||
* Animate a number from its previous value to `target` on the eased count-up curve.
|
||||
* Returns the value to display right now. When `enabled` is false (reduced motion,
|
||||
* or count-up turned off in config), it snaps to `target` — the tile still shows the
|
||||
* real number, just without the tween. Cleans up its rAF on unmount/retarget.
|
||||
*/
|
||||
export function useCountUp(target: number, enabled: boolean, durationMs = 700): number {
|
||||
const [value, setValue] = useState(target)
|
||||
// The value currently ON SCREEN, tracked every frame. A retarget mid-flight
|
||||
// animates from HERE (not the previous target), so a new poll never snaps the
|
||||
// digits back before easing — smooth all the way.
|
||||
const shownRef = useRef(target)
|
||||
const frameRef = useRef<number | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const from = shownRef.current
|
||||
if (!enabled || from === target) {
|
||||
shownRef.current = target
|
||||
setValue(target)
|
||||
return
|
||||
}
|
||||
const startedAt = typeof performance !== 'undefined' ? performance.now() : Date.now()
|
||||
const tick = () => {
|
||||
const now = typeof performance !== 'undefined' ? performance.now() : Date.now()
|
||||
const t = progress(startedAt, now, durationMs)
|
||||
const v = countUpValue(from, target, t)
|
||||
shownRef.current = v
|
||||
setValue(v)
|
||||
if (t < 1) frameRef.current = requestAnimationFrame(tick)
|
||||
}
|
||||
frameRef.current = requestAnimationFrame(tick)
|
||||
return () => {
|
||||
if (frameRef.current != null) cancelAnimationFrame(frameRef.current)
|
||||
}
|
||||
}, [target, enabled, durationMs])
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
/**
|
||||
* A monotonic tick that fires every `everyMs` while the tab is visible, paused when
|
||||
* `everyMs <= 0`. The consumer refetches on each tick. Self-clearing on unmount and
|
||||
* on interval change — no stacked timers. Returns the tick count (a dependency the
|
||||
* caller can watch) and a `bump()` to force an immediate refetch (e.g. range change).
|
||||
*/
|
||||
export function usePoll(everyMs: number): { tick: number; bump: () => void } {
|
||||
const [tick, setTick] = useState(0)
|
||||
useEffect(() => {
|
||||
if (everyMs <= 0) return
|
||||
const id = setInterval(() => setTick((n) => n + 1), everyMs)
|
||||
return () => clearInterval(id)
|
||||
}, [everyMs])
|
||||
return { tick, bump: () => setTick((n) => n + 1) }
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* Overview motion — the PURE math behind the "videogame-like" feel, kept
|
||||
* out of the React components so it is unit-testable in the node env (no jsdom).
|
||||
* The `.tsx` tiles are thin wrappers that drive `requestAnimationFrame` with these
|
||||
* functions.
|
||||
*
|
||||
* Three primitives, one law each:
|
||||
* - `easeOutCubic` / `countUpValue` — a number animates from a→b on an eased
|
||||
* curve; at `t>=1` it lands EXACTLY on `b` (no float drift, no fabricated
|
||||
* overshoot). Reduced-motion callers pass `t=1` and get `b` immediately.
|
||||
* - `pushSample` — a live sparkline is a bounded ring of the most-recent real
|
||||
* samples; a new reading appends and evicts the oldest past `cap`. It never
|
||||
* invents points — an empty history stays empty (the tile shows no trend).
|
||||
* - `shouldTick` — a self-correcting poll clock: given the last tick and now, it
|
||||
* answers whether it is time to refetch, so a throttled live loop can't drift
|
||||
* or stack overlapping fetches.
|
||||
*
|
||||
* Honesty: none of these create data. Count-up interpolates BETWEEN two real
|
||||
* values the caller already has; the sparkline ring holds only real samples.
|
||||
*/
|
||||
|
||||
/** Ease-out cubic — fast then settling, the house count-up curve. Clamps to [0,1]. */
|
||||
export const easeOutCubic = (t: number): number => {
|
||||
const x = t <= 0 ? 0 : t >= 1 ? 1 : t
|
||||
return 1 - Math.pow(1 - x, 3)
|
||||
}
|
||||
|
||||
/**
|
||||
* The displayed value at normalized progress `t` (0..1) animating `from`→`to`.
|
||||
* Lands exactly on `to` at t>=1 (and returns `to` for a non-finite `from`, so a
|
||||
* first-ever value counts up from 0 only when the caller seeds `from=0`).
|
||||
*/
|
||||
export function countUpValue(from: number, to: number, t: number): number {
|
||||
if (!Number.isFinite(to)) return Number.isFinite(from) ? from : 0
|
||||
if (t >= 1) return to
|
||||
const base = Number.isFinite(from) ? from : to
|
||||
return base + (to - base) * easeOutCubic(t)
|
||||
}
|
||||
|
||||
/** Normalized progress for an animation started at `startedAt`, now `now`, over `durationMs`. */
|
||||
export function progress(startedAt: number, now: number, durationMs: number): number {
|
||||
if (durationMs <= 0) return 1
|
||||
return Math.min(1, Math.max(0, (now - startedAt) / durationMs))
|
||||
}
|
||||
|
||||
/**
|
||||
* Append `sample` to a live-sparkline ring, evicting oldest beyond `cap`. Returns
|
||||
* a NEW array (never mutates) so React state updates cleanly. A non-finite sample
|
||||
* is dropped — the trend only ever holds real readings.
|
||||
*/
|
||||
export function pushSample(history: readonly number[], sample: number, cap: number): number[] {
|
||||
if (!Number.isFinite(sample)) return history.slice()
|
||||
const next = [...history, sample]
|
||||
return next.length > cap ? next.slice(next.length - cap) : next
|
||||
}
|
||||
|
||||
/**
|
||||
* Poll clock. `true` when `now - lastTickAt >= everyMs` (time to refetch). A
|
||||
* non-positive interval disables polling (always `false`) so a config can turn
|
||||
* live updates off without a special case.
|
||||
*/
|
||||
export function shouldTick(lastTickAt: number, now: number, everyMs: number): boolean {
|
||||
if (everyMs <= 0) return false
|
||||
return now - lastTickAt >= everyMs
|
||||
}
|
||||
|
||||
/**
|
||||
* Effective poll interval given a requested `everyMs`, a floor, and whether the
|
||||
* document is hidden. Returns 0 (paused) when hidden — a background tab must not
|
||||
* burn the backend — and never goes below `floorMs` (throttle guard) when active.
|
||||
*/
|
||||
export function effectiveInterval(everyMs: number, floorMs: number, hidden: boolean): number {
|
||||
if (everyMs <= 0) return 0
|
||||
if (hidden) return 0
|
||||
return Math.max(floorMs, everyMs)
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Overview — the ONE reusable, videogame-like dashboard driver. Given an
|
||||
* `OverviewConfig` (a product's tiles + its REAL data loader + live/motion
|
||||
* settings) it renders the board and keeps it alive: a single throttled poll loop
|
||||
* refetches on an interval (paused on a hidden tab), the range selector re-scopes,
|
||||
* and every number counts up on change (unless reduced motion / count-up off).
|
||||
*
|
||||
* DRY + orthogonal: this file knows nothing about any specific product or endpoint.
|
||||
* The tiles read their slice out of the normalized `OverviewData` the loader returns;
|
||||
* a product is added by writing a config, never overview UI.
|
||||
*
|
||||
* Honest by construction: the FIRST load shows a spinner; a failure shows the error
|
||||
* card; thereafter a background refetch never blanks the board (the last real data
|
||||
* stays until new real data lands), and any tile whose slice is absent renders its
|
||||
* own skeleton/empty. Style props are the @hanzo/gui v5 shorthand set.
|
||||
*/
|
||||
import { useEffect, useRef, useState, type ReactNode } from 'react'
|
||||
import { Button, Card, Spinner, Text, XStack, YStack } from '@hanzo/gui'
|
||||
|
||||
import { effectiveInterval } from '../motion/motion'
|
||||
import { usePageHidden, usePoll } from '../motion/hooks'
|
||||
import { type Loadable } from './logic'
|
||||
import { PanelSpinner } from './primitives'
|
||||
import { Tile } from './tiles'
|
||||
import type { OverviewConfig, OverviewData, OverviewRange } from './config'
|
||||
|
||||
/** Throttle floor: a config can ask for faster, but never faster than this. */
|
||||
const POLL_FLOOR_MS = 5000
|
||||
|
||||
const RANGES: { key: OverviewRange; label: string }[] = [
|
||||
{ key: '24h', label: '24H' },
|
||||
{ key: '7d', label: '7D' },
|
||||
{ key: '30d', label: '30D' },
|
||||
]
|
||||
|
||||
export function Overview({ config, allOrgs, isGlobalAdmin = false }: { config: OverviewConfig; allOrgs?: boolean; isGlobalAdmin?: boolean }) {
|
||||
const [range, setRange] = useState<OverviewRange>('24h')
|
||||
const [state, setState] = useState<Loadable<OverviewData>>({ s: 'loading' })
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
const reqRef = useRef(0)
|
||||
|
||||
const hidden = usePageHidden()
|
||||
// Pause polling while the tab is hidden OR the board is in its error state — an
|
||||
// errored source should not be hammered on a loop; the user retries explicitly.
|
||||
const pollMs = effectiveInterval(config.live?.pollMs ?? 0, POLL_FLOOR_MS, hidden || state.s === 'error')
|
||||
const { tick, bump } = usePoll(pollMs)
|
||||
const live = (config.live?.pollMs ?? 0) > 0
|
||||
const countUp = config.live?.countUp !== false
|
||||
|
||||
// One load path for the first paint, the range change, and every poll tick. The
|
||||
// reqRef guard drops a stale in-flight response so a fast range toggle can't
|
||||
// render older data over newer.
|
||||
useEffect(() => {
|
||||
const id = ++reqRef.current
|
||||
const first = state.s === 'loading'
|
||||
if (!first) setRefreshing(true)
|
||||
config
|
||||
.load({ range, allOrgs, isGlobalAdmin })
|
||||
.then((v) => {
|
||||
if (id === reqRef.current) setState({ s: 'ready', v })
|
||||
})
|
||||
.catch((e) => {
|
||||
// A background refetch that fails must NOT blank a board that already has
|
||||
// real data — keep the last data, surface the error only on the first load.
|
||||
if (id !== reqRef.current) return
|
||||
if (first) {
|
||||
const err = asLoadError(e)
|
||||
setState({ s: 'error', message: err.message, status: err.status })
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (id === reqRef.current) setRefreshing(false)
|
||||
})
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [range, allOrgs, isGlobalAdmin, tick, config])
|
||||
|
||||
const onRange = (k: OverviewRange) => {
|
||||
setRange(k)
|
||||
bump()
|
||||
}
|
||||
|
||||
const data = state.s === 'ready' ? state.v : null
|
||||
|
||||
return (
|
||||
<YStack gap="$4">
|
||||
<Header
|
||||
title={allOrgs ? `${config.title} — all orgs` : config.title}
|
||||
subtitle={config.subtitle}
|
||||
actions={
|
||||
<XStack gap="$2" items="center">
|
||||
{refreshing ? <Spinner size="small" color="$color10" /> : null}
|
||||
{config.ranged === false ? null : <RangeSelector value={range} onChange={onRange} />}
|
||||
</XStack>
|
||||
}
|
||||
/>
|
||||
|
||||
{state.s === 'error' ? (
|
||||
<ErrorState
|
||||
message={state.message}
|
||||
status={state.status}
|
||||
onRetry={() => {
|
||||
setState({ s: 'loading' })
|
||||
bump()
|
||||
}}
|
||||
/>
|
||||
) : state.s === 'loading' ? (
|
||||
<PanelSpinner />
|
||||
) : (
|
||||
<YStack gap="$4">
|
||||
{config.rows.map((row, ri) => (
|
||||
<FadeIn key={ri} index={ri} step={60}>
|
||||
<XStack flexWrap="wrap" gap="$3">
|
||||
{row.map((tile, ti) => (
|
||||
<Tile key={ti} tile={tile} data={data} loading={false} live={live && countUp} index={ti} />
|
||||
))}
|
||||
</XStack>
|
||||
</FadeIn>
|
||||
))}
|
||||
</YStack>
|
||||
)}
|
||||
</YStack>
|
||||
)
|
||||
}
|
||||
|
||||
// ── chrome (self-contained — no host-app coupling) ───────────────────────────
|
||||
|
||||
/** Extract an honest message + status from an unknown thrown value. */
|
||||
function asLoadError(e: unknown): { message: string; status: number } {
|
||||
if (e && typeof e === 'object') {
|
||||
const anyE = e as { message?: unknown; status?: unknown }
|
||||
return {
|
||||
message: typeof anyE.message === 'string' ? anyE.message : 'Something went wrong.',
|
||||
status: typeof anyE.status === 'number' ? anyE.status : 0,
|
||||
}
|
||||
}
|
||||
return { message: typeof e === 'string' ? e : 'Something went wrong.', status: 0 }
|
||||
}
|
||||
|
||||
/** Section title + optional subtitle and right-aligned actions. */
|
||||
function Header({ title, subtitle, actions }: { title: string; subtitle?: string; actions?: ReactNode }) {
|
||||
return (
|
||||
<XStack justify="space-between" items="flex-start" gap="$3" flexWrap="wrap">
|
||||
<YStack gap="$1" flex={1} minW={200}>
|
||||
<Text fontSize="$7" fontWeight="500" letterSpacing={-0.4}>
|
||||
{title}
|
||||
</Text>
|
||||
{subtitle ? (
|
||||
<Text fontSize="$3" color="$color11">
|
||||
{subtitle}
|
||||
</Text>
|
||||
) : null}
|
||||
</YStack>
|
||||
{actions ? <XStack gap="$2" items="center">{actions}</XStack> : null}
|
||||
</XStack>
|
||||
)
|
||||
}
|
||||
|
||||
/** A fade-up entrance (staggered by index). Honors reduced motion via the CSS. */
|
||||
function FadeIn({ children, index = 0, step = 50 }: { children: ReactNode; index?: number; step?: number }) {
|
||||
return (
|
||||
<div className="hz-fade-up" style={{ animationDelay: `${index * step}ms` }}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** The first-load / retry error card (honest, with a retry). */
|
||||
function ErrorState({ message, status, onRetry }: { message: string; status: number; onRetry: () => void }) {
|
||||
const note =
|
||||
status === 404
|
||||
? 'This overview’s data source is not routed on this deployment yet — it appears automatically once the backend is reachable for your org.'
|
||||
: message
|
||||
return (
|
||||
<Card borderWidth={1} borderColor="$borderColor" p="$5" gap="$3" items="flex-start">
|
||||
<Text fontSize="$5" fontWeight="600" color="$color12">
|
||||
Could not load
|
||||
</Text>
|
||||
<Text fontSize="$3" color="$color11" maxW={620}>
|
||||
{note}
|
||||
</Text>
|
||||
<Button onPress={onRetry}>Retry</Button>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function RangeSelector({ value, onChange }: { value: OverviewRange; onChange: (k: OverviewRange) => void }) {
|
||||
return (
|
||||
<XStack borderWidth={1} borderColor="$borderColor" rounded="$4" overflow="hidden">
|
||||
{RANGES.map((r) => {
|
||||
const active = r.key === value
|
||||
return (
|
||||
<Button key={r.key} size="$2" chromeless={!active} bg={active ? '$color5' : 'transparent'} rounded="$0" onPress={() => onChange(r.key)}>
|
||||
<Text fontSize="$2" fontWeight={active ? '700' : '500'} color={active ? '$color12' : '$color10'}>
|
||||
{r.label}
|
||||
</Text>
|
||||
</Button>
|
||||
)
|
||||
})}
|
||||
</XStack>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
/**
|
||||
* Overview config — the DECLARATIVE contract that makes one overview
|
||||
* component serve every product. A product declares WHICH tiles it has and WHERE
|
||||
* their data comes from; `Overview` renders + animates them. No product
|
||||
* writes overview UI — it writes a `OverviewConfig`.
|
||||
*
|
||||
* Orthogonal by design:
|
||||
* - a `LoadOverview` is the ONE async that fetches a product's REAL data and
|
||||
* returns a normalized `OverviewData` (KPIs, series, distribution, activity,
|
||||
* alerts, health). It owns the endpoint; the tiles know nothing about it.
|
||||
* - each tile is a small value object naming a slice of that data by key
|
||||
* (`kpi.tokens`, `series.spend`, …). A missing slice renders its honest empty
|
||||
* state — the config can over-declare tiles a slow backend hasn't filled yet.
|
||||
* - `live` is the videogame layer: a poll interval (throttled, hidden-tab-paused)
|
||||
* and whether numbers count up. Off → a static overview, same component.
|
||||
*
|
||||
* The consumer supplies its own adapter that maps each real source (usage ledger,
|
||||
* admin aggregate, functions metrics, …) onto `OverviewData`, so wiring a product =
|
||||
* one adapter + one config, never new UI.
|
||||
*/
|
||||
import type { IconComponent } from '../types'
|
||||
|
||||
/** A KPI datum for an animated metric tile. Raw numbers; the tile formats them. */
|
||||
export type OverviewKpi = {
|
||||
value: number
|
||||
/** Prior-period value for the ±% delta; omit when there is no basis (→ honest "—"). */
|
||||
prior?: number
|
||||
/** A recent dense series for the tile's live sparkline (oldest→newest). */
|
||||
series?: number[]
|
||||
}
|
||||
|
||||
/** A point on a temporal series. */
|
||||
export type OverviewPoint = { t: string; value: number }
|
||||
|
||||
/** A named temporal series with its bucket interval (drives the x-axis labels). */
|
||||
export type OverviewSeries = { interval: 'hour' | 'day' | (string & {}); points: OverviewPoint[] }
|
||||
|
||||
/** A slice of a distribution (spend by model, revenue by product). */
|
||||
export type OverviewSlice = { label: string; value: number; sub?: string }
|
||||
|
||||
/** A live activity/event row. */
|
||||
export type OverviewEvent = {
|
||||
id: string
|
||||
time: string
|
||||
title: string
|
||||
subtitle?: string
|
||||
/** `success` | `error` | `warn` | '' — drives the status dot. */
|
||||
status: string
|
||||
}
|
||||
|
||||
/** An open alert. */
|
||||
export type OverviewAlert = { id: string; severity: 'critical' | 'warning' | 'info' | (string & {}); title: string; detail?: string }
|
||||
|
||||
/** A service-health row. */
|
||||
export type OverviewHealth = { service: string; health: 'green' | 'yellow' | 'red' | (string & {}); detail?: string }
|
||||
|
||||
/**
|
||||
* The normalized data every tile reads. Keyed maps so a tile names its slice by a
|
||||
* stable key; every map may be partial (a slice with no data → honest empty tile).
|
||||
*/
|
||||
export type OverviewData = {
|
||||
kpi: Record<string, OverviewKpi>
|
||||
series: Record<string, OverviewSeries>
|
||||
distribution: Record<string, OverviewSlice[]>
|
||||
activity: OverviewEvent[]
|
||||
alerts: OverviewAlert[]
|
||||
health: OverviewHealth[]
|
||||
/** Total count behind `activity` for pagination, when the source knows it. */
|
||||
activityTotal?: number
|
||||
}
|
||||
|
||||
/** How a number is displayed. `cents`→USD, `count`→compacted, `ms`→duration, `pct`→percent. */
|
||||
export type MetricUnit = 'count' | 'cents' | 'ms' | 'pct'
|
||||
|
||||
/** An animated KPI tile: a count-up number + optional live sparkline + delta. */
|
||||
export type MetricTile = {
|
||||
tile: 'metric'
|
||||
/** Key into `OverviewData.kpi`. */
|
||||
key: string
|
||||
label: string
|
||||
icon: IconComponent
|
||||
unit?: MetricUnit
|
||||
/** Static caption under the value (e.g. "across 3 providers"). */
|
||||
caption?: string
|
||||
/** Accent color for the sparkline (defaults to the palette by position). */
|
||||
color?: string
|
||||
}
|
||||
|
||||
/** A timeseries panel (line or bars) over `OverviewData.series[key]`. */
|
||||
export type TimeseriesTile = {
|
||||
tile: 'timeseries'
|
||||
key: string
|
||||
title: string
|
||||
kind?: 'line' | 'bar'
|
||||
unit?: MetricUnit
|
||||
}
|
||||
|
||||
/** A distribution panel (donut + legend) over `OverviewData.distribution[key]`. */
|
||||
export type DistributionTile = {
|
||||
tile: 'distribution'
|
||||
key: string
|
||||
title: string
|
||||
/** Center caption under the total. */
|
||||
centerLabel?: string
|
||||
unit?: MetricUnit
|
||||
}
|
||||
|
||||
/** The live activity stream (virtualized, streaming/polled). */
|
||||
export type ActivityTile = {
|
||||
tile: 'activity'
|
||||
title?: string
|
||||
/** Empty-state copy when there is no activity in range. */
|
||||
empty?: string
|
||||
}
|
||||
|
||||
/** The open-alerts panel. */
|
||||
export type AlertsTile = { tile: 'alerts'; title?: string }
|
||||
|
||||
/** The service-health board. */
|
||||
export type HealthTile = { tile: 'health'; title?: string; empty?: string }
|
||||
|
||||
/** Any tile. Discriminated on `tile` so the renderer branches exhaustively. */
|
||||
export type OverviewTile =
|
||||
| MetricTile
|
||||
| TimeseriesTile
|
||||
| DistributionTile
|
||||
| ActivityTile
|
||||
| AlertsTile
|
||||
| HealthTile
|
||||
|
||||
/** The "videogame" layer — live updates + motion, all off-able for a static view. */
|
||||
export type LiveConfig = {
|
||||
/**
|
||||
* Poll the loader every `pollMs` (throttled to a floor, paused when the tab is
|
||||
* hidden). 0/undefined → no polling (a one-shot static overview).
|
||||
*/
|
||||
pollMs?: number
|
||||
/** Count numbers up on change (respects prefers-reduced-motion). Default true. */
|
||||
countUp?: boolean
|
||||
}
|
||||
|
||||
/** The context passed to a loader (range + org scope). */
|
||||
export type OverviewContext = {
|
||||
range: OverviewRange
|
||||
/** Global-admin all-orgs god view. */
|
||||
allOrgs?: boolean
|
||||
/**
|
||||
* True when the viewer is a global (cross-tenant) admin. Loaders use this to
|
||||
* SKIP admin-only aggregates that a tenant user's browser would only ever get a
|
||||
* 403 from, going straight to the org-scoped source instead — the board renders
|
||||
* the same, minus the console noise.
|
||||
*/
|
||||
isGlobalAdmin?: boolean
|
||||
}
|
||||
|
||||
export type OverviewRange = '24h' | '7d' | '30d'
|
||||
|
||||
/** The ONE async that fetches a product's REAL data, normalized to `OverviewData`. */
|
||||
export type LoadOverview = (ctx: OverviewContext) => Promise<OverviewData>
|
||||
|
||||
/**
|
||||
* A product's complete overview — its header, its tiles (in render order), its
|
||||
* real data loader, and its live/motion settings. This IS the reusable unit: a
|
||||
* consumer builds one of these; `Overview` renders it.
|
||||
*/
|
||||
export type OverviewConfig = {
|
||||
id: string
|
||||
title: string
|
||||
subtitle?: string
|
||||
/** Ordered rows of tiles (each row is a responsive wrap group). */
|
||||
rows: OverviewTile[][]
|
||||
load: LoadOverview
|
||||
live?: LiveConfig
|
||||
/** Show the range selector (24H/7D/30D). Default true. */
|
||||
ranged?: boolean
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
/**
|
||||
* Overview tile logic — the PURE decisions behind every tile, so the honest
|
||||
* empty/loading/error/reduced-motion behavior is unit-testable in the node env and
|
||||
* the `.tsx` tiles stay thin.
|
||||
*
|
||||
* Every function here answers ONE question a tile needs: how to format a value for
|
||||
* its unit, whether a live sparkline has enough real points to draw, what a delta
|
||||
* reads (or the honest "—" when there's no basis), what color a status/health dot
|
||||
* is, and — critically — how to SELECT a tile's slice out of `OverviewData` so a
|
||||
* missing slice becomes an honest empty tile instead of a crash.
|
||||
*
|
||||
* Nothing here fabricates data: a formatter turns a real number into a string, a
|
||||
* selector returns `undefined`/`[]` for absent data, and the sparkline gate returns
|
||||
* false for <2 points (the tile then renders no trend).
|
||||
*/
|
||||
import type {
|
||||
MetricUnit,
|
||||
OverviewData,
|
||||
OverviewKpi,
|
||||
OverviewSlice,
|
||||
OverviewEvent,
|
||||
OverviewHealth,
|
||||
OverviewSeries,
|
||||
} from './config'
|
||||
|
||||
// ── value formatting ─────────────────────────────────────────────────────────
|
||||
|
||||
const compact = (n: number): string =>
|
||||
new Intl.NumberFormat(undefined, { notation: 'compact', maximumFractionDigits: 1 }).format(n)
|
||||
|
||||
const usd = (cents: number): string => {
|
||||
const d = cents / 100
|
||||
if (Math.abs(d) >= 1000) return '$' + new Intl.NumberFormat(undefined, { notation: 'compact', maximumFractionDigits: 1 }).format(d)
|
||||
return '$' + d.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
||||
}
|
||||
|
||||
const duration = (ms: number): string => {
|
||||
if (ms >= 1000) return (ms / 1000).toFixed(ms >= 10_000 ? 0 : 1) + 's'
|
||||
return Math.round(ms) + 'ms'
|
||||
}
|
||||
|
||||
/** Format a raw metric value for its unit. The ONE place units become strings. */
|
||||
export function formatMetric(value: number, unit: MetricUnit = 'count'): string {
|
||||
if (!Number.isFinite(value)) return '—'
|
||||
switch (unit) {
|
||||
case 'cents':
|
||||
return usd(value)
|
||||
case 'ms':
|
||||
return duration(value)
|
||||
case 'pct':
|
||||
return `${Math.round(value)}%`
|
||||
default:
|
||||
return compact(value)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The number the count-up animation should target for a unit. Money counts up in
|
||||
* DOLLARS (not cents) so the animated digits read naturally; everything else counts
|
||||
* up in its own value. The tile formats the interpolated number back via
|
||||
* `formatMetric` on the same unit. This returns the raw value; the divisor is in
|
||||
* `formatMetric`.
|
||||
*/
|
||||
export function metricTarget(value: number): number {
|
||||
return Number.isFinite(value) ? value : 0
|
||||
}
|
||||
|
||||
/** A delta descriptor for a KPI, or null when there's no prior basis (honest "—"). */
|
||||
export function deltaOf(kpi: OverviewKpi | undefined): { pct: number; up: boolean } | null {
|
||||
if (!kpi || kpi.prior === undefined || !Number.isFinite(kpi.prior) || kpi.prior === 0) return null
|
||||
const pct = Math.round(((kpi.value - kpi.prior) / kpi.prior) * 100)
|
||||
return { pct, up: pct >= 0 }
|
||||
}
|
||||
|
||||
/** True when a live sparkline has ≥2 finite points to draw (else the tile shows no trend). */
|
||||
export function hasTrend(series: readonly number[] | undefined): boolean {
|
||||
if (!series) return false
|
||||
return series.filter((v) => Number.isFinite(v)).length >= 2
|
||||
}
|
||||
|
||||
// ── status / health colors (the ONE mapping) ─────────────────────────────────
|
||||
|
||||
export const OK = '#23c562'
|
||||
export const WARN = '#f0a868'
|
||||
export const BAD = '#ff5d8f'
|
||||
export const MUTED = '#64748b'
|
||||
|
||||
/** Dot color for an activity/event status. */
|
||||
export function statusColor(status: string): string {
|
||||
const s = status.toLowerCase()
|
||||
if (s === 'error' || s === 'failed' || s === 'fail') return BAD
|
||||
if (s === 'warn' || s === 'warning' || s === 'pending') return WARN
|
||||
return OK // success or unknown/'' reads as nominal
|
||||
}
|
||||
|
||||
/** Dot color for a service-health verdict. */
|
||||
export function healthColor(health: string): string {
|
||||
const h = health.toLowerCase()
|
||||
if (h === 'green' || h === 'healthy' || h === 'ok') return OK
|
||||
if (h === 'yellow' || h === 'degraded' || h === 'warn') return WARN
|
||||
if (h === 'red' || h === 'down' || h === 'unhealthy') return BAD
|
||||
return MUTED
|
||||
}
|
||||
|
||||
/** Dot color for an alert severity. */
|
||||
export function severityColor(severity: string): string {
|
||||
const s = severity.toLowerCase()
|
||||
if (s === 'critical' || s === 'error') return BAD
|
||||
if (s === 'warning' || s === 'warn') return WARN
|
||||
return MUTED
|
||||
}
|
||||
|
||||
// ── overall health verdict for the board header ──────────────────────────────
|
||||
|
||||
/** The worst verdict across health rows: red > yellow > green; '' when no rows. */
|
||||
export function worstHealth(rows: readonly OverviewHealth[]): '' | 'green' | 'yellow' | 'red' {
|
||||
let sawYellow = false
|
||||
let sawGreen = false
|
||||
for (const r of rows) {
|
||||
const h = r.health.toLowerCase()
|
||||
if (h === 'red' || h === 'down' || h === 'unhealthy') return 'red' // worst — short-circuit
|
||||
if (h === 'yellow' || h === 'degraded') sawYellow = true
|
||||
else if (h === 'green' || h === 'healthy' || h === 'ok') sawGreen = true
|
||||
}
|
||||
if (sawYellow) return 'yellow'
|
||||
if (sawGreen) return 'green'
|
||||
return ''
|
||||
}
|
||||
|
||||
/** Count healthy rows / total (for the "12/14 healthy" header). */
|
||||
export function healthTally(rows: readonly OverviewHealth[]): { healthy: number; total: number } {
|
||||
const total = rows.length
|
||||
const healthy = rows.filter((r) => healthColor(r.health) === OK).length
|
||||
return { healthy, total }
|
||||
}
|
||||
|
||||
// ── slice selectors (a missing slice → honest empty, never a crash) ──────────
|
||||
|
||||
export const selectKpi = (data: OverviewData, key: string): OverviewKpi | undefined => data.kpi[key]
|
||||
export const selectSeries = (data: OverviewData, key: string): OverviewSeries | undefined => data.series[key]
|
||||
export const selectDistribution = (data: OverviewData, key: string): OverviewSlice[] => data.distribution[key] ?? []
|
||||
|
||||
/** Sum a distribution's positive values (the donut center total). */
|
||||
export function distributionTotal(slices: readonly OverviewSlice[]): number {
|
||||
return slices.reduce((sum, s) => sum + Math.max(0, s.value), 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge freshly-polled activity into the current list, newest-first, de-duped by
|
||||
* id, capped. This is what lets the live stream GROW smoothly across polls without
|
||||
* duplicating a row already on screen or re-ordering the feed. Rows with an empty
|
||||
* id are kept (can't dedupe them) but still ordered by time.
|
||||
*/
|
||||
export function mergeActivity(current: readonly OverviewEvent[], incoming: readonly OverviewEvent[], cap: number): OverviewEvent[] {
|
||||
const seen = new Set<string>()
|
||||
const out: OverviewEvent[] = []
|
||||
for (const e of [...incoming, ...current]) {
|
||||
if (e.id && seen.has(e.id)) continue
|
||||
if (e.id) seen.add(e.id)
|
||||
out.push(e)
|
||||
}
|
||||
out.sort((a, b) => tOf(b.time) - tOf(a.time))
|
||||
return out.slice(0, cap)
|
||||
}
|
||||
|
||||
const tOf = (iso: string): number => {
|
||||
const t = Date.parse(iso)
|
||||
return Number.isNaN(t) ? 0 : t
|
||||
}
|
||||
|
||||
/** Which rows of a virtualized list are visible for a scroll offset + viewport. */
|
||||
export function windowRows<T>(rows: readonly T[], scrollTop: number, rowH: number, viewportH: number, overscan = 4): { start: number; end: number; padTop: number; padBottom: number; slice: T[] } {
|
||||
if (rows.length === 0 || rowH <= 0) return { start: 0, end: 0, padTop: 0, padBottom: 0, slice: [] }
|
||||
const start = Math.max(0, Math.floor(scrollTop / rowH) - overscan)
|
||||
const visible = Math.ceil(viewportH / rowH) + overscan * 2
|
||||
const end = Math.min(rows.length, start + visible)
|
||||
return {
|
||||
start,
|
||||
end,
|
||||
padTop: start * rowH,
|
||||
padBottom: Math.max(0, (rows.length - end) * rowH),
|
||||
slice: rows.slice(start, end),
|
||||
}
|
||||
}
|
||||
|
||||
// ── async state (the repo idiom, shared by the driver + tests) ───────────────
|
||||
|
||||
export type Loadable<T> = { s: 'loading' } | { s: 'error'; message: string; status: number } | { s: 'ready'; v: T }
|
||||
|
||||
/** True when a tile should render its SKELETON (first load, no data yet). */
|
||||
export const isSkeleton = <T>(state: Loadable<T>): boolean => state.s === 'loading'
|
||||
|
||||
/** Relative-time label for an event/activity timestamp (honest for a bad stamp). */
|
||||
export function ago(iso: string, now: number): string {
|
||||
const t = Date.parse(iso)
|
||||
if (Number.isNaN(t)) return '—'
|
||||
const s = Math.max(0, Math.round((now - t) / 1000))
|
||||
if (s < 60) return `${s}s ago`
|
||||
const m = Math.round(s / 60)
|
||||
if (m < 60) return `${m}m ago`
|
||||
const h = Math.round(m / 60)
|
||||
if (h < 24) return `${h}h ago`
|
||||
return `${Math.round(h / 24)}d ago`
|
||||
}
|
||||
@@ -0,0 +1,306 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Composable dashboard primitives — the small, honest building blocks a consumer
|
||||
* mixes directly (without the full `Overview` driver): `Kpi` (an animated stat
|
||||
* tile), `Feed` (a virtualized activity stream), `Board` (a service-health board),
|
||||
* plus the shared `Panel`/`SkeletonBar`/`EmptyPanel`/`PanelSpinner` chrome.
|
||||
*
|
||||
* Every primitive is driven by direct props (data in, view out) and is honest by
|
||||
* construction: a null/absent value renders an em-dash, a <2-point series draws no
|
||||
* trend, reduced motion snaps to the end state. Style props are the @hanzo/gui v5
|
||||
* shorthand set; raw/SVG colors go through inline `style`.
|
||||
*/
|
||||
import { useMemo, useRef, useState, type ReactNode } from 'react'
|
||||
import { Card, Spinner, Text, XStack, YStack } from '@hanzo/gui'
|
||||
import { Clock, TrendingDown, TrendingUp } from '@hanzogui/lucide-icons-2'
|
||||
|
||||
import { CHART_PALETTE, Sparkline } from '../charts/Charts'
|
||||
import { useCountUp, useReducedMotion } from '../motion/hooks'
|
||||
import { ago, formatMetric, hasTrend, healthColor, healthTally, statusColor, windowRows, worstHealth, OK, MUTED } from './logic'
|
||||
import type { MetricUnit, OverviewEvent, OverviewHealth } from './config'
|
||||
import type { IconComponent } from '../types'
|
||||
|
||||
const UP = '#23c562'
|
||||
const DOWN = '#ff5d8f'
|
||||
|
||||
// ── Kpi (count-up stat tile + optional live sparkline + delta) ───────────────
|
||||
|
||||
export interface KpiProps {
|
||||
label: string
|
||||
/** The value; `null`/`undefined` → an honest em-dash (backend doesn't report it). */
|
||||
value: number | null | undefined
|
||||
unit?: MetricUnit
|
||||
/** Signed ±% vs the prior window; `null`/`undefined` → honest "— vs prior". */
|
||||
deltaPct?: number | null
|
||||
/** A recent dense series for the sparkline (≥2 finite points, else omitted). */
|
||||
series?: number[]
|
||||
icon?: IconComponent
|
||||
/** Sparkline accent (defaults to the palette head). */
|
||||
color?: string
|
||||
/** A small caption under the value. */
|
||||
caption?: string
|
||||
/** Count the value up on change (respects reduced motion). Default true. */
|
||||
animate?: boolean
|
||||
/** Show the loading skeleton for the value + delta. */
|
||||
loading?: boolean
|
||||
}
|
||||
|
||||
/** An animated KPI / stat tile — the canonical dashboard number. */
|
||||
export function Kpi({ label, value, unit, deltaPct, series, icon: Icon, color, caption, animate = true, loading = false }: KpiProps) {
|
||||
const reduced = useReducedMotion()
|
||||
const finite = value != null && Number.isFinite(value)
|
||||
const target = finite ? (value as number) : 0
|
||||
const shown = useCountUp(target, animate && !reduced && finite)
|
||||
const accent = color ?? CHART_PALETTE[0]
|
||||
|
||||
return (
|
||||
// Responsive KPI columns so a 4-card row balances instead of wrapping 3+1: one
|
||||
// column on phones, two from md, four from xl. flexGrow equalizes the widths.
|
||||
<Card
|
||||
borderWidth={1}
|
||||
borderColor="$borderColor"
|
||||
p="$4"
|
||||
gap="$2"
|
||||
flexGrow={1}
|
||||
flexShrink={1}
|
||||
flexBasis="100%"
|
||||
$md={{ flexBasis: '48%' }}
|
||||
$xl={{ flexBasis: '22%' }}
|
||||
minW={200}
|
||||
>
|
||||
<XStack items="center" gap="$2">
|
||||
{Icon ? <Icon size={15} opacity={0.7} /> : null}
|
||||
<Text fontSize="$2" color="$color10" numberOfLines={1}>
|
||||
{label}
|
||||
</Text>
|
||||
</XStack>
|
||||
|
||||
{loading && !finite ? (
|
||||
<SkeletonBar w={110} h={34} />
|
||||
) : (
|
||||
<Text fontSize="$9" fontWeight="500" color={finite ? '$color12' : '$color10'} numberOfLines={1} className="hz-tnum">
|
||||
{finite ? formatMetric(shown, unit) : '—'}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<XStack justify="space-between" items="flex-end" gap="$2">
|
||||
<YStack gap="$1">
|
||||
{caption ? (
|
||||
<Text fontSize="$1" color="$color10" numberOfLines={1}>
|
||||
{caption}
|
||||
</Text>
|
||||
) : null}
|
||||
<DeltaPill pct={deltaPct ?? null} loading={loading && !finite} />
|
||||
</YStack>
|
||||
{hasTrend(series) ? <Sparkline values={series!} color={accent} /> : null}
|
||||
</XStack>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function DeltaPill({ pct, loading }: { pct: number | null; loading: boolean }) {
|
||||
if (loading) return <SkeletonBar w={80} h={12} />
|
||||
if (pct == null || !Number.isFinite(pct)) {
|
||||
return (
|
||||
<Text fontSize="$1" color="$color10">
|
||||
— vs prior
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
const up = pct >= 0
|
||||
const Icon = up ? TrendingUp : TrendingDown
|
||||
const color = up ? UP : DOWN
|
||||
return (
|
||||
<XStack items="center" gap="$1">
|
||||
<Icon size={12} color={color} />
|
||||
<Text fontSize="$1" fontWeight="700" style={{ color }}>
|
||||
{`${up ? '+' : ''}${Math.round(pct)}%`}
|
||||
</Text>
|
||||
<Text fontSize="$1" color="$color10">
|
||||
vs prior
|
||||
</Text>
|
||||
</XStack>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Feed (virtualized live activity stream) ──────────────────────────────────
|
||||
|
||||
const ROW_H = 52
|
||||
const VIEWPORT_H = 360
|
||||
|
||||
export interface FeedProps {
|
||||
items: OverviewEvent[]
|
||||
title?: string
|
||||
empty?: string
|
||||
loading?: boolean
|
||||
}
|
||||
|
||||
/** A live activity stream — newest rows on top, virtualized past a viewport. */
|
||||
export function Feed({ items, title = 'Live activity', empty = 'No activity in this range yet.', loading = false }: FeedProps) {
|
||||
const [scrollTop, setScrollTop] = useState(0)
|
||||
const nowRef = useRef(Date.now())
|
||||
nowRef.current = Date.now()
|
||||
const rows = items ?? []
|
||||
// Virtualize only when the stream is long; small feeds render whole (no scroll jank).
|
||||
const virtualize = rows.length > Math.ceil(VIEWPORT_H / ROW_H)
|
||||
const win = useMemo(() => windowRows(rows, scrollTop, ROW_H, VIEWPORT_H), [rows, scrollTop])
|
||||
|
||||
return (
|
||||
<Panel title={title} right={<LiveDot on={!loading && rows.length > 0} />}>
|
||||
{loading && rows.length === 0 ? (
|
||||
<YStack gap="$2">
|
||||
{[0, 1, 2, 3].map((i) => (
|
||||
<SkeletonBar key={i} w="100%" h={ROW_H - 12} />
|
||||
))}
|
||||
</YStack>
|
||||
) : rows.length === 0 ? (
|
||||
<EmptyPanel note={empty} />
|
||||
) : virtualize ? (
|
||||
<div style={{ height: VIEWPORT_H, overflowY: 'auto' }} onScroll={(e) => setScrollTop((e.target as HTMLDivElement).scrollTop)}>
|
||||
<div style={{ height: win.padTop }} />
|
||||
{win.slice.map((e) => (
|
||||
<FeedRow key={e.id || `${e.time}-${e.title}`} title={e.title} subtitle={e.subtitle} status={e.status} when={ago(e.time, nowRef.current)} />
|
||||
))}
|
||||
<div style={{ height: win.padBottom }} />
|
||||
</div>
|
||||
) : (
|
||||
<YStack>
|
||||
{rows.map((e) => (
|
||||
<FeedRow key={e.id || `${e.time}-${e.title}`} title={e.title} subtitle={e.subtitle} status={e.status} when={ago(e.time, nowRef.current)} />
|
||||
))}
|
||||
</YStack>
|
||||
)}
|
||||
</Panel>
|
||||
)
|
||||
}
|
||||
|
||||
function FeedRow({ title, subtitle, status, when }: { title: string; subtitle?: string; status: string; when: string }) {
|
||||
return (
|
||||
<XStack items="center" gap="$3" py="$2.5" borderBottomWidth={1} borderColor="$borderColor" style={{ height: ROW_H }}>
|
||||
<YStack width={8} height={8} rounded="$10" style={{ backgroundColor: statusColor(status) }} />
|
||||
<YStack flex={1}>
|
||||
<Text fontSize="$3" color="$color12" numberOfLines={1}>
|
||||
{title || 'Event'}
|
||||
</Text>
|
||||
{subtitle ? (
|
||||
<Text fontSize="$1" color="$color10" numberOfLines={1}>
|
||||
{subtitle}
|
||||
</Text>
|
||||
) : null}
|
||||
</YStack>
|
||||
<XStack items="center" gap="$1.5">
|
||||
<Clock size={12} opacity={0.6} />
|
||||
<Text fontSize="$2" color="$color11">
|
||||
{when}
|
||||
</Text>
|
||||
</XStack>
|
||||
</XStack>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Board (service-health board) ─────────────────────────────────────────────
|
||||
|
||||
export interface BoardProps {
|
||||
items: OverviewHealth[]
|
||||
title?: string
|
||||
empty?: string
|
||||
loading?: boolean
|
||||
}
|
||||
|
||||
/** A service-health board — a healthy/total header + per-service dots. */
|
||||
export function Board({ items, title = 'System health', empty = 'Health is reported once the control plane is reachable.', loading = false }: BoardProps) {
|
||||
const rows = items ?? []
|
||||
const worst = worstHealth(rows)
|
||||
const { healthy, total } = healthTally(rows)
|
||||
const headColor = worst === 'red' ? DOWN : worst === 'yellow' ? '#f0a868' : worst === 'green' ? OK : MUTED
|
||||
|
||||
return (
|
||||
<Panel
|
||||
title={title}
|
||||
right={
|
||||
total > 0 ? (
|
||||
<XStack items="center" gap="$2">
|
||||
<YStack width={9} height={9} rounded="$10" style={{ backgroundColor: headColor }} />
|
||||
<Text fontSize="$2" color="$color10">
|
||||
{healthy}/{total} healthy
|
||||
</Text>
|
||||
</XStack>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
{loading && rows.length === 0 ? (
|
||||
<SkeletonBar w="100%" h={40} />
|
||||
) : rows.length === 0 ? (
|
||||
<EmptyPanel note={empty} />
|
||||
) : (
|
||||
<XStack flexWrap="wrap" gap="$2">
|
||||
{rows.map((r) => (
|
||||
<XStack key={r.service} items="center" gap="$2" bg="$color2" px="$2.5" py="$1.5" rounded="$10" borderWidth={1} borderColor="$borderColor">
|
||||
<YStack width={8} height={8} rounded="$10" style={{ backgroundColor: healthColor(r.health) }} />
|
||||
<Text fontSize="$2" color="$color11" numberOfLines={1}>
|
||||
{r.service}
|
||||
</Text>
|
||||
</XStack>
|
||||
))}
|
||||
</XStack>
|
||||
)}
|
||||
</Panel>
|
||||
)
|
||||
}
|
||||
|
||||
// ── shared chrome ────────────────────────────────────────────────────────────
|
||||
|
||||
/** A titled panel shell — the ONE section container across the dashboard. */
|
||||
export function Panel({ title, right, children, minW = 320, flex }: { title: string; right?: ReactNode; children: ReactNode; minW?: number; flex?: number }) {
|
||||
return (
|
||||
<Card borderWidth={1} borderColor="$borderColor" p="$4" gap="$3" flex={flex} minW={minW}>
|
||||
<XStack items="center" justify="space-between" gap="$2">
|
||||
<Text fontSize="$4" fontWeight="500" color="$color12">
|
||||
{title}
|
||||
</Text>
|
||||
{right}
|
||||
</XStack>
|
||||
{children}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
/** A pulsing "live" indicator (steady dot under reduced motion). */
|
||||
export function LiveDot({ on }: { on: boolean }) {
|
||||
const reduced = useReducedMotion()
|
||||
if (!on) return null
|
||||
return (
|
||||
<XStack items="center" gap="$1.5">
|
||||
<YStack width={7} height={7} rounded="$10" style={{ backgroundColor: OK, animation: reduced ? undefined : 'hz-pulse 1.6s ease-in-out infinite' }} />
|
||||
<Text fontSize="$1" color="$color10">
|
||||
Live
|
||||
</Text>
|
||||
</XStack>
|
||||
)
|
||||
}
|
||||
|
||||
/** A shimmer skeleton block (honest "loading", not fabricated content). */
|
||||
export function SkeletonBar({ w, h, rounded }: { w: number | string; h: number; rounded?: boolean }) {
|
||||
return <div className="hz-skeleton" style={{ width: w, height: h, borderRadius: rounded ? '50%' : 8 }} aria-hidden />
|
||||
}
|
||||
|
||||
/** An honest empty-state note inside a panel. */
|
||||
export function EmptyPanel({ note }: { note: string }) {
|
||||
return (
|
||||
<YStack p="$5" items="center" gap="$1">
|
||||
<Text fontSize="$3" color="$color11">
|
||||
{note}
|
||||
</Text>
|
||||
</YStack>
|
||||
)
|
||||
}
|
||||
|
||||
/** A loading spinner centered in a panel (used by the driver's first paint). */
|
||||
export function PanelSpinner() {
|
||||
return (
|
||||
<XStack p="$6" justify="center">
|
||||
<Spinner size="large" color="$color11" />
|
||||
</XStack>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Overview tiles — the config-tile adapters that select a slice out of the
|
||||
* normalized `OverviewData` and render the matching primitive. Thin by design:
|
||||
* the presentation lives in the composable primitives (`Kpi`/`Feed`/`Board`) and
|
||||
* the charts (`Line`/`Columns`/`Donut`); these adapters only pick the slice and
|
||||
* hand it over. `Tile` routes a declared tile spec to its adapter (exhaustive on
|
||||
* the `tile` discriminant), which is what the `Overview` driver renders per row.
|
||||
*/
|
||||
import { Text, XStack, YStack } from '@hanzo/gui'
|
||||
|
||||
import { CHART_OTHER, CHART_PALETTE, Columns, Donut, Line, type ChartPoint, type Slice } from '../charts/Charts'
|
||||
import { Board, EmptyPanel, Feed, Kpi, Panel, SkeletonBar } from './primitives'
|
||||
import {
|
||||
deltaOf,
|
||||
distributionTotal,
|
||||
formatMetric,
|
||||
selectDistribution,
|
||||
selectKpi,
|
||||
selectSeries,
|
||||
severityColor,
|
||||
OK,
|
||||
} from './logic'
|
||||
import type {
|
||||
ActivityTile,
|
||||
AlertsTile,
|
||||
DistributionTile,
|
||||
HealthTile,
|
||||
MetricTile,
|
||||
OverviewData,
|
||||
OverviewTile,
|
||||
TimeseriesTile,
|
||||
} from './config'
|
||||
|
||||
/** Format an axis label for a series interval. */
|
||||
const fmtAxis = (iso: string, interval: string): string => {
|
||||
const d = new Date(iso)
|
||||
if (Number.isNaN(d.getTime())) return iso
|
||||
return interval === 'day'
|
||||
? d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })
|
||||
: d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })
|
||||
}
|
||||
|
||||
// ── Metric tile → Kpi ────────────────────────────────────────────────────────
|
||||
|
||||
export function MetricTileView({ tile, data, loading, live, index }: { tile: MetricTile; data: OverviewData | null; loading: boolean; live: boolean; index: number }) {
|
||||
const kpi = data ? selectKpi(data, tile.key) : undefined
|
||||
const delta = deltaOf(kpi)
|
||||
return (
|
||||
<Kpi
|
||||
label={tile.label}
|
||||
icon={tile.icon}
|
||||
unit={tile.unit}
|
||||
caption={tile.caption}
|
||||
color={tile.color ?? CHART_PALETTE[index % CHART_PALETTE.length]}
|
||||
value={kpi === undefined ? null : kpi.value}
|
||||
deltaPct={delta ? delta.pct : null}
|
||||
series={kpi?.series}
|
||||
animate={live}
|
||||
loading={loading && kpi === undefined}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Timeseries tile → Line / Columns ─────────────────────────────────────────
|
||||
|
||||
export function TimeseriesTileView({ tile, data, loading }: { tile: TimeseriesTile; data: OverviewData | null; loading: boolean }) {
|
||||
const series = data ? selectSeries(data, tile.key) : undefined
|
||||
const points: ChartPoint[] = (series?.points ?? []).map((p) => ({ label: fmtAxis(p.t, series?.interval ?? 'day'), value: p.value }))
|
||||
const fmt = (v: number) => formatMetric(v, tile.unit)
|
||||
const total = points.reduce((s, p) => s + p.value, 0)
|
||||
|
||||
return (
|
||||
<Panel title={tile.title} flex={1} right={points.length ? <Text fontSize="$2" color="$color10" className="hz-tnum">{fmt(total)}</Text> : undefined}>
|
||||
{loading && series === undefined ? (
|
||||
<SkeletonBar w="100%" h={200} />
|
||||
) : points.length < 2 ? (
|
||||
<EmptyPanel note="Not enough data in this range yet." />
|
||||
) : tile.kind === 'bar' ? (
|
||||
<Columns data={points} color={CHART_PALETTE[1]} formatValue={fmt} />
|
||||
) : (
|
||||
<Line data={points} color={CHART_PALETTE[0]} formatValue={fmt} />
|
||||
)}
|
||||
</Panel>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Distribution tile → Donut + legend ───────────────────────────────────────
|
||||
|
||||
export function DistributionTileView({ tile, data, loading }: { tile: DistributionTile; data: OverviewData | null; loading: boolean }) {
|
||||
const raw = data ? selectDistribution(data, tile.key) : []
|
||||
const total = distributionTotal(raw)
|
||||
const slices: Slice[] = raw
|
||||
.filter((s) => s.value > 0)
|
||||
.map((s, i) => ({ label: s.label, value: s.value, color: i < CHART_PALETTE.length ? CHART_PALETTE[i] : CHART_OTHER }))
|
||||
const fmt = (v: number) => formatMetric(v, tile.unit)
|
||||
|
||||
return (
|
||||
<Panel title={tile.title} flex={1} minW={340}>
|
||||
{loading && raw.length === 0 ? (
|
||||
<XStack p="$4" justify="center">
|
||||
<SkeletonBar w={160} h={160} rounded />
|
||||
</XStack>
|
||||
) : total <= 0 ? (
|
||||
<EmptyPanel note="No breakdown recorded in this range." />
|
||||
) : (
|
||||
<XStack flexWrap="wrap" gap="$4" items="center">
|
||||
<Donut
|
||||
slices={slices}
|
||||
center={
|
||||
<>
|
||||
<Text fontSize="$5" fontWeight="500" color="$color12" className="hz-tnum">
|
||||
{fmt(total)}
|
||||
</Text>
|
||||
{tile.centerLabel ? (
|
||||
<Text fontSize="$1" color="$color10">
|
||||
{tile.centerLabel}
|
||||
</Text>
|
||||
) : null}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<YStack flex={1} minW={200} gap="$2">
|
||||
{raw.filter((s) => s.value > 0).map((s, i) => (
|
||||
<XStack key={s.label} items="center" gap="$2.5">
|
||||
<YStack width={10} height={10} rounded="$10" style={{ backgroundColor: i < CHART_PALETTE.length ? CHART_PALETTE[i] : CHART_OTHER }} />
|
||||
<YStack flex={1}>
|
||||
<Text fontSize="$3" fontWeight="600" color="$color12" numberOfLines={1}>
|
||||
{s.label}
|
||||
</Text>
|
||||
{s.sub ? (
|
||||
<Text fontSize="$1" color="$color10" numberOfLines={1}>
|
||||
{s.sub}
|
||||
</Text>
|
||||
) : null}
|
||||
</YStack>
|
||||
<YStack items="flex-end">
|
||||
<Text fontSize="$3" fontWeight="500" color="$color12" className="hz-tnum">
|
||||
{fmt(s.value)}
|
||||
</Text>
|
||||
<Text fontSize="$1" color="$color10" className="hz-tnum">
|
||||
{Math.round((s.value / total) * 100)}%
|
||||
</Text>
|
||||
</YStack>
|
||||
</XStack>
|
||||
))}
|
||||
</YStack>
|
||||
</XStack>
|
||||
)}
|
||||
</Panel>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Activity tile → Feed ─────────────────────────────────────────────────────
|
||||
|
||||
export function ActivityTileView({ tile, data, loading }: { tile: ActivityTile; data: OverviewData | null; loading: boolean }) {
|
||||
return <Feed items={data?.activity ?? []} title={tile.title} empty={tile.empty} loading={loading} />
|
||||
}
|
||||
|
||||
// ── Alerts tile ──────────────────────────────────────────────────────────────
|
||||
|
||||
export function AlertsTileView({ tile, data, loading }: { tile: AlertsTile; data: OverviewData | null; loading: boolean }) {
|
||||
const alerts = data?.alerts ?? []
|
||||
return (
|
||||
<Panel title={tile.title ?? 'Alerts'}>
|
||||
{loading && alerts.length === 0 ? (
|
||||
<SkeletonBar w="100%" h={44} />
|
||||
) : alerts.length === 0 ? (
|
||||
<XStack items="center" gap="$2">
|
||||
<YStack width={9} height={9} rounded="$10" style={{ backgroundColor: OK }} />
|
||||
<Text fontSize="$3" color="$color11">
|
||||
No active alerts.
|
||||
</Text>
|
||||
</XStack>
|
||||
) : (
|
||||
<YStack gap="$2.5">
|
||||
{alerts.map((a) => (
|
||||
<XStack key={a.id || a.title} items="flex-start" gap="$2.5">
|
||||
<YStack width={9} height={9} rounded="$10" mt="$1.5" style={{ backgroundColor: severityColor(a.severity) }} />
|
||||
<YStack flex={1}>
|
||||
<Text fontSize="$3" fontWeight="600" color="$color12">
|
||||
{a.title}
|
||||
</Text>
|
||||
{a.detail ? (
|
||||
<Text fontSize="$1" color="$color10">
|
||||
{a.detail}
|
||||
</Text>
|
||||
) : null}
|
||||
</YStack>
|
||||
</XStack>
|
||||
))}
|
||||
</YStack>
|
||||
)}
|
||||
</Panel>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Health tile → Board ──────────────────────────────────────────────────────
|
||||
|
||||
export function HealthTileView({ tile, data, loading }: { tile: HealthTile; data: OverviewData | null; loading: boolean }) {
|
||||
return <Board items={data?.health ?? []} title={tile.title} empty={tile.empty} loading={loading} />
|
||||
}
|
||||
|
||||
// ── Tile router ──────────────────────────────────────────────────────────────
|
||||
|
||||
/** Render one declared tile spec against the normalized `OverviewData`. */
|
||||
export function Tile({ tile, data, loading, live, index }: { tile: OverviewTile; data: OverviewData | null; loading: boolean; live: boolean; index: number }) {
|
||||
switch (tile.tile) {
|
||||
case 'metric':
|
||||
return <MetricTileView tile={tile} data={data} loading={loading} live={live} index={index} />
|
||||
case 'timeseries':
|
||||
return <TimeseriesTileView tile={tile} data={data} loading={loading} />
|
||||
case 'distribution':
|
||||
return <DistributionTileView tile={tile} data={data} loading={loading} />
|
||||
case 'activity':
|
||||
return <ActivityTileView tile={tile} data={data} loading={loading} />
|
||||
case 'alerts':
|
||||
return <AlertsTileView tile={tile} data={data} loading={loading} />
|
||||
case 'health':
|
||||
return <HealthTileView tile={tile} data={data} loading={loading} />
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Pipeline — the animated deploy-stage line. A horizontal track with N stages:
|
||||
* completed stages fill in with a check, the current stage pulses, the active leg
|
||||
* shows a flowing gradient, and a failure turns the reached stage red. Purely
|
||||
* presentational — driven by a `stages` prop (each `{ name, state }`); the consumer
|
||||
* derives those from a real status string with the pure `pipelineModel` and polls
|
||||
* to pass fresh stages. Reduced motion → a static, filled line.
|
||||
*
|
||||
* All geometry derives from the stage states, so it renders whatever pipeline the
|
||||
* caller supplies (the default four-stage deploy line, or a custom set).
|
||||
*/
|
||||
import { useContainerWidth } from '../charts/Charts'
|
||||
import { useReducedMotion } from '../motion/hooks'
|
||||
import type { PipelineStage, StageState } from './pipeline'
|
||||
import { Text, XStack, YStack } from '@hanzo/gui'
|
||||
|
||||
const ACCENT = '#7c5cff'
|
||||
const GREEN = '#23c562'
|
||||
const RED = '#ff5d8f'
|
||||
const MUTED = 'rgba(148,163,184,0.28)'
|
||||
|
||||
export interface PipelineProps {
|
||||
/** The stages to render, in order (derive via `pipelineModel(status).stages`). */
|
||||
stages: PipelineStage[]
|
||||
/** A short status label shown above the track (e.g. `pipelineModel(...).label`). */
|
||||
label?: string
|
||||
/** Accent tone for the filled track + active stage. Defaults by state (green live / red error / purple). */
|
||||
tone?: string
|
||||
/** Animate the active leg + pulse. Default true; reduced motion still forces static. */
|
||||
animate?: boolean
|
||||
}
|
||||
|
||||
export function Pipeline({ stages, label, tone, animate = true }: PipelineProps) {
|
||||
const reduced = useReducedMotion()
|
||||
|
||||
const n = stages.length
|
||||
const errorIdx = stages.findIndex((s) => s.state === 'error')
|
||||
const activeStateIdx = stages.findIndex((s) => s.state === 'active')
|
||||
const doneCount = stages.filter((s) => s.state === 'done').length
|
||||
const errored = errorIdx >= 0
|
||||
const live = n > 0 && doneCount === n && !errored
|
||||
const activeIndex = errored ? errorIdx : activeStateIdx >= 0 ? activeStateIdx : live ? n - 1 : -1
|
||||
const inProgress = activeStateIdx >= 0 && !errored && !live
|
||||
|
||||
const accent = tone ?? (errored ? RED : live ? GREEN : ACCENT)
|
||||
|
||||
return (
|
||||
<YStack gap="$3" aria-label={label ? `Deployment: ${label}` : 'Deployment pipeline'}>
|
||||
{label ? (
|
||||
<XStack items="center" gap="$2">
|
||||
<YStack width={9} height={9} rounded="$10" style={{ backgroundColor: accent }} className={animate && !reduced && inProgress ? 'hz-pipe-dot' : undefined} />
|
||||
<Text fontSize="$3" fontWeight="700" color="$color12">
|
||||
{label}
|
||||
</Text>
|
||||
</XStack>
|
||||
) : null}
|
||||
<Track stages={stages} activeIndex={activeIndex} inProgress={inProgress} reduced={reduced || !animate} tone={accent} />
|
||||
</YStack>
|
||||
)
|
||||
}
|
||||
|
||||
// ── The SVG track + aligned labels ───────────────────────────────────────────
|
||||
|
||||
function Track({ stages, activeIndex, inProgress, reduced, tone }: { stages: PipelineStage[]; activeIndex: number; inProgress: boolean; reduced: boolean; tone: string }) {
|
||||
const { ref, w } = useContainerWidth()
|
||||
const n = stages.length
|
||||
const H = 60
|
||||
const padX = 24
|
||||
const trackY = 28
|
||||
const usable = Math.max(1, w - padX * 2)
|
||||
const x = (i: number) => padX + (n <= 1 ? 0 : (i / (n - 1)) * usable)
|
||||
|
||||
const fillTo = activeIndex < 0 ? x(0) : x(activeIndex)
|
||||
const flowing = !reduced && inProgress && activeIndex >= 0 && activeIndex < n - 1
|
||||
const flowStart = x(Math.max(0, activeIndex))
|
||||
const flowEnd = x(Math.min(activeIndex + 1, n - 1))
|
||||
|
||||
return (
|
||||
<div ref={ref} style={{ width: '100%' }}>
|
||||
{w > 0 ? (
|
||||
<>
|
||||
<svg width={w} height={H} style={{ display: 'block', overflow: 'visible' }} role="presentation">
|
||||
<defs>
|
||||
<linearGradient id="hz-pipe-grad" x1="0" y1="0" x2="1" y2="0">
|
||||
<stop offset="0%" stopColor={tone} stopOpacity="0.15" />
|
||||
<stop offset="50%" stopColor={tone} stopOpacity="1" />
|
||||
<stop offset="100%" stopColor={tone} stopOpacity="0.15" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
{/* base track */}
|
||||
<line x1={x(0)} y1={trackY} x2={x(n - 1)} y2={trackY} stroke={MUTED} strokeWidth={4} strokeLinecap="round" />
|
||||
|
||||
{/* filled (completed) track */}
|
||||
{fillTo > x(0) ? <line x1={x(0)} y1={trackY} x2={fillTo} y2={trackY} stroke={tone} strokeWidth={4} strokeLinecap="round" /> : null}
|
||||
|
||||
{/* flowing gradient on the active leg */}
|
||||
{flowing ? (
|
||||
<line x1={flowStart} y1={trackY} x2={flowEnd} y2={trackY} stroke="url(#hz-pipe-grad)" strokeWidth={4} strokeLinecap="round" className="hz-pipe-flow" />
|
||||
) : null}
|
||||
|
||||
{/* stages */}
|
||||
{stages.map((st, i) => (
|
||||
<Stage key={st.name} cx={x(i)} cy={trackY} state={st.state} tone={tone} reduced={reduced} />
|
||||
))}
|
||||
</svg>
|
||||
|
||||
{/* labels aligned under each stage */}
|
||||
<div style={{ position: 'relative', width: w, height: 30, marginTop: 2 }}>
|
||||
{stages.map((st, i) => (
|
||||
<div
|
||||
key={st.name}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: x(i),
|
||||
transform: i === 0 ? 'translateX(0)' : i === n - 1 ? 'translateX(-100%)' : 'translateX(-50%)',
|
||||
textAlign: i === 0 ? 'left' : i === n - 1 ? 'right' : 'center',
|
||||
maxWidth: 110,
|
||||
}}
|
||||
>
|
||||
<StageLabel name={st.name} state={st.state} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div style={{ height: H + 30 }} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function StageLabel({ name, state }: { name: string; state: StageState }) {
|
||||
const sub = state === 'active' ? 'in progress' : state === 'error' ? 'failed' : state === 'done' ? 'done' : ''
|
||||
return (
|
||||
<YStack gap="$0.5">
|
||||
<Text fontSize="$1" fontWeight={state === 'active' || state === 'error' ? '800' : '600'} color={state === 'pending' ? '$color9' : '$color12'} numberOfLines={1}>
|
||||
{name}
|
||||
</Text>
|
||||
<Text fontSize="$1" style={state === 'error' ? { color: RED } : undefined} color={state === 'error' ? undefined : '$color10'}>
|
||||
{sub || ' '}
|
||||
</Text>
|
||||
</YStack>
|
||||
)
|
||||
}
|
||||
|
||||
function Stage({ cx, cy, state, tone, reduced }: { cx: number; cy: number; state: StageState; tone: string; reduced: boolean }) {
|
||||
const r = 11
|
||||
if (state === 'done') {
|
||||
return (
|
||||
<g>
|
||||
<circle cx={cx} cy={cy} r={r} fill={tone} />
|
||||
<path d={`M${cx - 4.5},${cy + 0.5} L${cx - 1.5},${cy + 3.5} L${cx + 4.5},${cy - 3.5}`} fill="none" stroke="#fff" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" />
|
||||
</g>
|
||||
)
|
||||
}
|
||||
if (state === 'error') {
|
||||
return (
|
||||
<g>
|
||||
<circle cx={cx} cy={cy} r={r} fill={RED} />
|
||||
<path d={`M${cx - 3.5},${cy - 3.5} L${cx + 3.5},${cy + 3.5} M${cx + 3.5},${cy - 3.5} L${cx - 3.5},${cy + 3.5}`} stroke="#fff" strokeWidth={2} strokeLinecap="round" />
|
||||
</g>
|
||||
)
|
||||
}
|
||||
if (state === 'active') {
|
||||
return (
|
||||
<g>
|
||||
{!reduced ? <circle cx={cx} cy={cy} r={r} fill={tone} className="hz-pipe-pulse" /> : null}
|
||||
<circle cx={cx} cy={cy} r={r} fill="var(--color2, #14161a)" stroke={tone} strokeWidth={3} />
|
||||
<circle cx={cx} cy={cy} r={4} fill={tone} />
|
||||
</g>
|
||||
)
|
||||
}
|
||||
// pending
|
||||
return <circle cx={cx} cy={cy} r={r} fill="var(--color2, #14161a)" stroke={MUTED} strokeWidth={2} />
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
/**
|
||||
* Deploy-stage pipeline domain logic — PURE, no UI, no I/O, unit-testable.
|
||||
*
|
||||
* A deploy moves through four stages: Queued → Building → Deploying → Live. This
|
||||
* maps a REAL app/deployment status string (building | deploying | live | error |
|
||||
* queued | …) onto that pipeline and decides, per stage, whether it is done /
|
||||
* active / pending / errored, plus an overall progress fraction and an honest
|
||||
* status label.
|
||||
*
|
||||
* Honesty: this never fabricates progress. The stage is derived from the real
|
||||
* status, and the caller passes the FURTHEST stage it has actually OBSERVED
|
||||
* (monotonic across polls) so an `error` marks the stage the deploy truly reached —
|
||||
* not a guess.
|
||||
*/
|
||||
|
||||
/** The four pipeline stages, in order. */
|
||||
export const PIPELINE_STAGES = ['Queued', 'Building', 'Deploying', 'Live'] as const
|
||||
export type PipelineStageName = (typeof PIPELINE_STAGES)[number]
|
||||
|
||||
/** Canonical pipeline phase, normalized from any backend status string. */
|
||||
export type PipelinePhase = 'idle' | 'queued' | 'building' | 'deploying' | 'live' | 'error'
|
||||
|
||||
/** Per-stage render state. */
|
||||
export type StageState = 'done' | 'active' | 'error' | 'pending'
|
||||
|
||||
/** One stage in the rendered pipeline. */
|
||||
export type PipelineStage = { name: string; state: StageState }
|
||||
|
||||
/** The full derived model a `Pipeline` renders from. */
|
||||
export type PipelineModel = {
|
||||
phase: PipelinePhase
|
||||
/** Index (0..3) of the current stage; -1 when idle (nothing deploying). */
|
||||
activeIndex: number
|
||||
/** Reached the final stage. */
|
||||
live: boolean
|
||||
/** The deploy failed. */
|
||||
errored: boolean
|
||||
/** True while actively progressing (has a stage to animate toward). */
|
||||
inProgress: boolean
|
||||
stages: PipelineStage[]
|
||||
/** 0..1 fraction of the line filled (static progress / aria-valuenow). */
|
||||
progress: number
|
||||
/** A short, honest status label ("Building…", "Live", "Failed while deploying"). */
|
||||
label: string
|
||||
}
|
||||
|
||||
const N = PIPELINE_STAGES.length
|
||||
|
||||
const QUEUED = new Set(['queued', 'queue', 'waiting', 'accepted', 'created', 'pending'])
|
||||
const BUILDING = new Set(['building', 'build', 'in_progress', 'inprogress', 'compiling'])
|
||||
const DEPLOYING = new Set(['deploying', 'deploy', 'rollout', 'rolling', 'releasing', 'provisioning'])
|
||||
const LIVE = new Set(['live', 'ready', 'running', 'available', 'succeeded', 'success', 'active', 'healthy', 'superseded'])
|
||||
const ERROR = new Set(['error', 'errored', 'failed', 'failure', 'crashed', 'canceled', 'cancelled', 'timeout'])
|
||||
const IDLE = new Set(['', 'draft', 'stopped', 'paused', 'idle', 'unknown', 'none', 'new'])
|
||||
|
||||
/** Normalize any app/deployment status string to a canonical pipeline phase. */
|
||||
export function pipelinePhase(status?: string | null): PipelinePhase {
|
||||
const s = (status ?? '').trim().toLowerCase()
|
||||
if (ERROR.has(s)) return 'error'
|
||||
if (LIVE.has(s)) return 'live'
|
||||
if (DEPLOYING.has(s)) return 'deploying'
|
||||
if (BUILDING.has(s)) return 'building'
|
||||
if (QUEUED.has(s)) return 'queued'
|
||||
if (IDLE.has(s)) return 'idle'
|
||||
return 'idle'
|
||||
}
|
||||
|
||||
/** The stage index (0..3) a phase sits at; -1 for idle/error (error uses `furthest`). */
|
||||
export function stageIndex(phase: PipelinePhase): number {
|
||||
switch (phase) {
|
||||
case 'queued':
|
||||
return 0
|
||||
case 'building':
|
||||
return 1
|
||||
case 'deploying':
|
||||
return 2
|
||||
case 'live':
|
||||
return N - 1
|
||||
default:
|
||||
return -1
|
||||
}
|
||||
}
|
||||
|
||||
/** Terminal phases (live or error) — the poller stops here. */
|
||||
export function isTerminalPhase(phase: PipelinePhase): boolean {
|
||||
return phase === 'live' || phase === 'error'
|
||||
}
|
||||
|
||||
/** A short, honest status label for the pipeline. */
|
||||
function pipelineLabel(phase: PipelinePhase, activeIndex: number, status?: string | null): string {
|
||||
switch (phase) {
|
||||
case 'live':
|
||||
return 'Live'
|
||||
case 'queued':
|
||||
return 'Queued'
|
||||
case 'building':
|
||||
return 'Building…'
|
||||
case 'deploying':
|
||||
return 'Deploying…'
|
||||
case 'error': {
|
||||
if (activeIndex <= 0) return 'Failed in queue'
|
||||
const at = PIPELINE_STAGES[Math.min(activeIndex, N - 1)].toLowerCase()
|
||||
return `Failed while ${at}`
|
||||
}
|
||||
default: {
|
||||
const s = (status ?? '').trim()
|
||||
if (!s || s.toLowerCase() === 'draft' || s.toLowerCase() === 'new') return 'Not deployed'
|
||||
return s.charAt(0).toUpperCase() + s.slice(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the pipeline model from a status string and the FURTHEST stage index
|
||||
* observed so far (monotonic across polls; default -1 = none observed). The furthest
|
||||
* index makes an `error` mark the stage the deploy actually reached, and keeps
|
||||
* completed stages filled even if a later poll returns a transient earlier status.
|
||||
*/
|
||||
export function pipelineModel(status?: string | null, furthestReached = -1): PipelineModel {
|
||||
const phase = pipelinePhase(status)
|
||||
const idx = stageIndex(phase)
|
||||
const reached = Math.max(furthestReached, idx)
|
||||
|
||||
const live = phase === 'live'
|
||||
const errored = phase === 'error'
|
||||
|
||||
let activeIndex: number
|
||||
if (live) activeIndex = N - 1
|
||||
else if (errored) activeIndex = reached >= 0 ? Math.min(reached, N - 1) : 0
|
||||
else if (phase === 'idle') activeIndex = -1 // nothing deploying — a neutral pipeline
|
||||
else activeIndex = Math.min(reached, N - 1) // active: monotonic (never backslides)
|
||||
|
||||
const stages: PipelineStage[] = PIPELINE_STAGES.map((name, i): PipelineStage => {
|
||||
if (live) return { name, state: 'done' }
|
||||
if (errored) {
|
||||
if (i < activeIndex) return { name, state: 'done' }
|
||||
if (i === activeIndex) return { name, state: 'error' }
|
||||
return { name, state: 'pending' }
|
||||
}
|
||||
if (activeIndex < 0) return { name, state: 'pending' } // idle: nothing started
|
||||
if (i < activeIndex) return { name, state: 'done' }
|
||||
if (i === activeIndex) return { name, state: 'active' }
|
||||
return { name, state: 'pending' }
|
||||
})
|
||||
|
||||
const progress = live ? 1 : activeIndex < 0 ? 0 : activeIndex / (N - 1)
|
||||
const inProgress = !live && !errored && activeIndex >= 0
|
||||
const label = pipelineLabel(phase, activeIndex, status)
|
||||
|
||||
return { phase, activeIndex, live, errored, inProgress, stages, progress, label }
|
||||
}
|
||||
|
||||
/** The accent tone for a model: red on failure, green on live, else the accent. */
|
||||
export function pipelineTone(model: Pick<PipelineModel, 'live' | 'errored'>, accent = '#7c5cff'): string {
|
||||
return model.errored ? '#ff5d8f' : model.live ? '#23c562' : accent
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Shared cross-module types for @hanzo/dashboard.
|
||||
*/
|
||||
import type { ComponentType } from 'react'
|
||||
|
||||
/**
|
||||
* A lucide-style icon component — the shape the dashboard passes around for
|
||||
* consumer-supplied product/tile/action icons. Structural (not tied to a specific
|
||||
* icon package) so the config types stay pure and any `size`/`color` icon fits
|
||||
* (e.g. `@hanzogui/lucide-icons-2` or `lucide-react` icons).
|
||||
*/
|
||||
export type IconComponent = ComponentType<{ size?: number | string; color?: string; opacity?: number }>
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"],
|
||||
"react-native": ["react-native-web"]
|
||||
}
|
||||
},
|
||||
"include": ["src", "gui.config.ts", "gui.d.ts"]
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
// Component exports
|
||||
// This file provides a central import point for all UI components
|
||||
// Usage: import { Button, Card, cn } from '@hanzo/ui/components'
|
||||
// Usage: import { Button, Card, cn } from '@hanzo/ui-shadcn/components'
|
||||
|
||||
// Export commonly used utilities
|
||||
export { cn, formatDate, absoluteUrl } from '../src/utils'
|
||||
|
||||
+12
-2
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@hanzo/ui-shadcn",
|
||||
"version": "5.7.0",
|
||||
"description": "Multi-framework UI library with React, Vue, Svelte, and React Native support. Based on shadcn/ui with comprehensive framework coverage. (Formerly @hanzo/ui ≤5.x; @hanzo/ui@8+ is the @hanzo/gui-based unified lib.)",
|
||||
"version": "5.7.4",
|
||||
"description": "Multi-framework UI library with React, Vue, Svelte, and React Native support. Based on shadcn/ui with comprehensive framework coverage. (Formerly @hanzo/ui \u22645.x; @hanzo/ui@8+ is the @hanzo/gui-based unified lib.)",
|
||||
"publishConfig": {
|
||||
"registry": "https://registry.npmjs.org/",
|
||||
"access": "public",
|
||||
@@ -527,6 +527,16 @@
|
||||
"import": "./dist/auth/*.mjs",
|
||||
"require": "./dist/auth/*.js"
|
||||
},
|
||||
"./network": {
|
||||
"types": "./dist/src/network/index.d.ts",
|
||||
"import": "./dist/network/index.mjs",
|
||||
"require": "./dist/network/index.js"
|
||||
},
|
||||
"./wallet": {
|
||||
"types": "./dist/src/wallet/index.d.ts",
|
||||
"import": "./dist/wallet/index.mjs",
|
||||
"require": "./dist/wallet/index.js"
|
||||
},
|
||||
"./navigation": {
|
||||
"types": "./dist/src/navigation/index.d.ts",
|
||||
"import": "./dist/navigation/index.mjs",
|
||||
|
||||
@@ -8,5 +8,5 @@ export * from './radial'
|
||||
export * from './tooltip'
|
||||
|
||||
// Note: Chart primitives (ChartContainer, ChartConfig, etc.) should be imported directly:
|
||||
// import { ChartContainer } from '@hanzo/ui/primitives/chart'
|
||||
// import { ChartContainer } from '@hanzo/ui-shadcn/primitives/chart'
|
||||
// Not from charts/index to avoid circular dependencies
|
||||
|
||||
@@ -26,8 +26,8 @@ export { cn } from '../src/utils'
|
||||
*
|
||||
* For more components, import them individually:
|
||||
*
|
||||
* import { Dialog } from '@hanzo/ui/dialog'
|
||||
* import { Select } from '@hanzo/ui/select'
|
||||
* import { Badge } from '@hanzo/ui/badge'
|
||||
* import { Dialog } from '@hanzo/ui-shadcn/dialog'
|
||||
* import { Select } from '@hanzo/ui-shadcn/select'
|
||||
* import { Badge } from '@hanzo/ui-shadcn/badge'
|
||||
* etc.
|
||||
*/
|
||||
@@ -231,9 +231,9 @@ export type { InputProps } from './input'
|
||||
* These are NOT exported here to avoid loading their dependencies
|
||||
* Users should import them specifically when needed:
|
||||
*
|
||||
* import { Calendar } from '@hanzo/ui/calendar'
|
||||
* import { Command } from '@hanzo/ui/command'
|
||||
* import { Carousel } from '@hanzo/ui/carousel'
|
||||
* import { Form } from '@hanzo/ui/form'
|
||||
* import { Calendar } from '@hanzo/ui-shadcn/calendar'
|
||||
* import { Command } from '@hanzo/ui-shadcn/command'
|
||||
* import { Carousel } from '@hanzo/ui-shadcn/carousel'
|
||||
* import { Form } from '@hanzo/ui-shadcn/form'
|
||||
* etc.
|
||||
*/
|
||||
@@ -176,7 +176,7 @@ export {
|
||||
FieldSeparator,
|
||||
} from './field'
|
||||
|
||||
// Form components available via '@hanzo/ui/form' - NOT bundled here due to
|
||||
// Form components available via '@hanzo/ui-shadcn/form' - NOT bundled here due to
|
||||
// react-hook-form ESM/CJS interop issues with Next.js 15 when bundled
|
||||
|
||||
export {
|
||||
@@ -335,7 +335,7 @@ export { Spinner } from './spinner'
|
||||
export { default as StepIndicator } from './step-indicator'
|
||||
export { default as Switch } from './switch'
|
||||
export { Textarea } from './textarea'
|
||||
// TextField available via @hanzo/ui/form - requires react-hook-form
|
||||
// TextField available via @hanzo/ui-shadcn/form - requires react-hook-form
|
||||
export { Toaster, toast } from './sonner'
|
||||
export { Toggle, toggleVariants } from './toggle'
|
||||
export { ToggleGroup, ToggleGroupItem } from './toggle-group'
|
||||
@@ -378,11 +378,11 @@ export { YouTubePipPlayer, youtubePipPlayerVariants } from './youtube-pip-player
|
||||
export * from '../assets'
|
||||
|
||||
// NOTE: Docs components are NOT re-exported here to avoid path alias resolution issues
|
||||
// during build. Import directly from @hanzo/ui/docs/* if needed:
|
||||
// - DocsLayout: @hanzo/ui/docs/layouts/docs
|
||||
// - HomeLayout: @hanzo/ui/docs/layouts/home
|
||||
// - NotebookLayout: @hanzo/ui/docs/layouts/notebook
|
||||
// - DocsPage, DocsBody, DocsTitle, DocsDescription: @hanzo/ui/docs/page
|
||||
// - defaultMdxComponents: @hanzo/ui/docs/mdx
|
||||
// - RootProvider: @hanzo/ui/docs/provider/next
|
||||
// - loader: @hanzo/ui/docs/source
|
||||
// during build. Import directly from @hanzo/ui-shadcn/docs/* if needed:
|
||||
// - DocsLayout: @hanzo/ui-shadcn/docs/layouts/docs
|
||||
// - HomeLayout: @hanzo/ui-shadcn/docs/layouts/home
|
||||
// - NotebookLayout: @hanzo/ui-shadcn/docs/layouts/notebook
|
||||
// - DocsPage, DocsBody, DocsTitle, DocsDescription: @hanzo/ui-shadcn/docs/page
|
||||
// - defaultMdxComponents: @hanzo/ui-shadcn/docs/mdx
|
||||
// - RootProvider: @hanzo/ui-shadcn/docs/provider/next
|
||||
// - loader: @hanzo/ui-shadcn/docs/source
|
||||
|
||||
@@ -21,7 +21,7 @@ const MediaStack: React.FC<{
|
||||
const transform = mediaTransform ?? {}
|
||||
|
||||
// Order of precedence: MP4 > Image
|
||||
// For 3D/Spline support, use @hanzo/ui/spline MediaStack instead
|
||||
// For 3D/Spline support, use @hanzo/ui-shadcn/spline MediaStack instead
|
||||
if (video) {
|
||||
const dim = constrain(video.dim.md, cnst)
|
||||
return (
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@hanzo/ui/lib/utils"
|
||||
import { cn } from "@hanzo/ui-shadcn/lib/utils"
|
||||
|
||||
const button3DVariants = cva(
|
||||
[
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@hanzo/ui/lib/utils"
|
||||
import { cn } from "@hanzo/ui-shadcn/lib/utils"
|
||||
|
||||
interface Point {
|
||||
x: number
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import * as React from "react"
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react"
|
||||
|
||||
import { cn } from "@hanzo/ui/lib/utils"
|
||||
import { cn } from "@hanzo/ui-shadcn/lib/utils"
|
||||
|
||||
interface Carousel3DItem {
|
||||
id: string
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@hanzo/ui/lib/utils"
|
||||
import { cn } from "@hanzo/ui-shadcn/lib/utils"
|
||||
|
||||
interface Grid3DItem {
|
||||
id: string
|
||||
|
||||
@@ -4,7 +4,7 @@ import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { motion } from "framer-motion"
|
||||
|
||||
import { cn } from "@hanzo/ui/lib/utils"
|
||||
import { cn } from "@hanzo/ui-shadcn/lib/utils"
|
||||
|
||||
const marquee3DVariants = cva(
|
||||
[
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import * as React from "react"
|
||||
import { motion } from "framer-motion"
|
||||
|
||||
import { cn } from "@hanzo/ui/lib/utils"
|
||||
import { cn } from "@hanzo/ui-shadcn/lib/utils"
|
||||
|
||||
interface Pin3DProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
title?: string
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
} from "framer-motion"
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react"
|
||||
|
||||
import { cn } from "@hanzo/ui/lib/utils"
|
||||
import { cn } from "@hanzo/ui-shadcn/lib/utils"
|
||||
import { Button } from "@hanzo/ui"
|
||||
import { Card } from "@hanzo/ui"
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import * as React from "react"
|
||||
import { motion } from "framer-motion"
|
||||
|
||||
import { cn } from "@hanzo/ui/lib/utils"
|
||||
import { cn } from "@hanzo/ui-shadcn/lib/utils"
|
||||
|
||||
interface AppleHelloEffectProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
text?: string
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import * as React from "react"
|
||||
import { motion } from "motion/react"
|
||||
|
||||
import { cn } from "@hanzo/ui/lib/utils"
|
||||
import { cn } from "@hanzo/ui-shadcn/lib/utils"
|
||||
|
||||
interface AnimatedBeamProps extends React.SVGAttributes<SVGSVGElement> {
|
||||
duration?: number
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import React, { useCallback, useEffect, useRef, useState } from "react"
|
||||
|
||||
import { cn } from "@hanzo/ui/lib/utils"
|
||||
import { cn } from "@hanzo/ui-shadcn/lib/utils"
|
||||
|
||||
export interface AnimatedCursorProps {
|
||||
/**
|
||||
|
||||
@@ -3,12 +3,12 @@
|
||||
import * as React from "react"
|
||||
import { AnimatePresence, motion } from "motion/react"
|
||||
|
||||
import { cn } from "@hanzo/ui/lib/utils"
|
||||
import { cn } from "@hanzo/ui-shadcn/lib/utils"
|
||||
import {
|
||||
Avatar,
|
||||
AvatarFallback,
|
||||
AvatarImage,
|
||||
} from "@hanzo/ui/primitives/avatar"
|
||||
} from "@hanzo/ui-shadcn/primitives/avatar"
|
||||
import { Card, CardContent } from "@hanzo/ui"
|
||||
|
||||
interface Testimonial {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import * as React from "react"
|
||||
import { AnimatePresence, motion } from "framer-motion"
|
||||
|
||||
import { cn } from "@hanzo/ui/lib/utils"
|
||||
import { cn } from "@hanzo/ui-shadcn/lib/utils"
|
||||
|
||||
interface AnimatedTooltipProps
|
||||
extends Omit<React.HTMLAttributes<HTMLDivElement>, "content"> {
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* @hanzo/ui/billing
|
||||
* @hanzo/ui-shadcn/billing
|
||||
* Billing, subscription, payment, and invoice management components
|
||||
*/
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// @hanzo/ui/calendar-ext - Extended calendar components
|
||||
// @hanzo/ui-shadcn/calendar-ext - Extended calendar components
|
||||
// Requires: npm install react-day-picker date-fns chrono-node
|
||||
|
||||
// Re-export base calendar
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// @hanzo/ui/charts - Chart components powered by Recharts
|
||||
// @hanzo/ui-shadcn/charts - Chart components powered by Recharts
|
||||
// Requires: npm install recharts
|
||||
|
||||
// Core chart primitives
|
||||
|
||||
@@ -5,10 +5,10 @@ import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { Check, Copy, FileText } from "lucide-react"
|
||||
import { codeToHtml } from "shiki"
|
||||
|
||||
import { cn } from "@hanzo/ui/lib/utils"
|
||||
import { Badge } from "@hanzo/ui/badge"
|
||||
import { Button } from "@hanzo/ui/button"
|
||||
import { Skeleton } from "@hanzo/ui/skeleton"
|
||||
import { cn } from "@hanzo/ui-shadcn/lib/utils"
|
||||
import { Badge } from "@hanzo/ui-shadcn/badge"
|
||||
import { Button } from "@hanzo/ui-shadcn/button"
|
||||
import { Skeleton } from "@hanzo/ui-shadcn/skeleton"
|
||||
|
||||
const codeBlockVariants = cva(
|
||||
"relative overflow-hidden rounded-lg border bg-muted/50",
|
||||
|
||||
@@ -14,25 +14,25 @@ import {
|
||||
SplitSquareHorizontal,
|
||||
} from "lucide-react"
|
||||
|
||||
import { cn } from "@hanzo/ui/lib/utils"
|
||||
import { Badge } from "@hanzo/ui/badge"
|
||||
import { Button } from "@hanzo/ui/button"
|
||||
import { CodeDiff } from "@hanzo/ui/code/diff"
|
||||
import { CodeSnippet } from "@hanzo/ui/code/snippet"
|
||||
import { cn } from "@hanzo/ui-shadcn/lib/utils"
|
||||
import { Badge } from "@hanzo/ui-shadcn/badge"
|
||||
import { Button } from "@hanzo/ui-shadcn/button"
|
||||
import { CodeDiff } from "@hanzo/ui-shadcn/code/diff"
|
||||
import { CodeSnippet } from "@hanzo/ui-shadcn/code/snippet"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@hanzo/ui/dropdown-menu"
|
||||
import { ScrollArea } from "@hanzo/ui/scroll-area"
|
||||
import { Separator } from "@hanzo/ui/separator"
|
||||
} from "@hanzo/ui-shadcn/dropdown-menu"
|
||||
import { ScrollArea } from "@hanzo/ui-shadcn/scroll-area"
|
||||
import { Separator } from "@hanzo/ui-shadcn/separator"
|
||||
import {
|
||||
Tabs,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
} from "@hanzo/ui/tabs"
|
||||
} from "@hanzo/ui-shadcn/tabs"
|
||||
|
||||
const codeCompareVariants = cva(
|
||||
"relative flex flex-col overflow-hidden rounded-lg border bg-background",
|
||||
|
||||
@@ -13,15 +13,15 @@ import {
|
||||
} from "lucide-react"
|
||||
import { codeToHtml } from "shiki"
|
||||
|
||||
import { cn } from "@hanzo/ui/lib/utils"
|
||||
import { Badge } from "@hanzo/ui/badge"
|
||||
import { Button } from "@hanzo/ui/button"
|
||||
import { Separator } from "@hanzo/ui/separator"
|
||||
import { Skeleton } from "@hanzo/ui/skeleton"
|
||||
import { cn } from "@hanzo/ui-shadcn/lib/utils"
|
||||
import { Badge } from "@hanzo/ui-shadcn/badge"
|
||||
import { Button } from "@hanzo/ui-shadcn/button"
|
||||
import { Separator } from "@hanzo/ui-shadcn/separator"
|
||||
import { Skeleton } from "@hanzo/ui-shadcn/skeleton"
|
||||
import {
|
||||
ToggleGroup,
|
||||
ToggleGroupItem,
|
||||
} from "@hanzo/ui/toggle-group"
|
||||
} from "@hanzo/ui-shadcn/toggle-group"
|
||||
|
||||
const codeDiffVariants = cva(
|
||||
"relative overflow-hidden rounded-lg border bg-muted/50",
|
||||
|
||||
@@ -5,14 +5,14 @@ import Editor, { OnChange, OnMount } from "@monaco-editor/react"
|
||||
import { Check, ChevronDown, Copy } from "lucide-react"
|
||||
import { useTheme } from "next-themes"
|
||||
|
||||
import { cn } from "@hanzo/ui/lib/utils"
|
||||
import { Button } from "@hanzo/ui/button"
|
||||
import { cn } from "@hanzo/ui-shadcn/lib/utils"
|
||||
import { Button } from "@hanzo/ui-shadcn/button"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@hanzo/ui/dropdown-menu"
|
||||
} from "@hanzo/ui-shadcn/dropdown-menu"
|
||||
|
||||
export interface CodeEditorProps {
|
||||
value?: string
|
||||
|
||||
@@ -15,10 +15,10 @@ import {
|
||||
X,
|
||||
} from "lucide-react"
|
||||
|
||||
import { cn } from "@hanzo/ui/lib/utils"
|
||||
import { Button } from "@hanzo/ui/button"
|
||||
import { Input } from "@hanzo/ui/input"
|
||||
import { ScrollArea } from "@hanzo/ui/scroll-area"
|
||||
import { cn } from "@hanzo/ui-shadcn/lib/utils"
|
||||
import { Button } from "@hanzo/ui-shadcn/button"
|
||||
import { Input } from "@hanzo/ui-shadcn/input"
|
||||
import { ScrollArea } from "@hanzo/ui-shadcn/scroll-area"
|
||||
|
||||
const codeExplorerVariants = cva(
|
||||
"relative flex flex-col overflow-hidden rounded-lg border bg-background",
|
||||
|
||||
@@ -16,18 +16,18 @@ import {
|
||||
X,
|
||||
} from "lucide-react"
|
||||
|
||||
import { cn } from "@hanzo/ui/lib/utils"
|
||||
import { Badge } from "@hanzo/ui/badge"
|
||||
import { Button } from "@hanzo/ui/button"
|
||||
import { CodeSnippet } from "@hanzo/ui/code/snippet"
|
||||
import { Separator } from "@hanzo/ui/separator"
|
||||
import { cn } from "@hanzo/ui-shadcn/lib/utils"
|
||||
import { Badge } from "@hanzo/ui-shadcn/badge"
|
||||
import { Button } from "@hanzo/ui-shadcn/button"
|
||||
import { CodeSnippet } from "@hanzo/ui-shadcn/code/snippet"
|
||||
import { Separator } from "@hanzo/ui-shadcn/separator"
|
||||
import {
|
||||
Tabs,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
} from "@hanzo/ui/tabs"
|
||||
import { Toggle } from "@hanzo/ui/toggle"
|
||||
} from "@hanzo/ui-shadcn/tabs"
|
||||
import { Toggle } from "@hanzo/ui-shadcn/toggle"
|
||||
|
||||
const codePreviewVariants = cva(
|
||||
"relative overflow-hidden rounded-lg border bg-background",
|
||||
|
||||
@@ -5,10 +5,10 @@ import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { Check, Copy, FileText, Maximize2, Minimize2 } from "lucide-react"
|
||||
import { bundledLanguages, codeToHtml } from "shiki"
|
||||
|
||||
import { cn } from "@hanzo/ui/lib/utils"
|
||||
import { Badge } from "@hanzo/ui/badge"
|
||||
import { Button } from "@hanzo/ui/button"
|
||||
import { Skeleton } from "@hanzo/ui/skeleton"
|
||||
import { cn } from "@hanzo/ui-shadcn/lib/utils"
|
||||
import { Badge } from "@hanzo/ui-shadcn/badge"
|
||||
import { Button } from "@hanzo/ui-shadcn/button"
|
||||
import { Skeleton } from "@hanzo/ui-shadcn/skeleton"
|
||||
|
||||
const codeSnippetVariants = cva("relative group", {
|
||||
variants: {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@hanzo/ui/lib/utils"
|
||||
import { cn } from "@hanzo/ui-shadcn/lib/utils"
|
||||
|
||||
export interface CodeTabsProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
tabs?: Array<{
|
||||
|
||||
@@ -17,11 +17,11 @@ import {
|
||||
X,
|
||||
} from "lucide-react"
|
||||
|
||||
import { cn } from "@hanzo/ui/lib/utils"
|
||||
import { Button } from "@hanzo/ui/button"
|
||||
import { Input } from "@hanzo/ui/input"
|
||||
import { ScrollArea } from "@hanzo/ui/scroll-area"
|
||||
import { Separator } from "@hanzo/ui/separator"
|
||||
import { cn } from "@hanzo/ui-shadcn/lib/utils"
|
||||
import { Button } from "@hanzo/ui-shadcn/button"
|
||||
import { Input } from "@hanzo/ui-shadcn/input"
|
||||
import { ScrollArea } from "@hanzo/ui-shadcn/scroll-area"
|
||||
import { Separator } from "@hanzo/ui-shadcn/separator"
|
||||
|
||||
const codeTerminalVariants = cva(
|
||||
"relative flex flex-col overflow-hidden rounded-lg border",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* @hanzo/ui/dash
|
||||
* @hanzo/ui-shadcn/dash
|
||||
*
|
||||
* Shared dash UI foundation. Dark-themed layout, navigation,
|
||||
* data table, form builder, and CRUD components.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* @hanzo/ui/dash - Shared design tokens
|
||||
* @hanzo/ui-shadcn/dash - Shared design tokens
|
||||
*
|
||||
* Framework-agnostic CSS custom properties for dash UIs.
|
||||
* Works in React (Tailwind) and Svelte (SCSS) builds.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// @hanzo/ui/kanban - Kanban and drag-drop components
|
||||
// @hanzo/ui-shadcn/kanban - Kanban and drag-drop components
|
||||
// Requires: npm install @dnd-kit/core @dnd-kit/sortable @dnd-kit/modifiers @dnd-kit/utilities
|
||||
|
||||
export { default as KanbanBoard } from '../project/kanban'
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// @hanzo/ui/mermaid - Mermaid diagram rendering
|
||||
// @hanzo/ui-shadcn/mermaid - Mermaid diagram rendering
|
||||
// Requires: npm install mermaid
|
||||
|
||||
export { MermaidDiagram as Mermaid, MermaidDiagram } from '../../primitives/mermaid'
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* @hanzo/ui/models — Shared Zen model UI components
|
||||
* @hanzo/ui-shadcn/models — Shared Zen model UI components
|
||||
*
|
||||
* Components are data-agnostic: they accept any data compatible with
|
||||
* ZenModelLike / ModelFamilyLike types, making them reusable across
|
||||
@@ -8,7 +8,7 @@
|
||||
* Data source: @hanzo/zen-models (the canonical Zen model registry)
|
||||
*
|
||||
* Usage:
|
||||
* import { ModelCard, ModelLibrary, ModelTable, ZenEnso } from '@hanzo/ui/models'
|
||||
* import { ModelCard, ModelLibrary, ModelTable, ZenEnso } from '@hanzo/ui-shadcn/models'
|
||||
* import { allModels, families } from '@hanzo/zen-models'
|
||||
* <ModelLibrary allModels={allModels} families={families} />
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* <NetworkSwitcher/> — the ONE network selector for every Hanzo surface
|
||||
* (desktop, app, chat, team, console). Renders the selected environment
|
||||
* and a menu of configured networks + a custom-endpoint form, backed by
|
||||
* the shared selected-network store, so dropping this into a header is
|
||||
* the whole integration.
|
||||
*
|
||||
* Self-contained (React + semantic Tailwind tokens only), matching the
|
||||
* other @hanzo/ui domain modules.
|
||||
*/
|
||||
|
||||
import * as React from 'react'
|
||||
|
||||
import { validateCustomNetwork, type Network, type NetworkEnv } from './networks'
|
||||
import { useNetwork } from './store'
|
||||
|
||||
const ENV_DOT: Record<NetworkEnv, string> = {
|
||||
mainnet: 'bg-emerald-500',
|
||||
testnet: 'bg-amber-500',
|
||||
devnet: 'bg-sky-500',
|
||||
local: 'bg-zinc-400',
|
||||
custom: 'bg-violet-500',
|
||||
}
|
||||
|
||||
export interface NetworkSwitcherProps {
|
||||
/** Called after the selection changes (endpoint rewiring is automatic). */
|
||||
onChange?: (network: Network) => void
|
||||
/** Where the menu opens relative to the trigger (headers: bottom; footers: top). */
|
||||
menuSide?: 'top' | 'bottom'
|
||||
className?: string
|
||||
}
|
||||
|
||||
interface CustomDraft {
|
||||
label: string
|
||||
chainID: string
|
||||
rpcEndpoint: string
|
||||
apiEndpoint: string
|
||||
}
|
||||
|
||||
const EMPTY_DRAFT: CustomDraft = { label: '', chainID: '', rpcEndpoint: '', apiEndpoint: '' }
|
||||
|
||||
export function NetworkSwitcher({ onChange, menuSide = 'bottom', className = '' }: NetworkSwitcherProps) {
|
||||
const { network, networks, select, selectCustom } = useNetwork()
|
||||
const [open, setOpen] = React.useState(false)
|
||||
const [customOpen, setCustomOpen] = React.useState(false)
|
||||
const [draft, setDraft] = React.useState<CustomDraft>(EMPTY_DRAFT)
|
||||
const [error, setError] = React.useState<string | null>(null)
|
||||
const ref = React.useRef<HTMLDivElement>(null)
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!open) return
|
||||
const onDown = (e: MouseEvent) => {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false)
|
||||
}
|
||||
document.addEventListener('mousedown', onDown)
|
||||
return () => document.removeEventListener('mousedown', onDown)
|
||||
}, [open])
|
||||
|
||||
const pick = (env: NetworkEnv) => {
|
||||
if (env === 'custom') {
|
||||
setCustomOpen(true)
|
||||
return
|
||||
}
|
||||
const next = select(env)
|
||||
setOpen(false)
|
||||
setCustomOpen(false)
|
||||
onChange?.(next)
|
||||
}
|
||||
|
||||
const saveCustom = () => {
|
||||
const candidate: Omit<Network, 'env'> = {
|
||||
label: draft.label.trim() || 'Custom',
|
||||
networkID: Number(draft.chainID),
|
||||
evmChainID: Number(draft.chainID),
|
||||
rpcEndpoint: draft.rpcEndpoint.trim(),
|
||||
apiEndpoint: draft.apiEndpoint.trim(),
|
||||
currency: network.currency,
|
||||
}
|
||||
const invalid = validateCustomNetwork({ ...candidate, env: 'custom' })
|
||||
if (invalid) {
|
||||
setError(invalid)
|
||||
return
|
||||
}
|
||||
const next = selectCustom(candidate)
|
||||
setError(null)
|
||||
setDraft(EMPTY_DRAFT)
|
||||
setOpen(false)
|
||||
setCustomOpen(false)
|
||||
onChange?.(next)
|
||||
}
|
||||
|
||||
const field =
|
||||
'w-full rounded-md border border-border bg-background px-2 py-1 text-xs text-foreground outline-none focus:ring-1 focus:ring-ring'
|
||||
|
||||
return (
|
||||
<div ref={ref} className={`relative ${className}`}>
|
||||
<button
|
||||
type="button"
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={open}
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className="flex items-center gap-2 rounded-md border border-border bg-background px-2.5 py-1.5 text-xs font-medium text-foreground transition-colors hover:bg-accent hover:text-accent-foreground"
|
||||
>
|
||||
<span className={`h-2 w-2 rounded-full ${ENV_DOT[network.env]}`} aria-hidden />
|
||||
<span className="max-w-[9rem] truncate">{network.label}</span>
|
||||
<svg
|
||||
className={`h-3 w-3 text-muted-foreground transition-transform ${open ? 'rotate-180' : ''}`}
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
aria-hidden
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div
|
||||
role="menu"
|
||||
className={`absolute right-0 z-50 w-64 rounded-lg border border-border bg-popover p-1 text-popover-foreground shadow-md ${menuSide === 'top' ? 'bottom-full mb-1.5' : 'top-full mt-1.5'}`}
|
||||
>
|
||||
{networks.map((n) => (
|
||||
<button
|
||||
key={n.env}
|
||||
type="button"
|
||||
role="menuitemradio"
|
||||
aria-checked={network.env === n.env}
|
||||
onClick={() => pick(n.env)}
|
||||
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-xs hover:bg-accent hover:text-accent-foreground"
|
||||
>
|
||||
<span className={`h-2 w-2 shrink-0 rounded-full ${ENV_DOT[n.env]}`} aria-hidden />
|
||||
<span className="flex-1">
|
||||
<span className="block font-medium">{n.label}</span>
|
||||
<span className="block text-[10px] text-muted-foreground">
|
||||
chain {n.evmChainID} · {n.rpcEndpoint.replace(/^https?:\/\//, '')}
|
||||
</span>
|
||||
</span>
|
||||
{network.env === n.env && (
|
||||
<svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2} aria-hidden>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
|
||||
<div className="my-1 h-px bg-border" role="separator" />
|
||||
|
||||
{!customOpen ? (
|
||||
<button
|
||||
type="button"
|
||||
role="menuitemradio"
|
||||
aria-checked={network.env === 'custom'}
|
||||
onClick={() => pick('custom')}
|
||||
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-xs hover:bg-accent hover:text-accent-foreground"
|
||||
>
|
||||
<span className={`h-2 w-2 shrink-0 rounded-full ${ENV_DOT.custom}`} aria-hidden />
|
||||
<span className="flex-1 font-medium">
|
||||
{network.env === 'custom' ? network.label : 'Custom…'}
|
||||
</span>
|
||||
{network.env === 'custom' && (
|
||||
<svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2} aria-hidden>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
) : (
|
||||
<form
|
||||
className="space-y-1.5 p-2"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
saveCustom()
|
||||
}}
|
||||
>
|
||||
<input
|
||||
className={field}
|
||||
placeholder="Label (optional)"
|
||||
value={draft.label}
|
||||
onChange={(e) => setDraft({ ...draft, label: e.target.value })}
|
||||
/>
|
||||
<input
|
||||
className={field}
|
||||
placeholder="Chain ID"
|
||||
inputMode="numeric"
|
||||
value={draft.chainID}
|
||||
onChange={(e) => setDraft({ ...draft, chainID: e.target.value })}
|
||||
/>
|
||||
<input
|
||||
className={field}
|
||||
placeholder="RPC endpoint (https://…)"
|
||||
value={draft.rpcEndpoint}
|
||||
onChange={(e) => setDraft({ ...draft, rpcEndpoint: e.target.value })}
|
||||
/>
|
||||
<input
|
||||
className={field}
|
||||
placeholder="API endpoint (https://…)"
|
||||
value={draft.apiEndpoint}
|
||||
onChange={(e) => setDraft({ ...draft, apiEndpoint: e.target.value })}
|
||||
/>
|
||||
{error && <p className="text-[10px] text-destructive">{error}</p>}
|
||||
<div className="flex justify-end gap-1.5 pt-0.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setCustomOpen(false)
|
||||
setError(null)
|
||||
}}
|
||||
className="rounded-md px-2 py-1 text-xs text-muted-foreground hover:bg-accent hover:text-accent-foreground"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="rounded-md bg-primary px-2 py-1 text-xs font-medium text-primary-foreground hover:opacity-90"
|
||||
>
|
||||
Connect
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
export {
|
||||
HANZO_NETWORKS,
|
||||
chainIdHex,
|
||||
networkByEnv,
|
||||
validateCustomNetwork,
|
||||
type Network,
|
||||
type NetworkEnv,
|
||||
} from './networks'
|
||||
export {
|
||||
configureNetworks,
|
||||
getNetwork,
|
||||
getNetworks,
|
||||
selectCustomNetwork,
|
||||
selectNetwork,
|
||||
subscribeNetwork,
|
||||
useNetwork,
|
||||
} from './store'
|
||||
export { NetworkSwitcher, type NetworkSwitcherProps } from './NetworkSwitcher'
|
||||
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* Tests for the shared network model + selector.
|
||||
*
|
||||
* Model: canonical envs mirror the `hanzo` CLI (sovereign L1 ⇒
|
||||
* networkID === evmChainID; one api.hanzo.ai for public envs).
|
||||
* Store: selection persists (env name only — endpoints re-resolve from
|
||||
* code), custom networks validate, subscribers fire.
|
||||
* Presentation: <NetworkSwitcher/> lists envs, switches, takes customs.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
|
||||
import { HANZO_NETWORKS, chainIdHex, networkByEnv } from './networks'
|
||||
import {
|
||||
getNetwork,
|
||||
selectCustomNetwork,
|
||||
selectNetwork,
|
||||
subscribeNetwork,
|
||||
} from './store'
|
||||
import { NetworkSwitcher } from './NetworkSwitcher'
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
selectNetwork('mainnet')
|
||||
})
|
||||
|
||||
describe('network model', () => {
|
||||
it('is the sovereign-L1 model: networkID === evmChainID per env', () => {
|
||||
for (const n of HANZO_NETWORKS) expect(n.networkID).toBe(n.evmChainID)
|
||||
})
|
||||
|
||||
it('mirrors the hanzo CLI env ids', () => {
|
||||
expect(networkByEnv(HANZO_NETWORKS, 'mainnet')?.evmChainID).toBe(36963)
|
||||
expect(networkByEnv(HANZO_NETWORKS, 'testnet')?.evmChainID).toBe(36962)
|
||||
expect(networkByEnv(HANZO_NETWORKS, 'devnet')?.evmChainID).toBe(36964)
|
||||
expect(networkByEnv(HANZO_NETWORKS, 'local')?.evmChainID).toBe(1337)
|
||||
})
|
||||
|
||||
it('public envs share the one cloud API', () => {
|
||||
for (const env of ['mainnet', 'testnet', 'devnet'] as const) {
|
||||
expect(networkByEnv(HANZO_NETWORKS, env)?.apiEndpoint).toBe('https://api.hanzo.ai')
|
||||
}
|
||||
})
|
||||
|
||||
it('encodes the EIP-3085 hex chain id', () => {
|
||||
expect(chainIdHex(networkByEnv(HANZO_NETWORKS, 'mainnet')!)).toBe('0x9063')
|
||||
expect(chainIdHex(networkByEnv(HANZO_NETWORKS, 'local')!)).toBe('0x539')
|
||||
})
|
||||
})
|
||||
|
||||
describe('network store', () => {
|
||||
it('defaults to mainnet and switches envs', () => {
|
||||
expect(getNetwork().env).toBe('mainnet')
|
||||
selectNetwork('testnet')
|
||||
expect(getNetwork().evmChainID).toBe(36962)
|
||||
expect(getNetwork().rpcEndpoint).toBe('https://rpc.testnet.hanzo.network')
|
||||
})
|
||||
|
||||
it('persists only the selection, notifies subscribers', () => {
|
||||
let fired = 0
|
||||
const off = subscribeNetwork(() => {
|
||||
fired += 1
|
||||
})
|
||||
selectNetwork('devnet')
|
||||
off()
|
||||
expect(fired).toBe(1)
|
||||
const stored = JSON.parse(localStorage.getItem('hanzo.network')!)
|
||||
expect(stored).toEqual({ env: 'devnet' })
|
||||
})
|
||||
|
||||
it('accepts a valid custom network and rejects junk', () => {
|
||||
const custom = selectCustomNetwork({
|
||||
label: 'My L1',
|
||||
networkID: 4242,
|
||||
evmChainID: 4242,
|
||||
rpcEndpoint: 'https://rpc.example.com',
|
||||
apiEndpoint: 'https://api.example.com',
|
||||
currency: 'AI',
|
||||
})
|
||||
expect(custom.env).toBe('custom')
|
||||
expect(getNetwork().evmChainID).toBe(4242)
|
||||
expect(() =>
|
||||
selectCustomNetwork({
|
||||
label: 'bad',
|
||||
networkID: 0,
|
||||
evmChainID: 0,
|
||||
rpcEndpoint: 'not-a-url',
|
||||
apiEndpoint: '',
|
||||
currency: 'AI',
|
||||
}),
|
||||
).toThrow()
|
||||
// Failed select leaves the previous selection intact.
|
||||
expect(getNetwork().evmChainID).toBe(4242)
|
||||
})
|
||||
|
||||
it('throws on selecting an unconfigured env', () => {
|
||||
expect(() => selectNetwork('nope' as never)).toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('<NetworkSwitcher/>', () => {
|
||||
it('renders the selection and switches networks', () => {
|
||||
render(<NetworkSwitcher />)
|
||||
fireEvent.click(screen.getByRole('button', { name: /Hanzo Mainnet/ }))
|
||||
fireEvent.click(screen.getByRole('menuitemradio', { name: /Hanzo Testnet/ }))
|
||||
expect(getNetwork().env).toBe('testnet')
|
||||
expect(screen.getByRole('button', { name: /Hanzo Testnet/ })).toBeTruthy()
|
||||
})
|
||||
|
||||
it('connects a custom network through the inline form', () => {
|
||||
render(<NetworkSwitcher />)
|
||||
fireEvent.click(screen.getByRole('button', { name: /Hanzo Mainnet/ }))
|
||||
fireEvent.click(screen.getByRole('menuitemradio', { name: /Custom/ }))
|
||||
fireEvent.change(screen.getByPlaceholderText('Chain ID'), { target: { value: '4242' } })
|
||||
fireEvent.change(screen.getByPlaceholderText(/RPC endpoint/), {
|
||||
target: { value: 'https://rpc.example.com' },
|
||||
})
|
||||
fireEvent.change(screen.getByPlaceholderText(/API endpoint/), {
|
||||
target: { value: 'https://api.example.com' },
|
||||
})
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Connect' }))
|
||||
expect(getNetwork()).toMatchObject({ env: 'custom', evmChainID: 4242 })
|
||||
})
|
||||
|
||||
it('shows a validation error instead of selecting a bad custom network', () => {
|
||||
render(<NetworkSwitcher />)
|
||||
fireEvent.click(screen.getByRole('button', { name: /Hanzo Mainnet/ }))
|
||||
fireEvent.click(screen.getByRole('menuitemradio', { name: /Custom/ }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Connect' }))
|
||||
expect(screen.getByText(/Chain ID must be/)).toBeTruthy()
|
||||
expect(getNetwork().env).toBe('mainnet')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* The ONE network model for every Hanzo surface — identical to the
|
||||
* `hanzo` CLI (`hanzo network list|use`, cli/src/commands/network.rs)
|
||||
* and the console selector. A network is a value:
|
||||
*
|
||||
* (env, label, networkID, evmChainID, rpcEndpoint, apiEndpoint)
|
||||
*
|
||||
* Hanzo is a sovereign L1 — its primary networkID EQUALS its EVM chainID,
|
||||
* one ID per environment. IDs + RPC hosts come from genesis
|
||||
* (lux/genesis/configs/hanzo-*), byte-identical to `hanzo network list`
|
||||
* and the console selector: mainnet 36963, testnet 36962, devnet 36964;
|
||||
* local is the 1337 localnet convention. Public environments share the
|
||||
* one cloud API (api.hanzo.ai); RPC + explorer are per-env fabrics
|
||||
* (rpc[.testnet|.devnet].hanzo.network). `custom` is user-supplied.
|
||||
*/
|
||||
|
||||
export type NetworkEnv = 'mainnet' | 'testnet' | 'devnet' | 'local' | 'custom'
|
||||
|
||||
export interface Network {
|
||||
env: NetworkEnv
|
||||
label: string
|
||||
/** Sovereign L1 primary network ID (=== evmChainID per environment). */
|
||||
networkID: number
|
||||
/** EVM chain ID (EIP-155). */
|
||||
evmChainID: number
|
||||
/** JSON-RPC endpoint for the chain. */
|
||||
rpcEndpoint: string
|
||||
/** Cloud control-plane / gateway API for this environment. */
|
||||
apiEndpoint: string
|
||||
/** Block explorer, when the environment has one. */
|
||||
explorerEndpoint?: string
|
||||
/** Native gas token symbol. */
|
||||
currency: string
|
||||
}
|
||||
|
||||
/** Canonical hanzo.network environments (mirrors `hanzo network list`). */
|
||||
export const HANZO_NETWORKS: readonly Network[] = [
|
||||
{
|
||||
env: 'mainnet',
|
||||
label: 'Hanzo Mainnet',
|
||||
networkID: 36963,
|
||||
evmChainID: 36963,
|
||||
rpcEndpoint: 'https://rpc.hanzo.network',
|
||||
apiEndpoint: 'https://api.hanzo.ai',
|
||||
explorerEndpoint: 'https://explorer.hanzo.network',
|
||||
currency: 'AI',
|
||||
},
|
||||
{
|
||||
env: 'testnet',
|
||||
label: 'Hanzo Testnet',
|
||||
networkID: 36962,
|
||||
evmChainID: 36962,
|
||||
rpcEndpoint: 'https://rpc.testnet.hanzo.network',
|
||||
apiEndpoint: 'https://api.hanzo.ai',
|
||||
explorerEndpoint: 'https://explorer.testnet.hanzo.network',
|
||||
currency: 'AI',
|
||||
},
|
||||
{
|
||||
env: 'devnet',
|
||||
label: 'Hanzo Devnet',
|
||||
networkID: 36964,
|
||||
evmChainID: 36964,
|
||||
rpcEndpoint: 'https://rpc.devnet.hanzo.network',
|
||||
apiEndpoint: 'https://api.hanzo.ai',
|
||||
explorerEndpoint: 'https://explorer.devnet.hanzo.network',
|
||||
currency: 'AI',
|
||||
},
|
||||
{
|
||||
env: 'local',
|
||||
label: 'Hanzo Local',
|
||||
networkID: 1337,
|
||||
evmChainID: 1337,
|
||||
rpcEndpoint: 'http://localhost:9630/v1/bc/C/rpc',
|
||||
apiEndpoint: 'http://localhost:3690',
|
||||
currency: 'AI',
|
||||
},
|
||||
]
|
||||
|
||||
/** EIP-3085/3326 hex chain id (e.g. 36963 → 0x9063). */
|
||||
export const chainIdHex = (n: Network): string => '0x' + n.evmChainID.toString(16)
|
||||
|
||||
/** Find a network by env in a list (undefined for 'custom' / unknown). */
|
||||
export const networkByEnv = (
|
||||
networks: readonly Network[],
|
||||
env: NetworkEnv,
|
||||
): Network | undefined => networks.find((n) => n.env === env)
|
||||
|
||||
const URL_RE = /^https?:\/\/.+/
|
||||
|
||||
/** Validate a user-supplied custom network. Returns an error string or null. */
|
||||
export function validateCustomNetwork(n: Network): string | null {
|
||||
if (!Number.isInteger(n.evmChainID) || n.evmChainID <= 0) return 'Chain ID must be a positive integer.'
|
||||
if (!URL_RE.test(n.rpcEndpoint)) return 'RPC endpoint must be an http(s) URL.'
|
||||
if (!URL_RE.test(n.apiEndpoint)) return 'API endpoint must be an http(s) URL.'
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Selected-network store — ONE selection shared by every component and
|
||||
* API client in a surface. Framework-light: a module store + a React hook
|
||||
* (useSyncExternalStore); persisted to localStorage; cross-tab via the
|
||||
* `storage` event. SSR-safe (defaults to the first configured network).
|
||||
*
|
||||
* Only the SELECTION is stored (env name, or the full custom network the
|
||||
* user typed). Endpoints for the named envs always come from the
|
||||
* configured network list, never from storage — so shipping new endpoints
|
||||
* updates every client on next load.
|
||||
*/
|
||||
|
||||
import * as React from 'react'
|
||||
|
||||
import {
|
||||
HANZO_NETWORKS,
|
||||
networkByEnv,
|
||||
validateCustomNetwork,
|
||||
type Network,
|
||||
type NetworkEnv,
|
||||
} from './networks'
|
||||
|
||||
const STORAGE_KEY = 'hanzo.network'
|
||||
|
||||
interface Selection {
|
||||
env: NetworkEnv
|
||||
/** Present only when env === 'custom'. */
|
||||
custom?: Network
|
||||
}
|
||||
|
||||
let networks: readonly Network[] = HANZO_NETWORKS
|
||||
let selection: Selection = { env: 'mainnet' }
|
||||
let loaded = false
|
||||
const listeners = new Set<() => void>()
|
||||
|
||||
/**
|
||||
* Configure the network list for this surface (call once at bootstrap for
|
||||
* non-Hanzo brands; Hanzo surfaces need not call it). Resets nothing the
|
||||
* user chose — the persisted env is re-resolved against the new list.
|
||||
*/
|
||||
export function configureNetworks(list: readonly Network[]): void {
|
||||
if (list.length === 0) throw new Error('configureNetworks: list must not be empty')
|
||||
networks = list
|
||||
emit()
|
||||
}
|
||||
|
||||
export const getNetworks = (): readonly Network[] => networks
|
||||
|
||||
function load(): void {
|
||||
if (loaded || typeof window === 'undefined') return
|
||||
loaded = true
|
||||
try {
|
||||
const raw = window.localStorage.getItem(STORAGE_KEY)
|
||||
if (raw) {
|
||||
const parsed = JSON.parse(raw) as Selection
|
||||
if (parsed && typeof parsed.env === 'string') selection = parsed
|
||||
}
|
||||
} catch {
|
||||
// Corrupt storage falls back to the default selection.
|
||||
}
|
||||
window.addEventListener('storage', (e) => {
|
||||
if (e.key !== STORAGE_KEY) return
|
||||
try {
|
||||
const parsed = e.newValue ? (JSON.parse(e.newValue) as Selection) : null
|
||||
if (parsed && typeof parsed.env === 'string') {
|
||||
selection = parsed
|
||||
emit()
|
||||
}
|
||||
} catch {
|
||||
// Ignore malformed cross-tab writes.
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function persist(): void {
|
||||
if (typeof window === 'undefined') return
|
||||
try {
|
||||
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(selection))
|
||||
} catch {
|
||||
// Private mode / quota — selection still works for this session.
|
||||
}
|
||||
}
|
||||
|
||||
function emit(): void {
|
||||
for (const l of listeners) l()
|
||||
}
|
||||
|
||||
/** The currently selected network, resolved against the configured list. */
|
||||
export function getNetwork(): Network {
|
||||
load()
|
||||
if (selection.env === 'custom' && selection.custom) return selection.custom
|
||||
return networkByEnv(networks, selection.env) ?? networks[0]
|
||||
}
|
||||
|
||||
/** Select a named environment from the configured list. */
|
||||
export function selectNetwork(env: Exclude<NetworkEnv, 'custom'>): Network {
|
||||
load()
|
||||
const found = networkByEnv(networks, env)
|
||||
if (!found) throw new Error(`selectNetwork: no '${env}' network configured`)
|
||||
selection = { env }
|
||||
persist()
|
||||
emit()
|
||||
return found
|
||||
}
|
||||
|
||||
/** Select a user-supplied custom network. Throws on invalid endpoints. */
|
||||
export function selectCustomNetwork(custom: Omit<Network, 'env'>): Network {
|
||||
load()
|
||||
const network: Network = { ...custom, env: 'custom' }
|
||||
const invalid = validateCustomNetwork(network)
|
||||
if (invalid) throw new Error(invalid)
|
||||
selection = { env: 'custom', custom: network }
|
||||
persist()
|
||||
emit()
|
||||
return network
|
||||
}
|
||||
|
||||
/** Subscribe to selection changes. Returns the unsubscriber. */
|
||||
export function subscribeNetwork(cb: () => void): () => void {
|
||||
load()
|
||||
listeners.add(cb)
|
||||
return () => listeners.delete(cb)
|
||||
}
|
||||
|
||||
/** React view of the selected network. */
|
||||
export function useNetwork(): {
|
||||
network: Network
|
||||
networks: readonly Network[]
|
||||
select: typeof selectNetwork
|
||||
selectCustom: typeof selectCustomNetwork
|
||||
} {
|
||||
const network = React.useSyncExternalStore(subscribeNetwork, getNetwork, getNetwork)
|
||||
return { network, networks, select: selectNetwork, selectCustom: selectCustomNetwork }
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@hanzo/ui/lib/utils"
|
||||
import { cn } from "@hanzo/ui-shadcn/lib/utils"
|
||||
|
||||
export interface GanttProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
tasks?: Array<{
|
||||
|
||||
@@ -31,10 +31,10 @@ import {
|
||||
X,
|
||||
} from "lucide-react"
|
||||
|
||||
import { cn } from "@hanzo/ui/lib/utils"
|
||||
import { Badge } from "@hanzo/ui/primitives/badge"
|
||||
import { Button } from "@hanzo/ui/primitives/button"
|
||||
import { Card, CardContent, CardHeader } from "@hanzo/ui/primitives/card"
|
||||
import { cn } from "@hanzo/ui-shadcn/lib/utils"
|
||||
import { Badge } from "@hanzo/ui-shadcn/primitives/badge"
|
||||
import { Button } from "@hanzo/ui-shadcn/primitives/button"
|
||||
import { Card, CardContent, CardHeader } from "@hanzo/ui-shadcn/primitives/card"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -43,23 +43,23 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@hanzo/ui/primitives/dialog"
|
||||
} from "@hanzo/ui-shadcn/primitives/dialog"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@hanzo/ui/primitives/dropdown-menu"
|
||||
import { Input } from "@hanzo/ui/primitives/input"
|
||||
import { Label } from "@hanzo/ui/primitives/label"
|
||||
} from "@hanzo/ui-shadcn/primitives/dropdown-menu"
|
||||
import { Input } from "@hanzo/ui-shadcn/primitives/input"
|
||||
import { Label } from "@hanzo/ui-shadcn/primitives/label"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@hanzo/ui/primitives/select"
|
||||
import { Textarea } from "@hanzo/ui/primitives/textarea"
|
||||
} from "@hanzo/ui-shadcn/primitives/select"
|
||||
import { Textarea } from "@hanzo/ui-shadcn/primitives/textarea"
|
||||
|
||||
// Types
|
||||
export interface KanbanCard {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@hanzo/ui/lib/utils"
|
||||
import { cn } from "@hanzo/ui-shadcn/lib/utils"
|
||||
|
||||
const List = React.forwardRef<
|
||||
HTMLUListElement,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user