Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a75fa99059 |
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"$schema": "https://unpkg.com/@changesets/config@2.3.0/schema.json",
|
||||
"changelog": ["@changesets/changelog-github", { "repo": "shadcn-ui/ui" }],
|
||||
"commit": false,
|
||||
"fixed": [],
|
||||
"linked": [],
|
||||
"access": "public",
|
||||
"baseBranch": "main",
|
||||
"updateInternalDependencies": "patch",
|
||||
"ignore": ["v4", "tests"]
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
{
|
||||
"extends": ["@commitlint/config-conventional"]
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
// ORIGINALLY FROM CLOUDFLARE WRANGLER:
|
||||
// https://github.com/cloudflare/wrangler2/blob/main/.github/changeset-version.js
|
||||
|
||||
import { execSync } from "child_process"
|
||||
|
||||
// This script is used by the `release.yml` workflow to update the version of the packages being released.
|
||||
// The standard step is only to run `changeset version` but this does not update the pnpm-lock.yaml file.
|
||||
// So we also run `pnpm install`, which does this update.
|
||||
// This is a workaround until this is handled automatically by `changeset version`.
|
||||
// See https://github.com/changesets/changesets/issues/421.
|
||||
execSync("npx changeset version", { stdio: "inherit" })
|
||||
execSync("pnpm install --lockfile-only", { stdio: "inherit" })
|
||||
@@ -4,43 +4,3 @@ updates:
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/templates/astro-app"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/templates/astro-monorepo"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/templates/next-app"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/templates/next-monorepo"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/templates/react-router-app"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/templates/react-router-monorepo"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/templates/start-app"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/templates/start-monorepo"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/templates/vite-app"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/templates/vite-monorepo"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1280" height="640" viewBox="0 0 1280 640" role="img" aria-label="ui">
|
||||
<rect width="1280" height="640" fill="#0A0A0A"/>
|
||||
<svg x="96" y="215" width="210" height="210" viewBox="0 0 67 67"><path d="M22.21 67V44.6369H0V67H22.21Z" fill="#fff"/><path d="M66.7038 22.3184H22.2534L0.0878906 44.6367H44.4634L66.7038 22.3184Z" fill="#fff"/><path d="M22.21 0H0V22.3184H22.21V0Z" fill="#fff"/><path d="M66.7198 0H44.5098V22.3184H66.7198V0Z" fill="#fff"/><path d="M66.7198 67V44.6369H44.5098V67H66.7198Z" fill="#fff"/></svg>
|
||||
<text x="378" y="276" font-family="Inter,system-ui,-apple-system,sans-serif" font-size="78" font-weight="800" letter-spacing="-2" fill="#ffffff">ui</text>
|
||||
<text x="378" y="322" font-family="Inter,system-ui,sans-serif" font-size="30" fill="#ffffff" opacity=".66">React component library for AI applications</text>
|
||||
<rect x="378" y="338" width="806" height="3" rx="1.5" fill="#ffffff" opacity=".9"/>
|
||||
<text x="378" y="390" font-family="Inter,system-ui,sans-serif" font-size="24" font-weight="600" fill="#ffffff" opacity=".5">github.com/hanzoai</text>
|
||||
<text x="1184" y="390" text-anchor="end" font-family="Inter,system-ui,sans-serif" font-size="24" font-weight="600" fill="#ffffff" opacity=".5">hanzo.ai</text>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.2 KiB |
@@ -4,7 +4,7 @@
|
||||
import { exec } from "child_process"
|
||||
import fs from "fs"
|
||||
|
||||
const pkgJsonPath = "pkgs/shadcn/package.json"
|
||||
const pkgJsonPath = "pkg/cli/package.json"
|
||||
try {
|
||||
const pkg = JSON.parse(fs.readFileSync(pkgJsonPath))
|
||||
exec("git rev-parse --short HEAD", (err, stdout) => {
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
import { exec } from "child_process"
|
||||
import fs from "fs"
|
||||
|
||||
const pkgJsonPath = "pkgs/shadcn/package.json"
|
||||
const pkgJsonPath = "pkg/cli/package.json"
|
||||
try {
|
||||
const pkg = JSON.parse(fs.readFileSync(pkgJsonPath))
|
||||
exec("git rev-parse --short HEAD", (err, stdout) => {
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- develop
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
- develop
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
name: Lint
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v3
|
||||
with:
|
||||
version: 9
|
||||
|
||||
- name: Get pnpm store directory
|
||||
shell: bash
|
||||
run: |
|
||||
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
|
||||
|
||||
- name: Setup pnpm cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ${{ env.STORE_PATH }}
|
||||
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pnpm-store-
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Run linting
|
||||
run: pnpm run lint
|
||||
|
||||
typecheck:
|
||||
name: Type Check
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v3
|
||||
with:
|
||||
version: 9
|
||||
|
||||
- name: Get pnpm store directory
|
||||
shell: bash
|
||||
run: |
|
||||
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
|
||||
|
||||
- name: Setup pnpm cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ${{ env.STORE_PATH }}
|
||||
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pnpm-store-
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build UI package
|
||||
run: cd pkg/ui && pnpm run build
|
||||
|
||||
- name: Run type checking
|
||||
run: |
|
||||
cd app && pnpm run typecheck
|
||||
cd ../pkg/ui && pnpm run tc --noEmit
|
||||
|
||||
build:
|
||||
name: Build
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v3
|
||||
with:
|
||||
version: 9
|
||||
|
||||
- name: Get pnpm store directory
|
||||
shell: bash
|
||||
run: |
|
||||
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
|
||||
|
||||
- name: Setup pnpm cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ${{ env.STORE_PATH }}
|
||||
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pnpm-store-
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build UI package
|
||||
run: cd pkg/ui && pnpm run build:full
|
||||
|
||||
- name: Build app
|
||||
run: cd app && pnpm run build
|
||||
env:
|
||||
NEXT_PUBLIC_APP_URL: https://ui.hanzo.ai
|
||||
|
||||
- name: Upload build artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: build-artifacts
|
||||
path: |
|
||||
app/.next
|
||||
pkg/ui/dist
|
||||
|
||||
test:
|
||||
name: Test
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v3
|
||||
with:
|
||||
version: 9
|
||||
|
||||
- name: Get pnpm store directory
|
||||
shell: bash
|
||||
run: |
|
||||
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
|
||||
|
||||
- name: Setup pnpm cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ${{ env.STORE_PATH }}
|
||||
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pnpm-store-
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Run pkg/ui tests
|
||||
run: cd pkg/ui && pnpm test -- --run
|
||||
|
||||
- name: Run app tests
|
||||
run: |
|
||||
if [ -f "app/package.json" ] && grep -q '"test"' app/package.json 2>/dev/null; then
|
||||
cd app && pnpm test || echo "No tests configured"
|
||||
else
|
||||
echo "No test script found"
|
||||
fi
|
||||
|
||||
- name: Upload coverage
|
||||
if: always()
|
||||
uses: codecov/codecov-action@v3
|
||||
with:
|
||||
files: ./pkg/ui/coverage/lcov.info
|
||||
flags: unit
|
||||
|
||||
deploy-preview:
|
||||
name: Deploy Preview
|
||||
runs-on: ubuntu-latest
|
||||
needs: [lint, typecheck, build]
|
||||
if: github.event_name == 'pull_request'
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v3
|
||||
with:
|
||||
version: 9
|
||||
|
||||
- name: Install Vercel CLI
|
||||
run: npm install --global vercel@latest
|
||||
|
||||
- name: Pull Vercel Environment Information
|
||||
run: vercel pull --yes --environment=preview --token=${{ secrets.VERCEL_TOKEN }}
|
||||
env:
|
||||
VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }}
|
||||
VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }}
|
||||
|
||||
- name: Build Project Artifacts
|
||||
run: vercel build --token=${{ secrets.VERCEL_TOKEN }}
|
||||
|
||||
- name: Deploy Project Artifacts to Vercel
|
||||
id: deploy
|
||||
run: |
|
||||
url=$(vercel deploy --prebuilt --token=${{ secrets.VERCEL_TOKEN }})
|
||||
echo "preview_url=$url" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Comment PR with preview URL
|
||||
if: github.event_name == 'pull_request'
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
github.rest.issues.createComment({
|
||||
issue_number: context.issue.number,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
body: '🚀 Preview deployed to: ${{ steps.deploy.outputs.preview_url }}'
|
||||
})
|
||||
|
||||
status:
|
||||
name: CI Status
|
||||
runs-on: ubuntu-latest
|
||||
needs: [lint, typecheck, build, test]
|
||||
if: always()
|
||||
steps:
|
||||
- name: Check status
|
||||
run: |
|
||||
if [ "${{ needs.lint.result }}" != "success" ] || \
|
||||
[ "${{ needs.typecheck.result }}" != "success" ] || \
|
||||
[ "${{ needs.build.result }}" != "success" ] || \
|
||||
[ "${{ needs.test.result }}" != "success" ]; then
|
||||
echo "CI checks failed"
|
||||
exit 1
|
||||
else
|
||||
echo "All CI checks passed!"
|
||||
fi
|
||||
@@ -0,0 +1,116 @@
|
||||
name: Code check
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: ["*"]
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
name: pnpm lint
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 20
|
||||
|
||||
- uses: pnpm/action-setup@v2.2.4
|
||||
name: Install pnpm
|
||||
id: pnpm-install
|
||||
with:
|
||||
version: 8.6.1
|
||||
run_install: false
|
||||
|
||||
- name: Get pnpm store directory
|
||||
id: pnpm-cache
|
||||
run: |
|
||||
echo "pnpm_cache_dir=$(pnpm store path)" >> $GITHUB_OUTPUT
|
||||
- uses: actions/cache@v3
|
||||
name: Setup pnpm cache
|
||||
with:
|
||||
path: ${{ steps.pnpm-cache.outputs.pnpm_cache_dir }}
|
||||
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pnpm-store-
|
||||
- name: Install dependencies
|
||||
run: pnpm install
|
||||
|
||||
#- run: pnpm lint
|
||||
|
||||
format:
|
||||
runs-on: ubuntu-latest
|
||||
name: pnpm format:check
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 20
|
||||
|
||||
- uses: pnpm/action-setup@v2.2.4
|
||||
name: Install pnpm
|
||||
id: pnpm-install
|
||||
with:
|
||||
version: 8.6.1
|
||||
run_install: false
|
||||
|
||||
- name: Get pnpm store directory
|
||||
id: pnpm-cache
|
||||
run: |
|
||||
echo "pnpm_cache_dir=$(pnpm store path)" >> $GITHUB_OUTPUT
|
||||
|
||||
- uses: actions/cache@v3
|
||||
name: Setup pnpm cache
|
||||
with:
|
||||
path: ${{ steps.pnpm-cache.outputs.pnpm_cache_dir }}
|
||||
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pnpm-store-
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install
|
||||
|
||||
- run: pnpm format:check
|
||||
|
||||
tsc:
|
||||
runs-on: ubuntu-latest
|
||||
name: pnpm typecheck
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 20
|
||||
|
||||
- uses: pnpm/action-setup@v2.2.4
|
||||
name: Install pnpm
|
||||
id: pnpm-install
|
||||
with:
|
||||
version: 8.6.1
|
||||
run_install: false
|
||||
|
||||
- name: Get pnpm store directory
|
||||
id: pnpm-cache
|
||||
run: |
|
||||
echo "pnpm_cache_dir=$(pnpm store path)" >> $GITHUB_OUTPUT
|
||||
- uses: actions/cache@v3
|
||||
name: Setup pnpm cache
|
||||
with:
|
||||
path: ${{ steps.pnpm-cache.outputs.pnpm_cache_dir }}
|
||||
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pnpm-store-
|
||||
- name: Install dependencies
|
||||
run: pnpm install
|
||||
|
||||
- run: pnpm typecheck
|
||||
@@ -0,0 +1,60 @@
|
||||
name: Test Coverage
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, develop]
|
||||
pull_request:
|
||||
branches: [main, develop]
|
||||
|
||||
jobs:
|
||||
coverage:
|
||||
name: Test Coverage
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v3
|
||||
with:
|
||||
version: 9
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Run tests with coverage
|
||||
run: cd pkg/ui && pnpm test:coverage
|
||||
|
||||
- name: Upload coverage to Codecov
|
||||
uses: codecov/codecov-action@v3
|
||||
with:
|
||||
files: ./pkg/ui/coverage/lcov.info
|
||||
flags: unit
|
||||
name: hanzo-ui-coverage
|
||||
|
||||
- name: Comment PR with coverage
|
||||
if: github.event_name == 'pull_request'
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const fs = require('fs');
|
||||
const coverage = JSON.parse(fs.readFileSync('./pkg/ui/coverage/coverage-summary.json', 'utf8'));
|
||||
const total = coverage.total;
|
||||
const comment = `## Test Coverage Report\n\n` +
|
||||
`Lines: ${total.lines.pct}%\n` +
|
||||
`Statements: ${total.statements.pct}%\n` +
|
||||
`Functions: ${total.functions.pct}%\n` +
|
||||
`Branches: ${total.branches.pct}%`;
|
||||
|
||||
github.rest.issues.createComment({
|
||||
issue_number: context.issue.number,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
body: comment
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
name: Deploy to GitHub Pages
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
capture_screenshots:
|
||||
description: 'Capture component screenshots (slow, optional)'
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pages: write
|
||||
id-token: write
|
||||
|
||||
concurrency:
|
||||
group: "pages"
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v3
|
||||
with:
|
||||
version: 9
|
||||
|
||||
- name: Get pnpm store directory
|
||||
shell: bash
|
||||
run: |
|
||||
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
|
||||
|
||||
- name: Setup pnpm cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ${{ env.STORE_PATH }}
|
||||
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pnpm-store-
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build @hanzo/ui package
|
||||
run: |
|
||||
cd pkg/ui && pnpm build
|
||||
|
||||
- name: Capture screenshots (optional)
|
||||
if: github.event.inputs.capture_screenshots == 'true'
|
||||
working-directory: ./app
|
||||
run: pnpm capture:registry
|
||||
timeout-minutes: 5
|
||||
|
||||
- name: Build documentation
|
||||
working-directory: ./app
|
||||
run: |
|
||||
pnpm build
|
||||
touch out/.nojekyll
|
||||
env:
|
||||
NODE_ENV: production
|
||||
GITHUB_ACTIONS: true
|
||||
NEXT_PUBLIC_APP_URL: https://ui.hanzo.ai
|
||||
SKIP_SCREENSHOTS: true
|
||||
|
||||
- name: Setup Pages
|
||||
uses: actions/configure-pages@v4
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-pages-artifact@v3
|
||||
with:
|
||||
path: ./app/out
|
||||
|
||||
deploy:
|
||||
environment:
|
||||
name: github-pages
|
||||
url: ${{ steps.deployment.outputs.page_url }}
|
||||
runs-on: ubuntu-latest
|
||||
needs: build
|
||||
steps:
|
||||
- name: Deploy to GitHub Pages
|
||||
id: deployment
|
||||
uses: actions/deploy-pages@v4
|
||||
@@ -0,0 +1,78 @@
|
||||
name: Deprecated
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [opened, synchronize]
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
contents: read
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
deprecated:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout PR
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Get changed files
|
||||
id: changed-files
|
||||
uses: tj-actions/changed-files@v46
|
||||
with:
|
||||
files: |
|
||||
apps/www/**
|
||||
files_ignore: |
|
||||
apps/www/public/r/**
|
||||
base_sha: ${{ github.event.pull_request.base.sha }}
|
||||
sha: ${{ github.event.pull_request.head.sha }}
|
||||
|
||||
- name: Comment on PR if www files changed
|
||||
if: steps.changed-files.outputs.any_changed == 'true'
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
script: |
|
||||
const changedFiles = `${{ steps.changed-files.outputs.all_changed_files }}`.split(' ');
|
||||
const wwwFiles = changedFiles.filter(file =>
|
||||
file.startsWith('apps/www/') &&
|
||||
!file.startsWith('apps/www/public/r/') &&
|
||||
file !== 'apps/www/package.json'
|
||||
);
|
||||
|
||||
if (wwwFiles.length > 0) {
|
||||
const comment = `Looks like this PR modifies files in \`apps/www\`, which is deprecated.
|
||||
|
||||
Consider applying the change to \`apps/v4\` if relevant.`;
|
||||
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
body: comment
|
||||
});
|
||||
|
||||
// Add deprecated label
|
||||
await github.rest.issues.addLabels({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
labels: ['deprecated']
|
||||
});
|
||||
} else {
|
||||
// Remove deprecated label if no www files are changed
|
||||
try {
|
||||
await github.rest.issues.removeLabel({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
name: 'deprecated'
|
||||
});
|
||||
} catch (error) {
|
||||
// Label doesn't exist, which is fine
|
||||
console.log('Deprecated label not found, skipping removal');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
# Adapted from vercel/next.js
|
||||
name: "Stale issue handler"
|
||||
on:
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
# This runs every day 20 minutes before midnight: https://crontab.guru/#40_23_*_*_*
|
||||
- cron: "40 23 * * *"
|
||||
|
||||
jobs:
|
||||
stale:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.repository_owner == 'shadcn-ui'
|
||||
steps:
|
||||
- uses: actions/stale@v9
|
||||
id: issue-stale
|
||||
name: "Mark stale issues, close stale issues"
|
||||
with:
|
||||
repo-token: ${{ secrets.STALE_TOKEN }}
|
||||
ascending: true
|
||||
days-before-issue-close: 7
|
||||
days-before-issue-stale: 365
|
||||
days-before-pr-stale: -1
|
||||
days-before-pr-close: -1
|
||||
remove-issue-stale-when-updated: true
|
||||
stale-issue-label: "stale?"
|
||||
exempt-issue-labels: "roadmap,next"
|
||||
stale-issue-message: "This issue has been automatically marked as stale due to one year of inactivity. It will be closed in 7 days unless there’s further input. If you believe this issue is still relevant, please leave a comment or provide updated details. Thank you. (This is an automated message)"
|
||||
close-issue-message: "This issue has been automatically closed due to one year of inactivity. If you’re still experiencing a similar problem or have additional details to share, please open a new issue following our current issue template. Your updated report helps us investigate and address concerns more efficiently. Thank you for your understanding! (This is an automated message)"
|
||||
operations-per-run: 300
|
||||
- uses: actions/stale@v9
|
||||
id: pr-state
|
||||
name: "Mark stale PRs, close stale PRs"
|
||||
with:
|
||||
repo-token: ${{ secrets.STALE_TOKEN }}
|
||||
ascending: true
|
||||
days-before-issue-close: -1
|
||||
days-before-issue-stale: -1
|
||||
days-before-pr-close: 7
|
||||
days-before-pr-stale: 365
|
||||
remove-pr-stale-when-updated: true
|
||||
exempt-pr-labels: "roadmap,next,bug"
|
||||
stale-pr-label: "stale?"
|
||||
stale-pr-message: "This PR has been automatically marked as stale due to one year of inactivity. It will be closed in 7 days unless there’s further input. If you believe this PR is still relevant, please leave a comment or provide updated details. Thank you. (This is an automated message)"
|
||||
close-pr-message: "This PR has been automatically closed due to one year of inactivity. Thank you for your understanding! (This is an automated message)"
|
||||
operations-per-run: 300
|
||||
@@ -0,0 +1,186 @@
|
||||
name: NPM Publish
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
id-token: write
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
package:
|
||||
description: 'Package to publish'
|
||||
required: true
|
||||
type: choice
|
||||
options:
|
||||
- ui
|
||||
- ui-mcp
|
||||
- auth
|
||||
- commerce
|
||||
- checkout
|
||||
- brand
|
||||
- react
|
||||
- all
|
||||
version_bump:
|
||||
description: 'Version bump type'
|
||||
required: true
|
||||
type: choice
|
||||
options:
|
||||
- patch
|
||||
- minor
|
||||
- major
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: pnpm/action-setup@v3
|
||||
with:
|
||||
version: 8
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: 'pnpm'
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install
|
||||
|
||||
- name: Build packages
|
||||
run: |
|
||||
if [ "${{ github.event.inputs.package }}" = "all" ] || [ "${{ github.event.inputs.package }}" = "ui" ]; then
|
||||
cd pkg/ui && pnpm build
|
||||
cd ../..
|
||||
fi
|
||||
if [ "${{ github.event.inputs.package }}" = "all" ] || [ "${{ github.event.inputs.package }}" = "ui-mcp" ]; then
|
||||
cd pkg/ui-mcp && pnpm build
|
||||
cd ../..
|
||||
fi
|
||||
if [ "${{ github.event.inputs.package }}" = "all" ] || [ "${{ github.event.inputs.package }}" = "auth" ]; then
|
||||
cd pkg/auth && pnpm build
|
||||
cd ../..
|
||||
fi
|
||||
if [ "${{ github.event.inputs.package }}" = "all" ] || [ "${{ github.event.inputs.package }}" = "commerce" ]; then
|
||||
cd pkg/commerce && pnpm build
|
||||
cd ../..
|
||||
fi
|
||||
if [ "${{ github.event.inputs.package }}" = "all" ] || [ "${{ github.event.inputs.package }}" = "checkout" ]; then
|
||||
cd pkg/checkout && pnpm build
|
||||
cd ../..
|
||||
fi
|
||||
if [ "${{ github.event.inputs.package }}" = "all" ] || [ "${{ github.event.inputs.package }}" = "brand" ]; then
|
||||
cd pkg/brand && pnpm build
|
||||
cd ../..
|
||||
fi
|
||||
if [ "${{ github.event.inputs.package }}" = "all" ] || [ "${{ github.event.inputs.package }}" = "react" ]; then
|
||||
cd pkg/react && pnpm build
|
||||
cd ../..
|
||||
fi
|
||||
|
||||
- name: Bump version and publish
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
NPM_CONFIG_PROVENANCE: true
|
||||
run: |
|
||||
# Set up npm and pnpm auth
|
||||
npm config set //registry.npmjs.org/:_authToken $NODE_AUTH_TOKEN
|
||||
echo "//registry.npmjs.org/:_authToken=${NODE_AUTH_TOKEN}" >> ~/.npmrc
|
||||
|
||||
publish_package() {
|
||||
local pkg_dir=$1
|
||||
local pkg_name=$(cd $pkg_dir && node -p "require('./package.json').name")
|
||||
local pkg_version=$(cd $pkg_dir && node -p "require('./package.json').version")
|
||||
echo "=== Publishing $pkg_name@$pkg_version from $pkg_dir ==="
|
||||
|
||||
# Change to package directory
|
||||
pushd "$pkg_dir"
|
||||
|
||||
# Bump version manually using node to avoid any pnpm/npm hooks
|
||||
echo "Bumping version (${{ github.event.inputs.version_bump }})..."
|
||||
node -e "
|
||||
const fs = require('fs');
|
||||
const pkg = JSON.parse(fs.readFileSync('package.json', 'utf8'));
|
||||
const [major, minor, patch] = pkg.version.split('.').map(Number);
|
||||
const bump = '${{ github.event.inputs.version_bump }}';
|
||||
if (bump === 'major') pkg.version = (major + 1) + '.0.0';
|
||||
else if (bump === 'minor') pkg.version = major + '.' + (minor + 1) + '.0';
|
||||
else pkg.version = major + '.' + minor + '.' + (patch + 1);
|
||||
fs.writeFileSync('package.json', JSON.stringify(pkg, null, 2) + '\n');
|
||||
console.log('New version:', pkg.version);
|
||||
"
|
||||
|
||||
local new_version=$(node -p "require('./package.json').version")
|
||||
echo "Version is now: $new_version"
|
||||
|
||||
# Pack the tarball
|
||||
echo "Running pnpm pack..."
|
||||
pnpm pack --pack-gzip-level 9
|
||||
local tarball=$(ls -t *.tgz | head -1)
|
||||
echo "Created tarball: $tarball"
|
||||
|
||||
# Verify tarball contents
|
||||
echo "Tarball package.json:"
|
||||
tar -xzf "$tarball" -O package/package.json
|
||||
|
||||
# Copy to temp dir and publish
|
||||
local temp_dir="/tmp/publish-$$"
|
||||
mkdir -p "$temp_dir"
|
||||
cp "$tarball" "$temp_dir/"
|
||||
echo "Publishing from $temp_dir..."
|
||||
cd "$temp_dir"
|
||||
npm publish "$tarball" --access public
|
||||
cd -
|
||||
|
||||
# Cleanup
|
||||
rm -rf "$temp_dir"
|
||||
rm -f "$tarball"
|
||||
popd
|
||||
}
|
||||
|
||||
case "${{ github.event.inputs.package }}" in
|
||||
ui)
|
||||
publish_package "pkg/ui"
|
||||
;;
|
||||
ui-mcp)
|
||||
publish_package "pkg/ui-mcp"
|
||||
;;
|
||||
auth)
|
||||
publish_package "pkg/auth"
|
||||
;;
|
||||
commerce)
|
||||
publish_package "pkg/commerce"
|
||||
;;
|
||||
checkout)
|
||||
publish_package "pkg/checkout"
|
||||
;;
|
||||
brand)
|
||||
publish_package "pkg/brand"
|
||||
;;
|
||||
react)
|
||||
publish_package "pkg/react"
|
||||
;;
|
||||
all)
|
||||
publish_package "pkg/ui"
|
||||
publish_package "pkg/ui-mcp"
|
||||
publish_package "pkg/auth"
|
||||
publish_package "pkg/commerce"
|
||||
publish_package "pkg/checkout"
|
||||
publish_package "pkg/brand"
|
||||
publish_package "pkg/react"
|
||||
;;
|
||||
esac
|
||||
|
||||
- name: Create Pull Request
|
||||
uses: peter-evans/create-pull-request@v6
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
commit-message: 'chore: bump ${{ github.event.inputs.package }} version to ${{ github.event.inputs.version_bump }}'
|
||||
title: 'chore: bump ${{ github.event.inputs.package }} version'
|
||||
body: |
|
||||
Automated version bump for ${{ github.event.inputs.package }} package(s).
|
||||
|
||||
Version bump type: ${{ github.event.inputs.version_bump }}
|
||||
branch: version-bump-${{ github.event.inputs.package }}-${{ github.run_number }}
|
||||
@@ -0,0 +1,65 @@
|
||||
# Adapted from create-t3-app.
|
||||
name: Write Beta Release comment
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ["Release - Beta"]
|
||||
types:
|
||||
- completed
|
||||
|
||||
jobs:
|
||||
comment:
|
||||
if: |
|
||||
github.repository_owner == 'hanzoai-ui' &&
|
||||
${{ github.event.workflow_run.conclusion == 'success' }}
|
||||
runs-on: ubuntu-latest
|
||||
name: Write comment to the PR
|
||||
steps:
|
||||
- name: "Comment on PR"
|
||||
uses: actions/github-script@v6
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
script: |
|
||||
const allArtifacts = await github.rest.actions.listWorkflowRunArtifacts({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
run_id: context.payload.workflow_run.id,
|
||||
});
|
||||
|
||||
for (const artifact of allArtifacts.data.artifacts) {
|
||||
// Extract the PR number and package version from the artifact name
|
||||
const match = /^npm-package-hanzoai-ui@(.*?)-pr-(\d+)/.exec(artifact.name);
|
||||
|
||||
if (match) {
|
||||
require("fs").appendFileSync(
|
||||
process.env.GITHUB_ENV,
|
||||
`\nBETA_PACKAGE_VERSION=${match[1]}` +
|
||||
`\nWORKFLOW_RUN_PR=${match[2]}` +
|
||||
`\nWORKFLOW_RUN_ID=${context.payload.workflow_run.id}`
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
- name: "Comment on PR with Link"
|
||||
uses: marocchino/sticky-pull-request-comment@v2
|
||||
with:
|
||||
number: ${{ env.WORKFLOW_RUN_PR }}
|
||||
message: |
|
||||
A new prerelease is available for testing:
|
||||
|
||||
```sh
|
||||
npx hanzoai-ui@${{ env.BETA_PACKAGE_VERSION }}
|
||||
```
|
||||
|
||||
- name: "Remove the autorelease label once published"
|
||||
uses: actions/github-script@v6
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
script: |
|
||||
github.rest.issues.removeLabel({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: '${{ env.WORKFLOW_RUN_PR }}',
|
||||
name: '🚀 autorelease',
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
# Adapted from create-t3-app.
|
||||
|
||||
name: Release - Beta
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [labeled]
|
||||
branches:
|
||||
- main
|
||||
jobs:
|
||||
prerelease:
|
||||
if: |
|
||||
github.repository_owner == 'hanzoai' &&
|
||||
contains(github.event.pull_request.labels.*.name, '🚀 autorelease')
|
||||
name: Build & Publish a beta release to NPM
|
||||
runs-on: ubuntu-latest
|
||||
environment: Preview
|
||||
|
||||
steps:
|
||||
- name: Checkout Repo
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Use PNPM
|
||||
uses: pnpm/action-setup@v2.2.4
|
||||
with:
|
||||
version: 8.6.1
|
||||
|
||||
- name: Use Node.js 18
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 18
|
||||
cache: "pnpm"
|
||||
|
||||
- name: Install NPM Dependencies
|
||||
run: pnpm install
|
||||
|
||||
- name: Modify package.json version
|
||||
run: node .github/version-script-beta.js
|
||||
|
||||
- name: Authenticate to NPM
|
||||
run: echo "//registry.npmjs.org/:_authToken=$NPM_ACCESS_TOKEN" >> pkg/cli/.npmrc
|
||||
env:
|
||||
NPM_ACCESS_TOKEN: ${{ secrets.NPM_ACCESS_TOKEN }}
|
||||
|
||||
- name: Publish Beta to NPM
|
||||
run: pnpm pub:beta
|
||||
|
||||
- name: get-npm-version
|
||||
id: package-version
|
||||
uses: martinbeentjes/npm-get-version-action@main
|
||||
with:
|
||||
path: pkg/cli
|
||||
|
||||
- name: Upload packaged artifact
|
||||
uses: actions/upload-artifact@v2
|
||||
with:
|
||||
name: npm-package-hanzoai-ui@${{ steps.package-version.outputs.current-version }}-pr-${{ github.event.number }} # encode the PR number into the artifact name
|
||||
path: pkg/cli/dist/index.js
|
||||
@@ -0,0 +1,154 @@
|
||||
name: Publish on Tag
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*' # Match @hanzo/ui version (e.g., v5.1.1)
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: Run Tests
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: pnpm/action-setup@v3
|
||||
with:
|
||||
version: 9
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build packages
|
||||
run: |
|
||||
cd pkg/ui && pnpm build && cd ../..
|
||||
cd pkg/auth && pnpm build && cd ../..
|
||||
cd pkg/commerce && pnpm build && cd ../..
|
||||
cd pkg/brand && pnpm build && cd ../..
|
||||
cd pkg/react && pnpm build && cd ../..
|
||||
|
||||
- name: Run tests
|
||||
run: |
|
||||
cd pkg/ui && pnpm test
|
||||
cd ../react && pnpm test
|
||||
cd ../..
|
||||
|
||||
publish:
|
||||
name: Publish to NPM
|
||||
needs: test
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
id-token: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: pnpm/action-setup@v3
|
||||
with:
|
||||
version: 9
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: 'pnpm'
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build all packages
|
||||
run: |
|
||||
cd pkg/ui && pnpm build && cd ../..
|
||||
cd pkg/auth && pnpm build && cd ../..
|
||||
cd pkg/commerce && pnpm build && cd ../..
|
||||
cd pkg/brand && pnpm build && cd ../..
|
||||
cd pkg/react && pnpm build && cd ../..
|
||||
|
||||
- name: Configure npm authentication
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_AUTH_TOKEN }}
|
||||
run: |
|
||||
npm config set //registry.npmjs.org/:_authToken $NODE_AUTH_TOKEN
|
||||
npm whoami
|
||||
|
||||
- name: Check and publish packages
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_AUTH_TOKEN }}
|
||||
run: |
|
||||
echo "Checking all packages for unpublished versions..."
|
||||
|
||||
PUBLISHED_COUNT=0
|
||||
SKIPPED_COUNT=0
|
||||
PUBLISHED_PACKAGES=""
|
||||
|
||||
for package in ui auth commerce brand react; do
|
||||
cd "pkg/$package"
|
||||
|
||||
CURRENT_VERSION=$(node -p "require('./package.json').version")
|
||||
PACKAGE_NAME=$(node -p "require('./package.json').name")
|
||||
|
||||
echo ""
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
echo "📦 Checking $PACKAGE_NAME@$CURRENT_VERSION"
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
|
||||
# Check if this version already exists on npm
|
||||
if npm view "$PACKAGE_NAME@$CURRENT_VERSION" version 2>/dev/null; then
|
||||
echo "⏭️ Already published - skipping"
|
||||
SKIPPED_COUNT=$((SKIPPED_COUNT + 1))
|
||||
else
|
||||
echo "🚀 Publishing to npm..."
|
||||
npm publish --access public
|
||||
echo "✅ Successfully published $PACKAGE_NAME@$CURRENT_VERSION"
|
||||
PUBLISHED_COUNT=$((PUBLISHED_COUNT + 1))
|
||||
PUBLISHED_PACKAGES="$PUBLISHED_PACKAGES\n- $PACKAGE_NAME@$CURRENT_VERSION"
|
||||
fi
|
||||
|
||||
cd ../..
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
echo "📊 Publishing Summary"
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
echo "✅ Published: $PUBLISHED_COUNT package(s)"
|
||||
echo "⏭️ Skipped: $SKIPPED_COUNT package(s)"
|
||||
|
||||
if [ $PUBLISHED_COUNT -gt 0 ]; then
|
||||
echo ""
|
||||
echo -e "Published packages:$PUBLISHED_PACKAGES"
|
||||
fi
|
||||
|
||||
# Save for GitHub release notes
|
||||
echo "PUBLISHED_COUNT=$PUBLISHED_COUNT" >> $GITHUB_ENV
|
||||
echo "PUBLISHED_PACKAGES<<EOF" >> $GITHUB_ENV
|
||||
echo -e "$PUBLISHED_PACKAGES" >> $GITHUB_ENV
|
||||
echo "EOF" >> $GITHUB_ENV
|
||||
|
||||
- name: Create GitHub Release
|
||||
if: ${{ env.PUBLISHED_COUNT > 0 }}
|
||||
uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
generate_release_notes: true
|
||||
body: |
|
||||
## 📦 NPM Packages Published
|
||||
|
||||
${{ env.PUBLISHED_PACKAGES }}
|
||||
|
||||
### Installation
|
||||
|
||||
```bash
|
||||
# Install latest versions
|
||||
npm install @hanzo/ui @hanzo/auth @hanzo/commerce @hanzo/brand @hanzo/react
|
||||
```
|
||||
files: |
|
||||
CHANGELOG.md
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -0,0 +1,76 @@
|
||||
name: Publish Packages
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'pkg/*/package.json'
|
||||
|
||||
jobs:
|
||||
detect-changes:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
packages: ${{ steps.changed.outputs.packages }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 2
|
||||
|
||||
- name: Detect changed packages
|
||||
id: changed
|
||||
run: |
|
||||
CHANGED_PACKAGES=()
|
||||
for pkg_json in pkg/*/package.json; do
|
||||
pkg_dir=$(dirname "$pkg_json")
|
||||
pkg_name=$(basename "$pkg_dir")
|
||||
|
||||
# Check if package.json version changed
|
||||
if git diff HEAD~1 HEAD --quiet "$pkg_json" 2>/dev/null; then
|
||||
continue
|
||||
fi
|
||||
|
||||
# Get old and new versions
|
||||
OLD_VERSION=$(git show HEAD~1:"$pkg_json" 2>/dev/null | jq -r '.version' || echo "0.0.0")
|
||||
NEW_VERSION=$(jq -r '.version' "$pkg_json")
|
||||
|
||||
if [ "$OLD_VERSION" != "$NEW_VERSION" ]; then
|
||||
echo "Version change detected: $pkg_name ($OLD_VERSION -> $NEW_VERSION)"
|
||||
CHANGED_PACKAGES+=("$pkg_name")
|
||||
fi
|
||||
done
|
||||
|
||||
if [ ${#CHANGED_PACKAGES[@]} -eq 0 ]; then
|
||||
echo "packages=[]" >> $GITHUB_OUTPUT
|
||||
else
|
||||
JSON=$(printf '%s\n' "${CHANGED_PACKAGES[@]}" | jq -R . | jq -s -c .)
|
||||
echo "packages=$JSON" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
publish:
|
||||
needs: detect-changes
|
||||
if: needs.detect-changes.outputs.packages != '[]'
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
package: ${{ fromJson(needs.detect-changes.outputs.packages) }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install
|
||||
|
||||
- name: Build package
|
||||
run: pnpm --filter ${{ matrix.package }} build
|
||||
|
||||
- name: Publish package
|
||||
run: pnpm --filter ${{ matrix.package }} publish --no-git-checks --access public
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
@@ -0,0 +1,60 @@
|
||||
# Adapted from create-t3-app.
|
||||
|
||||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
permissions:
|
||||
id-token: write
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
release:
|
||||
if: ${{ github.repository_owner == 'hanzoai-ui' }}
|
||||
name: Create a PR for release workflow
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout Repo
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Use PNPM
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 9.0.6
|
||||
|
||||
- name: Use Node.js 20
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
registry-url: "https://registry.npmjs.org"
|
||||
cache: "pnpm"
|
||||
|
||||
- name: Update npm for OIDC support
|
||||
run: npm install -g npm@latest
|
||||
|
||||
- name: Install NPM Dependencies
|
||||
run: pnpm install
|
||||
|
||||
# - name: Check for errors
|
||||
# run: pnpm check
|
||||
|
||||
- name: Build the package
|
||||
run: pnpm shadcn:build
|
||||
|
||||
- name: Create Version PR or Publish to NPM
|
||||
id: changesets
|
||||
uses: changesets/action@v1
|
||||
with:
|
||||
commit: "chore(release): version packages"
|
||||
title: "chore(release): version packages"
|
||||
version: node .github/changeset-version.js
|
||||
publish: npx changeset publish
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
NODE_ENV: "production"
|
||||
@@ -0,0 +1,100 @@
|
||||
name: Sync Forks
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
fork:
|
||||
description: 'Fork to sync (luxfi or zoo or all)'
|
||||
required: false
|
||||
default: 'all'
|
||||
|
||||
jobs:
|
||||
sync-luxfi:
|
||||
if: github.event.inputs.fork == 'luxfi' || github.event.inputs.fork == 'all' || github.event_name == 'push'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout Hanzo UI
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Git
|
||||
run: |
|
||||
git config --global user.name "Hanzo Bot"
|
||||
git config --global user.email "bot@hanzo.ai"
|
||||
|
||||
- name: Clone Luxfi Fork
|
||||
run: |
|
||||
git clone https://github.com/luxfi/ui.git luxfi-ui
|
||||
cd luxfi-ui
|
||||
git remote add upstream https://github.com/hanzoai/ui.git
|
||||
git fetch upstream
|
||||
|
||||
- name: Sync and Rebrand
|
||||
run: |
|
||||
cd luxfi-ui
|
||||
# Merge upstream changes
|
||||
git checkout main
|
||||
git merge upstream/main --no-edit || true
|
||||
|
||||
# Run rebrand script
|
||||
cp ../scripts/rebrand.sh ./
|
||||
chmod +x rebrand.sh
|
||||
./rebrand.sh luxfi
|
||||
|
||||
# Commit changes
|
||||
git add -A
|
||||
git commit -m "sync: Update from hanzoai/ui and rebrand for Luxfi" || true
|
||||
|
||||
- name: Push to Luxfi
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.FORK_SYNC_TOKEN }}
|
||||
run: |
|
||||
cd luxfi-ui
|
||||
git push https://$GITHUB_TOKEN@github.com/luxfi/ui.git main || true
|
||||
|
||||
sync-zoo:
|
||||
if: github.event.inputs.fork == 'zoo' || github.event.inputs.fork == 'all' || github.event_name == 'push'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout Hanzo UI
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Git
|
||||
run: |
|
||||
git config --global user.name "Hanzo Bot"
|
||||
git config --global user.email "bot@hanzo.ai"
|
||||
|
||||
- name: Clone Zoo Fork
|
||||
run: |
|
||||
git clone https://github.com/zooai/ui.git zoo-ui
|
||||
cd zoo-ui
|
||||
git remote add upstream https://github.com/hanzoai/ui.git
|
||||
git fetch upstream
|
||||
|
||||
- name: Sync and Rebrand
|
||||
run: |
|
||||
cd zoo-ui
|
||||
# Merge upstream changes
|
||||
git checkout main
|
||||
git merge upstream/main --no-edit || true
|
||||
|
||||
# Run rebrand script
|
||||
cp ../scripts/rebrand.sh ./
|
||||
chmod +x rebrand.sh
|
||||
./rebrand.sh zoo
|
||||
|
||||
# Commit changes
|
||||
git add -A
|
||||
git commit -m "sync: Update from hanzoai/ui and rebrand for Zoo" || true
|
||||
|
||||
- name: Push to Zoo
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.FORK_SYNC_TOKEN }}
|
||||
run: |
|
||||
cd zoo-ui
|
||||
git push https://$GITHUB_TOKEN@github.com/zooai/ui.git main || true
|
||||
@@ -0,0 +1,76 @@
|
||||
name: E2E and Visual Tests
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, develop]
|
||||
pull_request:
|
||||
branches: [main, develop]
|
||||
schedule:
|
||||
# Run tests weekly on Sunday at 2 AM UTC to catch breaking updates
|
||||
- cron: '0 2 * * 0'
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=8192
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
strategy:
|
||||
matrix:
|
||||
node-version: [20.x]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v2
|
||||
with:
|
||||
version: 9.0.6
|
||||
|
||||
- name: Setup Node.js ${{ matrix.node-version }}
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install
|
||||
|
||||
- name: Install Playwright browsers
|
||||
run: pnpm exec playwright install --with-deps
|
||||
|
||||
- name: Build the project
|
||||
run: pnpm build
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=8192
|
||||
NEXT_PUBLIC_APP_URL: https://ui.hanzo.ai
|
||||
|
||||
- name: Run component health check
|
||||
run: pnpm health-check
|
||||
continue-on-error: true
|
||||
|
||||
- name: Run E2E tests
|
||||
run: pnpm test:e2e
|
||||
timeout-minutes: 30
|
||||
env:
|
||||
CI: true
|
||||
|
||||
- name: Run visual regression tests
|
||||
run: pnpm test:visual
|
||||
timeout-minutes: 20
|
||||
continue-on-error: true
|
||||
env:
|
||||
CI: true
|
||||
|
||||
- name: Upload test results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: playwright-report
|
||||
path: tests/reports/playwright-report/
|
||||
retention-days: 30
|
||||
@@ -0,0 +1,53 @@
|
||||
name: Validate Registries
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- "apps/v4/public/r/registries.json"
|
||||
- "apps/v4/registry/directory.json"
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- "apps/v4/public/r/registries.json"
|
||||
- "apps/v4/registry/directory.json"
|
||||
|
||||
jobs:
|
||||
validate:
|
||||
runs-on: ubuntu-latest
|
||||
name: pnpm validate:registries
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 20
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
name: Install pnpm
|
||||
id: pnpm-install
|
||||
with:
|
||||
version: 9.0.6
|
||||
run_install: false
|
||||
|
||||
- name: Get pnpm store directory
|
||||
id: pnpm-cache
|
||||
run: |
|
||||
echo "pnpm_cache_dir=$(pnpm store path)" >> $GITHUB_OUTPUT
|
||||
- uses: actions/cache@v3
|
||||
name: Setup pnpm cache
|
||||
with:
|
||||
path: ${{ steps.pnpm-cache.outputs.pnpm_cache_dir }}
|
||||
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pnpm-store-
|
||||
- name: Install dependencies
|
||||
run: pnpm install
|
||||
|
||||
- name: Validate registries
|
||||
run: pnpm --filter=v4 validate:registries
|
||||
@@ -80,4 +80,3 @@ playwright-report/
|
||||
.notes
|
||||
.claude/settings.local.json
|
||||
pkg/ui/dist-test/
|
||||
app/public/api/
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
# The whole caller. Every decision this pipeline makes — what to test, what to
|
||||
# build, where to roll it — is read from the root hanzo.yml, so this file only
|
||||
# ever names the triggers and the reusable.
|
||||
#
|
||||
# `.hanzo/workflows`, not `.github/workflows`, and that is not a style choice:
|
||||
# CI for this repo runs on git.hanzo.ai, which resolves ONLY `.hanzo/workflows`.
|
||||
# github.com has zero self-hosted runners registered for the `hanzo-build-*`
|
||||
# labels this pipeline asks for, so the same file under `.github/workflows`
|
||||
# would queue there forever and never report anything at all.
|
||||
name: CI/CD
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
tags: ['v*']
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: cicd-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
cicd:
|
||||
uses: hanzoai/ci/.hanzo/workflows/build.yml@v1
|
||||
secrets: inherit
|
||||
@@ -1,70 +0,0 @@
|
||||
name: deploy
|
||||
|
||||
# RETIRED as an automatic lane. The image is built by hanzo.yml's `images:` block
|
||||
# through hanzoai/ci (.hanzo/workflows/cicd.yml), and this job is kept only as a
|
||||
# manually-dispatched fallback.
|
||||
#
|
||||
# The reason is one capability, not tidiness. ui.hanzo.ai is a static export, so
|
||||
# its ingest key has to be BAKED IN at build; the key lives in KMS as
|
||||
# `deploy/PUBLISHABLE_KEY`, and hanzoai/ci is the only lane that reads it and
|
||||
# passes it as `--build-arg PUBLISHABLE_KEY=…`. The `docker build -t "$image" .`
|
||||
# below cannot — there is no KMS hop in it and nowhere to put the value — so any
|
||||
# image it produced would ship a client with no key at all, and cloud answers
|
||||
# `401 ingest_key_required` for an unattributed write. With the Dockerfile now
|
||||
# failing closed on an empty key, this job fails on every run instead of quietly
|
||||
# publishing an inert site.
|
||||
#
|
||||
# Left in place rather than deleted, and dispatch-only rather than on push:
|
||||
# deleting a repo's only build lane strands the host with no failing run to show
|
||||
# it, and two push-triggered builders for one image means one commit yields two
|
||||
# images under two tag schemes. Dispatching this publishes `<short-sha>` with an
|
||||
# EMPTY key and the Dockerfile will refuse it — so if you dispatch it, pass the
|
||||
# key yourself or expect the gate to fire.
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: deploy-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
image:
|
||||
# hanzo-build-linux-amd64, not ubuntu-latest. This file lives in
|
||||
# .hanzo/workflows, which GitHub never reads -- git.hanzo.ai is the only
|
||||
# thing that can run it, and the fleet deliberately advertises no generic
|
||||
# ubuntu-* label (~1400 mirrored forks all ask for it; one bad job was
|
||||
# retried ~520 times across 10 runners). An unmatched label is not an
|
||||
# error here, it is silence: the job queues until the 24h timeout, so this
|
||||
# deploy has never produced an image and nothing ever said so.
|
||||
runs-on: hanzo-build-linux-amd64
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: build and push
|
||||
# GHCR_TOKEN/GHCR_USER, which are the org secrets that actually exist.
|
||||
# This job asked for REGISTRY_TOKEN, which was never defined anywhere —
|
||||
# the org has exactly GHCR_TOKEN, GHCR_USER, GH_PAT, KMS_CLIENT_ID,
|
||||
# KMS_CLIENT_SECRET, OCI_TOKEN, OCI_USER — so the guard below fired on
|
||||
# every run and nothing was ever published. Every other image build in
|
||||
# the org already uses the GHCR_* pair; this file and papers were the
|
||||
# two that invented a name.
|
||||
#
|
||||
# The username comes from the secret too, rather than being hardcoded to
|
||||
# hanzo-dev, so rotating the publishing identity is one org-level change.
|
||||
env:
|
||||
GHCR_TOKEN: ${{ secrets.GHCR_TOKEN }}
|
||||
GHCR_USER: ${{ secrets.GHCR_USER }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -z "${GHCR_TOKEN:-}" ] || [ -z "${GHCR_USER:-}" ]; then
|
||||
echo "::error::GHCR_TOKEN/GHCR_USER are unset, so nothing was published."
|
||||
exit 1
|
||||
fi
|
||||
tag="$(git rev-parse --short HEAD)"
|
||||
image="ghcr.io/hanzoai/ui:${tag}"
|
||||
echo "${GHCR_TOKEN}" | docker login ghcr.io -u "${GHCR_USER}" --password-stdin
|
||||
docker build -t "${image}" .
|
||||
docker push "${image}"
|
||||
echo "published ${image} — set image.tag AND image.digest in universe charts/app/values/hanzo/ui.yaml"
|
||||
@@ -1,343 +0,0 @@
|
||||
name: Publish Packages
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
# BOTH package roots, and the singular one is not a typo — `@hanzo/ui` and
|
||||
# `@hanzo/data` live under pkg/, everything else under pkgs/.
|
||||
#
|
||||
# ⚠️ This list and the `for pkg_json in …` loop below are ONE FACT. The loop
|
||||
# was already fixed to walk both roots, with a comment explaining that a
|
||||
# `pkgs/*` glob made pkg/ packages invisible — but this gate was left
|
||||
# matching `pkgs/*` only, so for a pkg/-only change the corrected job could
|
||||
# never be REACHED. The fix was reasoned about in the body and not applied
|
||||
# to the thing that decides whether the body runs.
|
||||
#
|
||||
# Cost: @hanzo/ui 8.0.29 sat unpublished while npmjs served 8.0.28, so
|
||||
# every consumer resolving `^8.0.17` silently got a version without the
|
||||
# `./chat` entrypoint — an import error in the dependent, nothing at all in
|
||||
# this repo. Add a root here whenever one is added to the loop.
|
||||
- 'pkg/*/package.json'
|
||||
- 'pkgs/*/package.json'
|
||||
# A publish that fails for an infrastructure reason — the runner missing a
|
||||
# tool, npmjs answering slowly — could only be retried by pushing another
|
||||
# commit that touches a package.json, i.e. by burning a version number to
|
||||
# re-run a job that was never wrong about the code. detect-changes reads what
|
||||
# npmjs SERVES, so a dispatch is idempotent by construction: it publishes
|
||||
# exactly the packages that are ahead of the registry, and nothing when none
|
||||
# are.
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
detect-changes:
|
||||
runs-on: hanzo-build-linux-amd64
|
||||
outputs:
|
||||
packages: ${{ steps.changed.outputs.packages }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 2
|
||||
|
||||
- name: Detect changed packages
|
||||
id: changed
|
||||
run: |
|
||||
CHANGED=()
|
||||
# Both package roots. `@hanzo/ui` and `@hanzo/data` live under pkg/ (singular)
|
||||
# and were invisible to a `pkgs/*` glob, so no version bump of either could
|
||||
# ever reach npmjs from here.
|
||||
for pkg_json in pkg/*/package.json pkgs/*/package.json; do
|
||||
[ ! -f "$pkg_json" ] && continue
|
||||
# Skip private packages
|
||||
IS_PRIVATE=$(jq -r '.private // false' "$pkg_json")
|
||||
[ "$IS_PRIVATE" = "true" ] && continue
|
||||
|
||||
# Only publish @hanzo/* packages
|
||||
PKG_NAME=$(jq -r '.name' "$pkg_json")
|
||||
case "$PKG_NAME" in
|
||||
@hanzo/*) ;;
|
||||
*) continue ;;
|
||||
esac
|
||||
|
||||
# What npm SERVES, not what the previous commit said. HEAD~1 is the
|
||||
# FIRST PARENT, so a merge whose first parent is the feature branch
|
||||
# already carries the new version and the diff is empty — the publish
|
||||
# is skipped and npm keeps serving the old release, silently. Asking
|
||||
# the registry is independent of merge topology and of how many
|
||||
# commits the runner fetched.
|
||||
NEW=$(jq -r '.version' "$pkg_json")
|
||||
OLD=$(curl -sf "https://registry.npmjs.org/${PKG_NAME}" \
|
||||
| jq -r '."dist-tags".latest // "0.0.0"' 2>/dev/null || echo "0.0.0")
|
||||
|
||||
# FORWARD ONLY. `!=` fires in both directions, and several packages
|
||||
# sit BEHIND npm (a release cut elsewhere, a revert) — publishing
|
||||
# those walks the registry backwards. Only a strictly greater local
|
||||
# version is a release.
|
||||
NEWEST=$(printf '%s\n%s\n' "$OLD" "$NEW" | sort -V | tail -1)
|
||||
if [ "$OLD" != "$NEW" ] && [ "$NEWEST" = "$NEW" ]; then
|
||||
echo "Version change: $PKG_NAME ($OLD -> $NEW)"
|
||||
# The package NAME, because every downstream step spends this as a
|
||||
# `pnpm --filter` argument and a directory basename matches nothing.
|
||||
# An unmatched filter exits 0, so the build silently no-ops and the
|
||||
# publish ships an unbuilt tarball — how 8.0.17 and 8.0.19 shipped broken.
|
||||
CHANGED+=("$PKG_NAME")
|
||||
fi
|
||||
done
|
||||
|
||||
if [ ${#CHANGED[@]} -eq 0 ]; then
|
||||
echo "packages=[]" >> $GITHUB_OUTPUT
|
||||
else
|
||||
JSON=$(printf '%s\n' "${CHANGED[@]}" | jq -R . | jq -s -c .)
|
||||
echo "packages=$JSON" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
publish:
|
||||
needs: detect-changes
|
||||
if: needs.detect-changes.outputs.packages != '[]'
|
||||
runs-on: hanzo-build-linux-amd64
|
||||
strategy:
|
||||
# One package's broken build must not withhold every other package's
|
||||
# release: the matrix legs are independent publishes, not one artifact.
|
||||
fail-fast: false
|
||||
matrix:
|
||||
package: ${{ fromJson(needs.detect-changes.outputs.packages) }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install
|
||||
|
||||
- name: Build package
|
||||
# The trailing `...` is load-bearing: it builds the package's WORKSPACE
|
||||
# DEPENDENCIES first, in topological order.
|
||||
#
|
||||
# Without it, `pnpm --filter @hanzo/ui build` runs alone against siblings
|
||||
# that have no `dist/`. Those siblings type themselves through
|
||||
# `"types": "dist/index.d.ts"`, so tsc reports them as missing modules and
|
||||
# the errors do not name the real cause:
|
||||
# src/product/SiteNav.tsx: Cannot find module '@hanzo/products'
|
||||
# src/core/tokens.ts: Cannot find module '@hanzo/tokens'
|
||||
# src/gitops.ts: Cannot find module '@hanzo/cd'
|
||||
# plus a wave of implicit-any errors in the files that imported them,
|
||||
# which read like a source problem in @hanzo/ui and are not.
|
||||
run: pnpm --filter ${{ matrix.package }}... build
|
||||
|
||||
- name: Provision bun (only for a package whose test script asks for it)
|
||||
# @hanzo/canvas declares `bun test` and this runner has no bun, so its
|
||||
# suite has been `spawn ENOENT` on every run — which fails the leg before
|
||||
# the publish and is why canvas has sat at 0.2.2 here against 0.2.1 on
|
||||
# npmjs. The step that exists to stop an untested release was itself the
|
||||
# thing stopping the release.
|
||||
#
|
||||
# Read from the package's OWN manifest, not from a list here, so a
|
||||
# package that adopts or drops bun needs no edit to this file. A package
|
||||
# that does not ask for bun does not pay for it.
|
||||
run: |
|
||||
set -euo pipefail
|
||||
PKG_DIR=$(pnpm --filter ${{ matrix.package }} exec pwd | tail -1)
|
||||
if ! jq -r '.scripts.test // ""' "$PKG_DIR/package.json" | grep -qw bun; then
|
||||
echo "::notice::${{ matrix.package }} does not run bun"
|
||||
exit 0
|
||||
fi
|
||||
if command -v bun >/dev/null 2>&1; then
|
||||
echo "bun already on PATH: $(bun --version)"
|
||||
exit 0
|
||||
fi
|
||||
# From npmjs, not bun.sh/install: this job already reaches the npm
|
||||
# registry (it is what the job exists to publish to) and setup-node
|
||||
# already put the global bin on PATH, so this adds no new host to
|
||||
# trust and no new failure mode. The shell installer would also want
|
||||
# unzip, which a minimal runner image need not carry.
|
||||
npm install -g bun
|
||||
bun --version
|
||||
|
||||
- name: Test package
|
||||
# A published version is IMMUTABLE, so this is the last point at which a
|
||||
# defect costs nothing. Nothing else runs a package's own tests before it
|
||||
# ships: ci.yml's test job runs `cd pkgs/ui && …` — that one directory —
|
||||
# and this workflow went checkout → build → publish. So @hanzo/event's
|
||||
# suite, which pins the version the client stamps on every event, ran in
|
||||
# no workflow that gates a release, and the stale stamp it was written to
|
||||
# catch could still have reached npmjs.
|
||||
#
|
||||
# `--if-present` so a package with no test script is skipped rather than
|
||||
# failing the release: the set of testable packages is read from each
|
||||
# package.json, not listed here, so a new package is covered the day it
|
||||
# adds a `test` script.
|
||||
#
|
||||
# This step needs `test` to mean the same thing in every package — the
|
||||
# package's own suite, no browser, no network. @hanzo/ui briefly chained
|
||||
# its two CONSUMER suites onto it (pack a tarball, npm-install it into a
|
||||
# throwaway app, drive it in chromium), which is a real gate and belongs
|
||||
# exactly where hanzo.yml already runs it: on the push, in its own
|
||||
# `ui-consumer` job, next to the `playwright install` that gives it a
|
||||
# browser. Here there is no such step, so a chained `test` could only
|
||||
# fail — the last gate before an immutable version would have been one
|
||||
# that cannot pass, which is the failure mode this repo has already paid
|
||||
# for twice.
|
||||
run: pnpm --filter ${{ matrix.package }} run --if-present test
|
||||
|
||||
- name: Verify the tarball carries what package.json promises
|
||||
# `types` is a PROMISE to every consumer, and tsup writes dist/*.mjs in a
|
||||
# separate pass from dist/*.d.ts — so a declaration failure yields a
|
||||
# package whose JS is fine and whose types are absent. That is invisible
|
||||
# here and surfaces in dependents as "Could not find a declaration file".
|
||||
#
|
||||
# Asserted from the package's OWN manifest, so it holds for every package
|
||||
# this workflow publishes and needs no per-package list.
|
||||
run: |
|
||||
set -euo pipefail
|
||||
PKG_DIR=$(pnpm --filter ${{ matrix.package }} exec pwd | tail -1)
|
||||
TYPES=$(jq -r '.types // .typings // empty' "$PKG_DIR/package.json")
|
||||
if [ -z "$TYPES" ]; then
|
||||
echo "::notice::${{ matrix.package }} declares no types entrypoint — nothing to verify"
|
||||
exit 0
|
||||
fi
|
||||
if [ ! -f "$PKG_DIR/$TYPES" ]; then
|
||||
echo "::error::${{ matrix.package }} declares types at $TYPES but the build produced no such file."
|
||||
echo "::error::Publishing would ship a package that silently has no types. Fix the declaration build."
|
||||
exit 1
|
||||
fi
|
||||
echo "types present: $TYPES"
|
||||
|
||||
- name: Fetch the npm token from KMS
|
||||
id: npmtok
|
||||
# publish.yml used secrets.NPM_TOKEN, which exists on NEITHER the hanzoai
|
||||
# org nor this repo on git.hanzo.ai. An absent secret interpolates to the
|
||||
# empty string rather than failing, so every publish reached npmjs
|
||||
# unauthenticated and 401'd -- the reason @hanzo/ui has never shipped from
|
||||
# CI and each release was a hand publish.
|
||||
#
|
||||
# KMS is where a secret lives, so the token is read at run time from the
|
||||
# same org path and with the same machine identity every other workflow
|
||||
# here already uses. Nothing new to rotate, and no npm credential is
|
||||
# stored on the forge.
|
||||
env:
|
||||
KMS_CLIENT_ID: ${{ secrets.KMS_CLIENT_ID }}
|
||||
KMS_CLIENT_SECRET: ${{ secrets.KMS_CLIENT_SECRET }}
|
||||
KMS_ENDPOINT: ${{ vars.KMS_ENDPOINT || 'https://kms.hanzo.ai' }}
|
||||
KMS_ORG: ${{ vars.KMS_ORG || 'hanzo' }}
|
||||
KMS_SECRET_ENV: ${{ vars.KMS_SECRET_ENV || 'prod' }}
|
||||
# Where the token actually lives, MEASURED: NPM_TOKEN sits at the org
|
||||
# root, not under deploy/ (which holds CLOUDFLARE_*, GIT_TOKEN,
|
||||
# KUBECONFIG, UNIVERSE_PIN_TOKEN). Empty means root; set the
|
||||
# KMS_NPM_PATH variable to move it without editing this file.
|
||||
KMS_NPM_PATH: ${{ vars.KMS_NPM_PATH || '' }}
|
||||
run: |
|
||||
token=$(curl -fsS "${KMS_ENDPOINT}/v1/kms/auth/login" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d "{\"clientId\":\"${KMS_CLIENT_ID}\",\"clientSecret\":\"${KMS_CLIENT_SECRET}\"}" \
|
||||
| jq -r '.accessToken // empty')
|
||||
if [ -z "${token}" ]; then
|
||||
echo "::error::KMS login failed at ${KMS_ENDPOINT}/v1/kms/auth/login (check KMS_CLIENT_ID/KMS_CLIENT_SECRET)."
|
||||
exit 1
|
||||
fi
|
||||
# The read is org-scoped by the TOKEN's owner claim, not by a path
|
||||
# segment: the flat form is the contract cloud's embedded KMS serves,
|
||||
# and it answers {name, env, value}. The older
|
||||
# /v1/kms/orgs/<org>/secrets/... form with .secret.value 404s here.
|
||||
secret_url="${KMS_ENDPOINT}/v1/kms/secrets/${KMS_NPM_PATH:+${KMS_NPM_PATH}/}NPM_TOKEN?env=${KMS_SECRET_ENV}"
|
||||
# No -f: a 404 must report WHICH path was empty, not just exit 22.
|
||||
npm_token=$(curl -sS "${secret_url}" -H "Authorization: Bearer ${token}" | jq -r '.value // empty')
|
||||
if [ -z "${npm_token}" ]; then
|
||||
echo "::error::No NPM_TOKEN at ${secret_url}."
|
||||
echo "::error::Put it there, or set the KMS_NPM_PATH variable to the path that has it."
|
||||
exit 1
|
||||
fi
|
||||
# Masked before it is ever an output, so it cannot surface in a log.
|
||||
echo "::add-mask::${npm_token}"
|
||||
echo "token=${npm_token}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Publish package
|
||||
# Idempotent on purpose: the desired state is "npmjs holds this version", and
|
||||
# a version already there satisfies it. Versions are immutable, so a conflict
|
||||
# means the release already happened (a re-run, or a hand publish) — that is
|
||||
# success, not a failure. Every other error still fails the job.
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ steps.npmtok.outputs.token }}
|
||||
run: |
|
||||
set -o pipefail
|
||||
OUT=$(pnpm --filter ${{ matrix.package }} publish \
|
||||
--no-git-checks --access public 2>&1) || true
|
||||
echo "$OUT"
|
||||
case "$OUT" in
|
||||
*EPUBLISHCONFLICT*|*"cannot publish over"*|*"previously published versions"*)
|
||||
echo "::notice::${{ matrix.package }} is already on npmjs at this version — nothing to do" ;;
|
||||
*)
|
||||
# Not a conflict: confirm the version really landed, else fail loudly.
|
||||
#
|
||||
# RETRIED, because npmjs is read-after-write eventually consistent
|
||||
# and this asked it exactly once, about two seconds after the
|
||||
# write. On run 26906 both @hanzo/og@1.0.0 and @hanzo/shop@1.0.0
|
||||
# failed here having printed npm's own `+ @hanzo/og@1.0.0` success
|
||||
# line moments earlier, and both are on the registry now: the
|
||||
# publishes worked and the check was simply too early.
|
||||
#
|
||||
# A false red here is worse than a slow green. It reports that a
|
||||
# release did not happen when it did, so the next person bumps the
|
||||
# version to "fix" it and burns a number over a replication lag —
|
||||
# and the real signal, a publish that genuinely failed, becomes
|
||||
# indistinguishable from the noise.
|
||||
PKG_DIR=$(pnpm --filter ${{ matrix.package }} exec pwd | tail -1)
|
||||
NAME=$(jq -r '.name' "$PKG_DIR/package.json")
|
||||
WANT=$(jq -r '.version' "$PKG_DIR/package.json")
|
||||
landed=
|
||||
for attempt in 1 2 3 4 5 6; do
|
||||
if npm view "$NAME@$WANT" version >/dev/null 2>&1; then landed=1; break; fi
|
||||
echo "$NAME@$WANT not visible to npmjs yet (attempt $attempt/6); waiting 10s"
|
||||
sleep 10
|
||||
done
|
||||
[ -n "$landed" ] \
|
||||
|| { echo "::error::$NAME@$WANT is not on npmjs 60s after publish"; exit 1; }
|
||||
echo "published $NAME@$WANT" ;;
|
||||
esac
|
||||
|
||||
- name: Publish to the Hanzo registry
|
||||
# Second home for the same tarball: api.hanzo.ai/v1/packages/hanzo/npm, our
|
||||
# own registry (hanzoai/git), so an install does not have to reach npmjs.
|
||||
# Same artifact, same version — this republishes what the step above just
|
||||
# built, it does not rebuild or re-version.
|
||||
#
|
||||
# BEST-EFFORT by construction. No token ⇒ skip with a notice, so this can
|
||||
# land before the credential exists and starts working the moment it does.
|
||||
# An already-published version is a success, not a failure: the registry
|
||||
# holding it is the desired state either way. npmjs is authoritative and is
|
||||
# never affected by anything here.
|
||||
env:
|
||||
# REGISTRY_TOKEN is the fleet-wide name (six other workflows use it and
|
||||
# the hanzoai org carries it). This asked for HANZO_REGISTRY_TOKEN, which
|
||||
# is defined nowhere, so it read as empty and the step below took its
|
||||
# "not set" branch and exited 0 on every run -- the mirror to
|
||||
# api.hanzo.ai has never happened, and said so only as a notice.
|
||||
HANZO_REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
run: |
|
||||
set -uo pipefail
|
||||
if [ -z "${HANZO_REGISTRY_TOKEN:-}" ]; then
|
||||
echo "::notice::HANZO_REGISTRY_TOKEN not set — package published to npmjs only."
|
||||
echo "::notice::Add it (a Hanzo Git token with package:write) to mirror to api.hanzo.ai."
|
||||
exit 0
|
||||
fi
|
||||
REG=https://api.hanzo.ai/v1/packages/hanzo/npm/
|
||||
NPMRC="$RUNNER_TEMP/.npmrc-hanzo"
|
||||
{
|
||||
echo "@hanzo:registry=${REG}"
|
||||
echo "//api.hanzo.ai/v1/packages/hanzo/npm/:_authToken=${HANZO_REGISTRY_TOKEN}"
|
||||
} > "$NPMRC"
|
||||
out=$(npm_config_userconfig="$NPMRC" \
|
||||
pnpm --filter ${{ matrix.package }} publish \
|
||||
--no-git-checks --access public --registry "$REG" 2>&1) || true
|
||||
echo "$out"
|
||||
case "$out" in
|
||||
*EPUBLISHCONFLICT*|*"cannot publish over"*|*"already exists"*)
|
||||
echo "::notice::already in the Hanzo registry at this version — nothing to do" ;;
|
||||
*) echo "$out" | grep -qiE 'error|failed' \
|
||||
&& echo "::warning::mirror to api.hanzo.ai failed (npmjs publish unaffected)" \
|
||||
|| echo "mirrored to the Hanzo registry" ;;
|
||||
esac
|
||||
@@ -1,91 +0,0 @@
|
||||
name: Validate Registries
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- "apps/v4/public/r/registries.json"
|
||||
- "apps/v4/registry/directory.json"
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- "apps/v4/public/r/registries.json"
|
||||
- "apps/v4/registry/directory.json"
|
||||
|
||||
jobs:
|
||||
validate:
|
||||
runs-on: hanzo-build-linux-amd64
|
||||
name: pnpm validate:registries
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 20
|
||||
|
||||
- name: Block reserved registry namespaces
|
||||
env:
|
||||
RESERVED_NAMESPACES: "@shadcn,@ui,@blocks,@components,@block,@component,@util,@utils,@registry,@lib,@hook,@hooks,@theme,@themes,@chart,@charts"
|
||||
run: |
|
||||
node <<'EOF'
|
||||
const fs = require("node:fs")
|
||||
|
||||
const files = [
|
||||
"apps/v4/public/r/registries.json",
|
||||
"apps/v4/registry/directory.json",
|
||||
]
|
||||
const reservedNamespaces = new Set(
|
||||
process.env.RESERVED_NAMESPACES.split(",").filter(Boolean)
|
||||
)
|
||||
|
||||
function readNames(filePath) {
|
||||
return JSON.parse(fs.readFileSync(filePath, "utf8")).map(
|
||||
(entry) => entry.name
|
||||
)
|
||||
}
|
||||
|
||||
const violations = files.flatMap((filePath) => {
|
||||
return readNames(filePath)
|
||||
.filter((name) => reservedNamespaces.has(name))
|
||||
.map((name) => `${filePath}: ${name}`)
|
||||
})
|
||||
|
||||
if (violations.length > 0) {
|
||||
console.error("Reserved registry namespaces are not allowed:")
|
||||
|
||||
for (const violation of violations) {
|
||||
console.error(`- ${violation}`)
|
||||
}
|
||||
|
||||
process.exit(1)
|
||||
}
|
||||
EOF
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
name: Install pnpm
|
||||
id: pnpm-install
|
||||
with:
|
||||
version: 9.0.6
|
||||
run_install: false
|
||||
|
||||
- name: Get pnpm store directory
|
||||
id: pnpm-cache
|
||||
run: |
|
||||
echo "pnpm_cache_dir=$(pnpm store path)" >> $GITHUB_OUTPUT
|
||||
- uses: actions/cache@v3
|
||||
name: Setup pnpm cache
|
||||
with:
|
||||
path: ${{ steps.pnpm-cache.outputs.pnpm_cache_dir }}
|
||||
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pnpm-store-
|
||||
- name: Install dependencies
|
||||
run: pnpm install
|
||||
|
||||
- name: Validate registries
|
||||
run: pnpm --filter=v4 validate:registries
|
||||
@@ -1,3 +1,2 @@
|
||||
auto-install-peers=true
|
||||
link-workspace-packages=true
|
||||
puppeteer_skip_download=true
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
dist
|
||||
node_modules
|
||||
.next
|
||||
build
|
||||
.contentlayer
|
||||
**/fixtures
|
||||
deprecated
|
||||
apps/v4/registry/styles/**/*.css
|
||||
|
||||
# Byte-coupled: hz.js carries the marked region of src/anon.js VERBATIM, and
|
||||
# pkgs/event/src/anon.test.ts compares them byte for byte. Reformatting either
|
||||
# one alone breaks the build; the same region is also vendored by hanzoai/cloud.
|
||||
pkgs/event/src/anon.js
|
||||
pkgs/event/hz.js
|
||||
Vendored
+3
-3
@@ -4,8 +4,8 @@
|
||||
"typescript.preferences.quoteStyle": "single",
|
||||
"eslint.workingDirectories": [
|
||||
{ "pattern": "apps/*/" },
|
||||
{ "pattern": "pkgs/*/" },
|
||||
{ "pattern": "pkgs/*/" }
|
||||
{ "pattern": "packages/*/" },
|
||||
{ "pattern": "pkg/*/" }
|
||||
],
|
||||
"tailwindCSS.classFunctions": ["cva", "cn"],
|
||||
"vitest.debugExclude": [
|
||||
@@ -19,6 +19,6 @@
|
||||
"search.exclude": {
|
||||
"apps/v4/registry/radix-*": true,
|
||||
"apps/v4/public/r/*": true,
|
||||
"pkgs/shadcn/test/fixtures/*": true
|
||||
"packages/shadcn/test/fixtures/*": true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,190 +0,0 @@
|
||||
# Hanzo UI Consolidation — one library, all polish, no duplication
|
||||
|
||||
**Goal.** RIP the fragmentation (`@hanzo/data` vs `@hanzo/ui` vs in-console duplicates) into ONE canonical, cross-platform, presentational, clean-room library: **`@hanzo/ui`, built on `@hanzo/gui`**. Preserve every polished component + token; zero loss; no fork.
|
||||
|
||||
**Status.**
|
||||
- **Step 1 — Audit:** complete (this document; four source trees read file-by-file).
|
||||
- **Step 2 — Establish `@hanzo/ui` + migrate the stable foundation:** **DONE & GREEN** (`pkg/ui` is now a real package; `tsc --noEmit` = 0 errors, `vitest` = 12/12).
|
||||
- **Step 3 — Console re-point:** **STAGED, not merged.** Gate is split (backend live, app lanes in-flight) — see [Step 3](#step-3--console-re-point-staged).
|
||||
|
||||
**One decision needs CTO sign-off before publish** — see [Naming / version](#naming--version--the-one-decision).
|
||||
|
||||
---
|
||||
|
||||
## Architecture — three layers, decomplected
|
||||
|
||||
```
|
||||
@hanzo/gui (Tamagui/One) cross-platform primitives: Text, XStack/YStack, Button, Input,
|
||||
│ Card, Popover, Select, Switch, Slider, Spinner, ScrollView …
|
||||
▼
|
||||
@hanzo/data (pkg/data) the metadata-driven RECORD layer: fields → records → views
|
||||
│ (RecordsView, DataTable, BoardView, RecordDetail, field editors)
|
||||
▼
|
||||
@hanzo/ui (pkg/ui) ◄──── THE ONE LIBRARY. Two orthogonal concerns, one package:
|
||||
• product/app layer → import { … } from '@hanzo/ui'
|
||||
• record layer → import { … } from '@hanzo/ui/data' (re-exports @hanzo/data)
|
||||
```
|
||||
|
||||
`@hanzo/data` stays the **source of truth** for the record layer; `@hanzo/ui` **composes** it (`export * from '@hanzo/data'` on the `./data` subpath) rather than copying — so the CMS/ERP/Help app lanes that consume `@hanzo/data` today keep working untouched, and there is exactly one home. This mirrors how the gui base wraps `hanzogui`.
|
||||
|
||||
Both layers own a `DataTable` (product = generic typed `<T>` list; data = field-driven record grid). Keeping the record layer on the `./data` subpath keeps each name unambiguous — no rename, no collision.
|
||||
|
||||
---
|
||||
|
||||
## The preserve-and-merge manifest
|
||||
|
||||
Legend: **✅ migrated** (in `pkg/ui`, GREEN) · **↪ re-exported** (composed from `@hanzo/data`) · **⏳ staged** (Step 3, after app lanes land) · **🔧 pending prop-injection** (app-coupled; decouple before it can live in a presentational lib) · **🏠 stays in console** (app glue, not a UI component).
|
||||
|
||||
### A. Record layer — `@hanzo/data` → `@hanzo/ui/data` ↪
|
||||
|
||||
Source: `pkg/data/src/*` (v1.2.0, clean-room, already published). Surfaced through `@hanzo/ui/data` and `@hanzo/ui/primitives/bases/data`. Nothing re-implemented.
|
||||
|
||||
| Component / module | Source | New home |
|
||||
|---|---|---|
|
||||
| `RecordsView` (flagship: toolbar over table⇆board, filter/sort/search/group, saved views) | `view/RecordsView.tsx`, `view/Toolbar.tsx` | `@hanzo/ui/data` |
|
||||
| `DataTable` (field-driven record grid: sort headers, drag resize/reorder, row select, inline edit, pagination) | `table/DataTable.tsx` | `@hanzo/ui/data` |
|
||||
| `BoardView` (kanban, optimistic drag-move) · `RecordCard` | `board/BoardView.tsx`, `table/RecordCard.tsx` | `@hanzo/ui/data` |
|
||||
| `RecordDetail` / `RecordForm` | `record/RecordDetail.tsx` | `@hanzo/ui/data` |
|
||||
| Field model + registry (`FieldType` ×24, `FieldDefinition`, `registerField`, routers) | `field/{types,registry,registerDefaults,FieldDisplay,FieldInput}` | `@hanzo/ui/data` |
|
||||
| Field displays + **editors** (select/currency/date/relation/file/JSON/boolean/fullName/address…) | `field/{displays,inputs}.tsx` | `@hanzo/ui/data` |
|
||||
| Composable primitives: `Menu`, `CheckBox`, `Toggle`, `Calendar` | `primitives/*` | `@hanzo/ui/data` |
|
||||
| Pure logic: `applyView`, `sortRecords`, `filterRecords`, `searchRecords`, `paginate`, `boardColumns`, … | `{table,board,view}/logic.ts` | `@hanzo/ui/data` (+ `@hanzo/data/*/logic` subpaths for Node hosts) |
|
||||
| Tokens: `tokens`, `TAG_TONES`, `tagTone` | `theme.ts` | **also top-level `@hanzo/ui`** (token surface) |
|
||||
|
||||
### B. Console `components/ui/*` product primitives → `@hanzo/ui/product` ✅ / 🔧
|
||||
|
||||
Source: `console2/src/components/ui/*`. The pure subset was ported **byte-identical** (verified by `diff`) — 100% of the polish preserved (Charts' hover tooltips/axis-ticks/honest-null, DataTable width/flex/hover-gating, Field's 6 variants, SlideOver's focus-trap + scroll-lock, ComboBox's ReDoS-safe filter).
|
||||
|
||||
| Component | Source (`console2/src/components/ui/`) | New home | Status |
|
||||
|---|---|---|---|
|
||||
| `DataTable<T>` (generic typed list) + `Column` | `DataTable.tsx` | `@hanzo/ui` (`product/DataTable`) | ✅ |
|
||||
| `PageHeader` | `PageHeader.tsx` | `@hanzo/ui` | ✅ |
|
||||
| `Field*` (`FieldRow/Text/TextArea/Switch/Select/Slider`) | `Field.tsx` | `@hanzo/ui` | ✅ |
|
||||
| **Charts** (`Sparkline/LineChart/BarChart/Donut/BarRows`, `CHART_PALETTE`) | `Charts.tsx` | `@hanzo/ui` | ✅ |
|
||||
| `Donut` (standalone dependency-free ring) | `Donut.tsx` | `@hanzo/ui` as **`DonutRing`** (alias — Charts.Donut is canonical) | ✅ |
|
||||
| `StatusTag` (health verdict → pill) | `StatusTag.tsx` | `@hanzo/ui` | ✅ |
|
||||
| `EmptyState` (DO/Vercel-class first-run) | `EmptyState.tsx` | `@hanzo/ui` | ✅ (**decoupled** — see §I) |
|
||||
| `Metric*` (`MetricCard/MiniBars/UtilBar/LegendDot/Panel/HintButton`, `SERIES`) | `Metric.tsx` | `@hanzo/ui` (its `Sparkline` preserved as **`MetricSparkline`**) | ✅ |
|
||||
| `ComboBox` + pure `filterOptions`/`isKnownOption` | `ComboBox.tsx`, `combobox/filter.ts` | `@hanzo/ui` | ✅ (+ `filter.test.ts`) |
|
||||
| `PrimaryButton` · `HanzoMark` · `ProductIcon` · `ProviderLogo` | resp. files | `@hanzo/ui` | ✅ |
|
||||
| `SlideOver` (a11y drawer) · `SelectMenu` · `Toast` (`useToast`) | resp. files | `@hanzo/ui` | ✅ (SlideOver import decoupled `~/…/color`→`./color`) |
|
||||
| `Reorder<T>` (pointer DnD) · `FadeIn` · `ThemeToggle` · `color` (`asColor`/`IconLike`) | resp. files | `@hanzo/ui` | ✅ (+ `Reorder.test.ts`) |
|
||||
| `DetailPane` (right-side pane over SlideOver) | `components/DetailPane.tsx` | `@hanzo/ui` | ⏳ (liftable; lands with Step 3 — depends only on SlideOver) |
|
||||
| `ChunkGuard` (stale-deploy chunk-error reload) | `components/ChunkGuard.tsx` | `@hanzo/ui` | ⏳ (fully portable; lands with Step 3) |
|
||||
|
||||
### C. Design tokens + motion → `@hanzo/ui` ✅
|
||||
|
||||
The brief's premise (`globals.css` `t_dark`/`t_light` token blocks) is **corrected**: `t_dark`/`t_light` are `@hanzo/gui` (Tamagui) theme **class names**; the calm values live in three real places, all preserved:
|
||||
|
||||
| Polish | Source | New home |
|
||||
|---|---|---|
|
||||
| Calm dark-first tokens (surface, text, accent `#60a5fa`, tag tones) — raw hex, theme-config independent | `pkg/data/src/theme.ts` | `@hanzo/ui` (top-level `tokens`/`TAG_TONES`/`tagTone`) |
|
||||
| OKLCH shadcn-compatible palette (soft-charcoal `oklch(0.145 0 0)`, `--radius: 0.5rem`, indigo sidebar accent) | `pkgs/ui/style/hanzo-default-colors.css`, `@hanzo/tokens` | unchanged (legacy shadcn line); the gui line uses the hex tokens above |
|
||||
| **Motion vocabulary** — `fade-up` (FadeIn), `collapse`/`slide`/`fade` (shell/drawer), `drag`, `skeleton`/`pulse`/`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`; MIT (repo `LICENSE.md`). 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: MIT (repo `LICENSE.md`). `@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 2–6 — 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).
|
||||
+49
-33
@@ -1,10 +1,10 @@
|
||||
# Contributing
|
||||
|
||||
Thanks for your interest in contributing to ui.shadcn.com. We're happy to have you here.
|
||||
Thanks for your interest in contributing to ui.hanzo.com. We're happy to have you here.
|
||||
|
||||
Please take a moment to review this document before submitting your first pull request. We also strongly recommend that you check for open issues and pull requests to see if someone else is working on something similar.
|
||||
|
||||
If you need any help, feel free to reach out to [@shadcn](https://twitter.com/shadcn).
|
||||
If you need any help, feel free to reach out to [@hanzo](https://x.com/hanzoai).
|
||||
|
||||
## About this repository
|
||||
|
||||
@@ -12,9 +12,7 @@ This repository is a monorepo.
|
||||
|
||||
- We use [pnpm](https://pnpm.io) and [`workspaces`](https://pnpm.io/workspaces) for development.
|
||||
- We use [Turborepo](https://turbo.build/repo) as our build system.
|
||||
- Releases are semver-driven: bump a package's `version` in its `package.json` and
|
||||
merge to `main`. CI (`.github/workflows/publish.yml`) detects the version change
|
||||
and publishes that package to npm. One step, one source of truth.
|
||||
- We use [changesets](https://github.com/changesets/changesets) for managing releases.
|
||||
|
||||
## Structure
|
||||
|
||||
@@ -22,25 +20,28 @@ This repository is structured as follows:
|
||||
|
||||
```
|
||||
apps
|
||||
└── v4
|
||||
└── www
|
||||
├── app
|
||||
├── components
|
||||
├── content
|
||||
└── registry
|
||||
└── new-york-v4
|
||||
├── default
|
||||
│ ├── example
|
||||
│ └── ui
|
||||
└── new-york
|
||||
├── example
|
||||
└── ui
|
||||
packages
|
||||
└── shadcn
|
||||
└── cli
|
||||
```
|
||||
|
||||
| Path | Description |
|
||||
| -------------------- | ---------------------------------------- |
|
||||
| `apps/v4/app` | The Next.js application for the website. |
|
||||
| `apps/v4/components` | The React components for the website. |
|
||||
| `apps/v4/content` | The content for the website. |
|
||||
| `apps/v4/registry` | The registry for the components. |
|
||||
| `packages/shadcn` | The `shadcn` package. |
|
||||
| Path | Description |
|
||||
| --------------------- | ---------------------------------------- |
|
||||
| `apps/www/app` | The Next.js application for the website. |
|
||||
| `apps/www/components` | The React components for the website. |
|
||||
| `apps/www/content` | The content for the website. |
|
||||
| `apps/www/registry` | The registry for the components. |
|
||||
| `packages/cli` | The `hanzo-ui` package. |
|
||||
|
||||
## Development
|
||||
|
||||
@@ -78,61 +79,76 @@ You can use the `pnpm --filter=[WORKSPACE]` command to start the development pro
|
||||
|
||||
#### Examples
|
||||
|
||||
1. To run the `ui.shadcn.com` website:
|
||||
1. To run the `ui.hanzo.com` website:
|
||||
|
||||
```bash
|
||||
pnpm --filter=v4 dev
|
||||
pnpm --filter=www dev
|
||||
```
|
||||
|
||||
2. To run the `shadcn` package:
|
||||
2. To run the `hanzo-ui` package:
|
||||
|
||||
```bash
|
||||
pnpm --filter=shadcn dev
|
||||
pnpm --filter=hanzo-ui dev
|
||||
```
|
||||
|
||||
## Running the CLI Locally
|
||||
|
||||
To run the CLI locally, you can follow the workflow:
|
||||
|
||||
1. Start by running the dev server:
|
||||
1. Start by running the registry (main site) to make sure the components are up to date:
|
||||
|
||||
```bash
|
||||
pnpm dev
|
||||
pnpm v4:dev
|
||||
```
|
||||
|
||||
2. In another terminal tab, test the CLI by running:
|
||||
2. Run the development script for the CLI:
|
||||
|
||||
```bash
|
||||
pnpm shadcn
|
||||
pnpm hanzo:dev
|
||||
```
|
||||
|
||||
3. In another terminal tab, test the CLI by running:
|
||||
|
||||
```bash
|
||||
pnpm hanzo
|
||||
```
|
||||
|
||||
To test the CLI in a specific app, use a command like:
|
||||
|
||||
```bash
|
||||
pnpm shadcn <init | add | ...> -c ~/Desktop/my-app
|
||||
pnpm hanzo <init | add | ...> -c ~/Desktop/my-app
|
||||
```
|
||||
|
||||
4. To run the tests for the CLI:
|
||||
|
||||
```bash
|
||||
pnpm --filter=hanzo test
|
||||
```
|
||||
|
||||
This workflow ensures that you are running the most recent version of the registry and testing the CLI properly in your local environment.
|
||||
|
||||
## Documentation
|
||||
|
||||
The documentation for this project is located in the `v4` workspace. You can run the documentation locally by running the following command:
|
||||
The documentation for this project is located in the `www` workspace. You can run the documentation locally by running the following command:
|
||||
|
||||
```bash
|
||||
pnpm --filter=v4 dev
|
||||
pnpm --filter=www dev
|
||||
```
|
||||
|
||||
Documentation is written using [MDX](https://mdxjs.com). You can find the documentation files in the `apps/v4/content/docs` directory.
|
||||
Documentation is written using [MDX](https://mdxjs.com). You can find the documentation files in the `apps/www/content/docs` directory.
|
||||
|
||||
## Components
|
||||
|
||||
We use a registry system for developing components. You can find the source code for the components under `apps/v4/registry`. The components are organized by styles.
|
||||
We use a registry system for developing components. You can find the source code for the components under `apps/www/registry`. The components are organized by styles.
|
||||
|
||||
```bash
|
||||
apps
|
||||
└── v4
|
||||
└── www
|
||||
└── registry
|
||||
└── new-york-v4
|
||||
├── default
|
||||
│ ├── example
|
||||
│ └── ui
|
||||
└── new-york
|
||||
├── example
|
||||
└── ui
|
||||
```
|
||||
@@ -141,7 +157,7 @@ When adding or modifying components, please ensure that:
|
||||
|
||||
1. You make the changes for every style.
|
||||
2. You update the documentation.
|
||||
3. You run `pnpm registry:build` to update the registry.
|
||||
3. You run `pnpm build:registry` to update the registry.
|
||||
|
||||
## Commit Convention
|
||||
|
||||
@@ -180,9 +196,9 @@ If you have a request for a new component, please open a discussion on GitHub. W
|
||||
|
||||
## CLI
|
||||
|
||||
The `shadcn` package is a CLI for adding components to your project. You can find the documentation for the CLI [here](https://ui.shadcn.com/docs/cli).
|
||||
The `hanzo-ui` package is a CLI for adding components to your project. You can find the documentation for the CLI [here](https://ui.hanzo.com/docs/cli).
|
||||
|
||||
Any changes to the CLI should be made in the `packages/shadcn` directory. If you can, it would be great if you could add tests for your changes.
|
||||
Any changes to the CLI should be made in the `packages/cli` directory. If you can, it would be great if you could add tests for your changes.
|
||||
|
||||
## Testing
|
||||
|
||||
|
||||
@@ -1,106 +0,0 @@
|
||||
# Hanzo Design System — Canonical Tokens
|
||||
|
||||
`@hanzo/ui` is the single source of truth for the shared Hanzo product look across
|
||||
the **Tailwind** apps (hanzo.chat, hanzo.app, hanzo console, commerce, hanzo-desktop).
|
||||
Change a value here; apps converge on it. This file is that source of truth for the
|
||||
three things that must read as **one product**: typography, the sidebar/panel system,
|
||||
and the dark-black palette.
|
||||
|
||||
> One library: **`@hanzo/ui@8`** (`pkg/ui`, on **`@hanzo/gui`**) IS the component
|
||||
> library — the cross-platform product/record layer every surface consumes.
|
||||
> **`@hanzo/ui-shadcn`** (`pkgs/ui`) is the legacy shadcn/Tailwind/Radix kit, kept
|
||||
> only for existing v5 consumers (pin `@hanzo/ui-shadcn@^5`; no new adoptions).
|
||||
> This file stays the source of truth for the *token values* (fonts, dark palette,
|
||||
> the sidebar glyph) both render.
|
||||
|
||||
---
|
||||
|
||||
## 1. Typography — Basel Grotesk + Geist Mono
|
||||
|
||||
| Role | Family | Notes |
|
||||
|------|--------|-------|
|
||||
| UI / body / display / heading (`sans`) | **Basel Grotesk** | Self-hosted. Book = weight **400**, Medium = weight **500**. |
|
||||
| code / data / mono (`mono`) | **Geist Mono** | `next/font/google` (`Geist_Mono`) or the geist CDN. |
|
||||
| Arabic / Hebrew (`--font-ar` / `--font-he`) | unchanged | i18n only — keep. |
|
||||
|
||||
**Dropped as defaults:** Geist Sans, DM Sans, Figtree, Inter, PT Sans, Roboto Mono.
|
||||
|
||||
Basel is a **licensed, non-Google** face — **self-host** the woff2/woff, do NOT use
|
||||
`next/font/google` for it. Canonical files (mirror lux.exchange):
|
||||
`Basel-Grotesk-Book.woff2/.woff` (400), `Basel-Grotesk-Medium.woff2/.woff` (500).
|
||||
|
||||
`@font-face` (weights 400/500, `font-display: swap`, `font-style: normal`):
|
||||
|
||||
```css
|
||||
@font-face {
|
||||
font-family: 'Basel';
|
||||
font-style: normal;
|
||||
font-weight: 400; /* Book; 500 = Medium */
|
||||
font-display: swap;
|
||||
src: url('.../Basel-Grotesk-Book.woff2') format('woff2'),
|
||||
url('.../Basel-Grotesk-Book.woff') format('woff');
|
||||
}
|
||||
```
|
||||
|
||||
Per-app adoption (converge the value, keep each app's own mechanism):
|
||||
- **@hanzo/ui / Next apps** → `next/font/local` for Basel (`--font-basel-sans`) +
|
||||
`next/font/google` `Geist_Mono` (`--font-geist-mono`). See `app/lib/fonts.ts`;
|
||||
tailwind `sans → var(--font-basel-sans)`, `mono → var(--font-geist-mono)`.
|
||||
- **Vite + Tailwind apps** (chat, launcher, desktop) → self-host Basel `@font-face`
|
||||
+ geist-mono CDN import; tailwind `fontFamily.sans = ['Basel', …]`,
|
||||
`mono = ['Geist Mono', …]`.
|
||||
- **Tamagui (console)** → Basel `@font-face` in globals + override the Tamagui
|
||||
`body`/`heading` font `family` to Basel; Geist Mono for `code`/`pre`.
|
||||
|
||||
---
|
||||
|
||||
## 2. Sidebar toggle icon — lucide `PanelLeft`
|
||||
|
||||
One glyph everywhere: lucide **`PanelLeft`** (the shadcn `SidebarTrigger` default).
|
||||
Where a directional open/close affordance is wanted, use the pair
|
||||
**`PanelLeftClose`** (expanded) / **`PanelLeft`** (collapsed). Never a hamburger,
|
||||
a magnifier, a directional arrow, or a bespoke panel SVG for the sidebar toggle.
|
||||
|
||||
- Icon: stroke-2, ~16–20px, `currentColor`.
|
||||
- Button: ghost/outline, square (`size-7`/`h-6 w-6`), subtle hover.
|
||||
|
||||
---
|
||||
|
||||
## 3. Sidebar + panels
|
||||
|
||||
| Spec | Value |
|
||||
|------|-------|
|
||||
| Sidebar width (expanded) | **16rem / 256px** (`SIDEBAR_WIDTH`) |
|
||||
| Sidebar width (collapsed / icon rail) | **3rem / 48px** (apps vary 48–70px) |
|
||||
| Sidebar / panel surface | resting **#0a0a0a** over the true-black page |
|
||||
| Border / separation | `border-border` — subtle white-alpha ~10% in dark (`border-r` / `border-l`) |
|
||||
| Item hover | subtle `white/5` |
|
||||
| Item active | monochrome `white/10` — **no colored accent** (the house style is monochrome) |
|
||||
| Right panel rail | `border-l border-border`, same surface, collapsible |
|
||||
|
||||
Prefer the `@hanzo/ui` `Sidebar` primitive (`pkgs/ui/primitives/sidebar.tsx`,
|
||||
`SidebarTrigger` → `PanelLeft`) where the app can consume it; otherwise match these
|
||||
classes/tokens.
|
||||
|
||||
---
|
||||
|
||||
## 4. Dark-black palette (true-black OLED)
|
||||
|
||||
The house dark theme is a **true-black** canvas (matches hanzo.ai marketing +
|
||||
hanzo.chat OLED), with a shallow surface-depth ladder for cards/panels and quiet
|
||||
hairline borders — never harsh pure-white on pure-black.
|
||||
|
||||
| Token | Value | Use |
|
||||
|-------|-------|-----|
|
||||
| Page background | **#000000** (`oklch(0 0 0)`) | body / canvas |
|
||||
| Surface / sidebar / panel (resting) | **#0a0a0a** | sidebars, panels, cards |
|
||||
| Press | **#050505** | pressed surface |
|
||||
| Elevated / hover | **#171717** | hover, raised card |
|
||||
| Border / divider | **rgba(255,255,255,0.10)** (≈ `#171717` opaque on black) | hairlines |
|
||||
| Foreground (primary text) | near-white **#ededf1** (`oklch(0.985)`) — not pure `#fff` | body text |
|
||||
| Muted / secondary text | `white/70` (≈ `#a1a1aa`) | secondary |
|
||||
|
||||
Keep each app's theme-token engine (CSS vars / Tailwind tokens / Tamagui `$color*`);
|
||||
converge the **values/usage** to the table above, don't rip the engine.
|
||||
|
||||
Reference: `pkgs/ui/style/hanzo-default-colors.css` (`.dark` / `.hanzo-ui-dark-theme`).
|
||||
-97
@@ -1,97 +0,0 @@
|
||||
# ui.hanzo.ai — the @hanzo/ui docs + component registry, served by the house
|
||||
# static server (ghcr.io/hanzoai/static, a Go binary), same as every other Hanzo
|
||||
# static site. Built on our own runners, never on a laptop and never by a
|
||||
# third-party builder.
|
||||
#
|
||||
# The app is a Next.js static export: `pnpm build` in app/ writes app/out, which
|
||||
# is the entire site — docs, the registry JSON the CLI reads, and the static
|
||||
# /api/registry/*.json index.
|
||||
FROM node:22 AS builder
|
||||
WORKDIR /src
|
||||
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
|
||||
# Publishable ingest key (pk-…), baked in at build because a static export has no
|
||||
# server to read config at runtime. Write-only and HMAC-verified to one org, so it
|
||||
# is safe in a public bundle; the deployment that builds decides which org the
|
||||
# site reports as.
|
||||
#
|
||||
# ONE name, end to end: KMS holds `deploy/PUBLISHABLE_KEY`, hanzo.yml declares it
|
||||
# as this image's build_secret, and the KMS name IS the build-arg name. NEXT_PUBLIC_
|
||||
# is added HERE because that prefix is what makes Next inline it — the app reads
|
||||
# process.env.NEXT_PUBLIC_PUBLISHABLE_KEY.
|
||||
#
|
||||
# Do NOT re-declare `ARG NEXT_PUBLIC_PUBLISHABLE_KEY` after the ENV below. A later
|
||||
# ARG of the same name shadows the ENV with its own (empty) default, and the build
|
||||
# stays green while the bundle ships blank — which is exactly how hanzo.chat 1.0.58
|
||||
# shipped a keyless site from a fully green run.
|
||||
#
|
||||
# Fail CLOSED, on BOTH ways this goes wrong.
|
||||
#
|
||||
# empty — builds, serves and looks correct while cloud answers
|
||||
# `401 ingest_key_required` for every anonymous pageview. The previous
|
||||
# `ARG …=""` default made that the normal outcome of an unattended
|
||||
# build, which is why no automated lane could ever publish a working
|
||||
# image.
|
||||
# `pk_…` — the OLDER key format. v5.7.6 shipped one, passed by hand on a local
|
||||
# `docker build`, and it is now dead: api.hanzo.ai 401s it on both the
|
||||
# fetch and beacon transports. A hand-passed key goes stale in silence,
|
||||
# so requiring the current `pk-` shape refuses the stale one outright.
|
||||
#
|
||||
# Neither failure is visible from outside the artifact, so refuse the artifact.
|
||||
ARG PUBLISHABLE_KEY
|
||||
ENV NEXT_PUBLIC_PUBLISHABLE_KEY=$PUBLISHABLE_KEY
|
||||
RUN case "$PUBLISHABLE_KEY" in \
|
||||
pk-*) : ;; \
|
||||
'') echo "PUBLISHABLE_KEY is empty - pass --build-arg PUBLISHABLE_KEY=<pk-...> (KMS deploy/PUBLISHABLE_KEY, env prod)" >&2; exit 1 ;; \
|
||||
*) echo "PUBLISHABLE_KEY is not a publishable key (expected a pk- prefix)" >&2; exit 1 ;; \
|
||||
esac
|
||||
# 300+ prerendered pages; the default heap is not enough.
|
||||
ENV NODE_OPTIONS=--max-old-space-size=8192
|
||||
|
||||
RUN corepack enable
|
||||
|
||||
COPY . .
|
||||
|
||||
# The lockfile is committed, so the build resolves exactly what was reviewed.
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
# Workspace packages the app imports must be built first: their package.json
|
||||
# exports point at dist/. Only @hanzo/event qualifies -- .npmrc sets
|
||||
# link-workspace-packages=true and the lockfile resolves it to link:../pkgs/event.
|
||||
#
|
||||
# There used to be a `cd pkgs/ui` line here. pkgs/ui was @hanzo/ui-shadcn, deleted
|
||||
# in 5dbdb2943 when shadcn was consolidated to one home, and that commit did not
|
||||
# touch this file -- so every build since has run `cd` into a directory that does
|
||||
# not exist and died with exit code 2 before compiling anything. The app takes
|
||||
# @hanzo/ui from the registry now (npm:@hanzo/ui-shadcn@^5), not the workspace.
|
||||
RUN cd pkgs/event && pnpm build
|
||||
|
||||
# Builds the component registry, then the site (app/package.json build script).
|
||||
#
|
||||
# ...and then PROVES the key reached the client bundle. The gate above proves a
|
||||
# key was PASSED; only this proves it was INLINED. Those are different failures:
|
||||
# a rename on either side of `process.env.NEXT_PUBLIC_PUBLISHABLE_KEY` leaves the
|
||||
# build-arg intact and the bundle keyless, and a static export cannot report that
|
||||
# at runtime because there is no runtime.
|
||||
#
|
||||
# `&&`, never `;` — a `;` chain returns the LAST command's status, so a failed
|
||||
# build followed by a passing grep exits 0 and the image is published.
|
||||
RUN cd app && pnpm build && \
|
||||
if [ -z "${NEXT_PUBLIC_PUBLISHABLE_KEY}" ]; then \
|
||||
echo "ERROR: NEXT_PUBLIC_PUBLISHABLE_KEY is empty after a successful build." >&2; exit 1; \
|
||||
elif grep -rqF "${NEXT_PUBLIC_PUBLISHABLE_KEY}" out; then \
|
||||
echo "Build OK - ingest key inlined into app/out, verified"; \
|
||||
else \
|
||||
echo "ERROR: key supplied but NOT present in app/out - ui would ship unattributed" >&2; exit 1; \
|
||||
fi
|
||||
|
||||
# 0.5.1 serves a directory's index.html in place. On 0.4.1 every page 301'd to
|
||||
# an explicit /index.html, which leaks that filename into the address bar and
|
||||
# into the URLs Next builds for its route prefetches.
|
||||
FROM ghcr.io/hanzoai/static:0.5.2-amd64
|
||||
COPY --from=builder /src/app/out /public
|
||||
EXPOSE 3000
|
||||
# No -spa: the export writes a real index.html per route (trailingSlash), so a
|
||||
# missing path must 404 rather than silently render the home page.
|
||||
ENTRYPOINT ["/static", "-port", "3000", "-root", "/public"]
|
||||
@@ -1,313 +1,16 @@
|
||||
# @hanzo/ui — LLM context
|
||||
# Hanzo UI - LLM Context
|
||||
|
||||
**What this is.** The React component library for AI applications: 161+
|
||||
components, 24+ blocks, two themes, and a single typed import surface, all on ONE
|
||||
substrate (`@hanzo/gui`) so the same import runs on web, native and desktop.
|
||||
Published as `@hanzo/ui` (v8) on npm. Docs at https://ui.hanzo.ai. Dev port: 3003.
|
||||
## Overview
|
||||
|
||||
**Canonical role.** This is the canonical impl repo for Hanzo's web UI kit —
|
||||
frontend components, not an SDK. It sits alongside the two SDK lines (full cloud
|
||||
SDK generated from OpenAPI in `hanzo-<lang>/sdk` + wrapper in `hanzoai/<lang>-sdk`;
|
||||
AI/agents lib `hanzo` in `hanzoai/python-sdk` flagship, `@hanzo/ai` in `hanzo-js/ai`).
|
||||
`@hanzo/event` (telemetry, `POST /v1/event`) lives here in `pkgs/event`. DRY: one
|
||||
impl, one place — link out, never duplicate.
|
||||
React component library (shadcn/ui fork). 161 components, 24+ blocks, two themes, multi-framework. Published as `@hanzo/ui` on npm.
|
||||
|
||||
**Brand rules (hard).**
|
||||
- Never call Hanzo an "LLM gateway" or position it against LiteLLM — it is a full
|
||||
AI SDK / AI cloud, not a proxy. Purge that framing on sight.
|
||||
- Paths are `/v1/…` only — never an `/api/` prefix.
|
||||
- Zen models are our own family — never name upstream models.
|
||||
- Voice: "Hanzo — the Open AI Cloud." Developer-first, crisp, no emoji-spam.
|
||||
|
||||
**Install / run.**
|
||||
```bash
|
||||
pnpm add @hanzo/ui # consume
|
||||
# dev:
|
||||
pnpm install && pnpm build:registry && pnpm dev # registry MUST build before app
|
||||
```
|
||||
|
||||
**Key entry points.** `pkg/ui/` (core lib + v8 subpaths: /product /data /canvas
|
||||
/dashboard /usage /gitops) · `app/registry/{default,new-york}/`
|
||||
(component SOURCE OF TRUTH) · `pkgs/*` (auto-published `@hanzo/*` packages) ·
|
||||
`packages/shadcn/` (CLI) · `app/content/docs/` (MDX docs). Publish = bump a
|
||||
package `version` + merge to main (`.github/workflows/publish.yml`).
|
||||
|
||||
**Spec / more context.** Canonical SDK + docs model: `~/work/hanzo/SDK-ARCHITECTURE.md`.
|
||||
Detailed engineering notes (build order, import surface, telemetry, upstream sync,
|
||||
gotchas) follow below.
|
||||
|
||||
---
|
||||
|
||||
## v8 — the canonical `@hanzo/ui` (`pkg/ui`)
|
||||
|
||||
`@hanzo/ui@8` (`pkg/ui`) is THE Hanzo component library, and there is ONE
|
||||
substrate: every component renders through `@hanzo/gui` (Tamagui) primitives on
|
||||
the `@hanzo/tokens` scale, so one import works on web, native (expo) and desktop
|
||||
(Tauri). The Radix + Tailwind surface it used to ship alongside is gone — it
|
||||
lives on as its own package, `@hanzo/shadcn`, and `@hanzo/ui` no longer depends
|
||||
on it, on any `@radix-ui/*` package, on cva, cmdk or sonner.
|
||||
|
||||
`@hanzo/ui-shadcn` (`pkgs/ui`, v5.x) is the legacy standalone package —
|
||||
superseded, being retired.
|
||||
|
||||
### Layout
|
||||
|
||||
```
|
||||
pkg/ui/src/
|
||||
core/ design core: cn.ts (clsx+tailwind-merge),
|
||||
tokens.ts (re-export of @hanzo/tokens), fonts.ts (Geist vars)
|
||||
root.tsx <Hanzo> — the root. Carries the gui config AND the stylesheet.
|
||||
gallery.tsx EVERY component, once, in every variant. The one list.
|
||||
theme.css SELF-CONTAINED token CSS vars + Geist Sans/Mono — the identity
|
||||
backends/gui/ THE component surface on @hanzo/gui. index.ts is its manifest.
|
||||
product/ the product/app layer (charts, PageHeader, ComboBox, …)
|
||||
models/ the unified ModelSelector + catalog helpers
|
||||
primitives/ GENERATED per-member entrypoints (scripts/gen-primitives.mjs)
|
||||
index.ts root barrel = the component surface + cn
|
||||
```
|
||||
|
||||
### Out of the box — the package carries its own config and its own CSS
|
||||
|
||||
```tsx
|
||||
import { Hanzo, Button } from '@hanzo/ui'
|
||||
<Hanzo><Button>Ship</Button></Hanzo>
|
||||
```
|
||||
|
||||
That is the entire setup. No `gui.config.ts`, no CSS import, no generator script.
|
||||
Three things used to be each app's job:
|
||||
|
||||
1. **The stylesheet.** gui compiles a style prop to an atomic class the first
|
||||
time something RENDERS it, so the sheet does not exist until a render has
|
||||
happened — which is why every app ran a `gen-gui-css.mjs` of its own. hanzo.app
|
||||
never did: it shipped 103 `_bg-` classes and 26 `_dsp-` classes against a
|
||||
stylesheet containing ZERO of either, every gui-styled element unstyled in
|
||||
production, green build throughout. The render happens at OUR publish time now
|
||||
(`scripts/gen-css.mjs` renders `src/gallery.tsx` in both themes and writes
|
||||
`dist/styles.css` — 381 KB, 35 KB gzipped, 340 atomic selectors), and
|
||||
`<Hanzo>` imports it. Styles gui generates at RUNTIME for props we could not
|
||||
know at publish time still reach the document through `insertStyleRules`;
|
||||
the shipped sheet is what makes the FIRST paint and every SSR/static render
|
||||
correct.
|
||||
|
||||
2. **The config.** `<Hanzo>` passes `config` from `gui-config.ts` to
|
||||
`GuiProvider` — as a VALUE, never a bare `import './gui-config'`. Vite 8
|
||||
(rolldown) ignores package.json `sideEffects` ARRAYS outright: with any array
|
||||
the registration is dropped and the first render dies on "Missing hanzogui
|
||||
config"; only `sideEffects: true` keeps it, and that costs +63% bundle
|
||||
(404 KB → 661 KB measured). Correctness does not live in bundler metadata.
|
||||
|
||||
3. **The theme.** gui throws `Missing theme.` for any component with no root
|
||||
theme context, so a root is structurally required — there is no version of
|
||||
this with no root at all. Forgetting `<Hanzo>` is therefore a hard crash on
|
||||
first paint, never a silently unstyled page.
|
||||
|
||||
`theme.css` is dark-first at `:root` (it used to claim dark-first while shipping
|
||||
LIGHT at `:root`, so an app that mounted the dark default and read `--background`
|
||||
got white). `.light` retunes, and both answer to gui's own `.t_light`/`.t_dark`
|
||||
that `<Hanzo>` stamps on the body — one theme, named the same by the CSS custom
|
||||
properties and the component tokens.
|
||||
|
||||
### Three tests, one list of components
|
||||
|
||||
`src/gallery.tsx` is the specification of "what this package has to style", and
|
||||
all three layers render THAT — a second copy of the list is how a component gets
|
||||
styled by one and missed by another.
|
||||
|
||||
| Layer | Command | What it catches |
|
||||
|---|---|---|
|
||||
| `src/styles.test.tsx` | `pnpm test:unit` | every atomic class the gallery renders vs every class `dist/styles.css` defines a rule for. Not "the intersection is large" — TOTAL. Catches a stale sheet. |
|
||||
| `src/backends/gui/render.test.tsx` | `pnpm test:unit` | the surface mounts under the real provider; a component that throws on first paint fails. |
|
||||
| `test/consumer.spec.ts` | `pnpm test:consumer` | packs the tarball, installs it into a temp app OUTSIDE the repo (never a workspace link — that hides `files`/`exports`/`workspace:*` defects), builds, serves, and asserts COMPUTED styles + screenshots at 390 and 1280 in both themes. |
|
||||
|
||||
The consumer spec also fails on a solid-white border (the `@hanzo/design`
|
||||
`border-card: var(--white…)` defect — borders are low-alpha hairlines) and on any
|
||||
element that has a text child and a zero-height box.
|
||||
|
||||
### House rules for a component
|
||||
|
||||
- Style through gui props and theme tokens (`$background`, `$color12`,
|
||||
`$borderColor`) — never a utility class string, never a hard-coded font.
|
||||
- Touch targets meet the 44px floor via `hitSlop`, never via padding.
|
||||
- Behaviour (focus, portalling, keyboard, a11y) comes from the matching
|
||||
`@hanzogui/*` primitive; nothing reimplements it.
|
||||
- Free-form text children go through `ink()`; `data-slot` markers through
|
||||
`slot()`. One helper each, one place.
|
||||
- Module scope stays side-effect free — `forwardRef`/`createContext` calls carry
|
||||
`/* @__PURE__ */` and nothing assigns `displayName`, so importing one symbol
|
||||
never drags a neighbour in. A one-symbol import bundles ~4.7KB against ~29KB
|
||||
for the whole barrel.
|
||||
- Utility classes carry `hz-`. This sheet used to claim `.row`, `.skeleton`,
|
||||
`.fade`, `.mono`, `.drag` and `.tnum` at the document level, in a package an
|
||||
app imports once at its root; an app with its own `.row` got no warning, it got
|
||||
whichever rule the cascade preferred. The unprefixed selectors survive as
|
||||
aliases on the same rules for one minor version and are **REMOVED IN 8.1.0**.
|
||||
Nothing here emits them — `styles.test.tsx` scans every `className=` literal in
|
||||
`src/` and fails on an unprefixed one. `glass`/`elevation-N` are the one family
|
||||
still bare: they are an API VALUE (`glass(3).className`), not a typed literal,
|
||||
so they move on their own change.
|
||||
- A prop must actually arrive. gui is not the DOM, and two measured cases prove
|
||||
it in both directions: **`name` is gui's OWN prop** (it names a styled component
|
||||
and a theme) and is consumed before it reaches the element, so a `name` on a
|
||||
field type-checks and renders nothing; and gui **drops `secureTextEntry` on
|
||||
web**, which is why masking needs BOTH spellings via `masked()` in
|
||||
`backends/gui/mask.ts` and why `<Input type="password">` rendered passwords in
|
||||
PLAIN TEXT until 8.0.61 (the wrapper destructured `type` out and never
|
||||
forwarded it, next to an eye offering to reveal what was already visible).
|
||||
Render it and read the markup before you believe a prop works.
|
||||
|
||||
### Subpaths
|
||||
|
||||
| Subpath | What |
|
||||
|---|---|
|
||||
| `@hanzo/ui` | the component API: Button, Badge, Card*, Checkbox, Dialog*, DropdownMenu*, Input, Toaster, Avatar*, Tabs*, Select*, Tooltip*, Popover*, Command*, Collapsible*, Resizable*, ScrollArea, Slider, Switch, Progress, Separator, Label, Textarea, AspectRatio — + `cn` (the product layer is kept off root, at `/product`) |
|
||||
| `@hanzo/ui/components` | alias of the root surface, for hosts that shim the package through a `declare module` |
|
||||
| `@hanzo/ui/product` | the product/app layer: charts, metrics, PageHeader, StatusTag, EmptyState, ComboBox, SlideOver, Toast, Reorder, Field |
|
||||
| `@hanzo/ui/models` | ModelSelector + fetchModelCatalog + catalog helpers |
|
||||
| `@hanzo/ui/core` · `/tokens` | cn, Geist font vars, the @hanzo/tokens color/theme/radii/spacing scale |
|
||||
| `@hanzo/ui/theme.css` | the design tokens alone (custom properties + Geist + touch/elevation) |
|
||||
| `@hanzo/ui/styles.css` | the COMPLETE sheet — tokens + motion + the generated gui atomic/theme CSS. `<Hanzo>` imports it, so an app never has to |
|
||||
| `@hanzo/ui/gallery` | every component, once — what the generator, the unit test and the consumer test all render |
|
||||
| `@hanzo/ui/primitives/<Member>` | per-member entrypoints (for hosts that modularize `@hanzo/ui` imports) |
|
||||
| `@hanzo/ui/data` | `@hanzo/data`: RecordsView, DataTable, typed field editors |
|
||||
| `@hanzo/ui/{canvas,dashboard,usage,gitops}` | the optional-peer kits (each re-exports its home package) |
|
||||
| `@hanzo/ui/product/*` · `/primitives/*` | deep imports — one module without its barrel |
|
||||
| `@hanzo/ui/product/pure` | the product layer's RULES with none of the layer (below) |
|
||||
| `@hanzo/ui/product/theme-toggle-next` | the `@hanzogui/next-theme` binding, off the barrel on purpose (below) |
|
||||
| `@hanzo/ui/css` | `substitute()` — resolve a `var()` chain, for tests jsdom cannot answer (below) |
|
||||
|
||||
Everything ships COMPILED from `dist` — every `exports` target is a real file in
|
||||
the tarball, including `theme.css` and all 90 `primitives/*` entrypoints.
|
||||
`src/dist.test.ts` asserts that against the built output, wildcards included; a
|
||||
subpath pointing at a file tsc never emitted is invisible from source.
|
||||
|
||||
### Three doors that exist because the barrel is not one
|
||||
|
||||
`@hanzo/ui/product` mounts the whole gui runtime to give you one component, and
|
||||
for three kinds of caller that is not a cost — it is a wall.
|
||||
|
||||
**`@hanzo/ui/product/pure` — the rules, without the layer.** `pages()`,
|
||||
`masked()`, `displayName()`, `tone()`, `orgScope`, `filterOptions`,
|
||||
`resolveBrand` and the wordmark geometry. Every module it re-exports imports
|
||||
NOTHING (`src/dist.test.ts` asserts the closure is empty) and is on
|
||||
`postbuild.mjs`'s `DATA` list, so none is stamped `'use client'` — a stamped
|
||||
module is a client REFERENCE on React's server layer, and calling `pages()`
|
||||
through one in a server component throws instead of paging. It loads under a
|
||||
bare `require()` with no transform and no DOM; the test proves it in a child
|
||||
node process rather than under vitest, which has vite's transform already
|
||||
installed and would prove nothing.
|
||||
|
||||
The components import these same modules, so there is one definition and not a
|
||||
testable copy of a shipped one.
|
||||
|
||||
**`@hanzo/ui/product/theme-toggle-next` — Next, quarantined.**
|
||||
`@hanzogui/next-theme`'s provider imports `next/script`, so the product barrel's
|
||||
one `export { ThemeToggleNext }` line put Next in the graph of every Vite,
|
||||
Express and Tauri host — the hosts this layer promises to run on. A barrel
|
||||
re-export is a static edge no bundler can split. It is off the barrel; `<ThemeToggle />`
|
||||
with no props still reaches it by dynamic import and degrades when next-theme is
|
||||
absent, and `dist.test.ts` asserts BOTH — no static edge, and the dynamic one
|
||||
still there, because a test that only asserted the absence would pass on a
|
||||
deleted feature. `next` is now an OPTIONAL peer here: next-theme requires it and
|
||||
nothing in this package admitted that.
|
||||
|
||||
**`@hanzo/ui/css` — `substitute(value, vars?)`.** jsdom does not resolve
|
||||
`var()`; it hands a test the text verbatim. The theme rungs are
|
||||
`var(--border, rgb(255 255 255 / .10))` on purpose (follow the live cascade
|
||||
where design's sheet is mounted, keep the audited literal where it is not), so
|
||||
every consumer trying to assert a border's contrast compared a colour to a
|
||||
function call. With no `vars` map the answer is exact rather than approximate:
|
||||
jsdom mounts no design sheet, so the fallback IS what a browser computes. It is
|
||||
NOT part of `@hanzo/ui/core` — that subpath is ESM-only because @hanzo/design
|
||||
publishes no `require` condition, and a jest consumer is the caller that needs
|
||||
this. Importing nothing is what lets it ship both formats.
|
||||
|
||||
### One DropdownMenu
|
||||
|
||||
There is one `DropdownMenu`, with one API. It is the compound surface (Trigger,
|
||||
Content, Item, CheckboxItem, RadioItem, Label, Separator, Shortcut, Group,
|
||||
Portal, Sub*, RadioGroup) AND it accepts the declarative `trigger` + `items`
|
||||
spec, which it renders through those very same parts. `@hanzo/ui` and
|
||||
`@hanzo/ui/product` export the same component; there is no second shape.
|
||||
|
||||
### modularizeImports support
|
||||
|
||||
`scripts/gen-primitives.mjs` reads the gui backend barrel and emits one
|
||||
`src/primitives/<Member>.tsx` per exported value (re-export from the backend).
|
||||
This makes `@hanzo/ui/primitives/Button` etc. resolve, so a host whose
|
||||
`next.config` rewrites `@hanzo/ui` → `@hanzo/ui/primitives/{{member}}` works
|
||||
unchanged. Re-run `pnpm gen:primitives` after changing the surface.
|
||||
|
||||
### Build — plain `tsc`, one file in, one file out
|
||||
|
||||
There is no bundler. `pnpm build` is two `tsc` passes, `scripts/postbuild.mjs`,
|
||||
then `scripts/gen-css.mjs` (which renders the gallery through vite's SSR pipeline
|
||||
to harvest `config.getCSS()` into `dist/styles.css` — the slow step, ~2 min):
|
||||
|
||||
| Pass | Config | Emits |
|
||||
|---|---|---|
|
||||
| ESM + types | `tsconfig.build.json` | `dist/**/*.js`, `.d.ts`, `.js.map`, `.d.ts.map` |
|
||||
| CJS | `tsconfig.cjs.json` (`--noCheck`) | `dist-cjs/**/*.js`, folded into `dist/**/*.cjs` |
|
||||
|
||||
`postbuild.mjs` does the only two things `tsc` will not: it resolves every
|
||||
relative specifier to a fully-specified path (`./button` → `./button.js`,
|
||||
`./x` → `./x/index.js`; `.cjs` on the CJS half) so Node ESM and strict bundlers
|
||||
resolve, and it prepends `'use client'` to every emitted module — the whole
|
||||
library is client-side @hanzo/gui UI and Next's flight-client loader wants the
|
||||
directive first. It is prepended without a newline so source-map lines hold.
|
||||
|
||||
**`@hanzo/data` runs this same script**, with its package root and its own data
|
||||
modules as arguments (`node ../ui/scripts/postbuild.mjs . theme,table/logic,…`).
|
||||
It has the same two formats and the same two problems, and a second copy of the
|
||||
file would be a second place to get the barrel rule wrong. That package used to
|
||||
point its `exports` at `src/index.ts` and ship raw TSX — every consumer had to
|
||||
transpile it, and `require('@hanzo/data')` could not load at all, which is one of
|
||||
the two edges that broke `require('@hanzo/ui')`. It emits `dist/` now, both
|
||||
formats, from 1.2.2.
|
||||
|
||||
The output is UNBUNDLED and mirrors `src/` one-for-one, so a consumer importing
|
||||
one symbol pulls one module. A bundler here would be actively harmful: tsup's
|
||||
code splitting emitted 11 shared `chunk-*.js`, and importing `Button` alone
|
||||
dragged in `chunk-RCMDRI6V.js` (48K of source). Measured with esbuild, `import
|
||||
{ Button } from '@hanzo/ui'` costs 4717 bytes bundled from tsup output vs 2021
|
||||
from `tsc` output. Dropping `rollup-plugin-dts` (tsup's `dts` worker) is also
|
||||
what makes the package build on TypeScript 7, which it cannot do otherwise.
|
||||
|
||||
Everything under `src/` is emitted, so every `exports` subpath resolves by
|
||||
construction — no hand-maintained entry list to drift.
|
||||
|
||||
NOTE: `pkg/ui` (singular) sits OUTSIDE the `pkgs/*` pnpm workspace, installs
|
||||
standalone, and publishes via the maintainer flow, not `publish.yml`. Because the
|
||||
optional-peer kits (canvas/dashboard/gitops/usage) are not on the public
|
||||
registry, a standalone install must skip auto-installing peers. `.npmrc` and
|
||||
`pnpm-lock.yaml` are gitignored here, so create the `.npmrc` once:
|
||||
|
||||
```bash
|
||||
cd pkg/ui
|
||||
printf 'auto-install-peers=false\nstrict-peer-dependencies=false\n' > .npmrc
|
||||
pnpm install --ignore-workspace # component-surface deps (radix, cmdk, sonner, …) + @hanzo/tokens
|
||||
pnpm gen:primitives # refresh the per-member entrypoints
|
||||
pnpm typecheck:ui # scoped typecheck of the component surface (green)
|
||||
```
|
||||
|
||||
`@hanzo/tokens` (`pkgs/tokens`) must be built first (`pnpm --filter @hanzo/tokens build`)
|
||||
so the `file:` link resolves. The scoped `typecheck:ui` excludes the optional-peer
|
||||
subpaths, whose homes aren't installed standalone.
|
||||
|
||||
The **kits** = canvas, wallet, network, billing, dashboard, usage, gitops, data.
|
||||
Add one by mirroring `src/gitops.ts` (a one-line `export *`) + a `./name` export
|
||||
+ an optional peer/devDep. `pkg/*` is a pnpm workspace member (for `workspace:*`
|
||||
dev links), but `pkg/ui` publishes via the maintainer flow, not `publish.yml`
|
||||
(which auto-publishes only `pkgs/*` on a version bump — see PUBLISH_GUIDE.md). The
|
||||
shared shell lives here too: `AppHeader` + `BrandMark` (@hanzo/logo) +
|
||||
`OrgSwitcher` + `orgScope` (the console org-scope contract, hoisted per #36). Lux
|
||||
surfaces use `@luxfi/web3` for wallet/login; `@hanzo/ui/wallet`+`/network` are the
|
||||
Hanzo-branded equivalents.
|
||||
**Docs**: https://ui.hanzo.ai | **Dev port**: 3003
|
||||
|
||||
## Repository Structure
|
||||
|
||||
```
|
||||
ui/
|
||||
app/ Hanzo documentation site (Next.js 15.3.1, React 19)
|
||||
app/ Documentation site (Next.js 15.3.1, React 19)
|
||||
registry/ Component registry (SOURCE OF TRUTH)
|
||||
default/ui/ 150+ components
|
||||
default/example/ Usage demos
|
||||
@@ -315,25 +18,13 @@ ui/
|
||||
new-york/ Alternative theme
|
||||
content/docs/ MDX documentation
|
||||
scripts/ Build scripts
|
||||
apps/
|
||||
v4/ Upstream shadcn v4 docs/registry app (port 4000)
|
||||
packages/
|
||||
shadcn/ shadcn CLI v4.1.0 (font system, chart colors, scaffold)
|
||||
tests/ Integration tests for shadcn CLI
|
||||
og/ OG image generation (Hanzo-only)
|
||||
pkg/
|
||||
ui/ Core library (npm)
|
||||
react/ React primitives
|
||||
brand/ Branding system
|
||||
auth/ Auth components (Firebase optional since v2.6.0)
|
||||
auth-firebase/ Firebase auth (opt-in package)
|
||||
commerce/ E-commerce components
|
||||
checkout/ Checkout flow
|
||||
shop/ Shop components
|
||||
agent-ui/ AI agent UI components
|
||||
tokens/ Design tokens
|
||||
skills/
|
||||
shadcn/ AI skill definitions for shadcn CLI
|
||||
templates/ Project templates (next, vite, astro, react-router, start + monorepo variants)
|
||||
template/next/ Hanzo-customized Next.js template
|
||||
brand/ Branding system
|
||||
brands/ White-label configs (Zoo, Lux)
|
||||
```
|
||||
|
||||
## Critical: Build Order
|
||||
@@ -355,173 +46,14 @@ pnpm lint # Lint all workspaces
|
||||
pnpm typecheck # Type checking
|
||||
pnpm test # Unit tests
|
||||
pnpm test:e2e # Playwright E2E
|
||||
pnpm changeset # Create changeset for publishing
|
||||
```
|
||||
|
||||
## How this ships
|
||||
|
||||
One way, and it runs on our own stack:
|
||||
|
||||
push -> github.com/hanzoai/ui (a mirror)
|
||||
.github/workflows/sync.yml carries refs onward
|
||||
-> git.hanzo.ai/hanzoai/ui CANONICAL
|
||||
.hanzo/workflows/ci.yml lint, typecheck, build, test
|
||||
.hanzo/workflows/registries.yml the v4 registry check
|
||||
.hanzo/workflows/publish.yml publishes every pkgs/* package
|
||||
.hanzo/workflows/deploy.yml builds ghcr.io/hanzoai/ui
|
||||
-> hanzoai/universe crs/ui.yaml names the tag that is live
|
||||
-> hanzoai/operator reconciles the App
|
||||
-> hanzoai/static behind hanzoai/ingress serves ui.hanzo.ai
|
||||
|
||||
**git.hanzo.ai is canonical; GitHub is a mirror.** `.github/workflows/` holds
|
||||
exactly one file, `sync.yml`, and its only job is getting refs to the forge. Every
|
||||
build, check, publish and deploy is a workflow under `.hanzo/workflows/`, which the
|
||||
forge reads. `.hanzo/workflows` uses GitHub Actions syntax, so a workflow moves
|
||||
between the two by changing directory and nothing else.
|
||||
|
||||
No Vercel. `ci.yml` used to end in a `deploy-preview` job that ran `vercel deploy`
|
||||
on every PR; previews come from our own stack or not at all, so that job is gone.
|
||||
Its `status` job never depended on it.
|
||||
|
||||
## Publishing
|
||||
|
||||
One way: bump a package's `version` in its `package.json` and merge to `main`.
|
||||
`.hanzo/workflows/publish.yml` detects the changed `@hanzo/*` package and publishes
|
||||
it to npm (needs `NPM_TOKEN` as a forge secret). No changesets, no version-PR bot
|
||||
— the semver bump is the trigger.
|
||||
|
||||
It is the SOLE publisher of every non-private `@hanzo/*` in `pkgs/*`, and it
|
||||
mirrors the same tarball to `api.hanzo.ai/v1/packages/hanzo/npm` when
|
||||
`HANZO_REGISTRY_TOKEN` is present. That mirror is best-effort by construction: no
|
||||
token means a notice, not a failure, and npmjs stays authoritative either way.
|
||||
|
||||
## Deploying the site
|
||||
|
||||
`app/` is a Next.js static export (`output: "export"`, `trailingSlash: true`);
|
||||
`pnpm build` there writes `app/out`, which `Dockerfile` copies into
|
||||
`ghcr.io/hanzoai/static`. ui.hanzo.ai has been served that way since 2026-07-25 —
|
||||
no Cloudflare, no GitHub Pages.
|
||||
|
||||
What was missing until now is the build. `crs/ui.yaml` is live and promoted, but no
|
||||
workflow ever produced the image it pins: every tag up to `v5.7.6` was pushed by
|
||||
hand. `.hanzo/workflows/deploy.yml` is that step. It publishes
|
||||
`ghcr.io/hanzoai/ui:<sha>` and stops there — a build never deploys itself. A human
|
||||
sets `spec.image.tag` in `hanzoai/universe`
|
||||
`infra/k8s/operator/crs/ui.yaml`, which is the one live thing that says which build
|
||||
serves.
|
||||
|
||||
Coverage lives in `ci.yml`'s `test` job, which runs `pnpm test:coverage` so the
|
||||
lcov it uploads to Codecov actually exists. A separate `coverage.yml` used to run
|
||||
the same suite a second time for the same upload plus a PR comment through the
|
||||
GitHub API; one workflow does it now.
|
||||
|
||||
`registries.yml` keeps the `apps/v4` registry honest: reserved namespaces are
|
||||
rejected and `pnpm --filter=v4 validate:registries` must pass. Its other job
|
||||
labelled and commented on pull requests with the `gh` CLI against the GitHub API,
|
||||
which does not exist on the forge, so that job did not come along.
|
||||
|
||||
## Telemetry — `@hanzo/event` is the ONE client (`pkgs/event`)
|
||||
|
||||
`@hanzo/event` is the single canonical telemetry client for every Hanzo surface.
|
||||
ONE API surface over **TWO** planes — the client never sends the org; the server
|
||||
resolves the tenant.
|
||||
|
||||
1. **Event stream** — pageview/event/identify/group (and an error breadcrumb),
|
||||
batched to `POST {host}/v1/event` with `{ batch: [Event, …] }`
|
||||
`-> { accepted, dropped }`. Tenant from the session or a publishable `pk_` key.
|
||||
2. **Error plane** — every captured exception is ALSO framed as a real **Sentry
|
||||
envelope** and POSTed to `POST {dsn.origin}/v1/sentry/{projectId}/envelope/?sentry_key=…`.
|
||||
This is the ONLY thing that reaches the Sentry error dashboard.
|
||||
|
||||
> **There is NO server-side fan-out from `/v1/event` into Sentry.** Versions
|
||||
> ≤ 0.3.1 claimed there was ("lensed server-side into … error tracking"). There
|
||||
> is not: cloud's handler folds the exception into `properties.$exception`,
|
||||
> writes one row to the event warehouse (readable via `GET /v1/errors`), and
|
||||
> stops. Because every property believed that claim, the whole fleet reported
|
||||
> **zero** errors to Sentry until 0.3.2 added the envelope. Do not re-collapse
|
||||
> these planes.
|
||||
|
||||
Configure the error plane with `dsn` (or `NEXT_PUBLIC_HANZO_EVENT_DSN`), minted
|
||||
per property via `POST /v1/sentry/projects`. The DSN key is publishable and
|
||||
write-only — safe in a bundle, same trust class as `pk_`. **No DSN => the error
|
||||
plane is inert** (fail-safe: nothing sent, nothing thrown, event stream
|
||||
unaffected); assert `client.errorPlaneEnabled` if you need to know.
|
||||
|
||||
There is NO third plane. Web analytics used to be one — `analytics.hanzo.ai/hz.js`
|
||||
posting a bare JSON array of `{site, ts, type, …}` to a second collector behind an
|
||||
identical path spelling — and 0.3.7 deleted both. `hz.js` now lives in this package
|
||||
as the script-tag DISTRIBUTION of this client: same `WireEvent`, same
|
||||
`{ batch: [ … ] }`, same `POST {host}/v1/event`. Point everything at the API host.
|
||||
|
||||
It could not authenticate until 0.3.12: it sent no `Authorization` and no
|
||||
`?ingest_key=`, so a keyed static surface's writes were unattributed, the door
|
||||
refused them (`401`), and nothing in the page said so. `data-ingest-key="pk-…"`.
|
||||
|
||||
Entries: `.` (framework-agnostic: `createAnalytics`, `EVENTS`, `GOALS`,
|
||||
attribution + DSN/scrub helpers) and `./react` (`AnalyticsProvider`,
|
||||
`useAnalytics`, `usePageview`, `ErrorBoundary`). Auto error capture
|
||||
(window.onerror / unhandledrejection / React boundary) makes it the drop-in
|
||||
error-tracking replacement. Secrets and PII are scrubbed client-side before an
|
||||
error leaves the device. SSR-safe, fail-soft, beacon-on-unload.
|
||||
|
||||
Build is a tsup dual bundle: **CJS → `.cjs`, ESM → `.mjs`** (required under
|
||||
`"type": "module"` — a CJS `.js` is parsed as ESM and crashes `require()` with
|
||||
"exports is not defined"). Each `exports` condition carries its own types.
|
||||
|
||||
### Interaction analytics — `<Hanzo analytics>` is the one wiring
|
||||
|
||||
An app instruments nothing. `<Hanzo analytics>` (`pkg/ui/src/root.tsx`) is the
|
||||
whole setup, and every click / change / submit / route change inside the tree
|
||||
reaches `POST /v1/event` named by the component it happened on:
|
||||
|
||||
```tsx
|
||||
<Hanzo analytics={{ product: 'console', ingestKey: process.env.NEXT_PUBLIC_PUBLISHABLE_KEY }}>
|
||||
```
|
||||
|
||||
Four packages, one of each concern, no duplication:
|
||||
|
||||
| Concern | Where | Note |
|
||||
|---|---|---|
|
||||
| client + wire | `@hanzo/event` (`pkgs/event`) | one endpoint, one key |
|
||||
| capture engine | `@hanzo/observe` (`pkgs/observe`) | delegated listeners, semantic annotation, redaction |
|
||||
| provider + consent | `@hanzogui/telemetry` (`~/work/hanzo/gui`) | `<TelemetryProvider/>`; owns DNT/GPC + stored choice |
|
||||
| curated events | `@hanzo/ui/product` `instrument.ts` | `emit({component, action})` — what autocapture cannot know |
|
||||
|
||||
**`analytics` is a prop, not a default.** Mounting a component library must not
|
||||
start a network conversation the app did not ask for. Off, no provider renders.
|
||||
|
||||
**Component names are real in production.** Every primitive already carries a
|
||||
`data-slot` (via `slot()`); `componentName()` in `pkgs/observe/src/annotate.ts`
|
||||
reads it, ranked ABOVE the React fiber owner deliberately — the fiber name is
|
||||
dev-only, so grouping on it silently empties the dashboard at deploy. Labels keep
|
||||
the qualifier: `card/button[Save]`.
|
||||
|
||||
**It cannot double-count.** The engine installs *delegated* listeners on a root,
|
||||
so two engines on one root report everything twice — which is what an app got by
|
||||
mounting a library provider AND `<ObserveProvider/>`, both correct instructions.
|
||||
Since observe 0.1.7 the first engine claims its root under a `Symbol.for`
|
||||
registry (page-wide, so duplicate copies of the package still see each other) and
|
||||
any later one stays inert (`engine.capturing === false`). Verified in Chromium,
|
||||
not only in jsdom.
|
||||
|
||||
**Consent is decided in ONE layer.** The engine takes `enabled` as a value; the
|
||||
provider resolves policy (GPC, DNT, stored `hz_consent`, build kill switch) and
|
||||
passes the answer down. Do not add a second, partial copy to the engine — the two
|
||||
then disagree about an explicit opt-in.
|
||||
|
||||
### One way — supersessions (no divergent telemetry client)
|
||||
|
||||
| Package | Status | Note |
|
||||
|---|---|---|
|
||||
| `@hanzo/event` | **canonical** | `pkgs/event`, posts `/v1/event` only |
|
||||
| `@hanzo/capture` (npm) | **deprecated → `@hanzo/event`** | the old name of this package; `@hanzo/event` is a superset |
|
||||
| `pkgs/capture` (`@hanzo/analytics@0.1.0` dup) | **deleted** | stale in-repo duplicate, removed |
|
||||
| `hanzoai/analytics` `packages/event` (`@hanzo/event@0.2.0`) | **deleted** | An unpublished FORK of this package in another repo. It was the only copy that could actually reach Sentry, while the published one here could not — the fleet's error telemetry died in that gap. Its envelope + scrub implementation was merged here in 0.3.2. Never fork this package again; it publishes from `pkgs/event` only. |
|
||||
|
||||
## Three-Layer Architecture
|
||||
|
||||
1. **Components** (`registry/{style}/ui/`) — single primitives (Button, Card, Dialog). CLI-installable.
|
||||
2. **Examples** (`registry/{style}/example/`) — usage demos for docs via `<ComponentPreview />`.
|
||||
3. **Blocks** (`registry/{style}/blocks/`) — full-page sections (Dashboard, Login). NOT CLI-installable, docs only.
|
||||
1. **Components** (`registry/{style}/ui/`) -- Single primitives (Button, Card, Dialog). CLI-installable.
|
||||
2. **Examples** (`registry/{style}/example/`) -- Usage demos for docs via `<ComponentPreview />`.
|
||||
3. **Blocks** (`registry/{style}/blocks/`) -- Full-page sections (Dashboard, Login). NOT CLI-installable, docs only.
|
||||
|
||||
## Import Path Transformation
|
||||
|
||||
@@ -546,29 +78,28 @@ import { cn } from '@hanzo/ui/lib/utils'
|
||||
|
||||
## Tech Stack
|
||||
|
||||
React 19, Next.js 15.3+, Tailwind CSS 4 (OKLCH colors), Radix UI, Turborepo + pnpm, Fumadocs (MDX), class-variance-authority.
|
||||
|
||||
## Upstream Sync
|
||||
|
||||
Remote `shadcn` points to a local clone of shadcn-ui/ui.
|
||||
hanzoai/ui is NOT a GitHub fork — no shared object store, so large merges can fail on push.
|
||||
Strategy: file-level checkout from shadcn/main for specific directories (not git merge).
|
||||
- Take theirs: packages/shadcn/, packages/tests/, apps/, templates/, scripts/, skills/
|
||||
- Keep ours: app/, pkg/, demo/, docs/, template/next/, pnpm-workspace.yaml, package.json
|
||||
- Regenerate: pnpm-lock.yaml after sync
|
||||
React 18.3.1 (19 experimental), Next.js 15.3.1, Tailwind CSS (OKLCH colors), Radix UI, Turborepo + pnpm, Fumadocs (MDX), class-variance-authority.
|
||||
|
||||
## Key Features
|
||||
|
||||
- **Page Builder** (`/builder`): drag-drop block assembly with @dnd-kit, export to TSX
|
||||
- **Page Builder** (`/builder`): Drag-drop block assembly with @dnd-kit, export to TSX
|
||||
- **White-Label**: Zoo/Lux forks via `brands/{BRAND}.brand.ts`
|
||||
- **External Registries**: 35+ sources in `app/registries.json`, install via `npx @hanzo/ui add @aceternity/spotlight`
|
||||
|
||||
## Gotchas
|
||||
|
||||
- Registry index is `Index[style][name]`, NOT `Index[name]` — caused silent block render failures
|
||||
- Shiki `getHighlighter` incompatible with static export — replaced with basic pre/code
|
||||
- Registry index is `Index[style][name]`, NOT `Index[name]` -- caused silent block render failures
|
||||
- Shiki `getHighlighter` incompatible with static export -- replaced with basic pre/code
|
||||
- Some blocks (login-01, login-02, sidebar-02) have Server Component issues with event handlers
|
||||
- `@hanzo/auth` v2.6.0 uses a pluggable provider registry: `registerAuthProvider('firebase', FirebaseAuthService)`
|
||||
- Zod validation removed from `_getAllBlocks()`/`_getBlockCode()` -- we control generation
|
||||
- Firebase split to optional `@hanzo/auth-firebase` package (Jan 2025)
|
||||
- `@hanzo/auth` v2.6.0 uses pluggable provider registry: `registerAuthProvider('firebase', FirebaseAuthService)`
|
||||
|
||||
## Component Stats (2025-10-18)
|
||||
|
||||
- 161 total files, ~127 implemented, ~34 stubs
|
||||
- Unique: 9 3D components, 12 AI components, 13 animation components, 15 nav variants
|
||||
- 3x more components than upstream shadcn/ui (161 vs 58)
|
||||
|
||||
## Rules
|
||||
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
ui
|
||||
Copyright (c) 2023 Hanzo AI, Inc.
|
||||
|
||||
This product includes software from shadcn/ui (https://github.com/shadcn-ui/ui), licensed under MIT:
|
||||
|
||||
Copyright (c) 2023 shadcn
|
||||
+159
-24
@@ -1,39 +1,174 @@
|
||||
# Publishing
|
||||
# NPM Publishing Guide - React 19 Packages
|
||||
|
||||
Two lanes, one trigger each. No changesets, no version-PR bot.
|
||||
## Current Package Versions
|
||||
|
||||
## 1. `pkgs/*` — auto-publish on version bump (`publish.yml`)
|
||||
All packages updated to support **React 19.2.0**:
|
||||
|
||||
Bump a package's `version` in `pkgs/<name>/package.json` and merge to `main`.
|
||||
`.github/workflows/publish.yml` detects the changed public `@hanzo/*` package,
|
||||
builds it, and publishes to npm (repo secret `NPM_TOKEN`). Patch bumps only
|
||||
(`x.y.z` → `x.y.z+1`).
|
||||
- `@hanzo/ui` - v5.1.1
|
||||
- `@hanzo/auth` - Latest
|
||||
- `@hanzo/commerce` - Latest
|
||||
- `@hanzo/brand` - Latest
|
||||
- `@hanzo/react` - v1.0.0
|
||||
|
||||
## 2. `pkg/ui` — `@hanzo/ui@8`, the v8 lane (maintainer flow)
|
||||
## Publishing Methods
|
||||
|
||||
`pkg/ui` (with `pkg/data`) is the modern cross-platform library on `@hanzo/gui`.
|
||||
It publishes from the package directory (`prepack` builds the `types/`):
|
||||
### 1. Automatic Publishing (Tag-based)
|
||||
|
||||
When you push a git tag starting with `v` (typically matching @hanzo/ui version), the workflow automatically checks all packages and publishes any with new versions:
|
||||
|
||||
```bash
|
||||
# Tag with @hanzo/ui version (workflow checks all packages)
|
||||
git tag v5.1.1
|
||||
git push origin v5.1.1
|
||||
```
|
||||
|
||||
**What happens:**
|
||||
1. Tests run (pkg/ui and pkg/react)
|
||||
2. All 5 packages build
|
||||
3. **Automatic version detection:**
|
||||
- Checks each package's current version in package.json
|
||||
- Queries npm to see if that version already exists
|
||||
- Only publishes packages with new versions not on npm
|
||||
4. GitHub release created (only if packages were published)
|
||||
|
||||
**Example workflow output:**
|
||||
```
|
||||
📦 Checking @hanzo/ui@5.1.1
|
||||
⏭️ Already published - skipping
|
||||
|
||||
📦 Checking @hanzo/auth@2.5.5
|
||||
🚀 Publishing to npm...
|
||||
✅ Successfully published @hanzo/auth@2.5.5
|
||||
|
||||
📊 Publishing Summary
|
||||
✅ Published: 1 package(s)
|
||||
⏭️ Skipped: 4 package(s)
|
||||
```
|
||||
|
||||
This approach means you:
|
||||
- Only need to tag once (with @hanzo/ui version)
|
||||
- Don't need to track which packages need publishing
|
||||
- Can bump any package version and it auto-publishes on next tag
|
||||
- Similar to python-sdk monorepo publishing
|
||||
|
||||
**Workflow:** `.github/workflows/publish-on-tag.yml`
|
||||
|
||||
### 2. Manual Publishing (Workflow Dispatch)
|
||||
|
||||
Use GitHub Actions UI to manually publish specific packages:
|
||||
|
||||
1. Go to **Actions** → **NPM Publish**
|
||||
2. Click **Run workflow**
|
||||
3. Select package: `ui`, `auth`, `commerce`, `brand`, `react`, or `all`
|
||||
4. Select version bump: `patch`, `minor`, or `major`
|
||||
5. Click **Run workflow**
|
||||
|
||||
**What happens:**
|
||||
- Selected package(s) build
|
||||
- Version bumped automatically
|
||||
- Package(s) published to npm
|
||||
- PR created with version bump
|
||||
|
||||
**Workflow:** `.github/workflows/npm-publish.yml`
|
||||
|
||||
### 3. Local Publishing (Manual)
|
||||
|
||||
For quick patches or testing:
|
||||
|
||||
```bash
|
||||
# Build and test
|
||||
cd pkg/ui
|
||||
pnpm typecheck && pnpm test && pnpm build
|
||||
# bump "version" in package.json (patch), commit to main, then:
|
||||
pnpm build
|
||||
pnpm test
|
||||
|
||||
# Bump version
|
||||
npm version patch # or minor/major
|
||||
|
||||
# Publish
|
||||
npm publish --access public
|
||||
```
|
||||
|
||||
`@hanzo/ui-shadcn` (`pkgs/ui`) is the legacy v5 kit — existing consumers pin
|
||||
`@hanzo/ui-shadcn@^5`; it rides lane 1 like any other `pkgs/*` package.
|
||||
|
||||
> The old tag-driven flow (`publish-on-tag.yml`, `npm-publish.yml`, the
|
||||
> `pkg/commerce|brand|react` paths) is gone — do not tag to publish here.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- `NPM_TOKEN` repo secret (lane 1) / npm auth as a maintainer (lane 2)
|
||||
- Every package carries `"publishConfig": { "access": "public" }`
|
||||
### NPM Authentication Token
|
||||
|
||||
## Checklist
|
||||
The GitHub secret `NPM_AUTH_TOKEN` must be set:
|
||||
|
||||
- [ ] `pnpm typecheck` + `pnpm test` green in the package
|
||||
- [ ] Patch version bump (check the last published patch first)
|
||||
- [ ] Commit to `main`; lane 1 publishes on merge, lane 2 via `npm publish`
|
||||
1. Generate token at https://www.npmjs.com/settings/tokens
|
||||
2. Add to GitHub: Settings → Secrets → Actions → `NPM_AUTH_TOKEN`
|
||||
|
||||
### Package Publish Configuration
|
||||
|
||||
All packages already configured with:
|
||||
```json
|
||||
{
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"registry": "https://registry.npmjs.org/"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## React 19 Compatibility
|
||||
|
||||
### Key Updates Made
|
||||
|
||||
1. **Type Declarations**: Added `hanzo-ui.d.ts` files for React 19 compatibility
|
||||
2. **Peer Dependencies**: Force React 19.2.0 via pnpm overrides
|
||||
3. **Test Environment**: Switched to happy-dom for better React 19 support
|
||||
4. **Build Configuration**: All packages build successfully with React 19
|
||||
|
||||
### Test Status
|
||||
|
||||
- **pkg/ui**: 206/207 tests passing (99.5%)
|
||||
- **pkg/react**: 10/10 tests passing (100%)
|
||||
|
||||
## Publishing Checklist
|
||||
|
||||
Before publishing:
|
||||
|
||||
- [ ] All packages build successfully: `pnpm build`
|
||||
- [ ] Tests pass: `pnpm test`
|
||||
- [ ] Types check: `cd app && pnpm typecheck`
|
||||
- [ ] Lint passes: `cd app && pnpm lint`
|
||||
- [ ] Update CHANGELOG.md
|
||||
- [ ] Update version in package.json (if manual)
|
||||
- [ ] Commit changes
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Build Failures
|
||||
|
||||
```bash
|
||||
# Clean install
|
||||
rm -rf node_modules pnpm-lock.yaml
|
||||
pnpm install
|
||||
|
||||
# Rebuild
|
||||
pnpm build
|
||||
```
|
||||
|
||||
### Test Failures
|
||||
|
||||
```bash
|
||||
# Run specific package tests
|
||||
cd pkg/ui && pnpm test
|
||||
cd pkg/react && pnpm test
|
||||
```
|
||||
|
||||
### Publish Failures
|
||||
|
||||
- Check NPM_AUTH_TOKEN is valid
|
||||
- Ensure version is unique (not already published)
|
||||
- Verify package builds: `cd pkg/<name> && pnpm build`
|
||||
|
||||
## Package URLs
|
||||
|
||||
- npm: https://www.npmjs.com/org/hanzo
|
||||
- GitHub: https://github.com/hanzoai/ui
|
||||
- Docs: https://ui.hanzo.ai
|
||||
|
||||
---
|
||||
|
||||
**Last Updated:** 2025-10-05
|
||||
**React Version:** 19.2.0
|
||||
|
||||
@@ -1,42 +1,35 @@
|
||||
<p align="center"><img src=".github/hero.svg" alt="@hanzo/ui" width="880"></p>
|
||||
|
||||
# @hanzo/ui
|
||||
|
||||
**The React component library for AI applications.** Accessible, customizable primitives for React, Vue, Svelte, and React Native — built on shadcn/ui, extended with AI, 3D, animation, and commerce components, and a single typed import surface.
|
||||
|
||||
<p align="center">
|
||||
<a href="https://www.npmjs.com/package/@hanzo/ui"><img src="https://img.shields.io/npm/v/@hanzo/ui?color=black&label=%40hanzo%2Fui" alt="npm"></a>
|
||||
<a href="./LICENSE.md"><img src="https://img.shields.io/badge/license-MIT-black" alt="MIT"></a>
|
||||
<a href="https://ui.hanzo.ai"><img src="https://img.shields.io/badge/docs-ui.hanzo.ai-black" alt="docs"></a>
|
||||
</p>
|
||||
Accessible and customizable components for React, Vue, Svelte, and React Native. **Built on shadcn/ui with multi-framework support, 3D components, AI components, and advanced features.**
|
||||
|
||||

|
||||
|
||||
## Features
|
||||
|
||||
- **161+ components** — 3x the surface of upstream shadcn/ui
|
||||
- **Multi-framework** — React, Vue, Svelte, React Native
|
||||
- **Two themes** — Default & New York variants
|
||||
- **AI components** — chat, assistants, agent UI, playground
|
||||
- **3D components** — interactive 3D elements
|
||||
- **Animations** — advanced motion components
|
||||
- **Page builder** — visual drag-and-drop assembly, export to TSX
|
||||
- **Blocks** — 24+ production-ready full-page templates
|
||||
- **White-label** — fork and rebrand by domain (Zoo, Lux, …)
|
||||
- **Accessible** — built on Radix UI primitives
|
||||
- **Customizable** — Tailwind CSS 4 (OKLCH), fully typed TypeScript
|
||||
- **161+ Components** - 3x more than shadcn/ui
|
||||
- **Multi-Framework** - React, Vue, Svelte, React Native
|
||||
- **Two Themes** - Default & New York variants
|
||||
- **AI Components** - Chat, assistants, playground
|
||||
- **3D Components** - Interactive 3D elements
|
||||
- **Animations** - Advanced motion components
|
||||
- **Page Builder** - Visual drag-drop interface
|
||||
- **White-Label** - Fork and rebrand easily
|
||||
- **Blocks** - 24+ production-ready templates
|
||||
- **Accessible** - Built with Radix UI primitives
|
||||
- **Customizable** - Tailwind CSS powered
|
||||
- **TypeScript** - Fully typed
|
||||
|
||||
## Quick start
|
||||
## Quick Start
|
||||
|
||||
### Install
|
||||
### Installation
|
||||
|
||||
```bash
|
||||
pnpm add @hanzo/ui
|
||||
# or
|
||||
npm install @hanzo/ui
|
||||
# or
|
||||
pnpm add @hanzo/ui
|
||||
```
|
||||
|
||||
### Use
|
||||
### Usage
|
||||
|
||||
```tsx
|
||||
import { Button, Card, Input } from '@hanzo/ui'
|
||||
@@ -58,59 +51,23 @@ export function App() {
|
||||
}
|
||||
```
|
||||
|
||||
## One import surface (v8)
|
||||
## Documentation
|
||||
|
||||
`@hanzo/ui@8` is the single entry point for the whole kit. Each capability is a
|
||||
thin subpath that re-exports its home package — code lives once, and each home is
|
||||
an optional peer, pulled only when you use its subpath.
|
||||
|
||||
| Import | What you get |
|
||||
|---|---|
|
||||
| `@hanzo/ui` · `/product` | charts, metrics, PageHeader, StatusTag, EmptyState, ComboBox, SlideOver, Toast |
|
||||
| `@hanzo/ui/data` | RecordsView, DataTable, typed field editors |
|
||||
| `@hanzo/ui/canvas` | ProjectCanvas, ServiceNode, DeployTimeline, EnvSwitcher |
|
||||
| `@hanzo/ui/dashboard` | landing + deploy-pipeline + overview kit |
|
||||
| `@hanzo/ui/usage` | UsageMeter, UsageProviderCard, UsageDashboard |
|
||||
| `@hanzo/ui/gitops` | GitopsAppList, tree, diff, sync/rollback, HealthBadge |
|
||||
|
||||
Also available as granular imports:
|
||||
|
||||
```ts
|
||||
import { Button, Card } from '@hanzo/ui/components'
|
||||
import * as Dialog from '@hanzo/ui/primitives/dialog'
|
||||
import { cn } from '@hanzo/ui/lib/utils'
|
||||
```
|
||||
Visit **[ui.hanzo.ai](https://ui.hanzo.ai)** for full docs.
|
||||
|
||||
## CLI
|
||||
|
||||
Add components straight into your project — the CLI copies source you own:
|
||||
|
||||
```bash
|
||||
npx @hanzo/ui add button
|
||||
npx @hanzo/ui add card dialog
|
||||
```
|
||||
|
||||
Install from 35+ external registries too:
|
||||
|
||||
```bash
|
||||
npx @hanzo/ui add @aceternity/spotlight
|
||||
```
|
||||
|
||||
## Packages
|
||||
|
||||
The workspace publishes a family of scoped packages under `@hanzo/*`:
|
||||
|
||||
| Package | Purpose |
|
||||
|---|---|
|
||||
| `@hanzo/ui` | Core library + the v8 import surface (161+ components) |
|
||||
| `@hanzo/react` | React primitives |
|
||||
| `@hanzo/data` | Records, data tables, typed field editors |
|
||||
| `@hanzo/canvas` | Service/deploy canvas components |
|
||||
| `@hanzo/dashboard` | Dashboard + deploy-pipeline kit |
|
||||
| `@hanzo/commerce` · `@hanzo/checkout` · `@hanzo/shop` | Commerce components |
|
||||
| `@hanzo/agent-ui` | AI agent UI components |
|
||||
| `@hanzo/brand` · `@hanzo/tokens` | Branding system & design tokens |
|
||||
| `@hanzo/event` | Telemetry client (`POST /v1/event`) |
|
||||
- `@hanzo/ui` - Main UI library (161 components)
|
||||
- `@hanzo/auth` - Authentication components
|
||||
- `@hanzo/commerce` - E-commerce components
|
||||
- `@hanzo/brand` - Branding system
|
||||
|
||||
## Development
|
||||
|
||||
@@ -118,39 +75,17 @@ The workspace publishes a family of scoped packages under `@hanzo/*`:
|
||||
git clone https://github.com/hanzoai/ui.git
|
||||
cd ui
|
||||
pnpm install
|
||||
|
||||
pnpm build:registry # generate the component registry FIRST
|
||||
pnpm dev # docs site + registry (http://localhost:3003)
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
> The registry generates the JSON the CLI reads, so `build:registry` must run
|
||||
> before `build`. Keep the Default and New York themes in sync when adding
|
||||
> components. Use pnpm — not npm or yarn.
|
||||
|
||||
```bash
|
||||
pnpm build # build the docs app
|
||||
pnpm lint # lint all workspaces
|
||||
pnpm typecheck # type check
|
||||
pnpm test # unit tests
|
||||
pnpm test:e2e # Playwright E2E
|
||||
```
|
||||
|
||||
## Documentation
|
||||
|
||||
Full docs, live previews, and the component catalog: **[ui.hanzo.ai](https://ui.hanzo.ai)**.
|
||||
|
||||
## Contributing
|
||||
|
||||
See the [contributing guide](/CONTRIBUTING.md).
|
||||
Please read the [contributing guide](/CONTRIBUTING.md).
|
||||
|
||||
## License
|
||||
|
||||
MIT — see [LICENSE.md](./LICENSE.md).
|
||||
MIT - See [LICENSE.md](./LICENSE.md) for details.
|
||||
|
||||
---
|
||||
|
||||
## Hanzo — the Open AI Cloud
|
||||
|
||||
Open source · every language · on-chain settlement. [hanzo.ai](https://hanzo.ai) · [docs.hanzo.ai](https://docs.hanzo.ai)
|
||||
|
||||
**SDKs in every language** — [Python](https://github.com/hanzoai/python-sdk) (flagship) · [TypeScript](https://github.com/hanzo-js/sdk) · [Go](https://github.com/hanzo-go/sdk) · [Rust](https://github.com/hanzo-rs/sdk) · [C++](https://github.com/hanzo-cpp/sdk) · [Swift](https://github.com/hanzo-swift/sdk) · [Kotlin](https://github.com/hanzo-kt/sdk) · [umbrella](https://github.com/hanzoai/sdk)
|
||||
Built by [Hanzo](https://hanzo.ai)
|
||||
|
||||
+1
-1
@@ -6,4 +6,4 @@ We will investigate all legitimate reports and do our best to quickly fix the pr
|
||||
|
||||
Our preference is that you make use of GitHub's private vulnerability reporting feature to disclose potential security vulnerabilities in our Open Source Software.
|
||||
|
||||
To do this, please visit the security tab of the repository and click the [Report a vulnerability](https://github.com/shadcn-ui/ui/security/advisories/new) button.
|
||||
To do this, please visit the security tab of the repository and click the "Report a vulnerability" button.
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
node_modules/
|
||||
.next/
|
||||
out/
|
||||
build/
|
||||
dist/
|
||||
next-env.d.ts
|
||||
__registry__/
|
||||
.source/
|
||||
*.config.js
|
||||
*.config.mjs
|
||||
.turbo/
|
||||
coverage/
|
||||
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"env": {
|
||||
"browser": true,
|
||||
"es2021": true,
|
||||
"node": true
|
||||
},
|
||||
"parser": "@typescript-eslint/parser",
|
||||
"parserOptions": {
|
||||
"ecmaVersion": "latest",
|
||||
"sourceType": "module",
|
||||
"ecmaFeatures": {
|
||||
"jsx": true
|
||||
}
|
||||
},
|
||||
"plugins": ["@typescript-eslint", "react"],
|
||||
"ignorePatterns": [
|
||||
"__registry__/**",
|
||||
".source/**",
|
||||
".next/**",
|
||||
"out/**",
|
||||
"build/**",
|
||||
"dist/**",
|
||||
".turbo/**",
|
||||
"node_modules/**"
|
||||
],
|
||||
"rules": {
|
||||
"@typescript-eslint/no-explicit-any": "warn",
|
||||
"@typescript-eslint/no-empty-object-type": "warn",
|
||||
"@typescript-eslint/ban-ts-comment": "warn",
|
||||
"@typescript-eslint/no-unused-vars": "warn",
|
||||
"@typescript-eslint/no-require-imports": "warn",
|
||||
"@typescript-eslint/no-this-alias": "warn",
|
||||
"@typescript-eslint/triple-slash-reference": "warn",
|
||||
"@typescript-eslint/ban-types": "off",
|
||||
"react/no-unescaped-entities": "warn",
|
||||
"react/jsx-no-comment-textnodes": "warn",
|
||||
"react/no-find-dom-node": "warn",
|
||||
"prefer-const": "warn"
|
||||
}
|
||||
}
|
||||
@@ -94,7 +94,7 @@ forge script script/DeployIdentitySystem.s.sol:DeployIdentitySystem \
|
||||
|
||||
# For Lux Mainnet
|
||||
forge script script/DeployIdentitySystem.s.sol:DeployIdentitySystem \
|
||||
--rpc-url https://api.lux.network/v1/bc/C/rpc \
|
||||
--rpc-url https://api.lux.network/ext/bc/C/rpc \
|
||||
--broadcast
|
||||
|
||||
# For Zoo Mainnet
|
||||
@@ -131,8 +131,8 @@ Visit `http://localhost:3333/identity` to access the identity registration page.
|
||||
|---------|----------|---------|----------|
|
||||
| Hanzo Mainnet | 36963 | https://rpc.hanzo.ai | https://explorer.hanzo.ai |
|
||||
| Hanzo Testnet | 36962 | https://testnet-rpc.hanzo.ai | https://testnet-explorer.hanzo.ai |
|
||||
| Lux Mainnet | 96369 | https://api.lux.network/v1/bc/C/rpc | https://explorer.lux.network |
|
||||
| Lux Testnet | 96368 | https://testnet-api.lux.network/v1/bc/C/rpc | https://testnet-explorer.lux.network |
|
||||
| Lux Mainnet | 96369 | https://api.lux.network/ext/bc/C/rpc | https://explorer.lux.network |
|
||||
| Lux Testnet | 96368 | https://testnet-api.lux.network/ext/bc/C/rpc | https://testnet-explorer.lux.network |
|
||||
| Zoo Mainnet | 200200 | https://rpc.zoo.network | https://explorer.zoo.network |
|
||||
| Zoo Testnet | 200201 | https://testnet-rpc.zoo.network | https://testnet-explorer.zoo.network |
|
||||
|
||||
|
||||
@@ -42,8 +42,8 @@ describe("@hanzo/ui namespace imports - Type Resolution", () => {
|
||||
* import { GridPattern } from '@hanzo/ui/pattern/grid'
|
||||
* ```
|
||||
*
|
||||
* The package exports are properly configured in pkgs/ui/package.json.
|
||||
* Build artifacts exist in pkgs/ui/dist/{code,3d,pattern}/.
|
||||
* The package exports are properly configured in pkg/ui/package.json.
|
||||
* Build artifacts exist in pkg/ui/dist/{code,3d,pattern}/.
|
||||
*
|
||||
* Vitest runtime resolution fails due to internal cross-package imports
|
||||
* (e.g., CodeBlock imports from @hanzo/ui/lib/utils), which require
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,427 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import dynamic from "next/dynamic"
|
||||
import { Index } from "@/__registry__"
|
||||
import {
|
||||
closestCenter,
|
||||
DndContext,
|
||||
DragEndEvent,
|
||||
DragOverlay,
|
||||
DragStartEvent,
|
||||
} from "@dnd-kit/core"
|
||||
import {
|
||||
arrayMove,
|
||||
SortableContext,
|
||||
useSortable,
|
||||
verticalListSortingStrategy,
|
||||
} from "@dnd-kit/sortable"
|
||||
import { CSS } from "@dnd-kit/utilities"
|
||||
import { Download, Eye, GripVertical, Plus, Trash2 } from "lucide-react"
|
||||
|
||||
import { Button } from "@/registry/new-york/ui/button"
|
||||
import { Card } from "@/registry/new-york/ui/card"
|
||||
import { Input } from "@/registry/new-york/ui/input"
|
||||
import { ScrollArea } from "@/registry/new-york/ui/scroll-area"
|
||||
import { Separator } from "@/registry/new-york/ui/separator"
|
||||
|
||||
// Dynamic block component loader
|
||||
const DynamicBlock = ({ blockName, scale = 1 }: { blockName: string; scale?: number }) => {
|
||||
const [BlockComponent, setBlockComponent] = React.useState<React.ComponentType | null>(null)
|
||||
const [error, setError] = React.useState(false)
|
||||
|
||||
React.useEffect(() => {
|
||||
import(`@/registry/default/block/${blockName}`)
|
||||
.then((mod) => {
|
||||
setBlockComponent(() => mod.default)
|
||||
setError(false)
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(`Failed to load block ${blockName}:`, err)
|
||||
setError(true)
|
||||
})
|
||||
}, [blockName])
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center bg-muted/50 p-4 text-center">
|
||||
<p className="text-xs text-muted-foreground">Failed to load {blockName}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!BlockComponent) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center bg-muted/50">
|
||||
<div className="h-4 w-4 animate-spin rounded-full border-2 border-primary border-t-transparent" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ transform: `scale(${scale})`, transformOrigin: "top left" }}>
|
||||
<BlockComponent />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface PageBlock {
|
||||
id: string
|
||||
blockName: string
|
||||
}
|
||||
|
||||
export default function PageBuilder() {
|
||||
const [blocks, setBlocks] = React.useState<string[]>([])
|
||||
const [availableBlocks, setAvailableBlocks] = React.useState<string[]>([])
|
||||
const [pageBlocks, setPageBlocks] = React.useState<PageBlock[]>([])
|
||||
const [activeId, setActiveId] = React.useState<string | null>(null)
|
||||
const [filter, setFilter] = React.useState("")
|
||||
const [viewport, setViewport] = React.useState<"desktop" | "tablet" | "mobile">("desktop")
|
||||
|
||||
React.useEffect(() => {
|
||||
// Get block IDs from the registry index
|
||||
const blockIds = Object.keys(Index.default || {}).filter((key) => {
|
||||
const item = Index.default[key]
|
||||
return item?.type === "components:block"
|
||||
})
|
||||
setBlocks(blockIds)
|
||||
setAvailableBlocks(blockIds)
|
||||
}, [])
|
||||
|
||||
const filteredBlocks = availableBlocks.filter((block) =>
|
||||
block.toLowerCase().includes(filter.toLowerCase())
|
||||
)
|
||||
|
||||
const addBlock = (blockName: string) => {
|
||||
setPageBlocks([...pageBlocks, { id: crypto.randomUUID(), blockName }])
|
||||
}
|
||||
|
||||
const removeBlock = (id: string) => {
|
||||
setPageBlocks(pageBlocks.filter((b) => b.id !== id))
|
||||
}
|
||||
|
||||
const handleDragStart = (event: DragStartEvent) => {
|
||||
setActiveId(event.active.id as string)
|
||||
}
|
||||
|
||||
const handleDragEnd = (event: DragEndEvent) => {
|
||||
const { active, over } = event
|
||||
setActiveId(null)
|
||||
|
||||
if (!over || active.id === over.id) return
|
||||
|
||||
setPageBlocks((items) => {
|
||||
const oldIndex = items.findIndex((item) => item.id === active.id)
|
||||
const newIndex = items.findIndex((item) => item.id === over.id)
|
||||
return arrayMove(items, oldIndex, newIndex)
|
||||
})
|
||||
}
|
||||
|
||||
const generatePageCode = () => {
|
||||
const imports = pageBlocks
|
||||
.map((block) => `import ${toPascalCase(block.blockName)} from "@/registry/default/block/${block.blockName}"`)
|
||||
.join("\n")
|
||||
|
||||
const components = pageBlocks
|
||||
.map((block) => ` <${toPascalCase(block.blockName)} />`)
|
||||
.join("\n")
|
||||
|
||||
return `"use client"
|
||||
|
||||
import * as React from "react"
|
||||
${imports}
|
||||
|
||||
export default function CustomPage() {
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col">
|
||||
${components}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
`
|
||||
}
|
||||
|
||||
const copyCode = async () => {
|
||||
const code = generatePageCode()
|
||||
await navigator.clipboard.writeText(code)
|
||||
// TODO: Show toast notification
|
||||
}
|
||||
|
||||
const downloadCode = () => {
|
||||
const code = generatePageCode()
|
||||
const blob = new Blob([code], { type: "text/typescript" })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement("a")
|
||||
a.href = url
|
||||
a.download = "page.tsx"
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
const deployWithHanzo = () => {
|
||||
// TODO: Integrate with Hanzo deployment API
|
||||
const code = generatePageCode()
|
||||
console.log("Deploying with Hanzo:", code)
|
||||
// This would call hanzo deployment service
|
||||
window.open("https://hanzo.ai/deploy", "_blank")
|
||||
}
|
||||
|
||||
const toPascalCase = (str: string) => {
|
||||
return str
|
||||
.split("-")
|
||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join("")
|
||||
}
|
||||
|
||||
const viewportWidths = {
|
||||
desktop: "100%",
|
||||
tablet: "768px",
|
||||
mobile: "375px",
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-screen max-h-screen gap-4 p-6">
|
||||
{/* Left Sidebar - Block Library */}
|
||||
<div className="w-64 space-y-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">Block Library</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Drag blocks to build your page
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Input
|
||||
placeholder="Filter blocks..."
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.target.value)}
|
||||
/>
|
||||
|
||||
<ScrollArea className="h-[calc(100vh-200px)]">
|
||||
<div className="space-y-4">
|
||||
{filteredBlocks.map((block) => (
|
||||
<Card
|
||||
key={block}
|
||||
className="cursor-grab overflow-hidden transition-colors hover:bg-muted"
|
||||
onClick={() => addBlock(block)}
|
||||
>
|
||||
{/* 1/4 Scale Block Preview */}
|
||||
<div className="relative h-32 overflow-hidden bg-muted/50">
|
||||
<div className="pointer-events-none">
|
||||
<DynamicBlock blockName={block} scale={0.25} />
|
||||
</div>
|
||||
{/* Overlay with block name and add button */}
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-background/0 opacity-0 transition-opacity hover:bg-background/80 hover:opacity-100">
|
||||
<div className="flex items-center gap-2">
|
||||
<Plus className="h-5 w-5" />
|
||||
<span className="text-sm font-medium">Add to page</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-t p-2">
|
||||
<p className="truncate text-xs font-medium">{block}</p>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
|
||||
<Separator orientation="vertical" />
|
||||
|
||||
{/* Center - Page Builder Canvas */}
|
||||
<div className="flex-1 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">Page Builder</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{pageBlocks.length} blocks in page
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Viewport Controls */}
|
||||
<div className="flex rounded-lg border">
|
||||
<Button
|
||||
variant={viewport === "mobile" ? "default" : "ghost"}
|
||||
size="sm"
|
||||
onClick={() => setViewport("mobile")}
|
||||
className="rounded-r-none"
|
||||
>
|
||||
Mobile
|
||||
</Button>
|
||||
<Button
|
||||
variant={viewport === "tablet" ? "default" : "ghost"}
|
||||
size="sm"
|
||||
onClick={() => setViewport("tablet")}
|
||||
className="rounded-none border-x"
|
||||
>
|
||||
Tablet
|
||||
</Button>
|
||||
<Button
|
||||
variant={viewport === "desktop" ? "default" : "ghost"}
|
||||
size="sm"
|
||||
onClick={() => setViewport("desktop")}
|
||||
className="rounded-l-none"
|
||||
>
|
||||
Desktop
|
||||
</Button>
|
||||
</div>
|
||||
<Separator orientation="vertical" className="h-8" />
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={copyCode}
|
||||
disabled={pageBlocks.length === 0}
|
||||
>
|
||||
<svg
|
||||
className="mr-2 h-4 w-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<rect width="13" height="13" x="9" y="9" rx="2" ry="2" />
|
||||
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
|
||||
</svg>
|
||||
Copy Code
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={downloadCode}
|
||||
disabled={pageBlocks.length === 0}
|
||||
>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
Download
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={deployWithHanzo}
|
||||
disabled={pageBlocks.length === 0}
|
||||
>
|
||||
<svg
|
||||
className="mr-2 h-4 w-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
|
||||
</svg>
|
||||
Deploy with Hanzo
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ScrollArea className="h-[calc(100vh-140px)] rounded-lg border bg-background">
|
||||
<div className="flex min-h-full items-start justify-center p-4">
|
||||
<div
|
||||
style={{
|
||||
width: viewportWidths[viewport],
|
||||
maxWidth: "100%",
|
||||
transition: "width 0.3s ease",
|
||||
}}
|
||||
>
|
||||
<DndContext
|
||||
collisionDetection={closestCenter}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<SortableContext
|
||||
items={pageBlocks.map((b) => b.id)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
<div className="min-h-[600px] bg-background">
|
||||
{pageBlocks.length === 0 ? (
|
||||
<div className="flex h-96 items-center justify-center rounded-lg border border-dashed text-center">
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Your page is empty
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Click blocks from the left to add them
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
pageBlocks.map((block) => (
|
||||
<SortableBlock
|
||||
key={block.id}
|
||||
id={block.id}
|
||||
blockName={block.blockName}
|
||||
onRemove={() => removeBlock(block.id)}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</SortableContext>
|
||||
|
||||
<DragOverlay>
|
||||
{activeId ? (
|
||||
<div className="rounded-lg border bg-card p-4 shadow-lg">
|
||||
<p className="text-sm font-medium">
|
||||
{pageBlocks.find((b) => b.id === activeId)?.blockName}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
</DragOverlay>
|
||||
</DndContext>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SortableBlock({
|
||||
id,
|
||||
blockName,
|
||||
onRemove,
|
||||
}: {
|
||||
id: string
|
||||
blockName: string
|
||||
onRemove: () => void
|
||||
}) {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({ id })
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
opacity: isDragging ? 0.5 : 1,
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={setNodeRef} style={style} className="group relative">
|
||||
<div className="absolute -left-12 top-2 z-10 flex flex-col items-center gap-2">
|
||||
<button
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
className="cursor-grab rounded bg-card p-1 shadow-sm hover:shadow active:cursor-grabbing"
|
||||
>
|
||||
<GripVertical className="h-4 w-4 text-muted-foreground" />
|
||||
</button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onRemove}
|
||||
className="h-6 w-6 bg-background/80 opacity-0 backdrop-blur transition-opacity group-hover:opacity-100"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Block Preview - No gaps between blocks */}
|
||||
<div className="relative overflow-hidden border-b last:border-b-0">
|
||||
<DynamicBlock blockName={blockName} scale={1} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { ConnectButton } from "@rainbow-me/rainbowkit"
|
||||
import { formatEther } from "viem"
|
||||
import {
|
||||
useAccount,
|
||||
@@ -10,7 +11,6 @@ import {
|
||||
useWriteContract,
|
||||
} from "wagmi"
|
||||
|
||||
import { ConnectWallet } from "@/components/connect-wallet"
|
||||
import {
|
||||
AI_TOKEN_ABI,
|
||||
CONTRACT_ADDRESSES,
|
||||
@@ -208,7 +208,7 @@ export function IdentityForm() {
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ConnectWallet />
|
||||
<ConnectButton />
|
||||
|
||||
{isConnected && aiBalance !== undefined && (
|
||||
<div className="mt-4">
|
||||
|
||||
@@ -304,7 +304,7 @@ export default function MCPPage() {
|
||||
</p>
|
||||
<div className="flex gap-4">
|
||||
<Button asChild>
|
||||
<Link href="https://discord.gg/CJCyAsm9Vr">Join Discord</Link>
|
||||
<Link href="https://discord.gg/hanzo">Join Discord</Link>
|
||||
</Button>
|
||||
<Button variant="outline" asChild>
|
||||
<Link href="/docs">Read Documentation</Link>
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
/**
|
||||
* POST /api/chat — AI chat about UI components.
|
||||
*
|
||||
* Uses Hanzo AI (zen-coder-flash) to answer questions about
|
||||
* Hanzo UI components, props, patterns, and composition.
|
||||
* Compatible with Vercel AI SDK useChat hook.
|
||||
*/
|
||||
|
||||
import { NextResponse } from 'next/server'
|
||||
|
||||
export const dynamic = "force-static"
|
||||
|
||||
export async function GET() {
|
||||
return NextResponse.json(
|
||||
{ error: 'AI chat requires a server runtime. Use api.hanzo.ai/v1 directly.' },
|
||||
{ status: 501 },
|
||||
)
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
/**
|
||||
* GET /api/registry/components/:name — Get component with full source.
|
||||
*
|
||||
* Returns the component JSON including embedded source code.
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
import { getComponent, getComponentMap } from "../../lib"
|
||||
|
||||
export const dynamic = "force-static"
|
||||
export const dynamicParams = false
|
||||
|
||||
export function generateStaticParams() {
|
||||
const map = getComponentMap()
|
||||
return Array.from(map.keys()).map((name) => ({ name }))
|
||||
}
|
||||
|
||||
export async function GET(
|
||||
_req: NextRequest,
|
||||
{ params }: { params: Promise<{ name: string }> }
|
||||
) {
|
||||
const { name } = await params
|
||||
const component = getComponent(name)
|
||||
|
||||
if (!component) {
|
||||
return NextResponse.json(
|
||||
{ error: "Component not found", name },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json(component, {
|
||||
headers: {
|
||||
"Cache-Control": "public, s-maxage=900, stale-while-revalidate=1800",
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
/**
|
||||
* GET /api/registry/index — Full registry manifest.
|
||||
*
|
||||
* Returns ALL components with source in a single payload.
|
||||
* MCP clients can hydrate their cache with one HTTP call.
|
||||
*/
|
||||
|
||||
import { NextResponse } from "next/server"
|
||||
import { getFullManifest } from "../lib"
|
||||
|
||||
export const dynamic = "force-static"
|
||||
|
||||
export async function GET() {
|
||||
const manifest = getFullManifest()
|
||||
|
||||
return NextResponse.json(manifest, {
|
||||
headers: {
|
||||
"Cache-Control": "public, s-maxage=900, stale-while-revalidate=1800",
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -1,117 +0,0 @@
|
||||
/**
|
||||
* Shared registry data loader for API routes.
|
||||
*
|
||||
* Reads from the built registry JSON files in public/registry/.
|
||||
* These are generated at build time by scripts/build-registry.mts.
|
||||
*/
|
||||
|
||||
import { readFileSync, readdirSync, existsSync } from "fs"
|
||||
import path from "path"
|
||||
|
||||
const REGISTRY_DIR = path.join(process.cwd(), "public/registry")
|
||||
const STYLES_DIR = path.join(REGISTRY_DIR, "styles/default")
|
||||
|
||||
export interface RegistryItem {
|
||||
name: string
|
||||
type: string
|
||||
dependencies?: string[]
|
||||
devDependencies?: string[]
|
||||
registryDependencies?: string[]
|
||||
files: Array<{ name: string; content: string } | string>
|
||||
description?: string
|
||||
category?: string
|
||||
}
|
||||
|
||||
// In-memory cache (populated on first access, lives for the server lifetime)
|
||||
let _index: RegistryItem[] | null = null
|
||||
let _components: Map<string, RegistryItem> | null = null
|
||||
|
||||
function loadIndex(): RegistryItem[] {
|
||||
if (_index) return _index
|
||||
const indexPath = path.join(REGISTRY_DIR, "index.json")
|
||||
if (!existsSync(indexPath)) return []
|
||||
_index = JSON.parse(readFileSync(indexPath, "utf-8")) as RegistryItem[]
|
||||
return _index
|
||||
}
|
||||
|
||||
function loadComponents(): Map<string, RegistryItem> {
|
||||
if (_components) return _components
|
||||
_components = new Map()
|
||||
|
||||
if (!existsSync(STYLES_DIR)) return _components
|
||||
|
||||
const files = readdirSync(STYLES_DIR).filter((f) => f.endsWith(".json"))
|
||||
for (const file of files) {
|
||||
try {
|
||||
const data = JSON.parse(
|
||||
readFileSync(path.join(STYLES_DIR, file), "utf-8")
|
||||
) as RegistryItem
|
||||
_components.set(data.name, data)
|
||||
} catch {
|
||||
// skip malformed files
|
||||
}
|
||||
}
|
||||
return _components
|
||||
}
|
||||
|
||||
/** Get the full component index (names, types, deps — no source). */
|
||||
export function getIndex(): RegistryItem[] {
|
||||
return loadIndex()
|
||||
}
|
||||
|
||||
/** Get all components with full source code. */
|
||||
export function getComponentMap(): Map<string, RegistryItem> {
|
||||
return loadComponents()
|
||||
}
|
||||
|
||||
/** Get a single component by name (with source). */
|
||||
export function getComponent(name: string): RegistryItem | undefined {
|
||||
const map = loadComponents()
|
||||
// Try exact match first
|
||||
let item = map.get(name)
|
||||
if (item) return item
|
||||
|
||||
// Try with -demo suffix stripped
|
||||
item = map.get(`${name}-demo`)
|
||||
return item
|
||||
}
|
||||
|
||||
/** Search components by name/type. */
|
||||
export function searchComponents(query: string): RegistryItem[] {
|
||||
const q = query.toLowerCase()
|
||||
const index = loadIndex()
|
||||
return index.filter(
|
||||
(item) =>
|
||||
item.name.toLowerCase().includes(q) ||
|
||||
item.type?.toLowerCase().includes(q) ||
|
||||
item.description?.toLowerCase().includes(q) ||
|
||||
item.category?.toLowerCase().includes(q)
|
||||
)
|
||||
}
|
||||
|
||||
/** List components filtered by type. */
|
||||
export function listByType(type?: string): RegistryItem[] {
|
||||
const index = loadIndex()
|
||||
if (!type) return index
|
||||
return index.filter((item) => item.type === type || item.type?.includes(type))
|
||||
}
|
||||
|
||||
/** Get full registry manifest (all components with source — single payload). */
|
||||
export function getFullManifest() {
|
||||
const map = loadComponents()
|
||||
const components: Record<string, any> = {}
|
||||
for (const [name, item] of map) {
|
||||
components[name] = item
|
||||
}
|
||||
return {
|
||||
generated_at: Date.now(),
|
||||
total: map.size,
|
||||
components,
|
||||
}
|
||||
}
|
||||
|
||||
/** Invalidate the in-memory cache (call after registry:build). */
|
||||
export function invalidateCache() {
|
||||
_index = null
|
||||
_components = null
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
/**
|
||||
* GET /api/registry/search?q=button — Search components.
|
||||
*/
|
||||
|
||||
import { NextResponse } from "next/server"
|
||||
import { getIndex } from "../lib"
|
||||
|
||||
export const dynamic = "force-static"
|
||||
|
||||
export async function GET() {
|
||||
// Static export: return full index (client-side filtering)
|
||||
const items = getIndex()
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
total: items.length,
|
||||
results: items.map((item) => ({
|
||||
name: item.name,
|
||||
type: item.type,
|
||||
dependencies: item.dependencies,
|
||||
})),
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
"Cache-Control": "public, s-maxage=300, stale-while-revalidate=600",
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
/**
|
||||
* POST /api/search — Search UI components via Hanzo Cloud.
|
||||
*
|
||||
* Proxies to Hanzo Cloud search-docs API with the publishable key.
|
||||
* Client-side code hits this route instead of Cloud directly.
|
||||
*/
|
||||
|
||||
import { NextResponse } from 'next/server'
|
||||
|
||||
export const dynamic = "force-static"
|
||||
|
||||
export async function GET() {
|
||||
return NextResponse.json(
|
||||
{ error: 'Search requires a server runtime. Use client-side search.' },
|
||||
{ status: 501 },
|
||||
)
|
||||
}
|
||||
@@ -1,21 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useRef } from "react"
|
||||
import { usePathname } from "next/navigation"
|
||||
|
||||
import { analytics } from "@/lib/analytics"
|
||||
import { Analytics as VercelAnalytics } from "@vercel/analytics/react"
|
||||
|
||||
export function Analytics() {
|
||||
const pathname = usePathname()
|
||||
const started = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!started.current) {
|
||||
started.current = true
|
||||
analytics.init()
|
||||
}
|
||||
analytics.pageview(pathname ?? undefined)
|
||||
}, [pathname])
|
||||
|
||||
return null
|
||||
return <VercelAnalytics />
|
||||
}
|
||||
|
||||
@@ -62,15 +62,7 @@ export function CommandMenu({ ...props }: DialogProps) {
|
||||
const { recentSearches, addRecentSearch, clearRecentSearches } =
|
||||
useRecentSearches()
|
||||
|
||||
// SSR renders without `window`, so the first client (hydration) render MUST
|
||||
// also produce "Ctrl" — computing the platform-specific glyph during render
|
||||
// makes the hydrated text ("⌘" on macOS) diverge from the server text
|
||||
// ("Ctrl") → React #418 (hydration text mismatch). Start from the SSR-stable
|
||||
// value and upgrade to the platform glyph after mount (client-only effect).
|
||||
const [modKey, setModKey] = React.useState("Ctrl")
|
||||
React.useEffect(() => {
|
||||
if (isMacOS()) setModKey("⌘")
|
||||
}, [])
|
||||
const modKey = React.useMemo(() => (isMacOS() ? "⌘" : "Ctrl"), [])
|
||||
|
||||
React.useEffect(() => {
|
||||
const down = (e: KeyboardEvent) => {
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { useAccount, useConnect, useDisconnect } from "wagmi"
|
||||
|
||||
import { Button } from "@/registry/default/ui/button"
|
||||
|
||||
const short = (address: string) => `${address.slice(0, 6)}…${address.slice(-4)}`
|
||||
|
||||
/**
|
||||
* Connect the wallet the browser already has, over EIP-1193. There is no
|
||||
* third-party modal and no bridge service in the page — the extension is the
|
||||
* only party involved.
|
||||
*/
|
||||
export function ConnectWallet() {
|
||||
const { address, isConnected } = useAccount()
|
||||
const { connect, connectors, isPending } = useConnect()
|
||||
const { disconnect } = useDisconnect()
|
||||
|
||||
if (isConnected && address) {
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="font-mono text-sm">{short(address)}</span>
|
||||
<Button variant="outline" size="sm" onClick={() => disconnect()}>
|
||||
Disconnect
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const injected = connectors[0]
|
||||
|
||||
return (
|
||||
<Button
|
||||
onClick={() => injected && connect({ connector: injected })}
|
||||
disabled={!injected || isPending}
|
||||
>
|
||||
{isPending ? "Connecting…" : "Connect Wallet"}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
@@ -1,18 +1,34 @@
|
||||
"use client"
|
||||
|
||||
import { type ReactNode } from "react"
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
|
||||
import { WagmiProvider } from "wagmi"
|
||||
import "@rainbow-me/rainbowkit/styles.css"
|
||||
|
||||
import { getConfig } from "@/lib/wagmi"
|
||||
import { useEffect, useState, type ReactNode } from "react"
|
||||
import { RainbowKitProvider } from "@rainbow-me/rainbowkit"
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
|
||||
import { WagmiProvider, type Config } from "wagmi"
|
||||
|
||||
const queryClient = new QueryClient()
|
||||
const config = getConfig()
|
||||
|
||||
export function Web3Provider({ children }: { children: ReactNode }) {
|
||||
const [config, setConfig] = useState<Config | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
// Dynamically import wagmi config only on client side
|
||||
import("@/lib/wagmi").then((mod) => {
|
||||
setConfig(mod.getConfig())
|
||||
})
|
||||
}, [])
|
||||
|
||||
// Don't render until config is loaded
|
||||
if (!config) {
|
||||
return <>{children}</>
|
||||
}
|
||||
|
||||
return (
|
||||
<WagmiProvider config={config}>
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<RainbowKitProvider modalSize="compact">{children}</RainbowKitProvider>
|
||||
</QueryClientProvider>
|
||||
</WagmiProvider>
|
||||
)
|
||||
}
|
||||
|
||||
+1
-1
@@ -8,7 +8,7 @@ export const siteConfig = {
|
||||
links: {
|
||||
twitter: "https://x.com/hanzoai",
|
||||
github: "https://github.com/hanzoai/ui",
|
||||
discord: "https://discord.gg/CJCyAsm9Vr",
|
||||
discord: "https://discord.gg/hanzo",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
---
|
||||
title: Stock Market
|
||||
description: Display real-time stock market overview with indices and major stocks.
|
||||
component: true
|
||||
---
|
||||
|
||||
<ComponentPreview
|
||||
name="market-overview"
|
||||
description="Stock market overview widget"
|
||||
/>
|
||||
|
||||
## Installation
|
||||
|
||||
<Tabs defaultValue="cli">
|
||||
|
||||
<TabsList>
|
||||
<TabsTrigger value="cli">CLI</TabsTrigger>
|
||||
<TabsTrigger value="manual">Manual</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="cli">
|
||||
|
||||
```bash
|
||||
npx @hanzo/ui@latest add market-overview
|
||||
```
|
||||
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="manual">
|
||||
|
||||
<Steps>
|
||||
|
||||
<Step>Install the following dependencies:</Step>
|
||||
|
||||
```bash
|
||||
npm install @hanzo/ui
|
||||
```
|
||||
|
||||
<Step>Copy and paste the following code into your project.</Step>
|
||||
|
||||
<ComponentSource name="market-overview" />
|
||||
|
||||
<Step>Update the import paths to match your project setup.</Step>
|
||||
|
||||
</Steps>
|
||||
|
||||
</TabsContent>
|
||||
|
||||
</Tabs>
|
||||
|
||||
## Usage
|
||||
|
||||
```tsx
|
||||
import { MarketOverview } from "@hanzo/ui/finance"
|
||||
|
||||
export default function Demo() {
|
||||
return <MarketOverview colorTheme="dark" showFloatingTooltip />
|
||||
}
|
||||
```
|
||||
|
||||
## Props
|
||||
|
||||
| Prop | Type | Default | Description |
|
||||
| ------------------- | ----------------- | ---------------------------------------- | ---------------------- |
|
||||
| colorTheme | "light" \| "dark" | "dark" | Color theme |
|
||||
| width | string \| number | "100%" | Widget width |
|
||||
| height | string \| number | 400 | Widget height |
|
||||
| showFloatingTooltip | boolean | false | Show tooltip on hover |
|
||||
| locale | string | "en" | Language locale |
|
||||
| isTransparent | boolean | false | Transparent background |
|
||||
| tabs | string[] | ["indices", "futures", "bonds", "forex"] | Visible tabs |
|
||||
+19
-8
@@ -1,5 +1,5 @@
|
||||
import js from "@eslint/js";
|
||||
import tsParser from "@typescript-eslint/parser";
|
||||
import tseslint from "typescript-eslint";
|
||||
|
||||
export default [
|
||||
{
|
||||
@@ -12,16 +12,14 @@ export default [
|
||||
"dist/**",
|
||||
".turbo/**",
|
||||
"node_modules/**",
|
||||
"coverage/**",
|
||||
"*.config.js",
|
||||
"*.config.mjs",
|
||||
],
|
||||
},
|
||||
// JavaScript files
|
||||
// Use only base configs without rules
|
||||
{
|
||||
files: ["**/*.{js,jsx,mjs,cjs}"],
|
||||
...js.configs.recommended,
|
||||
rules: {
|
||||
// Turn off all rules
|
||||
"no-unused-vars": "off",
|
||||
"no-undef": "off",
|
||||
"no-empty": "off",
|
||||
@@ -32,13 +30,26 @@ export default [
|
||||
"prefer-const": "off",
|
||||
},
|
||||
},
|
||||
// TypeScript files - parser only, all rules off (rely on TypeScript compiler)
|
||||
// TypeScript files
|
||||
{
|
||||
files: ["**/*.{ts,tsx,mts}"],
|
||||
languageOptions: {
|
||||
parser: tsParser,
|
||||
parser: tseslint.parser,
|
||||
},
|
||||
plugins: {
|
||||
"@typescript-eslint": tseslint.plugin,
|
||||
},
|
||||
rules: {
|
||||
// Turn off ALL rules - rely on TypeScript compiler
|
||||
"@typescript-eslint/no-explicit-any": "off",
|
||||
"@typescript-eslint/no-empty-object-type": "off",
|
||||
"@typescript-eslint/ban-ts-comment": "off",
|
||||
"@typescript-eslint/no-unused-vars": "off",
|
||||
"@typescript-eslint/no-require-imports": "off",
|
||||
"@typescript-eslint/no-this-alias": "off",
|
||||
"@typescript-eslint/no-unsafe-function-type": "off",
|
||||
"@typescript-eslint/no-empty-interface": "off",
|
||||
},
|
||||
rules: {},
|
||||
},
|
||||
// CommonJS files
|
||||
{
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -1,35 +0,0 @@
|
||||
import { createAnalytics } from "@hanzo/event"
|
||||
|
||||
// One client for the whole site. Pageviews (components/analytics.tsx) and
|
||||
// product events (lib/events.ts) share it, so there is a single place that
|
||||
// knows where telemetry goes — call sites only name what happened. Host
|
||||
// defaults to the one edge (api.hanzo.ai), which is what a static site wants.
|
||||
//
|
||||
// /v1/event is authed, and deliberately does not trust the request Host — a Host
|
||||
// header is spoofable, so a static page proves which org it belongs to by
|
||||
// carrying a publishable key. `pk-` keys are write-only and HMAC-verified with no
|
||||
// database hop, which is what makes them safe to ship inside a public bundle (the
|
||||
// same reason a Sentry DSN is public). The build supplies it, so each deployment
|
||||
// reports as the org that deployed it; without one cloud answers
|
||||
// `401 ingest_key_required` and every logged-out pageview is dropped in silence.
|
||||
//
|
||||
// NEXT_PUBLIC_PUBLISHABLE_KEY is the ONE name, and it is the name the fleet
|
||||
// already carries end to end: KMS holds `deploy/PUBLISHABLE_KEY`, the Dockerfile
|
||||
// takes it as the PUBLISHABLE_KEY build-arg and re-exports it with the
|
||||
// NEXT_PUBLIC_ prefix that makes Next inline it.
|
||||
//
|
||||
// This file used to read NEXT_PUBLIC_HANZO_INGEST_KEY — a spelling nothing in KMS
|
||||
// or CI carries, so no builder could ever supply it. A key reached the live bundle
|
||||
// exactly once, passed by hand on a local `docker build`, and that is the failure
|
||||
// this rename removes: a name only a human can satisfy gets satisfied once and
|
||||
// then goes stale silently. The key v5.7.6 shipped (`pk_…`, the older format) is
|
||||
// now dead — api.hanzo.ai answers `401 ingest_key_required` for it on both
|
||||
// transports, so ui.hanzo.ai has been dropping every logged-out pageview.
|
||||
//
|
||||
// Written as a full literal on purpose: Next replaces textual `process.env.
|
||||
// NEXT_PUBLIC_*` occurrences at build time, so a name assembled at runtime
|
||||
// resolves to undefined in a static export.
|
||||
export const analytics = createAnalytics({
|
||||
product: "site",
|
||||
ingestKey: process.env.NEXT_PUBLIC_PUBLISHABLE_KEY,
|
||||
})
|
||||
+7
-28
@@ -165,42 +165,22 @@ export const AI_TOKEN_ABI = [
|
||||
// Contract addresses by chain ID
|
||||
export const CONTRACT_ADDRESSES: Record<
|
||||
number,
|
||||
{
|
||||
registry: `0x${string}`;
|
||||
token: `0x${string}`;
|
||||
chainConfig?: `0x${string}`;
|
||||
husd?: `0x${string}`;
|
||||
faucet?: `0x${string}`;
|
||||
}
|
||||
{ registry: `0x${string}`; token: `0x${string}` }
|
||||
> = {
|
||||
// Local Testnet (31337)
|
||||
31337: {
|
||||
registry: "0x9fE46736679d2D9a65F0992F2272dE9f3c7fa6e0",
|
||||
token: "0x5FbDB2315678afecb367f032d93F642f64180aa3",
|
||||
},
|
||||
// Hanzo Mainnet (36963) — sovereign L1, deployed 2026-06
|
||||
// Hanzo Mainnet (36963)
|
||||
36963: {
|
||||
registry: "0xf3df584A4a996b5D215E740B2240886d42C7307a",
|
||||
token: "0x799586e3637E68250449e840F22F8a1a01d6E934", // AIToken (AI)
|
||||
chainConfig: "0x25C806e07bA1c7B5c3495a8C57E6b8fd346092E1",
|
||||
husd: "0xe9e32EF8aaECB68794Da3E1E9191b0a64CeC2c83", // HUSD (LUSD)
|
||||
faucet: "0xd27d8049A575A63b54aAbbC4C41dBa5963cedF56",
|
||||
registry: "0x0000000000000000000000000000000000000000", // Deploy and update
|
||||
token: "0x0000000000000000000000000000000000000000", // Deploy and update
|
||||
},
|
||||
// Hanzo Testnet (36962) — sovereign L1, deployed 2026-06
|
||||
// Hanzo Testnet (36962)
|
||||
36962: {
|
||||
registry: "0x6EA9D7C669DAC51830219ff5d4391872a25AB147",
|
||||
token: "0x9Adf4583DDB3aFF5fA08a6788fc203e9d9908F4F", // AIToken (AI)
|
||||
chainConfig: "0x6162A52F71a1C8F0F1F86FE8D17d6DDedaEdaC3c",
|
||||
husd: "0xc57b7eCE2Ce2E74ef3Bc08Cfd5f5Fb41B6Ad4D66", // HUSD (LUSD)
|
||||
faucet: "0x88810C4F376aF0018641e98Fcc06f0b7Ba529937",
|
||||
},
|
||||
// Hanzo Devnet (36964) — sovereign L1, deployed 2026-06
|
||||
36964: {
|
||||
registry: "0xDeA8179dEc51eA55E03fbe257d51c6d0f5908E3F",
|
||||
token: "0x486809dD1bac9A17f18a1a640cdEf014C7DD809a", // AIToken (AI)
|
||||
chainConfig: "0xf911b6e4952781949Db84B478582Ca05817fECB4",
|
||||
husd: "0xBf92c933774daDF112159Be4b29e6BDc3ffAa2B1", // HUSD (LUSD)
|
||||
faucet: "0x0B2B0BF9f423C03151480e1bB63caCd4cB3B6343",
|
||||
registry: "0x0000000000000000000000000000000000000000", // Deploy and update
|
||||
token: "0x0000000000000000000000000000000000000000", // Deploy and update
|
||||
},
|
||||
// Lux Mainnet (96369)
|
||||
96369: {
|
||||
@@ -228,7 +208,6 @@ export const NETWORK_NAMES: Record<number, string> = {
|
||||
31337: "localhost",
|
||||
36963: "hanzo",
|
||||
36962: "hanzo-testnet",
|
||||
36964: "hanzo-devnet",
|
||||
96369: "lux",
|
||||
96368: "lux-testnet",
|
||||
200200: "zoo",
|
||||
|
||||
+7
-3
@@ -1,7 +1,6 @@
|
||||
import va from "@vercel/analytics"
|
||||
import { z } from "zod"
|
||||
|
||||
import { analytics } from "@/lib/analytics"
|
||||
|
||||
const eventSchema = z.object({
|
||||
name: z.enum([
|
||||
"copy_npm_command",
|
||||
@@ -28,6 +27,11 @@ export type Event = z.infer<typeof eventSchema>
|
||||
export function trackEvent(input: Event): void {
|
||||
const event = eventSchema.parse(input)
|
||||
if (event) {
|
||||
analytics.capture(event.name, event.properties)
|
||||
va.track(
|
||||
event.name,
|
||||
event.properties as
|
||||
| Record<string, string | number | boolean | null>
|
||||
| undefined
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+2
-13
@@ -1,17 +1,6 @@
|
||||
import { GeistMono } from "geist/font/mono"
|
||||
import localFont from "next/font/local"
|
||||
import { GeistSans } from "geist/font/sans"
|
||||
|
||||
// Canonical Hanzo typography (single source of truth; see DESIGN.md):
|
||||
// - UI / body / display / heading -> Basel Grotesk (self-hosted, Book 400 + Medium 500)
|
||||
// - code / data / mono -> Geist Mono
|
||||
// Basel replaces Geist Sans / DM Sans / Figtree / Inter as the default sans.
|
||||
export const fontSans = localFont({
|
||||
src: [
|
||||
{ path: "../fonts/Basel-Grotesk-Book.woff2", weight: "400", style: "normal" },
|
||||
{ path: "../fonts/Basel-Grotesk-Medium.woff2", weight: "500", style: "normal" },
|
||||
],
|
||||
variable: "--font-basel-sans",
|
||||
display: "swap",
|
||||
})
|
||||
export const fontSans = GeistSans
|
||||
|
||||
export const fontMono = GeistMono
|
||||
|
||||
@@ -3,9 +3,10 @@ import { promises as fs } from "fs"
|
||||
import path from "path"
|
||||
import type { Highlighter } from "shiki"
|
||||
|
||||
// Highlight on the server while developing. A production build is a static
|
||||
// export, which highlights on the client instead — so the build stays cheap.
|
||||
const highlightCodeEnabled = process.env.NODE_ENV === "development"
|
||||
// Enable syntax highlighting in development
|
||||
// For static exports, we can use client-side highlighting instead
|
||||
const highlightCodeEnabled =
|
||||
process.env.NODE_ENV === "development" || !process.env.GITHUB_ACTIONS
|
||||
|
||||
// Singleton highlighter instance to prevent memory leaks
|
||||
let highlighterInstance: Highlighter | null = null
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
/**
|
||||
* Hanzo Cloud search configuration for ui.hanzo.ai
|
||||
*
|
||||
* Uses the same infrastructure as docs.hanzo.ai:
|
||||
* - Meilisearch for fulltext search
|
||||
* - Qdrant for vector/semantic search
|
||||
* - Hanzo Cloud API for RAG chat
|
||||
*/
|
||||
|
||||
export const searchBackend =
|
||||
(process.env.NEXT_PUBLIC_HANZO_SEARCH_BACKEND as 'cloud' | 'meilisearch' | undefined) ??
|
||||
'cloud'
|
||||
|
||||
export const searchEndpoint =
|
||||
process.env.NEXT_PUBLIC_HANZO_SEARCH_ENDPOINT ??
|
||||
'https://cloud-api.hanzo.ai/api/search-docs'
|
||||
|
||||
export const searchIndex =
|
||||
process.env.NEXT_PUBLIC_HANZO_SEARCH_INDEX ?? 'app-ui-hanzo-ai'
|
||||
|
||||
export const indexEndpoint =
|
||||
process.env.HANZO_SEARCH_INDEX_ENDPOINT ??
|
||||
'https://cloud-api.hanzo.ai/api/index-docs'
|
||||
|
||||
export const chatEndpoint =
|
||||
process.env.NEXT_PUBLIC_HANZO_CHAT_ENDPOINT ??
|
||||
'https://cloud-api.hanzo.ai/api/chat-docs'
|
||||
|
||||
export const publishableKey =
|
||||
process.env.NEXT_PUBLIC_HANZO_SEARCH_KEY ?? 'pk-hanzo-ui-search-2026'
|
||||
|
||||
export const adminKey =
|
||||
process.env.HANZO_SEARCH_ADMIN_KEY ?? ''
|
||||
+27
-26
@@ -1,8 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import { getDefaultConfig } from "@rainbow-me/rainbowkit"
|
||||
import { defineChain } from "viem"
|
||||
import { createConfig, http } from "wagmi"
|
||||
import { injected } from "wagmi/connectors"
|
||||
|
||||
// Localhost (Anvil)
|
||||
export const localhost = defineChain({
|
||||
@@ -77,7 +76,7 @@ export const luxMainnet = defineChain({
|
||||
},
|
||||
rpcUrls: {
|
||||
default: {
|
||||
http: ["https://api.lux.network/v1/bc/C/rpc"],
|
||||
http: ["https://api.lux.network/ext/bc/C/rpc"],
|
||||
},
|
||||
},
|
||||
blockExplorers: {
|
||||
@@ -99,7 +98,7 @@ export const luxTestnet = defineChain({
|
||||
},
|
||||
rpcUrls: {
|
||||
default: {
|
||||
http: ["https://testnet-api.lux.network/v1/bc/C/rpc"],
|
||||
http: ["https://testnet-api.lux.network/ext/bc/C/rpc"],
|
||||
},
|
||||
},
|
||||
blockExplorers: {
|
||||
@@ -156,30 +155,28 @@ export const zooTestnet = defineChain({
|
||||
testnet: true,
|
||||
})
|
||||
|
||||
const chains = [
|
||||
localhost,
|
||||
hanzoMainnet,
|
||||
hanzoTestnet,
|
||||
luxMainnet,
|
||||
luxTestnet,
|
||||
zooMainnet,
|
||||
zooTestnet,
|
||||
] as const
|
||||
let _config: ReturnType<typeof getDefaultConfig> | undefined
|
||||
|
||||
type Config = ReturnType<typeof createConfig>
|
||||
export const getConfig = () => {
|
||||
if (typeof window === "undefined") {
|
||||
// Return a minimal config for SSR that won't be used
|
||||
return {} as ReturnType<typeof getDefaultConfig>
|
||||
}
|
||||
|
||||
let _config: Config | undefined
|
||||
|
||||
export const getConfig = (): Config => {
|
||||
if (!_config) {
|
||||
_config = createConfig({
|
||||
chains,
|
||||
// The browser wallet speaks EIP-1193 directly. No third-party bridge, no
|
||||
// vendor project id, and nothing phoning home from a docs page.
|
||||
connectors: [injected()],
|
||||
transports: Object.fromEntries(
|
||||
chains.map((chain) => [chain.id, http()])
|
||||
) as Record<(typeof chains)[number]["id"], ReturnType<typeof http>>,
|
||||
_config = getDefaultConfig({
|
||||
appName: "Hanzo Identity",
|
||||
projectId:
|
||||
process.env.NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID || "YOUR_PROJECT_ID",
|
||||
chains: [
|
||||
localhost,
|
||||
hanzoMainnet,
|
||||
hanzoTestnet,
|
||||
luxMainnet,
|
||||
luxTestnet,
|
||||
zooMainnet,
|
||||
zooTestnet,
|
||||
],
|
||||
ssr: true,
|
||||
})
|
||||
}
|
||||
@@ -187,4 +184,8 @@ export const getConfig = (): Config => {
|
||||
return _config
|
||||
}
|
||||
|
||||
export const config = getConfig()
|
||||
// For backward compatibility
|
||||
export const config =
|
||||
typeof window !== "undefined"
|
||||
? getConfig()
|
||||
: ({} as ReturnType<typeof getDefaultConfig>)
|
||||
|
||||
+6
-4
@@ -5,13 +5,15 @@ const nextConfig = {
|
||||
// Transpile packages that might have issues with pnpm symlinks
|
||||
transpilePackages: ["chrono-node", "@hanzo/ui"],
|
||||
|
||||
// The docs site is a static export, wherever it is built. It is served by
|
||||
// hanzoai/static; `next dev` is unaffected by this.
|
||||
output: "export",
|
||||
// Enable static export for GitHub Pages deployment (but not for E2E tests)
|
||||
output: process.env.GITHUB_ACTIONS && !process.env.E2E_TEST ? "export" : undefined,
|
||||
|
||||
// Directory-style URLs, so a static server resolves /docs/button/index.html.
|
||||
// Use trailing slashes for GitHub Pages compatibility
|
||||
trailingSlash: true,
|
||||
|
||||
// Base path for GitHub Pages (when deployed to github.io subdirectory)
|
||||
basePath: process.env.GITHUB_PAGES ? "/react-sdk" : "",
|
||||
|
||||
// Asset prefix for proper loading on custom domain
|
||||
assetPrefix: process.env.NEXT_PUBLIC_APP_URL || "",
|
||||
|
||||
|
||||
+72
-75
@@ -5,7 +5,7 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "next dev --port 3333",
|
||||
"build": "pnpm registry:build && pnpm registry:api && (pnpm registry:capture || echo 'Skipping screenshots in CI') && next build",
|
||||
"build": "pnpm registry:build && (pnpm registry:capture || echo 'Skipping screenshots in CI') && next build",
|
||||
"start": "next start --port 3001",
|
||||
"lint": "eslint .",
|
||||
"lint:fix": "eslint --fix .",
|
||||
@@ -17,28 +17,24 @@
|
||||
"format:write": "prettier --write \"**/*.{ts,tsx,mdx}\" --cache",
|
||||
"format:check": "prettier --check \"**/*.{ts,tsx,mdx}\" --cache",
|
||||
"registry:build": "tsx --tsconfig ./tsconfig.scripts.json ./scripts/build-registry.mts",
|
||||
"registry:api": "tsx --tsconfig ./tsconfig.scripts.json ./scripts/build-registry-api.mts",
|
||||
"registry:capture": "tsx --tsconfig ./tsconfig.scripts.json ./scripts/capture-registry.mts",
|
||||
"validate:registries": "tsx --tsconfig ./tsconfig.scripts.json ./scripts/validate-registries.mts",
|
||||
"postinstall": "docs-mdx source.config.ts .source"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-sdk/openai-compatible": "^0.2.0",
|
||||
"ai": "^4.3.0",
|
||||
"@ctrl/tinycolor": "^4.2.0",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/modifiers": "^9.0.0",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@faker-js/faker": "^10.4.0",
|
||||
"@hanzo/event": "^0.3.3",
|
||||
"@hanzo/docs-core": "16.5.3",
|
||||
"@hanzo/docs-docgen": "3.0.7",
|
||||
"@faker-js/faker": "^10.0.0",
|
||||
"@hanzo/docs-core": "16.2.6",
|
||||
"@hanzo/docs-docgen": "3.0.4",
|
||||
"@hanzo/docs-mdx": "14.3.0",
|
||||
"@hanzo/docs-ui": "16.5.3",
|
||||
"@hanzo/logo": "^1.0.5",
|
||||
"@hanzo/ui": "npm:@hanzo/ui-shadcn@^5",
|
||||
"@hookform/resolvers": "5.2.2",
|
||||
"@hanzo/docs-ui": "16.2.6",
|
||||
"@hanzo/logo": "^1.0.3",
|
||||
"@hanzo/ui": "workspace:^",
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@monaco-editor/react": "^4.7.0",
|
||||
"@radix-ui/react-accessible-icon": "^1.1.8",
|
||||
"@radix-ui/react-accordion": "^1.2.12",
|
||||
@@ -71,16 +67,18 @@
|
||||
"@radix-ui/react-toggle": "^1.1.10",
|
||||
"@radix-ui/react-toggle-group": "^1.1.11",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@rc-component/color-picker": "^3.1.1",
|
||||
"@tabler/icons-react": "^3.40.0",
|
||||
"@tanstack/react-query": "^5.94.5",
|
||||
"@rainbow-me/rainbowkit": "^2.2.9",
|
||||
"@rc-component/color-picker": "^3.0.2",
|
||||
"@tabler/icons-react": "^3.35.0",
|
||||
"@tanstack/react-query": "^5.90.7",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"@tiptap/extension-placeholder": "^3.20.4",
|
||||
"@tiptap/react": "^3.20.4",
|
||||
"@tiptap/starter-kit": "^3.20.4",
|
||||
"@tiptap/extension-placeholder": "^3.10.2",
|
||||
"@tiptap/react": "^3.10.2",
|
||||
"@tiptap/starter-kit": "^3.10.2",
|
||||
"@types/react-syntax-highlighter": "^15.5.13",
|
||||
"@vercel/og": "^0.11.1",
|
||||
"@xyflow/react": "^12.10.1",
|
||||
"@vercel/analytics": "^1.5.0",
|
||||
"@vercel/og": "^0.8.5",
|
||||
"@xyflow/react": "^12.8.6",
|
||||
"chrono-node": "^2.9.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
@@ -89,83 +87,82 @@
|
||||
"date-fns": "^4.1.0",
|
||||
"embla-carousel-autoplay": "8.6.0",
|
||||
"embla-carousel-react": "8.6.0",
|
||||
"fumadocs-core": "^16.7.4",
|
||||
"fumadocs-mdx": "^14.2.11",
|
||||
"geist": "^1.7.0",
|
||||
"fumadocs-core": "^16.4.8",
|
||||
"fumadocs-mdx": "^14.2.6",
|
||||
"geist": "^1.5.1",
|
||||
"input-otp": "^1.4.2",
|
||||
"jotai": "^2.18.1",
|
||||
"js-yaml": "^4.1.1",
|
||||
"lodash": "^4.17.23",
|
||||
"lucide-react": "^0.577.0",
|
||||
"monaco-editor": "^0.55.1",
|
||||
"motion": "^12.38.0",
|
||||
"jotai": "^2.15.0",
|
||||
"js-yaml": "^4.1.0",
|
||||
"lodash": "^4.17.21",
|
||||
"lucide-react": "^0.544.0",
|
||||
"monaco-editor": "^0.53.0",
|
||||
"motion": "^12.34.0",
|
||||
"next-themes": "0.4.6",
|
||||
"qrcode.react": "^4.2.0",
|
||||
"react": "19.2.4",
|
||||
"react": "19.2.0",
|
||||
"react-colorful": "^5.6.1",
|
||||
"react-day-picker": "^9.14.0",
|
||||
"react-dom": "19.2.4",
|
||||
"react-dropzone": "^15.0.0",
|
||||
"react-hook-form": "^7.71.2",
|
||||
"react-day-picker": "^9.11.0",
|
||||
"react-dom": "19.2.0",
|
||||
"react-dropzone": "^14.3.8",
|
||||
"react-hook-form": "^7.64.0",
|
||||
"react-image-crop": "^11.0.10",
|
||||
"react-medium-image-zoom": "^5.4.1",
|
||||
"react-medium-image-zoom": "^5.4.0",
|
||||
"react-resizable-panels": "^3.0.6",
|
||||
"react-syntax-highlighter": "^16.1.1",
|
||||
"react-syntax-highlighter": "^15.6.6",
|
||||
"react-wrap-balancer": "^1.1.1",
|
||||
"recharts": "3.8.0",
|
||||
"recharts": "3.3.0",
|
||||
"sonner": "^2.0.7",
|
||||
"swr": "^2.4.1",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"swr": "^2.3.6",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"ts-morph": "27.0.2",
|
||||
"vaul": "1.1.2",
|
||||
"viem": "^2.47.6",
|
||||
"wagmi": "^3.5.0",
|
||||
"zod": "^4.3.6",
|
||||
"zustand": "^5.0.12"
|
||||
"viem": "^2.38.6",
|
||||
"wagmi": "^2.19.2",
|
||||
"zod": "^4.1.11",
|
||||
"zustand": "^5.0.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3.3.5",
|
||||
"@ianvs/prettier-plugin-sort-imports": "^4.7.1",
|
||||
"@playwright/test": "^1.58.2",
|
||||
"@tailwindcss/postcss": "^4.2.2",
|
||||
"@eslint/eslintrc": "^3.3.1",
|
||||
"@ianvs/prettier-plugin-sort-imports": "^4.7.0",
|
||||
"@playwright/test": "^1.55.1",
|
||||
"@tailwindcss/postcss": "^4.1.17",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"@types/lodash": "^4.17.24",
|
||||
"@types/lodash": "^4.17.20",
|
||||
"@types/mdx": "^2.0.13",
|
||||
"@types/node": "^25.5.0",
|
||||
"@types/react": "19.2.14",
|
||||
"@types/react-dom": "19.2.3",
|
||||
"@types/node": "^24.6.2",
|
||||
"@types/react": "19.2.2",
|
||||
"@types/react-dom": "19.2.2",
|
||||
"@types/unist": "^3.0.3",
|
||||
"@typescript-eslint/parser": "^8.57.1",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"@vitest/ui": "^4.1.0",
|
||||
"autoprefixer": "^10.4.27",
|
||||
"@typescript-eslint/parser": "^8.46.3",
|
||||
"@vitejs/plugin-react": "^5.0.4",
|
||||
"@vitest/ui": "^4.0.7",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"del-cli": "^7.0.0",
|
||||
"@eslint/js": "^9.28.0",
|
||||
"eslint": "^10.1.0",
|
||||
"eslint-config-next": "16.2.1",
|
||||
"happy-dom": "^20.8.4",
|
||||
"next": "^16.2.1",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-config-next": "16.0.1",
|
||||
"happy-dom": "^20.0.10",
|
||||
"next": "^16.1.6",
|
||||
"npm-run-all": "^4.1.5",
|
||||
"playwright": "^1.58.2",
|
||||
"playwright": "^1.55.1",
|
||||
"png-to-ico": "^3.0.1",
|
||||
"postcss": "^8.5.8",
|
||||
"postcss-selector-parser": "^7.1.1",
|
||||
"prettier": "^3.8.1",
|
||||
"prettier-plugin-tailwindcss": "^0.7.2",
|
||||
"puppeteer": "^25.1.0",
|
||||
"rehype-pretty-code": "^0.14.3",
|
||||
"rimraf": "^6.1.3",
|
||||
"sharp": "^0.34.5",
|
||||
"shiki": "^4.0.2",
|
||||
"tailwindcss": "^4.2.2",
|
||||
"tsx": "^4.21.0",
|
||||
"postcss": "^8.5.6",
|
||||
"postcss-selector-parser": "^7.1.0",
|
||||
"prettier": "^3.6.2",
|
||||
"prettier-plugin-tailwindcss": "^0.6.14",
|
||||
"puppeteer": "^23.6.0",
|
||||
"rehype-pretty-code": "^0.14.1",
|
||||
"rimraf": "^6.0.1",
|
||||
"sharp": "^0.34.4",
|
||||
"shiki": "^3.15.0",
|
||||
"tailwindcss": "^4.1.17",
|
||||
"tsx": "^4.20.6",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "^5.9.3",
|
||||
"unist-builder": "4.0.0",
|
||||
"unist-util-visit": "^5.1.0",
|
||||
"vitest": "^4.1.0"
|
||||
"unist-util-visit": "^5.0.0",
|
||||
"vitest": "^4.0.7"
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,14 @@
|
||||
import { NextApiRequest, NextApiResponse } from "next"
|
||||
|
||||
import components from "./components.json"
|
||||
|
||||
export default async function handler(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse
|
||||
) {
|
||||
if (req.method !== "GET") {
|
||||
return res.status(405).end()
|
||||
}
|
||||
|
||||
return res.status(200).json(components)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
ui.hanzo.ai
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -76,7 +76,6 @@ export default function Component() {
|
||||
strokeWidth={2}
|
||||
dot={({ cx, cy, payload }) => {
|
||||
const r = 24
|
||||
if (cx == null || cy == null) return null
|
||||
return (
|
||||
<GitCommitVertical
|
||||
key={payload.month}
|
||||
|
||||
@@ -749,7 +749,7 @@ const AICode = React.forwardRef<HTMLDivElement, AICodeProps>(
|
||||
|
||||
// Setup hover providers for explanations
|
||||
monaco.languages.registerHoverProvider(currentLanguage, {
|
||||
provideHover: (model: any, position: any) => {
|
||||
provideHover: (model, position) => {
|
||||
const line = position.lineNumber
|
||||
const explanation = explanations.find((e) => e.line === line)
|
||||
|
||||
@@ -768,7 +768,7 @@ const AICode = React.forwardRef<HTMLDivElement, AICodeProps>(
|
||||
|
||||
// Setup code actions for suggestions
|
||||
monaco.languages.registerCodeActionProvider(currentLanguage, {
|
||||
provideCodeActions: (model: any, range: any, context: any, token: any) => {
|
||||
provideCodeActions: (model, range, context, token) => {
|
||||
const actions = suggestions
|
||||
.filter(
|
||||
(s) =>
|
||||
|
||||
@@ -34,68 +34,57 @@ import { Slider } from "@/registry/default/ui/slider"
|
||||
import { Switch } from "@/registry/default/ui/switch"
|
||||
import { Textarea } from "@/registry/default/ui/textarea"
|
||||
|
||||
// Web Speech API shapes, declared MODULE-LOCALLY and deliberately not in
|
||||
// `declare global`.
|
||||
//
|
||||
// The Speech API is only partly standardised, so how much of it lib.dom ships
|
||||
// varies by TypeScript version — and this file has now been broken from both
|
||||
// directions by that. Declaring the names globally MERGES with whatever the lib
|
||||
// provides instead of shadowing it, so every small difference became an error
|
||||
// (`readonly` vs mutable → TS2687, a second `[index: number]` → TS2374,
|
||||
// `error: string` against the real `SpeechRecognitionErrorCode` union → TS2717).
|
||||
// Deleting the declarations instead broke the opposite way on a lib that does
|
||||
// NOT carry them: TS2552 "Cannot find name 'SpeechRecognitionEvent'".
|
||||
//
|
||||
// Local names collide with neither. They describe exactly the surface this
|
||||
// component touches, and the version of lib.dom in use stops mattering.
|
||||
interface SpeechAlternative {
|
||||
readonly transcript: string
|
||||
}
|
||||
declare global {
|
||||
interface Window {
|
||||
SpeechRecognition: typeof SpeechRecognition
|
||||
webkitSpeechRecognition: typeof SpeechRecognition
|
||||
webkitAudioContext: typeof AudioContext
|
||||
}
|
||||
|
||||
interface SpeechResult {
|
||||
readonly isFinal: boolean
|
||||
readonly [index: number]: SpeechAlternative
|
||||
}
|
||||
interface SpeechRecognition extends EventTarget {
|
||||
continuous: boolean
|
||||
interimResults: boolean
|
||||
lang: string
|
||||
onresult: (event: SpeechRecognitionEvent) => void
|
||||
onerror: (event: SpeechRecognitionErrorEvent) => void
|
||||
onend: () => void
|
||||
start(): void
|
||||
stop(): void
|
||||
abort(): void
|
||||
}
|
||||
|
||||
interface SpeechResultList {
|
||||
readonly length: number
|
||||
readonly [index: number]: SpeechResult
|
||||
}
|
||||
interface SpeechRecognitionEvent extends Event {
|
||||
results: SpeechRecognitionResultList
|
||||
resultIndex: number
|
||||
}
|
||||
|
||||
interface SpeechRecognitionEventLike {
|
||||
readonly resultIndex: number
|
||||
readonly results: SpeechResultList
|
||||
}
|
||||
interface SpeechRecognitionErrorEvent extends Event {
|
||||
error: string
|
||||
message: string
|
||||
}
|
||||
|
||||
interface SpeechRecognitionErrorEventLike {
|
||||
readonly error: string
|
||||
}
|
||||
interface SpeechRecognitionResultList {
|
||||
readonly length: number
|
||||
item(index: number): SpeechRecognitionResult
|
||||
[index: number]: SpeechRecognitionResult
|
||||
}
|
||||
|
||||
interface SpeechRecognizer {
|
||||
continuous: boolean
|
||||
interimResults: boolean
|
||||
lang: string
|
||||
onresult: (event: SpeechRecognitionEventLike) => void
|
||||
onerror: (event: SpeechRecognitionErrorEventLike) => void
|
||||
onend: () => void
|
||||
start(): void
|
||||
stop(): void
|
||||
abort(): void
|
||||
}
|
||||
interface SpeechRecognitionResult {
|
||||
readonly length: number
|
||||
readonly isFinal: boolean
|
||||
item(index: number): SpeechRecognitionAlternative
|
||||
[index: number]: SpeechRecognitionAlternative
|
||||
}
|
||||
|
||||
// The constructor is read off `window` through a narrow cast rather than a
|
||||
// `Window` augmentation, for the same reason: lib.dom may or may not already
|
||||
// declare `SpeechRecognition` there, and re-declaring it with a different type
|
||||
// is itself an error.
|
||||
type SpeechRecognizerCtor = new () => SpeechRecognizer
|
||||
interface SpeechRecognitionAlternative {
|
||||
readonly transcript: string
|
||||
readonly confidence: number
|
||||
}
|
||||
|
||||
const getSpeechRecognizerCtor = (): SpeechRecognizerCtor | undefined => {
|
||||
if (typeof window === "undefined") return undefined
|
||||
const w = window as unknown as {
|
||||
SpeechRecognition?: SpeechRecognizerCtor
|
||||
webkitSpeechRecognition?: SpeechRecognizerCtor
|
||||
const SpeechRecognition: {
|
||||
prototype: SpeechRecognition
|
||||
new (): SpeechRecognition
|
||||
}
|
||||
return w.webkitSpeechRecognition ?? w.SpeechRecognition
|
||||
}
|
||||
|
||||
interface VoiceProfile {
|
||||
@@ -219,7 +208,7 @@ export function AIVoice({
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [audioLevel, setAudioLevel] = useState(0)
|
||||
|
||||
const recognitionRef = useRef<SpeechRecognizer | null>(null)
|
||||
const recognitionRef = useRef<SpeechRecognition | null>(null)
|
||||
const synthRef = useRef<SpeechSynthesis | null>(null)
|
||||
const audioContextRef = useRef<AudioContext | null>(null)
|
||||
const analyserRef = useRef<AnalyserNode | null>(null)
|
||||
@@ -229,9 +218,10 @@ export function AIVoice({
|
||||
|
||||
// Initialize speech recognition
|
||||
useEffect(() => {
|
||||
const Recognizer = getSpeechRecognizerCtor()
|
||||
if (Recognizer) {
|
||||
recognitionRef.current = new Recognizer()
|
||||
if (typeof window !== "undefined" && "webkitSpeechRecognition" in window) {
|
||||
const SpeechRecognition =
|
||||
window.webkitSpeechRecognition || window.SpeechRecognition
|
||||
recognitionRef.current = new SpeechRecognition()
|
||||
|
||||
if (recognitionRef.current) {
|
||||
recognitionRef.current.continuous = true
|
||||
@@ -309,15 +299,8 @@ export function AIVoice({
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
|
||||
|
||||
// `webkitAudioContext` is a vendor prefix lib.dom does not declare, so it
|
||||
// is read through the same narrow cast as the recogniser rather than by
|
||||
// augmenting Window (see the note at the top of this file).
|
||||
const AudioCtx =
|
||||
window.AudioContext ??
|
||||
(window as unknown as { webkitAudioContext?: typeof AudioContext })
|
||||
.webkitAudioContext
|
||||
if (!AudioCtx) throw new Error("Web Audio API is not available")
|
||||
audioContextRef.current = new AudioCtx()
|
||||
audioContextRef.current = new (window.AudioContext ||
|
||||
window.webkitAudioContext)()
|
||||
analyserRef.current = audioContextRef.current.createAnalyser()
|
||||
microphoneRef.current =
|
||||
audioContextRef.current.createMediaStreamSource(stream)
|
||||
|
||||
@@ -1,119 +0,0 @@
|
||||
/**
|
||||
* Build static API responses from the registry data.
|
||||
*
|
||||
* Generates /public/api/registry/ files that work on CF Pages,
|
||||
* GitHub Pages, or any static hosting. Run after build-registry.mts.
|
||||
*
|
||||
* Output:
|
||||
* public/api/registry/index.json — full manifest (all components + source)
|
||||
* public/api/registry/components.json — component list (no source)
|
||||
* public/api/registry/components/{name}.json — individual component with source
|
||||
* public/api/registry/search-index.json — lightweight search index
|
||||
*/
|
||||
|
||||
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "fs"
|
||||
import path from "path"
|
||||
|
||||
const REGISTRY_DIR = path.join(process.cwd(), "public/registry")
|
||||
const STYLES_DIR = path.join(REGISTRY_DIR, "styles/default")
|
||||
const API_DIR = path.join(process.cwd(), "public/api/registry")
|
||||
const COMPONENTS_DIR = path.join(API_DIR, "components")
|
||||
|
||||
interface RegistryItem {
|
||||
name: string
|
||||
type: string
|
||||
dependencies?: string[]
|
||||
devDependencies?: string[]
|
||||
registryDependencies?: string[]
|
||||
files: Array<{ name: string; content: string } | string>
|
||||
description?: string
|
||||
category?: string
|
||||
}
|
||||
|
||||
function main() {
|
||||
console.log("Building static registry API files...")
|
||||
|
||||
// Read the index
|
||||
const indexPath = path.join(REGISTRY_DIR, "index.json")
|
||||
if (!existsSync(indexPath)) {
|
||||
console.error("Registry index.json not found. Run registry:build first.")
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const index: RegistryItem[] = JSON.parse(readFileSync(indexPath, "utf-8"))
|
||||
|
||||
// Read all component files
|
||||
const components = new Map<string, RegistryItem>()
|
||||
if (existsSync(STYLES_DIR)) {
|
||||
const files = readdirSync(STYLES_DIR).filter((f) => f.endsWith(".json"))
|
||||
for (const file of files) {
|
||||
try {
|
||||
const data: RegistryItem = JSON.parse(
|
||||
readFileSync(path.join(STYLES_DIR, file), "utf-8")
|
||||
)
|
||||
components.set(data.name, data)
|
||||
} catch {
|
||||
// skip
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create output directories
|
||||
mkdirSync(COMPONENTS_DIR, { recursive: true })
|
||||
|
||||
// 1. Component list (no source — lightweight)
|
||||
const componentList = index.map((item) => ({
|
||||
name: item.name,
|
||||
type: item.type,
|
||||
dependencies: item.dependencies,
|
||||
registryDependencies: item.registryDependencies,
|
||||
}))
|
||||
|
||||
writeFileSync(
|
||||
path.join(API_DIR, "components.json"),
|
||||
JSON.stringify({ total: componentList.length, components: componentList })
|
||||
)
|
||||
console.log(` components.json: ${componentList.length} components`)
|
||||
|
||||
// 2. Individual component files (with source)
|
||||
let written = 0
|
||||
for (const [name, data] of components) {
|
||||
writeFileSync(
|
||||
path.join(COMPONENTS_DIR, `${name}.json`),
|
||||
JSON.stringify(data)
|
||||
)
|
||||
written++
|
||||
}
|
||||
console.log(` components/*.json: ${written} files`)
|
||||
|
||||
// 3. Full manifest (single payload with all source)
|
||||
const manifest: Record<string, any> = {}
|
||||
for (const [name, data] of components) {
|
||||
manifest[name] = data
|
||||
}
|
||||
writeFileSync(
|
||||
path.join(API_DIR, "index.json"),
|
||||
JSON.stringify({
|
||||
generated_at: Date.now(),
|
||||
total: components.size,
|
||||
components: manifest,
|
||||
})
|
||||
)
|
||||
console.log(` index.json: full manifest`)
|
||||
|
||||
// 4. Search index (lightweight — names + types for client-side search)
|
||||
const searchIndex = index.map((item) => ({
|
||||
n: item.name,
|
||||
t: item.type,
|
||||
d: (item.dependencies || []).join(","),
|
||||
}))
|
||||
writeFileSync(
|
||||
path.join(API_DIR, "search-index.json"),
|
||||
JSON.stringify(searchIndex)
|
||||
)
|
||||
console.log(` search-index.json: ${searchIndex.length} entries`)
|
||||
|
||||
console.log("Done! Static API files written to public/api/registry/")
|
||||
}
|
||||
|
||||
main()
|
||||
@@ -23,7 +23,7 @@ const config = {
|
||||
},
|
||||
extend: {
|
||||
fontFamily: {
|
||||
sans: ["var(--font-basel-sans)", ...fontFamily.sans],
|
||||
sans: ["var(--font-geist-sans)", ...fontFamily.sans],
|
||||
mono: ["var(--font-geist-mono)", ...fontFamily.mono],
|
||||
},
|
||||
colors: {
|
||||
|
||||
+1
-9
@@ -19,18 +19,10 @@
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "react-jsx",
|
||||
// `types/nav` and `types/unist` are imported as BARE specifiers, which only
|
||||
// ever resolved because of an implicit project root. Under TypeScript 7
|
||||
// `baseUrl` is gone outright — the compiler answers TS5102 "Option 'baseUrl'
|
||||
// has been removed … use '\"paths\": {\"*\": [\"./*\"]}' instead" — so the
|
||||
// mapping has to be explicit. `paths` entries resolve relative to this file,
|
||||
// so no baseUrl is needed or accepted.
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"./*"
|
||||
],
|
||||
"types/*": [
|
||||
"./types/*"
|
||||
]
|
||||
},
|
||||
"plugins": [
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
node_modules
|
||||
dist
|
||||
e2e-shots
|
||||
*.tsbuildinfo
|
||||
@@ -1,78 +0,0 @@
|
||||
# Hanzo CD — the dedicated deploy dashboard (cd.hanzo.ai)
|
||||
|
||||
A focused, mobile-first CD dashboard served at **cd.hanzo.ai**. It replaces the
|
||||
ArgoCD React fork (`~/work/hanzo/deploy/ui`, webpack/`argo-cd-ui`) with the shared
|
||||
Hanzo component packages over the native cloud CD plane. Its whole job: **see every
|
||||
service deployed as an operator App CR — stats · sync · health · resource tree ·
|
||||
logs · sync/rollback.**
|
||||
|
||||
## Stack + why
|
||||
|
||||
- **Vite + React 19 static SPA** → `dist/` (a static bundle, no runtime server).
|
||||
- **`@hanzo/cd`** (workspace) is the ArgoCD-replacement UI — a clean-room,
|
||||
framework-free port (plain React + scoped CSS, no Tamagui/Tailwind). We mount
|
||||
`GitopsAppList` (the fleet), and compose `GitopsSyncPanel` + `GitopsAppTree` +
|
||||
`GitopsNodeInfo` + `GitopsRollbackDialog` for the app detail (lazy per-node
|
||||
`/resource` + `/logs` fetch).
|
||||
- **`@hanzo/canvas/pure`** (workspace) for the pure helpers.
|
||||
- The workspace packages are resolved by **Vite path alias** (`vite.config.ts`),
|
||||
so the app builds off `@hanzo/cd/dist` + `@hanzo/canvas/src` **without a full
|
||||
monorepo install** (the shared pnpm store lacks the registry deps of the heavier
|
||||
packages). Run `pnpm --filter @hanzo/cd build` once if its `dist` is stale.
|
||||
|
||||
## Backend + auth (the contract this preserves)
|
||||
|
||||
- **API = the cloud binary at `/v1/deploy`** (SuperAdmin-gated, clean paths —
|
||||
`applications`, `:name/tree`, `:name/resource/:ref`, `:name/logs`, `sync`,
|
||||
`rollback`). The cd.hanzo.ai ingress peels `/v1/deploy/*` off to `cloud:8000`;
|
||||
the SPA calls it **same-origin** with `credentials: 'include'`.
|
||||
- **Auth = the `admin-console` PKCE gate** (`public/login.html`, ported verbatim
|
||||
from the ArgoCD fork — self-contained, proven): `hanzo.id` → sets the non-httpOnly
|
||||
`hanzo_iam_token` cookie the cloud binary validates (`SanitizeIdentity` →
|
||||
`c.IsAdmin()`). The SPA reads that cookie for its gate; a missing/expired session
|
||||
(or an API 401/403) lands on the sign-in screen — never a fabricated row.
|
||||
- `src/lib/adapt.ts` maps the `/v1/deploy` DTOs INTO the shared `@hanzo/cd`
|
||||
view-models (reusing the package's own `foldHealth`/`foldSync`). Note: the cloud
|
||||
wire uses the hyphenated `out-of-sync`, which `foldSync` (whitespace-only strip)
|
||||
reads as Unknown — the adapter strips `[-_]` before folding.
|
||||
|
||||
## Build + serve
|
||||
|
||||
```bash
|
||||
pnpm --filter cd build # tsc -b && vite build → dist/ (index.html + login.html + CNAME + assets)
|
||||
pnpm --filter cd preview # serve dist locally
|
||||
pnpm --filter cd dev # vite dev (proxies /v1 → CD_API, default https://cd.hanzo.ai)
|
||||
```
|
||||
|
||||
Serving: the built `dist/` is a static bundle published to **`s3://cdn/cd`** — the
|
||||
existing static plane cd.hanzo.ai already serves (ingress `staticFiles` +
|
||||
`spaMode`, zero pods; see `universe/infra/k8s/operator/crs/static-sites.yaml`). No
|
||||
ingress or backend change: publish the bundle, retire the old ArgoCD `ui/` + the
|
||||
`deploy/dashboard.go` embed.
|
||||
|
||||
## Verify
|
||||
|
||||
- `tsc --noEmit -p tsconfig.app.json` — clean.
|
||||
- `npx vitest run src/lib/adapt.test.ts` — the `/v1/deploy` → `@hanzo/cd`
|
||||
mapping (incl. the `out-of-sync` fold fix, `parentRefs` tree linking, clean-semver
|
||||
rollback).
|
||||
- `npx playwright test` — builds + serves + proves the fleet renders, a row opens
|
||||
the ArgoCD-grade detail (sync panel + resource topology), and **no horizontal body
|
||||
scroll at 390px**. Screenshots in `e2e-shots/`.
|
||||
|
||||
## Follow-ons (registry-install / routing gated — flagged, not fabricated)
|
||||
|
||||
- **`@hanzo/canvas` fleet MAP** (the Railway board) needs `@hanzo/gui` (Tamagui),
|
||||
which is an external npm dep not in the offline store here — so the map is a
|
||||
registry-install follow-on. It already ships green in the console
|
||||
(`hanzoai/console` `feat/cd-canvas-map`); lifting it in is an import once
|
||||
`@hanzo/gui` installs.
|
||||
- **`@hanzo/ui-shadcn` shared shell** (`HanzoHeader` + org/project switcher + ⌘K)
|
||||
needs a full monorepo install (its Tailwind deps aren't in the offline store).
|
||||
This app ships a lean self-contained topbar (Geist, small mark, env scope) in the
|
||||
interim; swap to the shared shell once installed. The **org/project↔IAM switcher**
|
||||
additionally needs `/v1/iam` routed on cd.hanzo.ai (today the SuperAdmin cookie
|
||||
sees the whole fleet; env is the scope control).
|
||||
- **Rollback targets** come from the app's `revisions` (forward-compat — the plane
|
||||
doesn't expose prior tags yet; empty → the dialog's honest "no history"). Cloud
|
||||
re-validates the clean semver.
|
||||
@@ -1,97 +0,0 @@
|
||||
/**
|
||||
* e2e: Hanzo CD — mocked-network render + RESPONSIVE proof.
|
||||
*
|
||||
* Serves the built static SPA; mocks the cloud CD plane (`/v1/deploy/*`) with
|
||||
* real-shaped `clients/deploy` rows + a session cookie so the app renders the
|
||||
* fleet. Proves: the fleet renders on the shared @hanzo/cd `GitopsAppList`, a
|
||||
* row opens the ArgoCD-grade detail (sync panel + resource topology), and — the
|
||||
* CTO requirement — the body never scrolls horizontally at a 390px viewport.
|
||||
* Screenshots at desktop (1440) and mobile (390).
|
||||
*/
|
||||
import { test, expect, type Route, type Page, type BrowserContext } from "@playwright/test"
|
||||
import { mkdirSync } from "node:fs"
|
||||
import { join } from "node:path"
|
||||
|
||||
const SHOTS = join(process.cwd(), "e2e-shots")
|
||||
|
||||
const FLEET = {
|
||||
applications: [
|
||||
{ name: "cloud", namespace: "hanzo", env: "main", repository: "ghcr.io/hanzoai/cloud", version: "v1.800.1", runningVersion: "v1.800.1", health: "healthy", sync: "synced", phase: "Running" },
|
||||
{ name: "iam", namespace: "hanzo", env: "main", repository: "ghcr.io/hanzoai/iam", version: "v1.4.11", runningVersion: "v1.4.10", health: "progressing", healthMessage: "rolling update (1/2)", sync: "out-of-sync", phase: "Running", revisions: ["v1.4.10", "v1.4.9"] },
|
||||
{ name: "gateway", namespace: "hanzo", env: "main", repository: "ghcr.io/hanzoai/gateway", version: "v2.16.4", runningVersion: "v2.16.4", health: "healthy", sync: "synced", phase: "Running" },
|
||||
{ name: "o11y", namespace: "hanzo", env: "test", repository: "ghcr.io/hanzoai/o11y", version: "v1.5.12", runningVersion: "v1.5.10", health: "degraded", healthMessage: "CrashLoopBackOff", sync: "out-of-sync", phase: "Degraded" },
|
||||
],
|
||||
summary: { total: 4, healthy: 2, degraded: 1, outOfSync: 2 },
|
||||
}
|
||||
const TREE = {
|
||||
application: FLEET.applications[1],
|
||||
nodes: [
|
||||
{ group: "hanzo.ai", version: "v1", kind: "App", namespace: "hanzo", name: "iam", ref: "hanzo.ai:App:hanzo:iam", uid: "u1", health: "progressing", parentRefs: [] },
|
||||
{ group: "apps", version: "v1", kind: "Deployment", namespace: "hanzo", name: "iam", ref: "apps:Deployment:hanzo:iam", uid: "u2", health: "progressing", parentRefs: [{ ref: "hanzo.ai:App:hanzo:iam" }] },
|
||||
{ group: "apps", version: "v1", kind: "ReplicaSet", namespace: "hanzo", name: "iam-6d8f", ref: "apps:ReplicaSet:hanzo:iam-6d8f", uid: "u3", health: "healthy", parentRefs: [{ ref: "apps:Deployment:hanzo:iam" }] },
|
||||
{ group: "", version: "v1", kind: "Pod", namespace: "hanzo", name: "iam-6d8f-abc", ref: ":Pod:hanzo:iam-6d8f-abc", uid: "u4", health: "healthy", parentRefs: [{ ref: "apps:ReplicaSet:hanzo:iam-6d8f" }] },
|
||||
],
|
||||
}
|
||||
const RESOURCE = { ref: "apps:Deployment:hanzo:iam", health: "healthy", liveManifest: { apiVersion: "apps/v1", kind: "Deployment", metadata: { name: "iam" }, spec: { replicas: 2 } }, desiredSource: "last-applied", diff: { modified: false } }
|
||||
const LOGS = { application: "hanzo/iam", pod: "iam-6d8f-abc", logs: "listening on :8080\nready to serve\n" }
|
||||
|
||||
async function mock(route: Route) {
|
||||
const p = new URL(route.request().url()).pathname
|
||||
const json = (b: unknown) => route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(b) })
|
||||
if (p === "/v1/deploy/applications") return json(FLEET)
|
||||
if (/^\/v1\/deploy\/[^/]+\/tree$/.test(p)) return json(TREE)
|
||||
if (/^\/v1\/deploy\/[^/]+\/resource\//.test(p)) return json(RESOURCE)
|
||||
if (/^\/v1\/deploy\/[^/]+\/logs$/.test(p)) return json(LOGS)
|
||||
if (p.startsWith("/v1/deploy/")) return json({ ok: true })
|
||||
return route.continue()
|
||||
}
|
||||
|
||||
async function open(ctx: BrowserContext): Promise<Page> {
|
||||
// The PKCE login sets `hanzo_iam_token`; seed it so the SPA renders the dashboard.
|
||||
await ctx.addCookies([{ name: "hanzo_iam_token", value: "e.y.j", url: "http://localhost:4173" }])
|
||||
const page = await ctx.newPage()
|
||||
await page.route("**/*", mock)
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" })
|
||||
await page.locator("text=Fleet").first().waitFor({ timeout: 15_000 })
|
||||
return page
|
||||
}
|
||||
|
||||
test.beforeAll(() => mkdirSync(SHOTS, { recursive: true }))
|
||||
|
||||
test("renders the fleet, opens a row → ArgoCD-grade detail (desktop)", async ({ browser }) => {
|
||||
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } })
|
||||
const page = await open(ctx)
|
||||
|
||||
await expect(page.locator("text=Applications").first()).toBeVisible()
|
||||
await expect(page.locator("tr", { hasText: "iam" }).first()).toBeVisible()
|
||||
// sync fold: the hyphenated cloud verdict renders OutOfSync (not Unknown).
|
||||
await expect(page.locator("text=OutOfSync").first()).toBeVisible()
|
||||
await page.screenshot({ path: join(SHOTS, "cd-fleet-desktop.png"), fullPage: true })
|
||||
|
||||
// Tap the app's NAME cell (always visible; the row overflows on narrow tables).
|
||||
await page.locator(".hz-gitops-row", { hasText: "iam" }).first().locator("td").first().click()
|
||||
await expect(page.getByText("← Fleet").first()).toBeVisible({ timeout: 10_000 }) // detail-only breadcrumb
|
||||
await expect(page.locator(".hz-gitops-tree-world").first()).toBeVisible({ timeout: 10_000 })
|
||||
await expect(page.locator("text=Deployment").first()).toBeVisible()
|
||||
await page.screenshot({ path: join(SHOTS, "cd-detail-desktop.png"), fullPage: true })
|
||||
await ctx.close()
|
||||
})
|
||||
|
||||
test("reflows with no horizontal body scroll at a narrow (mobile) viewport", async ({ browser }) => {
|
||||
const ctx = await browser.newContext({ viewport: { width: 390, height: 844 } })
|
||||
const page = await open(ctx)
|
||||
|
||||
await expect(page.locator("tr", { hasText: "iam" }).first()).toBeVisible()
|
||||
const overflow = await page.evaluate(() => ({ sw: document.documentElement.scrollWidth, cw: document.documentElement.clientWidth }))
|
||||
expect(overflow.sw, "no horizontal body scroll at 390px").toBeLessThanOrEqual(overflow.cw + 1)
|
||||
await page.screenshot({ path: join(SHOTS, "cd-fleet-mobile.png"), fullPage: true })
|
||||
|
||||
// Tap-to-open must work on mobile: tap the name cell → the ArgoCD-grade detail.
|
||||
await page.locator(".hz-gitops-row", { hasText: "iam" }).first().locator("td").first().click()
|
||||
await expect(page.getByText("← Fleet").first()).toBeVisible({ timeout: 10_000 })
|
||||
await expect(page.locator(".hz-gitops-tree-world").first()).toBeVisible({ timeout: 10_000 })
|
||||
const overflow2 = await page.evaluate(() => ({ sw: document.documentElement.scrollWidth, cw: document.documentElement.clientWidth }))
|
||||
expect(overflow2.sw, "no horizontal body scroll on the detail at 390px").toBeLessThanOrEqual(overflow2.cw + 1)
|
||||
await page.screenshot({ path: join(SHOTS, "cd-detail-mobile.png"), fullPage: true })
|
||||
await ctx.close()
|
||||
})
|
||||
@@ -1,17 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en" data-theme="dark">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<title>Hanzo CD</title>
|
||||
<link
|
||||
rel="icon"
|
||||
type="image/svg+xml"
|
||||
href="data:image/svg+xml,%3Csvg viewBox='0 0 64 64' xmlns='http://www.w3.org/2000/svg'%3E%3Crect width='64' height='64' rx='8' fill='%23000'/%3E%3Cg transform='translate(8,8) scale(0.716)'%3E%3Cpath d='M22.21 67V44.6369H0V67H22.21Z' fill='%23fff'/%3E%3Cpath d='M66.7038 22.3184H22.2534L0.0878906 44.6367H44.4634L66.7038 22.3184Z' fill='%23fff'/%3E%3Cpath d='M22.21 0H0V22.3184H22.21V0Z' fill='%23fff'/%3E%3Cpath d='M66.7198 0H44.5098V22.3184H66.7198V0Z' fill='%23fff'/%3E%3Cpath d='M66.7198 67V44.6369H44.5098V67H66.7198Z' fill='%23fff'/%3E%3C/g%3E%3C/svg%3E"
|
||||
/>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,26 +0,0 @@
|
||||
{
|
||||
"name": "cd",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"description": "Hanzo CD — the dedicated deploy dashboard served at cd.hanzo.ai (a @hanzo/cd + @hanzo/canvas SPA over /v1/deploy). Replaces the ArgoCD UI fork.",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"typecheck": "tsc --noEmit -p tsconfig.app.json",
|
||||
"preview": "vite preview --port 4173"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hanzo/canvas": "workspace:*",
|
||||
"@hanzo/cd": "workspace:*",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.3",
|
||||
"typescript": "~6.0.2",
|
||||
"vite": "^8.1.4"
|
||||
}
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
import { defineConfig, devices } from "@playwright/test"
|
||||
|
||||
/**
|
||||
* Hanzo CD render + responsive proof. Builds the static SPA and serves it (vite
|
||||
* preview), then the spec mocks `/v1/deploy/*` + a session cookie and asserts the
|
||||
* fleet renders, a row opens the ArgoCD-grade detail (sync panel + resource tree),
|
||||
* and the body never scrolls horizontally at 390px. Screenshots per width.
|
||||
*
|
||||
* pnpm --filter cd exec playwright test # builds + serves + runs
|
||||
* BASE_URL=https://cd.hanzo.ai pnpm … test # against a live deploy
|
||||
*/
|
||||
export default defineConfig({
|
||||
testDir: "./e2e",
|
||||
timeout: 60_000,
|
||||
retries: 1,
|
||||
workers: 1,
|
||||
reporter: "list",
|
||||
use: { baseURL: process.env.BASE_URL ?? "http://localhost:4173", headless: true },
|
||||
webServer: process.env.BASE_URL
|
||||
? undefined
|
||||
: {
|
||||
command: "npx vite build && npx vite preview --port 4173 --strictPort",
|
||||
url: "http://localhost:4173",
|
||||
reuseExistingServer: true,
|
||||
timeout: 120_000,
|
||||
},
|
||||
projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }],
|
||||
})
|
||||
@@ -1 +0,0 @@
|
||||
cd.hanzo.ai
|
||||
@@ -1,143 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Hanzo CD — Sign in</title>
|
||||
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg viewBox='0 0 64 64' xmlns='http://www.w3.org/2000/svg'%3E%3Crect width='64' height='64' rx='8' fill='%23000'/%3E%3Cg transform='translate(8,8) scale(0.716)'%3E%3Cpath d='M22.21 67V44.6369H0V67H22.21Z' fill='%23fff'/%3E%3Cpath d='M66.7038 22.3184H22.2534L0.0878906 44.6367H44.4634L66.7038 22.3184Z' fill='%23fff'/%3E%3Cpath d='M22.21 0H0V22.3184H22.21V0Z' fill='%23fff'/%3E%3Cpath d='M66.7198 0H44.5098V22.3184H66.7198V0Z' fill='%23fff'/%3E%3Cpath d='M66.7198 67V44.6369H44.5098V67H66.7198Z' fill='%23fff'/%3E%3C/g%3E%3C/svg%3E">
|
||||
<style>
|
||||
:root { color-scheme: light dark; }
|
||||
* { box-sizing: border-box; }
|
||||
html,body { height:100%; margin:0; }
|
||||
body {
|
||||
font-family: "Geist", "Geist Sans", ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||||
background:#0a0a0a; color:#e7e7e7;
|
||||
display:grid; grid-template-columns: 1.1fr 1fr; min-height:100vh;
|
||||
}
|
||||
@media (max-width: 820px){ body{ grid-template-columns:1fr; } .pitch{ display:none; } }
|
||||
.pitch { padding:64px 56px; display:flex; flex-direction:column; justify-content:center; gap:22px;
|
||||
border-right:1px solid #1c1c1c; }
|
||||
.brand { display:flex; align-items:center; gap:12px; font-size:22px; font-weight:700; letter-spacing:-.02em; }
|
||||
.brand .mark { width:26px; height:26px; display:block; }
|
||||
h1 { font-size:40px; line-height:1.08; margin:6px 0 0; letter-spacing:-.03em; font-weight:800; }
|
||||
.sub { color:#9a9a9a; font-size:16px; max-width:38ch; line-height:1.5; }
|
||||
ul.feat { list-style:none; padding:0; margin:8px 0 0; display:grid; gap:12px; }
|
||||
ul.feat li { display:flex; gap:12px; align-items:flex-start; color:#cfcfcf; font-size:14.5px; }
|
||||
ul.feat li b { color:#fff; font-weight:600; }
|
||||
ul.feat .dot { width:6px; height:6px; border-radius:50%; background:#fff; margin-top:7px; flex:none; }
|
||||
.auth { display:flex; align-items:center; justify-content:center; padding:40px; }
|
||||
.card { width:100%; max-width:360px; text-align:center; }
|
||||
.card .mark2 { width:52px; height:52px; display:block; margin:0 auto 18px; }
|
||||
.card h2 { font-size:22px; margin:0 0 6px; font-weight:700; }
|
||||
.card p.hint { color:#8f8f8f; font-size:14px; margin:0 0 26px; }
|
||||
button#login {
|
||||
width:100%; padding:14px 18px; font-size:15px; font-weight:600; cursor:pointer;
|
||||
background:#fff; color:#000; border:0; border-radius:10px; display:flex; align-items:center;
|
||||
justify-content:center; gap:10px; transition:opacity .15s;
|
||||
}
|
||||
button#login:hover { opacity:.88; }
|
||||
button#login:disabled { opacity:.5; cursor:default; }
|
||||
.status { margin-top:16px; font-size:13.5px; color:#9a9a9a; min-height:20px; }
|
||||
.status.err { color:#ff7a7a; }
|
||||
.foot { margin-top:28px; font-size:12px; color:#5c5c5c; }
|
||||
a { color:#bdbdbd; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<section class="pitch">
|
||||
<div class="brand"><svg class="mark" viewBox="0 0 67 67" xmlns="http://www.w3.org/2000/svg" aria-hidden="true"><path d="M22.21 67V44.6369H0V67H22.21Z" fill="#fff"/><path d="M66.7038 22.3184H22.2534L0.0878906 44.6367H44.4634L66.7038 22.3184Z" fill="#fff"/><path d="M22.21 0H0V22.3184H22.21V0Z" fill="#fff"/><path d="M66.7198 0H44.5098V22.3184H66.7198V0Z" fill="#fff"/><path d="M66.7198 67V44.6369H44.5098V67H66.7198Z" fill="#fff"/></svg> Hanzo CD</div>
|
||||
<h1>Continuous delivery for your whole fleet.</h1>
|
||||
<p class="sub">GitOps for every Hanzo App. Declarative, versioned, and auto-synced from git to your clusters — one control plane across hanzo, lux, zoo & pars.</p>
|
||||
<ul class="feat">
|
||||
<li><span class="dot"></span><span><b>Live fleet view</b> — health & sync status for every App across all namespaces.</span></li>
|
||||
<li><span class="dot"></span><span><b>Git-to-cluster</b> — desired state lives in <code>infra/k8s/operator/crs</code>, reconciled continuously.</span></li>
|
||||
<li><span class="dot"></span><span><b>Sync & rollback</b> — one click to reconcile or roll back any application.</span></li>
|
||||
<li><span class="dot"></span><span><b>Resource trees & logs</b> — drill into any workload the operator manages.</span></li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="auth">
|
||||
<div class="card">
|
||||
<svg class="mark2" viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg" aria-hidden="true"><rect width="64" height="64" rx="14" fill="#fff"/><g transform="translate(8,8) scale(0.716)"><path d="M22.21 67V44.6369H0V67H22.21Z" fill="#000"/><path d="M66.7038 22.3184H22.2534L0.0878906 44.6367H44.4634L66.7038 22.3184Z" fill="#000"/><path d="M22.21 0H0V22.3184H22.21V0Z" fill="#000"/><path d="M66.7198 0H44.5098V22.3184H66.7198V0Z" fill="#000"/><path d="M66.7198 67V44.6369H44.5098V67H66.7198Z" fill="#000"/></g></svg>
|
||||
<h2>Hanzo CD</h2>
|
||||
<p class="hint">Sign in with your Hanzo account to manage the fleet.</p>
|
||||
<button id="login">
|
||||
<svg width="17" height="17" viewBox="0 0 67 67" xmlns="http://www.w3.org/2000/svg" aria-hidden="true"><path d="M22.21 67V44.6369H0V67H22.21Z" fill="currentColor"/><path d="M66.7038 22.3184H22.2534L0.0878906 44.6367H44.4634L66.7038 22.3184Z" fill="currentColor"/><path d="M22.21 0H0V22.3184H22.21V0Z" fill="currentColor"/><path d="M66.7198 0H44.5098V22.3184H66.7198V0Z" fill="currentColor"/><path d="M66.7198 67V44.6369H44.5098V67H66.7198Z" fill="currentColor"/></svg>
|
||||
Sign in with Hanzo
|
||||
</button>
|
||||
<div class="status" id="status"></div>
|
||||
<div class="foot">Powered by <a href="https://hanzo.id" rel="noreferrer">Hanzo IAM</a> · SuperAdmin access</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
var IAM = 'https://hanzo.id';
|
||||
var CLIENT = 'admin-console'; // the IAM app whose org is `admin`
|
||||
var REDIRECT = location.origin + '/login.html'; // must be registered on admin-console
|
||||
var COOKIE = 'hanzo_iam_token'; // the cookie hanzoai/cloud validates
|
||||
|
||||
var statusEl = document.getElementById('status');
|
||||
var btn = document.getElementById('login');
|
||||
function setStatus(m, err){ statusEl.textContent = m || ''; statusEl.className = 'status' + (err ? ' err' : ''); }
|
||||
|
||||
function b64url(bytes){
|
||||
var s = btoa(String.fromCharCode.apply(null, new Uint8Array(bytes)));
|
||||
return s.replace(/\+/g,'-').replace(/\//g,'_').replace(/=+$/,'');
|
||||
}
|
||||
function randB64(n){ return b64url(crypto.getRandomValues(new Uint8Array(n))); }
|
||||
async function sha256b64(str){
|
||||
var d = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(str));
|
||||
return b64url(d);
|
||||
}
|
||||
|
||||
async function startLogin(){
|
||||
btn.disabled = true; setStatus('Redirecting to Hanzo…');
|
||||
var verifier = randB64(48);
|
||||
var state = randB64(16);
|
||||
sessionStorage.setItem('cd_pkce_verifier', verifier);
|
||||
sessionStorage.setItem('cd_oauth_state', state);
|
||||
var challenge = await sha256b64(verifier);
|
||||
var u = new URL(IAM + '/login/oauth/authorize');
|
||||
u.search = new URLSearchParams({
|
||||
client_id: CLIENT, response_type: 'code', redirect_uri: REDIRECT,
|
||||
scope: 'openid profile email', state: state,
|
||||
code_challenge: challenge, code_challenge_method: 'S256'
|
||||
}).toString();
|
||||
location.href = u.toString();
|
||||
}
|
||||
|
||||
async function finishLogin(code, state){
|
||||
btn.disabled = true; setStatus('Signing in…');
|
||||
if (state !== sessionStorage.getItem('cd_oauth_state')) { setStatus('Sign-in state mismatch — please try again.', true); btn.disabled = false; return; }
|
||||
var verifier = sessionStorage.getItem('cd_pkce_verifier');
|
||||
if (!verifier) { setStatus('Sign-in session lost — please try again.', true); btn.disabled = false; return; }
|
||||
try {
|
||||
var r = await fetch(IAM + '/v1/iam/oauth/access_token', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({
|
||||
grant_type: 'authorization_code', client_id: CLIENT, code: code,
|
||||
redirect_uri: REDIRECT, code_verifier: verifier
|
||||
})
|
||||
});
|
||||
var j = await r.json();
|
||||
if (!j.access_token) { setStatus('Sign-in failed: ' + (j.error_description || j.error || 'no token'), true); btn.disabled = false; return; }
|
||||
// set the session cookie cloud reads (non-httpOnly by design so the page can set it; still Secure)
|
||||
document.cookie = COOKIE + '=' + j.access_token + '; path=/; secure; samesite=lax; max-age=86400';
|
||||
sessionStorage.removeItem('cd_pkce_verifier'); sessionStorage.removeItem('cd_oauth_state');
|
||||
setStatus('Signed in. Loading the dashboard…');
|
||||
location.replace('/');
|
||||
} catch (e) {
|
||||
setStatus('Sign-in error: ' + (e && e.message ? e.message : e), true); btn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
var q = new URLSearchParams(location.search);
|
||||
if (q.get('error')) { setStatus('Hanzo returned: ' + (q.get('error_description') || q.get('error')), true); }
|
||||
else if (q.get('code')) { finishLogin(q.get('code'), q.get('state')); }
|
||||
btn.onclick = startLogin;
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,171 +0,0 @@
|
||||
/**
|
||||
* Hanzo CD — the dedicated deploy dashboard (cd.hanzo.ai). A static SPA over the
|
||||
* cloud CD plane (`/v1/deploy`): the fleet of operator App CRs with health, sync,
|
||||
* resource topology, logs, and sync/rollback, built on the shared @hanzo/cd
|
||||
* components. Auth is the first-party `hanzo_iam_token` cookie (set by the PKCE
|
||||
* login); a missing/expired session lands on the sign-in screen — never a fake row.
|
||||
*/
|
||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||
|
||||
import { DeployApi, hasSession, isAuthError } from "./lib/deploy"
|
||||
import type { DeployApp } from "./lib/adapt"
|
||||
import { Topbar, type EnvOption } from "./shell/Topbar"
|
||||
import { FleetView } from "./views/FleetView"
|
||||
import { AppView } from "./views/AppView"
|
||||
|
||||
const POLL_MS = 20_000
|
||||
|
||||
type Phase = "signin" | "loading" | "error" | "ready"
|
||||
|
||||
const parseHash = (): string => {
|
||||
const m = /^#\/app\/(.+)$/.exec(location.hash)
|
||||
return m ? decodeURIComponent(m[1]) : ""
|
||||
}
|
||||
|
||||
function SignIn() {
|
||||
return (
|
||||
<div style={{ minHeight: "100vh", display: "grid", placeItems: "center", padding: 24 }}>
|
||||
<div style={{ textAlign: "center", maxWidth: 360 }}>
|
||||
<svg width={52} height={52} viewBox="0 0 64 64" style={{ marginBottom: 18 }} xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
||||
<rect width="64" height="64" rx="14" fill="#fff" />
|
||||
<g transform="translate(8,8) scale(0.716)">
|
||||
<path d="M22.21 67V44.6369H0V67H22.21Z" fill="#000" />
|
||||
<path d="M66.7038 22.3184H22.2534L0.0878906 44.6367H44.4634L66.7038 22.3184Z" fill="#000" />
|
||||
<path d="M22.21 0H0V22.3184H22.21V0Z" fill="#000" />
|
||||
<path d="M66.7198 0H44.5098V22.3184H66.7198V0Z" fill="#000" />
|
||||
<path d="M66.7198 67V44.6369H44.5098V67H66.7198Z" fill="#000" />
|
||||
</g>
|
||||
</svg>
|
||||
<h2 style={{ fontSize: 22, margin: "0 0 6px", fontWeight: 700 }}>Hanzo CD</h2>
|
||||
<p className="cd-muted" style={{ margin: "0 0 26px", fontSize: 14 }}>
|
||||
Sign in with your Hanzo account to manage the fleet.
|
||||
</p>
|
||||
<button type="button" className="cd-btn cd-btn--primary" style={{ width: "100%", justifyContent: "center", padding: "13px 18px" }} onClick={() => (location.href = "/login.html")}>
|
||||
Sign in with Hanzo
|
||||
</button>
|
||||
<div className="cd-muted" style={{ marginTop: 28, fontSize: 12 }}>
|
||||
Powered by Hanzo IAM · SuperAdmin access
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function App() {
|
||||
const [phase, setPhase] = useState<Phase>(hasSession() ? "loading" : "signin")
|
||||
const [apps, setApps] = useState<DeployApp[]>([])
|
||||
const [errMsg, setErrMsg] = useState("")
|
||||
const [env, setEnv] = useState("")
|
||||
const [selected, setSelected] = useState<string>(parseHash())
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
const [toast, setToast] = useState<{ msg: string; err?: boolean } | null>(null)
|
||||
|
||||
const load = useCallback(() => {
|
||||
if (!hasSession()) {
|
||||
setPhase("signin")
|
||||
return
|
||||
}
|
||||
setRefreshing(true)
|
||||
DeployApi.applications()
|
||||
.then((rows) => {
|
||||
setApps(rows)
|
||||
setPhase("ready")
|
||||
})
|
||||
.catch((e) => {
|
||||
if (isAuthError(e)) setPhase("signin")
|
||||
else {
|
||||
setPhase("error")
|
||||
setErrMsg(e instanceof Error ? e.message : "failed to load")
|
||||
}
|
||||
})
|
||||
.finally(() => setRefreshing(false))
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
load()
|
||||
const t = setInterval(load, POLL_MS)
|
||||
const onHash = () => setSelected(parseHash())
|
||||
window.addEventListener("hashchange", onHash)
|
||||
return () => {
|
||||
clearInterval(t)
|
||||
window.removeEventListener("hashchange", onHash)
|
||||
}
|
||||
}, [load])
|
||||
|
||||
const notify = useCallback((msg: string, err?: boolean) => {
|
||||
setToast({ msg, err })
|
||||
setTimeout(() => setToast(null), 4000)
|
||||
}, [])
|
||||
|
||||
const envs: EnvOption[] = useMemo(() => {
|
||||
const counts = new Map<string, number>()
|
||||
for (const a of apps) if (a.env) counts.set(a.env, (counts.get(a.env) ?? 0) + 1)
|
||||
return Array.from(counts, ([id, count]) => ({ id, label: id, count })).sort((x, y) => x.id.localeCompare(y.id))
|
||||
}, [apps])
|
||||
|
||||
const visibleApps = useMemo(() => (env ? apps.filter((a) => a.env === env) : apps), [apps, env])
|
||||
const selectedApp = selected ? apps.find((a) => a.name === selected) ?? null : null
|
||||
|
||||
const openApp = (name: string) => {
|
||||
location.hash = `#/app/${encodeURIComponent(name)}`
|
||||
}
|
||||
const backToFleet = () => {
|
||||
location.hash = ""
|
||||
}
|
||||
const signOut = () => {
|
||||
document.cookie = "hanzo_iam_token=; path=/; max-age=0"
|
||||
location.href = "/login.html"
|
||||
}
|
||||
|
||||
if (phase === "signin") return <SignIn />
|
||||
|
||||
return (
|
||||
<div className="cd-app">
|
||||
<Topbar envs={envs} env={env} onEnv={setEnv} onRefresh={load} refreshing={refreshing} onSignOut={signOut} />
|
||||
<main className="cd-main">
|
||||
{phase === "loading" ? (
|
||||
<div className="cd-muted" style={{ padding: 40 }}>
|
||||
Loading the fleet…
|
||||
</div>
|
||||
) : phase === "error" ? (
|
||||
<div style={{ padding: 24, border: "1px solid var(--cd-border-strong)", borderRadius: 10, background: "var(--cd-surface)" }}>
|
||||
<div style={{ fontWeight: 700, marginBottom: 6 }}>Could not reach the deploy plane</div>
|
||||
<div className="cd-muted" style={{ fontSize: 14, marginBottom: 14 }}>
|
||||
{errMsg || "The CD read (GET /v1/deploy/applications) failed."}
|
||||
</div>
|
||||
<button type="button" className="cd-btn" onClick={load}>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
) : selectedApp ? (
|
||||
<AppView app={selectedApp} onBack={backToFleet} onChanged={load} notify={notify} />
|
||||
) : (
|
||||
<FleetView apps={visibleApps} onOpen={openApp} />
|
||||
)}
|
||||
</main>
|
||||
|
||||
{toast ? (
|
||||
<div
|
||||
role="status"
|
||||
style={{
|
||||
position: "fixed",
|
||||
bottom: 18,
|
||||
left: "50%",
|
||||
transform: "translateX(-50%)",
|
||||
zIndex: 1000,
|
||||
maxWidth: "calc(100vw - 32px)",
|
||||
padding: "11px 16px",
|
||||
borderRadius: 10,
|
||||
background: toast.err ? "#3d1418" : "var(--cd-surface-2)",
|
||||
border: `1px solid ${toast.err ? "#E96D76" : "var(--cd-border-strong)"}`,
|
||||
color: "var(--cd-fg-strong)",
|
||||
fontSize: 13.5,
|
||||
boxShadow: "0 8px 30px rgba(0,0,0,0.4)",
|
||||
}}
|
||||
>
|
||||
{toast.msg}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,261 +0,0 @@
|
||||
/**
|
||||
* RED adversarial suite — feeds hostile / malformed /v1/deploy JSON to the adapter
|
||||
* and the shared @hanzo/cd folds, asserting the view degrades honestly and
|
||||
* NEVER (a) crashes, (b) wedges the tree builder into a hang, or (c) mislabels a
|
||||
* bad state as Healthy/Synced. Written by Red; not part of Blue's suite.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest"
|
||||
import { buildResourceGraph, foldHealth, foldSync } from "@hanzo/cd"
|
||||
|
||||
import {
|
||||
manifestText,
|
||||
normalizeDeployApp,
|
||||
parseApplications,
|
||||
toGitopsApp,
|
||||
toLogLines,
|
||||
toManagedResource,
|
||||
toResourceTree,
|
||||
toRollbackHistory,
|
||||
} from "./adapt"
|
||||
|
||||
// A wall-clock guard: proves a call TERMINATES (no infinite loop) fast.
|
||||
function within<T>(ms: number, fn: () => T): T {
|
||||
const t0 = Date.now()
|
||||
const out = fn()
|
||||
const dt = Date.now() - t0
|
||||
expect(dt).toBeLessThan(ms)
|
||||
return out
|
||||
}
|
||||
|
||||
// ── A. Robustness: hostile top-level inputs never throw ──────────────────────
|
||||
|
||||
describe("RED robustness — malformed payloads never crash", () => {
|
||||
const junk: unknown[] = [null, undefined, 42, "str", true, [], {}, { applications: null }, { apps: 7 }, NaN]
|
||||
|
||||
it("parseApplications tolerates any junk → array, never throws", () => {
|
||||
for (const j of junk) expect(Array.isArray(parseApplications(j))).toBe(true)
|
||||
})
|
||||
it("normalizeDeployApp of junk → safe defaults", () => {
|
||||
for (const j of junk) {
|
||||
const a = normalizeDeployApp(j)
|
||||
expect(typeof a.name).toBe("string")
|
||||
expect(a.namespace).toBe("hanzo") // default
|
||||
expect(Array.isArray(a.endpoints)).toBe(true)
|
||||
}
|
||||
})
|
||||
it("toResourceTree / toManagedResource / toLogLines tolerate junk", () => {
|
||||
for (const j of junk) {
|
||||
expect(Array.isArray(toResourceTree(j).nodes)).toBe(true)
|
||||
expect(() => toManagedResource(j)).not.toThrow()
|
||||
expect(Array.isArray(toLogLines(j))).toBe(true)
|
||||
}
|
||||
})
|
||||
it("wrong-typed fields (health object, numeric name) degrade to empty, dropped", () => {
|
||||
const rows = parseApplications({ applications: [{ name: 123, health: { evil: 1 }, sync: ["x"] }, { name: "ok" }] })
|
||||
expect(rows.map((r) => r.name)).toEqual(["ok"]) // numeric-name row dropped
|
||||
const g = toGitopsApp(normalizeDeployApp({ name: "z", health: { nested: true } }))
|
||||
expect(g.health).toBe("Unknown") // object health → '' → Unknown, NOT guessed up
|
||||
})
|
||||
})
|
||||
|
||||
// ── B. CYCLE SAFETY — the headline #3 case ───────────────────────────────────
|
||||
// Adversarial parentRefs must not wedge buildResourceGraph into an infinite loop.
|
||||
|
||||
const treeJSON = (nodes: object[]) => toResourceTree({ nodes })
|
||||
const node = (ref: string, kind: string, parents: string[] = []) => ({
|
||||
ref,
|
||||
kind,
|
||||
name: ref,
|
||||
namespace: "demo",
|
||||
parentRefs: parents.map((p) => ({ ref: p })),
|
||||
})
|
||||
|
||||
describe("RED cycle safety — buildResourceGraph terminates on hostile parentRefs", () => {
|
||||
it("self-referencing parent (A→A) terminates; node survives as a root", () => {
|
||||
const g = within(1000, () => buildResourceGraph(treeJSON([node("A", "Pod", ["A"])])))
|
||||
expect(g.nodes.map((n) => n.id)).toContain("A")
|
||||
expect(g.nodes.find((n) => n.id === "A")!.depth).toBe(0)
|
||||
expect(g.edges).toHaveLength(0) // self-edge dropped
|
||||
})
|
||||
|
||||
// ── HIGH-1 (FIXED): buildResourceGraph used to THROW whenever a node's parent
|
||||
// appeared LATER in the array — `children` was seeded lazily per-node, so
|
||||
// `children.get(pid)!.push(id)` dereferenced undefined. That is EVERY cycle AND
|
||||
// any tree listing a child before its parent (K8s lists are not topologically
|
||||
// sorted), and with no error boundary the whole SPA white-screened. `children`
|
||||
// is now pre-seeded for every id, so order and cycles are both safe. These are
|
||||
// the regression tests: each MUST build a graph, never throw. ───────────────
|
||||
it("BENIGN acyclic tree that lists a child before its parent builds", () => {
|
||||
// Deployment listed before the Application that owns it — a totally normal,
|
||||
// non-adversarial ordering a backend may emit.
|
||||
const g = within(1000, () =>
|
||||
buildResourceGraph(treeJSON([node("dep", "Deployment", ["app"]), node("app", "Application")])),
|
||||
)
|
||||
expect(g.nodes.map((n) => n.id).sort()).toEqual(["app", "dep"])
|
||||
expect(g.nodes.find((n) => n.id === "app")!.depth).toBe(0)
|
||||
expect(g.nodes.find((n) => n.id === "dep")!.depth).toBe(1)
|
||||
})
|
||||
|
||||
it("2-cycle with NO external root (A↔B) does not crash", () => {
|
||||
const g = within(1000, () =>
|
||||
buildResourceGraph(treeJSON([node("A", "Pod", ["B"]), node("B", "Pod", ["A"])])),
|
||||
)
|
||||
expect(Array.isArray(g.nodes)).toBe(true) // unreachable-from-a-root nodes may drop; must not throw
|
||||
})
|
||||
|
||||
it("2-cycle WITH an external root (R→A, A↔B) renders R, A, B", () => {
|
||||
const g = within(1000, () =>
|
||||
buildResourceGraph(treeJSON([node("R", "App"), node("A", "Pod", ["R", "B"]), node("B", "Pod", ["A"])])),
|
||||
)
|
||||
expect(g.nodes.map((n) => n.id).sort()).toEqual(["A", "B", "R"])
|
||||
})
|
||||
|
||||
it("3-cycle (A→B→C→A) with a root renders 4 nodes", () => {
|
||||
const g = within(1000, () =>
|
||||
buildResourceGraph(
|
||||
treeJSON([node("R", "App"), node("A", "Pod", ["R", "C"]), node("B", "Pod", ["A"]), node("C", "Pod", ["B"])]),
|
||||
),
|
||||
)
|
||||
expect(g.nodes.map((n) => n.id).sort()).toEqual(["A", "B", "C", "R"])
|
||||
})
|
||||
|
||||
it("dangling parent (points at a non-existent uid) → node is a root, no crash", () => {
|
||||
const g = within(1000, () => buildResourceGraph(treeJSON([node("A", "Pod", ["ghost-does-not-exist"])])))
|
||||
expect(g.nodes.find((n) => n.id === "A")!.depth).toBe(0)
|
||||
})
|
||||
|
||||
it("large deep chain (2000 nodes) terminates quickly", () => {
|
||||
const chain = Array.from({ length: 2000 }, (_, i) => node(`n${i}`, "Pod", i ? [`n${i - 1}`] : []))
|
||||
const g = within(6000, () => buildResourceGraph(treeJSON(chain)))
|
||||
expect(g.nodes.length).toBe(2000)
|
||||
})
|
||||
|
||||
it("every node self-parents (all self-cycles) → all render as roots, terminates", () => {
|
||||
const many = Array.from({ length: 500 }, (_, i) => node(`s${i}`, "Pod", [`s${i}`]))
|
||||
const g = within(4000, () => buildResourceGraph(treeJSON(many)))
|
||||
expect(g.nodes.length).toBe(500)
|
||||
})
|
||||
})
|
||||
|
||||
// ── C. MISLABEL — the fold's one dangerous direction: bad → Healthy/Synced ────
|
||||
|
||||
describe("RED mislabel (FIXED) — a BAD state is never up-guessed to a GOOD one", () => {
|
||||
it("health words that MEAN broken never fold to Healthy", () => {
|
||||
// The positive substring up-guess is gone: unrecognized → honest Unknown.
|
||||
expect(foldHealth("Broken")).toBe("Unknown") // contains 'ok'
|
||||
expect(foldHealth("revoked")).toBe("Unknown") // cert/token revoked
|
||||
expect(foldHealth("invoked")).toBe("Unknown")
|
||||
expect(foldHealth("NotReady")).toBe("Unknown") // contains 'ready'
|
||||
expect(foldHealth("unavailable")).toBe("Unknown") // contains 'available'
|
||||
expect(foldHealth("MinimumReplicasUnavailable")).toBe("Unknown")
|
||||
// The canonical vocabulary cloud actually emits still folds exactly:
|
||||
expect(foldHealth("healthy")).toBe("Healthy")
|
||||
expect(foldHealth("")).toBe("Unknown")
|
||||
expect(foldHealth("degraded")).toBe("Degraded")
|
||||
expect(foldHealth("CrashLoopBackOff")).toBe("Degraded") // bad-substring — safe direction
|
||||
})
|
||||
|
||||
it("sync words that MEAN not-synced never fold to Synced", () => {
|
||||
expect(foldSync("notsynced")).toBe("Unknown") // means NOT synced
|
||||
expect(foldSync("unsynced")).toBe("Unknown")
|
||||
expect(foldSync("NotSynced")).toBe("Unknown")
|
||||
// Canonical + separator-normalized cases still fold exactly:
|
||||
expect(foldSync("synced")).toBe("Synced")
|
||||
expect(foldSync("out-of-sync")).toBe("OutOfSync") // hyphens normalized at the source
|
||||
expect(foldSync("out-of-sync".replace(/[-_]/g, ""))).toBe("OutOfSync")
|
||||
expect(foldSync("")).toBe("Unknown")
|
||||
})
|
||||
|
||||
it("SAFE: a Degraded app is never shown Healthy through the adapter", () => {
|
||||
const g = toGitopsApp(normalizeDeployApp({ name: "x", health: "degraded" }))
|
||||
expect(g.health).toBe("Degraded")
|
||||
})
|
||||
})
|
||||
|
||||
// ── D. Honest sync derivation (no fabricated Synced) ─────────────────────────
|
||||
|
||||
describe("RED honest sync — derived state does not fabricate Synced", () => {
|
||||
it("missing sync + version drift → OutOfSync", () => {
|
||||
expect(toGitopsApp(normalizeDeployApp({ name: "a", version: "v2", runningVersion: "v1" })).sync).toBe("OutOfSync")
|
||||
})
|
||||
it("missing sync + equal version → Synced (declared==running proxy)", () => {
|
||||
expect(toGitopsApp(normalizeDeployApp({ name: "a", version: "v1", runningVersion: "v1" })).sync).toBe("Synced")
|
||||
})
|
||||
it("missing sync + BOTH versions empty → Synced (''==''), a benign over-report", () => {
|
||||
// documents: an app the plane reports with no versions folds to Synced, not Unknown.
|
||||
expect(toGitopsApp(normalizeDeployApp({ name: "a" })).sync).toBe("Synced")
|
||||
})
|
||||
})
|
||||
|
||||
// ── E. Manifest handling — object/string/circular ────────────────────────────
|
||||
|
||||
describe("RED manifest — object/string/circular never throw", () => {
|
||||
it("circular object manifest → '' (no throw)", () => {
|
||||
const circular: Record<string, unknown> = { kind: "Deployment" }
|
||||
circular.self = circular
|
||||
expect(manifestText(circular)).toBe("")
|
||||
const m = toManagedResource({ ref: "r", liveManifest: circular })
|
||||
expect(m.liveState).toBe("")
|
||||
})
|
||||
it("object manifest → pretty JSON; string manifest → verbatim; null → ''", () => {
|
||||
expect(toManagedResource({ ref: "r", liveManifest: { a: 1 } }).liveState).toContain('"a": 1')
|
||||
expect(toManagedResource({ ref: "r", liveManifest: "raw yaml here" }).liveState).toBe("raw yaml here")
|
||||
expect(toManagedResource({ ref: "r", liveManifest: null }).liveState).toBe("")
|
||||
})
|
||||
it("a manifest string carrying HTML is passed through as TEXT (React escapes on render)", () => {
|
||||
const evil = "<img src=x onerror=alert(document.cookie)>"
|
||||
// The adapter must NOT interpret it; it stays a plain string (rendered via {text}).
|
||||
expect(toManagedResource({ ref: "r", liveManifest: evil }).liveState).toBe(evil)
|
||||
})
|
||||
})
|
||||
|
||||
// ── F. Logs — array gap + unbounded blob (no client cap) ─────────────────────
|
||||
|
||||
describe("RED logs (FIXED) — fidelity + bound", () => {
|
||||
it("logs delivered as an ARRAY yield lines (blob AND array shapes)", () => {
|
||||
expect(toLogLines({ pod: "p", logs: ["line-1", "line-2"] })).toEqual([
|
||||
{ content: "line-1", podName: "p" },
|
||||
{ content: "line-2", podName: "p" },
|
||||
])
|
||||
// the blob shape still works
|
||||
expect(toLogLines({ pod: "p", logs: "a\nb" }).map((l) => l.content)).toEqual(["a", "b"])
|
||||
})
|
||||
it("a huge blob is line-capped client-side (keeps the most recent lines)", () => {
|
||||
const blob = Array.from({ length: 50_000 }, (_, i) => `l${i}`).join("\n")
|
||||
const out = toLogLines({ pod: "p", logs: blob })
|
||||
expect(out.length).toBe(2000) // MAX_LOG_LINES
|
||||
expect(out[out.length - 1].content).toBe("l49999") // tail kept, not head
|
||||
})
|
||||
it("a single enormous line is clamped (no one-megabyte text node)", () => {
|
||||
const huge = "x".repeat(50_000)
|
||||
const out = toLogLines({ pod: "p", logs: huge })
|
||||
expect(out[0].content.length).toBeLessThanOrEqual(4001) // MAX_LOG_LINE + ellipsis
|
||||
})
|
||||
})
|
||||
|
||||
// ── G. Rollback semver filter — injection + hygiene ──────────────────────────
|
||||
|
||||
describe("RED rollback — only clean semver, current excluded, no injection", () => {
|
||||
it("drops non-semver + injection-shaped tags, excludes current, newest-first, dedupes", () => {
|
||||
const h = toRollbackHistory("v1.800.1", [
|
||||
"v1.800.0",
|
||||
"latest",
|
||||
"main",
|
||||
"deadbeef",
|
||||
"v1.800.1", // == current → excluded
|
||||
"v1.799.15",
|
||||
"v1.799.15", // dup
|
||||
"v1.2.3; rm -rf /", // injection shape → not semver → dropped
|
||||
"'; DROP TABLE apps;--",
|
||||
])
|
||||
expect(h.map((r) => r.revision)).toEqual(["v1.800.0", "v1.799.15"])
|
||||
expect(h[0].id).toBeGreaterThan(h[1].id)
|
||||
})
|
||||
it("pre-release semver IS offered as a rollback target (documents the accepted grammar)", () => {
|
||||
const h = toRollbackHistory("v2.0.0", ["v1.9.9-rc.1", "v1.9.9"])
|
||||
expect(h.map((r) => r.revision)).toContain("v1.9.9-rc.1")
|
||||
})
|
||||
it("empty history → []", () => {
|
||||
expect(toRollbackHistory("v1.0.0", [])).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -1,149 +0,0 @@
|
||||
/**
|
||||
* The /v1/deploy → @hanzo/cd adapter — pins the console CD app against cloud's
|
||||
* REAL clients/deploy shapes (the hyphenated sync verdict, object manifests,
|
||||
* parentRefs tree edges, the logs blob, clean-semver rollback).
|
||||
*/
|
||||
import { describe, expect, it } from "vitest"
|
||||
|
||||
import {
|
||||
normalizeDeployApp,
|
||||
parseApplications,
|
||||
toGitopsApp,
|
||||
toLogLines,
|
||||
toManagedResource,
|
||||
toResourceTree,
|
||||
toRollbackHistory,
|
||||
} from "./adapt"
|
||||
|
||||
describe("toGitopsApp", () => {
|
||||
it("folds the hyphenated cloud sync verdict (the foldSync hyphen bug)", () => {
|
||||
const a = parseApplications({
|
||||
applications: [
|
||||
{ name: "iam", namespace: "hanzo", env: "main", repository: "ghcr.io/hanzoai/iam", version: "v1.4.11", runningVersion: "v1.4.10", health: "progressing", healthMessage: "rolling", sync: "out-of-sync" },
|
||||
],
|
||||
})[0]
|
||||
const g = toGitopsApp(a)
|
||||
expect(g.sync).toBe("OutOfSync") // 'out-of-sync' must NOT fall through to Unknown
|
||||
expect(g.health).toBe("Progressing")
|
||||
expect(g.revision).toBe("v1.4.11")
|
||||
expect(g.source).toEqual({ repoURL: "ghcr.io/hanzoai/iam" })
|
||||
expect(g.message).toBe("rolling")
|
||||
expect(g.project).toBe("main")
|
||||
})
|
||||
it("derives sync from declared-vs-running when the plane omits it", () => {
|
||||
const [drift, synced] = parseApplications([
|
||||
{ name: "a", repository: "r", version: "v2", runningVersion: "v1" },
|
||||
{ name: "b", repository: "r", version: "v1", runningVersion: "v1" },
|
||||
])
|
||||
expect(toGitopsApp(drift).sync).toBe("OutOfSync")
|
||||
expect(toGitopsApp(synced).sync).toBe("Synced")
|
||||
})
|
||||
})
|
||||
|
||||
describe("parseApplications", () => {
|
||||
it("tolerates a bare array and drops nameless rows", () => {
|
||||
expect(parseApplications([{ name: "cloud" }, { name: "" }]).map((a) => a.name)).toEqual(["cloud"])
|
||||
})
|
||||
})
|
||||
|
||||
describe("toResourceTree", () => {
|
||||
it("maps parentRefs[].ref into linking AppTreeNodes (uid == ref token)", () => {
|
||||
const tree = toResourceTree({
|
||||
nodes: [
|
||||
{ group: "hanzo.ai", version: "v1", kind: "App", namespace: "hanzo", name: "iam", ref: "hanzo.ai:App:hanzo:iam", parentRefs: [] },
|
||||
{ group: "apps", version: "v1", kind: "Deployment", namespace: "hanzo", name: "iam", ref: "apps:Deployment:hanzo:iam", health: "progressing", parentRefs: [{ ref: "hanzo.ai:App:hanzo:iam" }] },
|
||||
],
|
||||
})
|
||||
expect(tree.nodes).toHaveLength(2)
|
||||
const dep = tree.nodes.find((n) => n.kind === "Deployment")!
|
||||
expect(dep.uid).toBe("apps:Deployment:hanzo:iam")
|
||||
expect(dep.parentRefs?.[0].uid).toBe("hanzo.ai:App:hanzo:iam") // matches the App node's uid → the tree links
|
||||
expect(dep.health?.status).toBe("Progressing")
|
||||
})
|
||||
})
|
||||
|
||||
describe("toManagedResource", () => {
|
||||
it("stringifies object manifests + reads the nested desired manifest", () => {
|
||||
const m = toManagedResource({
|
||||
ref: { group: "apps", version: "v1", kind: "Deployment", namespace: "hanzo", name: "iam", ref: "apps:Deployment:hanzo:iam" },
|
||||
liveManifest: { kind: "Deployment", spec: { replicas: 1 } },
|
||||
diff: { modified: true, desiredManifest: { kind: "Deployment", spec: { replicas: 2 } } },
|
||||
})
|
||||
expect(m.uid).toBe("apps:Deployment:hanzo:iam")
|
||||
expect(m.liveState).toContain('"replicas": 1')
|
||||
expect(m.targetState).toContain('"replicas": 2')
|
||||
})
|
||||
})
|
||||
|
||||
describe("toLogLines", () => {
|
||||
it("splits the /v1/deploy logs blob and tags the pod", () => {
|
||||
const lines = toLogLines({ pod: "iam-abc", logs: "first\nsecond\n" })
|
||||
expect(lines).toHaveLength(2)
|
||||
expect(lines[0]).toEqual({ content: "first", podName: "iam-abc" })
|
||||
})
|
||||
it("honest empty when no logs", () => {
|
||||
expect(toLogLines({ pod: "", logs: "" })).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe("toRollbackHistory", () => {
|
||||
it("offers only clean-semver releases, current excluded, newest first", () => {
|
||||
const h = toRollbackHistory("v1.800.1", ["v1.800.0", "latest", "v1.799.15", "v1.800.1", "main"])
|
||||
expect(h.map((r) => r.revision)).toEqual(["v1.800.0", "v1.799.15"])
|
||||
expect(h[0].id).toBeGreaterThan(h[1].id) // monotonic ids for the dialog
|
||||
})
|
||||
})
|
||||
|
||||
// ── the ACTUAL argoproj shape /v1/deploy/applications serves in production ────
|
||||
// Captured live (cloud v1.801.109). The first cut read only flat keys, so the
|
||||
// fleet bound to zero rows against real data; this pins the real contract.
|
||||
describe("live argoproj-shaped application", () => {
|
||||
const LIVE = {
|
||||
apiVersion: "argoproj.io/v1alpha1",
|
||||
kind: "Application",
|
||||
metadata: {
|
||||
name: "admin-guard",
|
||||
namespace: "hanzo",
|
||||
uid: "257fcb88-974d-46fc-8ac2-6f8b86a1f15f",
|
||||
creationTimestamp: "2026-07-15T05:31:10Z",
|
||||
labels: { "hanzo.ai/env": "main", "hanzo.ai/instance": "admin-guard" },
|
||||
},
|
||||
spec: {
|
||||
source: { repoURL: "https://git.hanzo.ai/hanzoai/universe", path: "infra/k8s/operator/crs", targetRevision: "main" },
|
||||
destination: { server: "https://kubernetes.default.svc", namespace: "hanzo" },
|
||||
project: "default",
|
||||
},
|
||||
status: {
|
||||
sync: { status: "Synced", revision: "v0.1.4" },
|
||||
health: { status: "Healthy", message: "Running: available" },
|
||||
resources: [],
|
||||
summary: { images: ["ghcr.io/hanzoai/admin-guard:v0.1.4"] },
|
||||
},
|
||||
}
|
||||
|
||||
it("binds name/env/health/sync/revision/repository from the nested shape", () => {
|
||||
const a = normalizeDeployApp(LIVE)
|
||||
expect(a.name).toBe("admin-guard")
|
||||
expect(a.namespace).toBe("hanzo")
|
||||
expect(a.env).toBe("main")
|
||||
expect(a.health).toBe("Healthy")
|
||||
expect(a.sync).toBe("Synced")
|
||||
expect(a.version).toBe("v0.1.4")
|
||||
expect(a.repository).toBe("ghcr.io/hanzoai/admin-guard")
|
||||
expect(a.runningVersion).toBe("v0.1.4")
|
||||
})
|
||||
|
||||
it("folds to a Healthy/Synced gitops app (the fleet row renders)", () => {
|
||||
const g = toGitopsApp(normalizeDeployApp(LIVE))
|
||||
expect(g.name).toBe("admin-guard")
|
||||
expect(g.health).toBe("Healthy")
|
||||
expect(g.sync).toBe("Synced")
|
||||
})
|
||||
|
||||
it("still reads the flat native shape (both wires supported)", () => {
|
||||
const a = normalizeDeployApp({ name: "x", health: "degraded", sync: "out-of-sync", version: "v1.2.3" })
|
||||
expect(a.name).toBe("x")
|
||||
expect(a.health).toBe("degraded")
|
||||
expect(toGitopsApp(a).sync).toBe("OutOfSync")
|
||||
})
|
||||
})
|
||||
@@ -1,271 +0,0 @@
|
||||
/**
|
||||
* The CD data adapter — maps cloud's `/v1/deploy` DTOs INTO the shared view-models
|
||||
* of `@hanzo/cd` (GitopsApplication / ResourceTree / ManagedResource / LogLine
|
||||
* / RevisionHistory). Reuses each package's OWN pure folds (foldHealth/foldSync) so
|
||||
* status vocabulary is one way. No React, no I/O — unit-testable.
|
||||
*
|
||||
* Contract (cloud clients/deploy):
|
||||
* applications: [{name,namespace,env,role,repository,version,runningVersion,
|
||||
* health,healthMessage,sync,phase,endpoints}]
|
||||
* {name}/tree: {application, nodes:[{group,version,kind,namespace,name,ref,uid,
|
||||
* createdAt,health,healthMessage,sync,version,parentRefs:[{…,ref}]}]}
|
||||
* {name}/resource/{ref}: {ref, health, healthMessage, liveManifest:{…},
|
||||
* desiredSource, diff:{modified,desiredManifest:{…}}}
|
||||
* {name}/logs: {application, pod, container, logs:"…", note?}
|
||||
*/
|
||||
import {
|
||||
foldHealth,
|
||||
foldSync,
|
||||
type AppTreeNode,
|
||||
type GitopsApplication,
|
||||
type LogLine,
|
||||
type ManagedResource,
|
||||
type ResourceRef,
|
||||
type ResourceTree,
|
||||
type RevisionHistory,
|
||||
} from "@hanzo/cd"
|
||||
|
||||
// ── optional-safe helpers (snake_case + camelCase tolerant) ──────────────────
|
||||
|
||||
const str = (v: unknown): string => (typeof v === "string" ? v : "")
|
||||
const arr = (v: unknown): unknown[] => (Array.isArray(v) ? v : [])
|
||||
const rec = (v: unknown): Record<string, unknown> =>
|
||||
v && typeof v === "object" ? (v as Record<string, unknown>) : {}
|
||||
const pick = (r: Record<string, unknown>, ...keys: string[]): unknown => {
|
||||
for (const k of keys) if (r[k] !== undefined && r[k] !== null) return r[k]
|
||||
return undefined
|
||||
}
|
||||
const strList = (v: unknown): string[] => arr(v).map(str).filter(Boolean)
|
||||
const epochMs = (v: unknown): number | undefined => {
|
||||
const s = str(v)
|
||||
if (!s) return undefined
|
||||
const t = Date.parse(s)
|
||||
return Number.isNaN(t) ? undefined : t
|
||||
}
|
||||
|
||||
// The cloud wire uses hyphenated verdicts (`out-of-sync`); @hanzo/cd's folds
|
||||
// strip whitespace but not hyphens, so normalize `[-_]` out before folding.
|
||||
const fHealth = (raw: string) => foldHealth(raw.replace(/[-_]/g, ""))
|
||||
const fSync = (raw: string) => foldSync(raw.replace(/[-_]/g, ""))
|
||||
|
||||
/** A manifest object → pretty JSON text; a string verbatim; else ''. */
|
||||
export function manifestText(v: unknown): string {
|
||||
if (typeof v === "string") return v
|
||||
if (v && typeof v === "object") {
|
||||
try {
|
||||
return JSON.stringify(v, null, 2)
|
||||
} catch {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
/** repo basename of an image repository: `ghcr.io/hanzoai/iam` → `iam`. */
|
||||
export function repoBaseName(repository: string): string {
|
||||
const s = (repository || "").trim().replace(/:.*/, "")
|
||||
return (s.split("/").filter(Boolean).pop() ?? "").toLowerCase()
|
||||
}
|
||||
|
||||
// ── Application ──────────────────────────────────────────────────────────────
|
||||
|
||||
export interface DeployApp {
|
||||
name: string
|
||||
namespace: string
|
||||
env: string
|
||||
role: string
|
||||
repository: string
|
||||
version: string
|
||||
runningVersion: string
|
||||
health: string
|
||||
healthMessage: string
|
||||
sync: string
|
||||
phase: string
|
||||
endpoints: string[]
|
||||
/** Prior release tags the plane records (forward-compat rollback source); [] today. */
|
||||
revisions: string[]
|
||||
}
|
||||
|
||||
/** Normalize a raw /v1/deploy application row to a stable internal shape. */
|
||||
/**
|
||||
* Normalize one application from EITHER wire shape:
|
||||
*
|
||||
* - argoproj (what `/v1/deploy/applications` actually serves): the projection
|
||||
* nests everything — `metadata.{name,namespace,labels}`, `spec.source.*`,
|
||||
* `spec.project`, `status.{sync,health}.status`, `status.summary.images[]`.
|
||||
* - flat native: `{name,namespace,env,repository,version,health,sync,…}`.
|
||||
*
|
||||
* Both are read here so the app binds regardless of which the plane returns —
|
||||
* flat keys win when present, then the nested argo fields fill in.
|
||||
*/
|
||||
export function normalizeDeployApp(raw: unknown): DeployApp {
|
||||
const r = rec(raw)
|
||||
const meta = rec(pick(r, "metadata"))
|
||||
const spec = rec(pick(r, "spec"))
|
||||
const status = rec(pick(r, "status"))
|
||||
const source = rec(pick(spec, "source"))
|
||||
const dest = rec(pick(spec, "destination"))
|
||||
const sync = rec(pick(status, "sync"))
|
||||
const health = rec(pick(status, "health"))
|
||||
const labels = rec(pick(meta, "labels"))
|
||||
const summary = rec(pick(status, "summary"))
|
||||
// `status.summary.images: ["ghcr.io/hanzoai/x:v1.2.3"]` → repository + tag.
|
||||
const image = strList(pick(summary, "images"))[0] ?? ""
|
||||
const cut = image.lastIndexOf(":")
|
||||
const imageRepo = cut > 0 ? image.slice(0, cut) : image
|
||||
const imageTag = cut > 0 ? image.slice(cut + 1) : ""
|
||||
|
||||
return {
|
||||
name: str(pick(r, "name")) || str(pick(meta, "name")),
|
||||
namespace:
|
||||
str(pick(r, "namespace", "ns")) || str(pick(meta, "namespace")) || str(pick(dest, "namespace")) || "hanzo",
|
||||
env: str(pick(r, "env", "environment")) || str(pick(labels, "hanzo.ai/env")) || str(pick(source, "targetRevision")),
|
||||
role: str(pick(r, "role")),
|
||||
// Prefer the deployed image repository; fall back to the manifest repo URL.
|
||||
repository: str(pick(r, "repository", "repo")) || imageRepo || str(pick(source, "repoURL")),
|
||||
version: str(pick(r, "version", "tag")) || str(pick(sync, "revision")) || imageTag,
|
||||
runningVersion: str(pick(r, "runningVersion", "running_version")) || imageTag,
|
||||
health: str(pick(r, "health")) || str(pick(health, "status")),
|
||||
healthMessage:
|
||||
str(pick(r, "healthMessage", "health_message", "message")) || str(pick(health, "message")),
|
||||
sync: str(pick(r, "sync", "syncStatus", "sync_status")) || str(pick(sync, "status")),
|
||||
phase: str(pick(r, "phase")) || str(pick(health, "status")),
|
||||
endpoints: strList(pick(r, "endpoints", "urls")),
|
||||
revisions: strList(pick(r, "revisions", "history", "tags")),
|
||||
}
|
||||
}
|
||||
|
||||
/** Parse the applications list payload (array or {applications|apps|items}). */
|
||||
export function parseApplications(data: unknown): DeployApp[] {
|
||||
const rows = Array.isArray(data) ? data : arr(pick(rec(data), "applications", "apps", "items", "services"))
|
||||
return rows.map(normalizeDeployApp).filter((a) => a.name)
|
||||
}
|
||||
|
||||
/** Fold a normalized app into the @hanzo/cd application view-model. */
|
||||
export function toGitopsApp(a: DeployApp): GitopsApplication {
|
||||
return {
|
||||
name: a.name,
|
||||
namespace: a.namespace,
|
||||
project: a.env || undefined,
|
||||
health: fHealth(a.health),
|
||||
// desired (declared version) vs live (runningVersion): equal ⇒ Synced.
|
||||
sync: a.sync ? fSync(a.sync) : fSync(a.version === a.runningVersion ? "synced" : "outofsync"),
|
||||
revision: a.version || undefined,
|
||||
source: a.repository ? { repoURL: a.repository } : undefined,
|
||||
message: a.healthMessage || undefined,
|
||||
}
|
||||
}
|
||||
|
||||
// ── tree → ResourceTree (AppTreeNode with linking parentRefs) ────────────────
|
||||
|
||||
/** Map a /v1/deploy ResourceRef DTO (carrying its `ref` token) to a linking ref.
|
||||
* The `ref` token is the stable id shared by a node and its children's
|
||||
* parentRefs, so it drives resourceId() — the tree links on it. */
|
||||
function toRef(raw: unknown): ResourceRef {
|
||||
const r = rec(raw)
|
||||
const token = str(pick(r, "ref"))
|
||||
return {
|
||||
uid: token || str(pick(r, "uid")),
|
||||
group: str(pick(r, "group")),
|
||||
version: str(pick(r, "version")),
|
||||
kind: str(pick(r, "kind")),
|
||||
namespace: str(pick(r, "namespace", "ns")),
|
||||
name: str(pick(r, "name")),
|
||||
}
|
||||
}
|
||||
|
||||
function toTreeNode(raw: unknown): AppTreeNode {
|
||||
const r = rec(raw)
|
||||
const self = toRef(r)
|
||||
const version = str(pick(r, "version"))
|
||||
return {
|
||||
...self,
|
||||
parentRefs: arr(pick(r, "parentRefs", "parent_refs")).map(toRef),
|
||||
health: { status: fHealth(str(pick(r, "health"))), message: str(pick(r, "healthMessage")) || undefined },
|
||||
sync: pick(r, "sync") ? fSync(str(pick(r, "sync"))) : undefined,
|
||||
images: version ? [version] : [],
|
||||
createdAt: epochMs(pick(r, "createdAt", "created_at", "creationTimestamp")),
|
||||
}
|
||||
}
|
||||
|
||||
export function toResourceTree(raw: unknown): ResourceTree {
|
||||
const r = rec(raw)
|
||||
const nodes = arr(pick(r, "nodes", "resources"))
|
||||
.map(toTreeNode)
|
||||
.filter((n) => n.name && n.kind)
|
||||
// Defense-in-depth: never render a Secret node (its live manifest carries
|
||||
// base64 data). Cloud already excludes Secrets from the tree; drop any that
|
||||
// slip through so the client never surfaces one.
|
||||
.filter((n) => n.kind !== "Secret")
|
||||
return { nodes }
|
||||
}
|
||||
|
||||
// ── resource → ManagedResource (live + desired for the node drawer/diff) ─────
|
||||
|
||||
export function toManagedResource(raw: unknown): ManagedResource {
|
||||
const r = rec(raw)
|
||||
const refField = pick(r, "ref")
|
||||
const self: ResourceRef =
|
||||
typeof refField === "string"
|
||||
? { uid: refField, group: "", version: "", kind: "", namespace: "", name: "" }
|
||||
: toRef(refField)
|
||||
const diffObj = rec(pick(r, "diff"))
|
||||
const desired = pick(r, "desiredManifest", "desired") ?? pick(diffObj, "desiredManifest", "desired")
|
||||
return {
|
||||
...self,
|
||||
liveState: manifestText(pick(r, "liveManifest", "live_manifest", "live", "manifest")),
|
||||
targetState: manifestText(desired),
|
||||
}
|
||||
}
|
||||
|
||||
// ── logs (blob OR array) → LogLine[], bounded ────────────────────────────────
|
||||
|
||||
// Client-side caps so one giant log payload can't bloat the DOM even if the
|
||||
// server's `?tail` is absent or ignored: keep the last MAX_LOG_LINES, and clamp
|
||||
// any single line (a newline-free megabyte otherwise renders as one huge node).
|
||||
const MAX_LOG_LINES = 2000
|
||||
const MAX_LOG_LINE = 4000
|
||||
|
||||
export function toLogLines(raw: unknown): LogLine[] {
|
||||
const r = rec(raw)
|
||||
const pod = str(pick(r, "pod")) || undefined
|
||||
// Tolerate both shapes: a newline blob (`logs:"…"`) and a line array
|
||||
// (`logs:[…]` / `lines:[…]`), which the blob-only path silently dropped to [].
|
||||
const listed = arr(pick(r, "logs", "lines", "log", "output"))
|
||||
const lines = listed.length
|
||||
? listed.map(str)
|
||||
: str(pick(r, "logs", "log", "output")).split("\n")
|
||||
const kept = lines.filter((l) => l.length > 0).slice(-MAX_LOG_LINES)
|
||||
return kept.map((l) => ({ content: l.length > MAX_LOG_LINE ? l.slice(0, MAX_LOG_LINE) + "…" : l, podName: pod }))
|
||||
}
|
||||
|
||||
// ── git tags → RevisionHistory[] (rollback targets; cloud takes a clean semver) ─
|
||||
|
||||
const SEMVER = /^v\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/
|
||||
export const isReleaseTag = (t: string): boolean => SEMVER.test(t.trim())
|
||||
|
||||
/** Build rollback history from an app's real git tags (clean semver, newest first,
|
||||
* current excluded). `revision` carries the tag cloud's rollback endpoint accepts. */
|
||||
export function toRollbackHistory(currentTag: string, tags: string[]): RevisionHistory[] {
|
||||
const seen = new Set<string>([currentTag.trim()])
|
||||
const clean: string[] = []
|
||||
for (const raw of tags) {
|
||||
const t = raw.trim()
|
||||
if (!t || seen.has(t) || !isReleaseTag(t)) continue
|
||||
seen.add(t)
|
||||
clean.push(t)
|
||||
}
|
||||
clean.sort(compareSemverDesc)
|
||||
return clean.map((revision, i) => ({ id: clean.length - i, revision }))
|
||||
}
|
||||
|
||||
export function compareSemverDesc(a: string, b: string): number {
|
||||
const pa = semverParts(a)
|
||||
const pb = semverParts(b)
|
||||
for (let i = 0; i < 3; i++) if (pa[i] !== pb[i]) return pb[i] - pa[i]
|
||||
return b.localeCompare(a)
|
||||
}
|
||||
function semverParts(tag: string): [number, number, number] {
|
||||
const m = /^v?(\d+)\.(\d+)\.(\d+)/.exec(tag.trim())
|
||||
return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : [-1, -1, -1]
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
/**
|
||||
* The typed client for cloud's native CD plane (`/v1/deploy`). Same-origin: the
|
||||
* cd.hanzo.ai ingress peels `/v1/deploy/*` off to the cloud binary, so the SPA
|
||||
* calls it with `credentials: 'include'` and the first-party `hanzo_iam_token`
|
||||
* cookie (set by the PKCE login) rides — cloud validates it (SuperAdmin gate). A
|
||||
* 401/403 surfaces as an `ApiError` so the app can send the user back to sign in.
|
||||
*
|
||||
* Responses are mapped INTO the shared `@hanzo/cd` view-models by `./adapt`.
|
||||
*/
|
||||
import type { LogLine, ManagedResource, ResourceTree } from "@hanzo/cd"
|
||||
|
||||
import { parseApplications, toLogLines, toManagedResource, toResourceTree, type DeployApp } from "./adapt"
|
||||
|
||||
export class ApiError extends Error {
|
||||
status: number
|
||||
constructor(message: string, status: number) {
|
||||
super(message)
|
||||
this.name = "ApiError"
|
||||
this.status = status
|
||||
}
|
||||
}
|
||||
|
||||
/** True when the error means "not signed in / not authorized" (→ sign-in screen). */
|
||||
export const isAuthError = (e: unknown): boolean => e instanceof ApiError && (e.status === 401 || e.status === 403)
|
||||
|
||||
const url = (path: string): string => `/v1/deploy/${path.replace(/^\/+/, "")}`
|
||||
|
||||
async function request<T>(method: "GET" | "POST", path: string, body?: unknown): Promise<T> {
|
||||
let res: Response
|
||||
try {
|
||||
res = await fetch(url(path), {
|
||||
method,
|
||||
credentials: "include",
|
||||
headers: body !== undefined ? { "Content-Type": "application/json" } : undefined,
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
})
|
||||
} catch (e) {
|
||||
throw new ApiError(e instanceof Error ? e.message : "network error", 0)
|
||||
}
|
||||
if (!res.ok) {
|
||||
let msg = `${res.status} ${res.statusText}`
|
||||
try {
|
||||
const j = (await res.json()) as { error?: string; message?: string }
|
||||
msg = j.error || j.message || msg
|
||||
} catch {
|
||||
/* non-JSON body */
|
||||
}
|
||||
throw new ApiError(msg, res.status)
|
||||
}
|
||||
if (res.status === 204) return undefined as T
|
||||
return (await res.json()) as T
|
||||
}
|
||||
|
||||
export const DeployApi = {
|
||||
/** The fleet: every operator App CR (`GET /v1/deploy/applications`). */
|
||||
applications: async (): Promise<DeployApp[]> => parseApplications(await request<unknown>("GET", "applications")),
|
||||
|
||||
/**
|
||||
* One application's owned-resource tree
|
||||
* (`GET /v1/deploy/applications/:name/resource-tree`).
|
||||
*
|
||||
* The wired route lives under `applications/` — the shorter `:name/tree` form
|
||||
* belongs to an unregistered handler, so it 404s against the live plane.
|
||||
*/
|
||||
tree: async (name: string): Promise<ResourceTree> =>
|
||||
toResourceTree(await request<unknown>("GET", `applications/${encodeURIComponent(name)}/resource-tree`)),
|
||||
|
||||
/** One tree node's live manifest + desired-vs-live (`GET /v1/deploy/:name/resource/:ref`). */
|
||||
resource: async (name: string, ref: string): Promise<ManagedResource> =>
|
||||
toManagedResource(await request<unknown>("GET", `${encodeURIComponent(name)}/resource/${encodeURIComponent(ref)}`)),
|
||||
|
||||
/** The newest pod's logs (`GET /v1/deploy/:name/logs`). */
|
||||
logs: async (name: string, tail = 300): Promise<LogLine[]> =>
|
||||
toLogLines(await request<unknown>("GET", `${encodeURIComponent(name)}/logs?tail=${tail}`)),
|
||||
|
||||
/** Pin the CR image to a prior clean-semver release (`POST /v1/deploy/:name/rollback`). */
|
||||
rollback: async (name: string, tag: string): Promise<void> => {
|
||||
await request<unknown>("POST", `${encodeURIComponent(name)}/rollback`, { tag })
|
||||
},
|
||||
|
||||
/** Request an operator reconcile now (`POST /v1/deploy/:name/sync`). */
|
||||
sync: async (name: string): Promise<void> => {
|
||||
await request<unknown>("POST", `${encodeURIComponent(name)}/sync`, {})
|
||||
},
|
||||
}
|
||||
|
||||
/** Read the `hanzo_iam_token` cookie the PKCE login sets (non-httpOnly by design). */
|
||||
export function hasSession(): boolean {
|
||||
return document.cookie.split(";").some((c) => c.trim().startsWith("hanzo_iam_token="))
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
import { StrictMode } from "react"
|
||||
import { createRoot } from "react-dom/client"
|
||||
|
||||
import { App } from "./App"
|
||||
import { ErrorBoundary } from "./shell/ErrorBoundary"
|
||||
import "./styles.css"
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<ErrorBoundary>
|
||||
<App />
|
||||
</ErrorBoundary>
|
||||
</StrictMode>,
|
||||
)
|
||||
@@ -1,51 +0,0 @@
|
||||
import { Component, type ErrorInfo, type ReactNode } from "react"
|
||||
|
||||
/**
|
||||
* Top-level boundary so a single component render throw can never white-screen
|
||||
* the whole dashboard. A caught error shows a recover panel (back to the fleet /
|
||||
* reload) instead of an unmounted root. Deliberately dependency-free.
|
||||
*/
|
||||
export class ErrorBoundary extends Component<{ children: ReactNode }, { error: Error | null }> {
|
||||
state = { error: null as Error | null }
|
||||
|
||||
static getDerivedStateFromError(error: Error) {
|
||||
return { error }
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, info: ErrorInfo) {
|
||||
// Surface to the console for the operator; never log identity/token.
|
||||
console.error("[cd] render error", error, info.componentStack)
|
||||
}
|
||||
|
||||
private reset = () => {
|
||||
this.setState({ error: null })
|
||||
if (location.hash && location.hash !== "#/") location.hash = "#/"
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this.state.error) return this.props.children
|
||||
return (
|
||||
<div style={{ maxWidth: 520, margin: "12vh auto", padding: "0 24px", textAlign: "center", fontFamily: "'Geist', ui-sans-serif, system-ui, sans-serif" }}>
|
||||
<h1 style={{ fontSize: 20, fontWeight: 700, margin: "0 0 8px" }}>Something went wrong</h1>
|
||||
<p style={{ color: "#8a8a8a", fontSize: 14, margin: "0 0 20px" }}>
|
||||
A view failed to render. Your fleet is unaffected — this is only the dashboard.
|
||||
</p>
|
||||
<div style={{ display: "flex", gap: 10, justifyContent: "center" }}>
|
||||
<button onClick={this.reset} style={btn(true)}>← Back to fleet</button>
|
||||
<button onClick={() => location.reload()} style={btn(false)}>Reload</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const btn = (primary: boolean): React.CSSProperties => ({
|
||||
padding: "9px 16px",
|
||||
fontSize: 14,
|
||||
fontWeight: 600,
|
||||
cursor: "pointer",
|
||||
borderRadius: 8,
|
||||
border: primary ? "0" : "1px solid #2a2a2a",
|
||||
background: primary ? "#fff" : "transparent",
|
||||
color: primary ? "#000" : "inherit",
|
||||
})
|
||||
@@ -1,102 +0,0 @@
|
||||
/**
|
||||
* The Hanzo CD topbar — the shared shell chrome for the dedicated dashboard: the
|
||||
* small Hanzo mark + wordmark (top-left), the environment scope switcher (the CD
|
||||
* "project" dimension — main/test/dev, the operator namespaces), a refresh, and
|
||||
* sign-out. Lean + self-contained (dark, Geist) so the static SPA has no heavy
|
||||
* shell dependency; the org/project↔IAM switcher lights up when /v1/iam is routed
|
||||
* on cd.hanzo.ai (today the SuperAdmin cookie sees the whole fleet).
|
||||
*/
|
||||
|
||||
const MARK = (size: number, fill: string) => (
|
||||
<svg width={size} height={size} viewBox="0 0 67 67" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
||||
<path d="M22.21 67V44.6369H0V67H22.21Z" fill={fill} />
|
||||
<path d="M66.7038 22.3184H22.2534L0.0878906 44.6367H44.4634L66.7038 22.3184Z" fill={fill} />
|
||||
<path d="M22.21 0H0V22.3184H22.21V0Z" fill={fill} />
|
||||
<path d="M66.7198 0H44.5098V22.3184H66.7198V0Z" fill={fill} />
|
||||
<path d="M66.7198 67V44.6369H44.5098V67H66.7198Z" fill={fill} />
|
||||
</svg>
|
||||
)
|
||||
|
||||
export interface EnvOption {
|
||||
id: string
|
||||
label: string
|
||||
count: number
|
||||
}
|
||||
|
||||
export function Topbar({
|
||||
envs,
|
||||
env,
|
||||
onEnv,
|
||||
onRefresh,
|
||||
refreshing,
|
||||
onSignOut,
|
||||
}: {
|
||||
envs: EnvOption[]
|
||||
env: string
|
||||
onEnv: (id: string) => void
|
||||
onRefresh: () => void
|
||||
refreshing?: boolean
|
||||
onSignOut: () => void
|
||||
}) {
|
||||
return (
|
||||
<header
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 12,
|
||||
padding: "10px 16px",
|
||||
borderBottom: "1px solid var(--cd-border)",
|
||||
background: "var(--cd-surface)",
|
||||
position: "sticky",
|
||||
top: 0,
|
||||
zIndex: 20,
|
||||
flexWrap: "wrap",
|
||||
}}
|
||||
>
|
||||
<a href="/" style={{ display: "flex", alignItems: "center", gap: 9, textDecoration: "none", color: "var(--cd-fg-strong)" }}>
|
||||
<span style={{ display: "inline-flex" }}>{MARK(18, "#fff")}</span>
|
||||
<span style={{ fontWeight: 800, letterSpacing: "-0.02em", fontSize: 15 }}>Hanzo CD</span>
|
||||
</a>
|
||||
|
||||
{envs.length > 1 ? (
|
||||
<div style={{ display: "inline-flex", gap: 2, background: "var(--cd-surface-2)", borderRadius: 8, padding: 2, border: "1px solid var(--cd-border)" }}>
|
||||
{[{ id: "", label: "All", count: envs.reduce((n, e) => n + e.count, 0) }, ...envs].map((o) => {
|
||||
const on = o.id === env
|
||||
return (
|
||||
<button
|
||||
key={o.id || "all"}
|
||||
type="button"
|
||||
onClick={() => onEnv(o.id)}
|
||||
className="cd-mono"
|
||||
style={{
|
||||
border: 0,
|
||||
cursor: "pointer",
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
padding: "5px 10px",
|
||||
borderRadius: 6,
|
||||
background: on ? "var(--cd-border-strong)" : "transparent",
|
||||
color: on ? "var(--cd-fg-strong)" : "var(--cd-fg-muted)",
|
||||
}}
|
||||
>
|
||||
{o.label}
|
||||
<span style={{ opacity: 0.6, marginLeft: 5 }}>{o.count}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<span style={{ flex: 1 }} />
|
||||
|
||||
<button type="button" className="cd-btn" onClick={onRefresh} disabled={refreshing}>
|
||||
{refreshing ? "Refreshing…" : "Refresh"}
|
||||
</button>
|
||||
<button type="button" className="cd-btn" onClick={onSignOut}>
|
||||
Sign out
|
||||
</button>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
|
||||
export { MARK }
|
||||
@@ -1,90 +0,0 @@
|
||||
/* Hanzo CD — app chrome. The @hanzo/gitops components bring their OWN scoped CSS
|
||||
(GitopsStyles + THEME_VARS); this is only the shell + page shell, dark-first,
|
||||
Geist. Mobile-first: nothing scrolls the body horizontally. */
|
||||
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
--cd-bg: #0a0a0a;
|
||||
--cd-surface: #0d1117;
|
||||
--cd-surface-2: #161b22;
|
||||
--cd-border: #21262d;
|
||||
--cd-border-strong: #30363d;
|
||||
--cd-fg: #e7e7e7;
|
||||
--cd-fg-muted: #8b949e;
|
||||
--cd-fg-strong: #f0f6fc;
|
||||
--cd-accent: #2f81f7;
|
||||
--cd-sans: "Geist", "Geist Sans", ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||||
--cd-mono: "Geist Mono", ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
html {
|
||||
overflow-x: hidden;
|
||||
}
|
||||
body {
|
||||
font-family: var(--cd-sans);
|
||||
background: var(--cd-bg);
|
||||
color: var(--cd-fg);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
.cd-app {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100%;
|
||||
}
|
||||
.cd-main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 20px;
|
||||
max-width: 1400px;
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.cd-main {
|
||||
padding: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.cd-btn {
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
border: 1px solid var(--cd-border-strong);
|
||||
background: var(--cd-surface-2);
|
||||
color: var(--cd-fg);
|
||||
border-radius: 8px;
|
||||
padding: 8px 12px;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-height: 36px;
|
||||
}
|
||||
.cd-btn:hover {
|
||||
border-color: var(--cd-fg-muted);
|
||||
}
|
||||
.cd-btn--primary {
|
||||
background: #fff;
|
||||
color: #000;
|
||||
border-color: #fff;
|
||||
}
|
||||
.cd-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
.cd-muted {
|
||||
color: var(--cd-fg-muted);
|
||||
}
|
||||
.cd-mono {
|
||||
font-family: var(--cd-mono);
|
||||
}
|
||||
@@ -1,194 +0,0 @@
|
||||
/**
|
||||
* The application detail — the ArgoCD-grade drill-in, composed from the shared
|
||||
* @hanzo/cd components: `GitopsSyncPanel` (health/sync/revision + Sync /
|
||||
* Refresh / History), `GitopsAppTree` (the owned-resource topology), `GitopsNodeInfo`
|
||||
* (a node's manifest / desired-vs-live diff / events / logs), and `GitopsRollbackDialog`.
|
||||
*
|
||||
* Composed (not the fixed `GitopsAppDetails`) so a node's manifest + logs are
|
||||
* fetched LAZILY on selection (the plane's `/resource` + `/logs` reads) rather than
|
||||
* pre-fetched for the whole tree. Every effect is a real `/v1/deploy` call; honest
|
||||
* loading/empty/error states, never fabricated.
|
||||
*/
|
||||
import { useCallback, useEffect, useState } from "react"
|
||||
import {
|
||||
GitopsAppTree,
|
||||
GitopsNodeInfo,
|
||||
GitopsRollbackDialog,
|
||||
GitopsSyncPanel,
|
||||
type AppTreeNode,
|
||||
type LogLine,
|
||||
type ManagedResource,
|
||||
type ResourceTree,
|
||||
type SyncOptions,
|
||||
} from "@hanzo/cd"
|
||||
|
||||
import { DeployApi } from "../lib/deploy"
|
||||
import { toGitopsApp, toRollbackHistory, type DeployApp } from "../lib/adapt"
|
||||
|
||||
export function AppView({
|
||||
app,
|
||||
onBack,
|
||||
onChanged,
|
||||
notify,
|
||||
}: {
|
||||
app: DeployApp
|
||||
onBack: () => void
|
||||
onChanged: () => void
|
||||
notify: (msg: string, err?: boolean) => void
|
||||
}) {
|
||||
const gitopsApp = toGitopsApp(app)
|
||||
const [tree, setTree] = useState<ResourceTree | null | undefined>(undefined)
|
||||
const [treeErr, setTreeErr] = useState<string>("")
|
||||
const [logs, setLogs] = useState<LogLine[]>([])
|
||||
const [selected, setSelected] = useState<AppTreeNode | null>(null)
|
||||
const [resource, setResource] = useState<ManagedResource | undefined>(undefined)
|
||||
const [rollbackOpen, setRollbackOpen] = useState(false)
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
const history = toRollbackHistory(app.version, app.revisions)
|
||||
|
||||
const load = useCallback(() => {
|
||||
let live = true
|
||||
setTree(undefined)
|
||||
setTreeErr("")
|
||||
setSelected(null)
|
||||
DeployApi.tree(app.name)
|
||||
.then((t) => live && setTree(t))
|
||||
.catch((e) => {
|
||||
if (live) {
|
||||
setTree(null)
|
||||
setTreeErr(e instanceof Error ? e.message : "failed to load")
|
||||
}
|
||||
})
|
||||
DeployApi.logs(app.name)
|
||||
.then((l) => live && setLogs(l))
|
||||
.catch(() => live && setLogs([]))
|
||||
return () => {
|
||||
live = false
|
||||
}
|
||||
}, [app.name])
|
||||
useEffect(() => load(), [load])
|
||||
|
||||
const onSelect = useCallback(
|
||||
(node: AppTreeNode | null) => {
|
||||
setSelected(node)
|
||||
setResource(undefined)
|
||||
if (!node) return
|
||||
DeployApi.resource(app.name, node.uid ?? "")
|
||||
.then(setResource)
|
||||
.catch(() => setResource(undefined))
|
||||
},
|
||||
[app.name],
|
||||
)
|
||||
|
||||
const onSync = useCallback(
|
||||
async (_opts: SyncOptions) => {
|
||||
setBusy(true)
|
||||
try {
|
||||
await DeployApi.sync(app.name)
|
||||
notify(`Sync requested for ${app.name}`)
|
||||
onChanged()
|
||||
load()
|
||||
} catch (e) {
|
||||
notify(`Sync failed: ${e instanceof Error ? e.message : e}`, true)
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
},
|
||||
[app.name, notify, onChanged, load],
|
||||
)
|
||||
|
||||
const onRollback = useCallback(
|
||||
async (revision: string) => {
|
||||
setBusy(true)
|
||||
try {
|
||||
await DeployApi.rollback(app.name, revision)
|
||||
notify(`Rolled back ${app.name} → ${revision}`)
|
||||
onChanged()
|
||||
load()
|
||||
} catch (e) {
|
||||
notify(`Rollback failed: ${e instanceof Error ? e.message : e}`, true)
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
},
|
||||
[app.name, notify, onChanged, load],
|
||||
)
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 14 }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8, flexWrap: "wrap" }}>
|
||||
<button type="button" className="cd-btn" onClick={onBack}>
|
||||
← Fleet
|
||||
</button>
|
||||
<span className="cd-muted">/</span>
|
||||
<span className="cd-mono" style={{ color: "var(--cd-fg-strong)", fontWeight: 700 }}>
|
||||
{app.name}
|
||||
</span>
|
||||
{app.env ? (
|
||||
<span className="cd-mono cd-muted" style={{ fontSize: 12 }}>
|
||||
· {app.env}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<GitopsSyncPanel
|
||||
app={gitopsApp}
|
||||
theme="dark"
|
||||
busy={busy}
|
||||
onSync={onSync}
|
||||
onRefresh={load}
|
||||
onRollback={() => setRollbackOpen(true)}
|
||||
/>
|
||||
|
||||
<div style={{ display: "flex", gap: 12, flexWrap: "wrap", alignItems: "flex-start" }}>
|
||||
{tree === undefined ? (
|
||||
<div className="cd-muted" style={{ padding: 24 }}>
|
||||
Loading topology…
|
||||
</div>
|
||||
) : tree === null ? (
|
||||
<div className="cd-muted" style={{ padding: 24 }}>
|
||||
Could not load the resource tree{treeErr ? `: ${treeErr}` : ""}.
|
||||
</div>
|
||||
) : tree.nodes.length === 0 ? (
|
||||
<div className="cd-muted" style={{ padding: 24 }}>
|
||||
No owned resources reported.
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<GitopsAppTree
|
||||
tree={tree}
|
||||
theme="dark"
|
||||
onSelect={onSelect}
|
||||
selectedId={selected?.uid ?? null}
|
||||
height={400}
|
||||
style={{ flex: 1, minWidth: 280 }}
|
||||
/>
|
||||
{selected ? (
|
||||
<GitopsNodeInfo
|
||||
node={selected}
|
||||
theme="dark"
|
||||
resource={resource}
|
||||
logs={logs}
|
||||
onClose={() => onSelect(null)}
|
||||
style={{ width: "min(440px, 100%)", height: 400, flexShrink: 0 }}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<GitopsRollbackDialog
|
||||
open={rollbackOpen}
|
||||
history={history}
|
||||
theme="dark"
|
||||
busy={busy}
|
||||
onClose={() => setRollbackOpen(false)}
|
||||
onRollback={(entry) => {
|
||||
setRollbackOpen(false)
|
||||
void onRollback(entry.revision)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user