Compare commits

...
Author SHA1 Message Date
hanzo-dev 1f0e77c3f7 fix(network): align to genesis-canonical chain IDs (v5.7.4)
The published 5.7.3 mirrored pre-reconcile CLI values. cli#3 + console#139
(both merged) fixed the canonical set to match genesis
(lux/genesis/configs/hanzo-*). Align EXACTLY to console main
src/lib/network.ts + cli main src/commands/network.rs:

  testnet  36964 -> 36962  (rpc.hanzo-test.network -> rpc.testnet.hanzo.network)
  devnet   36965 -> 36964  (rpc.hanzo-dev.network  -> rpc.devnet.hanzo.network)
  local    31337 -> 1337   (:9650/ext/bc/C/rpc     -> :9630/v1/bc/C/rpc)

Sovereign L1 networkID === evmChainID preserved. mainnet 36963 unchanged.
The shared component is the ONE place — it MUST match genesis.

vitest 254/254; tsup + tsc dts green.
2026-07-08 09:06:49 -07:00
hanzo-dev 6343c3e384 feat(network,wallet): menuSide prop — menus open upward from footers (v5.7.3) 2026-07-08 08:41:00 -07:00
hanzo-dev 031a3e105e feat(network,wallet): the ONE hanzo.network selector + wallet menu
<NetworkSwitcher/> + <WalletMenu/> at @hanzo/ui-shadcn/{network,wallet} —
the shared network/wallet standard for desktop, app, chat, team, console.

- Network = (env, label, networkID, evmChainID, rpcEndpoint, apiEndpoint):
  sovereign L1, networkID === evmChainID; envs mirror the hanzo CLI
  (mainnet 36963 / testnet 36964 / devnet 36965 / local 31337; one
  api.hanzo.ai across public envs; per-env rpc.hanzo[-test|-dev].network).
- Selection persists env name only; endpoints always re-resolve from code.
- WalletAdapter seam keeps custody per-surface (desktop lux-wallet PQ HD,
  web injected EIP-1193 — non-custodial, no key material, no storage).
