Compare 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
104 changed files with 6480 additions and 2988 deletions
+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).
+1 -1
View File
@@ -36,7 +36,7 @@
"@hanzo/docs-mdx": "14.3.0",
"@hanzo/docs-ui": "16.5.3",
"@hanzo/logo": "^1.0.5",
"@hanzo/ui": "workspace:^",
"@hanzo/ui": "workspace:@hanzo/ui-shadcn@^",
"@hookform/resolvers": "5.2.2",
"@monaco-editor/react": "^4.7.0",
"@radix-ui/react-accessible-icon": "^1.1.8",
+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
+61
View File
@@ -0,0 +1,61 @@
# @hanzo/ui
The **one** Hanzo component library, built on [`@hanzo/gui`](https://github.com/hanzoai/gui) (Tamagui) so every component runs on **web, native (iOS), and desktop**. It unifies what used to be three fragmented homes — `@hanzo/data`, the console's in-app `components/ui/*`, and ad-hoc duplicates — into one canonical, presentational, host-agnostic, clean-room library.
> This is the gui-based line (**v8+**). The legacy shadcn/Radix `@hanzo/ui` (v5.x) is a different architecture; it is being retired to `@hanzo/ui-shadcn` so the `@hanzo/ui` name carries the unified gui-based library forward — see `CONSOLIDATION.md` at the repo root.
## Install
```sh
bun add @hanzo/ui @hanzo/gui @hanzo/data
```
`@hanzo/gui`, `@hanzo/data`, and `react` are peers.
## Two orthogonal layers, one package
```tsx
// product / app layer — charts, metrics, headers, status, empty states, combobox…
import { PageHeader, Sparkline, LineChart, EmptyState, StatusTag, ComboBox, tokens } from '@hanzo/ui'
// metadata-driven record layer — RecordsView & the typed field editors (@hanzo/data)
import { RecordsView, DataTable, BoardView, RecordDetail, registerField } from '@hanzo/ui/data'
```
- **`@hanzo/ui`** — the product/app component layer:
- **Charts** (dependency-free inline SVG): `Sparkline`, `LineChart`, `BarChart`, `Donut`, `BarRows` — hover tooltips, axis ticks, honest `null` under two real points.
- **Metric** tiles/panels: `MetricCard`, `MiniBars`, `UtilBar`, `LegendDot`, `Panel`, `HintButton` (+ `MetricSparkline`).
- **Chrome**: `PageHeader`, `StatusTag`, `EmptyState` (DO/Vercel-class first-run), `PrimaryButton`, `HanzoMark`, `ProductIcon`, `ProviderLogo`.
- **Interaction**: `ComboBox` (typeable, ReDoS-safe filter), `SelectMenu`, `SlideOver` (a11y drawer), `Toast` (`useToast`), `Reorder` (pointer DnD), `Field*` rows, `FadeIn`, `ThemeToggle`, generic `DataTable<T>`.
- **Tokens**: `tokens`, `TAG_TONES`, `tagTone` — the calm, dark-first, zinc-on-black identity.
- **`@hanzo/ui/data`** — the metadata-driven record layer (source of truth: `@hanzo/data`): `RecordsView` (table ⇆ board, filter/sort/search/group, inline edit, saved views), the record-grid `DataTable`, `BoardView`, `RecordDetail`/`RecordForm`, every typed field editor, the field registry, and the pure view/sort/filter logic.
Both layers are **presentational + data-injected**: the host supplies rows and persistence callbacks; the components own only interaction state. Nothing imports an app's `~/lib`.
> Each layer owns a `DataTable` — the product one is a generic typed `<T>` list; the data one is the field-driven record grid. Keeping the record layer on the `./data` subpath keeps each name unambiguous.
## Motion
The calm motion vocabulary (`FadeIn`, drawer/backdrop transitions, skeleton shimmer, live pulse) ships as one stylesheet. Import it once at the app root:
```ts
import '@hanzo/ui/styles/motion.css'
```
Every animation honors `prefers-reduced-motion`.
## Extend the record layer
```tsx
import { registerField } from '@hanzo/ui/data'
registerField('rating', { Display: MyStars, Input: MyStarPicker })
```
## Design principles
- **One home, no duplication** — components are extracted, never copied. The record layer's source of truth stays in `@hanzo/data`; `@hanzo/ui` composes it.
- **Host-agnostic** — data + effects injected; zero coupling to any app.
- **Clean-room** — original implementation, no GPL / Twenty code. Airtable/Twenty-*class* polish, our own code.
- **Cross-platform** — web + native + desktop, because it's only `@hanzo/gui`.
BSD-3-Clause · Hanzo AI
+13
View File
@@ -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 {}
}
+63
View File
@@ -0,0 +1,63 @@
{
"name": "@hanzo/ui",
"version": "8.0.0",
"type": "module",
"description": "Hanzo UI — the one cross-platform component library on @hanzo/gui. The product/app layer (charts, metrics, page headers, status tags, rich empty states, combobox, slide-over, toasts, drag-reorder, labeled field rows, provider/product marks) + the metadata-driven record layer (@hanzo/data: RecordsView, DataTable, board, typed field editors) + the calm dark-first tokens and motion. Presentational, host-agnostic (data/effects injected), clean-room. Web + native (iOS) + desktop.",
"exports": {
".": {
"types": "./src/index.ts",
"default": "./src/index.ts"
},
"./product": {
"types": "./src/product/index.ts",
"default": "./src/product/index.ts"
},
"./data": {
"types": "./src/data.ts",
"default": "./src/data.ts"
},
"./primitives/bases/data": {
"types": "./src/primitives/bases/data/index.ts",
"default": "./src/primitives/bases/data/index.ts"
},
"./styles/motion.css": "./src/styles/hanzo-motion.css",
"./package.json": "./package.json"
},
"main": "./src/index.ts",
"module": "./src/index.ts",
"types": "./src/index.ts",
"sideEffects": [
"**/*.css"
],
"files": [
"src"
],
"scripts": {
"tc": "tsc --noEmit",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest"
},
"peerDependencies": {
"@hanzo/data": ">=1.2.0",
"@hanzo/gui": ">=7.2.2",
"react": ">=19"
},
"devDependencies": {
"@hanzo/data": "1.2.0",
"@hanzo/gui": "7.3.0",
"@hanzogui/config": "7.3.0",
"@hanzogui/lucide-icons-2": "7.3.0",
"@hanzogui/next-theme": "7.3.0",
"@types/react": "^19.1.10",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"typescript": "^5.7.2",
"vitest": "^2.1.9"
},
"publishConfig": {
"access": "public"
},
"license": "BSD-3-Clause",
"author": "Hanzo AI <dev@hanzo.ai>"
}
+12
View File
@@ -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'
+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'
@@ -1,10 +1,11 @@
'use client'
/**
* Charts the ONE way to draw a trend, a series, a share, or a distribution.
* Dependency-free: inline SVG for the data-dense temporal charts (Sparkline /
* Line / Columns) and @hanzo/gui primitives for the token-native ones
* (Bars, and the Donut's optional legend), so everything themes to the shell.
* 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
@@ -13,7 +14,7 @@
* richer legend (amounts, click targets) leaves `legend` off and composes its own.
*
* Honest by construction: a Sparkline with fewer than two points renders nothing
* (no invented trend); Donut / Bars with no positive value render an em-dash.
* (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
@@ -39,10 +40,10 @@ const ACCENT = CHART_PALETTE[0]
const GRID = 'rgba(148,163,184,0.16)'
const AXIS = 'rgba(148,163,184,0.85)'
/** A point on a temporal/sequential axis (Line / Columns). */
/** A point on a temporal/sequential axis (LineChart / BarChart). */
export type ChartPoint = { label: string; value: number }
/** A labelled magnitude in a categorical chart (Donut / Bars). */
/** 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>
@@ -57,7 +58,7 @@ const tone = (theme: Themed, key: string, fallback: string): string => {
/** Opacity ramp so N monochrome slices/areas stay visually distinct. */
const rampOpacity = (i: number, n: number): number => (n <= 1 ? 0.9 : 0.95 - (i / (n - 1)) * 0.62)
export function useContainerWidth() {
function useContainerWidth() {
const ref = useRef<HTMLDivElement>(null)
const [w, setW] = useState(0)
useEffect(() => {
@@ -105,7 +106,7 @@ export function Sparkline({
)
}
// ── Shared hover read-out (Line / Columns) ─────────────────────────────
// ── 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.
@@ -165,7 +166,7 @@ function axisTicks(data: ChartPoint[]): { i: number; label: string; anchor: 'sta
// ── Line chart (a value over time) ───────────────────────────────────────────
export function Line({
export function LineChart({
data,
height = 210,
color = ACCENT,
@@ -228,9 +229,9 @@ export function Line({
)
}
// ── Columns (a value over time, vertical bars) ───────────────────────────────
// ── Bar chart (a value over time, vertical bars) ─────────────────────────────
export function Columns({
export function BarChart({
data,
height = 210,
color = ACCENT,
@@ -439,9 +440,9 @@ function annularArc(cx: number, cy: number, rOuter: number, rInner: number, a0:
return `M${x0},${y0} A${rOuter},${rOuter} 0 ${large} 1 ${x1},${y1} L${x2},${y2} A${rInner},${rInner} 0 ${large} 0 ${x3},${y3} Z`
}
// ── Bars (a ranked distribution, horizontal, token-native) ───────────────────
// ── Bar rows (a ranked distribution, horizontal, token-native) ───────────────
export function Bars({ bars }: { bars: Slice[] }) {
export function BarRows({ bars }: { bars: Slice[] }) {
const max = Math.max(0, ...bars.map((b) => b.value))
if (max <= 0) {
return (
+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 }
+49
View File
@@ -0,0 +1,49 @@
'use client'
/**
* Status pill — maps a resource/cluster status string to a tone, one place.
*
* Provisioned things (managed resources, clusters) report free-form lifecycle
* strings; this normalizes them to green/yellow/red/neutral so every list reads
* the same. Style props use the v5 shorthand set.
*/
import { Text } from '@hanzo/gui'
type Tone = 'green' | 'yellow' | 'red' | 'neutral'
const toneOf = (status: string): Tone => {
const s = status.toLowerCase()
// Platform health verdicts map straight to a tone (the apps inventory reports
// green/yellow/red directly).
if (s === 'green') return 'green'
if (s === 'yellow') return 'yellow'
if (s === 'red') return 'red'
if (['ready', 'active', 'running', 'available', 'ok'].includes(s)) return 'green'
if (['creating', 'provisioning', 'pending', 'updating', 'attaching'].includes(s)) return 'yellow'
if (['error', 'failed', 'degraded', 'down'].includes(s)) return 'red'
return 'neutral'
}
// `as const` keeps the literal token types (not widened to `string`) so they
// satisfy the GUI `bg`/`color` token unions.
const TONE_BG = {
green: '$color5',
yellow: '$color4',
red: '$color4',
neutral: '$color3',
} as const
const TONE_FG = {
green: '$color12',
yellow: '$color12',
red: '$color12',
neutral: '$color11',
} as const
export function StatusTag({ status }: { status?: string }) {
const tone = toneOf(status ?? '')
return (
<Text fontSize="$1" px="$2" py="$1" rounded="$2" bg={TONE_BG[tone]} color={TONE_FG[tone]}>
{status || 'unknown'}
</Text>
)
}
+25
View File
@@ -0,0 +1,25 @@
'use client'
/**
* Theme toggle — flips the console between dark and light via next-theme. The
* provider already wires `NextThemeProvider` → `GuiProvider`, so setting the
* theme here re-themes the whole tree. Shows the action's target icon (a sun in
* dark mode, a moon in light mode).
*/
import { Button } from '@hanzo/gui'
import { useThemeSetting } from '@hanzogui/next-theme'
import { Moon, Sun } from '@hanzogui/lucide-icons-2'
export function ThemeToggle() {
const { current, resolvedTheme, set } = useThemeSetting()
const isDark = (resolvedTheme ?? current ?? 'dark') !== 'light'
return (
<Button
size="$2"
chromeless
icon={isDark ? <Sun size={16} /> : <Moon size={16} />}
onPress={() => set(isDark ? 'light' : 'dark')}
aria-label={isDark ? 'Switch to light theme' : 'Switch to dark theme'}
/>
)
}
+166
View File
@@ -0,0 +1,166 @@
'use client'
/**
* Toast — the ONE feedback primitive. Every mutation reports through `useToast()`
* so success/failure looks and behaves the same everywhere. A single provider
* holds the queue and renders the viewport (portalled top-right); there is no
* per-call markup. Built from GUI primitives only.
*
* Mount `<ToastProvider>` once (the dashboard layout does). Call:
* const toast = useToast()
* toast.success('Created my-db')
* toast.error(err instanceof ApiError ? err.message : 'Failed to create')
*/
import { createContext, useCallback, useContext, useEffect, useRef, useState, type ReactNode } from 'react'
import { createPortal } from 'react-dom'
import { Button, Card, Text, XStack, YStack } from '@hanzo/gui'
import { CircleCheck, CircleX, Info, X } from '@hanzogui/lucide-icons-2'
export type ToastKind = 'success' | 'error' | 'info'
export type ToastInput = {
title: string
description?: string
kind?: ToastKind
/** Auto-dismiss after this many ms; 0 keeps it until dismissed. */
durationMs?: number
}
type Toast = Required<Omit<ToastInput, 'description'>> & { id: number; description?: string }
type ToastApi = {
toast: (t: ToastInput) => void
success: (title: string, description?: string) => void
error: (title: string, description?: string) => void
info: (title: string, description?: string) => void
dismiss: (id: number) => void
}
const ToastContext = createContext<ToastApi | null>(null)
/** Theme-aware accent per kind (adapts to light/dark). */
const ACCENT = {
success: '$green10',
error: '$red10',
info: '$color11',
} as const
const ICON: Record<ToastKind, typeof Info> = {
success: CircleCheck,
error: CircleX,
info: Info,
}
function ToastCard({ t, onClose }: { t: Toast; onClose: () => void }) {
const Icon = ICON[t.kind]
const accent = ACCENT[t.kind]
return (
<Card
width={360}
maxWidth="90%"
p="$3"
gap="$2"
bg="$color2"
borderWidth={1}
borderColor="$borderColor"
borderLeftWidth={3}
borderLeftColor={accent}
rounded="$4"
elevation="$2"
>
<XStack gap="$2.5" items="flex-start">
<YStack pt={1}>
<Icon size={18} color={accent} />
</YStack>
<YStack flex={1} gap="$1">
<Text fontSize="$3" fontWeight="700" color="$color12">
{t.title}
</Text>
{t.description ? (
<Text fontSize="$2" color="$color11">
{t.description}
</Text>
) : null}
</YStack>
<Button size="$1" chromeless icon={<X size={14} />} onPress={onClose} aria-label="Dismiss" />
</XStack>
</Card>
)
}
/** Portalled, fixed top-right stack. Rendered only after mount (SSR-safe). */
function ToastViewport({ toasts, dismiss }: { toasts: Toast[]; dismiss: (id: number) => void }) {
const [mounted, setMounted] = useState(false)
useEffect(() => setMounted(true), [])
if (!mounted || typeof document === 'undefined') return null
return createPortal(
<div style={{ position: 'fixed', top: 16, right: 16, zIndex: 100000, pointerEvents: 'none' }}>
<YStack gap="$2" items="flex-end">
{toasts.map((t) => (
<YStack key={t.id} pointerEvents="auto">
<ToastCard t={t} onClose={() => dismiss(t.id)} />
</YStack>
))}
</YStack>
</div>,
document.body,
)
}
export function ToastProvider({ children }: { children: ReactNode }) {
const [toasts, setToasts] = useState<Toast[]>([])
const timers = useRef(new Map<number, ReturnType<typeof setTimeout>>())
const seq = useRef(0)
const dismiss = useCallback((id: number) => {
setToasts((ts) => ts.filter((t) => t.id !== id))
const timer = timers.current.get(id)
if (timer) {
clearTimeout(timer)
timers.current.delete(id)
}
}, [])
const toast = useCallback(
(input: ToastInput) => {
const id = ++seq.current
const kind = input.kind ?? 'info'
const durationMs = input.durationMs ?? (kind === 'error' ? 6000 : 3500)
setToasts((ts) => [...ts, { id, kind, title: input.title, description: input.description, durationMs }])
if (durationMs > 0) {
timers.current.set(
id,
setTimeout(() => dismiss(id), durationMs),
)
}
},
[dismiss],
)
// Clear any pending timers on unmount.
useEffect(() => {
const map = timers.current
return () => map.forEach((t) => clearTimeout(t))
}, [])
const api: ToastApi = {
toast,
success: (title, description) => toast({ title, description, kind: 'success' }),
error: (title, description) => toast({ title, description, kind: 'error' }),
info: (title, description) => toast({ title, description, kind: 'info' }),
dismiss,
}
return (
<ToastContext.Provider value={api}>
{children}
<ToastViewport toasts={toasts} dismiss={dismiss} />
</ToastContext.Provider>
)
}
export function useToast(): ToastApi {
const ctx = useContext(ToastContext)
if (!ctx) throw new Error('useToast must be used within <ToastProvider>')
return ctx
}
+17
View File
@@ -0,0 +1,17 @@
import type { ComponentType } from 'react'
import type { GetThemeValueForKey } from '@hanzo/gui'
/**
* Bridge between raw CSS colors (product hex accents) and Gui's typed `color`
* prop. Gui types `color` as a theme-token template (`$color12`, …), but ANY CSS
* color is valid at runtime — so tinting an icon with a product hex needs one
* sanctioned cast, in ONE place, rather than `as any` scattered around.
*/
export type IconColor = GetThemeValueForKey<'color'>
/** Cast a raw CSS color (e.g. `#5E6AD2`) to Gui's `color` prop type. */
export const asColor = (hex: string): IconColor => hex as unknown as IconColor
/** A lucide/Gui icon component that accepts a size + a (token-or-cast) color —
* the shape the shell passes around for product/nav icons. */
export type IconLike = ComponentType<{ size?: number; color?: IconColor }>
@@ -0,0 +1,51 @@
import { describe, it, expect } from 'vitest'
import { filterOptions, isKnownOption, type ComboOption } from './filter'
const opts: ComboOption[] = [
{ value: 'zen-omni', label: 'Zen Omni', hint: 'hanzo' },
{ value: 'gpt-4o-mini', label: 'GPT-4o mini', hint: 'openai' },
{ value: 'claude-sonnet-4-5', hint: 'anthropic' },
]
describe('filterOptions', () => {
it('returns every option for an empty / whitespace query', () => {
expect(filterOptions(opts, '')).toHaveLength(3)
expect(filterOptions(opts, ' ')).toHaveLength(3)
})
it('matches on value (case-insensitive substring)', () => {
expect(filterOptions(opts, 'ZEN').map((o) => o.value)).toEqual(['zen-omni'])
expect(filterOptions(opts, 'sonnet').map((o) => o.value)).toEqual(['claude-sonnet-4-5'])
})
it('matches on label', () => {
expect(filterOptions(opts, 'GPT-4o').map((o) => o.value)).toEqual(['gpt-4o-mini'])
})
it('matches on hint (the provider)', () => {
expect(filterOptions(opts, 'anthropic').map((o) => o.value)).toEqual(['claude-sonnet-4-5'])
expect(filterOptions(opts, 'openai').map((o) => o.value)).toEqual(['gpt-4o-mini'])
})
it('returns nothing when nothing matches (the typed value is still usable by the caller)', () => {
expect(filterOptions(opts, 'llama')).toEqual([])
})
it('treats the query as a LITERAL string, never a regex (no ReDoS / injection)', () => {
// A regex-special query must match literally (or not at all) — never be compiled.
expect(filterOptions(opts, '.*')).toEqual([]) // no option literally contains ".*"
expect(filterOptions(opts, '(')).toEqual([]) // an unbalanced paren would throw if compiled
expect(filterOptions(opts, '[a-z')).toEqual([]) // an invalid char class would throw if compiled
// A literal hyphen (regex-benign but proves substring semantics):
expect(filterOptions(opts, '4o-mini').map((o) => o.value)).toEqual(['gpt-4o-mini'])
})
})
describe('isKnownOption', () => {
it('is true only for an exact value match (case-sensitive)', () => {
expect(isKnownOption(opts, 'zen-omni')).toBe(true)
expect(isKnownOption(opts, 'ZEN-OMNI')).toBe(false)
expect(isKnownOption(opts, 'custom-model')).toBe(false)
})
})
+39
View File
@@ -0,0 +1,39 @@
/**
* Combobox option filtering — pure, unit-tested.
*
* The ONE matcher the ComboBox uses to narrow a live option list against typed
* text. Matching is a LITERAL, case-insensitive substring over the option's
* value + label + hint — NEVER a compiled RegExp of user input (that would be a
* ReDoS / injection surface; the whole codebase filters with literal `includes`,
* e.g. marketplace/logic + filterOrgs). Kept dependency-free so it lifts into
* `@hanzo/ui` alongside the ComboBox.
*/
/** One option in a combobox list. `value` is what gets committed; label/hint are display. */
export type ComboOption = {
/** The value committed to the field when this option is chosen. */
value: string
/** Display label (defaults to `value`). */
label?: string
/** Optional secondary text (e.g. a provider, a description) — also searched. */
hint?: string
}
/** The searchable haystack for one option (value + label + hint), lowercased. */
const hay = (o: ComboOption): string => `${o.value} ${o.label ?? ''} ${o.hint ?? ''}`.toLowerCase()
/**
* Filter `options` by `query` — a literal, case-insensitive substring over
* value/label/hint. An empty/whitespace query returns every option (no filter).
* PURE. The input is treated as a plain string, never a pattern.
*/
export function filterOptions(options: ComboOption[], query: string): ComboOption[] {
const q = query.trim().toLowerCase()
if (!q) return options
return options.filter((o) => hay(o).includes(q))
}
/** True iff `value` exactly matches an option's `value` (case-sensitive). */
export function isKnownOption(options: ComboOption[], value: string): boolean {
return options.some((o) => o.value === value)
}
+57
View File
@@ -0,0 +1,57 @@
// @hanzo/ui/product — the product/app component layer on @hanzo/gui.
//
// Charts, metrics, page headers, status tags, rich empty states, combobox,
// slide-over, toasts, drag-reorder, labeled field rows, provider/product marks,
// theme toggle. Presentational + host-agnostic: data and effects are INJECTED as
// props — nothing here imports an app's `~/lib`. Cross-platform (web + native +
// desktop) because it builds only on @hanzo/gui primitives. Clean-room.
//
// Single collision-free namespace: where a name exists in two files the canonical
// (Charts) keeps the name and the variant is aliased, so NOTHING is dropped.
// Charts — the canonical Sparkline + Donut live here (dependency-free inline SVG:
// hover tooltips, axis ticks, honest null under 2 real points).
export * from './Charts'
// Metric — resource-console stat tiles/panels/bars. Its own near-identical
// Sparkline is preserved as `MetricSparkline` (zero loss; see CONSOLIDATION.md
// for the eventual single-Sparkline dedup).
export {
SERIES,
colorForIndex,
utilColor,
Sparkline as MetricSparkline,
MiniBars,
UtilBar,
LegendDot,
MetricCard,
HintButton,
Panel,
} from './Metric'
// Standalone dependency-free ring — aliased to avoid the Charts.Donut clash.
export { Donut as DonutRing, type DonutSegment } from './Donut'
// ComboBox — the typeable select. `ComboOption` comes from the ONE pure filter
// module (ReDoS-safe literal substring), so export the component here and the
// option type + matchers from there.
export { ComboBox } from './ComboBox'
export * from './combobox/filter'
// The rest — every exported name below is unique across the layer.
export * from './DataTable'
export * from './EmptyState'
export * from './FadeIn'
export * from './Field'
export * from './HanzoMark'
export * from './PageHeader'
export * from './PrimaryButton'
export * from './ProductIcon'
export * from './ProviderLogo'
export * from './Reorder'
export * from './SelectMenu'
export * from './SlideOver'
export * from './StatusTag'
export * from './ThemeToggle'
export * from './Toast'
export * from './color'
+136
View File
@@ -0,0 +1,136 @@
/* @hanzo/ui motion primitives — the calm, restrained animation vocabulary the
Hanzo surfaces share. One place defines the easing; components apply the class.
`className` forwards to the underlying DOM node on web, so the browser
transitions the Gui-driven inline width/transform/opacity. Every animation is
guarded by `prefers-reduced-motion: reduce`.
Import once at the app root: import '@hanzo/ui/styles/motion.css' */
/* Fade-up entrance — <FadeIn> applies the class + a per-item stagger delay.
~0.4s ease-out, small upward travel (matches the hanzo.ai marketing feel). */
@keyframes hz-fade-up {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.hz-fade-up {
animation: hz-fade-up 0.4s cubic-bezier(0.16, 1, 0.3, 1) both;
will-change: transform, opacity;
}
@media (prefers-reduced-motion: reduce) {
.hz-fade-up {
animation: none;
}
}
/* Shell chrome motion — sidebar collapse (width) and the Linear-style two-level
nav slide (transform); backdrop cross-fade behind a SlideOver / dialog. */
.hz-collapse {
transition: width 220ms cubic-bezier(0.16, 1, 0.3, 1);
will-change: width;
}
.hz-slide {
transition: transform 260ms cubic-bezier(0.16, 1, 0.3, 1);
will-change: transform;
}
.hz-fade {
transition: opacity 240ms cubic-bezier(0.16, 1, 0.3, 1);
will-change: opacity;
}
/* Drag-to-reorder — a pinned row while it is being dragged (pointer DnD). The
lifted row gets a subtle lift; siblings ease into place via `.hz-slide`. */
.hz-drag-item {
touch-action: none;
transition: transform 180ms cubic-bezier(0.16, 1, 0.3, 1);
}
.hz-drag-item[data-dragging='true'] {
transition: none;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.28);
opacity: 0.96;
cursor: grabbing;
}
@media (prefers-reduced-motion: reduce) {
.hz-collapse,
.hz-slide,
.hz-fade,
.hz-drag-item {
transition: none;
}
}
/* Living-overview motion — the "videogame-like" living dashboard. The count-up +
live sparkline are driven in JS (rAF, gated by prefers-reduced-motion in the
hooks); these are the pure-CSS bits: a loading shimmer, a live-feed pulse, and
a brief highlight when a freshly-arrived row lands. */
/* Skeleton shimmer — an honest "loading", never fabricated content. */
@keyframes hz-shimmer {
0% {
background-position: -160px 0;
}
100% {
background-position: 160px 0;
}
}
.hz-skeleton {
background-color: var(--color3, rgba(148, 163, 184, 0.14));
background-image: linear-gradient(
90deg,
transparent 0%,
var(--color4, rgba(148, 163, 184, 0.22)) 50%,
transparent 100%
);
background-size: 160px 100%;
background-repeat: no-repeat;
animation: hz-shimmer 1.2s ease-in-out infinite;
}
/* Live-feed pulse — the "Live" dot on a streaming panel. */
@keyframes hz-pulse {
0%,
100% {
opacity: 1;
transform: scale(1);
}
50% {
opacity: 0.45;
transform: scale(0.82);
}
}
/* Entrance for a freshly-arrived activity row (staggerless — one row at a time). */
@keyframes hz-row-in {
from {
opacity: 0;
transform: translateY(-6px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.hz-row-in {
animation: hz-row-in 0.35s cubic-bezier(0.16, 1, 0.3, 1) both;
}
@media (prefers-reduced-motion: reduce) {
.hz-skeleton {
animation: none;
}
.hz-row-in {
animation: none;
}
}
+19
View File
@@ -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 (drag-reorder math, combobox option filtering) — the modules that import
* no @hanzo/gui runtime. The view components themselves are verified by the
* consuming app's build + visual e2e.
*/
export default defineConfig({
test: { environment: 'node', include: ['src/**/*.test.ts'] },
})
+1
View File
@@ -52,6 +52,7 @@
"zod": "^3.23.8"
},
"devDependencies": {
"@hanzo/ui": "workspace:@hanzo/ui-shadcn@^",
"@types/react": "19.2.14",
"@types/react-dom": "19.2.3",
"cross-fetch": "^4.1.0",
-245
View File
@@ -1,245 +0,0 @@
# @hanzo/dashboard
The reusable **dashboard layer on [`@hanzo/gui`](https://github.com/hanzoai/gui)**.
A set of composable, honest, reduced-motion-guarded dashboard primitives extracted
from the Hanzo Cloud Console so every Hanzo app — console, admin surfaces,
hanzo.team, desktop dashboards — builds the **same** dashboard system instead of
re-implementing it.
Everything here is **pure**: data comes in via props or a loader you supply, actions
are callbacks, and `@hanzo/gui` is a peer dependency (this layer sits _on_ it, never
forks it). No app-specific API imports.
```bash
pnpm add @hanzo/dashboard @hanzo/gui @hanzogui/lucide-icons-2
```
```tsx
import '@hanzo/dashboard/dashboard.css' // once — ships the motion keyframes
import { Overview, Kpi, Sparkline, Landing, Pipeline } from '@hanzo/dashboard'
```
> `dashboard.css` carries the loading shimmer, live-feed pulse, fade-up entrance, and
> the deploy-pipeline animations — **every one reduced-motion-guarded** (→ static).
> The count-up + live sparkline motion is driven in JS and gated on
> `prefers-reduced-motion` in the hooks.
---
## Charts — the ONE way to draw a trend / series / share / distribution
Monochrome SVG (dependency-free) that themes to the shell. Honest by construction: a
`Sparkline` with < 2 points renders nothing; `Donut`/`Bars` with no positive value
render an em-dash. Callers pass REAL series — these never fabricate data.
| Export | What it draws |
| --- | --- |
| `Sparkline` | A fixed-size mini trend (for stat tiles). |
| `Line` | A value over time (line + hover tooltip). |
| `Columns` | A value over time (vertical bars + hover). |
| `Donut` | Categorical share (optional legend, center slot). |
| `Bars` | A ranked distribution (horizontal, token-native). |
| `useContainerWidth` | Measure a container for responsive SVG. |
| `CHART_PALETTE`, `CHART_OTHER` | The categorical hues. |
| types `ChartPoint`, `Slice` | Data shapes. |
```tsx
<Line data={[{ label: 'Mon', value: 12 }, { label: 'Tue', value: 30 }]} formatValue={(v) => `${v}`} />
<Donut slices={[{ label: 'zen', value: 60 }, { label: 'gpt', value: 40 }]} legend />
<Sparkline values={[3, 5, 4, 9, 12]} />
```
## Motion — pure math + rAF/interval hooks
Unit-testable pure functions plus the thin browser hooks that drive them; every hook
self-cleans on unmount (no leaked frames/timers).
- Pure: `easeOutCubic`, `countUpValue`, `progress`, `pushSample`, `shouldTick`, `effectiveInterval`.
- Hooks: `useCountUp`, `usePoll`, `useReducedMotion`, `usePageHidden`.
```tsx
const value = useCountUp(target, /* enabled */ true) // snaps to target under reduced motion
const { tick, bump } = usePoll(15_000) // paused when interval ≤ 0
```
## Overview — the videogame-like living dashboard
Declare a product's tiles + a REAL data loader; `Overview` renders + animates the
board and keeps it alive (one throttled poll loop, hidden-tab-paused, range selector,
count-up on change). A product is added by writing a config, never overview UI.
```tsx
import { Overview, type OverviewConfig } from '@hanzo/dashboard'
import { Cpu } from '@hanzogui/lucide-icons-2'
const config: OverviewConfig = {
id: 'gpus', title: 'GPUs',
live: { pollMs: 15_000, countUp: true },
rows: [
[{ tile: 'metric', key: 'active', label: 'Active', icon: Cpu }],
[{ tile: 'timeseries', key: 'usage', title: 'Usage over time' },
{ tile: 'distribution', key: 'byModel', title: 'By model' }],
[{ tile: 'activity' }, { tile: 'health' }],
],
load: async ({ range }) => fromMyApi(await MyApi.overview(range)), // → OverviewData
}
<Overview config={config} isGlobalAdmin={amAdmin} />
```
The loader returns the normalized **`OverviewData`** (`kpi` / `series` / `distribution`
/ `activity` / `alerts` / `health` maps). A missing slice renders an honest empty tile
— you can over-declare tiles a slow backend hasn't filled yet. First load shows a
spinner; a failure shows a retry card; a background refetch never blanks a board that
already has real data.
### Composable primitives (use without the driver)
The tiles are also standalone, direct-props components — the valuable, reusable core:
| Export | Props (data in, view out) |
| --- | --- |
| `Kpi` | `label`, `value` (null → "—"), `unit`, `deltaPct`, `series`, `icon`, `color`, `caption`, `animate`, `loading` |
| `Feed` | `items: OverviewEvent[]`, `title`, `empty`, `loading` (virtualizes past a viewport) |
| `Board` | `items: OverviewHealth[]`, `title`, `empty`, `loading` (health tally + dots) |
| `Tile` | Render one declared tile spec against `OverviewData`. |
| `Panel`, `SkeletonBar`, `EmptyPanel`, `PanelSpinner`, `LiveDot` | Shared chrome. |
```tsx
<Kpi label="Requests" value={32_412} unit="count" deltaPct={12} series={[10,14,22,31]} icon={Zap} />
```
Plus the pure tile logic (`formatMetric`, `deltaOf`, `hasTrend`, `mergeActivity`,
`windowRows`, `statusColor`/`healthColor`/`severityColor`, `selectKpi`/`selectSeries`/
`selectDistribution`, …) and the `OverviewConfig` / `OverviewData` / tile types.
## Landing — the polished product landing kit
Compose a `LandingConfig` and render `<Landing>` to get a hero + live count-up metrics
row + an interactive code-sample block + a resources rail. Parts are exported too
(`Hero`, `Metrics`, `Samples`, `Rail`).
```tsx
import { Landing, type LandingConfig } from '@hanzo/dashboard'
const config: LandingConfig = {
productId: 'embeddings', title: 'Embeddings',
tagline: 'Vector search over your data.',
brandName: 'Hanzo Cloud', docsUrl: 'https://docs.hanzo.ai', docsProduct: 'embeddings',
primary: { label: 'Create collection', onPress: onCreate },
loadMetrics: async () => [{ key: 'vectors', label: 'Vectors', value: 1_240, series: [...] }],
samples: [{ lang: 'curl', label: 'cURL', code: 'curl https://api.hanzo.ai/v1/embeddings ...' }],
}
<Landing config={config}>{/* the product's own real content */}</Landing>
```
All links (docs / quickstart / examples / API / support) derive from `config.docsUrl`,
so a Lux/Zoo console links to ITS OWN surfaces. Brand + accent come from the config
(`brandName`, `accent`) — no hardcoded brand. Pure link helpers are exported
(`apexFromDocs`, `apiBaseFromDocs`, `landingDocsUrl`, `standardResources`, `supportMailto`).
## Pipeline — the deploy-stage line
A small, presentational animated pipeline (Queued → Building → Deploying → Live, or any
custom stages). Driven by a `stages` prop; derive it from a real status string with the
pure `pipelineModel`, and poll to pass fresh stages.
```tsx
import { Pipeline, pipelineModel } from '@hanzo/dashboard'
const model = pipelineModel(app.status, furthestReached) // furthest tracked across polls
<Pipeline stages={model.stages} label={model.label} />
```
Pure helpers: `pipelineModel`, `pipelinePhase`, `stageIndex`, `isTerminalPhase`,
`pipelineTone`, `PIPELINE_STAGES`, and the `PipelineStage`/`PipelineModel`/`StageState`
types. Completed stages fill with a check, the current stage pulses, the active leg
flows, a failure turns the reached stage red — all reduced-motion-guarded.
---
## Provenance — how each export maps back to the console
Extracted (and generalized: no `~/lib/*` / `~/config` imports) from
`~/work/hanzo/console2`:
| `@hanzo/dashboard` | console2 source |
| --- | --- |
| `charts/Charts` (`Sparkline`/`Line`/`Columns`/`Donut`/`Bars`) | `src/components/ui/Charts.tsx` (`Sparkline`/`LineChart`/`BarChart`/`Donut`/`BarRows`) |
| `motion/motion` + `motion/hooks` | `src/components/products/overview/living/{motion,hooks}.ts` |
| `overview/{config,logic}` | `.../overview/living/{config,logic}.ts` |
| `overview/primitives` (`Kpi`/`Feed`/`Board`) + `overview/tiles` (`Tile`) | `.../overview/living/tiles.tsx` (the tile views) |
| `overview/Overview` | `.../overview/living/LivingOverview.tsx` |
| `landing/*` (`Landing`/`Hero`/`Metrics`/`Samples`/`Rail`) | `src/components/products/landing/*` (`ProductLanding`/`LandingHero`/`LandingMetrics`/`CodeSamples`/`ResourceRail`) |
| `pipeline/{pipeline,Pipeline}` | `src/components/products/paas/{railway.ts,RailwayDeploy.tsx}` |
### Generalizations made during extraction
- **Icons** are consumer-supplied (`IconComponent` = a `size`/`color` icon), replacing
the console's `ProductIcon` from its registry.
- **`Overview`** drops `~/lib/api` `ApiError`, `useIsGlobalAdmin`, `PageHeader`,
`FadeIn`, and `States``isGlobalAdmin` is now a prop and the header/fade/error
chrome is inlined (self-contained).
- **Landing** takes brand/docs/support/discord/accent from `LandingConfig` (was
`~/config`). `LandingHero` inlines the hero graphic + accent button (was
`../inference/parts`).
- **Pipeline** takes a `stages` prop and drops the console's `PaasApi.getApp`
self-poll — the consumer polls and passes fresh stages.
- Naming is de-branded and single-word: `LivingOverview``Overview`,
`RailwayDeploy``Pipeline`, `ProductLanding``Landing`, `LineChart`/`BarChart`/
`BarRows``Line`/`Columns`/`Bars`. CSS classes `hz-rail-*``hz-pipe-*`.
---
## Follow-up: swapping console2 onto this package (a later pass)
console2 is **untouched** this pass. To consume `@hanzo/dashboard` back in console2,
per component:
1. **Charts** — delete `src/components/ui/Charts.tsx`; re-export from the package
(`export { Sparkline, Line as LineChart, Columns as BarChart, Bars as BarRows, Donut, useContainerWidth, CHART_PALETTE, CHART_OTHER } from '@hanzo/dashboard'`)
or update call sites to the new names. `~/components/products/inference/parts`,
`landing/*`, and `overview/living/tiles` import `Charts` — the alias keeps them working.
2. **Motion** — replace `overview/living/{motion,hooks}.ts` with re-exports from
`@hanzo/dashboard`; `RailwayDeploy` + `LandingMetrics` import these hooks.
3. **Overview** — replace `overview/living/{config,logic,tiles,LivingOverview}` with
thin wrappers over the package. The console's `LivingOverviewModule` keeps its
registry-router wrapper and passes `isGlobalAdmin={useIsGlobalAdmin()}` +
maps `ApiError` into the driver's caught error. `PageHeader`/`FadeIn`/`States`
stay in console (they're used elsewhere) — the driver now inlines its own.
4. **Landing** — replace `products/landing/*` with the package; the console builds a
`LandingConfig` with `docsUrl: config.docsUrl`, `brandName: config.brandName`,
`discordUrl: 'https://discord.gg/hanzo'`. `LandingHero`'s console-specific
`HeroGraphic`/`AccentButton` (from `inference/parts`) are now inlined in the package.
5. **Pipeline** — swap `paas/RailwayDeploy.tsx` for `<Pipeline>`; the console owns the
`PaasApi.getApp` poll (`usePoll` + `PaasApi`) and the monotonic `furthest` state,
calling `pipelineModel(status, furthest)` and passing `.stages` + `.label`.
Once swapped, the console2 tests that already cover the pure logic
(`motion.test.ts`, `logic.test.ts`, `railway.test.ts`, `landing/logic.test.ts`) move
here (or re-run against the re-exports) so coverage follows the code.
## Honest about what didn't extract cleanly
- The console's **`config.ts` registry** (`LivingOverviewConfig.load` adapters:
`fromCloudUsage`, `fromAdminOverview`, `fromFunctions`, `healthFromApps`) is
**product/API-specific** and stays in console2 — it's the data half. This package is
the view half; the `OverviewData` contract is the seam between them.
- Console tile icons come from the console's product registry; here they're plain
`IconComponent` props (the consumer supplies them).
- The `Pipeline` self-poll was PaaS-coupled and is intentionally dropped — the small,
minor pipeline primitive is presentational only.
## Build
```bash
pnpm typecheck # tsc --noEmit (strict) — clean
pnpm build # tsc → dist (proof; the package ships src)
```
The package **ships `src`** (source-only, like `@hanzo/data`); the consumer's bundler
transpiles it, and the `@hanzo/gui` shorthand style props type-check against the real
token unions via `gui.config.ts` + `gui.d.ts`.
License: BSD-3-Clause · Hanzo AI, Inc.
-17
View File
@@ -1,17 +0,0 @@
// Gui config for @hanzo/dashboard typechecking.
//
// The canonical v5 default config — the same shape Hanzo's admin SPAs use, so
// the shorthand style props (`bg`, `p`, `items`, `justify`, `rounded`, …) the
// dashboard components use typecheck against the real Gui token unions.
// Consumers supply their own config + theme at runtime; this exists so the
// library type-checks standalone. The config-type registration lives in
// `gui.d.ts` (augmenting `@hanzogui/web`), matching the @hanzo/data pattern.
import { defaultConfig } from '@hanzogui/config/v5'
import { createGui } from '@hanzo/gui'
export const config = createGui(defaultConfig)
export default config
export type Conf = typeof config
-15
View File
@@ -1,15 +0,0 @@
/**
* Registers our Gui config with the type system so component style props
* (tokens, themes, shorthands) are typed. `GuiCustomConfig` is declared in
* `@hanzogui/web` and re-exported across the Gui packages; augmenting it there
* flows the types through every `@hanzo/gui` component.
*/
import type { Conf } from './gui.config'
declare module '@hanzogui/web' {
interface GuiCustomConfig extends Conf {}
}
declare module '@hanzogui/core' {
interface GuiCustomConfig extends Conf {}
}
-53
View File
@@ -1,53 +0,0 @@
{
"name": "@hanzo/dashboard",
"version": "0.1.0",
"type": "module",
"description": "Hanzo Dashboard — the reusable dashboard layer on @hanzo/gui: monochrome SVG charts, a videogame-like LivingOverview (count-up KPIs, live sparklines, streaming feed, health), the ProductLanding kit, and a RailwayPipeline deploy animation. Pure (data via props/loaders), typed, reduced-motion-guarded. Extracted from the Hanzo Cloud Console so every Hanzo app composes ONE dashboard system.",
"exports": {
".": {
"types": "./src/index.ts",
"default": "./src/index.ts"
},
"./dashboard.css": "./src/dashboard.css"
},
"main": "./src/index.ts",
"module": "./src/index.ts",
"types": "./src/index.ts",
"sideEffects": [
"*.css"
],
"files": [
"src"
],
"scripts": {
"tc": "tsc --noEmit",
"typecheck": "tsc --noEmit",
"build": "tsc --outDir dist --declaration --emitDeclarationOnly false --module ESNext --moduleResolution bundler --jsx react-jsx --esModuleInterop true --skipLibCheck true --sourceMap true",
"test": "bun test"
},
"peerDependencies": {
"@hanzo/gui": ">=7.2.2",
"@hanzogui/lucide-icons-2": ">=7.2.2",
"react": ">=19"
},
"devDependencies": {
"@hanzo/gui": "7.3.0",
"@hanzogui/config": "7.3.0",
"@hanzogui/core": "7.3.0",
"@hanzogui/lucide-icons-2": "7.3.0",
"@hanzogui/web": "7.3.0",
"@types/react": "^19.1.10",
"react": "^19.0.0",
"typescript": "^5.7.2"
},
"publishConfig": {
"access": "public"
},
"license": "BSD-3-Clause",
"author": "Hanzo AI <dev@hanzo.ai>",
"repository": {
"type": "git",
"url": "git+https://github.com/hanzoai/ui.git",
"directory": "pkgs/dashboard"
}
}
-157
View File
@@ -1,157 +0,0 @@
/**
* @hanzo/dashboard motion + numeric-face stylesheet.
*
* Import ONCE in a consumer app (e.g. `import '@hanzo/dashboard/dashboard.css'`).
* It ships the CSS the dashboard layer's JS-driven motion pairs with: the loading
* shimmer, the live-feed pulse, the fade-up entrance, the deploy-Pipeline track
* animations, and the tabular-numeral face for metric values.
*
* Every animation here is reduced-motion-guarded (→ static) — the count-up + live
* sparkline are driven in JS and gated by `prefers-reduced-motion` in the hooks;
* these are the pure-CSS bits. One place defines the easing; components apply the
* classes. Extracted verbatim from the Hanzo Cloud Console so the "videogame-like"
* feel is identical across apps.
*/
/* Tabular numerals — metric values, prices, counts and IDs align on a fixed
advance width so columns of numbers read cleanly (the dashboard-grade detail). */
.hz-tnum {
font-variant-numeric: tabular-nums;
font-feature-settings: 'tnum' 1;
}
/* Motion — a single fade-up entrance (~0.4s ease-out, small upward travel,
staggered by the consumer). One place defines it; <FadeIn> applies the class. */
@keyframes hz-fade-up {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.hz-fade-up {
animation: hz-fade-up 0.4s cubic-bezier(0.16, 1, 0.3, 1) both;
will-change: transform, opacity;
}
@media (prefers-reduced-motion: reduce) {
.hz-fade-up {
animation: none;
}
}
/* Overview motion — the "videogame-like" live dashboard. The count-up +
live sparkline are driven in JS (rAF, gated by prefers-reduced-motion in the
hooks); these are the pure-CSS bits: a loading shimmer, a live-feed pulse, and
a brief highlight when a tile's number changes. */
/* Skeleton shimmer — an honest "loading", never fabricated content. */
@keyframes hz-shimmer {
0% {
background-position: -160px 0;
}
100% {
background-position: 160px 0;
}
}
.hz-skeleton {
background-color: var(--color3, rgba(148, 163, 184, 0.14));
background-image: linear-gradient(
90deg,
transparent 0%,
var(--color4, rgba(148, 163, 184, 0.22)) 50%,
transparent 100%
);
background-size: 160px 100%;
background-repeat: no-repeat;
animation: hz-shimmer 1.2s ease-in-out infinite;
}
/* Live-feed pulse — the "Live" dot on a streaming panel. */
@keyframes hz-pulse {
0%,
100% {
opacity: 1;
transform: scale(1);
}
50% {
opacity: 0.45;
transform: scale(0.82);
}
}
/* Entrance for a freshly-arrived activity row (staggerless — one row at a time). */
@keyframes hz-row-in {
from {
opacity: 0;
transform: translateY(-6px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.hz-row-in {
animation: hz-row-in 0.35s cubic-bezier(0.16, 1, 0.3, 1) both;
}
@media (prefers-reduced-motion: reduce) {
.hz-skeleton {
animation: none;
}
.hz-row-in {
animation: none;
}
}
/* Pipeline — the deploy-stage line. A smooth flowing gradient marches along the
active leg of the track (stroke-dashoffset), a soft halo pulses out from the
current stage, and the status dot breathes. All reduced-motion-guarded (→
static). One place defines the easing; Pipeline applies the classes. */
@keyframes hz-pipe-flow {
to {
stroke-dashoffset: -28;
}
}
.hz-pipe-flow {
stroke-dasharray: 5 9;
animation: hz-pipe-flow 0.85s linear infinite;
}
@keyframes hz-pipe-pulse {
0% {
transform: scale(1);
opacity: 0.34;
}
70% {
transform: scale(2.1);
opacity: 0;
}
100% {
transform: scale(2.1);
opacity: 0;
}
}
.hz-pipe-pulse {
transform-box: fill-box;
transform-origin: center;
animation: hz-pipe-pulse 1.7s ease-out infinite;
}
.hz-pipe-dot {
animation: hz-pulse 1.5s ease-in-out infinite;
}
@media (prefers-reduced-motion: reduce) {
.hz-pipe-flow,
.hz-pipe-pulse,
.hz-pipe-dot {
animation: none;
}
}
-116
View File
@@ -1,116 +0,0 @@
// @hanzo/dashboard — the reusable dashboard layer on @hanzo/gui.
//
// Composable dashboard primitives extracted from the Hanzo Cloud Console so every
// Hanzo app builds the SAME dashboard system: monochrome SVG charts, a live
// count-up KPI + streaming-feed + health Overview, the Landing kit, and a deploy
// Pipeline. Each is pure (data via props / a loader), typed, reduced-motion-guarded.
//
// import '@hanzo/dashboard/dashboard.css' // once, for the motion keyframes
// import { Overview, Kpi, Sparkline, Landing, Pipeline } from '@hanzo/dashboard'
// Shared
export type { IconComponent } from './types'
// ── Charts (the ONE way to draw a trend / series / share / distribution) ─────
export {
Sparkline,
Line,
Columns,
Donut,
Bars,
useContainerWidth,
CHART_PALETTE,
CHART_OTHER,
type ChartPoint,
type Slice,
} from './charts/Charts'
// ── Motion (pure math + rAF/interval hooks) ──────────────────────────────────
export { easeOutCubic, countUpValue, progress, pushSample, shouldTick, effectiveInterval } from './motion/motion'
export { useReducedMotion, usePageHidden, useCountUp, usePoll } from './motion/hooks'
// ── Overview: the videogame-like dashboard ────────────────────────────
export { Overview } from './overview/Overview'
export { Tile } from './overview/tiles'
export { Kpi, Feed, Board, Panel, SkeletonBar, EmptyPanel, PanelSpinner, LiveDot } from './overview/primitives'
export type { KpiProps, FeedProps, BoardProps } from './overview/primitives'
export {
formatMetric,
metricTarget,
deltaOf,
hasTrend,
statusColor,
healthColor,
severityColor,
worstHealth,
healthTally,
selectKpi,
selectSeries,
selectDistribution,
distributionTotal,
mergeActivity,
windowRows,
ago,
isSkeleton,
OK,
WARN,
BAD,
MUTED,
type Loadable,
} from './overview/logic'
export type {
OverviewConfig,
OverviewContext,
OverviewData,
OverviewRange,
OverviewTile,
MetricTile,
TimeseriesTile,
DistributionTile,
ActivityTile,
AlertsTile,
HealthTile,
MetricUnit,
LiveConfig,
LoadOverview,
OverviewKpi,
OverviewSeries,
OverviewSlice,
OverviewPoint,
OverviewEvent,
OverviewAlert,
OverviewHealth,
} from './overview/config'
// ── Landing kit (Hero / Metrics / Samples / Rail) ────────────────────────────
export { Landing } from './landing/Landing'
export { Hero } from './landing/Hero'
export { Metrics } from './landing/Metrics'
export { Samples } from './landing/Samples'
export { Rail } from './landing/Rail'
export { LandingCard, ActionRow, DeltaChip, openExternal } from './landing/parts'
export { apexFromDocs, apiBaseFromDocs, landingDocsUrl, standardResources, supportMailto, type StandardResources } from './landing/logic'
export type {
LandingConfig,
LandingAction,
LandingMetric,
ResourceLink,
CodeSample,
CodeLang,
} from './landing/types'
// ── Pipeline (deploy-stage line) ─────────────────────────────────────────────
export { Pipeline, type PipelineProps } from './pipeline/Pipeline'
export {
PIPELINE_STAGES,
pipelinePhase,
stageIndex,
isTerminalPhase,
pipelineModel,
pipelineTone,
type PipelinePhase,
type PipelineStage,
type PipelineStageName,
type PipelineModel,
type StageState,
} from './pipeline/pipeline'
-115
View File
@@ -1,115 +0,0 @@
'use client'
/**
* Hero — the clean product hero (kicker + title + one-line what-it-is + primary
* CTA): monochrome-tasteful with a functional accent + a pure-SVG hero graphic.
* White-labels by brand (`config.brandName`) and accent (`config.accent`).
*/
import type { ComponentProps } from 'react'
import { Button, Card, Text, XStack, YStack } from '@hanzo/gui'
import { CHART_PALETTE } from '../charts/Charts'
import { openExternal } from './parts'
import type { LandingAction, LandingConfig } from './types'
/** A hex color at an alpha, as an `rgba()`. */
function hex(h: string, a: number): string {
const n = h.replace('#', '')
const r = parseInt(n.slice(0, 2), 16)
const g = parseInt(n.slice(2, 4), 16)
const b = parseInt(n.slice(4, 6), 16)
return `rgba(${r}, ${g}, ${b}, ${a})`
}
const act = (a: LandingAction) => (a.href ? openExternal(a.href) : a.onPress?.())
export function Hero({ config: c }: { config: LandingConfig }) {
const Icon = c.icon
const accent = c.accent ?? CHART_PALETTE[0]
return (
<Card
borderWidth={1}
borderColor="$borderColor"
rounded="$6"
p="$5"
overflow="hidden"
style={{ background: `linear-gradient(115deg, ${hex(accent, 0.16)} 0%, ${hex(accent, 0.04)} 48%, rgba(0,0,0,0) 78%)` }}
>
<XStack items="center" gap="$4" flexWrap="wrap">
<YStack flex={1} minW={280} gap="$2.5">
{Icon || c.brandName ? (
<XStack items="center" gap="$2">
{Icon ? <Icon size={16} color={accent} /> : null}
{c.brandName ? (
<Text fontSize="$1" color="$color11" fontWeight="700" style={{ letterSpacing: 0.4, textTransform: 'uppercase' }}>
{c.brandName}
</Text>
) : null}
</XStack>
) : null}
<Text fontSize="$8" fontWeight="900" color="$color12">
{c.title}
</Text>
<Text fontSize="$3" color="$color11" maxW={620}>
{c.tagline}
</Text>
{c.primary || c.secondary ? (
<XStack gap="$2" pt="$2" flexWrap="wrap" items="center">
{c.primary ? (
<AccentButton accent={accent} icon={c.primary.icon} onPress={() => act(c.primary as LandingAction)}>
{c.primary.label}
</AccentButton>
) : null}
{c.secondary ? (
<Button icon={c.secondary.icon} onPress={() => act(c.secondary as LandingAction)}>
{c.secondary.label}
</Button>
) : null}
</XStack>
) : null}
</YStack>
<YStack items="center" justify="center" minW={170}>
<HeroGraphic size={170} accent={accent} />
</YStack>
</XStack>
</Card>
)
}
/** The one high-emphasis accent action (a filled button on the brand accent). */
function AccentButton({ accent, children, ...props }: { accent: string } & ComponentProps<typeof Button>) {
return (
<Button {...props} borderWidth={1} style={{ backgroundColor: accent, borderColor: accent, color: '#fff' }} hoverStyle={{ opacity: 0.92 }} pressStyle={{ opacity: 0.85 }}>
{children}
</Button>
)
}
/** A pure-SVG hero motif — a glowing isometric "serving unit" cube. */
function HeroGraphic({ size = 170, accent = CHART_PALETTE[0] }: { size?: number; accent?: string }) {
return (
<svg width={size} height={size} viewBox="0 0 200 200" aria-hidden style={{ display: 'block', maxWidth: '100%' }}>
<defs>
<radialGradient id="hz-hero-glow" cx="50%" cy="45%" r="60%">
<stop offset="0%" stopColor={accent} stopOpacity="0.55" />
<stop offset="55%" stopColor={accent} stopOpacity="0.14" />
<stop offset="100%" stopColor={accent} stopOpacity="0" />
</radialGradient>
<linearGradient id="hz-hero-cube" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stopColor="#a78bfa" />
<stop offset="100%" stopColor={accent} />
</linearGradient>
</defs>
<circle cx="100" cy="96" r="92" fill="url(#hz-hero-glow)" />
{[74, 56, 40].map((r, i) => (
<circle key={r} cx="100" cy="96" r={r} fill="none" stroke={accent} strokeOpacity={0.18 + i * 0.12} strokeWidth={1.5} />
))}
<g transform="translate(100 96)">
<polygon points="0,-34 30,-17 30,17 0,34 -30,17 -30,-17" fill="url(#hz-hero-cube)" fillOpacity="0.9" />
<polygon points="0,-34 30,-17 0,0 -30,-17" fill="#c4b5fd" fillOpacity="0.85" />
<polygon points="0,0 30,-17 30,17 0,34" fill={accent} fillOpacity="0.7" />
<polygon points="0,0 -30,-17 -30,17 0,34" fill="#6d28d9" fillOpacity="0.7" />
</g>
</svg>
)
}
-47
View File
@@ -1,47 +0,0 @@
'use client'
/**
* Landing — the reusable polished landing/overview any product composes from a
* small `LandingConfig`: a clean hero, a live count-up metrics row, an interactive
* code-sample block (curl/python/ts/js + copy + Run), and a resources rail
* (docs/quickstart/examples/code/support). DRY — one landing system across products.
*
* Layout: hero on top, then a two-column body (main content + resource rail) that
* wraps to a single column on narrow viewports. `children` is the product's own
* REAL content, rendered below the code block so a product keeps everything it
* already had — this is polish, not a rewrite.
*/
import { useRef, type ReactNode } from 'react'
import { XStack, YStack } from '@hanzo/gui'
import { Hero } from './Hero'
import { Metrics } from './Metrics'
import { Samples } from './Samples'
import { Rail } from './Rail'
import type { LandingConfig } from './types'
export function Landing({ config, children }: { config: LandingConfig; children?: ReactNode }) {
const codeRef = useRef<HTMLDivElement>(null)
const hasCode = Boolean(config.samples && config.samples.length)
const scrollToCode = () => codeRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' })
return (
<YStack gap="$4">
<Hero config={config} />
<XStack gap="$4" flexWrap="wrap" items="flex-start">
<YStack flex={2} minW={360} gap="$4">
<Metrics config={config} />
{hasCode ? (
<div ref={codeRef} style={{ width: '100%' }}>
<Samples samples={config.samples as NonNullable<LandingConfig['samples']>} run={config.run} />
</div>
) : null}
{children}
</YStack>
<Rail config={config} onViewCode={hasCode ? scrollToCode : undefined} />
</XStack>
</YStack>
)
}
-134
View File
@@ -1,134 +0,0 @@
'use client'
/**
* Metrics — the live KPI row for a landing. Count-up values + real sparklines +
* honest deltas, reusing the motion system (`useCountUp`/`usePoll`/
* `useReducedMotion`) and the shared `Sparkline`. Two modes:
* - controlled: the parent passes `config.metrics` (it owns the data / reload);
* - self-loading: `config.loadMetrics` (+ optional `pollMs`) fetches + polls here.
* Honest throughout: a null value renders "—", a background poll failure keeps the
* last real data, and a first-load failure shows a quiet note (metrics are
* supplementary — never a full error card).
*/
import { useEffect, useRef, useState } from 'react'
import { Card, Text, XStack, YStack } from '@hanzo/gui'
import { CHART_PALETTE, Sparkline } from '../charts/Charts'
import { useCountUp, usePoll, useReducedMotion } from '../motion/hooks'
import { DeltaChip } from './parts'
import type { LandingConfig, LandingMetric } from './types'
export function Metrics({ config }: { config: LandingConfig }) {
const reduced = useReducedMotion()
const controlled = config.metrics !== undefined
const load = config.loadMetrics
const accent = config.accent ?? CHART_PALETTE[0]
const [metrics, setMetrics] = useState<LandingMetric[] | null>(config.metrics ?? null)
const [failed, setFailed] = useState(false)
const hasData = useRef(false)
hasData.current = metrics !== null
const { tick } = usePoll(!controlled && load && config.pollMs ? config.pollMs : 0)
// Controlled: mirror the parent-provided metrics.
useEffect(() => {
if (controlled) setMetrics(config.metrics ?? null)
}, [controlled, config.metrics])
// Self-loading: fetch (and re-fetch on each poll tick). Keep last-good on error.
useEffect(() => {
if (controlled || !load) return
let cancelled = false
void load()
.then((m) => {
if (!cancelled) {
setMetrics(m)
setFailed(false)
}
})
.catch(() => {
if (!cancelled && !hasData.current) setFailed(true)
})
return () => {
cancelled = true
}
}, [controlled, load, tick])
if (!metrics) {
if (failed) return <MetricsNote text="Live metrics arent connected for your organization yet — nothing is fabricated." />
if (!controlled && load) return <MetricsSkeleton />
return null
}
if (metrics.length === 0) return null
return (
<XStack flexWrap="wrap" gap="$3">
{metrics.map((m) => (
<MetricCard key={m.key} metric={m} reduced={reduced} accent={accent} />
))}
</XStack>
)
}
function MetricCard({ metric, reduced, accent }: { metric: LandingMetric; reduced: boolean; accent: string }) {
const finite = metric.value != null && Number.isFinite(metric.value)
const target = finite ? (metric.value as number) : 0
// Count up from zero on first mount (then re-animate on every value change). Seeding
// via an after-mount effect means `useCountUp` animates 0 → value instead of snapping.
const [seeded, setSeeded] = useState(false)
useEffect(() => {
setSeeded(true)
}, [])
const shown = useCountUp(seeded ? target : 0, !reduced && finite)
const fmt = metric.format ?? ((n: number) => Math.round(n).toLocaleString())
return (
<Card p="$3.5" gap="$2" borderWidth={1} borderColor="$borderColor" bg="$color2" flex={1} minW={168}>
<XStack items="center" justify="space-between" gap="$2">
<Text fontSize="$2" color="$color10" numberOfLines={1}>
{metric.label}
</Text>
{metric.icon ?? null}
</XStack>
<Text fontSize="$8" fontWeight="900" color={finite ? '$color12' : '$color10'} numberOfLines={1}>
{finite ? fmt(shown) : '—'}
</Text>
<XStack items="center" justify="space-between" gap="$2" minH={22}>
{metric.deltaPct !== undefined ? <DeltaChip pct={metric.deltaPct ?? null} /> : <YStack />}
{metric.series && metric.series.filter((v) => Number.isFinite(v)).length >= 2 ? (
<Sparkline values={metric.series} width={72} height={22} color={accent} />
) : metric.hint ? (
<Text fontSize="$1" color="$color9" numberOfLines={1}>
{metric.hint}
</Text>
) : null}
</XStack>
</Card>
)
}
function MetricsSkeleton() {
return (
<XStack flexWrap="wrap" gap="$3">
{[0, 1, 2, 3].map((i) => (
<Card key={i} p="$3.5" gap="$2.5" borderWidth={1} borderColor="$borderColor" bg="$color2" flex={1} minW={168}>
<div className="hz-skeleton" style={{ height: 10, width: '55%', borderRadius: 4 }} />
<div className="hz-skeleton" style={{ height: 26, width: '72%', borderRadius: 6 }} />
<div className="hz-skeleton" style={{ height: 10, width: '40%', borderRadius: 4 }} />
</Card>
))}
</XStack>
)
}
function MetricsNote({ text }: { text: string }) {
return (
<Card p="$3.5" borderWidth={1} borderColor="$borderColor" bg="$color2">
<Text fontSize="$2" color="$color10">
{text}
</Text>
</Card>
)
}
-61
View File
@@ -1,61 +0,0 @@
'use client'
/**
* Rail — the right rail of a product landing (Documentation, Quick Start, Examples,
* Code samples, API Reference, Quick Actions, Support). Every row is a REAL link
* (docs derived from `config.docsUrl`) or an in-console action — no dead rows.
* "Code samples" scrolls to the interactive block; Community/Support are opt-in
* (`config.discordUrl` / `config.supportEmail`), never a hardcoded brand link.
*/
import { YStack } from '@hanzo/gui'
import { BookOpen, Boxes, Code2, LifeBuoy, MessageSquare, Rocket, Zap } from '@hanzogui/lucide-icons-2'
import { ActionRow, LandingCard, openExternal } from './parts'
import { apexFromDocs, standardResources } from './logic'
import type { LandingAction, LandingConfig } from './types'
const runAction = (a: LandingAction) => (a.href ? openExternal(a.href) : a.onPress?.())
export function Rail({ config, onViewCode }: { config: LandingConfig; onViewCode?: () => void }) {
const docs = config.docsUrl
const std = standardResources(docs, config.docsProduct)
const apex = apexFromDocs(docs)
const supportAddr = config.supportEmail ?? `support@${apex}`
const support = `mailto:${supportAddr}`
return (
<YStack gap="$4" flex={1} minW={280}>
<LandingCard title="Resources" p="$3">
<YStack gap="$0.5">
<ActionRow icon={<BookOpen size={15} />} label="Documentation" sub="Guides & concepts" onPress={() => openExternal(std.docs)} />
<ActionRow icon={<Rocket size={15} />} label="Quick Start" sub="Ship in minutes" onPress={() => openExternal(std.quickstart)} />
<ActionRow icon={<Boxes size={15} />} label="Examples" sub="Sample projects" onPress={() => openExternal(std.examples)} />
{onViewCode ? <ActionRow icon={<Code2 size={15} />} label="Code samples" sub="Copy a real API call" onPress={onViewCode} /> : null}
<ActionRow icon={<Zap size={15} />} label="API Reference" sub={`api.${apex}`} onPress={() => openExternal(std.api)} />
{config.resources?.map((r) => (
<ActionRow key={r.id} icon={r.icon} label={r.label} sub={r.sub} onPress={() => (r.href ? openExternal(r.href) : r.onPress?.())} />
))}
</YStack>
</LandingCard>
{config.actions?.length ? (
<LandingCard title="Quick Actions" p="$3">
<YStack gap="$0.5">
{config.actions.map((a) => (
<ActionRow key={a.label} icon={a.icon} label={a.label} onPress={() => runAction(a)} />
))}
</YStack>
</LandingCard>
) : null}
<LandingCard title="Need help?" p="$3">
<YStack gap="$0.5">
{config.discordUrl ? (
<ActionRow icon={<MessageSquare size={15} />} label="Community" sub="Join the chat" onPress={() => openExternal(config.discordUrl as string)} />
) : null}
<ActionRow icon={<LifeBuoy size={15} />} label="Contact Support" sub={supportAddr} onPress={() => openExternal(support)} />
</YStack>
</LandingCard>
</YStack>
)
}
-78
View File
@@ -1,78 +0,0 @@
'use client'
/**
* Samples — the interactive Quick Start / code block. Tab-switch between the REAL
* API call in curl / Python / TS / JS, copy the current sample, and (optionally)
* Run / Try in Playground. Not a screenshot — real, copyable, tab-switchable code.
* The block scrolls horizontally inside its own container so a wide line never
* scrolls the page.
*/
import { useState } from 'react'
import { Button, Text, XStack, YStack } from '@hanzo/gui'
import { Check, Copy, Play } from '@hanzogui/lucide-icons-2'
import { LandingCard, openExternal } from './parts'
import type { CodeSample, LandingAction } from './types'
export function Samples({ samples, run, title = 'Quick start' }: { samples: CodeSample[]; run?: LandingAction; title?: string }) {
const [active, setActive] = useState(0)
const [copied, setCopied] = useState(false)
const idx = Math.min(active, samples.length - 1)
const sample = samples[idx]
const copy = async () => {
if (typeof navigator === 'undefined' || !navigator.clipboard) return
try {
await navigator.clipboard.writeText(sample.code)
setCopied(true)
setTimeout(() => setCopied(false), 1400)
} catch {
/* clipboard denied — no-op (the code is still visible + selectable) */
}
}
return (
<LandingCard
title={title}
actions={
run ? (
<Button size="$2" icon={<Play size={14} />} onPress={() => (run.href ? openExternal(run.href) : run.onPress?.())}>
{run.label}
</Button>
) : undefined
}
>
<XStack items="center" justify="space-between" gap="$2" flexWrap="wrap">
<XStack gap="$1" flexWrap="wrap" items="center">
{samples.map((s, i) => {
const on = i === idx
return (
<Button key={s.lang} size="$2" bg={on ? '$color5' : 'transparent'} borderWidth={1} borderColor={on ? '$color7' : '$borderColor'} onPress={() => setActive(i)}>
<Text fontSize="$2" fontWeight={on ? '700' : '500'} color={on ? '$color12' : '$color11'}>
{s.label}
</Text>
</Button>
)
})}
</XStack>
<Button size="$2" chromeless icon={copied ? <Check size={14} color="#23c562" /> : <Copy size={14} />} onPress={copy}>
{copied ? 'Copied' : 'Copy'}
</Button>
</XStack>
<YStack bg="$color1" borderWidth={1} borderColor="$borderColor" rounded="$4" p="$3" style={{ overflowX: 'auto' }}>
<Text
color="$color11"
style={{
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace',
whiteSpace: 'pre',
fontSize: 12,
lineHeight: 1.6,
}}
>
{sample.code}
</Text>
</YStack>
</LandingCard>
)
}
-47
View File
@@ -1,47 +0,0 @@
/**
* Landing — PURE link helpers (no UI, no I/O), unit-testable. Builds the real docs
* / API / support links a landing rail and code samples point at, derived from the
* brand docs host so a Lux/Zoo console links to ITS OWN surfaces (never hardcoded).
*/
/** Apex domain from a docs URL (https://docs.hanzo.ai → hanzo.ai). */
export function apexFromDocs(docsUrl: string): string {
return docsUrl
.replace(/^https?:\/\//, '')
.replace(/\/.*$/, '')
.replace(/^docs\./, '')
.trim()
}
/** The brand API base for code samples, e.g. https://api.hanzo.ai/v1. */
export function apiBaseFromDocs(docsUrl: string): string {
return `https://api.${apexFromDocs(docsUrl)}/v1`
}
/** A docs URL for a product, with an optional sub-path. */
export function landingDocsUrl(docsUrl: string, product: string, sub?: string): string {
const base = `${docsUrl.replace(/\/+$/, '')}/${product}`
return sub ? `${base}/${sub}` : base
}
/** The brand support mailbox derived from the docs host (docs.hanzo.ai → support@hanzo.ai). */
export function supportMailto(docsUrl: string): string {
return `mailto:support@${apexFromDocs(docsUrl)}`
}
/** The standard doc links every product's resource rail shows. */
export interface StandardResources {
docs: string
quickstart: string
examples: string
api: string
}
export function standardResources(docsUrl: string, product: string): StandardResources {
return {
docs: landingDocsUrl(docsUrl, product),
quickstart: landingDocsUrl(docsUrl, product, 'quickstart'),
examples: landingDocsUrl(docsUrl, product, 'examples'),
api: landingDocsUrl(docsUrl, product, 'api-reference'),
}
}
-116
View File
@@ -1,116 +0,0 @@
'use client'
/**
* Landing UI atoms — the shared card / action-row / delta-chip language, in a
* dark-card idiom. Strictly @hanzo/gui v5 shorthands.
*/
import type { ComponentProps, ReactNode } from 'react'
import { Card, Text, XStack, YStack } from '@hanzo/gui'
import { TrendingDown, TrendingUp } from '@hanzogui/lucide-icons-2'
/** Open an external URL in a new tab (SSR-guarded). */
export function openExternal(href: string): void {
if (typeof window !== 'undefined') window.open(href, '_blank', 'noopener')
}
/** A titled dark card — the ONE section container across the landing. */
export function LandingCard({
title,
subtitle,
actions,
children,
p = '$4',
...rest
}: {
title?: ReactNode
subtitle?: ReactNode
actions?: ReactNode
children: ReactNode
p?: ComponentProps<typeof Card>['p']
} & Omit<ComponentProps<typeof Card>, 'children' | 'title'>) {
return (
<Card borderWidth={1} borderColor="$borderColor" bg="$color2" rounded="$5" p={p} gap="$3.5" {...rest}>
{title || actions ? (
<XStack justify="space-between" items="center" gap="$3" flexWrap="wrap">
<YStack gap="$0.5" flex={1} minW={120}>
{title ? (
<Text fontSize="$5" fontWeight="800" color="$color12">
{title}
</Text>
) : null}
{subtitle ? (
<Text fontSize="$2" color="$color10">
{subtitle}
</Text>
) : null}
</YStack>
{actions ? (
<XStack gap="$2" items="center">
{actions}
</XStack>
) : null}
</XStack>
) : null}
{children}
</Card>
)
}
/** One tappable action row (icon + label + optional sub-line). */
export function ActionRow({
icon,
label,
sub,
onPress,
}: {
icon?: ReactNode
label: string
sub?: string
onPress: () => void
}) {
return (
<XStack items="center" gap="$3" py="$2" px="$2" rounded="$3" cursor="pointer" hoverStyle={{ bg: '$color3' }} onPress={onPress}>
<YStack width={30} height={30} items="center" justify="center" rounded="$3" bg="$color3">
{icon}
</YStack>
<YStack flex={1} minW={0} gap="$0.5">
<Text fontSize="$3" color="$color12" numberOfLines={1}>
{label}
</Text>
{sub ? (
<Text fontSize="$1" color="$color10" numberOfLines={1}>
{sub}
</Text>
) : null}
</YStack>
<Text fontSize="$4" color="$color9">
</Text>
</XStack>
)
}
/** An ↑/↓ delta chip. Up→green, down→red, null→muted em-dash. Direction only (honest). */
export function DeltaChip({ pct }: { pct: number | null }) {
if (pct == null || !Number.isFinite(pct)) {
return (
<Text fontSize="$1" color="$color10">
</Text>
)
}
const rounded = Math.round(pct)
const up = rounded >= 0
const color = up ? '#23c562' : '#ff5d8f'
const Icon = up ? TrendingUp : TrendingDown
const sign = rounded > 0 ? '+' : rounded < 0 ? '' : ''
return (
<XStack items="center" gap="$1">
<Icon size={13} color={color} />
<Text fontSize="$1" fontWeight="700" style={{ color }}>
{sign}
{Math.abs(rounded)}%
</Text>
</XStack>
)
}
-98
View File
@@ -1,98 +0,0 @@
/**
* Landing — the shared config contract every product supplies to get a polished
* landing/overview: a hero, live count-up metrics, an interactive code-sample
* block, and a resources rail. One landing system, DRY across products.
*
* Types only — erased at build; the pure link logic lives in `logic.ts`.
*/
import type { ReactElement, ReactNode } from 'react'
import type { IconComponent } from '../types'
/** Languages the interactive code block offers. */
export type CodeLang = 'curl' | 'python' | 'ts' | 'js'
/** One copyable code sample — the REAL API call for the product. */
export interface CodeSample {
lang: CodeLang
/** Tab label, e.g. "cURL", "Python", "TypeScript", "JavaScript". */
label: string
code: string
}
/** A call-to-action — an in-app handler OR an external link. `icon` is a rendered
* element so it satisfies the Button `icon` prop. */
export interface LandingAction {
label: string
onPress?: () => void
href?: string
icon?: ReactElement
}
/** One headline metric — a real value (null → honest "—"), optional trend + delta. */
export interface LandingMetric {
key: string
label: string
/** Current value, or null when the backend doesn't report it (renders "—"). */
value: number | null
/** Formats the (count-up) value; defaults to a localized integer. */
format?: (n: number) => string
/** Real time series for the sparkline (≥2 points, else omitted — never faked). */
series?: number[]
/** Fractional/percent change vs the prior window; null → honest em-dash. */
deltaPct?: number | null
icon?: ReactNode
/** A small honest caption shown when there is no series (e.g. "Awaiting metering"). */
hint?: string
}
/** A resource rail row (Documentation / Quick Start / Examples / Support …). */
export interface ResourceLink {
id: string
label: string
sub?: string
href?: string
onPress?: () => void
icon?: ReactNode
}
/**
* The full landing config a product composes. `metrics` (controlled by the parent)
* OR `loadMetrics` (self-loading + polling) drives the live KPI row; supply one.
*/
export interface LandingConfig {
productId: string
title: string
/** One-line "what it is". */
tagline: string
icon?: IconComponent
/** Docs host base the rail/samples derive links from (e.g. 'https://docs.hanzo.ai'). */
docsUrl: string
/** Docs path segment under the docs host (e.g. 'embeddings'). */
docsProduct: string
/** Brand name shown in the hero kicker (omitted when unset). */
brandName?: string
/** Accent color for the hero + sparklines (defaults to the palette head). */
accent?: string
/** Community link for the resource rail (row omitted when unset). */
discordUrl?: string
/** Support email override (defaults to `support@<docs apex>`). */
supportEmail?: string
/** The hero's primary + optional secondary CTA. */
primary?: LandingAction
secondary?: LandingAction
/** Controlled metrics (parent owns the data). */
metrics?: LandingMetric[]
/** Self-loading metrics (real data / honest gaps) — used with `pollMs`. */
loadMetrics?: () => Promise<LandingMetric[]>
/** Poll interval for `loadMetrics` in ms (0/undefined → load once, no poll). */
pollMs?: number
/** Interactive, copyable code samples (curl/python/ts/js). */
samples?: CodeSample[]
/** A "Run"/"Try in Playground" affordance for the code block. */
run?: LandingAction
/** Quick-action rows appended to the resource rail. */
actions?: LandingAction[]
/** Extra resource rows appended after the standard ones. */
resources?: ResourceLink[]
}
-98
View File
@@ -1,98 +0,0 @@
'use client'
/**
* Overview React hooks — the thin rAF/interval drivers on top of the pure
* `motion.ts` math. Each hook owns exactly one browser concern (animation frames,
* the poll clock, reduced-motion, tab visibility) and cleans itself up on unmount
* (no leaked intervals/frames).
*/
import { useEffect, useRef, useState } from 'react'
import { countUpValue, progress } from './motion'
/** `true` when the user asked for reduced motion — every animation short-circuits to its end state. */
export function useReducedMotion(): boolean {
const [reduced, setReduced] = useState(false)
useEffect(() => {
if (typeof window === 'undefined' || !window.matchMedia) return
const mq = window.matchMedia('(prefers-reduced-motion: reduce)')
const on = () => setReduced(mq.matches)
on()
// addEventListener is the modern API; older Safari uses addListener.
if (mq.addEventListener) mq.addEventListener('change', on)
else mq.addListener(on)
return () => {
if (mq.removeEventListener) mq.removeEventListener('change', on)
else mq.removeListener(on)
}
}, [])
return reduced
}
/** `true` when the document is hidden (background tab) — the poll clock pauses. */
export function usePageHidden(): boolean {
const [hidden, setHidden] = useState(false)
useEffect(() => {
if (typeof document === 'undefined') return
const on = () => setHidden(document.visibilityState === 'hidden')
on()
document.addEventListener('visibilitychange', on)
return () => document.removeEventListener('visibilitychange', on)
}, [])
return hidden
}
/**
* Animate a number from its previous value to `target` on the eased count-up curve.
* Returns the value to display right now. When `enabled` is false (reduced motion,
* or count-up turned off in config), it snaps to `target` — the tile still shows the
* real number, just without the tween. Cleans up its rAF on unmount/retarget.
*/
export function useCountUp(target: number, enabled: boolean, durationMs = 700): number {
const [value, setValue] = useState(target)
// The value currently ON SCREEN, tracked every frame. A retarget mid-flight
// animates from HERE (not the previous target), so a new poll never snaps the
// digits back before easing — smooth all the way.
const shownRef = useRef(target)
const frameRef = useRef<number | null>(null)
useEffect(() => {
const from = shownRef.current
if (!enabled || from === target) {
shownRef.current = target
setValue(target)
return
}
const startedAt = typeof performance !== 'undefined' ? performance.now() : Date.now()
const tick = () => {
const now = typeof performance !== 'undefined' ? performance.now() : Date.now()
const t = progress(startedAt, now, durationMs)
const v = countUpValue(from, target, t)
shownRef.current = v
setValue(v)
if (t < 1) frameRef.current = requestAnimationFrame(tick)
}
frameRef.current = requestAnimationFrame(tick)
return () => {
if (frameRef.current != null) cancelAnimationFrame(frameRef.current)
}
}, [target, enabled, durationMs])
return value
}
/**
* A monotonic tick that fires every `everyMs` while the tab is visible, paused when
* `everyMs <= 0`. The consumer refetches on each tick. Self-clearing on unmount and
* on interval change — no stacked timers. Returns the tick count (a dependency the
* caller can watch) and a `bump()` to force an immediate refetch (e.g. range change).
*/
export function usePoll(everyMs: number): { tick: number; bump: () => void } {
const [tick, setTick] = useState(0)
useEffect(() => {
if (everyMs <= 0) return
const id = setInterval(() => setTick((n) => n + 1), everyMs)
return () => clearInterval(id)
}, [everyMs])
return { tick, bump: () => setTick((n) => n + 1) }
}
-76
View File
@@ -1,76 +0,0 @@
/**
* Overview motion — the PURE math behind the "videogame-like" feel, kept
* out of the React components so it is unit-testable in the node env (no jsdom).
* The `.tsx` tiles are thin wrappers that drive `requestAnimationFrame` with these
* functions.
*
* Three primitives, one law each:
* - `easeOutCubic` / `countUpValue` — a number animates from a→b on an eased
* curve; at `t>=1` it lands EXACTLY on `b` (no float drift, no fabricated
* overshoot). Reduced-motion callers pass `t=1` and get `b` immediately.
* - `pushSample` — a live sparkline is a bounded ring of the most-recent real
* samples; a new reading appends and evicts the oldest past `cap`. It never
* invents points — an empty history stays empty (the tile shows no trend).
* - `shouldTick` — a self-correcting poll clock: given the last tick and now, it
* answers whether it is time to refetch, so a throttled live loop can't drift
* or stack overlapping fetches.
*
* Honesty: none of these create data. Count-up interpolates BETWEEN two real
* values the caller already has; the sparkline ring holds only real samples.
*/
/** Ease-out cubic — fast then settling, the house count-up curve. Clamps to [0,1]. */
export const easeOutCubic = (t: number): number => {
const x = t <= 0 ? 0 : t >= 1 ? 1 : t
return 1 - Math.pow(1 - x, 3)
}
/**
* The displayed value at normalized progress `t` (0..1) animating `from`→`to`.
* Lands exactly on `to` at t>=1 (and returns `to` for a non-finite `from`, so a
* first-ever value counts up from 0 only when the caller seeds `from=0`).
*/
export function countUpValue(from: number, to: number, t: number): number {
if (!Number.isFinite(to)) return Number.isFinite(from) ? from : 0
if (t >= 1) return to
const base = Number.isFinite(from) ? from : to
return base + (to - base) * easeOutCubic(t)
}
/** Normalized progress for an animation started at `startedAt`, now `now`, over `durationMs`. */
export function progress(startedAt: number, now: number, durationMs: number): number {
if (durationMs <= 0) return 1
return Math.min(1, Math.max(0, (now - startedAt) / durationMs))
}
/**
* Append `sample` to a live-sparkline ring, evicting oldest beyond `cap`. Returns
* a NEW array (never mutates) so React state updates cleanly. A non-finite sample
* is dropped — the trend only ever holds real readings.
*/
export function pushSample(history: readonly number[], sample: number, cap: number): number[] {
if (!Number.isFinite(sample)) return history.slice()
const next = [...history, sample]
return next.length > cap ? next.slice(next.length - cap) : next
}
/**
* Poll clock. `true` when `now - lastTickAt >= everyMs` (time to refetch). A
* non-positive interval disables polling (always `false`) so a config can turn
* live updates off without a special case.
*/
export function shouldTick(lastTickAt: number, now: number, everyMs: number): boolean {
if (everyMs <= 0) return false
return now - lastTickAt >= everyMs
}
/**
* Effective poll interval given a requested `everyMs`, a floor, and whether the
* document is hidden. Returns 0 (paused) when hidden — a background tab must not
* burn the backend — and never goes below `floorMs` (throttle guard) when active.
*/
export function effectiveInterval(everyMs: number, floorMs: number, hidden: boolean): number {
if (everyMs <= 0) return 0
if (hidden) return 0
return Math.max(floorMs, everyMs)
}
-203
View File
@@ -1,203 +0,0 @@
'use client'
/**
* Overview — the ONE reusable, videogame-like dashboard driver. Given an
* `OverviewConfig` (a product's tiles + its REAL data loader + live/motion
* settings) it renders the board and keeps it alive: a single throttled poll loop
* refetches on an interval (paused on a hidden tab), the range selector re-scopes,
* and every number counts up on change (unless reduced motion / count-up off).
*
* DRY + orthogonal: this file knows nothing about any specific product or endpoint.
* The tiles read their slice out of the normalized `OverviewData` the loader returns;
* a product is added by writing a config, never overview UI.
*
* Honest by construction: the FIRST load shows a spinner; a failure shows the error
* card; thereafter a background refetch never blanks the board (the last real data
* stays until new real data lands), and any tile whose slice is absent renders its
* own skeleton/empty. Style props are the @hanzo/gui v5 shorthand set.
*/
import { useEffect, useRef, useState, type ReactNode } from 'react'
import { Button, Card, Spinner, Text, XStack, YStack } from '@hanzo/gui'
import { effectiveInterval } from '../motion/motion'
import { usePageHidden, usePoll } from '../motion/hooks'
import { type Loadable } from './logic'
import { PanelSpinner } from './primitives'
import { Tile } from './tiles'
import type { OverviewConfig, OverviewData, OverviewRange } from './config'
/** Throttle floor: a config can ask for faster, but never faster than this. */
const POLL_FLOOR_MS = 5000
const RANGES: { key: OverviewRange; label: string }[] = [
{ key: '24h', label: '24H' },
{ key: '7d', label: '7D' },
{ key: '30d', label: '30D' },
]
export function Overview({ config, allOrgs, isGlobalAdmin = false }: { config: OverviewConfig; allOrgs?: boolean; isGlobalAdmin?: boolean }) {
const [range, setRange] = useState<OverviewRange>('24h')
const [state, setState] = useState<Loadable<OverviewData>>({ s: 'loading' })
const [refreshing, setRefreshing] = useState(false)
const reqRef = useRef(0)
const hidden = usePageHidden()
// Pause polling while the tab is hidden OR the board is in its error state — an
// errored source should not be hammered on a loop; the user retries explicitly.
const pollMs = effectiveInterval(config.live?.pollMs ?? 0, POLL_FLOOR_MS, hidden || state.s === 'error')
const { tick, bump } = usePoll(pollMs)
const live = (config.live?.pollMs ?? 0) > 0
const countUp = config.live?.countUp !== false
// One load path for the first paint, the range change, and every poll tick. The
// reqRef guard drops a stale in-flight response so a fast range toggle can't
// render older data over newer.
useEffect(() => {
const id = ++reqRef.current
const first = state.s === 'loading'
if (!first) setRefreshing(true)
config
.load({ range, allOrgs, isGlobalAdmin })
.then((v) => {
if (id === reqRef.current) setState({ s: 'ready', v })
})
.catch((e) => {
// A background refetch that fails must NOT blank a board that already has
// real data — keep the last data, surface the error only on the first load.
if (id !== reqRef.current) return
if (first) {
const err = asLoadError(e)
setState({ s: 'error', message: err.message, status: err.status })
}
})
.finally(() => {
if (id === reqRef.current) setRefreshing(false)
})
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [range, allOrgs, isGlobalAdmin, tick, config])
const onRange = (k: OverviewRange) => {
setRange(k)
bump()
}
const data = state.s === 'ready' ? state.v : null
return (
<YStack gap="$4">
<Header
title={allOrgs ? `${config.title} — all orgs` : config.title}
subtitle={config.subtitle}
actions={
<XStack gap="$2" items="center">
{refreshing ? <Spinner size="small" color="$color10" /> : null}
{config.ranged === false ? null : <RangeSelector value={range} onChange={onRange} />}
</XStack>
}
/>
{state.s === 'error' ? (
<ErrorState
message={state.message}
status={state.status}
onRetry={() => {
setState({ s: 'loading' })
bump()
}}
/>
) : state.s === 'loading' ? (
<PanelSpinner />
) : (
<YStack gap="$4">
{config.rows.map((row, ri) => (
<FadeIn key={ri} index={ri} step={60}>
<XStack flexWrap="wrap" gap="$3">
{row.map((tile, ti) => (
<Tile key={ti} tile={tile} data={data} loading={false} live={live && countUp} index={ti} />
))}
</XStack>
</FadeIn>
))}
</YStack>
)}
</YStack>
)
}
// ── chrome (self-contained — no host-app coupling) ───────────────────────────
/** Extract an honest message + status from an unknown thrown value. */
function asLoadError(e: unknown): { message: string; status: number } {
if (e && typeof e === 'object') {
const anyE = e as { message?: unknown; status?: unknown }
return {
message: typeof anyE.message === 'string' ? anyE.message : 'Something went wrong.',
status: typeof anyE.status === 'number' ? anyE.status : 0,
}
}
return { message: typeof e === 'string' ? e : 'Something went wrong.', status: 0 }
}
/** Section title + optional subtitle and right-aligned actions. */
function Header({ title, subtitle, actions }: { title: string; subtitle?: string; actions?: ReactNode }) {
return (
<XStack justify="space-between" items="flex-start" gap="$3" flexWrap="wrap">
<YStack gap="$1" flex={1} minW={200}>
<Text fontSize="$7" fontWeight="500" letterSpacing={-0.4}>
{title}
</Text>
{subtitle ? (
<Text fontSize="$3" color="$color11">
{subtitle}
</Text>
) : null}
</YStack>
{actions ? <XStack gap="$2" items="center">{actions}</XStack> : null}
</XStack>
)
}
/** A fade-up entrance (staggered by index). Honors reduced motion via the CSS. */
function FadeIn({ children, index = 0, step = 50 }: { children: ReactNode; index?: number; step?: number }) {
return (
<div className="hz-fade-up" style={{ animationDelay: `${index * step}ms` }}>
{children}
</div>
)
}
/** The first-load / retry error card (honest, with a retry). */
function ErrorState({ message, status, onRetry }: { message: string; status: number; onRetry: () => void }) {
const note =
status === 404
? 'This overviews data source is not routed on this deployment yet — it appears automatically once the backend is reachable for your org.'
: message
return (
<Card borderWidth={1} borderColor="$borderColor" p="$5" gap="$3" items="flex-start">
<Text fontSize="$5" fontWeight="600" color="$color12">
Could not load
</Text>
<Text fontSize="$3" color="$color11" maxW={620}>
{note}
</Text>
<Button onPress={onRetry}>Retry</Button>
</Card>
)
}
function RangeSelector({ value, onChange }: { value: OverviewRange; onChange: (k: OverviewRange) => void }) {
return (
<XStack borderWidth={1} borderColor="$borderColor" rounded="$4" overflow="hidden">
{RANGES.map((r) => {
const active = r.key === value
return (
<Button key={r.key} size="$2" chromeless={!active} bg={active ? '$color5' : 'transparent'} rounded="$0" onPress={() => onChange(r.key)}>
<Text fontSize="$2" fontWeight={active ? '700' : '500'} color={active ? '$color12' : '$color10'}>
{r.label}
</Text>
</Button>
)
})}
</XStack>
)
}
-176
View File
@@ -1,176 +0,0 @@
/**
* Overview config — the DECLARATIVE contract that makes one overview
* component serve every product. A product declares WHICH tiles it has and WHERE
* their data comes from; `Overview` renders + animates them. No product
* writes overview UI — it writes a `OverviewConfig`.
*
* Orthogonal by design:
* - a `LoadOverview` is the ONE async that fetches a product's REAL data and
* returns a normalized `OverviewData` (KPIs, series, distribution, activity,
* alerts, health). It owns the endpoint; the tiles know nothing about it.
* - each tile is a small value object naming a slice of that data by key
* (`kpi.tokens`, `series.spend`, …). A missing slice renders its honest empty
* state — the config can over-declare tiles a slow backend hasn't filled yet.
* - `live` is the videogame layer: a poll interval (throttled, hidden-tab-paused)
* and whether numbers count up. Off → a static overview, same component.
*
* The consumer supplies its own adapter that maps each real source (usage ledger,
* admin aggregate, functions metrics, …) onto `OverviewData`, so wiring a product =
* one adapter + one config, never new UI.
*/
import type { IconComponent } from '../types'
/** A KPI datum for an animated metric tile. Raw numbers; the tile formats them. */
export type OverviewKpi = {
value: number
/** Prior-period value for the ±% delta; omit when there is no basis (→ honest "—"). */
prior?: number
/** A recent dense series for the tile's live sparkline (oldest→newest). */
series?: number[]
}
/** A point on a temporal series. */
export type OverviewPoint = { t: string; value: number }
/** A named temporal series with its bucket interval (drives the x-axis labels). */
export type OverviewSeries = { interval: 'hour' | 'day' | (string & {}); points: OverviewPoint[] }
/** A slice of a distribution (spend by model, revenue by product). */
export type OverviewSlice = { label: string; value: number; sub?: string }
/** A live activity/event row. */
export type OverviewEvent = {
id: string
time: string
title: string
subtitle?: string
/** `success` | `error` | `warn` | '' — drives the status dot. */
status: string
}
/** An open alert. */
export type OverviewAlert = { id: string; severity: 'critical' | 'warning' | 'info' | (string & {}); title: string; detail?: string }
/** A service-health row. */
export type OverviewHealth = { service: string; health: 'green' | 'yellow' | 'red' | (string & {}); detail?: string }
/**
* The normalized data every tile reads. Keyed maps so a tile names its slice by a
* stable key; every map may be partial (a slice with no data → honest empty tile).
*/
export type OverviewData = {
kpi: Record<string, OverviewKpi>
series: Record<string, OverviewSeries>
distribution: Record<string, OverviewSlice[]>
activity: OverviewEvent[]
alerts: OverviewAlert[]
health: OverviewHealth[]
/** Total count behind `activity` for pagination, when the source knows it. */
activityTotal?: number
}
/** How a number is displayed. `cents`→USD, `count`→compacted, `ms`→duration, `pct`→percent. */
export type MetricUnit = 'count' | 'cents' | 'ms' | 'pct'
/** An animated KPI tile: a count-up number + optional live sparkline + delta. */
export type MetricTile = {
tile: 'metric'
/** Key into `OverviewData.kpi`. */
key: string
label: string
icon: IconComponent
unit?: MetricUnit
/** Static caption under the value (e.g. "across 3 providers"). */
caption?: string
/** Accent color for the sparkline (defaults to the palette by position). */
color?: string
}
/** A timeseries panel (line or bars) over `OverviewData.series[key]`. */
export type TimeseriesTile = {
tile: 'timeseries'
key: string
title: string
kind?: 'line' | 'bar'
unit?: MetricUnit
}
/** A distribution panel (donut + legend) over `OverviewData.distribution[key]`. */
export type DistributionTile = {
tile: 'distribution'
key: string
title: string
/** Center caption under the total. */
centerLabel?: string
unit?: MetricUnit
}
/** The live activity stream (virtualized, streaming/polled). */
export type ActivityTile = {
tile: 'activity'
title?: string
/** Empty-state copy when there is no activity in range. */
empty?: string
}
/** The open-alerts panel. */
export type AlertsTile = { tile: 'alerts'; title?: string }
/** The service-health board. */
export type HealthTile = { tile: 'health'; title?: string; empty?: string }
/** Any tile. Discriminated on `tile` so the renderer branches exhaustively. */
export type OverviewTile =
| MetricTile
| TimeseriesTile
| DistributionTile
| ActivityTile
| AlertsTile
| HealthTile
/** The "videogame" layer — live updates + motion, all off-able for a static view. */
export type LiveConfig = {
/**
* Poll the loader every `pollMs` (throttled to a floor, paused when the tab is
* hidden). 0/undefined → no polling (a one-shot static overview).
*/
pollMs?: number
/** Count numbers up on change (respects prefers-reduced-motion). Default true. */
countUp?: boolean
}
/** The context passed to a loader (range + org scope). */
export type OverviewContext = {
range: OverviewRange
/** Global-admin all-orgs god view. */
allOrgs?: boolean
/**
* True when the viewer is a global (cross-tenant) admin. Loaders use this to
* SKIP admin-only aggregates that a tenant user's browser would only ever get a
* 403 from, going straight to the org-scoped source instead — the board renders
* the same, minus the console noise.
*/
isGlobalAdmin?: boolean
}
export type OverviewRange = '24h' | '7d' | '30d'
/** The ONE async that fetches a product's REAL data, normalized to `OverviewData`. */
export type LoadOverview = (ctx: OverviewContext) => Promise<OverviewData>
/**
* A product's complete overview — its header, its tiles (in render order), its
* real data loader, and its live/motion settings. This IS the reusable unit: a
* consumer builds one of these; `Overview` renders it.
*/
export type OverviewConfig = {
id: string
title: string
subtitle?: string
/** Ordered rows of tiles (each row is a responsive wrap group). */
rows: OverviewTile[][]
load: LoadOverview
live?: LiveConfig
/** Show the range selector (24H/7D/30D). Default true. */
ranged?: boolean
}
-204
View File
@@ -1,204 +0,0 @@
/**
* Overview tile logic — the PURE decisions behind every tile, so the honest
* empty/loading/error/reduced-motion behavior is unit-testable in the node env and
* the `.tsx` tiles stay thin.
*
* Every function here answers ONE question a tile needs: how to format a value for
* its unit, whether a live sparkline has enough real points to draw, what a delta
* reads (or the honest "—" when there's no basis), what color a status/health dot
* is, and — critically — how to SELECT a tile's slice out of `OverviewData` so a
* missing slice becomes an honest empty tile instead of a crash.
*
* Nothing here fabricates data: a formatter turns a real number into a string, a
* selector returns `undefined`/`[]` for absent data, and the sparkline gate returns
* false for <2 points (the tile then renders no trend).
*/
import type {
MetricUnit,
OverviewData,
OverviewKpi,
OverviewSlice,
OverviewEvent,
OverviewHealth,
OverviewSeries,
} from './config'
// ── value formatting ─────────────────────────────────────────────────────────
const compact = (n: number): string =>
new Intl.NumberFormat(undefined, { notation: 'compact', maximumFractionDigits: 1 }).format(n)
const usd = (cents: number): string => {
const d = cents / 100
if (Math.abs(d) >= 1000) return '$' + new Intl.NumberFormat(undefined, { notation: 'compact', maximumFractionDigits: 1 }).format(d)
return '$' + d.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })
}
const duration = (ms: number): string => {
if (ms >= 1000) return (ms / 1000).toFixed(ms >= 10_000 ? 0 : 1) + 's'
return Math.round(ms) + 'ms'
}
/** Format a raw metric value for its unit. The ONE place units become strings. */
export function formatMetric(value: number, unit: MetricUnit = 'count'): string {
if (!Number.isFinite(value)) return '—'
switch (unit) {
case 'cents':
return usd(value)
case 'ms':
return duration(value)
case 'pct':
return `${Math.round(value)}%`
default:
return compact(value)
}
}
/**
* The number the count-up animation should target for a unit. Money counts up in
* DOLLARS (not cents) so the animated digits read naturally; everything else counts
* up in its own value. The tile formats the interpolated number back via
* `formatMetric` on the same unit. This returns the raw value; the divisor is in
* `formatMetric`.
*/
export function metricTarget(value: number): number {
return Number.isFinite(value) ? value : 0
}
/** A delta descriptor for a KPI, or null when there's no prior basis (honest "—"). */
export function deltaOf(kpi: OverviewKpi | undefined): { pct: number; up: boolean } | null {
if (!kpi || kpi.prior === undefined || !Number.isFinite(kpi.prior) || kpi.prior === 0) return null
const pct = Math.round(((kpi.value - kpi.prior) / kpi.prior) * 100)
return { pct, up: pct >= 0 }
}
/** True when a live sparkline has ≥2 finite points to draw (else the tile shows no trend). */
export function hasTrend(series: readonly number[] | undefined): boolean {
if (!series) return false
return series.filter((v) => Number.isFinite(v)).length >= 2
}
// ── status / health colors (the ONE mapping) ─────────────────────────────────
export const OK = '#23c562'
export const WARN = '#f0a868'
export const BAD = '#ff5d8f'
export const MUTED = '#64748b'
/** Dot color for an activity/event status. */
export function statusColor(status: string): string {
const s = status.toLowerCase()
if (s === 'error' || s === 'failed' || s === 'fail') return BAD
if (s === 'warn' || s === 'warning' || s === 'pending') return WARN
return OK // success or unknown/'' reads as nominal
}
/** Dot color for a service-health verdict. */
export function healthColor(health: string): string {
const h = health.toLowerCase()
if (h === 'green' || h === 'healthy' || h === 'ok') return OK
if (h === 'yellow' || h === 'degraded' || h === 'warn') return WARN
if (h === 'red' || h === 'down' || h === 'unhealthy') return BAD
return MUTED
}
/** Dot color for an alert severity. */
export function severityColor(severity: string): string {
const s = severity.toLowerCase()
if (s === 'critical' || s === 'error') return BAD
if (s === 'warning' || s === 'warn') return WARN
return MUTED
}
// ── overall health verdict for the board header ──────────────────────────────
/** The worst verdict across health rows: red > yellow > green; '' when no rows. */
export function worstHealth(rows: readonly OverviewHealth[]): '' | 'green' | 'yellow' | 'red' {
let sawYellow = false
let sawGreen = false
for (const r of rows) {
const h = r.health.toLowerCase()
if (h === 'red' || h === 'down' || h === 'unhealthy') return 'red' // worst — short-circuit
if (h === 'yellow' || h === 'degraded') sawYellow = true
else if (h === 'green' || h === 'healthy' || h === 'ok') sawGreen = true
}
if (sawYellow) return 'yellow'
if (sawGreen) return 'green'
return ''
}
/** Count healthy rows / total (for the "12/14 healthy" header). */
export function healthTally(rows: readonly OverviewHealth[]): { healthy: number; total: number } {
const total = rows.length
const healthy = rows.filter((r) => healthColor(r.health) === OK).length
return { healthy, total }
}
// ── slice selectors (a missing slice → honest empty, never a crash) ──────────
export const selectKpi = (data: OverviewData, key: string): OverviewKpi | undefined => data.kpi[key]
export const selectSeries = (data: OverviewData, key: string): OverviewSeries | undefined => data.series[key]
export const selectDistribution = (data: OverviewData, key: string): OverviewSlice[] => data.distribution[key] ?? []
/** Sum a distribution's positive values (the donut center total). */
export function distributionTotal(slices: readonly OverviewSlice[]): number {
return slices.reduce((sum, s) => sum + Math.max(0, s.value), 0)
}
/**
* Merge freshly-polled activity into the current list, newest-first, de-duped by
* id, capped. This is what lets the live stream GROW smoothly across polls without
* duplicating a row already on screen or re-ordering the feed. Rows with an empty
* id are kept (can't dedupe them) but still ordered by time.
*/
export function mergeActivity(current: readonly OverviewEvent[], incoming: readonly OverviewEvent[], cap: number): OverviewEvent[] {
const seen = new Set<string>()
const out: OverviewEvent[] = []
for (const e of [...incoming, ...current]) {
if (e.id && seen.has(e.id)) continue
if (e.id) seen.add(e.id)
out.push(e)
}
out.sort((a, b) => tOf(b.time) - tOf(a.time))
return out.slice(0, cap)
}
const tOf = (iso: string): number => {
const t = Date.parse(iso)
return Number.isNaN(t) ? 0 : t
}
/** Which rows of a virtualized list are visible for a scroll offset + viewport. */
export function windowRows<T>(rows: readonly T[], scrollTop: number, rowH: number, viewportH: number, overscan = 4): { start: number; end: number; padTop: number; padBottom: number; slice: T[] } {
if (rows.length === 0 || rowH <= 0) return { start: 0, end: 0, padTop: 0, padBottom: 0, slice: [] }
const start = Math.max(0, Math.floor(scrollTop / rowH) - overscan)
const visible = Math.ceil(viewportH / rowH) + overscan * 2
const end = Math.min(rows.length, start + visible)
return {
start,
end,
padTop: start * rowH,
padBottom: Math.max(0, (rows.length - end) * rowH),
slice: rows.slice(start, end),
}
}
// ── async state (the repo idiom, shared by the driver + tests) ───────────────
export type Loadable<T> = { s: 'loading' } | { s: 'error'; message: string; status: number } | { s: 'ready'; v: T }
/** True when a tile should render its SKELETON (first load, no data yet). */
export const isSkeleton = <T>(state: Loadable<T>): boolean => state.s === 'loading'
/** Relative-time label for an event/activity timestamp (honest for a bad stamp). */
export function ago(iso: string, now: number): string {
const t = Date.parse(iso)
if (Number.isNaN(t)) return '—'
const s = Math.max(0, Math.round((now - t) / 1000))
if (s < 60) return `${s}s ago`
const m = Math.round(s / 60)
if (m < 60) return `${m}m ago`
const h = Math.round(m / 60)
if (h < 24) return `${h}h ago`
return `${Math.round(h / 24)}d ago`
}
-306
View File
@@ -1,306 +0,0 @@
'use client'
/**
* Composable dashboard primitives — the small, honest building blocks a consumer
* mixes directly (without the full `Overview` driver): `Kpi` (an animated stat
* tile), `Feed` (a virtualized activity stream), `Board` (a service-health board),
* plus the shared `Panel`/`SkeletonBar`/`EmptyPanel`/`PanelSpinner` chrome.
*
* Every primitive is driven by direct props (data in, view out) and is honest by
* construction: a null/absent value renders an em-dash, a <2-point series draws no
* trend, reduced motion snaps to the end state. Style props are the @hanzo/gui v5
* shorthand set; raw/SVG colors go through inline `style`.
*/
import { useMemo, useRef, useState, type ReactNode } from 'react'
import { Card, Spinner, Text, XStack, YStack } from '@hanzo/gui'
import { Clock, TrendingDown, TrendingUp } from '@hanzogui/lucide-icons-2'
import { CHART_PALETTE, Sparkline } from '../charts/Charts'
import { useCountUp, useReducedMotion } from '../motion/hooks'
import { ago, formatMetric, hasTrend, healthColor, healthTally, statusColor, windowRows, worstHealth, OK, MUTED } from './logic'
import type { MetricUnit, OverviewEvent, OverviewHealth } from './config'
import type { IconComponent } from '../types'
const UP = '#23c562'
const DOWN = '#ff5d8f'
// ── Kpi (count-up stat tile + optional live sparkline + delta) ───────────────
export interface KpiProps {
label: string
/** The value; `null`/`undefined` → an honest em-dash (backend doesn't report it). */
value: number | null | undefined
unit?: MetricUnit
/** Signed ±% vs the prior window; `null`/`undefined` → honest "— vs prior". */
deltaPct?: number | null
/** A recent dense series for the sparkline (≥2 finite points, else omitted). */
series?: number[]
icon?: IconComponent
/** Sparkline accent (defaults to the palette head). */
color?: string
/** A small caption under the value. */
caption?: string
/** Count the value up on change (respects reduced motion). Default true. */
animate?: boolean
/** Show the loading skeleton for the value + delta. */
loading?: boolean
}
/** An animated KPI / stat tile — the canonical dashboard number. */
export function Kpi({ label, value, unit, deltaPct, series, icon: Icon, color, caption, animate = true, loading = false }: KpiProps) {
const reduced = useReducedMotion()
const finite = value != null && Number.isFinite(value)
const target = finite ? (value as number) : 0
const shown = useCountUp(target, animate && !reduced && finite)
const accent = color ?? CHART_PALETTE[0]
return (
// Responsive KPI columns so a 4-card row balances instead of wrapping 3+1: one
// column on phones, two from md, four from xl. flexGrow equalizes the widths.
<Card
borderWidth={1}
borderColor="$borderColor"
p="$4"
gap="$2"
flexGrow={1}
flexShrink={1}
flexBasis="100%"
$md={{ flexBasis: '48%' }}
$xl={{ flexBasis: '22%' }}
minW={200}
>
<XStack items="center" gap="$2">
{Icon ? <Icon size={15} opacity={0.7} /> : null}
<Text fontSize="$2" color="$color10" numberOfLines={1}>
{label}
</Text>
</XStack>
{loading && !finite ? (
<SkeletonBar w={110} h={34} />
) : (
<Text fontSize="$9" fontWeight="500" color={finite ? '$color12' : '$color10'} numberOfLines={1} className="hz-tnum">
{finite ? formatMetric(shown, unit) : '—'}
</Text>
)}
<XStack justify="space-between" items="flex-end" gap="$2">
<YStack gap="$1">
{caption ? (
<Text fontSize="$1" color="$color10" numberOfLines={1}>
{caption}
</Text>
) : null}
<DeltaPill pct={deltaPct ?? null} loading={loading && !finite} />
</YStack>
{hasTrend(series) ? <Sparkline values={series!} color={accent} /> : null}
</XStack>
</Card>
)
}
function DeltaPill({ pct, loading }: { pct: number | null; loading: boolean }) {
if (loading) return <SkeletonBar w={80} h={12} />
if (pct == null || !Number.isFinite(pct)) {
return (
<Text fontSize="$1" color="$color10">
vs prior
</Text>
)
}
const up = pct >= 0
const Icon = up ? TrendingUp : TrendingDown
const color = up ? UP : DOWN
return (
<XStack items="center" gap="$1">
<Icon size={12} color={color} />
<Text fontSize="$1" fontWeight="700" style={{ color }}>
{`${up ? '+' : ''}${Math.round(pct)}%`}
</Text>
<Text fontSize="$1" color="$color10">
vs prior
</Text>
</XStack>
)
}
// ── Feed (virtualized live activity stream) ──────────────────────────────────
const ROW_H = 52
const VIEWPORT_H = 360
export interface FeedProps {
items: OverviewEvent[]
title?: string
empty?: string
loading?: boolean
}
/** A live activity stream — newest rows on top, virtualized past a viewport. */
export function Feed({ items, title = 'Live activity', empty = 'No activity in this range yet.', loading = false }: FeedProps) {
const [scrollTop, setScrollTop] = useState(0)
const nowRef = useRef(Date.now())
nowRef.current = Date.now()
const rows = items ?? []
// Virtualize only when the stream is long; small feeds render whole (no scroll jank).
const virtualize = rows.length > Math.ceil(VIEWPORT_H / ROW_H)
const win = useMemo(() => windowRows(rows, scrollTop, ROW_H, VIEWPORT_H), [rows, scrollTop])
return (
<Panel title={title} right={<LiveDot on={!loading && rows.length > 0} />}>
{loading && rows.length === 0 ? (
<YStack gap="$2">
{[0, 1, 2, 3].map((i) => (
<SkeletonBar key={i} w="100%" h={ROW_H - 12} />
))}
</YStack>
) : rows.length === 0 ? (
<EmptyPanel note={empty} />
) : virtualize ? (
<div style={{ height: VIEWPORT_H, overflowY: 'auto' }} onScroll={(e) => setScrollTop((e.target as HTMLDivElement).scrollTop)}>
<div style={{ height: win.padTop }} />
{win.slice.map((e) => (
<FeedRow key={e.id || `${e.time}-${e.title}`} title={e.title} subtitle={e.subtitle} status={e.status} when={ago(e.time, nowRef.current)} />
))}
<div style={{ height: win.padBottom }} />
</div>
) : (
<YStack>
{rows.map((e) => (
<FeedRow key={e.id || `${e.time}-${e.title}`} title={e.title} subtitle={e.subtitle} status={e.status} when={ago(e.time, nowRef.current)} />
))}
</YStack>
)}
</Panel>
)
}
function FeedRow({ title, subtitle, status, when }: { title: string; subtitle?: string; status: string; when: string }) {
return (
<XStack items="center" gap="$3" py="$2.5" borderBottomWidth={1} borderColor="$borderColor" style={{ height: ROW_H }}>
<YStack width={8} height={8} rounded="$10" style={{ backgroundColor: statusColor(status) }} />
<YStack flex={1}>
<Text fontSize="$3" color="$color12" numberOfLines={1}>
{title || 'Event'}
</Text>
{subtitle ? (
<Text fontSize="$1" color="$color10" numberOfLines={1}>
{subtitle}
</Text>
) : null}
</YStack>
<XStack items="center" gap="$1.5">
<Clock size={12} opacity={0.6} />
<Text fontSize="$2" color="$color11">
{when}
</Text>
</XStack>
</XStack>
)
}
// ── Board (service-health board) ─────────────────────────────────────────────
export interface BoardProps {
items: OverviewHealth[]
title?: string
empty?: string
loading?: boolean
}
/** A service-health board — a healthy/total header + per-service dots. */
export function Board({ items, title = 'System health', empty = 'Health is reported once the control plane is reachable.', loading = false }: BoardProps) {
const rows = items ?? []
const worst = worstHealth(rows)
const { healthy, total } = healthTally(rows)
const headColor = worst === 'red' ? DOWN : worst === 'yellow' ? '#f0a868' : worst === 'green' ? OK : MUTED
return (
<Panel
title={title}
right={
total > 0 ? (
<XStack items="center" gap="$2">
<YStack width={9} height={9} rounded="$10" style={{ backgroundColor: headColor }} />
<Text fontSize="$2" color="$color10">
{healthy}/{total} healthy
</Text>
</XStack>
) : undefined
}
>
{loading && rows.length === 0 ? (
<SkeletonBar w="100%" h={40} />
) : rows.length === 0 ? (
<EmptyPanel note={empty} />
) : (
<XStack flexWrap="wrap" gap="$2">
{rows.map((r) => (
<XStack key={r.service} items="center" gap="$2" bg="$color2" px="$2.5" py="$1.5" rounded="$10" borderWidth={1} borderColor="$borderColor">
<YStack width={8} height={8} rounded="$10" style={{ backgroundColor: healthColor(r.health) }} />
<Text fontSize="$2" color="$color11" numberOfLines={1}>
{r.service}
</Text>
</XStack>
))}
</XStack>
)}
</Panel>
)
}
// ── shared chrome ────────────────────────────────────────────────────────────
/** A titled panel shell — the ONE section container across the dashboard. */
export function Panel({ title, right, children, minW = 320, flex }: { title: string; right?: ReactNode; children: ReactNode; minW?: number; flex?: number }) {
return (
<Card borderWidth={1} borderColor="$borderColor" p="$4" gap="$3" flex={flex} minW={minW}>
<XStack items="center" justify="space-between" gap="$2">
<Text fontSize="$4" fontWeight="500" color="$color12">
{title}
</Text>
{right}
</XStack>
{children}
</Card>
)
}
/** A pulsing "live" indicator (steady dot under reduced motion). */
export function LiveDot({ on }: { on: boolean }) {
const reduced = useReducedMotion()
if (!on) return null
return (
<XStack items="center" gap="$1.5">
<YStack width={7} height={7} rounded="$10" style={{ backgroundColor: OK, animation: reduced ? undefined : 'hz-pulse 1.6s ease-in-out infinite' }} />
<Text fontSize="$1" color="$color10">
Live
</Text>
</XStack>
)
}
/** A shimmer skeleton block (honest "loading", not fabricated content). */
export function SkeletonBar({ w, h, rounded }: { w: number | string; h: number; rounded?: boolean }) {
return <div className="hz-skeleton" style={{ width: w, height: h, borderRadius: rounded ? '50%' : 8 }} aria-hidden />
}
/** An honest empty-state note inside a panel. */
export function EmptyPanel({ note }: { note: string }) {
return (
<YStack p="$5" items="center" gap="$1">
<Text fontSize="$3" color="$color11">
{note}
</Text>
</YStack>
)
}
/** A loading spinner centered in a panel (used by the driver's first paint). */
export function PanelSpinner() {
return (
<XStack p="$6" justify="center">
<Spinner size="large" color="$color11" />
</XStack>
)
}
-223
View File
@@ -1,223 +0,0 @@
'use client'
/**
* Overview tiles — the config-tile adapters that select a slice out of the
* normalized `OverviewData` and render the matching primitive. Thin by design:
* the presentation lives in the composable primitives (`Kpi`/`Feed`/`Board`) and
* the charts (`Line`/`Columns`/`Donut`); these adapters only pick the slice and
* hand it over. `Tile` routes a declared tile spec to its adapter (exhaustive on
* the `tile` discriminant), which is what the `Overview` driver renders per row.
*/
import { Text, XStack, YStack } from '@hanzo/gui'
import { CHART_OTHER, CHART_PALETTE, Columns, Donut, Line, type ChartPoint, type Slice } from '../charts/Charts'
import { Board, EmptyPanel, Feed, Kpi, Panel, SkeletonBar } from './primitives'
import {
deltaOf,
distributionTotal,
formatMetric,
selectDistribution,
selectKpi,
selectSeries,
severityColor,
OK,
} from './logic'
import type {
ActivityTile,
AlertsTile,
DistributionTile,
HealthTile,
MetricTile,
OverviewData,
OverviewTile,
TimeseriesTile,
} from './config'
/** Format an axis label for a series interval. */
const fmtAxis = (iso: string, interval: string): string => {
const d = new Date(iso)
if (Number.isNaN(d.getTime())) return iso
return interval === 'day'
? d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })
: d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })
}
// ── Metric tile → Kpi ────────────────────────────────────────────────────────
export function MetricTileView({ tile, data, loading, live, index }: { tile: MetricTile; data: OverviewData | null; loading: boolean; live: boolean; index: number }) {
const kpi = data ? selectKpi(data, tile.key) : undefined
const delta = deltaOf(kpi)
return (
<Kpi
label={tile.label}
icon={tile.icon}
unit={tile.unit}
caption={tile.caption}
color={tile.color ?? CHART_PALETTE[index % CHART_PALETTE.length]}
value={kpi === undefined ? null : kpi.value}
deltaPct={delta ? delta.pct : null}
series={kpi?.series}
animate={live}
loading={loading && kpi === undefined}
/>
)
}
// ── Timeseries tile → Line / Columns ─────────────────────────────────────────
export function TimeseriesTileView({ tile, data, loading }: { tile: TimeseriesTile; data: OverviewData | null; loading: boolean }) {
const series = data ? selectSeries(data, tile.key) : undefined
const points: ChartPoint[] = (series?.points ?? []).map((p) => ({ label: fmtAxis(p.t, series?.interval ?? 'day'), value: p.value }))
const fmt = (v: number) => formatMetric(v, tile.unit)
const total = points.reduce((s, p) => s + p.value, 0)
return (
<Panel title={tile.title} flex={1} right={points.length ? <Text fontSize="$2" color="$color10" className="hz-tnum">{fmt(total)}</Text> : undefined}>
{loading && series === undefined ? (
<SkeletonBar w="100%" h={200} />
) : points.length < 2 ? (
<EmptyPanel note="Not enough data in this range yet." />
) : tile.kind === 'bar' ? (
<Columns data={points} color={CHART_PALETTE[1]} formatValue={fmt} />
) : (
<Line data={points} color={CHART_PALETTE[0]} formatValue={fmt} />
)}
</Panel>
)
}
// ── Distribution tile → Donut + legend ───────────────────────────────────────
export function DistributionTileView({ tile, data, loading }: { tile: DistributionTile; data: OverviewData | null; loading: boolean }) {
const raw = data ? selectDistribution(data, tile.key) : []
const total = distributionTotal(raw)
const slices: Slice[] = raw
.filter((s) => s.value > 0)
.map((s, i) => ({ label: s.label, value: s.value, color: i < CHART_PALETTE.length ? CHART_PALETTE[i] : CHART_OTHER }))
const fmt = (v: number) => formatMetric(v, tile.unit)
return (
<Panel title={tile.title} flex={1} minW={340}>
{loading && raw.length === 0 ? (
<XStack p="$4" justify="center">
<SkeletonBar w={160} h={160} rounded />
</XStack>
) : total <= 0 ? (
<EmptyPanel note="No breakdown recorded in this range." />
) : (
<XStack flexWrap="wrap" gap="$4" items="center">
<Donut
slices={slices}
center={
<>
<Text fontSize="$5" fontWeight="500" color="$color12" className="hz-tnum">
{fmt(total)}
</Text>
{tile.centerLabel ? (
<Text fontSize="$1" color="$color10">
{tile.centerLabel}
</Text>
) : null}
</>
}
/>
<YStack flex={1} minW={200} gap="$2">
{raw.filter((s) => s.value > 0).map((s, i) => (
<XStack key={s.label} items="center" gap="$2.5">
<YStack width={10} height={10} rounded="$10" style={{ backgroundColor: i < CHART_PALETTE.length ? CHART_PALETTE[i] : CHART_OTHER }} />
<YStack flex={1}>
<Text fontSize="$3" fontWeight="600" color="$color12" numberOfLines={1}>
{s.label}
</Text>
{s.sub ? (
<Text fontSize="$1" color="$color10" numberOfLines={1}>
{s.sub}
</Text>
) : null}
</YStack>
<YStack items="flex-end">
<Text fontSize="$3" fontWeight="500" color="$color12" className="hz-tnum">
{fmt(s.value)}
</Text>
<Text fontSize="$1" color="$color10" className="hz-tnum">
{Math.round((s.value / total) * 100)}%
</Text>
</YStack>
</XStack>
))}
</YStack>
</XStack>
)}
</Panel>
)
}
// ── Activity tile → Feed ─────────────────────────────────────────────────────
export function ActivityTileView({ tile, data, loading }: { tile: ActivityTile; data: OverviewData | null; loading: boolean }) {
return <Feed items={data?.activity ?? []} title={tile.title} empty={tile.empty} loading={loading} />
}
// ── Alerts tile ──────────────────────────────────────────────────────────────
export function AlertsTileView({ tile, data, loading }: { tile: AlertsTile; data: OverviewData | null; loading: boolean }) {
const alerts = data?.alerts ?? []
return (
<Panel title={tile.title ?? 'Alerts'}>
{loading && alerts.length === 0 ? (
<SkeletonBar w="100%" h={44} />
) : alerts.length === 0 ? (
<XStack items="center" gap="$2">
<YStack width={9} height={9} rounded="$10" style={{ backgroundColor: OK }} />
<Text fontSize="$3" color="$color11">
No active alerts.
</Text>
</XStack>
) : (
<YStack gap="$2.5">
{alerts.map((a) => (
<XStack key={a.id || a.title} items="flex-start" gap="$2.5">
<YStack width={9} height={9} rounded="$10" mt="$1.5" style={{ backgroundColor: severityColor(a.severity) }} />
<YStack flex={1}>
<Text fontSize="$3" fontWeight="600" color="$color12">
{a.title}
</Text>
{a.detail ? (
<Text fontSize="$1" color="$color10">
{a.detail}
</Text>
) : null}
</YStack>
</XStack>
))}
</YStack>
)}
</Panel>
)
}
// ── Health tile → Board ──────────────────────────────────────────────────────
export function HealthTileView({ tile, data, loading }: { tile: HealthTile; data: OverviewData | null; loading: boolean }) {
return <Board items={data?.health ?? []} title={tile.title} empty={tile.empty} loading={loading} />
}
// ── Tile router ──────────────────────────────────────────────────────────────
/** Render one declared tile spec against the normalized `OverviewData`. */
export function Tile({ tile, data, loading, live, index }: { tile: OverviewTile; data: OverviewData | null; loading: boolean; live: boolean; index: number }) {
switch (tile.tile) {
case 'metric':
return <MetricTileView tile={tile} data={data} loading={loading} live={live} index={index} />
case 'timeseries':
return <TimeseriesTileView tile={tile} data={data} loading={loading} />
case 'distribution':
return <DistributionTileView tile={tile} data={data} loading={loading} />
case 'activity':
return <ActivityTileView tile={tile} data={data} loading={loading} />
case 'alerts':
return <AlertsTileView tile={tile} data={data} loading={loading} />
case 'health':
return <HealthTileView tile={tile} data={data} loading={loading} />
}
}
-178
View File
@@ -1,178 +0,0 @@
'use client'
/**
* Pipeline — the animated deploy-stage line. A horizontal track with N stages:
* completed stages fill in with a check, the current stage pulses, the active leg
* shows a flowing gradient, and a failure turns the reached stage red. Purely
* presentational — driven by a `stages` prop (each `{ name, state }`); the consumer
* derives those from a real status string with the pure `pipelineModel` and polls
* to pass fresh stages. Reduced motion → a static, filled line.
*
* All geometry derives from the stage states, so it renders whatever pipeline the
* caller supplies (the default four-stage deploy line, or a custom set).
*/
import { useContainerWidth } from '../charts/Charts'
import { useReducedMotion } from '../motion/hooks'
import type { PipelineStage, StageState } from './pipeline'
import { Text, XStack, YStack } from '@hanzo/gui'
const ACCENT = '#7c5cff'
const GREEN = '#23c562'
const RED = '#ff5d8f'
const MUTED = 'rgba(148,163,184,0.28)'
export interface PipelineProps {
/** The stages to render, in order (derive via `pipelineModel(status).stages`). */
stages: PipelineStage[]
/** A short status label shown above the track (e.g. `pipelineModel(...).label`). */
label?: string
/** Accent tone for the filled track + active stage. Defaults by state (green live / red error / purple). */
tone?: string
/** Animate the active leg + pulse. Default true; reduced motion still forces static. */
animate?: boolean
}
export function Pipeline({ stages, label, tone, animate = true }: PipelineProps) {
const reduced = useReducedMotion()
const n = stages.length
const errorIdx = stages.findIndex((s) => s.state === 'error')
const activeStateIdx = stages.findIndex((s) => s.state === 'active')
const doneCount = stages.filter((s) => s.state === 'done').length
const errored = errorIdx >= 0
const live = n > 0 && doneCount === n && !errored
const activeIndex = errored ? errorIdx : activeStateIdx >= 0 ? activeStateIdx : live ? n - 1 : -1
const inProgress = activeStateIdx >= 0 && !errored && !live
const accent = tone ?? (errored ? RED : live ? GREEN : ACCENT)
return (
<YStack gap="$3" aria-label={label ? `Deployment: ${label}` : 'Deployment pipeline'}>
{label ? (
<XStack items="center" gap="$2">
<YStack width={9} height={9} rounded="$10" style={{ backgroundColor: accent }} className={animate && !reduced && inProgress ? 'hz-pipe-dot' : undefined} />
<Text fontSize="$3" fontWeight="700" color="$color12">
{label}
</Text>
</XStack>
) : null}
<Track stages={stages} activeIndex={activeIndex} inProgress={inProgress} reduced={reduced || !animate} tone={accent} />
</YStack>
)
}
// ── The SVG track + aligned labels ───────────────────────────────────────────
function Track({ stages, activeIndex, inProgress, reduced, tone }: { stages: PipelineStage[]; activeIndex: number; inProgress: boolean; reduced: boolean; tone: string }) {
const { ref, w } = useContainerWidth()
const n = stages.length
const H = 60
const padX = 24
const trackY = 28
const usable = Math.max(1, w - padX * 2)
const x = (i: number) => padX + (n <= 1 ? 0 : (i / (n - 1)) * usable)
const fillTo = activeIndex < 0 ? x(0) : x(activeIndex)
const flowing = !reduced && inProgress && activeIndex >= 0 && activeIndex < n - 1
const flowStart = x(Math.max(0, activeIndex))
const flowEnd = x(Math.min(activeIndex + 1, n - 1))
return (
<div ref={ref} style={{ width: '100%' }}>
{w > 0 ? (
<>
<svg width={w} height={H} style={{ display: 'block', overflow: 'visible' }} role="presentation">
<defs>
<linearGradient id="hz-pipe-grad" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" stopColor={tone} stopOpacity="0.15" />
<stop offset="50%" stopColor={tone} stopOpacity="1" />
<stop offset="100%" stopColor={tone} stopOpacity="0.15" />
</linearGradient>
</defs>
{/* base track */}
<line x1={x(0)} y1={trackY} x2={x(n - 1)} y2={trackY} stroke={MUTED} strokeWidth={4} strokeLinecap="round" />
{/* filled (completed) track */}
{fillTo > x(0) ? <line x1={x(0)} y1={trackY} x2={fillTo} y2={trackY} stroke={tone} strokeWidth={4} strokeLinecap="round" /> : null}
{/* flowing gradient on the active leg */}
{flowing ? (
<line x1={flowStart} y1={trackY} x2={flowEnd} y2={trackY} stroke="url(#hz-pipe-grad)" strokeWidth={4} strokeLinecap="round" className="hz-pipe-flow" />
) : null}
{/* stages */}
{stages.map((st, i) => (
<Stage key={st.name} cx={x(i)} cy={trackY} state={st.state} tone={tone} reduced={reduced} />
))}
</svg>
{/* labels aligned under each stage */}
<div style={{ position: 'relative', width: w, height: 30, marginTop: 2 }}>
{stages.map((st, i) => (
<div
key={st.name}
style={{
position: 'absolute',
left: x(i),
transform: i === 0 ? 'translateX(0)' : i === n - 1 ? 'translateX(-100%)' : 'translateX(-50%)',
textAlign: i === 0 ? 'left' : i === n - 1 ? 'right' : 'center',
maxWidth: 110,
}}
>
<StageLabel name={st.name} state={st.state} />
</div>
))}
</div>
</>
) : (
<div style={{ height: H + 30 }} />
)}
</div>
)
}
function StageLabel({ name, state }: { name: string; state: StageState }) {
const sub = state === 'active' ? 'in progress' : state === 'error' ? 'failed' : state === 'done' ? 'done' : ''
return (
<YStack gap="$0.5">
<Text fontSize="$1" fontWeight={state === 'active' || state === 'error' ? '800' : '600'} color={state === 'pending' ? '$color9' : '$color12'} numberOfLines={1}>
{name}
</Text>
<Text fontSize="$1" style={state === 'error' ? { color: RED } : undefined} color={state === 'error' ? undefined : '$color10'}>
{sub || ' '}
</Text>
</YStack>
)
}
function Stage({ cx, cy, state, tone, reduced }: { cx: number; cy: number; state: StageState; tone: string; reduced: boolean }) {
const r = 11
if (state === 'done') {
return (
<g>
<circle cx={cx} cy={cy} r={r} fill={tone} />
<path d={`M${cx - 4.5},${cy + 0.5} L${cx - 1.5},${cy + 3.5} L${cx + 4.5},${cy - 3.5}`} fill="none" stroke="#fff" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" />
</g>
)
}
if (state === 'error') {
return (
<g>
<circle cx={cx} cy={cy} r={r} fill={RED} />
<path d={`M${cx - 3.5},${cy - 3.5} L${cx + 3.5},${cy + 3.5} M${cx + 3.5},${cy - 3.5} L${cx - 3.5},${cy + 3.5}`} stroke="#fff" strokeWidth={2} strokeLinecap="round" />
</g>
)
}
if (state === 'active') {
return (
<g>
{!reduced ? <circle cx={cx} cy={cy} r={r} fill={tone} className="hz-pipe-pulse" /> : null}
<circle cx={cx} cy={cy} r={r} fill="var(--color2, #14161a)" stroke={tone} strokeWidth={3} />
<circle cx={cx} cy={cy} r={4} fill={tone} />
</g>
)
}
// pending
return <circle cx={cx} cy={cy} r={r} fill="var(--color2, #14161a)" stroke={MUTED} strokeWidth={2} />
}
-156
View File
@@ -1,156 +0,0 @@
/**
* Deploy-stage pipeline domain logic — PURE, no UI, no I/O, unit-testable.
*
* A deploy moves through four stages: Queued → Building → Deploying → Live. This
* maps a REAL app/deployment status string (building | deploying | live | error |
* queued | …) onto that pipeline and decides, per stage, whether it is done /
* active / pending / errored, plus an overall progress fraction and an honest
* status label.
*
* Honesty: this never fabricates progress. The stage is derived from the real
* status, and the caller passes the FURTHEST stage it has actually OBSERVED
* (monotonic across polls) so an `error` marks the stage the deploy truly reached —
* not a guess.
*/
/** The four pipeline stages, in order. */
export const PIPELINE_STAGES = ['Queued', 'Building', 'Deploying', 'Live'] as const
export type PipelineStageName = (typeof PIPELINE_STAGES)[number]
/** Canonical pipeline phase, normalized from any backend status string. */
export type PipelinePhase = 'idle' | 'queued' | 'building' | 'deploying' | 'live' | 'error'
/** Per-stage render state. */
export type StageState = 'done' | 'active' | 'error' | 'pending'
/** One stage in the rendered pipeline. */
export type PipelineStage = { name: string; state: StageState }
/** The full derived model a `Pipeline` renders from. */
export type PipelineModel = {
phase: PipelinePhase
/** Index (0..3) of the current stage; -1 when idle (nothing deploying). */
activeIndex: number
/** Reached the final stage. */
live: boolean
/** The deploy failed. */
errored: boolean
/** True while actively progressing (has a stage to animate toward). */
inProgress: boolean
stages: PipelineStage[]
/** 0..1 fraction of the line filled (static progress / aria-valuenow). */
progress: number
/** A short, honest status label ("Building…", "Live", "Failed while deploying"). */
label: string
}
const N = PIPELINE_STAGES.length
const QUEUED = new Set(['queued', 'queue', 'waiting', 'accepted', 'created', 'pending'])
const BUILDING = new Set(['building', 'build', 'in_progress', 'inprogress', 'compiling'])
const DEPLOYING = new Set(['deploying', 'deploy', 'rollout', 'rolling', 'releasing', 'provisioning'])
const LIVE = new Set(['live', 'ready', 'running', 'available', 'succeeded', 'success', 'active', 'healthy', 'superseded'])
const ERROR = new Set(['error', 'errored', 'failed', 'failure', 'crashed', 'canceled', 'cancelled', 'timeout'])
const IDLE = new Set(['', 'draft', 'stopped', 'paused', 'idle', 'unknown', 'none', 'new'])
/** Normalize any app/deployment status string to a canonical pipeline phase. */
export function pipelinePhase(status?: string | null): PipelinePhase {
const s = (status ?? '').trim().toLowerCase()
if (ERROR.has(s)) return 'error'
if (LIVE.has(s)) return 'live'
if (DEPLOYING.has(s)) return 'deploying'
if (BUILDING.has(s)) return 'building'
if (QUEUED.has(s)) return 'queued'
if (IDLE.has(s)) return 'idle'
return 'idle'
}
/** The stage index (0..3) a phase sits at; -1 for idle/error (error uses `furthest`). */
export function stageIndex(phase: PipelinePhase): number {
switch (phase) {
case 'queued':
return 0
case 'building':
return 1
case 'deploying':
return 2
case 'live':
return N - 1
default:
return -1
}
}
/** Terminal phases (live or error) — the poller stops here. */
export function isTerminalPhase(phase: PipelinePhase): boolean {
return phase === 'live' || phase === 'error'
}
/** A short, honest status label for the pipeline. */
function pipelineLabel(phase: PipelinePhase, activeIndex: number, status?: string | null): string {
switch (phase) {
case 'live':
return 'Live'
case 'queued':
return 'Queued'
case 'building':
return 'Building…'
case 'deploying':
return 'Deploying…'
case 'error': {
if (activeIndex <= 0) return 'Failed in queue'
const at = PIPELINE_STAGES[Math.min(activeIndex, N - 1)].toLowerCase()
return `Failed while ${at}`
}
default: {
const s = (status ?? '').trim()
if (!s || s.toLowerCase() === 'draft' || s.toLowerCase() === 'new') return 'Not deployed'
return s.charAt(0).toUpperCase() + s.slice(1)
}
}
}
/**
* Build the pipeline model from a status string and the FURTHEST stage index
* observed so far (monotonic across polls; default -1 = none observed). The furthest
* index makes an `error` mark the stage the deploy actually reached, and keeps
* completed stages filled even if a later poll returns a transient earlier status.
*/
export function pipelineModel(status?: string | null, furthestReached = -1): PipelineModel {
const phase = pipelinePhase(status)
const idx = stageIndex(phase)
const reached = Math.max(furthestReached, idx)
const live = phase === 'live'
const errored = phase === 'error'
let activeIndex: number
if (live) activeIndex = N - 1
else if (errored) activeIndex = reached >= 0 ? Math.min(reached, N - 1) : 0
else if (phase === 'idle') activeIndex = -1 // nothing deploying — a neutral pipeline
else activeIndex = Math.min(reached, N - 1) // active: monotonic (never backslides)
const stages: PipelineStage[] = PIPELINE_STAGES.map((name, i): PipelineStage => {
if (live) return { name, state: 'done' }
if (errored) {
if (i < activeIndex) return { name, state: 'done' }
if (i === activeIndex) return { name, state: 'error' }
return { name, state: 'pending' }
}
if (activeIndex < 0) return { name, state: 'pending' } // idle: nothing started
if (i < activeIndex) return { name, state: 'done' }
if (i === activeIndex) return { name, state: 'active' }
return { name, state: 'pending' }
})
const progress = live ? 1 : activeIndex < 0 ? 0 : activeIndex / (N - 1)
const inProgress = !live && !errored && activeIndex >= 0
const label = pipelineLabel(phase, activeIndex, status)
return { phase, activeIndex, live, errored, inProgress, stages, progress, label }
}
/** The accent tone for a model: red on failure, green on live, else the accent. */
export function pipelineTone(model: Pick<PipelineModel, 'live' | 'errored'>, accent = '#7c5cff'): string {
return model.errored ? '#ff5d8f' : model.live ? '#23c562' : accent
}
-12
View File
@@ -1,12 +0,0 @@
/**
* Shared cross-module types for @hanzo/dashboard.
*/
import type { ComponentType } from 'react'
/**
* A lucide-style icon component — the shape the dashboard passes around for
* consumer-supplied product/tile/action icons. Structural (not tied to a specific
* icon package) so the config types stay pure and any `size`/`color` icon fits
* (e.g. `@hanzogui/lucide-icons-2` or `lucide-react` icons).
*/
export type IconComponent = ComponentType<{ size?: number | string; color?: string; opacity?: number }>
-25
View File
@@ -1,25 +0,0 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"resolveJsonModule": true,
"isolatedModules": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"baseUrl": ".",
"paths": {
"@/*": ["src/*"],
"react-native": ["react-native-web"]
}
},
"include": ["src", "gui.config.ts", "gui.d.ts"]
}
+2 -2
View File
@@ -27,8 +27,8 @@
"react": ">=19"
},
"devDependencies": {
"@hanzo/gui": "7.2.2",
"@hanzogui/config": "7.2.2",
"@hanzo/gui": "7.3.0",
"@hanzogui/config": "7.3.0",
"@types/react": "^19.1.10",
"react": "^19.0.0",
"typescript": "^5.7.2"
+117
View File
@@ -166,3 +166,120 @@ export function RatingInput({ field, value, onChange }: FieldInputProps) {
</XStack>
)
}
// ── relation (record picker — single or to-many over host-injected options) ───
// The host injects candidate records as metadata.options ({value:id, label}).
// maxSelect>1 (or 0/undefined with an array value) = to-many → array of ids;
// otherwise a single id (or null when cleared by re-tapping).
export function RelationInput({ field, value, onChange }: FieldInputProps) {
const meta = (field.metadata ?? {}) as { options?: SelectOption[]; maxSelect?: number }
const options = meta.options ?? []
const multi = (meta.maxSelect ?? 1) !== 1 || Array.isArray(value)
const selected = new Set(multi ? asArray(value) : [asStr(value)].filter(Boolean))
const tap = (v: string) => {
if (multi) {
const next = new Set(selected)
next.has(v) ? next.delete(v) : next.add(v)
onChange([...next])
} else {
onChange(selected.has(v) ? null : v) // re-tap clears
}
}
if (options.length === 0) {
// No candidates injected — fall back to raw id entry so the field is still editable.
return (
<Input
value={multi ? asArray(value).join(', ') : asStr(value)}
onChangeText={(t: string) => onChange(multi ? t.split(',').map((s) => s.trim()).filter(Boolean) : (t || null))}
placeholder="record id"
/>
)
}
return (
<XStack gap={6} items="center" style={{ flexWrap: 'wrap' }}>
{options.map((o) => (
<Button key={o.value} size="$2" theme={selected.has(o.value) ? 'blue' : undefined} onPress={() => tap(o.value)}>
{o.label}
</Button>
))}
</XStack>
)
}
// ── files (filename/URL list — add + remove; upload is a host concern) ────────
export function FilesInput({ value, onChange }: FieldInputProps) {
const files = asArray(value)
const add = (t: string) => { const v = t.trim(); if (v) onChange([...files, v]) }
const remove = (i: number) => onChange(files.filter((_, idx) => idx !== i))
return (
<XStack gap={6} items="center" style={{ flexWrap: 'wrap' }}>
{files.map((f, i) => (
<Button key={`${f}-${i}`} size="$2" onPress={() => remove(i)}>
{shortName(f)}
</Button>
))}
<Input placeholder="Add file URL/ref…" onSubmitEditing={(e: { nativeEvent: { text: string } }) => add(e.nativeEvent.text)} />
</XStack>
)
}
// ── links (url list — add + remove) ───────────────────────────────────────────
export function LinksInput({ value, onChange }: FieldInputProps) {
const links = asArray(value)
const add = (t: string) => { const v = t.trim(); if (v) onChange([...links, v]) }
const remove = (i: number) => onChange(links.filter((_, idx) => idx !== i))
return (
<XStack gap={6} items="center" style={{ flexWrap: 'wrap' }}>
{links.map((l, i) => (
<Button key={`${l}-${i}`} size="$2" onPress={() => remove(i)}>{l} </Button>
))}
<Input placeholder="Add link…" onSubmitEditing={(e: { nativeEvent: { text: string } }) => add(e.nativeEvent.text)} />
</XStack>
)
}
// ── json (raw text; parses on change, keeps text on invalid so typing isn't lost)
export function JsonInput({ value, onChange }: FieldInputProps) {
const text = typeof value === 'string' ? value : value == null ? '' : safeStringify(value)
return (
<Input
value={text}
onChangeText={(t: string) => { try { onChange(JSON.parse(t)) } catch { onChange(t) } }}
placeholder='{ }'
/>
)
}
// ── fullName (first + last sub-fields → { first, last }) ──────────────────────
export function FullNameInput({ value, onChange }: FieldInputProps) {
const v = (value ?? {}) as { first?: string; last?: string }
return (
<XStack gap={8} items="center">
<Input value={asStr(v.first)} placeholder="First" onChangeText={(t: string) => onChange({ ...v, first: t })} />
<Input value={asStr(v.last)} placeholder="Last" onChangeText={(t: string) => onChange({ ...v, last: t })} />
</XStack>
)
}
// ── address (street/city/state/postal sub-fields) ─────────────────────────────
export function AddressInput({ value, onChange }: FieldInputProps) {
const v = (value ?? {}) as { street?: string; city?: string; state?: string; postalCode?: string }
const set = (k: string, t: string) => onChange({ ...v, [k]: t })
return (
<XStack gap={6} items="center" style={{ flexWrap: 'wrap' }}>
<Input value={asStr(v.street)} placeholder="Street" onChangeText={(t: string) => set('street', t)} />
<Input value={asStr(v.city)} placeholder="City" onChangeText={(t: string) => set('city', t)} />
<Input value={asStr(v.state)} placeholder="State" onChangeText={(t: string) => set('state', t)} />
<Input value={asStr(v.postalCode)} placeholder="ZIP" onChangeText={(t: string) => set('postalCode', t)} />
</XStack>
)
}
// ── helpers ───────────────────────────────────────────────────────────────────
const shortName = (s: string): string => {
const base = s.split(/[/\\]/).pop() || s
return base.length > 24 ? base.slice(0, 21) + '…' : base
}
const safeStringify = (v: unknown): string => {
try { return JSON.stringify(v) } catch { return String(v) }
}

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