Compare commits

..
209 Commits
Author SHA1 Message Date
Hanzo AI 756541fae8 refactor(ui): retire shadcn @hanzo/ui → @hanzo/ui-shadcn (name freed for v8)
Rename the legacy shadcn/Radix line (pkgs/ui, 5.7.0) to @hanzo/ui-shadcn so the
gui-based unified library can carry the @hanzo/ui name forward at v8. Code is
untouched — only the package name changes; the published @hanzo/ui@5.7.1 stays
on npm for external ^5.x consumers.

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

Drive-by (unblocks workspace install/CI): pkgs/data pinned the now-unpublished
@hanzogui/config@7.2.2 — patch-forward to 7.3.0 (published latest, same major,
matches pkg/ui).
2026-07-03 15:54:24 -07:00
Hanzo AI a2b0a9d133 feat(@hanzo/ui): v8 unified lib on @hanzo/gui — product + record (@hanzo/data) layers
The one cross-platform, presentational, host-agnostic, clean-room component
library. Product/app layer (charts, metrics, page headers, status tags, empty
states, combobox, slide-over, toasts, drag-reorder, field rows, marks) at
'@hanzo/ui'; metadata-driven record layer composed from @hanzo/data at
'@hanzo/ui/data'; calm dark-first tokens + motion vocabulary. Web + native + desktop.