- 20 vitest cases; v5.7.2.
2026-07-08 08:34:25 -07:00
hanzo-dev 7885a8e77f fix(ui-shadcn): self-referencing imports use the package's own name
The @hanzo/ui -> @hanzo/ui-shadcn rename (name freed for v8) left 29 files
importing themselves via the OLD name, breaking tsc/dts and making dist
resolve against npm @hanzo/ui@8.x at runtime. Self-reference by own name
resolves correctly under any install name (including npm: aliases).
2026-07-08 08:33:58 -07:00
hanzoandz 4a7e50c207 feat(contracts): wire live Hanzo L1 addresses (mainnet 36963 / testnet 36962 / devnet 36964)
AIToken/ChainConfig/HUSD/Faucet/HanzoRegistry deployed + cast-verified on all 3 sovereign nets.
2026-07-07 22:10:40 -07:00
2d9f3d445b build(ui,data): compiled .d.ts — retire raw-.tsx type surface (8.0.1/1.2.1) (#236)
* feat(@hanzo/data): Twenty-grade record views (table/board/detail/editors) — clean-room

Bring the Hanzo Base data-app layer to Airtable/Twenty-class polish, 100% original
(no Twenty code — GPL kept at arm's length; Twenty observed as a running-UI reference only).

New in pkg/data (published as @hanzo/data@1.2.0):
- RecordsView shell: table <-> board switch, search, filter builder, sort builder,
  board group-by, optional saved views — one ViewConfig, applied via pure view/logic.
- DataTable (Twenty-grade): click-to-sort headers, drag-resize + drag-reorder columns,
  row selection + select-all, inline cell editing, pagination, hover-open, honest states.
- BoardView: kanban grouped by a select/status/boolean/relation field; drag-between-lanes
  emits the record patch (optimistic, reverts on failure).
- RecordDetail: titled, inline-editable panel + related slot; RecordForm gains fieldOptions.
- Field editors upgraded: searchable select dropdown w/ color chips, month-grid calendar,
  relation record-picker, file upload, validated JSON, sliding boolean toggle.
- Pure logic (sort/filter/search/group/paginate/view) — gui-free, 37 unit tests, exported
  on subpaths (@hanzo/data/{table,board,view}/logic).
- Self-contained primitives (Menu/CheckBox/Toggle/Calendar) on the minimal proven gui surface.
- gui.config.ts + gui.d.ts so the package type-checks the v5 shorthands standalone.

@hanzo/ui/primitives/bases/data re-exports @hanzo/data (the bases surface convention).

tsc --noEmit clean; vitest 37/37.

* feat(@hanzo/ui): v8 unified lib on @hanzo/gui — product + record (@hanzo/data) layers

The one cross-platform, presentational, host-agnostic, clean-room component
library. Product/app layer (charts, metrics, page headers, status tags, empty
states, combobox, slide-over, toasts, drag-reorder, field rows, marks) at
'@hanzo/ui'; metadata-driven record layer composed from @hanzo/data at
'@hanzo/ui/data'; calm dark-first tokens + motion vocabulary. Web + native + desktop.

Retires the shadcn @hanzo/ui (5.x) → @hanzo/ui-shadcn; this gui-based line
carries the name forward at 8.0.0. tsc --noEmit clean, vitest 12/12.
Manifest: CONSOLIDATION.md.

* refactor(ui): retire shadcn @hanzo/ui → @hanzo/ui-shadcn (name freed for v8)

Rename the legacy shadcn/Radix line (pkgs/ui, 5.7.0) to @hanzo/ui-shadcn so the
gui-based unified library can carry the @hanzo/ui name forward at v8. Code is
untouched — only the package name changes; the published @hanzo/ui@5.7.1 stays
on npm for external ^5.x consumers.

Internal workspace consumers keep resolving the shadcn line with ZERO source
edits via workspace aliases (@hanzo/ui → @hanzo/ui-shadcn):
  - app (@hanzo/ui-web): dependency workspace alias
  - commerce: devDependency workspace alias (source imports @hanzo/ui/*),
    peer stays @hanzo/ui>=5.0.0 for external consumers
  - checkout, agent-ui: peer ranges unchanged (no source imports)
Proven: app + commerce node_modules/@hanzo/ui resolve to @hanzo/ui-shadcn@5.7.0.

Drive-by (unblocks workspace install/CI): pkgs/data pinned the now-unpublished
@hanzogui/config@7.2.2 — patch-forward to 7.3.0 (published latest, same major,
matches pkg/ui).

* build(ui,data): emit compiled .d.ts — retire raw-.tsx type surface

@hanzo/ui@8.0.1 + @hanzo/data@1.2.1: types/exports now point at flat
compiled declarations (tsc -p tsconfig.build.json → types/), matching
@hanzo/gui@7.3.0. src/gui-env.d.ts bakes the GuiCustomConfig augmentation
into each package's own compilation so shorthand props resolve internally —
consumers no longer type-check vendor .tsx (the 171-phantom-error/hoisted-
linker fragility).

---------

Co-authored-by: Hanzo AI <ai@hanzo.ai>
Co-authored-by: hanzo-dev <dev@hanzo.ai>
2026-07-07 17:55:44 -07:00
hanzo-devandGitHub 7cbb8b8e16 Merge pull request #237 from hanzoai/feat/ui-8-unified
feat(ui): v8 unified lib + shared AI usage components
2026-07-07 16:16:46 -07:00
Hanzo AI 65a530e5a4 Merge remote-tracking branch 'origin/main' into feat/ui-8-unified
# Conflicts:
#	pkgs/data/package.json
#	pkgs/ui/package.json
2026-07-07 16:16:32 -07:00
Hanzo AI cf38fa82cf feat(ui): add shared AI usage components (UsageMeter, UsageProviderCard, UsageDashboard)
The ONE cross-platform AI-usage surface every Hanzo app (console, desktop,
app, chat) renders: a labeled rate-limit bar with % left + honest reset
countdown, a per-provider quota card (session/weekly/extra windows, spend,
history sparkline) mirroring the Codex menu card, and a dashboard grid with a
totals header. Composes existing Charts.Sparkline + Metric idioms on @hanzo/gui
primitives only; presentational, host-agnostic, web + native + desktop.

Bumps @hanzo/ui 8.0.0 -> 8.0.1.
2026-07-07 15:12:12 -07:00
d0d2520121 feat(billing): shared CreditModal (trial + prepaid buckets, injected top-up) (#235)
Presentational, self-contained credit/top-up modal any Hanzo app
(console2, hanzo.app, billing.hanzo.ai) can mount with its own data +
handlers. All data/handlers injected via props (cents-based, commerce
balance shape); no network calls, no app coupling.

- Two distinct buckets: non-cash trial credit vs. real prepaid money,
  with a combined breakdown + explicit total.
- Welcome celebration state when a new user's trial credit just landed.
- Top-up affordance (preset amounts + custom) that calls the injected
  onTopUp(amountCents); Square/HUSD payment flow stays in the caller,
  optionally rendered via children. Reuses the existing handler-prop
  pattern (onAddFunds / SquareCardForm) rather than duplicating it.
- Reuses the package radix Dialog primitive for focus trap, escape,
  overlay + aria; styled with the billing semantic tokens.
- Exported from the billing barrel; 8 vitest cases.

Co-authored-by: hanzo-dev <dev@hanzo.ai>
2026-07-04 14:54:44 -07:00
zandGitHub 5f915401cb Merge pull request #234 from hanzoai/feat/dashboard-layer
feat(dashboard): @hanzo/dashboard — reusable dashboard layer on @hanzo/gui
2026-07-04 12:35:35 -07:00
hanzo-dev ec81483941 polish(dashboard): align docstrings to clean export names
Drop 'LivingOverview'/'LineChart'/'BarChart'/'BarRows' from prose comments;
match the de-branded single-word exports. No code change (typecheck still clean).
2026-07-04 12:34:14 -07:00
hanzo-dev f87f01dac3 docs(dashboard): README (exports, usage, provenance, console2 swap plan)
Documents every export + usage snippet, maps each back to its console2 source,
the generalizations made, and the exact per-component swap plan for consuming
@hanzo/dashboard back in console2 (follow-up pass). Tidy a double import.
2026-07-04 12:32:43 -07:00
hanzo-dev b2ab3d6499 fix(dashboard): bind gui shorthand types (typecheck + build clean)
Augment GuiCustomConfig in BOTH @hanzogui/web and @hanzogui/core, and declare
@hanzogui/web + @hanzogui/core as devDeps so the 'declare module' targets resolve
under pnpm strict nesting (npm-flat installs like console2 hoist them; pnpm does
not). Result: tsc --noEmit clean (0 errors) and tsc build emits dist cleanly.
2026-07-04 12:30:47 -07:00
hanzo-dev c97e62192a feat(dashboard): landing kit + deploy pipeline + barrel
- landing/: Landing (Hero/Metrics/Samples/Rail) + pure link logic — brand/docs
  via LandingConfig props (no host-app config coupling)
- pipeline/: pure pipeline.ts (de-branded stage model) + small stages-driven
  Pipeline component; de-branded CSS classes (hz-pipe-*)
- src/index.ts: clean single-word public API
No other-company brand names anywhere; single-word exports per naming guidance.
2026-07-04 12:25:35 -07:00
hanzo-dev c91a55053e feat(dashboard): overview driver + composable primitives
- overview/config.ts: declarative OverviewConfig contract (generalized icon type)
- overview/logic.ts: pure tile decisions (format/delta/selectors/health/virtualize)
- overview/primitives.tsx: Kpi / Feed / Board + Panel/Skeleton chrome (direct props)
- overview/tiles.tsx: config-tile adapters + Tile router (delegate to primitives)
- overview/Overview.tsx: the poll-loop driver (isGlobalAdmin as prop; inline
  header/fade/error — no host-app coupling)
- charts: rename to clean single words (Line/Columns/Bars)
Clean single-word exports per naming guidance; no 'Living' prefix.
2026-07-04 12:20:38 -07:00
hanzo-dev faa26ad205 feat(dashboard): scaffold @hanzo/dashboard + charts + motion layer
Extracted from Hanzo Cloud Console (console2):
- charts/Charts.tsx: Sparkline/LineChart/BarChart/Donut/BarRows (monochrome SVG)
- motion/motion.ts: pure count-up/ring/poll-clock math
- motion/hooks.ts: useReducedMotion/usePageHidden/useCountUp/usePoll
- dashboard.css: motion keyframes (reduced-motion-guarded)
Mirrors the @hanzo/data package convention (@hanzo/gui peer, source-shipped, tsc).
2026-07-04 12:12:47 -07:00
zandClaude Opus 4.8 faae394d2c fix(data): @hanzogui/config 7.2.2 (unpublished) -> 7.3.0 — unblock workspace install + @hanzo/ui publish
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 09:04:33 -07:00
zandClaude Opus 4.8 9f513ad63c chore(ui): release @hanzo/ui 5.7.1 — design unification (Basel Grotesk + Geist Mono canonical, DESIGN.md)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 08:56:52 -07:00
zandGitHub 958824f746 Merge pull request #233 from hanzoai/design/unify-basel-geist-mono
design: unify Basel Grotesk + Geist Mono, sidebar icon, panels, dark tokens
2026-07-03 08:46:51 -07:00
zandClaude Opus 4.8 451c57df28 design: make Basel Grotesk + Geist Mono canonical; add DESIGN.md source of truth
- Typography: fontSans -> Basel Grotesk via next/font/local (self-hosted,
  Book 400 + Medium 500, --font-basel-sans); keep fontMono = Geist Mono.
  Point both tailwind configs' sans at var(--font-basel-sans). Basel replaces
  Geist Sans as the default; DM Sans/Figtree/Inter stay optional .theme-*
  variants only, never defaults.
- DESIGN.md: the single source of truth for the shared Hanzo look — canonical
  typography (Basel + Geist Mono), the sidebar toggle icon (lucide PanelLeft),
  sidebar/panel specs (16rem width, border-border, monochrome hover/active),
  and the true-black dark palette (#000 canvas / #0a0a0a surface / white-10
  borders / #ededf1 text).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 01:53:40 -07:00
zandClaude Opus 4.8 26ce908779 feat(data): every field type editable — relation/files/links/json/fullName/address inputs
The @hanzo/data field registry had Displays for all 24 types but Inputs for only
~15 — records were read-mostly. Fill the gaps so a Base record is FULLY editable
in table/detail (the CRM/CMS foundation):

- RelationInput — single/to-many record picker over host-injected candidate
  options (metadata.options), falls back to raw-id entry when none injected.
- FilesInput / LinksInput — add/remove chip lists.
- JsonInput — parses on change, keeps text on invalid so typing isn't lost.
- FullNameInput (first/last) + AddressInput (street/city/state/zip) — composite
  sub-field editors.
- relation metadata gains options + maxSelect; files gains accept + maxSelect.

Only the true system types (uuid/position/actor) stay display-only. Built on the
same @hanzo/gui primitives + FieldInputProps as the existing inputs (cross-
platform, shorthand style). registry.test.ts asserts every non-system type now
has an Input (mocking @hanzo/gui). 11/11 tests pass, tsc clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 18:45:23 -07:00
108 changed files with 5607 additions and 196 deletions
+103
View File
@@ -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, ~1620px, `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 4870px) |
| 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
View File
@@ -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
View File
@@ -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
+1 -1
View File
@@ -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: {
+6
View File
@@ -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
View File
@@ -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
-13
View File
@@ -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
-15
View File
@@ -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
View File
@@ -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",
+27
View File
@@ -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 {}
}
+13
View File
@@ -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"]
}
+2 -2
View File
@@ -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"]
}
+6
View File
@@ -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
View File
@@ -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
-13
View File
@@ -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
-15
View File
@@ -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
View File
@@ -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",
+27
View File
@@ -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 {}
}
+3
View File
@@ -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>
)
}
+75
View File
@@ -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 (0100) 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>
)
}
+10
View File
@@ -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'
+13
View File
@@ -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
View File
@@ -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"
]
}
+245
View File
@@ -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.
+17
View File
@@ -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
+15
View File
@@ -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 {}
}
+53
View File
@@ -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"
}
}
+470
View File
@@ -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>
)
}
+157
View File
@@ -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;
}
}
+116
View File
@@ -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'
+115
View File
@@ -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>
)
}
+47
View File
@@ -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>
)
}
+134
View File
@@ -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 arent 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>
)
}
+61
View File
@@ -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>
)
}
+78
View File
@@ -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>
)
}
+47
View File
@@ -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'),
}
}
+116
View File
@@ -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>
)
}
+98
View File
@@ -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[]
}
+98
View File
@@ -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) }
}
+76
View File
@@ -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)
}
+203
View File
@@ -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 overviews 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>
)
}
+176
View File
@@ -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
}
+204
View File
@@ -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`
}
+306
View File
@@ -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>
)
}
+223
View File
@@ -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} />
}
}
+178
View File
@@ -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} />
}
+156
View File
@@ -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
}
+12
View File
@@ -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 }>
+25
View File
@@ -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 -1
View File
@@ -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
View File
@@ -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",
+1 -1
View File
@@ -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
+3 -3
View File
@@ -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.
*/
+4 -4
View File
@@ -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.
*/
+10 -10
View File
@@ -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
+1 -1
View File
@@ -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 (
+1 -1
View File
@@ -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(
[
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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(
[
+1 -1
View File
@@ -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"
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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 {
/**
+2 -2
View File
@@ -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 {
+1 -1
View File
@@ -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>
)
}
+3
View File
@@ -25,6 +25,9 @@ export type { UsagePanelProps } from './usage-panel'
export { CreditsPanel } from './credits-panel'
export type { CreditsPanelProps } from './credits-panel'
export { CreditModal } from './credit-modal'
export type { CreditModalProps } from './credit-modal'
export { TransactionsPanel } from './transactions-panel'
export type { TransactionsPanelProps } from './transactions-panel'
+1 -1
View File
@@ -1,5 +1,5 @@
/**
* @hanzo/ui/billing
* @hanzo/ui-shadcn/billing
* Billing, subscription, payment, and invoice management components
*/
+1 -1
View File
@@ -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 -1
View File
@@ -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
+4 -4
View File
@@ -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",
+9 -9
View File
@@ -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",
+6 -6
View File
@@ -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",
+3 -3
View File
@@ -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
+4 -4
View File
@@ -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",
+7 -7
View File
@@ -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",
+4 -4
View File
@@ -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: {
+1 -1
View File
@@ -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<{
+5 -5
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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'
+2 -2
View File
@@ -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} />
*/
+227
View File
@@ -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} &middot; {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>
)
}
+18
View File
@@ -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'
+135
View File
@@ -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')
})
})
+96
View File
@@ -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
}
+136
View File
@@ -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 }
}
+1 -1
View File
@@ -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<{
+10 -10
View File
@@ -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 -1
View File
@@ -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