Compare commits

..
306 Commits
Author SHA1 Message Date
zeekay dd02bb0dc4 fix(analytics): carry a publishable ingest key, so events are accepted
Removing Vercel analytics left the site posting to /v1/event with no
credential, and every batch came back 403 — telemetry replaced with
telemetry that ingests nothing.

The door does not trust the request Host on purpose, because a Host
header is spoofable, so a static page proves its org by carrying a
publishable key. pk_ keys are write-only and HMAC-verified with no
database hop, which is what makes one safe inside a public bundle. The
build supplies it, so each deployment reports as the org that built it,
and a build without one stays inert instead of posting rejects.

Verified against prod: POST /v1/event with a pk_ returns
{"accepted":1,"dropped":0} on both the header and beacon paths.
2026-07-25 14:04:47 -07:00
zeekay 367c9235c1 build: static 0.5.2 — compressed responses and a real cache policy 2026-07-25 13:37:47 -07:00
zeekay 3a3ac71fc9 chore: lockfile without rainbowkit 2026-07-25 13:19:05 -07:00
zeekay d6d9aafc66 feat(web3): connect over EIP-1193, drop RainbowKit and WalletConnect
The docs site pulled in RainbowKit, which bundles a WalletConnect
connector. That connector phoned pulse.walletconnect.org on every page
load — third-party telemetry from a documentation page — and it could
not have worked anyway: the project id fell back to the literal
'YOUR_PROJECT_ID'.

The browser wallet already speaks EIP-1193, so wagmi's injected
connector is all the identity demo needs: the extension is the only
party in the flow, with no bridge service and no vendor id. ConnectWallet
replaces RainbowKit's modal using our own Button.

wagmi and viem stay — they are how the page reads and writes contracts.
Verified: a clean build exports 8,879 files and the shipped JS contains
no walletconnect reference.
2026-07-25 13:18:55 -07:00
zeekay 077a728693 build: serve on static 0.5.1, which resolves a directory index in place
On 0.4.1 every HTML route answered 301 -> /index.html, so the internal
filename showed up in the address bar and in the URLs Next derives for
route prefetches.
2026-07-25 13:04:19 -07:00
zeekay 4b690519c3 build: serve ui.hanzo.ai from our own stack
The site was on Cloudflare Pages as a direct upload — no git connection,
last deployed 2026-03-28 — while a GitHub Pages workflow ran on every
push against a repo that has Pages disabled (the API 404s). Two deploy
paths, neither of which shipped what main said.

One way now: a Dockerfile builds the static export and serves it with
ghcr.io/hanzoai/static, exactly like every other Hanzo static site, on
our own runners and our own ingress.

Along the way, drop what those two vendors left behind:
- output:'export' was gated on GITHUB_ACTIONS || CF_PAGES, so a build
  anywhere else silently produced no export at all. The site is a static
  export wherever it is built.
- basePath pointed at a '/react-sdk' GitHub Pages subdirectory that is
  not this project.
- highlight-code keyed off GITHUB_ACTIONS to pick server vs client
  highlighting; it means 'is this a production export', so it says that.
- pages/api/components returned a JSON blob and 404s in production,
  because a static export cannot serve an API route. The live index is
  the generated /api/registry/components.json.
2026-07-25 12:46:18 -07:00
zeekay 248101b84b fix(ci): commit pnpm-lock.yaml so the build is reproducible
.gitignore contradicted itself: line 8 ignored the lockfile while the
lockfiles block declared 'pnpm-lock.yaml - needed for CI/CD, do not
ignore'. With no lockfile in the repo and 'pnpm install
--no-frozen-lockfile', every CI run resolved whatever was newest, so the
Pages deploy has been red since 2026-07-22 on '@x402/*' — optional lazy
imports inside a dependency that a newer release started pulling in and
nothing pinned.

This lockfile is the resolution the app builds green on locally
(verified: full next build, 312+ static pages, exit 0).
2026-07-25 12:28:03 -07:00
zeekay bbffa64c2a fix(ci): build @hanzo/event before the docs build
link-workspace-packages=true, so the app resolves @hanzo/event to
pkgs/event no matter what the range says, and its exports point at dist/
— which only exists once the package is built. The workflow already
builds pkgs/ui for the same reason; event needs the same step. Declare
the dep by published range like every other @hanzo/* dep in the app.
2026-07-25 11:36:41 -07:00
zeekay 74525b25dd fix(analytics): point the site at @hanzo/event, the canonical client
@hanzo/analytics (pkgs/capture) is the superseded duplicate and is gone
from the tree, so depending on it would not resolve. @hanzo/event is the
one telemetry client — one Event type, one door (POST /v1/event). Its
host already defaults to the one edge, so the site needs no config
beyond naming itself.
2026-07-25 10:59:00 -07:00
zeekay 20f56d5b9d fix(analytics): use @hanzo/analytics, drop @vercel/analytics
ui.hanzo.ai is a static export on GitHub Pages, not Vercel, so
@vercel/analytics injected /_vercel/insights/script.js — which 404s and
is then refused as text/html, on every page. It was also a second way to
do a thing we already own: pkgs/capture ships @hanzo/analytics.

Route both call sites through one client (lib/analytics.ts): the mount
sends pageviews, trackEvent sends events. Neither knows the vendor —
they name what happened and the client owns where it goes.
2026-07-25 10:57:38 -07:00
zandhanzo-dev ae34f15348 feat(ui): menus on ONE Portal path (fix gui-native "Missing theme") + ship compiled dist (fix Next flight loader) — 8.0.8
Two consumer-caught blockers in 8.0.7:

1. gui-native "Missing theme". The gui Popover's SheetController re-roots the trigger
   subtree and reads the theme from React context; on gui-native hosts
   (@hanzogui/react-native-web-lite, theme in context, no CSS-class fallback) that throws
   at MOUNT. FIX: drop the gui Popover entirely — every menu now rides ONE Portal path
   (the ContextMenu approach that already worked on gui-native):
   - menu/FloatingMenu.tsx — shared floating primitive: gui Portal + PortalTheme
     (re-applies the captured theme inside the portal) + anchor positioning (trigger rect
     or cursor point) + edge-flip + dismiss + roving keys.
   - DropdownMenu, ContextMenu, SelectMenu (now a thin DropdownMenu), ComboBox all use it.
     No Popover, no Sheet, no re-root.
   Verified on a Vite + rnw-lite harness (theme in React context, NO root theme fallback):
   NEW DropdownMenu + SelectMenu render correctly themed through the Portal, dark + light.

2. Next 16 flight-loader parse error. 8.0.7 shipped raw `.ts` with inline
   `export { X, type Y }`; Next's flight-client loader parses node_modules `'use client'`
   modules WITHOUT TS and choked on `type`. FIX: ship a COMPILED dist —
   - tsup → ESM (.js) + CJS (.cjs), every dep external, all 11 subpath entries.
   - tsc → .d.ts (dist). scripts/add-use-client.mjs stamps `'use client'` on every output
     (tsup banner misses split chunks). CSS copied.
   - package.json main/module/exports repointed at dist; files=[dist].
   `node --check` passes on all 28 compiled files; the 8.0.7 offender is now valid JS with
   the TS `type` stripped — the flight-loader parse error is structurally impossible.

pnpm typecheck: 0 · pnpm test: 19/19 · pnpm build: exit 0.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-25 09:35:14 -07:00
zandhanzo-dev 668cba2601 fix(ui): menu style props use @hanzo/gui config shorthands (green pkg build)
The @hanzo/gui v5 config omits longhand style aliases that have a shorthand, so
the strict pkg/ui build (createGui augmentation) rejected backgroundColor/alignItems/
justifyContent/minWidth/maxHeight/paddingHorizontal/paddingVertical/marginVertical/
flexShrink/borderRadius/userSelect. Convert the menu primitives + the Popover.Content
shells to the config vocabulary (bg/items/justify/minW/maxH/px/py/my/shrink/rounded/
select). Runtime is unchanged (Tamagui accepts both) — this is the published 8.0.7 code.

pnpm typecheck: 0 errors · pnpm test: 19/19 · pnpm build: exit 0 (48 d.ts emitted).

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-25 08:33:24 -07:00
zandhanzo-dev d1b6db0a14 feat(ui): ONE menu system — portal-theme-safe DropdownMenu + ContextMenu, agnostic ThemeToggle (8.0.7)
@hanzo/ui/product gains the shared menu primitives every Hanzo surface
(app/chat/desktop) needs, all on @hanzo/gui/Tamagui, all on ONE item spec so
menus are pixel-identical across the fleet.

- menu/items.tsx — the ONE spec: MenuPanel ($color2 surface, hairline, radius-12,
  pad-4), MenuItemView (h30, px8, gap8, 16px icon slot left, 13px label, right
  affordance shortcut/check/chevron; hover/focus/press → purple accent-soft;
  selected → check+accent; disabled muted; optional 2nd-line description),
  MenuSeparatorView (1px hairline, 4px margin), MenuLabelView (11px uppercase),
  renderMenuItems. Geometry literal px on the 8-grid; colour theme-adaptive tokens
  + brand purple via var(--hanzo-accent[-soft]).
- menu/DropdownMenu.tsx — click menu on gui Popover (bottom-start, allowFlip,
  useControllableState).
- menu/ContextMenu.tsx — right-click menu on gui Portal, cursor-positioned (fixed),
  edge-flip, dismiss on outside/Escape/scroll/resize/blur.
- menu/portal-theme.tsx — PortalTheme: captures useThemeName() at the trigger and
  re-applies <Theme name> INSIDE portaled content, so menus render correctly through
  a portal under a nested <Theme> (light+dark). Fixes GAP 1 (desktop "Missing theme").
- menu/roving.ts — shared Arrow/Home/End/Escape keyboard nav.
- SelectMenu + ComboBox — adopt the shared spec + PortalTheme fix (DRY; identical rows).
- ThemeToggle — framework-agnostic: controlled via theme + onToggle/onThemeChange
  (NO framework dep, for Vite/Tauri/Express); uncontrolled falls back to the OPTIONAL
  @hanzogui/next-theme via ThemeToggleNext (lazy, ErrorBoundary→DOM), so console/Next
  stays backward-compatible and non-Next hosts build.
- package.json — 8.0.7; @hanzogui/next-theme added as an OPTIONAL peer.

Verified against the real published @hanzo/gui@7.3.0: product source type-checks
clean; a Vite harness renders DropdownMenu + ContextMenu + SelectMenu + ThemeToggle
through portals under a nested <Theme name="dark"/"light"> — panels correctly themed
(t_dark rgb(20,20,20) / t_light rgb(247,247,247)) while root stays light, no errors.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-25 08:16:25 -07:00
z 1078bd980d docs: modernize + LLM.md + cross-links (one-way SDK model) 2026-07-24 12:41:27 -07:00
z e15c3f945c docs: modernize + LLM.md + cross-links (one-way SDK model) 2026-07-24 12:41:25 -07:00
Hanzo AI 9c3ea47c3c feat(commerce): schema.org microdata on ProductCard/AddToCart + data-slot on TextLink for zero-config capture 2026-07-24 01:54:42 -07:00
hanzo-dev 31ab3c8a1d feat(observe-native): native + desktop binding for @hanzo/observe
Makes @hanzo/gui (Tamagui) / React Native and Tauri desktop emit the SAME
canonical Events as the web — same semantic hierarchy, privacy gate, and ONE front
door (POST /v1/event via @hanzo/event).

- '.' (React/Tamagui): ObserveProvider + ObserveScope compose the semantic path
  from the React tree (no DOM); useObserve().press/changeText/event/screen wrap
  RN/Tamagui handlers; useEventStream for playback
- './tauri' (react-free): bindTauri runs the @hanzo/observe DOM engine in the
  webview + forwards Tauri native events; @tauri-apps/api is a runtime-optional peer
- pure emit/semantic/redact core reuses @hanzo/observe wireProps/labelFor/
  sensitiveKey so native and web are byte-identical on the wire

19 tests green (semantic/emit/tauri + React mount via react-dom/client).
2026-07-22 22:57:09 -07:00
hanzo-dev d946fc15d7 feat(observe-svelte): Svelte adaptor for @hanzo/observe
Reuses the framework-agnostic @hanzo/observe engine; binds it to Svelte idioms:
- createObserver(client) — bootstrap capture in a root +layout (idempotent, SSR-safe)
- observe action — use:observe={{ name }} stamps a stable component name the engine
  labels interactions with; { private } / { view } opt out / into a subtree
- stream store — a Svelte-readable live window of interactions for session playback

Emits through @hanzo/event to POST /v1/event. 7 tests green; svelte is an optional
peer (action + store satisfy Svelte contracts structurally).
2026-07-22 22:45:00 -07:00
hanzo-dev 201648dfaf feat(observe): default-on semantic interaction capture for React
@hanzo/observe — watches every click/input/nav/visibility across the tree,
annotates each with a semantic hierarchy (component path / role / data-testid /
aria) auto-derived from the DOM as a small JSON-LD document, and emits it through
@hanzo/event to the ONE front door (POST /v1/event).

- framework-agnostic engine ('.'): Observer + annotate + redact + Stream
- React default experience ('./react'): ObserveProvider + useEventStream (session
  playback) + useObserver
- privacy-first: input values withheld by default, sensitive fields always
  redacted, data-hz-private subtree opt-out; fail-soft throughout

28 tests green (annotate/redact/stream/observer).
2026-07-22 22:45:00 -07:00
hanzo-dev ea63e4e144 docs(event): document @hanzo/event as the ONE telemetry client + supersessions 2026-07-22 22:14:24 -07:00
hanzo-dev 4603f234d0 fix(event): emit CJS as .cjs so require() works under type:module
The 0.3.0 CJS bundle went to dist/index.js, which Node parses as ESM under
"type": "module" — require('@hanzo/event') threw "exports is not defined in
ES module scope". Emit CJS as .cjs (ESM stays .mjs) and map each exports
condition to its own types. No API change. Bumps 0.3.0 -> 0.3.1.
2026-07-22 22:04:20 -07:00
hanzo-dev 312f44787d feat(event): emit the canonical {batch} wire to the ONE /v1/event door
Repoint @hanzo/event onto Hanzo Cloud's single ingestion front door
(POST /v1/event, body {batch:[Event,...]}), replacing the deprecated
/v1/analytics + /v1/tracker split and the interim /v1/ingest key door.
One door, one wire, for cookie / bearer / publishable-key auth alike.

Errors now reach the error-tracking lens. The batched Event wire carries
`type`, which cloud folds to event_type='error' (foldException +
canonicalType); the exception rides the top-level `error` field, lifted
into properties.$exception. The four-field {event,distinctId,time,
properties} array has no `type`, so it can never be lensed as an error —
the batched wire is the one that lights up web + product + error.

Publishable keys authenticate directly on /v1/event now: Authorization:
Bearer pk_ on fetch, ?ingest_key=pk_ on a headerless unload beacon.

Delete the orphan @hanzo/analytics@0.1.0 thin dup (pkgs/capture, a
leftover of the @hanzo/capture -> @hanzo/event rename that nothing
imports) so the repo carries exactly one telemetry client.

VERSION 0.2.0 -> 0.3.0.
2026-07-22 18:08:55 -07:00
Hanzo AI 9a0badbdc6 @hanzo/ui: canonical cross-surface list (surfaces.data) + bot/chat + current-aware AppHeader
ONE framework-agnostic surfaces.data module (id doubles as icon key) is now the
single source every launcher consumes. Adds hanzo.bot + hanzo.chat, collapses the
redundant cloud/console pair, renders a distinct per-surface icon, and omits the
current surface (no self-link). Publishes 8.0.6.
2026-07-21 23:56:10 -07:00
hanzo-dev 3e4db1de3b merge(feat/analytics-client): consolidate onto main 2026-07-21 17:19:09 -07:00
Hanzo AI 2328b33558 fix(event): commit the actual @hanzo/event content (prior commit was the bare rename)
afd5f82f renamed the dir but a bad `git add` pathspec dropped the content edits,
so pkgs/event shipped as @hanzo/capture@0.1.1 and `pnpm --filter event` matched
nothing (nothing published). This lands the real change: name @hanzo/event@0.2.0,
the captureError/captureException surface, auto error handlers, React ErrorBoundary,
Exception type, and the 6 error-capture tests (28/28 green locally).
2026-07-20 16:13:01 -07:00
Hanzo AI afd5f82fea feat(event): @hanzo/capture → @hanzo/event — the ONE telemetry client (errors are events)
Rename the capture SDK to @hanzo/event and fold error tracking into it, so ONE
client emits every kind of event — pageview/event/identify/group AND errors — on
one batched stream. The server lenses that one stream into product analytics, web
analytics, and error tracking (insights/analytics/sentry.hanzo.ai). Subsumes
@sentry: no second SDK, no second pipe.

- New 'error' EventKind + Exception type; WireEvent carries the exception.
- Analytics.captureError()/captureException(): normalize any throwable, emit a
  type:'error' event, flush at once (a crash may unload the page). Never throws
  back into the app.
- Auto-capture (config captureErrors, default on, browser-only): window.onerror +
  unhandledrejection → error events. The drop-in @sentry replacement.
- React ErrorBoundary (./react): reports render errors React swallows before
  window.onerror sees them — the React half of the replacement.
- 0.1.1 → 0.2.0. 28/28 tests pass (6 new error-capture specs), tsup build green.

Consumers (app/chat/console/hanzo.ai/operator) migrate @hanzo/capture →
@hanzo/event next; @hanzo/capture stops shipping new versions.
2026-07-20 16:05:22 -07:00
Hanzo AI 59f751e21c ui-shadcn 5.9.1: allow framer-motion ^12 beside ^11 — the peerOptional ^11 range ERESOLVE'd any consumer whose tree carries motion@12 (every @hanzo/gui 7.3 surface); the peer is optional and range-only, no code change 2026-07-20 13:38:37 -07:00
Hanzo AI ab8bcd56fa dashboard: finish the pipeline/pipeline -> pipeline/stages rename — repoint the two imports (the rename itself rode the prior commit) 2026-07-20 13:37:52 -07:00
Hanzo AI 589e7cc476 ui 8.0.5: @hanzo/canvas peer floor >=0.1.0 — 0.2.0 was never published, the optional peer was unsatisfiable and ERESOLVE'd every consumer install 2026-07-20 13:37:26 -07:00
Hanzo AI 8e17b10312 commerce 7.6.3: publish the @hanzo/ui-shadcn@^5 peer repin 2026-07-20 13:09:48 -07:00
Hanzo AI 8b8b820c24 ui 8.0.4: shared shell — AppHeader + BrandMark (@hanzo/logo) + OrgSwitcher + orgScope (console contract hoisted, #36); canonical 7-path shaded HanzoMark; v8 lane docs + legacy repin (@hanzo/ui-shadcn@^5) 2026-07-20 13:07:50 -07:00
Hanzo AI b15381f922 products: team CatalogEntry — hanzo.team app, /v1/team, team plan 2026-07-20 10:46:41 -07:00
Hanzo AI b8f3fe36f8 merge @hanzo/products — canonical catalog on the locked 10-category taxonomy (pkgs/products from claude/products-snapshot-reconcile) 2026-07-20 10:43:55 -07:00
hanzo-dev 1ac1a7a49c cd: bind the real /v1/deploy wire — argoproj shape + the wired tree route
Live verification against prod found two contract mismatches the offline build
could not see:

- the fleet bound to ZERO rows: /v1/deploy/applications serves argoproj-shaped
  items (metadata.name, spec.source.*, status.{sync,health}.status,
  status.summary.images[]) but the adapter read only the flat native keys.
  normalizeDeployApp now reads BOTH wires, flat first, then the nested fields.
- the resource tree 404d: the wired route is applications/:name/resource-tree;
  the shorter :name/tree belongs to an unregistered handler.

Live now: 78 applications (69 Healthy / 9 Degraded), env chips, detail with the
real APP->DEPLOYMENT->POD topology, 0 page errors, 0px mobile overflow. The live
payload is pinned as a regression fixture. 38/38 unit, e2e desktop+mobile green.
2026-07-19 18:26:28 -07:00
hanzo-dev c1bb8ca61c ui: Hanzo CD — dedicated cd.hanzo.ai dashboard on @hanzo/gitops
A native Hanzo CD app (Vite + React 19) that replaces the ArgoCD React fork:
fleet list, app detail with the live resource tree, sync/rollback, over cloud
/v1/deploy. Mobile-first, Geist, small mark, 245KB (the fork was ~18MB).
Ships as a static build to the s3://cdn/cd plane cd.hanzo.ai already serves.
2026-07-19 18:14:49 -07:00
hanzo-dev e76773d065 gitops+cd: fix the tree crash, the health/sync mislabel, and the diff OOM
Red review of the cd.hanzo.ai app found three real defects in the SHARED
components (so this hardens the console surfaces too):

- tree: buildResourceGraph seeded `children` lazily per node, so any tree that
  listed a child before its parent — or any ownerRef cycle — dereferenced
  undefined and threw during render. K8s object lists are not topologically
  sorted, so this fired on ordinary data and, with no error boundary, blanked
  the whole dashboard. Pre-seed every id before the edge pass.
- health/sync: the substring fallback up-guessed a BAD state to a GOOD one
  (NotReady/unavailable -> Healthy, notsynced -> Synced). An incident shown
  green is worse than one shown Unknown, so the positive up-guess is gone;
  unrecognized folds to Unknown. foldSync also normalizes separators so the
  canonical out-of-sync folds at the source.
- diff: lineDiff always built the full O(n*m) LCS matrix; a large ConfigMap
  (reachable — not Secret-excluded) froze the tab. Size-guard to a block diff.

Also: an app-level ErrorBoundary so one render throw can never white-screen the
dashboard, client log caps + array-shaped log tolerance, a Secret-kind skip in
the tree, and vitest no longer globs the Playwright specs.

Tests assert the fixed behavior: 35/35 unit (incl. the adversarial fuzz suite
flipped from codifying the bugs), 42/42 gitops, 2/2 e2e desktop+mobile.
2026-07-19 18:14:36 -07:00
hanzo-dev 5308dea0fa feat(cd): dedicated Hanzo CD dashboard on @hanzo/gitops (cd.hanzo.ai)
Replace the ArgoCD React fork (deploy/ui, webpack argo-cd-ui) with a focused,
mobile-first CD dashboard built on the shared Hanzo component packages over the
native cloud CD plane (/v1/deploy). Its job stays: see every operator App CR with
stats · sync · health · resource tree · logs · sync/rollback.

- Vite + React 19 STATIC SPA → dist/ (index.html + login.html + CNAME + assets),
  published to the existing s3://cdn/cd static plane cd.hanzo.ai already serves
  (ingress staticFiles/spaMode; /v1/deploy peeled to cloud). No ingress/backend
  change.
- @hanzo/gitops (framework-free ArgoCD-replacement views): GitopsAppList for the
  fleet; GitopsSyncPanel + GitopsAppTree + GitopsNodeInfo + GitopsRollbackDialog
  composed for the app detail (lazy per-node /resource + /logs). Resolved via Vite
  path alias off the built dist so it builds without a full monorepo install.
- src/lib/adapt.ts maps the real /v1/deploy DTOs into the @hanzo/gitops
  view-models, reusing the package's foldHealth/foldSync (strips [-_] so the
  hyphenated 'out-of-sync' folds to OutOfSync, not Unknown).
- Auth: the admin-console PKCE login.html (ported) → hanzo_iam_token cookie the
  cloud binary validates (SuperAdmin gate); same-origin credentialed /v1/deploy.
- Lean self-contained topbar (Geist, small mark, env scope) in the interim; the
  @hanzo/ui-shadcn shared shell + @hanzo/canvas map are registry-install-gated
  follow-ons (the map already ships green in hanzoai/console).
- Verify: tsc 0; vitest 8/8 (adapter + the sync-fold fix); playwright render +
  mobile (no horizontal body scroll at 390).
2026-07-19 12:52:11 -07:00
zandhanzo-dev c56477b4d4 feat(models): ModelSelector — THE unified family-grouped model picker (5.9.0)
One selector for hanzo.app/chat/console: family groups (Enso, Zen,
Anthropic, OpenAI first), cmdk search, premium marks, context hints,
fetchModelCatalog off /v1/models. catalog.ts pure + SSR-safe.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-19 08:49:37 -07:00
zandhanzo-dev 64695e9747 feat(ui/models): unified ModelSelector + catalog helpers
Adds the one model selector for every Hanzo app to the existing ./models
subpath: hanzo.chat-style family grouping in a compact Radix Popover + cmdk
Command combobox (grouped sections, family headers, premium markers, context
suffixes, keyboard nav, type-to-filter search over 12 models). Monochrome,
dark-first, data-agnostic.

- catalog.ts: ModelCatalogEntry, familyOf, groupModelsByFamily, isChatModel,
  filterChatModels, fetchModelCatalog (pure, SSR-safe, no caching/state)
- ModelSelector.tsx: ModelSelector + ModelSelectorProps
- wired through src/models/index.ts; version 5.8.0 -> 5.9.0 (minor, additive)

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-19 08:47:37 -07:00
zandhanzo-dev 0bb83f9e20 feat(ui): @hanzo/ui/account — unified self-service account surfaces (5.8.0)
CreditsMeter, UsagePanel, PlanCard/PlansGrid, PaymentMethods, InvoiceTable,
OrgIdentityRow, TeamMembersTable, SettingsSection + shared types. Props-driven,
React 18+19, monochrome. One account UI kit for hanzo.app + hanzo.chat + console.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-18 23:36:52 -07:00
zandhanzo-dev b3d10cc5a7 feat(ui): unified @hanzo/ui/account self-service surfaces (v5.8.0)
One composable, data-agnostic account-surface kit so hanzo.app, hanzo.chat,
and console stop hand-rolling billing/usage/payments/org/team/settings UI and
render it all from @hanzo/ui.

Components (props-driven, no data fetching, monochrome-neutral by default;
semantic color only on invoice status + danger settings):
- CreditsMeter (compact + full, animated monochrome fill)
- UsagePanel (period select, total, empty state)
- PlanCard + PlansGrid (standard 3-tier)
- PaymentMethodRow + PaymentMethodsList
- InvoiceTable (paid/open/failed status pills)
- OrgIdentityRow (emoji|image|initials, sm/md)
- TeamMembersTable (role select, remove, invite row, pending)
- SettingsSection (danger variant)

Shared types + formatters exported from the same subpath. Reuses existing
primitives (Button, Badge, Avatar, Select, Table, DropdownMenu). Wired as the
./account export (tsup entry + package.json exports); builds clean incl. .d.ts.
react peer stays ^18 || ^19 (React 18 Vite + React 19 Next). Additive minor:
5.7.5 -> 5.8.0. Not published — CTO review gate.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-18 23:36:02 -07:00
zandhanzo-dev 785dc4f8bd fix(ui): Button asChild no longer crashes with React.Children.only (v5.7.5)
The Button always rendered two JSX children (a loading-spinner slot + children).
Under asChild, Comp is a Radix Slot which calls React.Children.only on that
2-element array and throws 'expected to receive a single React element child',
crashing the consuming tree. A slotted button can't host an injected spinner
anyway (the child replaces the button), so with asChild we now pass children
through as the single child Slot requires. Non-asChild loading behavior is
unchanged. This kills the whole <Button asChild> crash class for consumers
(hanzo.app hit it on /projects and /dev).

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-18 16:34:36 -07:00
zandhanzo-dev e85c3ec141 docs: @hanzo/ui v8 one-surface subpaths — the 8 newest component kits
Document the canvas/wallet/network/billing/dashboard/usage/gitops/data subpaths
(thin re-exports of their home packages, optional peers) + the pkg/ui publish
caveat, so consoles import every recent component from the single @hanzo/ui.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-15 18:18:00 -07:00
zandhanzo-dev e1d6480396 feat(ui): expose the 8 newest components via @hanzo/ui subpaths + publish lagging homes
Complete the one-surface pattern the @hanzo/ui/gitops subpath started: add thin
re-export subpaths so a console imports every recent component from the single
@hanzo/ui entry while each lives once in its home package (zero duplication,
optional peers — only pulled when the subpath is used):

  @hanzo/ui/canvas    -> @hanzo/canvas         (ProjectCanvas, ServiceNode, DeployTimeline, EnvSwitcher, ServiceDetailDrawer, ServiceStatusBadge)
  @hanzo/ui/wallet    -> @hanzo/ui-shadcn/wallet   (WalletMenu, injectedEvmAdapter [EIP-1193], walletAvailable, ensureEvmNetwork)
  @hanzo/ui/network   -> @hanzo/ui-shadcn/network  (NetworkSwitcher, useNetwork, configureNetworks, HANZO_NETWORKS)
  @hanzo/ui/billing   -> @hanzo/ui-shadcn/billing  (CreditModal)
  @hanzo/ui/dashboard -> @hanzo/dashboard
  @hanzo/ui/usage     -> @hanzo/usage          (UsageMeter, UsageProviderCard, UsageDashboard)

Together with the existing ./gitops and ./data subpaths that is the 8 newest
component kits reachable from @hanzo/ui. Bump @hanzo/ui 8.0.2 -> 8.0.3.

Publish the two lagging homes via ARC (pkgs/* -> publish.yml):
  @hanzo/canvas 0.2.0 -> 0.2.1  (npm was behind at 0.1.0)
  @hanzo/gitops 0.1.0 -> 0.1.1  (first publish)

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-15 17:59:40 -07:00
hanzo-dev 493654db0e ui: expose @hanzo/ui/gitops subpath (re-export @hanzo/gitops)
Thin subpath so a console can import from '@hanzo/ui/gitops' while the code lives
once in @hanzo/gitops. Optional peer dependency — only pulled when the subpath is
used.
2026-07-14 16:21:03 -07:00
hanzo-dev 3c33e3e459 gitops: demo tree gaps fit static render (all pods visible) 2026-07-14 16:20:31 -07:00
hanzo-dev 8099715023 gitops: static SSR demo (real components over fixtures)
demo/demo.tsx server-renders the sync panel + resource tree + node drill-in
(diff tab) + applications list into a self-contained demo/index.html for visual
review. Reproduce command in the file header.
2026-07-14 16:18:00 -07:00
hanzo-dev 166a7176c6 gitops: fixtures + render tests (tree, node panel, diff, badges, list)
happy-dom + Testing Library render proofs: tree draws a card per resource with
SVG edges + collapse hides pods + selection fires; node panel shows manifest and
switches to diff/events/logs; diff classes add/del + tallies; app list filters by
search. 42 tests green (33 pure + 9 render).
2026-07-14 16:14:58 -07:00
hanzo-dev 3223c7112c gitops: sync/rollback dialogs + app detail shell + public barrel
- Dialog: minimal portal-free modal (backdrop + Escape close)
- SyncPanel (GitopsSyncPanel) + GitopsSyncDialog: action bar + sync options
  (prune, dry-run); effects injected as callbacks
- RollbackDialog (GitopsRollbackDialog): deploy-history picker -> rollback
- ApplicationDetails (GitopsAppDetails): composes sync panel + tree + node
  drawer + rollback, wiring selection between them
- index.ts: the @hanzo/gitops public export contract
Full package typechecks + tsup builds (CJS+ESM+DTS, . and ./pure) green.
2026-07-14 16:12:00 -07:00
hanzo-dev 4acc825a82 gitops: applications list (GitopsAppList)
Table/grid of applications with name/project, sync + health badges, revision,
source and age. Search across name/project/namespace/source, multi-select
health + sync filters, sortable columns (name/health/sync/age via the pure
ranks). Data-prop-driven; row-open callback.
2026-07-14 16:09:31 -07:00
hanzo-dev bba35bbd25 gitops: diff view + node info panel (manifest/diff/events/logs)
- DiffView (GitopsDiffView): two-gutter colorized diff from a unified string
  (classifyDiff) or raw live/desired manifests (lineDiff), +/- stats header
- NodeInfoPanel (GitopsNodeInfo): tabbed drill-in over a selected resource —
  live manifest, desired-vs-live diff, events, logs; reuses canvas relativeTime
2026-07-14 16:08:33 -07:00
hanzo-dev 86abf5a066 gitops: status marks, badges, resource node + tree topology
- styles.tsx: one theme-aware stylesheet (GITOPS_CSS + THEME_VARS, --hz-* vars)
- glyphs.tsx: inline-SVG health/sync marks (heart/broken-heart/ghost/pause/
  spinner/check/arrow-up) + category-mapped resource-kind icons (zero icon dep)
- HealthBadge / SyncBadge: the color-coded status pills
- ResourceNode: the node card (kind glyph + name + health + sync marks)
- ResourceTree (GitopsAppTree): SVG-edge topology over buildResourceGraph,
  CSS-transform pan/zoom, collapse toggles, auto-fit; no graph dependency
2026-07-14 16:07:03 -07:00
hanzo-dev a55edecee0 gitops: pure core — health/sync folds, resource-tree layout, diff classing
- types.ts: the data contract (HealthStatus/SyncStatus orthogonal axes, tree,
  managed resource, diff, events, logs, revision history)
- health.ts / sync.ts: foldHealth/foldSync + Argo-hue palettes + rank + rollup
- tree.ts: buildResourceGraph reuses @hanzo/canvas layoutGraph (parentRefs ->
  positioned graph, collapse pruning, normalized origin)
- diff.ts: classifyDiff (unified) + lineDiff (LCS live-vs-desired) + diffStats
- 33 unit tests green; tsc + tsup dts pipeline validated
2026-07-14 16:02:37 -07:00
hanzo-dev 340d66c0a0 gitops: scaffold @hanzo/gitops package (Apache-2.0 port of argo-cd UI)
Presentational, data-prop-driven CD/GitOps React surface. tsup build + vitest,
mirrors pkgs/canvas + pkgs/capture conventions. Reuses @hanzo/canvas/pure
(layoutGraph) for resource-tree topology; no separate graph dependency.
Attribution to argoproj/argo-cd in NOTICE.
2026-07-14 15:56:36 -07:00
hanzo-dev 61aa5c8b8e ci: kill changesets — a semver bump is the one publish trigger
Publishing is now a single step: bump a package's version in its
package.json, merge to main, and publish.yml publishes the changed
@hanzo/* package to npm.

Remove the changeset machinery (.changeset/, changeset-version.js) and
the redundant publish paths — release.yml (version-PR bot),
npm-publish.yml (manual dispatch), publish-on-tag.yml (tag trigger),
prerelease*.yml (betas) — leaving publish.yml as the only path. Drop the
@changesets deps and the `changeset version` scripts; refresh the
CONTRIBUTING and LLM docs.
2026-07-14 14:19:54 -07:00
a18f51798d ci(release): manual-only (stop churny auto-release) (#247)
* ci(release): build only @hanzo/capture in the publish path (drop shadcn:build — OOM + upstream-fork name)

* fix(changeset): repo shadcn-ui/ui -> hanzoai/ui (changelog-github null.author crash; fork leftover)

* ci(release): manual-only trigger (stop auto version-bump/publish churn; needs npm scope auth)

---------

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-14 12:03:38 -07:00
github-actions[bot]GitHubgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
9fd630bcde chore(release): version packages (#246)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-07-14 11:48:28 -07:00
1d932b5119 fix(changeset): changelog repo -> hanzoai/ui (unblocks version/publish) (#245)
* ci(release): build only @hanzo/capture in the publish path (drop shadcn:build — OOM + upstream-fork name)

* fix(changeset): repo shadcn-ui/ui -> hanzoai/ui (changelog-github null.author crash; fork leftover)

---------

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-14 11:45:05 -07:00
c582dacbd6 ci(release): build only @hanzo/capture in the publish path (drop shadcn:build — OOM + upstream-fork name) (#244)
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-14 11:39:59 -07:00
89451b2973 fix(pkgs/data): pin @hanzogui/config 7.2.2 -> 7.3.0 (7.2.2 unpublished; matches all sibling pkgs) (#243)
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-14 11:32:01 -07:00
fa8974b5db ci(release): pin npm@11 (npm@latest=12 needs node>=22; job pins node20) — keeps OIDC publish (#242)
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-14 11:29:40 -07:00
4a4fe7518d ci(release): fix fork-leftover owner gate shadcn-ui -> hanzoai (unblocks changeset publish) (#241)
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-14 11:27:50 -07:00
316081dd52 @hanzo/capture: shared product-analytics capture client (#240)
* @hanzo/analytics: shared product-analytics capture client

Tiny batched client emitting pageview/event/identify/group to Hanzo Cloud
(/v1/analytics + /v1/tracker) — never to insights-capture directly. First-touch
UTM/referrer/refCode attribution persisted and attached to every event;
beacon-on-unload; dual cookie/bearer auth; SSR-safe; tenant is stamped
server-side (never sent by the client). Ships a framework-agnostic core and a
@hanzo/analytics/react provider + hooks, plus the shared EVENTS/GOALS/COHORTS
vocabulary. 22 unit tests + tsup build (cjs/esm/dts) green.

* rename @hanzo/analytics -> @hanzo/capture (name collision)

The intended name @hanzo/analytics is already a LIVE, hanzoai-owned npm package
(team-manager's, latest 0.6.4, an incompatible providers/Analytics API).
Publishing this new capture client under that name would move the 'latest' tag
onto a different codebase and confuse/​break bare + latest consumers. Renamed to
the free, single-word @hanzo/capture, which also matches cloud's 'capture plane'.
One-line revert if the owner prefers to supersede team-manager instead.

---------

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-14 11:25:13 -07:00
hanzo-dev de5907fd12 rename @hanzo/analytics -> @hanzo/capture (name collision)
The intended name @hanzo/analytics is already a LIVE, hanzoai-owned npm package
(team-manager's, latest 0.6.4, an incompatible providers/Analytics API).
Publishing this new capture client under that name would move the 'latest' tag
onto a different codebase and confuse/​break bare + latest consumers. Renamed to
the free, single-word @hanzo/capture, which also matches cloud's 'capture plane'.
One-line revert if the owner prefers to supersede team-manager instead.
2026-07-14 11:02:17 -07:00
Hanzo AI 89c2aab767 refactor(ui): drop orphaned usage kits — usage has ONE home (@hanzo/usage)
Zero-consumer AI-usage duplicates removed so <UsagePanel> in @hanzo/usage is the
single source: @hanzo/ui product usage kit (UsageDashboard/UsageMeter/
UsageProviderCard) and @hanzo/ui-shadcn billing usage-panel. Verified no
remaining references across pkg/pkgs/apps; the panel now lives at
@hanzo/usage/panel (gui/Tamagui) with the DOM <UsageDashboard> at /react.

(Phase-A intent from feat/usage-panel-phase-a, applied onto current main — that
branch sits on the retired v8 line and is not itself mergeable.)
2026-07-14 09:41:30 -07:00
hanzo-dev 0430ec8222 @hanzo/analytics: shared product-analytics capture client
Tiny batched client emitting pageview/event/identify/group to Hanzo Cloud
(/v1/analytics + /v1/tracker) — never to insights-capture directly. First-touch
UTM/referrer/refCode attribution persisted and attached to every event;
beacon-on-unload; dual cookie/bearer auth; SSR-safe; tenant is stamped
server-side (never sent by the client). Ships a framework-agnostic core and a
@hanzo/analytics/react provider + hooks, plus the shared EVENTS/GOALS/COHORTS
vocabulary. 22 unit tests + tsup build (cjs/esm/dts) green.
2026-07-13 16:21:27 -07:00
z e3a8f957ba fix: @hanzo/ui .dark --card/--popover oklch(0.045≈#000)→0.06 (#050505) so panels are visible on true-black 2026-07-10 14:15:28 -07:00
z b7320ac01e feat: converge @hanzo/ui .dark to true-black #000 (DESIGN.md §4) — web matches native 2026-07-10 10:19:52 -07:00
zandhanzo-dev 7d3fed557f feat(data): @hanzo/data — cross-platform metadata-driven data-app layer
Typed field system (26 types) → record table / card / detail, on @hanzo/gui
(web + native + desktop), shorthand style props, zero Tailwind. The universal
object/field/record/view core for any Base-backed CRM, CMS, or commerce app.
Registry-dispatched (add a type = one registerField call). Ships TS source
(zero-build internal package). tsc --noEmit clean against @hanzo/gui 7.2.2.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-10 10:19:52 -07:00
9bc9ef785e feat(canvas): @hanzo/canvas — Railway-grade PaaS project canvas (#239)
A new source-only @hanzo/gui package: a pannable/zoomable board of service
nodes (ProjectCanvas over @xyflow/react) with live status (ServiceStatusBadge),
metric sparklines (MetricSparkline), deploy timelines (DeployTimeline), an
environment switcher (EnvSwitcher), and a tabbed service detail drawer
(ServiceDetailDrawer) — plus the ServiceNode card and SourceRef/ReplicaPill
primitives. Presentational + data-prop-driven; brand/white-label aware via the
design tokens (semantic status palette overridable per brand). Pure folds
(status normalization, layered graph layout, relative time) are unit-tested
(17 tests). React-free logic re-exported at @hanzo/canvas/pure so data mappers
and their tests never pull in JSX/xyflow.


Co-authored-by: hanzo-dev <dev@hanzo.ai>
2026-07-10 03:17:23 -07:00
04783533c1 feat(network,wallet): shared NetworkSwitcher + WalletMenu — the ONE hanzo.network standard (#238)
* 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).

* 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.

* feat(network,wallet): menuSide prop — menus open upward from footers (v5.7.3)

* 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.

---------

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-08 09:12:11 -07:00
hanzoandz f597098f76 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
e4493ec846 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 0a2979a6f1 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 152a044b03 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 2b3e5125fd 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
4696584876 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 6ca7daed8e 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 27805a336d 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 301eea3569 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 3bc4a98d52 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 a0fa7026e6 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 5028329338 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 86bcfe6dce 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
Hanzo AI dfb4abddb9 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).
2026-07-03 15:54:24 -07:00
Hanzo AI 172f604622 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.
2026-07-03 15:46:45 -07:00
zandhanzo-dev 4b97d20922 fix(data): @hanzogui/config 7.2.2 (unpublished) -> 7.3.0 — unblock workspace install + @hanzo/ui publish
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-03 09:04:33 -07:00
zandhanzo-dev 4b5f28bb75 chore(ui): release @hanzo/ui 5.7.1 — design unification (Basel Grotesk + Geist Mono canonical, DESIGN.md)
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-03 08:56:52 -07:00
zandGitHub c4fb363efe 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
zandhanzo-dev c6e5c94f66 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: Hanzo Dev <dev@hanzo.ai>
2026-07-03 01:53:40 -07:00
Hanzo AI 9f9f5b7083 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.
2026-07-02 23:47:23 -07:00
Hanzo AI 1ea48d96f2 feat(products): add the 10 missing customer products to the snapshot (92 -> 102)
The @hanzo/products snapshot (the shared-registry fallback + commerce seed + the
source docs' gen-services-nav derives from) had drifted behind the canonical console
registry (hanzoai/console src/lib/products/registry.tsx). Ten customer products that
shipped in recent console waves were absent from the snapshot, so docs coverage +
the derived services nav under-counted the real product set.

Purely additive — the existing 92 rows are byte-identical; ten rows appended, each
at the end of its category run (diff is insertions only):
  Observe: open-edition, analytics    Data: records    Platform: apps
  Apps: crm, cms, erp, helpdesk, accessibility, templates

Fields derived to match the existing snapshot shape + the package maps:
brandColor = defaultColorKey(id) (curated pin or FNV-1a hash), iconKey = the
registry icon component, slug=id, route=/id, docsUrl=/docs/services/<id>,
apiPath /v1-prefixed, brands derived from category. All package invariants hold
(icon-drift guard over the 1760-icon vocab, swatch keys, 10 canonical categories,
/v1 apiPaths, unique slug==id). Count assertion + doc-comment 92 -> 102.

vitest 90/90, tsc --noEmit clean.
2026-07-02 23:28:26 -07:00
Hanzo AI b475beb2d9 feat(products): @hanzo/products canonical catalog on the locked 10-category taxonomy
The single source of truth for the Hanzo product taxonomy/icons/colors that
console, docs, site, and pricing all derive from — committed on the CTO-locked
10-category cut so every surface groups by ONE axis.

CATEGORY_ORDER (13 -> 10): AI · Compute · Data · Network · Security · Observe ·
Platform · Web3 · Apps · Commerce. The three cuts:
  - Training -> AI          (finetuning, kubeflow — AI training)
  - Dev -> Platform         (cli, sdks, api, integrations, ide, desktop, api-keys —
                             the platform's developer surface)
  - Settings -> removed     (settings/team/profile are account/avatar-menu items,
                             not products — dropped from the catalog grid)

Snapshot: 92 products (95 - 3 dropped); every row's brands[] re-derived from
category (never hand-authored — the drift-killer). Sovereign brand scope
(lux/zoo/pars) follows the merge: Web3 · Network · Security · Platform, so the
chains keep CLI/SDKs/API keys; account settings move to the avatar menu.

types/categories/brands/docs/snapshot + LLM.md updated; tests assert the new
canonical set (CATEGORY_ORDER.length === 10, none of Training/Dev/Settings
remain, sovereign+hanzo-only scopes partition the 10). 90 tests green,
tsc --noEmit clean, tsup build OK.
2026-07-01 22:52:52 -07:00
6649df2dee feat(data): every field type editable — relation/files/links/json/fullName/address inputs (#232)
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: z <z@zeekay.io>
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-01 18:45:40 -07:00
zandhanzo-dev bf370dd986 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: Hanzo Dev <dev@hanzo.ai>
2026-07-01 18:45:23 -07:00
zandhanzo-dev f23df126e0 test(data): field registry + theme tests (@hanzo/data was untested)
9 bun tests / 64 assertions on the pure core: registry dispatch (register/
retrieve/override/read-only-omits-Input/enumerate) + theme (all 9 tag colors
legible, tagTone fallback, surface tokens = Hanzo zinc-on-black). No @hanzo/gui
needed (type-only imports). Adds `test` script.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-06-30 23:03:56 -07:00
zandhanzo-dev 4fb384e5ca refactor(data): canonical createGui (not createHanzogui) + GuiCustomConfig augmentation
Match the console pattern: createGui + gui.d.ts augmenting @hanzogui/web's
GuiCustomConfig, pinned to @hanzo/gui 7.3.0 (which renamed createHanzogui →
createGui). Shorthand props typecheck clean.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-06-30 15:35:12 -07:00
zandhanzo-dev e9bd15578f feat(data): @hanzo/data — cross-platform metadata-driven data-app layer
Typed field system (26 types) → record table / card / detail, on @hanzo/gui
(web + native + desktop), shorthand style props, zero Tailwind. The universal
object/field/record/view core for any Base-backed CRM, CMS, or commerce app.
Registry-dispatched (add a type = one registerField call). Ships TS source
(zero-build internal package). tsc --noEmit clean against @hanzo/gui 7.2.2.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-06-30 15:13:34 -07:00
z f1bbaa8f02 docs(brand): add hero banner 2026-06-28 20:07:59 -07:00
z 104ba324c2 chore(brand): dynamic hero banner 2026-06-28 20:07:57 -07:00
zeekay e93640365a ci(publish): use canonical org NPM_TOKEN (stale repo NPM_AUTH_TOKEN → E401)
The repo-level NPM_AUTH_TOKEN (2026-03-29) shadowed the org token and was
expired → 'npm error 401 Unauthorized' on every tag publish. Converge on the
org-level NPM_TOKEN (2026-06-18, the one @hanzo/iam published 0.13.0 with).
One token, one way.
2026-06-25 00:47:02 -07:00
zeekay 6a92e279f8 ci: drop cache:'pnpm' from remaining workflows (no committed lockfile) 2026-06-24 23:38:22 -07:00
zeekay 4d29f67eaa ci: drop broken cache:'pnpm' (no committed lockfile) — unblock @hanzo/ui publish 2026-06-24 23:37:37 -07:00
2f7f7ce8cb feat(auth): composable <SignIn> surface — password + social + web3, brand-neutral (#231)
@hanzo/ui becomes the PRESENTATION layer over @hanzo/iam's mechanism. Atomic,
composable, knows nothing of token exchange — it just starts a login per method.

- <SignIn providers={[...]}> composes <PasswordForm> + one <SocialButton> per
  provider + <Web3Connect>. providers is the ONLY app-level knob.
- <SocialButton provider> delegates to startIamLogin with the provider knob
  (rides as &provider on /v1/iam/oauth/authorize); apps never register per-app
  Google/GitHub clients. Composition escape hatch: inject onLogin (e.g. the
  @hanzo/iam SDK's startLogin) so @hanzo/ui stays dependency-light.
- buildIamAuthorizeUrl adds the provider knob to iam.ts, mirroring the SDK.
- IAMLoginButton de-hexed: brand via CSS tokens (bg-primary, border-input, …),
  not literals. Every atom is monochrome/brand-neutral by construction.
- demo/sign-in-demo.tsx: the CONFIGURATION layer — zero auth code, wires
  <SignIn> to the @hanzo/iam SDK (startLogin + loginWithPassword).
- auth.test.tsx (10 cases, vitest+happy-dom): authorize URL carries PKCE-S256 +
  provider hint for google/github/web3, no /api/; <SignIn> renders a method per
  provider; rendered markup has zero hex (brand-neutral); <SocialButton>
  delegates to the injected starter with the provider knob.
- declare happy-dom (vitest env, was referenced but undeclared).

Co-authored-by: zeekay <z@zeekay.io>
2026-06-24 19:18:10 -07:00
11e8e64a81 feat(auth): canonical IAM OIDC paths + PKCE-S256 for @hanzo/ui auth (HIP-0111) (#230)
The zero-dep auth components hand-rolled the OAuth authorize URL against the
legacy '/login/oauth/authorize' path WITHOUT PKCE, and the shell hook fetched
userinfo from '/api/userinfo'. Both violate HIP-0111 (canonical paths are
'/v1/iam/oauth/*'; PKCE S256 is always required).

- new auth/iam.ts: one canonical helper (IAM_OIDC_PATHS mirroring @hanzo/iam's
  OIDC_PATHS; startIamLogin() does authorization-code + PKCE-S256 to
  /v1/iam/oauth/authorize). One way to start a login.
- IAMLoginButton + AuthGuard: route through startIamLogin() instead of
  hand-rolling a PKCE-less authorize URL on the legacy path.
- useHanzoAuth: /api/userinfo -> /v1/iam/oauth/userinfo.

@hanzo/ui stays dependency-light, so iam.ts mirrors the SDK's path contract
byte-for-byte rather than pulling in @hanzo/iam; identical endpoints, PKCE on.

Co-authored-by: z <z@zeekay.io>
2026-06-24 19:15:49 -07:00
f0ce43ac73 fix(ui): resolve React #418 hydration mismatch in command menu (#229)
The mod-key glyph was computed during render via isMacOS() (window.navigator),
so the static-export SSR produced 'Ctrl' while the macOS client hydrated '⌘' on
the same <span>, tripping React error #418 (hydration text mismatch) on every
page (CommandMenu is in the global SiteHeader).

Start modKey from the SSR-stable 'Ctrl' and upgrade to the platform glyph in a
client-only mount effect, so SSR and first hydration render agree. Verified via
CDP: pre-fix decoder showed args[]=text with the exact Ctrl->⌘ text node.

Co-authored-by: Hanzo CTO <ai@hanzo.ai>
2026-06-23 18:30:44 -07:00
hanzo-dev e608d02295 chore: restore shadcn/ui attribution (NOTICE), OSS compliance 2026-06-21 07:19:40 -07:00
f2f9f3a2e4 ci: run on self-hosted ARC pool (hanzo-build-linux-amd64/deploy), not GitHub-hosted (#225)
Co-authored-by: zeekay <z@hanzo.ai>
2026-06-19 20:39:27 -07:00
Artem AshandGitHub a3af2102df decouple @hanzogui, standardize on 'pkgs/', etc (#228)
* refactor: decouple @hanzo/ui from hanzogui

  Make @hanzo/ui shadcn-only — no hanzogui/Tamagui coupling.

  - remove primitives/bases (gui/admin/svelte/vue) re-exports
  - drop @hanzogui peerDependencies + peerDependenciesMeta entries
  - add check-no-hanzogui guard, wired into build
  - fix latent NodeJS.Timeout types to keep the build green

* refactor: remove duplicate @hanzo/brand from the monorepo

  @hanzo/brand is owned by the standalone hanzoai/brand (the npm-canonical
  source); this monorepo's copy was unused and had drifted.

  - delete pkg/brand
  - add check-no-brand-pkg guard (wired into `check`) so it can't reappear

* refactor(ui): stop importing from the app; use own cn util

  pkg/ui pulled `cn` from @/lib/utils (../../app) — an inverted dependency on
  the consuming app. Point the animation components at pkg/ui's own cn and
  drop the @/app, @/registry, @/lib tsconfig path aliases.

* fix(ui): type errors; stop tracking the root lockfile

  - ModelCard: lucide-react dropped the Github icon → use Code
  - drawer: annotate DrawerTrigger/DrawerClose (radix type portability)
  - untrack root pnpm-lock.yaml; CI → --no-frozen-lockfile

* release: bump @hanzo/ui to 5.7.0

* refactor: rename pkg/ → pkgs/, merge packages/ into it

  standardize on 'pkgs/': move pkg/* and packages/* into pkgs/.
  Update workspace globs, CI, scripts, and config references accordingly.
2026-06-19 15:30:30 -10:00
ef14d2a120 feat(commerce): add server-side usage-metering hook (@hanzo/commerce/metering) (#227)
The TypeScript counterpart of github.com/hanzoai/go-sdk/metering — the one
way every Hanzo product meters usage to commerce (the billing source of
truth) so everything can be paid for, not just the LLM/cloud path. Shares
an identical wire contract with the Go client.

- Metering class composes the existing Commerce client (no HTTP dup):
  authorize() pre-request balance gate (fail-closed by default; 402 vs 503),
  record() post-request usage write. tierAware gates on effectiveAvailable
  (prepaid + included plan allotment).
- S2S auth: Authorization: Bearer COMMERCE_SERVICE_TOKEN (KMS-sourced) +
  X-IAM-Org-Id. Metering.fromEnv() for canonical env wiring.
- identityFromHeaders(): reads gateway-minted X-User-Id/X-Org-Id.
- client.ts: getTier() + custom-headers support on request/getBalance/
  addUsageRecord (DRY enablers for the S2S org header).
- 14 contract tests (mock fetch) mirroring the Go suite; isolated tsc clean.
- exports: ./metering ; index re-exports ; version 7.6.1 -> 7.6.2.

Refs universe task #28.

Co-authored-by: Antje Worring <worringantje@gmail.com>
2026-06-19 10:22:54 -07:00
Artem Ash 964645890b added publish script 2026-06-18 18:49:05 -10:00
8792c9d521 feat(navigation): surface web3.hanzo.ai in the Hanzo app switcher (#226)
The Hanzo cross-app console (the shared header / app switcher rendered by
@hanzo/ui across console, platform, billing, chat, etc.) is driven by a
single canonical registry in navigation/hanzo-shell/types.ts. The
bootnode-powered chain orchestration surface at web3.hanzo.ai was live but
absent from that registry, so it never appeared in the switcher.

Add "Web3" as an Infrastructure-category app, threaded through every
structure that enumerates the registry so the list stays orthogonal:

- DEFAULT_HANZO_APPS: the static hanzo.ai default list.
- OrgDomains type + all four ORG_DOMAINS maps (hanzo, lux, zoo, pars):
  white-label by org domain — web3.hanzo.ai / web3.lux.network /
  web3.zoo.ngo / web3.pars.network.
- getAppsForOrg(): the org-aware URL builder.
- AppSwitcher APP_GROUPS: place "web3" in the Infrastructure section
  (between cloud and storage) so it renders grouped, not under "Other".

No icon is set — the switcher renders label + description only; every
existing entry is icon-less, so this matches the one established pattern.

Description: "Deploy & manage blockchain validators across Bitcoin,
Ethereum, Solana, Lux, and any Lux-derived L1".

Co-authored-by: zeekay <z@zeekay.io>
2026-06-18 20:47:28 -07:00
c48174c395 chore(deps): vite 8 + plugin-react 6, fumadocs 16.10.3, dep sweep (#224)
Land the Dependabot dependency upgrades with real migration + build
verification.

templates/vite-monorepo (standalone workspace):
- vite 7.3.1 -> ^8.0.16 (closes #151)
- @vitejs/plugin-react 5.1.4 -> ^6.0.2 (closes #143)
- @types/node -> ^25.5.0 (closes #135)
  vite.config needed no migration (only react()+tailwindcss() plugins);
  verified `tsc -b && vite build` under Node 26 -> "vite v8.0.16 ... built".

apps/v4 (docs site):
- fumadocs-ui 16.0.5 -> 16.10.3, fumadocs-core 16.0.5 -> 16.10.3 (closes #119)
- fumadocs-mdx 13.0.2 -> 15.0.12, fumadocs-docgen 2.0.0 -> 3.0.10 (lockstep)
- unist-builder 3.0.0 -> 4.0.0 (closes #122)
  Migration: fumadocs-mdx 15 splits generated output into .source/server.ts
  (no barrel index.ts), so lib/source.ts imports `docs` from "@/.source/server".
  Verified full `next build` -> 1023 static routes prerendered (205 /docs/*).

pkg/ui:
- @next/third-parties 16.2.1 -> ^16.2.7 (closes #120); verified tsup build.

packages/shadcn:
- @dotenvx/dotenvx 1.48.4 -> ^1.73.1 (closes #118); verified tsup build.

app:
- puppeteer 24.40.0 -> ^25.1.0 (lockstep with root, closes #121);
  verified import + launch API under Node 26.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-06-17 04:37:47 -07:00
hanzo-dev ae2728f622 chore: update 2026-06-10 14:11:16 -07:00
hanzo-dev eda304c0ca cleanup: remove AI-slop summary / status / plan / report files
Reverts violations of the durable rule that current-state docs belong
in LLM.md and history belongs in git log. Removed files were session
handoffs, agent-style "complete success" / "1000%" reports, dated
audit dumps, and stub NOTES.
2026-06-07 13:36:17 -07:00
hanzo-dev b4ef94c02c chore: drop stale .disabled files
sync-forks.yml.disabled and market-overview-alt.mdx.disabled are fossilized
copies left over from earlier work. Stale .disabled files in a tracked tree
are dead code that lives forever; either re-enable or delete.
2026-06-07 13:36:17 -07:00
hanzo-devandGitHub 9e74a52323 Merge pull request #117 from hanzoai/dependabot/npm_and_yarn/faker-js/faker-10.3.0
chore(deps): bump @faker-js/faker from 10.3.0 to 10.4.0
2026-06-04 01:33:14 -07:00
hanzo-devandGitHub 1a9f96ea44 Merge pull request #124 from hanzoai/dependabot/npm_and_yarn/unist-util-visit-5.1.0
chore(deps-dev): bump unist-util-visit from 4.1.2 to 5.1.0
2026-06-04 01:33:02 -07:00
hanzo-devandGitHub 3a055a5938 Merge pull request #126 from hanzoai/dependabot/npm_and_yarn/templates/vite-app/eslint/js-10.0.1
chore(deps-dev): bump @eslint/js from 9.39.4 to 10.0.1 in /templates/vite-app
2026-06-04 01:31:54 -07:00
hanzo-devandGitHub da7d0f14db Merge pull request #137 from hanzoai/dependabot/npm_and_yarn/templates/react-router-app/react-router/serve-7.13.2
chore(deps): bump @react-router/serve from 7.13.1 to 7.16.0 in /templates/react-router-app
2026-06-04 01:31:48 -07:00
hanzo-devandGitHub 3edaaa2f53 Merge pull request #140 from hanzoai/dependabot/npm_and_yarn/templates/astro-monorepo/globals-17.4.0
chore(deps-dev): bump globals from 14.0.0 to 17.6.0 in /templates/astro-monorepo
2026-06-04 01:31:44 -07:00
hanzo-devandGitHub c880dd98d4 Merge pull request #148 from hanzoai/dependabot/npm_and_yarn/templates/react-router-monorepo/react-router/node-7.13.2
chore(deps): bump @react-router/node from 7.12.0 to 7.16.0 in /templates/react-router-monorepo
2026-06-04 01:31:39 -07:00
hanzo-devandGitHub 7eecef3fcb Merge pull request #152 from hanzoai/dependabot/npm_and_yarn/templates/next-monorepo/eslint-plugin-turbo-2.8.20
chore(deps-dev): bump eslint-plugin-turbo from 2.8.7 to 2.9.16 in /templates/next-monorepo
2026-06-04 01:31:33 -07:00
hanzo-devandGitHub cdfdad6561 Merge pull request #154 from hanzoai/dependabot/npm_and_yarn/templates/start-app/typescript-6.0.2
chore(deps-dev): bump typescript from 5.9.3 to 6.0.3 in /templates/start-app
2026-06-04 01:31:30 -07:00
hanzo-devandGitHub b10bff252f Merge pull request #161 from hanzoai/dependabot/npm_and_yarn/templates/react-router-monorepo/vite-8.0.2
chore(deps-dev): bump vite from 7.3.1 to 8.0.16 in /templates/react-router-monorepo
2026-06-04 01:31:23 -07:00
hanzo-devandGitHub 6f7d18e9a9 Merge pull request #163 from hanzoai/dependabot/npm_and_yarn/templates/next-monorepo/typescript-eslint/parser-8.57.2
chore(deps-dev): bump @typescript-eslint/parser from 8.55.0 to 8.60.1 in /templates/next-monorepo
2026-06-04 01:31:18 -07:00
hanzo-devandGitHub 5330bd72cb Merge pull request #165 from hanzoai/dependabot/npm_and_yarn/templates/start-monorepo/vite-tsconfig-paths-6.1.1
chore(deps): bump vite-tsconfig-paths from 5.1.4 to 6.1.1 in /templates/start-monorepo
2026-06-04 01:31:15 -07:00
hanzo-devandGitHub f5c7e74bc6 Merge pull request #171 from hanzoai/dependabot/npm_and_yarn/templates/start-app/vitest-4.1.2
chore(deps-dev): bump vitest from 2.1.9 to 4.1.8 in /templates/start-app
2026-06-04 01:30:54 -07:00
hanzo-devandGitHub aec505578e Merge pull request #174 from hanzoai/dependabot/npm_and_yarn/templates/next-app/eslint-10.2.0
chore(deps-dev): bump eslint from 10.1.0 to 10.4.1 in /templates/next-app
2026-06-04 01:30:43 -07:00
dependabot[bot]andGitHub 5428e2207d chore(deps): bump @faker-js/faker from 10.3.0 to 10.4.0
Bumps [@faker-js/faker](https://github.com/faker-js/faker) from 10.3.0 to 10.4.0.
- [Release notes](https://github.com/faker-js/faker/releases)
- [Changelog](https://github.com/faker-js/faker/blob/next/CHANGELOG.md)
- [Commits](https://github.com/faker-js/faker/compare/v10.3.0...v10.4.0)

---
updated-dependencies:
- dependency-name: "@faker-js/faker"
  dependency-version: 10.3.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-04 08:14:02 +00:00
dependabot[bot]andGitHub ab39f938a4 chore(deps-dev): bump unist-util-visit from 4.1.2 to 5.1.0
Bumps [unist-util-visit](https://github.com/syntax-tree/unist-util-visit) from 4.1.2 to 5.1.0.
- [Release notes](https://github.com/syntax-tree/unist-util-visit/releases)
- [Commits](https://github.com/syntax-tree/unist-util-visit/compare/4.1.2...5.1.0)

---
updated-dependencies:
- dependency-name: unist-util-visit
  dependency-version: 5.1.0
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-04 08:13:41 +00:00
dependabot[bot]andGitHub 7950a33e88 chore(deps): bump @react-router/node in /templates/react-router-monorepo
Bumps [@react-router/node](https://github.com/remix-run/react-router/tree/HEAD/packages/react-router-node) from 7.12.0 to 7.16.0.
- [Release notes](https://github.com/remix-run/react-router/releases)
- [Changelog](https://github.com/remix-run/react-router/blob/main/packages/react-router-node/CHANGELOG.md)
- [Commits](https://github.com/remix-run/react-router/commits/@react-router/node@7.16.0/packages/react-router-node)

---
updated-dependencies:
- dependency-name: "@react-router/node"
  dependency-version: 7.13.2
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-04 08:11:32 +00:00
dependabot[bot]andGitHub 0f7c62d61f chore(deps-dev): bump @eslint/js in /templates/vite-app
Bumps [@eslint/js](https://github.com/eslint/eslint/tree/HEAD/packages/js) from 9.39.4 to 10.0.1.
- [Release notes](https://github.com/eslint/eslint/releases)
- [Commits](https://github.com/eslint/eslint/commits/v10.0.1/packages/js)

---
updated-dependencies:
- dependency-name: "@eslint/js"
  dependency-version: 10.0.1
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-04 08:11:21 +00:00
dependabot[bot]andGitHub 9264c8136f chore(deps): bump @react-router/serve in /templates/react-router-app
Bumps [@react-router/serve](https://github.com/remix-run/react-router/tree/HEAD/packages/react-router-serve) from 7.13.1 to 7.16.0.
- [Release notes](https://github.com/remix-run/react-router/releases)
- [Changelog](https://github.com/remix-run/react-router/blob/main/packages/react-router-serve/CHANGELOG.md)
- [Commits](https://github.com/remix-run/react-router/commits/@react-router/serve@7.16.0/packages/react-router-serve)

---
updated-dependencies:
- dependency-name: "@react-router/serve"
  dependency-version: 7.13.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-04 08:11:11 +00:00
dependabot[bot]andGitHub ff35fe92fd chore(deps-dev): bump globals in /templates/astro-monorepo
Bumps [globals](https://github.com/sindresorhus/globals) from 14.0.0 to 17.6.0.
- [Release notes](https://github.com/sindresorhus/globals/releases)
- [Commits](https://github.com/sindresorhus/globals/compare/v14.0.0...v17.6.0)

---
updated-dependencies:
- dependency-name: globals
  dependency-version: 17.4.0
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-04 08:11:10 +00:00
dependabot[bot]andGitHub 8d46368562 chore(deps-dev): bump eslint-plugin-turbo in /templates/next-monorepo
Bumps [eslint-plugin-turbo](https://github.com/vercel/turborepo/tree/HEAD/packages/eslint-plugin-turbo) from 2.8.7 to 2.9.16.
- [Release notes](https://github.com/vercel/turborepo/releases)
- [Changelog](https://github.com/vercel/turborepo/blob/main/RELEASE.md)
- [Commits](https://github.com/vercel/turborepo/commits/v2.9.16/packages/eslint-plugin-turbo)

---
updated-dependencies:
- dependency-name: eslint-plugin-turbo
  dependency-version: 2.8.20
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-04 08:09:20 +00:00
dependabot[bot]andGitHub 1bca918811 chore(deps-dev): bump typescript in /templates/start-app
Bumps [typescript](https://github.com/microsoft/TypeScript) from 5.9.3 to 6.0.3.
- [Release notes](https://github.com/microsoft/TypeScript/releases)
- [Commits](https://github.com/microsoft/TypeScript/compare/v5.9.3...v6.0.3)

---
updated-dependencies:
- dependency-name: typescript
  dependency-version: 6.0.2
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-04 08:08:51 +00:00
dependabot[bot]andGitHub 8d9f53596e chore(deps-dev): bump vite in /templates/react-router-monorepo
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 7.3.1 to 8.0.16.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v8.0.16/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-version: 8.0.2
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-04 08:08:23 +00:00
dependabot[bot]andGitHub e38d695966 chore(deps): bump vite-tsconfig-paths in /templates/start-monorepo
Bumps [vite-tsconfig-paths](https://github.com/aleclarson/vite-tsconfig-paths) from 5.1.4 to 6.1.1.
- [Release notes](https://github.com/aleclarson/vite-tsconfig-paths/releases)
- [Commits](https://github.com/aleclarson/vite-tsconfig-paths/compare/v5.1.4...v6.1.1)

---
updated-dependencies:
- dependency-name: vite-tsconfig-paths
  dependency-version: 6.1.1
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-04 08:07:55 +00:00
dependabot[bot]andGitHub 207f2c6707 chore(deps-dev): bump @typescript-eslint/parser
Bumps [@typescript-eslint/parser](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/parser) from 8.55.0 to 8.60.1.
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/parser/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.60.1/packages/parser)

---
updated-dependencies:
- dependency-name: "@typescript-eslint/parser"
  dependency-version: 8.57.2
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-04 08:06:04 +00:00
dependabot[bot]andGitHub a43112c276 chore(deps-dev): bump eslint in /templates/next-app
Bumps [eslint](https://github.com/eslint/eslint) from 10.1.0 to 10.4.1.
- [Release notes](https://github.com/eslint/eslint/releases)
- [Commits](https://github.com/eslint/eslint/compare/v10.1.0...v10.4.1)

---
updated-dependencies:
- dependency-name: eslint
  dependency-version: 10.2.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-04 08:05:12 +00:00
dependabot[bot]andGitHub 2df4ecfd08 chore(deps-dev): bump vitest from 2.1.9 to 4.1.8 in /templates/start-app
Bumps [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest) from 2.1.9 to 4.1.8.
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Changelog](https://github.com/vitest-dev/vitest/blob/main/docs/releases.md)
- [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.8/packages/vitest)

---
updated-dependencies:
- dependency-name: vitest
  dependency-version: 4.1.2
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-04 08:05:11 +00:00
hanzo-devandGitHub 6c59003ab9 Merge pull request #125 from hanzoai/dependabot/npm_and_yarn/templates/react-router-app/typescript-6.0.2
chore(deps-dev): bump typescript from 5.9.3 to 6.0.2 in /templates/react-router-app
2026-06-03 13:15:36 -07:00
hanzo-devandGitHub 64e252063a Merge pull request #129 from hanzoai/dependabot/npm_and_yarn/templates/astro-monorepo/astro-6.0.8
chore(deps): bump astro from 5.18.1 to 6.0.8 in /templates/astro-monorepo
2026-06-03 13:15:30 -07:00
hanzo-devandGitHub 39a61da463 Merge pull request #128 from hanzoai/dependabot/npm_and_yarn/templates/astro-app/astrojs/react-5.0.1
chore(deps): bump @astrojs/react from 4.4.2 to 5.0.1 in /templates/astro-app
2026-06-03 13:15:26 -07:00
hanzo-devandGitHub e6fb02c67e Merge pull request #127 from hanzoai/dependabot/npm_and_yarn/templates/next-app/typescript-6.0.2
chore(deps-dev): bump typescript from 5.9.3 to 6.0.2 in /templates/next-app
2026-06-03 13:15:22 -07:00
hanzo-devandGitHub 8c913b7864 Merge pull request #130 from hanzoai/dependabot/npm_and_yarn/templates/vite-app/typescript-6.0.2
chore(deps-dev): bump typescript from 5.9.3 to 6.0.2 in /templates/vite-app
2026-06-03 13:15:18 -07:00
hanzo-devandGitHub fbf1226a1a Merge pull request #132 from hanzoai/dependabot/npm_and_yarn/templates/astro-monorepo/eslint/js-10.0.1
chore(deps-dev): bump @eslint/js from 9.39.4 to 10.0.1 in /templates/astro-monorepo
2026-06-03 13:15:14 -07:00
hanzo-devandGitHub fdb785c32b Merge pull request #131 from hanzoai/dependabot/npm_and_yarn/templates/react-router-app/react-router-7.13.2
chore(deps): bump react-router from 7.13.1 to 7.13.2 in /templates/react-router-app
2026-06-03 13:15:10 -07:00
hanzo-devandGitHub f70b82345d Merge pull request #133 from hanzoai/dependabot/npm_and_yarn/templates/vite-app/globals-17.4.0
chore(deps-dev): bump globals from 14.0.0 to 17.4.0 in /templates/vite-app
2026-06-03 13:15:06 -07:00
hanzo-devandGitHub a2d482b662 Merge pull request #134 from hanzoai/dependabot/npm_and_yarn/templates/astro-app/eslint/js-10.0.1
chore(deps-dev): bump @eslint/js from 9.39.4 to 10.0.1 in /templates/astro-app
2026-06-03 13:15:02 -07:00
hanzo-devandGitHub 40b75f0484 Merge pull request #136 from hanzoai/dependabot/npm_and_yarn/templates/next-monorepo/zod-4.3.6
chore(deps): bump zod from 3.25.76 to 4.3.6 in /templates/next-monorepo
2026-06-03 13:14:56 -07:00
hanzo-devandGitHub eca7e91fad Merge pull request #141 from hanzoai/dependabot/npm_and_yarn/templates/react-router-app/react-router/node-7.13.2
chore(deps): bump @react-router/node from 7.13.1 to 7.13.2 in /templates/react-router-app
2026-06-03 13:14:47 -07:00
hanzo-devandGitHub df082c7042 Merge pull request #142 from hanzoai/dependabot/npm_and_yarn/templates/astro-app/astro-6.0.8
chore(deps): bump astro from 5.18.1 to 6.0.8 in /templates/astro-app
2026-06-03 13:14:43 -07:00
hanzo-devandGitHub 5df6fe211f Merge pull request #145 from hanzoai/dependabot/npm_and_yarn/templates/astro-app/globals-17.4.0
chore(deps-dev): bump globals from 14.0.0 to 17.4.0 in /templates/astro-app
2026-06-03 13:14:37 -07:00
hanzo-devandGitHub af0cabe1fc Merge pull request #146 from hanzoai/dependabot/npm_and_yarn/templates/astro-monorepo/astrojs/react-5.0.1
chore(deps): bump @astrojs/react from 4.4.2 to 5.0.1 in /templates/astro-monorepo
2026-06-03 13:14:33 -07:00
hanzo-devandGitHub 766ebee45e Merge pull request #147 from hanzoai/dependabot/npm_and_yarn/templates/react-router-app/react-router/dev-7.13.2
chore(deps-dev): bump @react-router/dev from 7.13.1 to 7.13.2 in /templates/react-router-app
2026-06-03 13:14:29 -07:00
hanzo-devandGitHub ac31fc6628 Merge pull request #150 from hanzoai/dependabot/npm_and_yarn/templates/astro-app/typescript-6.0.2
chore(deps-dev): bump typescript from 5.9.3 to 6.0.2 in /templates/astro-app
2026-06-03 13:14:23 -07:00
hanzo-devandGitHub 6f26240758 Merge pull request #149 from hanzoai/dependabot/npm_and_yarn/templates/astro-monorepo/typescript-6.0.2
chore(deps-dev): bump typescript from 5.9.3 to 6.0.2 in /templates/astro-monorepo
2026-06-03 13:14:18 -07:00
hanzo-devandGitHub 8badff6bd7 Merge pull request #157 from hanzoai/dependabot/npm_and_yarn/templates/vite-monorepo/tailwindcss/vite-4.2.2
chore(deps-dev): bump @tailwindcss/vite from 4.2.1 to 4.2.2 in /templates/vite-monorepo
2026-06-03 13:14:05 -07:00
hanzo-devandGitHub d0fa302242 Merge pull request #159 from hanzoai/dependabot/npm_and_yarn/templates/next-monorepo/typescript-6.0.2
chore(deps-dev): bump typescript from 5.9.3 to 6.0.2 in /templates/next-monorepo
2026-06-03 13:13:59 -07:00
hanzo-devandGitHub 7d34b33939 Merge pull request #160 from hanzoai/dependabot/npm_and_yarn/templates/vite-monorepo/zod-4.3.6
chore(deps): bump zod from 3.25.76 to 4.3.6 in /templates/vite-monorepo
2026-06-03 13:13:55 -07:00
hanzo-devandGitHub 3265635e0f Merge pull request #164 from hanzoai/dependabot/npm_and_yarn/templates/react-router-monorepo/react-router-7.13.2
chore(deps): bump react-router from 7.12.0 to 7.13.2 in /templates/react-router-monorepo
2026-06-03 13:13:43 -07:00
hanzo-devandGitHub 70b15baf1f Merge pull request #167 from hanzoai/dependabot/npm_and_yarn/templates/start-monorepo/types/node-25.5.0
chore(deps-dev): bump @types/node from 25.3.0 to 25.5.0 in /templates/start-monorepo
2026-06-03 13:13:37 -07:00
hanzo-devandGitHub 7735a5082a Merge pull request #166 from hanzoai/dependabot/npm_and_yarn/templates/next-monorepo/next-16.2.1
chore(deps): bump next from 16.1.6 to 16.2.1 in /templates/next-monorepo
2026-06-03 13:13:33 -07:00
hanzo-devandGitHub 2ceceb3f90 Merge pull request #168 from hanzoai/dependabot/npm_and_yarn/templates/vite-app/vite-8.0.3
chore(deps-dev): bump vite from 8.0.2 to 8.0.3 in /templates/vite-app
2026-06-03 13:12:49 -07:00
hanzo-devandGitHub 11c3c8d284 Merge pull request #169 from hanzoai/dependabot/npm_and_yarn/templates/start-app/types/node-25.5.0
chore(deps-dev): bump @types/node from 20.19.37 to 25.5.0 in /templates/start-app
2026-06-03 13:12:45 -07:00
hanzo-devandGitHub 4b13dbf5cb Merge pull request #170 from hanzoai/dependabot/npm_and_yarn/templates/vite-app/types/node-25.5.0
chore(deps-dev): bump @types/node from 20.19.37 to 25.5.0 in /templates/vite-app
2026-06-03 13:12:41 -07:00
hanzo-devandGitHub f6dc1032b6 Merge pull request #172 from hanzoai/dependabot/npm_and_yarn/templates/start-app/vite-tsconfig-paths-6.1.1
chore(deps): bump vite-tsconfig-paths from 4.3.2 to 6.1.1 in /templates/start-app
2026-06-03 13:12:35 -07:00
hanzo-devandGitHub 06e07799c0 Merge pull request #173 from hanzoai/dependabot/npm_and_yarn/templates/start-app/vite-8.0.3
chore(deps-dev): bump vite from 8.0.2 to 8.0.3 in /templates/start-app
2026-06-03 13:12:31 -07:00
hanzo-devandGitHub baa78a757e Merge pull request #175 from hanzoai/dependabot/npm_and_yarn/templates/next-app/next-16.2.2
chore(deps): bump next from 16.1.7 to 16.2.2 in /templates/next-app
2026-06-03 13:12:00 -07:00
hanzo-devandGitHub 0900d8c8c2 Merge pull request #176 from hanzoai/dependabot/npm_and_yarn/templates/next-app/eslint-config-next-16.2.2
chore(deps-dev): bump eslint-config-next from 16.1.7 to 16.2.2 in /templates/next-app
2026-06-03 13:11:55 -07:00
hanzo-devandGitHub 97e54c7854 Merge pull request #177 from hanzoai/dependabot/npm_and_yarn/templates/next-app/types/node-25.5.2
chore(deps-dev): bump @types/node from 20.19.37 to 25.5.2 in /templates/next-app
2026-06-03 13:11:51 -07:00
hanzo-dev 11caebd961 chore(deps): pnpm up --latest --recursive 2026-06-02 11:35:03 -07:00
hanzo-dev faeec92455 chore: brand-neutral cleanup — remove cross-tenant references 2026-05-25 15:14:48 -07:00
hanzo-dev a736e4443c chore: update 2026-05-25 15:14:48 -07:00
hanzo-dev e339b8f8ae chore(brand): scrub Tamagui mentions, use @hanzo/gui v7 / Hanzo GUI
Brand policy: do not reference Tamagui by name on disk. The product is
@hanzo/gui v7 (Hanzo GUI). Internal workspace umbrella is `hanzogui`
(lowercase). Source code imports `from 'hanzogui'`. NPM publish:
@hanzo/gui.

This commit replaces "Tamagui v7" → "Hanzo GUI v7" / "@hanzo/gui v7"
in pkg/ui/BASES.md and pkg/ui/src/primitives/bases/{gui,svelte,vue}/
header docstrings + placeholder error messages.
2026-04-27 13:36:07 -07:00
hanzo-dev de08a2b6b9 chore(brand): scrub Tamagui mentions, use @hanzo/gui v7 / Hanzo GUI
Brand policy: do not reference Tamagui by name on disk. The product is
@hanzo/gui v7 (Hanzo GUI). Internal workspace umbrella is `hanzogui`
(lowercase). Source code imports `from 'hanzogui'`. NPM publish:
@hanzo/gui.

This commit replaces "Tamagui v7" → "Hanzo GUI v7" / "@hanzo/gui v7"
in pkg/ui/BASES.md and pkg/ui/src/primitives/bases/{gui,svelte,vue}/
header docstrings + placeholder error messages.
2026-04-27 13:36:07 -07:00
hanzo-dev 1eaab01b7e feat(@hanzo/ui): bases re-export structure (admin/gui/svelte/vue subpaths)
Add framework-base re-exports under @hanzo/ui/primitives/bases/* so
consumers can swap framework backends without changing imports:

- bases/admin → @hanzogui/admin (Tamagui v7 admin chrome, canonical)
- bases/gui   → hanzogui (Tamagui v7 primitives umbrella)
- bases/svelte → throws (placeholder until Svelte port lands)
- bases/vue    → throws (placeholder until Vue port lands)

Source-of-truth files stay in ~/work/hanzo/gui/ — this package only
re-exports. Component names are identical across bases by contract,
so swapping a base is a one-line import change in consumer code.

Adds @hanzogui/admin, @hanzogui/lucide-icons-2, hanzogui as optional
peer deps. See pkg/ui/BASES.md for the full doc.
2026-04-27 13:33:01 -07:00
hanzo-dev 1c9818a69f feat(@hanzo/ui): bases re-export structure (admin/gui/svelte/vue subpaths)
Add framework-base re-exports under @hanzo/ui/primitives/bases/* so
consumers can swap framework backends without changing imports:

- bases/admin → @hanzogui/admin (Tamagui v7 admin chrome, canonical)
- bases/gui   → hanzogui (Tamagui v7 primitives umbrella)
- bases/svelte → throws (placeholder until Svelte port lands)
- bases/vue    → throws (placeholder until Vue port lands)

Source-of-truth files stay in ~/work/hanzo/gui/ — this package only
re-exports. Component names are identical across bases by contract,
so swapping a base is a one-line import change in consumer code.

Adds @hanzogui/admin, @hanzogui/lucide-icons-2, hanzogui as optional
peer deps. See pkg/ui/BASES.md for the full doc.
2026-04-27 13:33:01 -07:00
hanzo-dev e2d20b79b6 chore(release): @hanzo/brand@1.3.1 2026-04-23 20:29:24 -07:00
hanzo-dev 0ee8b88038 chore(release): @hanzo/brand@1.3.1 2026-04-23 20:29:24 -07:00
hanzo-dev 3465aefabe chore(brand): gitignore dist/, node_modules, turbo cache 2026-04-23 20:28:36 -07:00
hanzo-dev f090863ecd chore(brand): gitignore dist/, node_modules, turbo cache 2026-04-23 20:28:36 -07:00
hanzo-dev b8cfe98dbd fix(brand): separation of concerns — hanzo owns hanzo only
@hanzo/brand was a cross-org registry (hanzo + lux + zoo + pars all
bundled). Brand belongs per-org:
  Zoo       → @zooai/brand       (github.com/zooai/brand)
  Lux       → @luxfi/brand       (github.com/luxfi/brand)
  Liquidity → @partner/brand (github.com/partner/brand)

Delete lux/zoo/pars blocks from orgs.ts, narrow OrgId to 'hanzo',
narrow index.ts exports. 200-line reduction.

No callers broken — nothing in ~/work/{hanzo,lux,zoo,liquidity}
imports @hanzo/ui/brand currently (verified via grep). This is
dead cross-org code being removed.
2026-04-23 19:48:35 -07:00
hanzo-dev 03a7d96cac fix(brand): separation of concerns — hanzo owns hanzo only
@hanzo/brand was a cross-org registry (hanzo + lux + zoo + pars all
bundled). Brand belongs per-org:
  Zoo       → @zooai/brand       (github.com/zooai/brand)
  Lux       → @luxfi/brand       (github.com/luxfi/brand)

Delete lux/zoo/pars blocks from orgs.ts, narrow OrgId to 'hanzo',
narrow index.ts exports. 200-line reduction.

No callers broken — nothing in ~/work/{hanzo,lux,zoo}
imports @hanzo/ui/brand currently (verified via grep). This is
dead cross-org code being removed.
2026-04-23 19:48:35 -07:00
hanzo-dev 7929048d5b fix(brand): correct Zoo canonical identities (IAM=zoo, GitHub=zooai, Twitter=@zoo_labs)
Each surface has a different handle — don't normalize them:
- orgHandle (IAM owner):  zoolabs  → zoo        (IAM org slug is just "zoo")
- githubOrg:              zoolabs  → zooai      (github.com/zooai is canonical)
- social.twitter:         @zoolabs → @zoo_labs  (actual Twitter handle)
- social.github:          zoolabs  → zooai

Legal entity 'Zoo Labs Foundation' unchanged.
2026-04-23 19:43:57 -07:00
Artem AshandGitHub 444a9ae41b admin: extract Tamagui component packages to hanzoai/gui (#178)
pkg/gui/ was a Tamagui subtree living in hanzoai/ui — the 57
  @hanzogui/* components (button, card, dialog, popover, switch, and
  the supporting primitives) belong alongside the rest of the
  @hanzogui/* engine in hanzoai/gui, not here. History for these
  packages was preserved via git filter-repo.

  - Deleted pkg/gui/ (57 packages, 744 files)
  - Removed "pkg/gui/*" entry from pnpm-workspace.yaml
  - Deleted scripts/publish-gui.ts — legacy ad-hoc publisher
    hardcoded to pkg/gui and an ancient version string
  - Narrowed .github/workflows/publish.yml from "@hanzo/*|@hanzogui/*"
    to "@hanzo/*" so this repo no longer tries to publish Tamagui
    components
  - Removed stale "gui/ GUI component packages (@hanzogui/*)" line
    from LLM.md
2026-04-21 15:02:14 -07:00
hanzo-dev 9f0d1f5ad8 fix: only @hanzo/* packages publish from this repo
Mark shadcn as private — upstream, not ours.
Publish workflow scans both pkg/ and packages/, filters by @hanzo/* org.
2026-04-09 08:02:07 -07:00
dependabot[bot]andGitHub 48e98d7df2 chore(deps-dev): bump @types/node in /templates/next-app
Bumps [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) from 20.19.37 to 25.5.2.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

---
updated-dependencies:
- dependency-name: "@types/node"
  dependency-version: 25.5.2
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-06 14:15:08 +00:00
dependabot[bot]andGitHub 4a662481f7 chore(deps-dev): bump eslint-config-next in /templates/next-app
Bumps [eslint-config-next](https://github.com/vercel/next.js/tree/HEAD/packages/eslint-config-next) from 16.1.7 to 16.2.2.
- [Release notes](https://github.com/vercel/next.js/releases)
- [Changelog](https://github.com/vercel/next.js/blob/canary/release.js)
- [Commits](https://github.com/vercel/next.js/commits/v16.2.2/packages/eslint-config-next)

---
updated-dependencies:
- dependency-name: eslint-config-next
  dependency-version: 16.2.2
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-06 14:15:00 +00:00
dependabot[bot]andGitHub 74e9811e6e chore(deps): bump next from 16.1.7 to 16.2.2 in /templates/next-app
Bumps [next](https://github.com/vercel/next.js) from 16.1.7 to 16.2.2.
- [Release notes](https://github.com/vercel/next.js/releases)
- [Changelog](https://github.com/vercel/next.js/blob/canary/release.js)
- [Commits](https://github.com/vercel/next.js/compare/v16.1.7...v16.2.2)

---
updated-dependencies:
- dependency-name: next
  dependency-version: 16.2.2
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-06 14:14:48 +00:00
Darkhorse7starsandhanzo-dev 41bbc4b6e1 chore: bump @hanzo/ui to 5.6.2 (Square card form fix)
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-03-31 15:07:25 -05:00
Darkhorse7starsandhanzo-dev b526752c4c fix(billing): use SquareCardForm in PaymentMethodManager
Replace the server-side CardForm (which calls /card/tokenize and gets
503 due to PCI compliance) with SquareCardForm which uses the Square
Web Payments SDK for client-side card tokenization.

The Square sourceId token is passed as _sourceToken on the PaymentMethod
object so consuming apps can send it to commerce's payment-methods
endpoint for real $1 pre-auth card verification.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-03-31 15:02:52 -05:00
dependabot[bot]andGitHub c1b06e2589 chore(deps-dev): bump vite from 8.0.2 to 8.0.3 in /templates/start-app
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 8.0.2 to 8.0.3.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/create-vite@8.0.3/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-version: 8.0.3
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-30 15:37:24 +00:00
dependabot[bot]andGitHub 2a4e0bf8de chore(deps): bump vite-tsconfig-paths in /templates/start-app
Bumps [vite-tsconfig-paths](https://github.com/aleclarson/vite-tsconfig-paths) from 4.3.2 to 6.1.1.
- [Release notes](https://github.com/aleclarson/vite-tsconfig-paths/releases)
- [Commits](https://github.com/aleclarson/vite-tsconfig-paths/compare/v4.3.2...v6.1.1)

---
updated-dependencies:
- dependency-name: vite-tsconfig-paths
  dependency-version: 6.1.1
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-30 15:37:15 +00:00
dependabot[bot]andGitHub f59d3e6a96 chore(deps-dev): bump @types/node in /templates/vite-app
Bumps [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) from 20.19.37 to 25.5.0.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

---
updated-dependencies:
- dependency-name: "@types/node"
  dependency-version: 25.5.0
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-30 15:36:55 +00:00
dependabot[bot]andGitHub 67ad7f5296 chore(deps-dev): bump @types/node in /templates/start-app
Bumps [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) from 20.19.37 to 25.5.0.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

---
updated-dependencies:
- dependency-name: "@types/node"
  dependency-version: 25.5.0
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-30 15:36:47 +00:00
dependabot[bot]andGitHub b64b133fa3 chore(deps-dev): bump vite from 8.0.2 to 8.0.3 in /templates/vite-app
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 8.0.2 to 8.0.3.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/create-vite@8.0.3/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-version: 8.0.3
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-30 15:36:40 +00:00
hanzo-dev 4ac6a4e0e2 chore: lint publish-on-tag workflow 2026-03-29 17:24:50 -07:00
hanzo-dev 103b8c1f91 fix: rename turbo.json pipeline → tasks (Turbo 2.0) 2026-03-28 14:52:09 -07:00
hanzo-dev c93c0f0a98 chore: gitignore generated static API files (build artifacts) 2026-03-28 14:41:32 -07:00
hanzo-dev d4c5b88519 fix: force-static on all API routes for CF Pages export
Add dynamic=force-static + generateStaticParams to registry routes.
Replace server-only chat/search routes with static stubs.
Remove conflicting /api/registry/route.ts (dir collision).
Update next.config.mjs for CF_PAGES env.
2026-03-28 00:15:05 -07:00
hanzo-dev 98632915fe chore: add generated static registry API files 2026-03-27 21:29:16 -07:00
hanzo-dev 7f19d72d28 fix: add missing AI SDK deps for chat and search routes
The chat API route (app/api/chat/route.ts) imports @ai-sdk/openai-compatible
and ai but neither was declared in app/package.json. Also skip puppeteer
Chrome download in .npmrc since it's only used for optional screenshot
capture and its postinstall failure breaks pnpm install in CI.
2026-03-27 20:28:30 -07:00
hanzo-dev 1fcf9dce94 feat: add AI chat and search API routes for ui.hanzo.ai
- /api/chat: streaming RAG chat about UI components (zen-coder-flash)
- /api/search: proxy to Hanzo Cloud search-docs with publishable key
- lib/search.ts: search config (cloud backend, pk-hanzo-ui-search-2026)

Same pattern as docs.hanzo.ai AI search. Users can chat on the site
and ask questions about components.
2026-03-27 19:31:18 -07:00
hanzo-dev 2515b596b7 feat: add registry API routes and static build for MCP tool
Add Next.js API routes at /api/registry/ for dynamic component queries
(server mode). Add build-registry-api.mts script that generates static
JSON files at /api/registry/ for CF Pages / static hosting.

Endpoints:
- /api/registry — component list
- /api/registry/components/{name} — component with source
- /api/registry/search?q= — search
- /api/registry/index — full manifest (single payload)

Static files built during `pnpm build` step.
2026-03-27 19:15:54 -07:00
hanzo-dev 2ad4de8410 fix: add component-helpers dep to checkbox/button/list-item 2026-03-27 17:26:12 -07:00
hanzo-dev 88863caa79 fix: ALL UI deps 3.0.0->3.0.1, publish 3.0.2 2026-03-27 16:50:15 -07:00
hanzo-dev 86c312b7d4 fix: purge workspace:*, publish 3.0.1 2026-03-27 15:54:38 -07:00
hanzo-dev becf552ac5 feat: all UI packages at 3.0.0 (unified) 2026-03-27 14:52:23 -07:00
hanzo-dev 6894aac69d fix: ALL UI packages 2.0.8, internal deps point to correct published versions 2026-03-27 14:18:17 -07:00
hanzo-dev 14219cf9b5 fix: ALL UI packages 2.0.7, helpers->component-helpers in all dist 2026-03-27 13:35:34 -07:00
hanzo-dev 54447b5025 fix: button/list-item import useCurrentColor from component-helpers, bump 2.0.6 2026-03-27 12:04:16 -07:00
hanzo-dev aca5c3cd41 fix: all imports @hanzo/gui-* -> @hanzogui/*, TamaguiRoot -> GuiRoot in dist, bump 2.0.5 2026-03-26 20:27:38 -07:00
hanzo-dev 1a9f041acc fix: repair JSON formatting in package.json 2026-03-24 20:32:33 -07:00
hanzo-dev 793cf731ab fix: correct package names @hanzo/gui-* -> @hanzogui/* 2026-03-24 20:31:50 -07:00
hanzo-dev 45df2ea8b9 fix: bump UI packages to 2.0.4 with consistent deps 2026-03-24 20:31:14 -07:00
hanzo-dev 1a6cc7888b fix: resolve workspace:* to 2.0.1, bump UI packages 2026-03-24 19:22:01 -07:00
hanzo-dev 179ae0de2e docs: update LLM.md with upstream sync info and current structure 2026-03-24 19:13:53 -07:00
dependabot[bot]andGitHub 6e3360edaa chore(deps-dev): bump @types/node in /templates/start-monorepo
Bumps [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) from 25.3.0 to 25.5.0.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

---
updated-dependencies:
- dependency-name: "@types/node"
  dependency-version: 25.5.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-25 02:08:31 +00:00
dependabot[bot]andGitHub 4c859ac0f2 chore(deps): bump next from 16.1.6 to 16.2.1 in /templates/next-monorepo
Bumps [next](https://github.com/vercel/next.js) from 16.1.6 to 16.2.1.
- [Release notes](https://github.com/vercel/next.js/releases)
- [Changelog](https://github.com/vercel/next.js/blob/canary/release.js)
- [Commits](https://github.com/vercel/next.js/compare/v16.1.6...v16.2.1)

---
updated-dependencies:
- dependency-name: next
  dependency-version: 16.2.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-25 02:08:31 +00:00
dependabot[bot]andGitHub 3e8a117f5b chore(deps): bump react-router in /templates/react-router-monorepo
Bumps [react-router](https://github.com/remix-run/react-router/tree/HEAD/packages/react-router) from 7.12.0 to 7.13.2.
- [Release notes](https://github.com/remix-run/react-router/releases)
- [Changelog](https://github.com/remix-run/react-router/blob/main/packages/react-router/CHANGELOG.md)
- [Commits](https://github.com/remix-run/react-router/commits/react-router@7.13.2/packages/react-router)

---
updated-dependencies:
- dependency-name: react-router
  dependency-version: 7.13.2
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-25 02:08:11 +00:00
dependabot[bot]andGitHub f179586d90 chore(deps): bump zod from 3.25.76 to 4.3.6 in /templates/vite-monorepo
Bumps [zod](https://github.com/colinhacks/zod) from 3.25.76 to 4.3.6.
- [Release notes](https://github.com/colinhacks/zod/releases)
- [Commits](https://github.com/colinhacks/zod/compare/v3.25.76...v4.3.6)

---
updated-dependencies:
- dependency-name: zod
  dependency-version: 4.3.6
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-25 02:07:54 +00:00
dependabot[bot]andGitHub 8da884864e chore(deps-dev): bump typescript in /templates/next-monorepo
Bumps [typescript](https://github.com/microsoft/TypeScript) from 5.9.3 to 6.0.2.
- [Release notes](https://github.com/microsoft/TypeScript/releases)
- [Commits](https://github.com/microsoft/TypeScript/compare/v5.9.3...v6.0.2)

---
updated-dependencies:
- dependency-name: typescript
  dependency-version: 6.0.2
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-25 02:07:48 +00:00
dependabot[bot]andGitHub 71d2ee86c6 chore(deps-dev): bump @tailwindcss/vite in /templates/vite-monorepo
Bumps [@tailwindcss/vite](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/packages/@tailwindcss-vite) from 4.2.1 to 4.2.2.
- [Release notes](https://github.com/tailwindlabs/tailwindcss/releases)
- [Changelog](https://github.com/tailwindlabs/tailwindcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/tailwindlabs/tailwindcss/commits/v4.2.2/packages/@tailwindcss-vite)

---
updated-dependencies:
- dependency-name: "@tailwindcss/vite"
  dependency-version: 4.2.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-25 02:07:42 +00:00
dependabot[bot]andGitHub 8d1059edd5 chore(deps-dev): bump typescript in /templates/astro-app
Bumps [typescript](https://github.com/microsoft/TypeScript) from 5.9.3 to 6.0.2.
- [Release notes](https://github.com/microsoft/TypeScript/releases)
- [Commits](https://github.com/microsoft/TypeScript/compare/v5.9.3...v6.0.2)

---
updated-dependencies:
- dependency-name: typescript
  dependency-version: 6.0.2
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-25 02:07:24 +00:00
dependabot[bot]andGitHub 40096d8471 chore(deps-dev): bump typescript in /templates/astro-monorepo
Bumps [typescript](https://github.com/microsoft/TypeScript) from 5.9.3 to 6.0.2.
- [Release notes](https://github.com/microsoft/TypeScript/releases)
- [Commits](https://github.com/microsoft/TypeScript/compare/v5.9.3...v6.0.2)

---
updated-dependencies:
- dependency-name: typescript
  dependency-version: 6.0.2
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-25 02:07:24 +00:00
dependabot[bot]andGitHub 8be202f42a chore(deps-dev): bump @react-router/dev in /templates/react-router-app
Bumps [@react-router/dev](https://github.com/remix-run/react-router/tree/HEAD/packages/react-router-dev) from 7.13.1 to 7.13.2.
- [Release notes](https://github.com/remix-run/react-router/releases)
- [Changelog](https://github.com/remix-run/react-router/blob/main/packages/react-router-dev/CHANGELOG.md)
- [Commits](https://github.com/remix-run/react-router/commits/@react-router/dev@7.13.2/packages/react-router-dev)

---
updated-dependencies:
- dependency-name: "@react-router/dev"
  dependency-version: 7.13.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-25 02:07:17 +00:00
dependabot[bot]andGitHub 6c3fef23ee chore(deps): bump @astrojs/react in /templates/astro-monorepo
Bumps [@astrojs/react](https://github.com/withastro/astro/tree/HEAD/packages/integrations/react) from 4.4.2 to 5.0.1.
- [Release notes](https://github.com/withastro/astro/releases)
- [Changelog](https://github.com/withastro/astro/blob/main/packages/integrations/react/CHANGELOG.md)
- [Commits](https://github.com/withastro/astro/commits/@astrojs/react@5.0.1/packages/integrations/react)

---
updated-dependencies:
- dependency-name: "@astrojs/react"
  dependency-version: 5.0.1
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-25 02:07:16 +00:00
dependabot[bot]andGitHub 661469fe51 chore(deps-dev): bump globals in /templates/astro-app
Bumps [globals](https://github.com/sindresorhus/globals) from 14.0.0 to 17.4.0.
- [Release notes](https://github.com/sindresorhus/globals/releases)
- [Commits](https://github.com/sindresorhus/globals/compare/v14.0.0...v17.4.0)

---
updated-dependencies:
- dependency-name: globals
  dependency-version: 17.4.0
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-25 02:07:15 +00:00
dependabot[bot]andGitHub 55be65b4f2 chore(deps): bump astro from 5.18.1 to 6.0.8 in /templates/astro-app
Bumps [astro](https://github.com/withastro/astro/tree/HEAD/packages/astro) from 5.18.1 to 6.0.8.
- [Release notes](https://github.com/withastro/astro/releases)
- [Changelog](https://github.com/withastro/astro/blob/main/packages/astro/CHANGELOG.md)
- [Commits](https://github.com/withastro/astro/commits/astro@6.0.8/packages/astro)

---
updated-dependencies:
- dependency-name: astro
  dependency-version: 6.0.8
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-25 02:07:10 +00:00
dependabot[bot]andGitHub e635bb6627 chore(deps): bump @react-router/node in /templates/react-router-app
Bumps [@react-router/node](https://github.com/remix-run/react-router/tree/HEAD/packages/react-router-node) from 7.13.1 to 7.13.2.
- [Release notes](https://github.com/remix-run/react-router/releases)
- [Changelog](https://github.com/remix-run/react-router/blob/main/packages/react-router-node/CHANGELOG.md)
- [Commits](https://github.com/remix-run/react-router/commits/@react-router/node@7.13.2/packages/react-router-node)

---
updated-dependencies:
- dependency-name: "@react-router/node"
  dependency-version: 7.13.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-25 02:07:09 +00:00
dependabot[bot]andGitHub 4fdf69a766 chore(deps): bump zod from 3.25.76 to 4.3.6 in /templates/next-monorepo
Bumps [zod](https://github.com/colinhacks/zod) from 3.25.76 to 4.3.6.
- [Release notes](https://github.com/colinhacks/zod/releases)
- [Commits](https://github.com/colinhacks/zod/compare/v3.25.76...v4.3.6)

---
updated-dependencies:
- dependency-name: zod
  dependency-version: 4.3.6
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-25 02:07:00 +00:00
dependabot[bot]andGitHub 8480853a7c chore(deps-dev): bump @eslint/js in /templates/astro-app
Bumps [@eslint/js](https://github.com/eslint/eslint/tree/HEAD/packages/js) from 9.39.4 to 10.0.1.
- [Release notes](https://github.com/eslint/eslint/releases)
- [Commits](https://github.com/eslint/eslint/commits/v10.0.1/packages/js)

---
updated-dependencies:
- dependency-name: "@eslint/js"
  dependency-version: 10.0.1
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-25 02:06:57 +00:00
dependabot[bot]andGitHub ad762f30e7 chore(deps-dev): bump @eslint/js in /templates/astro-monorepo
Bumps [@eslint/js](https://github.com/eslint/eslint/tree/HEAD/packages/js) from 9.39.4 to 10.0.1.
- [Release notes](https://github.com/eslint/eslint/releases)
- [Commits](https://github.com/eslint/eslint/commits/v10.0.1/packages/js)

---
updated-dependencies:
- dependency-name: "@eslint/js"
  dependency-version: 10.0.1
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-25 02:06:55 +00:00
dependabot[bot]andGitHub f00b55894a chore(deps-dev): bump globals in /templates/vite-app
Bumps [globals](https://github.com/sindresorhus/globals) from 14.0.0 to 17.4.0.
- [Release notes](https://github.com/sindresorhus/globals/releases)
- [Commits](https://github.com/sindresorhus/globals/compare/v14.0.0...v17.4.0)

---
updated-dependencies:
- dependency-name: globals
  dependency-version: 17.4.0
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-25 02:06:55 +00:00
dependabot[bot]andGitHub 269545eaa4 chore(deps): bump react-router in /templates/react-router-app
Bumps [react-router](https://github.com/remix-run/react-router/tree/HEAD/packages/react-router) from 7.13.1 to 7.13.2.
- [Release notes](https://github.com/remix-run/react-router/releases)
- [Changelog](https://github.com/remix-run/react-router/blob/main/packages/react-router/CHANGELOG.md)
- [Commits](https://github.com/remix-run/react-router/commits/react-router@7.13.2/packages/react-router)

---
updated-dependencies:
- dependency-name: react-router
  dependency-version: 7.13.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-25 02:06:54 +00:00
dependabot[bot]andGitHub e48482db66 chore(deps-dev): bump typescript in /templates/vite-app
Bumps [typescript](https://github.com/microsoft/TypeScript) from 5.9.3 to 6.0.2.
- [Release notes](https://github.com/microsoft/TypeScript/releases)
- [Commits](https://github.com/microsoft/TypeScript/compare/v5.9.3...v6.0.2)

---
updated-dependencies:
- dependency-name: typescript
  dependency-version: 6.0.2
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-25 02:06:51 +00:00
dependabot[bot]andGitHub 408ae15c25 chore(deps): bump astro in /templates/astro-monorepo
Bumps [astro](https://github.com/withastro/astro/tree/HEAD/packages/astro) from 5.18.1 to 6.0.8.
- [Release notes](https://github.com/withastro/astro/releases)
- [Changelog](https://github.com/withastro/astro/blob/main/packages/astro/CHANGELOG.md)
- [Commits](https://github.com/withastro/astro/commits/astro@6.0.8/packages/astro)

---
updated-dependencies:
- dependency-name: astro
  dependency-version: 6.0.8
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-25 02:06:48 +00:00
dependabot[bot]andGitHub 9cb2ccfb25 chore(deps): bump @astrojs/react in /templates/astro-app
Bumps [@astrojs/react](https://github.com/withastro/astro/tree/HEAD/packages/integrations/react) from 4.4.2 to 5.0.1.
- [Release notes](https://github.com/withastro/astro/releases)
- [Changelog](https://github.com/withastro/astro/blob/main/packages/integrations/react/CHANGELOG.md)
- [Commits](https://github.com/withastro/astro/commits/@astrojs/react@5.0.1/packages/integrations/react)

---
updated-dependencies:
- dependency-name: "@astrojs/react"
  dependency-version: 5.0.1
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-25 02:06:47 +00:00
dependabot[bot]andGitHub c45b00c942 chore(deps-dev): bump typescript in /templates/next-app
Bumps [typescript](https://github.com/microsoft/TypeScript) from 5.9.3 to 6.0.2.
- [Release notes](https://github.com/microsoft/TypeScript/releases)
- [Commits](https://github.com/microsoft/TypeScript/compare/v5.9.3...v6.0.2)

---
updated-dependencies:
- dependency-name: typescript
  dependency-version: 6.0.2
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-25 02:06:47 +00:00
dependabot[bot]andGitHub 88b693b90d chore(deps-dev): bump typescript in /templates/react-router-app
Bumps [typescript](https://github.com/microsoft/TypeScript) from 5.9.3 to 6.0.2.
- [Release notes](https://github.com/microsoft/TypeScript/releases)
- [Commits](https://github.com/microsoft/TypeScript/compare/v5.9.3...v6.0.2)

---
updated-dependencies:
- dependency-name: typescript
  dependency-version: 6.0.2
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-25 02:06:40 +00:00
hanzo-dev 51c4d12dce feat: sync upstream shadcn/ui — shadcn@4.1.0, v4 app, font system, chart improvements
Upstream sync from shadcn-ui/ui (1686 commits since last merge base).

Key changes:
- packages/shadcn upgraded to 4.1.0 (CLI font transformers, scaffold from github, chart colors)
- apps/v4 replaces apps/www as the primary docs/registry app
- Font system: 25+ heading font registries, font markers utility, @supports override
- Chart improvements: recharts v3 compat, radial chart fixes
- templates/ updated with monorepo variants (astro, vite, react-router, start)
- skills/shadcn AI skill definitions added
- deprecated/ removed (auth, auth-firebase, cli, www)

Preserved Hanzo-only paths: app/, pkg/, demo/, docs/, template/next/
2026-03-24 19:04:31 -07:00
hanzo-dev 6fe7078b06 fix: update checkout client 2026-03-24 18:42:34 -07:00
hanzo-dev 3cdad07e32 feat: migrate tamagui primitives to @hanzogui/*, rename tokens/tamagui to tokens/gui 2026-03-24 18:42:34 -07:00
hanzo-dev 8a492a2d18 feat: migrate UI packages from @hanzo/gui-* to @hanzogui/* scope, purge all tamagui refs, v2.0.0 2026-03-24 18:42:34 -07:00
Darkhorse7starsandhanzo-dev fb6da37f9f fix: ESLint 10 config compatibility and cleanup temp files
- Rewrite app/eslint.config.mjs to use @typescript-eslint/parser directly
  (fixes "Class extends value undefined" error with typescript-eslint 8.x)
- Add typescript-eslint override in root package.json for ESLint 10 compat
- Remove deprecated .eslintignore (use flat config ignores instead)
- Delete temp/backup files (.bak, .old, .tmp)

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-03-23 16:55:41 -05:00
Darkhorse7starsandhanzo-dev 41d618d1d8 fix: resolve CI type check and lint failures
- Downgrade app/ react-resizable-panels to v3 (code uses v3 API names)
- Add missing @eslint/js dependency for eslint.config.mjs
- Fix TS18048 in chart-line-dots-custom.tsx (null check cx/cy)
- Fix TS7006 in ai-code.tsx (explicit any types for monaco callbacks)

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-03-23 12:41:22 -05:00
Darkhorse7starsandhanzo-dev 4cb8cac44c fix: use react-resizable-panels v3 API for compatibility
Changed imports from v4 names (Group, Separator) to v3 names
(PanelGroup, PanelResizeHandle) to fix build errors in consumers
using react-resizable-panels v3. Updated peer dep to ^3.0.0.

Bump @hanzo/ui to 5.5.1.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-03-23 12:22:29 -05:00
hanzo-dev 5aee8715d7 fix: resolve 34 missing gui workspace deps to published npm versions
The gui-* packages were imported from hanzo/gui but 34 underlying
utility/core packages (gui-build, gui-core, gui-helpers, gui-web, etc.)
were not included in the workspace. Changed their workspace:* refs to
the published 2.0.0-rc.29 versions so pnpm can resolve from registry.
2026-03-22 19:02:28 -07:00
hanzo-dev aa7f45b29d chore: brand + gui publish artifacts 2026-03-22 18:38:17 -07:00
hanzo-dev 65d32dda4c feat: add @hanzo/gui-* UI component packages from hanzo/gui rebrand 2026-03-21 13:08:01 -07:00
hanzo-dev 9ba70a4e2c chore: update all dependencies to latest 2026-03-21 11:44:34 -07:00
hanzo-dev 5850560681 chore: bump @hanzo/ui to 5.5.0 (dash components) 2026-03-18 14:52:20 -07:00
hanzo-dev 189b34971f fix: clean names — dash/layout, dash/sidebar, dash/table, dash/form, dash/crud 2026-03-18 14:49:17 -07:00
hanzo-dev e010864f26 fix: rename admin->dash in tsup build config 2026-03-18 14:47:05 -07:00
hanzo-dev e74efbab46 chore: bump @hanzo/ui to 5.4.0 (adds @hanzo/ui/dash) 2026-03-18 14:40:24 -07:00
hanzo-dev 4884736c69 feat: add @hanzo/ui/dash — shared dashboard UI (layout, data table, forms, CRUD) 2026-03-18 14:39:39 -07:00
hanzo-dev bb48d51d70 fix: update repository URL from react-sdk to ui for npm provenance 2026-03-16 22:03:53 -07:00
hanzo-dev 3d88cc75eb chore: bump @hanzo/ui to 5.3.42 2026-03-16 21:51:19 -07:00
hanzo-dev bc8945ff4e fix: rename Developer tier to Pay As You Go
"Developer" is the free/OSS tier, not a paid plan. Rename to
"Pay As You Go" to match actual billing semantics.
2026-03-16 21:50:12 -07:00
hanzo-dev 9627ebbbbc feat: move upgrade CTA to top, improve zero-usage display
- Upgrade CTA now renders first (above usage card) when not subscribed
- "No usage yet" instead of "— tokens" when zero usage
- "Credits available" with balance instead of "$0.00 API spend"
- "Start using Hanzo AI to see stats" subtitle for zero-usage state
2026-03-16 21:38:50 -07:00
hanzo-dev 80147c328f feat(navigation): expand AppSwitcher from 6 to 26 apps with grouped layout
Unified app switcher is the single source of truth for cross-app navigation.
All services now accessible from every Hanzo app via HanzoHeader.

- DEFAULT_HANZO_APPS: 26 apps across 7 groups (Core, AI, Observability,
  Infrastructure, Apps, Business, Resources)
- OrgDomains: white-label domain mapping for all 4 orgs (hanzo, lux, zoo, pars)
  expanded from 7 to 27 fields
- getAppsForOrg(): returns org-aware URLs for all 26 apps
- AppSwitcher: grouped 2-column grid with section headers, scrollable
2026-03-13 04:08:28 -07:00
hanzo-dev 29b3652711 fix: expand BillingSection type to include all billing sections 2026-03-11 18:29:53 -07:00
hanzo-dev 891818443a feat(@hanzo/ui): add billing module to dist build
Add billing/index tsup entry so @hanzo/ui/billing resolves from npm.
Update package.json export to point to dist instead of source.
Bumps to v5.3.41.
2026-03-11 17:55:00 -07:00
hanzo-dev 532d8e692c feat(@hanzo/ui): add navigation/hanzo-shell build entry
Add separate tsup entry point for navigation/hanzo-shell so the
wildcard package export ./navigation/* resolves to a real dist file.
Bumps version to 5.3.40.
2026-03-11 17:40:01 -07:00
hanzo-dev e45833b5f2 feat: add hard refresh and settings to HanzoHeader shell 2026-03-11 11:38:55 -07:00
hanzo-dev dffa6cef34 feat: add @hanzo/ui/auth package with IAMLoginButton and AuthGuard
New auth components for unified IAM integration:
- IAMLoginButton: "Sign in with Hanzo" button that initiates OAuth flow
- AuthGuard: wrapper that redirects to IAM when unauthenticated
- Re-exports useHanzoAuth, UserOrgDropdown, HanzoUser/HanzoOrg types
Adds ./auth and ./auth/* subpath exports to package.json.
2026-03-10 16:59:46 -07:00
hanzo-dev 04407e9cb5 feat(shell): org-aware app switcher, white-label domains, hanzo.space
- chat.hanzo.ai → hanzo.chat (canonical domain)
- Add hanzo.space (Storage) to DEFAULT_HANZO_APPS
- Add ORG_DOMAINS map: hanzo, lux, zoo, pars with per-org domains
- Add getAppsForOrg() for white-label app switcher URLs
- HanzoHeader now resolves org-specific domains automatically
- UserOrgDropdown Account/Billing links adapt to current org
- Export OrgDomains type and getAppsForOrg from hanzo-shell
2026-03-10 16:06:51 -07:00
hanzo-dev 9f0e37d8c3 fix(billing): export BillingSection type from @hanzo/ui/billing 2026-03-10 15:32:03 -07:00
hanzo-dev bf18ef7a35 chore: gitignore dist-test output 2026-03-03 10:43:12 -08:00
hanzo-dev edda5a41b8 feat: add UserAvatar with three-tier fallback (photo → Gravatar → generative SVG)
BeamAvatar: deterministic face-like SVG from any string, zero deps, boring-avatars beam algorithm
UserAvatar: three-tier fallback — uploaded photo, Gravatar SHA-256, generative beam SVG
UserOrgDropdown now uses UserAvatar instead of simple letter initials
2026-03-03 20:37:01 -08:00
Zach Kelling 320885b073 chore(ui): bump to v5.3.38
Published with HanzoCommandPalette component.
2026-03-03 17:35:30 -08:00
Zach Kelling d97b670580 feat(ui): add HanzoCommandPalette — shared Cmd+K command palette
Zero-dependency React component for cross-app command navigation.
Supports extensible app-specific commands, keyboard nav, search filtering.
2026-03-03 17:33:30 -08:00
Zach Kelling b8ecd6c408 fix(ci): resolve TypeScript build errors blocking deployment
- resizable.tsx: update react-resizable-panels v4 imports (PanelGroup→Group, PanelResizeHandle→Separator)
- blob.ts: wrap Buffer in Uint8Array for BlobPart compatibility
- tsconfig.build.json: exclude typo-plugin JS from type-checked build
2026-03-03 14:33:02 -08:00
Zach Kelling 4086d54305 test: add static CI test to validate all docs URLs have matching MDX files
Runs 228 tests checking that every href in docs.ts config resolves to
an actual MDX file. Catches 404s before deployment without needing a
running server. Reports orphaned MDX files not in nav config.
2026-03-03 14:25:06 -08:00
Zach Kelling 6831117b97 feat: full docs modernization — zero 404s, 585 pages, dep updates
- Enable all 15 finance component docs (from .disabled → .mdx)
- Create stock-market.mdx, 8 chart docs, 8 block docs, 2 framework docs
- Add direction.tsx component (RTL/LTR support from upstream shadcn)
- Add xs/icon-xs button sizes, data-variant/data-size attributes
- Fix build-registry.mts to handle default exports in namespace re-exports
- Add default props to order-entry, positions-list, orders-history, trading-panel
- Fix 18 navigation stubs → re-export from root implementations
- Add 44 orphaned MDX files to nav (installation, dark-mode, desktop, guides, etc)
- Migrate 6 framer-motion imports → motion/react
- Bump React 19.2.0→19.2.4, Next.js 16.0.1→16.1.6, motion→12.34.0
- Align lucide-react, sonner v2, tailwind-merge v3, postcss-selector-parser v7
- Remove framer-motion dep (superseded by motion)
- Fix @types/react-dom, TypeScript versions across workspaces
2026-03-03 07:34:51 -08:00
hanzo-dev 6ea1042d44 feat: @hanzo/shop payment popup + Square billing components
- @hanzo/shop: new embeddable 3-tab topup widget (card/crypto/bank)
  - HanzoTopup dialog, TopupButton trigger
  - CardInput (Square Web Payments SDK), CryptoInput (MPC chains), BankInput (ACH)
  - hooks: useSquareCard, useTopup
- @hanzo/commerce client: add getWalletAddress, addBankAccount, addCryptoWallet, topup
- @hanzo/ui billing: comprehensive billing dashboard components
  - OverviewDashboard, PaymentManager (Square), CreditsPanel, UsagePanel
  - InvoicesPayments, TransactionsPanel, SquareCardForm
  - CostExplorer, SpendAlerts, AccountMembers, AccountSwitcher
  - BillingSettings, SupportTiersPanel, PromotionsPanel
  - GuidedSetup, AnimatedCard, StatusBar
2026-03-02 11:09:00 -08:00
hanzo-dev d9f090bb53 feat(commerce): update commerce client SDK 2026-03-01 19:40:49 -08:00
hanzo-dev e1184390dc feat(@hanzo/commerce): extend client with checkout, coupons, referrals, affiliates
Add comprehensive commerce client methods:
- validateCoupon / applyCoupon (pre-checkout validation)
- createCheckoutSession (hosted checkout, returns checkoutUrl)
- tokenizeCard (S2S card tokenization without external SDK)
- referrals: createReferralProgram, getReferralLink, trackReferral, getReferralStats
- affiliates: registerAffiliate, getAffiliateStats, getAffiliateCommissions
- Backwards-compatible: commerceUrl still works, baseUrl preferred
- Default baseUrl: https://api.hanzo.ai
- Request timeout support (timeoutMs, default 15s)
2026-03-01 16:20:08 -08:00
hanzo-dev a42e158a07 fix(ci): add NEXT_PUBLIC_APP_URL env and coverage json-summary reporter 2026-03-01 16:01:01 -08:00
hanzo-dev 6347fe0aeb feat: remove @hanzo/auth, all auth goes through @hanzo/iam
- Move pkg/auth and pkg/auth-firebase to deprecated/ (excluded from workspace)
- Remove @hanzo/auth from commerce and checkout peerDependencies
- Remove useAuth() from payment-step-form; contact form defaults to empty strings
- Update pnpm lockfile

Production auth is now handled entirely by IAM (hanzo.id). The legacy
auth package is preserved in deprecated/ for reference.
2026-03-01 15:39:33 -08:00
hanzo-dev f08abfe55a chore: update pnpm lockfile 2026-03-01 15:27:19 -08:00
hanzo-dev fbe58782d9 chore(@hanzo/ui): bump to 5.3.37 — publish model components 2026-03-01 15:25:31 -08:00
hanzo-dev 16ffaea878 feat(@hanzo/ui): add shared model components — ModelCard, ModelTable, ModelLibrary, ZenEnso
Adds @hanzo/ui/models export with data-agnostic model UI components
that work across hanzo.ai, zen-docs, and any other Hanzo site:

- ZenModelLike/ModelFamilyLike interfaces (structural compatibility
  with @hanzo/zen-models, no cross-package dependency needed)
- ModelCard: rich clickable card with status badges, spec, action buttons
- ModelTable: static table view with pricing and context columns
- ModelLibrary: full catalog with family sections and filter toggle
- ZenEnso: animated SVG enso circle logo component

Fix ./models ESM export path (dist/models/index.mjs not dist/src/models/).
2026-03-01 15:19:28 -08:00
hanzo-dev a4f1a1c16f feat: add HanzoShell shared navigation components
New pkg/ui/src/navigation/hanzo-shell/ module:
- HanzoHeader: sticky 14px header with logo/breadcrumb/app-switcher/user-dropdown
- HanzoMark: official H-mark SVG with hover origami animation + brand context menu
  (right-click → Brand Guidelines, Press Kit, Download Logo, Copy SVG, hanzo.ai)
- AppSwitcher: 3x3-grid icon → dropdown listing all Hanzo apps
- UserOrgDropdown: avatar/initials, org switcher (from IAM groups), sign-out
- useHanzoAuth: zero-dep hook reads hanzo-auth-token from localStorage,
  fetches iam.hanzo.ai/api/userinfo, maps IAM groups to org list
- DEFAULT_HANZO_APPS: Account, Billing, Console, Chat, Platform

Design tokens: bg-[#09090b]/90, border-white/[0.07], text-[11-13px], monochrome
Exported from @hanzo/ui navigation index for use across all Hanzo properties.
2026-03-01 12:38:58 -08:00
hanzo-dev 8ed0e27d73 [cleanup] remove AI slop files 2026-02-28 12:25:38 -08:00
hanzo-dev 9958dde439 feat: add @hanzo/og programmatic OG image package
Shared package for generating OpenGraph/social images across all Hanzo
sites. Supports 6 layout variants (page, model, code, stat, split,
minimal) with inline styles for next/og ImageResponse compatibility.
Includes HANZO_AI_THEME and HANZO_INDUSTRIES_THEME brand presets.
2026-02-28 10:45:00 -08:00
hanzo-dev e64e9cdbdc perf: reduce @hanzo/ui bundle 74% by removing duplicate entries and enabling minification
- Remove duplicate tsup entries: primitives-export and primitives/index
  both compiled same primitives/index-standard.ts → save 2×436K=870K
- Enable minify:true; safe because 'use client' banner is added post-build
  via onSuccess hook, not as source directive
- Update ./primitives export in package.json to point to ./dist/index.mjs
- Result: dist 10M → 2.6M, index.mjs 436K → 314K (minified)

Published as @hanzo/ui@5.3.36
2026-02-28 10:27:43 -08:00
hanzo-dev 095773b2da chore: bump versions — @hanzo/ui@5.3.35, @hanzo/auth-firebase@1.0.0, @hanzo/commerce@7.5.1 2026-02-27 21:30:24 -08:00
hanzo-dev 926985e5ad chore: update generated registry and color files from build 2026-02-27 21:28:17 -08:00
hanzo-dev 24ace5354a fix: resolve build errors — add avatar/org to HanzoUserInfoStore, fix Zod v3/v4 type mismatch, generate d.ts files in @hanzo/ui build 2026-02-27 21:20:16 -08:00
hanzo-dev 412ee93a25 chore: untrack .claude/settings.local.json, add to gitignore 2026-02-27 20:42:26 -08:00
hanzo-dev 751111a0d7 chore: update pnpm-lock.yaml 2026-02-27 19:35:51 -08:00
hanzo-dev 8dab1688f7 Remove Firebase from @hanzo/auth and @hanzo/commerce
Firebase has been completely removed from core packages:
- @hanzo/auth v2.8.0: stub implementations replace Firebase auth/wallet
- @hanzo/commerce v7.5.0: no-op order persistence replaces Firestore
- Firebase code preserved in @hanzo/auth-firebase (optional package)
- Zero Firebase imports in auth and commerce packages
2026-02-27 19:35:51 -08:00
Zoo Queenandhanzo-dev 7887485e95 feat(commerce): add billingRefund method for correction deposits
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-02-20 19:07:18 -08:00
Zoo Queenandhanzo-dev 3e1283365a refactor: clean Commerce API client — one class, no aliases
Commerce is THE client for the Commerce API. No aliases, no backwards
compat wrappers. import { Commerce } from '@hanzo/commerce'

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-02-17 21:06:22 -08:00
Zoo Queenandhanzo-dev 8919c32d0f feat: rename BillingClient to CommerceClient, add @hanzo/commerce/client
CommerceClient is the canonical Commerce API client. @hanzo/commerce/billing
re-exports as BillingClient for backwards compat.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-02-17 21:05:35 -08:00
Zoo Queenandhanzo-dev be95271bc4 fix: update repository URLs to hanzoai/ui for npm provenance
npm provenance requires repository.url to match the publishing repo.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-02-17 21:02:20 -08:00
Zoo Queenandhanzo-dev f2752141ff feat: add BillingClient to @hanzo/commerce, fix npm-publish token
- billing.ts: canonical billing client for Commerce API
- Exports at @hanzo/commerce/billing
- Fix workspace:* peerDeps (resolve to version ranges for npm compat)
- Fix npm-publish workflow to use NPM_TOKEN secret

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-02-17 20:59:46 -08:00
Zoo Queenandhanzo-dev b6311022f3 fix: add avatar and organization fields to Firebase auth store
All HanzoUserInfo implementations must include the new fields.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-02-17 20:55:38 -08:00
Zoo Queenandhanzo-dev 07f1182d52 feat: add IAM auth service as primary auth provider
- IamAuthService: OIDC/PKCE-based auth, auto-registers when IAM config present
- Enhanced types: avatar, organization fields on HanzoUserInfo
- AuthServiceConf: IAM fields (iamServerUrl, iamClientId, etc.)
- Firebase becomes optional integration, IAM is the default
- Fix npm-publish workflow: add id-token permission for provenance

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-02-17 20:52:49 -08:00
Zoo Queenandhanzo-dev bacd545b67 fix: resolve pnpm version conflict in publish workflow
Remove explicit version to let packageManager field in package.json
control the pnpm version.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-02-17 20:47:34 -08:00
Zoo Queenandhanzo-dev d95693b61a chore: bump @hanzo/auth to 2.7.0
Adds shared OrgProjectSwitcher component for unified org/project
selection across all Hanzo apps.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-02-17 20:45:28 -08:00
Zoo Queenandhanzo-dev dce79e1309 feat: add shared OrgProjectSwitcher component to @hanzo/auth
Props-driven org/project/environment switcher used across all
Hanzo services. Supports single-org display, multi-org dropdown,
project selector, and environment badge.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-02-17 20:38:07 -08:00
370 changed files with 66386 additions and 2086 deletions
-11
View File
@@ -1,11 +0,0 @@
{
"$schema": "https://unpkg.com/@changesets/config@2.3.0/schema.json",
"changelog": ["@changesets/changelog-github", { "repo": "shadcn-ui/ui" }],
"commit": false,
"fixed": [],
"linked": [],
"access": "public",
"baseBranch": "main",
"updateInternalDependencies": "patch",
"ignore": ["v4", "tests"]
}
+10
View File
@@ -0,0 +1,10 @@
---
"@hanzo/analytics": patch
---
Add `@hanzo/analytics` — the shared product-analytics capture client. Batched
pageview/event/identify/group emit to Hanzo Cloud (`/v1/analytics` +
`/v1/tracker`) with first-touch UTM/referrer/refCode attribution,
beacon-on-unload, dual cookie/bearer auth, and the shared event + goal + cohort
vocabulary (`EVENTS`, `GOALS`, `COHORTS`). Framework-agnostic core plus a
`@hanzo/analytics/react` provider and hooks.
-12
View File
@@ -1,12 +0,0 @@
// ORIGINALLY FROM CLOUDFLARE WRANGLER:
// https://github.com/cloudflare/wrangler2/blob/main/.github/changeset-version.js
import { execSync } from "child_process"
// This script is used by the `release.yml` workflow to update the version of the packages being released.
// The standard step is only to run `changeset version` but this does not update the pnpm-lock.yaml file.
// So we also run `pnpm install`, which does this update.
// This is a workaround until this is handled automatically by `changeset version`.
// See https://github.com/changesets/changesets/issues/421.
execSync("npx changeset version", { stdio: "inherit" })
execSync("pnpm install --lockfile-only", { stdio: "inherit" })
-96
View File
@@ -1,96 +0,0 @@
name: Deploy to GitHub Pages
on:
push:
branches: [main]
workflow_dispatch:
inputs:
capture_screenshots:
description: 'Capture component screenshots (slow, optional)'
required: false
type: boolean
default: false
permissions:
contents: read
pages: write
id-token: write
concurrency:
group: "pages"
cancel-in-progress: false
jobs:
build:
runs-on: hanzo-build-linux-amd64
timeout-minutes: 10
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: "20"
- name: Setup pnpm
uses: pnpm/action-setup@v3
with:
version: 9
- name: Get pnpm store directory
shell: bash
run: |
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
- name: Setup pnpm cache
uses: actions/cache@v4
with:
path: ${{ env.STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
- name: Install dependencies
run: |
pnpm install --no-frozen-lockfile
- name: Build @hanzo/ui package
run: |
cd pkgs/ui && pnpm build
- name: Capture screenshots (optional)
if: github.event.inputs.capture_screenshots == 'true'
working-directory: ./app
run: pnpm capture:registry
timeout-minutes: 5
- name: Build documentation
working-directory: ./app
run: |
pnpm build
touch out/.nojekyll
env:
NODE_ENV: production
GITHUB_ACTIONS: true
NEXT_PUBLIC_APP_URL: https://ui.hanzo.ai
SKIP_SCREENSHOTS: true
- name: Setup Pages
uses: actions/configure-pages@v4
- name: Upload artifact
uses: actions/upload-pages-artifact@v3
with:
path: ./app/out
deploy:
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
runs-on: hanzo-deploy-linux-amd64
needs: build
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4
-185
View File
@@ -1,185 +0,0 @@
name: NPM Publish
permissions:
contents: write
id-token: write
on:
workflow_dispatch:
inputs:
package:
description: 'Package to publish'
required: true
type: choice
options:
- ui
- ui-mcp
- auth
- commerce
- checkout
- brand
- react
- all
version_bump:
description: 'Version bump type'
required: true
type: choice
options:
- patch
- minor
- major
jobs:
publish:
runs-on: hanzo-build-linux-amd64
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v3
with:
version: 8
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: '20'
registry-url: 'https://registry.npmjs.org'
- name: Install dependencies
run: pnpm install
- name: Build packages
run: |
if [ "${{ github.event.inputs.package }}" = "all" ] || [ "${{ github.event.inputs.package }}" = "ui" ]; then
cd pkgs/ui && pnpm build
cd ../..
fi
if [ "${{ github.event.inputs.package }}" = "all" ] || [ "${{ github.event.inputs.package }}" = "ui-mcp" ]; then
cd pkgs/ui-mcp && pnpm build
cd ../..
fi
if [ "${{ github.event.inputs.package }}" = "all" ] || [ "${{ github.event.inputs.package }}" = "auth" ]; then
cd pkgs/auth && pnpm build
cd ../..
fi
if [ "${{ github.event.inputs.package }}" = "all" ] || [ "${{ github.event.inputs.package }}" = "commerce" ]; then
cd pkgs/commerce && pnpm build
cd ../..
fi
if [ "${{ github.event.inputs.package }}" = "all" ] || [ "${{ github.event.inputs.package }}" = "checkout" ]; then
cd pkgs/checkout && pnpm build
cd ../..
fi
if [ "${{ github.event.inputs.package }}" = "all" ] || [ "${{ github.event.inputs.package }}" = "brand" ]; then
cd pkgs/brand && pnpm build
cd ../..
fi
if [ "${{ github.event.inputs.package }}" = "all" ] || [ "${{ github.event.inputs.package }}" = "react" ]; then
cd pkgs/react && pnpm build
cd ../..
fi
- name: Bump version and publish
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
NPM_CONFIG_PROVENANCE: true
run: |
# Set up npm and pnpm auth
npm config set //registry.npmjs.org/:_authToken $NODE_AUTH_TOKEN
echo "//registry.npmjs.org/:_authToken=${NODE_AUTH_TOKEN}" >> ~/.npmrc
publish_package() {
local pkg_dir=$1
local pkg_name=$(cd $pkg_dir && node -p "require('./package.json').name")
local pkg_version=$(cd $pkg_dir && node -p "require('./package.json').version")
echo "=== Publishing $pkg_name@$pkg_version from $pkg_dir ==="
# Change to package directory
pushd "$pkg_dir"
# Bump version manually using node to avoid any pnpm/npm hooks
echo "Bumping version (${{ github.event.inputs.version_bump }})..."
node -e "
const fs = require('fs');
const pkg = JSON.parse(fs.readFileSync('package.json', 'utf8'));
const [major, minor, patch] = pkg.version.split('.').map(Number);
const bump = '${{ github.event.inputs.version_bump }}';
if (bump === 'major') pkg.version = (major + 1) + '.0.0';
else if (bump === 'minor') pkg.version = major + '.' + (minor + 1) + '.0';
else pkg.version = major + '.' + minor + '.' + (patch + 1);
fs.writeFileSync('package.json', JSON.stringify(pkg, null, 2) + '\n');
console.log('New version:', pkg.version);
"
local new_version=$(node -p "require('./package.json').version")
echo "Version is now: $new_version"
# Pack the tarball
echo "Running pnpm pack..."
pnpm pack --pack-gzip-level 9
local tarball=$(ls -t *.tgz | head -1)
echo "Created tarball: $tarball"
# Verify tarball contents
echo "Tarball package.json:"
tar -xzf "$tarball" -O package/package.json
# Copy to temp dir and publish
local temp_dir="/tmp/publish-$$"
mkdir -p "$temp_dir"
cp "$tarball" "$temp_dir/"
echo "Publishing from $temp_dir..."
cd "$temp_dir"
npm publish "$tarball" --access public
cd -
# Cleanup
rm -rf "$temp_dir"
rm -f "$tarball"
popd
}
case "${{ github.event.inputs.package }}" in
ui)
publish_package "pkgs/ui"
;;
ui-mcp)
publish_package "pkgs/ui-mcp"
;;
auth)
publish_package "pkgs/auth"
;;
commerce)
publish_package "pkgs/commerce"
;;
checkout)
publish_package "pkgs/checkout"
;;
brand)
publish_package "pkgs/brand"
;;
react)
publish_package "pkgs/react"
;;
all)
publish_package "pkgs/ui"
publish_package "pkgs/ui-mcp"
publish_package "pkgs/auth"
publish_package "pkgs/commerce"
publish_package "pkgs/checkout"
publish_package "pkgs/brand"
publish_package "pkgs/react"
;;
esac
- name: Create Pull Request
uses: peter-evans/create-pull-request@v6
with:
token: ${{ secrets.GITHUB_TOKEN }}
commit-message: 'chore: bump ${{ github.event.inputs.package }} version to ${{ github.event.inputs.version_bump }}'
title: 'chore: bump ${{ github.event.inputs.package }} version'
body: |
Automated version bump for ${{ github.event.inputs.package }} package(s).
Version bump type: ${{ github.event.inputs.version_bump }}
branch: version-bump-${{ github.event.inputs.package }}-${{ github.run_number }}
-65
View File
@@ -1,65 +0,0 @@
# Adapted from create-t3-app.
name: Write Beta Release comment
on:
workflow_run:
workflows: ["Release - Beta"]
types:
- completed
jobs:
comment:
if: |
github.repository_owner == 'shadcn-ui' &&
${{ github.event.workflow_run.conclusion == 'success' }}
runs-on: hanzo-build-linux-amd64
name: Write comment to the PR
steps:
- name: "Comment on PR"
uses: actions/github-script@v6
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
const allArtifacts = await github.rest.actions.listWorkflowRunArtifacts({
owner: context.repo.owner,
repo: context.repo.repo,
run_id: context.payload.workflow_run.id,
});
for (const artifact of allArtifacts.data.artifacts) {
// Extract the PR number and package version from the artifact name
const match = /^npm-package-shadcn@(.*?)-pr-(\d+)/.exec(artifact.name);
if (match) {
require("fs").appendFileSync(
process.env.GITHUB_ENV,
`\nBETA_PACKAGE_VERSION=${match[1]}` +
`\nWORKFLOW_RUN_PR=${match[2]}` +
`\nWORKFLOW_RUN_ID=${context.payload.workflow_run.id}`
);
break;
}
}
- name: "Comment on PR with Link"
uses: marocchino/sticky-pull-request-comment@v2
with:
number: ${{ env.WORKFLOW_RUN_PR }}
message: |
A new prerelease is available for testing:
```sh
pnpm dlx shadcn@${{ env.BETA_PACKAGE_VERSION }}
```
- name: "Remove the autorelease label once published"
uses: actions/github-script@v6
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
github.rest.issues.removeLabel({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: '${{ env.WORKFLOW_RUN_PR }}',
name: '🚀 autorelease',
});
-63
View File
@@ -1,63 +0,0 @@
# Adapted from create-t3-app.
name: Release - Beta
on:
pull_request:
types: [labeled]
branches:
- main
permissions:
id-token: write
contents: read
jobs:
prerelease:
if: |
github.repository_owner == 'shadcn-ui' &&
contains(github.event.pull_request.labels.*.name, '🚀 autorelease')
name: Build & Publish a beta release to NPM
runs-on: hanzo-build-linux-amd64
environment: Preview
steps:
- name: Checkout Repo
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Use PNPM
uses: pnpm/action-setup@v4
with:
version: 9.0.6
- name: Use Node.js 20
uses: actions/setup-node@v4
with:
node-version: 20
registry-url: "https://registry.npmjs.org"
- name: Update npm for OIDC support
run: npm install -g npm@latest
- name: Install NPM Dependencies
run: pnpm install
- name: Modify package.json version
run: node .github/version-script-beta.js
- name: Publish Beta to NPM
run: pnpm pub:beta
- name: get-npm-version
id: package-version
uses: martinbeentjes/npm-get-version-action@main
with:
path: pkgs/shadcn
- name: Upload packaged artifact
uses: actions/upload-artifact@v4
with:
name: npm-package-shadcn@${{ steps.package-version.outputs.current-version }}-pr-${{ github.event.number }} # encode the PR number into the artifact name
path: pkgs/shadcn/dist/index.js
-150
View File
@@ -1,150 +0,0 @@
name: Publish on Tag
on:
push:
tags:
- 'v*' # Match @hanzo/ui version (e.g., v5.1.1)
jobs:
test:
name: Run Tests
runs-on: hanzo-build-linux-amd64
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v3
with:
version: 9
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install dependencies
run: pnpm install --no-frozen-lockfile
- name: Build packages
run: |
cd pkgs/ui && pnpm build && cd ../..
cd pkgs/commerce && pnpm build && cd ../..
cd pkgs/brand && pnpm build && cd ../..
cd pkgs/react && pnpm build && cd ../..
- name: Run tests
run: |
cd pkgs/ui && pnpm test
cd ../react && pnpm test
cd ../..
publish:
name: Publish to NPM
needs: test
runs-on: hanzo-build-linux-amd64
permissions:
contents: write
id-token: write
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v3
with:
version: 9
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: '20'
registry-url: 'https://registry.npmjs.org'
- name: Install dependencies
run: pnpm install --no-frozen-lockfile
- name: Build all packages
run: |
cd pkgs/ui && pnpm build && cd ../..
cd pkgs/commerce && pnpm build && cd ../..
cd pkgs/brand && pnpm build && cd ../..
cd pkgs/react && pnpm build && cd ../..
- name: Configure npm authentication
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
run: |
npm config set //registry.npmjs.org/:_authToken $NODE_AUTH_TOKEN
npm whoami
- name: Check and publish packages
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
run: |
echo "Checking all packages for unpublished versions..."
PUBLISHED_COUNT=0
SKIPPED_COUNT=0
PUBLISHED_PACKAGES=""
for package in ui commerce brand react; do
cd "pkgs/$package"
CURRENT_VERSION=$(node -p "require('./package.json').version")
PACKAGE_NAME=$(node -p "require('./package.json').name")
echo ""
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "📦 Checking $PACKAGE_NAME@$CURRENT_VERSION"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
# Check if this version already exists on npm
if npm view "$PACKAGE_NAME@$CURRENT_VERSION" version 2>/dev/null; then
echo "⏭️ Already published - skipping"
SKIPPED_COUNT=$((SKIPPED_COUNT + 1))
else
echo "🚀 Publishing to npm..."
npm publish --access public
echo "✅ Successfully published $PACKAGE_NAME@$CURRENT_VERSION"
PUBLISHED_COUNT=$((PUBLISHED_COUNT + 1))
PUBLISHED_PACKAGES="$PUBLISHED_PACKAGES\n- $PACKAGE_NAME@$CURRENT_VERSION"
fi
cd ../..
done
echo ""
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "📊 Publishing Summary"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "✅ Published: $PUBLISHED_COUNT package(s)"
echo "⏭️ Skipped: $SKIPPED_COUNT package(s)"
if [ $PUBLISHED_COUNT -gt 0 ]; then
echo ""
echo -e "Published packages:$PUBLISHED_PACKAGES"
fi
# Save for GitHub release notes
echo "PUBLISHED_COUNT=$PUBLISHED_COUNT" >> $GITHUB_ENV
echo "PUBLISHED_PACKAGES<<EOF" >> $GITHUB_ENV
echo -e "$PUBLISHED_PACKAGES" >> $GITHUB_ENV
echo "EOF" >> $GITHUB_ENV
- name: Create GitHub Release
if: ${{ env.PUBLISHED_COUNT > 0 }}
uses: softprops/action-gh-release@v1
with:
generate_release_notes: true
body: |
## 📦 NPM Packages Published
${{ env.PUBLISHED_PACKAGES }}
### Installation
```bash
# Install latest versions
npm install @hanzo/ui @hanzo/commerce @hanzo/brand @hanzo/react
```
files: |
CHANGELOG.md
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+1 -2
View File
@@ -5,7 +5,6 @@ on:
branches: [main]
paths:
- 'pkgs/*/package.json'
- 'pkgs/*/package.json'
jobs:
detect-changes:
@@ -21,7 +20,7 @@ jobs:
id: changed
run: |
CHANGED=()
for pkg_json in pkgs/*/package.json pkgs/*/package.json; do
for pkg_json in pkgs/*/package.json; do
[ ! -f "$pkg_json" ] && continue
pkg_dir=$(dirname "$pkg_json")
-59
View File
@@ -1,59 +0,0 @@
# Adapted from create-t3-app.
name: Release
on:
push:
branches:
- main
permissions:
id-token: write
contents: write
pull-requests: write
jobs:
release:
if: ${{ github.repository_owner == 'shadcn-ui' }}
name: Create a PR for release workflow
runs-on: hanzo-build-linux-amd64
steps:
- name: Checkout Repo
uses: actions/checkout@v3
with:
fetch-depth: 0
- name: Use PNPM
uses: pnpm/action-setup@v4
with:
version: 9.0.6
- name: Use Node.js 20
uses: actions/setup-node@v4
with:
node-version: 20
registry-url: "https://registry.npmjs.org"
- name: Update npm for OIDC support
run: npm install -g npm@latest
- name: Install NPM Dependencies
run: pnpm install
# - name: Check for errors
# run: pnpm check
- name: Build the package
run: pnpm shadcn:build
- name: Create Version PR or Publish to NPM
id: changesets
uses: changesets/action@v1
with:
commit: "chore(release): version packages"
title: "chore(release): version packages"
version: node .github/changeset-version.js
publish: npx changeset publish
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NODE_ENV: "production"
-1
View File
@@ -5,7 +5,6 @@ node_modules
**/node_modules
.pnp
.pnp.js
/pnpm-lock.yaml
# testing
coverage
+3 -1
View File
@@ -12,7 +12,9 @@ This repository is a monorepo.
- We use [pnpm](https://pnpm.io) and [`workspaces`](https://pnpm.io/workspaces) for development.
- We use [Turborepo](https://turbo.build/repo) as our build system.
- We use [changesets](https://github.com/changesets/changesets) for managing releases.
- Releases are semver-driven: bump a package's `version` in its `package.json` and
merge to `main`. CI (`.github/workflows/publish.yml`) detects the version change
and publishes that package to npm. One step, one source of truth.
## Structure
+106
View File
@@ -0,0 +1,106 @@
# 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.
> One library: **`@hanzo/ui@8`** (`pkg/ui`, on **`@hanzo/gui`**) IS the component
> library — the cross-platform product/record layer every surface consumes.
> **`@hanzo/ui-shadcn`** (`pkgs/ui`) is the legacy shadcn/Tailwind/Radix kit, kept
> only for existing v5 consumers (pin `@hanzo/ui-shadcn@^5`; no new adoptions).
> This file stays the source of truth for the *token values* (fonts, dark palette,
> the sidebar glyph) both render.
---
## 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`).
+46
View File
@@ -0,0 +1,46 @@
# ui.hanzo.ai — the @hanzo/ui docs + component registry, served by the house
# static server (ghcr.io/hanzoai/static, a Go binary), same as every other Hanzo
# static site. Built on our own runners, never on a laptop and never by a
# third-party builder.
#
# The app is a Next.js static export: `pnpm build` in app/ writes app/out, which
# is the entire site — docs, the registry JSON the CLI reads, and the static
# /api/registry/*.json index.
FROM node:22 AS builder
WORKDIR /src
ENV NEXT_TELEMETRY_DISABLED=1
# Publishable ingest key (pk_…), baked in at build because a static export has no
# server to read config at runtime. Write-only and HMAC-verified to one org, so it
# is safe in a public bundle; the deployment that builds decides which org the
# site reports as. Absent, the client stays inert.
ARG NEXT_PUBLIC_HANZO_INGEST_KEY=""
ENV NEXT_PUBLIC_HANZO_INGEST_KEY=$NEXT_PUBLIC_HANZO_INGEST_KEY
# 300+ prerendered pages; the default heap is not enough.
ENV NODE_OPTIONS=--max-old-space-size=8192
RUN corepack enable
COPY . .
# The lockfile is committed, so the build resolves exactly what was reviewed.
RUN pnpm install --frozen-lockfile
# Workspace packages the app imports must be built first: their package.json
# exports point at dist/.
RUN cd pkgs/ui && pnpm build
RUN cd pkgs/event && pnpm build
# Builds the component registry, then the site (app/package.json build script).
RUN cd app && pnpm build
# 0.5.1 serves a directory's index.html in place. On 0.4.1 every page 301'd to
# an explicit /index.html, which leaks that filename into the address bar and
# into the URLs Next builds for its route prefetches.
FROM ghcr.io/hanzoai/static:0.5.2-amd64
COPY --from=builder /src/app/out /public
EXPOSE 3000
# No -spa: the export writes a real index.html per route (trailingSlash), so a
# missing path must 404 rather than silently render the home page.
ENTRYPOINT ["/static", "-port", "3000", "-root", "/public"]
+107 -26
View File
@@ -1,10 +1,71 @@
# Hanzo UI - LLM Context
# @hanzo/ui — LLM context
## Overview
**What this is.** The React component library for AI applications: a shadcn/ui
fork with 161+ components, 24+ blocks, two themes, multi-framework output, and a
single typed import surface. Published as `@hanzo/ui` (v8) on npm. Docs at
https://ui.hanzo.ai. Dev port: 3003.
React component library (shadcn/ui fork). 161 components, 24+ blocks, two themes, multi-framework. Published as `@hanzo/ui` on npm.
**Canonical role.** This is the canonical impl repo for Hanzo's web UI kit —
frontend components, not an SDK. It sits alongside the two SDK lines (full cloud
SDK generated from OpenAPI in `hanzo-<lang>/sdk` + wrapper in `hanzoai/<lang>-sdk`;
AI/agents lib `hanzo` in `hanzoai/python-sdk` flagship, `@hanzo/ai` in `hanzo-js/ai`).
`@hanzo/event` (telemetry, `POST /v1/event`) lives here in `pkgs/event`. DRY: one
impl, one place — link out, never duplicate.
**Docs**: https://ui.hanzo.ai | **Dev port**: 3003
**Brand rules (hard).**
- Never call Hanzo an "LLM gateway" or position it against LiteLLM — it is a full
AI SDK / AI cloud, not a proxy. Purge that framing on sight.
- Paths are `/v1/…` only — never an `/api/` prefix.
- Zen models are our own family — never name upstream models.
- Voice: "Hanzo — the Open AI Cloud." Developer-first, crisp, no emoji-spam.
**Install / run.**
```bash
pnpm add @hanzo/ui # consume
# dev:
pnpm install && pnpm build:registry && pnpm dev # registry MUST build before app
```
**Key entry points.** `pkg/ui/` (core lib + v8 subpaths: /product /data /canvas
/wallet /network /billing /dashboard /usage /gitops) · `app/registry/{default,new-york}/`
(component SOURCE OF TRUTH) · `pkgs/*` (auto-published `@hanzo/*` packages) ·
`packages/shadcn/` (CLI) · `app/content/docs/` (MDX docs). Publish = bump a
package `version` + merge to main (`.github/workflows/publish.yml`).
**Spec / more context.** Canonical SDK + docs model: `~/work/hanzo/SDK-ARCHITECTURE.md`.
Detailed engineering notes (build order, import surface, telemetry, upstream sync,
gotchas) follow below.
---
## v8 — the one import surface (`@hanzo/ui@8`, `pkg/ui`)
`@hanzo/ui@8` (`pkg/ui`) is the cross-platform product/record layer on `@hanzo/gui`.
Every recent component kit is reachable from this single package as a thin subpath
that re-exports its home package (code lives once; each home is an OPTIONAL peer,
pulled only when the subpath is used):
| Subpath | Home | Components |
|---|---|---|
| `@hanzo/ui` · `/product` | (this) | charts, metrics, PageHeader, StatusTag, EmptyState, ComboBox, SlideOver, Toast, Reorder, Field |
| `@hanzo/ui/data` | `@hanzo/data` | RecordsView, DataTable, typed field editors |
| `@hanzo/ui/canvas` | `@hanzo/canvas` | ProjectCanvas, ServiceNode, DeployTimeline, EnvSwitcher, ServiceDetailDrawer, ServiceStatusBadge |
| `@hanzo/ui/wallet` | `@hanzo/ui-shadcn/wallet` | WalletMenu, injectedEvmAdapter (EIP-1193), walletAvailable, ensureEvmNetwork |
| `@hanzo/ui/network` | `@hanzo/ui-shadcn/network` | NetworkSwitcher, useNetwork, configureNetworks, HANZO_NETWORKS |
| `@hanzo/ui/billing` | `@hanzo/ui-shadcn/billing` | CreditModal |
| `@hanzo/ui/dashboard` | `@hanzo/dashboard` | landing + deploy-pipeline + overview kit |
| `@hanzo/ui/usage` | `@hanzo/usage` | UsageMeter, UsageProviderCard, UsageDashboard |
| `@hanzo/ui/gitops` | `@hanzo/gitops` | GitopsAppList, tree, diff, sync/rollback, HealthBadge |
The **8 newest kits** = canvas, wallet, network, billing, dashboard, usage, gitops,
data. Add one by mirroring `src/gitops.ts` (a one-line `export *`) + a `./name`
export + an optional peer/devDep. NOTE: `pkg/*` is a pnpm workspace member (for
`workspace:*` dev links), but `pkg/ui` publishes via the maintainer flow, not
`publish.yml` (which auto-publishes only `pkgs/*` on a version bump — see
PUBLISH_GUIDE.md). The shared shell lives here too: `AppHeader` + `BrandMark`
(@hanzo/logo) + `OrgSwitcher` + `orgScope` (the console org-scope contract,
hoisted per #36). Lux surfaces use `@luxfi/web3`
for wallet/login; `@hanzo/ui/wallet`+`/network` are the Hanzo-branded equivalents.
## Repository Structure
@@ -58,14 +119,46 @@ pnpm lint # Lint all workspaces
pnpm typecheck # Type checking
pnpm test # Unit tests
pnpm test:e2e # Playwright E2E
pnpm changeset # Create changeset for publishing
```
## Publishing
One way: bump a package's `version` in its `package.json` and merge to `main`.
`.github/workflows/publish.yml` detects the changed `@hanzo/*` package and
publishes it to npm (needs the repo `NPM_TOKEN` secret). No changesets, no
version-PR bot — the semver bump is the trigger.
## Telemetry — `@hanzo/event` is the ONE client (`pkgs/event`)
`@hanzo/event` is the single canonical telemetry client for every Hanzo surface.
It emits **one** kind of thing — an `Event` — to **one** door: `POST /v1/event`
with the batched `{ batch: [Event, …] }` wire, `-> { accepted, dropped }`.
Pageview, event, identify, group, AND errors are all events on that one stream;
Cloud resolves the tenant server-side (session / publishable `pk_` key) and fans
the stream to the read lenses (analytics = web, insights = product, sentry =
errors). The client **never** sends the org. Entries: `.` (framework-agnostic:
`createAnalytics`, `EVENTS`, `GOALS`, attribution helpers) and `./react`
(`AnalyticsProvider`, `useAnalytics`, `usePageview`, `ErrorBoundary`). Auto error
capture (window.onerror / unhandledrejection / React boundary) makes it the
drop-in error-tracking replacement. SSR-safe, fail-soft, beacon-on-unload.
Build is a tsup dual bundle: **CJS → `.cjs`, ESM → `.mjs`** (required under
`"type": "module"` — a CJS `.js` is parsed as ESM and crashes `require()` with
"exports is not defined"). Each `exports` condition carries its own types.
### One way — supersessions (no divergent telemetry client)
| Package | Status | Note |
|---|---|---|
| `@hanzo/event` | **canonical** | `pkgs/event`, posts `/v1/event` only |
| `@hanzo/capture` (npm) | **deprecated → `@hanzo/event`** | the old name of this package; `@hanzo/event` is a superset |
| `pkgs/capture` (`@hanzo/analytics@0.1.0` dup) | **deleted** | stale in-repo duplicate, removed |
## Three-Layer Architecture
1. **Components** (`registry/{style}/ui/`) -- Single primitives (Button, Card, Dialog). CLI-installable.
2. **Examples** (`registry/{style}/example/`) -- Usage demos for docs via `<ComponentPreview />`.
3. **Blocks** (`registry/{style}/blocks/`) -- Full-page sections (Dashboard, Login). NOT CLI-installable, docs only.
1. **Components** (`registry/{style}/ui/`) — single primitives (Button, Card, Dialog). CLI-installable.
2. **Examples** (`registry/{style}/example/`) — usage demos for docs via `<ComponentPreview />`.
3. **Blocks** (`registry/{style}/blocks/`) — full-page sections (Dashboard, Login). NOT CLI-installable, docs only.
## Import Path Transformation
@@ -94,37 +187,25 @@ React 19, Next.js 15.3+, Tailwind CSS 4 (OKLCH colors), Radix UI, Turborepo + pn
## Upstream Sync
Remote `shadcn` points to `/Users/z/work/shadcn/ui` (local clone of shadcn-ui/ui).
hanzoai/ui is NOT a GitHub fork -- no shared object store, so large merges can fail on push.
Last sync: 2026-03-24 (shadcn@4.1.0, commit 8bec9c123)
Remote `shadcn` points to a local clone of shadcn-ui/ui.
hanzoai/ui is NOT a GitHub fork no shared object store, so large merges can fail on push.
Strategy: file-level checkout from shadcn/main for specific directories (not git merge).
- Take theirs: packages/shadcn/, packages/tests/, apps/, templates/, scripts/, skills/
- Keep ours: app/, pkg/, demo/, docs/, template/next/, pnpm-workspace.yaml, package.json
- Remove: deprecated/ (upstream deleted it)
- Regenerate: pnpm-lock.yaml after sync
## Key Features
- **Page Builder** (`/builder`): Drag-drop block assembly with @dnd-kit, export to TSX
- **Page Builder** (`/builder`): drag-drop block assembly with @dnd-kit, export to TSX
- **White-Label**: Zoo/Lux forks via `brands/{BRAND}.brand.ts`
- **External Registries**: 35+ sources in `app/registries.json`, install via `npx @hanzo/ui add @aceternity/spotlight`
## Gotchas
- Registry index is `Index[style][name]`, NOT `Index[name]` -- caused silent block render failures
- Shiki `getHighlighter` incompatible with static export -- replaced with basic pre/code
- Registry index is `Index[style][name]`, NOT `Index[name]` caused silent block render failures
- Shiki `getHighlighter` incompatible with static export replaced with basic pre/code
- Some blocks (login-01, login-02, sidebar-02) have Server Component issues with event handlers
- Zod validation removed from `_getAllBlocks()`/`_getBlockCode()` -- we control generation
- Firebase split to optional `@hanzo/auth-firebase` package (Jan 2025)
- `@hanzo/auth` v2.6.0 uses pluggable provider registry: `registerAuthProvider('firebase', FirebaseAuthService)`
## Component Stats
- 161 total files, ~127 implemented, ~34 stubs
- Unique: 9 3D components, 12 AI components, 13 animation components, 15 nav variants
- 3x more components than upstream shadcn/ui (161 vs 58)
- shadcn CLI: v4.1.0 with font transformers, chart color picker, scaffold from github
- `@hanzo/auth` v2.6.0 uses a pluggable provider registry: `registerAuthProvider('firebase', FirebaseAuthService)`
## Rules
+24 -159
View File
@@ -1,174 +1,39 @@
# NPM Publishing Guide - React 19 Packages
# Publishing
## Current Package Versions
Two lanes, one trigger each. No changesets, no version-PR bot.
All packages updated to support **React 19.2.0**:
## 1. `pkgs/*` — auto-publish on version bump (`publish.yml`)
- `@hanzo/ui` - v5.1.1
- `@hanzo/auth` - Latest
- `@hanzo/commerce` - Latest
- `@hanzo/brand` - Latest
- `@hanzo/react` - v1.0.0
Bump a package's `version` in `pkgs/<name>/package.json` and merge to `main`.
`.github/workflows/publish.yml` detects the changed public `@hanzo/*` package,
builds it, and publishes to npm (repo secret `NPM_TOKEN`). Patch bumps only
(`x.y.z``x.y.z+1`).
## Publishing Methods
## 2. `pkg/ui` — `@hanzo/ui@8`, the v8 lane (maintainer flow)
### 1. Automatic Publishing (Tag-based)
When you push a git tag starting with `v` (typically matching @hanzo/ui version), the workflow automatically checks all packages and publishes any with new versions:
`pkg/ui` (with `pkg/data`) is the modern cross-platform library on `@hanzo/gui`.
It publishes from the package directory (`prepack` builds the `types/`):
```bash
# Tag with @hanzo/ui version (workflow checks all packages)
git tag v5.1.1
git push origin v5.1.1
```
**What happens:**
1. Tests run (pkg/ui and pkg/react)
2. All 5 packages build
3. **Automatic version detection:**
- Checks each package's current version in package.json
- Queries npm to see if that version already exists
- Only publishes packages with new versions not on npm
4. GitHub release created (only if packages were published)
**Example workflow output:**
```
📦 Checking @hanzo/ui@5.1.1
⏭️ Already published - skipping
📦 Checking @hanzo/auth@2.5.5
🚀 Publishing to npm...
✅ Successfully published @hanzo/auth@2.5.5
📊 Publishing Summary
✅ Published: 1 package(s)
⏭️ Skipped: 4 package(s)
```
This approach means you:
- Only need to tag once (with @hanzo/ui version)
- Don't need to track which packages need publishing
- Can bump any package version and it auto-publishes on next tag
- Similar to python-sdk monorepo publishing
**Workflow:** `.github/workflows/publish-on-tag.yml`
### 2. Manual Publishing (Workflow Dispatch)
Use GitHub Actions UI to manually publish specific packages:
1. Go to **Actions****NPM Publish**
2. Click **Run workflow**
3. Select package: `ui`, `auth`, `commerce`, `brand`, `react`, or `all`
4. Select version bump: `patch`, `minor`, or `major`
5. Click **Run workflow**
**What happens:**
- Selected package(s) build
- Version bumped automatically
- Package(s) published to npm
- PR created with version bump
**Workflow:** `.github/workflows/npm-publish.yml`
### 3. Local Publishing (Manual)
For quick patches or testing:
```bash
# Build and test
cd pkg/ui
pnpm build
pnpm test
# Bump version
npm version patch # or minor/major
# Publish
pnpm typecheck && pnpm test && pnpm build
# bump "version" in package.json (patch), commit to main, then:
npm publish --access public
```
`@hanzo/ui-shadcn` (`pkgs/ui`) is the legacy v5 kit — existing consumers pin
`@hanzo/ui-shadcn@^5`; it rides lane 1 like any other `pkgs/*` package.
> The old tag-driven flow (`publish-on-tag.yml`, `npm-publish.yml`, the
> `pkg/commerce|brand|react` paths) is gone — do not tag to publish here.
## Prerequisites
### NPM Authentication Token
- `NPM_TOKEN` repo secret (lane 1) / npm auth as a maintainer (lane 2)
- Every package carries `"publishConfig": { "access": "public" }`
The GitHub secret `NPM_AUTH_TOKEN` must be set:
## Checklist
1. Generate token at https://www.npmjs.com/settings/tokens
2. Add to GitHub: Settings → Secrets → Actions → `NPM_AUTH_TOKEN`
### Package Publish Configuration
All packages already configured with:
```json
{
"publishConfig": {
"access": "public",
"registry": "https://registry.npmjs.org/"
}
}
```
## React 19 Compatibility
### Key Updates Made
1. **Type Declarations**: Added `hanzo-ui.d.ts` files for React 19 compatibility
2. **Peer Dependencies**: Force React 19.2.0 via pnpm overrides
3. **Test Environment**: Switched to happy-dom for better React 19 support
4. **Build Configuration**: All packages build successfully with React 19
### Test Status
- **pkg/ui**: 206/207 tests passing (99.5%)
- **pkg/react**: 10/10 tests passing (100%)
## Publishing Checklist
Before publishing:
- [ ] All packages build successfully: `pnpm build`
- [ ] Tests pass: `pnpm test`
- [ ] Types check: `cd app && pnpm typecheck`
- [ ] Lint passes: `cd app && pnpm lint`
- [ ] Update CHANGELOG.md
- [ ] Update version in package.json (if manual)
- [ ] Commit changes
## Troubleshooting
### Build Failures
```bash
# Clean install
rm -rf node_modules pnpm-lock.yaml
pnpm install
# Rebuild
pnpm build
```
### Test Failures
```bash
# Run specific package tests
cd pkg/ui && pnpm test
cd pkg/react && pnpm test
```
### Publish Failures
- Check NPM_AUTH_TOKEN is valid
- Ensure version is unique (not already published)
- Verify package builds: `cd pkg/<name> && pnpm build`
## Package URLs
- npm: https://www.npmjs.com/org/hanzo
- GitHub: https://github.com/hanzoai/ui
- Docs: https://ui.hanzo.ai
---
**Last Updated:** 2025-10-05
**React Version:** 19.2.0
- [ ] `pnpm typecheck` + `pnpm test` green in the package
- [ ] Patch version bump (check the last published patch first)
- [ ] Commit to `main`; lane 1 publishes on merge, lane 2 via `npm publish`
+95 -29
View File
@@ -1,37 +1,42 @@
<p align="center"><img src=".github/hero.svg" alt="ui" width="880"></p>
<p align="center"><img src=".github/hero.svg" alt="@hanzo/ui" width="880"></p>
# @hanzo/ui
Accessible and customizable components for React, Vue, Svelte, and React Native. **Built on shadcn/ui with multi-framework support, 3D components, AI components, and advanced features.**
**The React component library for AI applications.** Accessible, customizable primitives for React, Vue, Svelte, and React Native — built on shadcn/ui, extended with AI, 3D, animation, and commerce components, and a single typed import surface.
<p align="center">
<a href="https://www.npmjs.com/package/@hanzo/ui"><img src="https://img.shields.io/npm/v/@hanzo/ui?color=black&label=%40hanzo%2Fui" alt="npm"></a>
<a href="./LICENSE.md"><img src="https://img.shields.io/badge/license-MIT-black" alt="MIT"></a>
<a href="https://ui.hanzo.ai"><img src="https://img.shields.io/badge/docs-ui.hanzo.ai-black" alt="docs"></a>
</p>
![hero](app/public/og.jpg)
## Features
- **161+ Components** - 3x more than shadcn/ui
- **Multi-Framework** - React, Vue, Svelte, React Native
- **Two Themes** - Default & New York variants
- **AI Components** - Chat, assistants, playground
- **3D Components** - Interactive 3D elements
- **Animations** - Advanced motion components
- **Page Builder** - Visual drag-drop interface
- **White-Label** - Fork and rebrand easily
- **Blocks** - 24+ production-ready templates
- **Accessible** - Built with Radix UI primitives
- **Customizable** - Tailwind CSS powered
- **TypeScript** - Fully typed
- **161+ components** 3x the surface of upstream shadcn/ui
- **Multi-framework** React, Vue, Svelte, React Native
- **Two themes** Default & New York variants
- **AI components** — chat, assistants, agent UI, playground
- **3D components** — interactive 3D elements
- **Animations** — advanced motion components
- **Page builder** — visual drag-and-drop assembly, export to TSX
- **Blocks** — 24+ production-ready full-page templates
- **White-label** — fork and rebrand by domain (Zoo, Lux, …)
- **Accessible** — built on Radix UI primitives
- **Customizable** Tailwind CSS 4 (OKLCH), fully typed TypeScript
## Quick Start
## Quick start
### Installation
### Install
```bash
npm install @hanzo/ui
# or
pnpm add @hanzo/ui
# or
npm install @hanzo/ui
```
### Usage
### Use
```tsx
import { Button, Card, Input } from '@hanzo/ui'
@@ -53,23 +58,62 @@ export function App() {
}
```
## Documentation
## One import surface (v8)
Visit **[ui.hanzo.ai](https://ui.hanzo.ai)** for full docs.
`@hanzo/ui@8` is the single entry point for the whole kit. Each capability is a
thin subpath that re-exports its home package — code lives once, and each home is
an optional peer, pulled only when you use its subpath.
| Import | What you get |
|---|---|
| `@hanzo/ui` · `/product` | charts, metrics, PageHeader, StatusTag, EmptyState, ComboBox, SlideOver, Toast |
| `@hanzo/ui/data` | RecordsView, DataTable, typed field editors |
| `@hanzo/ui/canvas` | ProjectCanvas, ServiceNode, DeployTimeline, EnvSwitcher |
| `@hanzo/ui/wallet` | WalletMenu, EIP-1193 adapter, network helpers |
| `@hanzo/ui/network` | NetworkSwitcher, useNetwork, configureNetworks |
| `@hanzo/ui/billing` | CreditModal |
| `@hanzo/ui/dashboard` | landing + deploy-pipeline + overview kit |
| `@hanzo/ui/usage` | UsageMeter, UsageProviderCard, UsageDashboard |
| `@hanzo/ui/gitops` | GitopsAppList, tree, diff, sync/rollback, HealthBadge |
Also available as granular imports:
```ts
import { Button, Card } from '@hanzo/ui/components'
import * as Dialog from '@hanzo/ui/primitives/dialog'
import { cn } from '@hanzo/ui/lib/utils'
```
## CLI
Add components straight into your project — the CLI copies source you own:
```bash
npx @hanzo/ui add button
npx @hanzo/ui add card dialog
```
Install from 35+ external registries too:
```bash
npx @hanzo/ui add @aceternity/spotlight
```
## Packages
- `@hanzo/ui` - Main UI library (161 components)
- `@hanzo/auth` - Authentication components
- `@hanzo/commerce` - E-commerce components
- `@hanzo/brand` - Branding system
The workspace publishes a family of scoped packages under `@hanzo/*`:
| Package | Purpose |
|---|---|
| `@hanzo/ui` | Core library + the v8 import surface (161+ components) |
| `@hanzo/react` | React primitives |
| `@hanzo/data` | Records, data tables, typed field editors |
| `@hanzo/canvas` | Service/deploy canvas components |
| `@hanzo/dashboard` | Dashboard + deploy-pipeline kit |
| `@hanzo/commerce` · `@hanzo/checkout` · `@hanzo/shop` | Commerce components |
| `@hanzo/agent-ui` | AI agent UI components |
| `@hanzo/brand` · `@hanzo/tokens` | Branding system & design tokens |
| `@hanzo/event` | Telemetry client (`POST /v1/event`) |
## Development
@@ -77,17 +121,39 @@ npx @hanzo/ui add card dialog
git clone https://github.com/hanzoai/ui.git
cd ui
pnpm install
pnpm dev
pnpm build:registry # generate the component registry FIRST
pnpm dev # docs site + registry (http://localhost:3003)
```
> The registry generates the JSON the CLI reads, so `build:registry` must run
> before `build`. Keep the Default and New York themes in sync when adding
> components. Use pnpm — not npm or yarn.
```bash
pnpm build # build the docs app
pnpm lint # lint all workspaces
pnpm typecheck # type check
pnpm test # unit tests
pnpm test:e2e # Playwright E2E
```
## Documentation
Full docs, live previews, and the component catalog: **[ui.hanzo.ai](https://ui.hanzo.ai)**.
## Contributing
Please read the [contributing guide](/CONTRIBUTING.md).
See the [contributing guide](/CONTRIBUTING.md).
## License
MIT - See [LICENSE.md](./LICENSE.md) for details.
MIT — see [LICENSE.md](./LICENSE.md).
---
Built by [Hanzo](https://hanzo.ai)
## Hanzo — the Open AI Cloud
Open source · every language · on-chain settlement. [hanzo.ai](https://hanzo.ai) · [docs.hanzo.ai](https://docs.hanzo.ai)
**SDKs in every language** — [Python](https://github.com/hanzoai/python-sdk) (flagship) · [TypeScript](https://github.com/hanzo-js/sdk) · [Go](https://github.com/hanzo-go/sdk) · [Rust](https://github.com/hanzo-rs/sdk) · [C++](https://github.com/hanzo-cpp/sdk) · [Swift](https://github.com/hanzo-swift/sdk) · [Kotlin](https://github.com/hanzo-kt/sdk) · [umbrella](https://github.com/hanzoai/sdk)
+2 -2
View File
@@ -1,7 +1,6 @@
"use client"
import { useEffect, useState } from "react"
import { ConnectButton } from "@rainbow-me/rainbowkit"
import { formatEther } from "viem"
import {
useAccount,
@@ -11,6 +10,7 @@ import {
useWriteContract,
} from "wagmi"
import { ConnectWallet } from "@/components/connect-wallet"
import {
AI_TOKEN_ABI,
CONTRACT_ADDRESSES,
@@ -208,7 +208,7 @@ export function IdentityForm() {
</CardDescription>
</CardHeader>
<CardContent>
<ConnectButton />
<ConnectWallet />
{isConnected && aiBalance !== undefined && (
<div className="mt-4">
+16 -2
View File
@@ -1,7 +1,21 @@
"use client"
import { Analytics as VercelAnalytics } from "@vercel/analytics/react"
import { useEffect, useRef } from "react"
import { usePathname } from "next/navigation"
import { analytics } from "@/lib/analytics"
export function Analytics() {
return <VercelAnalytics />
const pathname = usePathname()
const started = useRef(false)
useEffect(() => {
if (!started.current) {
started.current = true
analytics.init()
}
analytics.pageview(pathname ?? undefined)
}, [pathname])
return null
}
+40
View File
@@ -0,0 +1,40 @@
"use client"
import { useAccount, useConnect, useDisconnect } from "wagmi"
import { Button } from "@/registry/default/ui/button"
const short = (address: string) => `${address.slice(0, 6)}${address.slice(-4)}`
/**
* Connect the wallet the browser already has, over EIP-1193. There is no
* third-party modal and no bridge service in the page — the extension is the
* only party involved.
*/
export function ConnectWallet() {
const { address, isConnected } = useAccount()
const { connect, connectors, isPending } = useConnect()
const { disconnect } = useDisconnect()
if (isConnected && address) {
return (
<div className="flex items-center gap-3">
<span className="font-mono text-sm">{short(address)}</span>
<Button variant="outline" size="sm" onClick={() => disconnect()}>
Disconnect
</Button>
</div>
)
}
const injected = connectors[0]
return (
<Button
onClick={() => injected && connect({ connector: injected })}
disabled={!injected || isPending}
>
{isPending ? "Connecting…" : "Connect Wallet"}
</Button>
)
}
+6 -22
View File
@@ -1,34 +1,18 @@
"use client"
import "@rainbow-me/rainbowkit/styles.css"
import { useEffect, useState, type ReactNode } from "react"
import { RainbowKitProvider } from "@rainbow-me/rainbowkit"
import { type ReactNode } from "react"
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
import { WagmiProvider, type Config } from "wagmi"
import { WagmiProvider } from "wagmi"
import { getConfig } from "@/lib/wagmi"
const queryClient = new QueryClient()
const config = getConfig()
export function Web3Provider({ children }: { children: ReactNode }) {
const [config, setConfig] = useState<Config | null>(null)
useEffect(() => {
// Dynamically import wagmi config only on client side
import("@/lib/wagmi").then((mod) => {
setConfig(mod.getConfig())
})
}, [])
// Don't render until config is loaded
if (!config) {
return <>{children}</>
}
return (
<WagmiProvider config={config}>
<QueryClientProvider client={queryClient}>
<RainbowKitProvider modalSize="compact">{children}</RainbowKitProvider>
</QueryClientProvider>
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
</WagmiProvider>
)
}
Binary file not shown.
Binary file not shown.
+18
View File
@@ -0,0 +1,18 @@
import { createAnalytics } from "@hanzo/event"
// One client for the whole site. Pageviews (components/analytics.tsx) and
// product events (lib/events.ts) share it, so there is a single place that
// knows where telemetry goes — call sites only name what happened. Host
// defaults to the one edge (api.hanzo.ai), which is what a static site wants.
//
// /v1/event is authed, and deliberately does not trust the request Host — a Host
// header is spoofable, so a static page proves which org it belongs to by
// carrying a publishable key. `pk_` keys are write-only and HMAC-verified with no
// database hop, which is what makes them safe to ship inside a public bundle (the
// same reason a Sentry DSN is public). The build supplies it, so each deployment
// reports as the org that deployed it; without one the client stays inert rather
// than posting events that would only be rejected.
export const analytics = createAnalytics({
product: "site",
ingestKey: process.env.NEXT_PUBLIC_HANZO_INGEST_KEY,
})
+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",
+3 -7
View File
@@ -1,6 +1,7 @@
import va from "@vercel/analytics"
import { z } from "zod"
import { analytics } from "@/lib/analytics"
const eventSchema = z.object({
name: z.enum([
"copy_npm_command",
@@ -27,11 +28,6 @@ export type Event = z.infer<typeof eventSchema>
export function trackEvent(input: Event): void {
const event = eventSchema.parse(input)
if (event) {
va.track(
event.name,
event.properties as
| Record<string, string | number | boolean | null>
| undefined
)
analytics.capture(event.name, event.properties)
}
}
+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
+3 -4
View File
@@ -3,10 +3,9 @@ import { promises as fs } from "fs"
import path from "path"
import type { Highlighter } from "shiki"
// Enable syntax highlighting in development
// For static exports, we can use client-side highlighting instead
const highlightCodeEnabled =
process.env.NODE_ENV === "development" || !process.env.GITHUB_ACTIONS
// Highlight on the server while developing. A production build is a static
// export, which highlights on the client instead — so the build stays cheap.
const highlightCodeEnabled = process.env.NODE_ENV === "development"
// Singleton highlighter instance to prevent memory leaks
let highlighterInstance: Highlighter | null = null
+24 -25
View File
@@ -1,7 +1,8 @@
"use client"
import { getDefaultConfig } from "@rainbow-me/rainbowkit"
import { defineChain } from "viem"
import { createConfig, http } from "wagmi"
import { injected } from "wagmi/connectors"
// Localhost (Anvil)
export const localhost = defineChain({
@@ -155,28 +156,30 @@ export const zooTestnet = defineChain({
testnet: true,
})
let _config: ReturnType<typeof getDefaultConfig> | undefined
const chains = [
localhost,
hanzoMainnet,
hanzoTestnet,
luxMainnet,
luxTestnet,
zooMainnet,
zooTestnet,
] as const
export const getConfig = () => {
if (typeof window === "undefined") {
// Return a minimal config for SSR that won't be used
return {} as ReturnType<typeof getDefaultConfig>
}
type Config = ReturnType<typeof createConfig>
let _config: Config | undefined
export const getConfig = (): Config => {
if (!_config) {
_config = getDefaultConfig({
appName: "Hanzo Identity",
projectId:
process.env.NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID || "YOUR_PROJECT_ID",
chains: [
localhost,
hanzoMainnet,
hanzoTestnet,
luxMainnet,
luxTestnet,
zooMainnet,
zooTestnet,
],
_config = createConfig({
chains,
// The browser wallet speaks EIP-1193 directly. No third-party bridge, no
// vendor project id, and nothing phoning home from a docs page.
connectors: [injected()],
transports: Object.fromEntries(
chains.map((chain) => [chain.id, http()])
) as Record<(typeof chains)[number]["id"], ReturnType<typeof http>>,
ssr: true,
})
}
@@ -184,8 +187,4 @@ export const getConfig = () => {
return _config
}
// For backward compatibility
export const config =
typeof window !== "undefined"
? getConfig()
: ({} as ReturnType<typeof getDefaultConfig>)
export const config = getConfig()
+4 -6
View File
@@ -5,15 +5,13 @@ const nextConfig = {
// Transpile packages that might have issues with pnpm symlinks
transpilePackages: ["chrono-node", "@hanzo/ui"],
// Enable static export for GitHub Pages / Cloudflare Pages deployment (but not for E2E tests)
output: (process.env.GITHUB_ACTIONS || process.env.CF_PAGES) && !process.env.E2E_TEST ? "export" : undefined,
// The docs site is a static export, wherever it is built. It is served by
// hanzoai/static; `next dev` is unaffected by this.
output: "export",
// Use trailing slashes for GitHub Pages compatibility
// Directory-style URLs, so a static server resolves /docs/button/index.html.
trailingSlash: true,
// Base path for GitHub Pages (when deployed to github.io subdirectory)
basePath: process.env.GITHUB_PAGES ? "/react-sdk" : "",
// Asset prefix for proper loading on custom domain
assetPrefix: process.env.NEXT_PUBLIC_APP_URL || "",
+1 -2
View File
@@ -31,6 +31,7 @@
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@faker-js/faker": "^10.4.0",
"@hanzo/event": "^0.3.1",
"@hanzo/docs-core": "16.5.3",
"@hanzo/docs-docgen": "3.0.7",
"@hanzo/docs-mdx": "14.3.0",
@@ -70,7 +71,6 @@
"@radix-ui/react-toggle": "^1.1.10",
"@radix-ui/react-toggle-group": "^1.1.11",
"@radix-ui/react-tooltip": "^1.2.8",
"@rainbow-me/rainbowkit": "^2.2.10",
"@rc-component/color-picker": "^3.1.1",
"@tabler/icons-react": "^3.40.0",
"@tanstack/react-query": "^5.94.5",
@@ -79,7 +79,6 @@
"@tiptap/react": "^3.20.4",
"@tiptap/starter-kit": "^3.20.4",
"@types/react-syntax-highlighter": "^15.5.13",
"@vercel/analytics": "^2.0.1",
"@vercel/og": "^0.11.1",
"@xyflow/react": "^12.10.1",
"chrono-node": "^2.9.0",
File diff suppressed because one or more lines are too long
-14
View File
@@ -1,14 +0,0 @@
import { NextApiRequest, NextApiResponse } from "next"
import components from "./components.json"
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
if (req.method !== "GET") {
return res.status(405).end()
}
return res.status(200).json(components)
}
-1
View File
@@ -1 +0,0 @@
ui.hanzo.ai
+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: {
+4
View File
@@ -0,0 +1,4 @@
node_modules
dist
e2e-shots
*.tsbuildinfo
+78
View File
@@ -0,0 +1,78 @@
# Hanzo CD — the dedicated deploy dashboard (cd.hanzo.ai)
A focused, mobile-first CD dashboard served at **cd.hanzo.ai**. It replaces the
ArgoCD React fork (`~/work/hanzo/deploy/ui`, webpack/`argo-cd-ui`) with the shared
Hanzo component packages over the native cloud CD plane. Its whole job: **see every
service deployed as an operator App CR — stats · sync · health · resource tree ·
logs · sync/rollback.**
## Stack + why
- **Vite + React 19 static SPA** → `dist/` (a static bundle, no runtime server).
- **`@hanzo/gitops`** (workspace) is the ArgoCD-replacement UI — a clean-room,
framework-free port (plain React + scoped CSS, no Tamagui/Tailwind). We mount
`GitopsAppList` (the fleet), and compose `GitopsSyncPanel` + `GitopsAppTree` +
`GitopsNodeInfo` + `GitopsRollbackDialog` for the app detail (lazy per-node
`/resource` + `/logs` fetch).
- **`@hanzo/canvas/pure`** (workspace) for the pure helpers.
- The workspace packages are resolved by **Vite path alias** (`vite.config.ts`),
so the app builds off `@hanzo/gitops/dist` + `@hanzo/canvas/src` **without a full
monorepo install** (the shared pnpm store lacks the registry deps of the heavier
packages). Run `pnpm --filter @hanzo/gitops build` once if its `dist` is stale.
## Backend + auth (the contract this preserves)
- **API = the cloud binary at `/v1/deploy`** (SuperAdmin-gated, clean paths —
`applications`, `:name/tree`, `:name/resource/:ref`, `:name/logs`, `sync`,
`rollback`). The cd.hanzo.ai ingress peels `/v1/deploy/*` off to `cloud:8000`;
the SPA calls it **same-origin** with `credentials: 'include'`.
- **Auth = the `admin-console` PKCE gate** (`public/login.html`, ported verbatim
from the ArgoCD fork — self-contained, proven): `hanzo.id` → sets the non-httpOnly
`hanzo_iam_token` cookie the cloud binary validates (`SanitizeIdentity`
`c.IsAdmin()`). The SPA reads that cookie for its gate; a missing/expired session
(or an API 401/403) lands on the sign-in screen — never a fabricated row.
- `src/lib/adapt.ts` maps the `/v1/deploy` DTOs INTO the shared `@hanzo/gitops`
view-models (reusing the package's own `foldHealth`/`foldSync`). Note: the cloud
wire uses the hyphenated `out-of-sync`, which `foldSync` (whitespace-only strip)
reads as Unknown — the adapter strips `[-_]` before folding.
## Build + serve
```bash
pnpm --filter cd build # tsc -b && vite build → dist/ (index.html + login.html + CNAME + assets)
pnpm --filter cd preview # serve dist locally
pnpm --filter cd dev # vite dev (proxies /v1 → CD_API, default https://cd.hanzo.ai)
```
Serving: the built `dist/` is a static bundle published to **`s3://cdn/cd`** — the
existing static plane cd.hanzo.ai already serves (ingress `staticFiles` +
`spaMode`, zero pods; see `universe/infra/k8s/operator/crs/static-sites.yaml`). No
ingress or backend change: publish the bundle, retire the old ArgoCD `ui/` + the
`deploy/dashboard.go` embed.
## Verify
- `tsc --noEmit -p tsconfig.app.json` — clean.
- `npx vitest run src/lib/adapt.test.ts` — the `/v1/deploy``@hanzo/gitops`
mapping (incl. the `out-of-sync` fold fix, `parentRefs` tree linking, clean-semver
rollback).
- `npx playwright test` — builds + serves + proves the fleet renders, a row opens
the ArgoCD-grade detail (sync panel + resource topology), and **no horizontal body
scroll at 390px**. Screenshots in `e2e-shots/`.
## Follow-ons (registry-install / routing gated — flagged, not fabricated)
- **`@hanzo/canvas` fleet MAP** (the Railway board) needs `@hanzo/gui` (Tamagui),
which is an external npm dep not in the offline store here — so the map is a
registry-install follow-on. It already ships green in the console
(`hanzoai/console` `feat/cd-canvas-map`); lifting it in is an import once
`@hanzo/gui` installs.
- **`@hanzo/ui-shadcn` shared shell** (`HanzoHeader` + org/project switcher + ⌘K)
needs a full monorepo install (its Tailwind deps aren't in the offline store).
This app ships a lean self-contained topbar (Geist, small mark, env scope) in the
interim; swap to the shared shell once installed. The **org/project↔IAM switcher**
additionally needs `/v1/iam` routed on cd.hanzo.ai (today the SuperAdmin cookie
sees the whole fleet; env is the scope control).
- **Rollback targets** come from the app's `revisions` (forward-compat — the plane
doesn't expose prior tags yet; empty → the dialog's honest "no history"). Cloud
re-validates the clean semver.
+97
View File
@@ -0,0 +1,97 @@
/**
* e2e: Hanzo CD — mocked-network render + RESPONSIVE proof.
*
* Serves the built static SPA; mocks the cloud CD plane (`/v1/deploy/*`) with
* real-shaped `clients/deploy` rows + a session cookie so the app renders the
* fleet. Proves: the fleet renders on the shared @hanzo/gitops `GitopsAppList`, a
* row opens the ArgoCD-grade detail (sync panel + resource topology), and — the
* CTO requirement — the body never scrolls horizontally at a 390px viewport.
* Screenshots at desktop (1440) and mobile (390).
*/
import { test, expect, type Route, type Page, type BrowserContext } from "@playwright/test"
import { mkdirSync } from "node:fs"
import { join } from "node:path"
const SHOTS = join(process.cwd(), "e2e-shots")
const FLEET = {
applications: [
{ name: "cloud", namespace: "hanzo", env: "main", repository: "ghcr.io/hanzoai/cloud", version: "v1.800.1", runningVersion: "v1.800.1", health: "healthy", sync: "synced", phase: "Running" },
{ name: "iam", namespace: "hanzo", env: "main", repository: "ghcr.io/hanzoai/iam", version: "v1.4.11", runningVersion: "v1.4.10", health: "progressing", healthMessage: "rolling update (1/2)", sync: "out-of-sync", phase: "Running", revisions: ["v1.4.10", "v1.4.9"] },
{ name: "gateway", namespace: "hanzo", env: "main", repository: "ghcr.io/hanzoai/gateway", version: "v2.16.4", runningVersion: "v2.16.4", health: "healthy", sync: "synced", phase: "Running" },
{ name: "o11y", namespace: "hanzo", env: "test", repository: "ghcr.io/hanzoai/o11y", version: "v1.5.12", runningVersion: "v1.5.10", health: "degraded", healthMessage: "CrashLoopBackOff", sync: "out-of-sync", phase: "Degraded" },
],
summary: { total: 4, healthy: 2, degraded: 1, outOfSync: 2 },
}
const TREE = {
application: FLEET.applications[1],
nodes: [
{ group: "hanzo.ai", version: "v1", kind: "App", namespace: "hanzo", name: "iam", ref: "hanzo.ai:App:hanzo:iam", uid: "u1", health: "progressing", parentRefs: [] },
{ group: "apps", version: "v1", kind: "Deployment", namespace: "hanzo", name: "iam", ref: "apps:Deployment:hanzo:iam", uid: "u2", health: "progressing", parentRefs: [{ ref: "hanzo.ai:App:hanzo:iam" }] },
{ group: "apps", version: "v1", kind: "ReplicaSet", namespace: "hanzo", name: "iam-6d8f", ref: "apps:ReplicaSet:hanzo:iam-6d8f", uid: "u3", health: "healthy", parentRefs: [{ ref: "apps:Deployment:hanzo:iam" }] },
{ group: "", version: "v1", kind: "Pod", namespace: "hanzo", name: "iam-6d8f-abc", ref: ":Pod:hanzo:iam-6d8f-abc", uid: "u4", health: "healthy", parentRefs: [{ ref: "apps:ReplicaSet:hanzo:iam-6d8f" }] },
],
}
const RESOURCE = { ref: "apps:Deployment:hanzo:iam", health: "healthy", liveManifest: { apiVersion: "apps/v1", kind: "Deployment", metadata: { name: "iam" }, spec: { replicas: 2 } }, desiredSource: "last-applied", diff: { modified: false } }
const LOGS = { application: "hanzo/iam", pod: "iam-6d8f-abc", logs: "listening on :8080\nready to serve\n" }
async function mock(route: Route) {
const p = new URL(route.request().url()).pathname
const json = (b: unknown) => route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(b) })
if (p === "/v1/deploy/applications") return json(FLEET)
if (/^\/v1\/deploy\/[^/]+\/tree$/.test(p)) return json(TREE)
if (/^\/v1\/deploy\/[^/]+\/resource\//.test(p)) return json(RESOURCE)
if (/^\/v1\/deploy\/[^/]+\/logs$/.test(p)) return json(LOGS)
if (p.startsWith("/v1/deploy/")) return json({ ok: true })
return route.continue()
}
async function open(ctx: BrowserContext): Promise<Page> {
// The PKCE login sets `hanzo_iam_token`; seed it so the SPA renders the dashboard.
await ctx.addCookies([{ name: "hanzo_iam_token", value: "e.y.j", url: "http://localhost:4173" }])
const page = await ctx.newPage()
await page.route("**/*", mock)
await page.goto("/", { waitUntil: "domcontentloaded" })
await page.locator("text=Fleet").first().waitFor({ timeout: 15_000 })
return page
}
test.beforeAll(() => mkdirSync(SHOTS, { recursive: true }))
test("renders the fleet, opens a row → ArgoCD-grade detail (desktop)", async ({ browser }) => {
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } })
const page = await open(ctx)
await expect(page.locator("text=Applications").first()).toBeVisible()
await expect(page.locator("tr", { hasText: "iam" }).first()).toBeVisible()
// sync fold: the hyphenated cloud verdict renders OutOfSync (not Unknown).
await expect(page.locator("text=OutOfSync").first()).toBeVisible()
await page.screenshot({ path: join(SHOTS, "cd-fleet-desktop.png"), fullPage: true })
// Tap the app's NAME cell (always visible; the row overflows on narrow tables).
await page.locator(".hz-gitops-row", { hasText: "iam" }).first().locator("td").first().click()
await expect(page.getByText("← Fleet").first()).toBeVisible({ timeout: 10_000 }) // detail-only breadcrumb
await expect(page.locator(".hz-gitops-tree-world").first()).toBeVisible({ timeout: 10_000 })
await expect(page.locator("text=Deployment").first()).toBeVisible()
await page.screenshot({ path: join(SHOTS, "cd-detail-desktop.png"), fullPage: true })
await ctx.close()
})
test("reflows with no horizontal body scroll at a narrow (mobile) viewport", async ({ browser }) => {
const ctx = await browser.newContext({ viewport: { width: 390, height: 844 } })
const page = await open(ctx)
await expect(page.locator("tr", { hasText: "iam" }).first()).toBeVisible()
const overflow = await page.evaluate(() => ({ sw: document.documentElement.scrollWidth, cw: document.documentElement.clientWidth }))
expect(overflow.sw, "no horizontal body scroll at 390px").toBeLessThanOrEqual(overflow.cw + 1)
await page.screenshot({ path: join(SHOTS, "cd-fleet-mobile.png"), fullPage: true })
// Tap-to-open must work on mobile: tap the name cell → the ArgoCD-grade detail.
await page.locator(".hz-gitops-row", { hasText: "iam" }).first().locator("td").first().click()
await expect(page.getByText("← Fleet").first()).toBeVisible({ timeout: 10_000 })
await expect(page.locator(".hz-gitops-tree-world").first()).toBeVisible({ timeout: 10_000 })
const overflow2 = await page.evaluate(() => ({ sw: document.documentElement.scrollWidth, cw: document.documentElement.clientWidth }))
expect(overflow2.sw, "no horizontal body scroll on the detail at 390px").toBeLessThanOrEqual(overflow2.cw + 1)
await page.screenshot({ path: join(SHOTS, "cd-detail-mobile.png"), fullPage: true })
await ctx.close()
})
+17
View File
@@ -0,0 +1,17 @@
<!doctype html>
<html lang="en" data-theme="dark">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<title>Hanzo CD</title>
<link
rel="icon"
type="image/svg+xml"
href="data:image/svg+xml,%3Csvg viewBox='0 0 64 64' xmlns='http://www.w3.org/2000/svg'%3E%3Crect width='64' height='64' rx='8' fill='%23000'/%3E%3Cg transform='translate(8,8) scale(0.716)'%3E%3Cpath d='M22.21 67V44.6369H0V67H22.21Z' fill='%23fff'/%3E%3Cpath d='M66.7038 22.3184H22.2534L0.0878906 44.6367H44.4634L66.7038 22.3184Z' fill='%23fff'/%3E%3Cpath d='M22.21 0H0V22.3184H22.21V0Z' fill='%23fff'/%3E%3Cpath d='M66.7198 0H44.5098V22.3184H66.7198V0Z' fill='%23fff'/%3E%3Cpath d='M66.7198 67V44.6369H44.5098V67H66.7198Z' fill='%23fff'/%3E%3C/g%3E%3C/svg%3E"
/>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+26
View File
@@ -0,0 +1,26 @@
{
"name": "cd",
"private": true,
"version": "0.1.0",
"type": "module",
"description": "Hanzo CD — the dedicated deploy dashboard served at cd.hanzo.ai (a @hanzo/gitops + @hanzo/canvas SPA over /v1/deploy). Replaces the ArgoCD UI fork.",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"typecheck": "tsc --noEmit -p tsconfig.app.json",
"preview": "vite preview --port 4173"
},
"dependencies": {
"@hanzo/canvas": "workspace:*",
"@hanzo/gitops": "workspace:*",
"react": "^19.2.4",
"react-dom": "^19.2.4"
},
"devDependencies": {
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.3",
"typescript": "~6.0.2",
"vite": "^8.1.4"
}
}
+28
View File
@@ -0,0 +1,28 @@
import { defineConfig, devices } from "@playwright/test"
/**
* Hanzo CD render + responsive proof. Builds the static SPA and serves it (vite
* preview), then the spec mocks `/v1/deploy/*` + a session cookie and asserts the
* fleet renders, a row opens the ArgoCD-grade detail (sync panel + resource tree),
* and the body never scrolls horizontally at 390px. Screenshots per width.
*
* pnpm --filter cd exec playwright test # builds + serves + runs
* BASE_URL=https://cd.hanzo.ai pnpm … test # against a live deploy
*/
export default defineConfig({
testDir: "./e2e",
timeout: 60_000,
retries: 1,
workers: 1,
reporter: "list",
use: { baseURL: process.env.BASE_URL ?? "http://localhost:4173", headless: true },
webServer: process.env.BASE_URL
? undefined
: {
command: "npx vite build && npx vite preview --port 4173 --strictPort",
url: "http://localhost:4173",
reuseExistingServer: true,
timeout: 120_000,
},
projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }],
})
+1
View File
@@ -0,0 +1 @@
cd.hanzo.ai
+143
View File
@@ -0,0 +1,143 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Hanzo CD — Sign in</title>
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg viewBox='0 0 64 64' xmlns='http://www.w3.org/2000/svg'%3E%3Crect width='64' height='64' rx='8' fill='%23000'/%3E%3Cg transform='translate(8,8) scale(0.716)'%3E%3Cpath d='M22.21 67V44.6369H0V67H22.21Z' fill='%23fff'/%3E%3Cpath d='M66.7038 22.3184H22.2534L0.0878906 44.6367H44.4634L66.7038 22.3184Z' fill='%23fff'/%3E%3Cpath d='M22.21 0H0V22.3184H22.21V0Z' fill='%23fff'/%3E%3Cpath d='M66.7198 0H44.5098V22.3184H66.7198V0Z' fill='%23fff'/%3E%3Cpath d='M66.7198 67V44.6369H44.5098V67H66.7198Z' fill='%23fff'/%3E%3C/g%3E%3C/svg%3E">
<style>
:root { color-scheme: light dark; }
* { box-sizing: border-box; }
html,body { height:100%; margin:0; }
body {
font-family: "Geist", "Geist Sans", ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
background:#0a0a0a; color:#e7e7e7;
display:grid; grid-template-columns: 1.1fr 1fr; min-height:100vh;
}
@media (max-width: 820px){ body{ grid-template-columns:1fr; } .pitch{ display:none; } }
.pitch { padding:64px 56px; display:flex; flex-direction:column; justify-content:center; gap:22px;
border-right:1px solid #1c1c1c; }
.brand { display:flex; align-items:center; gap:12px; font-size:22px; font-weight:700; letter-spacing:-.02em; }
.brand .mark { width:26px; height:26px; display:block; }
h1 { font-size:40px; line-height:1.08; margin:6px 0 0; letter-spacing:-.03em; font-weight:800; }
.sub { color:#9a9a9a; font-size:16px; max-width:38ch; line-height:1.5; }
ul.feat { list-style:none; padding:0; margin:8px 0 0; display:grid; gap:12px; }
ul.feat li { display:flex; gap:12px; align-items:flex-start; color:#cfcfcf; font-size:14.5px; }
ul.feat li b { color:#fff; font-weight:600; }
ul.feat .dot { width:6px; height:6px; border-radius:50%; background:#fff; margin-top:7px; flex:none; }
.auth { display:flex; align-items:center; justify-content:center; padding:40px; }
.card { width:100%; max-width:360px; text-align:center; }
.card .mark2 { width:52px; height:52px; display:block; margin:0 auto 18px; }
.card h2 { font-size:22px; margin:0 0 6px; font-weight:700; }
.card p.hint { color:#8f8f8f; font-size:14px; margin:0 0 26px; }
button#login {
width:100%; padding:14px 18px; font-size:15px; font-weight:600; cursor:pointer;
background:#fff; color:#000; border:0; border-radius:10px; display:flex; align-items:center;
justify-content:center; gap:10px; transition:opacity .15s;
}
button#login:hover { opacity:.88; }
button#login:disabled { opacity:.5; cursor:default; }
.status { margin-top:16px; font-size:13.5px; color:#9a9a9a; min-height:20px; }
.status.err { color:#ff7a7a; }
.foot { margin-top:28px; font-size:12px; color:#5c5c5c; }
a { color:#bdbdbd; }
</style>
</head>
<body>
<section class="pitch">
<div class="brand"><svg class="mark" viewBox="0 0 67 67" xmlns="http://www.w3.org/2000/svg" aria-hidden="true"><path d="M22.21 67V44.6369H0V67H22.21Z" fill="#fff"/><path d="M66.7038 22.3184H22.2534L0.0878906 44.6367H44.4634L66.7038 22.3184Z" fill="#fff"/><path d="M22.21 0H0V22.3184H22.21V0Z" fill="#fff"/><path d="M66.7198 0H44.5098V22.3184H66.7198V0Z" fill="#fff"/><path d="M66.7198 67V44.6369H44.5098V67H66.7198Z" fill="#fff"/></svg> Hanzo CD</div>
<h1>Continuous delivery for your whole fleet.</h1>
<p class="sub">GitOps for every Hanzo App. Declarative, versioned, and auto-synced from git to your clusters — one control plane across hanzo, lux, zoo &amp; pars.</p>
<ul class="feat">
<li><span class="dot"></span><span><b>Live fleet view</b> — health &amp; sync status for every App across all namespaces.</span></li>
<li><span class="dot"></span><span><b>Git-to-cluster</b> — desired state lives in <code>infra/k8s/operator/crs</code>, reconciled continuously.</span></li>
<li><span class="dot"></span><span><b>Sync &amp; rollback</b> — one click to reconcile or roll back any application.</span></li>
<li><span class="dot"></span><span><b>Resource trees &amp; logs</b> — drill into any workload the operator manages.</span></li>
</ul>
</section>
<section class="auth">
<div class="card">
<svg class="mark2" viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg" aria-hidden="true"><rect width="64" height="64" rx="14" fill="#fff"/><g transform="translate(8,8) scale(0.716)"><path d="M22.21 67V44.6369H0V67H22.21Z" fill="#000"/><path d="M66.7038 22.3184H22.2534L0.0878906 44.6367H44.4634L66.7038 22.3184Z" fill="#000"/><path d="M22.21 0H0V22.3184H22.21V0Z" fill="#000"/><path d="M66.7198 0H44.5098V22.3184H66.7198V0Z" fill="#000"/><path d="M66.7198 67V44.6369H44.5098V67H66.7198Z" fill="#000"/></g></svg>
<h2>Hanzo CD</h2>
<p class="hint">Sign in with your Hanzo account to manage the fleet.</p>
<button id="login">
<svg width="17" height="17" viewBox="0 0 67 67" xmlns="http://www.w3.org/2000/svg" aria-hidden="true"><path d="M22.21 67V44.6369H0V67H22.21Z" fill="currentColor"/><path d="M66.7038 22.3184H22.2534L0.0878906 44.6367H44.4634L66.7038 22.3184Z" fill="currentColor"/><path d="M22.21 0H0V22.3184H22.21V0Z" fill="currentColor"/><path d="M66.7198 0H44.5098V22.3184H66.7198V0Z" fill="currentColor"/><path d="M66.7198 67V44.6369H44.5098V67H66.7198Z" fill="currentColor"/></svg>
Sign in with Hanzo
</button>
<div class="status" id="status"></div>
<div class="foot">Powered by <a href="https://hanzo.id" rel="noreferrer">Hanzo IAM</a> · SuperAdmin access</div>
</div>
</section>
<script>
(function () {
var IAM = 'https://hanzo.id';
var CLIENT = 'admin-console'; // the IAM app whose org is `admin`
var REDIRECT = location.origin + '/login.html'; // must be registered on admin-console
var COOKIE = 'hanzo_iam_token'; // the cookie hanzoai/cloud validates
var statusEl = document.getElementById('status');
var btn = document.getElementById('login');
function setStatus(m, err){ statusEl.textContent = m || ''; statusEl.className = 'status' + (err ? ' err' : ''); }
function b64url(bytes){
var s = btoa(String.fromCharCode.apply(null, new Uint8Array(bytes)));
return s.replace(/\+/g,'-').replace(/\//g,'_').replace(/=+$/,'');
}
function randB64(n){ return b64url(crypto.getRandomValues(new Uint8Array(n))); }
async function sha256b64(str){
var d = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(str));
return b64url(d);
}
async function startLogin(){
btn.disabled = true; setStatus('Redirecting to Hanzo…');
var verifier = randB64(48);
var state = randB64(16);
sessionStorage.setItem('cd_pkce_verifier', verifier);
sessionStorage.setItem('cd_oauth_state', state);
var challenge = await sha256b64(verifier);
var u = new URL(IAM + '/login/oauth/authorize');
u.search = new URLSearchParams({
client_id: CLIENT, response_type: 'code', redirect_uri: REDIRECT,
scope: 'openid profile email', state: state,
code_challenge: challenge, code_challenge_method: 'S256'
}).toString();
location.href = u.toString();
}
async function finishLogin(code, state){
btn.disabled = true; setStatus('Signing in…');
if (state !== sessionStorage.getItem('cd_oauth_state')) { setStatus('Sign-in state mismatch — please try again.', true); btn.disabled = false; return; }
var verifier = sessionStorage.getItem('cd_pkce_verifier');
if (!verifier) { setStatus('Sign-in session lost — please try again.', true); btn.disabled = false; return; }
try {
var r = await fetch(IAM + '/v1/iam/oauth/access_token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'authorization_code', client_id: CLIENT, code: code,
redirect_uri: REDIRECT, code_verifier: verifier
})
});
var j = await r.json();
if (!j.access_token) { setStatus('Sign-in failed: ' + (j.error_description || j.error || 'no token'), true); btn.disabled = false; return; }
// set the session cookie cloud reads (non-httpOnly by design so the page can set it; still Secure)
document.cookie = COOKIE + '=' + j.access_token + '; path=/; secure; samesite=lax; max-age=86400';
sessionStorage.removeItem('cd_pkce_verifier'); sessionStorage.removeItem('cd_oauth_state');
setStatus('Signed in. Loading the dashboard…');
location.replace('/');
} catch (e) {
setStatus('Sign-in error: ' + (e && e.message ? e.message : e), true); btn.disabled = false;
}
}
var q = new URLSearchParams(location.search);
if (q.get('error')) { setStatus('Hanzo returned: ' + (q.get('error_description') || q.get('error')), true); }
else if (q.get('code')) { finishLogin(q.get('code'), q.get('state')); }
btn.onclick = startLogin;
})();
</script>
</body>
</html>
+171
View File
@@ -0,0 +1,171 @@
/**
* Hanzo CD — the dedicated deploy dashboard (cd.hanzo.ai). A static SPA over the
* cloud CD plane (`/v1/deploy`): the fleet of operator App CRs with health, sync,
* resource topology, logs, and sync/rollback, built on the shared @hanzo/gitops
* components. Auth is the first-party `hanzo_iam_token` cookie (set by the PKCE
* login); a missing/expired session lands on the sign-in screen — never a fake row.
*/
import { useCallback, useEffect, useMemo, useState } from "react"
import { DeployApi, hasSession, isAuthError } from "./lib/deploy"
import type { DeployApp } from "./lib/adapt"
import { Topbar, type EnvOption } from "./shell/Topbar"
import { FleetView } from "./views/FleetView"
import { AppView } from "./views/AppView"
const POLL_MS = 20_000
type Phase = "signin" | "loading" | "error" | "ready"
const parseHash = (): string => {
const m = /^#\/app\/(.+)$/.exec(location.hash)
return m ? decodeURIComponent(m[1]) : ""
}
function SignIn() {
return (
<div style={{ minHeight: "100vh", display: "grid", placeItems: "center", padding: 24 }}>
<div style={{ textAlign: "center", maxWidth: 360 }}>
<svg width={52} height={52} viewBox="0 0 64 64" style={{ marginBottom: 18 }} xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<rect width="64" height="64" rx="14" fill="#fff" />
<g transform="translate(8,8) scale(0.716)">
<path d="M22.21 67V44.6369H0V67H22.21Z" fill="#000" />
<path d="M66.7038 22.3184H22.2534L0.0878906 44.6367H44.4634L66.7038 22.3184Z" fill="#000" />
<path d="M22.21 0H0V22.3184H22.21V0Z" fill="#000" />
<path d="M66.7198 0H44.5098V22.3184H66.7198V0Z" fill="#000" />
<path d="M66.7198 67V44.6369H44.5098V67H66.7198Z" fill="#000" />
</g>
</svg>
<h2 style={{ fontSize: 22, margin: "0 0 6px", fontWeight: 700 }}>Hanzo CD</h2>
<p className="cd-muted" style={{ margin: "0 0 26px", fontSize: 14 }}>
Sign in with your Hanzo account to manage the fleet.
</p>
<button type="button" className="cd-btn cd-btn--primary" style={{ width: "100%", justifyContent: "center", padding: "13px 18px" }} onClick={() => (location.href = "/login.html")}>
Sign in with Hanzo
</button>
<div className="cd-muted" style={{ marginTop: 28, fontSize: 12 }}>
Powered by Hanzo IAM · SuperAdmin access
</div>
</div>
</div>
)
}
export function App() {
const [phase, setPhase] = useState<Phase>(hasSession() ? "loading" : "signin")
const [apps, setApps] = useState<DeployApp[]>([])
const [errMsg, setErrMsg] = useState("")
const [env, setEnv] = useState("")
const [selected, setSelected] = useState<string>(parseHash())
const [refreshing, setRefreshing] = useState(false)
const [toast, setToast] = useState<{ msg: string; err?: boolean } | null>(null)
const load = useCallback(() => {
if (!hasSession()) {
setPhase("signin")
return
}
setRefreshing(true)
DeployApi.applications()
.then((rows) => {
setApps(rows)
setPhase("ready")
})
.catch((e) => {
if (isAuthError(e)) setPhase("signin")
else {
setPhase("error")
setErrMsg(e instanceof Error ? e.message : "failed to load")
}
})
.finally(() => setRefreshing(false))
}, [])
useEffect(() => {
load()
const t = setInterval(load, POLL_MS)
const onHash = () => setSelected(parseHash())
window.addEventListener("hashchange", onHash)
return () => {
clearInterval(t)
window.removeEventListener("hashchange", onHash)
}
}, [load])
const notify = useCallback((msg: string, err?: boolean) => {
setToast({ msg, err })
setTimeout(() => setToast(null), 4000)
}, [])
const envs: EnvOption[] = useMemo(() => {
const counts = new Map<string, number>()
for (const a of apps) if (a.env) counts.set(a.env, (counts.get(a.env) ?? 0) + 1)
return Array.from(counts, ([id, count]) => ({ id, label: id, count })).sort((x, y) => x.id.localeCompare(y.id))
}, [apps])
const visibleApps = useMemo(() => (env ? apps.filter((a) => a.env === env) : apps), [apps, env])
const selectedApp = selected ? apps.find((a) => a.name === selected) ?? null : null
const openApp = (name: string) => {
location.hash = `#/app/${encodeURIComponent(name)}`
}
const backToFleet = () => {
location.hash = ""
}
const signOut = () => {
document.cookie = "hanzo_iam_token=; path=/; max-age=0"
location.href = "/login.html"
}
if (phase === "signin") return <SignIn />
return (
<div className="cd-app">
<Topbar envs={envs} env={env} onEnv={setEnv} onRefresh={load} refreshing={refreshing} onSignOut={signOut} />
<main className="cd-main">
{phase === "loading" ? (
<div className="cd-muted" style={{ padding: 40 }}>
Loading the fleet
</div>
) : phase === "error" ? (
<div style={{ padding: 24, border: "1px solid var(--cd-border-strong)", borderRadius: 10, background: "var(--cd-surface)" }}>
<div style={{ fontWeight: 700, marginBottom: 6 }}>Could not reach the deploy plane</div>
<div className="cd-muted" style={{ fontSize: 14, marginBottom: 14 }}>
{errMsg || "The CD read (GET /v1/deploy/applications) failed."}
</div>
<button type="button" className="cd-btn" onClick={load}>
Retry
</button>
</div>
) : selectedApp ? (
<AppView app={selectedApp} onBack={backToFleet} onChanged={load} notify={notify} />
) : (
<FleetView apps={visibleApps} onOpen={openApp} />
)}
</main>
{toast ? (
<div
role="status"
style={{
position: "fixed",
bottom: 18,
left: "50%",
transform: "translateX(-50%)",
zIndex: 1000,
maxWidth: "calc(100vw - 32px)",
padding: "11px 16px",
borderRadius: 10,
background: toast.err ? "#3d1418" : "var(--cd-surface-2)",
border: `1px solid ${toast.err ? "#E96D76" : "var(--cd-border-strong)"}`,
color: "var(--cd-fg-strong)",
fontSize: 13.5,
boxShadow: "0 8px 30px rgba(0,0,0,0.4)",
}}
>
{toast.msg}
</div>
) : null}
</div>
)
}
+261
View File
@@ -0,0 +1,261 @@
/**
* RED adversarial suite — feeds hostile / malformed /v1/deploy JSON to the adapter
* and the shared @hanzo/gitops folds, asserting the view degrades honestly and
* NEVER (a) crashes, (b) wedges the tree builder into a hang, or (c) mislabels a
* bad state as Healthy/Synced. Written by Red; not part of Blue's suite.
*/
import { describe, expect, it } from "vitest"
import { buildResourceGraph, foldHealth, foldSync } from "@hanzo/gitops"
import {
manifestText,
normalizeDeployApp,
parseApplications,
toGitopsApp,
toLogLines,
toManagedResource,
toResourceTree,
toRollbackHistory,
} from "./adapt"
// A wall-clock guard: proves a call TERMINATES (no infinite loop) fast.
function within<T>(ms: number, fn: () => T): T {
const t0 = Date.now()
const out = fn()
const dt = Date.now() - t0
expect(dt).toBeLessThan(ms)
return out
}
// ── A. Robustness: hostile top-level inputs never throw ──────────────────────
describe("RED robustness — malformed payloads never crash", () => {
const junk: unknown[] = [null, undefined, 42, "str", true, [], {}, { applications: null }, { apps: 7 }, NaN]
it("parseApplications tolerates any junk → array, never throws", () => {
for (const j of junk) expect(Array.isArray(parseApplications(j))).toBe(true)
})
it("normalizeDeployApp of junk → safe defaults", () => {
for (const j of junk) {
const a = normalizeDeployApp(j)
expect(typeof a.name).toBe("string")
expect(a.namespace).toBe("hanzo") // default
expect(Array.isArray(a.endpoints)).toBe(true)
}
})
it("toResourceTree / toManagedResource / toLogLines tolerate junk", () => {
for (const j of junk) {
expect(Array.isArray(toResourceTree(j).nodes)).toBe(true)
expect(() => toManagedResource(j)).not.toThrow()
expect(Array.isArray(toLogLines(j))).toBe(true)
}
})
it("wrong-typed fields (health object, numeric name) degrade to empty, dropped", () => {
const rows = parseApplications({ applications: [{ name: 123, health: { evil: 1 }, sync: ["x"] }, { name: "ok" }] })
expect(rows.map((r) => r.name)).toEqual(["ok"]) // numeric-name row dropped
const g = toGitopsApp(normalizeDeployApp({ name: "z", health: { nested: true } }))
expect(g.health).toBe("Unknown") // object health → '' → Unknown, NOT guessed up
})
})
// ── B. CYCLE SAFETY — the headline #3 case ───────────────────────────────────
// Adversarial parentRefs must not wedge buildResourceGraph into an infinite loop.
const treeJSON = (nodes: object[]) => toResourceTree({ nodes })
const node = (ref: string, kind: string, parents: string[] = []) => ({
ref,
kind,
name: ref,
namespace: "demo",
parentRefs: parents.map((p) => ({ ref: p })),
})
describe("RED cycle safety — buildResourceGraph terminates on hostile parentRefs", () => {
it("self-referencing parent (A→A) terminates; node survives as a root", () => {
const g = within(1000, () => buildResourceGraph(treeJSON([node("A", "Pod", ["A"])])))
expect(g.nodes.map((n) => n.id)).toContain("A")
expect(g.nodes.find((n) => n.id === "A")!.depth).toBe(0)
expect(g.edges).toHaveLength(0) // self-edge dropped
})
// ── HIGH-1 (FIXED): buildResourceGraph used to THROW whenever a node's parent
// appeared LATER in the array — `children` was seeded lazily per-node, so
// `children.get(pid)!.push(id)` dereferenced undefined. That is EVERY cycle AND
// any tree listing a child before its parent (K8s lists are not topologically
// sorted), and with no error boundary the whole SPA white-screened. `children`
// is now pre-seeded for every id, so order and cycles are both safe. These are
// the regression tests: each MUST build a graph, never throw. ───────────────
it("BENIGN acyclic tree that lists a child before its parent builds", () => {
// Deployment listed before the Application that owns it — a totally normal,
// non-adversarial ordering a backend may emit.
const g = within(1000, () =>
buildResourceGraph(treeJSON([node("dep", "Deployment", ["app"]), node("app", "Application")])),
)
expect(g.nodes.map((n) => n.id).sort()).toEqual(["app", "dep"])
expect(g.nodes.find((n) => n.id === "app")!.depth).toBe(0)
expect(g.nodes.find((n) => n.id === "dep")!.depth).toBe(1)
})
it("2-cycle with NO external root (A↔B) does not crash", () => {
const g = within(1000, () =>
buildResourceGraph(treeJSON([node("A", "Pod", ["B"]), node("B", "Pod", ["A"])])),
)
expect(Array.isArray(g.nodes)).toBe(true) // unreachable-from-a-root nodes may drop; must not throw
})
it("2-cycle WITH an external root (R→A, A↔B) renders R, A, B", () => {
const g = within(1000, () =>
buildResourceGraph(treeJSON([node("R", "App"), node("A", "Pod", ["R", "B"]), node("B", "Pod", ["A"])])),
)
expect(g.nodes.map((n) => n.id).sort()).toEqual(["A", "B", "R"])
})
it("3-cycle (A→B→C→A) with a root renders 4 nodes", () => {
const g = within(1000, () =>
buildResourceGraph(
treeJSON([node("R", "App"), node("A", "Pod", ["R", "C"]), node("B", "Pod", ["A"]), node("C", "Pod", ["B"])]),
),
)
expect(g.nodes.map((n) => n.id).sort()).toEqual(["A", "B", "C", "R"])
})
it("dangling parent (points at a non-existent uid) → node is a root, no crash", () => {
const g = within(1000, () => buildResourceGraph(treeJSON([node("A", "Pod", ["ghost-does-not-exist"])])))
expect(g.nodes.find((n) => n.id === "A")!.depth).toBe(0)
})
it("large deep chain (2000 nodes) terminates quickly", () => {
const chain = Array.from({ length: 2000 }, (_, i) => node(`n${i}`, "Pod", i ? [`n${i - 1}`] : []))
const g = within(6000, () => buildResourceGraph(treeJSON(chain)))
expect(g.nodes.length).toBe(2000)
})
it("every node self-parents (all self-cycles) → all render as roots, terminates", () => {
const many = Array.from({ length: 500 }, (_, i) => node(`s${i}`, "Pod", [`s${i}`]))
const g = within(4000, () => buildResourceGraph(treeJSON(many)))
expect(g.nodes.length).toBe(500)
})
})
// ── C. MISLABEL — the fold's one dangerous direction: bad → Healthy/Synced ────
describe("RED mislabel (FIXED) — a BAD state is never up-guessed to a GOOD one", () => {
it("health words that MEAN broken never fold to Healthy", () => {
// The positive substring up-guess is gone: unrecognized → honest Unknown.
expect(foldHealth("Broken")).toBe("Unknown") // contains 'ok'
expect(foldHealth("revoked")).toBe("Unknown") // cert/token revoked
expect(foldHealth("invoked")).toBe("Unknown")
expect(foldHealth("NotReady")).toBe("Unknown") // contains 'ready'
expect(foldHealth("unavailable")).toBe("Unknown") // contains 'available'
expect(foldHealth("MinimumReplicasUnavailable")).toBe("Unknown")
// The canonical vocabulary cloud actually emits still folds exactly:
expect(foldHealth("healthy")).toBe("Healthy")
expect(foldHealth("")).toBe("Unknown")
expect(foldHealth("degraded")).toBe("Degraded")
expect(foldHealth("CrashLoopBackOff")).toBe("Degraded") // bad-substring — safe direction
})
it("sync words that MEAN not-synced never fold to Synced", () => {
expect(foldSync("notsynced")).toBe("Unknown") // means NOT synced
expect(foldSync("unsynced")).toBe("Unknown")
expect(foldSync("NotSynced")).toBe("Unknown")
// Canonical + separator-normalized cases still fold exactly:
expect(foldSync("synced")).toBe("Synced")
expect(foldSync("out-of-sync")).toBe("OutOfSync") // hyphens normalized at the source
expect(foldSync("out-of-sync".replace(/[-_]/g, ""))).toBe("OutOfSync")
expect(foldSync("")).toBe("Unknown")
})
it("SAFE: a Degraded app is never shown Healthy through the adapter", () => {
const g = toGitopsApp(normalizeDeployApp({ name: "x", health: "degraded" }))
expect(g.health).toBe("Degraded")
})
})
// ── D. Honest sync derivation (no fabricated Synced) ─────────────────────────
describe("RED honest sync — derived state does not fabricate Synced", () => {
it("missing sync + version drift → OutOfSync", () => {
expect(toGitopsApp(normalizeDeployApp({ name: "a", version: "v2", runningVersion: "v1" })).sync).toBe("OutOfSync")
})
it("missing sync + equal version → Synced (declared==running proxy)", () => {
expect(toGitopsApp(normalizeDeployApp({ name: "a", version: "v1", runningVersion: "v1" })).sync).toBe("Synced")
})
it("missing sync + BOTH versions empty → Synced (''==''), a benign over-report", () => {
// documents: an app the plane reports with no versions folds to Synced, not Unknown.
expect(toGitopsApp(normalizeDeployApp({ name: "a" })).sync).toBe("Synced")
})
})
// ── E. Manifest handling — object/string/circular ────────────────────────────
describe("RED manifest — object/string/circular never throw", () => {
it("circular object manifest → '' (no throw)", () => {
const circular: Record<string, unknown> = { kind: "Deployment" }
circular.self = circular
expect(manifestText(circular)).toBe("")
const m = toManagedResource({ ref: "r", liveManifest: circular })
expect(m.liveState).toBe("")
})
it("object manifest → pretty JSON; string manifest → verbatim; null → ''", () => {
expect(toManagedResource({ ref: "r", liveManifest: { a: 1 } }).liveState).toContain('"a": 1')
expect(toManagedResource({ ref: "r", liveManifest: "raw yaml here" }).liveState).toBe("raw yaml here")
expect(toManagedResource({ ref: "r", liveManifest: null }).liveState).toBe("")
})
it("a manifest string carrying HTML is passed through as TEXT (React escapes on render)", () => {
const evil = "<img src=x onerror=alert(document.cookie)>"
// The adapter must NOT interpret it; it stays a plain string (rendered via {text}).
expect(toManagedResource({ ref: "r", liveManifest: evil }).liveState).toBe(evil)
})
})
// ── F. Logs — array gap + unbounded blob (no client cap) ─────────────────────
describe("RED logs (FIXED) — fidelity + bound", () => {
it("logs delivered as an ARRAY yield lines (blob AND array shapes)", () => {
expect(toLogLines({ pod: "p", logs: ["line-1", "line-2"] })).toEqual([
{ content: "line-1", podName: "p" },
{ content: "line-2", podName: "p" },
])
// the blob shape still works
expect(toLogLines({ pod: "p", logs: "a\nb" }).map((l) => l.content)).toEqual(["a", "b"])
})
it("a huge blob is line-capped client-side (keeps the most recent lines)", () => {
const blob = Array.from({ length: 50_000 }, (_, i) => `l${i}`).join("\n")
const out = toLogLines({ pod: "p", logs: blob })
expect(out.length).toBe(2000) // MAX_LOG_LINES
expect(out[out.length - 1].content).toBe("l49999") // tail kept, not head
})
it("a single enormous line is clamped (no one-megabyte text node)", () => {
const huge = "x".repeat(50_000)
const out = toLogLines({ pod: "p", logs: huge })
expect(out[0].content.length).toBeLessThanOrEqual(4001) // MAX_LOG_LINE + ellipsis
})
})
// ── G. Rollback semver filter — injection + hygiene ──────────────────────────
describe("RED rollback — only clean semver, current excluded, no injection", () => {
it("drops non-semver + injection-shaped tags, excludes current, newest-first, dedupes", () => {
const h = toRollbackHistory("v1.800.1", [
"v1.800.0",
"latest",
"main",
"deadbeef",
"v1.800.1", // == current → excluded
"v1.799.15",
"v1.799.15", // dup
"v1.2.3; rm -rf /", // injection shape → not semver → dropped
"'; DROP TABLE apps;--",
])
expect(h.map((r) => r.revision)).toEqual(["v1.800.0", "v1.799.15"])
expect(h[0].id).toBeGreaterThan(h[1].id)
})
it("pre-release semver IS offered as a rollback target (documents the accepted grammar)", () => {
const h = toRollbackHistory("v2.0.0", ["v1.9.9-rc.1", "v1.9.9"])
expect(h.map((r) => r.revision)).toContain("v1.9.9-rc.1")
})
it("empty history → []", () => {
expect(toRollbackHistory("v1.0.0", [])).toEqual([])
})
})
+149
View File
@@ -0,0 +1,149 @@
/**
* The /v1/deploy → @hanzo/gitops adapter — pins the console CD app against cloud's
* REAL clients/deploy shapes (the hyphenated sync verdict, object manifests,
* parentRefs tree edges, the logs blob, clean-semver rollback).
*/
import { describe, expect, it } from "vitest"
import {
normalizeDeployApp,
parseApplications,
toGitopsApp,
toLogLines,
toManagedResource,
toResourceTree,
toRollbackHistory,
} from "./adapt"
describe("toGitopsApp", () => {
it("folds the hyphenated cloud sync verdict (the foldSync hyphen bug)", () => {
const a = parseApplications({
applications: [
{ name: "iam", namespace: "hanzo", env: "main", repository: "ghcr.io/hanzoai/iam", version: "v1.4.11", runningVersion: "v1.4.10", health: "progressing", healthMessage: "rolling", sync: "out-of-sync" },
],
})[0]
const g = toGitopsApp(a)
expect(g.sync).toBe("OutOfSync") // 'out-of-sync' must NOT fall through to Unknown
expect(g.health).toBe("Progressing")
expect(g.revision).toBe("v1.4.11")
expect(g.source).toEqual({ repoURL: "ghcr.io/hanzoai/iam" })
expect(g.message).toBe("rolling")
expect(g.project).toBe("main")
})
it("derives sync from declared-vs-running when the plane omits it", () => {
const [drift, synced] = parseApplications([
{ name: "a", repository: "r", version: "v2", runningVersion: "v1" },
{ name: "b", repository: "r", version: "v1", runningVersion: "v1" },
])
expect(toGitopsApp(drift).sync).toBe("OutOfSync")
expect(toGitopsApp(synced).sync).toBe("Synced")
})
})
describe("parseApplications", () => {
it("tolerates a bare array and drops nameless rows", () => {
expect(parseApplications([{ name: "cloud" }, { name: "" }]).map((a) => a.name)).toEqual(["cloud"])
})
})
describe("toResourceTree", () => {
it("maps parentRefs[].ref into linking AppTreeNodes (uid == ref token)", () => {
const tree = toResourceTree({
nodes: [
{ group: "hanzo.ai", version: "v1", kind: "App", namespace: "hanzo", name: "iam", ref: "hanzo.ai:App:hanzo:iam", parentRefs: [] },
{ group: "apps", version: "v1", kind: "Deployment", namespace: "hanzo", name: "iam", ref: "apps:Deployment:hanzo:iam", health: "progressing", parentRefs: [{ ref: "hanzo.ai:App:hanzo:iam" }] },
],
})
expect(tree.nodes).toHaveLength(2)
const dep = tree.nodes.find((n) => n.kind === "Deployment")!
expect(dep.uid).toBe("apps:Deployment:hanzo:iam")
expect(dep.parentRefs?.[0].uid).toBe("hanzo.ai:App:hanzo:iam") // matches the App node's uid → the tree links
expect(dep.health?.status).toBe("Progressing")
})
})
describe("toManagedResource", () => {
it("stringifies object manifests + reads the nested desired manifest", () => {
const m = toManagedResource({
ref: { group: "apps", version: "v1", kind: "Deployment", namespace: "hanzo", name: "iam", ref: "apps:Deployment:hanzo:iam" },
liveManifest: { kind: "Deployment", spec: { replicas: 1 } },
diff: { modified: true, desiredManifest: { kind: "Deployment", spec: { replicas: 2 } } },
})
expect(m.uid).toBe("apps:Deployment:hanzo:iam")
expect(m.liveState).toContain('"replicas": 1')
expect(m.targetState).toContain('"replicas": 2')
})
})
describe("toLogLines", () => {
it("splits the /v1/deploy logs blob and tags the pod", () => {
const lines = toLogLines({ pod: "iam-abc", logs: "first\nsecond\n" })
expect(lines).toHaveLength(2)
expect(lines[0]).toEqual({ content: "first", podName: "iam-abc" })
})
it("honest empty when no logs", () => {
expect(toLogLines({ pod: "", logs: "" })).toEqual([])
})
})
describe("toRollbackHistory", () => {
it("offers only clean-semver releases, current excluded, newest first", () => {
const h = toRollbackHistory("v1.800.1", ["v1.800.0", "latest", "v1.799.15", "v1.800.1", "main"])
expect(h.map((r) => r.revision)).toEqual(["v1.800.0", "v1.799.15"])
expect(h[0].id).toBeGreaterThan(h[1].id) // monotonic ids for the dialog
})
})
// ── the ACTUAL argoproj shape /v1/deploy/applications serves in production ────
// Captured live (cloud v1.801.109). The first cut read only flat keys, so the
// fleet bound to zero rows against real data; this pins the real contract.
describe("live argoproj-shaped application", () => {
const LIVE = {
apiVersion: "argoproj.io/v1alpha1",
kind: "Application",
metadata: {
name: "admin-guard",
namespace: "hanzo",
uid: "257fcb88-974d-46fc-8ac2-6f8b86a1f15f",
creationTimestamp: "2026-07-15T05:31:10Z",
labels: { "hanzo.ai/env": "main", "hanzo.ai/instance": "admin-guard" },
},
spec: {
source: { repoURL: "https://git.hanzo.ai/hanzoai/universe", path: "infra/k8s/operator/crs", targetRevision: "main" },
destination: { server: "https://kubernetes.default.svc", namespace: "hanzo" },
project: "default",
},
status: {
sync: { status: "Synced", revision: "v0.1.4" },
health: { status: "Healthy", message: "Running: available" },
resources: [],
summary: { images: ["ghcr.io/hanzoai/admin-guard:v0.1.4"] },
},
}
it("binds name/env/health/sync/revision/repository from the nested shape", () => {
const a = normalizeDeployApp(LIVE)
expect(a.name).toBe("admin-guard")
expect(a.namespace).toBe("hanzo")
expect(a.env).toBe("main")
expect(a.health).toBe("Healthy")
expect(a.sync).toBe("Synced")
expect(a.version).toBe("v0.1.4")
expect(a.repository).toBe("ghcr.io/hanzoai/admin-guard")
expect(a.runningVersion).toBe("v0.1.4")
})
it("folds to a Healthy/Synced gitops app (the fleet row renders)", () => {
const g = toGitopsApp(normalizeDeployApp(LIVE))
expect(g.name).toBe("admin-guard")
expect(g.health).toBe("Healthy")
expect(g.sync).toBe("Synced")
})
it("still reads the flat native shape (both wires supported)", () => {
const a = normalizeDeployApp({ name: "x", health: "degraded", sync: "out-of-sync", version: "v1.2.3" })
expect(a.name).toBe("x")
expect(a.health).toBe("degraded")
expect(toGitopsApp(a).sync).toBe("OutOfSync")
})
})
+271
View File
@@ -0,0 +1,271 @@
/**
* The CD data adapter — maps cloud's `/v1/deploy` DTOs INTO the shared view-models
* of `@hanzo/gitops` (GitopsApplication / ResourceTree / ManagedResource / LogLine
* / RevisionHistory). Reuses each package's OWN pure folds (foldHealth/foldSync) so
* status vocabulary is one way. No React, no I/O — unit-testable.
*
* Contract (cloud clients/deploy):
* applications: [{name,namespace,env,role,repository,version,runningVersion,
* health,healthMessage,sync,phase,endpoints}]
* {name}/tree: {application, nodes:[{group,version,kind,namespace,name,ref,uid,
* createdAt,health,healthMessage,sync,version,parentRefs:[{…,ref}]}]}
* {name}/resource/{ref}: {ref, health, healthMessage, liveManifest:{…},
* desiredSource, diff:{modified,desiredManifest:{…}}}
* {name}/logs: {application, pod, container, logs:"…", note?}
*/
import {
foldHealth,
foldSync,
type AppTreeNode,
type GitopsApplication,
type LogLine,
type ManagedResource,
type ResourceRef,
type ResourceTree,
type RevisionHistory,
} from "@hanzo/gitops"
// ── optional-safe helpers (snake_case + camelCase tolerant) ──────────────────
const str = (v: unknown): string => (typeof v === "string" ? v : "")
const arr = (v: unknown): unknown[] => (Array.isArray(v) ? v : [])
const rec = (v: unknown): Record<string, unknown> =>
v && typeof v === "object" ? (v as Record<string, unknown>) : {}
const pick = (r: Record<string, unknown>, ...keys: string[]): unknown => {
for (const k of keys) if (r[k] !== undefined && r[k] !== null) return r[k]
return undefined
}
const strList = (v: unknown): string[] => arr(v).map(str).filter(Boolean)
const epochMs = (v: unknown): number | undefined => {
const s = str(v)
if (!s) return undefined
const t = Date.parse(s)
return Number.isNaN(t) ? undefined : t
}
// The cloud wire uses hyphenated verdicts (`out-of-sync`); @hanzo/gitops's folds
// strip whitespace but not hyphens, so normalize `[-_]` out before folding.
const fHealth = (raw: string) => foldHealth(raw.replace(/[-_]/g, ""))
const fSync = (raw: string) => foldSync(raw.replace(/[-_]/g, ""))
/** A manifest object → pretty JSON text; a string verbatim; else ''. */
export function manifestText(v: unknown): string {
if (typeof v === "string") return v
if (v && typeof v === "object") {
try {
return JSON.stringify(v, null, 2)
} catch {
return ""
}
}
return ""
}
/** repo basename of an image repository: `ghcr.io/hanzoai/iam` → `iam`. */
export function repoBaseName(repository: string): string {
const s = (repository || "").trim().replace(/:.*/, "")
return (s.split("/").filter(Boolean).pop() ?? "").toLowerCase()
}
// ── Application ──────────────────────────────────────────────────────────────
export interface DeployApp {
name: string
namespace: string
env: string
role: string
repository: string
version: string
runningVersion: string
health: string
healthMessage: string
sync: string
phase: string
endpoints: string[]
/** Prior release tags the plane records (forward-compat rollback source); [] today. */
revisions: string[]
}
/** Normalize a raw /v1/deploy application row to a stable internal shape. */
/**
* Normalize one application from EITHER wire shape:
*
* - argoproj (what `/v1/deploy/applications` actually serves): the projection
* nests everything — `metadata.{name,namespace,labels}`, `spec.source.*`,
* `spec.project`, `status.{sync,health}.status`, `status.summary.images[]`.
* - flat native: `{name,namespace,env,repository,version,health,sync,…}`.
*
* Both are read here so the app binds regardless of which the plane returns —
* flat keys win when present, then the nested argo fields fill in.
*/
export function normalizeDeployApp(raw: unknown): DeployApp {
const r = rec(raw)
const meta = rec(pick(r, "metadata"))
const spec = rec(pick(r, "spec"))
const status = rec(pick(r, "status"))
const source = rec(pick(spec, "source"))
const dest = rec(pick(spec, "destination"))
const sync = rec(pick(status, "sync"))
const health = rec(pick(status, "health"))
const labels = rec(pick(meta, "labels"))
const summary = rec(pick(status, "summary"))
// `status.summary.images: ["ghcr.io/hanzoai/x:v1.2.3"]` → repository + tag.
const image = strList(pick(summary, "images"))[0] ?? ""
const cut = image.lastIndexOf(":")
const imageRepo = cut > 0 ? image.slice(0, cut) : image
const imageTag = cut > 0 ? image.slice(cut + 1) : ""
return {
name: str(pick(r, "name")) || str(pick(meta, "name")),
namespace:
str(pick(r, "namespace", "ns")) || str(pick(meta, "namespace")) || str(pick(dest, "namespace")) || "hanzo",
env: str(pick(r, "env", "environment")) || str(pick(labels, "hanzo.ai/env")) || str(pick(source, "targetRevision")),
role: str(pick(r, "role")),
// Prefer the deployed image repository; fall back to the manifest repo URL.
repository: str(pick(r, "repository", "repo")) || imageRepo || str(pick(source, "repoURL")),
version: str(pick(r, "version", "tag")) || str(pick(sync, "revision")) || imageTag,
runningVersion: str(pick(r, "runningVersion", "running_version")) || imageTag,
health: str(pick(r, "health")) || str(pick(health, "status")),
healthMessage:
str(pick(r, "healthMessage", "health_message", "message")) || str(pick(health, "message")),
sync: str(pick(r, "sync", "syncStatus", "sync_status")) || str(pick(sync, "status")),
phase: str(pick(r, "phase")) || str(pick(health, "status")),
endpoints: strList(pick(r, "endpoints", "urls")),
revisions: strList(pick(r, "revisions", "history", "tags")),
}
}
/** Parse the applications list payload (array or {applications|apps|items}). */
export function parseApplications(data: unknown): DeployApp[] {
const rows = Array.isArray(data) ? data : arr(pick(rec(data), "applications", "apps", "items", "services"))
return rows.map(normalizeDeployApp).filter((a) => a.name)
}
/** Fold a normalized app into the @hanzo/gitops application view-model. */
export function toGitopsApp(a: DeployApp): GitopsApplication {
return {
name: a.name,
namespace: a.namespace,
project: a.env || undefined,
health: fHealth(a.health),
// desired (declared version) vs live (runningVersion): equal ⇒ Synced.
sync: a.sync ? fSync(a.sync) : fSync(a.version === a.runningVersion ? "synced" : "outofsync"),
revision: a.version || undefined,
source: a.repository ? { repoURL: a.repository } : undefined,
message: a.healthMessage || undefined,
}
}
// ── tree → ResourceTree (AppTreeNode with linking parentRefs) ────────────────
/** Map a /v1/deploy ResourceRef DTO (carrying its `ref` token) to a linking ref.
* The `ref` token is the stable id shared by a node and its children's
* parentRefs, so it drives resourceId() — the tree links on it. */
function toRef(raw: unknown): ResourceRef {
const r = rec(raw)
const token = str(pick(r, "ref"))
return {
uid: token || str(pick(r, "uid")),
group: str(pick(r, "group")),
version: str(pick(r, "version")),
kind: str(pick(r, "kind")),
namespace: str(pick(r, "namespace", "ns")),
name: str(pick(r, "name")),
}
}
function toTreeNode(raw: unknown): AppTreeNode {
const r = rec(raw)
const self = toRef(r)
const version = str(pick(r, "version"))
return {
...self,
parentRefs: arr(pick(r, "parentRefs", "parent_refs")).map(toRef),
health: { status: fHealth(str(pick(r, "health"))), message: str(pick(r, "healthMessage")) || undefined },
sync: pick(r, "sync") ? fSync(str(pick(r, "sync"))) : undefined,
images: version ? [version] : [],
createdAt: epochMs(pick(r, "createdAt", "created_at", "creationTimestamp")),
}
}
export function toResourceTree(raw: unknown): ResourceTree {
const r = rec(raw)
const nodes = arr(pick(r, "nodes", "resources"))
.map(toTreeNode)
.filter((n) => n.name && n.kind)
// Defense-in-depth: never render a Secret node (its live manifest carries
// base64 data). Cloud already excludes Secrets from the tree; drop any that
// slip through so the client never surfaces one.
.filter((n) => n.kind !== "Secret")
return { nodes }
}
// ── resource → ManagedResource (live + desired for the node drawer/diff) ─────
export function toManagedResource(raw: unknown): ManagedResource {
const r = rec(raw)
const refField = pick(r, "ref")
const self: ResourceRef =
typeof refField === "string"
? { uid: refField, group: "", version: "", kind: "", namespace: "", name: "" }
: toRef(refField)
const diffObj = rec(pick(r, "diff"))
const desired = pick(r, "desiredManifest", "desired") ?? pick(diffObj, "desiredManifest", "desired")
return {
...self,
liveState: manifestText(pick(r, "liveManifest", "live_manifest", "live", "manifest")),
targetState: manifestText(desired),
}
}
// ── logs (blob OR array) → LogLine[], bounded ────────────────────────────────
// Client-side caps so one giant log payload can't bloat the DOM even if the
// server's `?tail` is absent or ignored: keep the last MAX_LOG_LINES, and clamp
// any single line (a newline-free megabyte otherwise renders as one huge node).
const MAX_LOG_LINES = 2000
const MAX_LOG_LINE = 4000
export function toLogLines(raw: unknown): LogLine[] {
const r = rec(raw)
const pod = str(pick(r, "pod")) || undefined
// Tolerate both shapes: a newline blob (`logs:"…"`) and a line array
// (`logs:[…]` / `lines:[…]`), which the blob-only path silently dropped to [].
const listed = arr(pick(r, "logs", "lines", "log", "output"))
const lines = listed.length
? listed.map(str)
: str(pick(r, "logs", "log", "output")).split("\n")
const kept = lines.filter((l) => l.length > 0).slice(-MAX_LOG_LINES)
return kept.map((l) => ({ content: l.length > MAX_LOG_LINE ? l.slice(0, MAX_LOG_LINE) + "…" : l, podName: pod }))
}
// ── git tags → RevisionHistory[] (rollback targets; cloud takes a clean semver) ─
const SEMVER = /^v\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/
export const isReleaseTag = (t: string): boolean => SEMVER.test(t.trim())
/** Build rollback history from an app's real git tags (clean semver, newest first,
* current excluded). `revision` carries the tag cloud's rollback endpoint accepts. */
export function toRollbackHistory(currentTag: string, tags: string[]): RevisionHistory[] {
const seen = new Set<string>([currentTag.trim()])
const clean: string[] = []
for (const raw of tags) {
const t = raw.trim()
if (!t || seen.has(t) || !isReleaseTag(t)) continue
seen.add(t)
clean.push(t)
}
clean.sort(compareSemverDesc)
return clean.map((revision, i) => ({ id: clean.length - i, revision }))
}
export function compareSemverDesc(a: string, b: string): number {
const pa = semverParts(a)
const pb = semverParts(b)
for (let i = 0; i < 3; i++) if (pa[i] !== pb[i]) return pb[i] - pa[i]
return b.localeCompare(a)
}
function semverParts(tag: string): [number, number, number] {
const m = /^v?(\d+)\.(\d+)\.(\d+)/.exec(tag.trim())
return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : [-1, -1, -1]
}
+90
View File
@@ -0,0 +1,90 @@
/**
* The typed client for cloud's native CD plane (`/v1/deploy`). Same-origin: the
* cd.hanzo.ai ingress peels `/v1/deploy/*` off to the cloud binary, so the SPA
* calls it with `credentials: 'include'` and the first-party `hanzo_iam_token`
* cookie (set by the PKCE login) rides — cloud validates it (SuperAdmin gate). A
* 401/403 surfaces as an `ApiError` so the app can send the user back to sign in.
*
* Responses are mapped INTO the shared `@hanzo/gitops` view-models by `./adapt`.
*/
import type { LogLine, ManagedResource, ResourceTree } from "@hanzo/gitops"
import { parseApplications, toLogLines, toManagedResource, toResourceTree, type DeployApp } from "./adapt"
export class ApiError extends Error {
status: number
constructor(message: string, status: number) {
super(message)
this.name = "ApiError"
this.status = status
}
}
/** True when the error means "not signed in / not authorized" (→ sign-in screen). */
export const isAuthError = (e: unknown): boolean => e instanceof ApiError && (e.status === 401 || e.status === 403)
const url = (path: string): string => `/v1/deploy/${path.replace(/^\/+/, "")}`
async function request<T>(method: "GET" | "POST", path: string, body?: unknown): Promise<T> {
let res: Response
try {
res = await fetch(url(path), {
method,
credentials: "include",
headers: body !== undefined ? { "Content-Type": "application/json" } : undefined,
body: body !== undefined ? JSON.stringify(body) : undefined,
})
} catch (e) {
throw new ApiError(e instanceof Error ? e.message : "network error", 0)
}
if (!res.ok) {
let msg = `${res.status} ${res.statusText}`
try {
const j = (await res.json()) as { error?: string; message?: string }
msg = j.error || j.message || msg
} catch {
/* non-JSON body */
}
throw new ApiError(msg, res.status)
}
if (res.status === 204) return undefined as T
return (await res.json()) as T
}
export const DeployApi = {
/** The fleet: every operator App CR (`GET /v1/deploy/applications`). */
applications: async (): Promise<DeployApp[]> => parseApplications(await request<unknown>("GET", "applications")),
/**
* One application's owned-resource tree
* (`GET /v1/deploy/applications/:name/resource-tree`).
*
* The wired route lives under `applications/` — the shorter `:name/tree` form
* belongs to an unregistered handler, so it 404s against the live plane.
*/
tree: async (name: string): Promise<ResourceTree> =>
toResourceTree(await request<unknown>("GET", `applications/${encodeURIComponent(name)}/resource-tree`)),
/** One tree node's live manifest + desired-vs-live (`GET /v1/deploy/:name/resource/:ref`). */
resource: async (name: string, ref: string): Promise<ManagedResource> =>
toManagedResource(await request<unknown>("GET", `${encodeURIComponent(name)}/resource/${encodeURIComponent(ref)}`)),
/** The newest pod's logs (`GET /v1/deploy/:name/logs`). */
logs: async (name: string, tail = 300): Promise<LogLine[]> =>
toLogLines(await request<unknown>("GET", `${encodeURIComponent(name)}/logs?tail=${tail}`)),
/** Pin the CR image to a prior clean-semver release (`POST /v1/deploy/:name/rollback`). */
rollback: async (name: string, tag: string): Promise<void> => {
await request<unknown>("POST", `${encodeURIComponent(name)}/rollback`, { tag })
},
/** Request an operator reconcile now (`POST /v1/deploy/:name/sync`). */
sync: async (name: string): Promise<void> => {
await request<unknown>("POST", `${encodeURIComponent(name)}/sync`, {})
},
}
/** Read the `hanzo_iam_token` cookie the PKCE login sets (non-httpOnly by design). */
export function hasSession(): boolean {
return document.cookie.split(";").some((c) => c.trim().startsWith("hanzo_iam_token="))
}
+14
View File
@@ -0,0 +1,14 @@
import { StrictMode } from "react"
import { createRoot } from "react-dom/client"
import { App } from "./App"
import { ErrorBoundary } from "./shell/ErrorBoundary"
import "./styles.css"
createRoot(document.getElementById("root")!).render(
<StrictMode>
<ErrorBoundary>
<App />
</ErrorBoundary>
</StrictMode>,
)
+51
View File
@@ -0,0 +1,51 @@
import { Component, type ErrorInfo, type ReactNode } from "react"
/**
* Top-level boundary so a single component render throw can never white-screen
* the whole dashboard. A caught error shows a recover panel (back to the fleet /
* reload) instead of an unmounted root. Deliberately dependency-free.
*/
export class ErrorBoundary extends Component<{ children: ReactNode }, { error: Error | null }> {
state = { error: null as Error | null }
static getDerivedStateFromError(error: Error) {
return { error }
}
componentDidCatch(error: Error, info: ErrorInfo) {
// Surface to the console for the operator; never log identity/token.
console.error("[cd] render error", error, info.componentStack)
}
private reset = () => {
this.setState({ error: null })
if (location.hash && location.hash !== "#/") location.hash = "#/"
}
render() {
if (!this.state.error) return this.props.children
return (
<div style={{ maxWidth: 520, margin: "12vh auto", padding: "0 24px", textAlign: "center", fontFamily: "'Geist', ui-sans-serif, system-ui, sans-serif" }}>
<h1 style={{ fontSize: 20, fontWeight: 700, margin: "0 0 8px" }}>Something went wrong</h1>
<p style={{ color: "#8a8a8a", fontSize: 14, margin: "0 0 20px" }}>
A view failed to render. Your fleet is unaffected this is only the dashboard.
</p>
<div style={{ display: "flex", gap: 10, justifyContent: "center" }}>
<button onClick={this.reset} style={btn(true)}> Back to fleet</button>
<button onClick={() => location.reload()} style={btn(false)}>Reload</button>
</div>
</div>
)
}
}
const btn = (primary: boolean): React.CSSProperties => ({
padding: "9px 16px",
fontSize: 14,
fontWeight: 600,
cursor: "pointer",
borderRadius: 8,
border: primary ? "0" : "1px solid #2a2a2a",
background: primary ? "#fff" : "transparent",
color: primary ? "#000" : "inherit",
})
+102
View File
@@ -0,0 +1,102 @@
/**
* The Hanzo CD topbar — the shared shell chrome for the dedicated dashboard: the
* small Hanzo mark + wordmark (top-left), the environment scope switcher (the CD
* "project" dimension — main/test/dev, the operator namespaces), a refresh, and
* sign-out. Lean + self-contained (dark, Geist) so the static SPA has no heavy
* shell dependency; the org/project↔IAM switcher lights up when /v1/iam is routed
* on cd.hanzo.ai (today the SuperAdmin cookie sees the whole fleet).
*/
const MARK = (size: number, fill: string) => (
<svg width={size} height={size} viewBox="0 0 67 67" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<path d="M22.21 67V44.6369H0V67H22.21Z" fill={fill} />
<path d="M66.7038 22.3184H22.2534L0.0878906 44.6367H44.4634L66.7038 22.3184Z" fill={fill} />
<path d="M22.21 0H0V22.3184H22.21V0Z" fill={fill} />
<path d="M66.7198 0H44.5098V22.3184H66.7198V0Z" fill={fill} />
<path d="M66.7198 67V44.6369H44.5098V67H66.7198Z" fill={fill} />
</svg>
)
export interface EnvOption {
id: string
label: string
count: number
}
export function Topbar({
envs,
env,
onEnv,
onRefresh,
refreshing,
onSignOut,
}: {
envs: EnvOption[]
env: string
onEnv: (id: string) => void
onRefresh: () => void
refreshing?: boolean
onSignOut: () => void
}) {
return (
<header
style={{
display: "flex",
alignItems: "center",
gap: 12,
padding: "10px 16px",
borderBottom: "1px solid var(--cd-border)",
background: "var(--cd-surface)",
position: "sticky",
top: 0,
zIndex: 20,
flexWrap: "wrap",
}}
>
<a href="/" style={{ display: "flex", alignItems: "center", gap: 9, textDecoration: "none", color: "var(--cd-fg-strong)" }}>
<span style={{ display: "inline-flex" }}>{MARK(18, "#fff")}</span>
<span style={{ fontWeight: 800, letterSpacing: "-0.02em", fontSize: 15 }}>Hanzo CD</span>
</a>
{envs.length > 1 ? (
<div style={{ display: "inline-flex", gap: 2, background: "var(--cd-surface-2)", borderRadius: 8, padding: 2, border: "1px solid var(--cd-border)" }}>
{[{ id: "", label: "All", count: envs.reduce((n, e) => n + e.count, 0) }, ...envs].map((o) => {
const on = o.id === env
return (
<button
key={o.id || "all"}
type="button"
onClick={() => onEnv(o.id)}
className="cd-mono"
style={{
border: 0,
cursor: "pointer",
fontSize: 12,
fontWeight: 600,
padding: "5px 10px",
borderRadius: 6,
background: on ? "var(--cd-border-strong)" : "transparent",
color: on ? "var(--cd-fg-strong)" : "var(--cd-fg-muted)",
}}
>
{o.label}
<span style={{ opacity: 0.6, marginLeft: 5 }}>{o.count}</span>
</button>
)
})}
</div>
) : null}
<span style={{ flex: 1 }} />
<button type="button" className="cd-btn" onClick={onRefresh} disabled={refreshing}>
{refreshing ? "Refreshing…" : "Refresh"}
</button>
<button type="button" className="cd-btn" onClick={onSignOut}>
Sign out
</button>
</header>
)
}
export { MARK }
+90
View File
@@ -0,0 +1,90 @@
/* Hanzo CD — app chrome. The @hanzo/gitops components bring their OWN scoped CSS
(GitopsStyles + THEME_VARS); this is only the shell + page shell, dark-first,
Geist. Mobile-first: nothing scrolls the body horizontally. */
:root {
color-scheme: dark;
--cd-bg: #0a0a0a;
--cd-surface: #0d1117;
--cd-surface-2: #161b22;
--cd-border: #21262d;
--cd-border-strong: #30363d;
--cd-fg: #e7e7e7;
--cd-fg-muted: #8b949e;
--cd-fg-strong: #f0f6fc;
--cd-accent: #2f81f7;
--cd-sans: "Geist", "Geist Sans", ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
--cd-mono: "Geist Mono", ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
* {
box-sizing: border-box;
}
html,
body,
#root {
height: 100%;
margin: 0;
}
html {
overflow-x: hidden;
}
body {
font-family: var(--cd-sans);
background: var(--cd-bg);
color: var(--cd-fg);
-webkit-font-smoothing: antialiased;
}
.cd-app {
display: flex;
flex-direction: column;
min-height: 100%;
}
.cd-main {
flex: 1;
min-width: 0;
padding: 20px;
max-width: 1400px;
width: 100%;
margin: 0 auto;
}
@media (max-width: 640px) {
.cd-main {
padding: 14px;
}
}
.cd-btn {
font-family: inherit;
font-size: 13px;
font-weight: 600;
border: 1px solid var(--cd-border-strong);
background: var(--cd-surface-2);
color: var(--cd-fg);
border-radius: 8px;
padding: 8px 12px;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 6px;
min-height: 36px;
}
.cd-btn:hover {
border-color: var(--cd-fg-muted);
}
.cd-btn--primary {
background: #fff;
color: #000;
border-color: #fff;
}
.cd-btn:disabled {
opacity: 0.5;
cursor: default;
}
.cd-muted {
color: var(--cd-fg-muted);
}
.cd-mono {
font-family: var(--cd-mono);
}
+194
View File
@@ -0,0 +1,194 @@
/**
* The application detail — the ArgoCD-grade drill-in, composed from the shared
* @hanzo/gitops components: `GitopsSyncPanel` (health/sync/revision + Sync /
* Refresh / History), `GitopsAppTree` (the owned-resource topology), `GitopsNodeInfo`
* (a node's manifest / desired-vs-live diff / events / logs), and `GitopsRollbackDialog`.
*
* Composed (not the fixed `GitopsAppDetails`) so a node's manifest + logs are
* fetched LAZILY on selection (the plane's `/resource` + `/logs` reads) rather than
* pre-fetched for the whole tree. Every effect is a real `/v1/deploy` call; honest
* loading/empty/error states, never fabricated.
*/
import { useCallback, useEffect, useState } from "react"
import {
GitopsAppTree,
GitopsNodeInfo,
GitopsRollbackDialog,
GitopsSyncPanel,
type AppTreeNode,
type LogLine,
type ManagedResource,
type ResourceTree,
type SyncOptions,
} from "@hanzo/gitops"
import { DeployApi } from "../lib/deploy"
import { toGitopsApp, toRollbackHistory, type DeployApp } from "../lib/adapt"
export function AppView({
app,
onBack,
onChanged,
notify,
}: {
app: DeployApp
onBack: () => void
onChanged: () => void
notify: (msg: string, err?: boolean) => void
}) {
const gitopsApp = toGitopsApp(app)
const [tree, setTree] = useState<ResourceTree | null | undefined>(undefined)
const [treeErr, setTreeErr] = useState<string>("")
const [logs, setLogs] = useState<LogLine[]>([])
const [selected, setSelected] = useState<AppTreeNode | null>(null)
const [resource, setResource] = useState<ManagedResource | undefined>(undefined)
const [rollbackOpen, setRollbackOpen] = useState(false)
const [busy, setBusy] = useState(false)
const history = toRollbackHistory(app.version, app.revisions)
const load = useCallback(() => {
let live = true
setTree(undefined)
setTreeErr("")
setSelected(null)
DeployApi.tree(app.name)
.then((t) => live && setTree(t))
.catch((e) => {
if (live) {
setTree(null)
setTreeErr(e instanceof Error ? e.message : "failed to load")
}
})
DeployApi.logs(app.name)
.then((l) => live && setLogs(l))
.catch(() => live && setLogs([]))
return () => {
live = false
}
}, [app.name])
useEffect(() => load(), [load])
const onSelect = useCallback(
(node: AppTreeNode | null) => {
setSelected(node)
setResource(undefined)
if (!node) return
DeployApi.resource(app.name, node.uid ?? "")
.then(setResource)
.catch(() => setResource(undefined))
},
[app.name],
)
const onSync = useCallback(
async (_opts: SyncOptions) => {
setBusy(true)
try {
await DeployApi.sync(app.name)
notify(`Sync requested for ${app.name}`)
onChanged()
load()
} catch (e) {
notify(`Sync failed: ${e instanceof Error ? e.message : e}`, true)
} finally {
setBusy(false)
}
},
[app.name, notify, onChanged, load],
)
const onRollback = useCallback(
async (revision: string) => {
setBusy(true)
try {
await DeployApi.rollback(app.name, revision)
notify(`Rolled back ${app.name}${revision}`)
onChanged()
load()
} catch (e) {
notify(`Rollback failed: ${e instanceof Error ? e.message : e}`, true)
} finally {
setBusy(false)
}
},
[app.name, notify, onChanged, load],
)
return (
<div style={{ display: "flex", flexDirection: "column", gap: 14 }}>
<div style={{ display: "flex", alignItems: "center", gap: 8, flexWrap: "wrap" }}>
<button type="button" className="cd-btn" onClick={onBack}>
Fleet
</button>
<span className="cd-muted">/</span>
<span className="cd-mono" style={{ color: "var(--cd-fg-strong)", fontWeight: 700 }}>
{app.name}
</span>
{app.env ? (
<span className="cd-mono cd-muted" style={{ fontSize: 12 }}>
· {app.env}
</span>
) : null}
</div>
<GitopsSyncPanel
app={gitopsApp}
theme="dark"
busy={busy}
onSync={onSync}
onRefresh={load}
onRollback={() => setRollbackOpen(true)}
/>
<div style={{ display: "flex", gap: 12, flexWrap: "wrap", alignItems: "flex-start" }}>
{tree === undefined ? (
<div className="cd-muted" style={{ padding: 24 }}>
Loading topology
</div>
) : tree === null ? (
<div className="cd-muted" style={{ padding: 24 }}>
Could not load the resource tree{treeErr ? `: ${treeErr}` : ""}.
</div>
) : tree.nodes.length === 0 ? (
<div className="cd-muted" style={{ padding: 24 }}>
No owned resources reported.
</div>
) : (
<>
<GitopsAppTree
tree={tree}
theme="dark"
onSelect={onSelect}
selectedId={selected?.uid ?? null}
height={400}
style={{ flex: 1, minWidth: 280 }}
/>
{selected ? (
<GitopsNodeInfo
node={selected}
theme="dark"
resource={resource}
logs={logs}
onClose={() => onSelect(null)}
style={{ width: "min(440px, 100%)", height: 400, flexShrink: 0 }}
/>
) : null}
</>
)}
</div>
<GitopsRollbackDialog
open={rollbackOpen}
history={history}
theme="dark"
busy={busy}
onClose={() => setRollbackOpen(false)}
onRollback={(entry) => {
setRollbackOpen(false)
void onRollback(entry.revision)
}}
/>
</div>
)
}
+75
View File
@@ -0,0 +1,75 @@
/**
* The fleet view — every operator App CR as a row, health + sync + revision +
* source, with the shared @hanzo/gitops `GitopsAppList` (search, health/sync
* filters, sortable). A compact KPI band up top gives the at-a-glance fleet stats.
* Data-prop-driven: the app fetches `/v1/deploy/applications` and hands the folded
* rows in; nothing is fabricated.
*/
import { useMemo } from "react"
import { GitopsAppList } from "@hanzo/gitops"
import { toGitopsApp, type DeployApp } from "../lib/adapt"
function Stat({ label, value, tone }: { label: string; value: number; tone?: string }) {
return (
<div
style={{
flex: 1,
minWidth: 104,
border: "1px solid var(--cd-border-strong)",
borderRadius: 10,
padding: "12px 14px",
background: "var(--cd-surface)",
}}
>
<div style={{ fontSize: 26, fontWeight: 800, color: tone && value > 0 ? tone : "var(--cd-fg-strong)" }}>{value}</div>
<div className="cd-muted" style={{ fontSize: 13, marginTop: 2 }}>
{label}
</div>
</div>
)
}
export function FleetView({ apps, onOpen }: { apps: DeployApp[]; onOpen: (name: string) => void }) {
const gitopsApps = useMemo(() => apps.map(toGitopsApp), [apps])
const kpi = useMemo(() => {
const s = { total: 0, healthy: 0, progressing: 0, degraded: 0, outOfSync: 0 }
for (const a of gitopsApps) {
s.total++
if (a.health === "Healthy") s.healthy++
else if (a.health === "Progressing") s.progressing++
else if (a.health === "Degraded" || a.health === "Missing") s.degraded++
if (a.sync === "OutOfSync") s.outOfSync++
}
return s
}, [gitopsApps])
return (
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
<div>
<h1 style={{ fontSize: 22, fontWeight: 800, margin: "0 0 4px", letterSpacing: "-0.02em" }}>Fleet</h1>
<p className="cd-muted" style={{ margin: 0, fontSize: 14, maxWidth: "60ch" }}>
Every Hanzo App reconciled by the operator declarative, versioned, auto-synced from git to your clusters. Open
one for its resource tree, logs, and sync/rollback.
</p>
</div>
<div style={{ display: "flex", gap: 10, flexWrap: "wrap" }}>
<Stat label="Applications" value={kpi.total} />
<Stat label="Healthy" value={kpi.healthy} tone="#18BE94" />
<Stat label="Progressing" value={kpi.progressing} tone="#0DADEA" />
<Stat label="Degraded" value={kpi.degraded} tone="#E96D76" />
<Stat label="Out of sync" value={kpi.outOfSync} tone="#f4c030" />
</div>
<GitopsAppList
applications={gitopsApps}
theme="dark"
view="table"
onOpen={(a) => onOpen(a.name)}
emptyLabel="No applications reported by the deploy plane yet."
/>
</div>
)
}
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+24
View File
@@ -0,0 +1,24 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"paths": {
"@hanzo/gitops": ["../../pkgs/gitops/dist/index.d.ts"],
"@hanzo/canvas/pure": ["../../pkgs/canvas/src/pure.ts"]
}
},
"include": ["src"]
}
+4
View File
@@ -0,0 +1,4 @@
{
"files": [],
"references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }]
}
+13
View File
@@ -0,0 +1,13 @@
{
"compilerOptions": {
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"noEmit": true,
"strict": true
},
"include": ["vite.config.ts"]
}
+41
View File
@@ -0,0 +1,41 @@
/// <reference types="vitest/config" />
import { fileURLToPath, URL } from "node:url"
import react from "@vitejs/plugin-react"
import { defineConfig } from "vite"
/**
* Hanzo CD — a static SPA served at cd.hanzo.ai from the shared static plane
* (s3://cdn/cd, ingress staticFiles/spaMode). `/v1/deploy/*` is peeled off to the
* cloud binary by the ingress, so in prod the SPA calls same-origin `/v1/deploy`
* with the `hanzo_iam_token` cookie (cloud validates → SuperAdmin gate).
*
* The @hanzo/gitops (built dist) + @hanzo/canvas/pure (pure TS source) workspace
* packages are resolved by path alias so the app builds without a full monorepo
* install (the shared store lacks the registry deps of the heavier packages). A
* dev proxy forwards `/v1` to CD_API (default the live cluster) for local dev.
*/
const r = (p: string) => fileURLToPath(new URL(p, import.meta.url))
const CD_API = process.env.CD_API ?? "https://cd.hanzo.ai"
export default defineConfig({
plugins: [react()],
resolve: {
dedupe: ["react", "react-dom"],
alias: {
"@hanzo/gitops": r("../../pkgs/gitops/dist/index.mjs"),
"@hanzo/canvas/pure": r("../../pkgs/canvas/src/pure.ts"),
},
},
// Plain CSS only — override the monorepo-root PostCSS/Tailwind config discovery
// (this app has no Tailwind; the @hanzo/gitops components ship their own scoped CSS).
css: { postcss: { plugins: [] } },
build: { outDir: "dist", target: "es2022", sourcemap: false },
server: {
port: 4200,
proxy: { "/v1": { target: CD_API, changeOrigin: true, secure: false } },
},
// The e2e/ specs are Playwright's (they use test.beforeAll); vitest must not
// glob them, or a bare `vitest run` reports a red suite for a passing app.
test: { exclude: ["e2e/**", "node_modules/**", "dist/**"] },
})
-3
View File
@@ -51,7 +51,6 @@
"format:check": "turbo run format:check",
"sync:templates": "./scripts/sync-templates.sh \"templates/*\"",
"check": "bash scripts/check-no-brand-pkg.sh && turbo lint typecheck format:check",
"release": "changeset version",
"pub:beta": "cd pkgs/hanzo && pnpm pub:beta",
"pub:release": "cd pkgs/hanzo && pnpm pub:release",
"test:dev": "turbo run test --filter=!hanzo-ui --force",
@@ -71,8 +70,6 @@
"packageManager": "pnpm@9.0.6",
"dependencies": {
"@babel/core": "^7.29.7",
"@changesets/changelog-github": "^0.7.0",
"@changesets/cli": "^2.31.0",
"@commitlint/cli": "^21.0.2",
"@commitlint/config-conventional": "^21.0.2",
"@dnd-kit/core": "^6.3.1",
+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 {}
}
+110 -18
View File
@@ -1,63 +1,155 @@
{
"name": "@hanzo/ui",
"version": "8.0.0",
"version": "8.0.8",
"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",
"default": "./src/index.ts"
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.cjs",
"default": "./dist/index.js"
},
"./product": {
"types": "./src/product/index.ts",
"default": "./src/product/index.ts"
"types": "./dist/product/index.d.ts",
"import": "./dist/product/index.js",
"require": "./dist/product/index.cjs",
"default": "./dist/product/index.js"
},
"./data": {
"types": "./src/data.ts",
"default": "./src/data.ts"
"types": "./dist/data.d.ts",
"import": "./dist/data.js",
"require": "./dist/data.cjs",
"default": "./dist/data.js"
},
"./primitives/bases/data": {
"types": "./src/primitives/bases/data/index.ts",
"default": "./src/primitives/bases/data/index.ts"
"types": "./dist/primitives/bases/data/index.d.ts",
"import": "./dist/primitives/bases/data/index.js",
"require": "./dist/primitives/bases/data/index.cjs",
"default": "./dist/primitives/bases/data/index.js"
},
"./styles/motion.css": "./src/styles/hanzo-motion.css",
"./gitops": {
"types": "./dist/gitops.d.ts",
"import": "./dist/gitops.js",
"require": "./dist/gitops.cjs",
"default": "./dist/gitops.js"
},
"./canvas": {
"types": "./dist/canvas.d.ts",
"import": "./dist/canvas.js",
"require": "./dist/canvas.cjs",
"default": "./dist/canvas.js"
},
"./wallet": {
"types": "./dist/wallet.d.ts",
"import": "./dist/wallet.js",
"require": "./dist/wallet.cjs",
"default": "./dist/wallet.js"
},
"./network": {
"types": "./dist/network.d.ts",
"import": "./dist/network.js",
"require": "./dist/network.cjs",
"default": "./dist/network.js"
},
"./billing": {
"types": "./dist/billing.d.ts",
"import": "./dist/billing.js",
"require": "./dist/billing.cjs",
"default": "./dist/billing.js"
},
"./dashboard": {
"types": "./dist/dashboard.d.ts",
"import": "./dist/dashboard.js",
"require": "./dist/dashboard.cjs",
"default": "./dist/dashboard.js"
},
"./usage": {
"types": "./dist/usage.d.ts",
"import": "./dist/usage.js",
"require": "./dist/usage.cjs",
"default": "./dist/usage.js"
},
"./styles/motion.css": "./dist/styles/hanzo-motion.css",
"./package.json": "./package.json"
},
"main": "./src/index.ts",
"module": "./src/index.ts",
"types": "./src/index.ts",
"main": "./dist/index.cjs",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"sideEffects": [
"**/*.css"
],
"files": [
"src"
"dist"
],
"scripts": {
"build": "tsup && tsc -p tsconfig.build.json && mkdir -p dist/styles && cp src/styles/hanzo-motion.css dist/styles/hanzo-motion.css && node scripts/add-use-client.mjs",
"clean": "rm -rf dist types",
"prepack": "pnpm run build",
"tc": "tsc --noEmit",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest"
},
"peerDependencies": {
"@hanzo/canvas": ">=0.1.0",
"@hanzo/dashboard": ">=0.1.0",
"@hanzo/data": ">=1.2.0",
"@hanzo/gitops": ">=0.1.0",
"@hanzo/gui": ">=7.2.2",
"@hanzo/ui-shadcn": ">=5.7.0",
"@hanzo/usage": ">=0.1.0",
"@hanzogui/next-theme": ">=7.3.0",
"react": ">=19"
},
"peerDependenciesMeta": {
"@hanzo/canvas": {
"optional": true
},
"@hanzo/dashboard": {
"optional": true
},
"@hanzo/gitops": {
"optional": true
},
"@hanzo/ui-shadcn": {
"optional": true
},
"@hanzo/usage": {
"optional": true
},
"@hanzogui/next-theme": {
"optional": true
}
},
"devDependencies": {
"@hanzo/data": "1.2.0",
"@hanzo/canvas": "workspace:*",
"@hanzo/dashboard": "workspace:*",
"@hanzo/data": "1.2.1",
"@hanzo/gitops": "workspace:*",
"@hanzo/gui": "7.3.0",
"@hanzo/ui-shadcn": "workspace:*",
"@hanzo/usage": "^0.1.6",
"@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",
"vitest": "^2.1.9"
"vitest": "^4.1.8",
"react-native-web": "^0.21.2",
"tsup": "^8.5.1"
},
"publishConfig": {
"access": "public"
},
"license": "BSD-3-Clause",
"author": "Hanzo AI <dev@hanzo.ai>"
"author": "Hanzo AI <dev@hanzo.ai>",
"dependencies": {
"@hanzo/logo": "^1.0.13"
}
}
+26
View File
@@ -0,0 +1,26 @@
// Prepend `'use client';` to every compiled JS/CJS file in dist.
//
// The whole library is client-side @hanzo/gui (Tamagui) UI that uses React hooks, so
// every module is a client module. tsup's `banner` is unreliable with code splitting
// (it misses chunks), and Next's flight-client loader needs the directive to be the
// FIRST statement — so we stamp it deterministically here, post-build.
import { readdirSync, readFileSync, writeFileSync, statSync } from 'node:fs'
import { join } from 'node:path'
const DIST = new URL('../dist/', import.meta.url).pathname
const DIRECTIVE = "'use client';\n"
function walk(dir) {
for (const name of readdirSync(dir)) {
const p = join(dir, name)
if (statSync(p).isDirectory()) {
walk(p)
} else if (/\.(c?js|mjs)$/.test(name)) {
const src = readFileSync(p, 'utf8')
if (!/^['"]use client['"]/.test(src)) writeFileSync(p, DIRECTIVE + src)
}
}
}
walk(DIST)
console.log("stamped 'use client' across dist")
+9
View File
@@ -0,0 +1,9 @@
// @hanzo/ui/billing — the shared billing surface, re-exported from its home
// module @hanzo/ui-shadcn/billing. `CreditModal` presents trial + prepaid credit
// buckets with an injected top-up handler (no payment logic in the component).
// Thin subpath so every console shows the same credit UI:
//
// import { CreditModal } from '@hanzo/ui/billing'
//
// Optional peer — only pulled when the subpath is used.
export * from '@hanzo/ui-shadcn/billing'
+11
View File
@@ -0,0 +1,11 @@
// @hanzo/ui/canvas — the PaaS project canvas, re-exported from its home package
// @hanzo/canvas (a Railway-grade pannable/zoomable board of service nodes with
// live status, metric sparklines, deploy timelines, an environment switcher and
// a service detail drawer). Kept as a thin subpath so a console can
//
// import { ProjectCanvas, ServiceNode, ServiceStatusBadge, DeployTimeline,
// EnvSwitcher, ServiceDetailDrawer } from '@hanzo/ui/canvas'
//
// while the code lives once in @hanzo/canvas. Optional peer — only pulled when
// the subpath is used.
export * from '@hanzo/canvas'
+184
View File
@@ -0,0 +1,184 @@
/**
* Deterministic layered graph layout — pure, no React, no DOM, unit-tested. This
* is the dependency-free equivalent of the GitOps engine UI's dagre `rankdir:LR`
* pass: resources flow left→right by ownership depth, each tier a clean column.
*
* Three folds:
* 1. columns — longest-path layering (Kahn topological order). A root (no
* incoming edge) sits in column 0; every other node one column right of its
* deepest parent. Cycles degrade gracefully (a child keeps its best column),
* so a malformed graph can never wedge the layout.
* 2. order — within a column, nodes are ordered by the barycentre of their
* parents' rows (then name), so children cluster under their owner and edge
* crossings stay low — dagre's crossing-reduction, cheaply.
* 3. place — nodes stack top-to-bottom at their own height (pods are taller
* than controllers) with a fixed gap, and each column is vertically centred
* on a shared axis so tiers read as balanced columns.
*
* The same graph always yields byte-identical positions.
*/
export interface LayoutNode {
id: string
/** Rendered height in px (pods are taller than controllers). */
h: number
}
export interface LayoutEdge {
source: string
target: string
}
export interface LayoutOptions {
/** Uniform node width in px (x is placed on a fixed column pitch). */
nodeWidth?: number
/** Horizontal gap between a column's right edge and the next column. */
columnGap?: number
/** Vertical gap between stacked nodes in a column. */
rowGap?: number
}
export interface PositionedNode {
id: string
/** Top-left corner, px. */
x: number
y: number
w: number
h: number
col: number
}
export interface GraphLayout {
nodes: PositionedNode[]
/** Content bounds — the transformed layer's intrinsic size. */
width: number
height: number
}
const DEFAULTS = { nodeWidth: 260, columnGap: 68, rowGap: 22 } as const
/**
* Assign each node a column via longest-path layering over the edges. Processes
* nodes in Kahn topological order; an edge into an unknown/cyclic parent is
* skipped so a cycle never wedges the pass.
*/
function assignColumns(ids: string[], edges: LayoutEdge[]): Map<string, number> {
const present = new Set(ids)
const outgoing = new Map<string, string[]>()
const indeg = new Map<string, number>()
for (const id of ids) {
outgoing.set(id, [])
indeg.set(id, 0)
}
for (const e of edges) {
if (!present.has(e.source) || !present.has(e.target) || e.source === e.target) continue
outgoing.get(e.source)!.push(e.target)
indeg.set(e.target, (indeg.get(e.target) ?? 0) + 1)
}
const column = new Map<string, number>()
for (const id of ids) column.set(id, 0)
const remaining = new Map(indeg)
const queue = ids.filter((id) => (remaining.get(id) ?? 0) === 0).sort()
const seen = new Set<string>()
while (queue.length) {
const u = queue.shift()!
if (seen.has(u)) continue
seen.add(u)
const cu = column.get(u) ?? 0
for (const v of outgoing.get(u) ?? []) {
if (cu + 1 > (column.get(v) ?? 0)) column.set(v, cu + 1)
const left = (remaining.get(v) ?? 0) - 1
remaining.set(v, left)
if (left <= 0 && !seen.has(v)) queue.push(v)
}
queue.sort()
}
return column
}
/**
* Lay a resource graph out into centred LR columns. Returns one positioned node
* per input node (input order preserved in the result array) plus content bounds.
*/
export function layoutTree(
nodes: LayoutNode[],
edges: LayoutEdge[],
opts: LayoutOptions = {}
): GraphLayout {
const nodeWidth = opts.nodeWidth ?? DEFAULTS.nodeWidth
const columnGap = opts.columnGap ?? DEFAULTS.columnGap
const rowGap = opts.rowGap ?? DEFAULTS.rowGap
const ids = nodes.map((n) => n.id)
const heightOf = new Map(nodes.map((n) => [n.id, n.h]))
const column = assignColumns(ids, edges)
// Parents per node (for the barycentre ordering).
const parents = new Map<string, string[]>()
for (const id of ids) parents.set(id, [])
const present = new Set(ids)
for (const e of edges) {
if (!present.has(e.source) || !present.has(e.target) || e.source === e.target) continue
parents.get(e.target)!.push(e.source)
}
// Group ids by column, preserving input order as the stable tie-break.
const inputOrder = new Map(ids.map((id, i) => [id, i]))
const byColumn = new Map<number, string[]>()
for (const id of ids) {
const c = column.get(id) ?? 0
const list = byColumn.get(c)
if (list) list.push(id)
else byColumn.set(c, [id])
}
// Order each column left→right by the barycentre of parents' rows in the
// previous column, so children sit under their owner.
const rowOrder = new Map<string, number>()
const sortedColumns = Array.from(byColumn.keys()).sort((a, b) => a - b)
for (const c of sortedColumns) {
const list = byColumn.get(c)!
list.sort((a, b) => {
const key = (id: string): number => {
const ps = parents.get(id) ?? []
const rows = ps.map((p) => rowOrder.get(p)).filter((r): r is number => r != null)
return rows.length ? rows.reduce((s, r) => s + r, 0) / rows.length : Number.POSITIVE_INFINITY
}
const ka = key(a)
const kb = key(b)
if (ka !== kb) return ka - kb
return (inputOrder.get(a) ?? 0) - (inputOrder.get(b) ?? 0)
})
list.forEach((id, i) => rowOrder.set(id, i))
}
// Column heights → the shared vertical axis (tallest column centred).
const columnHeight = new Map<number, number>()
for (const [c, list] of byColumn) {
const total = list.reduce((s, id) => s + (heightOf.get(id) ?? 0), 0) + Math.max(0, list.length - 1) * rowGap
columnHeight.set(c, total)
}
const axis = Math.max(0, ...Array.from(columnHeight.values())) / 2
const pos = new Map<string, { x: number; y: number }>()
for (const c of sortedColumns) {
const list = byColumn.get(c)!
const x = c * (nodeWidth + columnGap)
let y = axis - (columnHeight.get(c) ?? 0) / 2
for (const id of list) {
pos.set(id, { x, y })
y += (heightOf.get(id) ?? 0) + rowGap
}
}
const positioned: PositionedNode[] = nodes.map((n) => {
const p = pos.get(n.id) ?? { x: 0, y: 0 }
return { id: n.id, x: p.x, y: p.y, w: nodeWidth, h: n.h, col: column.get(n.id) ?? 0 }
})
const width = positioned.reduce((m, n) => Math.max(m, n.x + n.w), 0)
const height = positioned.reduce((m, n) => Math.max(m, n.y + n.h), 0)
return { nodes: positioned, width, height }
}
+158
View File
@@ -0,0 +1,158 @@
/**
* CD status → colour, tone, label, priority — the ONE semantic mapping, pure and
* unit-tested. Everything visual reads from here so a health/sync verdict looks
* identical in a pill, a tree node's accent, a mini-bar, and the minimap.
*
* The hues keep the GitOps meaning (green = healthy/synced, amber = out-of-sync /
* missing, red = degraded, blue = progressing, purple = suspended, grey =
* unknown) but are re-tuned for a dark-first surface so they stay legible on
* near-black without the washed-out look of the light-theme originals.
*/
import type { HealthStatus, OperationPhase, PodPhase, SyncStatus } from './types'
/** A coarse tone bucket, shared with the rest of @hanzo/ui's tag vocabulary. */
export type StatusTone = 'green' | 'yellow' | 'red' | 'blue' | 'purple' | 'neutral'
/** The concrete swatch a status renders in: dot/text hue + a soft chip fill. */
export interface StatusColor {
/** The saturated hue — the dot, the accent bar, the pill text. */
hue: string
tone: StatusTone
}
// Dark-tuned semantic hues. Bright enough to read as the pill's own text on a
// near-black surface; the soft chip fill is derived from these at low alpha.
const HUES: Record<StatusTone, string> = {
green: '#22c55e',
yellow: '#f5b544',
red: '#f4636e',
blue: '#38a0f5',
purple: '#a684f5',
neutral: '#8b96a5',
}
const HEALTH_TONE: Record<HealthStatus, StatusTone> = {
Healthy: 'green',
Progressing: 'blue',
Degraded: 'red',
Suspended: 'purple',
Missing: 'yellow',
Unknown: 'neutral',
}
const SYNC_TONE: Record<SyncStatus, StatusTone> = {
Synced: 'green',
OutOfSync: 'yellow',
Unknown: 'neutral',
}
const OPERATION_TONE: Record<OperationPhase, StatusTone> = {
Succeeded: 'green',
Running: 'blue',
Pending: 'blue',
Terminating: 'red',
Failed: 'red',
Error: 'red',
}
const POD_TONE: Record<PodPhase, StatusTone> = {
Running: 'green',
Succeeded: 'green',
Pending: 'blue',
Failed: 'red',
Unknown: 'neutral',
}
/**
* Worst-first ordering (lower = worse), so a rollup surfaces the most broken
* verdict and a sort puts problems on top — matching the GitOps engine's own
* health precedence.
*/
export const HEALTH_PRIORITY: Record<HealthStatus, number> = {
Missing: 0,
Degraded: 1,
Unknown: 2,
Progressing: 3,
Suspended: 4,
Healthy: 5,
}
export const SYNC_PRIORITY: Record<SyncStatus, number> = {
OutOfSync: 0,
Unknown: 1,
Synced: 2,
}
const HEALTH_SET = new Set<string>(Object.keys(HEALTH_TONE))
const SYNC_SET = new Set<string>(Object.keys(SYNC_TONE))
/** Coerce a free-form backend string into a known health code (fails to Unknown). */
export function normalizeHealth(s?: string): HealthStatus {
return s && HEALTH_SET.has(s) ? (s as HealthStatus) : 'Unknown'
}
/** Coerce a free-form backend string into a known sync code (fails to Unknown). */
export function normalizeSync(s?: string): SyncStatus {
return s && SYNC_SET.has(s) ? (s as SyncStatus) : 'Unknown'
}
export function healthColor(status: HealthStatus): StatusColor {
const tone = HEALTH_TONE[status] ?? 'neutral'
return { hue: HUES[tone], tone }
}
export function syncColor(status: SyncStatus): StatusColor {
const tone = SYNC_TONE[status] ?? 'neutral'
return { hue: HUES[tone], tone }
}
export function operationColor(phase: OperationPhase): StatusColor {
const tone = OPERATION_TONE[phase] ?? 'neutral'
return { hue: HUES[tone], tone }
}
export function podColor(phase: PodPhase): StatusColor {
const tone = POD_TONE[phase] ?? 'neutral'
return { hue: HUES[tone], tone }
}
/** The saturated hue for a tone (for callers that already have a tone). */
export function toneHue(tone: StatusTone): string {
return HUES[tone]
}
/**
* Roll a set of resource healths up to the single verdict that best describes
* the whole — the worst one present. Empty ⇒ `Unknown` (honest, never `Healthy`).
*/
export function worstHealth(statuses: HealthStatus[]): HealthStatus {
if (statuses.length === 0) return 'Unknown'
return statuses.reduce((worst, s) =>
HEALTH_PRIORITY[s] < HEALTH_PRIORITY[worst] ? s : worst
)
}
/** Roll a set of sync verdicts up to one — OutOfSync wins, then Unknown. */
export function worstSync(statuses: SyncStatus[]): SyncStatus {
if (statuses.length === 0) return 'Unknown'
return statuses.reduce((worst, s) =>
SYNC_PRIORITY[s] < SYNC_PRIORITY[worst] ? s : worst
)
}
/**
* Parse a `#rgb`/`#rrggbb` hue into an `rgba()` at the given alpha — for the
* soft chip fills and translucent accents. Non-hex input passes through (so a
* caller may hand a hue that's already `rgba(...)`).
*/
export function withAlpha(hex: string, alpha: number): string {
const m = /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.exec(hex)
if (!m) return hex
let h = m[1]
if (h.length === 3) h = h[0] + h[0] + h[1] + h[1] + h[2] + h[2]
const r = parseInt(h.slice(0, 2), 16)
const g = parseInt(h.slice(2, 4), 16)
const b = parseInt(h.slice(4, 6), 16)
const a = Math.max(0, Math.min(1, alpha))
return `rgba(${r}, ${g}, ${b}, ${a})`
}
+52
View File
@@ -0,0 +1,52 @@
/**
* Compact relative time — pure and deterministic given `now`. Future or invalid
* timestamps degrade honestly (`—` / `now`), never a fabricated duration. Shared
* by the tile's last-synced, the drawer's created/age, and the history rows.
*/
export function relativeTime(
epochMs: number | undefined | null,
now: number = Date.now()
): string {
if (epochMs == null || !Number.isFinite(epochMs) || epochMs <= 0) return '—'
const diff = now - epochMs
if (diff < 0) return 'now'
const s = Math.floor(diff / 1000)
if (s < 5) return 'now'
if (s < 60) return `${s}s ago`
const m = Math.floor(s / 60)
if (m < 60) return `${m}m ago`
const h = Math.floor(m / 60)
if (h < 24) return `${h}h ago`
const d = Math.floor(h / 24)
if (d < 7) return `${d}d ago`
const w = Math.floor(d / 7)
if (w < 5) return `${w}w ago`
const mo = Math.floor(d / 30)
if (mo < 12) return `${mo}mo ago`
const y = Math.floor(d / 365)
return `${y}y ago`
}
/**
* Parse an ISO-8601 (or any `Date`-parseable) timestamp to epoch **ms**, or
* `undefined` when absent/unparseable — so the contract's string timestamps
* (`createdAt`, event times) flow into `relativeTime` without throwing.
*/
export function toEpochMs(v: string | number | undefined | null): number | undefined {
if (v == null) return undefined
if (typeof v === 'number') {
if (!Number.isFinite(v) || v <= 0) return undefined
// Hanzo `/v1` rows may report epoch seconds; scale sub-2001 values to ms.
return v < 1e12 ? Math.round(v * 1000) : Math.round(v)
}
const t = Date.parse(v)
return Number.isFinite(t) && t > 0 ? t : undefined
}
/** Relative time for a string/number timestamp (parses, then formats). */
export function relativeTimeOf(
v: string | number | undefined | null,
now: number = Date.now()
): string {
return relativeTime(toEpochMs(v), now)
}
+318
View File
@@ -0,0 +1,318 @@
/**
* @hanzo/ui/cd — the Hanzo CD (continuous-delivery) data contract.
*
* This is the FE↔BE handshake for the `/v1/gitops` API. Every component in this
* module is presentational and data-prop-driven: a host console fetches these
* rows (via the injected `GitopsClient`) and hands them to the views. The shapes
* live in ONE place so the pure folds (status → tone, tree → graph, diff → hunks)
* are unit-tested in isolation and the visual components never reach into a data
* layer.
*
* The vocabulary mirrors a GitOps engine's model (health/sync verdicts, the
* owner-reference resource graph, operation phases) so a backend that projects a
* cluster's live state maps onto it directly, but the names are engine-neutral.
*
* Attribution: the information design of these views is a derivative port of the
* Argo CD web UI (Apache-2.0). See ./NOTICE.
*/
// ──────────────────────────────────────────────────────────────────────────────
// Status vocabularies — small, glanceable, closed unions.
// ──────────────────────────────────────────────────────────────────────────────
/**
* A resource/application health verdict. `Unknown` is honest — a missing signal
* is never guessed up to `Healthy`.
*/
export type HealthStatus =
| 'Healthy'
| 'Progressing'
| 'Degraded'
| 'Suspended'
| 'Missing'
| 'Unknown'
/** Whether live state matches the desired (Git) state. */
export type SyncStatus = 'Synced' | 'OutOfSync' | 'Unknown'
/** The phase of an in-flight sync/rollback operation. */
export type OperationPhase =
| 'Running'
| 'Terminating'
| 'Failed'
| 'Error'
| 'Succeeded'
| 'Pending'
/** A pod's lifecycle phase (drives the pod-status dot). */
export type PodPhase = 'Pending' | 'Running' | 'Succeeded' | 'Failed' | 'Unknown'
/** Per-resource result code within a sync operation. */
export type SyncResultCode = 'Synced' | 'Pruned' | 'SyncFailed' | 'Unknown'
// ──────────────────────────────────────────────────────────────────────────────
// Resource graph — the owner-reference tree of live cluster objects.
// ──────────────────────────────────────────────────────────────────────────────
/** A group/version/kind/namespace/name coordinate for a cluster object. */
export interface ResourceRef {
/** Stable unique id (k8s uid, or a synthesized `group/kind/ns/name`). */
uid: string
group: string
version: string
kind: string
namespace: string
name: string
}
/** A labelled datum shown as a chip on a node / in the summary (e.g. `Revision: 3`). */
export interface InfoItem {
name: string
value: string
}
/** Ingress/service surface of a networked resource (Service, Ingress). */
export interface ResourceNetworking {
/** Objects this resource routes to (Service → Pods, Ingress → Service). */
targetRefs?: ResourceRef[]
labels?: Record<string, string>
/** Externally reachable URLs (a domain's live site). */
externalURLs?: string[]
ingress?: { hostname?: string; ip?: string }[]
}
/**
* One node of the live resource graph. Ownership is expressed by `parentRefs`
* (an object may have several owners); the graph is the transitive closure of
* those edges rooted at the application. Pure data — no React, no back-refs.
*/
export interface ResourceNode extends ResourceRef {
/** The object(s) that own this one — the edges of the graph. */
parentRefs?: ResourceRef[]
/** Health verdict for this object, when the engine computes one. */
health?: { status: HealthStatus; message?: string }
/** Sync verdict for this object (managed objects only). */
status?: SyncStatus
/** Glanceable facts shown as chips (revision, replicas, image tag, …). */
info?: InfoItem[]
networkingInfo?: ResourceNetworking
images?: string[]
createdAt?: string
/** True for hook/sync-operation objects; `resourceVersion` for freshness. */
hook?: boolean
resourceVersion?: string
/** Present on Pod nodes — drives pod-specific rendering. */
podInfo?: PodInfo
}
/** Extra facts a Pod node carries (containers, phase, restarts, node/host). */
export interface PodInfo {
phase: PodPhase
/** Reason string when the phase alone is ambiguous (CrashLoopBackOff, …). */
reason?: string
ready?: string // e.g. "2/3"
restarts?: number
node?: string
age?: string
}
/**
* The full resource graph for an application: the managed nodes plus any
* orphaned objects (live in the app's namespace but not owned by it).
*/
export interface ResourceTree {
nodes: ResourceNode[]
orphanedNodes?: ResourceNode[]
}
// ──────────────────────────────────────────────────────────────────────────────
// Application — the deployable unit the list shows and the detail drills into.
// ──────────────────────────────────────────────────────────────────────────────
/** Where an application's desired state comes from (a Git repo path @ revision). */
export interface AppSource {
repoURL: string
path?: string
targetRevision?: string
chart?: string
}
/** The destination cluster + namespace an application deploys to. */
export interface AppDestination {
server?: string
name?: string
namespace: string
}
/** Revision metadata resolved from the source repo (for the status panel). */
export interface RevisionMetadata {
revision: string
author?: string
date?: string
message?: string
tags?: string[]
}
/**
* A summary of one application — the shape the list/tiles render. Enough to draw
* a row without fetching the tree.
*/
export interface AppSummary {
name: string
namespace?: string
project?: string
health: HealthStatus
sync: SyncStatus
/** Short revision (sha/tag) currently reconciled. */
revision?: string
source?: AppSource
destination?: AppDestination
/** Epoch ms of the last successful sync (rendered relative). */
lastSyncedAt?: number
/** Phase of an in-flight operation, when one is running. */
operationPhase?: OperationPhase
/** Rollup counts of managed resources by health, for the tile mini-bar. */
resourceCounts?: Partial<Record<HealthStatus, number>>
labels?: Record<string, string>
}
/** A single sync/deploy in the history (for the rollback list). */
export interface DeployRevision {
/** Monotonic history id — the handle passed to `rollback`. */
id: number
revision: string
deployedAt?: number
source?: AppSource
metadata?: RevisionMetadata
}
/** The result of (or progress of) the most recent sync/rollback operation. */
export interface OperationState {
phase: OperationPhase
message?: string
startedAt?: number
finishedAt?: number
/** The revision the operation targeted. */
revision?: string
/** Per-resource results (kind/name → code + message). */
syncResult?: {
ref: ResourceRef
result: SyncResultCode
message?: string
hookPhase?: string
}[]
}
/**
* The full application detail — everything the status panel + controls need
* beyond the tree. Returned by `getApp`.
*/
export interface AppDetail extends AppSummary {
/** Resolved metadata for the reconciled revision. */
revisionMetadata?: RevisionMetadata
/** Deploy/rollout history, newest last (matches engine ordering). */
history?: DeployRevision[]
/** In-flight or last-completed operation. */
operationState?: OperationState
/** Free-form conditions/warnings surfaced by the engine. */
conditions?: { type: string; message: string; lastTransitionTime?: string }[]
}
// ──────────────────────────────────────────────────────────────────────────────
// Node detail — manifest + events + logs for the drawer.
// ──────────────────────────────────────────────────────────────────────────────
/** A k8s-style event row shown in the node drawer's Events tab. */
export interface ResourceEvent {
reason: string
message: string
type?: 'Normal' | 'Warning'
count?: number
firstSeen?: string
lastSeen?: string
source?: string
}
/** One log line (optionally tagged with its container + a parsed timestamp). */
export interface LogEntry {
content: string
timestamp?: string
container?: string
/** True for stderr — rendered in the warn tone. */
err?: boolean
}
/**
* Everything the node drawer needs for a single resource: the live manifest
* (YAML), recent events, a recent-logs snapshot (pods), and the desired/live
* pair for the inline diff. Returned by `getResource`.
*/
export interface ResourceDetail {
ref: ResourceRef
health?: { status: HealthStatus; message?: string }
syncStatus?: SyncStatus
/** Live manifest as YAML (or JSON) text. */
liveManifest?: string
/** Desired manifest as YAML text — enables the drawer's inline diff. */
desiredManifest?: string
events?: ResourceEvent[]
/** Recent log lines for a pod/container (a snapshot; streaming is optional). */
logs?: LogEntry[]
/** Container names, when the resource is a Pod (drives the log selector). */
containers?: string[]
info?: InfoItem[]
}
// ──────────────────────────────────────────────────────────────────────────────
// Diff — desired vs live for the whole application.
// ──────────────────────────────────────────────────────────────────────────────
/** One resource's desired/live manifest pair for the application diff view. */
export interface ResourceDiff {
ref: ResourceRef
/** Sync verdict for this resource (drives the section pill). */
status?: SyncStatus
desired?: string
live?: string
/** True when the resource is unmanaged/hook (rendered muted). */
hook?: boolean
}
/** The full application diff payload. Returned by `getDiff`. */
export interface AppDiff {
items: ResourceDiff[]
}
// ──────────────────────────────────────────────────────────────────────────────
// Mutations — sync + rollback request bodies.
// ──────────────────────────────────────────────────────────────────────────────
/** Body for `POST /v1/gitops/{name}/sync`. */
export interface SyncRequest {
/** Target revision; omit to sync to the source's configured revision. */
revision?: string
/** Prune resources that no longer exist in the desired state. */
prune?: boolean
/** Server-side dry-run (compute the plan without applying). */
dryRun?: boolean
/** Restrict the sync to specific resources (default: all). */
resources?: ResourceRef[]
}
/** Body for `POST /v1/gitops/{name}/rollback`. */
export interface RollbackRequest {
/** The history id (from `DeployRevision.id`) to roll back to. */
id: number
prune?: boolean
dryRun?: boolean
}
/** The response to a sync/rollback — the operation the engine kicked off. */
export interface OperationResponse {
operationState: OperationState
}
/** The apps-list response: `GET /v1/gitops`. */
export interface AppListResponse {
items: AppSummary[]
}
+8
View File
@@ -0,0 +1,8 @@
// @hanzo/ui/dashboard — the console dashboard kit (landing + deploy pipeline +
// composable overview primitives + charts/motion layer), re-exported from its
// home package @hanzo/dashboard. Thin subpath:
//
// import { ... } from '@hanzo/ui/dashboard'
//
// Optional peer — only pulled when the subpath is used.
export * from '@hanzo/dashboard'
+5
View File
@@ -0,0 +1,5 @@
// @hanzo/ui/gitops — the CD/GitOps surface, re-exported from its home package
// @hanzo/gitops (an Apache-2.0 clean-room port of the Argo CD UI). Kept as a thin
// subpath so a console can `import { GitopsAppList, GitopsAppTree, GitopsNodeInfo,
// HealthBadge, ... } from '@hanzo/ui/gitops'` while the code lives in one place.
export * from '@hanzo/gitops'
+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 {}
}
+11
View File
@@ -0,0 +1,11 @@
// @hanzo/ui/network — the ONE network selector, re-exported from its home module
// @hanzo/ui-shadcn/network. `NetworkSwitcher` renders the selected environment
// and a menu of configured networks + a custom-endpoint form, backed by the
// shared selected-network store; `configureNetworks` swaps the network set so a
// downstream brand can white-label it. Thin subpath:
//
// import { NetworkSwitcher, useNetwork, configureNetworks, HANZO_NETWORKS,
// type Network } from '@hanzo/ui/network'
//
// Optional peer — only pulled when the subpath is used.
export * from '@hanzo/ui-shadcn/network'
+183
View File
@@ -0,0 +1,183 @@
'use client'
/**
* AppHeader — the shared shell header every Hanzo surface renders: the brand
* mark top-left (from @hanzo/logo, white-label via `wordmark`/`brand`), an
* org/project slot (usually `<OrgSwitcher/>`), an app switcher listing the
* Hanzo surfaces (modeled on the console's AppLauncher), and an identity menu
* (Profile · Theme · Team settings · Billing · Sign out).
*
* Host-agnostic: every action is an injected handler; a row renders only when
* its handler is provided (honest — no dead menu items). Mobile-first: fluid
* flex + truncation, the wordmark collapse is the brand motion itself.
*/
import { useState, type ReactNode } from 'react'
import { Button, Popover, Separator, Text, XStack, YStack } from '@hanzo/gui'
import { AppWindow, Bot, CreditCard, Grip, LayoutGrid, LogOut, MessageCircle, Settings2, Sparkles, UserRound, Users } from '@hanzogui/lucide-icons-2'
import { BrandMark } from './BrandMark'
import { ThemeToggle } from './ThemeToggle'
import { otherSurfaces, type Surface, type SurfaceId } from './surfaces.data'
/** The per-surface glyph — keyed by `Surface.id`, so each surface reads distinctly. */
const SURFACE_ICON = {
ai: Sparkles,
console: LayoutGrid,
app: AppWindow,
chat: MessageCircle,
bot: Bot,
team: Users,
billing: CreditCard,
} as const satisfies Record<SurfaceId, unknown>
const openHref = (href: string) => {
if (typeof window !== 'undefined') window.open(href, '_blank', 'noopener')
}
function MenuRow({ icon, label, onPress }: { icon: ReactNode; label: string; onPress: () => void }) {
return (
<XStack onPress={onPress} cursor="pointer" items="center" gap="$2.5" px="$2" py="$2" rounded="$3" hoverStyle={{ bg: '$color5' }}>
{icon}
<Text fontSize="$2" color="$color12">
{label}
</Text>
</XStack>
)
}
export type AppHeaderProps = {
/** Brand slot — defaults to the animated `<BrandMark/>`. */
brand?: ReactNode
/** White-label wordmark for the default brand mark. */
wordmark?: string
/** Brand press (usually "go home"). */
onBrand?: () => void
/** Org/project slot — usually `<OrgSwitcher/>` (+ a project switcher). */
org?: ReactNode
/** Free slot between the org slot and the right cluster. */
children?: ReactNode
/** The surface this header renders on — omitted from the switcher (no self-link). */
current?: SurfaceId
/** App-switcher surfaces; defaults to every surface but `current`. [] hides it. */
surfaces?: Surface[]
/** Open a surface — default `window.open` (new tab). */
open?: (surface: Surface) => void
/** Identity-menu trigger label (the signed-in user's name/email). */
user?: string
/** Replace the whole identity menu content. */
menu?: ReactNode
/** Theme row content — default `<ThemeToggle/>`; null hides the row. */
theme?: ReactNode
onProfile?: () => void
onTeam?: () => void
onBilling?: () => void
onSignOut?: () => void
}
export function AppHeader({
brand,
wordmark = 'Hanzo',
onBrand,
org,
children,
current,
surfaces,
open,
user,
menu,
theme,
onProfile,
onTeam,
onBilling,
onSignOut,
}: AppHeaderProps) {
const [appsOpen, setAppsOpen] = useState(false)
const [menuOpen, setMenuOpen] = useState(false)
const items = surfaces ?? otherSurfaces(current)
const launch = (s: Surface) => {
setAppsOpen(false)
if (open) open(s)
else openHref(s.href)
}
return (
<XStack items="center" gap="$2" px="$3" height={52} borderBottomWidth={1} borderColor="$borderColor" bg="$background">
<XStack items="center" cursor={onBrand ? 'pointer' : undefined} onPress={onBrand}>
{brand ?? <BrandMark wordmark={wordmark} />}
</XStack>
{org ? (
<XStack items="center" minW={0}>
{org}
</XStack>
) : null}
<XStack flex={1} items="center" minW={0}>
{children}
</XStack>
{items.length > 0 ? (
<Popover open={appsOpen} onOpenChange={setAppsOpen} placement="bottom-end">
<Popover.Trigger asChild>
<Button size="$2" chromeless icon={<Grip size={16} />} aria-label="Apps" />
</Popover.Trigger>
<Popover.Content bordered elevate p="$2" width={230} bg="$color2" borderColor="$borderColor">
<YStack gap="$1">
{items.map((s) => {
const Icon = SURFACE_ICON[s.id]
return (
<XStack key={s.id} onPress={() => launch(s)} cursor="pointer" items="center" gap="$2.5" px="$2" py="$2" rounded="$3" hoverStyle={{ bg: '$color5' }}>
<Icon size={16} />
<Text flex={1} fontSize="$2" fontWeight="600" color="$color12">
{s.label}
</Text>
{s.hint ? (
<Text fontSize="$1" color="$color10">
{s.hint}
</Text>
) : null}
</XStack>
)
})}
</YStack>
</Popover.Content>
</Popover>
) : null}
<Popover open={menuOpen} onOpenChange={setMenuOpen} placement="bottom-end">
<Popover.Trigger asChild>
<Button size="$2" chromeless icon={<UserRound size={16} />} aria-label="Account">
{user ? (
<Text fontSize="$2" color="$color12" numberOfLines={1} maxW={140}>
{user}
</Text>
) : null}
</Button>
</Popover.Trigger>
<Popover.Content bordered elevate p="$2" width={220} bg="$color2" borderColor="$borderColor">
{menu ?? (
<YStack gap="$1">
{onProfile ? <MenuRow icon={<UserRound size={15} />} label="Profile" onPress={() => (setMenuOpen(false), onProfile())} /> : null}
{theme !== null ? (
<XStack items="center" gap="$2.5" px="$2" py="$1" rounded="$3">
<Text flex={1} fontSize="$2" color="$color12">
Theme
</Text>
{theme ?? <ThemeToggle />}
</XStack>
) : null}
{onTeam ? <MenuRow icon={<Settings2 size={15} />} label="Team settings" onPress={() => (setMenuOpen(false), onTeam())} /> : null}
{onBilling ? <MenuRow icon={<CreditCard size={15} />} label="Billing" onPress={() => (setMenuOpen(false), onBilling())} /> : null}
{onSignOut ? (
<>
<Separator borderColor="$borderColor" my="$1" />
<MenuRow icon={<LogOut size={15} />} label="Sign out" onPress={() => (setMenuOpen(false), onSignOut())} />
</>
) : null}
</YStack>
)}
</Popover.Content>
</Popover>
</XStack>
)
}
+27
View File
@@ -0,0 +1,27 @@
'use client'
/**
* BrandMark — the canonical brand lockup for an app header, from @hanzo/logo
* (the ONE home of the mark geometry + motion). Animated by default: intro
* flip + idle breathing + a wordmark that slides in, holds, then collapses
* (returns on hover). Pure CSS, reduced-motion-safe; the mark inherits
* `currentColor` so it themes for free. White-label via `wordmark`.
*/
import { HanzoLogo } from '@hanzo/logo/react'
import { HanzoMark } from './HanzoMark'
export function BrandMark({
size = 20,
wordmark = 'Hanzo',
animated = true,
}: {
size?: number
/** Wordmark text (white-label): "Hanzo", "Lux", "Zoo", … */
wordmark?: string
/** false = the static mark alone (no motion shell, no wordmark). */
animated?: boolean
}) {
if (!animated) return <HanzoMark size={size} />
return <HanzoLogo animated size={size} wordmark={wordmark} />
}
+67 -83
View File
@@ -1,22 +1,23 @@
'use client'
/**
* ComboBox — a typeable select: a text input the user can type any value into,
* PLUS a popover of LIVE options (filtered by what's typed) they can pick from.
* The value is always exactly the input text, so a custom id is inherently
* supported — selecting an option just fills the input. This is the ONE way the
* console offers "pick from a live list OR type your own" (the model field, the
* tool field). Prop-driven + self-contained (options/loading/error injected by the
* caller), so it is orthogonal to the data source and lifts into `@hanzo/ui`.
* ComboBox — a typeable select: a text input the user can type any value into, PLUS a
* menu of LIVE options (filtered by what's typed) they can pick from. The value is
* always exactly the input text, so a custom id is inherently supported. Prop-driven +
* self-contained (options/loading/error injected by the caller).
*
* Idiom: the same @hanzo/gui Popover shell as OrgSwitcher/SelectMenu (bordered,
* elevate, `$color2`). Options render in a portal above the DetailPane SlideOver.
* Uses the ONE shared menu spec (MenuItemView) + FloatingMenu (gui Portal) so options look
* identical to every other menu and render correctly through the portal under a nested
* `<Theme>` on gui-native hosts. The input row is the anchor (excluded from dismiss) and
* the panel does NOT steal focus, so typing keeps filtering.
*/
import { useMemo, useState } from 'react'
import { Button, Input, Popover, Spinner, Text, XStack, YStack } from '@hanzo/gui'
import { Check, ChevronDown, RefreshCw } from '@hanzogui/lucide-icons-2'
import { useCallback, useMemo, useRef, useState } from 'react'
import { Button, Input, Spinner, Text, XStack } from '@hanzo/gui'
import { ChevronDown, RefreshCw } from '@hanzogui/lucide-icons-2'
import { filterOptions, type ComboOption } from './combobox/filter'
import { MenuItemView } from './menu/items'
import { FloatingMenu } from './menu/FloatingMenu'
export type { ComboOption } from './combobox/filter'
@@ -49,6 +50,9 @@ export function ComboBox({
minWidth?: number
}) {
const [open, setOpen] = useState(false)
const rowRef = useRef<HTMLElement | null>(null)
const anchorRect = useCallback(() => rowRef.current?.getBoundingClientRect() ?? null, [])
const anchorEl = useCallback(() => rowRef.current, [])
const filtered = useMemo(() => filterOptions(options, value), [options, value])
const pick = (v: string) => {
@@ -57,12 +61,12 @@ export function ComboBox({
}
return (
<Popover open={open} onOpenChange={setOpen} placement="bottom-start" allowFlip>
<XStack items="center" gap="$2" minW={minWidth}>
<>
<XStack ref={rowRef as never} items="center" gap="$2" minW={minWidth}>
<Input
flex={1}
value={value}
onChangeText={(v) => {
onChangeText={(v: string) => {
onChange(v)
if (!open) setOpen(true)
}}
@@ -71,77 +75,57 @@ export function ComboBox({
placeholder={placeholder}
autoCapitalize="none"
/>
<Popover.Trigger asChild>
<Button
size="$2"
chromeless
disabled={disabled}
icon={<ChevronDown size={16} opacity={0.7} />}
onPress={() => setOpen((o) => !o)}
aria-label="Show options"
/>
</Popover.Trigger>
<Button
size="$2"
chromeless
disabled={disabled}
icon={<ChevronDown size={16} opacity={0.7} />}
onPress={() => setOpen((o) => !o)}
aria-label="Show options"
/>
</XStack>
<Popover.Content bordered elevate p="$1.5" minW={minWidth} bg="$color2" borderColor="$borderColor">
<YStack gap="$0.5" minW={minWidth} maxH={300} overflow="scroll">
{loading ? (
<XStack items="center" gap="$2" px="$2.5" py="$2">
<Spinner size="small" color="$color11" />
<Text fontSize="$2" color="$color10">
Loading options
</Text>
</XStack>
) : error ? (
<XStack items="center" gap="$2" px="$2.5" py="$2">
<Text fontSize="$2" color="$color10" flex={1} numberOfLines={2}>
{error}
</Text>
{onRetry ? (
<Button size="$1" chromeless icon={<RefreshCw size={12} />} onPress={onRetry} aria-label="Retry" />
) : null}
</XStack>
) : filtered.length === 0 ? (
<Text fontSize="$2" color="$color10" px="$2.5" py="$2">
{emptyText}
<FloatingMenu
open={open}
onClose={() => setOpen(false)}
anchorRect={anchorRect}
anchorEl={anchorEl}
autoFocus={false}
minWidth={minWidth}
maxHeight={300}
>
{loading ? (
<XStack items="center" gap="$2" px="$2" py="$2">
<Spinner size="small" color="$color11" />
<Text fontSize="$2" color="$color10">
Loading options
</Text>
) : (
filtered.map((o) => (
<Row key={o.value} option={o} active={o.value === value} onPress={() => pick(o.value)} />
))
)}
</YStack>
</Popover.Content>
</Popover>
)
}
function Row({ option, active, onPress }: { option: ComboOption; active: boolean; onPress: () => void }) {
return (
<XStack
items="center"
gap="$2"
px="$2.5"
py="$1.5"
rounded="$3"
cursor="pointer"
hoverStyle={{ bg: '$color4' }}
bg={active ? '$color4' : 'transparent'}
onPress={onPress}
>
<XStack width={14} items="center" justify="center">
{active ? <Check size={13} /> : null}
</XStack>
<YStack flex={1} minW={0}>
<Text fontSize="$2" color="$color12" numberOfLines={1}>
{option.label ?? option.value}
</Text>
{option.hint ? (
<Text fontSize="$1" color="$color10" numberOfLines={1}>
{option.hint}
</XStack>
) : error ? (
<XStack items="center" gap="$2" px="$2" py="$2">
<Text fontSize="$2" color="$color10" flex={1} numberOfLines={2}>
{error}
</Text>
{onRetry ? (
<Button size="$1" chromeless icon={<RefreshCw size={12} />} onPress={onRetry} aria-label="Retry" />
) : null}
</XStack>
) : filtered.length === 0 ? (
<Text fontSize="$2" color="$color10" px="$2" py="$2">
{emptyText}
</Text>
) : null}
</YStack>
</XStack>
) : (
filtered.map((o) => (
<MenuItemView
key={o.value}
label={o.label ?? o.value}
description={o.hint}
selected={o.value === value}
onSelect={() => pick(o.value)}
/>
))
)}
</FloatingMenu>
</>
)
}
+7 -4
View File
@@ -1,11 +1,12 @@
'use client'
/**
* The Hanzo "H" mark — the canonical brand glyph (same geometry as app/icon.svg).
* The Hanzo "H" mark — the canonical 7-path shaded glyph: five body blocks +
* two shade slivers (geometry canon: @hanzo/logo `MARK_PATHS`).
*
* Rendered as inline SVG with `fill: currentColor`, so it inherits the
* surrounding text color and adapts to the dark/light theme with no per-theme
* asset. ONE source for the mark across the chrome (sidebar + header).
* Rendered as inline SVG with `fill: currentColor` (shade at reduced opacity),
* so it inherits the surrounding text color and adapts to the dark/light theme
* with no per-theme asset. ONE source for the mark across the chrome.
*/
export function HanzoMark({ size = 22, color = 'currentColor' }: { size?: number; color?: string }) {
return (
@@ -19,9 +20,11 @@ export function HanzoMark({ size = 22, color = 'currentColor' }: { size?: number
style={{ display: 'block', flexShrink: 0 }}
>
<path d="M22.21 67V44.6369H0V67H22.21Z" />
<path opacity={0.55} d="M0 44.6369L22.21 46.8285V44.6369H0Z" />
<path d="M66.7038 22.3184H22.2534L0.0878906 44.6367H44.4634L66.7038 22.3184Z" />
<path d="M22.21 0H0V22.3184H22.21V0Z" />
<path d="M66.7198 0H44.5098V22.3184H66.7198V0Z" />
<path opacity={0.55} d="M66.6753 22.3185L44.5098 20.0822V22.3185H66.6753Z" />
<path d="M66.7198 67V44.6369H44.5098V67H66.7198Z" />
</svg>
)
+332
View File
@@ -0,0 +1,332 @@
'use client'
/**
* OrgSwitcher — the Vercel-style org/team switcher, hoisted from the console
* (hanzoai/ui#36) onto @hanzo/gui primitives. Shows the org the surface is
* scoped to, switches between the orgs the caller can see, and creates one.
*
* Host-agnostic: the LIST is a lazy, paged loader the host injects (IAM
* `get-organizations` behind its own proxy) — one page at a time, the search
* term pushed to the server, more on demand (scroll + "Load more"), so it
* scales to thousands of orgs. No loader → the current org alone, synthesized
* from the scope (never fabricated). Selecting re-scopes IN PLACE via
* `scope.switchOrg` (persist + reload → every module refetches under the new
* `X-Org-Id`). Create posts through the injected hook, then scopes into the
* new org.
*/
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Button, Input, Popover, Spinner, Text, XStack, YStack } from '@hanzo/gui'
import { Check, ChevronsUpDown, LayoutGrid, Plus, Search } from '@hanzogui/lucide-icons-2'
import { filterOrgs, type Org, type OrgScope } from './scope'
const titleCase = (s: string) => (s ? s[0].toUpperCase() + s.slice(1) : s)
const initialsOf = (o: Org) =>
(o.displayName || o.name)
.split(/\s+/)
.map((w) => w[0])
.join('')
.slice(0, 2)
.toUpperCase()
/** An org's avatar — its logo when set, else a monogram tile. */
function OrgAvatar({ org, size = 22 }: { org: Org; size?: number }) {
if (org.logo) {
// eslint-disable-next-line @next/next/no-img-element
return <img src={org.logo} alt="" style={{ height: size, width: size, objectFit: 'contain', display: 'block', borderRadius: 6 }} />
}
return (
<YStack width={size} height={size} rounded="$3" bg="$color4" items="center" justify="center">
<Text fontSize="$1" fontWeight="800" color="$color12">
{initialsOf(org)}
</Text>
</YStack>
)
}
export type OrgSwitcherProps = {
/** The active-org contract (see `orgScope`). */
scope: OrgScope
/**
* Lazy paged org list: 0-based page + server-pushed search → one page of
* rows. A full page (=== pageSize) implies another. Omit when the caller
* can only see its own org.
*/
orgs?: (page: number, query: string) => Promise<Org[]>
/** Rows per page the loader returns. Default 20. */
pageSize?: number
/** Create-org hook → the created org's id; omit to hide the affordance. */
create?: (name: string) => Promise<string>
/** Show the "All organizations" de-scope row (`scope.leaveOrg`). */
picker?: boolean
}
export function OrgSwitcher({ scope, orgs, pageSize = 20, create, picker = false }: OrgSwitcherProps) {
const currentId = scope.currentOrg()
const [open, setOpen] = useState(false)
const [query, setQuery] = useState('')
const [debounced, setDebounced] = useState('')
const [rows, setRows] = useState<Org[]>([])
const [loading, setLoading] = useState(false)
const [loadingMore, setLoadingMore] = useState(false)
const [hasMore, setHasMore] = useState(false)
const [creating, setCreating] = useState(false)
const [newName, setNewName] = useState('')
const [busy, setBusy] = useState(false)
const [err, setErr] = useState<string | null>(null)
const pageRef = useRef(0)
const reqRef = useRef(0) // race token — a newer request supersedes older ones
// Debounce the search so type-as-you-search re-hits the server at most ~4×/s.
useEffect(() => {
const id = setTimeout(() => setDebounced(query.trim()), 250)
return () => clearTimeout(id)
}, [query])
const fetchPage = useCallback(
async (page: number, q: string, append: boolean) => {
if (!orgs) return
const token = ++reqRef.current
append ? setLoadingMore(true) : setLoading(true)
try {
const incoming = await orgs(page, q)
if (token !== reqRef.current) return
pageRef.current = page
setRows((prev) => {
const merged = append ? [...prev] : []
const seen = new Set(merged.map((o) => o.name))
for (const o of incoming) if (!seen.has(o.name)) merged.push(o)
return merged
})
setHasMore(incoming.length >= pageSize)
} catch {
// Honest empty / keep what's shown — never a fabricated list.
if (token !== reqRef.current) return
if (!append) setRows([])
setHasMore(false)
} finally {
if (token === reqRef.current) {
setLoading(false)
setLoadingMore(false)
}
}
},
[orgs, pageSize],
)
// (Re)load page 0 when the popover opens or the debounced query changes.
useEffect(() => {
if (!open || !orgs) return
void fetchPage(0, debounced, false)
}, [open, debounced, fetchPage, orgs])
const loadMore = useCallback(() => {
if (loading || loadingMore || !hasMore) return
void fetchPage(pageRef.current + 1, debounced, true)
}, [loading, loadingMore, hasMore, debounced, fetchPage])
// Rows to render — the loaded list (client-filtered too), else the current
// org synthesized from the scope.
const visible: Org[] = useMemo(() => {
if (orgs) return filterOrgs(rows, query)
return [{ name: currentId, displayName: titleCase(currentId) }]
}, [orgs, rows, query, currentId])
const current: Org = useMemo(
() => rows.find((o) => o.name === currentId) ?? { name: currentId, displayName: titleCase(currentId) },
[rows, currentId],
)
const select = useCallback(
(org: string) => {
setOpen(false)
scope.switchOrg(org)
},
[scope],
)
const submitCreate = async () => {
const name = newName.trim()
if (!name || !create) return
setBusy(true)
setErr(null)
try {
const org = await create(name)
scope.switchOrg(org)
} catch (e) {
setErr(e instanceof Error ? e.message : 'Could not create the organization.')
setBusy(false)
}
}
return (
<Popover open={open} onOpenChange={setOpen} placement="bottom-start">
<Popover.Trigger asChild>
<Button size="$2" chromeless icon={<OrgAvatar org={current} size={18} />} iconAfter={<ChevronsUpDown size={13} />}>
{current.displayName || titleCase(current.name)}
</Button>
</Popover.Trigger>
<Popover.Content bordered elevate p="$2" width={300} bg="$color2" borderColor="$borderColor">
{creating ? (
<YStack gap="$2">
<Text fontSize="$2" color="$color12" fontWeight="700">
Create organization
</Text>
<Input
size="$3"
placeholder="Organization name"
value={newName}
onChangeText={setNewName}
autoCapitalize="words"
onSubmitEditing={() => void submitCreate()}
/>
{err ? (
<Text fontSize="$1" color="$red10">
{err}
</Text>
) : null}
<XStack gap="$2" justify="flex-end">
<Button size="$2" chromeless onPress={() => setCreating(false)} disabled={busy}>
Cancel
</Button>
<Button
size="$2"
onPress={() => void submitCreate()}
disabled={busy || !newName.trim()}
icon={busy ? <Spinner size="small" /> : <Plus size={14} />}
>
Create
</Button>
</XStack>
</YStack>
) : (
<YStack gap="$1">
{orgs ? (
<XStack items="center" gap="$2" px="$2" py="$1" rounded="$3" borderWidth={1} borderColor="$borderColor">
<Search size={13} opacity={0.6} />
<Input
flex={1}
size="$2"
borderWidth={0}
bg="transparent"
placeholder="Find organization…"
value={query}
onChangeText={setQuery}
autoCapitalize="none"
autoCorrect={false}
/>
</XStack>
) : null}
<div
style={{ maxHeight: 300, overflowY: 'auto', display: 'flex', flexDirection: 'column' }}
onScroll={(e) => {
const el = e.currentTarget
if (el.scrollHeight - el.scrollTop - el.clientHeight < 48) loadMore()
}}
>
{loading ? (
<XStack items="center" gap="$2" px="$2" py="$3">
<Spinner size="small" color="$color11" />
<Text fontSize="$2" color="$color10">
Loading organizations
</Text>
</XStack>
) : visible.length === 0 ? (
<Text px="$2" py="$2" fontSize="$2" color="$color10">
{query.trim() ? `No organizations match “${query.trim()}”.` : 'No organizations yet.'}
</Text>
) : (
visible.map((org) => (
<XStack
key={org.name}
onPress={() => select(org.name)}
cursor="pointer"
items="center"
gap="$2.5"
px="$2"
py="$2"
rounded="$3"
bg={org.name === currentId ? '$color4' : 'transparent'}
hoverStyle={{ bg: '$color5' }}
>
<OrgAvatar org={org} />
<Text flex={1} fontSize="$2" color="$color12" numberOfLines={1}>
{org.displayName || org.name}
</Text>
{org.name === currentId ? <Check size={15} /> : null}
</XStack>
))
)}
{loadingMore ? (
<XStack items="center" gap="$2" px="$2" py="$2">
<Spinner size="small" color="$color11" />
<Text fontSize="$1" color="$color10">
Loading more
</Text>
</XStack>
) : hasMore ? (
<XStack onPress={loadMore} cursor="pointer" items="center" justify="center" px="$2" py="$1.5" rounded="$3" hoverStyle={{ bg: '$color4' }}>
<Text fontSize="$1" color="$color11" fontWeight="600">
Load more
</Text>
</XStack>
) : null}
</div>
{create ? (
<XStack
onPress={() => {
setCreating(true)
setErr(null)
}}
cursor="pointer"
items="center"
gap="$2.5"
px="$2"
py="$2"
mt="$1"
rounded="$3"
borderTopWidth={1}
borderColor="$borderColor"
hoverStyle={{ bg: '$color5' }}
>
<YStack width={22} height={22} rounded="$3" bg="$color3" items="center" justify="center">
<Plus size={14} />
</YStack>
<Text fontSize="$2" color="$color12">
Create organization
</Text>
</XStack>
) : null}
{picker ? (
<XStack
onPress={() => {
setOpen(false)
scope.leaveOrg()
}}
cursor="pointer"
items="center"
gap="$2.5"
px="$2"
py="$2"
rounded="$3"
hoverStyle={{ bg: '$color5' }}
>
<YStack width={22} height={22} rounded="$3" bg="$color3" items="center" justify="center">
<LayoutGrid size={14} />
</YStack>
<Text fontSize="$2" color="$color12">
All organizations
</Text>
</XStack>
) : null}
</YStack>
)}
</Popover.Content>
</Popover>
)
}
+21 -48
View File
@@ -1,16 +1,19 @@
'use client'
/**
* SelectMenu — a compact, reusable dropdown select on @hanzo/gui Popover (the
* console's proven dropdown idiom, same as OrgSwitcher/ScopeSwitcher). Renders a
* labelled trigger ("All types", or the chosen option) and a popover list with a
* check on the active option. Prop-driven + self-contained so it lifts into
* `@hanzo/ui`. `value === null` is the "all"/unfiltered state.
* SelectMenu — a compact, reusable dropdown select. Renders a labelled trigger ("All
* types", or the chosen option) and a menu list with a check on the active option.
* Prop-driven + self-contained. `value === null` is the "all"/unfiltered state.
*
* A thin DropdownMenu (the ONE menu mechanism) so it is pixel-identical to every other
* menu and rides the same portal-theme-safe Portal path — no gui Popover, no Sheet
* re-root, works on gui-native hosts.
*/
import type { ReactElement } from 'react'
import { useState } from 'react'
import { Button, Popover, Text, XStack, YStack } from '@hanzo/gui'
import { ChevronDown, Check } from '@hanzogui/lucide-icons-2'
import { Button, Text } from '@hanzo/gui'
import { ChevronDown } from '@hanzogui/lucide-icons-2'
import { DropdownMenu } from './menu/DropdownMenu'
import type { MenuItemSpec } from './menu/items'
export type SelectOption<T extends string> = { key: T; label: string }
@@ -31,18 +34,18 @@ export function SelectMenu<T extends string>({
icon?: ReactElement
minWidth?: number
}) {
const [open, setOpen] = useState(false)
const active = value === null ? null : options.find((o) => o.key === value) ?? null
const triggerLabel = active ? active.label : allLabel
const pick = (v: T | null) => {
onChange(v)
setOpen(false)
}
const items: MenuItemSpec[] = [
{ key: '__all__', label: allLabel, selected: value === null, onSelect: () => onChange(null) },
...options.map((o) => ({ key: o.key, label: o.label, selected: value === o.key, onSelect: () => onChange(o.key) })),
]
return (
<Popover open={open} onOpenChange={setOpen} placement="bottom-start">
<Popover.Trigger asChild>
<DropdownMenu
minWidth={Math.max(minWidth, 160)}
trigger={
<Button
size="$2"
minW={minWidth}
@@ -57,38 +60,8 @@ export function SelectMenu<T extends string>({
{triggerLabel}
</Text>
</Button>
</Popover.Trigger>
<Popover.Content bordered elevate p="$1.5" minW={minWidth} bg="$color2" borderColor="$borderColor">
<YStack gap="$0.5" minW={minWidth} maxH={320} overflow="scroll">
<Row label={allLabel} active={value === null} onPress={() => pick(null)} />
{options.map((o) => (
<Row key={o.key} label={o.label} active={value === o.key} onPress={() => pick(o.key)} />
))}
</YStack>
</Popover.Content>
</Popover>
)
}
function Row({ label, active, onPress }: { label: string; active: boolean; onPress: () => void }) {
return (
<XStack
items="center"
gap="$2"
px="$2.5"
py="$1.5"
rounded="$3"
cursor="pointer"
hoverStyle={{ bg: '$color4' }}
bg={active ? '$color4' : 'transparent'}
onPress={onPress}
>
<XStack width={14} items="center" justify="center">
{active ? <Check size={13} /> : null}
</XStack>
<Text fontSize="$2" color="$color12" flex={1} numberOfLines={1}>
{label}
</Text>
</XStack>
}
items={items}
/>
)
}
+121 -11
View File
@@ -1,25 +1,135 @@
'use client'
/**
* Theme toggle — flips the console between dark and light via next-theme. The
* provider already wires `NextThemeProvider` → `GuiProvider`, so setting the
* theme here re-themes the whole tree. Shows the action's target icon (a sun in
* dark mode, a moon in light mode).
* ThemeToggle — flips between dark and light. Framework-agnostic by default so
* Vite / Tauri / Express hosts can use it with NO Next dependency:
*
* • Controlled: <ThemeToggle theme={theme} onToggle={setTheme} /> (host owns theme)
* • Uncontrolled (agnostic): <ThemeToggle onThemeChange={fn} /> (toggles the DOM
* `.dark` class + persists to localStorage)
* • Uncontrolled (no props): <ThemeToggle /> (console/Next —
* falls back to the OPTIONAL @hanzogui/next-theme path;
* if it cannot load, degrades to the agnostic DOM toggle)
*
* Shows the action's target icon (a sun in dark mode, a moon in light mode).
*/
import type { ReactNode } from 'react'
import { Component, Suspense, lazy, useState } from 'react'
import { Button } from '@hanzo/gui'
import { useThemeSetting } from '@hanzogui/next-theme'
import { Moon, Sun } from '@hanzogui/lucide-icons-2'
export function ThemeToggle() {
const { current, resolvedTheme, set } = useThemeSetting()
const isDark = (resolvedTheme ?? current ?? 'dark') !== 'light'
export type ThemeMode = 'light' | 'dark'
export type ThemeToggleProps = {
/** Controlled theme. Pair with `onToggle`/`onThemeChange` to fully control it. */
theme?: ThemeMode
/** Seed for uncontrolled mode (default: read from the DOM `.dark` class, else 'dark'). */
defaultTheme?: ThemeMode
/** Called with the next theme on toggle (alias of `onThemeChange`). */
onToggle?: (next: ThemeMode) => void
/** Called with the next theme on toggle. */
onThemeChange?: (next: ThemeMode) => void
/** @hanzo/gui Button size token (default "$2"). */
size?: string
/** aria-label override. */
label?: string
}
const STORAGE_KEY = 'theme'
function readDomTheme(): ThemeMode | undefined {
if (typeof document === 'undefined') return undefined
const el = document.documentElement
if (el.classList.contains('dark')) return 'dark'
if (el.classList.contains('light')) return 'light'
try {
const ls = window.localStorage.getItem(STORAGE_KEY)
if (ls === 'light' || ls === 'dark') return ls
} catch {
/* localStorage may be unavailable */
}
return undefined
}
function applyDomTheme(next: ThemeMode): void {
if (typeof document === 'undefined') return
const el = document.documentElement
el.classList.toggle('dark', next === 'dark')
el.classList.toggle('light', next === 'light')
el.style.colorScheme = next
try {
window.localStorage.setItem(STORAGE_KEY, next)
} catch {
/* ignore */
}
}
/**
* The framework-agnostic toggle button. Controlled when `theme` is provided; otherwise
* self-managed via the DOM `.dark` class. No framework dependency — safe on any host.
*/
export function AgnosticThemeToggle({
theme,
defaultTheme,
onToggle,
onThemeChange,
size = '$2',
label,
}: ThemeToggleProps) {
const controlled = theme !== undefined
const [internal, setInternal] = useState<ThemeMode>(() => theme ?? defaultTheme ?? readDomTheme() ?? 'dark')
const current: ThemeMode = controlled ? (theme as ThemeMode) : internal
const isDark = current === 'dark'
const toggle = () => {
const next: ThemeMode = isDark ? 'light' : 'dark'
if (!controlled) {
setInternal(next)
applyDomTheme(next)
}
onToggle?.(next)
onThemeChange?.(next)
}
return (
<Button
size="$2"
size={size as never}
chromeless
icon={isDark ? <Sun size={16} /> : <Moon size={16} />}
onPress={() => set(isDark ? 'light' : 'dark')}
aria-label={isDark ? 'Switch to light theme' : 'Switch to dark theme'}
onPress={toggle}
aria-label={label ?? (isDark ? 'Switch to light theme' : 'Switch to dark theme')}
/>
)
}
// @hanzogui/next-theme is an OPTIONAL dependency — loaded only on the uncontrolled,
// no-props path (console/Next). Lazily code-split so non-Next hosts never need it at
// runtime; if it fails to load, the boundary below degrades to the agnostic toggle.
const NextThemeToggle = lazy(() => import('./ThemeToggleNext'))
class NextThemeBoundary extends Component<{ fallback: ReactNode; children: ReactNode }, { failed: boolean }> {
state = { failed: false }
static getDerivedStateFromError() {
return { failed: true }
}
render() {
return this.state.failed ? this.props.fallback : this.props.children
}
}
export function ThemeToggle(props: ThemeToggleProps) {
const injected =
props.theme !== undefined || props.onToggle !== undefined || props.onThemeChange !== undefined
if (injected) return <AgnosticThemeToggle {...props} />
// No props → keep the existing console/Next behaviour via the optional next-theme
// path, degrading to the agnostic DOM toggle if it is not installed.
const fallback = <AgnosticThemeToggle {...props} />
return (
<NextThemeBoundary fallback={fallback}>
<Suspense fallback={fallback}>
<NextThemeToggle size={props.size} label={props.label} />
</Suspense>
</NextThemeBoundary>
)
}
+27
View File
@@ -0,0 +1,27 @@
'use client'
/**
* ThemeToggleNext — the @hanzogui/next-theme binding (console / Next). This is the ONLY
* module that imports @hanzogui/next-theme, so it stays an OPTIONAL dependency: the base
* ThemeToggle lazy-loads it (default export) and non-Next hosts never pull it in unless
* they import this component directly. UI is delegated to the framework-agnostic button
* in controlled mode, so every toggle looks identical.
*/
import { useThemeSetting } from '@hanzogui/next-theme'
import { AgnosticThemeToggle, type ThemeToggleProps } from './ThemeToggle'
function ThemeToggleNext({ size, label }: Pick<ThemeToggleProps, 'size' | 'label'>) {
const { current, resolvedTheme, set } = useThemeSetting()
const isDark = (resolvedTheme ?? current ?? 'dark') !== 'light'
return (
<AgnosticThemeToggle
theme={isDark ? 'dark' : 'light'}
onToggle={(next) => set(next)}
size={size}
label={label}
/>
)
}
export default ThemeToggleNext
export { ThemeToggleNext }
+19
View File
@@ -38,6 +38,20 @@ export { Donut as DonutRing, type DonutSegment } from './Donut'
export { ComboBox } from './ComboBox'
export * from './combobox/filter'
// The shared shell — brand mark, org scope + switcher, app header (the
// console's org-scope contract + switcher hoisted here; hanzoai/ui#36).
// `surfaces.data` is the ONE canonical cross-surface list every launcher consumes.
export * from './surfaces.data'
export * from './AppHeader'
export * from './BrandMark'
export * from './OrgSwitcher'
export * from './scope'
// Menu — the ONE menu system: portal-theme-safe DropdownMenu (click) + ContextMenu
// (right-click), both rendering the SAME item spec (MenuPanel/MenuItemView) so every
// menu across the fleet is pixel-identical. SelectMenu/ComboBox share the same spec.
export * from './menu'
// The rest — every exported name below is unique across the layer.
export * from './DataTable'
export * from './EmptyState'
@@ -53,5 +67,10 @@ export * from './SelectMenu'
export * from './SlideOver'
export * from './StatusTag'
export * from './ThemeToggle'
export { ThemeToggleNext } from './ThemeToggleNext'
export * from './Toast'
export * from './color'
// Usage — the canonical AI-usage surface (totals, chart, spend-by-model, activity)
// now lives in ONE home: `@hanzo/usage/panel` (<UsagePanel>). The former meter/card/
// dashboard kit here was orphaned (zero consumers) and folded into it.
+55
View File
@@ -0,0 +1,55 @@
'use client'
/**
* ContextMenu — a right-click menu that renders the SAME shared item spec as DropdownMenu,
* so every menu in the fleet is identical. Built on FloatingMenu (gui Portal), anchored to
* the cursor point. Web/desktop right-click (`onContextMenu`); on native — which has no
* right-click — the wrapped child renders untouched.
*
* <ContextMenu items={[{ key:'copy', label:'Copy', icon:<Copy size={16}/>, onSelect:copy }]}>
* <YStack>right-click me</YStack>
* </ContextMenu>
*/
import type { ReactElement, MouseEvent } from 'react'
import { cloneElement, useCallback, useState } from 'react'
import { renderMenuItems, type MenuItemSpec } from './items'
import { FloatingMenu } from './FloatingMenu'
export type ContextMenuProps = {
/** The right-clickable target. Its `onContextMenu` is composed, not replaced. */
children: ReactElement
items: MenuItemSpec[]
disabled?: boolean
minWidth?: number
maxHeight?: number
}
export function ContextMenu({ children, items, disabled, minWidth = 200, maxHeight }: ContextMenuProps) {
const [state, setState] = useState<{ open: boolean; x: number; y: number }>({ open: false, x: 0, y: 0 })
const close = useCallback(() => setState((s) => (s.open ? { ...s, open: false } : s)), [])
// A zero-size rect at the cursor; FloatingMenu opens the panel at that point (gap 0).
const anchorRect = useCallback(
() => ({ left: state.x, top: state.y, right: state.x, bottom: state.y, width: 0, height: 0 }),
[state.x, state.y],
)
const childOnContextMenu = (children.props as { onContextMenu?: (e: MouseEvent) => void }).onContextMenu
const target = cloneElement(children, {
onContextMenu: (e: MouseEvent) => {
childOnContextMenu?.(e)
if (disabled) return
e.preventDefault()
e.stopPropagation()
setState({ open: true, x: e.clientX, y: e.clientY })
},
} as Partial<typeof children.props>)
return (
<>
{target}
<FloatingMenu open={state.open} onClose={close} anchorRect={anchorRect} gap={0} minWidth={minWidth} maxHeight={maxHeight}>
{renderMenuItems(items, close)}
</FloatingMenu>
</>
)
}
+71
View File
@@ -0,0 +1,71 @@
'use client'
/**
* DropdownMenu — a portal-theme-safe click menu. Declarative `items` rendered through the
* ONE shared menu-item spec, so it is pixel-identical to ContextMenu/SelectMenu/ComboBox.
* Built on FloatingMenu (gui Portal), NOT the gui Popover — so it mounts without the
* Sheet re-root that loses theme context on gui-native hosts (react-native-web-lite).
*
* <DropdownMenu
* trigger={<Button>Actions</Button>}
* items={[
* { key: 'rename', label: 'Rename', icon: <Pencil size={16} />, onSelect: rename },
* { type: 'separator' },
* { key: 'del', label: 'Delete', destructive: true, onSelect: del },
* ]}
* />
*/
import type { ReactElement } from 'react'
import { useCallback, useRef } from 'react'
import { XStack, useControllableState } from '@hanzo/gui'
import { renderMenuItems, type MenuItemSpec } from './items'
import { FloatingMenu } from './FloatingMenu'
export type DropdownMenuProps = {
/** The clickable element. Wrapped so any element opens the menu on press. */
trigger: ReactElement
items: MenuItemSpec[]
/** Controlled open state. Omit for uncontrolled. */
open?: boolean
defaultOpen?: boolean
onOpenChange?: (open: boolean) => void
minWidth?: number
maxHeight?: number
}
export function DropdownMenu({
trigger,
items,
open,
defaultOpen = false,
onOpenChange,
minWidth = 200,
maxHeight,
}: DropdownMenuProps) {
const [isOpen, setOpen] = useControllableState({
prop: open,
defaultProp: defaultOpen,
onChange: onOpenChange,
})
const anchorRef = useRef<HTMLElement | null>(null)
const anchorRect = useCallback(() => anchorRef.current?.getBoundingClientRect() ?? null, [])
const anchorEl = useCallback(() => anchorRef.current, [])
return (
<>
<XStack ref={anchorRef as never} self="flex-start" cursor="pointer" onPress={() => setOpen(!isOpen)}>
{trigger}
</XStack>
<FloatingMenu
open={isOpen}
onClose={() => setOpen(false)}
anchorRect={anchorRect}
anchorEl={anchorEl}
minWidth={minWidth}
maxHeight={maxHeight}
>
{renderMenuItems(items, () => setOpen(false))}
</FloatingMenu>
</>
)
}
+136
View File
@@ -0,0 +1,136 @@
'use client'
/**
* FloatingMenu — the ONE floating-panel mechanism for every menu. Renders the shared
* MenuPanel through the @hanzo/gui Portal, positioned at an anchor (a trigger rect or a
* cursor point), theme-forwarded via PortalTheme, edge-flipped, dismiss-wired and
* keyboard-navigable.
*
* Why Portal, not gui Popover: gui's Popover pulls in a SheetController that re-roots the
* trigger subtree and reads the theme from React context — on gui-native hosts
* (react-native-web-lite, theme in context, no CSS-class fallback) that throws
* "Missing theme" AT MOUNT. The raw Portal does not re-root, so DropdownMenu, ContextMenu,
* SelectMenu and ComboBox all ride this ONE working path.
*/
import type { ReactNode, KeyboardEvent as ReactKeyboardEvent } from 'react'
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
import { Portal } from '@hanzo/gui'
import { MenuPanel } from './items'
import { PortalTheme, useThemeName } from './portal-theme'
import { menuKeyDown } from './roving'
const EDGE = 8
export type RectLike = { left: number; top: number; right: number; bottom: number; width: number; height: number }
export function FloatingMenu({
open,
onClose,
anchorRect,
anchorEl,
gap = 4,
minWidth = 200,
maxHeight,
autoFocus = true,
children,
}: {
open: boolean
onClose: () => void
/** Anchor in viewport coords — a trigger's rect, or a zero-size rect at a cursor point. */
anchorRect: () => RectLike | null | undefined
/** Element excluded from outside-dismiss (the trigger / input row). */
anchorEl?: () => HTMLElement | null | undefined
/** Space between the anchor's bottom and the panel (0 for a cursor menu). */
gap?: number
minWidth?: number
maxHeight?: number
/** Focus the panel on open (for roving keys). ComboBox keeps focus in its input. */
autoFocus?: boolean
children: ReactNode
}) {
const themeName = useThemeName()
const panelRef = useRef<HTMLElement | null>(null)
const [pos, setPos] = useState<{ left: number; top: number } | null>(null)
const setPanel = useCallback((n: HTMLElement | null) => {
panelRef.current = n
}, [])
const place = useCallback(() => {
if (typeof window === 'undefined') return
const a = anchorRect()
if (!a) return
const node = panelRef.current
const w = node?.offsetWidth || minWidth
const h = node?.offsetHeight || 0
let left = a.left
let top = a.bottom + gap
if (left + w > window.innerWidth - EDGE) left = Math.max(EDGE, window.innerWidth - w - EDGE)
if (h && top + h > window.innerHeight - EDGE) {
const above = a.top - gap - h
top = above >= EDGE ? above : Math.max(EDGE, window.innerHeight - h - EDGE)
}
setPos({ left, top })
}, [anchorRect, gap, minWidth])
// Measure + position before paint; focus the panel for keyboard nav.
useLayoutEffect(() => {
if (!open) {
setPos(null)
return
}
place()
if (autoFocus) panelRef.current?.focus?.()
}, [open, place, autoFocus])
// Dismiss on outside pointer (excluding the anchor), Escape, scroll, resize, blur.
useEffect(() => {
if (!open || typeof document === 'undefined') return
const onPointerDown = (e: PointerEvent) => {
const t = e.target as Node
if (panelRef.current?.contains(t)) return
const anchor = anchorEl?.()
if (anchor && anchor.contains(t)) return
onClose()
}
const onKey = (e: globalThis.KeyboardEvent) => {
if (e.key === 'Escape') onClose()
}
document.addEventListener('pointerdown', onPointerDown, true)
document.addEventListener('keydown', onKey, true)
window.addEventListener('scroll', onClose, true)
window.addEventListener('resize', onClose)
window.addEventListener('blur', onClose)
return () => {
document.removeEventListener('pointerdown', onPointerDown, true)
document.removeEventListener('keydown', onKey, true)
window.removeEventListener('scroll', onClose, true)
window.removeEventListener('resize', onClose)
window.removeEventListener('blur', onClose)
}
}, [open, onClose, anchorEl])
if (!open) return null
return (
<Portal>
<PortalTheme name={themeName}>
<MenuPanel
panelRef={setPanel}
minWidth={minWidth}
maxHeight={maxHeight}
tabIndex={-1}
onKeyDown={(e: ReactKeyboardEvent) => menuKeyDown(e, onClose)}
style={{
position: 'fixed',
left: pos?.left ?? 0,
top: pos?.top ?? 0,
zIndex: 100000,
visibility: pos ? 'visible' : 'hidden',
}}
>
{children}
</MenuPanel>
</PortalTheme>
</Portal>
)
}
+16
View File
@@ -0,0 +1,16 @@
// @hanzo/ui/product menu — ONE menu system. Portal-theme-safe DropdownMenu (click)
// and ContextMenu (right-click) both render the SAME item spec, so every menu across
// the fleet is pixel-identical. See items.tsx for the shared spec.
export { DropdownMenu, type DropdownMenuProps } from './DropdownMenu'
export { ContextMenu, type ContextMenuProps } from './ContextMenu'
export { FloatingMenu, type RectLike } from './FloatingMenu'
export {
MenuPanel,
MenuItemView,
MenuSeparatorView,
MenuLabelView,
renderMenuItems,
type MenuItemSpec,
} from './items'
export { PortalTheme } from './portal-theme'
+230
View File
@@ -0,0 +1,230 @@
'use client'
/**
* The ONE menu-item spec — every menu across the fleet renders through these
* presentational primitives, so DropdownMenu, ContextMenu, SelectMenu and ComboBox
* are pixel-identical. Geometry is literal px on a strict 8-grid (never random);
* colour is theme-adaptive tokens ($color2/$color11/$color12/$borderColor) so light
* and dark both work, with the single purple accent supplied by the brand CSS vars.
*
* Style props use the @hanzo/gui config shorthands (bg/items/justify/px/py/p/mx/my/
* minW/maxH/rounded/select/shrink) — the config omits the longhand aliases.
*
* Panel — bg $color2 (#111 dark), hairline border, radius 12, inner pad 4, subtle
* border+ambient elevation.
* Item — height 30 (2832 band), px 8, gap 8, radius 7; icon in a fixed 16px slot
* on the left; label 13px $color12; right affordance (shortcut / check /
* chevron) right-aligned. States: hover/active/focus → accent-soft, focus
* visible; selected → check + purple; disabled → muted, no hover.
* Separator — 1px hairline, 4px vertical margin.
* Label — 11px uppercase muted section header, px 8.
*/
import type { ReactNode, KeyboardEvent } from 'react'
import { Text, XStack, YStack } from '@hanzo/gui'
import { Check, ChevronRight } from '@hanzogui/lucide-icons-2'
// ── Geometry — literal px, 8-grid ──────────────────────────────────────────────
const ITEM_MIN_HEIGHT = 30
const ITEM_RADIUS = 7
const ITEM_PX = 8
const ITEM_GAP = 8
const ICON_SLOT = 16
export const PANEL_RADIUS = 12
export const PANEL_PAD = 4
const PANEL_GAP = 2
const SEP_MARGIN = 4
const FONT_LABEL = 13
const FONT_MUTED = 11
const FONT_SHORTCUT = 12
// ── Colour — brand purple accent via CSS vars (fallbacks keep it correct even when
// @hanzo/brand is not loaded, e.g. a bare Vite/Tauri host); everything else uses
// theme-adaptive Tamagui tokens. ──────────────────────────────────────────────
const ACCENT = 'var(--hanzo-accent, #8b5cf6)'
const ACCENT_SOFT = 'var(--hanzo-accent-soft, rgba(139,92,246,0.16))'
const DANGER = 'var(--hanzo-danger, #ef4444)'
// ── Declarative item model ──────────────────────────────────────────────────────
export type MenuItemSpec =
| {
type?: 'item'
/** Stable key. */
key: string
label: string
/** Leading Lucide icon (16px, unfilled) in the fixed left slot. */
icon?: ReactNode
/** Second muted line under the label (e.g. an id or hint). */
description?: string
/** Right-aligned shortcut / hint text (e.g. "⌘K"). */
shortcut?: string
/** Renders a right check + purple tint. */
selected?: boolean
disabled?: boolean
/** Danger styling (red label/icon). */
destructive?: boolean
/** Renders a right chevron (submenu / drill-in affordance). */
hasSubmenu?: boolean
onSelect: () => void
/** Keep the menu open after selecting (default: close). */
closeOnSelect?: boolean
}
| { type: 'separator'; key?: string }
| { type: 'label'; key?: string; label: string }
// ── Surface ───────────────────────────────────────────────────────────────────
export function MenuPanel({
children,
minWidth = 200,
maxHeight = 360,
onKeyDown,
panelRef,
...rest
}: {
children: ReactNode
minWidth?: number
maxHeight?: number
onKeyDown?: (e: KeyboardEvent) => void
/** Web DOM node of the panel (for measuring / edge-flip). */
panelRef?: (node: HTMLElement | null) => void
[key: string]: unknown
}) {
return (
<YStack
// Web DOM ref; Gui forwards it to the underlying node. Cast (not @ts-expect-error)
// so it is env-agnostic — the ref type is strict under the pkg build, loose here.
ref={panelRef as never}
role="menu"
bg="$color2"
borderColor="$borderColor"
borderWidth={1}
rounded={PANEL_RADIUS}
p={PANEL_PAD}
gap={PANEL_GAP}
minW={minWidth}
maxH={maxHeight}
overflow="scroll"
// Subtle border + ambient elevation, minimal shadow.
shadowColor="rgba(0,0,0,0.45)"
shadowRadius={20}
shadowOffset={{ width: 0, height: 10 }}
onKeyDown={onKeyDown as never}
{...rest}
>
{children}
</YStack>
)
}
// ── Item — the ONE row ──────────────────────────────────────────────────────────
export function MenuItemView({
icon,
label,
description,
shortcut,
selected = false,
disabled = false,
destructive = false,
hasSubmenu = false,
onSelect,
}: Omit<Extract<MenuItemSpec, { type?: 'item' }>, 'key' | 'type' | 'closeOnSelect'>) {
const press = () => {
if (!disabled) onSelect()
}
const onKeyDown = (e: KeyboardEvent) => {
if (disabled) return
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
onSelect()
}
}
const labelColor = destructive ? DANGER : '$color12'
return (
<XStack
role="menuitem"
tabIndex={disabled ? -1 : 0}
aria-disabled={disabled || undefined}
items="center"
gap={ITEM_GAP}
px={ITEM_PX}
minH={ITEM_MIN_HEIGHT}
rounded={ITEM_RADIUS}
cursor={disabled ? 'default' : 'pointer'}
opacity={disabled ? 0.4 : 1}
select="none"
style={{ outline: 'none' }}
hoverStyle={disabled ? {} : { bg: ACCENT_SOFT as never }}
pressStyle={disabled ? {} : { bg: ACCENT_SOFT as never }}
focusStyle={disabled ? {} : { bg: ACCENT_SOFT as never }}
onPress={press}
onKeyDown={onKeyDown as never}
>
<XStack width={ICON_SLOT} height={ICON_SLOT} items="center" justify="center" shrink={0}>
{icon ? (
<XStack items="center" justify="center" opacity={destructive ? 1 : 0.9} style={destructive ? { color: DANGER } : undefined}>
{icon}
</XStack>
) : null}
</XStack>
<YStack flex={1} minW={0}>
<Text fontSize={FONT_LABEL} lineHeight={18} color={labelColor as never} numberOfLines={1}>
{label}
</Text>
{description ? (
<Text fontSize={FONT_MUTED} lineHeight={14} color="$color11" numberOfLines={1}>
{description}
</Text>
) : null}
</YStack>
{shortcut ? (
<Text fontSize={FONT_SHORTCUT} color="$color11" shrink={0}>
{shortcut}
</Text>
) : null}
{selected ? <Check size={14} color={ACCENT} /> : null}
{hasSubmenu ? <ChevronRight size={14} color="var(--hanzo-muted, currentColor)" opacity={0.6} /> : null}
</XStack>
)
}
// ── Separator ───────────────────────────────────────────────────────────────────
export function MenuSeparatorView() {
return <YStack height={1} bg="$borderColor" my={SEP_MARGIN} mx={SEP_MARGIN} role="separator" />
}
// ── Section label ────────────────────────────────────────────────────────────────
export function MenuLabelView({ children }: { children: ReactNode }) {
return (
<Text
fontSize={FONT_MUTED}
color="$color11"
px={ITEM_PX}
py={SEP_MARGIN}
textTransform="uppercase"
letterSpacing={0.4}
select="none"
>
{children}
</Text>
)
}
// ── The single render path shared by every menu ──────────────────────────────────
export function renderMenuItems(items: MenuItemSpec[], close?: () => void): ReactNode {
return items.map((it, i) => {
if (it.type === 'separator') return <MenuSeparatorView key={it.key ?? `sep-${i}`} />
if (it.type === 'label') return <MenuLabelView key={it.key ?? `lbl-${i}`}>{it.label}</MenuLabelView>
const { key, onSelect, closeOnSelect = true, ...rest } = it
return (
<MenuItemView
key={key}
{...rest}
onSelect={() => {
onSelect()
if (closeOnSelect) close?.()
}}
/>
)
})
}
+33
View File
@@ -0,0 +1,33 @@
'use client'
/**
* PortalTheme — the ONE fix for "themed content escapes its theme through a portal".
*
* @hanzo/gui's `Popover.Content` and `Portal` render their children in a SEPARATE
* React subtree (a Gorhom-style portal host mounted at the app root), NOT via
* `ReactDOM.createPortal`. React context therefore does NOT flow from the trigger's
* location to the host. Under a nested `<Theme name="dark">` the portaled content
* lands OUTSIDE that theme context and Tamagui throws "Missing theme" (or renders
* unthemed).
*
* The fix is two-part and must stay two-part:
* 1. At the TRIGGER site (theme context still available) capture the resolved
* theme name with `useThemeName()`.
* 2. INSIDE the portaled content re-apply it: `<PortalTheme name={captured}>`.
*
* Every portal-backed menu in this package (DropdownMenu, ContextMenu, SelectMenu,
* ComboBox) uses this so a menu renders identically whether it is opened under the
* root theme or under any nested `<Theme>`, light or dark.
*/
import type { ReactNode } from 'react'
import { Theme, useThemeName } from '@hanzo/gui'
/** Capture the current resolved theme name at the trigger site. Re-exported so the
* capture + re-apply pair reads from one module. */
export { useThemeName }
export function PortalTheme({ name, children }: { name: string; children: ReactNode }) {
// `name` is the resolved theme name (e.g. "dark", or a compound "dark_purple").
// Tamagui's `Theme` accepts the resolved name and reconstructs the context.
return <Theme name={name as never}>{children}</Theme>
}
+34
View File
@@ -0,0 +1,34 @@
'use client'
/**
* Roving keyboard focus for a menu panel — ArrowUp/Down move focus between enabled
* `[role="menuitem"]` children, Home/End jump to ends, Escape closes. Web/desktop
* only (guards on `document`); native menus have no pointer-keyboard nav. Shared by
* DropdownMenu and ContextMenu so navigation is identical.
*/
import type { KeyboardEvent } from 'react'
export function menuKeyDown(e: KeyboardEvent, onClose?: () => void): void {
if (e.key === 'Escape') {
onClose?.()
return
}
if (e.key !== 'ArrowDown' && e.key !== 'ArrowUp' && e.key !== 'Home' && e.key !== 'End') return
if (typeof document === 'undefined') return
const panel = e.currentTarget as HTMLElement
const items = Array.from(
panel.querySelectorAll<HTMLElement>('[role="menuitem"]:not([aria-disabled="true"])'),
)
if (items.length === 0) return
e.preventDefault()
const active = document.activeElement as HTMLElement | null
const current = active ? items.indexOf(active) : -1
let next: number
if (e.key === 'Home') next = 0
else if (e.key === 'End') next = items.length - 1
else if (e.key === 'ArrowDown') next = current < 0 ? 0 : (current + 1) % items.length
else next = current <= 0 ? items.length - 1 : current - 1
items[next]?.focus()
}

Some files were not shown because too many files have changed in this diff Show More