Compare commits

..
211 Commits
Author SHA1 Message Date
zandhanzo-dev 4b97d20922 fix(data): @hanzogui/config 7.2.2 (unpublished) -> 7.3.0 — unblock workspace install + @hanzo/ui publish
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-03 09:04:33 -07:00
zandhanzo-dev 4b5f28bb75 chore(ui): release @hanzo/ui 5.7.1 — design unification (Basel Grotesk + Geist Mono canonical, DESIGN.md)
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-03 08:56:52 -07:00
zandGitHub c4fb363efe Merge pull request #233 from hanzoai/design/unify-basel-geist-mono
design: unify Basel Grotesk + Geist Mono, sidebar icon, panels, dark tokens
2026-07-03 08:46:51 -07:00
zandhanzo-dev c6e5c94f66 design: make Basel Grotesk + Geist Mono canonical; add DESIGN.md source of truth
- Typography: fontSans -> Basel Grotesk via next/font/local (self-hosted,
  Book 400 + Medium 500, --font-basel-sans); keep fontMono = Geist Mono.
  Point both tailwind configs' sans at var(--font-basel-sans). Basel replaces
  Geist Sans as the default; DM Sans/Figtree/Inter stay optional .theme-*
  variants only, never defaults.
- DESIGN.md: the single source of truth for the shared Hanzo look — canonical
  typography (Basel + Geist Mono), the sidebar toggle icon (lucide PanelLeft),
  sidebar/panel specs (16rem width, border-border, monochrome hover/active),
  and the true-black dark palette (#000 canvas / #0a0a0a surface / white-10
  borders / #ededf1 text).

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-03 01:53:40 -07:00
6649df2dee feat(data): every field type editable — relation/files/links/json/fullName/address inputs (#232)
The @hanzo/data field registry had Displays for all 24 types but Inputs for only
~15 — records were read-mostly. Fill the gaps so a Base record is FULLY editable
in table/detail (the CRM/CMS foundation):

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

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

Co-authored-by: z <z@zeekay.io>
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-01 18:45:40 -07:00
zandhanzo-dev bf370dd986 feat(data): every field type editable — relation/files/links/json/fullName/address inputs
The @hanzo/data field registry had Displays for all 24 types but Inputs for only
~15 — records were read-mostly. Fill the gaps so a Base record is FULLY editable
in table/detail (the CRM/CMS foundation):

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

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

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-01 18:45:23 -07:00
zandhanzo-dev f23df126e0 test(data): field registry + theme tests (@hanzo/data was untested)
9 bun tests / 64 assertions on the pure core: registry dispatch (register/
retrieve/override/read-only-omits-Input/enumerate) + theme (all 9 tag colors
legible, tagTone fallback, surface tokens = Hanzo zinc-on-black). No @hanzo/gui
needed (type-only imports). Adds `test` script.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* release: bump @hanzo/ui to 5.7.0

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

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

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

Refs universe task #28.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Bump @hanzo/ui to 5.5.1.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-02-17 20:38:07 -08:00
81 changed files with 123 additions and 6768 deletions
-190
View File
@@ -1,190 +0,0 @@
# Hanzo UI Consolidation — one library, all polish, no duplication
**Goal.** RIP the fragmentation (`@hanzo/data` vs `@hanzo/ui` vs in-console duplicates) into ONE canonical, cross-platform, presentational, clean-room library: **`@hanzo/ui`, built on `@hanzo/gui`**. Preserve every polished component + token; zero loss; no fork.
**Status.**
- **Step 1 — Audit:** complete (this document; four source trees read file-by-file).
- **Step 2 — Establish `@hanzo/ui` + migrate the stable foundation:** **DONE & GREEN** (`pkg/ui` is now a real package; `tsc --noEmit` = 0 errors, `vitest` = 12/12).
- **Step 3 — Console re-point:** **STAGED, not merged.** Gate is split (backend live, app lanes in-flight) — see [Step 3](#step-3--console-re-point-staged).
**One decision needs CTO sign-off before publish** — see [Naming / version](#naming--version--the-one-decision).
---
## Architecture — three layers, decomplected
```
@hanzo/gui (Tamagui/One) cross-platform primitives: Text, XStack/YStack, Button, Input,
│ Card, Popover, Select, Switch, Slider, Spinner, ScrollView …
@hanzo/data (pkg/data) the metadata-driven RECORD layer: fields → records → views
│ (RecordsView, DataTable, BoardView, RecordDetail, field editors)
@hanzo/ui (pkg/ui) ◄──── THE ONE LIBRARY. Two orthogonal concerns, one package:
• product/app layer → import { … } from '@hanzo/ui'
• record layer → import { … } from '@hanzo/ui/data' (re-exports @hanzo/data)
```
`@hanzo/data` stays the **source of truth** for the record layer; `@hanzo/ui` **composes** it (`export * from '@hanzo/data'` on the `./data` subpath) rather than copying — so the CMS/ERP/Help app lanes that consume `@hanzo/data` today keep working untouched, and there is exactly one home. This mirrors how the gui base wraps `hanzogui`.
Both layers own a `DataTable` (product = generic typed `<T>` list; data = field-driven record grid). Keeping the record layer on the `./data` subpath keeps each name unambiguous — no rename, no collision.
---
## The preserve-and-merge manifest
Legend: **✅ migrated** (in `pkg/ui`, GREEN) · **↪ re-exported** (composed from `@hanzo/data`) · **⏳ staged** (Step 3, after app lanes land) · **🔧 pending prop-injection** (app-coupled; decouple before it can live in a presentational lib) · **🏠 stays in console** (app glue, not a UI component).
### A. Record layer — `@hanzo/data` → `@hanzo/ui/data` ↪
Source: `pkg/data/src/*` (v1.2.0, clean-room, already published). Surfaced through `@hanzo/ui/data` and `@hanzo/ui/primitives/bases/data`. Nothing re-implemented.
| Component / module | Source | New home |
|---|---|---|
| `RecordsView` (flagship: toolbar over table⇆board, filter/sort/search/group, saved views) | `view/RecordsView.tsx`, `view/Toolbar.tsx` | `@hanzo/ui/data` |
| `DataTable` (field-driven record grid: sort headers, drag resize/reorder, row select, inline edit, pagination) | `table/DataTable.tsx` | `@hanzo/ui/data` |
| `BoardView` (kanban, optimistic drag-move) · `RecordCard` | `board/BoardView.tsx`, `table/RecordCard.tsx` | `@hanzo/ui/data` |
| `RecordDetail` / `RecordForm` | `record/RecordDetail.tsx` | `@hanzo/ui/data` |
| Field model + registry (`FieldType` ×24, `FieldDefinition`, `registerField`, routers) | `field/{types,registry,registerDefaults,FieldDisplay,FieldInput}` | `@hanzo/ui/data` |
| Field displays + **editors** (select/currency/date/relation/file/JSON/boolean/fullName/address…) | `field/{displays,inputs}.tsx` | `@hanzo/ui/data` |
| Composable primitives: `Menu`, `CheckBox`, `Toggle`, `Calendar` | `primitives/*` | `@hanzo/ui/data` |
| Pure logic: `applyView`, `sortRecords`, `filterRecords`, `searchRecords`, `paginate`, `boardColumns`, … | `{table,board,view}/logic.ts` | `@hanzo/ui/data` (+ `@hanzo/data/*/logic` subpaths for Node hosts) |
| Tokens: `tokens`, `TAG_TONES`, `tagTone` | `theme.ts` | **also top-level `@hanzo/ui`** (token surface) |
### B. Console `components/ui/*` product primitives → `@hanzo/ui/product` ✅ / 🔧
Source: `console2/src/components/ui/*`. The pure subset was ported **byte-identical** (verified by `diff`) — 100% of the polish preserved (Charts' hover tooltips/axis-ticks/honest-null, DataTable width/flex/hover-gating, Field's 6 variants, SlideOver's focus-trap + scroll-lock, ComboBox's ReDoS-safe filter).
| Component | Source (`console2/src/components/ui/`) | New home | Status |
|---|---|---|---|
| `DataTable<T>` (generic typed list) + `Column` | `DataTable.tsx` | `@hanzo/ui` (`product/DataTable`) | ✅ |
| `PageHeader` | `PageHeader.tsx` | `@hanzo/ui` | ✅ |
| `Field*` (`FieldRow/Text/TextArea/Switch/Select/Slider`) | `Field.tsx` | `@hanzo/ui` | ✅ |
| **Charts** (`Sparkline/LineChart/BarChart/Donut/BarRows`, `CHART_PALETTE`) | `Charts.tsx` | `@hanzo/ui` | ✅ |
| `Donut` (standalone dependency-free ring) | `Donut.tsx` | `@hanzo/ui` as **`DonutRing`** (alias — Charts.Donut is canonical) | ✅ |
| `StatusTag` (health verdict → pill) | `StatusTag.tsx` | `@hanzo/ui` | ✅ |
| `EmptyState` (DO/Vercel-class first-run) | `EmptyState.tsx` | `@hanzo/ui` | ✅ (**decoupled** — see §I) |
| `Metric*` (`MetricCard/MiniBars/UtilBar/LegendDot/Panel/HintButton`, `SERIES`) | `Metric.tsx` | `@hanzo/ui` (its `Sparkline` preserved as **`MetricSparkline`**) | ✅ |
| `ComboBox` + pure `filterOptions`/`isKnownOption` | `ComboBox.tsx`, `combobox/filter.ts` | `@hanzo/ui` | ✅ (+ `filter.test.ts`) |
| `PrimaryButton` · `HanzoMark` · `ProductIcon` · `ProviderLogo` | resp. files | `@hanzo/ui` | ✅ |
| `SlideOver` (a11y drawer) · `SelectMenu` · `Toast` (`useToast`) | resp. files | `@hanzo/ui` | ✅ (SlideOver import decoupled `~/…/color``./color`) |
| `Reorder<T>` (pointer DnD) · `FadeIn` · `ThemeToggle` · `color` (`asColor`/`IconLike`) | resp. files | `@hanzo/ui` | ✅ (+ `Reorder.test.ts`) |
| `DetailPane` (right-side pane over SlideOver) | `components/DetailPane.tsx` | `@hanzo/ui` | ⏳ (liftable; lands with Step 3 — depends only on SlideOver) |
| `ChunkGuard` (stale-deploy chunk-error reload) | `components/ChunkGuard.tsx` | `@hanzo/ui` | ⏳ (fully portable; lands with Step 3) |
### C. Design tokens + motion → `@hanzo/ui` ✅
The brief's premise (`globals.css` `t_dark`/`t_light` token blocks) is **corrected**: `t_dark`/`t_light` are `@hanzo/gui` (Tamagui) theme **class names**; the calm values live in three real places, all preserved:
| Polish | Source | New home |
|---|---|---|
| Calm dark-first tokens (surface, text, accent `#60a5fa`, tag tones) — raw hex, theme-config independent | `pkg/data/src/theme.ts` | `@hanzo/ui` (top-level `tokens`/`TAG_TONES`/`tagTone`) |
| OKLCH shadcn-compatible palette (soft-charcoal `oklch(0.145 0 0)`, `--radius: 0.5rem`, indigo sidebar accent) | `pkgs/ui/style/hanzo-default-colors.css`, `@hanzo/tokens` | unchanged (legacy shadcn line); the gui line uses the hex tokens above |
| **Motion vocabulary**`hz-fade-up` (FadeIn), `hz-collapse`/`hz-slide`/`hz-fade` (shell/drawer), `hz-drag-item`, `hz-skeleton`/`hz-pulse`/`hz-row-in`; all `prefers-reduced-motion`-guarded | `console2/app/globals.css` (motion section) | **`@hanzo/ui/styles/motion.css`** (verbatim) — `import '@hanzo/ui/styles/motion.css'` once at app root |
### D. Generic DocType renderer → `@hanzo/ui` ⏳ (Step 3, FINAL — active lane)
Source: `console2/src/components/doctype/*`. **Actively edited by the CMS/ERP/Help lanes (untracked).** Dependency-**injected** (`client: FrameworkClient` prop) — not `~/lib/api`-coupled — so the real move-blockers are `~/lib/framework/{types,fields}` + the `ui/*` primitives (now in `@hanzo/ui`) + `@hanzo/data` (now `@hanzo/ui/data`).
| File | Class | Step-3 disposition |
|---|---|---|
| `MediaGrid.tsx` | presentational | → `@hanzo/ui` (cleanest lift; props-only DAM gallery) |
| `DocTypeRecords.tsx` / `DocTypeDetail.tsx` | mixed (data-bound shell over `@hanzo/data` views) | shell **stays in console**; renders `@hanzo/ui/data` views (already true) |
| `CollectionsBrowser.tsx` | mixed (card grid + install/create orchestration) | presentational card-grid → `@hanzo/ui`; orchestration stays |
| `data.ts` | glue (relation loading) | stays in console |
### E. LivingOverview → `@hanzo/ui` ⏳ (Step 3 — active lane)
Source: `console2/src/components/products/overview/living/*` (untracked/modified). Pure/presentational parts portable; app-glue concentrated in `registry.ts` (live API clients).
| Part | Class | Step-3 disposition |
|---|---|---|
| `motion.ts`, `hooks.ts`, `logic.ts`, `config.ts` (types) | pure | → `@hanzo/ui` (unit-tested; count-up, poll clock, unit formatting) |
| `tiles.tsx`, `LivingOverview.tsx` | presentational (reuse `ui/Charts` verbatim) | → `@hanzo/ui` (once `config.ts`'s one `~/lib/products/registry` `ProductIcon` type is swapped for `@hanzo/ui`'s `IconLike`) |
| `adapters.ts`, `registry.ts` | glue (map REAL `/v1` sources → `OverviewData`) | **stay in console** |
### F. Framework / Base-data libs → stay in console 🏠
`src/lib/framework/*` (DocType wire contract + `FrameworkApi` client + pure `fields.ts` mapper to `@hanzo/data`) and `src/lib/base-data/*` (Base REST client + schema→field mapper) are **app data-access glue**, not UI. They stay in the console. Their pure mappers (`docTypeToFields`, `baseCollectionToFields`) could later publish as a small `@hanzo/base-data` adapter, but they are not UI components and are out of scope for `@hanzo/ui`.
### G. App-coupled console components → 🔧 pending prop-injection (NOT yet in the lib)
These import app `~/lib`/session/router/branding and must be made presentational (inject props) before they belong in a host-agnostic lib. Kept in console for now; the injection contract is specified so the eventual lift is mechanical.
| Component | Coupling | Inject to lift |
|---|---|---|
| `BackendStateCard` (`BackendState.tsx`) | `~/lib/api` `ApiError` | accept a numeric `status` (or shared error shape) instead of `ApiError` |
| `BrandLogo` | session + live IAM `organization()` + `~/config` + branding | `orgName`, `logoUrl` (or a `loadOrgLogo` loader), brand mark/name as props |
| `Breadcrumbs` | `next/navigation` + catalog lookups | `crumbs[]` (or `pathname` + resolver) + `onNavigate` |
| `Loader` / `BrandMark` | `~/config` + brand logo pkgs | `brand`/`brandName` + the animated-SVG getter |
| `States` (`ErrorState`, `OperatorAccessRequired`) | `~/lib/api` + session + branding | the error, the account identity, brand strings |
---
## What this pass built (Step 2)
New in **`pkg/ui`** (was a bare `src/` staging dir — no package):
- `package.json``@hanzo/ui` `8.0.0`, source-published (`files: ["src"]`, exports map to `.ts`), peers `@hanzo/gui >=7.2.2`, `@hanzo/data >=1.2.0`, `react >=19`; BSD-3-Clause. Exports: `.`, `./product`, `./data`, `./primitives/bases/data`, `./styles/motion.css`.
- `src/index.ts` — top-level barrel (product layer + tokens).
- `src/product/index.ts` — product barrel; **collision-free** (Charts `Sparkline`/`Donut` canonical; Metric's → `MetricSparkline`; standalone ring → `DonutRing`; `ComboOption` from the one filter module) — **zero loss**.
- `src/data.ts``export * from '@hanzo/data'` (the record layer, one home).
- `src/styles/hanzo-motion.css` — the motion vocabulary, verbatim.
- `tsconfig.json`, `vitest.config.ts`, `gui.config.ts`, `gui.d.ts`, `.npmignore`, `README.md` — mirror the proven `pkg/data` setup.
- **One decouple fix:** `product/EmptyState.tsx` no longer imports `~/lib/products/registry`; it uses the local `IconLike` type — the last host coupling in the product tree is gone.
**Green:** `tsc --noEmit` = 0 errors (strict); `vitest run` = 12/12 (`combobox/filter`, `Reorder`). Every product component is host-agnostic (imports only `react`, `@hanzo/gui`, `@hanzogui/*`, `@hanzo/data`, relative).
## Clean-room guarantee (preserved)
`pkg/data` audited for GPL/Twenty contamination: **none.** No GPL, no copied license headers/SPDX, no Twenty entity/decorator architecture. The field/record model is an independent `FieldType` union + `FieldDefinition` + runtime `Map` registry (records are plain `Record<string, unknown>`). "Twenty" appears **only as benchmark prose** ("Airtable/Twenty-class polish, clean-room"). License: BSD-3-Clause. `@hanzo/ui` inherits the same posture.
---
## Naming / version — DECIDED: `@hanzo/ui@8`, shadcn retired to `@hanzo/ui-shadcn`
**Decision:** the gui-based unified library takes the `@hanzo/ui` name **forward at `8.0.0`** (aligning with the "Hanzo Cloud 8.x" umbrella; major = breaking re-platform). The legacy shadcn/Radix line (`pkgs/ui`, v5.7.0) is **retired by renaming** to `@hanzo/ui-shadcn` (never hard-deleted) — it stays fully alive under the new name, freeing `@hanzo/ui` for v8. Precedent: `pkg/data@1.2.0` already superseded `pkgs/data@1.1.0` under the same name.
This is **DONE (repo-local):** `pkg/ui/package.json` is `@hanzo/ui@8.0.0`, GREEN. The rest is a coordinated, **sequenced** retire — because publishing `@hanzo/ui@8` (a different, gui-based API with no shadcn `Button/Card/Dialog`) under the name ~20 repos consume for shadcn primitives will BREAK any consumer that resolves to `@8`. So order matters:
**Blast radius (measured across `~/work/hanzo`).** Declared deps on `@hanzo/ui` in ~20 repos. Most pin `^5.x` (semver-safe from an `@8` bump): paas, chat, platform, app, hanzo.ai, o11y, docs, mdx, hanzobot, ui-repo `pkgs/checkout` `^5.3`, `pkgs/agent-ui` `^5.0`. **Would break on `@8`:** `hanzoai/identity/app` (`"latest"`), ui-repo `pkgs/commerce` (`>=5.0.0`); `app/` uses `workspace:^`. The `ui.hanzo.ai` docs app + `pkgs/{commerce,checkout,agent-ui}` import the shadcn line internally.
**Publish reality.** `.github/workflows/publish-on-tag.yml` publishes **`pkgs/ui`** (the shadcn line) as `@hanzo/ui` on a `v*` tag — it does not reference `pkg/ui`. So a tag push today publishes shadcn, not v8. Publishing v8 needs the workflow rewired to build/publish `pkg/ui`. `npm` is not authed locally (`npm whoami` empty) — per house rules, publish goes through **CI (self-hosted runners, canonical org `NPM_TOKEN`)**, not a local `npm publish`.
**Safe sequence to fully land v8 (each step reversible until the tag push):**
1.`pkg/ui` = `@hanzo/ui@8.0.0`, GREEN (done, committed to a branch).
2. Rename `pkgs/ui` name → `@hanzo/ui-shadcn`; update the internal ui-repo consumers (`app/`, `pkgs/{commerce,checkout,agent-ui}`) + `check-no-hanzogui`/registry scripts that reference it.
3. Migrate external consumers off `@hanzo/ui``@hanzo/ui-shadcn` (start with the break-risk ones: `identity` `latest`, `commerce` `>=5`; the `^5` pins are safe to migrate at leisure). ~20 repos — do as a tracked sweep or flag per-repo.
4. Rewire `publish-on-tag.yml` (and `release.yml`) to build + publish `pkg/ui` as `@hanzo/ui@8`, and `pkgs/ui-shadcn` as `@hanzo/ui-shadcn`.
5. `npm deprecate '@hanzo/ui@<8' 'moved to @hanzo/ui-shadcn; @hanzo/ui@8+ is the @hanzo/gui-based unified lib'`.
6. Tag `v8.0.0` → CI publishes `@hanzo/ui@8.0.0`. Verify `npm view @hanzo/ui@8.0.0`.
**Gated on the user's direct go-ahead** (irreversible / globally-visible / ecosystem-wide): steps 26 — the `pkgs/ui` rename, the ~20-repo consumer sweep, the CI rewire, `npm deprecate`, and the `v8.0.0` tag push that triggers publish. These were not executed autonomously.
---
## Step 3 — console re-point (STAGED)
**Gate (per the brief):** cloud `/v1/framework/modules` live **AND** console CMS/ERP/Help native.
- ✅ Backend: `/v1/framework/modules[/:module[/install]]` is **implemented + wired** (`cloud/clients/framework/framework.go:71-73`, HIP-0106 order 129, bound in `subsystems.go:130`, real tests).
- ⏳ Console: `CmsModule.tsx`, `ErpModule.tsx`, `HelpModule.tsx`, `components/doctype/` are **untracked/in-flight** on `feat/console-native-cms`.
→ Gate is **split**, so the re-point is **staged, not applied.** I deliberately did **not** touch `console2`'s working tree (it holds the lanes' uncommitted work — editing it would collide and risk loss, the opposite of the goal).
**Ready-to-apply re-point (mechanical, once the lanes land + merge):**
1. `console2` deps: keep `@hanzo/gui`, `@hanzo/data`; add `@hanzo/ui` (`^6.0.0`). (`@hanzo/data` may stay as a direct dep or be dropped in favor of `@hanzo/ui/data` — both resolve to the same source.)
2. Re-point imports (delete the now-duplicated in-console copies — extract, don't copy):
- `~/components/ui/{DataTable,PageHeader,Field,Charts,Donut,StatusTag,EmptyState,Metric,ComboBox,combobox/filter,PrimaryButton,HanzoMark,ProductIcon,ProviderLogo,SlideOver,SelectMenu,Toast,Reorder,FadeIn,ThemeToggle,color}`**`@hanzo/ui`** (or `@hanzo/ui/product`). Two spot-renames: Metric's `Sparkline``MetricSparkline`, standalone `Donut``DonutRing`.
- `@hanzo/data` (in `doctype/*`, `base-data/*`, `products/*Module`) → **`@hanzo/ui/data`** (identical surface).
- `~/components/{DetailPane,ChunkGuard}`**`@hanzo/ui`**.
- `app/globals.css` motion block → `import '@hanzo/ui/styles/motion.css'` (keep base resets local).
- Move the 5 app-coupled components (§G) into the lib only after applying their inject-props contract; until then they stay local.
3. Verify **identical render**: `tsc --noEmit` + `vitest` + `next build` green, then headless-Playwright the live pages pixel-same (the polish is byte-identical, so parity is expected).
**Report:** `@hanzo/ui` is the ONE unified library — product layer + record layer (`@hanzo/data`) + charts + calm tokens + motion, all on `@hanzo/gui`, presentational, cross-platform, clean-room, GREEN. Console re-point is **ready to apply once the CMS/ERP/Help lanes land**; it was not merged to avoid colliding with that in-flight work.
## Follow-ups (semver-minor, with visual e2e)
- Collapse the preserved Sparkline/Donut variants to one each (`MetricSparkline``Sparkline`, `DonutRing``Donut`) once call-sites are proven identical.
- Lift §G's five components after applying their prop-injection contracts.
- Consider publishing the pure `framework`/`base-data` mappers as a small `@hanzo/base-data` adapter (not UI).
+103
View File
@@ -0,0 +1,103 @@
# Hanzo Design System — Canonical Tokens
`@hanzo/ui` is the single source of truth for the shared Hanzo product look across
the **Tailwind** apps (hanzo.chat, hanzo.app, hanzo console, commerce, hanzo-desktop).
Change a value here; apps converge on it. This file is that source of truth for the
three things that must read as **one product**: typography, the sidebar/panel system,
and the dark-black palette.
> Two design systems, do not confuse them: **`@hanzo/ui`** = shadcn/ui + Tailwind +
> Radix (this repo). **`@hanzo/gui`** = the Tamagui/RN system (hanzo console). They
> share these *token values* (fonts, dark palette, the sidebar glyph), not code.
---
## 1. Typography — Basel Grotesk + Geist Mono
| Role | Family | Notes |
|------|--------|-------|
| UI / body / display / heading (`sans`) | **Basel Grotesk** | Self-hosted. Book = weight **400**, Medium = weight **500**. |
| code / data / mono (`mono`) | **Geist Mono** | `next/font/google` (`Geist_Mono`) or the geist CDN. |
| Arabic / Hebrew (`--font-ar` / `--font-he`) | unchanged | i18n only — keep. |
**Dropped as defaults:** Geist Sans, DM Sans, Figtree, Inter, PT Sans, Roboto Mono.
Basel is a **licensed, non-Google** face — **self-host** the woff2/woff, do NOT use
`next/font/google` for it. Canonical files (mirror lux.exchange):
`Basel-Grotesk-Book.woff2/.woff` (400), `Basel-Grotesk-Medium.woff2/.woff` (500).
`@font-face` (weights 400/500, `font-display: swap`, `font-style: normal`):
```css
@font-face {
font-family: 'Basel';
font-style: normal;
font-weight: 400; /* Book; 500 = Medium */
font-display: swap;
src: url('.../Basel-Grotesk-Book.woff2') format('woff2'),
url('.../Basel-Grotesk-Book.woff') format('woff');
}
```
Per-app adoption (converge the value, keep each app's own mechanism):
- **@hanzo/ui / Next apps** → `next/font/local` for Basel (`--font-basel-sans`) +
`next/font/google` `Geist_Mono` (`--font-geist-mono`). See `app/lib/fonts.ts`;
tailwind `sans → var(--font-basel-sans)`, `mono → var(--font-geist-mono)`.
- **Vite + Tailwind apps** (chat, launcher, desktop) → self-host Basel `@font-face`
+ geist-mono CDN import; tailwind `fontFamily.sans = ['Basel', …]`,
`mono = ['Geist Mono', …]`.
- **Tamagui (console)** → Basel `@font-face` in globals + override the Tamagui
`body`/`heading` font `family` to Basel; Geist Mono for `code`/`pre`.
---
## 2. Sidebar toggle icon — lucide `PanelLeft`
One glyph everywhere: lucide **`PanelLeft`** (the shadcn `SidebarTrigger` default).
Where a directional open/close affordance is wanted, use the pair
**`PanelLeftClose`** (expanded) / **`PanelLeft`** (collapsed). Never a hamburger,
a magnifier, a directional arrow, or a bespoke panel SVG for the sidebar toggle.
- Icon: stroke-2, ~1620px, `currentColor`.
- Button: ghost/outline, square (`size-7`/`h-6 w-6`), subtle hover.
---
## 3. Sidebar + panels
| Spec | Value |
|------|-------|
| Sidebar width (expanded) | **16rem / 256px** (`SIDEBAR_WIDTH`) |
| Sidebar width (collapsed / icon rail) | **3rem / 48px** (apps vary 4870px) |
| Sidebar / panel surface | resting **#0a0a0a** over the true-black page |
| Border / separation | `border-border` — subtle white-alpha ~10% in dark (`border-r` / `border-l`) |
| Item hover | subtle `white/5` |
| Item active | monochrome `white/10`**no colored accent** (the house style is monochrome) |
| Right panel rail | `border-l border-border`, same surface, collapsible |
Prefer the `@hanzo/ui` `Sidebar` primitive (`pkgs/ui/primitives/sidebar.tsx`,
`SidebarTrigger``PanelLeft`) where the app can consume it; otherwise match these
classes/tokens.
---
## 4. Dark-black palette (true-black OLED)
The house dark theme is a **true-black** canvas (matches hanzo.ai marketing +
hanzo.chat OLED), with a shallow surface-depth ladder for cards/panels and quiet
hairline borders — never harsh pure-white on pure-black.
| Token | Value | Use |
|-------|-------|-----|
| Page background | **#000000** (`oklch(0 0 0)`) | body / canvas |
| Surface / sidebar / panel (resting) | **#0a0a0a** | sidebars, panels, cards |
| Press | **#050505** | pressed surface |
| Elevated / hover | **#171717** | hover, raised card |
| Border / divider | **rgba(255,255,255,0.10)** (≈ `#171717` opaque on black) | hairlines |
| Foreground (primary text) | near-white **#ededf1** (`oklch(0.985)`) — not pure `#fff` | body text |
| Muted / secondary text | `white/70` (≈ `#a1a1aa`) | secondary |
Keep each app's theme-token engine (CSS vars / Tailwind tokens / Tamagui `$color*`);
converge the **values/usage** to the table above, don't rip the engine.
Reference: `pkgs/ui/style/hanzo-default-colors.css` (`.dark` / `.hanzo-ui-dark-theme`).
Binary file not shown.
Binary file not shown.
+13 -2
View File
@@ -1,6 +1,17 @@
import { GeistMono } from "geist/font/mono"
import { GeistSans } from "geist/font/sans"
import localFont from "next/font/local"
export const fontSans = GeistSans
// Canonical Hanzo typography (single source of truth; see DESIGN.md):
// - UI / body / display / heading -> Basel Grotesk (self-hosted, Book 400 + Medium 500)
// - code / data / mono -> Geist Mono
// Basel replaces Geist Sans / DM Sans / Figtree / Inter as the default sans.
export const fontSans = localFont({
src: [
{ path: "../fonts/Basel-Grotesk-Book.woff2", weight: "400", style: "normal" },
{ path: "../fonts/Basel-Grotesk-Medium.woff2", weight: "500", style: "normal" },
],
variable: "--font-basel-sans",
display: "swap",
})
export const fontMono = GeistMono
+1 -1
View File
@@ -36,7 +36,7 @@
"@hanzo/docs-mdx": "14.3.0",
"@hanzo/docs-ui": "16.5.3",
"@hanzo/logo": "^1.0.5",
"@hanzo/ui": "workspace:@hanzo/ui-shadcn@^",
"@hanzo/ui": "workspace:^",
"@hookform/resolvers": "5.2.2",
"@monaco-editor/react": "^4.7.0",
"@radix-ui/react-accessible-icon": "^1.1.8",
+1 -1
View File
@@ -23,7 +23,7 @@ const config = {
},
extend: {
fontFamily: {
sans: ["var(--font-geist-sans)", ...fontFamily.sans],
sans: ["var(--font-basel-sans)", ...fontFamily.sans],
mono: ["var(--font-geist-mono)", ...fontFamily.mono],
},
colors: {
-8
View File
@@ -1,8 +0,0 @@
# Keep the published tarball lean — ship src, not tests or dev type-check config.
**/*.test.ts
**/*.test.tsx
tsconfig.json
vitest.config.ts
gui.config.ts
gui.d.ts
node_modules
-69
View File
@@ -1,69 +0,0 @@
# @hanzo/data
Cross-platform, metadata-driven **data-app components** for any [Hanzo Base](https://github.com/hanzoai/base)-backed app — CRM, CMS, commerce, or a one-off internal tool. One model (typed **fields****records****views**) renders everywhere, on web, native (iOS), and desktop, because it's built on [`@hanzo/gui`](https://github.com/hanzoai/gui) (Tamagui). **Airtable/Twenty-class polish** — table ⇆ board, filter, sort, group, inline edit, saved views — all clean-room and original.
An object is a set of typed fields. A record is values for those fields. Every view — table, board, detail — renders from that one model, dispatched through a registry, so adding a field type is **one registration**, never a `switch` edit. A business app (CRM/CMS/ERP) is just a curated set of views over these components.
## Install
```sh
bun add @hanzo/data @hanzo/gui
```
`@hanzo/gui` and `react` are peers.
## The flagship: `RecordsView`
One shell turns any collection into a Twenty-class surface — a toolbar (view switch, search, filter builder, sort builder, board group-by) over a **DataTable** or a **BoardView**, driven by a single `ViewConfig`. Presentational + data-injected: the host supplies raw records and the persistence callbacks; the view owns interaction state.
```tsx
import { RecordsView, type FieldDefinition } from '@hanzo/data'
<RecordsView
fields={fields}
records={records} // raw rows; the view applies search + filter + sort
onOpen={openDetail} // expand a record
onEditCommit={(rec, field, value) => save(rec, field, value)} // inline cell / board edit
onCreate={createRecord}
savedViews={{ views, activeId, onSelect, onSave, onDelete }} // optional named views
/>
```
- **DataTable** — click-to-sort headers, drag-resize + drag-reorder columns, row selection with a header select-all, inline cell editing (the field's own editor), pagination, hover-reveal open, honest empty/loading.
- **BoardView** — kanban grouped by a select/status/boolean/relation field; drag a card to another lane and the record patch is emitted (optimistic, reverts on failure).
- **RecordDetail** — a titled panel of fields, optionally inline-editable, with a `related` slot. **RecordForm** — the create/edit form.
## Field types
`text · longText · number · percent · currency · boolean · select · multiSelect · date · dateTime · email · url · links · phone · rating · relation · json · uuid · fullName · address · files · richText · position · actor`
Each type has a read **Display** and (where editable) an **Input**, resolved by the registry. Editors are polished: searchable **select** dropdowns with color chips, a month-grid **calendar**, a **relation** record-picker (over injected candidates), a **file** upload, a validated **JSON** editor, a sliding **boolean** toggle.
## Extend
Register a renderer for any type — built-in or custom — and it works in every view:
```tsx
import { registerField } from '@hanzo/data'
registerField('rating', { Display: MyStars, Input: MyStarPicker })
```
## Pure logic (host-usable, tested)
The sort / filter / search / group / view helpers are pure and exported (also on subpaths `@hanzo/data/{table,board,view}/logic` so a Node host can import them with no gui runtime):
```ts
import { applyView, sortRecords, filterRecords, boardColumns } from '@hanzo/data'
```
## Surface
- **Model** — `FieldDefinition`, `FieldType`, `FieldMetadata`, `SelectOption`, `CurrencyValue`, `LinkValue`
- **Views** — `RecordsView`, `DataTable`, `BoardView`, `RecordDetail`, `RecordForm`, `RecordCard`, `RecordsToolbar`
- **Primitives** — `Menu`, `MenuItem`, `CheckBox`, `Toggle`, `Calendar`
- **Routers / Registry** — `FieldDisplay`, `FieldInput`, `isEditable`, `registerField`, `getFieldRenderers`
- **Logic** — `applyView`, `sortRecords`, `filterRecords`, `searchRecords`, `paginate`, `boardColumns`, `movePatch`, `operatorsForType`, `emptyView`, `describeView`, …
- **Theme** — `tokens`, `TAG_TONES`, `tagTone`
BSD-3-Clause · Hanzo AI
-13
View File
@@ -1,13 +0,0 @@
// The @hanzo/gui v5 config this package is authored against. `createGui` with the
// shared `@hanzogui/config/v5` default enables the shorthand style props
// (bg/px/py/items/justify/rounded/…) the components use; `Conf` feeds the type
// augmentation in `gui.d.ts` so `tsc --noEmit` type-checks the shorthands exactly
// as the consuming app (console) does. Not shipped — dev/type-check only.
import { defaultConfig } from '@hanzogui/config/v5'
import { createGui } from '@hanzo/gui'
export const config = createGui(defaultConfig)
export default config
export type Conf = typeof config
-15
View File
@@ -1,15 +0,0 @@
/**
* Registers our Gui config with the type system so shorthand style props
* (tokens, themes, bg/px/py/items/justify etc.) type-check exactly as they do in
* the consuming app. GuiCustomConfig is declared in @hanzogui/web and flows
* through @hanzo/gui. Dev/type-check only — never shipped.
*/
import type { Conf } from './gui.config'
declare module '@hanzogui/web' {
interface GuiCustomConfig extends Conf {}
}
declare module '@hanzogui/core' {
interface GuiCustomConfig extends Conf {}
}
-59
View File
@@ -1,59 +0,0 @@
{
"name": "@hanzo/data",
"version": "1.2.0",
"type": "module",
"description": "Hanzo Data — cross-platform, metadata-driven data-app components (typed fields, record table, kanban board, record detail, saved views) on @hanzo/gui. The universal object/field/record/view layer that powers any Base-backed CRM, CMS, or commerce app on web, native (iOS), and desktop. Airtable/Twenty-class polish, clean-room.",
"exports": {
".": {
"types": "./src/index.ts",
"default": "./src/index.ts"
},
"./table/logic": {
"types": "./src/table/logic.ts",
"default": "./src/table/logic.ts"
},
"./board/logic": {
"types": "./src/board/logic.ts",
"default": "./src/board/logic.ts"
},
"./view/logic": {
"types": "./src/view/logic.ts",
"default": "./src/view/logic.ts"
},
"./package.json": "./package.json"
},
"main": "./src/index.ts",
"module": "./src/index.ts",
"types": "./src/index.ts",
"sideEffects": [
"**/registerDefaults.ts",
"**/index.ts"
],
"files": [
"src"
],
"scripts": {
"tc": "tsc --noEmit",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest"
},
"peerDependencies": {
"@hanzo/gui": ">=7.2.2",
"react": ">=19"
},
"devDependencies": {
"@hanzo/gui": "7.3.0",
"@hanzogui/config": "7.3.0",
"@hanzogui/lucide-icons-2": "7.3.0",
"@types/react": "^19.1.10",
"react": "^19.0.0",
"typescript": "^5.7.2",
"vitest": "^2.1.9"
},
"publishConfig": {
"access": "public"
},
"license": "BSD-3-Clause",
"author": "Hanzo AI <dev@hanzo.ai>"
}
-166
View File
@@ -1,166 +0,0 @@
'use client'
// BoardView — the kanban view. Records are grouped into columns by a select /
// status / boolean / relation field (board/logic), each rendered as a RecordCard.
// Dragging a card to another column emits the record patch that move implies
// (movePatch) via onRecordChange; the move is optimistic (the card follows the
// pointer and lands immediately) and reverts if persistence fails — responsive,
// never fabricated. The same FieldDisplay engine as the table, so the card
// understands every field type. Presentational + data-injected.
import { useEffect, useMemo, useRef, useState } from 'react'
import { Plus } from '@hanzogui/lucide-icons-2'
import { ScrollView, Text, XStack, YStack } from '@hanzo/gui'
import type { FieldDefinition } from '../field/types'
import { RecordCard } from '../table/RecordCard'
import { Tag } from '../field/displays'
import { tokens, tagTone } from '../theme'
import { boardColumns, movePatch, groupKeyOf, NO_VALUE, type BoardColumn } from './logic'
type Record_ = Record<string, unknown>
const COL_W = 300
export interface BoardViewProps {
fields: FieldDefinition[]
records: Record_[]
/** The field records are grouped into columns by (a select / status / boolean / relation). */
groupField: string
/** The card title field (defaults to the first non-group field). */
titleField?: string
/** Fields shown on each card (defaults to a few beyond the title/group). */
cardFields?: FieldDefinition[]
onOpen?: (record: Record_) => void
/** Persist a card's move to another column. May be async; reverts on failure. */
onRecordChange?: (record: Record_, patch: Record<string, unknown>) => void | Promise<void>
/** Create a record in a column (the column value is pre-filled). */
onCreate?: (columnKey: string) => void
getRowKey?: (record: Record_) => string
}
export function BoardView(props: BoardViewProps) {
const { fields, records, groupField, titleField, cardFields, onOpen, onRecordChange, onCreate, getRowKey } = props
const keyOf = (r: Record_) => (getRowKey ? getRowKey(r) : String(r.id ?? ''))
const field = fields.find((f) => f.name === groupField)
const [overrides, setOverrides] = useState<Record<string, string>>({})
useEffect(() => { setOverrides({}) }, [records])
const boardRef = useRef<HTMLElement | null>(null)
const colRefs = useRef<Array<HTMLElement | null>>([])
const [drag, setDrag] = useState<{ id: string; from: string; x: number; y: number; target: string } | null>(null)
const effective = useMemo(
() => (field ? records.map((r) => (overrides[keyOf(r)] != null ? { ...r, [field.name]: overrides[keyOf(r)] === NO_VALUE ? null : overrides[keyOf(r)] } : r)) : records),
[records, overrides, field],
)
const columns: BoardColumn[] = useMemo(() => (field ? boardColumns(effective, field) : []), [effective, field])
if (!field) {
return (
<YStack p={24} items="center" borderWidth={1} rounded={10} style={{ borderColor: tokens.border, backgroundColor: tokens.surface }}>
<Text style={{ color: tokens.textFaint }}>Pick a select or status field to group the board by.</Text>
</YStack>
)
}
const startDrag = (record: Record_) => (e: { clientX?: number; clientY?: number; nativeEvent?: { clientX?: number; clientY?: number } }) => {
if (!onRecordChange) return
const id = keyOf(record)
const from = groupKeyOf(field, record[field.name])
const rects = colRefs.current.map((n) => n?.getBoundingClientRect() ?? null)
const cx = e.clientX ?? e.nativeEvent?.clientX ?? 0
const cy = e.clientY ?? e.nativeEvent?.clientY ?? 0
setDrag({ id, from, x: cx, y: cy, target: from })
const move = (ev: PointerEvent) => {
let target = from
for (let i = 0; i < rects.length; i++) {
const r = rects[i]
if (r && ev.clientX >= r.left && ev.clientX <= r.right) { target = columns[i]?.key ?? from; break }
}
setDrag({ id, from, x: ev.clientX, y: ev.clientY, target })
}
const end = () => {
window.removeEventListener('pointermove', move)
window.removeEventListener('pointerup', end)
setDrag((d) => {
if (d && d.target !== d.from) {
setOverrides((o) => ({ ...o, [d.id]: d.target }))
Promise.resolve(onRecordChange(record, movePatch(field, d.target))).catch(() =>
setOverrides((o) => { const next = { ...o }; delete next[d.id]; return next }),
)
}
return null
})
}
window.addEventListener('pointermove', move)
window.addEventListener('pointerup', end)
}
const draggedRecord = drag ? effective.find((r) => keyOf(r) === drag.id) : undefined
const boardRect = () => boardRef.current?.getBoundingClientRect()
return (
<YStack ref={(node: unknown) => { boardRef.current = node as HTMLElement | null }} style={{ position: 'relative' }}>
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
<XStack gap={12} py={2} items="flex-start">
{columns.map((col, i) => {
const isTarget = drag && drag.target === col.key && drag.from !== col.key
return (
<YStack
key={col.key || '∅'}
width={COL_W}
ref={(node: unknown) => { colRefs.current[i] = (node as HTMLElement | null) }}
gap={8}
p={8}
rounded={12}
style={{ backgroundColor: tokens.surfaceRaised, borderWidth: 1, borderColor: isTarget ? tokens.accent : tokens.border }}
>
<XStack items="center" justify="space-between" px={4} py={2}>
<XStack items="center" gap={8}>
{col.key === NO_VALUE ? (
<Text fontSize={13} fontWeight="700" style={{ color: tokens.textMuted }}>{col.label}</Text>
) : (
<Tag label={col.label} color={col.color} />
)}
<Text fontSize={12} style={{ color: tokens.textFaint }}>{col.records.length}</Text>
</XStack>
{onCreate ? (
<YStack cursor="pointer" p={3} rounded={6} hoverStyle={{ bg: tokens.hover }} onPress={() => onCreate(col.key)}>
<Plus size={14} color={tokens.textMuted} />
</YStack>
) : null}
</XStack>
<YStack gap={8} style={{ minHeight: 40 }}>
{col.records.map((r) => {
const dragging = drag?.id === keyOf(r)
return (
<YStack key={keyOf(r)} onPointerDown={startDrag(r)} style={{ opacity: dragging ? 0.35 : 1, cursor: onRecordChange ? 'grab' : undefined }}>
<RecordCard titleField={titleField} fields={cardFields ?? fields.filter((f) => f.name !== field.name)} record={r} onPress={onOpen} />
</YStack>
)
})}
{col.records.length === 0 ? (
<YStack items="center" justify="center" py={16} rounded={8} style={{ borderWidth: 1, borderColor: tokens.border, borderStyle: 'dashed' }}>
<Text fontSize={12} style={{ color: tokens.textFaint }}>Drop here</Text>
</YStack>
) : null}
</YStack>
</YStack>
)
})}
</XStack>
</ScrollView>
{/* floating card follows the pointer during a drag */}
{drag && draggedRecord ? (
<YStack
width={COL_W - 20}
style={{ position: 'absolute', left: (drag.x - (boardRect()?.left ?? 0)) - (COL_W - 20) / 2, top: (drag.y - (boardRect()?.top ?? 0)) - 20, pointerEvents: 'none', zIndex: 50, transform: [{ rotate: '2deg' }], boxShadow: '0 12px 32px rgba(0,0,0,0.4)' }}
>
<RecordCard titleField={titleField} fields={cardFields ?? fields.filter((f) => f.name !== field.name)} record={draggedRecord} />
</YStack>
) : null}
</YStack>
)
}
-83
View File
@@ -1,83 +0,0 @@
import { describe, it, expect } from 'vitest'
import type { FieldDefinition } from '../field/types'
import {
groupFieldCandidates,
defaultGroupField,
groupKeyOf,
boardColumns,
movePatch,
NO_VALUE,
} from './logic'
const stage: FieldDefinition = {
name: 'stage',
label: 'Stage',
type: 'select',
metadata: { options: [
{ value: 'NEW', label: 'New', color: 'blue' },
{ value: 'WON', label: 'Won', color: 'green' },
{ value: 'LOST', label: 'Lost', color: 'red' },
] },
}
const active: FieldDefinition = { name: 'active', label: 'Active', type: 'boolean' }
const name: FieldDefinition = { name: 'name', label: 'Name', type: 'text' }
const rows: Record<string, unknown>[] = [
{ id: '1', name: 'A', stage: 'NEW', active: true },
{ id: '2', name: 'B', stage: 'WON', active: false },
{ id: '3', name: 'C', stage: 'NEW', active: true },
{ id: '4', name: 'D', stage: '', active: false },
{ id: '5', name: 'E', stage: 'ARCHIVED', active: true }, // value not in options
]
describe('group field candidates', () => {
it('offers select/boolean/relation fields only', () => {
expect(groupFieldCandidates([name, stage, active]).map((f) => f.name)).toEqual(['stage', 'active'])
})
it('defaults to the first select', () => {
expect(defaultGroupField([name, active, stage])).toBe('stage')
})
})
describe('groupKeyOf', () => {
it('maps boolean and empties', () => {
expect(groupKeyOf(active, true)).toBe('true')
expect(groupKeyOf(active, false)).toBe('false')
expect(groupKeyOf(active, null)).toBe(NO_VALUE)
expect(groupKeyOf(stage, 'NEW')).toBe('NEW')
expect(groupKeyOf(stage, '')).toBe(NO_VALUE)
})
})
describe('boardColumns', () => {
it('keeps declared option order, shows empty lanes, and appends unknown values', () => {
const cols = boardColumns(rows, stage)
// no-value lane appears (row 4 has ''), then NEW, WON, LOST (declared), then ARCHIVED (unknown)
expect(cols.map((c) => c.key)).toEqual([NO_VALUE, 'NEW', 'WON', 'LOST', 'ARCHIVED'])
const byKey = Object.fromEntries(cols.map((c) => [c.key, c.records.map((r) => r.id)]))
expect(byKey['NEW']).toEqual(['1', '3'])
expect(byKey['WON']).toEqual(['2'])
expect(byKey['LOST']).toEqual([]) // declared but empty — still a lane
expect(byKey['ARCHIVED']).toEqual(['5'])
expect(byKey[NO_VALUE]).toEqual(['4'])
})
it('drops the empty no-value lane when nothing is unset', () => {
const cols = boardColumns(rows.filter((r) => r.stage !== ''), stage)
expect(cols.map((c) => c.key)).not.toContain(NO_VALUE)
})
it('groups booleans into Yes/No lanes', () => {
const cols = boardColumns(rows, active)
const byKey = Object.fromEntries(cols.map((c) => [c.key, c.records.map((r) => r.id)]))
expect(byKey['true']).toEqual(['1', '3', '5'])
expect(byKey['false']).toEqual(['2', '4'])
})
})
describe('movePatch', () => {
it('produces the field patch for a target lane', () => {
expect(movePatch(stage, 'WON')).toEqual({ stage: 'WON' })
expect(movePatch(stage, NO_VALUE)).toEqual({ stage: null })
expect(movePatch(active, 'true')).toEqual({ active: true })
expect(movePatch(active, 'false')).toEqual({ active: false })
})
})
-117
View File
@@ -1,117 +0,0 @@
// Pure board (kanban) logic — group a collection's records into ordered columns
// by a select / status / boolean / relation field, and describe the patch that
// moving a card between columns implies. No React, no @hanzo/gui: data in, data
// out, testable in plain Node.
import type { FieldDefinition, FieldType, SelectOption, TagColor } from '../field/types'
type Record_ = Record<string, unknown>
const asStr = (v: unknown): string => (v == null ? '' : String(v))
/** One board column: a group value + the records in it, in input order. */
export interface BoardColumn {
/** The group value (`''` for the no-value column). */
key: string
label: string
color?: TagColor
records: Record_[]
}
/** The sentinel column key for records with no value in the group field. */
export const NO_VALUE = ''
/** Field types that make sensible board lanes (a bounded, single set of values). */
const GROUPABLE: ReadonlySet<FieldType> = new Set<FieldType>(['select', 'boolean', 'relation'])
/** The fields a board can group by — the same order they appear in the schema. */
export function groupFieldCandidates(fields: FieldDefinition[]): FieldDefinition[] {
return fields.filter((f) => GROUPABLE.has(f.type))
}
/** The best default group field (first select, else first candidate), or undefined. */
export function defaultGroupField(fields: FieldDefinition[]): string | undefined {
const selects = fields.filter((f) => f.type === 'select')
if (selects.length > 0) return selects[0].name
const c = groupFieldCandidates(fields)
return c[0]?.name
}
/** The group key for one record's value in a group field (the option value / bool / id). */
export function groupKeyOf(field: FieldDefinition, value: unknown): string {
if (field.type === 'boolean') {
if (value == null || value === '') return NO_VALUE
return value === true || value === 'true' || value === 1 ? 'true' : 'false'
}
if (field.type === 'relation') {
if (value && typeof value === 'object') {
const rec = value as Record<string, unknown>
return asStr(rec.id ?? rec.value ?? '')
}
return asStr(value)
}
return asStr(value)
}
/** The declared column keys for a group field, in display order (before data). */
function declaredColumns(field: FieldDefinition): { key: string; label: string; color?: TagColor }[] {
if (field.type === 'boolean') {
const meta = (field.metadata ?? {}) as { trueLabel?: string; falseLabel?: string }
return [
{ key: 'true', label: meta.trueLabel ?? 'Yes', color: 'green' },
{ key: 'false', label: meta.falseLabel ?? 'No', color: 'gray' },
]
}
const options = (field.metadata as { options?: SelectOption[] } | undefined)?.options ?? []
return options.map((o) => ({ key: o.value, label: o.label, color: o.color }))
}
export interface BoardColumnsOptions {
/** Include an explicit "no value" column even when empty (default true). */
includeEmpty?: boolean
/** A label for the no-value column (default "No <field label>"). */
emptyLabel?: string
}
/**
* Partition records into board columns. Declared option columns come first in
* their schema order (so an empty stage still shows as a lane); any value not in
* the schema (or a relation id) becomes a trailing lane; records with no value
* collect in the leading "no value" lane. Order within a lane is preserved.
*/
export function boardColumns(
records: Record_[],
field: FieldDefinition,
opts: BoardColumnsOptions = {},
): BoardColumn[] {
const includeEmpty = opts.includeEmpty ?? true
const declared = declaredColumns(field)
const order: string[] = []
const meta = new Map<string, { label: string; color?: TagColor }>()
const buckets = new Map<string, Record_[]>()
const ensure = (key: string, label: string, color?: TagColor) => {
if (!buckets.has(key)) { buckets.set(key, []); order.push(key); meta.set(key, { label, color }) }
}
// Leading no-value lane, then declared option lanes (empty-but-present).
if (includeEmpty) ensure(NO_VALUE, opts.emptyLabel ?? `No ${field.label.toLowerCase()}`)
for (const c of declared) ensure(c.key, c.label, c.color)
for (const rec of records) {
const key = groupKeyOf(field, rec[field.name])
if (key === NO_VALUE && !includeEmpty) continue
if (!buckets.has(key)) ensure(key, key || (opts.emptyLabel ?? `No ${field.label.toLowerCase()}`))
buckets.get(key)!.push(rec)
}
// Drop the no-value lane if it ended up empty and wasn't forced.
return order
.filter((key) => !(key === NO_VALUE && (buckets.get(key)?.length ?? 0) === 0 && declared.length > 0))
.map((key) => ({ key, ...(meta.get(key) as { label: string; color?: TagColor }), records: buckets.get(key) ?? [] }))
}
/** The record patch that moving a card into a column implies (`{ [field]: value }`). */
export function movePatch(field: FieldDefinition, columnKey: string): Record<string, unknown> {
if (field.type === 'boolean') return { [field.name]: columnKey === 'true' }
return { [field.name]: columnKey === NO_VALUE ? null : columnKey }
}
-74
View File
@@ -1,74 +0,0 @@
'use client'
// A self-contained example exercising the public API end-to-end: a small object
// schema (the kind a CRM/CMS/commerce app declares), a few records, and the
// flagship RecordsView (table ⇆ board, filter, sort, group, inline edit, detail).
// Doubles as a render smoke — if it composes, the surface is coherent.
import { useState } from 'react'
import { YStack, Text } from '@hanzo/gui'
import { RecordsView } from './view/RecordsView'
import { RecordDetail } from './record/RecordDetail'
import type { FieldDefinition } from './field/types'
import { tokens } from './theme'
const fields: FieldDefinition[] = [
{ name: 'name', label: 'Name', type: 'text', width: 180 },
{ name: 'email', label: 'Email', type: 'email' },
{
name: 'stage',
label: 'Stage',
type: 'select',
width: 130,
metadata: {
options: [
{ value: 'lead', label: 'Lead', color: 'blue' },
{ value: 'won', label: 'Won', color: 'green' },
{ value: 'lost', label: 'Lost', color: 'red' },
],
},
},
{ name: 'amount', label: 'Amount', type: 'currency', width: 130, metadata: { currencyCode: 'USD' } },
{ name: 'rating', label: 'Rating', type: 'rating', width: 120, metadata: { max: 5 } },
{ name: 'active', label: 'Active', type: 'boolean', width: 100 },
]
const seed: Array<Record<string, unknown>> = [
{ id: '1', name: 'Acme Inc', email: 'hi@acme.com', stage: 'won', amount: { amount: 4200, currencyCode: 'USD' }, rating: 5, active: true },
{ id: '2', name: 'Globex', email: 'sales@globex.io', stage: 'lead', amount: { amount: 900, currencyCode: 'USD' }, rating: 3, active: false },
{ id: '3', name: 'Initech', email: 'ap@initech.com', stage: 'lost', amount: { amount: 0, currencyCode: 'USD' }, rating: 2, active: false },
]
export function DataAppDemo() {
const [records, setRecords] = useState(seed)
const [open, setOpen] = useState<Record<string, unknown> | null>(null)
const editCommit = (record: Record<string, unknown>, field: FieldDefinition, value: unknown) =>
setRecords((rs) => rs.map((r) => (r.id === record.id ? { ...r, [field.name]: value } : r)))
return (
<YStack gap={24} p={24} style={{ backgroundColor: tokens.surfaceRaised, minHeight: '100vh' }}>
<RecordsView
title="Companies"
fields={fields}
records={records}
onOpen={setOpen}
onEditCommit={editCommit}
onCreate={() => {}}
createLabel="New company"
/>
{open ? (
<YStack maxW={560} p={20} rounded={12} borderWidth={1} style={{ borderColor: tokens.border, backgroundColor: tokens.surface }}>
<RecordDetail
title={String(open.name ?? 'Record')}
fields={fields}
record={open}
editable
onEditCommit={(field, value) => { editCommit(open, field, value); setOpen({ ...open, [field.name]: value }) }}
/>
</YStack>
) : (
<Text fontSize={13} style={{ color: tokens.textFaint }}>Open a record to see the detail panel.</Text>
)}
</YStack>
)
}
-12
View File
@@ -1,12 +0,0 @@
// The display router — resolves a field's read-only renderer by type and renders
// it. The single dispatch point: callers never branch on field type. Unknown
// types fall back to a safe string display rather than crashing.
import { getFieldRenderers } from './registry'
import { FallbackDisplay } from './displays'
import type { FieldDisplayProps } from './types'
import './registerDefaults'
export function FieldDisplay(props: FieldDisplayProps) {
const Display = getFieldRenderers(props.field.type)?.Display ?? FallbackDisplay
return <Display {...props} />
}
-17
View File
@@ -1,17 +0,0 @@
// The input router — resolves a field's editor by type. Read-only types (and
// any without a registered Input yet) render nothing, so a host can safely ask
// for an editor and get one only when the type supports editing.
import { getFieldRenderers } from './registry'
import type { FieldInputProps } from './types'
import './registerDefaults'
export function FieldInput(props: FieldInputProps) {
const Input = getFieldRenderers(props.field.type)?.Input
if (!Input) return null
return <Input {...props} />
}
/** True when a field type can be edited (has a registered Input). */
export function isEditable(type: FieldInputProps['field']['type']): boolean {
return Boolean(getFieldRenderers(type)?.Input)
}
-199
View File
@@ -1,199 +0,0 @@
// Read-only field renderers. Each is standalone and value-agnostic: it coerces
// the raw record value for its own type and renders with @hanzo/gui primitives
// (cross-platform — web, native, desktop). Static token colors use literal
// props; runtime palette colors (tag tones) go through `style` so they satisfy
// Gui's typed color props.
import { Text, XStack } from '@hanzo/gui'
import type {
FieldDisplayProps,
SelectOption,
CurrencyValue,
LinkValue,
TagColor,
} from './types'
import { tagTone, tokens } from '../theme'
// ── coercion helpers (one place; components stay tiny) ───────────────────────
const asStr = (v: unknown): string => (v == null ? '' : String(v))
const asNum = (v: unknown): number | null => {
if (v == null || v === '') return null
const n = typeof v === 'number' ? v : Number(v)
return Number.isFinite(n) ? n : null
}
const asArray = (v: unknown): unknown[] => (Array.isArray(v) ? v : v == null ? [] : [v])
const asDate = (v: unknown): Date | null => {
if (v == null || v === '') return null
const d = v instanceof Date ? v : new Date(v as string | number)
return Number.isNaN(d.getTime()) ? null : d
}
/** Em-dash placeholder for empty values — one consistent "no value" glyph. */
function Empty() {
return <Text color={tokens.textFaint}></Text>
}
// ── Tag: the shared pill for select / multiSelect / status ───────────────────
export function Tag({ label, color }: { label: string; color?: TagColor }) {
const t = tagTone(color)
return (
<XStack px={8} py={2} rounded={4} items="center" style={{ backgroundColor: t.bg }}>
<Text fontSize={12} fontWeight="600" numberOfLines={1} style={{ color: t.fg }}>
{label}
</Text>
</XStack>
)
}
const optionFor = (options: SelectOption[] | undefined, value: string): SelectOption =>
options?.find((o) => o.value === value) ?? { value, label: value }
// ── text family ──────────────────────────────────────────────────────────────
export function TextDisplay({ value }: FieldDisplayProps) {
const s = asStr(value)
return s ? <Text color={tokens.text} numberOfLines={1}>{s}</Text> : <Empty />
}
export function LongTextDisplay({ value }: FieldDisplayProps) {
const s = asStr(value)
return s ? <Text color={tokens.text} numberOfLines={2}>{s}</Text> : <Empty />
}
export function EmailDisplay({ value }: FieldDisplayProps) {
const s = asStr(value)
return s ? <Text color={tokens.accent} numberOfLines={1}>{s}</Text> : <Empty />
}
export function UrlDisplay({ value }: FieldDisplayProps) {
const s = asStr(value)
return s ? <Text color={tokens.accent} numberOfLines={1}>{s}</Text> : <Empty />
}
export function PhoneDisplay({ value }: FieldDisplayProps) {
const s = asStr(value)
return s ? <Text color={tokens.text} numberOfLines={1}>{s}</Text> : <Empty />
}
// ── numeric family ────────────────────────────────────────────────────────────
export function NumberDisplay({ field, value }: FieldDisplayProps) {
const n = asNum(value)
if (n == null) return <Empty />
const meta = (field.metadata ?? {}) as { decimals?: number; prefix?: string; suffix?: string }
const body = meta.decimals != null ? n.toFixed(meta.decimals) : n.toLocaleString()
return <Text color={tokens.text}>{`${meta.prefix ?? ''}${body}${meta.suffix ?? ''}`}</Text>
}
export function PercentDisplay({ field, value }: FieldDisplayProps) {
const n = asNum(value)
if (n == null) return <Empty />
const decimals = ((field.metadata ?? {}) as { decimals?: number }).decimals ?? 0
return <Text color={tokens.text}>{`${n.toFixed(decimals)}%`}</Text>
}
export function CurrencyDisplay({ field, value }: FieldDisplayProps) {
const meta = (field.metadata ?? {}) as { currencyCode?: string; decimals?: number }
const raw = (value ?? {}) as Partial<CurrencyValue>
const amount = typeof raw === 'object' && raw != null ? asNum(raw.amount) : asNum(value)
if (amount == null) return <Empty />
const code = raw.currencyCode || meta.currencyCode || 'USD'
let text: string
try {
text = new Intl.NumberFormat(undefined, {
style: 'currency',
currency: code,
maximumFractionDigits: meta.decimals ?? 2,
}).format(amount)
} catch {
text = `${amount} ${code}`
}
return <Text color={tokens.text}>{text}</Text>
}
export function RatingDisplay({ field, value }: FieldDisplayProps) {
const n = asNum(value) ?? 0
const max = ((field.metadata ?? {}) as { max?: number }).max ?? 5
const stars = '★'.repeat(Math.max(0, Math.min(max, Math.round(n)))) +
'☆'.repeat(Math.max(0, max - Math.round(n)))
return <Text style={{ color: tagTone('amber').fg }}>{stars}</Text>
}
// ── boolean ───────────────────────────────────────────────────────────────────
export function BooleanDisplay({ field, value }: FieldDisplayProps) {
const meta = (field.metadata ?? {}) as { trueLabel?: string; falseLabel?: string }
const on = value === true || value === 'true' || value === 1
return <Tag label={on ? meta.trueLabel ?? 'Yes' : meta.falseLabel ?? 'No'} color={on ? 'green' : 'gray'} />
}
// ── select / multiSelect ──────────────────────────────────────────────────────
export function SelectDisplay({ field, value }: FieldDisplayProps) {
const s = asStr(value)
if (!s) return <Empty />
const options = (field.metadata as { options?: SelectOption[] } | undefined)?.options
const opt = optionFor(options, s)
return <Tag label={opt.label} color={opt.color} />
}
export function MultiSelectDisplay({ field, value }: FieldDisplayProps) {
const values = asArray(value).map(asStr).filter(Boolean)
if (values.length === 0) return <Empty />
const options = (field.metadata as { options?: SelectOption[] } | undefined)?.options
return (
<XStack gap={4} items="center" style={{ flexWrap: 'wrap' }}>
{values.map((v) => {
const opt = optionFor(options, v)
return <Tag key={v} label={opt.label} color={opt.color} />
})}
</XStack>
)
}
// ── date family ───────────────────────────────────────────────────────────────
export function DateDisplay({ value }: FieldDisplayProps) {
const d = asDate(value)
return d ? <Text color={tokens.text}>{d.toLocaleDateString()}</Text> : <Empty />
}
export function DateTimeDisplay({ value }: FieldDisplayProps) {
const d = asDate(value)
return d ? <Text color={tokens.text}>{d.toLocaleString()}</Text> : <Empty />
}
// ── links ─────────────────────────────────────────────────────────────────────
export function LinksDisplay({ value }: FieldDisplayProps) {
const links = asArray(value)
.map((l) => (typeof l === 'string' ? { url: l } : (l as LinkValue)))
.filter((l) => l && l.url)
if (links.length === 0) return <Empty />
return (
<XStack gap={4} items="center" style={{ flexWrap: 'wrap' }}>
{links.map((l, i) => (
<Tag key={`${l.url}-${i}`} label={l.label || l.url} color="blue" />
))}
</XStack>
)
}
// ── relation ──────────────────────────────────────────────────────────────────
export function RelationDisplay({ field, value }: FieldDisplayProps) {
const labelField = ((field.metadata ?? {}) as { labelField?: string }).labelField ?? 'name'
let label = ''
if (typeof value === 'string') label = value
else if (value && typeof value === 'object') {
const rec = value as Record<string, unknown>
label = asStr(rec[labelField] ?? rec.label ?? rec.name ?? rec.id)
}
return label ? <Tag label={label} color="purple" /> : <Empty />
}
// ── json / fallback ───────────────────────────────────────────────────────────
export function JsonDisplay({ value }: FieldDisplayProps) {
if (value == null) return <Empty />
let text: string
try { text = JSON.stringify(value) } catch { text = String(value) }
return <Text color={tokens.textMuted} numberOfLines={1}>{text}</Text>
}
export function FallbackDisplay({ value }: FieldDisplayProps) {
const s = asStr(value)
return s ? <Text color={tokens.text} numberOfLines={1}>{s}</Text> : <Empty />
}
-368
View File
@@ -1,368 +0,0 @@
// Editable field renderers — Twenty-grade. Each coerces the raw value, renders a
// polished control on @hanzo/gui primitives + this package's small dropdown /
// calendar / toggle / checkbox primitives, and emits the next value via onChange;
// the host owns draft/save (matching the display/edit split). One editor per type,
// resolved through the registry, so a new type is a `registerField` call — never a
// switch. Palette is the package's raw-hex tokens (theme-config independent).
import { useMemo, useState, type ReactNode } from 'react'
import { Button, Input, Text, TextArea, XStack, YStack } from '@hanzo/gui'
import { Calendar as CalendarIcon, ChevronDown, Upload, X } from '@hanzogui/lucide-icons-2'
import type { FieldInputProps, SelectOption, CurrencyValue } from './types'
import { Tag } from './displays'
import { Menu, MenuItem, Toggle, Calendar } from '../primitives'
import { tokens } from '../theme'
const asStr = (v: unknown): string => (v == null ? '' : String(v))
const asNum = (v: unknown): number | null => {
if (v == null || v === '') return null
const n = typeof v === 'number' ? v : Number(v)
return Number.isFinite(n) ? n : null
}
const asArray = (v: unknown): string[] =>
(Array.isArray(v) ? v : v == null ? [] : [v]).map(asStr).filter(Boolean)
const optionsOf = (props: FieldInputProps): SelectOption[] =>
(props.field.metadata as { options?: SelectOption[] } | undefined)?.options ?? []
/** A bordered trigger pill — the shared look for select / relation / date pickers. */
function TriggerPill({ children }: { children: ReactNode }) {
return (
<XStack
items="center"
justify="space-between"
gap={8}
px={10}
py={7}
rounded={8}
borderWidth={1}
cursor="pointer"
minH={36}
hoverStyle={{ borderColor: tokens.textMuted }}
style={{ borderColor: tokens.border, backgroundColor: tokens.surface }}
>
<XStack items="center" gap={6} flex={1} style={{ flexWrap: 'wrap' }}>{children}</XStack>
<ChevronDown size={14} color={tokens.textFaint} />
</XStack>
)
}
const Placeholder = ({ text }: { text: string }) => <Text style={{ color: tokens.textFaint }}>{text}</Text>
const SearchBox = ({ q, onQ, placeholder }: { q: string; onQ: (v: string) => void; placeholder: string }) => (
<Input size="$2" value={q} onChangeText={onQ} placeholder={placeholder} autoFocus />
)
// ── text family ──────────────────────────────────────────────────────────────
export function TextInput({ field, value, onChange, onSubmit, autoFocus }: FieldInputProps) {
const placeholder = (field.metadata as { placeholder?: string } | undefined)?.placeholder
return (
<Input
value={asStr(value)}
onChangeText={(t: string) => onChange(t)}
onSubmitEditing={onSubmit}
placeholder={placeholder}
autoFocus={autoFocus}
/>
)
}
export function EmailInput(props: FieldInputProps) { return <TextInput {...props} /> }
export function UrlInput(props: FieldInputProps) { return <TextInput {...props} /> }
export function PhoneInput(props: FieldInputProps) { return <TextInput {...props} /> }
export function LongTextInput({ field, value, onChange, autoFocus }: FieldInputProps) {
const meta = (field.metadata ?? {}) as { placeholder?: string; rows?: number }
return (
<TextArea
value={asStr(value)}
onChangeText={(t: string) => onChange(t)}
placeholder={meta.placeholder}
autoFocus={autoFocus}
numberOfLines={meta.rows ?? 4}
style={{ minHeight: (meta.rows ?? 4) * 22 }}
/>
)
}
// ── numeric family ────────────────────────────────────────────────────────────
export function NumberInput({ field, value, onChange, onSubmit, autoFocus }: FieldInputProps) {
const placeholder = (field.metadata as { placeholder?: string } | undefined)?.placeholder
return (
<Input
value={value == null ? '' : String(value)}
onChangeText={(t: string) => onChange(t === '' ? null : asNum(t))}
onSubmitEditing={onSubmit}
placeholder={placeholder}
inputMode="numeric"
autoFocus={autoFocus}
/>
)
}
export function PercentInput(props: FieldInputProps) { return <NumberInput {...props} /> }
export function CurrencyInput({ field, value, onChange, onSubmit, autoFocus }: FieldInputProps) {
const meta = (field.metadata ?? {}) as { currencyCode?: string }
const raw = (value ?? {}) as Partial<CurrencyValue>
const amount = typeof raw === 'object' && raw != null ? raw.amount : asNum(value)
const code = raw.currencyCode || meta.currencyCode || 'USD'
return (
<XStack items="center" gap={8}>
<Text style={{ color: tokens.textMuted }} fontSize={13}>{code}</Text>
<Input
flex={1}
value={amount == null ? '' : String(amount)}
onChangeText={(t: string) => onChange({ amount: t === '' ? null : asNum(t), currencyCode: code })}
onSubmitEditing={onSubmit}
inputMode="decimal"
autoFocus={autoFocus}
/>
</XStack>
)
}
export function RatingInput({ field, value, onChange }: FieldInputProps) {
const max = ((field.metadata ?? {}) as { max?: number }).max ?? 5
const current = asNum(value) ?? 0
return (
<XStack gap={2} items="center">
{Array.from({ length: max }, (_, i) => i + 1).map((n) => (
<YStack key={n} cursor="pointer" onPress={() => onChange(n === current ? 0 : n)}>
<Text fontSize={18} style={{ color: n <= current ? '#fbbf24' : tokens.border }}>{n <= current ? '★' : '☆'}</Text>
</YStack>
))}
</XStack>
)
}
// ── boolean (sliding toggle) ──────────────────────────────────────────────────
export function BooleanInput({ field, value, onChange }: FieldInputProps) {
const meta = (field.metadata ?? {}) as { trueLabel?: string; falseLabel?: string }
const on = value === true || value === 'true' || value === 1
return (
<XStack items="center" gap={8}>
<Toggle on={on} onChange={(next) => onChange(next)} />
<Text fontSize={13} style={{ color: tokens.textMuted }}>{on ? meta.trueLabel ?? 'Yes' : meta.falseLabel ?? 'No'}</Text>
</XStack>
)
}
// ── select (searchable dropdown, chip value) ──────────────────────────────────
export function SelectInput(props: FieldInputProps) {
const { value, onChange } = props
const options = optionsOf(props)
const [open, setOpen] = useState(false)
const [q, setQ] = useState('')
const current = asStr(value)
const selected = options.find((o) => o.value === current)
const filtered = useMemo(
() => options.filter((o) => o.label.toLowerCase().includes(q.trim().toLowerCase())),
[options, q],
)
return (
<Menu
open={open}
onOpenChange={(o) => { setOpen(o); if (!o) setQ('') }}
width={260}
header={options.length > 6 ? <SearchBox q={q} onQ={setQ} placeholder="Search options…" /> : undefined}
trigger={<TriggerPill>{selected ? <Tag label={selected.label} color={selected.color} /> : <Placeholder text="Select…" />}</TriggerPill>}
>
{current ? (
<MenuItem onPress={() => { onChange(null); setOpen(false) }}>
<Text fontSize={13} style={{ color: tokens.textFaint }}>Clear</Text>
</MenuItem>
) : null}
{filtered.map((o) => (
<MenuItem key={o.value} active={o.value === current} onPress={() => { onChange(o.value); setOpen(false) }}>
<Tag label={o.label} color={o.color} />
</MenuItem>
))}
{filtered.length === 0 ? <Text px={10} py={6} fontSize={12} style={{ color: tokens.textFaint }}>No options</Text> : null}
</Menu>
)
}
// ── multiSelect (chips + checkable dropdown) ──────────────────────────────────
export function MultiSelectInput(props: FieldInputProps) {
const { value, onChange } = props
const options = optionsOf(props)
const [open, setOpen] = useState(false)
const [q, setQ] = useState('')
const selected = new Set(asArray(value))
const toggle = (v: string) => {
const next = new Set(selected)
if (next.has(v)) next.delete(v)
else next.add(v)
onChange([...next])
}
const chosen = options.filter((o) => selected.has(o.value))
const filtered = options.filter((o) => o.label.toLowerCase().includes(q.trim().toLowerCase()))
return (
<Menu
open={open}
onOpenChange={(o) => { setOpen(o); if (!o) setQ('') }}
width={260}
header={options.length > 6 ? <SearchBox q={q} onQ={setQ} placeholder="Search options…" /> : undefined}
trigger={
<TriggerPill>
{chosen.length > 0 ? chosen.map((o) => <Tag key={o.value} label={o.label} color={o.color} />) : <Placeholder text="Select…" />}
</TriggerPill>
}
>
{filtered.map((o) => (
<MenuItem
key={o.value}
active={selected.has(o.value)}
onPress={() => toggle(o.value)}
trailing={selected.has(o.value) ? <Text style={{ color: tokens.accent }}></Text> : null}
>
<Tag label={o.label} color={o.color} />
</MenuItem>
))}
{filtered.length === 0 ? <Text px={10} py={6} fontSize={12} style={{ color: tokens.textFaint }}>No options</Text> : null}
</Menu>
)
}
// ── date / dateTime (calendar picker) ─────────────────────────────────────────
export function DateInput({ value, onChange, autoFocus }: FieldInputProps) {
const [open, setOpen] = useState(false)
const isoDay = value == null ? '' : value instanceof Date ? value.toISOString().slice(0, 10) : String(value).slice(0, 10)
const label = isoDay ? new Date(`${isoDay}T00:00:00`).toLocaleDateString() : ''
return (
<Menu
open={open}
onOpenChange={setOpen}
width={272}
trigger={
<TriggerPill>
<XStack items="center" gap={8} flex={1}>
<CalendarIcon size={15} color={tokens.textFaint} />
{label ? <Text style={{ color: tokens.text }}>{label}</Text> : <Placeholder text="Pick a date…" />}
</XStack>
</TriggerPill>
}
>
<Calendar value={isoDay} onSelect={(d) => { onChange(d); setOpen(false) }} />
{isoDay ? (
<MenuItem onPress={() => { onChange(null); setOpen(false) }}>
<Text fontSize={13} style={{ color: tokens.textFaint }}>Clear</Text>
</MenuItem>
) : null}
<YStack px={10} pb={8}>
<Input size="$2" value={isoDay} placeholder="YYYY-MM-DD" onChangeText={(t: string) => onChange(t)} autoFocus={autoFocus} />
</YStack>
</Menu>
)
}
// ── relation (record picker over injected options, else id text) ──────────────
export function RelationInput(props: FieldInputProps) {
const { value, onChange } = props
const options = optionsOf(props)
const [open, setOpen] = useState(false)
const [q, setQ] = useState('')
// Presentational: when the host injects candidate records as options, pick one;
// otherwise fall back to a raw id entry (honest — no fabricated candidate list).
if (options.length === 0) {
const label = typeof value === 'object' && value != null
? asStr((value as Record<string, unknown>).name ?? (value as Record<string, unknown>).id)
: asStr(value)
return <Input value={label} onChangeText={(t: string) => onChange(t)} placeholder="Related record id…" />
}
const current = asStr(
typeof value === 'object' && value != null
? (value as Record<string, unknown>).id ?? (value as Record<string, unknown>).value
: value,
)
const selected = options.find((o) => o.value === current)
const filtered = options.filter((o) => o.label.toLowerCase().includes(q.trim().toLowerCase()))
return (
<Menu
open={open}
onOpenChange={(o) => { setOpen(o); if (!o) setQ('') }}
width={280}
header={<SearchBox q={q} onQ={setQ} placeholder="Search records…" />}
trigger={<TriggerPill>{selected ? <Tag label={selected.label} color="purple" /> : <Placeholder text="Link a record…" />}</TriggerPill>}
>
{current ? (
<MenuItem onPress={() => { onChange(null); setOpen(false) }}>
<Text fontSize={13} style={{ color: tokens.textFaint }}>Clear</Text>
</MenuItem>
) : null}
{filtered.map((o) => (
<MenuItem key={o.value} active={o.value === current} onPress={() => { onChange(o.value); setOpen(false) }}>
<Text fontSize={13} style={{ color: tokens.text }}>{o.label}</Text>
</MenuItem>
))}
{filtered.length === 0 ? <Text px={10} py={6} fontSize={12} style={{ color: tokens.textFaint }}>No matches</Text> : null}
</Menu>
)
}
// ── files (web upload; host persists) ─────────────────────────────────────────
export function FileInput({ value, onChange }: FieldInputProps) {
const names = (Array.isArray(value) ? value : value == null ? [] : [value]).map((f) =>
typeof f === 'string' ? f : asStr((f as { name?: string })?.name ?? 'file'),
)
const pick = () => {
if (typeof document === 'undefined') return
const el = document.createElement('input')
el.type = 'file'
el.multiple = true
el.onchange = () => onChange(el.files ? Array.from(el.files) : [])
el.click()
}
const supported = typeof document !== 'undefined'
return (
<YStack gap={8}>
{supported ? (
<Button size="$2" icon={<Upload size={15} />} onPress={pick}>Choose files</Button>
) : (
<Text fontSize={13} style={{ color: tokens.textFaint }}>File upload is available on the web.</Text>
)}
{names.length > 0 ? (
<XStack gap={6} items="center" style={{ flexWrap: 'wrap' }}>
{names.map((n, i) => (
<XStack key={`${n}-${i}`} items="center" gap={4} px={8} py={3} rounded={6} style={{ backgroundColor: tokens.hover }}>
<Text fontSize={12} style={{ color: tokens.text }}>{n}</Text>
<YStack cursor="pointer" onPress={() => onChange((Array.isArray(value) ? value : []).filter((_, j) => j !== i))}>
<X size={12} color={tokens.textFaint} />
</YStack>
</XStack>
))}
</XStack>
) : null}
</YStack>
)
}
// ── json (validated textarea) ─────────────────────────────────────────────────
function safeStringify(v: unknown): string {
try { return JSON.stringify(v, null, 2) } catch { return String(v) }
}
export function JsonInput({ value, onChange, autoFocus }: FieldInputProps) {
const initial = value == null ? '' : typeof value === 'string' ? value : safeStringify(value)
const [text, setText] = useState(initial)
const [error, setError] = useState<string | null>(null)
const commit = (t: string) => {
setText(t)
if (t.trim() === '') { setError(null); onChange(null); return }
try { onChange(JSON.parse(t)); setError(null) } catch { setError('Invalid JSON') }
}
return (
<YStack gap={4}>
<TextArea
value={text}
onChangeText={commit}
autoFocus={autoFocus}
numberOfLines={6}
placeholder="{ }"
style={{ minHeight: 132, fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace' }}
/>
{error ? <Text fontSize={12} style={{ color: tokens.danger }}>{error}</Text> : null}
</YStack>
)
}
-52
View File
@@ -1,52 +0,0 @@
// Wire the built-in field types to their renderers. Importing this module (the
// barrel does) registers everything. Read-only types (uuid, position, actor,
// json, files, address, relation, links) omit an Input until a richer editor
// (record picker, file upload, calendar) is registered over them — the table
// and detail views already work with display-only types.
import { registerField } from './registry'
import {
TextDisplay, LongTextDisplay, NumberDisplay, PercentDisplay, CurrencyDisplay,
BooleanDisplay, SelectDisplay, MultiSelectDisplay, DateDisplay, DateTimeDisplay,
EmailDisplay, UrlDisplay, PhoneDisplay, LinksDisplay, RatingDisplay,
RelationDisplay, JsonDisplay, FallbackDisplay,
} from './displays'
import {
TextInput, LongTextInput, NumberInput, PercentInput, CurrencyInput,
BooleanInput, SelectInput, MultiSelectInput, DateInput, EmailInput,
UrlInput, PhoneInput, RatingInput, RelationInput, FileInput, JsonInput,
} from './inputs'
let done = false
/** Idempotent — registers the built-in renderers once. */
export function registerDefaultFields(): void {
if (done) return
done = true
registerField('text', { Display: TextDisplay, Input: TextInput })
registerField('longText', { Display: LongTextDisplay, Input: LongTextInput })
registerField('richText', { Display: LongTextDisplay, Input: LongTextInput })
registerField('number', { Display: NumberDisplay, Input: NumberInput })
registerField('percent', { Display: PercentDisplay, Input: PercentInput })
registerField('currency', { Display: CurrencyDisplay, Input: CurrencyInput })
registerField('boolean', { Display: BooleanDisplay, Input: BooleanInput })
registerField('select', { Display: SelectDisplay, Input: SelectInput })
registerField('multiSelect', { Display: MultiSelectDisplay, Input: MultiSelectInput })
registerField('date', { Display: DateDisplay, Input: DateInput })
registerField('dateTime', { Display: DateTimeDisplay, Input: DateInput })
registerField('email', { Display: EmailDisplay, Input: EmailInput })
registerField('url', { Display: UrlDisplay, Input: UrlInput })
registerField('phone', { Display: PhoneDisplay, Input: PhoneInput })
registerField('rating', { Display: RatingDisplay, Input: RatingInput })
registerField('links', { Display: LinksDisplay })
registerField('relation', { Display: RelationDisplay, Input: RelationInput })
registerField('json', { Display: JsonDisplay, Input: JsonInput })
registerField('files', { Display: FallbackDisplay, Input: FileInput })
registerField('uuid', { Display: FallbackDisplay })
registerField('fullName', { Display: FallbackDisplay })
registerField('address', { Display: FallbackDisplay })
registerField('position', { Display: NumberDisplay })
registerField('actor', { Display: FallbackDisplay })
}
registerDefaultFields()
-40
View File
@@ -1,40 +0,0 @@
// The field registry — the one dispatch point from a FieldType to its renderers.
//
// Why a registry and not a switch: adding (or overriding) a field type is a
// single `registerField` call, with zero edits to FieldDisplay/FieldInput/
// DataTable. Apps can register custom types (e.g. a domain-specific "sku" or
// "geo") and they render everywhere automatically. Orthogonal + open/closed.
import type {
FieldType,
FieldDisplayComponent,
FieldInputComponent,
} from './types'
export interface FieldRenderers {
/** Read-only renderer. Required — every type can at least display. */
Display: FieldDisplayComponent
/** Edit renderer. Optional — read-only types (uuid, actor, position) omit it. */
Input?: FieldInputComponent
}
const registry = new Map<FieldType, FieldRenderers>()
/** Register (or override) the renderers for a field type. */
export function registerField(type: FieldType, renderers: FieldRenderers): void {
registry.set(type, renderers)
}
/** Resolve renderers for a type, or undefined if none registered yet. */
export function getFieldRenderers(type: FieldType): FieldRenderers | undefined {
return registry.get(type)
}
/** True when a type has a registered renderer. */
export function hasField(type: FieldType): boolean {
return registry.has(type)
}
/** Every registered type — useful for schema editors / type pickers. */
export function registeredFieldTypes(): FieldType[] {
return [...registry.keys()]
}
-142
View File
@@ -1,142 +0,0 @@
// The field type system — the universal vocabulary every Base-backed app
// (CRM, CMS, commerce, internal tool) is built from. An "object" is a set of
// typed fields; a "record" is values for those fields; every view (table,
// board, detail) renders from this one model. One source of truth.
//
// Each FieldType maps to exactly one display component (read) and at most one
// input component (edit), resolved through the registry — so adding a type is
// adding one entry, never editing a switch.
import type { ReactElement } from 'react'
/**
* The built-in field types. Extensible: register a new type's renderers and it
* works everywhere. Covers the universal CRM/CMS/commerce set; niche types
* (actor/position/json/files/richText/address/fullName) are declared here so
* schemas can reference them and slot renderers in incrementally.
*/
export type FieldType =
| 'text' // single-line text
| 'longText' // multi-line text
| 'number' // integer or decimal
| 'percent' // 0100(%), stored as a number
| 'currency' // money: { amount, currencyCode }
| 'boolean' // true/false toggle
| 'select' // one color-tagged option
| 'multiSelect' // many color-tagged options
| 'date' // calendar date (no time)
| 'dateTime' // date + time
| 'email' // single email address
| 'url' // single URL
| 'links' // many labelled URLs
| 'phone' // phone number
| 'rating' // 0N stars
| 'relation' // reference to another record
| 'json' // arbitrary JSON
| 'uuid' // immutable identifier
| 'fullName' // { first, last }
| 'address' // postal address
| 'files' // file attachments
| 'richText' // block/markdown rich text
| 'position' // sort order
| 'actor' // audit: who/what created the record
/** Tag palette key — resolved to bg/fg by the theme. Brand-neutral by name. */
export type TagColor =
| 'gray' | 'blue' | 'green' | 'amber' | 'red'
| 'purple' | 'pink' | 'teal' | 'orange'
/** One option of a select/multiSelect field. */
export interface SelectOption {
value: string
label: string
color?: TagColor
}
/** A labelled URL, for `links`. */
export interface LinkValue {
url: string
label?: string
}
/** A money value, for `currency`. */
export interface CurrencyValue {
/** Major-unit amount (e.g. dollars), not micros — kept simple at the edge. */
amount: number | null
/** ISO 4217 code, e.g. "USD". */
currencyCode: string
}
/** Per-type metadata — the schema knobs each type understands. */
export interface FieldMetadataMap {
text: { placeholder?: string }
longText: { placeholder?: string; rows?: number }
number: { placeholder?: string; decimals?: number; prefix?: string; suffix?: string }
percent: { decimals?: number }
currency: { currencyCode?: string; decimals?: number }
boolean: { trueLabel?: string; falseLabel?: string }
select: { options: SelectOption[] }
multiSelect: { options: SelectOption[] }
date: { display?: 'relative' | 'absolute' }
dateTime: { display?: 'relative' | 'absolute' }
email: { placeholder?: string }
url: { placeholder?: string }
links: Record<string, never>
phone: { placeholder?: string }
rating: { max?: number }
relation: { objectName?: string; labelField?: string }
json: Record<string, never>
uuid: Record<string, never>
fullName: Record<string, never>
address: Record<string, never>
files: Record<string, never>
richText: Record<string, never>
position: Record<string, never>
actor: Record<string, never>
}
export type FieldMetadata<T extends FieldType = FieldType> = FieldMetadataMap[T]
/**
* A field's definition — the schema unit. Mirrors a Base collection field:
* a stable `name`, a human `label`, a `type`, and type-specific `metadata`.
*/
export interface FieldDefinition<T extends FieldType = FieldType> {
/** Stable key — the record property / collection field name. */
name: string
/** Human-readable column/label text. */
label: string
/** Drives which display + input render. */
type: T
/** Type-specific options (select options, currency code, …). */
metadata?: FieldMetadata<T>
/** When true, the field never enters edit mode. */
readOnly?: boolean
/** Fixed pixel width when used as a table column; otherwise it flexes. */
width?: number
}
// ── Component contracts ──────────────────────────────────────────────────────
// Display + Input are intentionally tiny and value-agnostic: each component
// owns the coercion of `value` for its own type, so they stay standalone and
// composable. The router (FieldDisplay/FieldInput) only resolves by type.
export interface FieldDisplayProps {
field: FieldDefinition
/** The record's raw value for this field — each component coerces it. */
value: unknown
}
export interface FieldInputProps {
field: FieldDefinition
value: unknown
/** Emit the next value. The host owns persistence (draft vs save). */
onChange: (value: unknown) => void
/** Commit (Enter / blur). */
onSubmit?: () => void
/** Discard (Escape). */
onCancel?: () => void
autoFocus?: boolean
}
export type FieldDisplayComponent = (props: FieldDisplayProps) => ReactElement | null
export type FieldInputComponent = (props: FieldInputProps) => ReactElement | null
-119
View File
@@ -1,119 +0,0 @@
// @hanzo/data — the cross-platform, metadata-driven data-app layer.
//
// One model (typed fields → records → views) powers any Base-backed app: CRM,
// CMS, commerce, internal tools. Built on @hanzo/gui so the same components run on
// web, native (iOS), and desktop. Airtable/Twenty-class polish, clean-room.
// Importing the package registers the built-in field renderers; register more to
// extend. A business app is a curated set of views over these components.
//
// import { RecordsView, DataTable, BoardView, RecordDetail, registerField } from '@hanzo/data'
// Register the built-in field renderers as a side effect of importing the lib.
import './field/registerDefaults'
// ── Field model + contracts ──────────────────────────────────────────────────
export type {
FieldType,
TagColor,
SelectOption,
LinkValue,
CurrencyValue,
FieldMetadata,
FieldMetadataMap,
FieldDefinition,
FieldDisplayProps,
FieldInputProps,
FieldDisplayComponent,
FieldInputComponent,
} from './field/types'
// Registry (extensibility)
export {
registerField,
getFieldRenderers,
hasField,
registeredFieldTypes,
type FieldRenderers,
} from './field/registry'
export { registerDefaultFields } from './field/registerDefaults'
// Routers + built-in renderers (usable standalone / for composition)
export { FieldDisplay } from './field/FieldDisplay'
export { FieldInput, isEditable } from './field/FieldInput'
export * from './field/displays'
export * from './field/inputs'
// ── Views ────────────────────────────────────────────────────────────────────
export { DataTable, type DataTableProps } from './table/DataTable'
export { RecordCard, type RecordCardProps } from './table/RecordCard'
export { BoardView, type BoardViewProps } from './board/BoardView'
export {
RecordDetail,
RecordForm,
type RecordDetailProps,
type RecordFormProps,
} from './record/RecordDetail'
export {
RecordsView,
type RecordsViewProps,
type SavedViews,
} from './view/RecordsView'
export { RecordsToolbar, type RecordsToolbarProps } from './view/Toolbar'
export type { ViewConfig, ViewKind } from './view/types'
// ── Composable primitives (dropdown, checkbox, toggle, calendar) ──────────────
export { Menu, MenuItem, CheckBox, Toggle, Calendar } from './primitives'
export type { MenuProps, MenuItemProps, CheckBoxProps, ToggleProps, CalendarProps } from './primitives'
// ── Pure logic (sort / filter / search / group / view) — host-usable, testable ──
export {
comparable,
relationLabel,
sortRecords,
cycleSort,
sortDirOf,
operatorsForType,
matchesRule,
filterRecords,
searchRecords,
paginate,
moveItem,
orderedColumns,
columnIndexAtX,
OP_LABELS,
type SortRule,
type SortDir,
type FilterRule,
type FilterOp,
type Page,
} from './table/logic'
export {
boardColumns,
movePatch,
groupKeyOf,
groupFieldCandidates,
defaultGroupField,
NO_VALUE,
type BoardColumn,
} from './board/logic'
export {
applyView,
emptyView,
defaultRuleFor,
addFilter,
updateFilter,
removeFilter,
setSorts,
cycleColumnSort,
setSearch,
setKind,
setGroupField,
setColumnOrder,
toggleFieldHidden,
upsertView,
removeView,
describeView,
} from './view/logic'
// ── Theme ────────────────────────────────────────────────────────────────────
export { tokens, TAG_TONES, tagTone, type TagTone } from './theme'
-98
View File
@@ -1,98 +0,0 @@
// Calendar — a compact month-grid day picker for date / dateTime fields. Pure
// primitives + the data palette; no date library. Emits an ISO `YYYY-MM-DD` on
// select. Month navigation is local state; today and the selected day are marked.
import { useState } from 'react'
import { ChevronLeft, ChevronRight } from '@hanzogui/lucide-icons-2'
import { Text, XStack, YStack } from '@hanzo/gui'
import { tokens, tagTone } from '../theme'
const DOW = ['S', 'M', 'T', 'W', 'T', 'F', 'S']
const MONTHS = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December']
const iso = (d: Date): string =>
`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
export interface CalendarProps {
/** Selected day as ISO `YYYY-MM-DD`, or undefined. */
value?: string
onSelect: (isoDate: string) => void
}
export function Calendar({ value, onSelect }: CalendarProps) {
const selected = value ? new Date(`${value}T00:00:00`) : null
const init = selected && !Number.isNaN(selected.getTime()) ? selected : new Date()
const [view, setView] = useState({ year: init.getFullYear(), month: init.getMonth() })
const today = iso(new Date())
const first = new Date(view.year, view.month, 1)
const startPad = first.getDay()
const daysInMonth = new Date(view.year, view.month + 1, 0).getDate()
const cells: (number | null)[] = [
...Array.from({ length: startPad }, () => null),
...Array.from({ length: daysInMonth }, (_, i) => i + 1),
]
while (cells.length % 7 !== 0) cells.push(null)
const shift = (delta: number) => {
const m = view.month + delta
setView({ year: view.year + Math.floor(m / 12), month: ((m % 12) + 12) % 12 })
}
const selectedIso = selected ? iso(selected) : ''
return (
<YStack width={252} p={10} gap={8}>
<XStack items="center" justify="space-between">
<YStack width={28} height={28} rounded={6} items="center" justify="center" cursor="pointer" hoverStyle={{ bg: tokens.hover }} onPress={() => shift(-1)}>
<ChevronLeft size={16} color={tokens.textMuted} />
</YStack>
<Text fontSize={13} fontWeight="700" style={{ color: tokens.text }}>{`${MONTHS[view.month]} ${view.year}`}</Text>
<YStack width={28} height={28} rounded={6} items="center" justify="center" cursor="pointer" hoverStyle={{ bg: tokens.hover }} onPress={() => shift(1)}>
<ChevronRight size={16} color={tokens.textMuted} />
</YStack>
</XStack>
<XStack>
{DOW.map((d, i) => (
<YStack key={i} width={32} items="center">
<Text fontSize={11} fontWeight="600" style={{ color: tokens.textFaint }}>{d}</Text>
</YStack>
))}
</XStack>
<YStack>
{Array.from({ length: cells.length / 7 }, (_, row) => (
<XStack key={row}>
{cells.slice(row * 7, row * 7 + 7).map((day, col) => {
if (day == null) return <YStack key={col} width={32} height={30} />
const dISO = iso(new Date(view.year, view.month, day))
const isSel = dISO === selectedIso
const isToday = dISO === today
return (
<YStack
key={col}
width={32}
height={30}
items="center"
justify="center"
rounded={6}
cursor="pointer"
hoverStyle={{ bg: tokens.hover }}
onPress={() => onSelect(dISO)}
style={isSel ? { backgroundColor: tokens.accent } : undefined}
>
<Text
fontSize={12}
fontWeight={isSel || isToday ? '700' : '400'}
style={{ color: isSel ? '#0b1220' : isToday ? tagTone('blue').fg : tokens.text }}
>
{day}
</Text>
</YStack>
)
})}
</XStack>
))}
</YStack>
</YStack>
)
}
-42
View File
@@ -1,42 +0,0 @@
// CheckBox — a small, self-contained selection box (no composite gui Checkbox
// API), used for row selection in the table. Checked / indeterminate states with
// the data palette; press toggles. Keeps the package on its minimal primitive
// surface so it type-checks everywhere.
import { Check, Minus } from '@hanzogui/lucide-icons-2'
import { YStack } from '@hanzo/gui'
import { tokens } from '../theme'
export interface CheckBoxProps {
checked?: boolean
/** Some-but-not-all selected (header checkbox). */
indeterminate?: boolean
onChange?: (checked: boolean) => void
size?: number
}
export function CheckBox({ checked, indeterminate, onChange, size = 16 }: CheckBoxProps) {
const on = checked || indeterminate
return (
<YStack
width={size}
height={size}
rounded={4}
items="center"
justify="center"
borderWidth={1}
cursor="pointer"
onPress={onChange ? () => onChange(!checked) : undefined}
hoverStyle={{ borderColor: tokens.accent }}
style={{
backgroundColor: on ? tokens.accent : 'transparent',
borderColor: on ? tokens.accent : tokens.border,
}}
>
{indeterminate ? (
<Minus size={size - 4} color="#0b1220" strokeWidth={3} />
) : checked ? (
<Check size={size - 4} color="#0b1220" strokeWidth={3} />
) : null}
</YStack>
)
}
-81
View File
@@ -1,81 +0,0 @@
// Menu — the ONE dropdown/overlay primitive the data views use (column sort menu,
// filter builder, select/relation pickers, view switcher). A thin, controlled
// wrapper over @hanzo/gui's proven Popover with the package's raw-hex palette
// (theme-config independent) and a scrolling content panel. Everything that pops
// over goes through here, so menus look and behave identically everywhere.
import type { ReactNode } from 'react'
import { Popover, ScrollView, XStack, YStack } from '@hanzo/gui'
import { tokens } from '../theme'
export interface MenuProps {
/** The element that opens the menu (wrapped in a Popover.Trigger). */
trigger: ReactNode
children: ReactNode
open?: boolean
onOpenChange?: (open: boolean) => void
placement?: 'bottom-start' | 'bottom-end' | 'bottom' | 'top-start' | 'top-end'
width?: number
/** Max content height before the panel scrolls (default 320). */
maxHeight?: number
/** A fixed panel above the scrolling body (e.g. a search box). */
header?: ReactNode
}
/** A controlled popover menu with the data palette. Trigger toggles; select closes. */
export function Menu({ trigger, children, open, onOpenChange, placement = 'bottom-start', width = 240, maxHeight = 320, header }: MenuProps) {
return (
<Popover placement={placement} open={open} onOpenChange={onOpenChange} allowFlip>
<Popover.Trigger asChild>{trigger}</Popover.Trigger>
<Popover.Content
borderWidth={1}
p={0}
rounded={10}
overflow="hidden"
elevate
width={width}
style={{ backgroundColor: tokens.surfaceRaised, borderColor: tokens.border }}
>
{header ? (
<YStack p={6} style={{ borderBottomWidth: 1, borderBottomColor: tokens.border }}>
{header}
</YStack>
) : null}
<ScrollView style={{ maxHeight }}>
<YStack p={4}>{children}</YStack>
</ScrollView>
</Popover.Content>
</Popover>
)
}
export interface MenuItemProps {
children: ReactNode
onPress?: () => void
active?: boolean
/** A leading glyph / control. */
icon?: ReactNode
/** A trailing glyph (e.g. a check). */
trailing?: ReactNode
tone?: 'default' | 'danger'
}
/** One selectable row in a Menu — hover-highlighted, with optional lead/trail slots. */
export function MenuItem({ children, onPress, active, icon, trailing, tone = 'default' }: MenuItemProps) {
return (
<YStack
onPress={onPress}
cursor="pointer"
px={10}
py={7}
rounded={6}
hoverStyle={{ bg: tokens.hover }}
style={active ? { backgroundColor: tokens.hover } : undefined}
>
<XStack items="center" gap={8}>
{icon ? <XStack items="center" justify="center">{icon}</XStack> : null}
<YStack flex={1}>{children}</YStack>
{trailing ?? null}
</XStack>
</YStack>
)
}
-44
View File
@@ -1,44 +0,0 @@
// Toggle — a compact switch (track + sliding thumb) for boolean fields. Built from
// primitives (no composite gui Switch API) so it type-checks everywhere and reads
// the data palette. Press flips; `on` is green.
import { YStack } from '@hanzo/gui'
import { tagTone, tokens } from '../theme'
export interface ToggleProps {
on?: boolean
onChange?: (on: boolean) => void
disabled?: boolean
}
export function Toggle({ on, onChange, disabled }: ToggleProps) {
const w = 34
const h = 20
const pad = 2
const thumb = h - pad * 2
return (
<YStack
width={w}
height={h}
rounded={h}
justify="center"
cursor={disabled ? undefined : 'pointer'}
onPress={disabled || !onChange ? undefined : () => onChange(!on)}
style={{
opacity: disabled ? 0.5 : 1,
backgroundColor: on ? tagTone('green').border : tokens.border,
paddingLeft: pad,
paddingRight: pad,
}}
>
<YStack
width={thumb}
height={thumb}
rounded={thumb}
style={{
backgroundColor: '#f4f4f5',
transform: [{ translateX: on ? w - thumb - pad * 2 : 0 }],
}}
/>
</YStack>
)
}
-4
View File
@@ -1,4 +0,0 @@
export { Menu, MenuItem, type MenuProps, type MenuItemProps } from './Menu'
export { CheckBox, type CheckBoxProps } from './CheckBox'
export { Toggle, type ToggleProps } from './Toggle'
export { Calendar, type CalendarProps } from './Calendar'
-143
View File
@@ -1,143 +0,0 @@
'use client'
// RecordDetail (view, optionally inline-editable) + RecordForm (create/edit form)
// — the single-record views, Twenty-grade.
//
// RecordDetail: a titled panel of label → value fields. When `editable`, clicking
// a value edits it in place through the field's own editor (dropdown selects on
// change, text on Enter/blur), persisting via onEditCommit — the same inline model
// as the table. A `related` slot hosts linked-record lists. Read-only without the
// edit props (backward compatible).
// RecordForm: a label → editor list for create/edit, emitting per-field changes;
// the host owns the draft + save.
import { useState, type ReactNode } from 'react'
import { Text, XStack, YStack } from '@hanzo/gui'
import type { FieldDefinition, SelectOption } from '../field/types'
import { FieldDisplay } from '../field/FieldDisplay'
import { FieldInput, isEditable } from '../field/FieldInput'
import { tokens } from '../theme'
const INSTANT = new Set(['boolean', 'select', 'multiSelect', 'date', 'dateTime', 'relation', 'rating', 'files'])
const FieldLabel = ({ children }: { children: ReactNode }) => (
<Text fontSize={11} fontWeight="700" style={{ color: tokens.textFaint, letterSpacing: 0.4, textTransform: 'uppercase' }}>
{children}
</Text>
)
export interface RecordDetailProps {
fields: FieldDefinition[]
record: Record<string, unknown>
/** Big title above the fields (e.g. the record's name). */
title?: string
/** Enable click-to-edit on each value. */
editable?: boolean
/** Persist one field edit (async allowed). */
onEditCommit?: (field: FieldDefinition, value: unknown) => void | Promise<void>
/** Inject candidate options for a field's editor (e.g. relation records). */
fieldOptions?: (field: FieldDefinition) => SelectOption[] | undefined
/** Linked-records / activity slot, rendered below the fields. */
related?: ReactNode
}
/** One label → value row; click-to-edit when editable + the type has an input. */
function DetailField({ field, value, editable, onCommit, options }: {
field: FieldDefinition
value: unknown
editable?: boolean
onCommit?: (value: unknown) => void | Promise<void>
options?: SelectOption[]
}) {
const [editing, setEditing] = useState(false)
const [draft, setDraft] = useState<unknown>(value)
const canEdit = editable && !field.readOnly && isEditable(field.type) && Boolean(onCommit)
const withOpts: FieldDefinition = options ? { ...field, metadata: { ...(field.metadata as object), options } as FieldDefinition['metadata'] } : field
const commit = (v: unknown) => { setEditing(false); void onCommit?.(v) }
return (
<YStack gap={5} py={8} style={{ borderBottomWidth: 1, borderBottomColor: tokens.border }}>
<FieldLabel>{field.label}</FieldLabel>
{editing ? (
<FieldInput
field={withOpts}
value={draft}
autoFocus
onChange={(v) => (INSTANT.has(field.type) ? commit(v) : setDraft(v))}
onSubmit={() => commit(draft)}
onCancel={() => { setDraft(value); setEditing(false) }}
/>
) : (
<XStack
items="center"
minH={26}
cursor={canEdit ? 'text' : undefined}
rounded={6}
px={canEdit ? 6 : 0}
py={canEdit ? 3 : 0}
hoverStyle={canEdit ? { bg: tokens.hover } : undefined}
style={{ marginLeft: canEdit ? -6 : 0 }}
onPress={canEdit ? () => { setDraft(value); setEditing(true) } : undefined}
>
<FieldDisplay field={field} value={value} />
</XStack>
)}
</YStack>
)
}
/** The record detail panel — titled, per-field, optionally inline-editable. */
export function RecordDetail({ fields, record, title, editable, onEditCommit, fieldOptions, related }: RecordDetailProps) {
return (
<YStack gap={2}>
{title ? (
<YStack pb={12} mb={4} style={{ borderBottomWidth: 1, borderBottomColor: tokens.border }}>
<Text fontSize={22} fontWeight="800" numberOfLines={2} style={{ color: tokens.text }}>{title}</Text>
</YStack>
) : null}
{fields.map((f) => (
<DetailField
key={f.name}
field={f}
value={record[f.name]}
editable={editable}
onCommit={onEditCommit ? (v) => onEditCommit(f, v) : undefined}
options={fieldOptions?.(f)}
/>
))}
{related ? <YStack pt={16} gap={8}>{related}</YStack> : null}
</YStack>
)
}
export interface RecordFormProps {
fields: FieldDefinition[]
/** Current draft values. */
values: Record<string, unknown>
/** Emit a single field's next value; the host owns the draft + save. */
onChange: (name: string, value: unknown) => void
/** Inject candidate options for a field's editor (e.g. relation records). */
fieldOptions?: (field: FieldDefinition) => SelectOption[] | undefined
}
/** Editable field list — create/edit form. Read-only fields render as display. */
export function RecordForm({ fields, values, onChange, fieldOptions }: RecordFormProps) {
return (
<YStack gap={14}>
{fields.map((f) => {
const opts = fieldOptions?.(f)
const withOpts: FieldDefinition = opts ? { ...f, metadata: { ...(f.metadata as object), options: opts } as FieldDefinition['metadata'] } : f
return (
<YStack key={f.name} gap={6}>
<FieldLabel>{f.label}</FieldLabel>
{f.readOnly ? (
<XStack><FieldDisplay field={f} value={values[f.name]} /></XStack>
) : (
<FieldInput field={withOpts} value={values[f.name]} onChange={(v) => onChange(f.name, v)} />
)}
</YStack>
)
})}
</YStack>
)
}
-369
View File
@@ -1,369 +0,0 @@
'use client'
// DataTable — the Twenty-grade, metadata-driven record grid. Columns are
// FieldDefinitions, rows are records; every cell renders through FieldDisplay /
// FieldInput, so the grid understands every field type and a new type needs zero
// table changes. It adds the record-app essentials on the same one model:
// • click-to-sort headers (controlled by the view; carets show direction)
// • drag-resize + drag-reorder columns (pointer events; snapshot rects)
// • row selection with a header select-all (indeterminate) + bulk callback
// • inline cell editing (click an editable cell → the field's own editor)
// • pagination, hover-reveal "open" affordance, honest empty / loading
// Presentational + data-injected: the host supplies records (already filtered +
// sorted by the view) and the persistence callbacks; the table owns only its
// interaction state. Palette is the package's raw-hex tokens (theme-independent).
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
import { Spinner, Text, XStack, YStack } from '@hanzo/gui'
import { ArrowDown, ArrowUp, ChevronLeft, ChevronRight, ChevronsUpDown, Maximize2 } from '@hanzogui/lucide-icons-2'
import type { FieldDefinition, SelectOption } from '../field/types'
import { FieldDisplay } from '../field/FieldDisplay'
import { FieldInput, isEditable } from '../field/FieldInput'
import { CheckBox } from '../primitives'
import { tokens } from '../theme'
import { cycleSort, orderedColumns, paginate, sortDirOf, type SortRule } from './logic'
type Record_ = Record<string, unknown>
/** Field types whose editor commits on selection (a dropdown / toggle), not on Enter. */
const INSTANT = new Set(['boolean', 'select', 'multiSelect', 'date', 'dateTime', 'relation', 'rating', 'files'])
const SELECT_COL_W = 44
const MIN_COL_W = 80
const DEFAULT_ROW_H = 44
export interface DataTableProps {
fields: FieldDefinition[]
/** Rows, already filtered + sorted by the host/view (a flat name→value map each). */
records: Record_[]
getRowKey?: (record: Record_, index: number) => string
loading?: boolean
empty?: ReactNode
// sort — controlled by the view; a header click cycles the column
sorts?: SortRule[]
onSortChange?: (sorts: SortRule[]) => void
// open a record (an expand affordance appears on row hover)
onOpen?: (record: Record_) => void
// selection
selectable?: boolean
onSelectionChange?: (ids: string[]) => void
/** Bulk-action bar content, shown when ≥1 row is selected. */
bulkActions?: (ids: string[]) => ReactNode
// inline editing
editable?: boolean
/** Persist one cell edit. May be async; the cell shows a saving state until it settles. */
onEditCommit?: (record: Record_, field: FieldDefinition, value: unknown) => void | Promise<void>
/** Inject candidate options for a field's editor (e.g. relation records). */
fieldOptions?: (field: FieldDefinition) => SelectOption[] | undefined
// column layout (uncontrolled internal state, mirrored up when provided)
columnOrder?: string[]
onColumnOrderChange?: (order: string[]) => void
hiddenFields?: string[]
// pagination (0 / undefined → show all)
pageSize?: number
rowHeight?: number
}
export function DataTable(props: DataTableProps) {
const {
fields, records, getRowKey, loading, empty = 'No records yet.',
sorts = [], onSortChange, onOpen,
selectable, onSelectionChange, bulkActions,
editable, onEditCommit, fieldOptions,
columnOrder, onColumnOrderChange, hiddenFields,
pageSize = 25, rowHeight = DEFAULT_ROW_H,
} = props
const hidden = useMemo(() => new Set(hiddenFields ?? []), [hiddenFields])
const [order, setOrder] = useState<string[] | undefined>(columnOrder)
useEffect(() => { if (columnOrder) setOrder(columnOrder) }, [columnOrder])
const columns = useMemo(() => orderedColumns(fields, order, hidden), [fields, order, hidden])
const [widths, setWidths] = useState<Record<string, number>>({})
const [selected, setSelected] = useState<Set<string>>(new Set())
const [editing, setEditing] = useState<{ key: string; field: string } | null>(null)
const [draft, setDraft] = useState<unknown>(undefined)
const [savingCell, setSavingCell] = useState<string | null>(null)
const [page, setPage] = useState(1)
const keyOf = useCallback(
(r: Record_, i: number) => (getRowKey ? getRowKey(r, i) : r.id != null ? String(r.id) : String(i)),
[getRowKey],
)
const pageData = useMemo(
() => (pageSize > 0 ? paginate(records, page, pageSize) : { items: records, page: 1, pageCount: 1, total: records.length, from: records.length ? 1 : 0, to: records.length }),
[records, page, pageSize],
)
useEffect(() => { if (page > pageData.pageCount) setPage(pageData.pageCount) }, [page, pageData.pageCount])
const emit = useCallback((next: Set<string>) => { setSelected(next); onSelectionChange?.([...next]) }, [onSelectionChange])
const pageKeys = pageData.items.map((r, i) => keyOf(r, i))
const allOnPage = pageKeys.length > 0 && pageKeys.every((k) => selected.has(k))
const someOnPage = pageKeys.some((k) => selected.has(k))
const toggleAll = () => {
const next = new Set(selected)
if (allOnPage) pageKeys.forEach((k) => next.delete(k))
else pageKeys.forEach((k) => next.add(k))
emit(next)
}
const toggleOne = (k: string) => {
const next = new Set(selected)
next.has(k) ? next.delete(k) : next.add(k)
emit(next)
}
const onHeaderSort = (name: string) => onSortChange?.(cycleSort(sorts, name))
// ── column resize + reorder (pointer events; snapshot rects at drag start) ──
const headerRefs = useRef<Array<HTMLElement | null>>([])
const [dragCol, setDragCol] = useState<{ from: number; x: number; target: number } | null>(null)
const beginResize = (name: string, startNode: HTMLElement | null) => (e: { clientX?: number; nativeEvent?: { clientX?: number } }) => {
const startX = e.clientX ?? e.nativeEvent?.clientX ?? 0
const startW = startNode?.getBoundingClientRect().width ?? MIN_COL_W
const move = (ev: PointerEvent) => setWidths((w) => ({ ...w, [name]: Math.max(MIN_COL_W, startW + (ev.clientX - startX)) }))
const end = () => {
window.removeEventListener('pointermove', move)
window.removeEventListener('pointerup', end)
}
window.addEventListener('pointermove', move)
window.addEventListener('pointerup', end)
}
const beginReorder = (from: number) => (e: { clientX?: number; nativeEvent?: { clientX?: number } }) => {
if (!onColumnOrderChange && columnOrder) return
const rects = headerRefs.current.map((n) => n?.getBoundingClientRect() ?? null)
const startX = e.clientX ?? e.nativeEvent?.clientX ?? 0
setDragCol({ from, x: startX, target: from })
const move = (ev: PointerEvent) => {
let target = rects.length - 1
for (let i = 0; i < rects.length; i++) {
const r = rects[i]
if (r && ev.clientX < r.left + r.width / 2) { target = i; break }
}
setDragCol({ from, x: ev.clientX, target })
}
const end = () => {
window.removeEventListener('pointermove', move)
window.removeEventListener('pointerup', end)
setDragCol((d) => {
if (d && d.target !== d.from) {
const names = columns.map((c) => c.name)
const [moved] = names.splice(d.from, 1)
names.splice(d.target, 0, moved)
setOrder(names)
onColumnOrderChange?.(names)
}
return null
})
}
window.addEventListener('pointermove', move)
window.addEventListener('pointerup', end)
}
// ── inline edit ─────────────────────────────────────────────────────────────
const startEdit = (rowKey: string, field: FieldDefinition, current: unknown) => {
if (!editable || field.readOnly || !isEditable(field.type)) return
setEditing({ key: rowKey, field: field.name })
setDraft(current)
}
const cancelEdit = () => { setEditing(null); setDraft(undefined) }
const commitEdit = useCallback(async (record: Record_, field: FieldDefinition, value: unknown) => {
const cellId = `${editing?.key}:${field.name}`
setEditing(null)
setDraft(undefined)
if (!onEditCommit) return
try { setSavingCell(cellId); await onEditCommit(record, field, value) } finally { setSavingCell(null) }
}, [editing, onEditCommit])
// Read-only rows open on a full-row press; editable rows open via the expand
// affordance (so a cell click edits instead of opening — no interaction conflict).
const rowPressable = !editable && Boolean(onOpen)
const gridMinWidth = (selectable ? SELECT_COL_W : 0) + columns.reduce((s, c) => s + (widths[c.name] ?? c.width ?? 160), 0)
const cellWidthProps = (c: FieldDefinition) => {
const w = widths[c.name] ?? c.width
return w ? { width: w } : { flex: 1, minW: 140 }
}
return (
<YStack rounded={10} overflow="hidden" borderWidth={1} style={{ borderColor: tokens.border, backgroundColor: tokens.surface }}>
{selectable && selected.size > 0 ? (
<XStack items="center" justify="space-between" px={12} py={8} style={{ backgroundColor: tokens.surfaceRaised, borderBottomWidth: 1, borderBottomColor: tokens.border }}>
<Text fontSize={13} fontWeight="600" style={{ color: tokens.text }}>{selected.size} selected</Text>
<XStack items="center" gap={8}>
{bulkActions?.([...selected])}
<Text fontSize={13} cursor="pointer" onPress={() => emit(new Set())} style={{ color: tokens.accent }}>Clear</Text>
</XStack>
</XStack>
) : null}
<YStack style={{ minWidth: gridMinWidth }}>
{/* header */}
<XStack style={{ backgroundColor: tokens.surfaceRaised, borderBottomWidth: 1, borderBottomColor: tokens.border }}>
{selectable ? (
<XStack width={SELECT_COL_W} items="center" justify="center" py={10}>
<CheckBox checked={allOnPage} indeterminate={!allOnPage && someOnPage} onChange={toggleAll} />
</XStack>
) : null}
{columns.map((c, i) => {
const dir = sortDirOf(sorts, c.name)
const isDropTarget = dragCol && dragCol.target === i && dragCol.from !== i
return (
<XStack
key={c.name}
{...cellWidthProps(c)}
items="center"
gap={4}
px={12}
py={10}
ref={(node: unknown) => { headerRefs.current[i] = (node as HTMLElement | null) }}
style={{ position: 'relative', borderLeftWidth: isDropTarget ? 2 : 0, borderLeftColor: tokens.accent, opacity: dragCol?.from === i ? 0.5 : 1 }}
>
<XStack items="center" gap={5} flex={1} cursor="pointer" onPress={() => onHeaderSort(c.name)} onPointerDown={beginReorder(i)}>
<Text fontSize={11} fontWeight="700" numberOfLines={1} style={{ color: tokens.textFaint, letterSpacing: 0.4, textTransform: 'uppercase' }}>
{c.label}
</Text>
{dir === 'asc' ? <ArrowUp size={12} color={tokens.textMuted} /> : dir === 'desc' ? <ArrowDown size={12} color={tokens.textMuted} /> : onSortChange ? <ChevronsUpDown size={12} color={tokens.border} /> : null}
</XStack>
{/* resize handle */}
<YStack
width={8}
height="100%"
cursor="col-resize"
onPointerDown={beginResize(c.name, headerRefs.current[i])}
style={{ position: 'absolute', right: -4, top: 0, zIndex: 2 }}
/>
</XStack>
)
})}
</XStack>
{/* body */}
{loading ? (
<XStack px={16} py={32} items="center" justify="center" gap={10}>
<Spinner size="small" color={tokens.textMuted} />
<Text style={{ color: tokens.textFaint }}>Loading</Text>
</XStack>
) : pageData.items.length === 0 ? (
<YStack px={16} py={40} items="center" gap={4}>
{typeof empty === 'string' ? <Text style={{ color: tokens.textFaint }}>{empty}</Text> : empty}
</YStack>
) : (
pageData.items.map((record, i) => {
const rowKey = keyOf(record, i)
const isSel = selected.has(rowKey)
return (
<XStack
key={rowKey}
group
items="stretch"
cursor={rowPressable ? 'pointer' : undefined}
onPress={rowPressable ? () => onOpen?.(record) : undefined}
hoverStyle={{ bg: tokens.hover }}
style={{ borderBottomWidth: 1, borderBottomColor: tokens.border, backgroundColor: isSel ? tokens.hover : 'transparent', minHeight: rowHeight }}
>
{selectable ? (
<XStack width={SELECT_COL_W} items="center" justify="center">
<CheckBox checked={isSel} onChange={() => toggleOne(rowKey)} />
</XStack>
) : null}
{columns.map((c, ci) => {
const isEditingCell = editable && editing?.key === rowKey && editing.field === c.name
const canEdit = editable && !c.readOnly && isEditable(c.type)
const cellId = `${rowKey}:${c.name}`
const withOpts: FieldDefinition = fieldOptions?.(c)
? { ...c, metadata: { ...(c.metadata as object), options: fieldOptions(c) } as FieldDefinition['metadata'] }
: c
return (
<XStack key={c.name} {...cellWidthProps(c)} items="center" px={12} py={6} style={{ position: 'relative' }}>
{isEditingCell ? (
<YStack flex={1} onPress={(e: { stopPropagation?: () => void }) => e.stopPropagation?.()}>
<FieldInput
field={withOpts}
value={draft}
autoFocus
onChange={(v) => (INSTANT.has(c.type) ? void commitEdit(record, c, v) : setDraft(v))}
onSubmit={() => void commitEdit(record, c, draft)}
onCancel={cancelEdit}
/>
</YStack>
) : (
<XStack
flex={1}
items="center"
minH={rowHeight - 12}
cursor={canEdit ? 'text' : undefined}
onPress={canEdit ? () => startEdit(rowKey, c, record[c.name]) : undefined}
hoverStyle={canEdit ? { bg: tokens.surfaceRaised } : undefined}
rounded={6}
px={canEdit ? 6 : 0}
style={{ marginLeft: canEdit ? -6 : 0 }}
>
<FieldDisplay field={c} value={record[c.name]} />
{savingCell === cellId ? <Spinner size="small" color={tokens.textFaint} /> : null}
</XStack>
)}
{/* open affordance on the first column (hover-revealed) — editable rows only; read-only rows open on full-row press */}
{ci === 0 && onOpen && editable && !isEditingCell ? (
<XStack
$group-hover={{ opacity: 1 }}
items="center"
justify="center"
width={26}
height={26}
rounded={6}
cursor="pointer"
onPress={() => onOpen(record)}
hoverStyle={{ bg: tokens.border }}
style={{ position: 'absolute', right: 6, opacity: 0, backgroundColor: tokens.surfaceRaised }}
>
<Maximize2 size={13} color={tokens.textMuted} />
</XStack>
) : null}
</XStack>
)
})}
</XStack>
)
})
)}
</YStack>
{/* footer / pagination */}
{!loading && pageSize > 0 && pageData.total > 0 ? (
<XStack items="center" justify="space-between" px={12} py={8} style={{ backgroundColor: tokens.surfaceRaised, borderTopWidth: 1, borderTopColor: tokens.border }}>
<Text fontSize={12} style={{ color: tokens.textFaint }}>{`${pageData.from}${pageData.to} of ${pageData.total}`}</Text>
<XStack items="center" gap={4}>
<PagerButton disabled={pageData.page <= 1} onPress={() => setPage((p) => Math.max(1, p - 1))}><ChevronLeft size={15} color={pageData.page <= 1 ? tokens.border : tokens.textMuted} /></PagerButton>
<Text fontSize={12} px={6} style={{ color: tokens.textMuted }}>{`${pageData.page} / ${pageData.pageCount}`}</Text>
<PagerButton disabled={pageData.page >= pageData.pageCount} onPress={() => setPage((p) => Math.min(pageData.pageCount, p + 1))}><ChevronRight size={15} color={pageData.page >= pageData.pageCount ? tokens.border : tokens.textMuted} /></PagerButton>
</XStack>
</XStack>
) : null}
</YStack>
)
}
function PagerButton({ children, disabled, onPress }: { children: ReactNode; disabled?: boolean; onPress: () => void }) {
return (
<XStack
width={28}
height={28}
items="center"
justify="center"
rounded={6}
cursor={disabled ? undefined : 'pointer'}
onPress={disabled ? undefined : onPress}
hoverStyle={disabled ? undefined : { bg: tokens.hover }}
>
{children}
</XStack>
)
}
-50
View File
@@ -1,50 +0,0 @@
// RecordCard — the compact card used by board (kanban) and gallery views. Shows
// a title field bold, then a few field displays. Same FieldDisplay engine as the
// table, so it understands every type. Drag/drop + column grouping live in the
// board host; this is the standalone card unit.
import { Card, Text, XStack, YStack } from '@hanzo/gui'
import type { FieldDefinition } from '../field/types'
import { FieldDisplay } from '../field/FieldDisplay'
import { tokens } from '../theme'
export interface RecordCardProps {
/** The field whose value is the card title (defaults to the first field). */
titleField?: string
/** Fields shown in the card body (the title field is excluded automatically). */
fields: FieldDefinition[]
record: Record<string, unknown>
onPress?: (record: Record<string, unknown>) => void
}
export function RecordCard({ titleField, fields, record, onPress }: RecordCardProps) {
const titleName = titleField ?? fields[0]?.name
const titleVal = titleName != null ? record[titleName] : undefined
const bodyFields = fields.filter((f) => f.name !== titleName)
return (
<Card
p={12}
gap={8}
borderWidth={1}
rounded={8}
cursor={onPress ? 'pointer' : undefined}
hoverStyle={onPress ? { bg: tokens.hover } : undefined}
onPress={onPress ? () => onPress(record) : undefined}
style={{ backgroundColor: tokens.surface, borderColor: tokens.border }}
>
<Text fontSize={14} fontWeight="700" numberOfLines={1} style={{ color: tokens.text }}>
{titleVal == null || titleVal === '' ? 'Untitled' : String(titleVal)}
</Text>
{bodyFields.map((f) => (
<XStack key={f.name} gap={8} items="center">
<Text fontSize={11} numberOfLines={1} width={84} style={{ color: tokens.textFaint }}>
{f.label}
</Text>
<YStack flex={1}>
<FieldDisplay field={f} value={record[f.name]} />
</YStack>
</XStack>
))}
</Card>
)
}
-171
View File
@@ -1,171 +0,0 @@
import { describe, it, expect } from 'vitest'
import type { FieldDefinition } from '../field/types'
import {
comparable,
sortRecords,
cycleSort,
sortDirOf,
operatorsForType,
matchesRule,
filterRecords,
searchRecords,
paginate,
moveItem,
orderedColumns,
columnIndexAtX,
} from './logic'
const fields: FieldDefinition[] = [
{ name: 'name', label: 'Name', type: 'text' },
{ name: 'employees', label: 'Employees', type: 'number' },
{ name: 'stage', label: 'Stage', type: 'select', metadata: { options: [
{ value: 'NEW', label: 'New', color: 'blue' },
{ value: 'WON', label: 'Won', color: 'green' },
] } },
{ name: 'active', label: 'Active', type: 'boolean' },
{ name: 'arr', label: 'ARR', type: 'currency' },
{ name: 'signed', label: 'Signed', type: 'date' },
]
const rows: Record<string, unknown>[] = [
{ id: '1', name: 'Beta', employees: 10, stage: 'NEW', active: true, arr: { amount: 500, currencyCode: 'USD' }, signed: '2024-02-01' },
{ id: '2', name: 'alpha', employees: 3, stage: 'WON', active: false, arr: { amount: 1500, currencyCode: 'USD' }, signed: '2024-01-01' },
{ id: '3', name: 'Gamma', employees: null, stage: '', active: true, arr: { amount: null, currencyCode: 'USD' }, signed: '' },
]
describe('comparable', () => {
it('coerces per field type', () => {
expect(comparable(fields[1], 10)).toBe(10)
expect(comparable(fields[4], { amount: 500, currencyCode: 'USD' })).toBe(500) // currency
expect(comparable(fields[2], 'WON')).toBe('won') // select → label lowercased
expect(comparable(fields[4], { amount: null })).toBeNull()
expect(comparable(fields[0], '')).toBeNull()
expect(comparable(fields[0], 'Hi')).toBe('hi')
})
})
describe('sortRecords', () => {
it('sorts text case-insensitively, ascending', () => {
const out = sortRecords(rows, [{ field: 'name', dir: 'asc' }], fields)
expect(out.map((r) => r.name)).toEqual(['alpha', 'Beta', 'Gamma'])
})
it('sorts numbers descending with null last', () => {
const out = sortRecords(rows, [{ field: 'employees', dir: 'desc' }], fields)
expect(out.map((r) => r.id)).toEqual(['1', '2', '3']) // 10, 3, null
})
it('is stable and does not mutate input', () => {
const before = rows.map((r) => r.id)
const out = sortRecords(rows, [{ field: 'active', dir: 'desc' }], fields)
expect(rows.map((r) => r.id)).toEqual(before)
// both active=true rows keep their relative order (1 before 3)
expect(out.filter((r) => r.active).map((r) => r.id)).toEqual(['1', '3'])
})
it('returns a copy when no sort rules', () => {
const out = sortRecords(rows, [], fields)
expect(out).not.toBe(rows)
expect(out.map((r) => r.id)).toEqual(['1', '2', '3'])
})
})
describe('cycleSort / sortDirOf', () => {
it('cycles unsorted → asc → desc → unsorted', () => {
let s = cycleSort([], 'name')
expect(s).toEqual([{ field: 'name', dir: 'asc' }])
expect(sortDirOf(s, 'name')).toBe('asc')
s = cycleSort(s, 'name')
expect(s).toEqual([{ field: 'name', dir: 'desc' }])
s = cycleSort(s, 'name')
expect(s).toEqual([])
})
it('switching column starts a fresh asc', () => {
const s = cycleSort([{ field: 'name', dir: 'desc' }], 'employees')
expect(s).toEqual([{ field: 'employees', dir: 'asc' }])
})
})
describe('operatorsForType', () => {
it('offers numeric operators for numbers/dates', () => {
expect(operatorsForType('number')).toContain('gte')
expect(operatorsForType('date')).toContain('lt')
})
it('offers set operators for selects', () => {
expect(operatorsForType('select')).toContain('isAnyOf')
})
it('offers text operators for text', () => {
expect(operatorsForType('text')).toContain('contains')
})
})
describe('matchesRule / filterRecords', () => {
it('text contains (case-insensitive)', () => {
const out = filterRecords(rows, [{ field: 'name', op: 'contains', value: 'A' }], fields)
expect(out.map((r) => r.id).sort()).toEqual(['1', '2', '3']) // Beta, alpha, Gamma all contain "a"
expect(filterRecords(rows, [{ field: 'name', op: 'contains', value: 'ph' }], fields).map((r) => r.id)).toEqual(['2']) // alpha only
})
it('number gte', () => {
const out = filterRecords(rows, [{ field: 'employees', op: 'gte', value: '10' }], fields)
expect(out.map((r) => r.id)).toEqual(['1'])
})
it('select isAnyOf', () => {
const out = filterRecords(rows, [{ field: 'stage', op: 'isAnyOf', value: ['WON'] }], fields)
expect(out.map((r) => r.id)).toEqual(['2'])
})
it('isEmpty / isNotEmpty', () => {
expect(filterRecords(rows, [{ field: 'employees', op: 'isEmpty' }], fields).map((r) => r.id)).toEqual(['3'])
expect(filterRecords(rows, [{ field: 'stage', op: 'isNotEmpty' }], fields).map((r) => r.id).sort()).toEqual(['1', '2'])
})
it('boolean is', () => {
expect(filterRecords(rows, [{ field: 'active', op: 'is', value: 'true' }], fields).map((r) => r.id).sort()).toEqual(['1', '3'])
})
it('date lt/gte compares ISO operands by time', () => {
expect(filterRecords(rows, [{ field: 'signed', op: 'lt', value: '2024-01-15' }], fields).map((r) => r.id)).toEqual(['2']) // 2024-01-01
expect(filterRecords(rows, [{ field: 'signed', op: 'gte', value: '2024-01-15' }], fields).map((r) => r.id)).toEqual(['1']) // 2024-02-01 (row 3 empty → excluded)
})
it('AND-combines rules; empty rules keep all', () => {
const out = filterRecords(rows, [
{ field: 'active', op: 'is', value: 'true' },
{ field: 'name', op: 'contains', value: 'g' },
], fields)
expect(out.map((r) => r.id)).toEqual(['3'])
expect(filterRecords(rows, [], fields)).toHaveLength(3)
})
it('ignores a rule whose value is not yet set', () => {
expect(filterRecords(rows, [{ field: 'name', op: 'contains' }], fields)).toHaveLength(3)
})
})
describe('searchRecords', () => {
it('matches across fields, case-insensitive', () => {
expect(searchRecords(rows, 'won', fields).map((r) => r.id)).toEqual(['2'])
expect(searchRecords(rows, 'amma', fields).map((r) => r.id)).toEqual(['3'])
expect(searchRecords(rows, '', fields)).toHaveLength(3)
})
})
describe('paginate', () => {
it('slices and clamps the page', () => {
const p = paginate([1, 2, 3, 4, 5], 1, 2)
expect(p.items).toEqual([1, 2])
expect(p).toMatchObject({ page: 1, pageCount: 3, total: 5, from: 1, to: 2 })
expect(paginate([1, 2, 3, 4, 5], 99, 2).items).toEqual([5])
expect(paginate([], 1, 10)).toMatchObject({ from: 0, to: 0, pageCount: 1 })
})
})
describe('moveItem / orderedColumns', () => {
it('moves an element and returns a new array', () => {
expect(moveItem(['a', 'b', 'c'], 0, 2)).toEqual(['b', 'c', 'a'])
expect(moveItem(['a', 'b', 'c'], 2, 0)).toEqual(['c', 'a', 'b'])
})
it('orders columns by order, appends missing, drops hidden', () => {
const cols = orderedColumns(fields, ['stage', 'name'], new Set(['arr', 'signed']))
expect(cols.map((f) => f.name)).toEqual(['stage', 'name', 'employees', 'active'])
})
it('columnIndexAtX finds the column under a pixel offset', () => {
const w = [40, 200, 120, 120] // selection, name, employees, stage
expect(columnIndexAtX(w, 10)).toBe(0)
expect(columnIndexAtX(w, 100)).toBe(1)
expect(columnIndexAtX(w, 250)).toBe(2)
expect(columnIndexAtX(w, 9999)).toBe(3) // past the end clamps to last
})
})
-384
View File
@@ -1,384 +0,0 @@
// Pure table logic — the decisions the record grid takes, isolated from React +
// @hanzo/gui so they are trivially testable (data in, data out) and reusable by a
// host doing server-side sort/filter. Imports only `type` from the field model,
// so this module pulls in no runtime and runs in plain Node.
//
// One vocabulary for sort, filter, search, and pagination powers the table, the
// board, and any saved view — decomplected from rendering. A field type maps to a
// comparable primitive HERE, once, so every view orders and filters identically.
import type { FieldDefinition, FieldType, SelectOption, CurrencyValue, LinkValue } from '../field/types'
export type Record_ = Record<string, unknown>
// ── comparable coercion (one place; mirrors the display coercions) ───────────
const asStr = (v: unknown): string => (v == null ? '' : String(v))
const asNum = (v: unknown): number | null => {
if (v == null || v === '') return null
const n = typeof v === 'number' ? v : Number(v)
return Number.isFinite(n) ? n : null
}
const asArray = (v: unknown): unknown[] => (Array.isArray(v) ? v : v == null ? [] : [v])
const asTime = (v: unknown): number | null => {
if (v == null || v === '') return null
const d = v instanceof Date ? v : new Date(v as string | number)
const t = d.getTime()
return Number.isNaN(t) ? null : t
}
const optionLabel = (field: FieldDefinition | undefined, value: string): string => {
const options = (field?.metadata as { options?: SelectOption[] } | undefined)?.options
return options?.find((o) => o.value === value)?.label ?? value
}
/** The record's relation/label text — the same resolution the display uses. */
export function relationLabel(field: FieldDefinition | undefined, value: unknown): string {
const labelField = (field?.metadata as { labelField?: string } | undefined)?.labelField ?? 'name'
if (typeof value === 'string') return value
if (value && typeof value === 'object') {
const rec = value as Record<string, unknown>
return asStr(rec[labelField] ?? rec.label ?? rec.name ?? rec.id)
}
return ''
}
/**
* A field value reduced to a comparable primitive (number | string | null) for
* sorting and relational filters. Numeric types compare numerically; dates by
* timestamp; selects by their human label; multi-value types by their joined
* labels; everything else case-folded text. `null` sorts last.
*/
export function comparable(field: FieldDefinition | undefined, value: unknown): number | string | null {
const type: FieldType | undefined = field?.type
switch (type) {
case 'number':
case 'percent':
case 'rating':
case 'position':
return asNum(value)
case 'currency': {
const raw = (value ?? {}) as Partial<CurrencyValue>
return typeof raw === 'object' && raw != null ? asNum(raw.amount) : asNum(value)
}
case 'boolean':
return value === true || value === 'true' || value === 1 ? 1 : 0
case 'date':
case 'dateTime':
return asTime(value)
case 'select':
return value == null || value === '' ? null : optionLabel(field, asStr(value)).toLowerCase()
case 'multiSelect':
return asArray(value).map((v) => optionLabel(field, asStr(v))).join(', ').toLowerCase() || null
case 'relation':
return relationLabel(field, value).toLowerCase() || null
case 'links':
return (
asArray(value)
.map((l) => (typeof l === 'string' ? l : (l as LinkValue)?.url ?? ''))
.join(', ')
.toLowerCase() || null
)
default: {
const s = asStr(value)
return s === '' ? null : s.toLowerCase()
}
}
}
// ── sort ──────────────────────────────────────────────────────────────────────
export type SortDir = 'asc' | 'desc'
export interface SortRule { field: string; dir: SortDir }
const fieldByName = (fields: FieldDefinition[], name: string): FieldDefinition | undefined =>
fields.find((f) => f.name === name)
/** Compare two NON-null comparable primitives (numbers numerically, else by locale). */
function cmpNonNull(a: number | string, b: number | string): number {
if (typeof a === 'number' && typeof b === 'number') return a - b
return String(a).localeCompare(String(b))
}
/**
* Stable multi-key sort. Records are ordered by each rule in turn; equal records
* keep their input order (stable). Empty values always sort LAST regardless of
* direction (a descending sort doesn't float blanks to the top). Returns a new
* array; the input is untouched.
*/
export function sortRecords(records: Record_[], sorts: SortRule[], fields: FieldDefinition[]): Record_[] {
if (sorts.length === 0) return records.slice()
return records
.map((r, i) => [r, i] as const)
.sort(([a, ia], [b, ib]) => {
for (const s of sorts) {
const f = fieldByName(fields, s.field)
const av = comparable(f, a[s.field])
const bv = comparable(f, b[s.field])
if (av === null && bv === null) continue
if (av === null) return 1 // a is empty → last
if (bv === null) return -1 // b is empty → last
const d = cmpNonNull(av, bv)
if (d !== 0) return s.dir === 'asc' ? d : -d
}
return ia - ib
})
.map(([r]) => r)
}
/**
* Header-click sort cycle for a single column: unsorted → asc → desc → unsorted.
* Sorting a column replaces the sort set (single-column sort is the common case;
* a host can compose multi-sort with `sortRecords` directly).
*/
export function cycleSort(sorts: SortRule[], field: string): SortRule[] {
const current = sorts.length === 1 && sorts[0].field === field ? sorts[0] : undefined
if (!current) return [{ field, dir: 'asc' }]
if (current.dir === 'asc') return [{ field, dir: 'desc' }]
return []
}
/** The current sort direction for a column, or undefined when it isn't sorted. */
export function sortDirOf(sorts: SortRule[], field: string): SortDir | undefined {
return sorts.find((s) => s.field === field)?.dir
}
// ── filter ──────────────────────────────────────────────────────────────────
export type FilterOp =
| 'contains'
| 'notContains'
| 'is'
| 'isNot'
| 'gt'
| 'gte'
| 'lt'
| 'lte'
| 'isEmpty'
| 'isNotEmpty'
| 'isAnyOf'
export interface FilterRule {
field: string
op: FilterOp
/** Operand — a string for text/number ops, a string[] for `isAnyOf`. */
value?: unknown
}
/** Human labels for the operators — for the filter UI menu. */
export const OP_LABELS: Record<FilterOp, string> = {
contains: 'contains',
notContains: 'does not contain',
is: 'is',
isNot: 'is not',
gt: 'is greater than',
gte: 'is at least',
lt: 'is less than',
lte: 'is at most',
isEmpty: 'is empty',
isNotEmpty: 'is not empty',
isAnyOf: 'is any of',
}
const TEXT_OPS: FilterOp[] = ['contains', 'notContains', 'is', 'isNot', 'isEmpty', 'isNotEmpty']
const NUM_OPS: FilterOp[] = ['is', 'isNot', 'gt', 'gte', 'lt', 'lte', 'isEmpty', 'isNotEmpty']
const SELECT_OPS: FilterOp[] = ['is', 'isNot', 'isAnyOf', 'isEmpty', 'isNotEmpty']
const BOOL_OPS: FilterOp[] = ['is']
/** The operators that make sense for a field type — drives the filter menu. */
export function operatorsForType(type: FieldType): FilterOp[] {
switch (type) {
case 'number':
case 'percent':
case 'currency':
case 'rating':
case 'date':
case 'dateTime':
return NUM_OPS
case 'select':
case 'multiSelect':
case 'relation':
return SELECT_OPS
case 'boolean':
return BOOL_OPS
default:
return TEXT_OPS
}
}
const isEmptyValue = (field: FieldDefinition | undefined, value: unknown): boolean => {
if (value == null || value === '') return true
if (Array.isArray(value)) return value.length === 0
if (field?.type === 'currency') return asNum((value as Partial<CurrencyValue>)?.amount) == null
return false
}
const numericType = (type: FieldType | undefined): boolean =>
type === 'number' || type === 'percent' || type === 'currency' || type === 'rating' || type === 'date' || type === 'dateTime'
/** The record's raw stored keys for a set-like field (option values / relation ids). */
function rawKeys(field: FieldDefinition | undefined, value: unknown): string[] {
if (field?.type === 'multiSelect') return asArray(value).map(asStr).filter((s) => s !== '')
if (field?.type === 'relation') {
const k = relationKey(value)
return k ? [k] : []
}
const s = asStr(value)
return s === '' ? [] : [s]
}
const relationKey = (value: unknown): string => {
if (typeof value === 'string') return value
if (value && typeof value === 'object') {
const rec = value as Record<string, unknown>
return asStr(rec.id ?? rec.value ?? '')
}
return ''
}
/**
* True when one record's value satisfies one filter rule. Filters compare RAW
* stored values (a select's option value, a relation's id, a boolean's truth) —
* not the display label — so equality is exact; text ops fold case; numeric ops
* compare magnitude. `null`/empty answers `isNot`-style predicates truthfully.
*/
export function matchesRule(field: FieldDefinition | undefined, value: unknown, rule: FilterRule): boolean {
if (rule.op === 'isEmpty') return isEmptyValue(field, value)
if (rule.op === 'isNotEmpty') return !isEmptyValue(field, value)
const type = field?.type
if (type === 'boolean') {
const on = value === true || value === 'true' || value === 1
const want = rule.value === true || rule.value === 'true' || rule.value === 1
return rule.op === 'isNot' ? on !== want : on === want
}
if (type === 'select' || type === 'multiSelect' || type === 'relation') {
const keys = new Set(rawKeys(field, value))
if (rule.op === 'isAnyOf') {
const set = new Set((Array.isArray(rule.value) ? rule.value : []).map(asStr))
if (set.size === 0) return true
return [...keys].some((k) => set.has(k))
}
const target = asStr(rule.value)
if (rule.op === 'is') return keys.has(target)
if (rule.op === 'isNot') return !keys.has(target)
return true
}
if (numericType(type)) {
const left = comparable(field, value)
const l = typeof left === 'number' ? left : null
const isDate = type === 'date' || type === 'dateTime'
const right = isDate ? asTime(rule.value) : asNum(rule.value)
if (l == null || right == null) return rule.op === 'isNot'
switch (rule.op) {
case 'is': return l === right
case 'isNot': return l !== right
case 'gt': return l > right
case 'gte': return l >= right
case 'lt': return l < right
case 'lte': return l <= right
default: return true
}
}
const l = asStr(comparable(field, value))
const r = asStr(rule.value).toLowerCase()
switch (rule.op) {
case 'contains': return l.includes(r)
case 'notContains': return !l.includes(r)
case 'is': return l === r
case 'isNot': return l !== r
default: return true
}
}
/** Keep records that satisfy EVERY rule (AND). Empty rules → all records. */
export function filterRecords(records: Record_[], filters: FilterRule[], fields: FieldDefinition[]): Record_[] {
const active = filters.filter((r) => r.op === 'isEmpty' || r.op === 'isNotEmpty' || r.value != null)
if (active.length === 0) return records.slice()
return records.filter((rec) => active.every((rule) => matchesRule(fieldByName(fields, rule.field), rec[rule.field], rule)))
}
// ── quick search (across all text-ish fields) ─────────────────────────────────
/** Substring match of `query` against every field's displayed text (case-insensitive). */
export function searchRecords(records: Record_[], query: string, fields: FieldDefinition[]): Record_[] {
const q = query.trim().toLowerCase()
if (!q) return records.slice()
return records.filter((rec) =>
fields.some((f) => {
const c = comparable(f, rec[f.name])
return c != null && String(c).toLowerCase().includes(q)
}),
)
}
// ── pagination ────────────────────────────────────────────────────────────────
export interface Page<T> {
items: T[]
page: number
pageCount: number
total: number
/** 1-based index of the first item on this page (0 when empty). */
from: number
/** 1-based index of the last item on this page. */
to: number
}
/** Slice records into a page; `page` is clamped to a valid range. */
export function paginate<T>(records: T[], page: number, pageSize: number): Page<T> {
const total = records.length
const pageCount = Math.max(1, Math.ceil(total / pageSize))
const clamped = Math.min(Math.max(1, page), pageCount)
const start = (clamped - 1) * pageSize
const items = records.slice(start, start + pageSize)
return {
items,
page: clamped,
pageCount,
total,
from: total === 0 ? 0 : start + 1,
to: start + items.length,
}
}
// ── column order helpers ──────────────────────────────────────────────────────
/** Move an array element from index `from` to index `to` (returns a new array). */
export function moveItem<T>(arr: T[], from: number, to: number): T[] {
const next = arr.slice()
if (from < 0 || from >= next.length) return next
const [item] = next.splice(from, 1)
const dest = Math.min(Math.max(0, to), next.length)
next.splice(dest, 0, item)
return next
}
/** The 0-based column index whose horizontal band contains `x` px (from the grid left). */
export function columnIndexAtX(widths: number[], x: number): number {
let acc = 0
for (let i = 0; i < widths.length; i++) {
acc += widths[i]
if (x < acc) return i
}
return Math.max(0, widths.length - 1)
}
/**
* The ordered, visible columns for the table: fields ordered by `order` (names),
* with any field missing from `order` appended in schema order, and any hidden
* name removed. One place decides column order so header + rows stay in lockstep.
*/
export function orderedColumns(
fields: FieldDefinition[],
order: string[] | undefined,
hidden: ReadonlySet<string> | undefined,
): FieldDefinition[] {
const visible = fields.filter((f) => !hidden?.has(f.name))
if (!order || order.length === 0) return visible
const byName = new Map(visible.map((f) => [f.name, f]))
const out: FieldDefinition[] = []
for (const name of order) {
const f = byName.get(name)
if (f) { out.push(f); byName.delete(name) }
}
for (const f of visible) if (byName.has(f.name)) out.push(f)
return out
}
-40
View File
@@ -1,40 +0,0 @@
// Hanzo data-app tokens. Brand-neutral, dark-first — the zinc-on-black identity
// the Base/console surfaces use. Tag colors are muted fills with legible text,
// resolved once here so every tag (select, multiSelect, status) reads the same.
import type { TagColor } from './field/types'
export interface TagTone {
bg: string
fg: string
border: string
}
/** TagColor → tone. Muted, dark-surface friendly. One source of truth. */
export const TAG_TONES: Record<TagColor, TagTone> = {
gray: { bg: '#27272a', fg: '#e4e4e7', border: '#3f3f46' },
blue: { bg: '#1e3a5f', fg: '#bfdbfe', border: '#1e40af' },
green: { bg: '#14432a', fg: '#bbf7d0', border: '#166534' },
amber: { bg: '#451a03', fg: '#fde68a', border: '#92400e' },
red: { bg: '#450a0a', fg: '#fecaca', border: '#991b1b' },
purple: { bg: '#2e1065', fg: '#ddd6fe', border: '#6d28d9' },
pink: { bg: '#500724', fg: '#fbcfe8', border: '#9d174d' },
teal: { bg: '#042f2e', fg: '#99f6e4', border: '#0f766e' },
orange: { bg: '#431407', fg: '#fed7aa', border: '#9a3412' },
}
export function tagTone(color: TagColor | undefined): TagTone {
return TAG_TONES[color ?? 'gray']
}
/** Core surface tokens (raw hex so components are theme-config independent). */
export const tokens = {
text: '#f4f4f5',
textMuted: '#a1a1aa',
textFaint: '#71717a',
surface: '#09090b',
surfaceRaised: '#0a0a0a',
border: '#27272a',
hover: '#18181b',
accent: '#60a5fa',
danger: '#fca5a5',
} as const
-194
View File
@@ -1,194 +0,0 @@
'use client'
// RecordsView — the one shell that makes any Base collection an Airtable/Twenty-
// class surface: a toolbar (view switch, search, filter, sort, group) over a
// DataTable OR a BoardView, driven by a single ViewConfig applied through the pure
// view/logic. Presentational + data-injected: the host supplies raw records and
// the persistence callbacks (open / create / inline-edit / move); RecordsView owns
// only the view state (or reflects a controlled one) and re-applies it. Optional
// saved views let a host persist named configurations. CRM, CMS, commerce — every
// business app is a curated set of these views over the same components.
import { useMemo, useState, type ReactNode } from 'react'
import { Text, XStack, YStack } from '@hanzo/gui'
import { Plus, X } from '@hanzogui/lucide-icons-2'
import type { FieldDefinition, SelectOption } from '../field/types'
import { DataTable } from '../table/DataTable'
import { BoardView } from '../board/BoardView'
import { defaultGroupField, movePatch } from '../board/logic'
import { tokens } from '../theme'
import type { ViewConfig, ViewKind } from './types'
import { applyView, emptyView, setSorts } from './logic'
import { RecordsToolbar } from './Toolbar'
type Record_ = Record<string, unknown>
/** Optional saved-view persistence (the host owns the store). */
export interface SavedViews {
views: ViewConfig[]
activeId?: string
onSelect: (id: string) => void
onSave: (view: ViewConfig) => void
onDelete?: (id: string) => void
}
export interface RecordsViewProps {
fields: FieldDefinition[]
/** Raw records; RecordsView applies search + filter + sort from the view. */
records: Record_[]
loading?: boolean
empty?: ReactNode
// view state — controlled (view + onViewChange) or internal (defaultKind)
view?: ViewConfig
onViewChange?: (v: ViewConfig) => void
defaultKind?: ViewKind
// saved views (optional)
savedViews?: SavedViews
// header + actions
title?: ReactNode
onOpen?: (record: Record_) => void
onCreate?: () => void
createLabel?: string
/** Persist an inline cell/field edit (table + board). */
onEditCommit?: (record: Record_, field: FieldDefinition, value: unknown) => void | Promise<void>
/** Persist a board card move; defaults to onEditCommit over the group field. */
onRecordChange?: (record: Record_, patch: Record<string, unknown>) => void | Promise<void>
fieldOptions?: (field: FieldDefinition) => SelectOption[] | undefined
selectable?: boolean
editable?: boolean
bulkActions?: (ids: string[]) => ReactNode
/** Toolbar extra (e.g. Refresh). */
toolbarExtra?: ReactNode
boardDisabled?: boolean
titleField?: string
/** Fields shown on board cards (defaults to all but the group field). */
cardFields?: FieldDefinition[]
}
export function RecordsView(props: RecordsViewProps) {
const {
fields, records, loading, empty,
view: controlledView, onViewChange, defaultKind = 'table',
savedViews, title, onOpen, onCreate, createLabel,
onEditCommit, onRecordChange, fieldOptions,
selectable = true, editable = true, bulkActions, toolbarExtra, boardDisabled, titleField, cardFields,
} = props
const [internalView, setInternalView] = useState<ViewConfig>(() => {
const v = emptyView(defaultKind)
return defaultKind === 'board' ? { ...v, groupField: defaultGroupField(fields) } : v
})
const view = controlledView ?? internalView
const setView = (v: ViewConfig) => {
// Entering board without a group field → pick a sensible default.
const next = v.kind === 'board' && !v.groupField ? { ...v, groupField: defaultGroupField(fields) } : v
onViewChange ? onViewChange(next) : setInternalView(next)
}
const visible = useMemo(() => applyView(records, view, fields), [records, view, fields])
const boardChange = onRecordChange
?? (onEditCommit
? (record: Record_, patch: Record<string, unknown>) => {
const [name] = Object.keys(patch)
const f = fields.find((ff) => ff.name === name)
if (f) return onEditCommit(record, f, patch[name])
}
: undefined)
return (
<YStack gap={12}>
{title ? (typeof title === 'string' ? <Text fontSize={20} fontWeight="800" style={{ color: tokens.text }}>{title}</Text> : title) : null}
{savedViews ? <SavedViewsBar saved={savedViews} current={view} onLoad={setView} /> : null}
<RecordsToolbar
fields={fields}
view={view}
onView={setView}
onCreate={onCreate}
createLabel={createLabel}
fieldOptions={fieldOptions}
extra={toolbarExtra}
boardDisabled={boardDisabled}
/>
{view.kind === 'board' ? (
<BoardView
fields={fields}
records={visible}
groupField={view.groupField ?? defaultGroupField(fields) ?? ''}
titleField={titleField}
cardFields={cardFields}
onOpen={onOpen}
onRecordChange={boardChange}
onCreate={onCreate ? () => onCreate() : undefined}
/>
) : (
<DataTable
fields={fields}
records={visible}
loading={loading}
empty={empty}
sorts={view.sorts}
onSortChange={(sorts) => setView(setSorts(view, sorts))}
onOpen={onOpen}
selectable={selectable}
bulkActions={bulkActions}
editable={editable}
onEditCommit={onEditCommit}
fieldOptions={fieldOptions}
hiddenFields={view.hiddenFields}
columnOrder={view.columnOrder}
onColumnOrderChange={(columnOrder) => setView({ ...view, columnOrder })}
pageSize={view.pageSize ?? 25}
/>
)}
</YStack>
)
}
function SavedViewsBar({ saved, current, onLoad }: { saved: SavedViews; current: ViewConfig; onLoad: (v: ViewConfig) => void }) {
return (
<XStack items="center" gap={4} style={{ flexWrap: 'wrap' }}>
{saved.views.map((v) => {
const active = v.id === (saved.activeId ?? current.id)
return (
<XStack
key={v.id}
items="center"
gap={6}
px={10}
py={6}
rounded={8}
cursor="pointer"
onPress={() => { saved.onSelect(v.id); onLoad(v) }}
style={{ backgroundColor: active ? tokens.hover : 'transparent', borderBottomWidth: 2, borderBottomColor: active ? tokens.accent : 'transparent' }}
hoverStyle={active ? undefined : { bg: tokens.surfaceRaised }}
>
<Text fontSize={13} fontWeight={active ? '700' : '500'} style={{ color: active ? tokens.text : tokens.textMuted }}>{v.name}</Text>
{saved.onDelete && saved.views.length > 1 ? (
<YStack cursor="pointer" onPress={() => saved.onDelete?.(v.id)}><X size={12} color={tokens.textFaint} /></YStack>
) : null}
</XStack>
)
})}
<XStack
items="center"
gap={4}
px={10}
py={6}
rounded={8}
cursor="pointer"
hoverStyle={{ bg: tokens.surfaceRaised }}
onPress={() => saved.onSave({ ...current, id: `view-${Date.now()}`, name: `View ${saved.views.length + 1}` })}
>
<Plus size={13} color={tokens.textFaint} />
<Text fontSize={13} style={{ color: tokens.textFaint }}>New view</Text>
</XStack>
</XStack>
)
}
-255
View File
@@ -1,255 +0,0 @@
'use client'
// RecordsToolbar — the view control surface: switch table ⇆ board, quick search,
// a filter builder, a sort builder, a board group-by picker, and a New action.
// Every control edits the one ViewConfig through the pure view/logic helpers and
// emits it via onView; the shell re-applies the view. Filter VALUES are entered
// with the field's OWN editor (FieldInput) — one editor vocabulary everywhere.
import { Fragment, useState, type ReactNode } from 'react'
import { Button, Input, Text, XStack, YStack } from '@hanzo/gui'
import { ArrowDownUp, ArrowDown, ArrowUp, Columns3, LayoutGrid, ListFilter, Plus, Search, Table2, X } from '@hanzogui/lucide-icons-2'
import type { FieldDefinition, SelectOption } from '../field/types'
import { FieldInput } from '../field/FieldInput'
import { Menu, MenuItem } from '../primitives'
import { tokens } from '../theme'
import { OP_LABELS, operatorsForType, type FilterOp, type SortRule } from '../table/logic'
import { groupFieldCandidates } from '../board/logic'
import type { ViewConfig } from './types'
import { addFilter, defaultRuleFor, removeFilter, setGroupField, setKind, setSearch, setSorts, updateFilter } from './logic'
const byName = (fields: FieldDefinition[], name: string) => fields.find((f) => f.name === name)
export interface RecordsToolbarProps {
fields: FieldDefinition[]
view: ViewConfig
onView: (v: ViewConfig) => void
onCreate?: () => void
createLabel?: string
fieldOptions?: (f: FieldDefinition) => SelectOption[] | undefined
/** Extra controls (e.g. Refresh), shown at the right. */
extra?: ReactNode
/** Hide the board view option (table-only surfaces). */
boardDisabled?: boolean
}
/** A compact bordered control used as a Menu trigger. */
function Control({ icon, label, count, active }: { icon: ReactNode; label: string; count?: number; active?: boolean }) {
return (
<XStack
items="center"
gap={6}
px={10}
py={7}
rounded={8}
borderWidth={1}
cursor="pointer"
hoverStyle={{ borderColor: tokens.textMuted }}
style={{ borderColor: active ? tokens.accent : tokens.border, backgroundColor: active ? tokens.hover : 'transparent' }}
>
{icon}
<Text fontSize={13} fontWeight="600" style={{ color: active ? tokens.text : tokens.textMuted }}>{label}</Text>
{count ? (
<XStack items="center" justify="center" width={18} height={18} rounded={9} style={{ backgroundColor: tokens.accent }}>
<Text fontSize={11} fontWeight="700" style={{ color: '#0b1220' }}>{count}</Text>
</XStack>
) : null}
</XStack>
)
}
function Segmented({ view, onView, boardDisabled }: { view: ViewConfig; onView: (v: ViewConfig) => void; boardDisabled?: boolean }) {
const seg = (kind: 'table' | 'board', icon: ReactNode, label: string) => {
const active = view.kind === kind
return (
<XStack
items="center"
gap={6}
px={10}
py={6}
rounded={7}
cursor="pointer"
onPress={() => onView(setKind(view, kind))}
style={{ backgroundColor: active ? tokens.surface : 'transparent' }}
hoverStyle={active ? undefined : { bg: tokens.hover }}
>
{icon}
<Text fontSize={13} fontWeight="600" style={{ color: active ? tokens.text : tokens.textFaint }}>{label}</Text>
</XStack>
)
}
return (
<XStack p={3} gap={2} rounded={9} borderWidth={1} style={{ borderColor: tokens.border, backgroundColor: tokens.surfaceRaised }}>
{seg('table', <Table2 size={14} color={view.kind === 'table' ? tokens.text : tokens.textFaint} />, 'Table')}
{boardDisabled ? null : seg('board', <LayoutGrid size={14} color={view.kind === 'board' ? tokens.text : tokens.textFaint} />, 'Board')}
</XStack>
)
}
/** A field picker menu (used by filter rows). */
function FieldPicker({ fields, value, onPick, trigger }: { fields: FieldDefinition[]; value?: string; onPick: (name: string) => void; trigger: ReactNode }) {
const [open, setOpen] = useState(false)
return (
<Menu open={open} onOpenChange={setOpen} width={220} trigger={trigger}>
{fields.map((f) => (
<MenuItem key={f.name} active={f.name === value} onPress={() => { onPick(f.name); setOpen(false) }}>
<Text fontSize={13} style={{ color: tokens.text }}>{f.label}</Text>
</MenuItem>
))}
</Menu>
)
}
function OpPicker({ ops, value, onPick }: { ops: FilterOp[]; value: FilterOp; onPick: (op: FilterOp) => void }) {
const [open, setOpen] = useState(false)
return (
<Menu open={open} onOpenChange={setOpen} width={200} trigger={<PillText text={OP_LABELS[value]} />}>
{ops.map((op) => (
<MenuItem key={op} active={op === value} onPress={() => { onPick(op); setOpen(false) }}>
<Text fontSize={13} style={{ color: tokens.text }}>{OP_LABELS[op]}</Text>
</MenuItem>
))}
</Menu>
)
}
function PillText({ text }: { text: string }) {
return (
<XStack items="center" px={8} py={5} rounded={7} borderWidth={1} cursor="pointer" hoverStyle={{ borderColor: tokens.textMuted }} style={{ borderColor: tokens.border, backgroundColor: tokens.surface }}>
<Text fontSize={12} numberOfLines={1} style={{ color: tokens.text }}>{text}</Text>
</XStack>
)
}
function FilterBuilder({ fields, view, onView, fieldOptions }: RecordsToolbarProps) {
const [open, setOpen] = useState(false)
const active = view.filters.filter((r) => r.op === 'isEmpty' || r.op === 'isNotEmpty' || r.value != null).length
return (
<Menu
open={open}
onOpenChange={setOpen}
width={420}
trigger={<Control icon={<ListFilter size={14} color={tokens.textMuted} />} label="Filter" count={active} active={active > 0} />}
>
<YStack gap={8} p={4}>
{view.filters.length === 0 ? (
<Text px={6} py={4} fontSize={12} style={{ color: tokens.textFaint }}>No filters yet.</Text>
) : null}
{view.filters.map((rule, i) => {
const field = byName(fields, rule.field) ?? fields[0]
const ops = operatorsForType(field.type)
const needsValue = rule.op !== 'isEmpty' && rule.op !== 'isNotEmpty'
const valueField: FieldDefinition = rule.op === 'isAnyOf' ? { ...field, type: 'multiSelect' } : field
const withOpts: FieldDefinition = fieldOptions?.(field)
? { ...valueField, metadata: { ...(valueField.metadata as object), options: fieldOptions(field) } as FieldDefinition['metadata'] }
: valueField
return (
<XStack key={i} items="center" gap={6}>
<Text fontSize={12} width={28} style={{ color: tokens.textFaint }}>{i === 0 ? 'Where' : 'and'}</Text>
<FieldPicker
fields={fields}
value={rule.field}
onPick={(name) => { const nf = byName(fields, name)!; onView(updateFilter(view, i, { field: name, op: operatorsForType(nf.type)[0], value: undefined })) }}
trigger={<PillText text={field.label} />}
/>
<OpPicker ops={ops} value={rule.op} onPick={(op) => onView(updateFilter(view, i, { op }))} />
{needsValue ? (
<XStack flex={1} minW={120}>
<FieldInput field={withOpts} value={rule.value} onChange={(v) => onView(updateFilter(view, i, { value: v }))} />
</XStack>
) : <XStack flex={1} />}
<YStack cursor="pointer" p={4} rounded={6} hoverStyle={{ bg: tokens.hover }} onPress={() => onView(removeFilter(view, i))}>
<X size={14} color={tokens.textFaint} />
</YStack>
</XStack>
)
})}
<XStack>
<Button size="$2" icon={<Plus size={14} />} onPress={() => onView(addFilter(view, defaultRuleFor(fields[0])))}>Add filter</Button>
</XStack>
</YStack>
</Menu>
)
}
function SortBuilder({ fields, view, onView }: { fields: FieldDefinition[]; view: ViewConfig; onView: (v: ViewConfig) => void }) {
const [open, setOpen] = useState(false)
const dirOf = (name: string) => view.sorts.find((s) => s.field === name)?.dir
const toggle = (name: string) => {
const cur = view.sorts.find((s) => s.field === name)
let sorts: SortRule[]
if (!cur) sorts = [...view.sorts, { field: name, dir: 'asc' }]
else if (cur.dir === 'asc') sorts = view.sorts.map((s) => (s.field === name ? { ...s, dir: 'desc' } : s))
else sorts = view.sorts.filter((s) => s.field !== name)
onView(setSorts(view, sorts))
}
return (
<Menu
open={open}
onOpenChange={setOpen}
width={240}
trigger={<Control icon={<ArrowDownUp size={14} color={tokens.textMuted} />} label="Sort" count={view.sorts.length} active={view.sorts.length > 0} />}
>
{fields.map((f) => {
const dir = dirOf(f.name)
return (
<MenuItem
key={f.name}
active={Boolean(dir)}
onPress={() => toggle(f.name)}
trailing={dir === 'asc' ? <ArrowUp size={13} color={tokens.accent} /> : dir === 'desc' ? <ArrowDown size={13} color={tokens.accent} /> : null}
>
<Text fontSize={13} style={{ color: tokens.text }}>{f.label}</Text>
</MenuItem>
)
})}
</Menu>
)
}
function GroupPicker({ fields, view, onView }: { fields: FieldDefinition[]; view: ViewConfig; onView: (v: ViewConfig) => void }) {
const [open, setOpen] = useState(false)
const candidates = groupFieldCandidates(fields)
const current = byName(fields, view.groupField ?? '')
return (
<Menu
open={open}
onOpenChange={setOpen}
width={220}
trigger={<Control icon={<Columns3 size={14} color={tokens.textMuted} />} label={current ? `Group: ${current.label}` : 'Group by'} active={Boolean(current)} />}
>
{candidates.length === 0 ? <Text px={10} py={6} fontSize={12} style={{ color: tokens.textFaint }}>No groupable fields</Text> : null}
{candidates.map((f) => (
<MenuItem key={f.name} active={f.name === view.groupField} onPress={() => { onView(setGroupField(view, f.name)); setOpen(false) }}>
<Text fontSize={13} style={{ color: tokens.text }}>{f.label}</Text>
</MenuItem>
))}
</Menu>
)
}
export function RecordsToolbar(props: RecordsToolbarProps) {
const { fields, view, onView, onCreate, createLabel = 'New', extra, boardDisabled } = props
return (
<XStack items="center" gap={8} style={{ flexWrap: 'wrap' }}>
<Segmented view={view} onView={onView} boardDisabled={boardDisabled} />
<FilterBuilder {...props} />
<SortBuilder fields={fields} view={view} onView={onView} />
{view.kind === 'board' ? <GroupPicker fields={fields} view={view} onView={onView} /> : null}
<XStack flex={1} minW={160} items="center" gap={6} px={10} py={6} rounded={8} borderWidth={1} style={{ borderColor: tokens.border, backgroundColor: tokens.surface }}>
<Search size={14} color={tokens.textFaint} />
<Input
flex={1}
size="$2"
value={view.search ?? ''}
onChangeText={(t: string) => onView(setSearch(view, t))}
placeholder="Search…"
style={{ borderWidth: 0, backgroundColor: 'transparent', paddingLeft: 0, paddingRight: 0 }}
/>
</XStack>
{extra ? <Fragment>{extra}</Fragment> : null}
{onCreate ? <Button size="$3" icon={<Plus size={16} />} theme="blue" onPress={onCreate}>{createLabel}</Button> : null}
</XStack>
)
}
-98
View File
@@ -1,98 +0,0 @@
import { describe, it, expect } from 'vitest'
import type { FieldDefinition } from '../field/types'
import {
emptyView,
applyView,
defaultRuleFor,
addFilter,
updateFilter,
removeFilter,
cycleColumnSort,
setSearch,
setGroupField,
upsertView,
removeView,
describeView,
} from './logic'
const fields: FieldDefinition[] = [
{ name: 'name', label: 'Name', type: 'text' },
{ name: 'count', label: 'Count', type: 'number' },
{ name: 'stage', label: 'Stage', type: 'select', metadata: { options: [{ value: 'NEW', label: 'New' }, { value: 'WON', label: 'Won' }] } },
]
const rows: Record<string, unknown>[] = [
{ id: '1', name: 'Acme', count: 5, stage: 'NEW' },
{ id: '2', name: 'Beta', count: 2, stage: 'WON' },
{ id: '3', name: 'Ace', count: 9, stage: 'NEW' },
]
describe('applyView', () => {
it('composes search → filter → sort', () => {
let v = emptyView('table')
v = setSearch(v, 'ac') // Acme, Ace
v = addFilter(v, { field: 'stage', op: 'is', value: 'NEW' })
v = { ...v, sorts: [{ field: 'count', dir: 'desc' }] }
const out = applyView(rows, v, fields)
expect(out.map((r) => r.id)).toEqual(['3', '1']) // Ace(9), Acme(5)
})
it('empty view returns all in order', () => {
expect(applyView(rows, emptyView('table'), fields).map((r) => r.id)).toEqual(['1', '2', '3'])
})
})
describe('filter edits (immutable)', () => {
it('adds, updates, removes without mutating', () => {
const v0 = emptyView('table')
const v1 = addFilter(v0, defaultRuleFor(fields[0]))
expect(v0.filters).toHaveLength(0)
expect(v1.filters).toHaveLength(1)
expect(v1.filters[0]).toMatchObject({ field: 'name', op: 'contains' })
const v2 = updateFilter(v1, 0, { op: 'is', value: 'Acme' })
expect(v2.filters[0]).toMatchObject({ op: 'is', value: 'Acme' })
expect(v1.filters[0].op).toBe('contains') // v1 untouched
const v3 = removeFilter(v2, 0)
expect(v3.filters).toHaveLength(0)
})
it('defaultRuleFor picks a type-appropriate operator', () => {
expect(defaultRuleFor(fields[1]).op).toBe('is') // number → first numeric op
expect(defaultRuleFor(fields[2]).op).toBe('is') // select
})
})
describe('cycleColumnSort', () => {
it('cycles a column through asc/desc/off', () => {
let v = emptyView('table')
v = cycleColumnSort(v, 'name')
expect(v.sorts).toEqual([{ field: 'name', dir: 'asc' }])
v = cycleColumnSort(v, 'name')
expect(v.sorts).toEqual([{ field: 'name', dir: 'desc' }])
v = cycleColumnSort(v, 'name')
expect(v.sorts).toEqual([])
})
})
describe('saved views', () => {
it('upsert appends new and replaces by id', () => {
const a = emptyView('table', 'a', 'A')
const b = emptyView('board', 'b', 'B')
let views = upsertView([], a)
views = upsertView(views, b)
expect(views.map((v) => v.id)).toEqual(['a', 'b'])
views = upsertView(views, { ...a, name: 'A2' })
expect(views.find((v) => v.id === 'a')?.name).toBe('A2')
expect(views).toHaveLength(2)
views = removeView(views, 'a')
expect(views.map((v) => v.id)).toEqual(['b'])
})
})
describe('describeView', () => {
it('summarizes filters, sort, and grouping', () => {
let v = emptyView('board')
v = addFilter(v, { field: 'stage', op: 'is', value: 'NEW' })
v = { ...v, sorts: [{ field: 'count', dir: 'asc' }] }
v = setGroupField(v, 'stage')
expect(describeView(v, fields)).toBe('1 filter · sorted by Count · grouped by Stage')
})
})
-109
View File
@@ -1,109 +0,0 @@
// Pure view-state logic — composing a saved view (search → filter → sort) over a
// record set, and the immutable edits the view toolbar makes (add/update/remove a
// filter, cycle a column's sort, change search / group / kind, save & delete
// views). No React, no @hanzo/gui: every helper is data-in/data-out and tested in
// plain Node. The ONE place a view is applied, so table + board agree.
import type { FieldDefinition } from '../field/types'
import {
filterRecords,
searchRecords,
sortRecords,
cycleSort,
operatorsForType,
type FilterRule,
type SortRule,
} from '../table/logic'
import type { ViewConfig, ViewKind } from './types'
type Record_ = Record<string, unknown>
/** A blank view of a given kind. */
export function emptyView(kind: ViewKind, id = 'default', name = kind === 'board' ? 'Board' : 'All records'): ViewConfig {
return { id, name, kind, filters: [], sorts: [] }
}
/**
* Apply a view to a record set: quick-search, then filter (AND), then sort. Pure
* — returns a new array, input untouched. Board grouping is applied by the board
* view over this same filtered+sorted result, so both views stay consistent.
*/
export function applyView(records: Record_[], view: ViewConfig, fields: FieldDefinition[]): Record_[] {
let out = records
if (view.search && view.search.trim()) out = searchRecords(out, view.search, fields)
out = filterRecords(out, view.filters, fields)
out = sortRecords(out, view.sorts, fields)
return out
}
// ── filter edits (immutable) ──────────────────────────────────────────────────
/** The sensible starting rule for a field — its first operator, no value yet. */
export function defaultRuleFor(field: FieldDefinition): FilterRule {
const ops = operatorsForType(field.type)
return { field: field.name, op: ops[0] ?? 'contains' }
}
export function addFilter(view: ViewConfig, rule: FilterRule): ViewConfig {
return { ...view, filters: [...view.filters, rule] }
}
export function updateFilter(view: ViewConfig, index: number, patch: Partial<FilterRule>): ViewConfig {
return { ...view, filters: view.filters.map((r, i) => (i === index ? { ...r, ...patch } : r)) }
}
export function removeFilter(view: ViewConfig, index: number): ViewConfig {
return { ...view, filters: view.filters.filter((_, i) => i !== index) }
}
// ── sort edits ────────────────────────────────────────────────────────────────
export function setSorts(view: ViewConfig, sorts: SortRule[]): ViewConfig {
return { ...view, sorts }
}
/** Header-click: cycle a column through unsorted → asc → desc → unsorted. */
export function cycleColumnSort(view: ViewConfig, field: string): ViewConfig {
return { ...view, sorts: cycleSort(view.sorts, field) }
}
// ── search / kind / group ─────────────────────────────────────────────────────
export function setSearch(view: ViewConfig, search: string): ViewConfig {
return { ...view, search }
}
export function setKind(view: ViewConfig, kind: ViewKind): ViewConfig {
return { ...view, kind }
}
export function setGroupField(view: ViewConfig, groupField: string | undefined): ViewConfig {
return { ...view, groupField }
}
// ── column layout edits (table) ───────────────────────────────────────────────
export function setColumnOrder(view: ViewConfig, columnOrder: string[]): ViewConfig {
return { ...view, columnOrder }
}
export function toggleFieldHidden(view: ViewConfig, name: string): ViewConfig {
const hidden = new Set(view.hiddenFields ?? [])
if (hidden.has(name)) hidden.delete(name)
else hidden.add(name)
return { ...view, hiddenFields: [...hidden] }
}
// ── saved views collection ────────────────────────────────────────────────────
/** Insert or replace a view by id (order preserved; appended when new). */
export function upsertView(views: ViewConfig[], view: ViewConfig): ViewConfig[] {
const i = views.findIndex((v) => v.id === view.id)
if (i < 0) return [...views, view]
return views.map((v, j) => (j === i ? view : v))
}
export function removeView(views: ViewConfig[], id: string): ViewConfig[] {
return views.filter((v) => v.id !== id)
}
/** A short human summary of a view's filter + sort state — for the switcher. */
export function describeView(view: ViewConfig, fields: FieldDefinition[]): string {
const label = (name: string) => fields.find((f) => f.name === name)?.label ?? name
const parts: string[] = []
if (view.filters.length) parts.push(`${view.filters.length} filter${view.filters.length > 1 ? 's' : ''}`)
if (view.sorts.length) parts.push(`sorted by ${label(view.sorts[0].field)}`)
if (view.kind === 'board' && view.groupField) parts.push(`grouped by ${label(view.groupField)}`)
return parts.join(' · ')
}
-30
View File
@@ -1,30 +0,0 @@
// The saved-view model — a named, serializable configuration of how a collection
// is presented: which view kind (table / board), what it's filtered + sorted +
// grouped by, and the table's column layout. A host persists these (per user, per
// collection); the components render from them and emit the next config. One
// value describes a view, so switching, saving, and restoring are all data.
import type { SortRule, FilterRule } from '../table/logic'
export type ViewKind = 'table' | 'board'
export interface ViewConfig {
/** Stable id (host-assigned). */
id: string
/** Human name shown in the view switcher. */
name: string
kind: ViewKind
/** AND-combined filter rules. */
filters: FilterRule[]
/** Ordered sort rules (first is primary). */
sorts: SortRule[]
/** Quick full-text search across fields. */
search?: string
/** Board: the select/status/boolean field records are grouped into columns by. */
groupField?: string
/** Table: column order by field name (missing names append in schema order). */
columnOrder?: string[]
/** Table: field names hidden from the grid. */
hiddenFields?: string[]
/** Table: rows per page. */
pageSize?: number
}
-19
View File
@@ -1,19 +0,0 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "Bundler",
"jsx": "react-jsx",
"strict": true,
"noEmit": true,
"isolatedModules": true,
"esModuleInterop": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"forceConsistentCasingInFileNames": true,
"types": ["react"]
},
"include": ["gui.config.ts", "gui.d.ts", "src"],
"exclude": ["node_modules", "dist"]
}
-11
View File
@@ -1,11 +0,0 @@
import { defineConfig } from 'vitest/config'
/**
* Unit tests run in a plain Node env. Every suite targets the package's PURE
* logic (sort / filter / group / view state) — the modules that import only
* `type` from the field model, so no @hanzo/gui runtime is pulled in. The view
* components themselves are verified by the consuming app's build + visual e2e.
*/
export default defineConfig({
test: { environment: 'node', include: ['src/**/*.test.ts'] },
})
-8
View File
@@ -1,8 +0,0 @@
# Keep the published tarball lean — ship src, not tests or dev type-check config.
**/*.test.ts
**/*.test.tsx
tsconfig.json
vitest.config.ts
gui.config.ts
gui.d.ts
node_modules
-61
View File
@@ -1,61 +0,0 @@
# @hanzo/ui
The **one** Hanzo component library, built on [`@hanzo/gui`](https://github.com/hanzoai/gui) (Tamagui) so every component runs on **web, native (iOS), and desktop**. It unifies what used to be three fragmented homes — `@hanzo/data`, the console's in-app `components/ui/*`, and ad-hoc duplicates — into one canonical, presentational, host-agnostic, clean-room library.
> This is the gui-based line (**v8+**). The legacy shadcn/Radix `@hanzo/ui` (v5.x) is a different architecture; it is being retired to `@hanzo/ui-shadcn` so the `@hanzo/ui` name carries the unified gui-based library forward — see `CONSOLIDATION.md` at the repo root.
## Install
```sh
bun add @hanzo/ui @hanzo/gui @hanzo/data
```
`@hanzo/gui`, `@hanzo/data`, and `react` are peers.
## Two orthogonal layers, one package
```tsx
// product / app layer — charts, metrics, headers, status, empty states, combobox…
import { PageHeader, Sparkline, LineChart, EmptyState, StatusTag, ComboBox, tokens } from '@hanzo/ui'
// metadata-driven record layer — RecordsView & the typed field editors (@hanzo/data)
import { RecordsView, DataTable, BoardView, RecordDetail, registerField } from '@hanzo/ui/data'
```
- **`@hanzo/ui`** — the product/app component layer:
- **Charts** (dependency-free inline SVG): `Sparkline`, `LineChart`, `BarChart`, `Donut`, `BarRows` — hover tooltips, axis ticks, honest `null` under two real points.
- **Metric** tiles/panels: `MetricCard`, `MiniBars`, `UtilBar`, `LegendDot`, `Panel`, `HintButton` (+ `MetricSparkline`).
- **Chrome**: `PageHeader`, `StatusTag`, `EmptyState` (DO/Vercel-class first-run), `PrimaryButton`, `HanzoMark`, `ProductIcon`, `ProviderLogo`.
- **Interaction**: `ComboBox` (typeable, ReDoS-safe filter), `SelectMenu`, `SlideOver` (a11y drawer), `Toast` (`useToast`), `Reorder` (pointer DnD), `Field*` rows, `FadeIn`, `ThemeToggle`, generic `DataTable<T>`.
- **Tokens**: `tokens`, `TAG_TONES`, `tagTone` — the calm, dark-first, zinc-on-black identity.
- **`@hanzo/ui/data`** — the metadata-driven record layer (source of truth: `@hanzo/data`): `RecordsView` (table ⇆ board, filter/sort/search/group, inline edit, saved views), the record-grid `DataTable`, `BoardView`, `RecordDetail`/`RecordForm`, every typed field editor, the field registry, and the pure view/sort/filter logic.
Both layers are **presentational + data-injected**: the host supplies rows and persistence callbacks; the components own only interaction state. Nothing imports an app's `~/lib`.
> Each layer owns a `DataTable` — the product one is a generic typed `<T>` list; the data one is the field-driven record grid. Keeping the record layer on the `./data` subpath keeps each name unambiguous.
## Motion
The calm motion vocabulary (`FadeIn`, drawer/backdrop transitions, skeleton shimmer, live pulse) ships as one stylesheet. Import it once at the app root:
```ts
import '@hanzo/ui/styles/motion.css'
```
Every animation honors `prefers-reduced-motion`.
## Extend the record layer
```tsx
import { registerField } from '@hanzo/ui/data'
registerField('rating', { Display: MyStars, Input: MyStarPicker })
```
## Design principles
- **One home, no duplication** — components are extracted, never copied. The record layer's source of truth stays in `@hanzo/data`; `@hanzo/ui` composes it.
- **Host-agnostic** — data + effects injected; zero coupling to any app.
- **Clean-room** — original implementation, no GPL / Twenty code. Airtable/Twenty-*class* polish, our own code.
- **Cross-platform** — web + native + desktop, because it's only `@hanzo/gui`.
BSD-3-Clause · Hanzo AI
-13
View File
@@ -1,13 +0,0 @@
// The @hanzo/gui v5 config this package is authored against. `createGui` with the
// shared `@hanzogui/config/v5` default enables the shorthand style props
// (bg/px/py/items/justify/rounded/…) the components use; `Conf` feeds the type
// augmentation in `gui.d.ts` so `tsc --noEmit` type-checks the shorthands exactly
// as the consuming app (console) does. Not shipped — dev/type-check only.
import { defaultConfig } from '@hanzogui/config/v5'
import { createGui } from '@hanzo/gui'
export const config = createGui(defaultConfig)
export default config
export type Conf = typeof config
-15
View File
@@ -1,15 +0,0 @@
/**
* Registers our Gui config with the type system so shorthand style props
* (tokens, themes, bg/px/py/items/justify etc.) type-check exactly as they do in
* the consuming app. GuiCustomConfig is declared in @hanzogui/web and flows
* through @hanzo/gui. Dev/type-check only — never shipped.
*/
import type { Conf } from './gui.config'
declare module '@hanzogui/web' {
interface GuiCustomConfig extends Conf {}
}
declare module '@hanzogui/core' {
interface GuiCustomConfig extends Conf {}
}
-63
View File
@@ -1,63 +0,0 @@
{
"name": "@hanzo/ui",
"version": "8.0.0",
"type": "module",
"description": "Hanzo UI — the one cross-platform component library on @hanzo/gui. The product/app layer (charts, metrics, page headers, status tags, rich empty states, combobox, slide-over, toasts, drag-reorder, labeled field rows, provider/product marks) + the metadata-driven record layer (@hanzo/data: RecordsView, DataTable, board, typed field editors) + the calm dark-first tokens and motion. Presentational, host-agnostic (data/effects injected), clean-room. Web + native (iOS) + desktop.",
"exports": {
".": {
"types": "./src/index.ts",
"default": "./src/index.ts"
},
"./product": {
"types": "./src/product/index.ts",
"default": "./src/product/index.ts"
},
"./data": {
"types": "./src/data.ts",
"default": "./src/data.ts"
},
"./primitives/bases/data": {
"types": "./src/primitives/bases/data/index.ts",
"default": "./src/primitives/bases/data/index.ts"
},
"./styles/motion.css": "./src/styles/hanzo-motion.css",
"./package.json": "./package.json"
},
"main": "./src/index.ts",
"module": "./src/index.ts",
"types": "./src/index.ts",
"sideEffects": [
"**/*.css"
],
"files": [
"src"
],
"scripts": {
"tc": "tsc --noEmit",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest"
},
"peerDependencies": {
"@hanzo/data": ">=1.2.0",
"@hanzo/gui": ">=7.2.2",
"react": ">=19"
},
"devDependencies": {
"@hanzo/data": "1.2.0",
"@hanzo/gui": "7.3.0",
"@hanzogui/config": "7.3.0",
"@hanzogui/lucide-icons-2": "7.3.0",
"@hanzogui/next-theme": "7.3.0",
"@types/react": "^19.1.10",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"typescript": "^5.7.2",
"vitest": "^2.1.9"
},
"publishConfig": {
"access": "public"
},
"license": "BSD-3-Clause",
"author": "Hanzo AI <dev@hanzo.ai>"
}
-12
View File
@@ -1,12 +0,0 @@
// @hanzo/ui/data — the metadata-driven record layer.
//
// RecordsView, DataTable, BoardView, RecordDetail/RecordForm, the typed field
// editors (select/relation/calendar/file/JSON/boolean…), the field registry, the
// pure sort/filter/search/group/view logic, and the tokens. The source of truth
// is @hanzo/data (pkg/data) — this is the canonical @hanzo/ui entry point for it,
// so an app depends on ONE package (@hanzo/ui) for both the product layer and the
// record layer. Do NOT re-implement anything here; add it upstream in @hanzo/data
// and it flows through.
//
// import { RecordsView, DataTable, registerField } from '@hanzo/ui/data'
export * from '@hanzo/data'
-22
View File
@@ -1,22 +0,0 @@
// @hanzo/ui — the one Hanzo component library, on @hanzo/gui.
//
// import { PageHeader, Sparkline, EmptyState, tokens } from '@hanzo/ui' // product + tokens
// import { RecordsView, DataTable, registerField } from '@hanzo/ui/data' // metadata record layer
//
// Two orthogonal concerns, ONE package, zero duplication:
// • the product/app component layer (this barrel — charts, metrics, headers,
// status tags, empty states, combobox, slide-over, toasts, reorder, fields), and
// • the metadata-driven record layer (the `./data` subpath — RecordsView and the
// typed field editors, whose source of truth is @hanzo/data).
// Both cross-platform (web + native + desktop), presentational, host-agnostic
// (data + effects injected), clean-room. The two layers each own a `DataTable`
// (product = generic typed <T> list; data = field-driven record grid); keeping the
// record layer on the `./data` subpath keeps each name unambiguous.
// Product/app component layer.
export * from './product'
// Design tokens — the calm, dark-first zinc-on-black identity (surface, text,
// accent, tag tones). One source of truth (@hanzo/data's theme), surfaced here
// for convenience so `import { tokens } from '@hanzo/ui'` works.
export { tokens, TAG_TONES, tagTone, type TagTone } from '@hanzo/data'
-13
View File
@@ -1,13 +0,0 @@
// @hanzo/ui/primitives/bases/data — the Hanzo Base data-app layer.
//
// Source of truth: the `@hanzo/data` workspace package (~/work/hanzo/hanzoai/ui/
// pkg/data). This file is a passthrough re-export so consumers of @hanzo/ui can
// pull the metadata-driven record views (RecordsView, DataTable, BoardView,
// RecordDetail, field editors) without depending on @hanzo/data directly — the
// same convention the `gui` base uses for `hanzogui`. Subpath resolution makes
// `@hanzo/ui/primitives/bases/data` equivalent to `@hanzo/data`.
//
// Do NOT re-implement components here. Add them upstream in pkg/data and let them
// flow through this re-export.
export * from '@hanzo/data'
-471
View File
@@ -1,471 +0,0 @@
'use client'
/**
* Charts — the ONE way the console draws a trend, a series, a share, or a
* distribution. Dependency-free: inline SVG for the data-dense temporal charts
* (Sparkline / LineChart / BarChart) and @hanzo/gui primitives for the
* token-native ones (BarRows, and the Donut's optional legend), so everything
* themes to the shell.
*
* Color is data-driven, not a mode flag: a `Slice` is drawn in its own `color`
* when it has one (categorical/polychrome — e.g. spend-by-model) and in a
* monochrome opacity ramp off the foreground token when it doesn't (on-brand —
* e.g. collections-by-model). One Donut serves both; a consumer that needs a
* richer legend (amounts, click targets) leaves `legend` off and composes its own.
*
* Honest by construction: a Sparkline with fewer than two points renders nothing
* (no invented trend); Donut / BarRows with no positive value render an em-dash.
* Callers pass REAL series — these never fabricate data.
*
* SVG marks can't read Tamagui tokens, so concrete colors for SVG come from the
* live theme (`useTheme().<token>.get()` → `var(--token)` on web, stays reactive).
*/
import { useEffect, useRef, useState, type ReactNode } from 'react'
import { Text, XStack, YStack, useTheme } from '@hanzo/gui'
/** Distinct hues for categorical series (e.g. spend-by-model donut), dark-tuned. */
export const CHART_PALETTE = [
'#7c5cff',
'#23c562',
'#ff9f45',
'#3aa0ff',
'#ff5d8f',
'#16c0c8',
'#c084fc',
'#f4c245',
]
export const CHART_OTHER = '#64748b'
const ACCENT = CHART_PALETTE[0]
const GRID = 'rgba(148,163,184,0.16)'
const AXIS = 'rgba(148,163,184,0.85)'
/** A point on a temporal/sequential axis (LineChart / BarChart). */
export type ChartPoint = { label: string; value: number }
/** A labelled magnitude in a categorical chart (Donut / BarRows). */
export type Slice = { label: string; value: number; color?: string; hint?: string }
type Themed = ReturnType<typeof useTheme>
/** Read a concrete color for a theme token (for SVG), with a safe fallback. */
const tone = (theme: Themed, key: string, fallback: string): string => {
const v = (theme as unknown as Record<string, { get?: () => unknown } | undefined>)[key]
const got = v?.get?.()
return typeof got === 'string' ? got : fallback
}
/** Opacity ramp so N monochrome slices/areas stay visually distinct. */
const rampOpacity = (i: number, n: number): number => (n <= 1 ? 0.9 : 0.95 - (i / (n - 1)) * 0.62)
function useContainerWidth() {
const ref = useRef<HTMLDivElement>(null)
const [w, setW] = useState(0)
useEffect(() => {
const el = ref.current
if (!el) return
const measure = () => setW(el.clientWidth)
measure()
const ro = new ResizeObserver(measure)
ro.observe(el)
return () => ro.disconnect()
}, [])
return { ref, w }
}
// ── Sparkline (fixed size, for metric cards) ─────────────────────────────────
export function Sparkline({
values,
width = 120,
height = 36,
color,
}: {
values: number[]
width?: number
height?: number
color?: string
}) {
const theme = useTheme()
const clean = (values ?? []).filter((v) => Number.isFinite(v))
if (clean.length < 2) return null
const stroke = color ?? tone(theme, 'color11', 'currentColor')
const max = Math.max(...clean)
const min = Math.min(...clean)
const span = max - min || 1
const n = clean.length
const px = (i: number) => (i / (n - 1)) * (width - 2) + 1
const py = (v: number) => height - 2 - ((v - min) / span) * (height - 4)
const line = clean.map((v, i) => `${i === 0 ? 'M' : 'L'}${px(i).toFixed(1)},${py(v).toFixed(1)}`).join(' ')
const area = `${line} L${(width - 1).toFixed(1)},${height} L1,${height} Z`
return (
<svg width={width} height={height} aria-hidden style={{ display: 'block' }}>
<path d={area} fill={stroke} fillOpacity={0.12} />
<path d={line} fill="none" stroke={stroke} strokeWidth={1.5} strokeLinejoin="round" strokeLinecap="round" />
</svg>
)
}
// ── Shared hover read-out (LineChart / BarChart) ─────────────────────────────
function Tooltip({ x, height, label, value }: { x: number; height: number; label: string; value: string }) {
// Keep the box on-screen near the guide line.
const left = Math.max(0, x - 60)
return (
<div
style={{
position: 'absolute',
left,
top: 0,
height,
pointerEvents: 'none',
display: 'flex',
alignItems: 'flex-start',
}}
>
<div
style={{
background: 'rgba(15,17,21,0.94)',
border: '1px solid rgba(148,163,184,0.25)',
borderRadius: 8,
padding: '6px 9px',
fontSize: 11,
lineHeight: 1.35,
color: '#e2e8f0',
whiteSpace: 'nowrap',
boxShadow: '0 4px 16px rgba(0,0,0,0.4)',
}}
>
<div style={{ color: AXIS }}>{label}</div>
<div style={{ fontWeight: 700 }}>{value}</div>
</div>
</div>
)
}
function nearestIndex(clientX: number, rect: DOMRect, padL: number, innerW: number, n: number): number {
if (n <= 1) return 0
const mx = clientX - rect.left - padL
const i = Math.round((mx / (innerW || 1)) * (n - 1))
return Math.max(0, Math.min(n - 1, i))
}
/** Pick a few evenly-spaced x labels (first/mid/last) to avoid crowding. */
function axisTicks(data: ChartPoint[]): { i: number; label: string; anchor: 'start' | 'middle' | 'end' }[] {
const n = data.length
if (n === 0) return []
if (n === 1) return [{ i: 0, label: data[0].label, anchor: 'middle' }]
const mid = Math.floor((n - 1) / 2)
const out: { i: number; label: string; anchor: 'start' | 'middle' | 'end' }[] = [
{ i: 0, label: data[0].label, anchor: 'start' },
{ i: n - 1, label: data[n - 1].label, anchor: 'end' },
]
if (n >= 5) out.splice(1, 0, { i: mid, label: data[mid].label, anchor: 'middle' })
return out
}
// ── Line chart (a value over time) ───────────────────────────────────────────
export function LineChart({
data,
height = 210,
color = ACCENT,
formatValue = (v) => String(v),
}: {
data: ChartPoint[]
height?: number
color?: string
formatValue?: (v: number) => string
}) {
const { ref, w } = useContainerWidth()
const [hover, setHover] = useState<number | null>(null)
const padL = 10
const padR = 10
const padT = 12
const padB = 24
const n = data.length
const innerW = Math.max(1, w - padL - padR)
const innerH = Math.max(1, height - padT - padB)
const max = Math.max(...data.map((d) => d.value), 1)
const x = (i: number) => padL + (n <= 1 ? innerW / 2 : (i / (n - 1)) * innerW)
const y = (v: number) => padT + innerH - (v / max) * innerH
const line = data.map((d, i) => `${i === 0 ? 'M' : 'L'}${x(i).toFixed(1)},${y(d.value).toFixed(1)}`).join(' ')
const area = n >= 2 ? `${line} L${x(n - 1).toFixed(1)},${(padT + innerH).toFixed(1)} L${x(0).toFixed(1)},${(padT + innerH).toFixed(1)} Z` : ''
const ticks = axisTicks(data)
return (
<div ref={ref} style={{ position: 'relative', width: '100%' }}>
{w > 0 ? (
<svg
width={w}
height={height}
style={{ display: 'block' }}
onMouseMove={(e) => setHover(nearestIndex(e.clientX, e.currentTarget.getBoundingClientRect(), padL, innerW, n))}
onMouseLeave={() => setHover(null)}
>
{[0, 0.5, 1].map((g) => (
<line key={g} x1={padL} x2={w - padR} y1={padT + innerH * g} y2={padT + innerH * g} stroke={GRID} strokeWidth={1} />
))}
{area ? <path d={area} fill={color} fillOpacity={0.1} /> : null}
{n >= 2 ? <path d={line} fill="none" stroke={color} strokeWidth={2} strokeLinejoin="round" strokeLinecap="round" /> : null}
{hover != null && data[hover] ? (
<>
<line x1={x(hover)} x2={x(hover)} y1={padT} y2={padT + innerH} stroke={AXIS} strokeWidth={1} strokeDasharray="3 3" />
<circle cx={x(hover)} cy={y(data[hover].value)} r={4} fill={color} stroke="#0f1115" strokeWidth={2} />
</>
) : null}
{ticks.map((t) => (
<text key={t.i} x={x(t.i)} y={height - 7} fontSize={10} fill={AXIS} textAnchor={t.anchor}>
{t.label}
</text>
))}
</svg>
) : null}
{hover != null && data[hover] ? (
<Tooltip x={x(hover)} height={height} label={data[hover].label} value={formatValue(data[hover].value)} />
) : null}
</div>
)
}
// ── Bar chart (a value over time, vertical bars) ─────────────────────────────
export function BarChart({
data,
height = 210,
color = ACCENT,
formatValue = (v) => String(v),
}: {
data: ChartPoint[]
height?: number
color?: string
formatValue?: (v: number) => string
}) {
const { ref, w } = useContainerWidth()
const [hover, setHover] = useState<number | null>(null)
const padL = 10
const padR = 10
const padT = 12
const padB = 24
const n = data.length
const innerW = Math.max(1, w - padL - padR)
const innerH = Math.max(1, height - padT - padB)
const max = Math.max(...data.map((d) => d.value), 1)
const slot = innerW / Math.max(1, n)
const barW = Math.max(1, Math.min(slot * 0.62, 36))
const cx = (i: number) => padL + slot * i + slot / 2
const ticks = axisTicks(data)
return (
<div ref={ref} style={{ position: 'relative', width: '100%' }}>
{w > 0 ? (
<svg
width={w}
height={height}
style={{ display: 'block' }}
onMouseMove={(e) => {
const rect = e.currentTarget.getBoundingClientRect()
const i = Math.floor((e.clientX - rect.left - padL) / (slot || 1))
setHover(Math.max(0, Math.min(n - 1, i)))
}}
onMouseLeave={() => setHover(null)}
>
{[0, 0.5, 1].map((g) => (
<line key={g} x1={padL} x2={w - padR} y1={padT + innerH * g} y2={padT + innerH * g} stroke={GRID} strokeWidth={1} />
))}
{data.map((d, i) => {
const h = (d.value / max) * innerH
return (
<rect
key={i}
x={cx(i) - barW / 2}
y={padT + innerH - h}
width={barW}
height={Math.max(0, h)}
rx={Math.min(3, barW / 2)}
fill={color}
fillOpacity={hover === i ? 1 : 0.78}
/>
)
})}
{ticks.map((t) => (
<text key={t.i} x={cx(t.i)} y={height - 7} fontSize={10} fill={AXIS} textAnchor="middle">
{t.label}
</text>
))}
</svg>
) : null}
{hover != null && data[hover] ? (
<Tooltip x={cx(hover)} height={height} label={data[hover].label} value={formatValue(data[hover].value)} />
) : null}
</div>
)
}
// ── Donut (categorical share) ────────────────────────────────────────────────
export function Donut({
slices,
size = 160,
thickness = 22,
center,
legend = false,
}: {
slices: Slice[]
size?: number
thickness?: number
/** Rendered, centered, inside the ring (a number, a label+sub, anything). */
center?: ReactNode
/**
* Render a built-in label/percentage legend beside the ring. Off by default so
* a consumer can compose a richer legend (amounts, click targets) itself.
*/
legend?: boolean
}) {
const theme = useTheme()
const fg = tone(theme, 'color12', 'currentColor')
const positive = slices.filter((s) => s.value > 0)
const total = positive.reduce((sum, s) => sum + s.value, 0)
const r = size / 2
const rInner = r - thickness
const mid = (r + rInner) / 2
if (total <= 0) {
return (
<XStack items="center" justify="center" height={size}>
<Text fontSize="$6" color="$color10">
</Text>
</XStack>
)
}
const ring = (
<div style={{ position: 'relative', width: size, height: size }}>
<svg width={size} height={size} style={{ display: 'block' }}>
{positive.length === 1 ? (
<circle
cx={r}
cy={r}
r={mid}
fill="none"
stroke={positive[0].color ?? fg}
strokeOpacity={positive[0].color ? 1 : 0.9}
strokeWidth={thickness}
/>
) : (
renderArcs(positive, total, r, rInner, fg)
)}
</svg>
{center ? (
<div
style={{
position: 'absolute',
inset: 0,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
textAlign: 'center',
pointerEvents: 'none',
}}
>
{center}
</div>
) : null}
</div>
)
if (!legend) return ring
return (
<XStack gap="$4" items="center" flexWrap="wrap">
{ring}
<YStack gap="$1.5" flex={1} minW={150}>
{positive.map((s, i) => (
<XStack key={s.label} gap="$2" items="center">
<div
style={{
width: 10,
height: 10,
borderRadius: 2,
backgroundColor: s.color ?? AXIS,
opacity: s.color ? 1 : rampOpacity(i, positive.length),
}}
/>
<Text fontSize="$2" color="$color11" flex={1} numberOfLines={1}>
{s.label}
</Text>
<Text fontSize="$2" color="$color12" fontWeight="600">
{Math.round((s.value / total) * 100)}%
</Text>
</XStack>
))}
</YStack>
</XStack>
)
}
function renderArcs(slices: Slice[], total: number, rOuter: number, rInner: number, fg: string): ReactNode {
let a0 = -Math.PI / 2
const paths: ReactNode[] = []
slices.forEach((s, idx) => {
const frac = Math.max(0, s.value) / total
if (frac <= 0) return
const a1 = a0 + frac * Math.PI * 2
paths.push(
<path
key={s.label || idx}
d={annularArc(rOuter, rOuter, rOuter, rInner, a0, Math.min(a1, a0 + Math.PI * 1.9999))}
fill={s.color ?? fg}
fillOpacity={s.color ? 1 : rampOpacity(idx, slices.length)}
/>,
)
a0 = a1
})
return <>{paths}</>
}
function polar(cx: number, cy: number, rad: number, a: number): [number, number] {
return [cx + rad * Math.cos(a), cy + rad * Math.sin(a)]
}
function annularArc(cx: number, cy: number, rOuter: number, rInner: number, a0: number, a1: number): string {
const large = a1 - a0 > Math.PI ? 1 : 0
const [x0, y0] = polar(cx, cy, rOuter, a0)
const [x1, y1] = polar(cx, cy, rOuter, a1)
const [x2, y2] = polar(cx, cy, rInner, a1)
const [x3, y3] = polar(cx, cy, rInner, a0)
return `M${x0},${y0} A${rOuter},${rOuter} 0 ${large} 1 ${x1},${y1} L${x2},${y2} A${rInner},${rInner} 0 ${large} 0 ${x3},${y3} Z`
}
// ── Bar rows (a ranked distribution, horizontal, token-native) ───────────────
export function BarRows({ bars }: { bars: Slice[] }) {
const max = Math.max(0, ...bars.map((b) => b.value))
if (max <= 0) {
return (
<Text fontSize="$3" color="$color10">
</Text>
)
}
return (
<YStack gap="$2.5">
{bars.map((b) => (
<XStack key={b.label} gap="$3" items="center">
<Text width={64} fontSize="$2" color="$color11" text="right">
{b.label}
</Text>
<YStack flex={1} height={10} bg="$color3" rounded="$2" overflow="hidden">
<YStack height={10} width={`${Math.max(2, (b.value / max) * 100)}%`} bg="$color9" rounded="$2" />
</YStack>
<Text width={56} fontSize="$2" color="$color12" fontWeight="600">
{b.value.toLocaleString()}
</Text>
</XStack>
))}
</YStack>
)
}
-147
View File
@@ -1,147 +0,0 @@
'use client'
/**
* ComboBox — a typeable select: a text input the user can type any value into,
* PLUS a popover of LIVE options (filtered by what's typed) they can pick from.
* The value is always exactly the input text, so a custom id is inherently
* supported — selecting an option just fills the input. This is the ONE way the
* console offers "pick from a live list OR type your own" (the model field, the
* tool field). Prop-driven + self-contained (options/loading/error injected by the
* caller), so it is orthogonal to the data source and lifts into `@hanzo/ui`.
*
* Idiom: the same @hanzo/gui Popover shell as OrgSwitcher/SelectMenu (bordered,
* elevate, `$color2`). Options render in a portal above the DetailPane SlideOver.
*/
import { useMemo, useState } from 'react'
import { Button, Input, Popover, Spinner, Text, XStack, YStack } from '@hanzo/gui'
import { Check, ChevronDown, RefreshCw } from '@hanzogui/lucide-icons-2'
import { filterOptions, type ComboOption } from './combobox/filter'
export type { ComboOption } from './combobox/filter'
export function ComboBox({
value,
onChange,
options,
loading = false,
error = null,
onRetry,
placeholder,
disabled,
emptyText = 'No matches — press to use what you typed.',
minWidth = 240,
}: {
value: string
onChange: (v: string) => void
/** The live option list. Filtered against the typed text. */
options: ComboOption[]
/** True while the caller is loading the option list (shows a spinner row). */
loading?: boolean
/** A human message when the option list failed to load (list still typeable). */
error?: string | null
/** Retry the option load (shown next to the error). */
onRetry?: () => void
placeholder?: string
disabled?: boolean
/** Row shown when the filter matches nothing (the typed value is still usable). */
emptyText?: string
minWidth?: number
}) {
const [open, setOpen] = useState(false)
const filtered = useMemo(() => filterOptions(options, value), [options, value])
const pick = (v: string) => {
onChange(v)
setOpen(false)
}
return (
<Popover open={open} onOpenChange={setOpen} placement="bottom-start" allowFlip>
<XStack items="center" gap="$2" minW={minWidth}>
<Input
flex={1}
value={value}
onChangeText={(v) => {
onChange(v)
if (!open) setOpen(true)
}}
onFocus={() => setOpen(true)}
disabled={disabled}
placeholder={placeholder}
autoCapitalize="none"
/>
<Popover.Trigger asChild>
<Button
size="$2"
chromeless
disabled={disabled}
icon={<ChevronDown size={16} opacity={0.7} />}
onPress={() => setOpen((o) => !o)}
aria-label="Show options"
/>
</Popover.Trigger>
</XStack>
<Popover.Content bordered elevate p="$1.5" minW={minWidth} bg="$color2" borderColor="$borderColor">
<YStack gap="$0.5" minW={minWidth} maxH={300} overflow="scroll">
{loading ? (
<XStack items="center" gap="$2" px="$2.5" py="$2">
<Spinner size="small" color="$color11" />
<Text fontSize="$2" color="$color10">
Loading options
</Text>
</XStack>
) : error ? (
<XStack items="center" gap="$2" px="$2.5" py="$2">
<Text fontSize="$2" color="$color10" flex={1} numberOfLines={2}>
{error}
</Text>
{onRetry ? (
<Button size="$1" chromeless icon={<RefreshCw size={12} />} onPress={onRetry} aria-label="Retry" />
) : null}
</XStack>
) : filtered.length === 0 ? (
<Text fontSize="$2" color="$color10" px="$2.5" py="$2">
{emptyText}
</Text>
) : (
filtered.map((o) => (
<Row key={o.value} option={o} active={o.value === value} onPress={() => pick(o.value)} />
))
)}
</YStack>
</Popover.Content>
</Popover>
)
}
function Row({ option, active, onPress }: { option: ComboOption; active: boolean; onPress: () => void }) {
return (
<XStack
items="center"
gap="$2"
px="$2.5"
py="$1.5"
rounded="$3"
cursor="pointer"
hoverStyle={{ bg: '$color4' }}
bg={active ? '$color4' : 'transparent'}
onPress={onPress}
>
<XStack width={14} items="center" justify="center">
{active ? <Check size={13} /> : null}
</XStack>
<YStack flex={1} minW={0}>
<Text fontSize="$2" color="$color12" numberOfLines={1}>
{option.label ?? option.value}
</Text>
{option.hint ? (
<Text fontSize="$1" color="$color10" numberOfLines={1}>
{option.hint}
</Text>
) : null}
</YStack>
</XStack>
)
}
-99
View File
@@ -1,99 +0,0 @@
'use client'
/**
* Generic data table — the shared list primitive for every product module.
*
* Rows are typed `<T>`; columns declare a key, header, and optional cell
* renderer. Built on Hanzo GUI Stacks (shorthand style props per the v5 config)
* so it adapts across platforms. Loading and empty states are first-class.
*/
import type { ReactNode } from 'react'
import { Spinner, Text, XStack, YStack } from '@hanzo/gui'
export type Column<T> = {
key: string
header: string
/** Cell renderer; defaults to `String(row[key])`. */
render?: (row: T) => ReactNode
/** Fixed width (px) for the column; otherwise flexes. */
width?: number
}
export function DataTable<T>({
columns,
rows,
loading,
empty = 'Nothing here yet.',
rowKey,
onRowPress,
}: {
columns: Column<T>[]
rows: T[]
loading?: boolean
empty?: string
rowKey: (row: T) => string
onRowPress?: (row: T) => void
}) {
if (loading) {
return (
<XStack p="$6" justify="center">
<Spinner size="large" color="$color11" />
</XStack>
)
}
if (rows.length === 0) {
return (
<YStack p="$6" items="center" borderWidth={1} borderColor="$borderColor" rounded="$4">
<Text color="$color11">{empty}</Text>
</YStack>
)
}
return (
<YStack borderWidth={1} borderColor="$borderColor" rounded="$4" overflow="hidden">
<XStack bg="$color2" py="$2.5" px="$3" gap="$3">
{columns.map((c) => (
<Text
key={c.key}
width={c.width}
flex={c.width ? undefined : 1}
fontSize="$2"
fontWeight="700"
color="$color11"
>
{c.header}
</Text>
))}
</XStack>
<YStack>
{rows.map((row) => (
<XStack
key={rowKey(row)}
py="$2.5"
px="$3"
gap="$3"
borderTopWidth={1}
borderColor="$borderColor"
items="center"
hoverStyle={onRowPress ? { bg: '$color2' } : undefined}
cursor={onRowPress ? 'pointer' : undefined}
onPress={onRowPress ? () => onRowPress(row) : undefined}
>
{columns.map((c) => (
<YStack key={c.key} width={c.width} flex={c.width ? undefined : 1}>
{c.render ? (
c.render(row)
) : (
<Text fontSize="$3" numberOfLines={1}>
{String((row as Record<string, unknown>)[c.key] ?? '')}
</Text>
)}
</YStack>
))}
</XStack>
))}
</YStack>
</YStack>
)
}
-83
View File
@@ -1,83 +0,0 @@
'use client'
/**
* Donut — a tiny dependency-free SVG ring for a categorical breakdown
* (e.g. machines by status). Standard library first: no chart dependency for a
* single ring. It renders ONLY the real segments it is given and returns `null`
* when the total is zero, so the caller shows an honest empty state instead of an
* empty circle. Purely presentational — all values come from the caller.
*/
export type DonutSegment = { label: string; value: number; color: string }
export function Donut({
segments,
size = 160,
thickness = 18,
centerValue,
centerLabel,
trackColor = '#2a2d2e',
valueColor = '#ecedee',
labelColor = '#9ba1a6',
}: {
segments: DonutSegment[]
size?: number
thickness?: number
/** Big number in the middle (e.g. total). */
centerValue?: string
/** Caption under the center value. */
centerLabel?: string
/** Unfilled-ring color (explicit hex so it never depends on theme var names). */
trackColor?: string
valueColor?: string
labelColor?: string
}) {
const total = segments.reduce((n, s) => n + Math.max(0, s.value), 0)
if (total <= 0) return null
const r = (size - thickness) / 2
const cx = size / 2
const cy = size / 2
const circ = 2 * Math.PI * r
let offset = 0
const arcs = segments
.filter((s) => s.value > 0)
.map((s) => {
const frac = s.value / total
const dash = frac * circ
const node = (
<circle
key={s.label}
cx={cx}
cy={cy}
r={r}
fill="none"
stroke={s.color}
strokeWidth={thickness}
strokeDasharray={`${dash} ${circ - dash}`}
strokeDashoffset={-offset}
/>
)
offset += dash
return node
})
return (
<svg width={size} height={size} viewBox={`0 0 ${size} ${size}`} role="img" aria-label="Donut chart">
{/* track */}
<circle cx={cx} cy={cy} r={r} fill="none" stroke={trackColor} strokeWidth={thickness} />
{/* segments, drawn from 12 o'clock */}
<g transform={`rotate(-90 ${cx} ${cy})`}>{arcs}</g>
{centerValue ? (
<text x={cx} y={cy - 2} textAnchor="middle" fontSize={size * 0.2} fontWeight="800" fill={valueColor}>
{centerValue}
</text>
) : null}
{centerLabel ? (
<text x={cx} y={cy + size * 0.13} textAnchor="middle" fontSize={size * 0.08} fill={labelColor}>
{centerLabel}
</text>
) : null}
</svg>
)
}
-129
View File
@@ -1,129 +0,0 @@
'use client'
/**
* Rich empty state — the ONE way the console turns a real, honest "nothing here
* yet" into a helpful onboarding moment (DigitalOcean / Vercel class) instead of
* a dead screen.
*
* It is NOT a fabricated-data fallback: callers render it only when a load
* genuinely succeeded with zero rows (or a resource has no surface yet). It shows
* the product icon, a one-line "what this is", a couple of guidance bullets, a
* single primary call-to-action (Deploy / Create / Connect …) and an optional
* secondary link (Docs / CLI). Orthogonal to `BackendStateCard` (failures) and
* `DataTable`'s thin inline empty (in-table) — this is the full-bleed first-run
* surface a module shows in place of its table.
*/
import type { ReactElement, ReactNode } from 'react'
import { Button, Card, Text, XStack, YStack } from '@hanzo/gui'
import { ArrowRight, Check, ExternalLink } from '@hanzogui/lucide-icons-2'
import type { IconLike } from './color'
import { PrimaryButton } from './PrimaryButton'
/** A single call-to-action — primary (filled) or a secondary/external link. */
export type EmptyAction = {
label: string
/** In-app navigation / handler. */
onPress?: () => void
/** External URL — opens in a new tab with an external-link affordance. */
href?: string
icon?: ReactElement
}
/** Open an external URL in a new tab (no-opener), guarded for SSR. */
function openHref(href: string): void {
if (typeof window !== 'undefined') window.open(href, '_blank', 'noopener')
}
export function EmptyState({
icon: Icon,
title,
description,
bullets,
primary,
secondary,
}: {
/** Product/section icon — any lucide/Gui icon component (size + optional color). */
icon: IconLike
title: string
description: string
/** Optional "here's how to get started" guidance bullets. */
bullets?: string[]
/** The single high-emphasis action (Deploy / Create / Connect). */
primary?: EmptyAction
/** An optional lower-emphasis action (Docs / CLI / Learn more). */
secondary?: EmptyAction
}) {
return (
<Card
borderWidth={1}
borderColor="$borderColor"
borderStyle="dashed"
p="$6"
gap="$4"
items="center"
maxWidth={640}
self="center"
width="100%"
>
<YStack
width={48}
height={48}
items="center"
justify="center"
rounded="$4"
bg="$color3"
>
<Icon size={24} />
</YStack>
<YStack gap="$2" items="center" maxW={480}>
<Text fontSize="$6" fontWeight="800" text="center">
{title}
</Text>
<Text fontSize="$3" color="$color11" text="center">
{description}
</Text>
</YStack>
{bullets && bullets.length ? (
<YStack gap="$2" self="stretch" maxW={420} mx="auto">
{bullets.map((b) => (
<XStack key={b} gap="$2" items="flex-start">
<YStack pt="$1">
<Check size={14} color="$color10" />
</YStack>
<Text fontSize="$3" color="$color11" flex={1}>
{b}
</Text>
</XStack>
))}
</YStack>
) : null}
{primary || secondary ? (
<XStack gap="$2" items="center" flexWrap="wrap" justify="center">
{primary ? (
<PrimaryButton
icon={primary.icon}
iconAfter={primary.href ? <ExternalLink size={15} /> : <ArrowRight size={15} />}
onPress={() => (primary.href ? openHref(primary.href) : primary.onPress?.())}
>
{primary.label}
</PrimaryButton>
) : null}
{secondary ? (
<Button
chromeless
icon={secondary.icon}
iconAfter={secondary.href ? <ExternalLink size={14} /> : undefined}
onPress={() => (secondary.href ? openHref(secondary.href) : secondary.onPress?.())}
>
{secondary.label}
</Button>
) : null}
</XStack>
) : null}
</Card>
)
}
-38
View File
@@ -1,38 +0,0 @@
'use client'
/**
* FadeIn — the ONE entrance animation for the console.
*
* A thin wrapper that applies the shared `.hz-fade-up` keyframe (defined once in
* globals.css) plus an optional stagger delay, so sections and cards rise in the
* way the hanzo.ai marketing surface does. Framework-free (no animation driver,
* no JS observers) so it works under static export and honors
* `prefers-reduced-motion` (handled in the CSS). Pass `index` to stagger a list
* (delay = index * step), or `delayMs` for an explicit delay.
*/
import type { CSSProperties, ReactNode } from 'react'
export function FadeIn({
children,
index = 0,
step = 50,
delayMs,
style,
}: {
children: ReactNode
/** Position in a list — staggers the entrance by `index * step` ms. */
index?: number
/** Per-index stagger in ms (default 50). */
step?: number
/** Explicit delay in ms; overrides the index-based stagger. */
delayMs?: number
/** Extra styles for the wrapper (e.g. layout/centering). */
style?: CSSProperties
}) {
const delay = delayMs ?? index * step
return (
<div className="hz-fade-up" style={{ animationDelay: `${delay}ms`, ...style }}>
{children}
</div>
)
}
-160
View File
@@ -1,160 +0,0 @@
'use client'
/**
* Form field primitives — labeled rows over Hanzo GUI inputs.
*
* One way to render a labeled control. Every editor field goes through these, so
* spacing, disabled styling, and the label column stay consistent. Style props
* use the v5 shorthand set (p/px/items/justify/...).
*/
import type { ReactNode } from 'react'
import {
Input,
Label,
Select,
Switch,
TextArea,
Slider,
Text,
XStack,
YStack,
} from '@hanzo/gui'
import { ChevronDown } from '@hanzogui/lucide-icons-2'
/** Labeled row: fixed label column + flexing control. */
export function FieldRow({ label, children }: { label: string; children: ReactNode }) {
return (
<XStack gap="$3" items="flex-start" flexWrap="wrap">
<Label width={180} pt="$2" color="$color11" fontSize="$3">
{label}
</Label>
<YStack flex={1} minW={240}>
{children}
</YStack>
</XStack>
)
}
export function FieldText({
value,
onChange,
disabled,
secure,
placeholder,
}: {
value: string
onChange: (v: string) => void
disabled?: boolean
secure?: boolean
placeholder?: string
}) {
return (
<Input
value={value}
onChangeText={onChange}
disabled={disabled}
secureTextEntry={secure}
placeholder={placeholder}
autoCapitalize="none"
/>
)
}
export function FieldTextArea({
value,
onChange,
disabled,
rows = 6,
}: {
value: string
onChange: (v: string) => void
disabled?: boolean
rows?: number
}) {
return (
<TextArea value={value} onChangeText={onChange} disabled={disabled} numberOfLines={rows} />
)
}
export function FieldSwitch({
checked,
onChange,
disabled,
}: {
checked: boolean
onChange: (v: boolean) => void
disabled?: boolean
}) {
return (
<Switch checked={checked} onCheckedChange={onChange} disabled={disabled} size="$3">
<Switch.Thumb />
</Switch>
)
}
export function FieldSelect({
value,
options,
onChange,
disabled,
}: {
value: string
options: string[]
onChange: (v: string) => void
disabled?: boolean
}) {
return (
<Select value={value} onValueChange={onChange} disablePreventBodyScroll native>
<Select.Trigger disabled={disabled} iconAfter={<ChevronDown size={16} />}>
<Select.Value placeholder="Select…" />
</Select.Trigger>
<Select.Content>
<Select.Viewport>
{options.map((opt, i) => (
<Select.Item key={opt} index={i} value={opt}>
<Select.ItemText>{opt}</Select.ItemText>
</Select.Item>
))}
</Select.Viewport>
</Select.Content>
</Select>
)
}
export function FieldSlider({
value,
min,
max,
step,
onChange,
disabled,
}: {
value: number
min: number
max: number
step: number
onChange: (v: number) => void
disabled?: boolean
}) {
return (
<XStack gap="$3" items="center">
<Text width={56} fontSize="$3" color="$color11">
{value}
</Text>
<Slider
flex={1}
min={min}
max={max}
step={step}
value={[value]}
onValueChange={(v) => onChange(v[0] ?? min)}
disabled={disabled}
>
<Slider.Track>
<Slider.TrackActive />
</Slider.Track>
<Slider.Thumb index={0} circular />
</Slider>
</XStack>
)
}
-28
View File
@@ -1,28 +0,0 @@
'use client'
/**
* The Hanzo "H" mark — the canonical brand glyph (same geometry as app/icon.svg).
*
* Rendered as inline SVG with `fill: currentColor`, so it inherits the
* surrounding text color and adapts to the dark/light theme with no per-theme
* asset. ONE source for the mark across the chrome (sidebar + header).
*/
export function HanzoMark({ size = 22, color = 'currentColor' }: { size?: number; color?: string }) {
return (
<svg
width={size}
height={size}
viewBox="0 0 67 67"
role="img"
aria-label="Hanzo"
fill={color}
style={{ display: 'block', flexShrink: 0 }}
>
<path d="M22.21 67V44.6369H0V67H22.21Z" />
<path d="M66.7038 22.3184H22.2534L0.0878906 44.6367H44.4634L66.7038 22.3184Z" />
<path d="M22.21 0H0V22.3184H22.21V0Z" />
<path d="M66.7198 0H44.5098V22.3184H66.7198V0Z" />
<path d="M66.7198 67V44.6369H44.5098V67H66.7198Z" />
</svg>
)
}
-253
View File
@@ -1,253 +0,0 @@
'use client'
/**
* Console metric primitives — the ONE shared set of stat tiles, panels, legend
* rows, util bars, and "real action or honest-disabled" buttons that every
* resource console (GPUs, Kubernetes, Containers, Training, Tasks) renders.
*
* These were first written for the GPU surface; promoted here so a sibling
* product never reaches into `gpus/` for shell chrome (separation of concerns).
* `gpus/charts.tsx` now re-exports them, so there is exactly one definition.
*
* Honest by construction: a `MetricCard` shows the pre-formatted `value` the
* caller passes (an absent metric is an em-dash, never a fabricated number); a
* `Sparkline`/`MiniBars` with no real series renders nothing; a `HintButton`
* with no `onPress` is visibly disabled with a tooltip saying WHY. Inline SVG is
* the house pattern (see ui/Loader, ui/HanzoMark); style props use the v5
* shorthand set.
*/
import type { ReactElement, ReactNode } from 'react'
import { Button, Card, Text, XStack, YStack } from '@hanzo/gui'
/** Distinct, theme-neutral series colors (mid-saturation reads on dark + light). */
export const SERIES = ['#6ea8fe', '#7ee787', '#f0a868', '#c792ea', '#56d4c4', '#e879a6', '#d6c15a', '#8b9bb4'] as const
export const colorForIndex = (i: number): string => SERIES[i % SERIES.length]
const TRACK = 'rgba(128,128,128,0.18)'
/** Tone for a utilization/temperature value (green calm → red hot). */
export function utilColor(pct: number): string {
if (pct >= 90) return '#e5534b'
if (pct >= 70) return '#f0a868'
return '#7ee787'
}
/**
* A single-series sparkline over real points. Renders nothing meaningful for <2
* points (the caller shows `—` instead). The y-range is the data's own min/max.
*/
export function Sparkline({
points,
width = 104,
height = 30,
color = SERIES[0],
}: {
points: number[]
width?: number
height?: number
color?: string
}): ReactElement | null {
if (!points || points.length < 2) return null
const min = Math.min(...points)
const max = Math.max(...points)
const span = max - min || 1
const stepX = width / (points.length - 1)
const y = (v: number) => height - 2 - ((v - min) / span) * (height - 4)
const d = points.map((p, i) => `${i === 0 ? 'M' : 'L'}${(i * stepX).toFixed(1)},${y(p).toFixed(1)}`).join(' ')
return (
<svg width={width} height={height} viewBox={`0 0 ${width} ${height}`} role="img" aria-label="trend">
<path d={d} fill="none" stroke={color} strokeWidth={1.5} strokeLinejoin="round" strokeLinecap="round" />
</svg>
)
}
/** A small vertical-bar chart (e.g. cost per day). One bar per real datum. */
export function MiniBars({
bars,
width = 320,
height = 96,
color = SERIES[0],
}: {
bars: { label: string; value: number }[]
width?: number
height?: number
color?: string
}): ReactElement | null {
if (!bars.length) return null
const max = Math.max(...bars.map((b) => b.value), 1)
const gap = 4
const bw = (width - gap * (bars.length - 1)) / bars.length
return (
<svg width="100%" height={height} viewBox={`0 0 ${width} ${height}`} preserveAspectRatio="none" role="img" aria-label="bars">
{bars.map((b, i) => {
const h = Math.max(1, (b.value / max) * (height - 2))
return (
<rect key={b.label + i} x={i * (bw + gap)} y={height - h} width={bw} height={h} rx={2} fill={color} opacity={0.85}>
<title>{`${b.label}: ${b.value}`}</title>
</rect>
)
})}
</svg>
)
}
/**
* A thin horizontal bar (0100). By default the fill tones by value (green→red,
* for utilization/temperature). Pass a fixed `color` for a neutral meter like
* training PROGRESS, where a high value is good, not "hot".
*/
export function UtilBar({ value, width = 120, color }: { value: number; width?: number; color?: string }): ReactElement {
const v = Math.max(0, Math.min(100, value))
return (
<svg width={width} height={8} viewBox={`0 0 ${width} 8`} role="img" aria-label={`${Math.round(v)}%`}>
<rect x={0} y={1} width={width} height={6} rx={3} fill={TRACK} />
<rect x={0} y={1} width={(v / 100) * width} height={6} rx={3} fill={color ?? utilColor(v)} />
</svg>
)
}
/** A legend row: color swatch + label + value. */
export function LegendDot({ color, label, value }: { color: string; label: string; value?: ReactNode }) {
return (
<XStack items="center" gap="$2" justify="space-between">
<XStack items="center" gap="$2">
<YStack width={10} height={10} rounded="$1" bg={color as never} />
<Text fontSize="$2" color="$color11">
{label}
</Text>
</XStack>
{value != null ? (
<Text fontSize="$2" color="$color12" fontWeight="600">
{value}
</Text>
) : null}
</XStack>
)
}
/**
* One overview metric tile. `value` is pre-formatted by the caller (so an absent
* metric is an honest `—`, never a fabricated number). The sparkline only appears
* when the caller has a REAL series; the delta only when a real comparison exists.
*/
export function MetricCard({
icon,
label,
value,
caption,
spark,
sparkColor,
delta,
}: {
icon: ReactElement
label: string
value: string
caption?: string
spark?: number[]
sparkColor?: string
delta?: { pct: number }
}) {
const up = delta ? delta.pct >= 0 : false
return (
<Card p="$3.5" gap="$2" borderWidth={1} borderColor="$borderColor" flex={1} minW={172}>
<XStack items="center" gap="$2" justify="space-between">
<XStack items="center" gap="$2">
{icon}
<Text fontSize="$2" color="$color11" fontWeight="600">
{label}
</Text>
</XStack>
{delta ? (
<Text fontSize="$1" color={up ? '#7ee787' : '#e5534b'}>
{up ? '▲' : '▼'} {Math.abs(delta.pct)}%
</Text>
) : null}
</XStack>
<XStack items="flex-end" justify="space-between" gap="$2">
<Text fontSize="$8" fontWeight="900" color="$color12" numberOfLines={1}>
{value}
</Text>
{spark && spark.length >= 2 ? <Sparkline points={spark} color={sparkColor ?? SERIES[0]} /> : null}
</XStack>
{caption ? (
<Text fontSize="$1" color="$color10" numberOfLines={1}>
{caption}
</Text>
) : null}
</Card>
)
}
/**
* A button that is either a real action (onPress) or HONESTLY disabled with a
* native tooltip explaining why (the action has no backend yet). One way to
* render the "real route or honest-disabled" affordance the consoles use.
*/
export function HintButton({
icon,
iconAfter,
children,
onPress,
disabled,
hint,
theme,
}: {
icon?: ReactElement
iconAfter?: ReactElement
children: ReactNode
onPress?: () => void
disabled?: boolean
/** Native tooltip text (shown on hover) — say WHY it is disabled. */
hint?: string
theme?: 'light'
}) {
const btn = (
<Button
size="$2"
theme={theme}
icon={icon}
iconAfter={iconAfter}
disabled={disabled}
onPress={disabled ? undefined : onPress}
>
{children}
</Button>
)
if (!hint) return btn
return (
<span title={hint} style={{ display: 'inline-flex' }}>
{btn}
</span>
)
}
/**
* A titled panel wrapper used across the consoles (chart + list cards). `grow`
* (default true) sets `flex={1}` so panels SHARE width in a ROW; pass `grow={false}`
* inside a vertical rail (a column YStack) so each panel sizes to its content and
* stacks — flex-grow in a column with no fixed height collapses them onto each other.
*/
export function Panel({
title,
right,
children,
minW = 280,
grow = true,
}: {
title: string
right?: ReactNode
children: ReactNode
minW?: number
grow?: boolean
}) {
return (
<Card p="$4" gap="$3" borderWidth={1} borderColor="$borderColor" flex={grow ? 1 : undefined} minW={minW} width={grow ? undefined : '100%'}>
<XStack items="center" justify="space-between" gap="$2">
<Text fontSize="$4" fontWeight="800" color="$color12">
{title}
</Text>
{right}
</XStack>
{children}
</Card>
)
}
-31
View File
@@ -1,31 +0,0 @@
'use client'
import type { ReactNode } from 'react'
import { Text, XStack, YStack } from '@hanzo/gui'
/** Section title + optional subtitle and right-aligned actions. */
export function PageHeader({
title,
subtitle,
actions,
}: {
title: string
subtitle?: string
actions?: ReactNode
}) {
return (
<XStack justify="space-between" items="flex-start" gap="$4">
<YStack gap="$1">
<Text fontSize="$7" fontWeight="800">
{title}
</Text>
{subtitle ? (
<Text fontSize="$3" color="$color11">
{subtitle}
</Text>
) : null}
</YStack>
{actions ? <XStack gap="$2">{actions}</XStack> : null}
</XStack>
)
}
-17
View File
@@ -1,17 +0,0 @@
'use client'
/**
* Primary button — the one white, high-emphasis action for the console.
*
* Monochrome brand: `theme="light"` flips the button to the light theme inside
* the dark console, giving a white fill with a near-black label and icon — no
* hue accent. Use it for the single primary action in a view (sign in, save,
* get started). Secondary and destructive actions use the default neutral
* `Button`.
*/
import type { ComponentProps } from 'react'
import { Button } from '@hanzo/gui'
export function PrimaryButton(props: ComponentProps<typeof Button>) {
return <Button theme="light" {...props} />
}
-61
View File
@@ -1,61 +0,0 @@
'use client'
/**
* ProductIcon — the Linear-style product tile: a rounded-square block filled with
* the product's accent color, with the product glyph knocked out in near-white so
* it reads "cut out" of the color. This is the ONE way a product icon renders in
* the console chrome (sidebar nav, command palette, product overview header) — a
* bare tinted glyph is replaced everywhere by this tile.
*
* Prop-driven and pure: the accent hex is resolved by the caller (registry-curated
* → per-user override → stable hash, via `useProductColors().colorOf`) and passed
* in as `color`, so this component owns only the look, never the palette. With no
* color it falls back to a tasteful neutral chip ($color12 fill + $color1 glyph) —
* never a random hue — matching the first-party `ProviderLogo` mark, so first-party
* marks and product tiles read as one family.
*
* The knock-out is the same technique the color-swatch button uses (a raw-hex
* `backgroundColor` fill + a `#ffffff` glyph) so an arbitrary product accent tints
* cleanly outside Gui's token palette, in the ONE sanctioned place.
*/
import { XStack } from '@hanzo/gui'
import { asColor, type IconLike } from './color'
export function ProductIcon({
icon: Icon,
color,
size = 24,
}: {
icon: IconLike
/** The product's resolved accent hex (e.g. `#5E6AD2`). Omit for the neutral chip. */
color?: string
size?: number
}) {
const radius = Math.round(size * 0.28)
const glyph = Math.round(size * 0.58)
const tinted = typeof color === 'string' && color.trim() !== ''
// Accent tile — product color fill + near-white glyph (the Linear "cut out").
if (tinted) {
return (
<XStack
width={size}
height={size}
items="center"
justify="center"
rounded={radius}
style={{ backgroundColor: color }}
>
<Icon size={glyph} color={asColor('#ffffff')} />
</XStack>
)
}
// No accent — a tasteful neutral chip (consistent with ProviderLogo's mark).
return (
<XStack width={size} height={size} items="center" justify="center" rounded={radius} bg="$color12">
<Icon size={glyph} color="$color1" />
</XStack>
)
}
-137
View File
@@ -1,137 +0,0 @@
'use client'
/**
* ProviderLogo — a small provider/model avatar resolved from a provider NAME
* alone (no external logo URLs, no network). Self-contained and prop-driven so
* it lifts cleanly into `@hanzo/ui` for hanzo.ai / @hanzo/dev / the desktop app.
*
* Resolution order:
* 1. First-party — Zen renders the real ensō mark, Hanzo the real block-H mark
* (same geometry as @zenlm/logo / @hanzo/logo), knocked out of a filled
* rounded tile so our own models read distinctly and on-brand.
* 2. A known provider → its mapped @hanzogui/lucide-icons-2 glyph.
* 3. Otherwise → a stable initials chip (12 letters from the name).
*
* Honest by construction: we never claim an official brand logo we don't ship —
* unknown providers get clean initials, not a guessed icon. Colors are @hanzo/gui
* theme tokens so the mark themes with the shell.
*/
import { Text, XStack, useTheme } from '@hanzo/gui'
import { Boxes, Server, Globe, Cpu } from '@hanzogui/lucide-icons-2'
type IconCmp = typeof Globe
/** Curated, extendable provider→glyph map (lowercased keys). Brand-neutral avatars. */
const KNOWN: Record<string, IconCmp> = {
openrouter: Globe,
nvidia: Cpu,
}
const isFirstParty = (provider: string): boolean => {
const p = provider.trim().toLowerCase()
return p === 'hanzo' || p === 'zen'
}
/** The Zen ensō mark — identical geometry to @zenlm/logo (svg/zen-enso.svg). */
function EnsoMark({ size, color }: { size: number; color: string }) {
return (
<svg width={size} height={size} viewBox="0 0 100 100" aria-hidden="true">
<path d="M66.22 83.26 A37 37 0 1 1 85.57 60.20" fill="none" stroke={color} strokeWidth={11} strokeLinecap="round" />
</svg>
)
}
/** The Hanzo block-H mark — identical geometry to @hanzo/logo (app/icon.svg). */
const HANZO_PATHS = [
'M22.21 67V44.6369H0V67H22.21Z',
'M66.7038 22.3184H22.2534L0.0878906 44.6367H44.4634L66.7038 22.3184Z',
'M22.21 0H0V22.3184H22.21V0Z',
'M66.7198 0H44.5098V22.3184H66.7198V0Z',
'M66.7198 67V44.6369H44.5098V67H66.7198Z',
]
function HanzoHMark({ size, color }: { size: number; color: string }) {
return (
<svg width={size} height={size} viewBox="0 0 67 67" aria-hidden="true">
{HANZO_PATHS.map((d) => (
<path key={d} d={d} fill={color} />
))}
</svg>
)
}
/** 12 uppercase initials from a provider name: words→first letters, else first 2 chars. */
export function providerInitials(provider: string): string {
const name = provider.trim()
if (!name) return '•'
const words = name.split(/[\s/_-]+/).filter(Boolean)
if (words.length >= 2) return (words[0][0] + words[1][0]).toUpperCase()
return name.slice(0, 2).toUpperCase()
}
export function ProviderLogo({ provider, size = 24 }: { provider: string; size?: number }) {
const theme = useTheme()
const radius = Math.round(size * 0.28)
const iconSize = Math.round(size * 0.56)
// First-party (Zen/Hanzo) — the real brand mark knocked out of a filled tile.
if (isFirstParty(provider)) {
const fg = theme.color1?.get() ?? '#000000' // cut-out mark: the tile's contrast color
const markSize = Math.round(size * 0.66)
const isZen = provider.trim().toLowerCase() === 'zen'
return (
<XStack width={size} height={size} items="center" justify="center" rounded={radius} bg="$color12">
{isZen ? <EnsoMark size={markSize} color={fg} /> : <HanzoHMark size={Math.round(size * 0.56)} color={fg} />}
</XStack>
)
}
// A known provider with a curated, brand-neutral glyph.
const Known = KNOWN[provider.trim().toLowerCase()]
if (Known) {
return (
<XStack
width={size}
height={size}
items="center"
justify="center"
rounded={radius}
bg="$color3"
borderWidth={1}
borderColor="$borderColor"
>
<Known size={iconSize} color="$color11" />
</XStack>
)
}
// Otherwise — a clean, stable initials chip (no fabricated brand logo).
return (
<XStack
width={size}
height={size}
items="center"
justify="center"
rounded={radius}
bg="$color3"
borderWidth={1}
borderColor="$borderColor"
>
<Text fontSize={Math.round(size * 0.4)} fontWeight="800" color="$color11">
{providerInitials(provider)}
</Text>
</XStack>
)
}
/** A generic fallback mark for an unspecified provider (used sparingly). */
export function GenericLogo({ size = 24 }: { size?: number }) {
return (
<XStack width={size} height={size} items="center" justify="center" rounded={Math.round(size * 0.28)} bg="$color3" borderWidth={1} borderColor="$borderColor">
<Server size={Math.round(size * 0.56)} color="$color11" />
</XStack>
)
}
// Boxes is re-exported as the conventional "custom model" mark for callers that
// render a non-provider tile next to ProviderLogo (keeps the icon source single).
export { Boxes as CustomModelMark }
-41
View File
@@ -1,41 +0,0 @@
import { describe, expect, it } from 'vitest'
import { rowShift, targetIndex } from './Reorder'
describe('targetIndex', () => {
it('rounds travel to the nearest row and clamps to range', () => {
// rowH=44, from=2, drag down 100px → +2.27 → +2 → index 4
expect(targetIndex(2, 100, 44, 6)).toBe(4)
// drag up 100px → 2 → index 0
expect(targetIndex(2, -100, 44, 6)).toBe(0)
// clamp at the ends
expect(targetIndex(5, 999, 44, 6)).toBe(5)
expect(targetIndex(0, -999, 44, 6)).toBe(0)
// sub-half-row travel stays put
expect(targetIndex(3, 20, 44, 6)).toBe(3)
})
it('is a safe no-op for degenerate inputs', () => {
expect(targetIndex(2, 100, 0, 6)).toBe(2)
expect(targetIndex(2, 100, 44, 0)).toBe(2)
})
})
describe('rowShift — displacement of siblings during a drag', () => {
const rowH = 44
it('the dragged row follows the pointer', () => {
expect(rowShift(2, 2, 4, 90, rowH)).toBe(90)
})
it('rows between from and to (dragging down) move up by one row', () => {
// from=1 → to=3: rows 2,3 shift up
expect(rowShift(2, 1, 3, 90, rowH)).toBe(-rowH)
expect(rowShift(3, 1, 3, 90, rowH)).toBe(-rowH)
expect(rowShift(4, 1, 3, 90, rowH)).toBe(0)
})
it('rows between to and from (dragging up) move down by one row', () => {
// from=4 → to=2: rows 2,3 shift down
expect(rowShift(2, 4, 2, -90, rowH)).toBe(rowH)
expect(rowShift(3, 4, 2, -90, rowH)).toBe(rowH)
expect(rowShift(1, 4, 2, -90, rowH)).toBe(0)
})
})
-110
View File
@@ -1,110 +0,0 @@
'use client'
/**
* Reorder — a tiny pointer-driven drag-to-reorder list. No dependency: it uses
* Pointer Events (so it works with mouse AND touch) and CSS transforms, matching
* the shell's transform-driven motion. Rows are a FIXED height (all pinned rows
* are), which keeps the "where does it drop?" math exact and pure (`targetIndex`)
* and the reflow buttery — the lifted row follows the pointer 1:1 while its
* displaced siblings ease into place.
*
* The list is controlled: `onReorder(from, to)` fires once on drop; the parent
* owns the data (here, the account-backed pins model). `renderItem` gets a
* `handle` to spread on the drag affordance so the rest of the row stays
* interactive (tap to open, etc.).
*/
import { useCallback, useRef, useState, type ReactNode } from 'react'
import { YStack } from '@hanzo/gui'
/** Final index for a drag: start + rounded rows travelled, clamped. PURE. */
export function targetIndex(from: number, dy: number, rowH: number, count: number): number {
if (rowH <= 0 || count <= 0) return from
const raw = from + Math.round(dy / rowH)
return Math.max(0, Math.min(raw, count - 1))
}
/** Vertical shift for row `i` during a drag from `from`→`to` (the dragged row
* follows the pointer; rows between them make room). PURE. */
export function rowShift(i: number, from: number, to: number, dy: number, rowH: number): number {
if (i === from) return dy
if (from < to && i > from && i <= to) return -rowH
if (from > to && i < from && i >= to) return rowH
return 0
}
export type DragHandle = { onPointerDown: (e: { clientY?: number; pointerId?: number; nativeEvent?: { clientY?: number } }) => void }
export function Reorder<T>({
items,
keyOf,
rowHeight = 44,
onReorder,
renderItem,
}: {
items: T[]
keyOf: (t: T) => string
rowHeight?: number
onReorder: (from: number, to: number) => void
renderItem: (item: T, handle: DragHandle, dragging: boolean) => ReactNode
}) {
const [drag, setDrag] = useState<{ from: number; dy: number } | null>(null)
const startRef = useRef<{ from: number; startY: number } | null>(null)
const to = drag ? targetIndex(drag.from, drag.dy, rowHeight, items.length) : -1
const begin = useCallback(
(index: number) =>
(e: { clientY?: number; pointerId?: number; nativeEvent?: { clientY?: number } }) => {
const startY = e.clientY ?? e.nativeEvent?.clientY ?? 0
startRef.current = { from: index, startY }
setDrag({ from: index, dy: 0 })
const move = (ev: PointerEvent) => {
if (!startRef.current) return
setDrag({ from: startRef.current.from, dy: ev.clientY - startRef.current.startY })
}
const end = () => {
const s = startRef.current
window.removeEventListener('pointermove', move)
window.removeEventListener('pointerup', end)
window.removeEventListener('pointercancel', end)
startRef.current = null
setDrag((d) => {
if (s && d) {
const t = targetIndex(s.from, d.dy, rowHeight, items.length)
if (t !== s.from) onReorder(s.from, t)
}
return null
})
}
window.addEventListener('pointermove', move)
window.addEventListener('pointerup', end)
window.addEventListener('pointercancel', end)
},
[items.length, rowHeight, onReorder],
)
return (
<YStack>
{items.map((item, i) => {
const dragging = drag?.from === i
const shift = drag && to >= 0 ? rowShift(i, drag.from, to, drag.dy, rowHeight) : 0
return (
<YStack
key={keyOf(item)}
height={rowHeight}
justify="center"
className="hz-drag-item"
style={{
transform: `translateY(${shift}px)`,
zIndex: dragging ? 2 : 1,
...(dragging ? { transition: 'none', boxShadow: '0 8px 24px rgba(0,0,0,0.28)' } : null),
}}
>
{renderItem(item, { onPointerDown: begin(i) }, dragging)}
</YStack>
)
})}
</YStack>
)
}
-94
View File
@@ -1,94 +0,0 @@
'use client'
/**
* SelectMenu — a compact, reusable dropdown select on @hanzo/gui Popover (the
* console's proven dropdown idiom, same as OrgSwitcher/ScopeSwitcher). Renders a
* labelled trigger ("All types", or the chosen option) and a popover list with a
* check on the active option. Prop-driven + self-contained so it lifts into
* `@hanzo/ui`. `value === null` is the "all"/unfiltered state.
*/
import type { ReactElement } from 'react'
import { useState } from 'react'
import { Button, Popover, Text, XStack, YStack } from '@hanzo/gui'
import { ChevronDown, Check } from '@hanzogui/lucide-icons-2'
export type SelectOption<T extends string> = { key: T; label: string }
export function SelectMenu<T extends string>({
options,
value,
onChange,
allLabel = 'All',
icon,
minWidth = 130,
}: {
options: SelectOption<T>[]
value: T | null
onChange: (v: T | null) => void
/** Label shown (and first menu row) for the unfiltered `null` state. */
allLabel?: string
/** Optional leading icon in the trigger. */
icon?: ReactElement
minWidth?: number
}) {
const [open, setOpen] = useState(false)
const active = value === null ? null : options.find((o) => o.key === value) ?? null
const triggerLabel = active ? active.label : allLabel
const pick = (v: T | null) => {
onChange(v)
setOpen(false)
}
return (
<Popover open={open} onOpenChange={setOpen} placement="bottom-start">
<Popover.Trigger asChild>
<Button
size="$2"
minW={minWidth}
justify="space-between"
borderWidth={1}
borderColor="$borderColor"
bg={value !== null ? '$color5' : 'transparent'}
icon={icon}
iconAfter={<ChevronDown size={14} opacity={0.6} />}
>
<Text fontSize="$2" color="$color12" numberOfLines={1} flex={1}>
{triggerLabel}
</Text>
</Button>
</Popover.Trigger>
<Popover.Content bordered elevate p="$1.5" minW={minWidth} bg="$color2" borderColor="$borderColor">
<YStack gap="$0.5" minW={minWidth} maxH={320} overflow="scroll">
<Row label={allLabel} active={value === null} onPress={() => pick(null)} />
{options.map((o) => (
<Row key={o.key} label={o.label} active={value === o.key} onPress={() => pick(o.key)} />
))}
</YStack>
</Popover.Content>
</Popover>
)
}
function Row({ label, active, onPress }: { label: string; active: boolean; onPress: () => void }) {
return (
<XStack
items="center"
gap="$2"
px="$2.5"
py="$1.5"
rounded="$3"
cursor="pointer"
hoverStyle={{ bg: '$color4' }}
bg={active ? '$color4' : 'transparent'}
onPress={onPress}
>
<XStack width={14} items="center" justify="center">
{active ? <Check size={13} /> : null}
</XStack>
<Text fontSize="$2" color="$color12" flex={1} numberOfLines={1}>
{label}
</Text>
</XStack>
)
}
-197
View File
@@ -1,197 +0,0 @@
'use client'
/**
* SlideOver — the ONE right-side (or left) slide-out overlay for the console.
*
* It is the single mechanism behind every off-canvas surface: the mobile nav
* drawer, the item DetailPane, and the account/settings menu. One definition, many
* mounts (DRY) — so every drawer slides, dims, traps, and closes identically.
*
* Smoothness is CSS-transform driven (never a JS layout branch): the panel is
* ALWAYS mounted and toggles `translateX(0) ↔ translateX(±100%)` via the shared
* `.hz-slide` class, and the backdrop cross-fades via `.hz-fade`. Because it never
* unmounts, the EXIT animates too (a `Dialog` that unmounts on close cannot). Both
* classes are reduced-motion-aware in `globals.css`, so the whole thing snaps
* instantly for users who ask for that — no bespoke logic here.
*
* Responsive by construction: FULL-WIDTH (full-screen) below `lg`, a fixed-width
* pane at `lg+` (`size`). Uses `100dvh` so the mobile browser chrome never clips it.
*
* A11y: `role="dialog" aria-modal`, Escape to close, backdrop click to close, body
* scroll lock while open (ref-counted so stacked overlays don't fight), and focus
* moves into the panel on open and returns to the opener on close.
*/
import { useEffect, useRef, type ReactNode } from 'react'
import { Button, ScrollView, Text, XStack, YStack } from '@hanzo/gui'
import { X } from '@hanzogui/lucide-icons-2'
import { asColor, type IconLike } from './color'
/** The lg breakpoint (px) — matches the shell's one responsive threshold. */
const LG = 1024
// ── Body scroll lock, ref-counted so nested/stacked overlays don't clobber it ──
let lockCount = 0
let savedOverflow = ''
function lockScroll() {
if (typeof document === 'undefined') return
if (lockCount === 0) {
savedOverflow = document.body.style.overflow
document.body.style.overflow = 'hidden'
}
lockCount++
}
function unlockScroll() {
if (typeof document === 'undefined') return
lockCount = Math.max(0, lockCount - 1)
if (lockCount === 0) document.body.style.overflow = savedOverflow
}
export type SlideOverProps = {
open: boolean
onClose: () => void
/** Which edge the panel slides from. Default right. */
side?: 'right' | 'left'
/** Panel width at lg+ (px). Below lg it is always full-screen. Default 420. */
size?: number
/** Optional header title — when set, SlideOver renders a header + a scroll body. */
title?: ReactNode
/** Optional header icon (tinted with `iconColor`). */
icon?: IconLike
iconColor?: string
/** Extra header content, right-aligned before the close button. */
headerRight?: ReactNode
/** Accessible label when no visible title is given. */
ariaLabel?: string
/** z-index for stacking (a pane opened over a drawer passes a higher value). */
zIndex?: number
children: ReactNode
}
export function SlideOver({
open,
onClose,
side = 'right',
size = 420,
title,
icon: Icon,
iconColor,
headerRight,
ariaLabel,
zIndex = 1000,
children,
}: SlideOverProps) {
const panelRef = useRef<HTMLElement | null>(null)
const openerRef = useRef<Element | null>(null)
// Body scroll lock + focus management, driven by `open`.
useEffect(() => {
if (!open) return
openerRef.current = typeof document !== 'undefined' ? document.activeElement : null
lockScroll()
// Move focus into the panel (next frame so it is laid out + on-screen).
const t = window.setTimeout(() => {
const el = panelRef.current
if (el && typeof el.focus === 'function') el.focus()
}, 0)
return () => {
window.clearTimeout(t)
unlockScroll()
const opener = openerRef.current
if (opener && opener instanceof HTMLElement && typeof opener.focus === 'function') opener.focus()
}
}, [open])
// Escape closes.
useEffect(() => {
if (!open) return
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
e.stopPropagation()
onClose()
}
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [open, onClose])
const offscreen = side === 'right' ? 'translateX(100%)' : 'translateX(-100%)'
const edge = side === 'right' ? { r: 0 } : { l: 0 }
return (
<YStack
// Fixed to the viewport; overflow hidden so the off-screen panel (absolute
// child) is CLIPPED and never adds a horizontal scrollbar.
overflow="hidden"
pointerEvents={open ? 'auto' : 'none'}
aria-hidden={!open}
style={{ position: 'fixed', inset: 0, zIndex }}
>
{/* Backdrop — cross-fades; click to dismiss. */}
<YStack
position="absolute"
t={0}
l={0}
r={0}
b={0}
bg="rgba(0,0,0,0.55)"
className="hz-fade"
style={{ opacity: open ? 1 : 0 }}
onPress={onClose}
/>
{/* Panel — slides in from the edge. Full-screen below lg, fixed width at lg+. */}
<YStack
// @ts-expect-error — web DOM ref; Gui forwards it to the underlying node.
ref={panelRef}
tabIndex={-1}
role="dialog"
aria-modal={open ? true : undefined}
aria-label={typeof title === 'string' ? title : ariaLabel}
position="absolute"
t={0}
b={0}
{...edge}
width="100%"
$lg={{ width: size, maxW: '100vw' }}
bg="$color1"
borderLeftWidth={side === 'right' ? 1 : 0}
borderRightWidth={side === 'left' ? 1 : 0}
borderColor="$borderColor"
className="hz-slide"
style={{ transform: open ? 'translateX(0)' : offscreen, height: '100dvh' }}
>
{title !== undefined ? (
<>
<XStack
items="center"
gap="$2.5"
px="$4"
height={56}
borderBottomWidth={1}
borderColor="$borderColor"
>
{Icon ? <Icon size={18} color={iconColor ? asColor(iconColor) : undefined} /> : null}
<Text flex={1} fontSize="$5" fontWeight="700" color="$color12" numberOfLines={1}>
{title}
</Text>
{headerRight}
<Button size="$2" chromeless icon={<X size={18} />} onPress={onClose} aria-label="Close" />
</XStack>
<ScrollView flex={1}>
<YStack flex={1} p="$4" gap="$3">
{children}
</YStack>
</ScrollView>
</>
) : (
// Bare mode — the caller owns the full panel layout (e.g. the nav drawer
// renders its own header + scroll + footer).
children
)}
</YStack>
</YStack>
)
}
export { LG as SLIDEOVER_LG }
-49
View File
@@ -1,49 +0,0 @@
'use client'
/**
* Status pill — maps a resource/cluster status string to a tone, one place.
*
* Provisioned things (managed resources, clusters) report free-form lifecycle
* strings; this normalizes them to green/yellow/red/neutral so every list reads
* the same. Style props use the v5 shorthand set.
*/
import { Text } from '@hanzo/gui'
type Tone = 'green' | 'yellow' | 'red' | 'neutral'
const toneOf = (status: string): Tone => {
const s = status.toLowerCase()
// Platform health verdicts map straight to a tone (the apps inventory reports
// green/yellow/red directly).
if (s === 'green') return 'green'
if (s === 'yellow') return 'yellow'
if (s === 'red') return 'red'
if (['ready', 'active', 'running', 'available', 'ok'].includes(s)) return 'green'
if (['creating', 'provisioning', 'pending', 'updating', 'attaching'].includes(s)) return 'yellow'
if (['error', 'failed', 'degraded', 'down'].includes(s)) return 'red'
return 'neutral'
}
// `as const` keeps the literal token types (not widened to `string`) so they
// satisfy the GUI `bg`/`color` token unions.
const TONE_BG = {
green: '$color5',
yellow: '$color4',
red: '$color4',
neutral: '$color3',
} as const
const TONE_FG = {
green: '$color12',
yellow: '$color12',
red: '$color12',
neutral: '$color11',
} as const
export function StatusTag({ status }: { status?: string }) {
const tone = toneOf(status ?? '')
return (
<Text fontSize="$1" px="$2" py="$1" rounded="$2" bg={TONE_BG[tone]} color={TONE_FG[tone]}>
{status || 'unknown'}
</Text>
)
}
-25
View File
@@ -1,25 +0,0 @@
'use client'
/**
* Theme toggle — flips the console between dark and light via next-theme. The
* provider already wires `NextThemeProvider` → `GuiProvider`, so setting the
* theme here re-themes the whole tree. Shows the action's target icon (a sun in
* dark mode, a moon in light mode).
*/
import { Button } from '@hanzo/gui'
import { useThemeSetting } from '@hanzogui/next-theme'
import { Moon, Sun } from '@hanzogui/lucide-icons-2'
export function ThemeToggle() {
const { current, resolvedTheme, set } = useThemeSetting()
const isDark = (resolvedTheme ?? current ?? 'dark') !== 'light'
return (
<Button
size="$2"
chromeless
icon={isDark ? <Sun size={16} /> : <Moon size={16} />}
onPress={() => set(isDark ? 'light' : 'dark')}
aria-label={isDark ? 'Switch to light theme' : 'Switch to dark theme'}
/>
)
}
-166
View File
@@ -1,166 +0,0 @@
'use client'
/**
* Toast — the ONE feedback primitive. Every mutation reports through `useToast()`
* so success/failure looks and behaves the same everywhere. A single provider
* holds the queue and renders the viewport (portalled top-right); there is no
* per-call markup. Built from GUI primitives only.
*
* Mount `<ToastProvider>` once (the dashboard layout does). Call:
* const toast = useToast()
* toast.success('Created my-db')
* toast.error(err instanceof ApiError ? err.message : 'Failed to create')
*/
import { createContext, useCallback, useContext, useEffect, useRef, useState, type ReactNode } from 'react'
import { createPortal } from 'react-dom'
import { Button, Card, Text, XStack, YStack } from '@hanzo/gui'
import { CircleCheck, CircleX, Info, X } from '@hanzogui/lucide-icons-2'
export type ToastKind = 'success' | 'error' | 'info'
export type ToastInput = {
title: string
description?: string
kind?: ToastKind
/** Auto-dismiss after this many ms; 0 keeps it until dismissed. */
durationMs?: number
}
type Toast = Required<Omit<ToastInput, 'description'>> & { id: number; description?: string }
type ToastApi = {
toast: (t: ToastInput) => void
success: (title: string, description?: string) => void
error: (title: string, description?: string) => void
info: (title: string, description?: string) => void
dismiss: (id: number) => void
}
const ToastContext = createContext<ToastApi | null>(null)
/** Theme-aware accent per kind (adapts to light/dark). */
const ACCENT = {
success: '$green10',
error: '$red10',
info: '$color11',
} as const
const ICON: Record<ToastKind, typeof Info> = {
success: CircleCheck,
error: CircleX,
info: Info,
}
function ToastCard({ t, onClose }: { t: Toast; onClose: () => void }) {
const Icon = ICON[t.kind]
const accent = ACCENT[t.kind]
return (
<Card
width={360}
maxWidth="90%"
p="$3"
gap="$2"
bg="$color2"
borderWidth={1}
borderColor="$borderColor"
borderLeftWidth={3}
borderLeftColor={accent}
rounded="$4"
elevation="$2"
>
<XStack gap="$2.5" items="flex-start">
<YStack pt={1}>
<Icon size={18} color={accent} />
</YStack>
<YStack flex={1} gap="$1">
<Text fontSize="$3" fontWeight="700" color="$color12">
{t.title}
</Text>
{t.description ? (
<Text fontSize="$2" color="$color11">
{t.description}
</Text>
) : null}
</YStack>
<Button size="$1" chromeless icon={<X size={14} />} onPress={onClose} aria-label="Dismiss" />
</XStack>
</Card>
)
}
/** Portalled, fixed top-right stack. Rendered only after mount (SSR-safe). */
function ToastViewport({ toasts, dismiss }: { toasts: Toast[]; dismiss: (id: number) => void }) {
const [mounted, setMounted] = useState(false)
useEffect(() => setMounted(true), [])
if (!mounted || typeof document === 'undefined') return null
return createPortal(
<div style={{ position: 'fixed', top: 16, right: 16, zIndex: 100000, pointerEvents: 'none' }}>
<YStack gap="$2" items="flex-end">
{toasts.map((t) => (
<YStack key={t.id} pointerEvents="auto">
<ToastCard t={t} onClose={() => dismiss(t.id)} />
</YStack>
))}
</YStack>
</div>,
document.body,
)
}
export function ToastProvider({ children }: { children: ReactNode }) {
const [toasts, setToasts] = useState<Toast[]>([])
const timers = useRef(new Map<number, ReturnType<typeof setTimeout>>())
const seq = useRef(0)
const dismiss = useCallback((id: number) => {
setToasts((ts) => ts.filter((t) => t.id !== id))
const timer = timers.current.get(id)
if (timer) {
clearTimeout(timer)
timers.current.delete(id)
}
}, [])
const toast = useCallback(
(input: ToastInput) => {
const id = ++seq.current
const kind = input.kind ?? 'info'
const durationMs = input.durationMs ?? (kind === 'error' ? 6000 : 3500)
setToasts((ts) => [...ts, { id, kind, title: input.title, description: input.description, durationMs }])
if (durationMs > 0) {
timers.current.set(
id,
setTimeout(() => dismiss(id), durationMs),
)
}
},
[dismiss],
)
// Clear any pending timers on unmount.
useEffect(() => {
const map = timers.current
return () => map.forEach((t) => clearTimeout(t))
}, [])
const api: ToastApi = {
toast,
success: (title, description) => toast({ title, description, kind: 'success' }),
error: (title, description) => toast({ title, description, kind: 'error' }),
info: (title, description) => toast({ title, description, kind: 'info' }),
dismiss,
}
return (
<ToastContext.Provider value={api}>
{children}
<ToastViewport toasts={toasts} dismiss={dismiss} />
</ToastContext.Provider>
)
}
export function useToast(): ToastApi {
const ctx = useContext(ToastContext)
if (!ctx) throw new Error('useToast must be used within <ToastProvider>')
return ctx
}
-17
View File
@@ -1,17 +0,0 @@
import type { ComponentType } from 'react'
import type { GetThemeValueForKey } from '@hanzo/gui'
/**
* Bridge between raw CSS colors (product hex accents) and Gui's typed `color`
* prop. Gui types `color` as a theme-token template (`$color12`, …), but ANY CSS
* color is valid at runtime — so tinting an icon with a product hex needs one
* sanctioned cast, in ONE place, rather than `as any` scattered around.
*/
export type IconColor = GetThemeValueForKey<'color'>
/** Cast a raw CSS color (e.g. `#5E6AD2`) to Gui's `color` prop type. */
export const asColor = (hex: string): IconColor => hex as unknown as IconColor
/** A lucide/Gui icon component that accepts a size + a (token-or-cast) color —
* the shape the shell passes around for product/nav icons. */
export type IconLike = ComponentType<{ size?: number; color?: IconColor }>
@@ -1,51 +0,0 @@
import { describe, it, expect } from 'vitest'
import { filterOptions, isKnownOption, type ComboOption } from './filter'
const opts: ComboOption[] = [
{ value: 'zen-omni', label: 'Zen Omni', hint: 'hanzo' },
{ value: 'gpt-4o-mini', label: 'GPT-4o mini', hint: 'openai' },
{ value: 'claude-sonnet-4-5', hint: 'anthropic' },
]
describe('filterOptions', () => {
it('returns every option for an empty / whitespace query', () => {
expect(filterOptions(opts, '')).toHaveLength(3)
expect(filterOptions(opts, ' ')).toHaveLength(3)
})
it('matches on value (case-insensitive substring)', () => {
expect(filterOptions(opts, 'ZEN').map((o) => o.value)).toEqual(['zen-omni'])
expect(filterOptions(opts, 'sonnet').map((o) => o.value)).toEqual(['claude-sonnet-4-5'])
})
it('matches on label', () => {
expect(filterOptions(opts, 'GPT-4o').map((o) => o.value)).toEqual(['gpt-4o-mini'])
})
it('matches on hint (the provider)', () => {
expect(filterOptions(opts, 'anthropic').map((o) => o.value)).toEqual(['claude-sonnet-4-5'])
expect(filterOptions(opts, 'openai').map((o) => o.value)).toEqual(['gpt-4o-mini'])
})
it('returns nothing when nothing matches (the typed value is still usable by the caller)', () => {
expect(filterOptions(opts, 'llama')).toEqual([])
})
it('treats the query as a LITERAL string, never a regex (no ReDoS / injection)', () => {
// A regex-special query must match literally (or not at all) — never be compiled.
expect(filterOptions(opts, '.*')).toEqual([]) // no option literally contains ".*"
expect(filterOptions(opts, '(')).toEqual([]) // an unbalanced paren would throw if compiled
expect(filterOptions(opts, '[a-z')).toEqual([]) // an invalid char class would throw if compiled
// A literal hyphen (regex-benign but proves substring semantics):
expect(filterOptions(opts, '4o-mini').map((o) => o.value)).toEqual(['gpt-4o-mini'])
})
})
describe('isKnownOption', () => {
it('is true only for an exact value match (case-sensitive)', () => {
expect(isKnownOption(opts, 'zen-omni')).toBe(true)
expect(isKnownOption(opts, 'ZEN-OMNI')).toBe(false)
expect(isKnownOption(opts, 'custom-model')).toBe(false)
})
})
-39
View File
@@ -1,39 +0,0 @@
/**
* Combobox option filtering — pure, unit-tested.
*
* The ONE matcher the ComboBox uses to narrow a live option list against typed
* text. Matching is a LITERAL, case-insensitive substring over the option's
* value + label + hint — NEVER a compiled RegExp of user input (that would be a
* ReDoS / injection surface; the whole codebase filters with literal `includes`,
* e.g. marketplace/logic + filterOrgs). Kept dependency-free so it lifts into
* `@hanzo/ui` alongside the ComboBox.
*/
/** One option in a combobox list. `value` is what gets committed; label/hint are display. */
export type ComboOption = {
/** The value committed to the field when this option is chosen. */
value: string
/** Display label (defaults to `value`). */
label?: string
/** Optional secondary text (e.g. a provider, a description) — also searched. */
hint?: string
}
/** The searchable haystack for one option (value + label + hint), lowercased. */
const hay = (o: ComboOption): string => `${o.value} ${o.label ?? ''} ${o.hint ?? ''}`.toLowerCase()
/**
* Filter `options` by `query` — a literal, case-insensitive substring over
* value/label/hint. An empty/whitespace query returns every option (no filter).
* PURE. The input is treated as a plain string, never a pattern.
*/
export function filterOptions(options: ComboOption[], query: string): ComboOption[] {
const q = query.trim().toLowerCase()
if (!q) return options
return options.filter((o) => hay(o).includes(q))
}
/** True iff `value` exactly matches an option's `value` (case-sensitive). */
export function isKnownOption(options: ComboOption[], value: string): boolean {
return options.some((o) => o.value === value)
}
-57
View File
@@ -1,57 +0,0 @@
// @hanzo/ui/product — the product/app component layer on @hanzo/gui.
//
// Charts, metrics, page headers, status tags, rich empty states, combobox,
// slide-over, toasts, drag-reorder, labeled field rows, provider/product marks,
// theme toggle. Presentational + host-agnostic: data and effects are INJECTED as
// props — nothing here imports an app's `~/lib`. Cross-platform (web + native +
// desktop) because it builds only on @hanzo/gui primitives. Clean-room.
//
// Single collision-free namespace: where a name exists in two files the canonical
// (Charts) keeps the name and the variant is aliased, so NOTHING is dropped.
// Charts — the canonical Sparkline + Donut live here (dependency-free inline SVG:
// hover tooltips, axis ticks, honest null under 2 real points).
export * from './Charts'
// Metric — resource-console stat tiles/panels/bars. Its own near-identical
// Sparkline is preserved as `MetricSparkline` (zero loss; see CONSOLIDATION.md
// for the eventual single-Sparkline dedup).
export {
SERIES,
colorForIndex,
utilColor,
Sparkline as MetricSparkline,
MiniBars,
UtilBar,
LegendDot,
MetricCard,
HintButton,
Panel,
} from './Metric'
// Standalone dependency-free ring — aliased to avoid the Charts.Donut clash.
export { Donut as DonutRing, type DonutSegment } from './Donut'
// ComboBox — the typeable select. `ComboOption` comes from the ONE pure filter
// module (ReDoS-safe literal substring), so export the component here and the
// option type + matchers from there.
export { ComboBox } from './ComboBox'
export * from './combobox/filter'
// The rest — every exported name below is unique across the layer.
export * from './DataTable'
export * from './EmptyState'
export * from './FadeIn'
export * from './Field'
export * from './HanzoMark'
export * from './PageHeader'
export * from './PrimaryButton'
export * from './ProductIcon'
export * from './ProviderLogo'
export * from './Reorder'
export * from './SelectMenu'
export * from './SlideOver'
export * from './StatusTag'
export * from './ThemeToggle'
export * from './Toast'
export * from './color'
-136
View File
@@ -1,136 +0,0 @@
/* @hanzo/ui motion primitives — the calm, restrained animation vocabulary the
Hanzo surfaces share. One place defines the easing; components apply the class.
`className` forwards to the underlying DOM node on web, so the browser
transitions the Gui-driven inline width/transform/opacity. Every animation is
guarded by `prefers-reduced-motion: reduce`.
Import once at the app root: import '@hanzo/ui/styles/motion.css' */
/* Fade-up entrance — <FadeIn> applies the class + a per-item stagger delay.
~0.4s ease-out, small upward travel (matches the hanzo.ai marketing feel). */
@keyframes hz-fade-up {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.hz-fade-up {
animation: hz-fade-up 0.4s cubic-bezier(0.16, 1, 0.3, 1) both;
will-change: transform, opacity;
}
@media (prefers-reduced-motion: reduce) {
.hz-fade-up {
animation: none;
}
}
/* Shell chrome motion — sidebar collapse (width) and the Linear-style two-level
nav slide (transform); backdrop cross-fade behind a SlideOver / dialog. */
.hz-collapse {
transition: width 220ms cubic-bezier(0.16, 1, 0.3, 1);
will-change: width;
}
.hz-slide {
transition: transform 260ms cubic-bezier(0.16, 1, 0.3, 1);
will-change: transform;
}
.hz-fade {
transition: opacity 240ms cubic-bezier(0.16, 1, 0.3, 1);
will-change: opacity;
}
/* Drag-to-reorder — a pinned row while it is being dragged (pointer DnD). The
lifted row gets a subtle lift; siblings ease into place via `.hz-slide`. */
.hz-drag-item {
touch-action: none;
transition: transform 180ms cubic-bezier(0.16, 1, 0.3, 1);
}
.hz-drag-item[data-dragging='true'] {
transition: none;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.28);
opacity: 0.96;
cursor: grabbing;
}
@media (prefers-reduced-motion: reduce) {
.hz-collapse,
.hz-slide,
.hz-fade,
.hz-drag-item {
transition: none;
}
}
/* Living-overview motion — the "videogame-like" living dashboard. The count-up +
live sparkline are driven in JS (rAF, gated by prefers-reduced-motion in the
hooks); these are the pure-CSS bits: a loading shimmer, a live-feed pulse, and
a brief highlight when a freshly-arrived row lands. */
/* Skeleton shimmer — an honest "loading", never fabricated content. */
@keyframes hz-shimmer {
0% {
background-position: -160px 0;
}
100% {
background-position: 160px 0;
}
}
.hz-skeleton {
background-color: var(--color3, rgba(148, 163, 184, 0.14));
background-image: linear-gradient(
90deg,
transparent 0%,
var(--color4, rgba(148, 163, 184, 0.22)) 50%,
transparent 100%
);
background-size: 160px 100%;
background-repeat: no-repeat;
animation: hz-shimmer 1.2s ease-in-out infinite;
}
/* Live-feed pulse — the "Live" dot on a streaming panel. */
@keyframes hz-pulse {
0%,
100% {
opacity: 1;
transform: scale(1);
}
50% {
opacity: 0.45;
transform: scale(0.82);
}
}
/* Entrance for a freshly-arrived activity row (staggerless — one row at a time). */
@keyframes hz-row-in {
from {
opacity: 0;
transform: translateY(-6px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.hz-row-in {
animation: hz-row-in 0.35s cubic-bezier(0.16, 1, 0.3, 1) both;
}
@media (prefers-reduced-motion: reduce) {
.hz-skeleton {
animation: none;
}
.hz-row-in {
animation: none;
}
}
-19
View File
@@ -1,19 +0,0 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "Bundler",
"jsx": "react-jsx",
"strict": true,
"noEmit": true,
"isolatedModules": true,
"esModuleInterop": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"forceConsistentCasingInFileNames": true,
"types": ["react"]
},
"include": ["gui.config.ts", "gui.d.ts", "src"],
"exclude": ["node_modules", "dist"]
}
-11
View File
@@ -1,11 +0,0 @@
import { defineConfig } from 'vitest/config'
/**
* Unit tests run in a plain Node env. Every suite targets the package's PURE
* logic (drag-reorder math, combobox option filtering) — the modules that import
* no @hanzo/gui runtime. The view components themselves are verified by the
* consuming app's build + visual e2e.
*/
export default defineConfig({
test: { environment: 'node', include: ['src/**/*.test.ts'] },
})
-1
View File
@@ -52,7 +52,6 @@
"zod": "^3.23.8"
},
"devDependencies": {
"@hanzo/ui": "workspace:@hanzo/ui-shadcn@^",
"@types/react": "19.2.14",
"@types/react-dom": "19.2.3",
"cross-fetch": "^4.1.0",
+1 -1
View File
@@ -27,7 +27,7 @@
"react": ">=19"
},
"devDependencies": {
"@hanzo/gui": "7.3.0",
"@hanzo/gui": "7.2.2",
"@hanzogui/config": "7.3.0",
"@types/react": "^19.1.10",
"react": "^19.0.0",
+3 -3
View File
@@ -1,7 +1,7 @@
{
"name": "@hanzo/ui-shadcn",
"version": "5.7.0",
"description": "Multi-framework UI library with React, Vue, Svelte, and React Native support. Based on shadcn/ui with comprehensive framework coverage. (Formerly @hanzo/ui ≤5.x; @hanzo/ui@8+ is the @hanzo/gui-based unified lib.)",
"name": "@hanzo/ui",
"version": "5.7.1",
"description": "Multi-framework UI library with React, Vue, Svelte, and React Native support. Based on shadcn/ui with comprehensive framework coverage.",
"publishConfig": {
"registry": "https://registry.npmjs.org/",
"access": "public",
+1 -1
View File
@@ -7,7 +7,7 @@ module.exports = {
theme: {
extend: {
fontFamily: {
sans: ["var(--font-geist-sans)", ...fontFamily.sans],
sans: ["var(--font-basel-sans)", ...fontFamily.sans],
mono: ["var(--font-geist-mono)", ...fontFamily.mono],
},
colors: {