gui converges on one canonical history, here

git.hanzo.ai and github.com/hanzoai/gui held two UNRELATED histories under one
name: no shared commit, no merge-base, and no way to tell from either side that
the other existed. The forge line ran 375 commits to 7.6.5 with the
Tenant*/AppSwitcher/HanzoAppBar naming; the GitHub line ran to 8.1.x with the
Org*/intent/rove refactor. Both were active this month.

The tie is broken by what CONSUMERS resolve, which is not a matter of taste:
every app in the fleet pins `@hanzogui/shell ^8.1.x`, and npm's latest is 8.1.5,
whose source is byte-identical to the GitHub line. The forge line publishes
7.6.x — semver BELOW what every consumer asks for — so nothing it shipped could
reach an app even when it built. That is why archiving the GitHub copy froze the
shared shell for five surfaces: it archived the only lineage anyone runs.

So this commit carries the 8.1.x TREE and BOTH PARENTS. Nothing is discarded and
nothing is force-pushed: the 375 commits stay in main's own ancestry, and the
tag `archive/gui-7x` names their tip so they are reachable without archaeology.
Being a descendant of the previous main, it is an ordinary fast-forward — the
`can_force_push = false` rule on this branch stays exactly as it was, which is
the point. Replacing a history is not a thing this repo should permit, and it
did not have to.

