Compare commits
160
Commits
rescue/wip
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c9cd26e3ee | ||
|
|
a93d68a05c | ||
|
|
33e4edcd8e | ||
|
|
2c79ff36ae | ||
|
|
48c5832fca | ||
|
|
75c5d5b5d0 | ||
|
|
7d9f0fa2a4 | ||
|
|
da50042d5e | ||
|
|
e4700d2c30 | ||
|
|
22b7f62f89 | ||
|
|
44a822f106 | ||
|
|
441ce04497 | ||
|
|
e97433f553 | ||
|
|
f74b440384 | ||
|
|
60c24492f1 | ||
|
|
81e2103f30 | ||
|
|
011441eb5a | ||
|
|
e05c06e404 | ||
|
|
3b3f9d6826 | ||
|
|
d804cb3db1 | ||
|
|
b3a0dc5861 | ||
|
|
d313a71bee | ||
|
|
fe4e96ee9e | ||
|
|
665f4bc849 | ||
|
|
2b72e2a146 | ||
|
|
93c8b0c2d1 | ||
|
|
88041ad619 | ||
|
|
2a1f78202a | ||
|
|
c375ee4628 | ||
|
|
04589acbca | ||
|
|
5312435b56 | ||
|
|
93e045ff5c | ||
|
|
44ac760e1d | ||
|
|
7fb6cbbf73 | ||
|
|
ea757718e8 | ||
|
|
72c577afe4 | ||
|
|
940e258da7 | ||
|
|
e259b03cfc | ||
|
|
54ee361b4a | ||
|
|
e95d5f405e | ||
|
|
26d2a04ac1 | ||
|
|
d1197103a0 | ||
|
|
11c0ec0c3d | ||
|
|
62a7c6a756 | ||
|
|
78e139f805 | ||
|
|
77717bc136 | ||
|
|
7d25b59fc5 | ||
|
|
7f6de44460 | ||
|
|
e0563f355e | ||
|
|
dcc262ae08 | ||
|
|
522dfdc4c7 | ||
|
|
dcccc19113 | ||
|
|
2fbe654af2 | ||
|
|
4ebc82ae3a | ||
|
|
6de65400fd | ||
|
|
208b1bfc26 | ||
|
|
4525ac6509 | ||
|
|
965bc63bea | ||
|
|
2728101ee0 | ||
|
|
46c4ab831a | ||
|
|
ce4a807e4a | ||
|
|
3cceace937 | ||
|
|
8e1283f099 | ||
|
|
3e302ebcbf | ||
|
|
7e5aa98886 | ||
|
|
777b248c7d | ||
|
|
2a55448f61 | ||
|
|
fdd38af868 | ||
|
|
024f131f57 | ||
|
|
a4c7fbdeb4 | ||
|
|
51bc03d148 | ||
|
|
f6afcdbfbb | ||
|
|
61758e3cbc | ||
|
|
87ea08f6e6 | ||
|
|
3f4edfc6f1 | ||
|
|
148958dbf2 | ||
|
|
2047312e7b | ||
|
|
428c9d34ab | ||
|
|
7b0da92e1b | ||
|
|
6e33e4beaf | ||
|
|
aebb0d6eb2 | ||
|
|
cb87cd50e2 | ||
|
|
1e76eec5f5 | ||
|
|
619a0b5282 | ||
|
|
9c5b9ff040 | ||
|
|
0d6c345847 | ||
|
|
521c64743b | ||
|
|
bccd6683d7 | ||
|
|
928b3ff5ee | ||
|
|
f4ee8fb9c6 | ||
|
|
f7121aad95 | ||
|
|
9bfd282b8c | ||
|
|
b86c851560 | ||
|
|
4466d8f086 | ||
|
|
4e6d40b8d2 | ||
|
|
68227219b9 | ||
|
|
fbc198cf52 | ||
|
|
28b550c404 | ||
|
|
9aea9ae2db | ||
|
|
87e6047823 | ||
|
|
ff03a35212 | ||
|
|
e7f7efa507 | ||
|
|
6824760c4d | ||
|
|
5c665148e6 | ||
|
|
b89a07b21e | ||
|
|
1dab77e86c | ||
|
|
b893b9bff9 | ||
|
|
95c35ecf05 | ||
|
|
b5d4b174ca | ||
|
|
775982ed07 | ||
|
|
82a01507ec | ||
|
|
c6d74f0475 | ||
|
|
7120906410 | ||
|
|
53a7346b88 | ||
|
|
45ac3268fd | ||
|
|
0d04ed0cd7 | ||
|
|
e7ae55d708 | ||
|
|
292e58613d | ||
|
|
aaec6bc4af | ||
|
|
29f097c474 | ||
|
|
f8f2b94ddb | ||
|
|
478a63c965 | ||
|
|
aedd495175 | ||
|
|
df15dc29d4 | ||
|
|
038bc8886e | ||
|
|
e1c2f1fe7e | ||
|
|
af85967e66 | ||
|
|
733953fda9 | ||
|
|
ed84370130 | ||
|
|
f492c1e524 | ||
|
|
ea5357b2ad | ||
|
|
4b565be7af | ||
|
|
143d990a69 | ||
|
|
c4df0c18bc | ||
|
|
84fdccaec5 | ||
|
|
29102d5e9c | ||
|
|
dd357fc559 | ||
|
|
0f8d23817e | ||
|
|
0ab87cf79e | ||
|
|
7c93d33c20 | ||
|
|
8eaf705f2f | ||
|
|
794da15224 | ||
|
|
5f38148a23 | ||
|
|
84f2b23f29 | ||
|
|
40a61dc5d0 | ||
|
|
eb3ab6f07c | ||
|
|
e5bb4d5db6 | ||
|
|
8900f673ed | ||
|
|
510c6d0dc2 | ||
|
|
94d73cedb9 | ||
|
|
51963f855c | ||
|
|
b897a3fa35 | ||
|
|
3a6ecca0bf | ||
|
|
0d00a20cee | ||
|
|
335e911a61 | ||
|
|
f138c65497 | ||
|
|
3600ce8d7b | ||
|
|
4c8c6ed1b6 | ||
|
|
10043d3478 | ||
|
|
d395e0c7ee |
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@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).
|
||||
@@ -0,0 +1,9 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1280" height="640" viewBox="0 0 1280 640" role="img" aria-label="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>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -1,285 +0,0 @@
|
||||
name: Build iOS Test Container App
|
||||
env:
|
||||
# Relative path from the monorepo root to the app
|
||||
test_container_app_path: code/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: guikitchensink
|
||||
# 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-guikitchensink
|
||||
# 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@v5
|
||||
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@v5
|
||||
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@v5
|
||||
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@v5
|
||||
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@v5
|
||||
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@v5
|
||||
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@v5
|
||||
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/upload-artifact@v5.3.1
|
||||
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@v5
|
||||
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
|
||||
@@ -1,18 +0,0 @@
|
||||
name: Changelog
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
jobs:
|
||||
tagged-release:
|
||||
name: Changelog
|
||||
runs-on: hanzo-build-linux-amd64
|
||||
# needs: release
|
||||
|
||||
steps:
|
||||
- uses: marvinpinto/action-automatic-releases@latest
|
||||
with:
|
||||
repo_token: '${{ github.token }}'
|
||||
prerelease: false
|
||||
@@ -1,124 +0,0 @@
|
||||
name: Checks
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
pull_request:
|
||||
push:
|
||||
paths-ignore:
|
||||
- 'assets/**'
|
||||
- '.vscode/**'
|
||||
branches:
|
||||
- main
|
||||
- v2
|
||||
- 'v2-*'
|
||||
|
||||
# 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: hanzo-build-linux-amd64
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Install
|
||||
uses: ./.github/actions/install
|
||||
|
||||
- name: Check
|
||||
run: bun run check
|
||||
|
||||
|
||||
- name: Lint
|
||||
run: bun run lint
|
||||
|
||||
unit-tests:
|
||||
runs-on: hanzo-build-linux-amd64
|
||||
env:
|
||||
NODE_OPTIONS: '--max-old-space-size=6144'
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v5
|
||||
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@v5
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
key: playwright-${{ runner.os }}-${{ steps.pw-version.outputs.version }}
|
||||
|
||||
- name: Install playwright
|
||||
run: bunx playwright install
|
||||
|
||||
- name: Run unit tests
|
||||
run: bun turbo run test:web --filter='!@hanzogui/kitchen-sink' --concurrency=1
|
||||
|
||||
changes:
|
||||
runs-on: hanzo-build-linux-amd64
|
||||
outputs:
|
||||
integration-relevant: ${{ steps.filter.outputs.integration-relevant }}
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: dorny/paths-filter@v3
|
||||
id: filter
|
||||
with:
|
||||
filters: |
|
||||
integration-relevant:
|
||||
- 'code/core/**'
|
||||
- 'code/ui/**'
|
||||
- 'code/packages/**'
|
||||
- 'code/kitchen-sink/**'
|
||||
- 'code/compiler/babel-plugin/**'
|
||||
- 'code/compiler/loader/**'
|
||||
- 'code/compiler/metro-plugin/**'
|
||||
- 'code/demos/**'
|
||||
- 'package.json'
|
||||
- 'bun.lock'
|
||||
|
||||
integration-tests:
|
||||
needs: changes
|
||||
if: needs.changes.outputs.integration-relevant == 'true'
|
||||
runs-on: hanzo-build-linux-amd64
|
||||
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@v5
|
||||
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@v5
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
key: playwright-${{ runner.os }}-${{ steps.pw-version.outputs.version }}
|
||||
|
||||
- name: Install playwright
|
||||
run: bunx playwright install --with-deps
|
||||
|
||||
- name: Run integration tests (shard ${{ matrix.shard }})
|
||||
run: cd code/kitchen-sink && NODE_ENV=test npx playwright test --shard=${{ matrix.shard }}
|
||||
timeout-minutes: 20
|
||||
@@ -1,46 +0,0 @@
|
||||
name: Release
|
||||
|
||||
# testing on a branch for now
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- test-release
|
||||
|
||||
jobs:
|
||||
release:
|
||||
name: Release
|
||||
runs-on: hanzo-build-linux-amd64
|
||||
steps:
|
||||
- name: Checkout Repo
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
# Full history needed for git tag and push
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.PAT }}
|
||||
|
||||
- name: Install
|
||||
uses: ./.github/actions/install
|
||||
|
||||
- name: Install playwright
|
||||
run: bunx playwright install
|
||||
|
||||
- name: Test
|
||||
run: bun run test
|
||||
|
||||
- name: Publish
|
||||
# just testing for now so --skip-publish
|
||||
run: bun run release:beta:dirty --ci --gui-git-user --skip-publish
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
|
||||
tagged-release:
|
||||
name: Tagged Release
|
||||
runs-on: hanzo-build-linux-amd64
|
||||
# needs: release
|
||||
|
||||
steps:
|
||||
- uses: marvinpinto/action-automatic-releases@latest
|
||||
with:
|
||||
repo_token: '${{ github.token }}'
|
||||
prerelease: false
|
||||
@@ -1,137 +0,0 @@
|
||||
name: Test iOS Kitchen Sink Go (Maestro)
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, 'v2-*']
|
||||
pull_request:
|
||||
branches: [main, 'v*']
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
test_container_app_path: code/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@v5
|
||||
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 HANZO_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
|
||||
|
||||
- name: Run Maestro Tests
|
||||
run: |
|
||||
for attempt in 1 2; do
|
||||
echo "Test attempt $attempt of 2..."
|
||||
if maestro test ./flows --exclude-tags=util --no-ansi; then
|
||||
echo "Tests passed!"
|
||||
exit 0
|
||||
fi
|
||||
if [ $attempt -lt 2 ]; then
|
||||
echo "Attempt $attempt failed, retrying..."
|
||||
sleep 10
|
||||
fi
|
||||
done
|
||||
echo "All attempts failed"
|
||||
exit 1
|
||||
|
||||
- name: Upload Maestro Artifacts on Failure
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v5
|
||||
with:
|
||||
name: maestro-artifacts-exports-${{ matrix.package-exports }}
|
||||
path: |
|
||||
~/.maestro/tests/**/*
|
||||
@@ -1,186 +0,0 @@
|
||||
name: Test iOS Native (Maestro)
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, 'v2-*', rn82]
|
||||
pull_request:
|
||||
branches: [main, 'v*']
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
test_container_app_path: code/kitchen-sink
|
||||
MAESTRO_DRIVER_STARTUP_TIMEOUT: 120000
|
||||
|
||||
jobs:
|
||||
build:
|
||||
uses: ./.github/workflows/build-ios-kitchensink-app.yml
|
||||
with:
|
||||
configuration: Debug
|
||||
secrets: inherit
|
||||
|
||||
test:
|
||||
name: Run Maestro Tests (exports=${{ matrix.package-exports }})
|
||||
needs: build
|
||||
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@v5
|
||||
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@v5
|
||||
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 while simulator boots
|
||||
cd "${{ env.test_container_app_path }}"
|
||||
EXPO_NO_TELEMETRY=true HANZO_GUI_PACKAGE_EXPORTS=${{ matrix.package-exports }} bunx expo start --dev-client --offline --reset-cache &
|
||||
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 while waiting for Metro
|
||||
xcrun simctl install booted "${{ needs.build.outputs.built-app-path }}"
|
||||
|
||||
# wait for Metro to be ready
|
||||
echo "Waiting for Metro..."
|
||||
for i in {1..60}; 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
|
||||
sleep 2
|
||||
done
|
||||
|
||||
- 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')
|
||||
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: 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 == "guikitchensink" OR subsystem == "com.gui.guikitchensink"' --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.
|
||||
maestro test ./flows/WarmUp.yaml --no-ansi || true
|
||||
|
||||
- name: Run Maestro Tests
|
||||
if: env.SKIP_MAESTRO_TESTS != 'true'
|
||||
run: |
|
||||
# Run tests with one retry for flaky simulator detection
|
||||
for attempt in 1 2; do
|
||||
echo "Test attempt $attempt of 2..."
|
||||
if maestro test ./flows --exclude-tags=util --no-ansi; then
|
||||
echo "Tests passed!"
|
||||
exit 0
|
||||
fi
|
||||
if [ $attempt -lt 2 ]; then
|
||||
echo "Attempt $attempt failed, retrying..."
|
||||
sleep 10
|
||||
fi
|
||||
done
|
||||
echo "All attempts failed"
|
||||
exit 1
|
||||
|
||||
- name: Dump App 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"
|
||||
# show any metro errors from the background process
|
||||
ps aux | grep -i metro | grep -v grep || echo "Metro process not found"
|
||||
echo "::endgroup::"
|
||||
echo "::group::Crash Reports"
|
||||
find ~/Library/Logs/DiagnosticReports -name "*guikitchensink*" -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@v5
|
||||
with:
|
||||
name: maestro-artifacts-exports-${{ matrix.package-exports }}
|
||||
path: |
|
||||
~/.maestro/tests/**/*
|
||||
/tmp/simulator-app.log
|
||||
/tmp/simulator-errors.log
|
||||
@@ -1,573 +0,0 @@
|
||||
name: Native Tests (Detox)
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- 'native-*'
|
||||
- 'v*'
|
||||
- rn82
|
||||
workflow_dispatch:
|
||||
|
||||
# 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: code/kitchen-sink
|
||||
# Increment this to force a fresh Android build (invalidates Redis KV cache)
|
||||
android_cache_version: v4
|
||||
|
||||
jobs:
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# iOS Build & Test
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
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
|
||||
runs-on: macos-15
|
||||
timeout-minutes: 45
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- shard_name: '1/2'
|
||||
test_files: 'e2e/check-rngh-status.test.ts e2e/GroupPressNative.test.ts e2e/MediaQueryGtMd.test.ts e2e/NativePortal.test.ts e2e/PointerEvents.test.ts e2e/PressStyleNative.noRngh.test.ts e2e/PressStyleNative.test.ts e2e/SafeArea.test.ts'
|
||||
- shard_name: '2/2'
|
||||
test_files: 'e2e/CompilerExtraction.test.ts e2e/SelectAndroidOnPress.test.ts e2e/SelectRemount.test.ts e2e/SheetDragResist.test.ts e2e/SheetKeyboardDrag.test.ts e2e/SheetScrollableDrag.test.ts e2e/ShorthandVariables.test.ts e2e/ThemeChangeBasic.test.ts e2e/ThemeMutation.test.ts'
|
||||
defaults:
|
||||
run:
|
||||
working-directory: ${{ env.test_container_app_path }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v5
|
||||
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@v5
|
||||
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 &
|
||||
(brew tap wix/brew && brew install applesimutils) &
|
||||
wait
|
||||
|
||||
- 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
|
||||
|
||||
- name: Install App on Simulator
|
||||
if: env.SKIP_IOS_TESTS != 'true'
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: |
|
||||
xcrun simctl install booted "${{ needs.build-ios.outputs.built-app-path }}"
|
||||
|
||||
- name: Setup Bun
|
||||
if: env.SKIP_IOS_TESTS != 'true'
|
||||
uses: oven-sh/setup-bun@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/guikitchensink.app
|
||||
run: |
|
||||
bun run ../packages/native-ci/src/run-detox-ios.ts --project-root "$PWD" --record-logs failing --retries 1 ${{ 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@v5
|
||||
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: hanzo-build-linux-amd64
|
||||
timeout-minutes: 45
|
||||
outputs:
|
||||
android-cache-key: ${{ steps.final-cache-key.outputs.cache_key }}
|
||||
android-path: code/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@v5
|
||||
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', 'code/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-guikitchensink-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-guikitchensink-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@v5
|
||||
with:
|
||||
path: code/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@v3
|
||||
|
||||
- name: Prebuild Android
|
||||
if: ${{ !steps.android-cache-check.outputs.cache-hit }}
|
||||
working-directory: code/kitchen-sink
|
||||
run: |
|
||||
bunx expo prebuild --platform android --clean
|
||||
|
||||
- name: Setup Detox Test Infrastructure
|
||||
if: ${{ !steps.android-cache-check.outputs.cache-hit }}
|
||||
working-directory: code/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/hanzoai/guikitchensink
|
||||
|
||||
# Create DetoxTest.java
|
||||
cat > android/app/src/androidTest/java/com/hanzoai/guikitchensink/DetoxTest.java << 'DETOXTEST'
|
||||
package com.gui.guikitchensink;
|
||||
|
||||
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: code/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-guikitchensink-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@v5
|
||||
with:
|
||||
path: code/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@v5
|
||||
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: code/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@v5
|
||||
with:
|
||||
path: code/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: hanzo-build-linux-amd64
|
||||
timeout-minutes: 45
|
||||
# Android emulator in CI is flaky with window focus issues - don't block on failures
|
||||
continue-on-error: true
|
||||
# Retry matrix - run up to 2 attempts sequentially to handle flakiness
|
||||
strategy:
|
||||
fail-fast: false
|
||||
max-parallel: 1
|
||||
matrix:
|
||||
attempt: [1, 2]
|
||||
defaults:
|
||||
run:
|
||||
working-directory: ${{ env.test_container_app_path }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v5
|
||||
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@v5
|
||||
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: 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@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@v2
|
||||
with:
|
||||
api-level: 31
|
||||
target: google_apis
|
||||
arch: x86_64
|
||||
profile: pixel_4
|
||||
avd-name: test
|
||||
ram-size: 2048M
|
||||
heap-size: 512M
|
||||
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 -memory 2048
|
||||
working-directory: code/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
|
||||
bun run ../packages/native-ci/src/run-detox-android.ts --headless --project-root "$PWD" --record-logs failing --retries 2 || 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@v5
|
||||
with:
|
||||
name: detox-artifacts-android-attempt-${{ matrix.attempt }}
|
||||
path: ${{ env.test_container_app_path }}/e2e/artifacts/
|
||||
retention-days: 7
|
||||
@@ -60,3 +60,6 @@ test-results/
|
||||
.gui/
|
||||
# Wed Mar 25 04:08:49 PDT 2026
|
||||
*.tgz
|
||||
|
||||
# tauri build output only — no source lives here (2.1G)
|
||||
apps/desktop/
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
name: Build image
|
||||
|
||||
# Builds + pushes ghcr.io/hanzoai/gui (the gui.hanzo.ai site, served on
|
||||
# :3000 by the repo's existing Dockerfile) on a v* semver tag via the
|
||||
# canonical reusable workflow, natively on the self-hosted arcd fleet
|
||||
# (NO GitHub-hosted runners). Semver-only tags. Deployable on DOKS.
|
||||
#
|
||||
# This is the CONTAINER-image path. The repo's publish-gui*.yml workflows
|
||||
# publish the npm library packages and are unaffected.
|
||||
on:
|
||||
push:
|
||||
tags: ['v*']
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
id-token: write
|
||||
|
||||
jobs:
|
||||
docker:
|
||||
uses: hanzoai/.github/.github/workflows/docker-build.yml@main
|
||||
with:
|
||||
image: ghcr.io/hanzoai/gui
|
||||
secrets: inherit
|
||||
@@ -0,0 +1,66 @@
|
||||
name: Checks
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
pull_request:
|
||||
push:
|
||||
paths-ignore:
|
||||
- 'assets/**'
|
||||
- '.vscode/**'
|
||||
branches:
|
||||
- main
|
||||
- v2
|
||||
- 'v2-*'
|
||||
|
||||
# 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: hanzo-build-linux-amd64
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Install
|
||||
uses: ./.github/actions/install
|
||||
|
||||
- name: Check
|
||||
run: bun run check
|
||||
|
||||
|
||||
- name: Lint
|
||||
run: bun run lint
|
||||
|
||||
unit-tests:
|
||||
runs-on: hanzo-build-linux-amd64
|
||||
env:
|
||||
NODE_OPTIONS: '--max-old-space-size=6144'
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v5
|
||||
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@v5
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
key: playwright-${{ runner.os }}-${{ steps.pw-version.outputs.version }}
|
||||
|
||||
- name: Install playwright
|
||||
run: bunx playwright install
|
||||
|
||||
- name: Run unit tests
|
||||
run: bun turbo run test:web --filter='!@hanzogui/kitchen-sink' --concurrency=1
|
||||
@@ -0,0 +1,111 @@
|
||||
name: Publish gui (all packages)
|
||||
|
||||
# Publishes the FULL gui surface — every @hanzogui/* primitive AND the
|
||||
# `hanzogui` + `@hanzo/gui` umbrellas — at the current on-disk version
|
||||
# (7.x), with freshly built `dist/`. This is the fix for the broken
|
||||
# 7.0.0 publish, where the primitives shipped without `dist/` and
|
||||
# `@hanzo/gui` therefore failed to resolve for standalone consumers.
|
||||
#
|
||||
# `publish-gui.yml` only ships the umbrella; this ships the primitives it
|
||||
# depends on so a clean `npm i @hanzo/gui` works end-to-end.
|
||||
#
|
||||
# Trigger: manual (workflow_dispatch, type "publish" to confirm) or by
|
||||
# pushing a `release/gui-all-v*` tag.
|
||||
#
|
||||
# NPM_TOKEN resolution mirrors publish-gui.yml:
|
||||
# 1. KMS via Universal Auth (KMS_CLIENT_ID/SECRET → short-lived token)
|
||||
# 2. KMS via long-lived HANZO_API_KEY (legacy bootstrap)
|
||||
# 3. Direct repo secret NPM_TOKEN (fallback)
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
confirm:
|
||||
description: 'Type "publish" to publish ALL gui packages at the current version'
|
||||
required: true
|
||||
default: ''
|
||||
push:
|
||||
tags:
|
||||
- 'release/gui-all-v*'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
|
||||
jobs:
|
||||
publish-all:
|
||||
name: Build + publish all gui packages
|
||||
# Retargeted arm64 self-hosted (spark, frequently offline) → the always-up
|
||||
# in-cluster amd64 ARC pool. @hanzogui/* are pure-JS; publishing is
|
||||
# arch-independent, so amd64 is safe and far more reliable.
|
||||
runs-on: hanzo-build-linux-amd64
|
||||
if: github.event_name == 'push' || github.event.inputs.confirm == 'publish'
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Install
|
||||
uses: ./.github/actions/install
|
||||
|
||||
- name: Build all packages
|
||||
# Fresh checkout has no dist; --republish skips release.ts's own
|
||||
# build, so build here explicitly. `npm pack` (inside release.ts)
|
||||
# will fail loudly if any dist tree is missing.
|
||||
run: bun run build
|
||||
|
||||
- name: Resolve NPM_TOKEN (KMS → fallback to repo secret)
|
||||
id: token
|
||||
env:
|
||||
DIRECT_NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
KMS_CLIENT_ID: ${{ secrets.KMS_CLIENT_ID }}
|
||||
KMS_CLIENT_SECRET: ${{ secrets.KMS_CLIENT_SECRET }}
|
||||
HANZO_API_KEY: ${{ secrets.HANZO_API_KEY }}
|
||||
KMS_ENDPOINT: ${{ vars.KMS_ENDPOINT || 'https://kms.hanzo.ai' }}
|
||||
KMS_WORKSPACE_ID: ${{ vars.KMS_WORKSPACE_ID_GUI || 'e1359bf4-31b4-4dfa-bb90-323e2c298ad8' }}
|
||||
run: |
|
||||
set -eu
|
||||
npm_token=""
|
||||
if [ -n "${KMS_CLIENT_ID:-}" ] && [ -n "${KMS_CLIENT_SECRET:-}" ]; then
|
||||
HANZO_API_KEY=$(curl -sf "${KMS_ENDPOINT}/api/v1/auth/universal-auth/login" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"clientId\":\"${KMS_CLIENT_ID}\",\"clientSecret\":\"${KMS_CLIENT_SECRET}\"}" \
|
||||
| jq -r '.accessToken') || true
|
||||
fi
|
||||
if [ -n "${HANZO_API_KEY:-}" ]; then
|
||||
response=$(curl -sf \
|
||||
"${KMS_ENDPOINT}/api/v3/secrets/raw?workspaceId=${KMS_WORKSPACE_ID}&secretPath=/publish&environment=prod" \
|
||||
-H "Authorization: Bearer ${HANZO_API_KEY}" 2>/dev/null) || true
|
||||
if [ -n "${response:-}" ]; then
|
||||
npm_token=$(echo "$response" | jq -r '.secrets[] | select(.secretKey=="NPM_TOKEN") | .secretValue // empty')
|
||||
fi
|
||||
fi
|
||||
if [ -z "$npm_token" ]; then
|
||||
npm_token="${DIRECT_NPM_TOKEN:-}"
|
||||
if [ -n "$npm_token" ]; then
|
||||
echo "Using NPM_TOKEN from repo secret (KMS path unavailable)."
|
||||
fi
|
||||
fi
|
||||
if [ -z "$npm_token" ]; then
|
||||
echo "::error::No NPM_TOKEN available — need KMS_CLIENT_ID/SECRET, HANZO_API_KEY, or NPM_TOKEN repo secret."
|
||||
exit 1
|
||||
fi
|
||||
echo "::add-mask::${npm_token}"
|
||||
echo "npm_token=${npm_token}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Configure npm registry
|
||||
env:
|
||||
NPM_TOKEN: ${{ steps.token.outputs.npm_token }}
|
||||
run: |
|
||||
echo 'registry=https://registry.npmjs.org/' > ~/.npmrc
|
||||
echo "//registry.npmjs.org/:_authToken=${NPM_TOKEN}" >> ~/.npmrc
|
||||
|
||||
- name: Publish gui surface (primitives + umbrella + @hanzo/gui alias)
|
||||
# Targeted publish of just the @hanzogui/* closure + hanzogui +
|
||||
# @hanzo/gui, at the current on-disk version. Avoids release.ts's
|
||||
# whole-workspace fan-out (which fails packing unrelated
|
||||
# unrelated workspace packages) and its fragile tarball-rename step.
|
||||
# Idempotent — skips any name@version already on npm.
|
||||
env:
|
||||
NPM_TOKEN: ${{ steps.token.outputs.npm_token }}
|
||||
NODE_AUTH_TOKEN: ${{ steps.token.outputs.npm_token }}
|
||||
run: node ./scripts/publish-gui-surface.mjs
|
||||
@@ -28,6 +28,8 @@ permissions:
|
||||
jobs:
|
||||
publish:
|
||||
name: Build, smoke-test, publish
|
||||
# Retargeted arm64 self-hosted (spark, frequently offline) → the always-up
|
||||
# in-cluster amd64 ARC pool. Pure-JS publish is arch-independent.
|
||||
runs-on: hanzo-build-linux-amd64
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
@@ -0,0 +1,288 @@
|
||||
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: `.
|
||||
|
||||
# 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({ ... }),
|
||||
})
|
||||
```
|
||||
|
||||
**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.
|
||||
|
||||
|
||||
## Tailwind → gui migration (marketing surfaces)
|
||||
|
||||
`@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.
|
||||
|
||||
**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`.
|
||||
|
||||
### What the migration actually is
|
||||
|
||||
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.
|
||||
|
||||
### 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).
|
||||
+141
@@ -0,0 +1,141 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,38 @@
|
||||
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.
|
||||
@@ -1,5 +1,9 @@
|
||||
<p align="center"><img src=".github/hero.svg" alt="gui" width="880"></p>
|
||||
|
||||
# Hanzo GUI
|
||||
|
||||
Forked from [tamagui](https://github.com/tamagui/tamagui) (MIT).
|
||||
|
||||
<h3 align="center">
|
||||
Style library, design system, composable components, and more.
|
||||
</h3>
|
||||
|
||||
@@ -3,7 +3,7 @@ import React from 'react'
|
||||
|
||||
import type { FontSizeTokens, SelectProps } from 'hanzogui'
|
||||
import { Adapt, Label, Select, Sheet, Theme, XStack, YStack, getFontSize } from 'hanzogui'
|
||||
import { LinearGradient } from 'gui/linear-gradient'
|
||||
import { LinearGradient } from 'hanzogui/linear-gradient'
|
||||
|
||||
export function SelectDemo() {
|
||||
return (
|
||||
|
||||
@@ -15,7 +15,6 @@
|
||||
|
||||
import { apiRoute } from '~/features/api/apiRoute'
|
||||
import { ensureAuth } from '~/features/api/ensureAuth'
|
||||
import { captureServerError } from '~/features/posthog'
|
||||
import { commerce, CommerceError } from '~/features/commerce/client'
|
||||
|
||||
type Body = {
|
||||
@@ -58,8 +57,14 @@ export default apiRoute(async (req) => {
|
||||
if (err instanceof CommerceError) {
|
||||
return Response.json(err.detail, { status: err.status })
|
||||
}
|
||||
// Server-side error reporting is the cluster log pipeline for now. The
|
||||
// previous captureServerError shipped this to a third-party PostHog project
|
||||
// (posthog-node, hardcoded key, us.i.posthog.com) — off-platform and not the
|
||||
// ONE door. @hanzogui/telemetry is DOM-oriented and would silently no-op
|
||||
// here, so wiring it would only pretend to capture. A real server plane
|
||||
// needs a service credential POSTing api.hanzo.ai/v1/event; until then this
|
||||
// log is the honest surface.
|
||||
console.error('create-subscription failed', err)
|
||||
captureServerError(err as Error, { endpoint: '/api/create-subscription' })
|
||||
return Response.json({ error: 'Failed to create subscription' }, { status: 500 })
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Component } from 'react'
|
||||
import { processError } from '~/features/posthog/errorHandling'
|
||||
import { captureError } from '@hanzogui/telemetry'
|
||||
|
||||
export class ErrorBoundary extends Component<any> {
|
||||
constructor(props) {
|
||||
@@ -13,14 +13,14 @@ export class ErrorBoundary extends Component<any> {
|
||||
}
|
||||
|
||||
componentDidCatch(error, errorInfo) {
|
||||
processError({
|
||||
error,
|
||||
context: {
|
||||
captureError(error, {
|
||||
handled: true,
|
||||
properties: {
|
||||
severity: 'high',
|
||||
source: 'error_boundary',
|
||||
url: typeof window !== 'undefined' ? window.location.href : undefined,
|
||||
additional: { componentStack: errorInfo?.componentStack },
|
||||
componentStack: errorInfo?.componentStack,
|
||||
},
|
||||
severity: 'high',
|
||||
tags: { source: 'error_boundary' },
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import { InitialPathContext, SeasonProvider } from '@hanzogui/logo'
|
||||
import { SchemeProvider, useUserScheme } from '@vxrn/color-scheme'
|
||||
import { GuiProvider } from 'hanzogui'
|
||||
import tamaConf from '~/gui.config'
|
||||
import { PostHogProvider } from '~/features/posthog/PostHogProvider'
|
||||
import { TelemetryProvider } from '@hanzogui/telemetry'
|
||||
import { SearchProvider } from '~/features/site/search/SearchProvider'
|
||||
import { ToastProvider } from '~/features/studio/ToastProvider'
|
||||
|
||||
@@ -10,13 +10,13 @@ export const Providers = (props: { children: any }) => {
|
||||
return (
|
||||
<InitialPathContext.Provider value={3}>
|
||||
<SchemeProvider>
|
||||
<PostHogProvider>
|
||||
<TelemetryProvider>
|
||||
<SeasonProvider>
|
||||
<WebsiteGuiProvider>
|
||||
<SearchProvider>{props.children}</SearchProvider>
|
||||
</WebsiteGuiProvider>
|
||||
</SeasonProvider>
|
||||
</PostHogProvider>
|
||||
</TelemetryProvider>
|
||||
</SchemeProvider>
|
||||
</InitialPathContext.Provider>
|
||||
)
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
*/
|
||||
|
||||
import { validateToken } from '@hanzo/iam/auth'
|
||||
import type { IamUser } from '@hanzo/iam'
|
||||
import type { User as IamUser } from '@hanzo/iam'
|
||||
|
||||
const IAM_SERVER_URL =
|
||||
process.env.HANZO_IAM_SERVER_URL ?? 'https://iam.hanzo.ai'
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
import { useEffect } from 'react'
|
||||
import { clientPostHog } from './client'
|
||||
import { initializeErrorHandling } from './errorHandling'
|
||||
import { usePostHogIdentify } from './usePostHogIdentify'
|
||||
|
||||
export function PostHogProvider({ children }: { children: React.ReactNode }) {
|
||||
useEffect(() => {
|
||||
clientPostHog.initialize()
|
||||
initializeErrorHandling()
|
||||
}, [])
|
||||
|
||||
usePostHogIdentify()
|
||||
|
||||
return <>{children}</>
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
import { serverPostHog } from './server'
|
||||
|
||||
export function captureServerError(
|
||||
error: Error,
|
||||
context?: {
|
||||
endpoint?: string
|
||||
userId?: string
|
||||
method?: string
|
||||
[key: string]: any
|
||||
}
|
||||
) {
|
||||
serverPostHog.captureException(error, {
|
||||
endpoint: context?.endpoint,
|
||||
method: context?.method,
|
||||
distinctId: context?.userId || 'anonymous-server',
|
||||
...context,
|
||||
})
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
import posthog from 'posthog-js'
|
||||
import type { PostHogInstance } from './types'
|
||||
|
||||
const POSTHOG_KEY = 'phc_vy6MdaPFUllGBQLrBNWs4RJ8tbGuHyFF0nY6lncB1Ol'
|
||||
const POSTHOG_HOST = 'https://us.i.posthog.com'
|
||||
|
||||
class ClientPostHog implements PostHogInstance {
|
||||
private isInitialized = false
|
||||
|
||||
initialize(): void {
|
||||
if (this.isInitialized) return
|
||||
if (typeof window === 'undefined') return
|
||||
if (process.env.NODE_ENV === 'development') return
|
||||
|
||||
posthog.init(POSTHOG_KEY, {
|
||||
api_host: POSTHOG_HOST,
|
||||
person_profiles: 'identified_only',
|
||||
capture_pageview: true,
|
||||
capture_pageleave: true,
|
||||
autocapture: true,
|
||||
session_recording: {
|
||||
recordCrossOriginIframes: true,
|
||||
},
|
||||
})
|
||||
|
||||
this.isInitialized = true
|
||||
}
|
||||
|
||||
capture(event: string, properties?: Record<string, any>): void {
|
||||
if (!this.isInitialized) return
|
||||
posthog.capture(event, properties)
|
||||
}
|
||||
|
||||
identify(userId: string, properties?: Record<string, any>): void {
|
||||
if (!this.isInitialized) return
|
||||
posthog.identify(userId, properties)
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
if (!this.isInitialized) return
|
||||
posthog.reset()
|
||||
}
|
||||
|
||||
captureException(error: Error, properties?: Record<string, any>): void {
|
||||
if (!this.isInitialized) return
|
||||
|
||||
if (typeof posthog.captureException === 'function') {
|
||||
posthog.captureException(error, properties)
|
||||
} else {
|
||||
posthog.capture('$exception', {
|
||||
$exception_type: error.name,
|
||||
$exception_message: error.message,
|
||||
$exception_stack_trace_raw: error.stack || '',
|
||||
...properties,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const clientPostHog = new ClientPostHog()
|
||||
@@ -1,69 +0,0 @@
|
||||
import { clientPostHog } from './client'
|
||||
import type { ErrorReport } from './types'
|
||||
|
||||
let handlersSetup = false
|
||||
|
||||
export function initializeErrorHandling(): void {
|
||||
if (typeof window === 'undefined') return
|
||||
if (process.env.NODE_ENV === 'development') return
|
||||
if (handlersSetup) return
|
||||
|
||||
setupWebHandlers()
|
||||
handlersSetup = true
|
||||
}
|
||||
|
||||
export function processError(report: ErrorReport): void {
|
||||
const { error, context = {}, severity = 'medium', tags } = report
|
||||
|
||||
clientPostHog.captureException(error, {
|
||||
...tags,
|
||||
severity,
|
||||
url: context.url,
|
||||
userAgent: context.userAgent,
|
||||
timestamp: context.timestamp || Date.now(),
|
||||
...context.additional,
|
||||
})
|
||||
|
||||
if (severity === 'critical' || severity === 'high') {
|
||||
console.error('[posthog error]', error)
|
||||
}
|
||||
}
|
||||
|
||||
function setupWebHandlers(): void {
|
||||
const ogWindowErrorHandler = window.onerror
|
||||
|
||||
window.onerror = (message, source, lineno, colno, error) => {
|
||||
ogWindowErrorHandler?.(message, source, lineno, colno, error)
|
||||
|
||||
const actualError = error || new Error(String(message))
|
||||
processError({
|
||||
error: actualError,
|
||||
context: {
|
||||
url: source?.toString(),
|
||||
timestamp: Date.now(),
|
||||
userAgent: navigator.userAgent,
|
||||
additional: { line: lineno, column: colno },
|
||||
},
|
||||
severity: 'high',
|
||||
tags: { source: 'window.onerror' },
|
||||
})
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
window.addEventListener('unhandledrejection', (event: PromiseRejectionEvent) => {
|
||||
const error =
|
||||
event.reason instanceof Error ? event.reason : new Error(String(event.reason))
|
||||
|
||||
processError({
|
||||
error,
|
||||
context: {
|
||||
url: window.location.href,
|
||||
userAgent: navigator.userAgent,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
severity: 'high',
|
||||
tags: { source: 'unhandled_promise_rejection' },
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
export { clientPostHog } from './client'
|
||||
export { serverPostHog } from './server'
|
||||
export { captureServerError } from './captureServerError'
|
||||
export { processError } from './errorHandling'
|
||||
export type { PostHogInstance, ErrorContext, ErrorReport } from './types'
|
||||
@@ -1,59 +0,0 @@
|
||||
import { PostHog } from 'posthog-node'
|
||||
import type { PostHogInstance } from './types'
|
||||
|
||||
const POSTHOG_KEY = 'phc_vy6MdaPFUllGBQLrBNWs4RJ8tbGuHyFF0nY6lncB1Ol'
|
||||
const POSTHOG_HOST = 'https://us.i.posthog.com'
|
||||
|
||||
class ServerPostHog implements PostHogInstance {
|
||||
private posthog: PostHog | null = null
|
||||
|
||||
constructor() {
|
||||
if (process.env.NODE_ENV !== 'development') {
|
||||
this.posthog = new PostHog(POSTHOG_KEY, {
|
||||
host: POSTHOG_HOST,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
capture(event: string, properties?: Record<string, any>): void {
|
||||
if (!this.posthog) return
|
||||
this.posthog.capture({
|
||||
event,
|
||||
properties,
|
||||
distinctId: properties?.distinctId || properties?.userId || 'anonymous-server',
|
||||
})
|
||||
}
|
||||
|
||||
captureException(error: Error, properties?: Record<string, any>): void {
|
||||
if (!this.posthog) return
|
||||
this.posthog.capture({
|
||||
event: '$exception',
|
||||
distinctId: properties?.distinctId || properties?.userId || 'anonymous-server',
|
||||
properties: {
|
||||
$exception_type: error.name,
|
||||
$exception_message: error.message,
|
||||
$exception_stack_trace_raw: error.stack || '',
|
||||
source: 'server',
|
||||
...properties,
|
||||
},
|
||||
})
|
||||
|
||||
// flush immediately for exceptions
|
||||
this.posthog.flush()
|
||||
}
|
||||
|
||||
identify(userId: string, properties?: Record<string, any>): void {
|
||||
if (!this.posthog) return
|
||||
this.posthog.identify({
|
||||
distinctId: userId,
|
||||
properties,
|
||||
})
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
if (!this.posthog) return
|
||||
this.posthog.shutdown()
|
||||
}
|
||||
}
|
||||
|
||||
export const serverPostHog = new ServerPostHog()
|
||||
@@ -1,21 +0,0 @@
|
||||
export interface PostHogInstance {
|
||||
capture(event: string, properties?: Record<string, any>): void
|
||||
captureException(error: Error, properties?: Record<string, any>): void
|
||||
identify(userId: string, properties?: Record<string, any>): void
|
||||
reset(): void
|
||||
}
|
||||
|
||||
export interface ErrorContext {
|
||||
userId?: string
|
||||
url?: string
|
||||
userAgent?: string
|
||||
timestamp?: number
|
||||
additional?: Record<string, any>
|
||||
}
|
||||
|
||||
export interface ErrorReport {
|
||||
error: Error
|
||||
context?: ErrorContext
|
||||
severity?: 'low' | 'medium' | 'high' | 'critical'
|
||||
tags?: Record<string, string>
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useUser } from '~/features/user/useUser'
|
||||
import { clientPostHog } from './client'
|
||||
|
||||
export function usePostHogIdentify() {
|
||||
const { data } = useUser()
|
||||
const user = data?.user
|
||||
|
||||
useEffect(() => {
|
||||
if (user) {
|
||||
clientPostHog.identify(user.id, {
|
||||
email: user.email,
|
||||
})
|
||||
}
|
||||
}, [user?.id, user?.email])
|
||||
}
|
||||
@@ -16,7 +16,7 @@ import type {
|
||||
import { router } from 'one'
|
||||
import { useToastController } from '@hanzogui/toast'
|
||||
import { lazy, Suspense, useEffect, useMemo, useState } from 'react'
|
||||
import { processError } from '~/features/posthog/errorHandling'
|
||||
import { captureError } from '@hanzogui/telemetry'
|
||||
import useSWR, { mutate } from 'swr'
|
||||
import useSWRMutation from 'swr/mutation'
|
||||
import {
|
||||
@@ -749,10 +749,9 @@ const DiscordPanel = ({
|
||||
toast.show('Discord search failed', {
|
||||
message: searchSwr.error.message || 'Could not search Discord members',
|
||||
})
|
||||
processError({
|
||||
error: searchSwr.error,
|
||||
severity: 'medium',
|
||||
tags: { source: 'discord_member_search' },
|
||||
captureError(searchSwr.error, {
|
||||
handled: true,
|
||||
properties: { severity: 'medium', source: 'discord_member_search' },
|
||||
})
|
||||
}
|
||||
}, [searchSwr.error])
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { GuiBuildOptions } from 'hanzogui'
|
||||
|
||||
export default {
|
||||
components: ['gui'],
|
||||
components: ['hanzogui'],
|
||||
logTimings: true,
|
||||
config: '@hanzogui/dev-config',
|
||||
outputCSS: './gui.generated.css',
|
||||
|
||||
@@ -38,11 +38,12 @@
|
||||
"@rehookify/datepicker": "^6.6.8",
|
||||
"@sindresorhus/slugify": "^2.2.1",
|
||||
"@hanzo/base": "^0.2.0",
|
||||
"@hanzo/iam": "^0.9.4",
|
||||
"@hanzo/iam": "^0.13.1",
|
||||
"@hanzogui/assert": "workspace:*",
|
||||
"@hanzogui/demos": "workspace:*",
|
||||
"@hanzogui/get-token": "workspace:*",
|
||||
"@hanzogui/logo": "workspace:*",
|
||||
"@hanzogui/telemetry": "workspace:*",
|
||||
"@hanzogui/lucide-icons-2": "workspace:*",
|
||||
"@hanzogui/dev-config": "workspace:*",
|
||||
"@hanzogui/use-store": "workspace:*",
|
||||
@@ -64,8 +65,6 @@
|
||||
"masonic": "^4.0.1",
|
||||
"one": "1.12.5",
|
||||
"parse-numeric-range": "^1.3.0",
|
||||
"posthog-js": "^1.351.4",
|
||||
"posthog-node": "^5.24.17",
|
||||
"postmark": "^3.0.18",
|
||||
"puppeteer": "^24.3.1",
|
||||
"react-dropzone": "^14.2.3",
|
||||
|
||||
@@ -1,10 +1,149 @@
|
||||
import { mkdirSync } from 'node:fs'
|
||||
import { mkdirSync, readdirSync, statSync } from 'node:fs'
|
||||
import { existsSync, writeFileSync, readFileSync } from 'node:fs'
|
||||
import { resolve, dirname } from 'node:path'
|
||||
import { resolve, dirname, join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||
|
||||
const RECIPES_COMPONENT_PREFIX = '@hanzogui/recipes/component/'
|
||||
|
||||
/**
|
||||
* Recursively collect .ts/.tsx source files under a directory, skipping
|
||||
* node_modules and build output.
|
||||
* @param {string} dir
|
||||
* @param {string[]} acc
|
||||
* @returns {string[]}
|
||||
*/
|
||||
function collectSourceFiles(dir, acc = []) {
|
||||
let entries
|
||||
try {
|
||||
entries = readdirSync(dir)
|
||||
} catch {
|
||||
return acc
|
||||
}
|
||||
for (const name of entries) {
|
||||
if (name === 'node_modules' || name === 'dist' || name === 'recipes-output') continue
|
||||
const full = join(dir, name)
|
||||
let s
|
||||
try {
|
||||
s = statSync(full)
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
if (s.isDirectory()) {
|
||||
collectSourceFiles(full, acc)
|
||||
} else if (name.endsWith('.ts') || name.endsWith('.tsx')) {
|
||||
acc.push(full)
|
||||
}
|
||||
}
|
||||
return acc
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan the app for deep `@hanzogui/recipes/component/*` imports and the named
|
||||
* bindings each subpath must provide, so concrete stub modules can be generated
|
||||
* when the (optional, pro-only) recipes repo is absent.
|
||||
*
|
||||
* Three import shapes are detected:
|
||||
* 1. namespace -> import * as NS from '<subpath>' (then NS.Member usages)
|
||||
* 2. named -> import { A, B } from '<subpath>'
|
||||
* 3. re-export -> export { A } from '<subpath>'
|
||||
*
|
||||
* @param {string} appDir
|
||||
* @returns {Record<string, string[]>} subpath -> sorted unique export names
|
||||
*/
|
||||
function scanRecipesSubpaths(appDir) {
|
||||
const subpaths = {}
|
||||
for (const file of collectSourceFiles(appDir)) {
|
||||
let src
|
||||
try {
|
||||
src = readFileSync(file, 'utf-8')
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
if (!src.includes(RECIPES_COMPONENT_PREFIX)) continue
|
||||
|
||||
// namespace imports + member accesses
|
||||
for (const m of src.matchAll(/import\s+\*\s+as\s+(\w+)\s+from\s+['"]([^'"]+)['"]/g)) {
|
||||
const [, ns, sp] = m
|
||||
if (!sp.startsWith(RECIPES_COMPONENT_PREFIX)) continue
|
||||
subpaths[sp] ??= new Set()
|
||||
const memberRe = new RegExp('\\b' + ns + '\\.([A-Za-z_][A-Za-z0-9_]*)', 'g')
|
||||
for (const u of src.matchAll(memberRe)) subpaths[sp].add(u[1])
|
||||
}
|
||||
|
||||
// named imports and re-exports
|
||||
for (const m of src.matchAll(/(?:import|export)\s+\{([^}]+)\}\s+from\s+['"]([^'"]+)['"]/g)) {
|
||||
const [, names, sp] = m
|
||||
if (!sp.startsWith(RECIPES_COMPONENT_PREFIX)) continue
|
||||
subpaths[sp] ??= new Set()
|
||||
for (const raw of names.split(',')) {
|
||||
const name = raw.trim().split(/\s+as\s+/)[0].trim()
|
||||
if (name) subpaths[sp].add(name)
|
||||
}
|
||||
}
|
||||
}
|
||||
return Object.fromEntries(
|
||||
Object.entries(subpaths).map(([k, v]) => [k, [...v].sort()])
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate concrete stub modules for deep `@hanzogui/recipes/component/*`
|
||||
* subpaths used by the showcase tree, returning a map from subpath -> absolute
|
||||
* stub file path. Each stub exports a no-op component (carrying a `.fileName`
|
||||
* field so `Member.fileName` reads don't throw) as the default and as every
|
||||
* named binding the app imports — concrete on-disk named exports are required
|
||||
* because rolldown statically validates `export { X } from` / `import { X }`
|
||||
* and does not transform virtual `\0`-modules through commonjs interop.
|
||||
*
|
||||
* @param {Object} options
|
||||
* @param {string} options.appDir - app root (dir containing components/, vite.config.ts)
|
||||
* @param {string} options.outDir - directory to write stub modules into
|
||||
* @returns {Record<string, string>} subpath -> absolute stub path
|
||||
*/
|
||||
export function generateSubpathStubs({ appDir, outDir }) {
|
||||
const map = scanRecipesSubpaths(appDir)
|
||||
mkdirSync(outDir, { recursive: true })
|
||||
const result = {}
|
||||
for (const [subpath, names] of Object.entries(map)) {
|
||||
const flat = subpath.slice(RECIPES_COMPONENT_PREFIX.length).replace(/[^A-Za-z0-9]+/g, '_')
|
||||
const stubPath = resolve(outDir, `${flat}.tsx`)
|
||||
// Hook-shaped exports (useFoo) are called, not rendered, and their result is
|
||||
// often destructured (e.g. `const { sm } = useGroupMedia(...)`), so they must
|
||||
// return a (proxy) object rather than the null-rendering component stub.
|
||||
const namedExports = names
|
||||
.filter((n) => n !== 'default')
|
||||
.map((n) =>
|
||||
/^use[A-Z0-9]/.test(n)
|
||||
? `export const ${n} = recipesSubpathHook`
|
||||
: `export const ${n} = RecipesSubpathStub`
|
||||
)
|
||||
.join('\n')
|
||||
writeFileSync(
|
||||
stubPath,
|
||||
`// AUTO-GENERATED stub for ${subpath}
|
||||
// Recipes is optional (pro users only); the real components live in the
|
||||
// separate recipes repo. Regenerate via scripts/generate-recipes-proxy.mjs.
|
||||
function RecipesSubpathStub() {
|
||||
return null
|
||||
}
|
||||
RecipesSubpathStub.fileName = ''
|
||||
|
||||
// Hook stub: returns an object that yields undefined for any destructured key,
|
||||
// so callers like \`const { sm } = useGroupMedia(...)\` never throw.
|
||||
const recipesSubpathHook = () =>
|
||||
new Proxy({}, { get: () => undefined }) as any
|
||||
|
||||
export default RecipesSubpathStub
|
||||
${namedExports}
|
||||
`
|
||||
)
|
||||
result[subpath] = stubPath
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate recipes proxy files based on whether the recipes repo is available.
|
||||
* Can be called from vite config or run directly as a script.
|
||||
@@ -19,11 +158,23 @@ export function generateRecipesProxy(options = {}) {
|
||||
|
||||
const RECIPES_PATH = resolve(basePath, '../../../../recipes')
|
||||
const HELPERS_DIST_PATH = resolve(basePath, '../helpers/dist')
|
||||
const APP_DIR = resolve(basePath, '..')
|
||||
const hasRecipes = existsSync(RECIPES_PATH)
|
||||
|
||||
// Ensure dist exists
|
||||
mkdirSync(HELPERS_DIST_PATH, { recursive: true })
|
||||
|
||||
// When recipes is absent, generate concrete stub modules for every deep
|
||||
// `@hanzogui/recipes/component/*` subpath the app imports, so the showcase
|
||||
// tree (which is always in the build graph via RecipesComponentSection) can
|
||||
// resolve its named imports/re-exports.
|
||||
const subpathStubs = hasRecipes
|
||||
? {}
|
||||
: generateSubpathStubs({
|
||||
appDir: APP_DIR,
|
||||
outDir: resolve(HELPERS_DIST_PATH, 'recipes-subpath-stubs'),
|
||||
})
|
||||
|
||||
const proxyPath = resolve(HELPERS_DIST_PATH, 'recipes-proxy.ts')
|
||||
const existingContent = existsSync(proxyPath) ? readFileSync(proxyPath, 'utf-8') : ''
|
||||
const isStub = existingContent.includes('Stub file for when recipes is not available')
|
||||
@@ -31,7 +182,7 @@ export function generateRecipesProxy(options = {}) {
|
||||
|
||||
// Skip if already generated with correct state and has all exports
|
||||
if (existingContent && isStub === !hasRecipes && hasCurrentRouteProvider) {
|
||||
return { hasRecipes }
|
||||
return { hasRecipes, subpathStubs }
|
||||
}
|
||||
|
||||
if (!hasRecipes) {
|
||||
@@ -87,7 +238,7 @@ export * as Sections from '../../components/recipes-showcase/sections'
|
||||
}
|
||||
}
|
||||
|
||||
return { hasRecipes }
|
||||
return { hasRecipes, subpathStubs }
|
||||
}
|
||||
|
||||
// Run if called directly (postinstall script)
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"rootDir": ".",
|
||||
"paths": {
|
||||
"~/*": ["./*"]
|
||||
@@ -11,7 +10,6 @@
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"declaration": true,
|
||||
"downlevelIteration": true,
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"inlineSourceMap": true,
|
||||
|
||||
@@ -37,8 +37,10 @@ if (!existsSync(vitePluginDist) || !existsSync(staticDist)) {
|
||||
}
|
||||
}
|
||||
|
||||
// Generate recipes proxy files (creates stubs if recipes repo not found)
|
||||
const { hasRecipes } = generateRecipesProxy({
|
||||
// Generate recipes proxy files (creates stubs if recipes repo not found).
|
||||
// When recipes is absent, subpathStubs maps each deep
|
||||
// `@hanzogui/recipes/component/*` import to a concrete generated stub module.
|
||||
const { hasRecipes, subpathStubs } = generateRecipesProxy({
|
||||
basePath: pathResolve(import.meta.dirname, 'scripts'),
|
||||
silent: false,
|
||||
})
|
||||
@@ -61,7 +63,7 @@ const include = [
|
||||
'swr/mutation',
|
||||
'mdx-bundler/client',
|
||||
// core hanzo-gui packages must be pre-bundled together to avoid duplicate instances
|
||||
'gui',
|
||||
'hanzogui',
|
||||
'@hanzogui/core',
|
||||
'@hanzogui/web',
|
||||
// existing
|
||||
@@ -119,17 +121,19 @@ export default {
|
||||
resolve: {
|
||||
preserveSymlinks: false,
|
||||
alias: [
|
||||
// Regex-based alias for recipes components when not available
|
||||
// When recipes is absent, map each deep @hanzogui/recipes/component/*
|
||||
// import to its concrete generated stub. These MUST precede the catch-all
|
||||
// '@hanzogui/recipes' alias below, otherwise that alias swallows the
|
||||
// subpath (resolving to recipes-proxy/component/...) and the build fails
|
||||
// with UNLOADABLE_DEPENDENCY. Stubs carry the exact named exports the
|
||||
// showcase tree imports/re-exports, which rolldown validates statically.
|
||||
...(!hasRecipes
|
||||
? [
|
||||
{
|
||||
find: /^@gui\/recipes\/component\/.+$/,
|
||||
replacement: pathResolve(
|
||||
import.meta.dirname,
|
||||
'./helpers/dist/recipes-component-stub.tsx'
|
||||
),
|
||||
},
|
||||
]
|
||||
? Object.entries(subpathStubs)
|
||||
// longest subpath first: vite matches `id === find || id.startsWith(find + '/')`,
|
||||
// so a parent like `.../user/preferences` must not shadow its child
|
||||
// `.../user/preferences/LocationNotification`.
|
||||
.sort(([a], [b]) => b.length - a.length)
|
||||
.map(([find, replacement]) => ({ find, replacement }))
|
||||
: []),
|
||||
// Standard string-based aliases
|
||||
{
|
||||
@@ -204,43 +208,6 @@ export default {
|
||||
},
|
||||
|
||||
plugins: [
|
||||
// Plugin to stub recipes component imports when recipes repo is not available
|
||||
!hasRecipes && {
|
||||
name: 'stub-recipes-components',
|
||||
enforce: 'pre', // Run before other plugins including alias resolution
|
||||
resolveId(id: string) {
|
||||
// Intercept imports from @hanzogui/recipes/component/*
|
||||
if (id.startsWith('@hanzogui/recipes/component/')) {
|
||||
// Return a virtual module ID
|
||||
return '\0recipes-component-stub:' + id
|
||||
}
|
||||
},
|
||||
load(id: string) {
|
||||
// Handle the virtual module
|
||||
if (id.startsWith('\0recipes-component-stub:')) {
|
||||
// Return stub component code
|
||||
return `
|
||||
import { YStack, Paragraph } from 'hanzogui'
|
||||
|
||||
export default function RecipesComponentStub() {
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
return null
|
||||
}
|
||||
return (
|
||||
<YStack p="$4" bc="$borderColor" br="$4">
|
||||
<Paragraph size="$2" color="$color10">
|
||||
Recipes component not available
|
||||
</Paragraph>
|
||||
</YStack>
|
||||
)
|
||||
}
|
||||
|
||||
// Export as default and named for compatibility
|
||||
export const LocationNotification = RecipesComponentStub
|
||||
`
|
||||
}
|
||||
},
|
||||
},
|
||||
guiPlugin({
|
||||
// see gui.build.ts
|
||||
disable: process.env.NODE_ENV !== 'production',
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
# Visual-Regression Gate (HIP-0504 §rule 6)
|
||||
|
||||
The design-system unification re-bases `@hanzo/ui` onto `@hanzo/gui` and turns
|
||||
`@luxfi/ui` / `@zooai/ui` into theme overlays. Per HIP-0504, **the rendered
|
||||
output must not change** — only the implementation underneath it. This gate
|
||||
enforces that with pixel-diffed screenshots.
|
||||
|
||||
## How it works
|
||||
|
||||
`visual-regression.test.ts` renders the kitchen-sink component gallery (the
|
||||
substrate showcase) and snapshots each surface, comparing against committed
|
||||
baselines in `tests/__screenshots__/`.
|
||||
|
||||
## The contract
|
||||
|
||||
1. **Capture the baseline on the pre-migration ref** (current `@hanzo/ui` / gui):
|
||||
|
||||
```bash
|
||||
cd apps/kitchen-sink
|
||||
PORT=9000 pnpm exec playwright test visual-regression --update-snapshots
|
||||
git add tests/__screenshots__ && git commit -m "test(visual): baseline before design-system migration"
|
||||
```
|
||||
|
||||
2. **Every migration PR runs the suite unchanged.** Drift beyond
|
||||
`maxDiffPixelRatio` (1%) fails CI. A passing run is the proof that Phase 0/1/2
|
||||
decomplected the code without moving a pixel the user sees.
|
||||
|
||||
## Per-phase application
|
||||
|
||||
- **Phase 0** (`@hanzo/ui` on `@hanzo/gui`): baseline captured on 5.x, asserted
|
||||
after the re-base. The public component surface must match.
|
||||
- **Phase 1** (brand overlays): capture a per-brand baseline (lux/zoo theme) and
|
||||
assert the overlay reproduces it — brand look/feel is also a contract.
|
||||
- **Phase 2** (app adoption): each site's own Playwright suite snapshots its key
|
||||
pages before/after switching to the unified `ui`.
|
||||
|
||||
## Notes
|
||||
|
||||
- Non-animated by design (`animations: 'disabled'`) so baselines are
|
||||
deterministic; the suite runs once with the default driver, not across the
|
||||
animation-driver matrix.
|
||||
- `SURFACES` lists the gallery anchors to snapshot — add anchors as demos grow;
|
||||
`gallery-full` always captures the whole showcase as a backstop.
|
||||
- Baselines are platform-sensitive (font rendering). Capture and assert on the
|
||||
same CI runner image; the canonical capture runs on the `hanzo-build-linux-amd64`
|
||||
pool.
|
||||
@@ -0,0 +1,49 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
|
||||
/**
|
||||
* Visual-regression gate — HIP-0504 §rule 6.
|
||||
*
|
||||
* Decomplecting the design system (re-basing @hanzo/ui onto @hanzo/gui, then
|
||||
* the brand overlays) MUST NOT change the user-visible pixels. This suite
|
||||
* renders the component gallery and asserts each surface matches a committed
|
||||
* baseline within tolerance.
|
||||
*
|
||||
* Workflow:
|
||||
* 1. On the PRE-migration ref, capture the baseline:
|
||||
* pnpm exec playwright test visual-regression --update-snapshots
|
||||
* Commit the generated __screenshots__/ as the contract.
|
||||
* 2. On every migration PR, run the suite unchanged. Any pixel drift beyond
|
||||
* tolerance fails the gate — the implementation changed, the output did not.
|
||||
*
|
||||
* Non-animated (default project) so snapshots are deterministic; animations are
|
||||
* frozen at capture time. Run once with the default driver, not across drivers.
|
||||
*/
|
||||
|
||||
// Stable, brand-agnostic surfaces of the gallery. Extend as demos gain anchors.
|
||||
const SURFACES = [
|
||||
{ name: 'gallery-full', path: '/' },
|
||||
{ name: 'buttons', path: '/#buttons' },
|
||||
{ name: 'inputs', path: '/#inputs' },
|
||||
{ name: 'cards', path: '/#cards' },
|
||||
{ name: 'dialogs', path: '/#dialogs' },
|
||||
{ name: 'sheets', path: '/#sheets' },
|
||||
{ name: 'typography', path: '/#typography' },
|
||||
]
|
||||
|
||||
// Tolerance: tiny sub-pixel AA differences across runs are allowed; structural
|
||||
// or color drift is not. Keep this tight — the contract is "same look/feel".
|
||||
const SNAPSHOT_OPTS = {
|
||||
animations: 'disabled' as const,
|
||||
maxDiffPixelRatio: 0.01,
|
||||
fullPage: true,
|
||||
}
|
||||
|
||||
for (const surface of SURFACES) {
|
||||
test(`look/feel stable — ${surface.name}`, async ({ page }) => {
|
||||
await page.goto(surface.path)
|
||||
// Let fonts + layout settle so the baseline is deterministic.
|
||||
await page.waitForLoadState('networkidle')
|
||||
await page.evaluate(() => document.fonts.ready)
|
||||
await expect(page).toHaveScreenshot(`${surface.name}.png`, SNAPSHOT_OPTS)
|
||||
})
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"target": "esnext",
|
||||
|
||||
@@ -3,7 +3,7 @@ import React from 'react'
|
||||
|
||||
import type { FontSizeTokens, SelectProps } from 'hanzogui'
|
||||
import { Adapt, Label, Select, Sheet, XStack, YStack, getFontSize } from 'hanzogui'
|
||||
import { LinearGradient } from 'gui/linear-gradient'
|
||||
import { LinearGradient } from 'hanzogui/linear-gradient'
|
||||
|
||||
export function SelectDemo() {
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
# Hanzo Team
|
||||
|
||||
Native Hanzo Team skeleton on hanzogui — one codebase for web, mobile, and desktop.
|
||||
|
||||
Three screens share components with web: Shell (AppHeader: mark, org row,
|
||||
five-surface switcher), Login (hanzo.id OIDC via system browser + deep link),
|
||||
Wallet (balance and usage, monochrome tokens).
|
||||
|
||||
```bash
|
||||
bun run dev # web dev server
|
||||
bun run build:web # web production build → dist/client
|
||||
bun run typecheck # tsc
|
||||
bun run ios # native (one / expo)
|
||||
```
|
||||
|
||||
Desktop wraps `dist/client` with Tauri: build web, then `cargo tauri dev` (or
|
||||
`cargo check`) inside `src-tauri/`.
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"expo": {
|
||||
"name": "Hanzo Team",
|
||||
"slug": "team",
|
||||
"scheme": "hanzo-team",
|
||||
"newArchEnabled": true,
|
||||
"platforms": ["ios", "android"],
|
||||
"plugins": ["vxrn/expo-plugin"],
|
||||
"ios": {
|
||||
"bundleIdentifier": "ai.hanzo.team"
|
||||
},
|
||||
"android": {
|
||||
"package": "ai.hanzo.team"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
body {
|
||||
margin: 0;
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import './_layout.css'
|
||||
|
||||
import { SchemeProvider, useUserScheme } from '@vxrn/color-scheme'
|
||||
import { Slot } from 'one'
|
||||
import { GuiProvider } from 'hanzogui'
|
||||
import { Shell } from '~/components/Shell'
|
||||
import { SessionProvider } from '~/src/session'
|
||||
import config from '~/src/gui.config'
|
||||
|
||||
export default function Layout() {
|
||||
return (
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charSet="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="icon" href="/favicon.svg" />
|
||||
<title>Hanzo Team</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<SchemeProvider>
|
||||
<Providers>
|
||||
<SessionProvider>
|
||||
<Shell>
|
||||
<Slot />
|
||||
</Shell>
|
||||
</SessionProvider>
|
||||
</Providers>
|
||||
</SchemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
|
||||
const Providers = ({ children }: { children: React.ReactNode }) => {
|
||||
const userScheme = useUserScheme()
|
||||
return (
|
||||
<GuiProvider config={config} defaultTheme={userScheme.value}>
|
||||
{children}
|
||||
</GuiProvider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { ActivityIndicator } from 'react-native'
|
||||
import { useLocalSearchParams } from 'one'
|
||||
import { SizableText, YStack } from 'hanzogui'
|
||||
import { Mark } from '~/components/Mark'
|
||||
import { completeWebCallback } from '~/src/auth'
|
||||
import { persistSession } from '~/src/session'
|
||||
|
||||
// Web-only: hanzo.id redirects here with ?code&state. Complete the PKCE exchange,
|
||||
// persist the session, then hard-navigate home so the session provider re-reads it.
|
||||
// (Native completes the flow in-process via expo-web-browser; this route is unused there.)
|
||||
export default function Callback() {
|
||||
const params = useLocalSearchParams<{ code?: string; state?: string; error?: string }>()
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
let live = true
|
||||
void (async () => {
|
||||
try {
|
||||
const session = await completeWebCallback({
|
||||
code: params.code,
|
||||
state: params.state,
|
||||
error: params.error,
|
||||
})
|
||||
await persistSession(session)
|
||||
globalThis.location?.assign('/')
|
||||
} catch (e) {
|
||||
if (live) setError(e instanceof Error ? e.message : String(e))
|
||||
}
|
||||
})()
|
||||
return () => {
|
||||
live = false
|
||||
}
|
||||
}, [params.code, params.state, params.error])
|
||||
|
||||
return (
|
||||
<YStack flex={1} items="center" justify="center" p="$4" gap="$4">
|
||||
<Mark size={44} />
|
||||
{error == null ? (
|
||||
<>
|
||||
<ActivityIndicator />
|
||||
<SizableText size="$3" color="$color10">
|
||||
Completing sign-in…
|
||||
</SizableText>
|
||||
</>
|
||||
) : (
|
||||
<YStack items="center" gap="$2">
|
||||
<SizableText size="$4" fontWeight="600">
|
||||
Sign-in failed
|
||||
</SizableText>
|
||||
<SizableText size="$2" color="$color10">
|
||||
{error}
|
||||
</SizableText>
|
||||
</YStack>
|
||||
)}
|
||||
</YStack>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { Link } from 'one'
|
||||
import { SizableText, XStack, YStack } from 'hanzogui'
|
||||
import { useSession } from '~/src/session'
|
||||
|
||||
export default function Home() {
|
||||
const { session, loading } = useSession()
|
||||
const signedIn = !loading && session != null
|
||||
const who = session?.user?.email ?? session?.user?.name
|
||||
|
||||
const views = signedIn
|
||||
? [{ href: '/wallet', title: 'Wallet', caption: 'Balance and AI usage' } as const]
|
||||
: [
|
||||
{ href: '/login', title: 'Sign in', caption: 'hanzo.id single sign-on' } as const,
|
||||
{ href: '/wallet', title: 'Wallet', caption: 'Balance and AI usage' } as const,
|
||||
]
|
||||
|
||||
return (
|
||||
<YStack p="$4" gap="$4" maxW={560} width="100%" self="center">
|
||||
<YStack gap="$1">
|
||||
<SizableText size="$7" fontWeight="600">
|
||||
Team
|
||||
</SizableText>
|
||||
<SizableText size="$3" color="$color10">
|
||||
{signedIn && who != null
|
||||
? `Signed in as ${who}.`
|
||||
: 'Chat, projects, and planning for your org — one native app for mobile and desktop.'}
|
||||
</SizableText>
|
||||
</YStack>
|
||||
|
||||
{views.map((view) => (
|
||||
<Link key={view.href} href={view.href} asChild>
|
||||
<XStack
|
||||
bg="$color1"
|
||||
borderWidth={1}
|
||||
borderColor="$borderColor"
|
||||
rounded="$6"
|
||||
p="$4"
|
||||
items="center"
|
||||
justify="space-between"
|
||||
cursor="pointer"
|
||||
pressStyle={{ opacity: 0.7 }}
|
||||
>
|
||||
<YStack gap="$1">
|
||||
<SizableText size="$4" fontWeight="600">
|
||||
{view.title}
|
||||
</SizableText>
|
||||
<SizableText size="$2" color="$color10">
|
||||
{view.caption}
|
||||
</SizableText>
|
||||
</YStack>
|
||||
<SizableText size="$4" color="$color10">
|
||||
→
|
||||
</SizableText>
|
||||
</XStack>
|
||||
</Link>
|
||||
))}
|
||||
</YStack>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { Redirect } from 'one'
|
||||
import { Button, SizableText, YStack } from 'hanzogui'
|
||||
import { Mark } from '~/components/Mark'
|
||||
import { useSession } from '~/src/session'
|
||||
|
||||
// Real hanzo.id OIDC (Authorization Code + PKCE) via the system browser (native) or a
|
||||
// full-page redirect (web); the session returns through the app deep link / callback
|
||||
// route. No in-app credential entry, no fake auth. See ~/src/auth.ts.
|
||||
export default function Login() {
|
||||
const { session, loading, signingIn, signIn } = useSession()
|
||||
|
||||
if (!loading && session != null) return <Redirect href="/" />
|
||||
|
||||
return (
|
||||
<YStack flex={1} items="center" justify="center" p="$4" gap="$4">
|
||||
<Mark size={44} />
|
||||
|
||||
<YStack items="center" gap="$1">
|
||||
<SizableText size="$6" fontWeight="600">
|
||||
Sign in to Hanzo
|
||||
</SizableText>
|
||||
<SizableText size="$2" color="$color10">
|
||||
One account for every surface
|
||||
</SizableText>
|
||||
</YStack>
|
||||
|
||||
<Button
|
||||
size="$4"
|
||||
bg="$color"
|
||||
borderWidth={0}
|
||||
disabled={signingIn}
|
||||
opacity={signingIn ? 0.6 : 1}
|
||||
pressStyle={{ opacity: 0.8, bg: '$color' }}
|
||||
onPress={() => void signIn()}
|
||||
>
|
||||
<Button.Text color="$background" fontWeight="600">
|
||||
{signingIn ? 'Opening…' : 'Continue with hanzo.id'}
|
||||
</Button.Text>
|
||||
</Button>
|
||||
|
||||
<SizableText size="$1" color="$color10">
|
||||
Opens hanzo.id · returns via hanzo-team://callback
|
||||
</SizableText>
|
||||
</YStack>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Link } from 'one'
|
||||
import { Separator, SizableText, XStack, YStack } from 'hanzogui'
|
||||
import { useSession } from '~/src/session'
|
||||
import { fetchWallet, type Wallet } from '~/src/billing'
|
||||
|
||||
// Real balance + usage from billing.hanzo.ai, read with the session's bearer token.
|
||||
// No hardcoded money: signed-out shows a sign-in prompt; a failed fetch shows an
|
||||
// honest unavailable state rather than a fake $0.00.
|
||||
export default function WalletScreen() {
|
||||
const { session, loading } = useSession()
|
||||
const [wallet, setWallet] = useState<Wallet | null>(null)
|
||||
const [state, setState] = useState<'idle' | 'loading' | 'error'>('idle')
|
||||
|
||||
useEffect(() => {
|
||||
const token = session?.accessToken
|
||||
if (token == null) return
|
||||
let live = true
|
||||
setState('loading')
|
||||
void fetchWallet(token)
|
||||
.then((w) => {
|
||||
if (live) {
|
||||
setWallet(w)
|
||||
setState('idle')
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (live) setState('error')
|
||||
})
|
||||
return () => {
|
||||
live = false
|
||||
}
|
||||
}, [session?.accessToken])
|
||||
|
||||
if (!loading && session == null) {
|
||||
return (
|
||||
<YStack p="$4" gap="$3" maxW={560} width="100%" self="center">
|
||||
<SizableText size="$6" fontWeight="600">
|
||||
Wallet
|
||||
</SizableText>
|
||||
<SizableText size="$3" color="$color10">
|
||||
Sign in to see your balance and AI usage.
|
||||
</SizableText>
|
||||
<Link href="/login" asChild>
|
||||
<XStack
|
||||
bg="$color"
|
||||
rounded="$10"
|
||||
px="$4"
|
||||
py="$2.5"
|
||||
self="flex-start"
|
||||
cursor="pointer"
|
||||
pressStyle={{ opacity: 0.8 }}
|
||||
>
|
||||
<SizableText size="$3" fontWeight="600" color="$background">
|
||||
Sign in
|
||||
</SizableText>
|
||||
</XStack>
|
||||
</Link>
|
||||
</YStack>
|
||||
)
|
||||
}
|
||||
|
||||
const balance = wallet != null ? `$${wallet.balanceUsd.toFixed(2)}` : state === 'error' ? '—' : '…'
|
||||
|
||||
return (
|
||||
<YStack p="$4" gap="$4" maxW={560} width="100%" self="center">
|
||||
<YStack bg="$color1" borderWidth={1} borderColor="$borderColor" rounded="$6" p="$4" gap="$1">
|
||||
<SizableText size="$2" color="$color10">
|
||||
Balance
|
||||
</SizableText>
|
||||
<SizableText size="$9" fontWeight="600">
|
||||
{balance}
|
||||
</SizableText>
|
||||
<SizableText size="$1" color="$color10">
|
||||
{state === 'error'
|
||||
? 'Balance unavailable right now'
|
||||
: 'Usage-metered · billed via billing.hanzo.ai'}
|
||||
</SizableText>
|
||||
</YStack>
|
||||
|
||||
{wallet != null && wallet.usage.length > 0 ? (
|
||||
<YStack bg="$color1" borderWidth={1} borderColor="$borderColor" rounded="$6">
|
||||
{wallet.usage.map((row, index) => (
|
||||
<YStack key={row.label}>
|
||||
{index > 0 ? <Separator borderColor="$borderColor" /> : null}
|
||||
<XStack p="$3.5" items="center" justify="space-between">
|
||||
<SizableText size="$3" color="$color10">
|
||||
{row.label}
|
||||
</SizableText>
|
||||
<SizableText size="$3" fontWeight="600">
|
||||
{row.value}
|
||||
</SizableText>
|
||||
</XStack>
|
||||
</YStack>
|
||||
))}
|
||||
</YStack>
|
||||
) : null}
|
||||
</YStack>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { useTheme } from 'hanzogui'
|
||||
import Svg, { Path } from 'react-native-svg'
|
||||
|
||||
const d =
|
||||
'M45.9269 46.3536C45.65 45.3274 44.9038 44.8299 43.8628 44.9314C41.8474 45.1268 41.1782 44.4808 40.2218 43.0613C42.7423 43.5952 44.3679 42.9389 44.8756 42.6471C45.5295 42.2694 46.191 41.785 45.9295 40.9306C45.6731 40.0945 44.9038 39.9278 44.1141 40.0789C42.9936 40.2925 41.9192 40.4175 41.0474 39.3261C41.232 39.1594 41.4166 38.9875 41.6064 38.8234C42.0705 38.4249 42.4295 37.9561 42.1936 37.3231C41.9679 36.7188 41.3961 36.3724 40.8141 36.4792C40.3397 36.5678 39.3884 36.8647 39.0987 36.3802L39.1115 36.375C39.0013 36.349 38.832 35.8957 38.7961 35.5649C38.6269 33.9474 39.3269 32.395 40.2705 31.1057C41.4474 29.5038 42.2551 27.7378 42.6141 25.7791C43.1756 22.7055 42.5807 19.8456 40.9192 17.2174C38.9474 14.097 36.1679 12.0419 32.509 11.2865C31.65 11.1094 30.8115 11.0156 29.9987 11C29.1833 11.0156 28.3474 11.1094 27.4884 11.2865C23.832 12.0419 21.05 14.097 19.0782 17.2174C17.4192 19.8456 16.8243 22.7055 17.3833 25.7791C17.7397 27.7404 18.55 29.5038 19.7269 31.1057C20.6731 32.395 21.3731 33.9448 21.2013 35.5649C21.1654 35.8957 20.9961 36.349 20.8859 36.375L20.8987 36.3802C20.609 36.8621 19.6602 36.5678 19.1833 36.4792C18.6038 36.3724 18.0295 36.7188 17.8038 37.3231C17.5679 37.9561 17.9243 38.4249 18.391 38.8234C18.5807 38.9875 18.7654 39.1594 18.95 39.3261C18.0782 40.4175 17.0013 40.2925 15.8833 40.0789C15.0936 39.9278 14.3243 40.0945 14.0679 40.9306C13.8064 41.785 14.4679 42.2694 15.1218 42.6471C15.6269 42.9389 17.2551 43.5926 19.7756 43.0613C18.8192 44.4808 18.15 45.1268 16.1346 44.9314C15.0936 44.8299 14.3448 45.3274 14.0705 46.3536C13.8013 47.3564 14.332 48.0753 15.1731 48.5546C15.7243 48.8672 16.3397 48.9896 16.991 49C20.5577 49.0495 23.5013 47.5778 26.1525 45.3638C27.6115 44.1448 28.8115 43.5275 30.0013 43.5119C31.191 43.5275 32.3884 44.1448 33.85 45.3638C36.5013 47.5778 39.4448 49.0495 43.0115 49C43.6628 48.9922 44.2782 48.8672 44.8295 48.5546C45.6731 48.0753 46.2013 47.3564 45.932 46.3536H45.9269ZM25.0756 34.2027C23.9731 34.2001 23.0628 33.278 23.0602 32.1658C23.0602 31.0328 23.9807 30.1055 25.1166 30.0951C26.1961 30.0847 27.1577 31.1083 27.1474 32.257C27.1372 33.3952 26.2705 34.2079 25.0756 34.2027ZM34.9192 34.2027C33.7218 34.2079 32.8577 33.3926 32.8474 32.257C32.8372 31.1083 33.8013 30.0847 34.8782 30.0951C36.0141 30.1081 36.9346 31.0354 36.9346 32.1658C36.9346 33.278 36.0218 34.2001 34.9192 34.2027Z'
|
||||
|
||||
export const Mark = ({ size = 24 }: { size?: number }) => {
|
||||
const theme = useTheme()
|
||||
return (
|
||||
<Svg width={size} height={size} viewBox="10 8 40 44" fill="none">
|
||||
<Path d={d} fill={theme.color.get()} />
|
||||
</Svg>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { Moon, Sun, SunMoon } from '@hanzogui/lucide-icons-2'
|
||||
import { useSystemScheme, useUserScheme } from '@vxrn/color-scheme'
|
||||
import { Appearance } from 'react-native'
|
||||
import { isWeb, View } from 'hanzogui'
|
||||
|
||||
const order = ['system', 'light', 'dark'] as const
|
||||
|
||||
export const SchemeToggle = () => {
|
||||
const userScheme = useUserScheme()
|
||||
const systemScheme = useSystemScheme()
|
||||
const Icon =
|
||||
userScheme.setting === 'system' ? SunMoon : userScheme.setting === 'dark' ? Moon : Sun
|
||||
|
||||
return (
|
||||
<View
|
||||
p="$2"
|
||||
cursor="pointer"
|
||||
pressStyle={{ opacity: 0.6 }}
|
||||
onPress={() => {
|
||||
const next = order[(order.indexOf(userScheme.setting) + 1) % 3]
|
||||
if (!isWeb) {
|
||||
Appearance.setColorScheme(next === 'system' ? systemScheme : next)
|
||||
}
|
||||
userScheme.set(next)
|
||||
}}
|
||||
>
|
||||
<Icon size={18} color="$color10" />
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import { useState } from 'react'
|
||||
import { Linking } from 'react-native'
|
||||
import { useRouter } from 'one'
|
||||
import { ChevronDown } from '@hanzogui/lucide-icons-2'
|
||||
import { ScrollView, SizableText, XStack, YStack } from 'hanzogui'
|
||||
import { Mark } from './Mark'
|
||||
import { SchemeToggle } from './Scheme'
|
||||
import { SURFACES } from '~/src/surfaces'
|
||||
import { useSession } from '~/src/session'
|
||||
|
||||
// app frame: AppHeader (mark · org switcher · seven-surface switcher · auth) over content
|
||||
export const Shell = ({ children }: { children: React.ReactNode }) => {
|
||||
const router = useRouter()
|
||||
const { session, signOut } = useSession()
|
||||
const [orgOpen, setOrgOpen] = useState(false)
|
||||
const [activeOrg, setActiveOrg] = useState<string | undefined>(undefined)
|
||||
|
||||
const orgs = session?.user?.orgs ?? []
|
||||
const currentOrg = activeOrg ?? orgs[0] ?? 'Hanzo'
|
||||
const signedIn = session != null
|
||||
|
||||
return (
|
||||
<YStack flex={1} minH="100%" bg="$background">
|
||||
<YStack borderBottomWidth={1} borderColor="$borderColor" px="$4" pt="$3" pb="$2" gap="$2">
|
||||
<XStack items="center" gap="$3">
|
||||
<Mark size={22} />
|
||||
|
||||
<YStack position="relative">
|
||||
<XStack
|
||||
items="center"
|
||||
gap="$2"
|
||||
rounded="$4"
|
||||
px="$2"
|
||||
py="$1"
|
||||
cursor="pointer"
|
||||
pressStyle={{ bg: '$color1' }}
|
||||
onPress={() => setOrgOpen((v) => (orgs.length > 1 ? !v : v))}
|
||||
>
|
||||
<YStack width={20} height={20} rounded={999} bg="$color" items="center" justify="center">
|
||||
<SizableText size="$1" fontWeight="700" color="$background">
|
||||
{currentOrg.charAt(0).toUpperCase()}
|
||||
</SizableText>
|
||||
</YStack>
|
||||
<SizableText size="$3" fontWeight="600">
|
||||
{currentOrg}
|
||||
</SizableText>
|
||||
{orgs.length > 1 ? <ChevronDown size={14} color="$color10" /> : null}
|
||||
</XStack>
|
||||
|
||||
{orgOpen && orgs.length > 1 ? (
|
||||
<YStack
|
||||
position="absolute"
|
||||
t={38}
|
||||
l={0}
|
||||
minW={180}
|
||||
bg="$background"
|
||||
borderWidth={1}
|
||||
borderColor="$borderColor"
|
||||
rounded="$4"
|
||||
py="$1"
|
||||
z={100}
|
||||
>
|
||||
{orgs.map((org) => (
|
||||
<XStack
|
||||
key={org}
|
||||
px="$3"
|
||||
py="$2"
|
||||
cursor="pointer"
|
||||
pressStyle={{ bg: '$color1' }}
|
||||
onPress={() => {
|
||||
setActiveOrg(org)
|
||||
setOrgOpen(false)
|
||||
}}
|
||||
>
|
||||
<SizableText size="$3" color={org === currentOrg ? '$color' : '$color10'}>
|
||||
{org}
|
||||
</SizableText>
|
||||
</XStack>
|
||||
))}
|
||||
</YStack>
|
||||
) : null}
|
||||
</YStack>
|
||||
|
||||
<XStack flex={1} />
|
||||
|
||||
{signedIn ? (
|
||||
<XStack
|
||||
rounded="$10"
|
||||
px="$3"
|
||||
py="$1.5"
|
||||
borderWidth={1}
|
||||
borderColor="$borderColor"
|
||||
cursor="pointer"
|
||||
pressStyle={{ opacity: 0.6 }}
|
||||
onPress={() => void signOut()}
|
||||
>
|
||||
<SizableText size="$2" color="$color10">
|
||||
Sign out
|
||||
</SizableText>
|
||||
</XStack>
|
||||
) : (
|
||||
<XStack
|
||||
rounded="$10"
|
||||
px="$3"
|
||||
py="$1.5"
|
||||
bg="$color"
|
||||
cursor="pointer"
|
||||
pressStyle={{ opacity: 0.8 }}
|
||||
onPress={() => router.push('/login')}
|
||||
>
|
||||
<SizableText size="$2" color="$background" fontWeight="600">
|
||||
Sign in
|
||||
</SizableText>
|
||||
</XStack>
|
||||
)}
|
||||
|
||||
<SchemeToggle />
|
||||
</XStack>
|
||||
|
||||
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
|
||||
<XStack gap="$2">
|
||||
{SURFACES.map((surface) => {
|
||||
const active = surface.id === 'team'
|
||||
return (
|
||||
<XStack
|
||||
key={surface.id}
|
||||
rounded="$10"
|
||||
px="$3"
|
||||
py="$1.5"
|
||||
borderWidth={1}
|
||||
borderColor={active ? '$borderColor' : 'transparent'}
|
||||
bg={active ? '$color1' : 'transparent'}
|
||||
cursor="pointer"
|
||||
pressStyle={{ opacity: 0.6 }}
|
||||
onPress={() => {
|
||||
if (!active) void Linking.openURL(surface.href)
|
||||
}}
|
||||
>
|
||||
<SizableText size="$2" color={active ? '$color' : '$color10'}>
|
||||
{surface.label}
|
||||
</SizableText>
|
||||
</XStack>
|
||||
)
|
||||
})}
|
||||
</XStack>
|
||||
</ScrollView>
|
||||
</YStack>
|
||||
|
||||
{children}
|
||||
</YStack>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { GuiBuildOptions } from '@hanzogui/core'
|
||||
|
||||
export default {
|
||||
components: ['hanzogui'],
|
||||
config: './src/gui.config.ts',
|
||||
disableExtraction: true,
|
||||
} satisfies GuiBuildOptions
|
||||
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"name": "@hanzogui/team",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "one dev",
|
||||
"build:web": "one build",
|
||||
"serve": "one serve",
|
||||
"ios": "one run:ios",
|
||||
"android": "one run:android",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hanzogui/config": "workspace:*",
|
||||
"@hanzogui/core": "workspace:*",
|
||||
"@hanzogui/lucide-icons-2": "workspace:*",
|
||||
"@vxrn/color-scheme": "^1.12.5",
|
||||
"expo": "~55.0.6",
|
||||
"one": "1.12.5",
|
||||
"react": ">=19",
|
||||
"react-native": "0.83.2",
|
||||
"react-native-safe-area-context": "~5.7.0",
|
||||
"react-native-screens": "~4.23.0",
|
||||
"react-native-svg": "15.15.3",
|
||||
"react-native-web": "^0.21.0",
|
||||
"hanzogui": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@hanzogui/vite-plugin": "workspace:*",
|
||||
"@types/react": "~19.1.10",
|
||||
"vite": "^8.0.3"
|
||||
},
|
||||
"license": "BSD-3-Clause",
|
||||
"author": "Hanzo AI <dev@hanzo.ai>"
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
<svg width="60" height="60" viewBox="0 0 60 60" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="60" height="60" rx="14" fill="#000000"/>
|
||||
<path d="M45.9269 46.3536C45.65 45.3274 44.9038 44.8299 43.8628 44.9314C41.8474 45.1268 41.1782 44.4808 40.2218 43.0613C42.7423 43.5952 44.3679 42.9389 44.8756 42.6471C45.5295 42.2694 46.191 41.785 45.9295 40.9306C45.6731 40.0945 44.9038 39.9278 44.1141 40.0789C42.9936 40.2925 41.9192 40.4175 41.0474 39.3261C41.232 39.1594 41.4166 38.9875 41.6064 38.8234C42.0705 38.4249 42.4295 37.9561 42.1936 37.3231C41.9679 36.7188 41.3961 36.3724 40.8141 36.4792C40.3397 36.5678 39.3884 36.8647 39.0987 36.3802L39.1115 36.375C39.0013 36.349 38.832 35.8957 38.7961 35.5649C38.6269 33.9474 39.3269 32.395 40.2705 31.1057C41.4474 29.5038 42.2551 27.7378 42.6141 25.7791C43.1756 22.7055 42.5807 19.8456 40.9192 17.2174C38.9474 14.097 36.1679 12.0419 32.509 11.2865C31.65 11.1094 30.8115 11.0156 29.9987 11C29.1833 11.0156 28.3474 11.1094 27.4884 11.2865C23.832 12.0419 21.05 14.097 19.0782 17.2174C17.4192 19.8456 16.8243 22.7055 17.3833 25.7791C17.7397 27.7404 18.55 29.5038 19.7269 31.1057C20.6731 32.395 21.3731 33.9448 21.2013 35.5649C21.1654 35.8957 20.9961 36.349 20.8859 36.375L20.8987 36.3802C20.609 36.8621 19.6602 36.5678 19.1833 36.4792C18.6038 36.3724 18.0295 36.7188 17.8038 37.3231C17.5679 37.9561 17.9243 38.4249 18.391 38.8234C18.5807 38.9875 18.7654 39.1594 18.95 39.3261C18.0782 40.4175 17.0013 40.2925 15.8833 40.0789C15.0936 39.9278 14.3243 40.0945 14.0679 40.9306C13.8064 41.785 14.4679 42.2694 15.1218 42.6471C15.6269 42.9389 17.2551 43.5926 19.7756 43.0613C18.8192 44.4808 18.15 45.1268 16.1346 44.9314C15.0936 44.8299 14.3448 45.3274 14.0705 46.3536C13.8013 47.3564 14.332 48.0753 15.1731 48.5546C15.7243 48.8672 16.3397 48.9896 16.991 49C20.5577 49.0495 23.5013 47.5778 26.1525 45.3638C27.6115 44.1448 28.8115 43.5275 30.0013 43.5119C31.191 43.5275 32.3884 44.1448 33.85 45.3638C36.5013 47.5778 39.4448 49.0495 43.0115 49C43.6628 48.9922 44.2782 48.8672 44.8295 48.5546C45.6731 48.0753 46.2013 47.3564 45.932 46.3536H45.9269ZM25.0756 34.2027C23.9731 34.2001 23.0628 33.278 23.0602 32.1658C23.0602 31.0328 23.9807 30.1055 25.1166 30.0951C26.1961 30.0847 27.1577 31.1083 27.1474 32.257C27.1372 33.3952 26.2705 34.2079 25.0756 34.2027ZM34.9192 34.2027C33.7218 34.2079 32.8577 33.3926 32.8474 32.257C32.8372 31.1083 33.8013 30.0847 34.8782 30.0951C36.0141 30.1081 36.9346 31.0354 36.9346 32.1658C36.9346 33.278 36.0218 34.2001 34.9192 34.2027Z" fill="#ffffff"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.5 KiB |
@@ -0,0 +1,2 @@
|
||||
/target
|
||||
/gen/schemas
|
||||
Generated
+4392
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,12 @@
|
||||
[package]
|
||||
name = "team"
|
||||
version = "0.1.0"
|
||||
description = "Hanzo Team desktop shell"
|
||||
license = "BSD-3-Clause"
|
||||
edition = "2021"
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2", features = [] }
|
||||
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 9.9 KiB |
@@ -0,0 +1,7 @@
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
tauri::Builder::default()
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running hanzo team");
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Hanzo Team",
|
||||
"version": "0.1.0",
|
||||
"identifier": "ai.hanzo.team",
|
||||
"build": {
|
||||
"frontendDist": "../dist/client"
|
||||
},
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"title": "Hanzo Team",
|
||||
"width": 1200,
|
||||
"height": 800
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": null
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
// Real hanzo.id OIDC — Authorization Code + PKCE. This module is the protocol (no
|
||||
// platform imports): it builds the authorize URL, exchanges the code, and reads the
|
||||
// profile. The browser step is delegated to the platform-split ./oidc-browser (native
|
||||
// opens the system browser; web redirects the page), so expo stays out of the web bundle.
|
||||
|
||||
import { CLIENT_ID, ISSUER, SCOPES } from './oidc-config'
|
||||
import { authorize, redirectUri } from './oidc-browser'
|
||||
import type { AuthOutcome } from './oidc-types'
|
||||
|
||||
export { CLIENT_ID, redirectUri }
|
||||
|
||||
export interface Tokens {
|
||||
accessToken: string
|
||||
refreshToken?: string
|
||||
/** epoch millis the access token expires, when the server reports expires_in */
|
||||
expiresAt?: number
|
||||
}
|
||||
|
||||
export interface UserInfo {
|
||||
sub: string
|
||||
name?: string
|
||||
email?: string
|
||||
/** org slugs the account belongs to, when the IAM userinfo carries groups */
|
||||
orgs?: string[]
|
||||
}
|
||||
|
||||
export interface Session extends Tokens {
|
||||
user?: UserInfo
|
||||
}
|
||||
|
||||
interface Endpoints {
|
||||
authorization: string
|
||||
token: string
|
||||
userinfo: string
|
||||
}
|
||||
|
||||
// IAM's endpoint paths, used when discovery is unreachable.
|
||||
const FALLBACK: Endpoints = {
|
||||
authorization: `${ISSUER}/login/oauth/authorize`,
|
||||
token: `${ISSUER}/api/login/oauth/access_token`,
|
||||
userinfo: `${ISSUER}/api/userinfo`,
|
||||
}
|
||||
|
||||
let endpointsCache: Endpoints | undefined
|
||||
|
||||
/** Resolve OIDC endpoints from the discovery document, falling back to IAM's paths. */
|
||||
export async function discover(): Promise<Endpoints> {
|
||||
if (endpointsCache !== undefined) return endpointsCache
|
||||
try {
|
||||
const res = await fetch(`${ISSUER}/.well-known/openid-configuration`)
|
||||
if (res.ok) {
|
||||
const d = (await res.json()) as Record<string, string>
|
||||
endpointsCache = {
|
||||
authorization: d.authorization_endpoint ?? FALLBACK.authorization,
|
||||
token: d.token_endpoint ?? FALLBACK.token,
|
||||
userinfo: d.userinfo_endpoint ?? FALLBACK.userinfo,
|
||||
}
|
||||
return endpointsCache
|
||||
}
|
||||
} catch {
|
||||
// discovery blocked — fall through to the known IAM paths
|
||||
}
|
||||
endpointsCache = FALLBACK
|
||||
return endpointsCache
|
||||
}
|
||||
|
||||
const UNRESERVED = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~'
|
||||
|
||||
function randomString(length: number): string {
|
||||
const bytes = new Uint8Array(length)
|
||||
const c = globalThis.crypto
|
||||
if (c?.getRandomValues != null) c.getRandomValues(bytes)
|
||||
else for (let i = 0; i < length; i++) bytes[i] = Math.floor(Math.random() * 256)
|
||||
let out = ''
|
||||
for (let i = 0; i < length; i++) out += UNRESERVED[bytes[i] % UNRESERVED.length]
|
||||
return out
|
||||
}
|
||||
|
||||
function base64Url(bytes: Uint8Array): string {
|
||||
let bin = ''
|
||||
for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i])
|
||||
const b64 = typeof btoa === 'function' ? btoa(bin) : Buffer.from(bytes).toString('base64')
|
||||
return b64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
|
||||
}
|
||||
|
||||
interface Challenge {
|
||||
value: string
|
||||
method: 'S256' | 'plain'
|
||||
}
|
||||
|
||||
/** PKCE challenge: SHA-256 where a subtle-crypto digest exists (web), else plain. */
|
||||
async function codeChallenge(verifier: string): Promise<Challenge> {
|
||||
const subtle = globalThis.crypto?.subtle
|
||||
if (subtle?.digest != null) {
|
||||
const data = new TextEncoder().encode(verifier)
|
||||
const digest = await subtle.digest('SHA-256', data)
|
||||
return { value: base64Url(new Uint8Array(digest)), method: 'S256' }
|
||||
}
|
||||
return { value: verifier, method: 'plain' }
|
||||
}
|
||||
|
||||
function buildAuthUrl(opts: {
|
||||
endpoint: string
|
||||
state: string
|
||||
challenge: Challenge
|
||||
redirect: string
|
||||
}): string {
|
||||
const q = new URLSearchParams({
|
||||
client_id: CLIENT_ID,
|
||||
response_type: 'code',
|
||||
scope: SCOPES,
|
||||
redirect_uri: opts.redirect,
|
||||
state: opts.state,
|
||||
code_challenge: opts.challenge.value,
|
||||
code_challenge_method: opts.challenge.method,
|
||||
})
|
||||
return `${opts.endpoint}?${q.toString()}`
|
||||
}
|
||||
|
||||
function parseCallback(url: string): { code?: string; state?: string; error?: string } {
|
||||
const q = url.includes('?') ? url.slice(url.indexOf('?') + 1) : ''
|
||||
const p = new URLSearchParams(q)
|
||||
return {
|
||||
code: p.get('code') ?? undefined,
|
||||
state: p.get('state') ?? undefined,
|
||||
error: p.get('error') ?? undefined,
|
||||
}
|
||||
}
|
||||
|
||||
async function exchangeCode(code: string, verifier: string, redirect: string): Promise<Tokens> {
|
||||
const { token } = await discover()
|
||||
const body = new URLSearchParams({
|
||||
grant_type: 'authorization_code',
|
||||
code,
|
||||
redirect_uri: redirect,
|
||||
client_id: CLIENT_ID,
|
||||
code_verifier: verifier,
|
||||
})
|
||||
const res = await fetch(token, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/x-www-form-urlencoded', accept: 'application/json' },
|
||||
body: body.toString(),
|
||||
})
|
||||
if (!res.ok) throw new Error(`token exchange failed (${res.status})`)
|
||||
const d = (await res.json()) as Record<string, unknown>
|
||||
const accessToken = d.access_token
|
||||
if (typeof accessToken !== 'string' || accessToken.length === 0) {
|
||||
throw new Error(typeof d.error === 'string' ? d.error : 'no access_token in token response')
|
||||
}
|
||||
const expiresIn = typeof d.expires_in === 'number' ? d.expires_in : undefined
|
||||
return {
|
||||
accessToken,
|
||||
refreshToken: typeof d.refresh_token === 'string' ? d.refresh_token : undefined,
|
||||
expiresAt: expiresIn !== undefined ? Date.now() + expiresIn * 1000 : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
/** Read the signed-in profile from the OIDC userinfo endpoint. */
|
||||
export async function fetchUserInfo(accessToken: string): Promise<UserInfo | undefined> {
|
||||
try {
|
||||
const { userinfo } = await discover()
|
||||
const res = await fetch(userinfo, { headers: { authorization: `Bearer ${accessToken}` } })
|
||||
if (!res.ok) return undefined
|
||||
const d = (await res.json()) as Record<string, unknown>
|
||||
const groups = Array.isArray(d.groups)
|
||||
? (d.groups as unknown[]).filter((g): g is string => typeof g === 'string')
|
||||
: undefined
|
||||
return {
|
||||
sub: String(d.sub ?? d.id ?? ''),
|
||||
name:
|
||||
typeof d.name === 'string'
|
||||
? d.name
|
||||
: typeof d.preferred_username === 'string'
|
||||
? d.preferred_username
|
||||
: undefined,
|
||||
email: typeof d.email === 'string' ? d.email : undefined,
|
||||
orgs: groups,
|
||||
}
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/** Web PKCE spans a page navigation — stash the verifier/state for the /callback route. */
|
||||
const PENDING_KEY = 'hanzo-team-oidc-pending'
|
||||
interface Pending {
|
||||
verifier: string
|
||||
state: string
|
||||
redirect: string
|
||||
}
|
||||
function savePending(p: Pending): void {
|
||||
globalThis.sessionStorage?.setItem(PENDING_KEY, JSON.stringify(p))
|
||||
}
|
||||
function loadPending(): Pending | undefined {
|
||||
const raw = globalThis.sessionStorage?.getItem(PENDING_KEY)
|
||||
return raw != null ? (JSON.parse(raw) as Pending) : undefined
|
||||
}
|
||||
function clearPending(): void {
|
||||
globalThis.sessionStorage?.removeItem(PENDING_KEY)
|
||||
}
|
||||
|
||||
export type LoginResult =
|
||||
| { status: 'session'; session: Session }
|
||||
| { status: 'redirecting' }
|
||||
| { status: 'cancelled' }
|
||||
|
||||
/**
|
||||
* Begin sign-in. Native completes in-process (returns the session); web navigates
|
||||
* away and completes on the /callback route (returns `redirecting`).
|
||||
*/
|
||||
export async function login(): Promise<LoginResult> {
|
||||
const { authorization } = await discover()
|
||||
const redirect = redirectUri()
|
||||
const verifier = randomString(64)
|
||||
const state = randomString(24)
|
||||
const challenge = await codeChallenge(verifier)
|
||||
const url = buildAuthUrl({ endpoint: authorization, state, challenge, redirect })
|
||||
|
||||
// Persist for the web /callback route; a no-op on native (no sessionStorage).
|
||||
savePending({ verifier, state, redirect })
|
||||
|
||||
const outcome: AuthOutcome = await authorize(url)
|
||||
if (outcome.kind === 'redirecting') return { status: 'redirecting' }
|
||||
if (outcome.kind === 'cancelled') return { status: 'cancelled' }
|
||||
|
||||
const cb = parseCallback(outcome.url)
|
||||
if (cb.error != null) throw new Error(cb.error)
|
||||
if (cb.code == null) throw new Error('no authorization code in callback')
|
||||
if (cb.state !== state) throw new Error('state mismatch')
|
||||
const tokens = await exchangeCode(cb.code, verifier, redirect)
|
||||
const user = await fetchUserInfo(tokens.accessToken)
|
||||
return { status: 'session', session: { ...tokens, user } }
|
||||
}
|
||||
|
||||
/** Complete the web redirect flow from the /callback route's query string. */
|
||||
export async function completeWebCallback(query: {
|
||||
code?: string
|
||||
state?: string
|
||||
error?: string
|
||||
}): Promise<Session> {
|
||||
if (query.error != null) throw new Error(query.error)
|
||||
const pending = loadPending()
|
||||
clearPending()
|
||||
if (pending == null) throw new Error('no pending sign-in')
|
||||
if (query.code == null) throw new Error('no authorization code')
|
||||
if (query.state !== pending.state) throw new Error('state mismatch')
|
||||
const tokens = await exchangeCode(query.code, pending.verifier, pending.redirect)
|
||||
const user = await fetchUserInfo(tokens.accessToken)
|
||||
return { ...tokens, user }
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
// Real wallet data — balance + usage read from the billing surface with the session's
|
||||
// bearer token. No hardcoded money: on error the wallet shows an honest unavailable
|
||||
// state, never a fake $0.00.
|
||||
|
||||
const BILLING_BASE = 'https://billing.hanzo.ai'
|
||||
|
||||
export interface UsageRow {
|
||||
label: string
|
||||
value: string
|
||||
}
|
||||
|
||||
export interface Wallet {
|
||||
/** balance in USD */
|
||||
balanceUsd: number
|
||||
usage: UsageRow[]
|
||||
}
|
||||
|
||||
function num(v: unknown): number | undefined {
|
||||
if (typeof v === 'number' && Number.isFinite(v)) return v
|
||||
if (typeof v === 'string' && v.trim() !== '' && Number.isFinite(Number(v))) return Number(v)
|
||||
return undefined
|
||||
}
|
||||
|
||||
function usd(record: Record<string, unknown>): number | undefined {
|
||||
// atto-USD (18-dec) is the canonical money unit; also accept plain USD / cents fields.
|
||||
const atto = record.balance_atto_usd ?? record.balanceAtto ?? record.atto_usd
|
||||
if (typeof atto === 'string' && /^-?\d+$/.test(atto)) return Number(BigInt(atto)) / 1e18
|
||||
const dollars = num(record.balance_usd ?? record.balanceUsd ?? record.balance ?? record.credits)
|
||||
if (dollars !== undefined) return dollars
|
||||
const cents = num(record.balance_cents ?? record.balanceCents)
|
||||
if (cents !== undefined) return cents / 100
|
||||
return undefined
|
||||
}
|
||||
|
||||
/** GET the org wallet for the signed-in session. Throws on any non-2xx / bad shape. */
|
||||
export async function fetchWallet(accessToken: string): Promise<Wallet> {
|
||||
const res = await fetch(`${BILLING_BASE}/v1/billing/balance`, {
|
||||
headers: { authorization: `Bearer ${accessToken}`, accept: 'application/json' },
|
||||
})
|
||||
if (!res.ok) throw new Error(`billing ${res.status}`)
|
||||
const data = (await res.json()) as Record<string, unknown>
|
||||
const balanceUsd = usd(data)
|
||||
if (balanceUsd === undefined) throw new Error('no balance in billing response')
|
||||
|
||||
const usage: UsageRow[] = []
|
||||
const period = data.period ?? data.month
|
||||
if (typeof period === 'string') usage.push({ label: 'Period', value: period })
|
||||
const spent = num(data.spent_usd ?? data.spentUsd ?? data.usage_usd)
|
||||
if (spent !== undefined) usage.push({ label: 'Spent this period', value: `$${spent.toFixed(2)}` })
|
||||
const requests = num(data.requests ?? data.request_count)
|
||||
if (requests !== undefined) usage.push({ label: 'Requests', value: String(requests) })
|
||||
const seats = num(data.seats ?? data.seat_count)
|
||||
if (seats !== undefined) usage.push({ label: 'Seats', value: String(seats) })
|
||||
|
||||
return { balanceUsd, usage }
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { defaultConfig } from '@hanzogui/config/v5'
|
||||
import { createGui } from 'hanzogui'
|
||||
|
||||
// hanzo monochrome: dark #000 / #0a0a0a / #1f1f1f · light #fff / #f7f7f7 / #ebebeb
|
||||
export const config = createGui({
|
||||
...defaultConfig,
|
||||
themes: {
|
||||
...defaultConfig.themes,
|
||||
dark: {
|
||||
...defaultConfig.themes.dark,
|
||||
background: '#000000',
|
||||
color1: '#0a0a0a',
|
||||
borderColor: '#1f1f1f',
|
||||
},
|
||||
light: {
|
||||
...defaultConfig.themes.light,
|
||||
background: '#ffffff',
|
||||
color1: '#f7f7f7',
|
||||
borderColor: '#ebebeb',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
export type Conf = typeof config
|
||||
|
||||
declare module 'hanzogui' {
|
||||
interface GuiCustomConfig extends Conf {}
|
||||
}
|
||||
|
||||
export default config
|
||||
@@ -0,0 +1,18 @@
|
||||
// NATIVE authorize step: open hanzo.id in the system browser and read the
|
||||
// `hanzo-team://callback` deep link back. Keeps the expo modules (which pull
|
||||
// expo-modules-core) out of the web bundle — the web build resolves oidc-browser.web.ts.
|
||||
|
||||
import * as WebBrowser from 'expo-web-browser'
|
||||
import * as Linking from 'expo-linking'
|
||||
import { REDIRECT_SCHEME } from './oidc-config'
|
||||
import type { AuthOutcome } from './oidc-types'
|
||||
|
||||
export function redirectUri(): string {
|
||||
return Linking.createURL('callback', { scheme: REDIRECT_SCHEME })
|
||||
}
|
||||
|
||||
export async function authorize(url: string): Promise<AuthOutcome> {
|
||||
const result = await WebBrowser.openAuthSessionAsync(url, redirectUri())
|
||||
if (result.type !== 'success' || result.url == null) return { kind: 'cancelled' }
|
||||
return { kind: 'callback', url: result.url }
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
// WEB authorize step: full-page redirect to hanzo.id. The /callback route completes
|
||||
// the exchange. No expo imports here, so the web bundle never pulls expo-modules-core.
|
||||
|
||||
import type { AuthOutcome } from './oidc-types'
|
||||
|
||||
export function redirectUri(): string {
|
||||
return `${globalThis.location?.origin ?? 'https://hanzo.team'}/callback`
|
||||
}
|
||||
|
||||
export async function authorize(url: string): Promise<AuthOutcome> {
|
||||
globalThis.location?.assign(url)
|
||||
return { kind: 'redirecting' }
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
// Shared hanzo.id OIDC constants (no platform imports) so both the protocol module
|
||||
// (auth.ts) and the platform-split browser modules read one source of truth.
|
||||
|
||||
/** The public native client registered in IAM (hanzo.id). */
|
||||
export const CLIENT_ID = 'hanzo-team-native'
|
||||
export const ISSUER = 'https://hanzo.id'
|
||||
export const SCOPES = 'openid profile email'
|
||||
/** Deep-link scheme (app.json `scheme`) the IAM redirect must whitelist. */
|
||||
export const REDIRECT_SCHEME = 'hanzo-team'
|
||||
@@ -0,0 +1,6 @@
|
||||
// The result of driving the authorize step, produced by the platform-split
|
||||
// ./oidc-browser module (native uses the system browser; web redirects the page).
|
||||
export type AuthOutcome =
|
||||
| { kind: 'callback'; url: string } // native: the app-scheme redirect came back
|
||||
| { kind: 'redirecting' } // web: the page navigated to hanzo.id
|
||||
| { kind: 'cancelled' } // the user dismissed the browser
|
||||
@@ -0,0 +1,91 @@
|
||||
// One session store for every screen. Persists the OIDC session (AsyncStorage works
|
||||
// on web via localStorage and on native), exposes it through React context, and owns
|
||||
// sign-in / sign-out so the screens never touch the protocol directly.
|
||||
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'
|
||||
import { getItem, removeItem, setItem } from './store'
|
||||
import { login as oidcLogin, type Session } from './auth'
|
||||
|
||||
const STORAGE_KEY = 'hanzo-team-session'
|
||||
|
||||
async function load(): Promise<Session | null> {
|
||||
const raw = await getItem(STORAGE_KEY)
|
||||
if (raw == null) return null
|
||||
try {
|
||||
return JSON.parse(raw) as Session
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function save(session: Session | null): Promise<void> {
|
||||
if (session == null) await removeItem(STORAGE_KEY)
|
||||
else await setItem(STORAGE_KEY, JSON.stringify(session))
|
||||
}
|
||||
|
||||
export interface SessionState {
|
||||
session: Session | null
|
||||
loading: boolean
|
||||
/** true while a sign-in is in flight */
|
||||
signingIn: boolean
|
||||
signIn: () => Promise<void>
|
||||
signOut: () => Promise<void>
|
||||
}
|
||||
|
||||
const SessionContext = createContext<SessionState | undefined>(undefined)
|
||||
|
||||
export function SessionProvider({ children }: { children: React.ReactNode }): React.ReactElement {
|
||||
const [session, setSession] = useState<Session | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [signingIn, setSigningIn] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
let live = true
|
||||
void load().then((s) => {
|
||||
if (live) {
|
||||
setSession(s)
|
||||
setLoading(false)
|
||||
}
|
||||
})
|
||||
return () => {
|
||||
live = false
|
||||
}
|
||||
}, [])
|
||||
|
||||
const signIn = useCallback(async () => {
|
||||
setSigningIn(true)
|
||||
try {
|
||||
const result = await oidcLogin()
|
||||
if (result.status === 'session') {
|
||||
await save(result.session)
|
||||
setSession(result.session)
|
||||
}
|
||||
// 'redirecting' (web) completes on the /callback route; 'cancelled' is a no-op
|
||||
} finally {
|
||||
setSigningIn(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const signOut = useCallback(async () => {
|
||||
await save(null)
|
||||
setSession(null)
|
||||
}, [])
|
||||
|
||||
const value = useMemo<SessionState>(
|
||||
() => ({ session, loading, signingIn, signIn, signOut }),
|
||||
[session, loading, signingIn, signIn, signOut],
|
||||
)
|
||||
|
||||
return <SessionContext.Provider value={value}>{children}</SessionContext.Provider>
|
||||
}
|
||||
|
||||
export function useSession(): SessionState {
|
||||
const ctx = useContext(SessionContext)
|
||||
if (ctx === undefined) throw new Error('useSession must be used within a SessionProvider')
|
||||
return ctx
|
||||
}
|
||||
|
||||
/** Persist a session obtained outside the provider (the web /callback route). */
|
||||
export async function persistSession(session: Session): Promise<void> {
|
||||
await save(session)
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
// NATIVE key/value store — AsyncStorage. Split so the web bundle (store.web.ts) uses
|
||||
// localStorage directly and never pulls the native module into the web build.
|
||||
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage'
|
||||
|
||||
export const getItem = (key: string): Promise<string | null> => AsyncStorage.getItem(key)
|
||||
export const setItem = (key: string, value: string): Promise<void> => AsyncStorage.setItem(key, value)
|
||||
export const removeItem = (key: string): Promise<void> => AsyncStorage.removeItem(key)
|
||||
@@ -0,0 +1,11 @@
|
||||
// WEB key/value store — localStorage, matching the async store.ts signature.
|
||||
|
||||
export async function getItem(key: string): Promise<string | null> {
|
||||
return globalThis.localStorage?.getItem(key) ?? null
|
||||
}
|
||||
export async function setItem(key: string, value: string): Promise<void> {
|
||||
globalThis.localStorage?.setItem(key, value)
|
||||
}
|
||||
export async function removeItem(key: string): Promise<void> {
|
||||
globalThis.localStorage?.removeItem(key)
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
// MIRROR of the canonical hanzoai/ui `pkg/ui/src/product/surfaces.data.ts` — keep
|
||||
// the data byte-identical. This native app cannot resolve the @hanzo/ui package, so
|
||||
// the ONE cross-surface app-switcher list is mirrored here. Update all mirrors in the
|
||||
// same change (hanzoai/ui, hanzoai/team Svelte fork, and here).
|
||||
|
||||
export type SurfaceId = 'ai' | 'console' | 'app' | 'chat' | 'bot' | 'team' | 'billing'
|
||||
|
||||
/** One Hanzo surface the app switcher offers. */
|
||||
export interface Surface {
|
||||
id: SurfaceId
|
||||
label: string
|
||||
href: string
|
||||
hint: string
|
||||
}
|
||||
|
||||
/** The seven Hanzo surfaces (`console` opens the cloud AI console). */
|
||||
export const SURFACES: Surface[] = [
|
||||
{ id: 'ai', label: 'Hanzo AI', href: 'https://hanzo.ai', hint: 'hanzo.ai' },
|
||||
{ id: 'console', label: 'Console', href: 'https://console.hanzo.ai', hint: 'console.hanzo.ai' },
|
||||
{ id: 'app', label: 'App', href: 'https://hanzo.app', hint: 'hanzo.app' },
|
||||
{ id: 'chat', label: 'Chat', href: 'https://hanzo.chat', hint: 'hanzo.chat' },
|
||||
{ id: 'bot', label: 'Bot', href: 'https://hanzo.bot', hint: 'hanzo.bot' },
|
||||
{ id: 'team', label: 'Team', href: 'https://hanzo.team', hint: 'hanzo.team' },
|
||||
{ id: 'billing', label: 'Billing', href: 'https://billing.hanzo.ai', hint: 'billing.hanzo.ai' },
|
||||
]
|
||||
|
||||
/** Every surface except `current` — a launcher never links to itself. */
|
||||
export function otherSurfaces(current?: SurfaceId): Surface[] {
|
||||
return current !== undefined ? SURFACES.filter((s) => s.id !== current) : SURFACES
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"target": "esnext",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"skipLibCheck": true,
|
||||
"paths": {
|
||||
"~/*": ["./*"]
|
||||
},
|
||||
"types": ["vite/client"],
|
||||
"noEmit": true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { guiPlugin } from '@hanzogui/vite-plugin'
|
||||
import { one } from 'one/vite'
|
||||
import type { UserConfig } from 'vite'
|
||||
|
||||
export default {
|
||||
clearScreen: false,
|
||||
|
||||
plugins: [
|
||||
one({
|
||||
ssr: {
|
||||
dedupeSymlinkedModules: true,
|
||||
},
|
||||
|
||||
web: {
|
||||
defaultRenderMode: 'spa',
|
||||
},
|
||||
}),
|
||||
|
||||
guiPlugin(),
|
||||
],
|
||||
} satisfies UserConfig
|
||||
@@ -4,7 +4,6 @@
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"declaration": true,
|
||||
"downlevelIteration": true,
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"inlineSourceMap": true,
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
// for jumping to things properly in vscode
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"hanzogui": [
|
||||
"../ui/gui"
|
||||
@@ -29,7 +27,7 @@
|
||||
"./data/*"
|
||||
],
|
||||
"react-native": [
|
||||
"react-native-web"
|
||||
"./react-native-web"
|
||||
]
|
||||
},
|
||||
"target": "es2021",
|
||||
@@ -42,7 +40,7 @@
|
||||
"allowJs": true,
|
||||
"noEmit": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "node",
|
||||
"moduleResolution": "bundler",
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"noImplicitThis": true,
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"rootDir": ".",
|
||||
"importHelpers": true,
|
||||
"allowJs": false,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"downlevelIteration": true,
|
||||
"esModuleInterop": true,
|
||||
"preserveSymlinks": true,
|
||||
"incremental": true,
|
||||
@@ -26,7 +24,15 @@
|
||||
"strictNullChecks": true,
|
||||
"target": "es2020",
|
||||
"types": ["node"],
|
||||
"lib": ["dom", "esnext"]
|
||||
"lib": [
|
||||
"dom",
|
||||
"esnext"
|
||||
],
|
||||
"paths": {
|
||||
"*": [
|
||||
"./*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"exclude": ["_"],
|
||||
"typeAcquisition": {
|
||||
|
||||
@@ -1,136 +0,0 @@
|
||||
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: `.
|
||||
|
||||
# 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({ ... }),
|
||||
})
|
||||
```
|
||||
|
||||
**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.
|
||||
|
||||
|
||||
---
|
||||
|
||||
## Additional notes (merged from LLM.md)
|
||||
|
||||
# gui — AI Assistant Context
|
||||
|
||||
# Hanzo GUI
|
||||
|
||||
<h3 align="center">
|
||||
Style library, design system, composable components, and more.
|
||||
</h3>
|
||||
Vendored
+8
@@ -0,0 +1,8 @@
|
||||
// TypeScript 7 reports TS2882 for a side-effect import with no type
|
||||
// declaration ("Cannot find module or type declarations for side-effect import
|
||||
// of './globals.css'"). TS 5.x accepted these silently.
|
||||
//
|
||||
// Stylesheets are resolved by the bundler (Next/Vite/webpack), never by the
|
||||
// TypeScript module resolver, so this declares them legitimate rather than
|
||||
// giving them a shape.
|
||||
declare module '*.css';
|
||||
+34
-4
@@ -16,8 +16,16 @@
|
||||
"./apps/kitchen-sink-go",
|
||||
"./apps/kitchen-sink-shared",
|
||||
"./apps/sandbox",
|
||||
"./apps/team",
|
||||
"./apps/tests/**/*",
|
||||
"./templates/*"
|
||||
"./templates/*",
|
||||
"./pkgs/ai",
|
||||
"./pkgs/net-ui",
|
||||
"./pkgs/net-state",
|
||||
"./pkgs/net-i18n",
|
||||
"./pkgs/net-message-ts",
|
||||
"./pkgs/net-brand-config",
|
||||
"./pkgs/net-logo"
|
||||
],
|
||||
"scripts": {
|
||||
"postinstall": "bun ./scripts/postinstall.ts",
|
||||
@@ -113,6 +121,8 @@
|
||||
"devDependencies": {
|
||||
"@manypkg/cli": "^0.23.0",
|
||||
"@playwright/test": "^1.49.1",
|
||||
"@tailwindcss/typography": "^0.5.20",
|
||||
"@tailwindcss/vite": "^4",
|
||||
"@testing-library/dom": "^10.4.0",
|
||||
"dotenv-cli": "^7.4.2",
|
||||
"esbuild": "^0.27.2",
|
||||
@@ -127,6 +137,7 @@
|
||||
"p-map": "^7.0.3",
|
||||
"prompts": "2.1.0",
|
||||
"react-native-screens": "~4.23.0",
|
||||
"tailwindcss": "^4",
|
||||
"turbo": "^2.1.3",
|
||||
"typescript": "~5.9.2",
|
||||
"ultra-runner": "^3.10.5"
|
||||
@@ -134,14 +145,33 @@
|
||||
"overrides": {
|
||||
"@react-navigation/core": "7.9.2",
|
||||
"@react-navigation/elements": "2.8.1",
|
||||
"@react-navigation/native": "7.1.9",
|
||||
"@react-navigation/native": "7.1.34",
|
||||
"@react-navigation/native-stack": "7.6.2",
|
||||
"@react-navigation/routers": "7.4.0",
|
||||
"@swc/core": "1.14.0",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"react-native": "0.83.2",
|
||||
"whatwg-url": "14.1.1"
|
||||
"whatwg-url": "14.1.1",
|
||||
"shiki": "^3.19.0",
|
||||
"react-resizable-panels": "^3.0.2",
|
||||
"one": "1.12.5",
|
||||
"vxrn": "1.12.5",
|
||||
"@vxrn/compiler": "1.12.5",
|
||||
"@vxrn/resolve": "1.12.5",
|
||||
"@vxrn/color-scheme": "1.12.5",
|
||||
"@vxrn/mdx": "1.12.5",
|
||||
"@vxrn/safe-area": "1.12.5",
|
||||
"@vxrn/utils": "1.12.5",
|
||||
"@vxrn/debug": "1.12.5",
|
||||
"@vxrn/vendor": "1.12.5",
|
||||
"@vxrn/query-string": "1.12.5",
|
||||
"@vxrn/url-parse": "1.12.5",
|
||||
"@vxrn/vite-flow": "1.12.5",
|
||||
"@vxrn/vite-plugin-metro": "1.12.5",
|
||||
"@vxrn/tslib-lite": "1.12.5",
|
||||
"create-vxrn": "1.12.5",
|
||||
"@react-navigation/bottom-tabs": "7.10.1"
|
||||
},
|
||||
"packageManager": "bun@1.3.9",
|
||||
"manypkg": {
|
||||
@@ -178,4 +208,4 @@
|
||||
"exports": {
|
||||
"./package.json": "./package.json"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
# @hanzo/ai — build / migration plan
|
||||
|
||||
Goal: the Hanzo AI app as **one declarative SDK component** (`<HanzoAI {...brand} />`),
|
||||
so `hanzo/zoo/lux` desktop+mobile apps become ~80-line shims — exactly how
|
||||
`zooai/exchange` / `luxfi/exchange` consume `@luxfi/exchange`.
|
||||
|
||||
```
|
||||
@hanzo/ai (this pkg — the app) @luxfi/exchange (the analog)
|
||||
▲ ▲
|
||||
hanzoai/desktop · zooai/app · luxfi/app zooai/exchange · luxfi/exchange
|
||||
<HanzoAI {...brand}/> <Exchange {...brand}/>
|
||||
```
|
||||
|
||||
## Status
|
||||
- [x] Props contract — `BrandConfig` lifted from `hanzoai/desktop/libs/brand-config` → `src/types.ts`.
|
||||
- [x] `<HanzoAI>` component + `BrandProvider`/`useBrand` (prop-driven; replaces `getBrand()` env lookup).
|
||||
- [ ] **Move the app in** (the big rock).
|
||||
- [ ] **Decouple Tauri** (44 files).
|
||||
- [ ] **Bundle + publish** `@hanzo/ai` and its libs.
|
||||
- [ ] **Shims**: rewrite `hanzoai/desktop`, `zooai/app`, `luxfi/app` to the shim.
|
||||
|
||||
## The move (~955 source files)
|
||||
From `hanzoai/desktop`:
|
||||
| Source | Files | Destination |
|
||||
|---|---|---|
|
||||
| `apps/hanzo-desktop/src/**` | 263 | `pkgs/ai/src/app/**` (App.tsx → `./app/App`) |
|
||||
| `libs/hanzo-node-state/src` | 460 | `pkgs/state` → `@hanzo_network/hanzo-node-state` |
|
||||
| `libs/hanzo-ui/src` | 192 | `pkgs/hanzo-ui` → `@hanzo_network/hanzo-ui` |
|
||||
| `libs/hanzo-message-ts/src` | 32 | `pkgs/message-ts` → `@hanzo_network/hanzo-message-ts` |
|
||||
| `libs/hanzo-i18n/src` | 8 | `pkgs/hanzo-i18n` → `@hanzo_network/hanzo-i18n` |
|
||||
|
||||
Mechanical, but two rewrites are required during the move:
|
||||
1. `getBrand()` / `import.meta.env.VITE_BRAND` → `useBrand()` (from `./brand-context`).
|
||||
2. The **44** `@tauri-apps/*` import sites → the injected `host: HostAdapter` prop
|
||||
(`host.invoke` / `host.listen`), so the SDK runs on plain web *and* Tauri *and* Expo.
|
||||
On web with no `host`, those features degrade gracefully.
|
||||
|
||||
## Then the shim (per app) — the whole app:
|
||||
```tsx
|
||||
// luxfi/app — apps/web/src/main.tsx
|
||||
import HanzoAI from '@hanzo/ai'
|
||||
import brand from '@luxfi/brand'
|
||||
import { tauriHost } from './host' // desktop adapter
|
||||
createRoot(root).render(
|
||||
<HanzoAI {...brand} host={tauriHost}
|
||||
features={{ chat:true, wallet:true, mining:true }} />)
|
||||
```
|
||||
Mobile = `apps/mobile` (Expo) with the same `<HanzoAI {...brand} host={expoHost} />`.
|
||||
Build via Dockerfile from the pinned `@hanzo/ai`, brand overlaid — like the exchanges.
|
||||
@@ -0,0 +1,3 @@
|
||||
{ "name": "@hanzo/desktop-shim", "private": true, "type": "module",
|
||||
"dependencies": { "@hanzo/ai": "workspace:*", "@hanzo/brand": "*",
|
||||
"react": "^19.0.0", "react-dom": "^19.0.0", "@tauri-apps/api": "^2.0.0" } }
|
||||
@@ -0,0 +1,12 @@
|
||||
// hanzo-desktop — the entire app. Brand is a prop; platform is an injected host.
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import HanzoAI from '@hanzo/ai';
|
||||
import brand from '@hanzo/brand';
|
||||
import { tauriHost } from '../../tauri-host';
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<HanzoAI {...brand}
|
||||
host={tauriHost}
|
||||
features={{ chat: true, wallet: true, mining: true, tools: true, agents: true }}
|
||||
/>,
|
||||
);
|
||||
@@ -0,0 +1,3 @@
|
||||
{ "name": "@lux/desktop-shim", "private": true, "type": "module",
|
||||
"dependencies": { "@hanzo/ai": "workspace:*", "@luxfi/brand": "*",
|
||||
"react": "^19.0.0", "react-dom": "^19.0.0", "@tauri-apps/api": "^2.0.0" } }
|
||||
@@ -0,0 +1,12 @@
|
||||
// lux-desktop — the entire app. Brand is a prop; platform is an injected host.
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import HanzoAI from '@hanzo/ai';
|
||||
import brand from '@luxfi/brand';
|
||||
import { tauriHost } from '../../tauri-host';
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<HanzoAI {...brand}
|
||||
host={tauriHost}
|
||||
features={{ chat: true, wallet: true, mining: true, tools: true, agents: true }}
|
||||
/>,
|
||||
);
|
||||
@@ -0,0 +1,10 @@
|
||||
// Desktop (Tauri) host adapter. The ONLY place the desktop shim touches
|
||||
// @tauri-apps — everything else in @hanzo/ai goes through this injected host.
|
||||
import { invoke as tauriInvoke } from '@tauri-apps/api/core';
|
||||
import { listen as tauriListen } from '@tauri-apps/api/event';
|
||||
import type { HostAdapter } from '@hanzo/ai';
|
||||
export const tauriHost: HostAdapter = {
|
||||
platform: 'tauri',
|
||||
invoke: (cmd, args) => tauriInvoke(cmd, args as Record<string, unknown>),
|
||||
listen: async (event, cb) => tauriListen(event, (e) => cb(e)),
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
{ "name": "@hanzo/web-shim", "private": true, "type": "module",
|
||||
"dependencies": { "@hanzo/ai": "workspace:*", "@hanzo/brand": "*",
|
||||
"react": "^19.0.0", "react-dom": "^19.0.0", "vite": "^7.0.0" } }
|
||||
@@ -0,0 +1,8 @@
|
||||
// hanzo.chat — the same app on the web. No host prop = web defaults.
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import HanzoAI from '@hanzo/ai';
|
||||
import brand from '@hanzo/brand';
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<HanzoAI {...brand} features={{ chat: true, wallet: true, agents: true }} />,
|
||||
);
|
||||
@@ -0,0 +1,3 @@
|
||||
{ "name": "@zoo/desktop-shim", "private": true, "type": "module",
|
||||
"dependencies": { "@hanzo/ai": "workspace:*", "@zooai/brand": "*",
|
||||
"react": "^19.0.0", "react-dom": "^19.0.0", "@tauri-apps/api": "^2.0.0" } }
|
||||
@@ -0,0 +1,12 @@
|
||||
// zoo-desktop — the entire app. Brand is a prop; platform is an injected host.
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import HanzoAI from '@hanzo/ai';
|
||||
import brand from '@zooai/brand';
|
||||
import { tauriHost } from '../../tauri-host';
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<HanzoAI {...brand}
|
||||
host={tauriHost}
|
||||
features={{ chat: true, wallet: true, mining: true, tools: true, agents: true }}
|
||||
/>,
|
||||
);
|
||||
@@ -0,0 +1,65 @@
|
||||
{
|
||||
"name": "@hanzo/ai",
|
||||
"version": "0.1.0",
|
||||
"description": "The Hanzo AI app as one declarative SDK component \u2014 <HanzoAI {...brand} />. Brand is a prop; hanzo/zoo/lux apps are thin shims (the @luxfi/exchange pattern, for AI).",
|
||||
"license": "BSD-3-Clause",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
"main": "dist/index.cjs",
|
||||
"module": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js",
|
||||
"require": "./dist/index.cjs"
|
||||
},
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"BUILD.md"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "vite build && tsc -p tsconfig.build.json --emitDeclarationOnly",
|
||||
"dev": "vite build --watch",
|
||||
"clean": "rm -rf dist"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=18",
|
||||
"react-dom": ">=18"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hanzo_network/hanzo-ui": "workspace:*",
|
||||
"@hanzo_network/hanzo-node-state": "workspace:*",
|
||||
"@hanzo_network/hanzo-i18n": "workspace:*",
|
||||
"@hanzo_network/hanzo-message-ts": "workspace:*",
|
||||
"lucide-react": "^0.460.0",
|
||||
"@tanstack/react-query": "^5.0.0",
|
||||
"date-fns": "^4.1.0",
|
||||
"recharts": "^2.13.0",
|
||||
"cronstrue": "^2.50.0",
|
||||
"@radix-ui/react-icons": "^1.3.0",
|
||||
"@radix-ui/react-popover": "^1.1.0",
|
||||
"@rjsf/core": "^5.22.0",
|
||||
"@rjsf/validator-ajv8": "^5.22.0",
|
||||
"primereact": "^10.8.0",
|
||||
"prism-react-editor": "^3.0.0",
|
||||
"yjs": "^13.6.0",
|
||||
"react-router": "^7.0.0",
|
||||
"react-router-dom": "^7.0.0",
|
||||
"react-error-boundary": "^4.1.0",
|
||||
"zustand": "^5.0.0",
|
||||
"react-i18next": "^15.0.0",
|
||||
"i18next": "^24.0.0",
|
||||
"clsx": "^2.1.0",
|
||||
"tailwind-merge": "^2.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"@vitejs/plugin-react": "^4.7.0",
|
||||
"typescript": "^5.8.3",
|
||||
"vite": "^7.0.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { I18nProvider } from '@hanzo_network/hanzo-i18n';
|
||||
import { QueryProvider } from '@hanzo_network/hanzo-node-state';
|
||||
import { Toaster, TooltipProvider } from '@hanzo_network/hanzo-ui';
|
||||
import { info } from '@tauri-apps/plugin-log';
|
||||
import { useEffect } from 'react';
|
||||
import { ErrorBoundary } from 'react-error-boundary';
|
||||
import { BrowserRouter as Router } from 'react-router';
|
||||
|
||||
import FullPageErrorFallback from './components/error-boundary';
|
||||
import { OAuthConnect } from './components/oauth/oauth-connect';
|
||||
import { useEmbeddingMigrationToast } from './lib/embedding-migration/embedding-migration-hooks';
|
||||
import { useEmbeddingStartupCheck } from './lib/embedding-migration/embedding-startup-check-hooks';
|
||||
import { AnalyticsProvider } from './lib/posthog-provider';
|
||||
import AppRoutes from './routes';
|
||||
import { useSyncStorageSecondary } from './store/sync-utils';
|
||||
|
||||
// Component that wraps router content and calls hooks that need router context
|
||||
function RouterContent() {
|
||||
useEmbeddingStartupCheck(); // This needs router context for useNavigate()
|
||||
|
||||
return <AppRoutes />;
|
||||
}
|
||||
|
||||
// Component that wraps features requiring QueryClient
|
||||
function AppWithQuery() {
|
||||
useEmbeddingMigrationToast(); // This doesn't need router context
|
||||
|
||||
return (
|
||||
<>
|
||||
<OAuthConnect />
|
||||
<Router>
|
||||
<RouterContent />
|
||||
</Router>
|
||||
<Toaster />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function App() {
|
||||
useEffect(() => {
|
||||
void info('initializing main');
|
||||
}, []);
|
||||
useSyncStorageSecondary();
|
||||
return (
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<I18nProvider>
|
||||
<ErrorBoundary FallbackComponent={FullPageErrorFallback}>
|
||||
<AnalyticsProvider>
|
||||
<QueryProvider>
|
||||
<AppWithQuery />
|
||||
</QueryProvider>
|
||||
</AnalyticsProvider>
|
||||
</ErrorBoundary>
|
||||
</I18nProvider>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 4.8 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 5.7 KiB |
@@ -0,0 +1,61 @@
|
||||
import { useTranslation } from '@hanzo_network/hanzo-i18n';
|
||||
import {
|
||||
Button,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
Input,
|
||||
} from '@hanzo_network/hanzo-ui';
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
export default function AddAgentFromIdModal() {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [agentId, setAgentId] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setAgentId('@@');
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const handleAdd = () => {
|
||||
// TODO: Add agent using the provided Hanzo ID
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline" size="sm">
|
||||
{t('networkAgentsPage.addAgentFromId')}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('networkAgentsPage.addAgentFromId')}</DialogTitle>
|
||||
<DialogDescription className="space-y-2">
|
||||
<p>{t('networkAgentsPage.addAgentFromIdDescription')}</p>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Input
|
||||
placeholder={t('networkAgentsPage.addAgentFromIdPlaceholder')}
|
||||
value={agentId}
|
||||
onChange={(e) => setAgentId(e.target.value)}
|
||||
/>
|
||||
<DialogFooter className="flex-row gap-1">
|
||||
<Button variant="outline" size="md" onClick={() => setOpen(false)}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button size="md" onClick={handleAdd}>
|
||||
{t('common.add')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user