Compare commits

..
Author SHA1 Message Date
hanzo-dev 2177c0a3ca refactor(sdk): remove dead streaming subsystem + fully-commented connect-qr page (~1.2k LOC, red-team #13/#42/#56) — build green 2026-06-22 06:01:30 +00:00
hanzo-devandGitHub f53dc2513a Merge pull request #7 from hanzoai/fix/onboarding-welcome-brand-crash
fix(chat): stream reasoning_content so thinking models reply (#19)
2026-06-21 20:53:56 -07:00
hanzo-dev f892023d4a Merge remote-tracking branch 'origin/main' into fix/onboarding-welcome-brand-crash 2026-06-22 03:53:29 +00:00
hanzo-dev 38b22e7265 fix(chat): stream reasoning_content too so thinking models show a reply (red-team #19/#55) 2026-06-22 03:53:28 +00:00
hanzo-devandGitHub aeed548966 Merge pull request #6 from hanzoai/fix/onboarding-welcome-brand-crash
fix(sdk): default-agents hang + tsc-breaking logo type + 5 GGUF zen models
2026-06-21 20:47:25 -07:00
hanzo-dev 0483a5b099 Merge remote-tracking branch 'origin/main' into fix/onboarding-welcome-brand-crash 2026-06-22 03:46:35 +00:00
hanzo-dev cf084837a5 fix(sdk): bound default-agents polling so it cannot hang 60s + drop dead HanzoAIProps.logo override that broke tsc for all 3 apps + 5 verified GGUF zen models 2026-06-22 03:46:07 +00:00
hanzo-devandGitHub 2a62e59c1c Merge pull request #5 from hanzoai/fix/onboarding-welcome-brand-crash
fix(onboarding): per-app /app-logo.png on landing (stop Hanzo H leaking onto zoo/lux)
2026-06-21 20:02:27 -07:00
hanzo-dev b5217aca35 Merge remote-tracking branch 'origin/main' into fix/onboarding-welcome-brand-crash 2026-06-22 03:00:48 +00:00
hanzo-dev 350f9f2c6e fix(onboarding): use per-app /app-logo.png on landing — stop Hanzo H leaking onto zoo/lux 2026-06-22 02:57:17 +00:00
hanzo-dev 3f7b90359c fix(brand): node-manager window + notifications use per-app /app-logo.png (was hardcoded Hanzo icon — leaked onto zoo/lux) 2026-06-22 00:14:29 +00:00
hanzo-dev 13c4dbf660 fix(ai): show compact app logo in collapsed sidebar 2026-06-21 22:11:56 +00:00
hanzo-devandGitHub 4be2d12e2e Merge pull request #4 from hanzoai/fix/onboarding-welcome-brand-crash
Merge fix/onboarding-welcome-brand-crash
2026-06-21 13:54:24 -07:00
hanzo-dev 45adbc8349 feat(models): Download-all per segment (DRY) + Featured community models (Qwen/Llama/Gemma/Mistral/DeepSeek, verified-public HF GGUF) 2026-06-21 20:51:04 +00:00
hanzo-dev bbe5b9ece6 chore(assets): remove dead Shinkai octopus logo + video (2.3MB, unreferenced) 2026-06-21 20:51:04 +00:00
hanzo-dev 689b811db9 feat(models): native Hanzo engine + Zen catalog (HF download), one Local Models gallery (engine choice Hanzo/Ollama/LMStudio), cosmetic fixes (real Ollama icon, per-brand sidebar logo, cloud scroll) 2026-06-21 20:51:04 +00:00
hanzo-dev 085d2ca295 fix(chat): resolve bare 'default' alias to the real model name (Zen Nano 0.6B) 2026-06-21 20:51:04 +00:00
hanzo-dev bd97e87ab3 fix(chat): show actual model name (Zen Nano 0.6B) in selector, not provider name (Zen Engine) 2026-06-21 20:51:03 +00:00
hanzo-dev 854b1d59d5 fix(onboarding): unblock Skip — inject sha512 for noble-ed25519 v2, node addr from brand config, reset(false), /v1/node paths 2026-06-21 20:51:03 +00:00
hanzo-dev 76247c6366 feat(ai): add optional node field to published BrandConfig surface (matches src/types.ts) 2026-06-21 20:51:03 +00:00
hanzo-dev 0cb5be28a2 feat(@hanzo/ai): BrandConfig.node field + brand-neutral node_* invokes
- BrandConfig.node { apiPort, enginePort } — single source for on-device node address
- brand-neutral node_* command invokes (was hardcoded hanzo_node_spawn)
2026-06-21 20:51:03 +00:00
hanzo-devandClaude Opus 4.8 c480b014b4 fix(@hanzo/ai): export ./ai.css subpath for consumer stylesheet import
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 20:51:03 +00:00
hanzo-dev 9fd1bd7b65 remove brand-hardcode gate; brand is data, not a gate 2026-06-21 20:51:03 +00:00
hanzo-devandClaude Opus 4.8 782cff59e6 feat(brand): brand-hardcode guard + brand-neutral i18n (decomplect brand=data)
- scripts/check-no-hardcoded-brand.mjs: fails if a brand literal (Hanzo/Zoo/Lux/Shinkai) appears in user-facing i18n copy; brand must come from {{appName}}/brandConfig
- net-i18n: 62 brand literals -> {{appName}}; tidy 2 stray 'shinkai' comments

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 20:51:03 +00:00
hanzo-devandClaude Opus 4.8 11c79d969d build(@hanzo/ai): restore @tailwindcss/vite + globals.css in SDK lib build and fix i18n en-US resource load so consumer apps render styled + translated
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 20:51:03 +00:00
hanzo-devandClaude Opus 4.8 f6c03b9f63 fix(ai): resolve brand at render time in onboarding so welcome stops crashing blank on Zoo/Lux
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 20:51:03 +00:00
hanzo-devandClaude Opus 4.8 75f734cba3 fix(ai): onboarding welcome uses each brand's own logo (no hardcoded Hanzo H / tagline)
Read brandConfig.logo inside OnboardingLayout (was a module-level useBrand()
frozen on the default brand, forcing visor.svg's Hanzo "H" onto Zoo/Lux).
Drop the broken right-column hero preview; tagline is now opt-in via
brandConfig.tagline (empty default — removes the old hardcoded "the way, the
truth, the life"). Welcome title uses {{appName}} so no brand name leaks.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 20:51:02 +00:00
hanzo-dev f22c35d920 fix(build): unbreak docker build:app (tsconfig refs, recipes deep-subpath stubs, react-navigation skew, gui->hanzogui imports)
Four root causes blocked the one/vxrn (rolldown) build of apps/gui.hanzo.ai:

1. tsconfig project references: single-level pkgs/*/tsconfig.json used ../../
   (depth-2) refs that resolved to /work/core/* and /work/ui/gui instead of
   pkgs/core/* and pkgs/ui/hanzogui. Fixed the depth (../) and the stale
   gui->hanzogui rename across 9 tsconfigs.

2. @hanzogui/recipes deep subpaths (component/*) were UNLOADABLE: the no-recipes
   path still pulls the showcase tree, which imports @hanzogui/recipes/component/*
   via named re-export / named import / namespace member access. The old
   virtual-module stub could not satisfy statically-validated named exports.
   generate-recipes-proxy.mjs now scans the app and emits concrete per-subpath
   stub modules with the exact named exports (hooks return a proxy object so
   destructuring is safe); vite.config aliases each deep subpath (longest-first)
   to its stub ahead of the catch-all @hanzogui/recipes alias.

3. @react-navigation version skew: expo-router pulled bottom-tabs@7.16.2 which
   imports NavigationProvider (only in native >=7.2.5), conflicting with one's
   ~7.1.33 peer. Pinned bottom-tabs to 7.10.1 and bumped native 7.1.9->7.1.34
   (latest 7.1.x), keeping the @react-navigation family internally consistent.

4. Stale gui->hanzogui imports: SelectDemo.tsx and TestSelect.tsx imported from
   gui/linear-gradient instead of hanzogui/linear-gradient.

Validated locally: bun run build:app completes (build complete, full dist emitted).
2026-06-20 08:09:39 +00:00
hanzo-dev 357f9f9690 fix(build): static-extract components must be hanzogui not gui
The config-bundler did require("gui") for each entry in
gui.build.ts components, but no workspace package is named gui
(the components package is hanzogui, pkgs/ui/hanzogui). This
threw Cannot find module \x27gui\x27 -> Must provide components in
the gui-extract step of build:prod (static extract), so gui
never published an image. Rename the stale tamagui->gui leftover
to hanzogui in components and the matching optimizeDeps include.
2026-06-20 06:40:26 +00:00
hanzo-dev cd9cc63361 fix(build): pin vxrn to version exposing ./worker (unbreak build:app)
The gui.hanzo.ai site (the only app shipped in the GHCR image) runs on
one@1.12.5, whose vxrn@1.12.5 self-imports vxrn/worker. kitchen-sink and
sandbox pull one@1.15.10 -> vxrn@1.15.10, which bun hoists to the root
node_modules/vxrn. vxrn@1.15.10 dropped the ./worker export, so the
hoisted copy breaks vxrn@1.12.5s self-referential import:
  Package subpath ./worker is not defined by exports in vxrn/package.json

Pin the whole one/vxrn family to 1.12.5 via root overrides so a single
vxrn (with ./worker) exists in the tree. One version, no hoist collision.
2026-06-20 02:56:44 +00:00
b888ef8e2a ci: containerize + arcd GHCR semver build (DOKS deploy) (#3)
Add .github/workflows/build.yml that builds+pushes ghcr.io/hanzoai/gui on
v* tags via the canonical hanzoai/.github docker-build reusable workflow
on the self-hosted arcd fleet (amd64+arm64, no GitHub-hosted runners).
Wires the existing working Dockerfile (gui.hanzo.ai, bun build, serves on
:3000) to GHCR. Semver-only tags. The npm publish-gui*.yml workflows are
unaffected.

Co-authored-by: hanzo-dev <z@hanzo.ai>
2026-06-19 16:22:49 -07:00
Antje WorringandClaude Opus 4.8 ea427bc66f docs: tidy LLM.md indexes; CLAUDE.md -> LLM.md symlink convention
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 10:04:45 -07:00
zeekay 926589c982 ci: route to canonical native arcd labels [self-hosted, linux, <arch>]
Replaces non-canonical scale-set / org-prefixed labels with the
existing labels every arcd host registers with. Matches evo for
amd64 and spark for arm64. No new labels added.
2026-06-10 20:15:30 -07:00
hanzo-devandClaude Opus 4.8 40921f9a3d ci: targeted gui-surface publish (skip whole-workspace fan-out)
release.ts --force-publish-all crashed packing unrelated @hanzo_network/*
packages. Replace with publish-gui-surface.mjs which publishes only the
@hanzogui/* closure + hanzogui + @hanzo/gui alias, rewriting workspace:*
to concrete versions and publishing the npm pack tarball directly.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 19:20:10 +00:00
hanzo-devandClaude Opus 4.8 15f130db5f ci: add publish-gui-all workflow to republish all gui packages with dist
Fixes broken 7.0.0 publish where @hanzogui/* primitives shipped without
dist/, breaking standalone @hanzo/gui installs. Builds then runs
release.ts --republish --force-publish-all using the same KMS->secret
NPM_TOKEN resolution as publish-gui.yml.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 19:09:27 +00:00
hanzo-devandClaude Opus 4.8 07ae7ee84a ci(publish-gui): run on spark runner (evo offline) to unblock publish
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 23:34:46 +00:00
hanzo-devandClaude Opus 4.8 3109fc14c2 perf(@hanzo/ai): optimize SDK build — skip minify/compressed-size in lib mode
CI OOM'd bundling the app-as-SDK. Skip minify (consumers minify in their own
build), skip compressed-size reporting, target esnext, and raise heap to 12GB.
Build ~89s, no OOM. (Lib output is unminified by design; downstream apps minify.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 23:04:31 +00:00
Antje WorringandClaude Opus 4.8 1ae9e1579b ci: target native arcd runners (evo/spark)
Replace retired self-hosted labels with native host arcd daemons:
evo (linux/amd64), spark (linux/arm64).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 15:55:49 -07:00
hanzo-devandClaude Opus 4.8 1f3d8df185 build(@hanzo/ai): raise Node heap to 8GB for the SDK vite build (CI OOM)
CI runner OOM'd bundling @hanzo/ai (exit 134). Add --max-old-space-size=8192.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 22:40:16 +00:00
hanzo-devandClaude Opus 4.8 e213be30bc release(@hanzo/ai): 0.1.1 — brand-agnostic SDK + workspace build fixes
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 21:58:25 +00:00
hanzo-devandClaude Opus 4.8 7c1765359c build: declare missing external deps across workspace packages
The monorepo relied on hoisting and never declared many externals; a clean CI
install (bun, no hoisting guarantees) failed to resolve them when bundling
@hanzo/ai. Declare them on the owning packages (net-ui +49, net-artifacts +33,
net-state, net-i18n, net-message-ts, …). @hanzogui/* workspace deps untouched.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 21:36:46 +00:00
hanzo-devandClaude Opus 4.8 7087215033 build(@hanzo/ai): declare missing runtime deps (immer, zod, ethers, …)
@hanzo/ai imported ~25 externals (immer, zod, ethers, framer-motion, axios,
react-hook-form, sonner, pyodide, …) not in its package.json — a clean install
(CI) failed to resolve them (local hoisting masked it). Declare them; move
test-only deps (vitest, testing-library, react-scan) to devDependencies.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 21:25:43 +00:00
hanzo-devandClaude Opus 4.8 416d87c08f build: fix workspace build (green 163/163)
- net-i18n: useChain/@hanzo_network/chain-config -> useBrand/brand-config
  (chain-config was renamed to brand-config; this import was never updated).
- loader: drop now-unused @ts-expect-error (TS2578) in GuiPlugin.ts.
Pre-existing breakage that gated the @hanzo/ai publish.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 21:18:09 +00:00
hanzo-dev d164901c6f rename: claude.md → CLAUDE.md
Canonical casing per durable rule. Filesystem is case-insensitive
on macOS but git tracks the lowercase variant; uppercase is the
project convention.
2026-06-07 13:38:06 -07:00
hanzo-devandClaude Opus 4.8 ab3fe3d70c build: add missing per-package tsconfig.json (unblocks workspace build/publish)
10 leaf TS packages (recipes, net-brand-config, examples, *-test) lacked a
committed tsconfig.json, so the workspace build died with ENOENT on the first
one (pkgs/recipes). Add them on the standard pattern (extends root + composite).
Pre-existing breakage, gates the @hanzo/ai publish.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 20:27:45 +00:00
hanzo-devandClaude Opus 4.8 2d663eba93 refactor(brand): brand-agnostic SDK — no hardcoded brands, apps own config
- net-brand-config bundles ZERO brands: registry (registerBrands) + injection
  (setBrand/useBrand/getBrand); add company + machinesEnabled to BrandConfig.
- Brand type: drop 'hanzo'|'zoo'|'lux' union -> string (open/extensible).
- Delete hardcoded BRAND/CHAIN consts -> proxy the active brand (16 call sites
  unchanged, now read the live brand).
- isMachinesEnabled: config field, not a hardcoded brand check.
- Examples each ship their own brand.config.ts (apps do the same downstream).
- Neutral test (fixture brands, no bundled assumptions).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 19:45:16 +00:00
hanzo-devandClaude Opus 4.8 6f179ac4f2 refactor(iam): brand-neutral login hook (config-driven, PKCE, per-brand)
Replace hardcoded-hanzo useHanzoLogin (hanzo.id, placeholder clientId, shipped
client secret, wrong oauth-deep-link event) with brand-neutral useIamLogin that
reads the active brand's iam config (baseUrl/clientId/redirectUri/callbackEvent)
from @hanzo_network/brand-config and uses public-client PKCE. Adding a brand is
now a config entry; nothing is hardcoded to one brand.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 18:46:40 +00:00
hanzo-devandClaude Opus 4.8 bce9d6f602 fix(iam): real OAuth client IDs (hanzo-app/lux-app/zoo-app) for desktop login
Per-brand iam used placeholder clientIds not registered in Hanzo IAM. Domains
(hanzo.id/lux.id/zoolabs.id), redirect URIs, and callbackEvents were already
correct. Pairs with desktop redirects added to IAM init_data.json.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 18:28:36 +00:00
hanzo-devandClaude Opus 4.8 8b0e6b0d59 feat(@hanzo/ai): desktop build variant — converged frontend with full native
Add `@hanzo/ai/desktop` (vite.desktop.config.ts → dist-desktop): same converged
app as the web build, but it EXTERNALIZES @tauri-apps (14 modules) instead of
aliasing them to web shims — so a desktop consumer gets the REAL native APIs
(window/fs/dialog/updater/node-sidecar), full parity with the standalone forks.

This is the reusable foundation for converging the desktops locally: a desktop
shim is `import HanzoAI, { getBrand } from '@hanzo/ai/desktop'; <HanzoAI
{...getBrand()}/>` (VITE_BRAND selects hanzo/zoo/lux), loaded by the repo's Tauri
shell (which provides @tauri-apps + the node-manager handlers). hanzo/zoo have
full shells; lux's bespoke shell needs those handlers added.

build now emits both dist (web) + dist-desktop; exports['./desktop'] added.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 07:38:48 +00:00
hanzo-devandClaude Opus 4.8 36fc4a2fd4 fix(@hanzo/ai web): per-brand document title + favicon (was hardcoded Hanzo)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 07:15:39 +00:00
hanzo-devandClaude Opus 4.8 41901783a8 build(@hanzo/ai): make publishable — drop workspace deps, public types, access
Prep for `npm publish @hanzo/ai` (the @hanzo scope):
- Drop the `@hanzo_network/* : workspace:*` runtime deps — they're bundled into
  the self-contained dist (aliased to src at build), and workspace:* can't be
  installed by consumers. Real 3rd-party deps stay.
- `publishConfig.access = public` (scoped package).
- Ship a hand-authored, self-contained public-API `dist/index.d.ts`
  (types/public.d.ts; the build copies it). Raw tsc would emit a broken tree of
  internal/@hanzo_network imports for a bundled lib.

Verified: `npm pack --dry-run` → valid tarball with dist + index.d.ts.
Note: 14 MB (bundles streamdown→shiki's ~200 grammars); a follow-up can
externalize shiki to slim it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 06:45:23 +00:00
hanzo-devandClaude Opus 4.8 c9ecfb0df5 ci(@hanzo/ai): run the SDK tests in CI; fix libsodium path portability
- Add `test:web` to @hanzo/ai so the existing Checks `unit-tests` job
  (`turbo run test:web`) includes the 63 SDK unit/regression tests
  (verified: `turbo run test:web --filter=@hanzo/ai` → 63 passed).
- Replace the hardcoded /home/z libsodium alias in web/vite.config.ts with a
  __dirname-relative resolve so the web build works off this machine (CI).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 06:37:52 +00:00
hanzo-devandClaude Opus 4.8 1485697c4d fix(@hanzo/ai): clean the web console — host shims, CORS, auth timing, motion
Every console error/warning from the running web app, fixed and guarded by the
e2e (which now asserts the console stays clean):

- react-query "Query data cannot be undefined": web host `invoke` returns null
  (not undefined) for no-op native calls.
- "isPermissionGranted is not a function": complete the notification host shim
  (isPermissionGranted/requestPermission/sendNotification via the browser API).
- CORS on direct :36900/v1/engine/models: route the hanzo-engine client through
  a same-origin `/engine-api` vite proxy (set VITE_ENGINE_BASE_URL=/engine-api).
- "ws://localhost:1501 failed": the app derives ws port = httpPort+1; add
  local-runtime/ws-proxy.js (:1501 → node WS :3701).
- 401 /v1/node/available_models: gate useGetLLMProviders on auth — it mounted
  during onboarding and fired with an empty Bearer.
- framer-motion `motion()` deprecation → `motion.create()` (2 sites).

Tests: unit 63/63, e2e 2/2 (now also asserts no CORS / "is not a function" /
react-query-undefined / available_models / :1501 in the console). Remaining: a
rare intermittent static-asset 404 + a node-side embedding warning — neither
affects chat (documented in CHAT.md).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 06:33:23 +00:00
hanzo-devandClaude Opus 4.8 8bb89b0163 test(@hanzo/ai): vitest unit/regression + Playwright e2e + TDD setup
Establish the test harness and lock in every bug found this session as a
regression test (TDD going forward — see TESTING.md):

- vitest.config.ts: jsdom + the web build's alias graph (@/, @tauri-apps→host
  shims, @hanzo_network→src), streamdown stub, @testing-library/jest-dom setup.
  `bun run --cwd pkgs/ai test` → 61 tests green (4 new + the 7 pre-existing).
- src/__tests__/ regressions:
  • brand-store — useBrand/getBrand are plain getters callable outside render
    (the invalid-hook-call that blocked the web app)
  • host-shims — getCurrentWindow().emit etc. exist; host adapter web↔tauri
  • resizable — react-resizable-panels v3 API present + net-ui renders
  • no-missing-imports — critical screens import every <Component> they use
    (the dropped Box/Boxes/Coins lucide imports)
- e2e/chat.e2e.test.ts (vitest.e2e.config.ts): Playwright drives the real
  browser — onboarding smoke + connect→register→send→assert zen-coder reply.
  Skips cleanly when the local runtime isn't up. `test:e2e` → 2 passed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 05:38:57 +00:00
hanzo-devandClaude Opus 4.8 9b6fb6b759 feat(@hanzo/ai): chat works end-to-end in the browser (verified local)
The web app now chats with zen-coder-24b through a real hanzo-node, fully in the
browser (Playwright e2e: "Reply with exactly: E2E CHAT OK" → zen-coder generated
exactly that). Fixes uncovered by driving the real UI:

- main-layout.tsx: restore missing lucide imports Box/Boxes/Coins (the chat
  screens crashed with "Box is not defined").
- react-resizable-panels: the merge pulled v4 (Group/Separator), but the app
  uses the v3 API (PanelGroup/PanelResizeHandle) — pin ^3.0.2 in net-ui so the
  chat conversation view renders.
- web/vite.config.ts: proxy /v1 (the node's API is /v1/node/*, 135 endpoints) in
  addition to /v2 + /ws, so the browser reaches the node same-origin (no CORS).

local-runtime/ holds the reproducible inference wiring (see CHAT.md):
- responses-proxy.py: the node's OpenAI provider POSTs /v1/engine/responses, but
  hanzo-engine serves the Responses API at /v1/responses — rewrite the path
  (+ enable_thinking:false). This was the "AI Provider API Error" root cause.
- run-node.sh: a standalone /v1 node wired to the engine + embeddings. Uses
  127.0.0.1 everywhere (localhost → IPv6 ::1 → instant connection failure).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 05:03:49 +00:00
hanzo-devandClaude Opus 4.8 4d18c6d07b Merge origin/main into @hanzo/ai SDK — land the AI app on main
Reconciles the 140 upstream commits with the @hanzo/ai SDK (pkgs/ai +
pkgs/net-*, purely additive). Only bun.lock conflicted; regenerated fresh from
the merged package.json. The markdown dep skew that this exposed is fixed by
the prior commit (shiki@^3 override + dev streamdown stub).

Verified post-merge: fresh `bun install` exit 0, turbo build passes, web prod
build (rollup, 13MB) renders the Hanzo onboarding screen, dev server renders.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 00:49:52 +00:00
hanzo-devandClaude Opus 4.8 ae9c3a8e5b fix(@hanzo/ai): harden markdown deps — clean install now renders
The committed lock had a latent skew that only surfaced on a clean `bun install`
(earlier session renders used an ephemeral node_modules):

- shiki: `@vxrn/mdx` pins shiki 1.3.0 *exact*; bun hoisted it over
  `@streamdown/code`'s `shiki@^3` (no `engine/javascript` export → build fails).
  Fix: `overrides.shiki = ^3.19.0`. Deterministic; @vxrn/mdx is docs-only, not
  in the app graph. This alone makes the PROD and LIB builds (rollup) clean —
  rollup resolves the rest of the nested micromark stack correctly.

- micromark: a v1↔v4 generation skew (16 v1 consumers from the remark/MDX docs
  stack vs 1 v2 consumer = streamdown). The two APIs (util@1 subpath exports vs
  util@2 root) must coexist nested, and rollup handles it — but esbuild's DEV
  pre-bundle flattens the bare `micromark-util-symbol` to the hoisted @1
  ("Missing '.' specifier"). Fix: a DEV-ONLY (command==='serve') stub of the
  streamdown stack (streamdown-stub.tsx); the prod build uses the real packages
  so chat markdown/code/math/mermaid are unaffected in production.

Verified clean: web dev server renders the onboarding screen, web prod build
(rollup, 13MB) renders, self-contained lib dist builds.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 00:46:14 +00:00
hanzo-devandClaude Opus 4.8 b512973819 feat(@hanzo/ai): web→node proxy + chat wiring recipe
Add an env-configurable same-origin dev proxy (/v2 + /ws → VITE_NODE_API/
VITE_NODE_WS) so the browser reaches a hanzo-node without CORS: the app's
nodeAddress becomes its own origin and vite forwards to the node.

CHAT.md documents the verified state: the zen engine answers on :36900
(zen-coder-24b), and a healthy node (:2000, v1.1.20) already has the zen_engine
provider wired to it. Remaining blocker is API-version alignment — the app
client speaks /v1/node/*, the healthy node serves /v2/* only, and the
/v1-compatible desktop node (:3690) is currently hung. Recipe for both paths
included.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 00:11:54 +00:00
hanzo-devandClaude Opus 4.8 8c454dd150 docs(@hanzo/ai): CONVERGENCE.md — one surface, web⇄desktop parity, verification matrix
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 23:54:57 +00:00
hanzo-devandClaude Opus 4.8 b559401d63 feat(@hanzo/ai): self-contained dist + brand re-export → thin shims work
Make @hanzo/ai consumable as a published APP-as-SDK so each app collapses to
a ~10-line shim (the luxfi/exchange model, for AI):

- Lib build now bundles EVERYTHING (the app, net-* libs, @tauri-apps host
  shims, all 3rd-party deps) and externalizes ONLY react/react-dom as peers.
  Previously every dep was external, so a consumer installing just @hanzo/ai
  was missing libsodium/framer-motion/radix/etc. (pinned libsodium's CJS build
  in the lib config too, now that it's bundled rather than external).
- Re-export getBrand/getBrandFromHostname from the package, so a shim needs no
  separate brand package for the default case:
    import HanzoAI, { getBrand } from '@hanzo/ai'
- Shims (hanzo/zoo/lux/web) aligned to the proven pattern: brand is a build
  axis (VITE_BRAND), platform an injected prop (host={tauriHost}), app is
  @hanzo/ai. Added a runnable web shim (index.html + vite.config) that imports
  @hanzo/ai from its built dist providing only react.

Verified: the external web shim (only react/react-dom provided, everything
else from dist) renders the full Hanzo onboarding screen on :1503.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 23:54:10 +00:00
hanzo-devandClaude Opus 4.8 699e8217a8 fix(@hanzo/ai): web app renders — break the dist self-import cycle
The web app threw `Cannot read properties of null (reading 'useContext')`
(invalid hook call) and never painted. Root cause was NOT React duplication
from a dep: 7 migrated app modules imported `useBrand` from the `@hanzo/ai`
PACKAGE name, which resolves to the built `dist/index.js`. That pulled the
whole minified library (a second React + a second copy of the app) back into
the source graph — a module cycle with a dual React dispatcher.

Fix: the injectable brand store lives in `@hanzo_network/brand-config`
(`setBrand`/`useBrand`, plain module getters — not hooks — so the app's
non-component call sites are safe), aliased to src so there is ONE copy and
no cycle. The 7 app files now import `useBrand` from there; `@hanzo/ai`'s
`brand-context` just re-exports it. Rule: app code never imports the
`@hanzo/ai` package by name.

Also completed `src/host/window.ts` to the full Tauri Window surface
(`emit`/`listen`/`once`/`onCloseRequested`/geometry) — the missing `emit`
was the next crash after the cycle was broken. Production web entry cleaned
up with a real ErrorBoundary.

Verified: dev server on :1500 paints the real Hanzo onboarding screen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 23:46:13 +00:00
hanzo-devandClaude Opus 4.8 1d818ebb88 feat(@hanzo/ai): decomplect the AI app into one declarative SDK
The entire shinkai-fork AI app (280 files) + its 7 @hanzo_network/* libs,
lifted into @hanzo/ai as <HanzoAI {...brand} host={}/> — brand is a prop,
platform is an injected HostAdapter (web / tauri / expo). One unified surface
for hanzo/zoo/lux desktop + mobile + web (hanzo.chat). Library build green
(2.8MB) and production web build green (14MB). Shims in examples/.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 22:31:02 +00:00
1237 changed files with 1488188 additions and 22919 deletions
-5
View File
@@ -1,5 +0,0 @@
---
"@hanzogui/shell": patch
---
Tight app-first shell defaults: 14px base type scale (linear.app/vercel register, still CSS-var overridable), H-only header lockup (drop product wordmark), and Community hub (remove Showcase; Community → hanzo.app/community).
-9
View File
@@ -1,9 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1280" height="640" viewBox="0 0 1280 640" role="img" aria-label="gui">
<rect width="1280" height="640" fill="#0A0A0A"/>
<svg x="96" y="215" width="210" height="210" viewBox="0 0 67 67"><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>
<text x="378" y="276" font-family="Inter,system-ui,-apple-system,sans-serif" font-size="78" font-weight="800" letter-spacing="-2" fill="#ffffff">gui</text>
<text x="378" y="322" font-family="Inter,system-ui,sans-serif" font-size="30" fill="#ffffff" opacity=".66">Hanzo GUI — cross-platform UI primitives, tenant shell…</text>
<rect x="378" y="338" width="806" height="3" rx="1.5" fill="#ffffff" opacity=".9"/>
<text x="378" y="390" font-family="Inter,system-ui,sans-serif" font-size="24" font-weight="600" fill="#ffffff" opacity=".5">github.com/hanzoai</text>
<text x="1184" y="390" text-anchor="end" font-family="Inter,system-ui,sans-serif" font-size="24" font-weight="600" fill="#ffffff" opacity=".5">hanzo.ai</text>
</svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

@@ -0,0 +1,285 @@
name: Build iOS Test Container App
env:
# Relative path from the monorepo root to the app
test_container_app_path: code/kitchen-sink
# Package name in the monorepo
test_app_package_name: '@hanzogui/kitchen-sink'
# Should match the name of "ios/<app_name>.xcworkspace"
ios_app_name: guikitchensink
# A unique ID to identify the app on GitHub Actions, this is used as part of cache keys and artifact names, must be unique among all workflows
app_id: ios-guikitchensink
# The command used to prebuild the app
prebuild_command: bunx expo prebuild --platform ios --no-install # --no-install is used to skip installing dependencies, specifically `pod install` as we want to do it after the Cache Pods step
# These should be set in the repository secrets
# Redis database used for caching and remembering things between runs, such as the last build number
# KV_STORE_REDIS_REST_URL:
# KV_STORE_REDIS_REST_TOKEN:
on:
workflow_call:
inputs:
configuration:
required: true
type: string
description: "Either 'Debug' or 'Release'."
outputs:
build-hash:
description: 'A hash to identify the build (expo fingerprint).'
value: ${{ jobs.build-ios.outputs.build-hash }}
built-app-cache-key:
description: 'The GitHub Actions cache key of the built .app, can be used in subsequent workflows to get the built app from cache using this key.'
value: ${{ jobs.build-ios.outputs.built-app-cache-key }}
built-app-path:
description: 'The path to the built .app relative to the repository root.'
value: ${{ jobs.build-ios.outputs.built-app-path }}
jobs:
build-ios:
name: Build
runs-on: macos-15
permissions:
contents: read
pull-requests: read
timeout-minutes: 60
outputs:
built-app-cache-key: ${{ steps.check-has-build.outputs.cache-primary-key || steps.pre-check-has-build.outputs.cache-primary-key }}
build-hash: ${{ steps.calculate-fingerprint.outputs.fingerprint || steps.get-fingerprint-from-cache.outputs.fingerprint }}
built-app-path: ${{ steps.get-built-app-path.outputs.built_app_path }}
defaults:
run:
working-directory: ${{ env.test_container_app_path }}
steps:
- name: Checkout
uses: actions/checkout@v5
with:
fetch-depth: 1
- name: Set Xcode version
# Xcode 26 (Swift 6.2) is required for expo-modules-core 55.x which uses
# conformance isolation syntax (@MainActor in protocol inheritance clause, SE-0434)
run: sudo xcode-select -s /Applications/Xcode_26.0.1.app
- name: Get Built App Path
id: get-built-app-path
env:
BUILT_APP_PATH: ${{ env.test_container_app_path }}/build/Build/Products/${{ inputs.configuration }}-iphonesimulator/${{ env.ios_app_name }}.app
run: |
echo "Built app path: $BUILT_APP_PATH"
echo "built_app_path=$BUILT_APP_PATH" >> $GITHUB_OUTPUT
- name: Calculate Pre-Fingerprint Hash
id: calculate-pre-fingerprint
env:
# A hash that represents bun.lock + app.json - if these haven't changed,
# the fingerprint likely hasn't changed either. This lets us skip bun install
# and prebuild if we have a cached fingerprint for this pre-fingerprint hash.
PRE_FINGERPRINT_HASH: ${{ hashFiles('bun.lock', format('{0}/app.json', env.test_container_app_path), format('{0}/package.json', env.test_container_app_path), 'packages/vxrn/expo-plugin.cjs') }}
run: |
if [ -z "$PRE_FINGERPRINT_HASH" ]; then
echo '[ERROR] Failed to calculate pre-fingerprint hash.'
fi
echo "Pre-fingerprint hash: $PRE_FINGERPRINT_HASH"
echo "pre_fingerprint_hash=$PRE_FINGERPRINT_HASH" >> $GITHUB_OUTPUT
- name: Read Cached Fingerprint
id: get-fingerprint-from-cache
env:
KV_STORE_REDIS_REST_URL: ${{ secrets.KV_STORE_REDIS_REST_URL }}
KV_STORE_REDIS_REST_TOKEN: ${{ secrets.KV_STORE_REDIS_REST_TOKEN }}
run: |
FINGERPRINT_FROM_CACHE=$(curl "$KV_STORE_REDIS_REST_URL/get/${{ env.app_id }}-fingerprint-from-pre-hash-${{ steps.calculate-pre-fingerprint.outputs.pre_fingerprint_hash }}" -H "Authorization: Bearer $KV_STORE_REDIS_REST_TOKEN" | jq -r '.result')
if [ "$FINGERPRINT_FROM_CACHE" != "null" ]; then
curl -X POST "$KV_STORE_REDIS_REST_URL/EXPIRE/${{ env.app_id }}-fingerprint-from-pre-hash-${{ steps.calculate-pre-fingerprint.outputs.pre_fingerprint_hash }}/2592000" -H "Authorization: Bearer $KV_STORE_REDIS_REST_TOKEN" # Reset TTL to 30 days
echo "Fingerprint from cache: $FINGERPRINT_FROM_CACHE"
echo "fingerprint=$FINGERPRINT_FROM_CACHE" >> $GITHUB_OUTPUT
else
echo 'No cached fingerprint found.'
echo "fingerprint=null" >> $GITHUB_OUTPUT
fi
- name: Check If Build Already Exists
uses: actions/cache/restore@v5
id: pre-check-has-build
if: ${{ steps.get-fingerprint-from-cache.outputs.fingerprint != 'null' }}
with:
key: ${{ env.app_id }}-${{ inputs.configuration }}-${{ steps.get-fingerprint-from-cache.outputs.fingerprint }}
lookup-only: true
path: ${{ steps.get-built-app-path.outputs.built_app_path }}
# The steps below are skipped if we have a cache hit
- name: Install
if: ${{ !steps.pre-check-has-build.outputs.cache-hit }}
uses: ./.github/actions/install
with:
workspace-focus: ${{ env.test_app_package_name }}
- name: Prebuild
if: ${{ !steps.pre-check-has-build.outputs.cache-hit }}
run: ${{ env.prebuild_command }}
# workaround for https://github.com/expo/expo/issues/42525
# remove once expo-modules-core ships a fix or expo SDK 56+ resolves it
- name: Patch Podfile for ExpoModulesCore Swift 6 and ContextMenuAuxiliaryPreview
if: ${{ !steps.pre-check-has-build.outputs.cache-hit }}
run: |
# workaround: expo-modules-core 55.x uses @MainActor in class inheritance
# clauses which requires Swift 6 mode. SWIFT_STRICT_CONCURRENCY=minimal
# suppresses concurrency warnings in swift 6 mode.
# workaround 2: ContextMenuAuxiliaryPreview uses deprecated transform: .default
# which is an error in Xcode 26 / Swift 6.2 - disable warnings-as-errors for it.
PODFILE="ios/Podfile"
if grep -q "ContextMenuAuxiliaryPreview" "$PODFILE" 2>/dev/null; then
echo "Podfile already patched (both ExpoModulesCore + ContextMenuAuxiliaryPreview), skipping"
elif [ -f "$PODFILE" ]; then
node -e "
const fs = require('fs');
const podfile = fs.readFileSync('$PODFILE', 'utf8');
const workaround = \`
# workaround: expo-modules-core 55.x requires Swift 6 mode with isolated
# conformances (SE-0470) for @MainActor in protocol conformance syntax.
installer.pods_project.targets.each do |target|
if target.name == 'ExpoModulesCore'
target.build_configurations.each do |build_config|
build_config.build_settings['SWIFT_VERSION'] = '6'
build_config.build_settings['SWIFT_STRICT_CONCURRENCY'] = 'minimal'
flags = build_config.build_settings['OTHER_SWIFT_FLAGS'] || '$(inherited)'
unless flags.include?('IsolatedConformances')
build_config.build_settings['OTHER_SWIFT_FLAGS'] = "\#{flags} -enable-upcoming-feature IsolatedConformances"
end
end
end
# workaround: ContextMenuAuxiliaryPreview uses deprecated transform: .default
# which becomes a build error in Xcode 26 / Swift 6.2 strict mode.
if target.name == 'ContextMenuAuxiliaryPreview'
target.build_configurations.each do |build_config|
build_config.build_settings['SWIFT_TREAT_WARNINGS_AS_ERRORS'] = 'NO'
build_config.build_settings['GCC_TREAT_WARNINGS_AS_ERRORS'] = 'NO'
end
end
end
\`;
const updated = podfile.replace(
/(post_install do \|installer\|.*?)(^\s+end\s*\nend)/ms,
'\$1' + workaround + '\$2'
);
if (updated === podfile) {
console.error('ERROR: could not find post_install block in Podfile');
process.exit(1);
}
fs.writeFileSync('$PODFILE', updated);
console.log('Patched Podfile with ExpoModulesCore Swift 6 and ContextMenuAuxiliaryPreview workarounds');
"
else
echo "WARNING: Podfile not found at $PODFILE"
fi
- name: Cache Pods
if: ${{ !steps.pre-check-has-build.outputs.cache-hit }}
uses: actions/cache@v5
env:
cache-name: ${{ env.app_id }}-pods
with:
path: ${{ env.test_container_app_path }}/ios/Pods
key: ${{ runner.os }}-${{ env.cache-name }}-${{ hashFiles(format('{0}/ios/Podfile', env.test_container_app_path)) }}
restore-keys: |
${{ runner.os }}-${{ env.cache-name }}-
- name: Pod Install
if: ${{ !steps.pre-check-has-build.outputs.cache-hit }}
run: |
set -o pipefail
pod install --project-directory=ios 2>&1 | tee pod-install.log || {
echo "::error::Pod install failed. Check pod-install.log artifact for details."
echo "=== Last 50 lines of pod install output ==="
tail -50 pod-install.log
exit 1
}
- name: Upload Pod Install Log on Failure
if: failure()
uses: actions/upload-artifact@v5
with:
name: pod-install-log
path: ${{ env.test_container_app_path }}/pod-install.log
retention-days: 7
- name: Calculate Fingerprint
if: ${{ !steps.pre-check-has-build.outputs.cache-hit }}
id: calculate-fingerprint
run: |
# Use expo fingerprint for accurate native dependency detection
FINGERPRINT=$(bunx @expo/fingerprint fingerprint:generate --platform ios | jq -r '.hash')
if [ -z "$FINGERPRINT" ]; then
echo '[ERROR] Failed to calculate fingerprint.'
exit 1
fi
echo "Fingerprint: $FINGERPRINT"
echo "fingerprint=$FINGERPRINT" >> $GITHUB_OUTPUT
- name: Write Fingerprint to Cache
if: ${{ !steps.pre-check-has-build.outputs.cache-hit }}
env:
KV_STORE_REDIS_REST_URL: ${{ secrets.KV_STORE_REDIS_REST_URL }}
KV_STORE_REDIS_REST_TOKEN: ${{ secrets.KV_STORE_REDIS_REST_TOKEN }}
run: |
curl -X POST "$KV_STORE_REDIS_REST_URL/SETEX/${{ env.app_id }}-fingerprint-from-pre-hash-${{ steps.calculate-pre-fingerprint.outputs.pre_fingerprint_hash }}/2592000/${{ steps.calculate-fingerprint.outputs.fingerprint }}" -H "Authorization: Bearer $KV_STORE_REDIS_REST_TOKEN"
- name: Check If Build Already Exists
if: ${{ !steps.pre-check-has-build.outputs.cache-hit }}
uses: actions/cache/restore@v5
id: check-has-build
with:
key: ${{ env.app_id }}-${{ inputs.configuration }}-${{ steps.calculate-fingerprint.outputs.fingerprint }}
lookup-only: true
path: ${{ steps.get-built-app-path.outputs.built_app_path }}
- name: Restore Build Cache
if: ${{ !steps.pre-check-has-build.outputs.cache-hit && !steps.check-has-build.outputs.cache-hit }}
id: restore-build-cache
uses: actions/cache/restore@v5
env:
cache-name: ${{ env.app_id }}-build
with:
key: ${{ runner.os }}-${{ env.cache-name }}
path: |
${{ env.test_container_app_path }}/build
- name: Build
if: ${{ !steps.pre-check-has-build.outputs.cache-hit && !steps.check-has-build.outputs.cache-hit }}
run: |
set -o pipefail
xcrun xcodebuild -scheme '${{ env.ios_app_name }}' \
-workspace 'ios/${{ env.ios_app_name }}.xcworkspace' \
-configuration ${{ inputs.configuration }} \
-sdk 'iphonesimulator' \
-destination 'generic/platform=iOS Simulator' \
-derivedDataPath build \
OTHER_SWIFT_FLAGS='$(inherited) -enable-upcoming-feature IsolatedConformances' \
SWIFT_STRICT_CONCURRENCY=minimal \
RCT_REMOVE_LEGACY_ARCH=1 \
| tee xcodebuild.log | xcpretty
- name: Upload Built App to Cache
if: ${{ !steps.pre-check-has-build.outputs.cache-hit && !steps.check-has-build.outputs.cache-hit }}
uses: actions/cache/save@v5
with:
key: ${{ steps.check-has-build.outputs.cache-primary-key }}
path: ${{ steps.get-built-app-path.outputs.built_app_path }}
- name: Upload Build Log
uses: actions/upload-artifact@v5.3.1
if: ${{ always() && !steps.pre-check-has-build.outputs.cache-hit && !steps.check-has-build.outputs.cache-hit }}
with:
name: xcodebuild-${{ env.app_id }}-${{ inputs.configuration }}.log
path: |
${{ env.test_container_app_path }}/xcodebuild.log
- name: Save Build Cache
uses: actions/cache/save@v5
if: ${{ always() && !steps.pre-check-has-build.outputs.cache-hit && !steps.check-has-build.outputs.cache-hit }}
with:
key: ${{ steps.restore-build-cache.outputs.cache-primary-key }}
path: |
${{ env.test_container_app_path }}/build
+18
View File
@@ -0,0 +1,18 @@
name: Changelog
on:
push:
tags:
- 'v*'
jobs:
tagged-release:
name: Changelog
runs-on: [self-hosted, linux, amd64]
# needs: release
steps:
- uses: marvinpinto/action-automatic-releases@latest
with:
repo_token: '${{ github.token }}'
prerelease: false
+124
View File
@@ -0,0 +1,124 @@
name: Checks
on:
workflow_dispatch:
pull_request:
push:
paths-ignore:
- 'assets/**'
- '.vscode/**'
branches:
- main
- v2
- 'v2-*'
# cancel in-progress runs on the same branch (avoids duplicate PR + push runs)
concurrency:
group: checks-${{ github.ref }}
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
jobs:
checks:
runs-on: [self-hosted, linux, amd64]
steps:
- name: Checkout
uses: actions/checkout@v5
with:
fetch-depth: 1
- name: Install
uses: ./.github/actions/install
- name: Check
run: bun run check
- name: Lint
run: bun run lint
unit-tests:
runs-on: [self-hosted, linux, amd64]
env:
NODE_OPTIONS: '--max-old-space-size=6144'
steps:
- name: Checkout
uses: actions/checkout@v5
with:
fetch-depth: 1
- name: Install
uses: ./.github/actions/install
- name: Get Playwright version
id: pw-version
run: echo "version=$(bunx playwright --version | awk '{print $2}')" >> $GITHUB_OUTPUT
- name: Cache Playwright browsers
uses: actions/cache@v5
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ steps.pw-version.outputs.version }}
- name: Install playwright
run: bunx playwright install
- name: Run unit tests
run: bun turbo run test:web --filter='!@hanzogui/kitchen-sink' --concurrency=1
changes:
runs-on: [self-hosted, linux, amd64]
outputs:
integration-relevant: ${{ steps.filter.outputs.integration-relevant }}
steps:
- uses: actions/checkout@v5
- uses: dorny/paths-filter@v3
id: filter
with:
filters: |
integration-relevant:
- 'code/core/**'
- 'code/ui/**'
- 'code/packages/**'
- 'code/kitchen-sink/**'
- 'code/compiler/babel-plugin/**'
- 'code/compiler/loader/**'
- 'code/compiler/metro-plugin/**'
- 'code/demos/**'
- 'package.json'
- 'bun.lock'
integration-tests:
needs: changes
if: needs.changes.outputs.integration-relevant == 'true'
runs-on: [self-hosted, linux, amd64]
strategy:
fail-fast: false
matrix:
shard: [1/3, 2/3, 3/3]
env:
NODE_OPTIONS: '--max-old-space-size=6144'
steps:
- name: Checkout
uses: actions/checkout@v5
with:
fetch-depth: 1
- name: Install
uses: ./.github/actions/install
- name: Get Playwright version
id: pw-version
run: echo "version=$(bunx playwright --version | awk '{print $2}')" >> $GITHUB_OUTPUT
- name: Cache Playwright browsers
uses: actions/cache@v5
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ steps.pw-version.outputs.version }}
- name: Install playwright
run: bunx playwright install --with-deps
- name: Run integration tests (shard ${{ matrix.shard }})
run: cd code/kitchen-sink && NODE_ENV=test npx playwright test --shard=${{ matrix.shard }}
timeout-minutes: 20
@@ -34,10 +34,7 @@ permissions:
jobs:
publish-all:
name: Build + publish all gui packages
# Retargeted arm64 self-hosted (spark, frequently offline) → the always-up
# in-cluster amd64 ARC pool. @hanzogui/* are pure-JS; publishing is
# arch-independent, so amd64 is safe and far more reliable.
runs-on: hanzo-build-linux-amd64
runs-on: [self-hosted, linux, arm64]
if: github.event_name == 'push' || github.event.inputs.confirm == 'publish'
steps:
- uses: actions/checkout@v5
@@ -103,7 +100,7 @@ jobs:
# Targeted publish of just the @hanzogui/* closure + hanzogui +
# @hanzo/gui, at the current on-disk version. Avoids release.ts's
# whole-workspace fan-out (which fails packing unrelated
# unrelated workspace packages) and its fragile tarball-rename step.
# @hanzo_network/* packages) and its fragile tarball-rename step.
# Idempotent — skips any name@version already on npm.
env:
NPM_TOKEN: ${{ steps.token.outputs.npm_token }}
@@ -28,9 +28,7 @@ permissions:
jobs:
publish:
name: Build, smoke-test, publish
# Retargeted arm64 self-hosted (spark, frequently offline) → the always-up
# in-cluster amd64 ARC pool. Pure-JS publish is arch-independent.
runs-on: hanzo-build-linux-amd64
runs-on: [self-hosted, linux, arm64]
steps:
- uses: actions/checkout@v5
with:
+46
View File
@@ -0,0 +1,46 @@
name: Release
# testing on a branch for now
on:
push:
branches:
- test-release
jobs:
release:
name: Release
runs-on: [self-hosted, linux, amd64]
steps:
- name: Checkout Repo
uses: actions/checkout@v5
with:
# Full history needed for git tag and push
fetch-depth: 0
token: ${{ secrets.PAT }}
- name: Install
uses: ./.github/actions/install
- name: Install playwright
run: bunx playwright install
- name: Test
run: bun run test
- name: Publish
# just testing for now so --skip-publish
run: bun run release:beta:dirty --ci --gui-git-user --skip-publish
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
tagged-release:
name: Tagged Release
runs-on: [self-hosted, linux, amd64]
# needs: release
steps:
- uses: marvinpinto/action-automatic-releases@latest
with:
repo_token: '${{ github.token }}'
prerelease: false
@@ -0,0 +1,137 @@
name: Test iOS Kitchen Sink Go (Maestro)
on:
push:
branches: [main, 'v2-*']
pull_request:
branches: [main, 'v*']
workflow_dispatch:
env:
test_container_app_path: code/kitchen-sink-go
jobs:
test:
# TODO: re-enable once Sheet demo is fixed on Expo Go CI
if: false
name: Run Maestro Tests (exports=${{ matrix.package-exports }})
runs-on: macos-14
timeout-minutes: 30
strategy:
fail-fast: false
matrix:
package-exports: [true, false]
defaults:
run:
working-directory: ${{ env.test_container_app_path }}
steps:
- name: Checkout
uses: actions/checkout@v5
with:
fetch-depth: 1
- name: Set Xcode version
run: sudo xcode-select -s /Applications/Xcode_16.2.app
- name: Install Maestro
env:
MAESTRO_VERSION: cli-2.3.0
run: |
curl -fsSL "https://github.com/mobile-dev-inc/maestro/releases/download/${MAESTRO_VERSION}/maestro.zip" -o maestro.zip
unzip -q maestro.zip
mv maestro "$HOME/.maestro"
echo "$HOME/.maestro/bin" >> $GITHUB_PATH
- name: Install Dependencies
uses: ./.github/actions/install
- name: Boot iOS Simulator
working-directory: ${{ github.workspace }}
run: |
DEVICE_ID=$(xcrun simctl list devices available -j | jq -r '.devices | to_entries[] | select(.key | contains("iOS")) | .value[] | select(.name == "iPhone 16") | .udid' | head -1)
if [ -z "$DEVICE_ID" ]; then
echo "iPhone 16 not found, using first available iPhone"
DEVICE_ID=$(xcrun simctl list devices available -j | jq -r '.devices | to_entries[] | select(.key | contains("iOS")) | .value[] | select(.name | contains("iPhone")) | .udid' | head -1)
fi
echo "Booting simulator: $DEVICE_ID"
xcrun simctl boot "$DEVICE_ID" || true
xcrun simctl bootstatus "$DEVICE_ID" -b
echo "Waiting for simulator to be fully ready for Maestro..."
for i in {1..30}; do
if $HOME/.maestro/bin/maestro --device "$DEVICE_ID" test --help > /dev/null 2>&1; then
echo "Maestro can communicate with device"
break
fi
echo "Waiting for Maestro device detection... ($i/30)"
sleep 2
done
sleep 5
- name: Install Expo Go
run: |
# Install Expo Go on simulator (no custom build needed)
bunx expo start --ios &
sleep 30
# Kill expo after it installs Expo Go
kill %1 || true
- name: Start Metro Bundler
run: |
EXPO_NO_TELEMETRY=true HANZO_GUI_PACKAGE_EXPORTS=${{ matrix.package-exports }} bunx expo start --offline &
echo "Waiting for Metro to start..."
for i in {1..30}; do
if curl -s "http://127.0.0.1:8081/" -H "Expo-Platform: ios" > /dev/null 2>&1; then
echo "Metro is responding!"
break
fi
echo "Waiting for Metro... ($i/30)"
sleep 2
done
- name: Pre-warm Bundle
run: |
echo "Pre-warming bundle..."
MANIFEST=$(curl -s "http://127.0.0.1:8081/" -H "Expo-Platform: ios")
BUNDLE_URL=$(echo "$MANIFEST" | jq -r '.launchAsset.url')
echo "Fetching bundle from: $BUNDLE_URL"
curl -s --max-time 900 "$BUNDLE_URL" > /dev/null 2>&1 || echo "Bundle fetch completed (or timed out)"
echo "Bundle pre-warm complete"
- name: Verify Maestro Device Connection
run: |
echo "Checking Maestro device connection..."
for i in {1..10}; do
if xcrun simctl list devices booted | grep -q "Booted"; then
echo "Simulator is booted"
sleep 3
break
fi
echo "Waiting for device... ($i/10)"
sleep 3
done
xcrun simctl list devices booted
- name: Run Maestro Tests
run: |
for attempt in 1 2; do
echo "Test attempt $attempt of 2..."
if maestro test ./flows --exclude-tags=util --no-ansi; then
echo "Tests passed!"
exit 0
fi
if [ $attempt -lt 2 ]; then
echo "Attempt $attempt failed, retrying..."
sleep 10
fi
done
echo "All attempts failed"
exit 1
- name: Upload Maestro Artifacts on Failure
if: failure()
uses: actions/upload-artifact@v5
with:
name: maestro-artifacts-exports-${{ matrix.package-exports }}
path: |
~/.maestro/tests/**/*
+186
View File
@@ -0,0 +1,186 @@
name: Test iOS Native (Maestro)
on:
push:
branches: [main, 'v2-*', rn82]
pull_request:
branches: [main, 'v*']
workflow_dispatch:
env:
test_container_app_path: code/kitchen-sink
MAESTRO_DRIVER_STARTUP_TIMEOUT: 120000
jobs:
build:
uses: ./.github/workflows/build-ios-kitchensink-app.yml
with:
configuration: Debug
secrets: inherit
test:
name: Run Maestro Tests (exports=${{ matrix.package-exports }})
needs: build
runs-on: macos-15
timeout-minutes: 45
strategy:
fail-fast: false
matrix:
package-exports: [true, false]
defaults:
run:
working-directory: ${{ env.test_container_app_path }}
steps:
- name: Checkout
uses: actions/checkout@v5
with:
fetch-depth: 1
- name: Set Xcode version
run: sudo xcode-select -s /Applications/Xcode_26.0.1.app
- name: Restore Built App from Cache
id: restore-app-cache
uses: actions/cache/restore@v5
with:
key: ${{ needs.build.outputs.built-app-cache-key }}
path: ${{ needs.build.outputs.built-app-path }}
- name: Check Cache Restore
if: steps.restore-app-cache.outputs.cache-hit != 'true'
run: |
echo "::warning::iOS cache not restored (key: ${{ needs.build.outputs.built-app-cache-key }}). This may happen on first run after cache key changes."
echo "SKIP_MAESTRO_TESTS=true" >> $GITHUB_ENV
- name: Install Maestro
if: env.SKIP_MAESTRO_TESTS != 'true'
env:
MAESTRO_VERSION: cli-2.3.0
run: |
# Pin to specific version via GitHub releases (avoids broken get.maestro.mobile.dev)
curl -fsSL "https://github.com/mobile-dev-inc/maestro/releases/download/${MAESTRO_VERSION}/maestro.zip" -o maestro.zip
unzip -q maestro.zip
mv maestro "$HOME/.maestro"
echo "$HOME/.maestro/bin" >> $GITHUB_PATH
# Install dependencies BEFORE booting simulator to avoid simulator timeout
# during potentially long bun install + build:js steps
- name: Install Dependencies
if: env.SKIP_MAESTRO_TESTS != 'true'
uses: ./.github/actions/install
- name: Boot Simulator & Start Metro (parallel)
if: env.SKIP_MAESTRO_TESTS != 'true'
working-directory: ${{ github.workspace }}
run: |
# start Metro in background while simulator boots
cd "${{ env.test_container_app_path }}"
EXPO_NO_TELEMETRY=true HANZO_GUI_PACKAGE_EXPORTS=${{ matrix.package-exports }} bunx expo start --dev-client --offline --reset-cache &
cd "${{ github.workspace }}"
# boot simulator
DEVICE_ID=$(xcrun simctl list devices available -j | jq -r '.devices | to_entries[] | select(.key | contains("iOS")) | .value[] | select(.name == "iPhone 16") | .udid' | head -1)
if [ -z "$DEVICE_ID" ]; then
echo "iPhone 16 not found, using first available iPhone"
DEVICE_ID=$(xcrun simctl list devices available -j | jq -r '.devices | to_entries[] | select(.key | contains("iOS")) | .value[] | select(.name | contains("iPhone")) | .udid' | head -1)
fi
echo "Booting simulator: $DEVICE_ID"
xcrun simctl boot "$DEVICE_ID" || true
xcrun simctl bootstatus "$DEVICE_ID" -b
# install app while waiting for Metro
xcrun simctl install booted "${{ needs.build.outputs.built-app-path }}"
# wait for Metro to be ready
echo "Waiting for Metro..."
for i in {1..60}; do
if curl -s "http://127.0.0.1:8081/" -H "Expo-Platform: ios" > /dev/null 2>&1; then
echo "Metro is responding!"
break
fi
sleep 2
done
- name: Pre-warm Bundle
if: env.SKIP_MAESTRO_TESTS != 'true'
run: |
echo "Pre-warming JS bundle (Metro build + cache)..."
MANIFEST=$(curl -s "http://127.0.0.1:8081/" -H "Expo-Platform: ios")
BUNDLE_URL=$(echo "$MANIFEST" | jq -r '.launchAsset.url')
echo "Fetching bundle from: $BUNDLE_URL"
curl -s --max-time 900 "$BUNDLE_URL" > /dev/null 2>&1 || echo "Bundle fetch completed (or timed out)"
echo "Bundle pre-warm complete"
- name: Start Simulator Log Collection
if: env.SKIP_MAESTRO_TESTS != 'true'
run: |
# capture simulator device logs in background for debugging app crashes
xcrun simctl spawn booted log stream --predicate 'process == "guikitchensink" OR subsystem == "com.gui.guikitchensink"' --level debug > /tmp/simulator-app.log 2>&1 &
echo $! > /tmp/simlog-pid
# also capture broader crash/error logs
xcrun simctl spawn booted log stream --predicate 'eventMessage CONTAINS "crash" OR eventMessage CONTAINS "fatal" OR eventMessage CONTAINS "error" OR process == "ReportCrash"' --level error > /tmp/simulator-errors.log 2>&1 &
echo $! > /tmp/simlog-errors-pid
- name: Warm up App (cold start + Hermes bytecode compile)
if: env.SKIP_MAESTRO_TESTS != 'true'
run: |
# WarmUp.yaml does clearState:true for a clean first launch.
# Subsequent test flows use OpenApp.yaml (no clearState) so Hermes
# bytecode cache persists across launches = much faster restarts.
maestro test ./flows/WarmUp.yaml --no-ansi || true
- name: Run Maestro Tests
if: env.SKIP_MAESTRO_TESTS != 'true'
run: |
# Run tests with one retry for flaky simulator detection
for attempt in 1 2; do
echo "Test attempt $attempt of 2..."
if maestro test ./flows --exclude-tags=util --no-ansi; then
echo "Tests passed!"
exit 0
fi
if [ $attempt -lt 2 ]; then
echo "Attempt $attempt failed, retrying..."
sleep 10
fi
done
echo "All attempts failed"
exit 1
- name: Dump App Logs on Failure
if: failure() && env.SKIP_MAESTRO_TESTS != 'true'
run: |
# stop log collection
kill "$(cat /tmp/simlog-pid 2>/dev/null)" 2>/dev/null || true
kill "$(cat /tmp/simlog-errors-pid 2>/dev/null)" 2>/dev/null || true
echo "::group::Simulator App Logs (last 200 lines)"
tail -200 /tmp/simulator-app.log 2>/dev/null || echo "No app logs captured"
echo "::endgroup::"
echo "::group::Simulator Error Logs (last 100 lines)"
tail -100 /tmp/simulator-errors.log 2>/dev/null || echo "No error logs captured"
echo "::endgroup::"
echo "::group::Metro Bundler Output"
# show any metro errors from the background process
ps aux | grep -i metro | grep -v grep || echo "Metro process not found"
echo "::endgroup::"
echo "::group::Crash Reports"
find ~/Library/Logs/DiagnosticReports -name "*guikitchensink*" -newer /tmp/simulator-app.log 2>/dev/null | while read f; do
echo "=== $f ==="
head -50 "$f"
done || echo "No crash reports found"
echo "::endgroup::"
- name: Report Test Status
if: always() && env.SKIP_MAESTRO_TESTS == 'true'
run: |
echo "::warning::Maestro tests skipped - cache was not available (will be available on next run)"
- name: Upload Maestro Artifacts on Failure
if: failure() && env.SKIP_MAESTRO_TESTS != 'true'
uses: actions/upload-artifact@v5
with:
name: maestro-artifacts-exports-${{ matrix.package-exports }}
path: |
~/.maestro/tests/**/*
/tmp/simulator-app.log
/tmp/simulator-errors.log
+573
View File
@@ -0,0 +1,573 @@
name: Native Tests (Detox)
on:
push:
branches:
- main
- 'native-*'
- 'v*'
- rn82
workflow_dispatch:
# cancel in-progress runs on feature branches, but let main finish for full history
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
env:
test_container_app_path: code/kitchen-sink
# Increment this to force a fresh Android build (invalidates Redis KV cache)
android_cache_version: v4
jobs:
# ─────────────────────────────────────────────────────────────────────────────
# iOS Build & Test
# ─────────────────────────────────────────────────────────────────────────────
build-ios:
name: Build iOS App
uses: ./.github/workflows/build-ios-kitchensink-app.yml
with:
configuration: Debug
secrets: inherit
test-ios:
name: iOS Detox Tests (${{ matrix.shard_name }})
needs: build-ios
runs-on: macos-15
timeout-minutes: 45
strategy:
fail-fast: false
matrix:
include:
- shard_name: '1/2'
test_files: 'e2e/check-rngh-status.test.ts e2e/GroupPressNative.test.ts e2e/MediaQueryGtMd.test.ts e2e/NativePortal.test.ts e2e/PointerEvents.test.ts e2e/PressStyleNative.noRngh.test.ts e2e/PressStyleNative.test.ts e2e/SafeArea.test.ts'
- shard_name: '2/2'
test_files: 'e2e/CompilerExtraction.test.ts e2e/SelectAndroidOnPress.test.ts e2e/SelectRemount.test.ts e2e/SheetDragResist.test.ts e2e/SheetKeyboardDrag.test.ts e2e/SheetScrollableDrag.test.ts e2e/ShorthandVariables.test.ts e2e/ThemeChangeBasic.test.ts e2e/ThemeMutation.test.ts'
defaults:
run:
working-directory: ${{ env.test_container_app_path }}
steps:
- name: Checkout
uses: actions/checkout@v5
with:
fetch-depth: 1
- name: Set Xcode version
run: sudo xcode-select -s /Applications/Xcode_26.0.1.app
- name: Restore Built App from Cache
id: restore-ios-cache
uses: actions/cache/restore@v5
with:
key: ${{ needs.build-ios.outputs.built-app-cache-key }}
path: ${{ needs.build-ios.outputs.built-app-path }}
- name: Check iOS Cache Restore
if: steps.restore-ios-cache.outputs.cache-hit != 'true'
run: |
echo "::warning::iOS cache not restored (key: ${{ needs.build-ios.outputs.built-app-cache-key }}). This may happen on first run after cache key changes."
echo "SKIP_IOS_TESTS=true" >> $GITHUB_ENV
- name: Boot iOS Simulator (background)
if: env.SKIP_IOS_TESTS != 'true'
working-directory: ${{ github.workspace }}
run: |
# start simulator boot in background while deps install
xcrun simctl shutdown all 2>/dev/null || true
sleep 1
# find iPhone 16 simulator
DEVICE_ID=$(xcrun simctl list devices available -j | jq -r '
.devices | to_entries[]
| select(.key | contains("iOS"))
| .value[]
| select(.name == "iPhone 16")
| .udid' | head -1)
if [ -z "$DEVICE_ID" ]; then
DEVICE_ID=$(xcrun simctl list devices available -j | jq -r '
.devices | to_entries[]
| select(.key | contains("iOS"))
| .value[]
| select(.name | contains("iPhone"))
| .udid' | head -1)
fi
echo "Booting simulator: $DEVICE_ID"
xcrun simctl boot "$DEVICE_ID" || true
echo "SIM_DEVICE_ID=$DEVICE_ID" >> $GITHUB_ENV
- name: Install Dependencies
if: env.SKIP_IOS_TESTS != 'true'
uses: ./.github/actions/install
with:
workspace-focus: '@hanzogui/kitchen-sink'
- name: Install tooling
if: env.SKIP_IOS_TESTS != 'true'
run: |
npm install -g detox-cli &
(brew tap wix/brew && brew install applesimutils) &
wait
- name: Rebuild Detox Framework Cache
if: env.SKIP_IOS_TESTS != 'true'
working-directory: ${{ github.workspace }}
run: npx detox rebuild-framework-cache
- name: Wait for Simulator Boot
if: env.SKIP_IOS_TESTS != 'true'
working-directory: ${{ github.workspace }}
timeout-minutes: 5
run: |
echo "Waiting for simulator $SIM_DEVICE_ID to boot..."
# macOS doesn't have `timeout` — use perl one-liner as fallback
perl -e 'alarm 300; exec @ARGV' xcrun simctl bootstatus "$SIM_DEVICE_ID" -b || {
if xcrun simctl list devices booted | grep -q "Booted"; then
echo "Simulator appears booted despite timeout"
else
echo "Simulator failed to boot"
exit 1
fi
}
sleep 5
- name: Set Simulator to Light Mode
if: env.SKIP_IOS_TESTS != 'true'
working-directory: ${{ github.workspace }}
run: |
xcrun simctl ui booted appearance light
- name: Install App on Simulator
if: env.SKIP_IOS_TESTS != 'true'
working-directory: ${{ github.workspace }}
run: |
xcrun simctl install booted "${{ needs.build-ios.outputs.built-app-path }}"
- name: Setup Bun
if: env.SKIP_IOS_TESTS != 'true'
uses: oven-sh/setup-bun@v2
with:
bun-version-file: 'package.json'
- name: Run iOS Detox Tests
if: env.SKIP_IOS_TESTS != 'true'
env:
DETOX_IOS_APP_PATH: build/Build/Products/Debug-iphonesimulator/guikitchensink.app
run: |
bun run ../packages/native-ci/src/run-detox-ios.ts --project-root "$PWD" --record-logs failing --retries 1 ${{ matrix.test_files }}
- name: Report iOS Test Status
if: always() && env.SKIP_IOS_TESTS == 'true'
run: |
echo "::warning::iOS Detox tests skipped - cache was not available (will be available on next run)"
- name: Upload Detox Artifacts on Failure
if: failure() && env.SKIP_IOS_TESTS != 'true'
uses: actions/upload-artifact@v5
with:
name: detox-artifacts-ios-${{ strategy.job-index }}
path: ${{ env.test_container_app_path }}/e2e/artifacts/
retention-days: 7
# ─────────────────────────────────────────────────────────────────────────────
# Android Build & Test
# ─────────────────────────────────────────────────────────────────────────────
build-android:
name: Build Android App
# Only run on main/v* branches (not PRs)
if: github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/heads/v') || github.ref == 'refs/heads/rn82'
runs-on: [self-hosted, linux, amd64]
timeout-minutes: 45
outputs:
android-cache-key: ${{ steps.final-cache-key.outputs.cache_key }}
android-path: code/kitchen-sink/android
fingerprint: ${{ steps.calculate-fingerprint.outputs.fingerprint || steps.get-fingerprint-from-cache.outputs.fingerprint }}
steps:
- name: Free Disk Space
run: |
# Android builds with debug symbols need significant disk space
# Remove unused tools to free ~10GB+
sudo rm -rf /usr/share/dotnet
sudo rm -rf /usr/local/lib/android/sdk/ndk
sudo rm -rf /opt/ghc
sudo rm -rf /opt/hostedtoolcache/CodeQL
df -h /
- name: Checkout
uses: actions/checkout@v5
with:
fetch-depth: 1
- name: Calculate Pre-Fingerprint Hash
id: calculate-pre-fingerprint
env:
# Include cache version to allow manual cache busting
PRE_FINGERPRINT_HASH: ${{ hashFiles('bun.lock', 'code/kitchen-sink/app.json', 'packages/vxrn/expo-plugin.cjs') }}-${{ env.android_cache_version }}
run: |
echo "Pre-fingerprint hash: $PRE_FINGERPRINT_HASH"
echo "pre_fingerprint_hash=$PRE_FINGERPRINT_HASH" >> $GITHUB_OUTPUT
- name: Read Cached Fingerprint
id: get-fingerprint-from-cache
env:
KV_STORE_REDIS_REST_URL: ${{ secrets.KV_STORE_REDIS_REST_URL }}
KV_STORE_REDIS_REST_TOKEN: ${{ secrets.KV_STORE_REDIS_REST_TOKEN }}
run: |
FINGERPRINT_FROM_CACHE=$(curl -s "$KV_STORE_REDIS_REST_URL/get/android-guikitchensink-fingerprint-from-pre-hash-${{ steps.calculate-pre-fingerprint.outputs.pre_fingerprint_hash }}" -H "Authorization: Bearer $KV_STORE_REDIS_REST_TOKEN" | jq -r '.result')
if [ "$FINGERPRINT_FROM_CACHE" != "null" ]; then
curl -s -X POST "$KV_STORE_REDIS_REST_URL/EXPIRE/android-guikitchensink-fingerprint-from-pre-hash-${{ steps.calculate-pre-fingerprint.outputs.pre_fingerprint_hash }}/2592000" -H "Authorization: Bearer $KV_STORE_REDIS_REST_TOKEN"
echo "Fingerprint from cache: $FINGERPRINT_FROM_CACHE"
echo "fingerprint=$FINGERPRINT_FROM_CACHE" >> $GITHUB_OUTPUT
else
echo 'No cached fingerprint found.'
echo "fingerprint=null" >> $GITHUB_OUTPUT
fi
- name: Check if Android folder is already cached
id: android-cache-check
if: ${{ steps.get-fingerprint-from-cache.outputs.fingerprint != 'null' }}
uses: actions/cache/restore@v5
with:
path: code/kitchen-sink/android
key: android-detox-${{ env.android_cache_version }}-${{ steps.get-fingerprint-from-cache.outputs.fingerprint }}
lookup-only: true
- name: Install Dependencies
if: ${{ !steps.android-cache-check.outputs.cache-hit }}
uses: ./.github/actions/install
with:
workspace-focus: '@hanzogui/kitchen-sink'
- name: Setup Java
if: ${{ !steps.android-cache-check.outputs.cache-hit }}
uses: actions/setup-java@v4
with:
distribution: 'temurin'
java-version: '17'
- name: Setup Android SDK
if: ${{ !steps.android-cache-check.outputs.cache-hit }}
uses: android-actions/setup-android@v3
- name: Prebuild Android
if: ${{ !steps.android-cache-check.outputs.cache-hit }}
working-directory: code/kitchen-sink
run: |
bunx expo prebuild --platform android --clean
- name: Setup Detox Test Infrastructure
if: ${{ !steps.android-cache-check.outputs.cache-hit }}
working-directory: code/kitchen-sink
run: |
# Create Gradle init script for JNI library conflicts
cat > android/init.gradle << 'INITGRADLE'
// Gradle init script to fix JNI library conflicts for Detox tests
allprojects {
afterEvaluate {
if (it.hasProperty('android')) {
android {
packagingOptions {
jniLibs {
pickFirsts += ['**/libfbjni.so']
}
}
}
}
}
}
INITGRADLE
# Remove leading whitespace from heredoc
sed -i 's/^ //' android/init.gradle
# Add Detox repository to build.gradle (using node for reliable file editing)
node -e "
const fs = require('fs');
let content = fs.readFileSync('android/build.gradle', 'utf8');
content = content.replace(
\"maven { url 'https://www.jitpack.io' }\",
\"maven { url 'https://www.jitpack.io' }\\n // Detox repository\\n maven { url \\\"\\\$rootDir/../../../node_modules/detox/Detox-android\\\" }\"
);
fs.writeFileSync('android/build.gradle', content);
"
# Add Detox test runner config and dependencies to app/build.gradle
node -e "
const fs = require('fs');
let content = fs.readFileSync('android/app/build.gradle', 'utf8');
// Add testInstrumentationRunner after versionName
content = content.replace(
'versionName \"1.0.0\"',
'versionName \"1.0.0\"\\n\\n // Detox instrumentation test runner\\n testBuildType System.getProperty(\\'testBuildType\\', \\'debug\\')\\n testInstrumentationRunner \\'androidx.test.runner.AndroidJUnitRunner\\''
);
// Add Detox dependencies before the final closing brace of dependencies block
content = content.replace(
/(implementation jscFlavor\s*\n\s*}\s*\n})/,
'implementation jscFlavor\\n }\\n\\n // Detox dependencies\\n androidTestImplementation(\\'com.wix:detox:+\\')\\n androidTestImplementation \\'junit:junit:4.13.2\\'\\n}'
);
fs.writeFileSync('android/app/build.gradle', content);
"
# Create androidTest directory structure
mkdir -p android/app/src/androidTest/java/com/hanzoai/guikitchensink
# Create DetoxTest.java
cat > android/app/src/androidTest/java/com/hanzoai/guikitchensink/DetoxTest.java << 'DETOXTEST'
package com.gui.guikitchensink;
import com.wix.detox.Detox;
import com.wix.detox.config.DetoxConfig;
import org.junit.Rule;
import org.junit.Test;
import org.junit.runner.RunWith;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import androidx.test.filters.LargeTest;
import androidx.test.rule.ActivityTestRule;
@RunWith(AndroidJUnit4.class)
@LargeTest
public class DetoxTest {
@Rule
public ActivityTestRule<MainActivity> mActivityRule = new ActivityTestRule<>(MainActivity.class, false, false);
@Test
public void runDetoxTests() {
DetoxConfig detoxConfig = new DetoxConfig();
detoxConfig.idlePolicyConfig.masterTimeoutSec = 90;
detoxConfig.idlePolicyConfig.idleResourceTimeoutSec = 60;
detoxConfig.rnContextLoadTimeoutSec = 180;
Detox.runTests(mActivityRule, detoxConfig);
}
}
DETOXTEST
# Create AndroidManifest.xml for androidTest (fixes Android 12+ exported requirement)
cat > android/app/src/androidTest/AndroidManifest.xml << 'MANIFEST'
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<!-- Fix for Android 12+ requiring explicit android:exported -->
<application>
<activity
android:name="androidx.test.core.app.InstrumentationActivityInvoker$BootstrapActivity"
android:exported="true"
tools:node="merge" />
<activity
android:name="androidx.test.core.app.InstrumentationActivityInvoker$EmptyActivity"
android:exported="true"
tools:node="merge" />
<activity
android:name="androidx.test.core.app.InstrumentationActivityInvoker$EmptyFloatingActivity"
android:exported="true"
tools:node="merge" />
</application>
</manifest>
MANIFEST
- name: Calculate Fingerprint
if: ${{ !steps.android-cache-check.outputs.cache-hit }}
id: calculate-fingerprint
working-directory: code/kitchen-sink
run: |
FINGERPRINT=$(bunx @expo/fingerprint fingerprint:generate --platform android | jq -r '.hash')
if [ -z "$FINGERPRINT" ]; then
echo '[ERROR] Failed to calculate fingerprint.'
exit 1
fi
echo "Fingerprint: $FINGERPRINT"
echo "fingerprint=$FINGERPRINT" >> $GITHUB_OUTPUT
- name: Write Fingerprint to Cache
if: ${{ !steps.android-cache-check.outputs.cache-hit }}
env:
KV_STORE_REDIS_REST_URL: ${{ secrets.KV_STORE_REDIS_REST_URL }}
KV_STORE_REDIS_REST_TOKEN: ${{ secrets.KV_STORE_REDIS_REST_TOKEN }}
run: |
curl -s -X POST "$KV_STORE_REDIS_REST_URL/SETEX/android-guikitchensink-fingerprint-from-pre-hash-${{ steps.calculate-pre-fingerprint.outputs.pre_fingerprint_hash }}/2592000/${{ steps.calculate-fingerprint.outputs.fingerprint }}" -H "Authorization: Bearer $KV_STORE_REDIS_REST_TOKEN"
- name: Check If Build Already Exists (with new fingerprint)
if: ${{ !steps.android-cache-check.outputs.cache-hit }}
id: android-cache-check-new
uses: actions/cache/restore@v5
with:
path: code/kitchen-sink/android
key: android-detox-${{ env.android_cache_version }}-${{ steps.calculate-fingerprint.outputs.fingerprint }}
lookup-only: true
- name: Cache Gradle
if: ${{ !steps.android-cache-check.outputs.cache-hit && !steps.android-cache-check-new.outputs.cache-hit }}
uses: actions/cache@v5
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
restore-keys: |
${{ runner.os }}-gradle-
- name: Build Android App (Debug + Test APKs)
if: ${{ !steps.android-cache-check.outputs.cache-hit && !steps.android-cache-check-new.outputs.cache-hit }}
working-directory: code/kitchen-sink
env:
# Limit Gradle memory to avoid OOM on free runners
GRADLE_OPTS: '-Dorg.gradle.jvmargs=-Xmx2g -XX:MaxMetaspaceSize=512m -XX:+HeapDumpOnOutOfMemoryError'
run: |
cd android && ./gradlew assembleDebug assembleAndroidTest -DtestBuildType=debug --init-script init.gradle --no-daemon --max-workers=2
- name: Cache Android Folder
if: ${{ !steps.android-cache-check.outputs.cache-hit && !steps.android-cache-check-new.outputs.cache-hit }}
uses: actions/cache/save@v5
with:
path: code/kitchen-sink/android
key: android-detox-${{ env.android_cache_version }}-${{ steps.calculate-fingerprint.outputs.fingerprint }}
- name: Set Final Cache Key
id: final-cache-key
run: |
if [ "${{ steps.android-cache-check.outputs.cache-hit }}" = "true" ]; then
echo "cache_key=android-detox-${{ env.android_cache_version }}-${{ steps.get-fingerprint-from-cache.outputs.fingerprint }}" >> $GITHUB_OUTPUT
else
echo "cache_key=android-detox-${{ env.android_cache_version }}-${{ steps.calculate-fingerprint.outputs.fingerprint }}" >> $GITHUB_OUTPUT
fi
test-android:
name: Android Detox Tests
needs: build-android
# Only run on main/v* branches (not PRs)
if: github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/heads/v') || github.ref == 'refs/heads/rn82'
runs-on: [self-hosted, linux, amd64]
timeout-minutes: 45
# Android emulator in CI is flaky with window focus issues - don't block on failures
continue-on-error: true
# Retry matrix - run up to 2 attempts sequentially to handle flakiness
strategy:
fail-fast: false
max-parallel: 1
matrix:
attempt: [1, 2]
defaults:
run:
working-directory: ${{ env.test_container_app_path }}
steps:
- name: Checkout
uses: actions/checkout@v5
with:
fetch-depth: 1
- name: Enable KVM
run: |
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules
sudo udevadm control --reload-rules
sudo udevadm trigger --name-match=kvm
- name: Restore Android Folder from Cache
id: restore-android-cache
uses: actions/cache/restore@v5
with:
key: ${{ needs.build-android.outputs.android-cache-key }}
path: ${{ needs.build-android.outputs.android-path }}
- name: Check Cache Restore
if: steps.restore-android-cache.outputs.cache-hit != 'true'
run: |
echo "::warning::Android cache not restored (key: ${{ needs.build-android.outputs.android-cache-key }}). This may happen on first run after cache key changes."
echo "SKIP_ANDROID_TESTS=true" >> $GITHUB_ENV
- name: Install Dependencies
if: env.SKIP_ANDROID_TESTS != 'true'
uses: ./.github/actions/install
with:
workspace-focus: '@hanzogui/kitchen-sink'
- name: Setup Java
if: env.SKIP_ANDROID_TESTS != 'true'
uses: actions/setup-java@v4
with:
distribution: 'temurin'
java-version: '17'
- name: Install Detox CLI
if: env.SKIP_ANDROID_TESTS != 'true'
run: npm install -g detox-cli
- name: Setup Bun
if: env.SKIP_ANDROID_TESTS != 'true'
uses: oven-sh/setup-bun@v2
with:
bun-version-file: 'package.json'
- name: Start Android Emulator and Run Tests
if: env.SKIP_ANDROID_TESTS != 'true'
id: android-tests
uses: reactivecircus/android-emulator-runner@v2
with:
api-level: 31
target: google_apis
arch: x86_64
profile: pixel_4
avd-name: test
ram-size: 2048M
heap-size: 512M
disk-size: 4G
force-avd-creation: false
emulator-boot-timeout: 600
disable-animations: true
emulator-options: -no-snapshot -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none -camera-front none -memory 2048
working-directory: code/kitchen-sink
script: |
# Free up memory before tests
sudo swapoff -a || true
sudo swapon -a || true
# Verify emulator is ready
adb devices
adb wait-for-device
# Wait for boot to complete
adb shell 'while [ -z "$(getprop sys.boot_completed)" ]; do sleep 1; done'
# More robust screen unlock and focus sequence
adb shell wm dismiss-keyguard
adb shell input keyevent 82 # KEYEVENT_MENU - wake/unlock
adb shell input keyevent 3 # KEYEVENT_HOME - go home first
sleep 1
adb shell input keyevent 82 # Unlock again
adb shell input keyevent 4 # Back button to dismiss any dialogs
sleep 3
# Verify screen is on and unlocked
adb shell dumpsys window | grep -E "mAwake|mScreenOn" || true
# Let Detox handle APK installation and ADB reverse setup via reversePorts config
# Android emulator is flaky - capture exit code but don't fail the job
bun run ../packages/native-ci/src/run-detox-android.ts --headless --project-root "$PWD" --record-logs failing --retries 2 || echo "ANDROID_TESTS_FAILED=true" >> $GITHUB_ENV
- name: Report Android Test Status
if: always()
run: |
if [ "$SKIP_ANDROID_TESTS" = "true" ]; then
echo "::warning::Android Detox tests skipped - cache was not available (will be available on next run)"
elif [ "$ANDROID_TESTS_FAILED" = "true" ]; then
echo "::warning::Android Detox tests failed - this is expected flakiness and does not block CI"
else
echo "Android Detox tests passed"
fi
- name: Upload Detox Artifacts on Failure
if: env.ANDROID_TESTS_FAILED == 'true' && env.SKIP_ANDROID_TESTS != 'true'
uses: actions/upload-artifact@v5
with:
name: detox-artifacts-android-attempt-${{ matrix.attempt }}
path: ${{ env.test_container_app_path }}/e2e/artifacts/
retention-days: 7
-3
View File
@@ -60,6 +60,3 @@ test-results/
.gui/
# Wed Mar 25 04:08:49 PDT 2026
*.tgz
# tauri build output only — no source lives here (2.1G)
apps/desktop/
-66
View File
@@ -1,66 +0,0 @@
name: Checks
on:
workflow_dispatch:
pull_request:
push:
paths-ignore:
- 'assets/**'
- '.vscode/**'
branches:
- main
- v2
- 'v2-*'
# cancel in-progress runs on the same branch (avoids duplicate PR + push runs)
concurrency:
group: checks-${{ github.ref }}
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
jobs:
checks:
runs-on: hanzo-build-linux-amd64
steps:
- name: Checkout
uses: actions/checkout@v5
with:
fetch-depth: 1
- name: Install
uses: ./.github/actions/install
- name: Check
run: bun run check
- name: Lint
run: bun run lint
unit-tests:
runs-on: hanzo-build-linux-amd64
env:
NODE_OPTIONS: '--max-old-space-size=6144'
steps:
- name: Checkout
uses: actions/checkout@v5
with:
fetch-depth: 1
- name: Install
uses: ./.github/actions/install
- name: Get Playwright version
id: pw-version
run: echo "version=$(bunx playwright --version | awk '{print $2}')" >> $GITHUB_OUTPUT
- name: Cache Playwright browsers
uses: actions/cache@v5
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ steps.pw-version.outputs.version }}
- name: Install playwright
run: bunx playwright install
- name: Run unit tests
run: bun turbo run test:web --filter='!@hanzogui/kitchen-sink' --concurrency=1
-152
View File
@@ -6,70 +6,6 @@ FOR LONG RUNNNING DEBUGGING run `bun run watch` in the background its faster and
keep commits to one line, add a trailing "Fixes #" if associated with a GH issue, and start with a convential commit style - UNLESS its a change that shouldn't go into the changelog, in those cases you can do things like `docs: ` or `site: `.
# How this ships
One way, and it runs on our own stack:
push -> github.com/hanzoai/gui (a mirror)
.github/workflows/sync.yml carries refs onward
-> git.hanzo.ai/hanzoai/gui CANONICAL
.hanzo/workflows/checks.yaml check, lint, unit tests
.hanzo/workflows/publish-gui.yml hanzogui + @hanzo/gui
.hanzo/workflows/publish-gui-all.yml every @hanzogui/* primitive
.hanzo/workflows/build.yml ghcr.io/hanzoai/gui
**git.hanzo.ai is canonical; GitHub is a mirror.** `.github/workflows/` holds
exactly one file, `sync.yml`, and its only job is getting refs to the forge. Every
build, check and publish is a workflow under `.hanzo/workflows/`, which the forge
reads. `.hanzo/workflows` uses GitHub Actions syntax, so a workflow moves between
the two by changing directory and nothing else. `.github/actions/install` stays
where it is — a moved workflow's `uses: ./.github/actions/install` still resolves.
## What publishes what
Each publisher is the only producer of its packages, and a tag is the trigger in
every case — a version bump alone never ships.
| tag | workflow | publishes |
| --- | --- | --- |
| `release/gui-v*` | `publish-gui.yml` | `hanzogui` + the `@hanzo/gui` alias |
| `release/gui-all-v*` (or dispatch) | `publish-gui-all.yml` | every `@hanzogui/*` primitive + both umbrellas |
`NPM_TOKEN` comes from KMS with a repo-secret fallback in both `publish-gui*`
workflows.
**This repo publishes primitives only.** The AI app that used to live at
`pkgs/ai``@hanzo/app`, plus the seven `@hanzo_network/*` libraries it is
built from — moved to `hanzo-js/app` on 2026-07-28, and publishes itself from
there. An application inside the primitives repo is the layering mistake the
owner named: `@hanzo/gui` (primitives) -> `@hanzo/ui` (widgets) -> apps.
## Known dead ends
- `build.yml` delegates to `hanzoai/.github/.github/workflows/docker-build.yml@main`
and **that file does not exist** in `hanzoai/.github` (which carries
`platform-build.yml` and `promote.yml`). The workflow has therefore never
completed a run and `ghcr.io/hanzoai/gui` is not in the registry. It moved to
`.hanzo/workflows/` unchanged rather than being deleted, because it is the only
declaration of the image path; making it real means either inlining a build or
pointing it at `platform-build.yml`.
- `gui.hanzo.ai` is **not served from this repo.** It answers from Cloudflare Pages
out of `hanzoai/docs` (`apps/gui-docs`). This repo's own `apps/gui.hanzo.ai` is
`one serve` — a long-running server, not a static export — so `hanzoai/static`
cannot serve it and there is no App CR for it. Either point a CR at the docs
image or convert that app to a static export; both are their own change.
- `checks.yaml` lost its `changes` + `integration-tests` jobs on the way over. They
filtered on `code/core/**`, `code/kitchen-sink/**` and friends — an upstream
Tamagui layout this repo does not have (the real paths are `apps/` and `pkgs/`) —
so the filter never matched and the shard matrix never ran. The Playwright suite
they were meant to run is at `apps/kitchen-sink/playwright.config.ts` and is
currently wired to nothing.
- Three iOS workflows were deleted outright: `build-ios-kitchensink-app.yml`,
`test-ios-native.yml` and `test-ios-kitchensink-go.yml`. All three wanted
`macos-14`/`macos-15` runners, which the forge does not have, and all three ran
in `code/kitchen-sink`, which does not exist. One was already `if: false`. Native
iOS testing needs macOS capacity first; these files could not have provided it.
# Hanzo GUI Testing Guide
## Running Tests
@@ -187,66 +123,6 @@ const response = await authFetch('/api/some-endpoint', {
**Why this matters:** Cookies alone are not reliable for auth in production due to cross-origin/SameSite issues. The `authFetch` helper automatically includes the Authorization header with the user's access token. All payment/subscription endpoints require this.
## Tailwind → gui migration (marketing surfaces)
`@hanzogui/chrome` is the shared public-site chrome (`HanzoNav`, `HanzoFooter`,
`ChatHero`, `HanzoWidget`) in Tamagui `styled()` + monochrome tokens. It compiles
and ships `dist/{esm,cjs,jsx}` + `types/`. It is the bridgehead: a surface adopts
the chrome first, then its page bodies.
**Read `pkgs/ui/chrome/src/styles.tsx`'s header before touching a chrome file.**
It states the contract this package is built under — no host config augmentation —
and every build break so far has been a violation of it: element type is `render`
(not `tag`); hover is DOM `onMouseEnter`/`onMouseLeave` (not `onHoverIn`); only
`Text` takes `color`; style props are LONGHAND (`textAlign`, `paddingHorizontal` —
the `text`/`px` shorthands live in augmentation that is absent here); anchors
forward `href` through the `linkable` `.styleable` wrapper. Two type consequences:
`GetProps<F>` requires `F extends StylableComponent`, and a raw-hex token handed to
a strict RN colour prop needs `as ColorTokens`.
### What the migration actually is
Measured on hanzo.ai (the largest surface): **126 marketing pages, 6,720
`className=` sites, 29,845 utility tokens, 671 distinct utilities**, and **zero**
imports of `@hanzo/ui` or `@hanzogui/*` in the page bodies. So this is not a
shadcn component swap — the pages are raw Tailwind. The vocabulary is regular
though: 30 utilities cover **51.4%** of all tokens, and colours are already
semantic (`text-foreground`, not hex), so the colour layer is a rename.
### Primitive map
| Tailwind (count) | gui equivalent |
|---|---|
| `text-foreground` 1091 · `text-muted-foreground` 859 · `text-foreground/80` 244 | `Txt` + `color={c.fg / c.fgMuted / c.fgDim}` (`chrome/src/tokens.ts`) |
| `border-border` 829 · `border` 795 | `borderColor={c.line}` · `borderWidth={1}` |
| `flex` 760 · `items-center` 887 · `justify-center` 512 | `XStack`/`YStack` + `alignItems` / `justifyContent` |
| `inline-flex` 484 | `XStack display="inline-flex"` |
| `grid` 220 | `YStack` + explicit rows, or `View display="grid"` |
| `mx-auto` 705 | `marginHorizontal="auto"` |
| `px-4` 542 · `py-3` 347 | `paddingHorizontal={16}` · `paddingVertical={12}` (longhand) |
| `gap-2` 509 · `gap-4` 239 | `gap={8}` · `gap={16}` |
| `mb-4` 507 | `marginBottom={16}` |
| `text-sm` 693 · `text-xl` 287 · `text-2xl` 323 | `Txt kind=` — the type scale in `chrome/src/styles.tsx` |
| `font-medium` 676 · `font-bold` 560 | `fontWeight="500"` / `"700"` |
| `text-center` 410 | `textAlign="center"` (never `text=`) |
| `rounded-full` 625 · `rounded-xl` 391 | `borderRadius={999}` · `borderRadius={12}` |
| `transition-colors` 372 | `useHover()` + explicit `color` on the child `Text` |
| `relative` 268 · `absolute` 256 · `overflow-hidden` 251 | `position` / `overflow` props |
| `h-4 w-4` 357/352 | icon `size={16}` (lucide props, unchanged) |
### Order
1. Adopt `@hanzogui/chrome` on a surface (nav/footer/hero) — the page bodies keep
working untouched, so this is independently shippable.
2. Extract the repeated page shapes. 17 hanzo.ai pages already open with a
byte-identical hero block; those become one primitive, not 17 rewrites.
3. Migrate bodies shape-by-shape, not page-by-page. Tailwind leaves as a
consequence of the last shape moving, which is the only point it can be
removed from the build.
Do not sed 6,720 class strings. The tail (671 30 utilities) is where the layout
bugs hide, and a marketing site is visually regression-tested by eye.
---
## Additional notes (merged from LLM.md)
@@ -258,31 +134,3 @@ bugs hide, and a marketing site is visually regression-tested by eye.
<h3 align="center">
Style library, design system, composable components, and more.
</h3>
---
## Telemetry — `@hanzogui/telemetry` (pkgs/telemetry)
The ONE zero-config telemetry surface. `<TelemetryProvider>{children}</TelemetryProvider>`
with no props wires all three planes; `@hanzo/ui/telemetry` re-exports it unchanged
so the component layer needs no second definition.
- **One front door.** Everything (pageviews, product events, exceptions,
interaction capture) is POSTed to `https://api.hanzo.ai/v1/event`. Cloud lenses
that one stream into `sentry.hanzo.ai` (errors + session capture),
`analytics.hanzo.ai` (web analytics) and `insights.hanzo.ai` (product insights,
incl. `analytics_errors`). Those three hosts are DASHBOARDS — never ingest
endpoints, never configured in a client.
- **Layering.** `@hanzo/event` = the client (batching, attribution, the wire).
`@hanzo/observe` = the capture engine (semantics, redaction, playback), loaded
with a dynamic `import()` inside an idle callback so it cannot cost LCP.
`@hanzogui/telemetry` = the zero-config policy that composes them. Mechanism
below, policy here, one of each.
- **Privacy.** DNT/GPC honored by default with no app code; an explicit
`setConsent()` choice outranks the browser in both directions; nothing is
written to storage while telemetry is refused.
- **Guarantees.** SSR-safe (a DOM is required before anything is collected),
fail-soft (every method swallows its own errors), no CDN script, ESM,
`sideEffects: false`.
- Tests: `cd pkgs/telemetry && bun run test` (24 tests — one-door URL, three-lens
wire, DNT/GPC, consent precedence, SPA route counting, error boundary, SSR).
-141
View File
@@ -1,141 +0,0 @@
# Fleet migration: shadcn `@hanzo/ui` → `@hanzo/gui`
The repeatable recipe for taking any Hanzo web surface OFF the shadcn/Radix+Tailwind
`@hanzo/ui` library and ONTO the canonical `@hanzo/gui` design system, so every
surface shares one look and feel. Written from increment 1 (hanzo.ai + hanzo.chat);
follow it to roll the rest of the fleet (docs sites, marketing sites, apps) mechanically.
There are **two layers**. They are independent — do them in either order, per surface.
| Layer | What | Package | Substrate | Works on |
|---|---|---|---|---|
| **Chrome** | Header, footer, hero, AI-widget mount (the shared frame) | `@hanzogui/chrome` | plain React + Tailwind + framer-motion + lucide | React 18 **and** 19 · Next **and** Vite |
| **Primitives** | Button/Input/Card/Badge/… inside pages | `@hanzo/gui` | Tamagui (styled + tokens + RN-web) | React ≥19 (Next proven; Vite via `@hanzogui/vite-plugin`) |
Why two: the cross-app chrome must run on React-18 Vite surfaces (hanzo.chat) and
React-19 static-export Next surfaces alike, so it is authored as plain React +
Tailwind — the SAME convention `@hanzogui/shell` (TenantHeader/HanzoAppBar) already
uses. The Tamagui primitive layer needs React ≥19; it is proven to render, SSR-style-
extracted with no FOUC, in a Next `output:'export'` static build (see Proof).
`@hanzogui/chrome` = PUBLIC marketing chrome. `@hanzogui/shell` = AUTHENTICATED tenant
chrome (org switcher, app launcher). Pick per surface; don't conflate.
---
## Recipe A — adopt the shared chrome (`@hanzogui/chrome`)
Exports: `HanzoNav`, `HanzoFooter`, `ChatHero`, `HanzoWidget` (+ types `NavItem`,
`NavLink`, `NavColumn`). All content and effects are **props** — the library is
presentational and host-agnostic (no analytics, no router, no config baked in).
1. **Link the package (no publish needed).** Add to the app's `package.json`:
```jsonc
"@hanzogui/chrome": "link:<relpath>/gui/pkgs/ui/chrome"
```
Install (`pnpm install` / `npm install`). It ships as SOURCE.
2. **Transpile it as first-party.**
- **Next**: `transpilePackages: ['@hanzogui/chrome']` in `next.config`. This ALSO
dedupes React for the linked package — do **NOT** alias `react`/`react-dom` in
webpack; a global react alias breaks Next's server/RSC React and crashes static
export with `Cannot read properties of null (reading 'useContext')`.
- **Vite**: `optimizeDeps.exclude: ['@hanzogui/chrome']` and ensure
`server.fs.allow` includes the gui checkout. Vite transpiles linked TS by default.
3. **Let Tailwind see it** (its classes live outside your project root):
- **Tailwind v4** (CSS-first): `@source '<relpath>/gui/pkgs/ui/chrome/src';` in globals.css.
- **Tailwind v3**: add `'<relpath>/gui/pkgs/ui/chrome/src/**/*.{js,jsx,ts,tsx}'` to `content`.
4. **Write thin adapters** that inject the surface's content + effects. Keep analytics
IN the app (the library takes callbacks):
```tsx
<HanzoNav items={NAV} logo={<HanzoLogo variant="white" size={22} />} brand="Hanzo AI"
login={{ links: LOGIN_LINKS }} primary={{ label: 'Try Hanzo', href: CHAT, links: TRY_LINKS }}
onPrimary={() => analytics.capture(EVENTS.CHAT_STARTED, { source: 'nav' })} />
```
`ChatHero` forwards on submit: pass `onSubmit={(q) => { analytics…; goTo(q) }}` (or a
plain `href` to auto-append `?q=`). Cross-origin surfaces use absolute hrefs in nav data;
a same-app landing forwards INTO itself (e.g. `navigate('/c/new?q='+q)`).
5. **AI widget** (optional): `<HanzoWidget repo="org/repo" />` injects the
`<meta name="hanzo:repo">` convention + loads `hanzo.app/edit.js` once. Mount-only;
edit.js behaviour is a separate workstream.
Gotcha — analytics stays put: never move `@hanzo/event` / `hz.js` into the library.
The chrome is effect-free; wire telemetry in the app via the callback props.
---
## Recipe B — migrate page primitives (`@hanzo/ui` shadcn → `@hanzo/gui` Tamagui)
Proven to build + render in a Next 15 `output:'export'` static export. Steps mirror the
one working fleet integration (`hanzoai/app/next.config.js`).
1. **Deps** (published npm, pin to the same versions the fleet uses, currently 7.3.0):
`@hanzo/gui @hanzogui/config @hanzogui/lucide-icons-2 react-native-web`.
2. **Next config** — transpile the whole gui graph + map RN→RN-web + prefer `.web.*`:
```js
transpilePackages: ['@hanzo/gui','react-native-web', ...fs.readdirSync('node_modules/@hanzogui').map(n=>`@hanzogui/${n}`)]
webpack: (c) => { c.resolve.alias['react-native$']='react-native-web';
c.resolve.extensions=['.web.tsx','.web.ts','.web.jsx','.web.js',...c.resolve.extensions];
c.resolve.fallback={...c.resolve.fallback, fs:false, '@react-native-async-storage/async-storage':false, 'pino-pretty':false}; return c }
```
No `@tamagui/next-plugin` — its dep is broken and unnecessary; `transpilePackages` +
Tamagui's runtime SSR-extracts the atomic CSS into the static HTML (styled on first paint).
(Vite surfaces: use `@hanzogui/vite-plugin` instead + the RN-web alias.)
3. **Provider** — wrap the tree once (context-only; additive, leaves other UI intact):
```tsx
import { createGui } from '@hanzo/gui'; import { defaultConfig } from '@hanzogui/config/v5'
const config = createGui(defaultConfig)
<GuiProvider config={config} defaultTheme="dark">{children}</GuiProvider>
```
4. **Swap primitives.** Build a local `components/ui/*` shim layer that exposes the
shadcn API but is backed by gui, then repoint imports (or alias `@hanzo/ui` → the shim
to flip every importer at once). Component map (hanzo.ai surface, frequency-ranked):
| shadcn `@hanzo/ui` | files | `@hanzo/gui` |
|---|---|---|
| `Button` (+`buttonVariants`) | 121 | `Button` (map `variant`/`size` → theme/size props) |
| `Input` | 12 | `Input` |
| `Badge` | 10 | compose `XStack`+`Text` (no Tamagui Badge) |
| `Label` | 6 | `Label` |
| `Card`+family | 5 | `Card` + `Card.Header`/`Card.Footer` |
| `Avatar`+family | 4 | `Avatar` (`Avatar.Image`/`Avatar.Fallback`) |
| `Tabs`+family | 4 | `Tabs` |
| `Textarea` | 4 | `TextArea` |
| `Table`+family | 3 | compose from `styled` stacks (no Tamagui Table) |
| `Progress`,`Accordion`,`Dialog`,`DropdownMenu`,`Select`,`Separator`,`Sheet`,`Slider`,`Checkbox` | ≤3 ea | `Progress`,`Accordion`,`Dialog`,`Menu`,`Select`,`Separator`,`Sheet`,`Slider`,`Checkbox` |
| `Toaster` | 1 | `Toast`/`ToastProvider` |
Do the top-5 first — they cover the overwhelming majority of files. If a gui primitive
is genuinely missing (Badge, Table), ADD it to `@hanzo/gui` — never reach back to shadcn.
5. **Cleanup during cutover**:
- Repoint any Tailwind `content` glob off `@hanzo/ui/dist/**` (it purges nothing once shadcn is gone).
- Collapse duplicate primitives to one (hanzo.ai has TWO Buttons: raw `@hanzo/ui` in 121
files + a bug-fix wrapper `components/ui/button.tsx` in 17 — pick one).
- Fix dangling type-only imports (`ToastActionElement`/`ToastProps` don't exist in `@hanzo/ui@5`).
---
## Proof (increment 1)
- **Spike** — a real `@hanzo/gui` component (`YStack`/`Text`/`Button` + live theme tokens)
built and rendered STYLED in hanzo.ai's Next 15 `output:'export'` static export; the
atomic CSS is SSR-extracted into the prerendered HTML (no FOUC). Toolchain GREEN.
- **Chrome on hanzo.ai** — apex nav/footer/hero migrated to `@hanzogui/chrome`; static
build passes (711 pages), chrome CSS generated by Tailwind `@source`, pixel-identical to
pre-migration, hz.js + `@hanzo/event` untouched.
- **Chrome on hanzo.chat** — new public landing (React 18 + Vite + Tailwind 3) on the SAME
`@hanzogui/chrome`; composer forwards into the chat app.
## Per-surface rollout checklist
- [ ] Chrome: link `@hanzogui/chrome` · transpile/optimizeDeps · Tailwind source · adapters (nav data + analytics callbacks).
- [ ] Primitives: add gui deps · next/vite config (transpile + RN-web alias) · `GuiProvider` · shim + swap top-5 · flip the rest.
- [ ] Cleanup: repoint Tailwind glob · collapse duplicates · fix dangling types · drop `@hanzo/ui`.
- [ ] Prove: build + Playwright screenshot (desktop + mobile), no horizontal scroll, console clean.
-38
View File
@@ -1,38 +0,0 @@
Hanzo GUI
Copyright (c) 2026-present, Hanzo AI, Inc.
This product includes software derived from Tamagui.
Tamagui
https://github.com/tamagui/tamagui
SPDX-License-Identifier: MIT
Tamagui is distributed under the MIT License. Its original copyright and
permission notices are reproduced below, as that license requires.
MIT License
Copyright (c) 2020 Nate Wienert
Copyright (c) 2015-present, Nicolas Gallagher.
Copyright (c) 2015-present, Facebook, Inc.
Copyright (c) 2021 Radix
Copyright (c) 2017 Carmelo Pullara
Copyright (c) 2018 Framer B.V.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
-4
View File
@@ -1,9 +1,5 @@
<p align="center"><img src=".github/hero.svg" alt="gui" width="880"></p>
# Hanzo GUI
Forked from [tamagui](https://github.com/tamagui/tamagui) (MIT).
<h3 align="center">
Style library, design system, composable components, and more.
</h3>
@@ -15,6 +15,7 @@
import { apiRoute } from '~/features/api/apiRoute'
import { ensureAuth } from '~/features/api/ensureAuth'
import { captureServerError } from '~/features/posthog'
import { commerce, CommerceError } from '~/features/commerce/client'
type Body = {
@@ -57,14 +58,8 @@ export default apiRoute(async (req) => {
if (err instanceof CommerceError) {
return Response.json(err.detail, { status: err.status })
}
// Server-side error reporting is the cluster log pipeline for now. The
// previous captureServerError shipped this to a third-party PostHog project
// (posthog-node, hardcoded key, us.i.posthog.com) — off-platform and not the
// ONE door. @hanzogui/telemetry is DOM-oriented and would silently no-op
// here, so wiring it would only pretend to capture. A real server plane
// needs a service credential POSTing api.hanzo.ai/v1/event; until then this
// log is the honest surface.
console.error('create-subscription failed', err)
captureServerError(err as Error, { endpoint: '/api/create-subscription' })
return Response.json({ error: 'Failed to create subscription' }, { status: 500 })
}
})
@@ -1,5 +1,5 @@
import { Component } from 'react'
import { captureError } from '@hanzogui/telemetry'
import { processError } from '~/features/posthog/errorHandling'
export class ErrorBoundary extends Component<any> {
constructor(props) {
@@ -13,14 +13,14 @@ export class ErrorBoundary extends Component<any> {
}
componentDidCatch(error, errorInfo) {
captureError(error, {
handled: true,
properties: {
severity: 'high',
source: 'error_boundary',
processError({
error,
context: {
url: typeof window !== 'undefined' ? window.location.href : undefined,
componentStack: errorInfo?.componentStack,
additional: { componentStack: errorInfo?.componentStack },
},
severity: 'high',
tags: { source: 'error_boundary' },
})
}
+3 -3
View File
@@ -2,7 +2,7 @@ import { InitialPathContext, SeasonProvider } from '@hanzogui/logo'
import { SchemeProvider, useUserScheme } from '@vxrn/color-scheme'
import { GuiProvider } from 'hanzogui'
import tamaConf from '~/gui.config'
import { TelemetryProvider } from '@hanzogui/telemetry'
import { PostHogProvider } from '~/features/posthog/PostHogProvider'
import { SearchProvider } from '~/features/site/search/SearchProvider'
import { ToastProvider } from '~/features/studio/ToastProvider'
@@ -10,13 +10,13 @@ export const Providers = (props: { children: any }) => {
return (
<InitialPathContext.Provider value={3}>
<SchemeProvider>
<TelemetryProvider>
<PostHogProvider>
<SeasonProvider>
<WebsiteGuiProvider>
<SearchProvider>{props.children}</SearchProvider>
</WebsiteGuiProvider>
</SeasonProvider>
</TelemetryProvider>
</PostHogProvider>
</SchemeProvider>
</InitialPathContext.Provider>
)
+1 -1
View File
@@ -10,7 +10,7 @@
*/
import { validateToken } from '@hanzo/iam/auth'
import type { User as IamUser } from '@hanzo/iam'
import type { IamUser } from '@hanzo/iam'
const IAM_SERVER_URL =
process.env.HANZO_IAM_SERVER_URL ?? 'https://iam.hanzo.ai'
@@ -0,0 +1,15 @@
import { useEffect } from 'react'
import { clientPostHog } from './client'
import { initializeErrorHandling } from './errorHandling'
import { usePostHogIdentify } from './usePostHogIdentify'
export function PostHogProvider({ children }: { children: React.ReactNode }) {
useEffect(() => {
clientPostHog.initialize()
initializeErrorHandling()
}, [])
usePostHogIdentify()
return <>{children}</>
}
@@ -0,0 +1,18 @@
import { serverPostHog } from './server'
export function captureServerError(
error: Error,
context?: {
endpoint?: string
userId?: string
method?: string
[key: string]: any
}
) {
serverPostHog.captureException(error, {
endpoint: context?.endpoint,
method: context?.method,
distinctId: context?.userId || 'anonymous-server',
...context,
})
}
@@ -0,0 +1,60 @@
import posthog from 'posthog-js'
import type { PostHogInstance } from './types'
const POSTHOG_KEY = 'phc_vy6MdaPFUllGBQLrBNWs4RJ8tbGuHyFF0nY6lncB1Ol'
const POSTHOG_HOST = 'https://us.i.posthog.com'
class ClientPostHog implements PostHogInstance {
private isInitialized = false
initialize(): void {
if (this.isInitialized) return
if (typeof window === 'undefined') return
if (process.env.NODE_ENV === 'development') return
posthog.init(POSTHOG_KEY, {
api_host: POSTHOG_HOST,
person_profiles: 'identified_only',
capture_pageview: true,
capture_pageleave: true,
autocapture: true,
session_recording: {
recordCrossOriginIframes: true,
},
})
this.isInitialized = true
}
capture(event: string, properties?: Record<string, any>): void {
if (!this.isInitialized) return
posthog.capture(event, properties)
}
identify(userId: string, properties?: Record<string, any>): void {
if (!this.isInitialized) return
posthog.identify(userId, properties)
}
reset(): void {
if (!this.isInitialized) return
posthog.reset()
}
captureException(error: Error, properties?: Record<string, any>): void {
if (!this.isInitialized) return
if (typeof posthog.captureException === 'function') {
posthog.captureException(error, properties)
} else {
posthog.capture('$exception', {
$exception_type: error.name,
$exception_message: error.message,
$exception_stack_trace_raw: error.stack || '',
...properties,
})
}
}
}
export const clientPostHog = new ClientPostHog()
@@ -0,0 +1,69 @@
import { clientPostHog } from './client'
import type { ErrorReport } from './types'
let handlersSetup = false
export function initializeErrorHandling(): void {
if (typeof window === 'undefined') return
if (process.env.NODE_ENV === 'development') return
if (handlersSetup) return
setupWebHandlers()
handlersSetup = true
}
export function processError(report: ErrorReport): void {
const { error, context = {}, severity = 'medium', tags } = report
clientPostHog.captureException(error, {
...tags,
severity,
url: context.url,
userAgent: context.userAgent,
timestamp: context.timestamp || Date.now(),
...context.additional,
})
if (severity === 'critical' || severity === 'high') {
console.error('[posthog error]', error)
}
}
function setupWebHandlers(): void {
const ogWindowErrorHandler = window.onerror
window.onerror = (message, source, lineno, colno, error) => {
ogWindowErrorHandler?.(message, source, lineno, colno, error)
const actualError = error || new Error(String(message))
processError({
error: actualError,
context: {
url: source?.toString(),
timestamp: Date.now(),
userAgent: navigator.userAgent,
additional: { line: lineno, column: colno },
},
severity: 'high',
tags: { source: 'window.onerror' },
})
return false
}
window.addEventListener('unhandledrejection', (event: PromiseRejectionEvent) => {
const error =
event.reason instanceof Error ? event.reason : new Error(String(event.reason))
processError({
error,
context: {
url: window.location.href,
userAgent: navigator.userAgent,
timestamp: Date.now(),
},
severity: 'high',
tags: { source: 'unhandled_promise_rejection' },
})
})
}
@@ -0,0 +1,5 @@
export { clientPostHog } from './client'
export { serverPostHog } from './server'
export { captureServerError } from './captureServerError'
export { processError } from './errorHandling'
export type { PostHogInstance, ErrorContext, ErrorReport } from './types'
@@ -0,0 +1,59 @@
import { PostHog } from 'posthog-node'
import type { PostHogInstance } from './types'
const POSTHOG_KEY = 'phc_vy6MdaPFUllGBQLrBNWs4RJ8tbGuHyFF0nY6lncB1Ol'
const POSTHOG_HOST = 'https://us.i.posthog.com'
class ServerPostHog implements PostHogInstance {
private posthog: PostHog | null = null
constructor() {
if (process.env.NODE_ENV !== 'development') {
this.posthog = new PostHog(POSTHOG_KEY, {
host: POSTHOG_HOST,
})
}
}
capture(event: string, properties?: Record<string, any>): void {
if (!this.posthog) return
this.posthog.capture({
event,
properties,
distinctId: properties?.distinctId || properties?.userId || 'anonymous-server',
})
}
captureException(error: Error, properties?: Record<string, any>): void {
if (!this.posthog) return
this.posthog.capture({
event: '$exception',
distinctId: properties?.distinctId || properties?.userId || 'anonymous-server',
properties: {
$exception_type: error.name,
$exception_message: error.message,
$exception_stack_trace_raw: error.stack || '',
source: 'server',
...properties,
},
})
// flush immediately for exceptions
this.posthog.flush()
}
identify(userId: string, properties?: Record<string, any>): void {
if (!this.posthog) return
this.posthog.identify({
distinctId: userId,
properties,
})
}
reset(): void {
if (!this.posthog) return
this.posthog.shutdown()
}
}
export const serverPostHog = new ServerPostHog()
@@ -0,0 +1,21 @@
export interface PostHogInstance {
capture(event: string, properties?: Record<string, any>): void
captureException(error: Error, properties?: Record<string, any>): void
identify(userId: string, properties?: Record<string, any>): void
reset(): void
}
export interface ErrorContext {
userId?: string
url?: string
userAgent?: string
timestamp?: number
additional?: Record<string, any>
}
export interface ErrorReport {
error: Error
context?: ErrorContext
severity?: 'low' | 'medium' | 'high' | 'critical'
tags?: Record<string, string>
}
@@ -0,0 +1,16 @@
import { useEffect } from 'react'
import { useUser } from '~/features/user/useUser'
import { clientPostHog } from './client'
export function usePostHogIdentify() {
const { data } = useUser()
const user = data?.user
useEffect(() => {
if (user) {
clientPostHog.identify(user.id, {
email: user.email,
})
}
}, [user?.id, user?.email])
}
@@ -16,7 +16,7 @@ import type {
import { router } from 'one'
import { useToastController } from '@hanzogui/toast'
import { lazy, Suspense, useEffect, useMemo, useState } from 'react'
import { captureError } from '@hanzogui/telemetry'
import { processError } from '~/features/posthog/errorHandling'
import useSWR, { mutate } from 'swr'
import useSWRMutation from 'swr/mutation'
import {
@@ -749,9 +749,10 @@ const DiscordPanel = ({
toast.show('Discord search failed', {
message: searchSwr.error.message || 'Could not search Discord members',
})
captureError(searchSwr.error, {
handled: true,
properties: { severity: 'medium', source: 'discord_member_search' },
processError({
error: searchSwr.error,
severity: 'medium',
tags: { source: 'discord_member_search' },
})
}
}, [searchSwr.error])
+3 -2
View File
@@ -38,12 +38,11 @@
"@rehookify/datepicker": "^6.6.8",
"@sindresorhus/slugify": "^2.2.1",
"@hanzo/base": "^0.2.0",
"@hanzo/iam": "^0.13.1",
"@hanzo/iam": "^0.9.4",
"@hanzogui/assert": "workspace:*",
"@hanzogui/demos": "workspace:*",
"@hanzogui/get-token": "workspace:*",
"@hanzogui/logo": "workspace:*",
"@hanzogui/telemetry": "workspace:*",
"@hanzogui/lucide-icons-2": "workspace:*",
"@hanzogui/dev-config": "workspace:*",
"@hanzogui/use-store": "workspace:*",
@@ -65,6 +64,8 @@
"masonic": "^4.0.1",
"one": "1.12.5",
"parse-numeric-range": "^1.3.0",
"posthog-js": "^1.351.4",
"posthog-node": "^5.24.17",
"postmark": "^3.0.18",
"puppeteer": "^24.3.1",
"react-dropzone": "^14.2.3",
+2
View File
@@ -1,5 +1,6 @@
{
"compilerOptions": {
"baseUrl": ".",
"rootDir": ".",
"paths": {
"~/*": ["./*"]
@@ -10,6 +11,7 @@
"allowSyntheticDefaultImports": true,
"forceConsistentCasingInFileNames": true,
"declaration": true,
"downlevelIteration": true,
"strict": true,
"esModuleInterop": true,
"inlineSourceMap": true,
@@ -1,46 +0,0 @@
# Visual-Regression Gate (HIP-0504 §rule 6)
The design-system unification re-bases `@hanzo/ui` onto `@hanzo/gui` and turns
`@luxfi/ui` / `@zooai/ui` into theme overlays. Per HIP-0504, **the rendered
output must not change** — only the implementation underneath it. This gate
enforces that with pixel-diffed screenshots.
## How it works
`visual-regression.test.ts` renders the kitchen-sink component gallery (the
substrate showcase) and snapshots each surface, comparing against committed
baselines in `tests/__screenshots__/`.
## The contract
1. **Capture the baseline on the pre-migration ref** (current `@hanzo/ui` / gui):
```bash
cd apps/kitchen-sink
PORT=9000 pnpm exec playwright test visual-regression --update-snapshots
git add tests/__screenshots__ && git commit -m "test(visual): baseline before design-system migration"
```
2. **Every migration PR runs the suite unchanged.** Drift beyond
`maxDiffPixelRatio` (1%) fails CI. A passing run is the proof that Phase 0/1/2
decomplected the code without moving a pixel the user sees.
## Per-phase application
- **Phase 0** (`@hanzo/ui` on `@hanzo/gui`): baseline captured on 5.x, asserted
after the re-base. The public component surface must match.
- **Phase 1** (brand overlays): capture a per-brand baseline (lux/zoo theme) and
assert the overlay reproduces it — brand look/feel is also a contract.
- **Phase 2** (app adoption): each site's own Playwright suite snapshots its key
pages before/after switching to the unified `ui`.
## Notes
- Non-animated by design (`animations: 'disabled'`) so baselines are
deterministic; the suite runs once with the default driver, not across the
animation-driver matrix.
- `SURFACES` lists the gallery anchors to snapshot — add anchors as demos grow;
`gallery-full` always captures the whole showcase as a backstop.
- Baselines are platform-sensitive (font rendering). Capture and assert on the
same CI runner image; the canonical capture runs on the `hanzo-build-linux-amd64`
pool.
@@ -1,49 +0,0 @@
import { test, expect } from '@playwright/test'
/**
* Visual-regression gate — HIP-0504 §rule 6.
*
* Decomplecting the design system (re-basing @hanzo/ui onto @hanzo/gui, then
* the brand overlays) MUST NOT change the user-visible pixels. This suite
* renders the component gallery and asserts each surface matches a committed
* baseline within tolerance.
*
* Workflow:
* 1. On the PRE-migration ref, capture the baseline:
* pnpm exec playwright test visual-regression --update-snapshots
* Commit the generated __screenshots__/ as the contract.
* 2. On every migration PR, run the suite unchanged. Any pixel drift beyond
* tolerance fails the gate — the implementation changed, the output did not.
*
* Non-animated (default project) so snapshots are deterministic; animations are
* frozen at capture time. Run once with the default driver, not across drivers.
*/
// Stable, brand-agnostic surfaces of the gallery. Extend as demos gain anchors.
const SURFACES = [
{ name: 'gallery-full', path: '/' },
{ name: 'buttons', path: '/#buttons' },
{ name: 'inputs', path: '/#inputs' },
{ name: 'cards', path: '/#cards' },
{ name: 'dialogs', path: '/#dialogs' },
{ name: 'sheets', path: '/#sheets' },
{ name: 'typography', path: '/#typography' },
]
// Tolerance: tiny sub-pixel AA differences across runs are allowed; structural
// or color drift is not. Keep this tight — the contract is "same look/feel".
const SNAPSHOT_OPTS = {
animations: 'disabled' as const,
maxDiffPixelRatio: 0.01,
fullPage: true,
}
for (const surface of SURFACES) {
test(`look/feel stable — ${surface.name}`, async ({ page }) => {
await page.goto(surface.path)
// Let fonts + layout settle so the baseline is deterministic.
await page.waitForLoadState('networkidle')
await page.evaluate(() => document.fonts.ready)
await expect(page).toHaveScreenshot(`${surface.name}.png`, SNAPSHOT_OPTS)
})
}
+1
View File
@@ -1,5 +1,6 @@
{
"compilerOptions": {
"baseUrl": ".",
"module": "esnext",
"moduleResolution": "bundler",
"target": "esnext",
-17
View File
@@ -1,17 +0,0 @@
# Hanzo Team
Native Hanzo Team skeleton on hanzogui — one codebase for web, mobile, and desktop.
Three screens share components with web: Shell (AppHeader: mark, org row,
five-surface switcher), Login (hanzo.id OIDC via system browser + deep link),
Wallet (balance and usage, monochrome tokens).
```bash
bun run dev # web dev server
bun run build:web # web production build → dist/client
bun run typecheck # tsc
bun run ios # native (one / expo)
```
Desktop wraps `dist/client` with Tauri: build web, then `cargo tauri dev` (or
`cargo check`) inside `src-tauri/`.
-16
View File
@@ -1,16 +0,0 @@
{
"expo": {
"name": "Hanzo Team",
"slug": "team",
"scheme": "hanzo-team",
"newArchEnabled": true,
"platforms": ["ios", "android"],
"plugins": ["vxrn/expo-plugin"],
"ios": {
"bundleIdentifier": "ai.hanzo.team"
},
"android": {
"package": "ai.hanzo.team"
}
}
}
-3
View File
@@ -1,3 +0,0 @@
body {
margin: 0;
}
-42
View File
@@ -1,42 +0,0 @@
import './_layout.css'
import { SchemeProvider, useUserScheme } from '@vxrn/color-scheme'
import { Slot } from 'one'
import { GuiProvider } from 'hanzogui'
import { Shell } from '~/components/Shell'
import { SessionProvider } from '~/src/session'
import config from '~/src/gui.config'
export default function Layout() {
return (
<html lang="en">
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.svg" />
<title>Hanzo Team</title>
</head>
<body>
<SchemeProvider>
<Providers>
<SessionProvider>
<Shell>
<Slot />
</Shell>
</SessionProvider>
</Providers>
</SchemeProvider>
</body>
</html>
)
}
const Providers = ({ children }: { children: React.ReactNode }) => {
const userScheme = useUserScheme()
return (
<GuiProvider config={config} defaultTheme={userScheme.value}>
{children}
</GuiProvider>
)
}
-58
View File
@@ -1,58 +0,0 @@
import { useEffect, useState } from 'react'
import { ActivityIndicator } from 'react-native'
import { useLocalSearchParams } from 'one'
import { SizableText, YStack } from 'hanzogui'
import { Mark } from '~/components/Mark'
import { completeWebCallback } from '~/src/auth'
import { persistSession } from '~/src/session'
// Web-only: hanzo.id redirects here with ?code&state. Complete the PKCE exchange,
// persist the session, then hard-navigate home so the session provider re-reads it.
// (Native completes the flow in-process via expo-web-browser; this route is unused there.)
export default function Callback() {
const params = useLocalSearchParams<{ code?: string; state?: string; error?: string }>()
const [error, setError] = useState<string | null>(null)
useEffect(() => {
let live = true
void (async () => {
try {
const session = await completeWebCallback({
code: params.code,
state: params.state,
error: params.error,
})
await persistSession(session)
globalThis.location?.assign('/')
} catch (e) {
if (live) setError(e instanceof Error ? e.message : String(e))
}
})()
return () => {
live = false
}
}, [params.code, params.state, params.error])
return (
<YStack flex={1} items="center" justify="center" p="$4" gap="$4">
<Mark size={44} />
{error == null ? (
<>
<ActivityIndicator />
<SizableText size="$3" color="$color10">
Completing sign-in
</SizableText>
</>
) : (
<YStack items="center" gap="$2">
<SizableText size="$4" fontWeight="600">
Sign-in failed
</SizableText>
<SizableText size="$2" color="$color10">
{error}
</SizableText>
</YStack>
)}
</YStack>
)
}
-59
View File
@@ -1,59 +0,0 @@
import { Link } from 'one'
import { SizableText, XStack, YStack } from 'hanzogui'
import { useSession } from '~/src/session'
export default function Home() {
const { session, loading } = useSession()
const signedIn = !loading && session != null
const who = session?.user?.email ?? session?.user?.name
const views = signedIn
? [{ href: '/wallet', title: 'Wallet', caption: 'Balance and AI usage' } as const]
: [
{ href: '/login', title: 'Sign in', caption: 'hanzo.id single sign-on' } as const,
{ href: '/wallet', title: 'Wallet', caption: 'Balance and AI usage' } as const,
]
return (
<YStack p="$4" gap="$4" maxW={560} width="100%" self="center">
<YStack gap="$1">
<SizableText size="$7" fontWeight="600">
Team
</SizableText>
<SizableText size="$3" color="$color10">
{signedIn && who != null
? `Signed in as ${who}.`
: 'Chat, projects, and planning for your org — one native app for mobile and desktop.'}
</SizableText>
</YStack>
{views.map((view) => (
<Link key={view.href} href={view.href} asChild>
<XStack
bg="$color1"
borderWidth={1}
borderColor="$borderColor"
rounded="$6"
p="$4"
items="center"
justify="space-between"
cursor="pointer"
pressStyle={{ opacity: 0.7 }}
>
<YStack gap="$1">
<SizableText size="$4" fontWeight="600">
{view.title}
</SizableText>
<SizableText size="$2" color="$color10">
{view.caption}
</SizableText>
</YStack>
<SizableText size="$4" color="$color10">
</SizableText>
</XStack>
</Link>
))}
</YStack>
)
}
-46
View File
@@ -1,46 +0,0 @@
import { Redirect } from 'one'
import { Button, SizableText, YStack } from 'hanzogui'
import { Mark } from '~/components/Mark'
import { useSession } from '~/src/session'
// Real hanzo.id OIDC (Authorization Code + PKCE) via the system browser (native) or a
// full-page redirect (web); the session returns through the app deep link / callback
// route. No in-app credential entry, no fake auth. See ~/src/auth.ts.
export default function Login() {
const { session, loading, signingIn, signIn } = useSession()
if (!loading && session != null) return <Redirect href="/" />
return (
<YStack flex={1} items="center" justify="center" p="$4" gap="$4">
<Mark size={44} />
<YStack items="center" gap="$1">
<SizableText size="$6" fontWeight="600">
Sign in to Hanzo
</SizableText>
<SizableText size="$2" color="$color10">
One account for every surface
</SizableText>
</YStack>
<Button
size="$4"
bg="$color"
borderWidth={0}
disabled={signingIn}
opacity={signingIn ? 0.6 : 1}
pressStyle={{ opacity: 0.8, bg: '$color' }}
onPress={() => void signIn()}
>
<Button.Text color="$background" fontWeight="600">
{signingIn ? 'Opening…' : 'Continue with hanzo.id'}
</Button.Text>
</Button>
<SizableText size="$1" color="$color10">
Opens hanzo.id · returns via hanzo-team://callback
</SizableText>
</YStack>
)
}
-100
View File
@@ -1,100 +0,0 @@
import { useEffect, useState } from 'react'
import { Link } from 'one'
import { Separator, SizableText, XStack, YStack } from 'hanzogui'
import { useSession } from '~/src/session'
import { fetchWallet, type Wallet } from '~/src/billing'
// Real balance + usage from billing.hanzo.ai, read with the session's bearer token.
// No hardcoded money: signed-out shows a sign-in prompt; a failed fetch shows an
// honest unavailable state rather than a fake $0.00.
export default function WalletScreen() {
const { session, loading } = useSession()
const [wallet, setWallet] = useState<Wallet | null>(null)
const [state, setState] = useState<'idle' | 'loading' | 'error'>('idle')
useEffect(() => {
const token = session?.accessToken
if (token == null) return
let live = true
setState('loading')
void fetchWallet(token)
.then((w) => {
if (live) {
setWallet(w)
setState('idle')
}
})
.catch(() => {
if (live) setState('error')
})
return () => {
live = false
}
}, [session?.accessToken])
if (!loading && session == null) {
return (
<YStack p="$4" gap="$3" maxW={560} width="100%" self="center">
<SizableText size="$6" fontWeight="600">
Wallet
</SizableText>
<SizableText size="$3" color="$color10">
Sign in to see your balance and AI usage.
</SizableText>
<Link href="/login" asChild>
<XStack
bg="$color"
rounded="$10"
px="$4"
py="$2.5"
self="flex-start"
cursor="pointer"
pressStyle={{ opacity: 0.8 }}
>
<SizableText size="$3" fontWeight="600" color="$background">
Sign in
</SizableText>
</XStack>
</Link>
</YStack>
)
}
const balance = wallet != null ? `$${wallet.balanceUsd.toFixed(2)}` : state === 'error' ? '—' : '…'
return (
<YStack p="$4" gap="$4" maxW={560} width="100%" self="center">
<YStack bg="$color1" borderWidth={1} borderColor="$borderColor" rounded="$6" p="$4" gap="$1">
<SizableText size="$2" color="$color10">
Balance
</SizableText>
<SizableText size="$9" fontWeight="600">
{balance}
</SizableText>
<SizableText size="$1" color="$color10">
{state === 'error'
? 'Balance unavailable right now'
: 'Usage-metered · billed via billing.hanzo.ai'}
</SizableText>
</YStack>
{wallet != null && wallet.usage.length > 0 ? (
<YStack bg="$color1" borderWidth={1} borderColor="$borderColor" rounded="$6">
{wallet.usage.map((row, index) => (
<YStack key={row.label}>
{index > 0 ? <Separator borderColor="$borderColor" /> : null}
<XStack p="$3.5" items="center" justify="space-between">
<SizableText size="$3" color="$color10">
{row.label}
</SizableText>
<SizableText size="$3" fontWeight="600">
{row.value}
</SizableText>
</XStack>
</YStack>
))}
</YStack>
) : null}
</YStack>
)
}
-14
View File
@@ -1,14 +0,0 @@
import { useTheme } from 'hanzogui'
import Svg, { Path } from 'react-native-svg'
const d =
'M45.9269 46.3536C45.65 45.3274 44.9038 44.8299 43.8628 44.9314C41.8474 45.1268 41.1782 44.4808 40.2218 43.0613C42.7423 43.5952 44.3679 42.9389 44.8756 42.6471C45.5295 42.2694 46.191 41.785 45.9295 40.9306C45.6731 40.0945 44.9038 39.9278 44.1141 40.0789C42.9936 40.2925 41.9192 40.4175 41.0474 39.3261C41.232 39.1594 41.4166 38.9875 41.6064 38.8234C42.0705 38.4249 42.4295 37.9561 42.1936 37.3231C41.9679 36.7188 41.3961 36.3724 40.8141 36.4792C40.3397 36.5678 39.3884 36.8647 39.0987 36.3802L39.1115 36.375C39.0013 36.349 38.832 35.8957 38.7961 35.5649C38.6269 33.9474 39.3269 32.395 40.2705 31.1057C41.4474 29.5038 42.2551 27.7378 42.6141 25.7791C43.1756 22.7055 42.5807 19.8456 40.9192 17.2174C38.9474 14.097 36.1679 12.0419 32.509 11.2865C31.65 11.1094 30.8115 11.0156 29.9987 11C29.1833 11.0156 28.3474 11.1094 27.4884 11.2865C23.832 12.0419 21.05 14.097 19.0782 17.2174C17.4192 19.8456 16.8243 22.7055 17.3833 25.7791C17.7397 27.7404 18.55 29.5038 19.7269 31.1057C20.6731 32.395 21.3731 33.9448 21.2013 35.5649C21.1654 35.8957 20.9961 36.349 20.8859 36.375L20.8987 36.3802C20.609 36.8621 19.6602 36.5678 19.1833 36.4792C18.6038 36.3724 18.0295 36.7188 17.8038 37.3231C17.5679 37.9561 17.9243 38.4249 18.391 38.8234C18.5807 38.9875 18.7654 39.1594 18.95 39.3261C18.0782 40.4175 17.0013 40.2925 15.8833 40.0789C15.0936 39.9278 14.3243 40.0945 14.0679 40.9306C13.8064 41.785 14.4679 42.2694 15.1218 42.6471C15.6269 42.9389 17.2551 43.5926 19.7756 43.0613C18.8192 44.4808 18.15 45.1268 16.1346 44.9314C15.0936 44.8299 14.3448 45.3274 14.0705 46.3536C13.8013 47.3564 14.332 48.0753 15.1731 48.5546C15.7243 48.8672 16.3397 48.9896 16.991 49C20.5577 49.0495 23.5013 47.5778 26.1525 45.3638C27.6115 44.1448 28.8115 43.5275 30.0013 43.5119C31.191 43.5275 32.3884 44.1448 33.85 45.3638C36.5013 47.5778 39.4448 49.0495 43.0115 49C43.6628 48.9922 44.2782 48.8672 44.8295 48.5546C45.6731 48.0753 46.2013 47.3564 45.932 46.3536H45.9269ZM25.0756 34.2027C23.9731 34.2001 23.0628 33.278 23.0602 32.1658C23.0602 31.0328 23.9807 30.1055 25.1166 30.0951C26.1961 30.0847 27.1577 31.1083 27.1474 32.257C27.1372 33.3952 26.2705 34.2079 25.0756 34.2027ZM34.9192 34.2027C33.7218 34.2079 32.8577 33.3926 32.8474 32.257C32.8372 31.1083 33.8013 30.0847 34.8782 30.0951C36.0141 30.1081 36.9346 31.0354 36.9346 32.1658C36.9346 33.278 36.0218 34.2001 34.9192 34.2027Z'
export const Mark = ({ size = 24 }: { size?: number }) => {
const theme = useTheme()
return (
<Svg width={size} height={size} viewBox="10 8 40 44" fill="none">
<Path d={d} fill={theme.color.get()} />
</Svg>
)
}
-30
View File
@@ -1,30 +0,0 @@
import { Moon, Sun, SunMoon } from '@hanzogui/lucide-icons-2'
import { useSystemScheme, useUserScheme } from '@vxrn/color-scheme'
import { Appearance } from 'react-native'
import { isWeb, View } from 'hanzogui'
const order = ['system', 'light', 'dark'] as const
export const SchemeToggle = () => {
const userScheme = useUserScheme()
const systemScheme = useSystemScheme()
const Icon =
userScheme.setting === 'system' ? SunMoon : userScheme.setting === 'dark' ? Moon : Sun
return (
<View
p="$2"
cursor="pointer"
pressStyle={{ opacity: 0.6 }}
onPress={() => {
const next = order[(order.indexOf(userScheme.setting) + 1) % 3]
if (!isWeb) {
Appearance.setColorScheme(next === 'system' ? systemScheme : next)
}
userScheme.set(next)
}}
>
<Icon size={18} color="$color10" />
</View>
)
}
-152
View File
@@ -1,152 +0,0 @@
import { useState } from 'react'
import { Linking } from 'react-native'
import { useRouter } from 'one'
import { ChevronDown } from '@hanzogui/lucide-icons-2'
import { ScrollView, SizableText, XStack, YStack } from 'hanzogui'
import { Mark } from './Mark'
import { SchemeToggle } from './Scheme'
import { SURFACES } from '~/src/surfaces'
import { useSession } from '~/src/session'
// app frame: AppHeader (mark · org switcher · seven-surface switcher · auth) over content
export const Shell = ({ children }: { children: React.ReactNode }) => {
const router = useRouter()
const { session, signOut } = useSession()
const [orgOpen, setOrgOpen] = useState(false)
const [activeOrg, setActiveOrg] = useState<string | undefined>(undefined)
const orgs = session?.user?.orgs ?? []
const currentOrg = activeOrg ?? orgs[0] ?? 'Hanzo'
const signedIn = session != null
return (
<YStack flex={1} minH="100%" bg="$background">
<YStack borderBottomWidth={1} borderColor="$borderColor" px="$4" pt="$3" pb="$2" gap="$2">
<XStack items="center" gap="$3">
<Mark size={22} />
<YStack position="relative">
<XStack
items="center"
gap="$2"
rounded="$4"
px="$2"
py="$1"
cursor="pointer"
pressStyle={{ bg: '$color1' }}
onPress={() => setOrgOpen((v) => (orgs.length > 1 ? !v : v))}
>
<YStack width={20} height={20} rounded={999} bg="$color" items="center" justify="center">
<SizableText size="$1" fontWeight="700" color="$background">
{currentOrg.charAt(0).toUpperCase()}
</SizableText>
</YStack>
<SizableText size="$3" fontWeight="600">
{currentOrg}
</SizableText>
{orgs.length > 1 ? <ChevronDown size={14} color="$color10" /> : null}
</XStack>
{orgOpen && orgs.length > 1 ? (
<YStack
position="absolute"
t={38}
l={0}
minW={180}
bg="$background"
borderWidth={1}
borderColor="$borderColor"
rounded="$4"
py="$1"
z={100}
>
{orgs.map((org) => (
<XStack
key={org}
px="$3"
py="$2"
cursor="pointer"
pressStyle={{ bg: '$color1' }}
onPress={() => {
setActiveOrg(org)
setOrgOpen(false)
}}
>
<SizableText size="$3" color={org === currentOrg ? '$color' : '$color10'}>
{org}
</SizableText>
</XStack>
))}
</YStack>
) : null}
</YStack>
<XStack flex={1} />
{signedIn ? (
<XStack
rounded="$10"
px="$3"
py="$1.5"
borderWidth={1}
borderColor="$borderColor"
cursor="pointer"
pressStyle={{ opacity: 0.6 }}
onPress={() => void signOut()}
>
<SizableText size="$2" color="$color10">
Sign out
</SizableText>
</XStack>
) : (
<XStack
rounded="$10"
px="$3"
py="$1.5"
bg="$color"
cursor="pointer"
pressStyle={{ opacity: 0.8 }}
onPress={() => router.push('/login')}
>
<SizableText size="$2" color="$background" fontWeight="600">
Sign in
</SizableText>
</XStack>
)}
<SchemeToggle />
</XStack>
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
<XStack gap="$2">
{SURFACES.map((surface) => {
const active = surface.id === 'team'
return (
<XStack
key={surface.id}
rounded="$10"
px="$3"
py="$1.5"
borderWidth={1}
borderColor={active ? '$borderColor' : 'transparent'}
bg={active ? '$color1' : 'transparent'}
cursor="pointer"
pressStyle={{ opacity: 0.6 }}
onPress={() => {
if (!active) void Linking.openURL(surface.href)
}}
>
<SizableText size="$2" color={active ? '$color' : '$color10'}>
{surface.label}
</SizableText>
</XStack>
)
})}
</XStack>
</ScrollView>
</YStack>
{children}
</YStack>
)
}
-7
View File
@@ -1,7 +0,0 @@
import type { GuiBuildOptions } from '@hanzogui/core'
export default {
components: ['hanzogui'],
config: './src/gui.config.ts',
disableExtraction: true,
} satisfies GuiBuildOptions
-36
View File
@@ -1,36 +0,0 @@
{
"name": "@hanzogui/team",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "one dev",
"build:web": "one build",
"serve": "one serve",
"ios": "one run:ios",
"android": "one run:android",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@hanzogui/config": "workspace:*",
"@hanzogui/core": "workspace:*",
"@hanzogui/lucide-icons-2": "workspace:*",
"@vxrn/color-scheme": "^1.12.5",
"expo": "~55.0.6",
"one": "1.12.5",
"react": ">=19",
"react-native": "0.83.2",
"react-native-safe-area-context": "~5.7.0",
"react-native-screens": "~4.23.0",
"react-native-svg": "15.15.3",
"react-native-web": "^0.21.0",
"hanzogui": "workspace:*"
},
"devDependencies": {
"@hanzogui/vite-plugin": "workspace:*",
"@types/react": "~19.1.10",
"vite": "^8.0.3"
},
"license": "BSD-3-Clause",
"author": "Hanzo AI <dev@hanzo.ai>"
}
-4
View File
@@ -1,4 +0,0 @@
<svg width="60" height="60" viewBox="0 0 60 60" xmlns="http://www.w3.org/2000/svg">
<rect width="60" height="60" rx="14" fill="#000000"/>
<path d="M45.9269 46.3536C45.65 45.3274 44.9038 44.8299 43.8628 44.9314C41.8474 45.1268 41.1782 44.4808 40.2218 43.0613C42.7423 43.5952 44.3679 42.9389 44.8756 42.6471C45.5295 42.2694 46.191 41.785 45.9295 40.9306C45.6731 40.0945 44.9038 39.9278 44.1141 40.0789C42.9936 40.2925 41.9192 40.4175 41.0474 39.3261C41.232 39.1594 41.4166 38.9875 41.6064 38.8234C42.0705 38.4249 42.4295 37.9561 42.1936 37.3231C41.9679 36.7188 41.3961 36.3724 40.8141 36.4792C40.3397 36.5678 39.3884 36.8647 39.0987 36.3802L39.1115 36.375C39.0013 36.349 38.832 35.8957 38.7961 35.5649C38.6269 33.9474 39.3269 32.395 40.2705 31.1057C41.4474 29.5038 42.2551 27.7378 42.6141 25.7791C43.1756 22.7055 42.5807 19.8456 40.9192 17.2174C38.9474 14.097 36.1679 12.0419 32.509 11.2865C31.65 11.1094 30.8115 11.0156 29.9987 11C29.1833 11.0156 28.3474 11.1094 27.4884 11.2865C23.832 12.0419 21.05 14.097 19.0782 17.2174C17.4192 19.8456 16.8243 22.7055 17.3833 25.7791C17.7397 27.7404 18.55 29.5038 19.7269 31.1057C20.6731 32.395 21.3731 33.9448 21.2013 35.5649C21.1654 35.8957 20.9961 36.349 20.8859 36.375L20.8987 36.3802C20.609 36.8621 19.6602 36.5678 19.1833 36.4792C18.6038 36.3724 18.0295 36.7188 17.8038 37.3231C17.5679 37.9561 17.9243 38.4249 18.391 38.8234C18.5807 38.9875 18.7654 39.1594 18.95 39.3261C18.0782 40.4175 17.0013 40.2925 15.8833 40.0789C15.0936 39.9278 14.3243 40.0945 14.0679 40.9306C13.8064 41.785 14.4679 42.2694 15.1218 42.6471C15.6269 42.9389 17.2551 43.5926 19.7756 43.0613C18.8192 44.4808 18.15 45.1268 16.1346 44.9314C15.0936 44.8299 14.3448 45.3274 14.0705 46.3536C13.8013 47.3564 14.332 48.0753 15.1731 48.5546C15.7243 48.8672 16.3397 48.9896 16.991 49C20.5577 49.0495 23.5013 47.5778 26.1525 45.3638C27.6115 44.1448 28.8115 43.5275 30.0013 43.5119C31.191 43.5275 32.3884 44.1448 33.85 45.3638C36.5013 47.5778 39.4448 49.0495 43.0115 49C43.6628 48.9922 44.2782 48.8672 44.8295 48.5546C45.6731 48.0753 46.2013 47.3564 45.932 46.3536H45.9269ZM25.0756 34.2027C23.9731 34.2001 23.0628 33.278 23.0602 32.1658C23.0602 31.0328 23.9807 30.1055 25.1166 30.0951C26.1961 30.0847 27.1577 31.1083 27.1474 32.257C27.1372 33.3952 26.2705 34.2079 25.0756 34.2027ZM34.9192 34.2027C33.7218 34.2079 32.8577 33.3926 32.8474 32.257C32.8372 31.1083 33.8013 30.0847 34.8782 30.0951C36.0141 30.1081 36.9346 31.0354 36.9346 32.1658C36.9346 33.278 36.0218 34.2001 34.9192 34.2027Z" fill="#ffffff"/>
</svg>

Before

Width:  |  Height:  |  Size: 2.5 KiB

-2
View File
@@ -1,2 +0,0 @@
/target
/gen/schemas
-4392
View File
File diff suppressed because it is too large Load Diff
-12
View File
@@ -1,12 +0,0 @@
[package]
name = "team"
version = "0.1.0"
description = "Hanzo Team desktop shell"
license = "BSD-3-Clause"
edition = "2021"
[build-dependencies]
tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = [] }
-3
View File
@@ -1,3 +0,0 @@
fn main() {
tauri_build::build()
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.9 KiB

-7
View File
@@ -1,7 +0,0 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
tauri::Builder::default()
.run(tauri::generate_context!())
.expect("error while running hanzo team");
}
-24
View File
@@ -1,24 +0,0 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Hanzo Team",
"version": "0.1.0",
"identifier": "ai.hanzo.team",
"build": {
"frontendDist": "../dist/client"
},
"app": {
"windows": [
{
"title": "Hanzo Team",
"width": 1200,
"height": 800
}
],
"security": {
"csp": null
}
},
"bundle": {
"active": false
}
}
-250
View File
@@ -1,250 +0,0 @@
// Real hanzo.id OIDC — Authorization Code + PKCE. This module is the protocol (no
// platform imports): it builds the authorize URL, exchanges the code, and reads the
// profile. The browser step is delegated to the platform-split ./oidc-browser (native
// opens the system browser; web redirects the page), so expo stays out of the web bundle.
import { CLIENT_ID, ISSUER, SCOPES } from './oidc-config'
import { authorize, redirectUri } from './oidc-browser'
import type { AuthOutcome } from './oidc-types'
export { CLIENT_ID, redirectUri }
export interface Tokens {
accessToken: string
refreshToken?: string
/** epoch millis the access token expires, when the server reports expires_in */
expiresAt?: number
}
export interface UserInfo {
sub: string
name?: string
email?: string
/** org slugs the account belongs to, when the IAM userinfo carries groups */
orgs?: string[]
}
export interface Session extends Tokens {
user?: UserInfo
}
interface Endpoints {
authorization: string
token: string
userinfo: string
}
// IAM's endpoint paths, used when discovery is unreachable.
const FALLBACK: Endpoints = {
authorization: `${ISSUER}/login/oauth/authorize`,
token: `${ISSUER}/api/login/oauth/access_token`,
userinfo: `${ISSUER}/api/userinfo`,
}
let endpointsCache: Endpoints | undefined
/** Resolve OIDC endpoints from the discovery document, falling back to IAM's paths. */
export async function discover(): Promise<Endpoints> {
if (endpointsCache !== undefined) return endpointsCache
try {
const res = await fetch(`${ISSUER}/.well-known/openid-configuration`)
if (res.ok) {
const d = (await res.json()) as Record<string, string>
endpointsCache = {
authorization: d.authorization_endpoint ?? FALLBACK.authorization,
token: d.token_endpoint ?? FALLBACK.token,
userinfo: d.userinfo_endpoint ?? FALLBACK.userinfo,
}
return endpointsCache
}
} catch {
// discovery blocked — fall through to the known IAM paths
}
endpointsCache = FALLBACK
return endpointsCache
}
const UNRESERVED = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~'
function randomString(length: number): string {
const bytes = new Uint8Array(length)
const c = globalThis.crypto
if (c?.getRandomValues != null) c.getRandomValues(bytes)
else for (let i = 0; i < length; i++) bytes[i] = Math.floor(Math.random() * 256)
let out = ''
for (let i = 0; i < length; i++) out += UNRESERVED[bytes[i] % UNRESERVED.length]
return out
}
function base64Url(bytes: Uint8Array): string {
let bin = ''
for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i])
const b64 = typeof btoa === 'function' ? btoa(bin) : Buffer.from(bytes).toString('base64')
return b64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
}
interface Challenge {
value: string
method: 'S256' | 'plain'
}
/** PKCE challenge: SHA-256 where a subtle-crypto digest exists (web), else plain. */
async function codeChallenge(verifier: string): Promise<Challenge> {
const subtle = globalThis.crypto?.subtle
if (subtle?.digest != null) {
const data = new TextEncoder().encode(verifier)
const digest = await subtle.digest('SHA-256', data)
return { value: base64Url(new Uint8Array(digest)), method: 'S256' }
}
return { value: verifier, method: 'plain' }
}
function buildAuthUrl(opts: {
endpoint: string
state: string
challenge: Challenge
redirect: string
}): string {
const q = new URLSearchParams({
client_id: CLIENT_ID,
response_type: 'code',
scope: SCOPES,
redirect_uri: opts.redirect,
state: opts.state,
code_challenge: opts.challenge.value,
code_challenge_method: opts.challenge.method,
})
return `${opts.endpoint}?${q.toString()}`
}
function parseCallback(url: string): { code?: string; state?: string; error?: string } {
const q = url.includes('?') ? url.slice(url.indexOf('?') + 1) : ''
const p = new URLSearchParams(q)
return {
code: p.get('code') ?? undefined,
state: p.get('state') ?? undefined,
error: p.get('error') ?? undefined,
}
}
async function exchangeCode(code: string, verifier: string, redirect: string): Promise<Tokens> {
const { token } = await discover()
const body = new URLSearchParams({
grant_type: 'authorization_code',
code,
redirect_uri: redirect,
client_id: CLIENT_ID,
code_verifier: verifier,
})
const res = await fetch(token, {
method: 'POST',
headers: { 'content-type': 'application/x-www-form-urlencoded', accept: 'application/json' },
body: body.toString(),
})
if (!res.ok) throw new Error(`token exchange failed (${res.status})`)
const d = (await res.json()) as Record<string, unknown>
const accessToken = d.access_token
if (typeof accessToken !== 'string' || accessToken.length === 0) {
throw new Error(typeof d.error === 'string' ? d.error : 'no access_token in token response')
}
const expiresIn = typeof d.expires_in === 'number' ? d.expires_in : undefined
return {
accessToken,
refreshToken: typeof d.refresh_token === 'string' ? d.refresh_token : undefined,
expiresAt: expiresIn !== undefined ? Date.now() + expiresIn * 1000 : undefined,
}
}
/** Read the signed-in profile from the OIDC userinfo endpoint. */
export async function fetchUserInfo(accessToken: string): Promise<UserInfo | undefined> {
try {
const { userinfo } = await discover()
const res = await fetch(userinfo, { headers: { authorization: `Bearer ${accessToken}` } })
if (!res.ok) return undefined
const d = (await res.json()) as Record<string, unknown>
const groups = Array.isArray(d.groups)
? (d.groups as unknown[]).filter((g): g is string => typeof g === 'string')
: undefined
return {
sub: String(d.sub ?? d.id ?? ''),
name:
typeof d.name === 'string'
? d.name
: typeof d.preferred_username === 'string'
? d.preferred_username
: undefined,
email: typeof d.email === 'string' ? d.email : undefined,
orgs: groups,
}
} catch {
return undefined
}
}
/** Web PKCE spans a page navigation — stash the verifier/state for the /callback route. */
const PENDING_KEY = 'hanzo-team-oidc-pending'
interface Pending {
verifier: string
state: string
redirect: string
}
function savePending(p: Pending): void {
globalThis.sessionStorage?.setItem(PENDING_KEY, JSON.stringify(p))
}
function loadPending(): Pending | undefined {
const raw = globalThis.sessionStorage?.getItem(PENDING_KEY)
return raw != null ? (JSON.parse(raw) as Pending) : undefined
}
function clearPending(): void {
globalThis.sessionStorage?.removeItem(PENDING_KEY)
}
export type LoginResult =
| { status: 'session'; session: Session }
| { status: 'redirecting' }
| { status: 'cancelled' }
/**
* Begin sign-in. Native completes in-process (returns the session); web navigates
* away and completes on the /callback route (returns `redirecting`).
*/
export async function login(): Promise<LoginResult> {
const { authorization } = await discover()
const redirect = redirectUri()
const verifier = randomString(64)
const state = randomString(24)
const challenge = await codeChallenge(verifier)
const url = buildAuthUrl({ endpoint: authorization, state, challenge, redirect })
// Persist for the web /callback route; a no-op on native (no sessionStorage).
savePending({ verifier, state, redirect })
const outcome: AuthOutcome = await authorize(url)
if (outcome.kind === 'redirecting') return { status: 'redirecting' }
if (outcome.kind === 'cancelled') return { status: 'cancelled' }
const cb = parseCallback(outcome.url)
if (cb.error != null) throw new Error(cb.error)
if (cb.code == null) throw new Error('no authorization code in callback')
if (cb.state !== state) throw new Error('state mismatch')
const tokens = await exchangeCode(cb.code, verifier, redirect)
const user = await fetchUserInfo(tokens.accessToken)
return { status: 'session', session: { ...tokens, user } }
}
/** Complete the web redirect flow from the /callback route's query string. */
export async function completeWebCallback(query: {
code?: string
state?: string
error?: string
}): Promise<Session> {
if (query.error != null) throw new Error(query.error)
const pending = loadPending()
clearPending()
if (pending == null) throw new Error('no pending sign-in')
if (query.code == null) throw new Error('no authorization code')
if (query.state !== pending.state) throw new Error('state mismatch')
const tokens = await exchangeCode(query.code, pending.verifier, pending.redirect)
const user = await fetchUserInfo(tokens.accessToken)
return { ...tokens, user }
}
-56
View File
@@ -1,56 +0,0 @@
// Real wallet data — balance + usage read from the billing surface with the session's
// bearer token. No hardcoded money: on error the wallet shows an honest unavailable
// state, never a fake $0.00.
const BILLING_BASE = 'https://billing.hanzo.ai'
export interface UsageRow {
label: string
value: string
}
export interface Wallet {
/** balance in USD */
balanceUsd: number
usage: UsageRow[]
}
function num(v: unknown): number | undefined {
if (typeof v === 'number' && Number.isFinite(v)) return v
if (typeof v === 'string' && v.trim() !== '' && Number.isFinite(Number(v))) return Number(v)
return undefined
}
function usd(record: Record<string, unknown>): number | undefined {
// atto-USD (18-dec) is the canonical money unit; also accept plain USD / cents fields.
const atto = record.balance_atto_usd ?? record.balanceAtto ?? record.atto_usd
if (typeof atto === 'string' && /^-?\d+$/.test(atto)) return Number(BigInt(atto)) / 1e18
const dollars = num(record.balance_usd ?? record.balanceUsd ?? record.balance ?? record.credits)
if (dollars !== undefined) return dollars
const cents = num(record.balance_cents ?? record.balanceCents)
if (cents !== undefined) return cents / 100
return undefined
}
/** GET the org wallet for the signed-in session. Throws on any non-2xx / bad shape. */
export async function fetchWallet(accessToken: string): Promise<Wallet> {
const res = await fetch(`${BILLING_BASE}/v1/billing/balance`, {
headers: { authorization: `Bearer ${accessToken}`, accept: 'application/json' },
})
if (!res.ok) throw new Error(`billing ${res.status}`)
const data = (await res.json()) as Record<string, unknown>
const balanceUsd = usd(data)
if (balanceUsd === undefined) throw new Error('no balance in billing response')
const usage: UsageRow[] = []
const period = data.period ?? data.month
if (typeof period === 'string') usage.push({ label: 'Period', value: period })
const spent = num(data.spent_usd ?? data.spentUsd ?? data.usage_usd)
if (spent !== undefined) usage.push({ label: 'Spent this period', value: `$${spent.toFixed(2)}` })
const requests = num(data.requests ?? data.request_count)
if (requests !== undefined) usage.push({ label: 'Requests', value: String(requests) })
const seats = num(data.seats ?? data.seat_count)
if (seats !== undefined) usage.push({ label: 'Seats', value: String(seats) })
return { balanceUsd, usage }
}
-30
View File
@@ -1,30 +0,0 @@
import { defaultConfig } from '@hanzogui/config/v5'
import { createGui } from 'hanzogui'
// hanzo monochrome: dark #000 / #0a0a0a / #1f1f1f · light #fff / #f7f7f7 / #ebebeb
export const config = createGui({
...defaultConfig,
themes: {
...defaultConfig.themes,
dark: {
...defaultConfig.themes.dark,
background: '#000000',
color1: '#0a0a0a',
borderColor: '#1f1f1f',
},
light: {
...defaultConfig.themes.light,
background: '#ffffff',
color1: '#f7f7f7',
borderColor: '#ebebeb',
},
},
})
export type Conf = typeof config
declare module 'hanzogui' {
interface GuiCustomConfig extends Conf {}
}
export default config
-18
View File
@@ -1,18 +0,0 @@
// NATIVE authorize step: open hanzo.id in the system browser and read the
// `hanzo-team://callback` deep link back. Keeps the expo modules (which pull
// expo-modules-core) out of the web bundle — the web build resolves oidc-browser.web.ts.
import * as WebBrowser from 'expo-web-browser'
import * as Linking from 'expo-linking'
import { REDIRECT_SCHEME } from './oidc-config'
import type { AuthOutcome } from './oidc-types'
export function redirectUri(): string {
return Linking.createURL('callback', { scheme: REDIRECT_SCHEME })
}
export async function authorize(url: string): Promise<AuthOutcome> {
const result = await WebBrowser.openAuthSessionAsync(url, redirectUri())
if (result.type !== 'success' || result.url == null) return { kind: 'cancelled' }
return { kind: 'callback', url: result.url }
}
-13
View File
@@ -1,13 +0,0 @@
// WEB authorize step: full-page redirect to hanzo.id. The /callback route completes
// the exchange. No expo imports here, so the web bundle never pulls expo-modules-core.
import type { AuthOutcome } from './oidc-types'
export function redirectUri(): string {
return `${globalThis.location?.origin ?? 'https://hanzo.team'}/callback`
}
export async function authorize(url: string): Promise<AuthOutcome> {
globalThis.location?.assign(url)
return { kind: 'redirecting' }
}
-9
View File
@@ -1,9 +0,0 @@
// Shared hanzo.id OIDC constants (no platform imports) so both the protocol module
// (auth.ts) and the platform-split browser modules read one source of truth.
/** The public native client registered in IAM (hanzo.id). */
export const CLIENT_ID = 'hanzo-team-native'
export const ISSUER = 'https://hanzo.id'
export const SCOPES = 'openid profile email'
/** Deep-link scheme (app.json `scheme`) the IAM redirect must whitelist. */
export const REDIRECT_SCHEME = 'hanzo-team'
-6
View File
@@ -1,6 +0,0 @@
// The result of driving the authorize step, produced by the platform-split
// ./oidc-browser module (native uses the system browser; web redirects the page).
export type AuthOutcome =
| { kind: 'callback'; url: string } // native: the app-scheme redirect came back
| { kind: 'redirecting' } // web: the page navigated to hanzo.id
| { kind: 'cancelled' } // the user dismissed the browser
-91
View File
@@ -1,91 +0,0 @@
// One session store for every screen. Persists the OIDC session (AsyncStorage works
// on web via localStorage and on native), exposes it through React context, and owns
// sign-in / sign-out so the screens never touch the protocol directly.
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'
import { getItem, removeItem, setItem } from './store'
import { login as oidcLogin, type Session } from './auth'
const STORAGE_KEY = 'hanzo-team-session'
async function load(): Promise<Session | null> {
const raw = await getItem(STORAGE_KEY)
if (raw == null) return null
try {
return JSON.parse(raw) as Session
} catch {
return null
}
}
async function save(session: Session | null): Promise<void> {
if (session == null) await removeItem(STORAGE_KEY)
else await setItem(STORAGE_KEY, JSON.stringify(session))
}
export interface SessionState {
session: Session | null
loading: boolean
/** true while a sign-in is in flight */
signingIn: boolean
signIn: () => Promise<void>
signOut: () => Promise<void>
}
const SessionContext = createContext<SessionState | undefined>(undefined)
export function SessionProvider({ children }: { children: React.ReactNode }): React.ReactElement {
const [session, setSession] = useState<Session | null>(null)
const [loading, setLoading] = useState(true)
const [signingIn, setSigningIn] = useState(false)
useEffect(() => {
let live = true
void load().then((s) => {
if (live) {
setSession(s)
setLoading(false)
}
})
return () => {
live = false
}
}, [])
const signIn = useCallback(async () => {
setSigningIn(true)
try {
const result = await oidcLogin()
if (result.status === 'session') {
await save(result.session)
setSession(result.session)
}
// 'redirecting' (web) completes on the /callback route; 'cancelled' is a no-op
} finally {
setSigningIn(false)
}
}, [])
const signOut = useCallback(async () => {
await save(null)
setSession(null)
}, [])
const value = useMemo<SessionState>(
() => ({ session, loading, signingIn, signIn, signOut }),
[session, loading, signingIn, signIn, signOut],
)
return <SessionContext.Provider value={value}>{children}</SessionContext.Provider>
}
export function useSession(): SessionState {
const ctx = useContext(SessionContext)
if (ctx === undefined) throw new Error('useSession must be used within a SessionProvider')
return ctx
}
/** Persist a session obtained outside the provider (the web /callback route). */
export async function persistSession(session: Session): Promise<void> {
await save(session)
}
-8
View File
@@ -1,8 +0,0 @@
// NATIVE key/value store — AsyncStorage. Split so the web bundle (store.web.ts) uses
// localStorage directly and never pulls the native module into the web build.
import AsyncStorage from '@react-native-async-storage/async-storage'
export const getItem = (key: string): Promise<string | null> => AsyncStorage.getItem(key)
export const setItem = (key: string, value: string): Promise<void> => AsyncStorage.setItem(key, value)
export const removeItem = (key: string): Promise<void> => AsyncStorage.removeItem(key)
-11
View File
@@ -1,11 +0,0 @@
// WEB key/value store — localStorage, matching the async store.ts signature.
export async function getItem(key: string): Promise<string | null> {
return globalThis.localStorage?.getItem(key) ?? null
}
export async function setItem(key: string, value: string): Promise<void> {
globalThis.localStorage?.setItem(key, value)
}
export async function removeItem(key: string): Promise<void> {
globalThis.localStorage?.removeItem(key)
}
-30
View File
@@ -1,30 +0,0 @@
// MIRROR of the canonical hanzoai/ui `pkg/ui/src/product/surfaces.data.ts` — keep
// the data byte-identical. This native app cannot resolve the @hanzo/ui package, so
// the ONE cross-surface app-switcher list is mirrored here. Update all mirrors in the
// same change (hanzoai/ui, hanzoai/team Svelte fork, and here).
export type SurfaceId = 'ai' | 'console' | 'app' | 'chat' | 'bot' | 'team' | 'billing'
/** One Hanzo surface the app switcher offers. */
export interface Surface {
id: SurfaceId
label: string
href: string
hint: string
}
/** The seven Hanzo surfaces (`console` opens the cloud AI console). */
export const SURFACES: Surface[] = [
{ id: 'ai', label: 'Hanzo AI', href: 'https://hanzo.ai', hint: 'hanzo.ai' },
{ id: 'console', label: 'Console', href: 'https://console.hanzo.ai', hint: 'console.hanzo.ai' },
{ id: 'app', label: 'App', href: 'https://hanzo.app', hint: 'hanzo.app' },
{ id: 'chat', label: 'Chat', href: 'https://hanzo.chat', hint: 'hanzo.chat' },
{ id: 'bot', label: 'Bot', href: 'https://hanzo.bot', hint: 'hanzo.bot' },
{ id: 'team', label: 'Team', href: 'https://hanzo.team', hint: 'hanzo.team' },
{ id: 'billing', label: 'Billing', href: 'https://billing.hanzo.ai', hint: 'billing.hanzo.ai' },
]
/** Every surface except `current` — a launcher never links to itself. */
export function otherSurfaces(current?: SurfaceId): Surface[] {
return current !== undefined ? SURFACES.filter((s) => s.id !== current) : SURFACES
}
-18
View File
@@ -1,18 +0,0 @@
{
"compilerOptions": {
"module": "esnext",
"moduleResolution": "bundler",
"target": "esnext",
"lib": ["dom", "dom.iterable", "esnext"],
"jsx": "react-jsx",
"strict": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"skipLibCheck": true,
"paths": {
"~/*": ["./*"]
},
"types": ["vite/client"],
"noEmit": true
}
}
-21
View File
@@ -1,21 +0,0 @@
import { guiPlugin } from '@hanzogui/vite-plugin'
import { one } from 'one/vite'
import type { UserConfig } from 'vite'
export default {
clearScreen: false,
plugins: [
one({
ssr: {
dedupeSymlinkedModules: true,
},
web: {
defaultRenderMode: 'spa',
},
}),
guiPlugin(),
],
} satisfies UserConfig
+1
View File
@@ -4,6 +4,7 @@
"allowSyntheticDefaultImports": true,
"forceConsistentCasingInFileNames": true,
"declaration": true,
"downlevelIteration": true,
"strict": true,
"esModuleInterop": true,
"inlineSourceMap": true,
+4 -2
View File
@@ -1,6 +1,8 @@
{
"compilerOptions": {
"composite": true,
// for jumping to things properly in vscode
"baseUrl": ".",
"paths": {
"hanzogui": [
"../ui/gui"
@@ -27,7 +29,7 @@
"./data/*"
],
"react-native": [
"./react-native-web"
"react-native-web"
]
},
"target": "es2021",
@@ -40,7 +42,7 @@
"allowJs": true,
"noEmit": true,
"module": "esnext",
"moduleResolution": "bundler",
"moduleResolution": "node",
"isolatedModules": true,
"jsx": "preserve",
"noImplicitThis": true,
+3 -9
View File
@@ -1,9 +1,11 @@
{
"compilerOptions": {
"baseUrl": ".",
"rootDir": ".",
"importHelpers": true,
"allowJs": false,
"allowSyntheticDefaultImports": true,
"downlevelIteration": true,
"esModuleInterop": true,
"preserveSymlinks": true,
"incremental": true,
@@ -24,15 +26,7 @@
"strictNullChecks": true,
"target": "es2020",
"types": ["node"],
"lib": [
"dom",
"esnext"
],
"paths": {
"*": [
"./*"
]
}
"lib": ["dom", "esnext"]
},
"exclude": ["_"],
"typeAcquisition": {
+2585 -1544
View File
File diff suppressed because it is too large Load Diff
-8
View File
@@ -1,8 +0,0 @@
// TypeScript 7 reports TS2882 for a side-effect import with no type
// declaration ("Cannot find module or type declarations for side-effect import
// of './globals.css'"). TS 5.x accepted these silently.
//
// Stylesheets are resolved by the bundler (Next/Vite/webpack), never by the
// TypeScript module resolver, so this declares them legitimate rather than
// giving them a shape.
declare module '*.css';
+1 -2
View File
@@ -16,7 +16,6 @@
"./apps/kitchen-sink-go",
"./apps/kitchen-sink-shared",
"./apps/sandbox",
"./apps/team",
"./apps/tests/**/*",
"./templates/*",
"./pkgs/ai",
@@ -208,4 +207,4 @@
"exports": {
"./package.json": "./package.json"
}
}
}
+59
View File
@@ -0,0 +1,59 @@
# Live chat — VERIFIED working end-to-end (web, local) 2026-06-05
The web app at `:1500` chats with **zen-coder-24b** through a real **hanzo-node**,
fully in the browser. Verified: prompt "Reply with exactly: E2E CHAT OK" →
zen-coder generated `E2E CHAT OK` (7 tokens), streamed back to the chat UI.
The full path:
```
browser :1500 ─(vite proxy /v1,/v2,/ws)→ hanzo-node :3700 ─job→ zen_engine provider
→ responses-proxy :36906 (rewrites /v1/engine/responses → /v1/responses)
→ hanzo-engine :36902 (zen-coder-24b) → "E2E CHAT OK" → streamed back to the UI
```
## The non-obvious fixes that made it work
1. **The node speaks the OpenAI _Responses_ API, the engine serves it at a
different path.** The node's `OpenAI` provider POSTs to
`<url>/v1/engine/responses` (`{input:[...], max_output_tokens, ...}`), but
hanzo-engine serves the Responses API at **`/v1/responses`** (`/v1/engine/...`
→ 404 → the node reports "AI Provider API Error: Unknown error"). A tiny proxy
rewrites the path (and injects `enable_thinking:false`). See
`local-runtime/responses-proxy.py`.
2. **Use `127.0.0.1`, never `localhost`.** `localhost` resolves to IPv6 `::1`
here; the engine/embeddings bind IPv4 only → instant connection failure.
3. **CORS:** the vite dev server proxies the app's own origin (`/v1`,`/v2`,`/ws`)
to the node, so the app's `nodeAddress` is just `http://<host>:1500`.
4. **Two migration import bugs** had to be fixed for the chat screens to render:
`main-layout.tsx` was missing `Box`/`Boxes`/`Coins` lucide imports, and the
merge pulled `react-resizable-panels@4` (renamed exports) — pinned to `^3.0.2`
in `pkgs/net-ui` (the app uses `PanelGroup`/`PanelResizeHandle`).
## Reproduce it
```bash
# 1. engine + embeddings already running: :36902 (zen-coder), :11436 (embed)
# 2. responses-proxy (path rewrite):
python3 pkgs/ai/local-runtime/responses-proxy.py & # :36906 → :36902
# 3. WS proxy (the app derives ws://host:1501 behind the vite proxy):
node pkgs/ai/local-runtime/ws-proxy.js & # :1501 → node WS :3701
# 4. a /v1 hanzo-node wired to the proxy (fresh storage, no reg code):
bash pkgs/ai/local-runtime/run-node.sh & # API :3700, zen_engine → :36906
# 5. web app pointed at the node + engine (engine-api proxy avoids CORS):
cd pkgs/ai/web && VITE_NODE_API=http://127.0.0.1:3700 VITE_NODE_WS=ws://127.0.0.1:3701 \
VITE_ENGINE_BASE_URL=/engine-api VITE_ENGINE_API=http://127.0.0.1:36906 \
bun x vite --config vite.config.ts # :1500
# 6. open http://spark.local:1500 → agree → Quick Connect (node address = same
# origin http://localhost:1500) → /home → chat. Reply streams from zen-coder.
```
## Console — clean (the e2e asserts it)
The earlier web console errors are fixed and guarded by `e2e/chat.e2e.test.ts`:
react-query "data cannot be undefined" (web `invoke` returns null), the
`isPermissionGranted`/notification gap, the `:36900/v1/engine/models` CORS
(routed via the `/engine-api` proxy), the `ws://…:1501` failure (ws-proxy), the
`available_models` 401 (gated `useGetLLMProviders` on auth), and the framer
`motion()` deprecation. Remaining: a rare, intermittent static-asset `404` and a
node-side `Embedding "Query is not read-only"` warning — neither affects chat.
+80
View File
@@ -0,0 +1,80 @@
# @hanzo/ai — one app surface, every platform
The Hanzo / Zoo / Lux AI app is **one** thing: `@hanzo/ai`. Web, desktop and
mobile are not three apps — they are the same app with two axes injected.
```
┌────────────────────────── @hanzo/ai ──────────────────────────┐
│ src/app/ (the migrated shinkai-fork app — ONE copy, one place) │
│ + net-* libs (ui, state, i18n, message-ts, brand-config, logo, │
│ artifacts) + src/host/* (the @tauri-apps shim surface) │
└──────────────────────────────────────────────────────────────────┘
▲ ▲ ▲
host = web (default) host = tauriHost host = expoHost
brand = getBrand() brand = getBrand() brand = getBrand()
│ │ │
hanzo.app / .chat hanzoai/desktop @hanzo/gui
zoo.cloud / lux.cloud zooai/app · luxfi/app (Expo mobile)
```
## The two orthogonal axes (decomplected)
| Axis | What it is | How it's injected | Default |
|---|---|---|---|
| **Brand** | hanzo / zoo / lux identity, cloud endpoints, chain, IAM | `getBrand()` (driven by `VITE_BRAND` or hostname) **or** spread as props `<HanzoAI {...brand}/>` | `getBrand()` → hostname → HANZO |
| **Platform** | how native calls (`invoke`, `listen`, fs, window…) resolve | `host` prop — a `HostAdapter` | web no-ops |
Everything else — the entire app — is shared. A new app is a ~10-line shim:
```tsx
import HanzoAI, { getBrand } from '@hanzo/ai';
import { tauriHost } from './tauri-host'; // desktop only; web omits it
createRoot(root).render(
<HanzoAI {...getBrand()} host={tauriHost} features={{ chat, wallet, agents }} />
);
```
## Web ⇄ desktop parity
The web shim and the desktop shim are **byte-identical except one prop**
(`host={tauriHost}`). They import the same `@hanzo/ai`, which is the same
`src/app`. So the rendered React tree — every screen, route and component — is
identical. The only runtime difference is what `HostAdapter` does:
| Call | web (default host) | desktop (tauriHost) |
|---|---|---|
| `invoke(cmd, args)` | no-op, returns `undefined` (logs in dev) | Tauri IPC → Rust |
| `listen(event, cb)` | no-op unlisten | Tauri event bus |
| `getCurrentWindow()` | no-op Window (emit/listen/geometry) | real Tauri window |
| fs / shell / process / updater | no-op / empty | Tauri plugins |
So on the web the app renders 1:1 with desktop; native-only actions (open a
folder, auto-update, tray) simply do nothing instead of crashing. Cloud
inference + chat work on both (they go over HTTP to the brand's
`inferenceEndpoint`, not through `invoke`).
## Verification (2026-06-04)
| Surface | Build | Render |
|---|---|---|
| web dev server (`pkgs/ai/web`, imports `src`) | ✅ | ✅ Hanzo onboarding |
| web prod (unminified, 55M) | ✅ 40s | ✅ |
| web prod (minified, 13M / gzip 3.75M) | ✅ 44s | ✅ |
| library `dist` (self-contained, react external) | ✅ | — |
| **external shim** (`examples/web`, only react provided, app from `dist`) | ✅ | ✅ — proves SDK consumption |
The render blocker that had to be cleared first: app modules imported
`useBrand` from the **`@hanzo/ai` package name**, which resolved to the built
`dist` — pulling a second React + a second app copy into the source graph (a
cycle → `useContext` of null). Fix: the brand store lives in
`@hanzo_network/brand-config` (plain getters, not hooks), aliased to `src`;
app code never imports the `@hanzo/ai` package by name.
## Turning the real repos into shims
`luxfi/app`, `zooai/app`, `hanzoai/desktop`, `hanzoai/app` each become the
shim above. Per repo: depend on `@hanzo/ai` (+ `react`, `react-dom`, and for
desktop `@tauri-apps/api`), set `VITE_BRAND`, drop all app source. The
Dockerfile/Tauri config builds from the pinned SDK — exactly how
`zooai/exchange` builds from `@luxfi/exchange`. (`@hanzo/ai` is not yet
published to npm; until then the shims consume it via the workspace.)
+51
View File
@@ -0,0 +1,51 @@
# Testing @hanzo/ai — and TDD going forward
## Run
```bash
bun run --cwd pkgs/ai test # unit + regression (vitest, jsdom) — fast, no external deps
bun run --cwd pkgs/ai test:watch # vitest watch mode (use while writing a fix)
bun run --cwd pkgs/ai test:e2e # Playwright e2e — needs the local runtime up (skips if not)
```
`test` runs everything under `src/**/*.{test,spec}.{ts,tsx}` in jsdom. Config:
`vitest.config.ts` mirrors the web build's alias graph (so `@/`, `@tauri-apps/*`
→ host shims, `@hanzo_network/*` → src resolve), stubs the streamdown markdown
stack (esbuild can't resolve its nested micromark), and loads
`@testing-library/jest-dom` via `src/__tests__/setup.ts`. Pyodide /
python-code-runner tests are excluded (30 MB wasm + network).
## What's covered
**Unit + regression** (`src/__tests__/`) — each guards a bug this stack actually hit:
- `brand-store.test.ts``useBrand`/`getBrand` are plain getters callable
outside render (the invalid-hook-call that blocked the whole web app).
- `host-shims.test.ts``getCurrentWindow().emit` etc. exist; the injected host
adapter switches web↔tauri (the "emit is not a function" mount crash).
- `resizable.test.tsx``react-resizable-panels` v3 API present, net-ui
resizable renders (the v4 "Element type is invalid" chat-view crash).
- `no-missing-imports.test.ts` — critical screens import every `<Component>`
they use (the dropped `Box`/`Boxes`/`Coins` lucide imports → "Box is not defined").
**App tests** (pre-existing, `src/app/**`) — engine client, machine-state queries,
mining page, playground utils. **E2E** (`e2e/chat.e2e.test.ts`) — drives the real
browser: onboarding renders, then connect → register → send → assert zen-coder's
reply (and assert no "is not defined" / "Element type is invalid" page errors).
## TDD workflow (going forward)
Every fix and feature starts with a failing test:
1. **Red** — write a test that reproduces the bug or specifies the behavior, in
`src/__tests__/` (unit) or `e2e/` (flow). Run `test:watch`; confirm it fails.
2. **Green** — make the smallest change that passes it.
3. **Refactor** — clean up with the test green.
4. Run `bun run --cwd pkgs/ai test` before committing; keep it green.
Most bugs this session were import/dep/runtime issues that *static render checks
missed but a test would have caught* — prefer a regression test over a manual
re-check. For anything touching the chat path, add/extend the e2e.
The local runtime for `test:e2e` is in `pkgs/ai/local-runtime/` (see `CHAT.md`):
restart `run-node.sh` (fresh storage) before a run so Quick Connect registers as
the first device.
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long

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