Anything still wanted from the 7.6.x line is cherry-picked forward onto this.
This commit is contained in:
zooqueen
2026-08-13 18:37:41 -07:00
6663 changed files with 1033965 additions and 530605 deletions
-5
View File
@@ -1,5 +0,0 @@
---
"@hanzogui/shell": patch
---
Tight app-first shell defaults: 14px base type scale (linear.app/vercel register, still CSS-var overridable), H-only header lockup (drop product wordmark), and Community hub (remove Showcase; Community → hanzo.app/community).
+45
View File
@@ -0,0 +1,45 @@
---
name: git-safety
description: Git safety rules. INVOKE WHEN: git push, force push, git reset, git clean, destructive git, push force, reset hard. NEVER force push or do destructive git operations.
---
# git safety
rules for git operations in this repo.
## rules
- NEVER force push (`git push --force` or `git push -f`)
- NEVER `git reset --hard` without explicit permission
- NEVER `git clean -fd` without explicit permission
- prefer `git stash` over `git reset`
- always `git pull` before `git push`
## forbidden commands
these require EXPLICIT user permission:
- `git push --force`
- `git push -f`
- `git push --force-with-lease`
- `git reset --hard`
- `git clean -fd`
- `git checkout -- .` (discards all changes)
## safe alternatives
instead of `git reset --hard`:
- `git stash` to save changes
- `git stash pop` to restore
instead of force push:
- `git pull --rebase` then normal push
- ask user how to resolve conflicts
## workflow
1. `git pull` before making changes
2. make changes
3. `git add` relevant files
4. `git commit`
5. `git pull` again before push
6. `git push` (no force flags)
+36
View File
@@ -0,0 +1,36 @@
---
name: release-safety
description: Release safety rules. INVOKE WHEN: yarn release, npm publish, release canary, release packages, publishing, skip checks, skip tests. NEVER skip checks or tests without explicit permission.
---
# release safety
rules for running releases in this monorepo.
## rules
- NEVER add `--skip-checks` unless user explicitly says "skip checks"
- NEVER add `--skip-tests` unless user explicitly says "skip tests"
- if checks or tests fail, FIX THE ISSUES instead of skipping
- ask user before adding any skip flags
- the point of checks is to catch problems before publishing
## when things fail
1. read the error output carefully
2. fix the underlying issue
3. re-run the release
4. do NOT just skip the failing step
## common flags
safe flags:
- `--canary` - publish canary version
- `--ci` - run in ci mode
- `--dirty` - allow dirty working directory
- `--build-fast` - faster builds
dangerous flags (require explicit permission):
- `--skip-checks` - skips type checking
- `--skip-tests` - skips test suite
- `--skip-build` - skips building packages
+1 -1
View File
@@ -7,7 +7,7 @@ node_modules
**/*/node_modules
**/*/dist
**/*/.turbo
**/*/.gui
**/*/.hanzogui
# expo
**/*/.expo
+3 -3
View File
@@ -16,7 +16,7 @@ body:
required: false
- type: textarea
attributes:
label: Hanzo GUI Version
label: Gui Version
render: markdown
validations:
required: true
@@ -32,9 +32,9 @@ body:
render: markdown
description: |
- If you don't include reproduction, we will close the issue.
- The easiest way to reproduce is using the starter free repo: https://github.com/hanzoai/starter-free
- The easiest way to reproduce is using the starter free repo: https://github.com/hanzogui/starter-free
- Clone that repo and push your reproduction as a new commit.
- For simpler bugs you can fork [this CodeSandbox](https://codesandbox.io/p/github/gui/starter-free) to reproduce the issue.
- For simpler bugs you can fork [this CodeSandbox](https://codesandbox.io/p/github/hanzogui/starter-free) to reproduce the issue.
validations:
required: true
- type: textarea
+5 -5
View File
@@ -10,13 +10,13 @@ runs:
using: composite
steps:
- name: Setup Node.js
uses: actions/setup-node@v5
uses: actions/setup-node@v4
with:
node-version-file: '.node-version'
registry-url: 'https://registry.npmjs.org'
- name: Setup Bun
uses: oven-sh/setup-bun@v2
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
with:
bun-version-file: 'package.json'
@@ -28,7 +28,7 @@ runs:
echo "path=$HOME" >> $GITHUB_OUTPUT
- name: Cache Bun
uses: actions/cache@v5
uses: actions/cache@v4
env:
cache-name: bun_cache
with:
@@ -39,9 +39,9 @@ runs:
- name: Install Dependencies
shell: bash
run: bun install
run: bun install --frozen-lockfile
- name: Re-link workspace bins
shell: bash
# Re-run install to ensure bin symlinks are created for workspace packages after build
run: bun install
run: bun install --frozen-lockfile
-9
View File
@@ -1,9 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1280" height="640" viewBox="0 0 1280 640" role="img" aria-label="gui">
<rect width="1280" height="640" fill="#0A0A0A"/>
<svg x="96" y="215" width="210" height="210" viewBox="0 0 67 67"><path d="M22.21 67V44.6369H0V67H22.21Z" fill="#fff"/><path d="M66.7038 22.3184H22.2534L0.0878906 44.6367H44.4634L66.7038 22.3184Z" fill="#fff"/><path d="M22.21 0H0V22.3184H22.21V0Z" fill="#fff"/><path d="M66.7198 0H44.5098V22.3184H66.7198V0Z" fill="#fff"/><path d="M66.7198 67V44.6369H44.5098V67H66.7198Z" fill="#fff"/></svg>
<text x="378" y="276" font-family="Inter,system-ui,-apple-system,sans-serif" font-size="78" font-weight="800" letter-spacing="-2" fill="#ffffff">gui</text>
<text x="378" y="322" font-family="Inter,system-ui,sans-serif" font-size="30" fill="#ffffff" opacity=".66">Hanzo GUI — cross-platform UI primitives, tenant shell…</text>
<rect x="378" y="338" width="806" height="3" rx="1.5" fill="#ffffff" opacity=".9"/>
<text x="378" y="390" font-family="Inter,system-ui,sans-serif" font-size="24" font-weight="600" fill="#ffffff" opacity=".5">github.com/hanzoai</text>
<text x="1184" y="390" text-anchor="end" font-family="Inter,system-ui,sans-serif" font-size="24" font-weight="600" fill="#ffffff" opacity=".5">hanzo.ai</text>
</svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

@@ -0,0 +1,95 @@
import { readdirSync, readFileSync } from 'node:fs'
import { basename, join } from 'node:path'
const repoRoot = process.cwd()
const workflowPath = join(repoRoot, '.github/workflows/test-native.yml')
const e2eDir = join(repoRoot, 'apps/kitchen-sink/e2e')
const explicitlyExcludedTests = new Map([
// android-only: every test skips on iOS (device.getPlatform() === 'ios'), so running
// it on the iOS sim produced zero coverage while burning ~6.6min. it still runs in the
// android Detox job, which executes all e2e files unsharded.
[
'SelectAndroidOnPress.test.ts',
'android-only (skips on iOS); runs in the android job',
],
])
const workflow = readFileSync(workflowPath, 'utf8')
const shardMatches = [...workflow.matchAll(/test_files:\s*'([^']*)'/g)]
if (shardMatches.length === 0) {
console.error(`No iOS Detox shard definitions found in ${workflowPath}`)
process.exit(1)
}
const assignedTestFiles = shardMatches.flatMap((match) =>
match[1]
.split(/\s+/)
.map((entry) => entry.trim())
.filter(Boolean)
)
const assignedTestBasenames = assignedTestFiles
.filter((entry) => entry.endsWith('.test.ts'))
.map((entry) => basename(entry))
const allDetoxTests = readdirSync(e2eDir)
.filter((entry) => entry.endsWith('.test.ts'))
.sort()
const counts = new Map()
for (const testName of assignedTestBasenames) {
counts.set(testName, (counts.get(testName) ?? 0) + 1)
}
const duplicateAssignments = [...counts.entries()]
.filter(([, count]) => count > 1)
.map(([testName, count]) => `${testName} (${count}x)`)
const missingAssignments = allDetoxTests.filter(
(testName) => !counts.has(testName) && !explicitlyExcludedTests.has(testName)
)
const staleExclusions = [...explicitlyExcludedTests.keys()].filter(
(testName) => !allDetoxTests.includes(testName)
)
if (
duplicateAssignments.length === 0 &&
missingAssignments.length === 0 &&
staleExclusions.length === 0
) {
console.info('iOS Detox shard coverage is valid.')
process.exit(0)
}
if (duplicateAssignments.length > 0) {
console.error('Duplicate iOS Detox shard assignments:')
for (const entry of duplicateAssignments) {
console.error(`- ${entry}`)
}
}
if (missingAssignments.length > 0) {
console.error('Missing iOS Detox tests from shard matrix:')
for (const testName of missingAssignments) {
console.error(`- ${testName}`)
}
}
if (staleExclusions.length > 0) {
console.error('Stale explicit shard exclusions:')
for (const testName of staleExclusions) {
console.error(`- ${testName}`)
}
}
if (explicitlyExcludedTests.size > 0) {
console.error('Explicitly excluded iOS Detox tests:')
for (const [testName, reason] of explicitlyExcludedTests.entries()) {
console.error(`- ${testName}: ${reason}`)
}
}
process.exit(1)
+132
View File
@@ -0,0 +1,132 @@
#!/usr/bin/env bash
#
# run each maestro flow as its own invocation, retrying ONLY the flow that
# fails. the previous harness re-ran the WHOLE suite on any failure and only
# passed on a single fully-clean attempt, so a transient flake landing on a
# DIFFERENT flow each attempt could never go green even though every flow
# passed at least once (observed on iOS Native: attempt 1 failed `Select`,
# attempt 2 failed `OpenApp`, each passed on the other attempt -> suite red).
#
# per-flow retry isolates a flake to its own flow and keeps the other flows'
# passes, so the suite goes green as long as every flow passes within its
# own attempt budget.
#
# configuration (env vars, all optional):
# FLOWS_DIR directory of flow .yaml files (default: ./flows)
# BUNDLE_ID app to terminate between attempts (default: com.hanzogui.hanzoguikitchensink)
# MAX_ATTEMPTS attempts per flow before giving up (default: 3)
# SKIP_FLOWS space-separated basenames to skip; these are util
# sub-flows / warm-ups, not standalone tests
# (default: "OpenApp.yaml WarmUp.yaml")
# METRO_PID_FILE if the pid in this file is dead, abort (default: /tmp/metro-pid)
# FLOW_TIMEOUT hard timeout (seconds) per flow attempt; a wedged xcuitest
# driver makes `maestro test` hang with no exit, so without it
# the loop never advances and the job sits idle until the
# runner's 45-min cap (default: 360)
#
# NOT using `set -e`: a failing/timed-out `maestro test` is expected and handled inline.
set -uo pipefail
FLOWS_DIR="${FLOWS_DIR:-./flows}"
BUNDLE_ID="${BUNDLE_ID:-com.hanzogui.hanzoguikitchensink}"
MAX_ATTEMPTS="${MAX_ATTEMPTS:-3}"
SKIP_FLOWS="${SKIP_FLOWS:-OpenApp.yaml WarmUp.yaml}"
METRO_PID_FILE="${METRO_PID_FILE:-/tmp/metro-pid}"
FLOW_TIMEOUT="${FLOW_TIMEOUT:-360}"
metro_alive() {
local pid
pid="$(cat "$METRO_PID_FILE" 2>/dev/null || true)"
[ -z "$pid" ] && return 0 # no tracked pid -> nothing to check
kill -0 "$pid" 2>/dev/null
}
recover_sim() {
echo "recovering simulator state (terminate app, keep Hermes bytecode cache)..."
xcrun simctl terminate booted "$BUNDLE_ID" 2>/dev/null || true
sleep 2
if ! metro_alive; then
echo "::error::Metro died during test run — aborting (every retry would fail)"
tail -50 /tmp/metro.log 2>/dev/null || true
exit 1
fi
}
# run `maestro test <flow>` with a hard per-attempt timeout. when the iOS
# xcuitest driver wedges (e.g. "Request for viewHierarchy failed, code: 500"),
# maestro hangs with no exit — without a timeout the retry loop below never
# advances and the whole job sits idle until the runner's 45-min cap (observed:
# a 28-min hang on one flow that then got force-cancelled = red CI for a flake).
# on timeout we SIGTERM then SIGKILL maestro's PROCESS GROUP — `set -m` gives the
# backgrounded job its own pgid, so killing -$pid also reaps the child
# xcodebuild/driver processes — then recover_sim resets state and the next
# attempt re-establishes a fresh driver. returns maestro's exit code, or 124.
run_flow_with_timeout() {
local flow="$1"
set -m
maestro test "$flow" --no-ansi &
local pid=$!
set +m
local waited=0
while kill -0 "$pid" 2>/dev/null; do
if [ "$waited" -ge "$FLOW_TIMEOUT" ]; then
echo "::warning::flow exceeded ${FLOW_TIMEOUT}s — killing maestro (wedged xcuitest driver?)"
kill -TERM -"$pid" 2>/dev/null || kill -TERM "$pid" 2>/dev/null
sleep 10
kill -KILL -"$pid" 2>/dev/null || kill -KILL "$pid" 2>/dev/null
wait "$pid" 2>/dev/null
return 124
fi
sleep 5
waited=$((waited + 5))
done
wait "$pid"
}
shopt -s nullglob
flows=("$FLOWS_DIR"/*.yaml "$FLOWS_DIR"/*.yml)
shopt -u nullglob
if [ "${#flows[@]}" -eq 0 ]; then
echo "::error::no flow files found in $FLOWS_DIR"
exit 1
fi
failed=""
for flow in "${flows[@]}"; do
base="$(basename "$flow")"
case " $SKIP_FLOWS " in
*" $base "*)
echo "skipping util sub-flow: $base"
continue
;;
esac
name="${base%.*}"
passed=false
for attempt in $(seq 1 "$MAX_ATTEMPTS"); do
echo "::group::$name (attempt $attempt/$MAX_ATTEMPTS)"
if run_flow_with_timeout "$flow"; then
passed=true
echo "::endgroup::"
echo "✓ $name passed (attempt $attempt/$MAX_ATTEMPTS)"
break
fi
echo "::endgroup::"
echo "::warning::$name failed (attempt $attempt/$MAX_ATTEMPTS)"
if [ "$attempt" -lt "$MAX_ATTEMPTS" ]; then
recover_sim
fi
done
if [ "$passed" = false ]; then
failed="$failed $name"
fi
done
if [ -n "$failed" ]; then
echo "::error::flows failed after $MAX_ATTEMPTS attempts:$failed"
exit 1
fi
echo "all flows passed"
@@ -0,0 +1,285 @@
name: Build iOS Test Container App
env:
# Relative path from the monorepo root to the app
test_container_app_path: apps/kitchen-sink
# Package name in the monorepo
test_app_package_name: '@hanzogui/kitchen-sink'
# Should match the name of "ios/<app_name>.xcworkspace"
ios_app_name: hanzoguikitchensink
# A unique ID to identify the app on GitHub Actions, this is used as part of cache keys and artifact names, must be unique among all workflows
app_id: ios-hanzoguikitchensink
# The command used to prebuild the app
prebuild_command: bunx expo prebuild --platform ios --no-install # --no-install is used to skip installing dependencies, specifically `pod install` as we want to do it after the Cache Pods step
# These should be set in the repository secrets
# Redis database used for caching and remembering things between runs, such as the last build number
# KV_STORE_REDIS_REST_URL:
# KV_STORE_REDIS_REST_TOKEN:
on:
workflow_call:
inputs:
configuration:
required: true
type: string
description: "Either 'Debug' or 'Release'."
outputs:
build-hash:
description: 'A hash to identify the build (expo fingerprint).'
value: ${{ jobs.build-ios.outputs.build-hash }}
built-app-cache-key:
description: 'The GitHub Actions cache key of the built .app, can be used in subsequent workflows to get the built app from cache using this key.'
value: ${{ jobs.build-ios.outputs.built-app-cache-key }}
built-app-path:
description: 'The path to the built .app relative to the repository root.'
value: ${{ jobs.build-ios.outputs.built-app-path }}
jobs:
build-ios:
name: Build
runs-on: macos-15
permissions:
contents: read
pull-requests: read
timeout-minutes: 60
outputs:
built-app-cache-key: ${{ steps.check-has-build.outputs.cache-primary-key || steps.pre-check-has-build.outputs.cache-primary-key }}
build-hash: ${{ steps.calculate-fingerprint.outputs.fingerprint || steps.get-fingerprint-from-cache.outputs.fingerprint }}
built-app-path: ${{ steps.get-built-app-path.outputs.built_app_path }}
defaults:
run:
working-directory: ${{ env.test_container_app_path }}
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Set Xcode version
# Xcode 26 (Swift 6.2) is required for expo-modules-core 55.x which uses
# conformance isolation syntax (@MainActor in protocol inheritance clause, SE-0434)
run: sudo xcode-select -s /Applications/Xcode_26.0.1.app
- name: Get Built App Path
id: get-built-app-path
env:
BUILT_APP_PATH: ${{ env.test_container_app_path }}/build/Build/Products/${{ inputs.configuration }}-iphonesimulator/${{ env.ios_app_name }}.app
run: |
echo "Built app path: $BUILT_APP_PATH"
echo "built_app_path=$BUILT_APP_PATH" >> $GITHUB_OUTPUT
- name: Calculate Pre-Fingerprint Hash
id: calculate-pre-fingerprint
env:
# A hash that represents bun.lock + app.json - if these haven't changed,
# the fingerprint likely hasn't changed either. This lets us skip bun install
# and prebuild if we have a cached fingerprint for this pre-fingerprint hash.
PRE_FINGERPRINT_HASH: ${{ hashFiles('bun.lock', format('{0}/app.json', env.test_container_app_path), format('{0}/package.json', env.test_container_app_path), 'packages/vxrn/expo-plugin.cjs') }}
run: |
if [ -z "$PRE_FINGERPRINT_HASH" ]; then
echo '[ERROR] Failed to calculate pre-fingerprint hash.'
fi
echo "Pre-fingerprint hash: $PRE_FINGERPRINT_HASH"
echo "pre_fingerprint_hash=$PRE_FINGERPRINT_HASH" >> $GITHUB_OUTPUT
- name: Read Cached Fingerprint
id: get-fingerprint-from-cache
env:
KV_STORE_REDIS_REST_URL: ${{ secrets.KV_STORE_REDIS_REST_URL }}
KV_STORE_REDIS_REST_TOKEN: ${{ secrets.KV_STORE_REDIS_REST_TOKEN }}
run: |
FINGERPRINT_FROM_CACHE=$(curl "$KV_STORE_REDIS_REST_URL/get/${{ env.app_id }}-fingerprint-from-pre-hash-${{ steps.calculate-pre-fingerprint.outputs.pre_fingerprint_hash }}" -H "Authorization: Bearer $KV_STORE_REDIS_REST_TOKEN" | jq -r '.result')
if [ "$FINGERPRINT_FROM_CACHE" != "null" ]; then
curl -X POST "$KV_STORE_REDIS_REST_URL/EXPIRE/${{ env.app_id }}-fingerprint-from-pre-hash-${{ steps.calculate-pre-fingerprint.outputs.pre_fingerprint_hash }}/2592000" -H "Authorization: Bearer $KV_STORE_REDIS_REST_TOKEN" # Reset TTL to 30 days
echo "Fingerprint from cache: $FINGERPRINT_FROM_CACHE"
echo "fingerprint=$FINGERPRINT_FROM_CACHE" >> $GITHUB_OUTPUT
else
echo 'No cached fingerprint found.'
echo "fingerprint=null" >> $GITHUB_OUTPUT
fi
- name: Check If Build Already Exists
uses: actions/cache/restore@v4
id: pre-check-has-build
if: ${{ steps.get-fingerprint-from-cache.outputs.fingerprint != 'null' }}
with:
key: ${{ env.app_id }}-${{ inputs.configuration }}-${{ steps.get-fingerprint-from-cache.outputs.fingerprint }}
lookup-only: true
path: ${{ steps.get-built-app-path.outputs.built_app_path }}
# The steps below are skipped if we have a cache hit
- name: Install
if: ${{ !steps.pre-check-has-build.outputs.cache-hit }}
uses: ./.github/actions/install
with:
workspace-focus: ${{ env.test_app_package_name }}
- name: Prebuild
if: ${{ !steps.pre-check-has-build.outputs.cache-hit }}
run: ${{ env.prebuild_command }}
# workaround for https://github.com/expo/expo/issues/42525
# remove once expo-modules-core ships a fix or expo SDK 56+ resolves it
- name: Patch Podfile for ExpoModulesCore Swift 6 and ContextMenuAuxiliaryPreview
if: ${{ !steps.pre-check-has-build.outputs.cache-hit }}
run: |
# workaround: expo-modules-core 55.x uses @MainActor in class inheritance
# clauses which requires Swift 6 mode. SWIFT_STRICT_CONCURRENCY=minimal
# suppresses concurrency warnings in swift 6 mode.
# workaround 2: ContextMenuAuxiliaryPreview uses deprecated transform: .default
# which is an error in Xcode 26 / Swift 6.2 - disable warnings-as-errors for it.
PODFILE="ios/Podfile"
if grep -q "ContextMenuAuxiliaryPreview" "$PODFILE" 2>/dev/null; then
echo "Podfile already patched (both ExpoModulesCore + ContextMenuAuxiliaryPreview), skipping"
elif [ -f "$PODFILE" ]; then
node -e "
const fs = require('fs');
const podfile = fs.readFileSync('$PODFILE', 'utf8');
const workaround = \`
# workaround: expo-modules-core 55.x requires Swift 6 mode with isolated
# conformances (SE-0470) for @MainActor in protocol conformance syntax.
installer.pods_project.targets.each do |target|
if target.name == 'ExpoModulesCore'
target.build_configurations.each do |build_config|
build_config.build_settings['SWIFT_VERSION'] = '6'
build_config.build_settings['SWIFT_STRICT_CONCURRENCY'] = 'minimal'
flags = build_config.build_settings['OTHER_SWIFT_FLAGS'] || '$(inherited)'
unless flags.include?('IsolatedConformances')
build_config.build_settings['OTHER_SWIFT_FLAGS'] = "\#{flags} -enable-upcoming-feature IsolatedConformances"
end
end
end
# workaround: ContextMenuAuxiliaryPreview uses deprecated transform: .default
# which becomes a build error in Xcode 26 / Swift 6.2 strict mode.
if target.name == 'ContextMenuAuxiliaryPreview'
target.build_configurations.each do |build_config|
build_config.build_settings['SWIFT_TREAT_WARNINGS_AS_ERRORS'] = 'NO'
build_config.build_settings['GCC_TREAT_WARNINGS_AS_ERRORS'] = 'NO'
end
end
end
\`;
const updated = podfile.replace(
/(post_install do \|installer\|.*?)(^\s+end\s*\nend)/ms,
'\$1' + workaround + '\$2'
);
if (updated === podfile) {
console.error('ERROR: could not find post_install block in Podfile');
process.exit(1);
}
fs.writeFileSync('$PODFILE', updated);
console.log('Patched Podfile with ExpoModulesCore Swift 6 and ContextMenuAuxiliaryPreview workarounds');
"
else
echo "WARNING: Podfile not found at $PODFILE"
fi
- name: Cache Pods
if: ${{ !steps.pre-check-has-build.outputs.cache-hit }}
uses: actions/cache@v4
env:
cache-name: ${{ env.app_id }}-pods
with:
path: ${{ env.test_container_app_path }}/ios/Pods
key: ${{ runner.os }}-${{ env.cache-name }}-${{ hashFiles(format('{0}/ios/Podfile', env.test_container_app_path)) }}
restore-keys: |
${{ runner.os }}-${{ env.cache-name }}-
- name: Pod Install
if: ${{ !steps.pre-check-has-build.outputs.cache-hit }}
run: |
set -o pipefail
pod install --project-directory=ios 2>&1 | tee pod-install.log || {
echo "::error::Pod install failed. Check pod-install.log artifact for details."
echo "=== Last 50 lines of pod install output ==="
tail -50 pod-install.log
exit 1
}
- name: Upload Pod Install Log on Failure
if: failure()
uses: actions/upload-artifact@v4
with:
name: pod-install-log
path: ${{ env.test_container_app_path }}/pod-install.log
retention-days: 7
- name: Calculate Fingerprint
if: ${{ !steps.pre-check-has-build.outputs.cache-hit }}
id: calculate-fingerprint
run: |
# Use expo fingerprint for accurate native dependency detection
FINGERPRINT=$(bunx @expo/fingerprint fingerprint:generate --platform ios | jq -r '.hash')
if [ -z "$FINGERPRINT" ]; then
echo '[ERROR] Failed to calculate fingerprint.'
exit 1
fi
echo "Fingerprint: $FINGERPRINT"
echo "fingerprint=$FINGERPRINT" >> $GITHUB_OUTPUT
- name: Write Fingerprint to Cache
if: ${{ !steps.pre-check-has-build.outputs.cache-hit }}
env:
KV_STORE_REDIS_REST_URL: ${{ secrets.KV_STORE_REDIS_REST_URL }}
KV_STORE_REDIS_REST_TOKEN: ${{ secrets.KV_STORE_REDIS_REST_TOKEN }}
run: |
curl -X POST "$KV_STORE_REDIS_REST_URL/SETEX/${{ env.app_id }}-fingerprint-from-pre-hash-${{ steps.calculate-pre-fingerprint.outputs.pre_fingerprint_hash }}/2592000/${{ steps.calculate-fingerprint.outputs.fingerprint }}" -H "Authorization: Bearer $KV_STORE_REDIS_REST_TOKEN"
- name: Check If Build Already Exists
if: ${{ !steps.pre-check-has-build.outputs.cache-hit }}
uses: actions/cache/restore@v4
id: check-has-build
with:
key: ${{ env.app_id }}-${{ inputs.configuration }}-${{ steps.calculate-fingerprint.outputs.fingerprint }}
lookup-only: true
path: ${{ steps.get-built-app-path.outputs.built_app_path }}
- name: Restore Build Cache
if: ${{ !steps.pre-check-has-build.outputs.cache-hit && !steps.check-has-build.outputs.cache-hit }}
id: restore-build-cache
uses: actions/cache/restore@v4
env:
cache-name: ${{ env.app_id }}-build
with:
key: ${{ runner.os }}-${{ env.cache-name }}
path: |
${{ env.test_container_app_path }}/build
- name: Build
if: ${{ !steps.pre-check-has-build.outputs.cache-hit && !steps.check-has-build.outputs.cache-hit }}
run: |
set -o pipefail
xcrun xcodebuild -scheme '${{ env.ios_app_name }}' \
-workspace 'ios/${{ env.ios_app_name }}.xcworkspace' \
-configuration ${{ inputs.configuration }} \
-sdk 'iphonesimulator' \
-destination 'generic/platform=iOS Simulator' \
-derivedDataPath build \
OTHER_SWIFT_FLAGS='$(inherited) -enable-upcoming-feature IsolatedConformances' \
SWIFT_STRICT_CONCURRENCY=minimal \
RCT_REMOVE_LEGACY_ARCH=1 \
| tee xcodebuild.log | xcpretty
- name: Upload Built App to Cache
if: ${{ !steps.pre-check-has-build.outputs.cache-hit && !steps.check-has-build.outputs.cache-hit }}
uses: actions/cache/save@v4
with:
key: ${{ steps.check-has-build.outputs.cache-primary-key }}
path: ${{ steps.get-built-app-path.outputs.built_app_path }}
- name: Upload Build Log
uses: actions/[email protected]
if: ${{ always() && !steps.pre-check-has-build.outputs.cache-hit && !steps.check-has-build.outputs.cache-hit }}
with:
name: xcodebuild-${{ env.app_id }}-${{ inputs.configuration }}.log
path: |
${{ env.test_container_app_path }}/xcodebuild.log
- name: Save Build Cache
uses: actions/cache/save@v4
if: ${{ always() && !steps.pre-check-has-build.outputs.cache-hit && !steps.check-has-build.outputs.cache-hit }}
with:
key: ${{ steps.restore-build-cache.outputs.cache-primary-key }}
path: |
${{ env.test_container_app_path }}/build
+23
View File
@@ -0,0 +1,23 @@
name: Changelog
on:
push:
tags:
- 'v*'
permissions:
contents: read
jobs:
tagged-release:
name: Changelog
runs-on: ubuntu-latest
# needs: release
permissions:
contents: write
steps:
- uses: marvinpinto/action-automatic-releases@d68defdd11f9dcc7f52f35c1b7c236ee7513bcc1 # latest (archived action - consider replacing with softprops/action-gh-release)
with:
repo_token: '${{ github.token }}'
prerelease: false
+213
View File
@@ -0,0 +1,213 @@
name: Checks
on:
workflow_dispatch:
pull_request:
push:
paths-ignore:
- 'assets/**'
- '.vscode/**'
branches:
- main
- v2
- 'v2-*'
permissions:
contents: read
# cancel in-progress runs on the same branch (avoids duplicate PR + push runs)
concurrency:
group: checks-${{ github.ref }}
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
jobs:
checks:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Install
uses: ./.github/actions/install
# replaces dependabot. all critical advisories are pinned to patched
# versions via root package.json "overrides", so this gates cleanly with
# no ignore list. remaining high/moderate advisories require cross-major
# bumps of example/tooling deps (vite, undici, tar, react-router, etc.) -
# run `bun audit` locally to review them.
- name: Security audit
run: bun audit --audit-level critical
- name: Check
run: bun run check
# Every package's declarations, which this gate reads and nothing else
# here emits. `<pkg>/types/*.d.ts` used to be committed — 2,885 of them,
# until d1f5ae7fc0 correctly reclassified them as build artifacts and
# gitignored them. A fresh checkout then has no types/ at all, so every
# workspace import resolved to an untyped module and Typecheck went red
# with 139 errors that are nowhere in the source ("@hanzogui/themes/v5 has
# no exported member 'themes'" and friends).
#
# Install's pre-build is the JS half — postinstall runs build:js, which is
# SKIP_TYPES=1. This is the other half. Both halves are one turbo `build`
# task, so the second pass reuses the first's cache for everything except
# the declarations it adds.
- name: Build declarations
run: bun run build:types
- name: Typecheck
run: bun run typecheck
- name: Lint
run: bun run lint
unit-tests:
runs-on: ubuntu-latest
env:
NODE_OPTIONS: '--max-old-space-size=6144'
# NO SKIP_TYPES. It used to be set here, reasoning that "types are the
# checks job's business, not ours" — but two of these suites are
# `vitest --typecheck` (@hanzogui/web, @hanzogui/input) and they read
# their dependencies' .d.ts. Those live in <pkg>/types/, gitignored and
# emitted only by a types build, so skipping types made ^build emit JS
# alone, @hanzogui/native resolve as an untyped module, and
# React.useContext(NativeMenuContext) collapse to `unknown`. The job then
# failed in createComponent.tsx on a type error that is not in the source
# and cannot be fixed there. Let ^build emit what the gate reads.
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Install
uses: ./.github/actions/install
- name: Get Playwright version
id: pw-version
run: echo "version=$(bunx playwright --version | awk '{print $2}')" >> $GITHUB_OUTPUT
- name: Cache Playwright browsers
uses: actions/cache@v4
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ steps.pw-version.outputs.version }}
- name: Install playwright
run: bunx playwright install
- name: Restore Turbo cache
uses: actions/cache@v4
with:
path: .turbo/cache
key: turbo-${{ runner.os }}-${{ github.sha }}
restore-keys: |
turbo-${{ runner.os }}-
- name: Run unit tests
run: bun turbo run test:web --filter='!@hanzogui/kitchen-sink' --concurrency=1
- name: Run native unit tests
run: bun turbo run test:native --filter=@hanzogui/static-tests --filter=@hanzogui/core-test --filter=@hanzogui/components-test --concurrency=1
changes:
runs-on: ubuntu-latest
outputs:
integration-relevant: ${{ steps.turbo-check.outputs.relevant }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
with:
bun-version-file: 'package.json'
# turbo reads workspace graph from package.json files — no bun install needed
# TODO: turbo ls --affected is broken in <=2.8.20 (ignores git refs, false positives
# on root config changes — vercel/turborepo#11144, #10869). fix landed in 2.8.21
# canaries (PR #12424). once stable, simplify this to: turbo ls --affected
- name: Install turbo
run: npm install -g turbo@2
- name: Check if kitchen-sink dependency tree changed
id: turbo-check
run: |
if [ "${{ github.event_name }}" = "pull_request" ]; then
BASE="${{ github.event.pull_request.base.sha }}"
else
BASE="HEAD~1"
fi
# get all package directories in kitchen-sink's transitive dependency tree
# turbo ls resolves the real workspace dependency graph — no manual list needed
DEP_DIRS=$(turbo ls --filter='@hanzogui/kitchen-sink...' 2>/dev/null | awk 'NR>1 && NF>0 {print $2}' | grep '/')
if [ -z "$DEP_DIRS" ]; then
echo "relevant=true" >> $GITHUB_OUTPUT
echo "::warning::Could not resolve dependency tree, running tests as fallback"
exit 0
fi
# build a single regex from all dependency directories + root config files
PATTERN=$(echo "$DEP_DIRS" | paste -sd'|' -)
FULL_PATTERN="^($PATTERN)/|^(package\.json|bun\.lock|turbo\.json)$"
CHANGED=$(git diff --name-only "$BASE" HEAD)
if echo "$CHANGED" | grep -qE "$FULL_PATTERN"; then
MATCHED=$(echo "$CHANGED" | grep -E "$FULL_PATTERN" | head -5)
echo "relevant=true" >> $GITHUB_OUTPUT
echo "::notice::Integration tests needed. Changed dependency files: $(echo "$MATCHED" | tr '\n' ', ')"
else
echo "relevant=false" >> $GITHUB_OUTPUT
echo "::notice::Integration tests skipped — no changes in kitchen-sink dependency tree"
fi
integration-tests:
needs: changes
if: needs.changes.outputs.integration-relevant == 'true'
# gate is turbo's actual workspace dependency graph — no manual path list to maintain
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
shard: [1/3, 2/3, 3/3]
env:
NODE_OPTIONS: '--max-old-space-size=6144'
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Install
uses: ./.github/actions/install
- name: Get Playwright version
id: pw-version
run: echo "version=$(bunx playwright --version | awk '{print $2}')" >> $GITHUB_OUTPUT
- name: Cache Playwright browsers
uses: actions/cache@v4
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ steps.pw-version.outputs.version }}
- name: Install playwright
run: bunx playwright install --with-deps
- name: Restore Turbo cache
uses: actions/cache@v4
with:
path: .turbo/cache
key: turbo-${{ runner.os }}-${{ github.sha }}
restore-keys: |
turbo-${{ runner.os }}-
- name: Run integration tests (shard ${{ matrix.shard }})
run: cd apps/kitchen-sink && NODE_ENV=test npx playwright test --shard=${{ matrix.shard }}
timeout-minutes: 20
+308
View File
@@ -0,0 +1,308 @@
name: Release
# github evaluates workflow_run from the default branch. keep this workflow on
# main as well as v3-beta so successful v3-beta CI can publish automatically.
on:
workflow_run:
workflows: [Checks]
types: [completed]
workflow_dispatch:
inputs:
release:
description: Version to publish from main
required: true
default: canary
type: choice
options:
- canary
- patch
- minor
- major
permissions: {}
concurrency:
group: npm-release
cancel-in-progress: false
jobs:
beta:
name: Publish v3 beta
if: >-
github.event_name == 'workflow_run' &&
github.event.workflow_run.conclusion == 'success' &&
github.event.workflow_run.event == 'push' &&
github.event.workflow_run.head_branch == 'v3-beta'
runs-on: ubuntu-latest
timeout-minutes: 60
permissions:
contents: read
id-token: write
steps:
- name: Checkout tested v3-beta source
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
fetch-depth: 0
ref: ${{ github.event.workflow_run.head_sha }}
persist-credentials: false
- name: Select latest v3-beta build
id: candidate
shell: bash
run: |
set -euo pipefail
git fetch --no-tags origin v3-beta
if [[ "$(git rev-parse HEAD)" != "$(git rev-parse origin/v3-beta)" ]]; then
echo "A newer v3-beta commit exists; its Checks run will publish instead."
echo "release=false" >> "$GITHUB_OUTPUT"
else
echo "release=true" >> "$GITHUB_OUTPUT"
fi
- name: Setup Node.js
if: steps.candidate.outputs.release == 'true'
uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0
with:
node-version: '24'
registry-url: 'https://registry.npmjs.org'
package-manager-cache: false
- name: Setup Bun
if: steps.candidate.outputs.release == 'true'
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
bun-version-file: 'package.json'
- name: Install npm with trusted publishing support
if: steps.candidate.outputs.release == 'true'
run: npm install --global [email protected]
- name: Install dependencies
if: steps.candidate.outputs.release == 'true'
run: bun install --frozen-lockfile
- name: Get Playwright version
if: steps.candidate.outputs.release == 'true'
id: beta-pw-version
run: echo "version=$(bunx playwright --version | awk '{print $2}')" >> "$GITHUB_OUTPUT"
- name: Cache Playwright browsers
if: steps.candidate.outputs.release == 'true'
uses: actions/cache@v4
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ steps.beta-pw-version.outputs.version }}
- name: Install Playwright Chromium
if: steps.candidate.outputs.release == 'true'
run: bunx playwright install --with-deps chromium
- name: Publish tested source to the beta channel
if: steps.candidate.outputs.release == 'true'
env:
BETA_VERSION: 3.0.0-beta.${{ github.run_number }}.${{ github.run_attempt }}
run: >-
bun scripts/release.ts
--beta
--version "$BETA_VERSION"
--ci
--dirty
--skip-finish
--skip-tests
--skip-native-tests
--skip-checks
ensure-ci:
name: Approve main release source
if: github.event_name == 'workflow_dispatch'
runs-on: ubuntu-latest
timeout-minutes: 90
permissions:
actions: write
contents: read
steps:
# a canary is a throwaway version on its own dist-tag that nothing installs
# unless asked for by name, so it is cuttable from any branch and does not
# wait on a full CI run. requiring current main meant merging a change just
# to test it. a stable release still needs both.
- name: Require current main and successful full CI
if: inputs.release != 'canary'
env:
GH_TOKEN: ${{ github.token }}
TARGET_SHA: ${{ github.sha }}
WORKFLOW_FILE: checks.yaml
shell: bash
run: |
set -euo pipefail
[[ "$GITHUB_REF" == "refs/heads/main" ]]
main_sha=$(gh api "repos/$GITHUB_REPOSITORY/git/ref/heads/main" --jq .object.sha)
[[ "$TARGET_SHA" == "$main_sha" ]]
get_runs() {
gh api --method GET "repos/$GITHUB_REPOSITORY/actions/workflows/$WORKFLOW_FILE/runs" \
-f head_sha="$TARGET_SHA" -f per_page=20
}
wait_for_run() {
local run_id="$1"
for ((attempt = 1; attempt <= 40; attempt++)); do
read -r status conclusion < <(
gh api "repos/$GITHUB_REPOSITORY/actions/runs/$run_id" \
--jq '[.status, (.conclusion // "pending")] | @tsv'
)
if [[ "$status" == "completed" ]]; then
[[ "$conclusion" == "success" ]]
return
fi
sleep 120
done
echo "Timed out waiting for CI run $run_id" >&2
return 1
}
runs=$(get_runs)
run_id=$(jq -r '.workflow_runs | sort_by(.created_at) | last | .id // empty' <<< "$runs")
if [[ -n "$run_id" ]]; then
status=$(jq -r --argjson id "$run_id" '.workflow_runs[] | select(.id == $id) | .status' <<< "$runs")
conclusion=$(jq -r --argjson id "$run_id" '.workflow_runs[] | select(.id == $id) | .conclusion // "pending"' <<< "$runs")
if [[ "$status" == "completed" && "$conclusion" == "success" ]]; then
echo "Full CI already passed for $TARGET_SHA."
exit 0
fi
if [[ "$status" != "completed" ]] && wait_for_run "$run_id"; then
echo "Existing full CI passed for $TARGET_SHA."
exit 0
fi
fi
started=$(date -u +'%Y-%m-%dT%H:%M:%SZ')
gh api --method POST \
"repos/$GITHUB_REPOSITORY/actions/workflows/$WORKFLOW_FILE/dispatches" \
-f ref=main
run_id=''
for ((attempt = 1; attempt <= 12; attempt++)); do
sleep 10
runs=$(get_runs)
run_id=$(jq -r --arg started "$started" \
'[.workflow_runs[] | select(.event == "workflow_dispatch" and .created_at >= $started)] | sort_by(.created_at) | last | .id // empty' \
<<< "$runs")
[[ -n "$run_id" ]] && break
done
[[ -n "$run_id" ]]
wait_for_run "$run_id"
publish:
name: Publish approved main release
if: github.event_name == 'workflow_dispatch'
needs: ensure-ci
runs-on: ubuntu-latest
timeout-minutes: 60
permissions:
contents: write
id-token: write
steps:
- name: Checkout release source
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
fetch-depth: 0
persist-credentials: false
- name: Validate release source and select baseline
id: source
env:
RELEASE_KIND: ${{ inputs.release }}
shell: bash
run: |
set -euo pipefail
git fetch --no-tags origin main
# baseline selection below is needed for every release kind; only the
# must-be-current-main assertion is stable-only (see ensure-ci).
if [[ "$RELEASE_KIND" != 'canary' ]]; then
[[ "$GITHUB_REF" == "refs/heads/main" ]]
[[ "$(git rev-parse HEAD)" == "$(git rev-parse origin/main)" ]]
fi
tag_ref=$(git describe --tags --match 'v*' --abbrev=0 2>/dev/null || true)
canary_ref=$(git log --grep='^canary' --format='%H' -1)
if [[ -z "$tag_ref" ]]; then
base_ref="$canary_ref"
elif [[ -z "$canary_ref" ]]; then
base_ref="$tag_ref"
elif (( $(git log -1 --format=%ct "$canary_ref") > $(git log -1 --format=%ct "$tag_ref") )); then
base_ref="$canary_ref"
else
base_ref="$tag_ref"
fi
[[ -n "$base_ref" ]]
echo "base-ref=$base_ref" >> "$GITHUB_OUTPUT"
- name: Setup Node.js
uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0
with:
node-version: '24'
registry-url: 'https://registry.npmjs.org'
package-manager-cache: false
- name: Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
bun-version-file: 'package.json'
- name: Install npm with trusted publishing support
run: npm install --global [email protected]
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Prepare release commit
env:
RELEASE_KIND: ${{ inputs.release }}
RELEASE_BASE_REF: ${{ steps.source.outputs.base-ref }}
shell: bash
run: |
set -euo pipefail
git config user.name 'Gui Release'
git config user.email '[email protected]'
bun scripts/release.ts "--$RELEASE_KIND" --ci --dirty --skip-publish --skip-push --skip-tests --skip-native-tests --skip-checks
# a canary mutates no git at all, matching orez and takeout. pushing a
# throwaway version commit to main is what wedged the one repo: main
# requires a pull request and a merge queue there, so the push is rejected
# with GH006 and the publish step below never runs. the bump only has to
# exist in the tree for npm.
- name: Push release commit
if: inputs.release != 'canary'
env:
GH_TOKEN: ${{ github.token }}
TARGET_SHA: ${{ github.sha }}
shell: bash
run: |
set -euo pipefail
git fetch --no-tags origin main
[[ "$(git rev-parse origin/main)" == "$TARGET_SHA" ]]
auth=$(printf 'x-access-token:%s' "$GH_TOKEN" | base64 | tr -d '\n')
trap 'git config --local --unset-all http.https://github.com/.extraheader || true' EXIT
git config --local http.https://github.com/.extraheader "AUTHORIZATION: basic $auth"
git push origin HEAD:main
- name: Publish prepared version
env:
RELEASE_BASE_REF: ${{ steps.source.outputs.base-ref }}
run: bun scripts/release.ts --republish --ci --dirty --skip-finish
- name: Push stable tag
if: inputs.release != 'canary'
env:
GH_TOKEN: ${{ github.token }}
shell: bash
run: |
set -euo pipefail
version=$(node -p "require('./pkgs/ui/hanzogui/package.json').version")
[[ "$(git rev-list -n 1 "v$version")" == "$(git rev-parse HEAD)" ]]
auth=$(printf 'x-access-token:%s' "$GH_TOKEN" | base64 | tr -d '\n')
trap 'git config --local --unset-all http.https://github.com/.extraheader || true' EXIT
git config --local http.https://github.com/.extraheader "AUTHORIZATION: basic $auth"
git push origin "v$version"
gh release create "v$version" --verify-tag --generate-notes --title "v$version"
@@ -0,0 +1,132 @@
name: Test iOS Kitchen Sink Go (Maestro)
on:
push:
branches: [main, 'v2-*']
pull_request:
branches: [main, 'v*']
workflow_dispatch:
permissions:
contents: read
env:
test_container_app_path: apps/kitchen-sink-go
jobs:
test:
# TODO: re-enable once Sheet demo is fixed on Expo Go CI
if: false
name: Run Maestro Tests (exports=${{ matrix.package-exports }})
runs-on: macos-14
timeout-minutes: 30
strategy:
fail-fast: false
matrix:
package-exports: [true, false]
defaults:
run:
working-directory: ${{ env.test_container_app_path }}
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Set Xcode version
run: sudo xcode-select -s /Applications/Xcode_16.2.app
- name: Install Maestro
env:
MAESTRO_VERSION: cli-2.3.0
run: |
curl -fsSL "https://github.com/mobile-dev-inc/maestro/releases/download/${MAESTRO_VERSION}/maestro.zip" -o maestro.zip
unzip -q maestro.zip
mv maestro "$HOME/.maestro"
echo "$HOME/.maestro/bin" >> $GITHUB_PATH
- name: Install Dependencies
uses: ./.github/actions/install
- name: Boot iOS Simulator
working-directory: ${{ github.workspace }}
run: |
DEVICE_ID=$(xcrun simctl list devices available -j | jq -r '.devices | to_entries[] | select(.key | contains("iOS")) | .value[] | select(.name == "iPhone 16") | .udid' | head -1)
if [ -z "$DEVICE_ID" ]; then
echo "iPhone 16 not found, using first available iPhone"
DEVICE_ID=$(xcrun simctl list devices available -j | jq -r '.devices | to_entries[] | select(.key | contains("iOS")) | .value[] | select(.name | contains("iPhone")) | .udid' | head -1)
fi
echo "Booting simulator: $DEVICE_ID"
xcrun simctl boot "$DEVICE_ID" || true
xcrun simctl bootstatus "$DEVICE_ID" -b
echo "Waiting for simulator to be fully ready for Maestro..."
for i in {1..30}; do
if $HOME/.maestro/bin/maestro --device "$DEVICE_ID" test --help > /dev/null 2>&1; then
echo "Maestro can communicate with device"
break
fi
echo "Waiting for Maestro device detection... ($i/30)"
sleep 2
done
sleep 5
- name: Install Expo Go
run: |
# Install Expo Go on simulator (no custom build needed)
bunx expo start --ios &
sleep 30
# Kill expo after it installs Expo Go
kill %1 || true
- name: Start Metro Bundler
run: |
EXPO_NO_TELEMETRY=true GUI_PACKAGE_EXPORTS=${{ matrix.package-exports }} bunx expo start --offline &
echo "Waiting for Metro to start..."
for i in {1..30}; do
if curl -s "http://127.0.0.1:8081/" -H "Expo-Platform: ios" > /dev/null 2>&1; then
echo "Metro is responding!"
break
fi
echo "Waiting for Metro... ($i/30)"
sleep 2
done
- name: Pre-warm Bundle
run: |
echo "Pre-warming bundle..."
MANIFEST=$(curl -s "http://127.0.0.1:8081/" -H "Expo-Platform: ios")
BUNDLE_URL=$(echo "$MANIFEST" | jq -r '.launchAsset.url')
echo "Fetching bundle from: $BUNDLE_URL"
curl -s --max-time 900 "$BUNDLE_URL" > /dev/null 2>&1 || echo "Bundle fetch completed (or timed out)"
echo "Bundle pre-warm complete"
- name: Verify Maestro Device Connection
run: |
echo "Checking Maestro device connection..."
for i in {1..10}; do
if xcrun simctl list devices booted | grep -q "Booted"; then
echo "Simulator is booted"
sleep 3
break
fi
echo "Waiting for device... ($i/10)"
sleep 3
done
xcrun simctl list devices booted
# per-flow retry (shared with test-ios-native): a flake in one flow is
# retried in isolation instead of re-running the whole suite. runs Expo
# Go (host.exp.Exponent), so terminate that between attempts.
- name: Run Maestro Tests
env:
BUNDLE_ID: host.exp.Exponent
run: bash "${{ github.workspace }}/.github/scripts/run-maestro-flows.sh"
- name: Upload Maestro Artifacts on Failure
if: failure()
uses: actions/upload-artifact@v4
with:
name: maestro-artifacts-exports-${{ matrix.package-exports }}
path: |
~/.maestro/tests/**/*
+238
View File
@@ -0,0 +1,238 @@
name: Test iOS Native (Maestro)
on:
push:
branches: [main, 'v2-*', rn82]
pull_request:
branches: [main, 'v*']
workflow_dispatch:
permissions:
contents: read
# required: this workflow calls the reusable build-ios-kitchensink-app.yml,
# whose build-ios job declares pull-requests: read (caller must grant >= callee)
pull-requests: read
env:
test_container_app_path: apps/kitchen-sink
MAESTRO_DRIVER_STARTUP_TIMEOUT: 360000
jobs:
build:
# don't forward secrets (KV_STORE_REDIS_REST_*) to fork PR builds running on macOS runners.
# fork PRs skip the native build entirely; same-repo refs still build with secrets.
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
uses: ./.github/workflows/build-ios-kitchensink-app.yml
with:
configuration: Debug
secrets: inherit
test:
name: Run Maestro Tests (exports=${{ matrix.package-exports }})
needs: build
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
runs-on: macos-15
timeout-minutes: 45
strategy:
fail-fast: false
matrix:
package-exports: [true, false]
defaults:
run:
working-directory: ${{ env.test_container_app_path }}
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Set Xcode version
run: sudo xcode-select -s /Applications/Xcode_26.0.1.app
- name: Restore Built App from Cache
id: restore-app-cache
uses: actions/cache/restore@v4
with:
key: ${{ needs.build.outputs.built-app-cache-key }}
path: ${{ needs.build.outputs.built-app-path }}
- name: Check Cache Restore
if: steps.restore-app-cache.outputs.cache-hit != 'true'
run: |
echo "::warning::iOS cache not restored (key: ${{ needs.build.outputs.built-app-cache-key }}). This may happen on first run after cache key changes."
echo "SKIP_MAESTRO_TESTS=true" >> $GITHUB_ENV
- name: Install Maestro
if: env.SKIP_MAESTRO_TESTS != 'true'
env:
MAESTRO_VERSION: cli-2.3.0
run: |
# Pin to specific version via GitHub releases (avoids broken get.maestro.mobile.dev)
curl -fsSL "https://github.com/mobile-dev-inc/maestro/releases/download/${MAESTRO_VERSION}/maestro.zip" -o maestro.zip
unzip -q maestro.zip
mv maestro "$HOME/.maestro"
echo "$HOME/.maestro/bin" >> $GITHUB_PATH
# Install dependencies BEFORE booting simulator to avoid simulator timeout
# during potentially long bun install + build:js steps
- name: Install Dependencies
if: env.SKIP_MAESTRO_TESTS != 'true'
uses: ./.github/actions/install
- name: Boot Simulator & Start Metro (parallel)
if: env.SKIP_MAESTRO_TESTS != 'true'
working-directory: ${{ github.workspace }}
run: |
# start Metro in background, capture output for debugging
cd "${{ env.test_container_app_path }}"
EXPO_NO_TELEMETRY=true GUI_PACKAGE_EXPORTS=${{ matrix.package-exports }} bunx expo start --dev-client --offline --reset-cache > /tmp/metro.log 2>&1 &
METRO_PID=$!
echo "$METRO_PID" > /tmp/metro-pid
cd "${{ github.workspace }}"
# boot simulator
DEVICE_ID=$(xcrun simctl list devices available -j | jq -r '.devices | to_entries[] | select(.key | contains("iOS")) | .value[] | select(.name == "iPhone 16") | .udid' | head -1)
if [ -z "$DEVICE_ID" ]; then
echo "iPhone 16 not found, using first available iPhone"
DEVICE_ID=$(xcrun simctl list devices available -j | jq -r '.devices | to_entries[] | select(.key | contains("iOS")) | .value[] | select(.name | contains("iPhone")) | .udid' | head -1)
fi
echo "Booting simulator: $DEVICE_ID"
xcrun simctl boot "$DEVICE_ID" || true
xcrun simctl bootstatus "$DEVICE_ID" -b
# install app and verify FrontBoard registration
APP_PATH="${{ needs.build.outputs.built-app-path }}"
BUNDLE_ID="com.hanzogui.hanzoguikitchensink"
for attempt in 1 2 3; do
xcrun simctl install booted "$APP_PATH"
sleep 3
if xcrun simctl get_app_container booted "$BUNDLE_ID" 2>/dev/null; then
echo "App installed and registered (attempt $attempt)"
break
fi
echo "::warning::App not registered after install (attempt $attempt)"
xcrun simctl uninstall booted "$BUNDLE_ID" 2>/dev/null || true
sleep 3
if [ $attempt -eq 3 ]; then
echo "::error::App failed to register after 3 attempts"
exit 1
fi
done
# wait for Metro to be ready, fail if it never responds
echo "Waiting for Metro..."
METRO_READY=false
for i in {1..90}; do
# check Metro process is still alive
if ! kill -0 "$METRO_PID" 2>/dev/null; then
echo "::error::Metro process died unexpectedly"
echo "=== Metro output ==="
cat /tmp/metro.log
exit 1
fi
if curl -s "http://127.0.0.1:8081/" -H "Expo-Platform: ios" > /dev/null 2>&1; then
echo "Metro is responding after ~$((i*2))s"
METRO_READY=true
break
fi
sleep 2
done
if [ "$METRO_READY" != "true" ]; then
echo "::error::Metro failed to respond within 180s"
echo "=== Metro output ==="
cat /tmp/metro.log
exit 1
fi
- name: Pre-warm Bundle
if: env.SKIP_MAESTRO_TESTS != 'true'
run: |
echo "Pre-warming JS bundle (Metro build + cache)..."
MANIFEST=$(curl -s "http://127.0.0.1:8081/" -H "Expo-Platform: ios")
BUNDLE_URL=$(echo "$MANIFEST" | jq -r '.launchAsset.url')
if [ -z "$BUNDLE_URL" ] || [ "$BUNDLE_URL" = "null" ]; then
echo "::error::Failed to get bundle URL from manifest"
echo "Manifest response: $MANIFEST"
exit 1
fi
echo "Fetching bundle from: $BUNDLE_URL"
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" --max-time 900 "$BUNDLE_URL")
if [ "$HTTP_CODE" != "200" ]; then
echo "::error::Bundle fetch failed with HTTP $HTTP_CODE"
exit 1
fi
echo "Bundle pre-warm complete (HTTP $HTTP_CODE)"
- name: Start Simulator Log Collection
if: env.SKIP_MAESTRO_TESTS != 'true'
run: |
# capture simulator device logs in background for debugging app crashes
xcrun simctl spawn booted log stream --predicate 'process == "hanzoguikitchensink" OR subsystem == "com.hanzogui.hanzoguikitchensink"' --level debug > /tmp/simulator-app.log 2>&1 &
echo $! > /tmp/simlog-pid
# also capture broader crash/error logs
xcrun simctl spawn booted log stream --predicate 'eventMessage CONTAINS "crash" OR eventMessage CONTAINS "fatal" OR eventMessage CONTAINS "error" OR process == "ReportCrash"' --level error > /tmp/simulator-errors.log 2>&1 &
echo $! > /tmp/simlog-errors-pid
- name: Warm up App (cold start + Hermes bytecode compile)
if: env.SKIP_MAESTRO_TESTS != 'true'
run: |
# WarmUp.yaml does clearState:true for a clean first launch.
# Subsequent test flows use OpenApp.yaml (no clearState) so Hermes
# bytecode cache persists across launches = much faster restarts.
# if WarmUp fails, the Maestro xcuitest driver (port 7001) is usually
# dead — every subsequent flow will fail with "Failed to connect to
# /127.0.0.1:7001", so fail fast rather than burn 15+ min on doomed retries.
if ! maestro test ./flows/WarmUp.yaml --no-ansi; then
echo "::error::WarmUp failed — Maestro driver is likely dead, aborting"
echo "=== Recent app logs ==="
tail -50 /tmp/simulator-app.log 2>/dev/null || echo "No app logs"
echo "=== Metro output ==="
tail -50 /tmp/metro.log 2>/dev/null || echo "No metro logs"
exit 1
fi
# run each flow independently and retry ONLY the flow that flakes, so a
# transient flake in one flow doesn't discard the passes of the others.
# the OpenApp/WarmUp util sub-flows are skipped (WarmUp ran in the step
# above; OpenApp is invoked via runFlow by every component flow).
- name: Run Maestro Tests
if: env.SKIP_MAESTRO_TESTS != 'true'
run: bash "${{ github.workspace }}/.github/scripts/run-maestro-flows.sh"
- name: Dump Logs on Failure
if: failure() && env.SKIP_MAESTRO_TESTS != 'true'
run: |
# stop log collection
kill "$(cat /tmp/simlog-pid 2>/dev/null)" 2>/dev/null || true
kill "$(cat /tmp/simlog-errors-pid 2>/dev/null)" 2>/dev/null || true
echo "::group::Simulator App Logs (last 200 lines)"
tail -200 /tmp/simulator-app.log 2>/dev/null || echo "No app logs captured"
echo "::endgroup::"
echo "::group::Simulator Error Logs (last 100 lines)"
tail -100 /tmp/simulator-errors.log 2>/dev/null || echo "No error logs captured"
echo "::endgroup::"
echo "::group::Metro Bundler Output (last 100 lines)"
tail -100 /tmp/metro.log 2>/dev/null || echo "No metro logs captured"
echo "::endgroup::"
echo "::group::Crash Reports"
find ~/Library/Logs/DiagnosticReports -name "*hanzoguikitchensink*" -newer /tmp/simulator-app.log 2>/dev/null | while read f; do
echo "=== $f ==="
head -50 "$f"
done || echo "No crash reports found"
echo "::endgroup::"
- name: Report Test Status
if: always() && env.SKIP_MAESTRO_TESTS == 'true'
run: |
echo "::warning::Maestro tests skipped - cache was not available (will be available on next run)"
- name: Upload Maestro Artifacts on Failure
if: failure() && env.SKIP_MAESTRO_TESTS != 'true'
uses: actions/upload-artifact@v4
with:
name: maestro-artifacts-exports-${{ matrix.package-exports }}
path: |
~/.maestro/tests/**/*
/tmp/simulator-app.log
/tmp/simulator-errors.log
/tmp/metro.log
+669
View File
@@ -0,0 +1,669 @@
name: Native Tests (Detox)
on:
push:
branches:
- main
- 'native-*'
- 'v*'
- rn82
workflow_dispatch:
permissions:
contents: read
# required: this workflow calls the reusable build-ios-kitchensink-app.yml,
# whose build-ios job declares pull-requests: read (caller must grant >= callee)
pull-requests: read
# cancel in-progress runs on feature branches, but let main finish for full history
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
env:
test_container_app_path: apps/kitchen-sink
# Increment this to force a fresh Android build (invalidates Redis KV cache)
android_cache_version: v4
jobs:
# ─────────────────────────────────────────────────────────────────────────────
# iOS Build & Test
# ─────────────────────────────────────────────────────────────────────────────
validate-ios-detox-shards:
name: Validate iOS Detox Shards
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Validate shard coverage
run: node .github/scripts/check-ios-detox-shards.mjs
build-ios:
name: Build iOS App
uses: ./.github/workflows/build-ios-kitchensink-app.yml
with:
configuration: Debug
secrets: inherit
test-ios:
name: iOS Detox Tests (${{ matrix.shard_name }})
needs:
- build-ios
- validate-ios-detox-shards
runs-on: macos-15
timeout-minutes: 90
strategy:
fail-fast: false
matrix:
include:
# 4 shards balanced by test execution time. the old ~7min/shard ts-jest
# type-check tax is gone (e2e now transpiles via e2e/tsconfig.json), so
# startup is small and these fit well under the native-ci runner's
# 45-minute process timeout. CompilerExtraction runs an in-test
# `npx hanzogui build` (~12-19min, variable) so it gets its OWN shard
# (with the iOS-skipped SafeArea/SheetKeyboardDrag, which cost ~0, plus
# the small iOS-running SheetFitKeyboardSafeArea, ~1 launch); the
# remaining files are bin-packed to ~14.5min test-exec each (~25min wall).
# SelectAndroidOnPress is android-only (excluded here via
# check-ios-detox-shards.mjs, still runs in the android job). Coverage
# validated by that script (every e2e test assigned exactly once).
- shard_name: '1/4'
test_files: 'e2e/Accordion.test.ts e2e/CompilerExtraction.test.ts e2e/SafeArea.test.ts e2e/SheetKeyboardDrag.test.ts e2e/SheetFitKeyboardSafeArea.test.ts e2e/SheetPressResponderSheetRngh.test.ts'
- shard_name: '2/4'
test_files: 'e2e/PressStyleNative.noRngh.test.ts e2e/CompilerTernaryActive.test.ts e2e/TabsOnInteraction.test.ts e2e/PointerEvents.test.ts e2e/GroupPressNative.test.ts e2e/ShorthandVariables.test.ts e2e/check-rngh-status.test.ts'
- shard_name: '3/4'
test_files: 'e2e/SheetScrollableDrag.test.ts e2e/ThemeMutation.test.ts e2e/NativePortal.test.ts e2e/GroupPressTransitionMatrix.test.ts e2e/MenuRadioGroup.test.ts e2e/PressStyleScrollStuck.test.ts e2e/NativeMixedDriver.test.ts'
- shard_name: '4/4'
test_files: 'e2e/PressStyleNative.test.ts e2e/SheetPressRegression.test.ts e2e/SheetKeyboardFitContent.test.ts e2e/SelectRemount.test.ts e2e/SheetDragResist.test.ts e2e/ThemeChangeBasic.test.ts e2e/NestedPressExclusive.test.ts e2e/MediaQueryGtMd.test.ts'
defaults:
run:
working-directory: ${{ env.test_container_app_path }}
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Set Xcode version
run: sudo xcode-select -s /Applications/Xcode_26.0.1.app
- name: Restore Built App from Cache
id: restore-ios-cache
uses: actions/cache/restore@v4
with:
key: ${{ needs.build-ios.outputs.built-app-cache-key }}
path: ${{ needs.build-ios.outputs.built-app-path }}
- name: Check iOS Cache Restore
if: steps.restore-ios-cache.outputs.cache-hit != 'true'
run: |
echo "::warning::iOS cache not restored (key: ${{ needs.build-ios.outputs.built-app-cache-key }}). This may happen on first run after cache key changes."
echo "SKIP_IOS_TESTS=true" >> $GITHUB_ENV
- name: Boot iOS Simulator (background)
if: env.SKIP_IOS_TESTS != 'true'
working-directory: ${{ github.workspace }}
run: |
# start simulator boot in background while deps install
xcrun simctl shutdown all 2>/dev/null || true
sleep 1
# find iPhone 16 simulator
DEVICE_ID=$(xcrun simctl list devices available -j | jq -r '
.devices | to_entries[]
| select(.key | contains("iOS"))
| .value[]
| select(.name == "iPhone 16")
| .udid' | head -1)
if [ -z "$DEVICE_ID" ]; then
DEVICE_ID=$(xcrun simctl list devices available -j | jq -r '
.devices | to_entries[]
| select(.key | contains("iOS"))
| .value[]
| select(.name | contains("iPhone"))
| .udid' | head -1)
fi
echo "Booting simulator: $DEVICE_ID"
xcrun simctl boot "$DEVICE_ID" || true
echo "SIM_DEVICE_ID=$DEVICE_ID" >> $GITHUB_ENV
- name: Install Dependencies
if: env.SKIP_IOS_TESTS != 'true'
uses: ./.github/actions/install
with:
workspace-focus: '@hanzogui/kitchen-sink'
- name: Install tooling
if: env.SKIP_IOS_TESTS != 'true'
run: |
npm install -g detox-cli &
detox_pid=$!
# install by fully-qualified name so homebrew auto-trusts only this
# formula (homebrew 6 refuses untrusted third-party taps otherwise)
brew install wix/brew/applesimutils &
applesimutils_pid=$!
wait "$detox_pid"
wait "$applesimutils_pid"
brew_bin="$(brew --prefix)/bin"
echo "$brew_bin" >> "$GITHUB_PATH"
export PATH="$brew_bin:$PATH"
command -v detox
command -v applesimutils
- name: Rebuild Detox Framework Cache
if: env.SKIP_IOS_TESTS != 'true'
working-directory: ${{ github.workspace }}
run: npx detox rebuild-framework-cache
- name: Wait for Simulator Boot
if: env.SKIP_IOS_TESTS != 'true'
working-directory: ${{ github.workspace }}
timeout-minutes: 5
run: |
echo "Waiting for simulator $SIM_DEVICE_ID to boot..."
# macOS doesn't have `timeout` — use perl one-liner as fallback
perl -e 'alarm 300; exec @ARGV' xcrun simctl bootstatus "$SIM_DEVICE_ID" -b || {
if xcrun simctl list devices booted | grep -q "Booted"; then
echo "Simulator appears booted despite timeout"
else
echo "Simulator failed to boot"
exit 1
fi
}
sleep 5
- name: Set Simulator to Light Mode
if: env.SKIP_IOS_TESTS != 'true'
working-directory: ${{ github.workspace }}
run: |
xcrun simctl ui booted appearance light
# wait for SpringBoard to be fully ready (FrontBoard app registration depends on it)
echo "Waiting for SpringBoard readiness..."
for i in {1..30}; do
if xcrun simctl spawn booted launchctl list 2>/dev/null | grep -q SpringBoard; then
echo "SpringBoard ready after ~${i}s"
break
fi
sleep 1
done
- name: Install App on Simulator
if: env.SKIP_IOS_TESTS != 'true'
working-directory: ${{ github.workspace }}
run: |
APP_PATH="${{ needs.build-ios.outputs.built-app-path }}"
BUNDLE_ID="com.hanzogui.hanzoguikitchensink"
for attempt in 1 2 3; do
echo "Install attempt $attempt/3..."
xcrun simctl install booted "$APP_PATH"
sleep 3
# verify FrontBoard registered the app
if xcrun simctl get_app_container booted "$BUNDLE_ID" 2>/dev/null; then
echo "App installed and registered on attempt $attempt"
# launch once to prime FrontBoard, then terminate
xcrun simctl launch booted "$BUNDLE_ID" 2>/dev/null || true
sleep 2
xcrun simctl terminate booted "$BUNDLE_ID" 2>/dev/null || true
break
fi
echo "::warning::App not registered with FrontBoard after install (attempt $attempt)"
if [ $attempt -lt 3 ]; then
xcrun simctl uninstall booted "$BUNDLE_ID" 2>/dev/null || true
sleep 5
else
echo "::error::App failed to register after 3 install attempts"
exit 1
fi
done
- name: Setup Bun
if: env.SKIP_IOS_TESTS != 'true'
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
with:
bun-version-file: 'package.json'
- name: Run iOS Detox Tests
if: env.SKIP_IOS_TESTS != 'true'
env:
DETOX_IOS_APP_PATH: build/Build/Products/Debug-iphonesimulator/hanzoguikitchensink.app
run: |
# --retries 0: no whole-file retry (that re-ran beforeAll + every test of a
# flaky file, the 2x wall-time variance). individual flaky tests retry
# in-place via jest.retryTimes (e2e/jest.setup.ts).
bun run ../native-ci/src/run-detox-ios.ts --project-root "$PWD" --record-logs failing --retries 0 ${{ matrix.test_files }}
- name: Report iOS Test Status
if: always() && env.SKIP_IOS_TESTS == 'true'
run: |
echo "::warning::iOS Detox tests skipped - cache was not available (will be available on next run)"
- name: Upload Detox Artifacts on Failure
if: failure() && env.SKIP_IOS_TESTS != 'true'
uses: actions/upload-artifact@v4
with:
name: detox-artifacts-ios-${{ strategy.job-index }}
path: ${{ env.test_container_app_path }}/e2e/artifacts/
retention-days: 7
# ─────────────────────────────────────────────────────────────────────────────
# Android Build & Test
# ─────────────────────────────────────────────────────────────────────────────
build-android:
name: Build Android App
# Only run on main/v* branches (not PRs)
if: github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/heads/v') || github.ref == 'refs/heads/rn82'
runs-on: ubuntu-latest
timeout-minutes: 45
outputs:
android-cache-key: ${{ steps.final-cache-key.outputs.cache_key }}
android-path: apps/kitchen-sink/android
fingerprint: ${{ steps.calculate-fingerprint.outputs.fingerprint || steps.get-fingerprint-from-cache.outputs.fingerprint }}
steps:
- name: Free Disk Space
run: |
# Android builds with debug symbols need significant disk space
# Remove unused tools to free ~10GB+
sudo rm -rf /usr/share/dotnet
sudo rm -rf /usr/local/lib/android/sdk/ndk
sudo rm -rf /opt/ghc
sudo rm -rf /opt/hostedtoolcache/CodeQL
df -h /
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Calculate Pre-Fingerprint Hash
id: calculate-pre-fingerprint
env:
# Include cache version to allow manual cache busting
PRE_FINGERPRINT_HASH: ${{ hashFiles('bun.lock', 'apps/kitchen-sink/app.json', 'packages/vxrn/expo-plugin.cjs') }}-${{ env.android_cache_version }}
run: |
echo "Pre-fingerprint hash: $PRE_FINGERPRINT_HASH"
echo "pre_fingerprint_hash=$PRE_FINGERPRINT_HASH" >> $GITHUB_OUTPUT
- name: Read Cached Fingerprint
id: get-fingerprint-from-cache
env:
KV_STORE_REDIS_REST_URL: ${{ secrets.KV_STORE_REDIS_REST_URL }}
KV_STORE_REDIS_REST_TOKEN: ${{ secrets.KV_STORE_REDIS_REST_TOKEN }}
run: |
FINGERPRINT_FROM_CACHE=$(curl -s "$KV_STORE_REDIS_REST_URL/get/android-hanzoguikitchensink-fingerprint-from-pre-hash-${{ steps.calculate-pre-fingerprint.outputs.pre_fingerprint_hash }}" -H "Authorization: Bearer $KV_STORE_REDIS_REST_TOKEN" | jq -r '.result')
if [ "$FINGERPRINT_FROM_CACHE" != "null" ]; then
curl -s -X POST "$KV_STORE_REDIS_REST_URL/EXPIRE/android-hanzoguikitchensink-fingerprint-from-pre-hash-${{ steps.calculate-pre-fingerprint.outputs.pre_fingerprint_hash }}/2592000" -H "Authorization: Bearer $KV_STORE_REDIS_REST_TOKEN"
echo "Fingerprint from cache: $FINGERPRINT_FROM_CACHE"
echo "fingerprint=$FINGERPRINT_FROM_CACHE" >> $GITHUB_OUTPUT
else
echo 'No cached fingerprint found.'
echo "fingerprint=null" >> $GITHUB_OUTPUT
fi
- name: Check if Android folder is already cached
id: android-cache-check
if: ${{ steps.get-fingerprint-from-cache.outputs.fingerprint != 'null' }}
uses: actions/cache/restore@v4
with:
path: apps/kitchen-sink/android
key: android-detox-${{ env.android_cache_version }}-${{ steps.get-fingerprint-from-cache.outputs.fingerprint }}
lookup-only: true
- name: Install Dependencies
if: ${{ !steps.android-cache-check.outputs.cache-hit }}
uses: ./.github/actions/install
with:
workspace-focus: '@hanzogui/kitchen-sink'
- name: Setup Java
if: ${{ !steps.android-cache-check.outputs.cache-hit }}
uses: actions/setup-java@v4
with:
distribution: 'temurin'
java-version: '17'
- name: Setup Android SDK
if: ${{ !steps.android-cache-check.outputs.cache-hit }}
uses: android-actions/setup-android@9fc6c4e9069bf8d3d10b2204b1fb8f6ef7065407 # v3
- name: Prebuild Android
if: ${{ !steps.android-cache-check.outputs.cache-hit }}
working-directory: apps/kitchen-sink
run: |
bunx expo prebuild --platform android --clean
- name: Setup Detox Test Infrastructure
if: ${{ !steps.android-cache-check.outputs.cache-hit }}
working-directory: apps/kitchen-sink
run: |
# Create Gradle init script for JNI library conflicts
cat > android/init.gradle << 'INITGRADLE'
// Gradle init script to fix JNI library conflicts for Detox tests
allprojects {
afterEvaluate {
if (it.hasProperty('android')) {
android {
packagingOptions {
jniLibs {
pickFirsts += ['**/libfbjni.so']
}
}
}
}
}
}
INITGRADLE
# Remove leading whitespace from heredoc
sed -i 's/^ //' android/init.gradle
# Add Detox repository to build.gradle (using node for reliable file editing)
node -e "
const fs = require('fs');
let content = fs.readFileSync('android/build.gradle', 'utf8');
content = content.replace(
\"maven { url 'https://www.jitpack.io' }\",
\"maven { url 'https://www.jitpack.io' }\\n // Detox repository\\n maven { url \\\"\\\$rootDir/../../../node_modules/detox/Detox-android\\\" }\"
);
fs.writeFileSync('android/build.gradle', content);
"
# Add Detox test runner config and dependencies to app/build.gradle
node -e "
const fs = require('fs');
let content = fs.readFileSync('android/app/build.gradle', 'utf8');
// Add testInstrumentationRunner after versionName
content = content.replace(
'versionName \"1.0.0\"',
'versionName \"1.0.0\"\\n\\n // Detox instrumentation test runner\\n testBuildType System.getProperty(\\'testBuildType\\', \\'debug\\')\\n testInstrumentationRunner \\'androidx.test.runner.AndroidJUnitRunner\\''
);
// Add Detox dependencies before the final closing brace of dependencies block
content = content.replace(
/(implementation jscFlavor\s*\n\s*}\s*\n})/,
'implementation jscFlavor\\n }\\n\\n // Detox dependencies\\n androidTestImplementation(\\'com.wix:detox:+\\')\\n androidTestImplementation \\'junit:junit:4.13.2\\'\\n}'
);
fs.writeFileSync('android/app/build.gradle', content);
"
# Create androidTest directory structure
mkdir -p android/app/src/androidTest/java/com/hanzogui/hanzoguikitchensink
# Create DetoxTest.java
cat > android/app/src/androidTest/java/com/hanzogui/hanzoguikitchensink/DetoxTest.java << 'DETOXTEST'
package com.hanzogui.hanzoguikitchensink;
import com.wix.detox.Detox;
import com.wix.detox.config.DetoxConfig;
import org.junit.Rule;
import org.junit.Test;
import org.junit.runner.RunWith;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import androidx.test.filters.LargeTest;
import androidx.test.rule.ActivityTestRule;
@RunWith(AndroidJUnit4.class)
@LargeTest
public class DetoxTest {
@Rule
public ActivityTestRule<MainActivity> mActivityRule = new ActivityTestRule<>(MainActivity.class, false, false);
@Test
public void runDetoxTests() {
DetoxConfig detoxConfig = new DetoxConfig();
detoxConfig.idlePolicyConfig.masterTimeoutSec = 90;
detoxConfig.idlePolicyConfig.idleResourceTimeoutSec = 60;
detoxConfig.rnContextLoadTimeoutSec = 180;
Detox.runTests(mActivityRule, detoxConfig);
}
}
DETOXTEST
# Create AndroidManifest.xml for androidTest (fixes Android 12+ exported requirement)
cat > android/app/src/androidTest/AndroidManifest.xml << 'MANIFEST'
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<!-- Fix for Android 12+ requiring explicit android:exported -->
<application>
<activity
android:name="androidx.test.core.app.InstrumentationActivityInvoker$BootstrapActivity"
android:exported="true"
tools:node="merge" />
<activity
android:name="androidx.test.core.app.InstrumentationActivityInvoker$EmptyActivity"
android:exported="true"
tools:node="merge" />
<activity
android:name="androidx.test.core.app.InstrumentationActivityInvoker$EmptyFloatingActivity"
android:exported="true"
tools:node="merge" />
</application>
</manifest>
MANIFEST
- name: Calculate Fingerprint
if: ${{ !steps.android-cache-check.outputs.cache-hit }}
id: calculate-fingerprint
working-directory: apps/kitchen-sink
run: |
FINGERPRINT=$(bunx @expo/fingerprint fingerprint:generate --platform android | jq -r '.hash')
if [ -z "$FINGERPRINT" ]; then
echo '[ERROR] Failed to calculate fingerprint.'
exit 1
fi
echo "Fingerprint: $FINGERPRINT"
echo "fingerprint=$FINGERPRINT" >> $GITHUB_OUTPUT
- name: Write Fingerprint to Cache
if: ${{ !steps.android-cache-check.outputs.cache-hit }}
env:
KV_STORE_REDIS_REST_URL: ${{ secrets.KV_STORE_REDIS_REST_URL }}
KV_STORE_REDIS_REST_TOKEN: ${{ secrets.KV_STORE_REDIS_REST_TOKEN }}
run: |
curl -s -X POST "$KV_STORE_REDIS_REST_URL/SETEX/android-hanzoguikitchensink-fingerprint-from-pre-hash-${{ steps.calculate-pre-fingerprint.outputs.pre_fingerprint_hash }}/2592000/${{ steps.calculate-fingerprint.outputs.fingerprint }}" -H "Authorization: Bearer $KV_STORE_REDIS_REST_TOKEN"
- name: Check If Build Already Exists (with new fingerprint)
if: ${{ !steps.android-cache-check.outputs.cache-hit }}
id: android-cache-check-new
uses: actions/cache/restore@v4
with:
path: apps/kitchen-sink/android
key: android-detox-${{ env.android_cache_version }}-${{ steps.calculate-fingerprint.outputs.fingerprint }}
lookup-only: true
- name: Cache Gradle
if: ${{ !steps.android-cache-check.outputs.cache-hit && !steps.android-cache-check-new.outputs.cache-hit }}
uses: actions/cache@v4
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
restore-keys: |
${{ runner.os }}-gradle-
- name: Build Android App (Debug + Test APKs)
if: ${{ !steps.android-cache-check.outputs.cache-hit && !steps.android-cache-check-new.outputs.cache-hit }}
working-directory: apps/kitchen-sink
env:
# Limit Gradle memory to avoid OOM on free runners
GRADLE_OPTS: '-Dorg.gradle.jvmargs=-Xmx2g -XX:MaxMetaspaceSize=512m -XX:+HeapDumpOnOutOfMemoryError'
run: |
cd android && ./gradlew assembleDebug assembleAndroidTest -DtestBuildType=debug --init-script init.gradle --no-daemon --max-workers=2
- name: Cache Android Folder
if: ${{ !steps.android-cache-check.outputs.cache-hit && !steps.android-cache-check-new.outputs.cache-hit }}
uses: actions/cache/save@v4
with:
path: apps/kitchen-sink/android
key: android-detox-${{ env.android_cache_version }}-${{ steps.calculate-fingerprint.outputs.fingerprint }}
- name: Set Final Cache Key
id: final-cache-key
run: |
if [ "${{ steps.android-cache-check.outputs.cache-hit }}" = "true" ]; then
echo "cache_key=android-detox-${{ env.android_cache_version }}-${{ steps.get-fingerprint-from-cache.outputs.fingerprint }}" >> $GITHUB_OUTPUT
else
echo "cache_key=android-detox-${{ env.android_cache_version }}-${{ steps.calculate-fingerprint.outputs.fingerprint }}" >> $GITHUB_OUTPUT
fi
test-android:
name: Android Detox Tests
needs: build-android
# Only run on main/v* branches (not PRs)
if: github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/heads/v') || github.ref == 'refs/heads/rn82'
runs-on: ubuntu-latest
timeout-minutes: 45
# Android emulator in CI is flaky with window focus issues - don't block on failures
continue-on-error: true
defaults:
run:
working-directory: ${{ env.test_container_app_path }}
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Enable KVM
run: |
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules
sudo udevadm control --reload-rules
sudo udevadm trigger --name-match=kvm
- name: Restore Android Folder from Cache
id: restore-android-cache
uses: actions/cache/restore@v4
with:
key: ${{ needs.build-android.outputs.android-cache-key }}
path: ${{ needs.build-android.outputs.android-path }}
- name: Check Cache Restore
if: steps.restore-android-cache.outputs.cache-hit != 'true'
run: |
echo "::warning::Android cache not restored (key: ${{ needs.build-android.outputs.android-cache-key }}). This may happen on first run after cache key changes."
echo "SKIP_ANDROID_TESTS=true" >> $GITHUB_ENV
- name: Free Disk Space
if: env.SKIP_ANDROID_TESTS != 'true'
run: |
sudo rm -rf /usr/share/dotnet
sudo rm -rf /usr/local/lib/android/sdk/ndk
sudo rm -rf /opt/ghc
sudo rm -rf /opt/hostedtoolcache/CodeQL
df -h /
- name: Install Dependencies
if: env.SKIP_ANDROID_TESTS != 'true'
uses: ./.github/actions/install
with:
workspace-focus: '@hanzogui/kitchen-sink'
- name: Setup Java
if: env.SKIP_ANDROID_TESTS != 'true'
uses: actions/setup-java@v4
with:
distribution: 'temurin'
java-version: '17'
- name: Install Detox CLI
if: env.SKIP_ANDROID_TESTS != 'true'
run: npm install -g detox-cli
- name: Setup Bun
if: env.SKIP_ANDROID_TESTS != 'true'
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
with:
bun-version-file: 'package.json'
- name: Start Android Emulator and Run Tests
if: env.SKIP_ANDROID_TESTS != 'true'
id: android-tests
uses: reactivecircus/android-emulator-runner@e89f39f1abbbd05b1113a29cf4db69e7540cae5a # v2.37.0
with:
api-level: 31
target: google_apis
arch: x86_64
profile: pixel_4
avd-name: test
# 4G ram + 1G heap (was 2G/512M) - the kitchen-sink JS bundle eagerly imports
# all 192 test cases, so first launch under software graphics needs more headroom
# to let the main looper idle within AndroidX's 45s startActivitySync timeout.
ram-size: 4096M
heap-size: 1024M
disk-size: 4G
force-avd-creation: false
emulator-boot-timeout: 600
disable-animations: true
emulator-options: -no-snapshot -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none -camera-front none
working-directory: apps/kitchen-sink
script: |
# Free up memory before tests
sudo swapoff -a || true
sudo swapon -a || true
# Verify emulator is ready
adb devices
adb wait-for-device
# Wait for boot to complete
adb shell 'while [ -z "$(getprop sys.boot_completed)" ]; do sleep 1; done'
# More robust screen unlock and focus sequence
adb shell wm dismiss-keyguard
adb shell input keyevent 82 # KEYEVENT_MENU - wake/unlock
adb shell input keyevent 3 # KEYEVENT_HOME - go home first
sleep 1
adb shell input keyevent 82 # Unlock again
adb shell input keyevent 4 # Back button to dismiss any dialogs
sleep 3
# Verify screen is on and unlocked
adb shell dumpsys window | grep -E "mAwake|mScreenOn" || true
# Let Detox handle APK installation and ADB reverse setup via reversePorts config
# Android emulator is flaky - capture exit code but don't fail the job
# --retries 0: no whole-file retry; individual tests retry via jest.retryTimes (e2e/jest.setup.ts)
bun run ../native-ci/src/run-detox-android.ts --headless --project-root "$PWD" --record-logs failing --retries 0 || echo "ANDROID_TESTS_FAILED=true" >> $GITHUB_ENV
- name: Report Android Test Status
if: always()
run: |
if [ "$SKIP_ANDROID_TESTS" = "true" ]; then
echo "::warning::Android Detox tests skipped - cache was not available (will be available on next run)"
elif [ "$ANDROID_TESTS_FAILED" = "true" ]; then
echo "::warning::Android Detox tests failed - this is expected flakiness and does not block CI"
else
echo "Android Detox tests passed"
fi
- name: Upload Detox Artifacts on Failure
if: env.ANDROID_TESTS_FAILED == 'true' && env.SKIP_ANDROID_TESTS != 'true'
uses: actions/upload-artifact@v4
with:
name: detox-artifacts-android
path: ${{ env.test_container_app_path }}/e2e/artifacts/
retention-days: 7
+32 -15
View File
@@ -1,11 +1,19 @@
**/test-results/*
**/.maestro-debug
**/.gui/
# keep types in repo to track them
# **/types/**/*.d.ts
# but map can be ignored and just published on npm
**/types/**/*.d.ts.map
# generated declarations are build artifacts: hanzogui-build emits every
# package's into <pkg>/types/, the build fixtures emit to their package root.
# they still ship on npm - every package lists "types" in its files[].
# hand-authored .d.ts stay tracked (re-export shims like pkgs/core/config/v5.d.ts,
# ambient stubs like apps/gui.hanzo.ai/types/bento.d.ts): none live under pkgs/*/types/.
pkgs/**/types/**/*.d.ts
pkgs/**/types/**/*.d.ts.map
pkgs/build/__tests__/fixtures/**/*.d.ts
pkgs/build/__tests__/fixtures/**/*.d.ts.map
pkgs/**/vitest.config.d.ts
pkgs/**/vitest.config.d.ts.map
pkgs/**/*.test.d.ts
pkgs/**/*.test.d.ts.map
.yarn/*
!.yarn/patches
@@ -32,20 +40,31 @@ node_modules
.vercel
code/kitchen-sink/ios
apps/kitchen-sink/ios
.gui
.hanzogui
.idea
.env
.env.local
.env.development.local
.env.test.localp
.env.test.local
.env.production.local
code/kitchen-sink/ios
code/kitchen-sink/android
# key material / credentials - never commit
*.pem
*.key
*.p12
*.pfx
*.keystore
*.jks
*.p8
*.mobileprovision
**/serviceAccount*.json
apps/kitchen-sink/ios
apps/kitchen-sink/android
code/studio/types/**
@@ -57,9 +76,7 @@ code/studio/types/**
.playwright-report/
.playwright-results/
test-results/
.gui/
# Wed Mar 25 04:08:49 PDT 2026
*.tgz
# tauri build output only — no source lives here (2.1G)
apps/desktop/
# Rust build artifacts — never commit; a debug .a is 272MB and GitHub caps at 100MB.
apps/desktop/src-tauri/target/
**/src-tauri/target/
+17
View File
@@ -0,0 +1,17 @@
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
-11
View File
@@ -1,11 +0,0 @@
{
"mcpServers": {
"XcodeBuildMCP": {
"command": "npx",
"args": ["-y", "xcodebuildmcp@latest"]
},
"native-devtools": {
"command": "/Users/n8/.local/bin/native-devtools-mcp"
}
}
}
+2 -2
View File
@@ -1,2 +1,2 @@
code/tests/next-turbopack/src/TestComponent.tsx
**/.gui/
apps/tests/next-turbopack/src/TestComponent.tsx
**/.react-router
+1 -1
View File
@@ -21,7 +21,7 @@
"**/*.lock",
"**/*.generated.*",
"**/generated-*",
"**/.gui/**",
"**/.hanzogui/**",
"**/node_modules/**",
],
}
+1 -1
View File
@@ -28,7 +28,7 @@
},
"ignorePatterns": [
"**/scripts/**",
"**/.gui/**",
"**/.hanzogui/**",
"**/tmp-data/**",
"**/.build/**",
"**/.sst/**",
+1 -1
View File
@@ -1,3 +1,3 @@
**/*.d.ts
**/generated-*.ts
code/kitchen-sink/src/usecases/CompilerExtraction.native.tsx
apps/kitchen-sink/src/usecases/CompilerExtraction.native.tsx
-1
View File
@@ -1 +0,0 @@
LLM.md
+186
View File
@@ -0,0 +1,186 @@
# Gui — agent operating contract
`CLAUDE.md` is a symlink to this file, so Claude, Codex, and every other agent
read the same rules. Put durable, repo-wide agent guidance here.
## Finish what you start, then merge it back
The most common failure here is stopping early: a fix left on a branch, a
migration written but never applied, a "critical, will do next" item dropped.
Don't do that. If you were asked to do something, it is not done until:
- it is committed, pushed, validated, and **merged back to `main`** (or a PR is
opened and driven to merge) — "I left it on a branch" / "I prepared the SQL"
is not done;
- it is **validated at the layer it changes**: typecheck/build for code, a real
request or Playwright run for site behavior, an applied-and-verified query for
a DB/RLS change. "Should work" is not validation;
- any **migration is actually applied and the live state verified** — this repo's
prod migration history is NOT auto-applied on deploy (see
`apps/gui.hanzo.ai/supabase/README.md`); a committed migration that was never
run is an open gap, not a fix;
- for security/payments work especially, the loop is closed end to end — a
documented finding with no shipped, deployed fix counts as no fix.
Keep going until the requested outcome is real. Continuing beats reporting. The
only hard stops are the externally-irreversible ones — publishing/releasing to
npm, force-pushing, rotating credentials, or changing prod infra (Cloudflare,
DNS, Railway settings): pause and confirm for those, and hand them back with the
exact steps when they block you. Everything else, finish it.
---
Please read ./CONTRIBUTING.md as well
Note you need to re-build packages (`bun run build` in the package directory) as you change them, unless you or someone is running a `bun run watch` at root.
FOR LONG RUNNNING DEBUGGING run `bun run watch` in the background its faster and rebuilds all packages.
keep commits to one line, add a trailing "Fixes #" if associated with a GH issue, and start with a convential commit style - UNLESS its a change that shouldn't go into the changelog, in those cases you can do things like `docs: ` or `site: `.
# Gui Testing Guide
## Running Tests
### Kitchen Sink Tests
The kitchen-sink package contains the main integration tests for Gui components. To run these tests:
1. **Start the web server** (in the background):
```bash
cd apps/kitchen-sink
bun run start:web
```
To open a specific test case in the browser:
```bash
open "http://localhost:7979/?test=YourTestCaseName"
```
Test case names match the file names in `apps/kitchen-sink/src/usecases/` (e.g., `SelectFocusScopeCase`).
To open a component demo:
```bash
open "http://localhost:7979/?demo=Select"
```
Demo names match files in `apps/demos/src/` without the `Demo` suffix (e.g., `Select` for `SelectDemo.tsx`).
2. **Run all web tests** with different animation drivers:
```bash
bun run test:web
```
This uses `run-tests-parallel.ts` which first runs `default` + `webkit` projects sequentially, then runs all four animated driver projects (`css`, `native`, `reanimated`, `motion`) in parallel against a single shared dev server.
3. **Run tests with a specific animation driver**:
```bash
# Using env var + playwright --project flag
cd apps/kitchen-sink
NODE_ENV=test GUI_TEST_ANIMATION_DRIVER=css npx playwright test --project=animated-css
# Available projects: animated-css, animated-native, animated-reanimated, animated-motion
```
4. **Run a specific test file**:
```bash
# Using playwright directly
cd apps/kitchen-sink
npx playwright test tests/PopoverFocusScope.test.tsx
# Or with a specific driver
NODE_ENV=test GUI_TEST_ANIMATION_DRIVER=css npx playwright test tests/YourTest.animated.test.tsx --project=animated-css
```
5. **Debug tests**:
```bash
bun run test:web:debug
# or
npx playwright test --debug
```
### Test Structure
Tests are located in `apps/kitchen-sink/tests/` and follow these naming conventions:
- `ComponentName.test.tsx` - Standard tests that run ONCE with the default animation driver
- `ComponentName.animated.test.tsx` - Animation-dependent tests that run with ALL animation drivers (css, native, reanimated, motion)
This separation significantly speeds up the test suite since most tests don't need to run 4x across all animation drivers. Only use `.animated.test.tsx` for tests that specifically verify animation behavior across different drivers.
### Writing Tests
When writing tests for focus behavior or component interactions:
1. Use appropriate wait times for animations and focus changes
2. Be aware that `trapFocus` behavior depends on the component's open state
3. Test both trapped and non-trapped focus scenarios
4. Consider browser focus behavior when `trapFocus` is false
### Common Issues
- If tests fail due to timing, add appropriate `waitForTimeout` calls
- For focus tests, ensure elements are visible before testing focus state
- When testing popover/dialog components, wait for animations to complete
## Releasing: the workspace has one version, and right now the tree disagrees
`scripts/release.ts` carries a single `version` for all 169 published packages,
read from `pkgs/ui/hanzogui/package.json`. Every path assumes it — the bump, the
`npm view name@version` already-published check, and the tarball name
`getPublishArtifactPaths` expects `npm pack` to produce.
The tree no longer holds to it. `@hanzogui/shell` was hand-bumped to 8.0.7 while
`@hanzo/gui` sits at 8.0.2 and `@hanzogui/core` at 8.0.1, so each dispatch of
`Release` fails a different way, and none of them says so:
- **`patch`** computes 8.0.3 and writes it to every package.json — rewriting
shell *backward* from 8.0.7, over versions 8.0.3–8.0.6 that are already on
npm. The publish then skips it as "already published" and pushes the
regression to `main`.
- **`republish`** (`--republish` skips the version write) packs shell at 8.0.7
but looks for `hanzogui-shell-8.0.2.tgz`, because the artifact path is built
from the workspace `version`. The run dies untarring a file `npm pack` never
named.
- **`minor`** is the one that works: 8.1.0 clears every published version, so
the tree reconverges on one number and all 169 publish cleanly.
Verify with `bun scripts/release.ts --<kind> --ci --dirty --skip-publish
--skip-push --skip-tests --skip-native-tests --skip-checks --dry-run`, which
prints the computed version and the full publish plan and writes nothing.
Releasing one package alone is `--only <name>` locally; there is no dispatch for
it, so a single-package release cannot carry npm provenance today — provenance
needs the GitHub `Release` workflow's `id-token: write`, and that workflow only
ships the whole workspace.
## Commit Message Conventions
- Use `site:` prefix (not `fix(site):`) for hanzogui.dev changes since they don't go in the changelog
- Use `ci:` prefix (not `fix(ci):`) for CI/workflow changes since they don't go in the changelog
- Keep commit messages to a single line
## iOS Development
See [docs/using-ios.md](./docs/using-ios.md) for iOS native development and Detox testing tips.
## hanzogui.dev API Authentication
When making authenticated API calls from the client side in hanzogui.dev, always use the `authFetch` helper:
```ts
import { authFetch } from '~/features/api/authFetch'
const response = await authFetch('/api/some-endpoint', {
method: 'POST',
body: JSON.stringify({ ... }),
})
```
**Why this matters:** Cookies alone are not reliable for auth in production due to cross-origin/SameSite issues. The `authFetch` helper automatically includes the Authorization header with the user's access token. All payment/subscription endpoints require this.
+1 -1
View File
@@ -1 +1 @@
LLM.md
AGENTS.md
+6 -6
View File
@@ -1,10 +1,10 @@
# Contributing to Hanzo GUI
# Contributing to `hanzogui`
To set up your development environment you'll need [bun](https://bun.sh) installed, then clone and install:
```bash
git clone https://github.com/hanzoai/gui
cd gui
cd hanzogui
bun install
```
@@ -18,13 +18,13 @@ bun run watch
We compile out js to `dist` for "0-setup" installs by compiling a .native version of every file, and then in every web file we replace react-native with react-native-web. We also build our types out to ./types/\*.d.ts for a variety of reasons - it means we can't cause type issues for people with stricter configurations using our packages, it means we can track type regressions (the styled() type setup is impossible delicate to explosions of complexity), and it's generally faster for end users.
### Linking hanzo-gui into your existing project
### Linking hanzogui into your existing project
Most package managers have a `link` command that lets you link in the local hanzo-gui to your project, we've built a package we found useful that works with `bun` or `yarn` depending on your configuration:
Most package managers have a `link` command that lets you link in the local hanzogui to your project, we've built a package we found useful that works with `bun` or `yarn` depending on your configuration:
```bash
npm i -g lllink
lllink ~/path/to/gui
lllink ~/path/to/hanzogui
```
### Running native apps
@@ -55,7 +55,7 @@ bun run kitchen-sink:build:ios
### Running web
You can run `bun run sandbox` or `bun run dev` (the Hanzo GUI website).
You can run `bun run sandbox` or `bun run dev` (the hanzogui website).
### Local Testing Setup
+6 -6
View File
@@ -17,10 +17,10 @@ ARG GITHUB_APP_CLIENT_ID
ARG GITHUB_APP_SECRET
ARG GITHUB_SPONSOR_WEBHOOK_SECRET
ARG GITHUB_TOKEN
ARG IS_HANZO_GUI_DEV
ARG IS_GUI_DEV
ARG NEXT_PUBLIC_GITHUB_APP_ID
ARG NEXT_PUBLIC_GITHUB_AUTH_CLIENT_ID
ARG NEXT_PUBLIC_IS_HANZO_GUI_DEV
ARG NEXT_PUBLIC_IS_GUI_DEV
ARG NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY
ARG NEXT_PUBLIC_SUPABASE_ANON_KEY
ARG NEXT_PUBLIC_SUPABASE_URL
@@ -36,7 +36,7 @@ ARG TAKEOUT_RENEWAL_COUPON_ID
ARG URL
ARG ONE_SERVER_URL
ARG APP_NAME
ARG HANZO_GUI_PRO_SECRET
ARG GUI_PRO_SECRET
ARG DEEPSEEK_API_KEY
ARG BENTO_GITHUB_TOKEN
ARG BENTO_BRANCH
@@ -44,7 +44,7 @@ ARG BENTO_BRANCH
# install dependencies (sharp needs libvips for image processing)
RUN apt-get update && apt-get install -y git bsdmainutils vim-common gh libvips-dev
WORKDIR /root/hanzo-gui
WORKDIR /root/hanzogui
COPY . .
# init git (allow empty commit if nothing to commit)
@@ -58,7 +58,7 @@ RUN if [ -n "$BENTO_GITHUB_TOKEN" ]; then \
echo "Cloning bento repository (branch: $BRANCH)..."; \
unset GITHUB_TOKEN && \
echo "$BENTO_GITHUB_TOKEN" | gh auth login --with-token && \
gh repo clone hanzoai/bento -- --branch "$BRANCH" && \
gh repo clone hanzogui/bento -- --branch "$BRANCH" && \
gh auth logout --hostname github.com && \
echo "✅ Bento repository cloned (branch: $BRANCH)" && \
echo "REQUIRE_BENTO=true" > /tmp/bento_status; \
@@ -67,7 +67,7 @@ RUN if [ -n "$BENTO_GITHUB_TOKEN" ]; then \
echo "REQUIRE_BENTO=false" > /tmp/bento_status; \
fi
WORKDIR /root/hanzo-gui
WORKDIR /root/hanzogui
# Set REQUIRE_BENTO based on whether bento was cloned
RUN export $(cat /tmp/bento_status)
+4 -4
View File
@@ -1,5 +1,5 @@
# local build dockerfile - uses bento from ../bento
# build from PARENT directory: docker build -f gui5/Dockerfile.local -t gui-site .
# build from PARENT directory: docker build -f hanzogui5/Dockerfile.local -t hanzogui-site .
FROM node:22
# install bun
@@ -46,14 +46,14 @@ WORKDIR /root
# copy both repos
COPY bento /root/bento
COPY gui5 /root/gui
COPY hanzogui5 /root/hanzogui
WORKDIR /root/gui
WORKDIR /root/hanzogui
# init git (allow empty commit if nothing to commit)
RUN git config --global user.email "[email protected]" && git config --global user.name "Docker Build" && git init . && git add -A && (git commit -m 'add' > /dev/null || true)
# install gui deps first
# install hanzogui deps first
RUN bun install
# setup bento - merge deps and symlink node_modules
+22 -38
View File
@@ -1,42 +1,26 @@
BSD 3-Clause License
MIT License
Copyright (c) 2026-present, Hanzo AI, Inc.
Copyright (c) 2020 Nate Wienert
Copyright (c) 2015-present, Nicolas Gallagher.
Copyright (c) 2015-present, Facebook, Inc.
Copyright (c) 2021 Radix
Copyright (c) 2017 Carmelo Pullara
Copyright (c) 2018 Framer B.V.
Portions of this software are derived from upstream code originally licensed under
the MIT License, with the following copyright notices retained per its terms:
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
Copyright (c) 2020 Nate Wienert
Copyright (c) 2015-present, Nicolas Gallagher.
Copyright (c) 2015-present, Facebook, Inc.
Copyright (c) 2021 Radix
Copyright (c) 2017 Carmelo Pullara
Copyright (c) 2018 Framer B.V.
Copyright (c) 2022 WorkOS
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
1. Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
3. Neither the name of the copyright holder nor the names of its contributors
may be used to endorse or promote products derived from this software
without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
POSSIBILITY OF SUCH DAMAGE.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+35 -276
View File
@@ -1,288 +1,47 @@
Please read ./CONTRIBUTING.md as well
# @hanzo/gui — notes for whoever is here next
Note you need to re-build packages (`bun run build` in the package directory) as you change them, unless you or someone is running a `bun run watch` at root.
## A targeted publish (`--only`) currently dies in the test gate
FOR LONG RUNNNING DEBUGGING run `bun run watch` in the background its faster and rebuilds all packages.
`bun ./scripts/release.ts --only @hanzo/gui` fails before it publishes:
keep commits to one line, add a trailing "Fixes #" if associated with a GH issue, and start with a convential commit style - UNLESS its a change that shouldn't go into the changelog, in those cases you can do things like `docs: ` or `site: `.
# How this ships
One way, and it runs on our own stack:
push -> github.com/hanzoai/gui (a mirror)
.github/workflows/sync.yml carries refs onward
-> git.hanzo.ai/hanzoai/gui CANONICAL
.hanzo/workflows/checks.yaml check, lint, unit tests
.hanzo/workflows/publish-gui.yml hanzogui + @hanzo/gui
.hanzo/workflows/publish-gui-all.yml every @hanzogui/* primitive
.hanzo/workflows/build.yml ghcr.io/hanzoai/gui
**git.hanzo.ai is canonical; GitHub is a mirror.** `.github/workflows/` holds
exactly one file, `sync.yml`, and its only job is getting refs to the forge. Every
build, check and publish is a workflow under `.hanzo/workflows/`, which the forge
reads. `.hanzo/workflows` uses GitHub Actions syntax, so a workflow moves between
the two by changing directory and nothing else. `.github/actions/install` stays
where it is — a moved workflow's `uses: ./.github/actions/install` still resolves.
## What publishes what
Each publisher is the only producer of its packages, and a tag is the trigger in
every case — a version bump alone never ships.
| tag | workflow | publishes |
| --- | --- | --- |
| `release/gui-v*` | `publish-gui.yml` | `hanzogui` + the `@hanzo/gui` alias |
| `release/gui-all-v*` (or dispatch) | `publish-gui-all.yml` | every `@hanzogui/*` primitive + both umbrellas |
`NPM_TOKEN` comes from KMS with a repo-secret fallback in both `publish-gui*`
workflows.
**This repo publishes primitives only.** The AI app that used to live at
`pkgs/ai` — `@hanzo/app`, plus the seven `@hanzo_network/*` libraries it is
built from — moved to `hanzo-js/app` on 2026-07-28, and publishes itself from
there. An application inside the primitives repo is the layering mistake the
owner named: `@hanzo/gui` (primitives) -> `@hanzo/ui` (widgets) -> apps.
## Known dead ends
- `build.yml` delegates to `hanzoai/.github/.github/workflows/docker-build.yml@main`
and **that file does not exist** in `hanzoai/.github` (which carries
`platform-build.yml` and `promote.yml`). The workflow has therefore never
completed a run and `ghcr.io/hanzoai/gui` is not in the registry. It moved to
`.hanzo/workflows/` unchanged rather than being deleted, because it is the only
declaration of the image path; making it real means either inlining a build or
pointing it at `platform-build.yml`.
- `gui.hanzo.ai` is **not served from this repo.** It answers from Cloudflare Pages
out of `hanzoai/docs` (`apps/gui-docs`). This repo's own `apps/gui.hanzo.ai` is
`one serve` — a long-running server, not a static export — so `hanzoai/static`
cannot serve it and there is no App CR for it. Either point a CR at the docs
image or convert that app to a static export; both are their own change.
- `checks.yaml` lost its `changes` + `integration-tests` jobs on the way over. They
filtered on `code/core/**`, `code/kitchen-sink/**` and friends — an upstream
Tamagui layout this repo does not have (the real paths are `apps/` and `pkgs/`) —
so the filter never matched and the shard matrix never ran. The Playwright suite
they were meant to run is at `apps/kitchen-sink/playwright.config.ts` and is
currently wired to nothing.
- Three iOS workflows were deleted outright: `build-ios-kitchensink-app.yml`,
`test-ios-native.yml` and `test-ios-kitchensink-go.yml`. All three wanted
`macos-14`/`macos-15` runners, which the forge does not have, and all three ran
in `code/kitchen-sink`, which does not exist. One was already `if: false`. Native
iOS testing needs macOS capacity first; these files could not have provided it.
# Hanzo GUI Testing Guide
## Running Tests
### Kitchen Sink Tests
The kitchen-sink package contains the main integration tests for Hanzo GUI components. To run these tests:
1. **Start the web server** (in the background):
```bash
cd apps/kitchen-sink
bun run start:web
```
To open a specific test case in the browser:
```bash
open "http://localhost:9000/?test=YourTestCaseName"
```
Test case names match the file names in `apps/kitchen-sink/src/usecases/` (e.g., `SelectFocusScopeCase`).
To open a component demo:
```bash
open "http://localhost:9000/?demo=Select"
```
Demo names match files in `apps/demos/src/` without the `Demo` suffix (e.g., `Select` for `SelectDemo.tsx`).
2. **Run all web tests** with different animation drivers:
```bash
bun run test:web
```
This uses `run-tests-parallel.ts` which first runs `default` + `webkit` projects sequentially, then runs all four animated driver projects (`css`, `native`, `reanimated`, `motion`) in parallel against a single shared dev server.
3. **Run tests with a specific animation driver**:
```bash
# Using env var + playwright --project flag
cd apps/kitchen-sink
NODE_ENV=test HANZO_GUI_TEST_ANIMATION_DRIVER=css npx playwright test --project=animated-css
# Available projects: animated-css, animated-native, animated-reanimated, animated-motion
```
4. **Run a specific test file**:
```bash
# Using playwright directly
cd apps/kitchen-sink
npx playwright test tests/PopoverFocusScope.test.tsx
# Or with a specific driver
NODE_ENV=test HANZO_GUI_TEST_ANIMATION_DRIVER=css npx playwright test tests/YourTest.animated.test.tsx --project=animated-css
```
5. **Debug tests**:
```bash
bun run test:web:debug
# or
npx playwright test --debug
```
### Test Structure
Tests are located in `apps/kitchen-sink/tests/` and follow these naming conventions:
- `ComponentName.test.tsx` - Standard tests that run ONCE with the default animation driver
- `ComponentName.animated.test.tsx` - Animation-dependent tests that run with ALL animation drivers (css, native, reanimated, motion)
This separation significantly speeds up the test suite since most tests don't need to run 4x across all animation drivers. Only use `.animated.test.tsx` for tests that specifically verify animation behavior across different drivers.
### Writing Tests
When writing tests for focus behavior or component interactions:
1. Use appropriate wait times for animations and focus changes
2. Be aware that `trapFocus` behavior depends on the component's open state
3. Test both trapped and non-trapped focus scenarios
4. Consider browser focus behavior when `trapFocus` is false
### Common Issues
- If tests fail due to timing, add appropriate `waitForTimeout` calls
- For focus tests, ensure elements are visible before testing focus state
- When testing popover/dialog components, wait for animations to complete
## Commit Message Conventions
- Use `site:` prefix (not `fix(site):`) for gui.hanzo.ai changes since they don't go in the changelog
- Use `ci:` prefix (not `fix(ci):`) for CI/workflow changes since they don't go in the changelog
- Keep commit messages to a single line
## iOS Development
See [docs/using-ios.md](./docs/using-ios.md) for iOS native development and Detox testing tips.
## gui.hanzo.ai API Authentication
When making authenticated API calls from the client side in gui.hanzo.ai, always use the `authFetch` helper:
```ts
import { authFetch } from '~/features/api/authFetch'
const response = await authFetch('/api/some-endpoint', {
method: 'POST',
body: JSON.stringify({ ... }),
})
```
@hanzogui/kitchen-sink#test -> 9 failed, 576 passed, 19 skipped
[webkit] tests/RemoveScroll.test.tsx (scroll position restored after close,
scroll lock prevents body scroll, edge cases, mobile touch device)
```
**Why this matters:** Cookies alone are not reliable for auth in production due to cross-origin/SameSite issues. The `authFetch` helper automatically includes the Authorization header with the user's access token. All payment/subscription endpoints require this.
**These predate the change you are trying to ship.** They are webkit-only
scroll-restoration failures in `apps/kitchen-sink`, and that file was last
touched by `95c64deb76 transplant onto tamagui v2.6.0`. Nothing in `pkgs/ui`
reaches them.
So a one-file fix to, say, `pkgs/ui/hanzogui/css-check.mjs` cannot pass the gate
on its own merits, and the honest options are:
## Tailwind → gui migration (marketing surfaces)
- let the fleet release carry it (this is what happened for 8.1.0 — a normal
release from `main` picked up a pending css-check fix and shipped it), or
- `--skip-test`, having first confirmed your change cannot reach kitchen-sink,
or
- fix the webkit failures, which is a real piece of work and nobody's yet.
`@hanzogui/chrome` is the shared public-site chrome (`HanzoNav`, `HanzoFooter`,
`ChatHero`, `HanzoWidget`) in Tamagui `styled()` + monochrome tokens. It compiles
and ships `dist/{esm,cjs,jsx}` + `types/`. It is the bridgehead: a surface adopts
the chrome first, then its page bodies.
Do not read a green `--only` run as "tests passed" — the script's exit code is
easy to mask behind a shell pipeline. Check the log for the turbo summary.
**Read `pkgs/ui/chrome/src/styles.tsx`'s header before touching a chrome file.**
It states the contract this package is built under — no host config augmentation —
and every build break so far has been a violation of it: element type is `render`
(not `tag`); hover is DOM `onMouseEnter`/`onMouseLeave` (not `onHoverIn`); only
`Text` takes `color`; style props are LONGHAND (`textAlign`, `paddingHorizontal` —
the `text`/`px` shorthands live in augmentation that is absent here); anchors
forward `href` through the `linkable` `.styleable` wrapper. Two type consequences:
`GetProps<F>` requires `F extends StylableComponent`, and a raw-hex token handed to
a strict RN colour prop needs `as ColorTokens`.
## `pkgs/ui/hanzogui/css-check.mjs` ships as the `gui-css-check` bin
### What the migration actually is
It is a real published surface, not a dev script: `files[]` includes it and
`bin` points at it. Its `DEFAULT_ALLOW` list names the classes `@hanzo/ui`
stamps as identity markers (`btn`, `btn-*`, `badge`, `badge-*`) and it is
ENUMERATED, not wildcarded — `btn-` is a name anyone might type, so a glob would
wave through a hand-written `btn-cta` that has no rule behind it, which is the
exact failure the checker exists to catch. When `@hanzo/ui` adds a Button or
Badge variant, add it here too; drift fails loudly rather than silently.
Measured on hanzo.ai (the largest surface): **126 marketing pages, 6,720
`className=` sites, 29,845 utility tokens, 671 distinct utilities**, and **zero**
imports of `@hanzo/ui` or `@hanzogui/*` in the page bodies. So this is not a
shadcn component swap — the pages are raw Tailwind. The vocabulary is regular
though: 30 utilities cover **51.4%** of all tokens, and colours are already
semantic (`text-foreground`, not hex), so the colour layer is a rename.
## `HanzoFooter` (pkgs/ui/chrome) is deprecated
### Primitive map
| Tailwind (count) | gui equivalent |
|---|---|
| `text-foreground` 1091 · `text-muted-foreground` 859 · `text-foreground/80` 244 | `Txt` + `color={c.fg / c.fgMuted / c.fgDim}` (`chrome/src/tokens.ts`) |
| `border-border` 829 · `border` 795 | `borderColor={c.line}` · `borderWidth={1}` |
| `flex` 760 · `items-center` 887 · `justify-center` 512 | `XStack`/`YStack` + `alignItems` / `justifyContent` |
| `inline-flex` 484 | `XStack display="inline-flex"` |
| `grid` 220 | `YStack` + explicit rows, or `View display="grid"` |
| `mx-auto` 705 | `marginHorizontal="auto"` |
| `px-4` 542 · `py-3` 347 | `paddingHorizontal={16}` · `paddingVertical={12}` (longhand) |
| `gap-2` 509 · `gap-4` 239 | `gap={8}` · `gap={16}` |
| `mb-4` 507 | `marginBottom={16}` |
| `text-sm` 693 · `text-xl` 287 · `text-2xl` 323 | `Txt kind=` — the type scale in `chrome/src/styles.tsx` |
| `font-medium` 676 · `font-bold` 560 | `fontWeight="500"` / `"700"` |
| `text-center` 410 | `textAlign="center"` (never `text=`) |
| `rounded-full` 625 · `rounded-xl` 391 | `borderRadius={999}` · `borderRadius={12}` |
| `transition-colors` 372 | `useHover()` + explicit `color` on the child `Text` |
| `relative` 268 · `absolute` 256 · `overflow-hidden` 251 | `position` / `overflow` props |
| `h-4 w-4` 357/352 | icon `size={16}` (lucide props, unchanged) |
### Order
1. Adopt `@hanzogui/chrome` on a surface (nav/footer/hero) — the page bodies keep
working untouched, so this is independently shippable.
2. Extract the repeated page shapes. 17 hanzo.ai pages already open with a
byte-identical hero block; those become one primitive, not 17 rewrites.
3. Migrate bodies shape-by-shape, not page-by-page. Tailwind leaves as a
consequence of the last shape moving, which is the only point it can be
removed from the build.
Do not sed 6,720 class strings. The tail (671 − 30 utilities) is where the layout
bugs hide, and a marketing site is visually regression-tested by eye.
---
## Additional notes (merged from LLM.md)
# gui — AI Assistant Context
# Hanzo GUI
<h3 align="center">
Style library, design system, composable components, and more.
</h3>
---
## Telemetry — `@hanzogui/telemetry` (pkgs/telemetry)
The ONE zero-config telemetry surface. `<TelemetryProvider>{children}</TelemetryProvider>`
with no props wires all three planes; `@hanzo/ui/telemetry` re-exports it unchanged
so the component layer needs no second definition.
- **One front door.** Everything (pageviews, product events, exceptions,
interaction capture) is POSTed to `https://api.hanzo.ai/v1/event`. Cloud lenses
that one stream into `sentry.hanzo.ai` (errors + session capture),
`analytics.hanzo.ai` (web analytics) and `insights.hanzo.ai` (product insights,
incl. `analytics_errors`). Those three hosts are DASHBOARDS — never ingest
endpoints, never configured in a client.
- **Layering.** `@hanzo/event` = the client (batching, attribution, the wire).
`@hanzo/observe` = the capture engine (semantics, redaction, playback), loaded
with a dynamic `import()` inside an idle callback so it cannot cost LCP.
`@hanzogui/telemetry` = the zero-config policy that composes them. Mechanism
below, policy here, one of each.
- **Privacy.** DNT/GPC honored by default with no app code; an explicit
`setConsent()` choice outranks the browser in both directions; nothing is
written to storage while telemetry is refused.
- **Guarantees.** SSR-safe (a DOM is required before anything is collected),
fail-soft (every method swallows its own errors), no CDN script, ESM,
`sideEffects: false`.
- Tests: `cd pkgs/telemetry && bun run test` (24 tests — one-door URL, three-lens
wire, DNT/GPC, consent precedence, SPA route counting, error boundary, SSR).
`SiteFooter` in `@hanzo/ui/product` is the one footer. It renders the FOOTER
model from `@hanzo/products`, so its links are shared data instead of a
per-caller prop. `HanzoFooter` is NOT a re-export of it, deliberately: this
package lives inside `@hanzo/gui` and `@hanzo/ui` peer-depends on `@hanzo/gui`,
so re-exporting would close a `gui -> ui -> gui` cycle. Its 8 callers keep
working and migrate on their own schedule.
-141
View File
@@ -1,141 +0,0 @@
# Fleet migration: shadcn `@hanzo/ui` → `@hanzo/gui`
The repeatable recipe for taking any Hanzo web surface OFF the shadcn/Radix+Tailwind
`@hanzo/ui` library and ONTO the canonical `@hanzo/gui` design system, so every
surface shares one look and feel. Written from increment 1 (hanzo.ai + hanzo.chat);
follow it to roll the rest of the fleet (docs sites, marketing sites, apps) mechanically.
There are **two layers**. They are independent — do them in either order, per surface.
| Layer | What | Package | Substrate | Works on |
|---|---|---|---|---|
| **Chrome** | Header, footer, hero, AI-widget mount (the shared frame) | `@hanzogui/chrome` | plain React + Tailwind + framer-motion + lucide | React 18 **and** 19 · Next **and** Vite |
| **Primitives** | Button/Input/Card/Badge/… inside pages | `@hanzo/gui` | Tamagui (styled + tokens + RN-web) | React ≥19 (Next proven; Vite via `@hanzogui/vite-plugin`) |
Why two: the cross-app chrome must run on React-18 Vite surfaces (hanzo.chat) and
React-19 static-export Next surfaces alike, so it is authored as plain React +
Tailwind — the SAME convention `@hanzogui/shell` (TenantHeader/HanzoAppBar) already
uses. The Tamagui primitive layer needs React ≥19; it is proven to render, SSR-style-
extracted with no FOUC, in a Next `output:'export'` static build (see Proof).
`@hanzogui/chrome` = PUBLIC marketing chrome. `@hanzogui/shell` = AUTHENTICATED tenant
chrome (org switcher, app launcher). Pick per surface; don't conflate.
---
## Recipe A — adopt the shared chrome (`@hanzogui/chrome`)
Exports: `HanzoNav`, `HanzoFooter`, `ChatHero`, `HanzoWidget` (+ types `NavItem`,
`NavLink`, `NavColumn`). All content and effects are **props** — the library is
presentational and host-agnostic (no analytics, no router, no config baked in).
1. **Link the package (no publish needed).** Add to the app's `package.json`:
```jsonc
"@hanzogui/chrome": "link:<relpath>/gui/pkgs/ui/chrome"
```
Install (`pnpm install` / `npm install`). It ships as SOURCE.
2. **Transpile it as first-party.**
- **Next**: `transpilePackages: ['@hanzogui/chrome']` in `next.config`. This ALSO
dedupes React for the linked package — do **NOT** alias `react`/`react-dom` in
webpack; a global react alias breaks Next's server/RSC React and crashes static
export with `Cannot read properties of null (reading 'useContext')`.
- **Vite**: `optimizeDeps.exclude: ['@hanzogui/chrome']` and ensure
`server.fs.allow` includes the gui checkout. Vite transpiles linked TS by default.
3. **Let Tailwind see it** (its classes live outside your project root):
- **Tailwind v4** (CSS-first): `@source '<relpath>/gui/pkgs/ui/chrome/src';` in globals.css.
- **Tailwind v3**: add `'<relpath>/gui/pkgs/ui/chrome/src/**/*.{js,jsx,ts,tsx}'` to `content`.
4. **Write thin adapters** that inject the surface's content + effects. Keep analytics
IN the app (the library takes callbacks):
```tsx
<HanzoNav items={NAV} logo={<HanzoLogo variant="white" size={22} />} brand="Hanzo AI"
login={{ links: LOGIN_LINKS }} primary={{ label: 'Try Hanzo', href: CHAT, links: TRY_LINKS }}
onPrimary={() => analytics.capture(EVENTS.CHAT_STARTED, { source: 'nav' })} />
```
`ChatHero` forwards on submit: pass `onSubmit={(q) => { analytics…; goTo(q) }}` (or a
plain `href` to auto-append `?q=`). Cross-origin surfaces use absolute hrefs in nav data;
a same-app landing forwards INTO itself (e.g. `navigate('/c/new?q='+q)`).
5. **AI widget** (optional): `<HanzoWidget repo="org/repo" />` injects the
`<meta name="hanzo:repo">` convention + loads `hanzo.app/edit.js` once. Mount-only;
edit.js behaviour is a separate workstream.
Gotcha — analytics stays put: never move `@hanzo/event` / `hz.js` into the library.
The chrome is effect-free; wire telemetry in the app via the callback props.
---
## Recipe B — migrate page primitives (`@hanzo/ui` shadcn → `@hanzo/gui` Tamagui)
Proven to build + render in a Next 15 `output:'export'` static export. Steps mirror the
one working fleet integration (`hanzoai/app/next.config.js`).
1. **Deps** (published npm, pin to the same versions the fleet uses, currently 7.3.0):
`@hanzo/gui @hanzogui/config @hanzogui/lucide-icons-2 react-native-web`.
2. **Next config** — transpile the whole gui graph + map RN→RN-web + prefer `.web.*`:
```js
transpilePackages: ['@hanzo/gui','react-native-web', ...fs.readdirSync('node_modules/@hanzogui').map(n=>`@hanzogui/${n}`)]
webpack: (c) => { c.resolve.alias['react-native$']='react-native-web';
c.resolve.extensions=['.web.tsx','.web.ts','.web.jsx','.web.js',...c.resolve.extensions];
c.resolve.fallback={...c.resolve.fallback, fs:false, '@react-native-async-storage/async-storage':false, 'pino-pretty':false}; return c }
```
No `@tamagui/next-plugin` — its dep is broken and unnecessary; `transpilePackages` +
Tamagui's runtime SSR-extracts the atomic CSS into the static HTML (styled on first paint).
(Vite surfaces: use `@hanzogui/vite-plugin` instead + the RN-web alias.)
3. **Provider** — wrap the tree once (context-only; additive, leaves other UI intact):
```tsx
import { createGui } from '@hanzo/gui'; import { defaultConfig } from '@hanzogui/config/v5'
const config = createGui(defaultConfig)
<GuiProvider config={config} defaultTheme="dark">{children}</GuiProvider>
```
4. **Swap primitives.** Build a local `components/ui/*` shim layer that exposes the
shadcn API but is backed by gui, then repoint imports (or alias `@hanzo/ui` → the shim
to flip every importer at once). Component map (hanzo.ai surface, frequency-ranked):
| shadcn `@hanzo/ui` | files | `@hanzo/gui` |
|---|---|---|
| `Button` (+`buttonVariants`) | 121 | `Button` (map `variant`/`size` → theme/size props) |
| `Input` | 12 | `Input` |
| `Badge` | 10 | compose `XStack`+`Text` (no Tamagui Badge) |
| `Label` | 6 | `Label` |
| `Card`+family | 5 | `Card` + `Card.Header`/`Card.Footer` |
| `Avatar`+family | 4 | `Avatar` (`Avatar.Image`/`Avatar.Fallback`) |
| `Tabs`+family | 4 | `Tabs` |
| `Textarea` | 4 | `TextArea` |
| `Table`+family | 3 | compose from `styled` stacks (no Tamagui Table) |
| `Progress`,`Accordion`,`Dialog`,`DropdownMenu`,`Select`,`Separator`,`Sheet`,`Slider`,`Checkbox` | ≤3 ea | `Progress`,`Accordion`,`Dialog`,`Menu`,`Select`,`Separator`,`Sheet`,`Slider`,`Checkbox` |
| `Toaster` | 1 | `Toast`/`ToastProvider` |
Do the top-5 first — they cover the overwhelming majority of files. If a gui primitive
is genuinely missing (Badge, Table), ADD it to `@hanzo/gui` — never reach back to shadcn.
5. **Cleanup during cutover**:
- Repoint any Tailwind `content` glob off `@hanzo/ui/dist/**` (it purges nothing once shadcn is gone).
- Collapse duplicate primitives to one (hanzo.ai has TWO Buttons: raw `@hanzo/ui` in 121
files + a bug-fix wrapper `components/ui/button.tsx` in 17 — pick one).
- Fix dangling type-only imports (`ToastActionElement`/`ToastProps` don't exist in `@hanzo/ui@5`).
---
## Proof (increment 1)
- **Spike** — a real `@hanzo/gui` component (`YStack`/`Text`/`Button` + live theme tokens)
built and rendered STYLED in hanzo.ai's Next 15 `output:'export'` static export; the
atomic CSS is SSR-extracted into the prerendered HTML (no FOUC). Toolchain GREEN.
- **Chrome on hanzo.ai** — apex nav/footer/hero migrated to `@hanzogui/chrome`; static
build passes (711 pages), chrome CSS generated by Tailwind `@source`, pixel-identical to
pre-migration, hz.js + `@hanzo/event` untouched.
- **Chrome on hanzo.chat** — new public landing (React 18 + Vite + Tailwind 3) on the SAME
`@hanzogui/chrome`; composer forwards into the chat app.
## Per-surface rollout checklist
- [ ] Chrome: link `@hanzogui/chrome` · transpile/optimizeDeps · Tailwind source · adapters (nav data + analytics callbacks).
- [ ] Primitives: add gui deps · next/vite config (transpile + RN-web alias) · `GuiProvider` · shim + swap top-5 · flip the rest.
- [ ] Cleanup: repoint Tailwind glob · collapse duplicates · fix dangling types · drop `@hanzo/ui`.
- [ ] Prove: build + Playwright screenshot (desktop + mobile), no horizontal scroll, console clean.
-38
View File
@@ -1,38 +0,0 @@
Hanzo GUI
Copyright (c) 2026-present, Hanzo AI, Inc.
This product includes software derived from Tamagui.
Tamagui
https://github.com/tamagui/tamagui
SPDX-License-Identifier: MIT
Tamagui is distributed under the MIT License. Its original copyright and
permission notices are reproduced below, as that license requires.
MIT License
Copyright (c) 2020 Nate Wienert
Copyright (c) 2015-present, Nicolas Gallagher.
Copyright (c) 2015-present, Facebook, Inc.
Copyright (c) 2021 Radix
Copyright (c) 2017 Carmelo Pullara
Copyright (c) 2018 Framer B.V.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+24 -18
View File
@@ -1,51 +1,57 @@
<p align="center"><img src=".github/hero.svg" alt="gui" width="880"></p>
# Hanzo GUI
Forked from [tamagui](https://github.com/tamagui/tamagui) (MIT).
<div align="center">
<picture width="572px">
<source media="(prefers-color-scheme: dark)" srcset="https://github.com/hanzoai/gui/raw/main/apps/gui.hanzo.ai/public/logo-black.png">
<source media="(prefers-color-scheme: light)" srcset="https://github.com/hanzoai/gui/raw/main/apps/gui.hanzo.ai/public/logo-white.png">
<img alt="Shows a black logo in light color mode and a white one in dark color mode." src="https://github.com/hanzoai/gui/raw/main/apps/gui.hanzo.ai/public/social.png">
</picture>
</div>
<h3 align="center">
Style library, design system, composable components, and more.
</h3>
<h4 align="center">
Hanzo GUI is a bunch of libraries for building UIs that share code across React and React Native.
Gui is a bunch of libraries for building UIs that share code across React and React Native.
</h4>
<br />
<div align="center">
<img alt="NPM downloads" src="https://img.shields.io/npm/dw/@hanzogui/core?logo=npm&label=NPM%20downloads&cacheSeconds=3600"/>
<img alt="Commits per month" src="https://img.shields.io/github/commit-activity/m/hanzoai/gui?label=Commits&logo=git" />
<img alt="Commits per month" src="https://img.shields.io/github/commit-activity/m/hanzogui/hanzogui?label=Commits&logo=git" />
<img alt="Discord users online" src="https://img.shields.io/discord/909986013848412191?logo=discord&label=Discord&cacheSeconds=3600" />
<a href="https://gurubase.io/g/hanzogui">
<img alt="Gurubase" src="https://img.shields.io/badge/Gurubase-Ask%20Gui%20Guru-006BFF" />
</a>
</div>
<br />
<br />
- `@hanzogui/web` - Universal style library for React.
- `gui` - UI kit that adapts to every platform.
- `@hanzogui/static` - Optimizing compiler that works with `@hanzogui/web` and `gui`.
- `@hanzogui/core` - Universal style library for React.
- `@hanzogui/static` - Optimizing compiler that works with `core` and `hanzogui`.
- `hanzogui` - UI kit that adapts to every platform.
<br />
**See [gui.hanzo.ai](https://gui.hanzo.ai) for documentation.**
**See [hanzogui.dev](https://hanzogui.dev) for documentation.**
Hanzo GUI lets you share more code between web and native apps without sacrificing the two things that typically suffer when you do: performance and code quality.
Gui lets you share more code between web and native apps without sacrificing the two things that typically suffer when you do: performance and code quality.
It does this with an optimizing compiler that outputs platform-specific optimizations - it turns styled components, even with complex logic or cross-module imports, into a simple `div` alongside atomic CSS on the web, or a View with its style objects hoisted on native.
The entirety of Hanzo GUI works at compile time and runtime, and can be set up gradually, with initial usage as simple as importing it and using the base views and styled function.
The entirety of Gui works at compile time and runtime, and can be set up gradually, with initial usage as simple as importing it and using the base views and styled function.
We recommend checking out the starters with `npm create gui@latest`, they range from a simple learning example to a production-ready monorepo.
We recommend checking out the starters with `npm create hanzogui@latest`, they range from a simple learning example to a production-ready monorepo.
The compiler optimizes most styled components by flattening them to plain `div`s (web) or `View`s (native) at build time. Real-world apps see double-digit Lighthouse improvements.
The compiler optimizes most and ultimately flattens a majority of styled components. In the [~500px² responsive browser section](https://hanzogui.dev) of the Gui website, 49 of the 55 or so [inline styled components](https://github.com/hanzoai/gui/blob/main/apps/gui.hanzo.ai/components/HeroResponsive.tsx) are flattened to a `div`. The homepage gains nearly 15% on Lighthouse with the compiler on.
[Learn more on the website](https://gui.hanzo.ai/docs/intro/introduction).
[Learn more on the website](https://hanzogui.dev/docs/intro/introduction).
---
## Contributing
To contribute to Hanzo GUI reference the [contributing guide](https://github.com/hanzoai/gui/blob/main/CONTRIBUTING.md).
To contribute to Gui reference the [contributing guide](https://github.com/hanzoai/gui/blob/main/CONTRIBUTING.md).
To contribute to documentation, see the docs source in `code/gui.hanzo.ai/data/docs/`.
To contribute to documentation, see the docs source in `apps/gui.hanzo.ai/data/docs/`.
+50
View File
@@ -0,0 +1,50 @@
# Security Policy
Thanks for helping keep Gui and its users safe.
## Supported versions
Security fixes are made against the latest published major (`hanzogui` v2 and the
current `@hanzogui/*` line). Please make sure you can reproduce on the latest
release before reporting.
## Reporting a vulnerability
**Please do not open a public issue for security problems.**
Report privately using **GitHub's private vulnerability reporting**:
1. Go to the repo's **Security** tab → **Report a vulnerability**
(https://github.com/hanzoai/gui/security/advisories/new)
2. Describe the issue, affected versions, and a proof-of-concept if you have one.
If you can't use GitHub advisories, email **[email protected]** (or
natewienert@gmail.com) with the details.
We aim to acknowledge reports within 3 business days and to ship a fix or
mitigation for confirmed high/critical issues as quickly as we reasonably can.
We're happy to credit you in the advisory unless you'd prefer to stay anonymous.
## Scope
In scope:
- The published `@hanzogui/*` / `hanzogui` npm packages.
- The hanzogui.dev web application and its API endpoints (auth, payments,
account/team management).
- This repository's CI/CD and release/publish pipeline.
Out of scope:
- Vulnerabilities in third-party dependencies that are already publicly known
(please report those upstream; Dependabot tracks them here).
- Findings that require a compromised developer machine, social engineering, or
physical access.
- Volumetric / denial-of-service testing against hanzogui.dev.
## Handling secrets
If you believe a credential has been committed to this repo (current tree or
history), treat it as compromised: report it privately as above and **do not**
post the value publicly. Maintainers: rotate the credential at its source first;
history scrubbing is secondary.
-42
View File
@@ -1,42 +0,0 @@
BSD 3-Clause License
Copyright (c) 2026-present, Hanzo AI, Inc.
Portions of this software are derived from upstream code originally licensed under
the MIT License, with the following copyright notices retained per its terms:
Copyright (c) 2020 Nate Wienert
Copyright (c) 2015-present, Nicolas Gallagher.
Copyright (c) 2015-present, Facebook, Inc.
Copyright (c) 2021 Radix
Copyright (c) 2017 Carmelo Pullara
Copyright (c) 2018 Framer B.V.
Copyright (c) 2022 WorkOS
All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
1. Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
3. Neither the name of the copyright holder nor the names of its contributors
may be used to endorse or promote products derived from this software
without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
POSSIBILITY OF SUCH DAMAGE.
-4
View File
@@ -1,4 +0,0 @@
{
"config": "./src/gui.config.ts",
"components": ["hanzogui"]
}
+4
View File
@@ -0,0 +1,4 @@
{
"config": "./src/hanzogui.config.ts",
"components": ["hanzogui"]
}
+8 -11
View File
@@ -1,6 +1,6 @@
{
"name": "@hanzogui/demos",
"version": "7.3.0",
"version": "8.1.1",
"source": "demos/src/index.tsx",
"files": [
"src",
@@ -40,10 +40,10 @@
"access": "public"
},
"scripts": {
"build": "gui-build --skip-types",
"watch": "gui-build --skip-types --watch",
"clean": "gui-build clean",
"clean:build": "gui-build clean:build"
"build": "hanzogui-build --skip-types",
"watch": "hanzogui-build --skip-types --watch",
"clean": "hanzogui-build clean",
"clean:build": "hanzogui-build clean:build"
},
"dependencies": {
"@hanzogui/animate-presence": "workspace:*",
@@ -78,7 +78,7 @@
"@hanzogui/dev-config": "workspace:*",
"@hanzogui/theme": "workspace:*",
"@hanzogui/toast": "workspace:*",
"hanzogui": "workspace:*"
"@hanzo/gui": "workspace:*"
},
"devDependencies": {
"@hanzogui/build": "workspace:*",
@@ -86,9 +86,6 @@
"react-native": "0.83.2"
},
"peerDependencies": {
"react": ">=19",
"react-native": "*"
},
"license": "BSD-3-Clause",
"author": "Hanzo AI <[email protected]>"
"react": ">=19"
}
}
+1 -1
View File
@@ -1,5 +1,5 @@
import { ChevronDown } from '@hanzogui/lucide-icons-2'
import { Accordion, Paragraph, Square } from 'hanzogui'
import { Accordion, Paragraph, Square } from '@hanzo/gui'
export function AccordionDemo() {
return (
+1 -1
View File
@@ -1,7 +1,7 @@
import { addTheme } from '@hanzogui/theme'
import React from 'react'
import { Button, H3, Theme, View, YStack } from 'hanzogui'
import { Button, H3, Theme, View, YStack } from '@hanzo/gui'
export function AddThemeDemo() {
const [theme, setTheme] = React.useState<any>()
+1 -1
View File
@@ -1,4 +1,4 @@
import { AlertDialog, Button, XStack, YStack } from 'hanzogui'
import { AlertDialog, Button, XStack, YStack } from '@hanzo/gui'
export function AlertDialogDemo() {
return (
+1 -1
View File
@@ -1,5 +1,5 @@
import React from 'react'
import { AnimatePresence, Button, Square, XStack, YStack, isWeb } from 'hanzogui'
import { AnimatePresence, Button, Square, XStack, YStack, isWeb } from '@hanzo/gui'
import { useIsIntersecting } from './useOnIntersecting'
+1 -1
View File
@@ -1,6 +1,6 @@
import { LogoIcon } from '@hanzogui/logo'
import { Play } from '@hanzogui/lucide-icons-2'
import { Button, isWeb, Square, useControllableState, Image, useEvent } from 'hanzogui'
import { Button, isWeb, Square, useControllableState, Image, useEvent } from '@hanzo/gui'
export function AnimationsDemo(props) {
const [positionI, setPositionI] = useControllableState({
+1 -1
View File
@@ -1,7 +1,7 @@
import React from 'react'
import { LogoIcon } from '@hanzogui/logo'
import { Button, Square, YStack, isWeb } from 'hanzogui'
import { Button, Square, YStack, isWeb } from '@hanzo/gui'
import { useIsIntersecting } from './useOnIntersecting'
+1 -1
View File
@@ -1,5 +1,5 @@
import { LogoIcon } from '@hanzogui/logo'
import { Square } from 'hanzogui'
import { Square } from '@hanzo/gui'
export function AnimationsHoverDemo() {
return (
+1 -1
View File
@@ -2,7 +2,7 @@ import React from 'react'
import { AnimatePresence } from '@hanzogui/animate-presence'
import { ArrowLeft, ArrowRight } from '@hanzogui/lucide-icons-2'
import { Button, Image, XStack, YStack, styled } from 'hanzogui'
import { Button, Image, XStack, YStack, styled } from '@hanzo/gui'
// @ts-ignore
import photo1 from '../../public/photo1.jpg'
+1 -1
View File
@@ -1,5 +1,5 @@
import { LogoIcon } from '@hanzogui/logo'
import { Square } from 'hanzogui'
import { Square } from '@hanzo/gui'
export function AnimationsTimingDemo() {
return (
+1 -1
View File
@@ -1,4 +1,4 @@
import { Avatar, XStack } from 'hanzogui'
import { Avatar, XStack } from '@hanzo/gui'
export function AvatarDemo() {
return (
+1 -1
View File
@@ -1,4 +1,4 @@
import { styled, View, Text, YStack } from 'hanzogui'
import { styled, View, Text, YStack } from '@hanzo/gui'
// styled() with boxShadow containing embedded $token
// if web imports are picked up on native, this resolves to var(--shadowColor)
+108
View File
@@ -0,0 +1,108 @@
import { Button, Card, H2, Paragraph, Text, Theme, XStack, YStack } from 'hanzogui'
// Proof surface for the token layer: true-black neutral ramp, one accent per
// white-label brand (read straight from each brand's generated theme — the
// $color9 accent slot), Geist type. Open with ?demo=BrandTokens.
// Brand accent SoT: pkgs/core/themes/src/brands.ts.
const BRANDS = ['hanzo', 'lux', 'zoo', 'pars'] as const
const SURFACES = [
['background', '$background', '#000 canvas'],
['color2', '$color2', '#0a0a0a surface'],
['color3', '$color3', '#111 raised'],
['color4', '$color4', '#171717 elevated'],
] as const
export function BrandTokensDemo() {
return (
<Theme name="dark">
<YStack backgroundColor="$background" padding="$6" gap="$5" minHeight={640}>
<YStack gap="$2">
<H2 fontFamily="$heading" color="$color12">
Hanzo GUI — design tokens
</H2>
<Paragraph color="$color11" fontFamily="$body">
True-black canvas · one accent per brand · Geist type system
</Paragraph>
</YStack>
<YStack gap="$2">
<Text color="$color11" fontFamily="$mono" fontSize="$2">
neutral surface ramp
</Text>
<XStack gap="$3" flexWrap="wrap">
{SURFACES.map(([name, tok, label]) => (
<YStack
key={name}
backgroundColor={tok}
width={160}
height={96}
borderRadius={12}
borderWidth={1}
borderColor="$borderColor"
padding="$3"
justifyContent="flex-end"
>
<Text color="$color12" fontFamily="$mono" fontSize="$3">
{name}
</Text>
<Text color="$color10" fontSize="$2">
{label}
</Text>
</YStack>
))}
</XStack>
</YStack>
<YStack gap="$2">
<Text color="$color11" fontFamily="$mono" fontSize="$2">
per-brand accent (applied sparingly over the shared canvas)
</Text>
<XStack gap="$4" flexWrap="wrap">
{BRANDS.map((b) => (
// brand themes come from the host app's config (@hanzogui/themes/v4),
// not from the @hanzogui/dev-config this package is typed against
// @ts-expect-error
<Theme key={b} name={b}>
<Card
backgroundColor="$color2"
borderWidth={1}
borderColor="$borderColor"
borderRadius={12}
padding="$4"
width={210}
gap="$3"
>
<XStack alignItems="center" gap="$2">
<YStack
width={14}
height={14}
borderRadius={999}
backgroundColor="$color9"
/>
<Text color="$color12" fontFamily="$heading" fontSize="$5">
{b}
</Text>
</XStack>
<YStack
height={44}
borderRadius={8}
backgroundColor="$color9"
alignItems="center"
justifyContent="center"
>
<Text color="$color1" fontSize="$3" fontWeight="700">
Primary CTA
</Text>
</YStack>
<Button>Themed button</Button>
</Card>
</Theme>
))}
</XStack>
</YStack>
</YStack>
</Theme>
)
}
+2 -2
View File
@@ -1,6 +1,6 @@
import { Drumstick, Hand } from '@hanzogui/lucide-icons-2'
import type { ButtonProps, ThemeName } from 'hanzogui'
import { Button, XStack, YStack } from 'hanzogui'
import type { ButtonProps, ThemeName } from '@hanzo/gui'
import { Button, XStack, YStack } from '@hanzo/gui'
const outlined = {
bg: 'transparent',
+1 -1
View File
@@ -1,6 +1,6 @@
import { Button } from '@hanzogui/button'
import { Activity, Airplay } from '@hanzogui/lucide-icons-2'
import { Theme, XGroup, XStack, YStack } from 'hanzogui'
import { Theme, XGroup, XStack, YStack } from '@hanzo/gui'
export function ButtonDemo() {
return (
+2 -2
View File
@@ -1,5 +1,5 @@
import type { CardProps } from 'hanzogui'
import { Button, Card, H2, Image, Paragraph, Theme, XStack } from 'hanzogui'
import type { CardProps } from '@hanzo/gui'
import { Button, Card, H2, Image, Paragraph, Theme, XStack } from '@hanzo/gui'
export function CardDemo() {
return (
+2 -2
View File
@@ -1,6 +1,6 @@
import { Check as CheckIcon } from '@hanzogui/lucide-icons-2'
import type { CheckboxProps } from 'hanzogui'
import { Checkbox, Label, Theme, XStack, YStack } from 'hanzogui'
import type { CheckboxProps } from '@hanzo/gui'
import { Checkbox, Label, Theme, XStack, YStack } from '@hanzo/gui'
export function CheckboxDemo() {
return (
+1 -1
View File
@@ -5,7 +5,7 @@ import { Check, Minus } from '@hanzogui/lucide-icons-2'
import type { View } from 'react-native'
import { Pressable } from 'react-native'
import { Label, XStack, YStack } from 'hanzogui'
import { Label, XStack, YStack } from '@hanzo/gui'
export function CheckboxHeadlessDemo() {
return (
+1 -1
View File
@@ -1,7 +1,7 @@
import { createCheckbox } from '@hanzogui/checkbox'
import { View, styled } from '@hanzogui/core'
import { Check } from '@hanzogui/lucide-icons-2'
import { Label, XStack, YStack } from 'hanzogui'
import { Label, XStack, YStack } from '@hanzo/gui'
const Frame = styled(View, {
borderWidth: 1,
+1 -1
View File
@@ -10,7 +10,7 @@ import {
yellow,
yellowDark,
} from '@hanzogui/colors'
import { H2, Paragraph, Separator, Square, XStack, YStack } from 'hanzogui'
import { H2, Paragraph, Separator, Square, XStack, YStack } from '@hanzo/gui'
const colorGroups = ['gray', 'blue', 'green', 'yellow', 'red'] as const
+1 -1
View File
@@ -1,7 +1,7 @@
import { ContextMenu } from '@hanzogui/context-menu'
import { Calendar, Check, ChevronRight } from '@hanzogui/lucide-icons-2'
import React from 'react'
import { Text, View, YStack } from 'hanzogui'
import { Text, View, YStack } from '@hanzo/gui'
/**
* Note: you'll want to use createMenu() to customize further.
+1 -1
View File
@@ -15,7 +15,7 @@ import {
View,
XGroup,
XStack,
} from 'hanzogui'
} from '@hanzo/gui'
import { SelectDemoContents } from './SelectDemo'
type DialogMode = 'plain' | 'adapt' | 'keepMounted'
+2 -2
View File
@@ -1,5 +1,5 @@
import React from 'react'
import type { SizeTokens } from 'hanzogui'
import type { SizeTokens } from '@hanzo/gui'
import {
Button,
Form,
@@ -11,7 +11,7 @@ import {
XGroup,
Label,
YStack,
} from 'hanzogui'
} from '@hanzo/gui'
export function FormsDemo(props: { size: SizeTokens }) {
const [status, setStatus] = React.useState<'off' | 'submitting' | 'submitted'>('off')
+1 -1
View File
@@ -1,5 +1,5 @@
import { Activity, Airplay } from '@hanzogui/lucide-icons-2'
import { Button, Group, ListItem, Separator, XGroup, YGroup, YStack } from 'hanzogui'
import { Button, Group, ListItem, Separator, XGroup, YGroup, YStack } from '@hanzo/gui'
export function GroupDemo() {
return (
+1 -1
View File
@@ -1,4 +1,4 @@
import { H1, H2, H3, H4, H5, H6, YStack } from 'hanzogui'
import { H1, H2, H3, H4, H5, H6, YStack } from '@hanzo/gui'
export function HeadingsDemo() {
return (
+2 -2
View File
@@ -1,5 +1,5 @@
import type { SizeTokens } from 'hanzogui'
import { Button, Input, TextArea, XStack, YStack } from 'hanzogui'
import type { SizeTokens } from '@hanzo/gui'
import { Button, Input, TextArea, XStack, YStack } from '@hanzo/gui'
export function InputsDemo() {
return (
+1 -1
View File
@@ -1,4 +1,4 @@
import { Input, Label, Switch, XStack, YStack } from 'hanzogui'
import { Input, Label, Switch, XStack, YStack } from '@hanzo/gui'
export function LabelDemo() {
return (
+1 -1
View File
@@ -1,4 +1,4 @@
import { XStack } from 'hanzogui'
import { XStack } from '@hanzo/gui'
import { LinearGradient } from '@hanzogui/linear-gradient'
export function LinearGradientDemo() {
+1 -1
View File
@@ -1,5 +1,5 @@
import { ChevronRight, Cloud, Moon, Star, Sun } from '@hanzogui/lucide-icons-2'
import { ListItem, Separator, Theme, XStack, YGroup } from 'hanzogui'
import { ListItem, Separator, Theme, XStack, YGroup } from '@hanzo/gui'
export function ListItemDemo() {
return (
+1 -1
View File
@@ -2,7 +2,7 @@ import React from 'react'
import * as LucideIcons from '@hanzogui/lucide-icons-2'
import { ScrollView } from 'react-native'
import { Input, Paragraph, Spacer, YStack, useDebounceValue } from 'hanzogui'
import { Input, Paragraph, Spacer, YStack, useDebounceValue } from '@hanzo/gui'
import { Grid } from './Grid'
+1 -1
View File
@@ -9,7 +9,7 @@ import {
} from '@hanzogui/lucide-icons-2'
import { Menu } from '@hanzogui/menu'
import React from 'react'
import { Button } from 'hanzogui'
import { Button } from '@hanzo/gui'
/**
* Menu Demo using Gui Menu component.
+2 -2
View File
@@ -1,5 +1,5 @@
import type { SizeTokens } from 'hanzogui'
import { Button, Theme, XStack, YStack } from 'hanzogui'
import type { SizeTokens } from '@hanzo/gui'
import { Button, Theme, XStack, YStack } from '@hanzo/gui'
import { Input, TextArea } from '@hanzogui/input'
export function NewInputsDemo() {
+2 -2
View File
@@ -5,7 +5,7 @@ import {
ChevronUp,
} from '@hanzogui/lucide-icons-2'
import { useState } from 'react'
import type { PopoverProps } from 'hanzogui'
import type { PopoverProps } from '@hanzo/gui'
import {
Adapt,
Button,
@@ -17,7 +17,7 @@ import {
styled,
XStack,
YStack,
} from 'hanzogui'
} from '@hanzo/gui'
export function PopoverDemo() {
const [shouldAdapt, setShouldAdapt] = useState(true)
+1 -1
View File
@@ -1,5 +1,5 @@
import React from 'react'
import { Button, Paragraph, Progress, Slider, Theme, XStack, YStack } from 'hanzogui'
import { Button, Paragraph, Progress, Slider, Theme, XStack, YStack } from '@hanzo/gui'
import { Play, RotateCcw } from '@hanzogui/lucide-icons-2'
export function ProgressDemo() {
+2 -2
View File
@@ -1,5 +1,5 @@
import type { SizeTokens } from 'hanzogui'
import { Label, RadioGroup, Theme, XStack, YStack } from 'hanzogui'
import type { SizeTokens } from '@hanzo/gui'
import { Label, RadioGroup, Theme, XStack, YStack } from '@hanzo/gui'
export function RadioGroupDemo() {
return (
+1 -1
View File
@@ -10,7 +10,7 @@ import {
import { RovingFocusGroup } from '@hanzogui/roving-focus'
import React from 'react'
import { Pressable, StyleSheet, Text, View } from 'react-native'
import { isWeb, useTheme } from 'hanzogui'
import { isWeb, useTheme } from '@hanzo/gui'
const RadioGroupContext = React.createContext<RadioGroupContextValue>({})
const RadioGroupItemContext = React.createContext<RadioGroupItemContextValue>({
+1 -1
View File
@@ -1,4 +1,4 @@
import { Label, XStack, YStack, styled } from 'hanzogui'
import { Label, XStack, YStack, styled } from '@hanzo/gui'
import { createRadioGroup } from '@hanzogui/radio-group'
+1 -1
View File
@@ -9,7 +9,7 @@ import {
YStack,
useForceUpdate,
useIsomorphicLayoutEffect,
} from 'hanzogui'
} from '@hanzo/gui'
export function ReplaceThemeDemo() {
const [theme, setTheme] = React.useState<any>()
+1 -1
View File
@@ -1,4 +1,4 @@
import { Circle, ScrollView, Square, XStack } from 'hanzogui'
import { Circle, ScrollView, Square, XStack } from '@hanzo/gui'
export function ScrollViewDemo() {
return (
+12 -3
View File
@@ -1,9 +1,18 @@
import { Check, ChevronDown, ChevronUp } from '@hanzogui/lucide-icons-2'
import React from 'react'
import type { FontSizeTokens, SelectProps } from 'hanzogui'
import { Adapt, Label, Select, Sheet, Theme, XStack, YStack, getFontSize } from 'hanzogui'
import { LinearGradient } from 'hanzogui/linear-gradient'
import type { FontSizeTokens, SelectProps } from '@hanzo/gui'
import {
Adapt,
Label,
Select,
Sheet,
Theme,
XStack,
YStack,
getFontSize,
} from '@hanzo/gui'
import { LinearGradient } from '@hanzo/gui/linear-gradient'
export function SelectDemo() {
return (
+1 -1
View File
@@ -1,4 +1,4 @@
import { Paragraph, Separator, XStack, YStack } from 'hanzogui'
import { Paragraph, Separator, XStack, YStack } from '@hanzo/gui'
export function SeparatorDemo() {
return (
+1 -1
View File
@@ -1,4 +1,4 @@
import { Circle, Square, XStack } from 'hanzogui'
import { Circle, Square, XStack } from '@hanzo/gui'
export function ShapesDemo() {
return (
+1 -1
View File
@@ -2,7 +2,7 @@ import { ChevronDown, ChevronUp } from '@hanzogui/lucide-icons-2'
import type { SheetProps } from '@hanzogui/sheet'
import { Sheet } from '@hanzogui/sheet'
import React, { memo } from 'react'
import { Button, H2, Input, Paragraph, XStack, YStack } from 'hanzogui'
import { Button, H2, Input, Paragraph, XStack, YStack } from '@hanzo/gui'
const spModes = ['percent', 'constant', 'fit', 'mixed'] as const
+2 -2
View File
@@ -1,5 +1,5 @@
import type { SliderProps } from 'hanzogui'
import { Slider, Theme, XStack } from 'hanzogui'
import type { SliderProps } from '@hanzo/gui'
import { Slider, Theme, XStack } from '@hanzo/gui'
export function SliderDemo() {
return (
+1 -1
View File
@@ -1,4 +1,4 @@
import { Spinner, YStack } from 'hanzogui'
import { Spinner, YStack } from '@hanzo/gui'
export function SpinnerDemo() {
return (
+1 -1
View File
@@ -1,5 +1,5 @@
import React from 'react'
import { XStack, YStack, ZStack } from 'hanzogui'
import { XStack, YStack, ZStack } from '@hanzo/gui'
export function StacksDemo() {
return (
+2 -2
View File
@@ -1,5 +1,5 @@
import type { SizeTokens } from 'hanzogui'
import { Label, Separator, Switch, Theme, XStack, YStack } from 'hanzogui'
import type { SizeTokens } from '@hanzo/gui'
import { Label, Separator, Switch, Theme, XStack, YStack } from '@hanzo/gui'
export function SwitchDemo() {
return (
+1 -1
View File
@@ -4,7 +4,7 @@ import { useSwitch } from '@hanzogui/switch-headless'
import type { View } from 'react-native'
import { Animated, Pressable } from 'react-native'
import { Label, XStack, YStack } from 'hanzogui'
import { Label, XStack, YStack } from '@hanzo/gui'
export function SwitchHeadlessDemo() {
return (
+1 -1
View File
@@ -1,6 +1,6 @@
import { View, styled } from '@hanzogui/core'
import { createSwitch } from '@hanzogui/switch'
import { Label, XStack, YStack } from 'hanzogui'
import { Label, XStack, YStack } from '@hanzo/gui'
const Frame = styled(View, {
width: 40,
+2 -2
View File
@@ -1,5 +1,5 @@
import React from 'react'
import type { ViewProps, TabLayout, TabsTabProps } from 'hanzogui'
import type { ViewProps, TabLayout, TabsTabProps } from '@hanzo/gui'
import {
AnimatePresence,
Button,
@@ -9,7 +9,7 @@ import {
XStack,
YStack,
styled,
} from 'hanzogui'
} from '@hanzo/gui'
const demos = ['background', 'underline'] as const
const demosTitle: Record<(typeof demos)[number], string> = {
+2 -2
View File
@@ -1,5 +1,5 @@
import React from 'react'
import type { TabsContentProps } from 'hanzogui'
import type { TabsContentProps } from '@hanzo/gui'
import {
Button,
H5,
@@ -10,7 +10,7 @@ import {
YStack,
isWeb,
useWindowDimensions,
} from 'hanzogui'
} from '@hanzo/gui'
const demos = ['horizontal', 'vertical'] as const
const demosTitle: Record<(typeof demos)[number], string> = {
+1 -1
View File
@@ -7,7 +7,7 @@ import {
YStack,
isWeb,
styled,
} from 'hanzogui'
} from '@hanzo/gui'
import { createTabs } from '@hanzogui/tabs'
import { getButtonSized } from '@hanzogui/get-button-sized'
+1 -1
View File
@@ -1,4 +1,4 @@
import { Em, Paragraph, SizableText, Span, Strong, YStack } from 'hanzogui'
import { Em, Paragraph, SizableText, Span, Strong, YStack } from '@hanzo/gui'
export function TextDemo() {
return (
+2 -2
View File
@@ -1,5 +1,5 @@
import type { SquareProps, ThemeName } from 'hanzogui'
import { Square, XStack, YStack } from 'hanzogui'
import type { SquareProps, ThemeName } from '@hanzo/gui'
import { Square, XStack, YStack } from '@hanzo/gui'
export function ThemeBuilderDemo() {
return (
+1 -1
View File
@@ -1,4 +1,4 @@
import { Button, H5, Theme, XStack, YStack } from 'hanzogui'
import { Button, H5, Theme, XStack, YStack } from '@hanzo/gui'
export function ThemeInverseDemo() {
return (
+2 -2
View File
@@ -1,4 +1,4 @@
import { isWeb } from 'hanzogui'
import { isWeb } from '@hanzo/gui'
import {
Toast,
toast,
@@ -15,7 +15,7 @@ import {
ArrowUpRight,
} from '@hanzogui/lucide-icons-2'
import { useRef, useState } from 'react'
import { Button, Label, SizableText, Slider, XStack, YStack } from 'hanzogui'
import { Button, Label, SizableText, Slider, XStack, YStack } from '@hanzo/gui'
export const ToastDemo = () => {
const [position, setPosition] = useState<ToastPosition>('bottom-right')
+1 -1
View File
@@ -1,6 +1,6 @@
import { CheckCircle2 } from '@hanzogui/lucide-icons-2'
import { Toast, toast, useToasts } from '@hanzogui/toast/v2'
import { Button, XStack, YStack } from 'hanzogui'
import { Button, XStack, YStack } from '@hanzo/gui'
/**
* IMPORTANT NOTE: if you're copy-pasting this demo into your code, make sure to add the Toast wrapper as well.
+1 -1
View File
@@ -1,6 +1,6 @@
import { Toast, useToastController, useToastState } from '@hanzogui/toast'
import React from 'react'
import { Button, Label, Switch, XStack, YStack } from 'hanzogui'
import { Button, Label, Switch, XStack, YStack } from '@hanzo/gui'
/**
* IMPORTANT NOTE: if you're copy-pasting this demo into your code, make sure to add:
+1 -1
View File
@@ -1,5 +1,5 @@
import { AlignCenter, AlignLeft, AlignRight } from '@hanzogui/lucide-icons-2'
import { Label, ToggleGroup, XGroup, YGroup, XStack, YStack } from 'hanzogui'
import { Label, ToggleGroup, XGroup, YGroup, XStack, YStack } from '@hanzo/gui'
export function ToggleGroupDemo() {
return (
+1 -1
View File
@@ -1,7 +1,7 @@
import type { Tokens } from '@hanzogui/core'
import { getConfig } from '@hanzogui/core'
import React from 'react'
import { Button, H2, H3, H4, Square, XGroup, XStack, YStack } from 'hanzogui'
import { Button, H2, H3, H4, Square, XGroup, XStack, YStack } from '@hanzo/gui'
type Section = 'spaceNegative' | keyof Tokens
+10 -2
View File
@@ -5,8 +5,16 @@ import {
ChevronUp,
Circle,
} from '@hanzogui/lucide-icons-2'
import type { TooltipProps } from 'hanzogui'
import { Button, Paragraph, Theme, Tooltip, TooltipGroup, XStack, YStack } from 'hanzogui'
import type { TooltipProps } from '@hanzo/gui'
import {
Button,
Paragraph,
Theme,
Tooltip,
TooltipGroup,
XStack,
YStack,
} from '@hanzo/gui'
export function TooltipDemo() {
return (
+1 -1
View File
@@ -10,7 +10,7 @@ import {
YStack,
getVariableValue,
useIsomorphicLayoutEffect,
} from 'hanzogui'
} from '@hanzo/gui'
const colors = config.tokens.color
const colorKeys = Object.keys(colors)

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