Retires the shadcn @hanzo/ui (5.x) → @hanzo/ui-shadcn; this gui-based line
carries the name forward at 8.0.0. tsc --noEmit clean, vitest 12/12.
Manifest: CONSOLIDATION.md.
2026-07-03 15:46:45 -07:00
Hanzo AI 17d239d11b feat(@hanzo/data): Twenty-grade record views (table/board/detail/editors) — clean-room
Bring the Hanzo Base data-app layer to Airtable/Twenty-class polish, 100% original
(no Twenty code — GPL kept at arm's length; Twenty observed as a running-UI reference only).

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

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

tsc --noEmit clean; vitest 37/37.
2026-07-02 23:47:23 -07:00
8e28d44e8e 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: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 18:45:40 -07:00
zandClaude Opus 4.8 0de2f425a3 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: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 23:03:56 -07:00
zandClaude Opus 4.8 609500d090 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: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 15:35:12 -07:00
zandClaude Opus 4.8 55ad4e63c6 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: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 15:13:34 -07:00
z a9ca7017f3 docs(brand): add hero banner 2026-06-28 20:07:59 -07:00
z 8bdf0004e0 chore(brand): dynamic hero banner 2026-06-28 20:07:57 -07:00
zeekay 9b62e07efd 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 721467d806 ci: drop cache:'pnpm' from remaining workflows (no committed lockfile) 2026-06-24 23:38:22 -07:00
zeekay ded5315a97 ci: drop broken cache:'pnpm' (no committed lockfile) — unblock @hanzo/ui publish 2026-06-24 23:37:37 -07:00
9dc5e717ca 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
9e564a6201 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
52e9b50470 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 70f449f170 chore: restore shadcn/ui attribution (NOTICE), OSS compliance 2026-06-21 07:19:40 -07:00
614a5ea1f7 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 60b691435e 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
ff392dd16d 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 03cc20917f added publish script 2026-06-18 18:49:05 -10:00
ce68baead2 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
1a5c64bcde 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: Claude <dev@hanzo.ai>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 04:37:47 -07:00
hanzo-dev 6f4ccb921b chore: update 2026-06-10 14:11:16 -07:00
hanzo-dev 7d519e6f6b 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 93fa9dfd70 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 aa826cad2a 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 4486e0dfd6 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 dbf846a300 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 1f0bd82390 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 cacc8606c6 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 6ce3c11e08 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 deaae518c4 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 3d64e4162c 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 2dff79192b 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 70dd5156fa 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 5c4a1cc227 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 dd757fce66 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 ef5ef73f5e 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 992b2ec8cc 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 f0f74d48e1 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 ebf8228094 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 c6a9ee63c3 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 b9eb5bfe84 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 5b50570c45 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 3e7d605123 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 a42b8a5110 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 16e6422111 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 090c334b89 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 0bdb3fc9d8 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 3a394932c4 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 ad216ff330 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 59f84d1e4d 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 7d6ca0e686 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 337c68bc77 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 aae5a87b8c 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 865f99f6d2 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 311e177e1c 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 0b79f23ad1 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 ff5c9c89b5 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 457dd99856 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 ca350670ec 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 a490a9d4f1 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 3b55d8f64d 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 a1c53689db 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 8f0368eeab 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 c55074acf0 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 814a093ca4 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 60f1881345 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 e7f4e087d6 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 9152dab189 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 545d41142e 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 c774c24ae5 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 bcef982329 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 9d70d30247 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 97d2bc1743 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 1037f96257 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 689d8e8f62 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 8241adf62d 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 5efcbc2902 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 6d8c07e6ec 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 0b7d793988 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 36d1a1fe1c 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 b20442a376 chore(deps): pnpm up --latest --recursive 2026-06-02 11:35:03 -07:00
hanzo-dev b7bdcc9ef1 chore: update 2026-05-25 15:14:48 -07:00
hanzo-dev 0ce4288b14 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 12b7514982 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 3a820cf678 chore(release): @hanzo/brand@1.3.1 2026-04-23 20:29:24 -07:00
hanzo-dev c588394881 chore(brand): gitignore dist/, node_modules, turbo cache 2026-04-23 20:28:36 -07:00
hanzo-dev 3686b4f0b4 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 5a13ab5b0a 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 b4d3f0130d 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 e65aaa067e 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 76e51dc2d0 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 042ed3a3cd 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 15b872cb19 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
Darkhorse7starsandClaude Opus 4.6 8cc0ce5f08 chore: bump @hanzo/ui to 5.6.2 (Square card form fix)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 15:07:25 -05:00
Darkhorse7starsandClaude Opus 4.6 cb143de14f 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: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 15:02:52 -05:00
dependabot[bot]andGitHub 5bdd553985 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 73f1f283e4 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 f1b1a62826 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 3d20aeed19 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 72beb3db0f 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 20f960c4ff chore: lint publish-on-tag workflow 2026-03-29 17:24:50 -07:00
hanzo-dev 4a9267c271 fix: rename turbo.json pipeline → tasks (Turbo 2.0) 2026-03-28 14:52:09 -07:00
hanzo-dev a669885879 chore: gitignore generated static API files (build artifacts) 2026-03-28 14:41:32 -07:00
hanzo-dev b769bafcae 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 3ca53144cd chore: add generated static registry API files 2026-03-27 21:29:16 -07:00
hanzo-dev 6fc19b257e 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 c6c45fe03f 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 c79c47a52e 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 e7cf07d6d3 fix: add component-helpers dep to checkbox/button/list-item 2026-03-27 17:26:12 -07:00
hanzo-dev 638491af79 fix: ALL UI deps 3.0.0->3.0.1, publish 3.0.2 2026-03-27 16:50:15 -07:00
hanzo-dev 6a0c974a06 fix: purge workspace:*, publish 3.0.1 2026-03-27 15:54:38 -07:00
hanzo-dev 68871624f8 feat: all UI packages at 3.0.0 (unified) 2026-03-27 14:52:23 -07:00
hanzo-dev 3c0ab7ce4a fix: ALL UI packages 2.0.8, internal deps point to correct published versions 2026-03-27 14:18:17 -07:00
hanzo-dev c77146332a fix: ALL UI packages 2.0.7, helpers->component-helpers in all dist 2026-03-27 13:35:34 -07:00
hanzo-dev 13dc3c4e86 fix: button/list-item import useCurrentColor from component-helpers, bump 2.0.6 2026-03-27 12:04:16 -07:00
hanzo-dev d5693fc864 fix: all imports @hanzo/gui-* -> @hanzogui/*, TamaguiRoot -> GuiRoot in dist, bump 2.0.5 2026-03-26 20:27:38 -07:00
hanzo-dev 264704c84a fix: repair JSON formatting in package.json 2026-03-24 20:32:33 -07:00
hanzo-dev dbe5096e95 fix: correct package names @hanzo/gui-* -> @hanzogui/* 2026-03-24 20:31:50 -07:00
hanzo-dev 3c8583c39e fix: bump UI packages to 2.0.4 with consistent deps 2026-03-24 20:31:14 -07:00
hanzo-dev 834d09d2e5 fix: resolve workspace:* to 2.0.1, bump UI packages 2026-03-24 19:22:01 -07:00
hanzo-dev eb0daae1ce docs: update LLM.md with upstream sync info and current structure 2026-03-24 19:13:53 -07:00
dependabot[bot]andGitHub 71b69254a1 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 18768197b9 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 48e5887087 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 844fe49f3f 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 0eab6ee723 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 5f9df0a24f 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 ed6e878470 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 6bb9106312 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 6cd4236dd7 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 7bee72276b 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 ca1d75369f 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 3b0138d746 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 de8231c590 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 c8bed7869a 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 0256b51383 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 c3e098200e 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 88b26cb498 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 407ba4509d 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 92065761d4 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 d8bf393e3e 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 296810b144 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 14b9ad1826 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 addb0a2d29 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 eebdfb0040 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 418615e4db fix: update checkout client 2026-03-24 18:42:34 -07:00
hanzo-dev d901cb2413 feat: migrate tamagui primitives to @hanzogui/*, rename tokens/tamagui to tokens/gui 2026-03-24 18:42:34 -07:00
hanzo-dev 47563da3fd 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
Darkhorse7starsandClaude Opus 4.6 892ef0901e 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: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 16:55:41 -05:00
Darkhorse7starsandClaude Opus 4.6 fe3f0b251e 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: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 12:41:22 -05:00
Darkhorse7starsandClaude Opus 4.6 08045d5e65 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: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 12:22:29 -05:00
hanzo-dev 71a14906b1 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 e5ba46f582 chore: brand + gui publish artifacts 2026-03-22 18:38:17 -07:00
hanzo-dev 56a29a2a8a feat: add @hanzo/gui-* UI component packages from hanzo/gui rebrand 2026-03-21 13:08:01 -07:00
hanzo-dev 6a34a6912b chore: update all dependencies to latest 2026-03-21 11:44:34 -07:00
hanzo-dev e8e47f3f64 chore: bump @hanzo/ui to 5.5.0 (dash components) 2026-03-18 14:52:20 -07:00
hanzo-dev b15fee02f7 fix: clean names — dash/layout, dash/sidebar, dash/table, dash/form, dash/crud 2026-03-18 14:49:17 -07:00
hanzo-dev 60aa11ddd8 fix: rename admin->dash in tsup build config 2026-03-18 14:47:05 -07:00
hanzo-dev 62e6c09c95 chore: bump @hanzo/ui to 5.4.0 (adds @hanzo/ui/dash) 2026-03-18 14:40:24 -07:00
hanzo-dev 23eb970eb6 feat: add @hanzo/ui/dash — shared dashboard UI (layout, data table, forms, CRUD) 2026-03-18 14:39:39 -07:00
hanzo-dev dad3600ca4 fix: update repository URL from react-sdk to ui for npm provenance 2026-03-16 22:03:53 -07:00
hanzo-dev bcc05e8ec2 chore: bump @hanzo/ui to 5.3.42 2026-03-16 21:51:19 -07:00
hanzo-dev 021587dc43 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 7dfbaaad4c 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 913f5e7fb7 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 e1a2b8289e fix: expand BillingSection type to include all billing sections 2026-03-11 18:29:53 -07:00
hanzo-dev db5f07eb37 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 93d607b9b7 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 cf960de5f1 feat: add hard refresh and settings to HanzoHeader shell 2026-03-11 11:38:55 -07:00
hanzo-dev 094f0b6558 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 c74756c642 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 07245e4c6a fix(billing): export BillingSection type from @hanzo/ui/billing 2026-03-10 15:32:03 -07:00
hanzo-dev c459010476 chore: gitignore dist-test output 2026-03-03 10:43:12 -08:00
hanzo-dev 49d6f39bac 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 dd71767385 chore(ui): bump to v5.3.38
Published with HanzoCommandPalette component.
2026-03-03 17:35:30 -08:00
Zach Kelling 56c2f782fb 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 7f650655f1 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 4b9ba765c4 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 0110be638c 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 ccf51e0a04 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 c9abcec8fb feat(commerce): update commerce client SDK 2026-03-01 19:40:49 -08:00
hanzo-dev d3faddb017 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 ac8e07e3d6 fix(ci): add NEXT_PUBLIC_APP_URL env and coverage json-summary reporter 2026-03-01 16:01:01 -08:00
hanzo-dev b973dec011 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 adf5f36fcc chore: update pnpm lockfile 2026-03-01 15:27:19 -08:00
hanzo-dev 460787d2a3 chore(@hanzo/ui): bump to 5.3.37 — publish model components 2026-03-01 15:25:31 -08:00
hanzo-dev eadd812805 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 9e4846a017 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 57eea59d52 [cleanup] remove AI slop files 2026-02-28 12:25:38 -08:00
hanzo-dev 42fd56d5d3 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 5c4304c0fa 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 bfb7ab2ebf 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 05e749fed9 chore: update generated registry and color files from build 2026-02-27 21:28:17 -08:00
hanzo-dev fda484b0f3 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 f71cc049de chore: untrack .claude/settings.local.json, add to gitignore 2026-02-27 20:42:26 -08:00
hanzo-dev 3e24bb45d2 chore: update pnpm-lock.yaml 2026-02-27 19:35:51 -08:00
hanzo-dev d1b4fdd1da 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 QueenandClaude Opus 4.6 0bf8089ce7 feat(commerce): add billingRefund method for correction deposits
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 19:07:18 -08:00
Zoo QueenandClaude Opus 4.6 7cae39177c 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: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 21:06:22 -08:00
Zoo QueenandClaude Opus 4.6 55e1deb687 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: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 21:05:35 -08:00
Zoo QueenandClaude Opus 4.6 b1aa25ea04 fix: update repository URLs to hanzoai/ui for npm provenance
npm provenance requires repository.url to match the publishing repo.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 21:02:20 -08:00
Zoo QueenandClaude Opus 4.6 74ac18ab13 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: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 20:59:46 -08:00
Zoo QueenandClaude Opus 4.6 47407cb088 fix: add avatar and organization fields to Firebase auth store
All HanzoUserInfo implementations must include the new fields.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 20:55:38 -08:00
Zoo QueenandClaude Opus 4.6 00312230a4 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: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 20:52:49 -08:00
Zoo QueenandClaude Opus 4.6 171d1ea064 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: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 20:47:34 -08:00
Zoo QueenandClaude Opus 4.6 1aa1c1a398 chore: bump @hanzo/auth to 2.7.0
Adds shared OrgProjectSwitcher component for unified org/project
selection across all Hanzo apps.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 20:45:28 -08:00
Zoo QueenandClaude Opus 4.6 549f6d0fa8 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: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 20:38:07 -08:00
1671 changed files with 11676 additions and 40185 deletions
+9
View File
@@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1280" height="640" viewBox="0 0 1280 640" role="img" aria-label="ui">
<rect width="1280" height="640" fill="#0A0A0A"/>
<svg x="96" y="215" width="210" height="210" viewBox="0 0 67 67"><path d="M22.21 67V44.6369H0V67H22.21Z" fill="#fff"/><path d="M66.7038 22.3184H22.2534L0.0878906 44.6367H44.4634L66.7038 22.3184Z" fill="#fff"/><path d="M22.21 0H0V22.3184H22.21V0Z" fill="#fff"/><path d="M66.7198 0H44.5098V22.3184H66.7198V0Z" fill="#fff"/><path d="M66.7198 67V44.6369H44.5098V67H66.7198Z" fill="#fff"/></svg>
<text x="378" y="276" font-family="Inter,system-ui,-apple-system,sans-serif" font-size="78" font-weight="800" letter-spacing="-2" fill="#ffffff">ui</text>
<text x="378" y="322" font-family="Inter,system-ui,sans-serif" font-size="30" fill="#ffffff" opacity=".66">React component library for AI applications</text>
<rect x="378" y="338" width="806" height="3" rx="1.5" fill="#ffffff" opacity=".9"/>
<text x="378" y="390" font-family="Inter,system-ui,sans-serif" font-size="24" font-weight="600" fill="#ffffff" opacity=".5">github.com/hanzoai</text>
<text x="1184" y="390" text-anchor="end" font-family="Inter,system-ui,sans-serif" font-size="24" font-weight="600" fill="#ffffff" opacity=".5">hanzo.ai</text>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

+1 -1
View File
@@ -4,7 +4,7 @@
import { exec } from "child_process"
import fs from "fs"
const pkgJsonPath = "packages/shadcn/package.json"
const pkgJsonPath = "pkgs/shadcn/package.json"
try {
const pkg = JSON.parse(fs.readFileSync(pkgJsonPath))
exec("git rev-parse --short HEAD", (err, stdout) => {
+1 -1
View File
@@ -4,7 +4,7 @@
import { exec } from "child_process"
import fs from "fs"
const pkgJsonPath = "packages/shadcn/package.json"
const pkgJsonPath = "pkgs/shadcn/package.json"
try {
const pkg = JSON.parse(fs.readFileSync(pkgJsonPath))
exec("git rev-parse --short HEAD", (err, stdout) => {
+11 -11
View File
@@ -47,7 +47,7 @@ jobs:
${{ runner.os }}-pnpm-store-
- name: Install dependencies
run: pnpm install --frozen-lockfile
run: pnpm install --no-frozen-lockfile
- name: Run linting
run: pnpm run lint
@@ -83,15 +83,15 @@ jobs:
${{ runner.os }}-pnpm-store-
- name: Install dependencies
run: pnpm install --frozen-lockfile
run: pnpm install --no-frozen-lockfile
- name: Build UI package
run: cd pkg/ui && pnpm run build
run: cd pkgs/ui && pnpm run build
- name: Run type checking
run: |
cd app && pnpm run typecheck
cd ../pkg/ui && pnpm run tc --noEmit
cd ../pkgs/ui && pnpm run tc --noEmit
build:
name: Build
@@ -124,10 +124,10 @@ jobs:
${{ runner.os }}-pnpm-store-
- name: Install dependencies
run: pnpm install --frozen-lockfile
run: pnpm install --no-frozen-lockfile
- name: Build UI package
run: cd pkg/ui && pnpm run build:full
run: cd pkgs/ui && pnpm run build:full
- name: Build app
run: cd app && pnpm run build
@@ -140,7 +140,7 @@ jobs:
name: build-artifacts
path: |
app/.next
pkg/ui/dist
pkgs/ui/dist
test:
name: Test
@@ -173,10 +173,10 @@ jobs:
${{ runner.os }}-pnpm-store-
- name: Install dependencies
run: pnpm install --frozen-lockfile
run: pnpm install --no-frozen-lockfile
- name: Run pkg/ui tests
run: cd pkg/ui && pnpm test -- --run
- name: Run pkgs/ui tests
run: cd pkgs/ui && pnpm test -- --run
- name: Run app tests
run: |
@@ -190,7 +190,7 @@ jobs:
if: always()
uses: codecov/codecov-action@v3
with:
files: ./pkg/ui/coverage/lcov.info
files: ./pkgs/ui/coverage/lcov.info
flags: unit
deploy-preview:
+4 -4
View File
@@ -26,15 +26,15 @@ jobs:
version: 9
- name: Install dependencies
run: pnpm install --frozen-lockfile
run: pnpm install --no-frozen-lockfile
- name: Run tests with coverage
run: cd pkg/ui && pnpm test:coverage
run: cd pkgs/ui && pnpm test:coverage
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v3
with:
files: ./pkg/ui/coverage/lcov.info
files: ./pkgs/ui/coverage/lcov.info
flags: unit
name: hanzo-ui-coverage
@@ -44,7 +44,7 @@ jobs:
with:
script: |
const fs = require('fs');
const coverage = JSON.parse(fs.readFileSync('./pkg/ui/coverage/coverage-summary.json', 'utf8'));
const coverage = JSON.parse(fs.readFileSync('./pkgs/ui/coverage/coverage-summary.json', 'utf8'));
const total = coverage.total;
const comment = `## Test Coverage Report\n\n` +
`Lines: ${total.lines.pct}%\n` +
+2 -2
View File
@@ -53,11 +53,11 @@ jobs:
- name: Install dependencies
run: |
pnpm install --frozen-lockfile
pnpm install --no-frozen-lockfile
- name: Build @hanzo/ui package
run: |
cd pkg/ui && pnpm build
cd pkgs/ui && pnpm build
- name: Capture screenshots (optional)
if: github.event.inputs.capture_screenshots == 'true'
+21 -22
View File
@@ -43,7 +43,6 @@ jobs:
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'pnpm'
registry-url: 'https://registry.npmjs.org'
- name: Install dependencies
@@ -52,31 +51,31 @@ jobs:
- name: Build packages
run: |
if [ "${{ github.event.inputs.package }}" = "all" ] || [ "${{ github.event.inputs.package }}" = "ui" ]; then
cd pkg/ui && pnpm build
cd pkgs/ui && pnpm build
cd ../..
fi
if [ "${{ github.event.inputs.package }}" = "all" ] || [ "${{ github.event.inputs.package }}" = "ui-mcp" ]; then
cd pkg/ui-mcp && pnpm build
cd pkgs/ui-mcp && pnpm build
cd ../..
fi
if [ "${{ github.event.inputs.package }}" = "all" ] || [ "${{ github.event.inputs.package }}" = "auth" ]; then
cd pkg/auth && pnpm build
cd pkgs/auth && pnpm build
cd ../..
fi
if [ "${{ github.event.inputs.package }}" = "all" ] || [ "${{ github.event.inputs.package }}" = "commerce" ]; then
cd pkg/commerce && pnpm build
cd pkgs/commerce && pnpm build
cd ../..
fi
if [ "${{ github.event.inputs.package }}" = "all" ] || [ "${{ github.event.inputs.package }}" = "checkout" ]; then
cd pkg/checkout && pnpm build
cd pkgs/checkout && pnpm build
cd ../..
fi
if [ "${{ github.event.inputs.package }}" = "all" ] || [ "${{ github.event.inputs.package }}" = "brand" ]; then
cd pkg/brand && pnpm build
cd pkgs/brand && pnpm build
cd ../..
fi
if [ "${{ github.event.inputs.package }}" = "all" ] || [ "${{ github.event.inputs.package }}" = "react" ]; then
cd pkg/react && pnpm build
cd pkgs/react && pnpm build
cd ../..
fi
@@ -142,34 +141,34 @@ jobs:
case "${{ github.event.inputs.package }}" in
ui)
publish_package "pkg/ui"
publish_package "pkgs/ui"
;;
ui-mcp)
publish_package "pkg/ui-mcp"
publish_package "pkgs/ui-mcp"
;;
auth)
publish_package "pkg/auth"
publish_package "pkgs/auth"
;;
commerce)
publish_package "pkg/commerce"
publish_package "pkgs/commerce"
;;
checkout)
publish_package "pkg/checkout"
publish_package "pkgs/checkout"
;;
brand)
publish_package "pkg/brand"
publish_package "pkgs/brand"
;;
react)
publish_package "pkg/react"
publish_package "pkgs/react"
;;
all)
publish_package "pkg/ui"
publish_package "pkg/ui-mcp"
publish_package "pkg/auth"
publish_package "pkg/commerce"
publish_package "pkg/checkout"
publish_package "pkg/brand"
publish_package "pkg/react"
publish_package "pkgs/ui"
publish_package "pkgs/ui-mcp"
publish_package "pkgs/auth"
publish_package "pkgs/commerce"
publish_package "pkgs/checkout"
publish_package "pkgs/brand"
publish_package "pkgs/react"
;;
esac
+2 -3
View File
@@ -37,7 +37,6 @@ jobs:
with:
node-version: 20
registry-url: "https://registry.npmjs.org"
cache: "pnpm"
- name: Update npm for OIDC support
run: npm install -g npm@latest
@@ -55,10 +54,10 @@ jobs:
id: package-version
uses: martinbeentjes/npm-get-version-action@main
with:
path: packages/shadcn
path: pkgs/shadcn
- name: Upload packaged artifact
uses: actions/upload-artifact@v4
with:
name: npm-package-shadcn@${{ steps.package-version.outputs.current-version }}-pr-${{ github.event.number }} # encode the PR number into the artifact name
path: packages/shadcn/dist/index.js
path: pkgs/shadcn/dist/index.js
+14 -16
View File
@@ -20,21 +20,20 @@ jobs:
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'pnpm'
- name: Install dependencies
run: pnpm install --frozen-lockfile
run: pnpm install --no-frozen-lockfile
- name: Build packages
run: |
cd pkg/ui && pnpm build && cd ../..
cd pkg/commerce && pnpm build && cd ../..
cd pkg/brand && pnpm build && cd ../..
cd pkg/react && pnpm build && cd ../..
cd pkgs/ui && pnpm build && cd ../..
cd pkgs/commerce && pnpm build && cd ../..
cd pkgs/brand && pnpm build && cd ../..
cd pkgs/react && pnpm build && cd ../..
- name: Run tests
run: |
cd pkg/ui && pnpm test
cd pkgs/ui && pnpm test
cd ../react && pnpm test
cd ../..
@@ -56,29 +55,28 @@ jobs:
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'pnpm'
registry-url: 'https://registry.npmjs.org'
- name: Install dependencies
run: pnpm install --frozen-lockfile
run: pnpm install --no-frozen-lockfile
- name: Build all packages
run: |
cd pkg/ui && pnpm build && cd ../..
cd pkg/commerce && pnpm build && cd ../..
cd pkg/brand && pnpm build && cd ../..
cd pkg/react && pnpm build && cd ../..
cd pkgs/ui && pnpm build && cd ../..
cd pkgs/commerce && pnpm build && cd ../..
cd pkgs/brand && pnpm build && cd ../..
cd pkgs/react && pnpm build && cd ../..
- name: Configure npm authentication
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_AUTH_TOKEN }}
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
run: |
npm config set //registry.npmjs.org/:_authToken $NODE_AUTH_TOKEN
npm whoami
- name: Check and publish packages
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_AUTH_TOKEN }}
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
run: |
echo "Checking all packages for unpublished versions..."
@@ -87,7 +85,7 @@ jobs:
PUBLISHED_PACKAGES=""
for package in ui commerce brand react; do
cd "pkg/$package"
cd "pkgs/$package"
CURRENT_VERSION=$(node -p "require('./package.json').version")
PACKAGE_NAME=$(node -p "require('./package.json').name")
+3 -4
View File
@@ -4,8 +4,8 @@ on:
push:
branches: [main]
paths:
- 'pkg/*/package.json'
- 'packages/*/package.json'
- 'pkgs/*/package.json'
- 'pkgs/*/package.json'
jobs:
detect-changes:
@@ -21,7 +21,7 @@ jobs:
id: changed
run: |
CHANGED=()
for pkg_json in pkg/*/package.json packages/*/package.json; do
for pkg_json in pkgs/*/package.json pkgs/*/package.json; do
[ ! -f "$pkg_json" ] && continue
pkg_dir=$(dirname "$pkg_json")
@@ -73,7 +73,6 @@ jobs:
with:
node-version: 20
registry-url: 'https://registry.npmjs.org'
cache: 'pnpm'
- name: Install dependencies
run: pnpm install
-1
View File
@@ -33,7 +33,6 @@ jobs:
with:
node-version: 20
registry-url: "https://registry.npmjs.org"
cache: "pnpm"
- name: Update npm for OIDC support
run: npm install -g npm@latest
+1
View File
@@ -5,6 +5,7 @@ node_modules
**/node_modules
.pnp
.pnp.js
/pnpm-lock.yaml
# testing
coverage
+3 -3
View File
@@ -4,8 +4,8 @@
"typescript.preferences.quoteStyle": "single",
"eslint.workingDirectories": [
{ "pattern": "apps/*/" },
{ "pattern": "packages/*/" },
{ "pattern": "pkg/*/" }
{ "pattern": "pkgs/*/" },
{ "pattern": "pkgs/*/" }
],
"tailwindCSS.classFunctions": ["cva", "cn"],
"vitest.debugExclude": [
@@ -19,6 +19,6 @@
"search.exclude": {
"apps/v4/registry/radix-*": true,
"apps/v4/public/r/*": true,
"packages/shadcn/test/fixtures/*": true
"pkgs/shadcn/test/fixtures/*": true
}
}
+190
View File
@@ -0,0 +1,190 @@
# 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).
+6
View File
@@ -0,0 +1,6 @@
ui
Copyright (c) 2023 Hanzo AI, Inc.
This product includes software from shadcn/ui (https://github.com/shadcn-ui/ui), licensed under MIT:
Copyright (c) 2023 shadcn
+2
View File
@@ -1,3 +1,5 @@
<p align="center"><img src=".github/hero.svg" alt="ui" width="880"></p>
# @hanzo/ui
Accessible and customizable components for React, Vue, Svelte, and React Native. **Built on shadcn/ui with multi-framework support, 3D components, AI components, and advanced features.**
+2 -2
View File
@@ -42,8 +42,8 @@ describe("@hanzo/ui namespace imports - Type Resolution", () => {
* import { GridPattern } from '@hanzo/ui/pattern/grid'
* ```
*
* The package exports are properly configured in pkg/ui/package.json.
* Build artifacts exist in pkg/ui/dist/{code,3d,pattern}/.
* The package exports are properly configured in pkgs/ui/package.json.
* Build artifacts exist in pkgs/ui/dist/{code,3d,pattern}/.
*
* Vitest runtime resolution fails due to internal cross-package imports
* (e.g., CodeBlock imports from @hanzo/ui/lib/utils), which require
+9 -1
View File
@@ -62,7 +62,15 @@ export function CommandMenu({ ...props }: DialogProps) {
const { recentSearches, addRecentSearch, clearRecentSearches } =
useRecentSearches()
const modKey = React.useMemo(() => (isMacOS() ? "⌘" : "Ctrl"), [])
// SSR renders without `window`, so the first client (hydration) render MUST
// also produce "Ctrl" — computing the platform-specific glyph during render
// makes the hydrated text ("⌘" on macOS) diverge from the server text
// ("Ctrl") → React #418 (hydration text mismatch). Start from the SSR-stable
// value and upgrade to the platform glyph after mount (client-only effect).
const [modKey, setModKey] = React.useState("Ctrl")
React.useEffect(() => {
if (isMacOS()) setModKey("⌘")
}, [])
React.useEffect(() => {
const down = (e: KeyboardEvent) => {
+2 -2
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": "workspace:@hanzo/ui-shadcn@^",
"@hookform/resolvers": "5.2.2",
"@monaco-editor/react": "^4.7.0",
"@radix-ui/react-accessible-icon": "^1.1.8",
@@ -156,7 +156,7 @@
"postcss-selector-parser": "^7.1.1",
"prettier": "^3.8.1",
"prettier-plugin-tailwindcss": "^0.7.2",
"puppeteer": "^24.40.0",
"puppeteer": "^25.1.0",
"rehype-pretty-code": "^0.14.3",
"rimraf": "^6.1.3",
"sharp": "^0.34.5",
+1 -1
View File
@@ -1,4 +1,4 @@
import { docs } from "@/.source"
import { docs } from "@/.source/server"
import { loader } from "fumadocs-core/source"
export const source = loader({
+5 -5
View File
@@ -50,10 +50,10 @@
"embla-carousel-autoplay": "8.5.2",
"embla-carousel-react": "8.5.2",
"front-matter": "^4.0.2",
"fumadocs-core": "16.0.5",
"fumadocs-docgen": "2.0.0",
"fumadocs-mdx": "13.0.2",
"fumadocs-ui": "16.0.5",
"fumadocs-core": "16.10.3",
"fumadocs-docgen": "3.0.10",
"fumadocs-mdx": "15.0.12",
"fumadocs-ui": "16.10.3",
"input-otp": "^1.4.2",
"jotai": "^2.15.0",
"little-date": "^1.0.0",
@@ -103,7 +103,7 @@
"tailwindcss": "^4",
"tw-animate-css": "^1.4.0",
"typescript": "^5",
"unist-builder": "3.0.0",
"unist-builder": "4.0.0",
"unist-util-visit": "^5.1.0"
},
"prettier": {
+78
View File
@@ -0,0 +1,78 @@
'use client'
/**
* The composable sign-in surface, end to end.
*
* This file is the CONFIGURATION layer — zero auth code. It picks a method set
* (`brand.providers`) and hands it to `<SignIn>`. The PRESENTATION layer
* (`@hanzo/ui` `<SignIn>` / `<SocialButton>` / `<PasswordForm>`) renders it,
* brand-themed by CSS tokens. The MECHANISM layer (`@hanzo/iam`) runs the one
* Authorization-Code + PKCE flow — `provider` is the only knob.
*
* Adoption: every app collapses its hand-rolled `iam-auth.ts` + bespoke login
* page to exactly this — `import { SignIn } from '@hanzo/ui/auth'` +
* `import { configureIam, startLogin, getIam } from '@hanzo/iam'`.
*/
import * as React from 'react'
import { SignIn } from '../src/auth'
// ── App brand config (the ONLY app-level knob) ──────────────────────────────
//
// Social/web3 are LIVE: one shared org-level OAuth provider per network in IAM
// (admin/provider-google, admin/provider-github), reused by every app via a
// `canSignIn` toggle. Apps NEVER register per-app Google/GitHub clients — the
// button just delegates with `provider`.
const brand = {
serverUrl: 'https://hanzo.id',
clientId: 'hanzo-app',
providers: ['password', 'google', 'github', 'web3'] as const,
}
export function SignInDemo() {
// The mechanism comes from `@hanzo/iam`. Configure once; pass the SDK's
// `startLogin` (provider knob) and `loginWithPassword` (embedded credential)
// into the presentation. Lazy-imported so this demo file stays render-only.
const [iam, setIam] = React.useState<null | {
startLogin: (o: { provider?: string }) => Promise<void>
loginWithPassword: (email: string, password: string) => Promise<string>
}>(null)
React.useEffect(() => {
let alive = true
void import('@hanzo/iam').then((sdk) => {
sdk.configureIam({ issuer: brand.serverUrl, clientId: brand.clientId })
const engine = sdk.getIam()
if (alive) {
setIam({
startLogin: (o) => sdk.startLogin(o),
loginWithPassword: (email, password) => engine.loginWithPassword(email, password),
})
}
})
return () => {
alive = false
}
}, [])
return (
<div className="mx-auto flex min-h-[480px] w-full max-w-sm items-center justify-center">
<SignIn
title="Sign in"
providers={[...brand.providers]}
config={{ serverUrl: brand.serverUrl, clientId: brand.clientId }}
// Social/web3: delegate to the SDK's startLogin (adds &provider=…).
onLogin={iam ? (opts) => iam.startLogin({ provider: opts.provider }) : undefined}
// Password: the SDK's embedded credential login → PKCE-bound code.
onPasswordSubmit={
iam
? async ({ email, password }) => {
const callbackUrl = await iam.loginWithPassword(email, password)
window.location.href = callbackUrl
}
: undefined
}
/>
</div>
)
}
+4 -5
View File
@@ -11,8 +11,7 @@
"workspaces": [
"app",
"apps/*",
"packages/*",
"pkg/*"
"pkgs/*"
],
"scripts": {
"build": "turbo run build",
@@ -51,10 +50,10 @@
"format:write": "turbo run format:write",
"format:check": "turbo run format:check",
"sync:templates": "./scripts/sync-templates.sh \"templates/*\"",
"check": "turbo lint typecheck format:check",
"check": "bash scripts/check-no-brand-pkg.sh && turbo lint typecheck format:check",
"release": "changeset version",
"pub:beta": "cd packages/hanzo && pnpm pub:beta",
"pub:release": "cd packages/hanzo && pnpm pub:release",
"pub:beta": "cd pkgs/hanzo && pnpm pub:beta",
"pub:release": "cd pkgs/hanzo && pnpm pub:release",
"test:dev": "turbo run test --filter=!hanzo-ui --force",
"test": "start-server-and-test v4:dev http://localhost:4000 test:dev",
"validate:registries": "pnpm --filter=v4 validate:registries",
@@ -1,2 +0,0 @@
DATABASE_URL="file:./data.db?connection_limit=1"
SESSION_SECRET="super-duper-s3cret"
@@ -1 +0,0 @@
legacy-peer-deps=true
-5
View File
@@ -1,5 +0,0 @@
.npmrc
node_modules/
dist/
.turbo/
*.tsbuildinfo
-32
View File
@@ -1,32 +0,0 @@
{
"name": "@hanzo/brand",
"version": "1.3.1",
"description": "Brand assets and configuration for Hanzo UI",
"main": "dist/index.js",
"module": "dist/index.mjs",
"types": "dist/index.d.ts",
"files": [
"dist",
"assets"
],
"scripts": {
"build": "tsup",
"dev": "tsup --watch",
"clean": "rm -rf dist"
},
"keywords": [
"hanzo",
"brand",
"ui",
"design-system"
],
"author": "Hanzo AI",
"license": "MIT",
"devDependencies": {
"tsup": "^8.5.1",
"typescript": "^5.9.3"
},
"publishConfig": {
"access": "public"
}
}
-32
View File
@@ -1,32 +0,0 @@
/**
* @hanzo/brand — Hanzo brand only.
*
* Each org owns its own brand package. Do NOT add other orgs here.
* Zoo → @zooai/brand
* Lux → @luxfi/brand
* Tenant → @<tenant>/brand
*/
export type {
OrgId,
ColorMode,
BrandColors,
BrandLogo,
BrandSocial,
BrandSeo,
BrandConfig,
} from './types'
export {
hanzoMark,
hanzoMarkDark,
hanzoMarkLight,
hanzoWordmark,
hanzoWordmarkDark,
hanzoWordmarkLight,
hanzoFavicon,
svgToDataUri,
} from './svg'
export { hanzo, orgs, getOrg } from './orgs'
export { hanzo as defaultBrand } from './orgs'
-73
View File
@@ -1,73 +0,0 @@
/**
* Hanzo brand configuration.
*
* Separation of concerns: this file owns ONLY Hanzo's identity.
* @zooai/brand → Zoo (github.com/zooai/brand)
* @luxfi/brand → Lux (github.com/luxfi/brand)
* @<tenant>/brand → Tenant (github.com/<tenant>/brand)
*
* Never add other orgs' identities here.
*/
import type { BrandConfig } from './types'
import {
hanzoMark,
hanzoMarkDark,
hanzoMarkLight,
hanzoWordmark,
hanzoWordmarkDark,
hanzoWordmarkLight,
hanzoFavicon,
} from './svg'
export const hanzo: BrandConfig = {
id: 'hanzo',
name: 'Hanzo',
orgName: 'Hanzo AI',
orgHandle: 'hanzoai',
tagline: 'AI infrastructure for the future',
description: 'Frontier AI infrastructure, foundational models, and developer tools.',
npmOrg: '@hanzo',
domain: 'hanzo.ai',
iamDomain: 'hanzo.id',
githubOrg: 'hanzoai',
docsUrl: 'https://docs.hanzo.ai',
logo: {
mark: hanzoMark,
markDark: hanzoMarkDark,
markLight: hanzoMarkLight,
wordmark: hanzoWordmark,
wordmarkDark: hanzoWordmarkDark,
wordmarkLight: hanzoWordmarkLight,
favicon: hanzoFavicon,
width: 67,
height: 67,
},
colors: {
primary: '222.2 47.4% 11.2%',
primaryForeground: '210 40% 98%',
secondary: '210 40% 96.1%',
secondaryForeground: '222.2 47.4% 11.2%',
accent: '210 40% 96.1%',
accentForeground: '222.2 47.4% 11.2%',
},
social: {
twitter: '@hanzoai',
github: 'https://github.com/hanzoai',
},
seo: {
title: 'Hanzo AI — Infrastructure for AI',
description: 'Frontier AI infrastructure and developer tools.',
keywords: ['ai', 'infrastructure', 'hanzo', 'llm', 'mcp'],
},
}
export const orgs = { hanzo }
export function getOrg(): BrandConfig {
return hanzo
}
-49
View File
@@ -1,49 +0,0 @@
/**
* Raw SVG strings for brand marks.
*
* These are plain strings, not React components.
* Consumers can use dangerouslySetInnerHTML, inline them in <img> via data URIs,
* or pass them to any framework.
*/
// ---------------------------------------------------------------------------
// Hanzo H-mark (viewBox 0 0 67 67)
// ---------------------------------------------------------------------------
/** Hanzo H-mark -- white fill on transparent, for dark backgrounds */
export const hanzoMarkDark = `<svg viewBox="0 0 67 67" xmlns="http://www.w3.org/2000/svg"><path d="M22.21 67V44.6369H0V67H22.21Z" fill="#ffffff"/><path d="M0 44.6369L22.21 46.8285V44.6369H0Z" fill="#DDDDDD"/><path d="M66.7038 22.3184H22.2534L0.0878906 44.6367H44.4634L66.7038 22.3184Z" fill="#ffffff"/><path d="M22.21 0H0V22.3184H22.21V0Z" fill="#ffffff"/><path d="M66.7198 0H44.5098V22.3184H66.7198V0Z" fill="#ffffff"/><path d="M66.6753 22.3185L44.5098 20.0822V22.3185H66.6753Z" fill="#DDDDDD"/><path d="M66.7198 67V44.6369H44.5098V67H66.7198Z" fill="#ffffff"/></svg>`
/** Hanzo H-mark -- dark fill on transparent, for light backgrounds */
export const hanzoMarkLight = `<svg viewBox="0 0 67 67" xmlns="http://www.w3.org/2000/svg"><path d="M22.21 67V44.6369H0V67H22.21Z" fill="#111113"/><path d="M0 44.6369L22.21 46.8285V44.6369H0Z" fill="#333333"/><path d="M66.7038 22.3184H22.2534L0.0878906 44.6367H44.4634L66.7038 22.3184Z" fill="#111113"/><path d="M22.21 0H0V22.3184H22.21V0Z" fill="#111113"/><path d="M66.7198 0H44.5098V22.3184H66.7198V0Z" fill="#111113"/><path d="M66.6753 22.3185L44.5098 20.0822V22.3185H66.6753Z" fill="#333333"/><path d="M66.7198 67V44.6369H44.5098V67H66.7198Z" fill="#111113"/></svg>`
/** Hanzo H-mark -- currentColor fill, inherits CSS color */
export const hanzoMark = `<svg viewBox="0 0 67 67" xmlns="http://www.w3.org/2000/svg"><path d="M22.21 67V44.6369H0V67H22.21Z" fill="currentColor"/><path d="M0 44.6369L22.21 46.8285V44.6369H0Z" fill="currentColor" opacity="0.85"/><path d="M66.7038 22.3184H22.2534L0.0878906 44.6367H44.4634L66.7038 22.3184Z" fill="currentColor"/><path d="M22.21 0H0V22.3184H22.21V0Z" fill="currentColor"/><path d="M66.7198 0H44.5098V22.3184H66.7198V0Z" fill="currentColor"/><path d="M66.6753 22.3185L44.5098 20.0822V22.3185H66.6753Z" fill="currentColor" opacity="0.85"/><path d="M66.7198 67V44.6369H44.5098V67H66.7198Z" fill="currentColor"/></svg>`
// ---------------------------------------------------------------------------
// Hanzo wordmark (text-based SVG)
// ---------------------------------------------------------------------------
/** Hanzo wordmark -- white, for dark backgrounds */
export const hanzoWordmarkDark = `<svg viewBox="0 0 200 40" xmlns="http://www.w3.org/2000/svg"><text x="0" y="32" font-family="system-ui,-apple-system,sans-serif" font-size="36" font-weight="700" fill="#ffffff" letter-spacing="-0.02em">HANZO</text></svg>`
/** Hanzo wordmark -- dark, for light backgrounds */
export const hanzoWordmarkLight = `<svg viewBox="0 0 200 40" xmlns="http://www.w3.org/2000/svg"><text x="0" y="32" font-family="system-ui,-apple-system,sans-serif" font-size="36" font-weight="700" fill="#111113" letter-spacing="-0.02em">HANZO</text></svg>`
/** Hanzo wordmark -- currentColor */
export const hanzoWordmark = `<svg viewBox="0 0 200 40" xmlns="http://www.w3.org/2000/svg"><text x="0" y="32" font-family="system-ui,-apple-system,sans-serif" font-size="36" font-weight="700" fill="currentColor" letter-spacing="-0.02em">HANZO</text></svg>`
// ---------------------------------------------------------------------------
// Favicon (viewBox 0 0 67 67, same as mark -- suitable for <link rel="icon">)
// ---------------------------------------------------------------------------
/** Favicon SVG -- white on dark background with rounded rect */
export const hanzoFavicon = `<svg viewBox="0 0 67 67" xmlns="http://www.w3.org/2000/svg"><rect width="67" height="67" rx="12" fill="#111113"/><path d="M22.21 67V44.6369H0V67H22.21Z" fill="#ffffff" transform="scale(0.7) translate(13,13)"/><path d="M0 44.6369L22.21 46.8285V44.6369H0Z" fill="#DDDDDD" transform="scale(0.7) translate(13,13)"/><path d="M66.7038 22.3184H22.2534L0.0878906 44.6367H44.4634L66.7038 22.3184Z" fill="#ffffff" transform="scale(0.7) translate(13,13)"/><path d="M22.21 0H0V22.3184H22.21V0Z" fill="#ffffff" transform="scale(0.7) translate(13,13)"/><path d="M66.7198 0H44.5098V22.3184H66.7198V0Z" fill="#ffffff" transform="scale(0.7) translate(13,13)"/><path d="M66.6753 22.3185L44.5098 20.0822V22.3185H66.6753Z" fill="#DDDDDD" transform="scale(0.7) translate(13,13)"/><path d="M66.7198 67V44.6369H44.5098V67H66.7198Z" fill="#ffffff" transform="scale(0.7) translate(13,13)"/></svg>`
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/** Convert an SVG string to a data URI for use in <img src> or CSS url() */
export function svgToDataUri(svg: string): string {
return `data:image/svg+xml,${encodeURIComponent(svg)}`
}
-89
View File
@@ -1,89 +0,0 @@
/**
* Shared types for brand configuration.
*/
export type OrgId = 'hanzo'
export type ColorMode = 'light' | 'dark'
export interface BrandColors {
/** HSL value (without hsl() wrapper), e.g. "222.2 47.4% 11.2%" */
primary: string
primaryForeground: string
secondary: string
secondaryForeground: string
accent: string
accentForeground: string
}
export interface BrandLogo {
/** SVG string -- currentColor variant */
mark: string
/** SVG string -- dark background variant (light fill) */
markDark: string
/** SVG string -- light background variant (dark fill) */
markLight: string
/** SVG string -- currentColor wordmark */
wordmark: string
/** SVG string -- dark background wordmark */
wordmarkDark: string
/** SVG string -- light background wordmark */
wordmarkLight: string
/** SVG string for favicon */
favicon: string
/** viewBox width */
width: number
/** viewBox height */
height: number
}
export interface BrandSocial {
twitter?: string
discord?: string
github: string
}
export interface BrandSeo {
title: string
description: string
keywords: string[]
}
export interface BrandConfig {
/** Short org identifier */
id: OrgId
/** Display name */
name: string
/** Full organization name */
orgName: string
/** Org handle (lowercase, no spaces) */
orgHandle: string
/** One-liner */
tagline: string
/** Longer description */
description: string
/** npm scope, e.g. "@hanzo" */
npmOrg: string
/** Primary domain */
domain: string
/** IAM domain for auth */
iamDomain: string
/** GitHub org handle */
githubOrg: string
/** Docs URL */
docsUrl: string
/** Logo/mark assets (SVG strings) */
logo: BrandLogo
/** Brand colors (HSL values) */
colors: BrandColors
/** Social links */
social: BrandSocial
/** SEO defaults */
seo: BrandSeo
}
-18
View File
@@ -1,18 +0,0 @@
{
"compilerOptions": {
"target": "es2020",
"module": "esnext",
"moduleResolution": "bundler",
"declaration": true,
"declarationMap": true,
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"isolatedModules": true,
"outDir": "dist",
"rootDir": "src"
},
"include": ["src"],
"exclude": ["node_modules", "dist"]
}
@@ -1,13 +0,0 @@
// tsup.config.ts
import { defineConfig } from "tsup";
var tsup_config_default = defineConfig({
entry: ["src/index.ts"],
format: ["cjs", "esm"],
dts: true,
clean: true,
sourcemap: true
});
export {
tsup_config_default as default
};
//# sourceMappingURL=data:application/json;base64,ewogICJ2ZXJzaW9uIjogMywKICAic291cmNlcyI6IFsidHN1cC5jb25maWcudHMiXSwKICAic291cmNlc0NvbnRlbnQiOiBbImNvbnN0IF9faW5qZWN0ZWRfZmlsZW5hbWVfXyA9IFwiL1VzZXJzL3ovd29yay9oYW56by91aS9wa2cvYnJhbmQvdHN1cC5jb25maWcudHNcIjtjb25zdCBfX2luamVjdGVkX2Rpcm5hbWVfXyA9IFwiL1VzZXJzL3ovd29yay9oYW56by91aS9wa2cvYnJhbmRcIjtjb25zdCBfX2luamVjdGVkX2ltcG9ydF9tZXRhX3VybF9fID0gXCJmaWxlOi8vL1VzZXJzL3ovd29yay9oYW56by91aS9wa2cvYnJhbmQvdHN1cC5jb25maWcudHNcIjtpbXBvcnQgeyBkZWZpbmVDb25maWcgfSBmcm9tIFwidHN1cFwiXG5cbmV4cG9ydCBkZWZhdWx0IGRlZmluZUNvbmZpZyh7XG4gIGVudHJ5OiBbXCJzcmMvaW5kZXgudHNcIl0sXG4gIGZvcm1hdDogW1wiY2pzXCIsIFwiZXNtXCJdLFxuICBkdHM6IHRydWUsXG4gIGNsZWFuOiB0cnVlLFxuICBzb3VyY2VtYXA6IHRydWUsXG59KVxuIl0sCiAgIm1hcHBpbmdzIjogIjtBQUE4TyxTQUFTLG9CQUFvQjtBQUUzUSxJQUFPLHNCQUFRLGFBQWE7QUFBQSxFQUMxQixPQUFPLENBQUMsY0FBYztBQUFBLEVBQ3RCLFFBQVEsQ0FBQyxPQUFPLEtBQUs7QUFBQSxFQUNyQixLQUFLO0FBQUEsRUFDTCxPQUFPO0FBQUEsRUFDUCxXQUFXO0FBQ2IsQ0FBQzsiLAogICJuYW1lcyI6IFtdCn0K
-9
View File
@@ -1,9 +0,0 @@
import { defineConfig } from "tsup"
export default defineConfig({
entry: ["src/index.ts"],
format: ["cjs", "esm"],
dts: true,
clean: true,
sourcemap: true,
})
+8
View File
@@ -0,0 +1,8 @@
# 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
@@ -0,0 +1,69 @@
# @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
@@ -0,0 +1,13 @@
// 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
@@ -0,0 +1,15 @@
/**
* 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
@@ -0,0 +1,59 @@
{
"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
@@ -0,0 +1,166 @@
'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
@@ -0,0 +1,83 @@
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
@@ -0,0 +1,117 @@
// 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
@@ -0,0 +1,74 @@
'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
@@ -0,0 +1,12 @@
// 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
@@ -0,0 +1,17 @@
// 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
@@ -0,0 +1,199 @@
// 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
@@ -0,0 +1,368 @@
// 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
@@ -0,0 +1,52 @@
// 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
@@ -0,0 +1,40 @@
// 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
@@ -0,0 +1,142 @@
// 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
@@ -0,0 +1,119 @@
// @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
@@ -0,0 +1,98 @@
// 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
@@ -0,0 +1,42 @@
// 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
@@ -0,0 +1,81 @@
// 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
@@ -0,0 +1,44 @@
// 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
@@ -0,0 +1,4 @@
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
@@ -0,0 +1,143 @@
'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
@@ -0,0 +1,369 @@
'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
@@ -0,0 +1,50 @@
// 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
@@ -0,0 +1,171 @@
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
@@ -0,0 +1,384 @@
// 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
@@ -0,0 +1,40 @@
// 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
@@ -0,0 +1,194 @@
'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
@@ -0,0 +1,255 @@
'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
@@ -0,0 +1,98 @@
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
@@ -0,0 +1,109 @@
// 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
@@ -0,0 +1,30 @@
// 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
@@ -0,0 +1,19 @@
{
"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
@@ -0,0 +1,11 @@
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
@@ -0,0 +1,8 @@
# 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
+42 -210
View File
@@ -1,229 +1,61 @@
# @hanzo/ui
A comprehensive UI component library for Hanzo applications, built with React and TypeScript.
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.
## Version 4.5.6
> 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.
## Installation
## Install
```bash
npm install @hanzo/ui
# or
pnpm add @hanzo/ui
# or
yarn add @hanzo/ui
```sh
bun add @hanzo/ui @hanzo/gui @hanzo/data
```
## Components
`@hanzo/gui`, `@hanzo/data`, and `react` are peers.
### Primitives
Core UI components based on Radix UI primitives:
- **Accordion** - Collapsible content panels
- **Alert** - Informative alert messages
- **AlertDialog** - Modal dialogs for important alerts
- **Avatar** - User avatar display
- **Badge** - Status and label badges
- **Breadcrumb** - Navigation breadcrumbs
- **Button** - Interactive buttons with variants
- **Calendar** - Date picker calendar
- **Card** - Container cards for content
- **Carousel** - Image/content carousel
- **Checkbox** - Checkbox input
- **Collapsible** - Collapsible content sections
- **Combobox** - Searchable select dropdown
- **Command** - Command palette component
- **ContextMenu** - Right-click context menus
- **Dialog** - Modal dialogs
- **Drawer** - Slide-out drawer panels
- **DropdownMenu** - Dropdown menu component
- **Form** - Form components with validation
- **HoverCard** - Hover-triggered info cards
- **Input** - Text input field
- **InputOTP** - One-time password input
- **Label** - Form labels
- **NavigationMenu** - Navigation menu bar
- **Popover** - Popover overlays
- **Progress** - Progress indicators
- **RadioGroup** - Radio button groups
- **ResizablePanel** - Resizable panel layouts
- **ScrollArea** - Custom scrollable areas
- **SearchInput** - Search input with icon
- **Select** - Select dropdown
- **Separator** - Visual separator line
- **Sheet** - Side sheet panels
- **Skeleton** - Loading skeleton screens
- **Slider** - Range slider input
- **Switch** - Toggle switch
- **Table** - Data tables
- **Tabs** - Tabbed interfaces
- **TextArea** - Multi-line text input
- **TextField** - Enhanced text input
- **Toast** - Toast notifications (via Sonner)
- **Toggle** - Toggle buttons
- **ToggleGroup** - Grouped toggle buttons
- **Tooltip** - Hover tooltips
- **VideoPlayer** - Video playback component
### Assets
Icon components and visual assets:
#### AI Provider Icons
- **AnthropicIcon** - Anthropic AI logo
- **OpenAIIcon** - OpenAI logo
- **GeminiIcon** - Google Gemini logo
- **DeepSeekIcon** - DeepSeek logo
- **MistralIcon** - Mistral AI logo
- **MetaIcon** - Meta AI logo
- **GroqIcon** - Groq logo
- **OllamaIcon** - Ollama logo
- **HanzoIcon** - Hanzo AI logo
- **TogetherAI** - Together AI logo
- **ExoIcon** - Exo logo
- **GrokIcon** - Grok logo
- **LmStudioIcon** - LM Studio logo
- **OpenRouterIcon** - OpenRouter logo
- **PerplexityIcon** - Perplexity logo
- **QwenIcon** - Qwen logo
- **AyaCohereIcon** - Aya/Cohere logo
#### Feature Icons
- **AIAgentIcon** - AI agent indicator
- **AisIcon** - AI services icon
- **ReactJsIcon** - React.js logo
- **ReasoningIcon** - AI reasoning indicator
- **ToolsIcon** - Tools/utilities icon
- **TracingIcon** - Tracing/monitoring icon
- **ScheduledTasksIcon** - Scheduled tasks icon
- **SendIcon** - Send/submit icon
#### File Type Icons
- **FileTypeIcon** - Dynamic file type icon based on extension
- **DirectoryTypeIcon** - Folder/directory icon
### Utilities
Helper functions and hooks:
- **cn()** - Class name utility (clsx + tailwind-merge)
- **markdown()** - Markdown to JSX converter
- **formatText()** - Text formatting utilities
- **useDebounce()** - Debounce hook
- **useMap()** - Map state management hook
- **formatDateToLocaleStringWithTime()** - Date formatting
- **getFileExt()** - File extension extraction
- **hexToRgb()** - Color conversion utilities
### Custom Components
Additional enhanced components:
- **ChatInput** - Chat message input
- **ChatInputArea** - Multi-line chat input
- **ChatSettingsIcon** - Chat settings icon
- **CopyToClipboardIcon** - Copy to clipboard button
- **DotsLoader** - Loading dots animation
- **FileList** - File list display
- **FileUploader** - File upload component
- **JsonForm** - JSON-based dynamic forms
- **MarkdownText** - Markdown renderer
- **PrettyJsonPrint** - Formatted JSON display
## Styling
The library uses Tailwind CSS for styling. Make sure your application includes Tailwind CSS configuration.
## Dependencies
Key peer dependencies:
- React 18.3.1+
- React DOM 18.3.1+
- @hookform/resolvers ^3.3.2
- react-hook-form 7.51.4
- lucide-react 0.456.0
- next-themes ^0.2.1
- embla-carousel ^8.1.6
## Usage Examples
### Basic Button
## Two orthogonal layers, one package
```tsx
import { Button } from '@hanzo/ui';
// product / app layer — charts, metrics, headers, status, empty states, combobox…
import { PageHeader, Sparkline, LineChart, EmptyState, StatusTag, ComboBox, tokens } from '@hanzo/ui'
function App() {
return (
<Button variant="primary" onClick={() => console.log('clicked')}>
Click me
</Button>
);
}
// metadata-driven record layer — RecordsView & the typed field editors (@hanzo/data)
import { RecordsView, DataTable, BoardView, RecordDetail, registerField } from '@hanzo/ui/data'
```
### Alert Dialog
- **`@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 {
AlertDialog,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogAction,
AlertDialogCancel
} from '@hanzo/ui';
function ConfirmDialog() {
return (
<AlertDialog>
<AlertDialogTrigger>
<Button>Open Dialog</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Are you sure?</AlertDialogTitle>
<AlertDialogDescription>
This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction>Continue</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}
import { registerField } from '@hanzo/ui/data'
registerField('rating', { Display: MyStars, Input: MyStarPicker })
```
### AI Provider Icons
## Design principles
```tsx
import { OpenAIIcon, AnthropicIcon, GeminiIcon } from '@hanzo/ui/assets';
- **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`.
function AIProviders() {
return (
<div className="flex gap-4">
<OpenAIIcon className="h-6 w-6" />
<AnthropicIcon className="h-6 w-6" />
<GeminiIcon className="h-6 w-6" />
</div>
);
}
```
## License
BSD-3-Clause
## Author
Hanzo AI, Inc.
## Repository
https://github.com/hanzoai/react-sdk
BSD-3-Clause · Hanzo AI
+13
View File
@@ -0,0 +1,13 @@
// 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
@@ -0,0 +1,15 @@
/**
* 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 {}
}
+52 -890
View File
@@ -1,901 +1,63 @@
{
"name": "@hanzo/ui",
"version": "5.6.2",
"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",
"scope": "@hanzo"
"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"
},
"author": "Hanzo AI, Inc.",
"license": "BSD-3-Clause",
"main": "./dist/index.js",
"module": "./dist/index.mjs",
"types": "./dist/index.d.ts",
"sideEffects": false,
"files": [
"dist",
"docs",
"content",
"style",
"bin",
"finance",
"README.md",
"LICENSE"
"main": "./src/index.ts",
"module": "./src/index.ts",
"types": "./src/index.ts",
"sideEffects": [
"**/*.css"
],
"repository": {
"type": "git",
"url": "git+https://github.com/hanzoai/ui.git",
"directory": "pkg/ui"
},
"keywords": [
"components",
"ecommerce",
"analytics",
"ai",
"chat",
"finance",
"trading",
"tradingview",
"hanzo",
"hanzo-ai",
"hanzoai",
"radix-ui",
"react",
"sdk",
"ui"
"files": [
"src"
],
"scripts": {
"lat": "npm show @hanzo/ui version",
"pub": "npm publish",
"tc": "tsc --project tsconfig.build.json",
"build": "npm run clean && npm run build:minimal && npm run tc",
"build:minimal": "tsup --config tsup.config.minimal.ts",
"build:namespaces": "tsup --config tsup.config.namespaces.ts",
"build:full": "tsup --config tsup.config.production.ts",
"build:frameworks": "npm run build",
"build:watch": "tsup --watch",
"build:check": "tsc --project tsconfig.build.json --noEmit",
"clean": "rm -rf dist",
"prepublishOnly": "npm run build",
"update-registry": "node ./bin/update-registry.js",
"registry:mcp": "node ./bin/cli.js registry:mcp",
"registry:mcp:http": "node ./bin/cli.js registry:mcp --http",
"analyze:frameworks": "node analyze-frameworks.js",
"test": "vitest",
"test:ui": "vitest --ui",
"test:coverage": "vitest --coverage",
"test:jest": "jest test/multi-framework.test.js",
"test:frameworks": "jest test/multi-framework.test.js --verbose"
},
"bin": {
"hanzo-ui": "./bin/cli.js"
},
"exports": {
"./package.json": "./package.json",
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.mjs",
"require": "./dist/index.js"
},
"./system": {
"types": "./dist/system/index.d.ts",
"import": "./dist/system/index.mjs",
"require": "./dist/system/index.js"
},
"./system/*": {
"types": "./dist/system/*.d.ts",
"import": "./dist/system/*.mjs",
"require": "./dist/system/*.js"
},
"./registry": {
"types": "./dist/src/registry/index.d.ts",
"import": "./dist/src/registry/index.mjs",
"require": "./dist/src/registry/index.js"
},
"./registry/*": {
"types": "./dist/registry/*.d.ts",
"import": "./dist/registry/*.mjs",
"require": "./dist/registry/*.js"
},
"./primitives/bases/gui": {
"types": "./dist/primitives/bases/gui/index.d.ts",
"import": "./dist/primitives/bases/gui/index.mjs",
"require": "./dist/primitives/bases/gui/index.js"
},
"./primitives/bases/gui/*": {
"types": "./dist/primitives/bases/gui/*.d.ts",
"import": "./dist/primitives/bases/gui/*.mjs",
"require": "./dist/primitives/bases/gui/*.js"
},
"./primitives/bases/admin": {
"types": "./dist/primitives/bases/admin/index.d.ts",
"import": "./dist/primitives/bases/admin/index.mjs",
"require": "./dist/primitives/bases/admin/index.js"
},
"./primitives/bases/admin/*": {
"types": "./dist/primitives/bases/admin/*.d.ts",
"import": "./dist/primitives/bases/admin/*.mjs",
"require": "./dist/primitives/bases/admin/*.js"
},
"./primitives/bases/svelte": {
"types": "./dist/primitives/bases/svelte/index.d.ts",
"import": "./dist/primitives/bases/svelte/index.mjs",
"require": "./dist/primitives/bases/svelte/index.js"
},
"./primitives/bases/vue": {
"types": "./dist/primitives/bases/vue/index.d.ts",
"import": "./dist/primitives/bases/vue/index.mjs",
"require": "./dist/primitives/bases/vue/index.js"
},
"./calendar": {
"types": "./dist/calendar.d.ts",
"import": "./dist/calendar.mjs",
"require": "./dist/calendar.js"
},
"./command": {
"types": "./dist/command.d.ts",
"import": "./dist/command.mjs",
"require": "./dist/command.js"
},
"./accordion": {
"types": "./dist/accordion.d.ts",
"import": "./dist/accordion.mjs",
"require": "./dist/accordion.js"
},
"./alert": {
"types": "./dist/alert.d.ts",
"import": "./dist/alert.mjs",
"require": "./dist/alert.js"
},
"./alert-dialog": {
"types": "./dist/alert-dialog.d.ts",
"import": "./dist/alert-dialog.mjs",
"require": "./dist/alert-dialog.js"
},
"./avatar": {
"types": "./dist/avatar.d.ts",
"import": "./dist/avatar.mjs",
"require": "./dist/avatar.js"
},
"./badge": {
"types": "./dist/badge.d.ts",
"import": "./dist/badge.mjs",
"require": "./dist/badge.js"
},
"./breadcrumb": {
"types": "./dist/breadcrumb.d.ts",
"import": "./dist/breadcrumb.mjs",
"require": "./dist/breadcrumb.js"
},
"./checkbox": {
"types": "./dist/checkbox.d.ts",
"import": "./dist/checkbox.mjs",
"require": "./dist/checkbox.js"
},
"./collapsible": {
"types": "./dist/collapsible.d.ts",
"import": "./dist/collapsible.mjs",
"require": "./dist/collapsible.js"
},
"./context-menu": {
"types": "./dist/context-menu.d.ts",
"import": "./dist/context-menu.mjs",
"require": "./dist/context-menu.js"
},
"./dialog": {
"types": "./dist/dialog.d.ts",
"import": "./dist/dialog.mjs",
"require": "./dist/dialog.js"
},
"./dropdown-menu": {
"types": "./dist/dropdown-menu.d.ts",
"import": "./dist/dropdown-menu.mjs",
"require": "./dist/dropdown-menu.js"
},
"./hover-card": {
"types": "./dist/hover-card.d.ts",
"import": "./dist/hover-card.mjs",
"require": "./dist/hover-card.js"
},
"./navigation-menu": {
"types": "./dist/navigation-menu.d.ts",
"import": "./dist/navigation-menu.mjs",
"require": "./dist/navigation-menu.js"
},
"./popover": {
"types": "./dist/popover.d.ts",
"import": "./dist/popover.mjs",
"require": "./dist/popover.js"
},
"./progress": {
"types": "./dist/progress.d.ts",
"import": "./dist/progress.mjs",
"require": "./dist/progress.js"
},
"./radio-group": {
"types": "./dist/radio-group.d.ts",
"import": "./dist/radio-group.mjs",
"require": "./dist/radio-group.js"
},
"./scroll-area": {
"types": "./dist/scroll-area.d.ts",
"import": "./dist/scroll-area.mjs",
"require": "./dist/scroll-area.js"
},
"./select": {
"types": "./dist/select.d.ts",
"import": "./dist/select.mjs",
"require": "./dist/select.js"
},
"./separator": {
"types": "./dist/separator.d.ts",
"import": "./dist/separator.mjs",
"require": "./dist/separator.js"
},
"./sheet": {
"types": "./dist/sheet.d.ts",
"import": "./dist/sheet.mjs",
"require": "./dist/sheet.js"
},
"./skeleton": {
"types": "./dist/skeleton.d.ts",
"import": "./dist/skeleton.mjs",
"require": "./dist/skeleton.js"
},
"./slider": {
"types": "./dist/slider.d.ts",
"import": "./dist/slider.mjs",
"require": "./dist/slider.js"
},
"./switch": {
"types": "./dist/switch.d.ts",
"import": "./dist/switch.mjs",
"require": "./dist/switch.js"
},
"./table": {
"types": "./dist/table.d.ts",
"import": "./dist/table.mjs",
"require": "./dist/table.js"
},
"./tabs": {
"types": "./dist/tabs.d.ts",
"import": "./dist/tabs.mjs",
"require": "./dist/tabs.js"
},
"./textarea": {
"types": "./dist/textarea.d.ts",
"import": "./dist/textarea.mjs",
"require": "./dist/textarea.js"
},
"./toggle": {
"types": "./dist/toggle.d.ts",
"import": "./dist/toggle.mjs",
"require": "./dist/toggle.js"
},
"./toggle-group": {
"types": "./dist/toggle-group.d.ts",
"import": "./dist/toggle-group.mjs",
"require": "./dist/toggle-group.js"
},
"./tooltip": {
"types": "./dist/tooltip.d.ts",
"import": "./dist/tooltip.mjs",
"require": "./dist/tooltip.js"
},
"./carousel": {
"types": "./dist/carousel.d.ts",
"import": "./dist/carousel.mjs",
"require": "./dist/carousel.js"
},
"./form": {
"types": "./dist/form/index.d.ts",
"import": "./dist/form/index.mjs",
"require": "./dist/form/index.js"
},
"./drawer": {
"types": "./dist/drawer.d.ts",
"import": "./dist/drawer.mjs",
"require": "./dist/drawer.js"
},
"./dash": {
"types": "./dist/src/dash/index.d.ts",
"import": "./dist/dash/index.mjs",
"require": "./dist/dash/index.js"
},
"./dash/tokens.css": "./src/dash/tokens.css",
"./dash/*": {
"types": "./dist/src/dash/*.d.ts",
"import": "./dist/dash/*.mjs",
"require": "./dist/dash/*.js"
},
"./billing": {
"types": "./dist/src/billing/index.d.ts",
"import": "./dist/billing/index.mjs",
"require": "./dist/billing/index.js"
},
"./finance": {
"types": "./dist/finance/index.d.ts",
"import": "./dist/finance/index.mjs",
"require": "./dist/finance/index.js"
},
"./finance/components": {
"types": "./finance/components/index.ts",
"import": "./finance/components/index.ts"
},
"./finance/AdvancedChart": {
"types": "./finance/components/AdvancedChart.tsx",
"import": "./finance/components/AdvancedChart.tsx"
},
"./finance/TickerTape": {
"types": "./finance/components/TickerTape.tsx",
"import": "./finance/components/TickerTape.tsx"
},
"./finance/*": {
"types": "./finance/components/*.tsx",
"import": "./finance/components/*.tsx"
},
"./sonner": {
"types": "./dist/sonner.d.ts",
"import": "./dist/sonner.mjs",
"require": "./dist/sonner.js"
},
"./input-otp": {
"types": "./dist/input-otp.d.ts",
"import": "./dist/input-otp.mjs",
"require": "./dist/input-otp.js"
},
"./resizable": {
"types": "./dist/resizable.d.ts",
"import": "./dist/resizable.mjs",
"require": "./dist/resizable.js"
},
"./assets": {
"types": "./dist/assets/index.d.ts",
"import": "./dist/assets/index.mjs",
"require": "./dist/assets/index.js"
},
"./assets/*": {
"types": "./dist/assets/*.d.ts",
"import": "./dist/assets/*.mjs",
"require": "./dist/assets/*.js"
},
"./blocks": {
"types": "./dist/blocks/index.d.ts",
"import": "./dist/blocks/index.mjs",
"require": "./dist/blocks/index.js"
},
"./blocks/*": {
"types": "./dist/blocks/*.d.ts",
"import": "./dist/blocks/*.mjs",
"require": "./dist/blocks/*.js"
},
"./components": {
"types": "./dist/components/index.d.ts",
"import": "./dist/components/index.mjs",
"require": "./dist/components/index.js"
},
"./components/*": {
"types": "./dist/components/*.d.ts",
"import": "./dist/components/*.mjs",
"require": "./dist/components/*.js"
},
"./primitives": {
"types": "./dist/index.d.ts",
"import": "./dist/index.mjs",
"require": "./dist/index.js"
},
"./primitives/*": {
"types": "./dist/primitives/*.d.ts",
"import": "./dist/primitives/*.mjs",
"require": "./dist/primitives/*.js"
},
"./primitives-common": {
"types": "./dist/primitives/index-common.d.ts",
"import": "./dist/primitives/index-common.mjs",
"require": "./dist/primitives/index-common.js"
},
"./style/*": "./style/*",
"./tailwind": {
"types": "./dist/tailwind/index.d.ts",
"import": "./dist/tailwind/index.mjs",
"require": "./dist/tailwind/index.js"
},
"./tailwind/*": {
"types": "./dist/tailwind/*.d.ts",
"import": "./dist/tailwind/*.mjs",
"require": "./dist/tailwind/*.js"
},
"./types": {
"types": "./dist/types/index.d.ts",
"import": "./dist/types/index.mjs",
"require": "./dist/types/index.js"
},
"./types/*": {
"types": "./dist/types/*.d.ts",
"import": "./dist/types/*.mjs",
"require": "./dist/types/*.js"
},
"./util": {
"types": "./dist/util/index.d.ts",
"import": "./dist/util/index.mjs",
"require": "./dist/util/index.js"
},
"./util/*": {
"types": "./dist/util/*.d.ts",
"import": "./dist/util/*.mjs",
"require": "./dist/util/*.js"
},
"./util-client": {
"types": "./dist/util/index-client.d.ts",
"import": "./dist/util/index-client.mjs",
"require": "./dist/util/index-client.js"
},
"./utils": {
"types": "./dist/src/utils.d.ts",
"import": "./dist/src/utils.mjs",
"require": "./dist/src/utils.js"
},
"./lib/utils": {
"types": "./dist/src/utils.d.ts",
"import": "./dist/src/utils.mjs",
"require": "./dist/src/utils.js"
},
"./mcp": {
"types": "./dist/src/mcp/index.d.ts",
"import": "./dist/src/mcp/index.mjs",
"require": "./dist/src/mcp/index.js"
},
"./models": {
"types": "./dist/src/models/index.d.ts",
"import": "./dist/models/index.mjs",
"require": "./dist/models/index.js"
},
"./react": {
"types": "./dist/frameworks/react/index.d.ts",
"import": "./dist/frameworks/react/index.mjs",
"require": "./dist/frameworks/react/index.js"
},
"./vue": {
"types": "./dist/frameworks/vue/index.d.ts",
"import": "./dist/frameworks/vue/index.mjs",
"require": "./dist/frameworks/vue/index.js"
},
"./svelte": {
"svelte": "./frameworks/svelte/index.ts",
"types": "./dist/frameworks/svelte/index.d.ts",
"import": "./dist/frameworks/svelte/index.mjs",
"require": "./dist/frameworks/svelte/index.js"
},
"./react-native": {
"types": "./dist/frameworks/react-native/index.d.ts",
"import": "./dist/frameworks/react-native/index.mjs",
"require": "./dist/frameworks/react-native/index.js"
},
"./content": {
"types": "./content/index.ts",
"import": "./content/index.ts"
},
"./code": {
"types": "./dist/code/index.d.ts",
"import": "./dist/code/index.mjs",
"require": "./dist/code/index.js"
},
"./code/*": {
"types": "./dist/code/*.d.ts",
"import": "./dist/code/*.mjs",
"require": "./dist/code/*.js"
},
"./spline": {
"types": "./dist/spline/index.d.ts",
"import": "./dist/spline/index.mjs",
"require": "./dist/spline/index.js"
},
"./spline/*": {
"types": "./dist/spline/*.d.ts",
"import": "./dist/spline/*.mjs",
"require": "./dist/spline/*.js"
},
"./charts": {
"types": "./dist/charts/index.d.ts",
"import": "./dist/charts/index.mjs",
"require": "./dist/charts/index.js"
},
"./kanban": {
"types": "./dist/kanban/index.d.ts",
"import": "./dist/kanban/index.mjs",
"require": "./dist/kanban/index.js"
},
"./mermaid": {
"types": "./dist/mermaid/index.d.ts",
"import": "./dist/mermaid/index.mjs",
"require": "./dist/mermaid/index.js"
},
"./calendar-ext": {
"types": "./dist/calendar-ext/index.d.ts",
"import": "./dist/calendar-ext/index.mjs",
"require": "./dist/calendar-ext/index.js"
},
"./3d": {
"types": "./dist/3d/index.d.ts",
"import": "./dist/3d/index.mjs",
"require": "./dist/3d/index.js"
},
"./3d/*": {
"types": "./dist/3d/*.d.ts",
"import": "./dist/3d/*.mjs",
"require": "./dist/3d/*.js"
},
"./animation": {
"types": "./dist/animation/index.d.ts",
"import": "./dist/animation/index.mjs",
"require": "./dist/animation/index.js"
},
"./animation/*": {
"types": "./dist/animation/*.d.ts",
"import": "./dist/animation/*.mjs",
"require": "./dist/animation/*.js"
},
"./pattern": {
"types": "./dist/pattern/index.d.ts",
"import": "./dist/pattern/index.mjs",
"require": "./dist/pattern/index.js"
},
"./pattern/*": {
"types": "./dist/pattern/*.d.ts",
"import": "./dist/pattern/*.mjs",
"require": "./dist/pattern/*.js"
},
"./auth": {
"types": "./dist/src/auth/index.d.ts",
"import": "./dist/auth/index.mjs",
"require": "./dist/auth/index.js"
},
"./auth/*": {
"types": "./dist/src/auth/*.d.ts",
"import": "./dist/auth/*.mjs",
"require": "./dist/auth/*.js"
},
"./navigation": {
"types": "./dist/src/navigation/index.d.ts",
"import": "./dist/navigation/index.mjs",
"require": "./dist/navigation/index.js"
},
"./navigation/*": {
"types": "./dist/src/navigation/*.d.ts",
"import": "./dist/navigation/*.mjs",
"require": "./dist/navigation/*.js"
},
"./form/*": {
"types": "./dist/form/*.d.ts",
"import": "./dist/form/*.mjs",
"require": "./dist/form/*.js"
},
"./device": {
"types": "./dist/device/index.d.ts",
"import": "./dist/device/index.mjs",
"require": "./dist/device/index.js"
},
"./device/*": {
"types": "./dist/device/*.d.ts",
"import": "./dist/device/*.mjs",
"require": "./dist/device/*.js"
},
"./dock": {
"types": "./dist/dock/index.d.ts",
"import": "./dist/dock/index.mjs",
"require": "./dist/dock/index.js"
},
"./dock/*": {
"types": "./dist/dock/*.d.ts",
"import": "./dist/dock/*.mjs",
"require": "./dist/dock/*.js"
},
"./desktop": {
"types": "./dist/desktop/index.d.ts",
"import": "./dist/desktop/index.mjs",
"require": "./dist/desktop/index.js"
},
"./desktop/*": {
"types": "./dist/desktop/*.d.ts",
"import": "./dist/desktop/*.mjs",
"require": "./dist/desktop/*.js"
},
"./project": {
"types": "./dist/project/index.d.ts",
"import": "./dist/project/index.mjs",
"require": "./dist/project/index.js"
},
"./project/*": {
"types": "./dist/project/*.d.ts",
"import": "./dist/project/*.mjs",
"require": "./dist/project/*.js"
},
"./ui": {
"types": "./dist/ui/index.d.ts",
"import": "./dist/ui/index.mjs",
"require": "./dist/ui/index.js"
},
"./ui/*": {
"types": "./dist/ui/*.d.ts",
"import": "./dist/ui/*.mjs",
"require": "./dist/ui/*.js"
},
"./docs/style.css": "./docs/css/style.css",
"./docs/css/*": "./docs/css/*",
"./docs/page": {
"types": "./docs/page.tsx",
"import": "./docs/page.tsx"
},
"./docs/mdx": {
"types": "./docs/mdx.tsx",
"import": "./docs/mdx.tsx"
},
"./docs/og": {
"types": "./docs/og.tsx",
"import": "./docs/og.tsx"
},
"./docs/i18n": {
"types": "./docs/i18n.tsx",
"import": "./docs/i18n.tsx"
},
"./docs/layouts/docs": {
"types": "./docs/layouts/docs/index.tsx",
"import": "./docs/layouts/docs/index.tsx"
},
"./docs/layouts/home": {
"types": "./docs/layouts/home/index.tsx",
"import": "./docs/layouts/home/index.tsx"
},
"./docs/layouts/notebook": {
"types": "./docs/layouts/notebook/index.tsx",
"import": "./docs/layouts/notebook/index.tsx"
},
"./docs/provider/next": {
"types": "./docs/provider/next.tsx",
"import": "./docs/provider/next.tsx"
},
"./docs/provider/*": {
"types": "./docs/provider/*.tsx",
"import": "./docs/provider/*.tsx"
},
"./docs/components": {
"types": "./docs/components/index.ts",
"import": "./docs/components/index.ts"
},
"./docs/components/*": {
"types": "./docs/components/*.tsx",
"import": "./docs/components/*.tsx"
},
"./docs/contexts/*": {
"types": "./docs/contexts/*.tsx",
"import": "./docs/contexts/*.tsx"
},
"./docs/utils/*": {
"types": "./docs/utils/*.ts",
"import": "./docs/utils/*.ts"
},
"./docs/source": {
"types": "./docs/source.ts",
"import": "./docs/source.ts"
}
},
"dependencies": {
"@next/third-parties": "^16.2.1",
"@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-aspect-ratio": "^1.1.8",
"@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-context-menu": "^2.2.16",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-hover-card": "^1.1.15",
"@radix-ui/react-icons": "^1.3.2",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-menubar": "1.1.16",
"@radix-ui/react-navigation-menu": "^1.2.14",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-progress": "^1.1.8",
"@radix-ui/react-radio-group": "^1.3.8",
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slider": "^1.3.6",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-toast": "^1.2.15",
"@radix-ui/react-toggle": "^1.1.10",
"@radix-ui/react-toggle-group": "^1.1.11",
"@radix-ui/react-tooltip": "^1.2.8",
"@radix-ui/react-use-callback-ref": "^1.1.1",
"@tailwindcss/container-queries": "^0.1.1",
"buffer": "^6.0.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"commander": "^14.0.3",
"embla-carousel-react": "^8.6.0",
"input-otp": "^1.4.2",
"little-date": "^1.2.1",
"lodash.castarray": "^4.4.0",
"lodash.isplainobject": "^4.0.6",
"lodash.merge": "^4.6.2",
"postcss-selector-parser": "^7.1.1",
"react-intersection-observer": "^10.0.3",
"sonner": "^2.0.7",
"svg-pan-zoom": "^3.6.2",
"tailwind-merge": "^3.5.0",
"tailwindcss-animate": "^1.0.7",
"tailwindcss-interaction-media": "^0.1.0",
"vaul": "^1.1.2",
"zod": "^3.25.76",
"zod-to-json-schema": "^3.25.1"
"tc": "tsc --noEmit",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest"
},
"peerDependencies": {
"@dnd-kit/core": "^6.0.0",
"@dnd-kit/modifiers": "^9.0.0",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.0",
"@hanzo/docs-core": "^16.0.0",
"@hanzogui/admin": ">=0.3.0",
"@hanzogui/lucide-icons-2": ">=7.0.0",
"hanzogui": ">=7.0.0",
"@hookform/resolvers": "^3.3.2",
"@modelcontextprotocol/sdk": "^1.0.0",
"@rainbow-me/rainbowkit": "^2.0.0",
"@tanstack/react-query": "^5.0.0",
"@tanstack/react-table": "^8.0.0",
"chrono-node": "^2.0.0",
"date-fns": "^3.0.0",
"framer-motion": "^11.0.0",
"lucide-react": ">=0.456.0",
"mermaid": "^10.0.0",
"mobx": "^6.12.3",
"next": ">=14.2.16",
"next-themes": "^0.2.1 || ^0.3.0 || ^0.4.0",
"react": "^18.0.0 || ^19.0.0",
"react-day-picker": "^9.0.0",
"react-dom": "^18.0.0 || ^19.0.0",
"react-hook-form": "^7.0.0",
"react-qrcode-logo": "^2.0.0",
"react-resizable-panels": "^3.0.0",
"recharts": "^2.0.0",
"sql.js": "^1.0.0",
"styled-components": "^6.0.0",
"validator": "^13.11.0",
"wagmi": "^2.0.0"
},
"peerDependenciesMeta": {
"styled-components": {
"optional": true
},
"@dnd-kit/core": {
"optional": true
},
"@dnd-kit/modifiers": {
"optional": true
},
"@dnd-kit/sortable": {
"optional": true
},
"@dnd-kit/utilities": {
"optional": true
},
"@hanzo/docs-core": {
"optional": true
},
"@hanzogui/admin": {
"optional": true
},
"@hanzogui/lucide-icons-2": {
"optional": true
},
"hanzogui": {
"optional": true
},
"@modelcontextprotocol/sdk": {
"optional": true
},
"@tanstack/react-query": {
"optional": true
},
"@tanstack/react-table": {
"optional": true
},
"chrono-node": {
"optional": true
},
"cmdk": {
"optional": true
},
"date-fns": {
"optional": true
},
"embla-carousel": {
"optional": true
},
"embla-carousel-react": {
"optional": true
},
"framer-motion": {
"optional": true
},
"input-otp": {
"optional": true
},
"mermaid": {
"optional": true
},
"react-day-picker": {
"optional": true
},
"react-hook-form": {
"optional": true
},
"react-qrcode-logo": {
"optional": true
},
"react-resizable-panels": {
"optional": true
},
"recharts": {
"optional": true
},
"sonner": {
"optional": true
},
"sql.js": {
"optional": true
},
"vaul": {
"optional": true
},
"@rainbow-me/rainbowkit": {
"optional": true
},
"wagmi": {
"optional": true
}
"@hanzo/data": ">=1.2.0",
"@hanzo/gui": ">=7.2.2",
"react": ">=19"
},
"devDependencies": {
"@mdx-js/loader": "^3.1.1",
"@mdx-js/react": "^3.1.1",
"@radix-ui/react-primitive": "^2.1.4",
"@splinetool/react-spline": "^4.1.0",
"@tailwindcss/postcss": "^4.2.2",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@types/facebook-pixel": "^0.0.31",
"@types/gtag.js": "^0.0.20",
"@types/lodash.merge": "^4.6.9",
"@types/mdx": "^2.0.13",
"@types/node": "25.5.0",
"@types/react": "19.2.14",
"@types/react-dom": "19.2.3",
"@types/react-syntax-highlighter": "^15.5.13",
"@types/styled-components": "^5.1.36",
"@vitejs/plugin-react": "^6.0.1",
"@vitest/coverage-v8": "^4.1.0",
"@vitest/ui": "^4.1.0",
"embla-carousel": "^8.6.0",
"filesize": "^11.0.13",
"glob": "^13.0.6",
"jest": "^30.3.0",
"jsdom": "^29.0.1",
"react-dropzone": "^15.0.0",
"react-markdown": "^10.1.0",
"react-syntax-highlighter": "^16.1.1",
"rehype-katex": "^7.0.1",
"remark-gfm": "^4.0.1",
"remark-math": "^6.0.0",
"styled-components": "^6.3.12",
"tailwindcss": "^4.2.2",
"tsup": "^8.5.1",
"tw-animate-css": "^1.4.0",
"typescript": "^5.9.3",
"vitest": "4.1.0"
}
"@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>"
}
-67
View File
@@ -1,67 +0,0 @@
'use client'
import React, { useCallback, useState } from 'react'
export interface IAMLoginButtonProps {
/** IAM server URL (e.g., https://hanzo.id) */
iamUrl?: string
/** OAuth client ID */
clientId: string
/** OAuth redirect URI (defaults to current origin + /auth/callback) */
redirectUri?: string
/** Button label */
label?: string
/** Additional CSS classes */
className?: string
/** Button variant: 'default' (filled) or 'outline' */
variant?: 'default' | 'outline'
}
/**
* "Sign in with Hanzo" button that initiates IAM OAuth flow.
* Zero-dependency — works in any React app.
*/
export function IAMLoginButton({
iamUrl = 'https://hanzo.id',
clientId,
redirectUri,
label = 'Sign in with Hanzo',
className = '',
variant = 'default',
}: IAMLoginButtonProps) {
const [loading, setLoading] = useState(false)
const handleClick = useCallback(() => {
setLoading(true)
const redirect = redirectUri || `${window.location.origin}/auth/callback`
const state = crypto.randomUUID()
const url = new URL(`${iamUrl}/login/oauth/authorize`)
url.searchParams.set('client_id', clientId)
url.searchParams.set('response_type', 'code')
url.searchParams.set('redirect_uri', redirect)
url.searchParams.set('scope', 'openid profile email')
url.searchParams.set('state', state)
window.location.href = url.toString()
}, [iamUrl, clientId, redirectUri])
const baseStyles = 'inline-flex items-center justify-center gap-2 rounded-md px-4 py-2.5 text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50'
const variantStyles = variant === 'outline'
? 'border border-[#333] bg-transparent text-white hover:bg-[#1a1a1f]'
: 'bg-white text-black hover:bg-[#e5e5e5]'
return (
<button
type="button"
className={`${baseStyles} ${variantStyles} ${className}`}
onClick={handleClick}
disabled={loading}
>
<svg width="16" height="16" viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg">
<text x="4" y="26" fontFamily="-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif" fontSize="28" fontWeight="700" fill="currentColor">H</text>
</svg>
{loading ? 'Redirecting...' : label}
</button>
)
}
-9
View File
@@ -1,9 +0,0 @@
export { IAMLoginButton } from './IAMLoginButton'
export type { IAMLoginButtonProps } from './IAMLoginButton'
export { AuthGuard } from './AuthGuard'
export type { AuthGuardProps } from './AuthGuard'
// Re-export auth-related navigation components for convenience
export { useHanzoAuth } from '../navigation/hanzo-shell/useHanzoAuth'
export { UserOrgDropdown } from '../navigation/hanzo-shell/UserOrgDropdown'
export type { HanzoUser, HanzoOrg } from '../navigation/hanzo-shell/types'
+12
View File
@@ -0,0 +1,12 @@
// @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
@@ -0,0 +1,22 @@
// @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'
@@ -1,14 +0,0 @@
// @hanzo/ui/primitives/bases/admin — admin chrome + primitives.
//
// Source of truth: `@hanzogui/admin` (in ~/work/hanzo/gui/code/ui-admin).
// Re-export the shared admin shell (Sidebar, TopBar, AdminApp,
// PageShell, NamespaceSwitcher), primitives (Badge, Empty, Loading,
// Alert, CopyField, DataTable, SummaryCard, etc.), data hooks
// (useFetch, useEvents), and the IAM admin pages once Bucket A-F port
// lands.
//
// Do NOT re-implement. Upstream lands new components in
// ~/work/hanzo/gui/code/ui-admin/ and they appear here through this
// re-export.
export * from '@hanzogui/admin'
+13
View File
@@ -0,0 +1,13 @@
// @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'
-15
View File
@@ -1,15 +0,0 @@
// @hanzo/ui/primitives/bases/gui — Hanzo GUI v7 base.
//
// Source of truth: `hanzogui` (the workspace umbrella in
// ~/work/hanzo/gui/code/ui/hanzogui). This file is a passthrough
// re-export so consumers of @hanzo/ui can pull Hanzo GUI v7 components
// without depending on hanzogui directly. Subpath resolution makes
// `@hanzo/ui/primitives/bases/gui` equivalent to `hanzogui` from a
// caller's perspective, but going through @hanzo/ui keeps the import
// path stable across future stack swaps.
//
// Do NOT re-implement primitives here. If a component is missing from
// `hanzogui`, add it upstream in ~/work/hanzo/gui and let it flow
// through this re-export.
export * from 'hanzogui'
@@ -1,18 +0,0 @@
// @hanzo/ui/primitives/bases/svelte — Svelte adapter (placeholder).
//
// Status: NOT YET IMPLEMENTED. The Svelte port lives outside this
// repo today (e.g., the upstream temporal/ui SvelteKit reference at
// ~/work/temporal/ui). When a Svelte port of @hanzogui/admin is
// authored, it lands at ~/work/hanzo/gui/code/ui-admin-svelte/ (or
// equivalent) and this file re-exports it.
//
// Importing from this subpath today throws at build time so callers
// get a clear signal rather than silent missing components.
throw new Error(
'@hanzo/ui/primitives/bases/svelte is not yet implemented. ' +
'Use @hanzo/ui/primitives/bases/admin (Hanzo GUI v7 admin chrome) or @hanzo/ui/primitives/bases/gui (hanzogui umbrella) ' +
'until the Svelte port lands.',
)
export {}
-16
View File
@@ -1,16 +0,0 @@
// @hanzo/ui/primitives/bases/vue — Vue adapter (placeholder).
//
// Status: NOT YET IMPLEMENTED. When a Vue port of @hanzogui/admin
// lands at ~/work/hanzo/gui/code/ui-admin-vue/ (or equivalent), this
// file re-exports it.
//
// Importing from this subpath today throws so callers get a clear
// signal.
throw new Error(
'@hanzo/ui/primitives/bases/vue is not yet implemented. ' +
'Use @hanzo/ui/primitives/bases/admin (Hanzo GUI v7 admin chrome) or @hanzo/ui/primitives/bases/gui (hanzogui umbrella) ' +
'until the Vue port lands.',
)
export {}
+471
View File
@@ -0,0 +1,471 @@
'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
@@ -0,0 +1,147 @@
'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
@@ -0,0 +1,99 @@
'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
@@ -0,0 +1,83 @@
'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
@@ -0,0 +1,129 @@
'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
@@ -0,0 +1,38 @@
'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
@@ -0,0 +1,160 @@
'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
@@ -0,0 +1,28 @@
'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
@@ -0,0 +1,253 @@
'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
@@ -0,0 +1,31 @@
'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
@@ -0,0 +1,17 @@
'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
@@ -0,0 +1,61 @@
'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
@@ -0,0 +1,137 @@
'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
@@ -0,0 +1,41 @@
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
@@ -0,0 +1,110 @@
'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
@@ -0,0 +1,94 @@
'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
@@ -0,0 +1,197 @@
'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 }

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