Compare commits
106
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9c332f6823 | ||
|
|
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 |
@@ -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: [self-hosted, 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: [self-hosted, 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: [self-hosted, 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: [self-hosted, 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: [self-hosted, 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: [self-hosted, 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: [self-hosted, linux, amd64]
|
||||
# needs: release
|
||||
|
||||
steps:
|
||||
- uses: marvinpinto/action-automatic-releases@latest
|
||||
with:
|
||||
repo_token: '${{ github.token }}'
|
||||
prerelease: false
|
||||
@@ -0,0 +1,102 @@
|
||||
name: sync
|
||||
|
||||
# Carries pushed refs to git.hanzo.ai, the canonical forge, and does nothing
|
||||
# else: build, test and release run natively there.
|
||||
#
|
||||
# Repo-agnostic. owner/name come from ${{ github.repository }}, so this file is
|
||||
# dropped into every repo unchanged as .github/workflows/sync.yml and is the
|
||||
# only workflow GitHub is permitted to run.
|
||||
|
||||
on:
|
||||
push:
|
||||
# '**' matches slashes; '*' does not, and would skip feature/foo.
|
||||
branches: ['**']
|
||||
tags: ['**']
|
||||
workflow_dispatch:
|
||||
|
||||
# Serialize per ref. Never cancel: a cancelled run drops that ref and nothing
|
||||
# reconciles it afterwards.
|
||||
concurrency:
|
||||
group: sync-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
sync:
|
||||
# .github/workflows is also on the forge's own workflow search path, so this
|
||||
# file executes there too in any repo without .hanzo/workflows, where it
|
||||
# would push the forge to itself. server_url is the forge's AppURL, which
|
||||
# makes this the test for "running on GitHub".
|
||||
if: github.server_url == 'https://github.com'
|
||||
# GitHub-hosted on purpose. This is the one workflow that must run ON GitHub,
|
||||
# and the self-hosted pool that used to serve it is retired — a job asking for
|
||||
# it now queues forever against a label nothing answers. Carrying refs is a
|
||||
# push, not a build, so it does not belong on the build fabric anyway: builds
|
||||
# run natively on git.hanzo.ai, which is the whole point of this file.
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
fetch-tags: true
|
||||
persist-credentials: false
|
||||
|
||||
- name: Push refs to git.hanzo.ai
|
||||
env:
|
||||
FORGE: https://git.hanzo.ai
|
||||
GIT_TOKEN: ${{ secrets.GIT_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
if [ -z "${GIT_TOKEN}" ]; then
|
||||
echo "::error::GIT_TOKEN is unset, so this push never reached the canonical forge. Add the org secret."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
REMOTE="${FORGE}/${GITHUB_REPOSITORY}.git"
|
||||
|
||||
# The forge reads the token from the basic-auth password and ignores
|
||||
# the username. base64 of a secret is not masked automatically.
|
||||
AUTH="$(printf 'x-access-token:%s' "${GIT_TOKEN}" | base64 -w0)"
|
||||
echo "::add-mask::${AUTH}"
|
||||
# Host-scoped, so the forge token is never sent to github.com.
|
||||
git config --local "http.${FORGE}/.extraheader" "Authorization: Basic ${AUTH}"
|
||||
|
||||
# Reachability is the only transient failure here, so it is the only
|
||||
# thing retried. ls-remote exits 0 on an existing empty repo and
|
||||
# non-zero when the forge is down or the repo is absent.
|
||||
ERR="$(mktemp)"
|
||||
REACHED=""
|
||||
for attempt in 1 2 3; do
|
||||
if git ls-remote "${REMOTE}" >/dev/null 2>"${ERR}"; then
|
||||
REACHED=1
|
||||
break
|
||||
fi
|
||||
echo "forge probe failed (attempt ${attempt}/3): $(cat "${ERR}")"
|
||||
if [ "${attempt}" -lt 3 ]; then
|
||||
sleep $((attempt * 5))
|
||||
fi
|
||||
done
|
||||
if [ -z "${REACHED}" ]; then
|
||||
echo "::error::git.hanzo.ai is unreachable or ${GITHUB_REPOSITORY} does not exist there. $(cat "${ERR}")"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Additive and fast-forward-only: no leading '+', no --force, no
|
||||
# --prune, no --mirror. Tags go every run so a tag that missed its own
|
||||
# event still lands; git refuses to move one that already differs.
|
||||
REFS=('refs/tags/*:refs/tags/*')
|
||||
case "${GITHUB_REF}" in
|
||||
refs/heads/*) REFS+=("${GITHUB_REF}:${GITHUB_REF}") ;;
|
||||
esac
|
||||
|
||||
# Not --atomic: a diverged ref must not hold back the refs that are
|
||||
# clean. Rejections still fail the job.
|
||||
if ! git push "${REMOTE}" "${REFS[@]}"; then
|
||||
echo "::error::git.hanzo.ai rejected a ref for ${GITHUB_REPOSITORY}. The forge is canonical and is never force-updated from here; reconcile the diverged branch or tag by hand."
|
||||
exit 1
|
||||
fi
|
||||
@@ -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: [self-hosted, 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: [self-hosted, 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,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,121 @@
|
||||
name: Publish @hanzo/ai
|
||||
|
||||
# Surgical publish of ONLY the @hanzo/ai SDK (pkgs/ai). The desktop apps
|
||||
# (hanzo/zoo/lux) render `<HanzoAI/>` from this package; it must be on npm
|
||||
# for a clean `npm i` to resolve it.
|
||||
#
|
||||
# Triggered only on `release/ai-v*` tags. A version bump alone does NOT
|
||||
# publish — the tag gates the run. Tag scheme:
|
||||
# release/ai-v0.1.1 -> @hanzo/ai@0.1.1
|
||||
#
|
||||
# NPM_TOKEN resolution order (same shape as 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 until KMS is provisioned)
|
||||
#
|
||||
# Idempotent: skips if @hanzo/ai@<version> is already on npm.
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'release/ai-v*'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
confirm:
|
||||
description: 'Type "publish" to publish @hanzo/ai at its current on-disk version'
|
||||
required: true
|
||||
default: ''
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
|
||||
# A transitive puppeteer dep tries to download Chromium in its postinstall,
|
||||
# which fails on the runner and kills `bun install` (run #7). The publish
|
||||
# never drives a browser, so skip every browser-binary download.
|
||||
env:
|
||||
PUPPETEER_SKIP_DOWNLOAD: '1'
|
||||
PUPPETEER_SKIP_CHROMIUM_DOWNLOAD: '1'
|
||||
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: '1'
|
||||
PUPPETEER_SKIP_CHROME_DOWNLOAD: '1'
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
name: Build, smoke-test, publish @hanzo/ai
|
||||
# Same runner publish-gui.yml uses. @hanzo/ai is arch-independent JS, and the
|
||||
# arm64 label this asked for is answered by one frequently-offline host — a job
|
||||
# requesting a label nothing answers queues silently instead of failing, which
|
||||
# is the worst outcome for a publisher. KMS is reachable from either pool.
|
||||
runs-on: hanzo-build-linux-amd64
|
||||
if: github.event_name == 'push' || github.event.inputs.confirm == 'publish'
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
# Shallow: the publish only builds pkgs/ai — it needs no git history.
|
||||
# A full clone (fetch-depth 0) of this monorepo's history OOMs git
|
||||
# index-pack on the runner and fails checkout (runs #5/#6 died here).
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Install
|
||||
uses: ./.github/actions/install
|
||||
|
||||
# Canonical, deterministic KMS auth — the ONE way every hanzo/lux/zoo repo
|
||||
# loads secrets (see hanzoai/universe/.github/actions/kms-action). OIDC-
|
||||
# first: GitHub's id-token proves this repo's identity to KMS, so NOTHING
|
||||
# is stored — only non-secret `vars.*` config. NPM_TOKEN is exported into
|
||||
# the job env at `secret-path`. No repo NPM_TOKEN secret, no client_id/
|
||||
# secret. Set KMS_IDENTITY_ID + KMS_PROJECT_ID once at the org level.
|
||||
- name: Load NPM_TOKEN from Hanzo KMS (OIDC)
|
||||
uses: hanzoai/universe/.github/actions/kms-action@main
|
||||
with:
|
||||
identity-id: ${{ vars.KMS_IDENTITY_ID }}
|
||||
project-id: ${{ vars.KMS_PROJECT_ID }}
|
||||
environment: ${{ vars.KMS_ENVIRONMENT || 'prod' }}
|
||||
secret-path: ${{ vars.KMS_PUBLISH_SECRET_PATH || '/publish' }}
|
||||
export-type: env
|
||||
|
||||
- name: Configure npm registry
|
||||
run: |
|
||||
# NPM_TOKEN is now in the job env (kms-action export-type=env).
|
||||
test -n "${NPM_TOKEN:-}" || { echo "::error::KMS did not yield NPM_TOKEN at the publish path"; exit 1; }
|
||||
echo 'registry=https://registry.npmjs.org/' > ~/.npmrc
|
||||
echo "//registry.npmjs.org/:_authToken=${NPM_TOKEN}" >> ~/.npmrc
|
||||
|
||||
- name: Build @hanzo/ai (dist + dist-desktop)
|
||||
# pkgs/ai's own build runs BOTH vite passes: dist/ (web entry,
|
||||
# ai.css) and dist-desktop/ (the <HanzoAI/> desktop bundle the apps
|
||||
# import). Build it explicitly so both trees exist before publish.
|
||||
working-directory: pkgs/ai
|
||||
run: bun run build
|
||||
|
||||
- name: Smoke-test dist trees + exports
|
||||
run: |
|
||||
set -eu
|
||||
cd pkgs/ai
|
||||
test -f dist/index.js || (echo "::error::dist/index.js missing"; exit 1)
|
||||
test -f dist/ai.css || (echo "::error::dist/ai.css missing"; exit 1)
|
||||
test -f dist-desktop/index.js || (echo "::error::dist-desktop/index.js missing"; exit 1)
|
||||
# Every file referenced from the exports map must exist.
|
||||
node -e "
|
||||
const fs=require('fs');
|
||||
const pkg=require('./package.json');
|
||||
const walk=(v)=>{
|
||||
if (typeof v==='string') { if (v.startsWith('./') && !fs.existsSync(v)) { console.error('missing:',v); process.exitCode=1; } }
|
||||
else if (v && typeof v==='object') { for (const k of Object.keys(v)) walk(v[k]); }
|
||||
};
|
||||
walk(pkg.exports||{}); walk(pkg.main); walk(pkg.module); walk(pkg.types);
|
||||
"
|
||||
echo "smoke OK: $(node -p "require('./package.json').name + '@' + require('./package.json').version")"
|
||||
|
||||
- name: Publish @hanzo/ai
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ env.NPM_TOKEN }}
|
||||
working-directory: pkgs/ai
|
||||
run: |
|
||||
set -eu
|
||||
version=$(node -p "require('./package.json').version")
|
||||
if npm view "@hanzo/ai@${version}" version >/dev/null 2>&1; then
|
||||
echo "@hanzo/ai@${version} already on npm - skipping."
|
||||
exit 0
|
||||
fi
|
||||
# pkgs/ai has no workspace:* deps, so a direct publish is clean.
|
||||
npm publish --access public
|
||||
@@ -34,7 +34,10 @@ permissions:
|
||||
jobs:
|
||||
publish-all:
|
||||
name: Build + publish all gui packages
|
||||
runs-on: [self-hosted, linux, arm64]
|
||||
# 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
|
||||
@@ -28,7 +28,9 @@ permissions:
|
||||
jobs:
|
||||
publish:
|
||||
name: Build, smoke-test, publish
|
||||
runs-on: [self-hosted, linux, arm64]
|
||||
# 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
|
||||
with:
|
||||
@@ -6,6 +6,75 @@ FOR LONG RUNNNING DEBUGGING run `bun run watch` in the background its faster and
|
||||
|
||||
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/publish-ai.yml @hanzo/ai
|
||||
.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 |
|
||||
| `release/ai-v*` | `publish-ai.yml` | `@hanzo/ai` from `pkgs/ai` |
|
||||
|
||||
`NPM_TOKEN` comes from KMS with a repo-secret fallback in the two `publish-gui*`
|
||||
workflows, and from `hanzoai/universe/.github/actions/kms-action` over OIDC in
|
||||
`publish-ai.yml`. The forge has to be able to resolve that cross-repo action, so
|
||||
`hanzoai/universe` must exist there; if it does not, the KMS step is the thing that
|
||||
fails.
|
||||
|
||||
`publish-ai.yml` asked for `runs-on: [self-hosted, linux, arm64]`, which is one
|
||||
frequently-offline host. A job requesting a label nothing answers queues silently
|
||||
instead of failing, which is the worst possible outcome for a publisher, so it now
|
||||
uses `hanzo-build-linux-amd64` like its two siblings — `@hanzo/ai` is
|
||||
arch-independent JS.
|
||||
|
||||
## 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
|
||||
@@ -123,6 +192,66 @@ const response = await authFetch('/api/some-endpoint', {
|
||||
**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)
|
||||
@@ -134,3 +263,31 @@ const response = await authFetch('/api/some-endpoint', {
|
||||
<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)
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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,60 @@
|
||||
# Hanzo Team
|
||||
|
||||
The hanzo.team shell. React owns the chrome; Svelte views mount inside it while
|
||||
they wait to be ported.
|
||||
|
||||
```bash
|
||||
bun run dev # dev server on :3000
|
||||
bun run build # production build → dist
|
||||
bun run typecheck # tsc
|
||||
bun run test # playwright, real browser
|
||||
```
|
||||
|
||||
Desktop wraps `dist` with Tauri: build, then `cargo tauri dev` inside `src-tauri/`.
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
components/Shell.tsx the chrome — sidebar, header, palette, account
|
||||
components/Svelte.tsx the ONE seam that mounts a Svelte view
|
||||
components/props.svelte.ts reactive props for a mounted view
|
||||
src/views.ts the view registry: each entry is React or Svelte
|
||||
src/brand.ts hostname -> brand
|
||||
src/session.ts hanzo.id delegation and the token
|
||||
src/account.ts POST /v1/team/account
|
||||
src/theme.css the design tokens BOTH engines read
|
||||
```
|
||||
|
||||
## The seam
|
||||
|
||||
`Svelte.tsx` is the only way a Svelte view reaches the screen. React owns the
|
||||
shell and navigation; a view receives props and renders, and cannot reach the
|
||||
chrome. Porting a view to React means changing which key its registry entry
|
||||
carries — `svelte` becomes `react` — and nothing else, which is what makes the
|
||||
remaining `*-resources` plugins a queue rather than a cliff.
|
||||
|
||||
`tests/seam.spec.ts` cycles mount/unmount 25 times and asserts the live-instance
|
||||
and listener counts return to zero. That assertion is load-bearing and has been
|
||||
shown to fail when teardown is removed: the DOM looks clean either way, because
|
||||
React removes the host element whether or not the Svelte instance was destroyed.
|
||||
|
||||
## Brand
|
||||
|
||||
Brand is a function of hostname (`src/brand.ts`), and an unrecognized host
|
||||
resolves to no brand rather than to a default — defaulting is how one brand's
|
||||
mark lands on another's host. The mark comes from `@hanzo/logo`, which ships only
|
||||
Hanzo's, so a non-Hanzo brand renders its wordmark and there is no code path that
|
||||
can do otherwise. `tests/brand.spec.ts` asserts the negatives, against real
|
||||
hostnames via Chromium's host-resolver rules.
|
||||
|
||||
## Sign-in
|
||||
|
||||
hanzo.id is the only door. The backend owns the whole OAuth hop — it holds the
|
||||
client id, mints and checks `state`, exchanges the code — so sign-in is one
|
||||
navigation to `/v1/team/account/auth/openid` and there is deliberately no
|
||||
credential form. `POST /v1/team/account {method:"login"}` answers
|
||||
`account:status:Unauthorized "sign in at hanzo.id"`.
|
||||
|
||||
The backend bounces back to `/login:component:LoginApp/auth?token=…` on success
|
||||
and `/login?error=…` on failure, so the token is read from the query rather than
|
||||
from a route — the success path is a Huly location string that means nothing here.
|
||||
@@ -0,0 +1,96 @@
|
||||
import {
|
||||
Avatar,
|
||||
AvatarFallback,
|
||||
Button,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@hanzo/ui'
|
||||
import { byOrg, type Login, type Workspace } from '~/src/account'
|
||||
import { signIn, signOut } from '~/src/session'
|
||||
|
||||
/*
|
||||
* Identity and scope: which workspace is open, and who has it open.
|
||||
*
|
||||
* Workspaces are grouped by owning org because `getUserWorkspaces` unions across
|
||||
* every org the user belongs to — a user in two orgs sees both, each tagged. So
|
||||
* the org is a heading over its workspaces rather than a second separate control:
|
||||
* picking a workspace IS picking its org, and two controls could disagree.
|
||||
*/
|
||||
export function Account({
|
||||
login,
|
||||
workspaces,
|
||||
current,
|
||||
onSelect,
|
||||
}: {
|
||||
login: Login | undefined
|
||||
workspaces: readonly Workspace[]
|
||||
current: string | undefined
|
||||
onSelect: (workspace: Workspace) => void
|
||||
}) {
|
||||
if (login === undefined) {
|
||||
return (
|
||||
<Button size="sm" onClick={signIn} data-account="out">
|
||||
Sign in
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
const name = login.name !== undefined && login.name !== '' ? login.name : login.account
|
||||
const groups = byOrg(workspaces)
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="gap-2" data-account="in">
|
||||
<Avatar className="size-6">
|
||||
<AvatarFallback className="text-[10px]">
|
||||
{name.slice(0, 2).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="max-w-40 truncate text-xs">{name}</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent align="end" className="w-64">
|
||||
<DropdownMenuLabel className="text-muted-foreground text-[11px] font-normal">
|
||||
{login.account}
|
||||
</DropdownMenuLabel>
|
||||
|
||||
{groups.map((group) => (
|
||||
<div key={group.org}>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuLabel className="text-[10px] font-medium uppercase tracking-wider">
|
||||
{group.org !== '' ? group.org : 'Personal'}
|
||||
</DropdownMenuLabel>
|
||||
{group.workspaces.map((workspace) => (
|
||||
<DropdownMenuItem
|
||||
key={workspace.uuid}
|
||||
data-workspace={workspace.url}
|
||||
aria-current={workspace.url === current ? 'true' : undefined}
|
||||
disabled={workspace.isDisabled}
|
||||
onSelect={() => onSelect(workspace)}
|
||||
>
|
||||
<span className="truncate">{workspace.name}</span>
|
||||
{workspace.url === current ? <span className="ml-auto text-xs">✓</span> : null}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem asChild>
|
||||
<a href="https://hanzo.id/account" data-account-link="settings">
|
||||
Settings
|
||||
</a>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={signOut} data-account-link="out">
|
||||
Sign out
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { MARK_PATHS, MARK_VIEWBOX } from '@hanzo/logo'
|
||||
import type { Brand } from '~/src/brand'
|
||||
|
||||
/*
|
||||
* The brand mark, from the brand pack.
|
||||
*
|
||||
* The geometry comes from `@hanzo/logo` — the app holds no path data of its own,
|
||||
* so a brand change is a package bump rather than an edit here.
|
||||
*
|
||||
* The pack ships ONE mark, Hanzo's. So a brand belonging to any other org has no
|
||||
* mark available and renders its wordmark instead. That is the whole white-label
|
||||
* guarantee, and it holds structurally rather than by remembering to check:
|
||||
* there is no code path that can put Hanzo's mark on a Lux or Zoo host, because
|
||||
* the mark is selected by `brand.org` and only `hanzo` has one.
|
||||
*
|
||||
* This owns the ENTIRE lockup — glyph and name together — so exactly one place
|
||||
* decides how a brand presents itself. Splitting it, with a caller rendering the
|
||||
* name alongside, is what produced "Lux Team Lux Team": the wordmark already IS
|
||||
* the name, and a caller cannot know that without re-deciding it.
|
||||
*/
|
||||
export function Mark({ brand, size = 22 }: { brand: Brand | undefined; size?: number }) {
|
||||
// Unknown host — no brand claims it, so show nothing rather than a guess.
|
||||
if (brand === undefined) return null
|
||||
|
||||
// No glyph for this org: the name is the mark.
|
||||
if (brand.org !== 'hanzo') {
|
||||
return (
|
||||
<span data-mark={brand.org} className="truncate text-sm font-semibold tracking-tight">
|
||||
{brand.name}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<svg
|
||||
data-mark="hanzo"
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox={MARK_VIEWBOX}
|
||||
aria-hidden="true"
|
||||
className="shrink-0 fill-foreground"
|
||||
// Static markup from the brand pack, not input.
|
||||
dangerouslySetInnerHTML={{ __html: MARK_PATHS }}
|
||||
/>
|
||||
<span className="truncate text-sm font-semibold tracking-tight">{brand.name}</span>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import {
|
||||
CommandDialog,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from '@hanzo/ui'
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { Workspace } from '~/src/account'
|
||||
import type { View } from '~/src/views'
|
||||
|
||||
/*
|
||||
* Cmd+K. One keyboard path to every view and every workspace.
|
||||
*
|
||||
* The palette navigates; it does not act. Selecting a view asks the shell to
|
||||
* change view, exactly as clicking the sidebar does — the same call, so the two
|
||||
* cannot drift into disagreeing about what "active" means.
|
||||
*/
|
||||
export function Palette({
|
||||
views,
|
||||
workspaces,
|
||||
onView,
|
||||
onWorkspace,
|
||||
}: {
|
||||
views: readonly View[]
|
||||
workspaces: readonly Workspace[]
|
||||
onView: (id: string) => void
|
||||
onWorkspace: (workspace: Workspace) => void
|
||||
}) {
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'k' && (e.metaKey || e.ctrlKey)) {
|
||||
e.preventDefault()
|
||||
setOpen((v) => !v)
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', onKey)
|
||||
return () => window.removeEventListener('keydown', onKey)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<CommandDialog open={open} onOpenChange={setOpen} title="Command palette">
|
||||
<CommandInput placeholder="Search views and workspaces…" data-palette="input" />
|
||||
<CommandList>
|
||||
<CommandEmpty>Nothing matches.</CommandEmpty>
|
||||
|
||||
<CommandGroup heading="Views">
|
||||
{views.map((view) => (
|
||||
<CommandItem
|
||||
key={view.id}
|
||||
value={`view ${view.label}`}
|
||||
data-palette-view={view.id}
|
||||
onSelect={() => {
|
||||
onView(view.id)
|
||||
setOpen(false)
|
||||
}}
|
||||
>
|
||||
{view.label}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
|
||||
{workspaces.length > 0 ? (
|
||||
<CommandGroup heading="Workspaces">
|
||||
{workspaces.map((workspace) => (
|
||||
<CommandItem
|
||||
key={workspace.uuid}
|
||||
value={`workspace ${workspace.name}`}
|
||||
data-palette-workspace={workspace.url}
|
||||
onSelect={() => {
|
||||
onWorkspace(workspace)
|
||||
setOpen(false)
|
||||
}}
|
||||
>
|
||||
{workspace.name}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
) : null}
|
||||
</CommandList>
|
||||
</CommandDialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import { Separator } from '@hanzo/ui'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import {
|
||||
login as fetchLogin,
|
||||
workspaces as fetchWorkspaces,
|
||||
type Login,
|
||||
type Workspace,
|
||||
} from '~/src/account'
|
||||
import { brandFor } from '~/src/brand'
|
||||
import { claim, token } from '~/src/session'
|
||||
import { VIEWS, viewFor, type Props } from '~/src/views'
|
||||
import { Account } from './Account'
|
||||
import { Palette } from './Palette'
|
||||
import { Sidebar } from './Sidebar'
|
||||
import { Svelte } from './Svelte'
|
||||
|
||||
/*
|
||||
* The chrome.
|
||||
*
|
||||
* React owns the frame and the navigation. The sidebar decides which view is
|
||||
* active; the content area renders it. A view is React or Svelte and gets the
|
||||
* same props either way, so which language it happens to be written in is not
|
||||
* something the shell — or the user — can observe.
|
||||
*/
|
||||
export function Shell() {
|
||||
const brand = useMemo(() => brandFor(window.location.hostname), [])
|
||||
const [active, setActive] = useState(() => window.location.hash.replace(/^#/, '') || VIEWS[0].id)
|
||||
const [login, setLogin] = useState<Login | undefined>(undefined)
|
||||
const [workspaces, setWorkspaces] = useState<readonly Workspace[]>([])
|
||||
const [workspace, setWorkspace] = useState<string | undefined>(undefined)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// Take any token the backend handed back before asking who we are.
|
||||
useEffect(() => {
|
||||
setError(claim().error)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (token() === null) return
|
||||
let live = true
|
||||
void (async () => {
|
||||
try {
|
||||
const [who, list] = await Promise.all([fetchLogin(), fetchWorkspaces()])
|
||||
if (!live) return
|
||||
setLogin(who)
|
||||
setWorkspaces(list)
|
||||
setWorkspace((current) => current ?? list[0]?.url)
|
||||
} catch (e) {
|
||||
if (live) setError(e instanceof Error ? e.message : String(e))
|
||||
}
|
||||
})()
|
||||
return () => {
|
||||
live = false
|
||||
}
|
||||
}, [])
|
||||
|
||||
const select = useCallback((id: string) => {
|
||||
setActive(id)
|
||||
window.location.hash = id
|
||||
}, [])
|
||||
|
||||
const view = viewFor(active)
|
||||
|
||||
// One object per (workspace, token) pair rather than per render, so a live
|
||||
// Svelte view is not re-pushed props it already has.
|
||||
const props = useMemo<Props>(() => ({ workspace: workspace ?? '', token: token() }), [workspace])
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full">
|
||||
<Sidebar brand={brand} views={VIEWS} active={view.id} onSelect={select} />
|
||||
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<header className="flex h-14 shrink-0 items-center gap-3 px-4" role="banner">
|
||||
<span className="truncate text-sm font-medium" data-shell="title">
|
||||
{view.label}
|
||||
</span>
|
||||
<div className="flex-1" />
|
||||
<Account
|
||||
login={login}
|
||||
workspaces={workspaces}
|
||||
current={workspace}
|
||||
onSelect={(w) => setWorkspace(w.url)}
|
||||
/>
|
||||
</header>
|
||||
|
||||
<Separator />
|
||||
|
||||
{error !== null ? (
|
||||
<p role="alert" data-shell="error" className="text-destructive px-4 py-2 text-xs">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<main className="min-h-0 flex-1 overflow-auto p-4" data-shell="content">
|
||||
{'react' in view.content ? (
|
||||
<view.content.react {...props} />
|
||||
) : (
|
||||
<Svelte view={view.content.svelte} props={props} className="h-full" />
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<Palette
|
||||
views={VIEWS}
|
||||
workspaces={workspaces}
|
||||
onView={select}
|
||||
onWorkspace={(w) => setWorkspace(w.url)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { Button, ScrollArea, Separator } from '@hanzo/ui'
|
||||
import type { Brand } from '~/src/brand'
|
||||
import type { View } from '~/src/views'
|
||||
import { Mark } from './Mark'
|
||||
|
||||
/*
|
||||
* The navigator. It owns which view is active — a view never moves itself.
|
||||
*
|
||||
* Composed from `@hanzo/ui` primitives rather than imported whole, because the
|
||||
* Sidebar family is not consumable: `@hanzo/ui@8.0.26`'s barrel exports 86 names
|
||||
* and none of them are Sidebar*, and `@hanzo/ui-shadcn@5.9.1` carries the 726-line
|
||||
* source but ships no `dist/primitives/sidebar`, so the 23 Sidebar exports resolve
|
||||
* from neither package. Publishing it collapses this file to an import.
|
||||
*/
|
||||
export function Sidebar({
|
||||
brand,
|
||||
views,
|
||||
active,
|
||||
onSelect,
|
||||
}: {
|
||||
brand: Brand | undefined
|
||||
views: readonly View[]
|
||||
active: string
|
||||
onSelect: (id: string) => void
|
||||
}) {
|
||||
return (
|
||||
<nav
|
||||
aria-label="Views"
|
||||
className="flex h-full w-60 shrink-0 flex-col border-r border-border bg-card"
|
||||
>
|
||||
<div className="flex h-14 items-center gap-2 px-4">
|
||||
<Mark brand={brand} />
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<ScrollArea className="flex-1">
|
||||
<ul className="flex flex-col gap-0.5 p-2">
|
||||
{views.map((view) => (
|
||||
<li key={view.id}>
|
||||
<Button
|
||||
variant={view.id === active ? 'secondary' : 'ghost'}
|
||||
aria-current={view.id === active ? 'page' : undefined}
|
||||
data-view={view.id}
|
||||
className="w-full justify-start font-normal"
|
||||
onClick={() => onSelect(view.id)}
|
||||
>
|
||||
{view.label}
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</ScrollArea>
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { mount, unmount, type Component } from 'svelte'
|
||||
import { push, track } from './props.svelte'
|
||||
|
||||
/*
|
||||
* The one seam between the React shell and a Svelte view.
|
||||
*
|
||||
* React owns the shell, the sidebar and navigation. A Svelte view is only ever
|
||||
* content: the sidebar decides which view is active, this mounts it, and nothing
|
||||
* a view does can reach the chrome. Every unported Huly view arrives through
|
||||
* here, so porting one to React means changing which key its registry entry
|
||||
* carries (`svelte` -> `react`) and touching nothing else.
|
||||
*
|
||||
* Lifecycle, and why it is split across two effects:
|
||||
*
|
||||
* mount keyed on `view` alone, so switching sidebar items — or swapping a
|
||||
* view for its React port — rebuilds, and a mere prop change does not.
|
||||
* props assigns onto the tracked proxy, so a live view updates in place.
|
||||
* destroy the mount effect's cleanup calls `unmount`, which runs the view's
|
||||
* `onDestroy` and removes its nodes. Leaking one instance per
|
||||
* navigation is the failure mode this ordering exists to prevent;
|
||||
* tests/seam.spec.ts cycles it and asserts the live count returns to
|
||||
* zero, and that the assertion can fail.
|
||||
*/
|
||||
export function Svelte<P extends Record<string, unknown>>({
|
||||
view,
|
||||
props,
|
||||
className,
|
||||
}: {
|
||||
view: Component<P>
|
||||
props: P
|
||||
className?: string
|
||||
}) {
|
||||
const host = useRef<HTMLDivElement | null>(null)
|
||||
const tracked = useRef<P | null>(null)
|
||||
|
||||
// Read at mount time without making `props` a mount dependency.
|
||||
const latest = useRef(props)
|
||||
latest.current = props
|
||||
|
||||
useEffect(() => {
|
||||
const target = host.current
|
||||
if (target === null) return
|
||||
|
||||
const box = track(latest.current)
|
||||
const instance = mount(view, { target, props: box })
|
||||
tracked.current = box
|
||||
|
||||
return () => {
|
||||
tracked.current = null
|
||||
void unmount(instance, { outro: false })
|
||||
}
|
||||
}, [view])
|
||||
|
||||
useEffect(() => {
|
||||
if (tracked.current !== null) push(tracked.current, props)
|
||||
}, [props])
|
||||
|
||||
return <div ref={host} className={className} />
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
/*
|
||||
* Reactive props for a mounted Svelte view.
|
||||
*
|
||||
* `mount` reads its props object once. To reach a LIVE view a prop change has to
|
||||
* land on something Svelte tracks, so the seam mounts this proxy and then assigns
|
||||
* into it. Without this, a prop change could only be delivered by tearing the view
|
||||
* down and building a new one — which is both slower and a different lifecycle.
|
||||
*
|
||||
* This file is `.svelte.ts` because `$state` is a compiler rune, not a function.
|
||||
*/
|
||||
|
||||
/** A props object Svelte tracks. */
|
||||
export function track<P extends Record<string, unknown>>(initial: P): P {
|
||||
const props = $state({ ...initial })
|
||||
return props as P
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy `next` onto a tracked object so the mounted view sees the change.
|
||||
* Svelte's proxy compares before notifying, so assigning an unchanged value is
|
||||
* inert — the seam does not need to diff first.
|
||||
*/
|
||||
export function push<P extends Record<string, unknown>>(tracked: P, next: P): void {
|
||||
const target = tracked as Record<string, unknown>
|
||||
for (const key of Object.keys(next)) target[key] = next[key]
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en" class="dark">
|
||||
<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>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"name": "@hanzogui/team",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"serve": "vite preview",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "playwright test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hanzo/svelte": "^1.0.0",
|
||||
"@hanzo/ui": "^8.0.26",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"svelte": "^5.56.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.49.1",
|
||||
"@sveltejs/vite-plugin-svelte": "^6.2.1",
|
||||
"@tailwindcss/vite": "^4",
|
||||
"@types/react": "~19.1.10",
|
||||
"@types/react-dom": "~19.1.0",
|
||||
"@vitejs/plugin-react": "^5.0.4",
|
||||
"tailwindcss": "^4",
|
||||
"typescript": "~5.9.2",
|
||||
"vite": "^7.1.5"
|
||||
},
|
||||
"license": "BSD-3-Clause",
|
||||
"author": "Hanzo AI <dev@hanzo.ai>"
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { defineConfig } from '@playwright/test'
|
||||
|
||||
// Brand is a function of hostname, so the tests need REAL hostnames rather than a
|
||||
// stand-in for one. `--host-resolver-rules` points the app's actual hosts at the
|
||||
// local dev server, which is what makes tests/brand.spec.ts able to assert that
|
||||
// tracker.hanzo.ai does not render Hanzo Team's mark.
|
||||
const hosts = ['hanzo.team', 'team.hanzo.ai', 'tracker.hanzo.ai', 'team.lux.network']
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './tests',
|
||||
fullyParallel: false,
|
||||
forbidOnly: Boolean(process.env.CI),
|
||||
reporter: [['list']],
|
||||
use: {
|
||||
baseURL: 'http://localhost:3000',
|
||||
launchOptions: {
|
||||
// No spaces after the commas — Chromium takes them as part of the next host.
|
||||
args: [`--host-resolver-rules=${hosts.map((h) => `MAP ${h} 127.0.0.1`).join(',')}`],
|
||||
},
|
||||
},
|
||||
webServer: {
|
||||
// Bind IPv4 explicitly. Vite's default `localhost` resolves to [::1] here, and
|
||||
// the resolver rules above send the browser to 127.0.0.1 — mismatched families
|
||||
// present as ERR_CONNECTION_REFUSED rather than as a bind error.
|
||||
command: 'vite --host 127.0.0.1 --port 3000 --strictPort',
|
||||
url: 'http://localhost:3000',
|
||||
reuseExistingServer: true,
|
||||
timeout: 120_000,
|
||||
},
|
||||
})
|
||||
@@ -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,65 @@
|
||||
/*
|
||||
* The account plane: who is signed in, and which workspaces they can open.
|
||||
*
|
||||
* One JSON-RPC endpoint, `POST /v1/team/account`, with `{method, params}` in and
|
||||
* `{result}` or `{error}` out. Types mirror the Go structs in cloud
|
||||
* `apps/team/account.go` (`LoginInfo`, `WorkspaceInfo`) — same field names, same
|
||||
* casing, so a drift shows up as a type error rather than as an empty menu.
|
||||
*/
|
||||
|
||||
import { token } from './session'
|
||||
|
||||
/** `LoginInfo` — cloud apps/team/account.go:114. */
|
||||
export interface Login {
|
||||
account: string
|
||||
name?: string
|
||||
socialId?: string
|
||||
}
|
||||
|
||||
/** One entry of `getUserWorkspaces` — cloud apps/team/account.go:135. */
|
||||
export interface Workspace {
|
||||
uuid: string
|
||||
name: string
|
||||
url: string
|
||||
/** The owning IAM tenant. `getUserWorkspaces` unions across every org the user
|
||||
* belongs to, so the switcher groups on this. */
|
||||
org?: string
|
||||
region: string
|
||||
mode: string
|
||||
isDisabled: boolean
|
||||
}
|
||||
|
||||
export class Refused extends Error {}
|
||||
|
||||
async function call<T>(method: string, params: Record<string, unknown> = {}): Promise<T> {
|
||||
const bearer = token()
|
||||
const res = await fetch('/v1/team/account', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(bearer !== null ? { Authorization: `Bearer ${bearer}` } : {}),
|
||||
},
|
||||
body: JSON.stringify({ method, params }),
|
||||
})
|
||||
|
||||
const body = (await res.json()) as { result?: T; error?: { code?: string; params?: { message?: string } } }
|
||||
if (body.error !== undefined) {
|
||||
throw new Refused(body.error.params?.message ?? body.error.code ?? 'refused')
|
||||
}
|
||||
return body.result as T
|
||||
}
|
||||
|
||||
export const login = (): Promise<Login> => call<Login>('getLoginInfoByToken')
|
||||
export const workspaces = (): Promise<Workspace[]> => call<Workspace[]>('getUserWorkspaces')
|
||||
|
||||
/** Group workspaces by owning org, preserving first-seen order. */
|
||||
export function byOrg(list: readonly Workspace[]): { org: string; workspaces: Workspace[] }[] {
|
||||
const groups: { org: string; workspaces: Workspace[] }[] = []
|
||||
for (const workspace of list) {
|
||||
const org = workspace.org ?? ''
|
||||
const found = groups.find((g) => g.org === org)
|
||||
if (found === undefined) groups.push({ org, workspaces: [workspace] })
|
||||
else found.workspaces.push(workspace)
|
||||
}
|
||||
return groups
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
/*
|
||||
* Which brand a host serves.
|
||||
*
|
||||
* Shared infrastructure white-labels by domain, so the mark and the name are a
|
||||
* function of the hostname and nothing else — never a build flag, never a
|
||||
* constant in a component.
|
||||
*
|
||||
* Fail closed. An unrecognized host resolves to `undefined`, and the chrome then
|
||||
* renders no mark at all. Defaulting an unknown host to Hanzo is precisely the
|
||||
* bug this shape prevents: it is how one brand's mark ends up on another's host.
|
||||
*
|
||||
* `@hanzogui/shell`'s `findSurfaceByHost` cannot be reused here, and that is
|
||||
* worth stating because it looks like it should be. Its surface list keys one
|
||||
* host per surface (`hanzo.team`) and then falls back to the longest matching
|
||||
* suffix, so `team.hanzo.ai` and `tracker.hanzo.ai` both match `hanzo.ai` and
|
||||
* resolve to brandName "Hanzo". tests/brand.spec.ts pins that they must not.
|
||||
*/
|
||||
|
||||
/** The org whose brand is being rendered. Decides the mark. */
|
||||
export type Org = 'hanzo' | 'lux' | 'zoo'
|
||||
|
||||
export interface Brand {
|
||||
/** Stable id — also the active-surface key in the app switcher. */
|
||||
id: string
|
||||
/** Name shown beside the mark. */
|
||||
name: string
|
||||
org: Org
|
||||
/** Every host this brand serves. Exact match wins; then longest dot-boundary suffix. */
|
||||
hosts: readonly string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* `localhost` and `127.0.0.1` are listed deliberately. A dev host is a known
|
||||
* host, not a reason to weaken the fallback.
|
||||
*/
|
||||
export const BRANDS: readonly Brand[] = [
|
||||
{ id: 'team', name: 'Hanzo Team', org: 'hanzo', hosts: ['hanzo.team', 'team.hanzo.ai', 'localhost', '127.0.0.1'] },
|
||||
{ id: 'tracker', name: 'Tracker', org: 'hanzo', hosts: ['tracker.hanzo.ai'] },
|
||||
{ id: 'lux', name: 'Lux Team', org: 'lux', hosts: ['team.lux.network'] },
|
||||
{ id: 'zoo', name: 'Zoo Team', org: 'zoo', hosts: ['team.zoo.ngo'] },
|
||||
]
|
||||
|
||||
/**
|
||||
* Resolve the brand for a hostname, or `undefined` when no brand claims it.
|
||||
* Exact match first, so a specific host is never swallowed by a broader one.
|
||||
*/
|
||||
export function brandFor(host: string | undefined): Brand | undefined {
|
||||
if (host === undefined || host === '') return undefined
|
||||
const h = host.toLowerCase().replace(/^www\./, '').replace(/:\d+$/, '')
|
||||
|
||||
for (const brand of BRANDS) if (brand.hosts.includes(h)) return brand
|
||||
|
||||
let best: Brand | undefined
|
||||
let length = 0
|
||||
for (const brand of BRANDS) {
|
||||
for (const candidate of brand.hosts) {
|
||||
if (h.endsWith(`.${candidate}`) && candidate.length > length) {
|
||||
best = brand
|
||||
length = candidate.length
|
||||
}
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { Shell } from '~/components/Shell'
|
||||
import './theme.css'
|
||||
|
||||
const root = document.getElementById('root')
|
||||
if (root === null) throw new Error('#root missing')
|
||||
|
||||
// StrictMode stays on deliberately. It mounts every effect twice in development,
|
||||
// which means the Svelte seam's mount/destroy pair is exercised on every single
|
||||
// navigation — a leak there shows up immediately rather than in production.
|
||||
createRoot(root).render(
|
||||
<StrictMode>
|
||||
<Shell />
|
||||
</StrictMode>,
|
||||
)
|
||||
@@ -0,0 +1,55 @@
|
||||
/*
|
||||
* The signed-in session.
|
||||
*
|
||||
* hanzo.id is the only door. The backend owns the whole OAuth hop — it holds the
|
||||
* client id, mints and checks `state`, and exchanges the code — so this module
|
||||
* never sees a credential, a client secret, or a PKCE verifier. Verified against
|
||||
* the live service:
|
||||
*
|
||||
* GET /v1/team/account/auth/openid -> 302 hanzo.id/v1/iam/oauth/authorize?...
|
||||
* POST /v1/team/account {method:login} -> account:status:Unauthorized
|
||||
* "sign in at hanzo.id"
|
||||
*
|
||||
* Sign-in is therefore one navigation, and there is deliberately no credential
|
||||
* form to build.
|
||||
*/
|
||||
|
||||
const KEY = 'hanzo-team-token'
|
||||
|
||||
/** Start sign-in. The backend redirects to hanzo.id and back. */
|
||||
export function signIn(): void {
|
||||
window.location.assign('/v1/team/account/auth/openid')
|
||||
}
|
||||
|
||||
export function signOut(): void {
|
||||
window.localStorage.removeItem(KEY)
|
||||
window.location.assign('/login')
|
||||
}
|
||||
|
||||
export function token(): string | null {
|
||||
return window.localStorage.getItem(KEY)
|
||||
}
|
||||
|
||||
/**
|
||||
* Take the token the backend handed back, and report any error it reported.
|
||||
*
|
||||
* Read from the query rather than from a route, because the backend chooses the
|
||||
* path: it bounces to `/login:component:LoginApp/auth?token=…` on success and
|
||||
* `/login?error=…` on failure (cloud `apps/team/account.go:836`). The first is a
|
||||
* Huly location string that means nothing to this shell, so matching on the path
|
||||
* would couple us to it. The query is the actual contract.
|
||||
*/
|
||||
export function claim(): { error: string | null } {
|
||||
const query = new URLSearchParams(window.location.search)
|
||||
const handed = query.get('token')
|
||||
const error = query.get('error')
|
||||
|
||||
if (handed !== null && handed !== '') {
|
||||
window.localStorage.setItem(KEY, handed)
|
||||
// Drop the token from the address bar so it stays out of history and out of
|
||||
// any Referer this page goes on to send.
|
||||
window.history.replaceState(null, '', window.location.pathname.startsWith('/login') ? '/' : window.location.pathname)
|
||||
}
|
||||
|
||||
return { error }
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
@import 'tailwindcss';
|
||||
|
||||
/*
|
||||
* Tailwind must scan the component libraries, not just this app.
|
||||
*
|
||||
* v4 auto-detects sources but excludes node_modules, and both libraries ship
|
||||
* utility classes in their published output rather than a stylesheet. Without
|
||||
* these two lines the classes they rely on — `sr-only`, `z-50`, `fixed inset-0`,
|
||||
* the `data-[state=open]` variants — are never generated, so every dialog, menu
|
||||
* and palette renders unpositioned and unlayered. It looks like a broken
|
||||
* component and is actually a missing utility.
|
||||
*
|
||||
* Worth knowing that this failure is invisible to assertions on text and data
|
||||
* attributes: the markup is correct and only the styling is absent.
|
||||
*/
|
||||
@source "../node_modules/@hanzo/ui/dist";
|
||||
@source "../node_modules/@hanzo/svelte";
|
||||
|
||||
/*
|
||||
* The standard design tokens both backends read.
|
||||
*
|
||||
* `@hanzo/ui` renders Radix primitives styled with token classes (bg-background,
|
||||
* text-foreground, border-border, ring-ring, …) and `@hanzo/svelte` emits the
|
||||
* SAME token classes through its own vendored `cn`. Neither package ships a
|
||||
* resolvable stylesheet — `@hanzo/ui`'s `./theme.css` subpath points at `src/`,
|
||||
* which the published tarball omits — so the host defines the variables. That is
|
||||
* the documented contract for both, and defining them once here is what makes a
|
||||
* React panel and a Svelte panel render identically.
|
||||
*/
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
:root {
|
||||
--radius: 0.625rem;
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.145 0 0);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.145 0 0);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.145 0 0);
|
||||
--primary: oklch(0.205 0 0);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.97 0 0);
|
||||
--secondary-foreground: oklch(0.205 0 0);
|
||||
--muted: oklch(0.97 0 0);
|
||||
--muted-foreground: oklch(0.556 0 0);
|
||||
--accent: oklch(0.97 0 0);
|
||||
--accent-foreground: oklch(0.205 0 0);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--destructive-foreground: oklch(0.985 0 0);
|
||||
--border: oklch(0.922 0 0);
|
||||
--input: oklch(0.922 0 0);
|
||||
--ring: oklch(0.708 0 0);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.145 0 0);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.205 0 0);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.205 0 0);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.985 0 0);
|
||||
--primary-foreground: oklch(0.205 0 0);
|
||||
--secondary: oklch(0.269 0 0);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.269 0 0);
|
||||
--muted-foreground: oklch(0.708 0 0);
|
||||
--accent: oklch(0.269 0 0);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--destructive-foreground: oklch(0.985 0 0);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.556 0 0);
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-card: var(--card);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-destructive-foreground: var(--destructive-foreground);
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-ring: var(--ring);
|
||||
--font-sans: 'Geist', ui-sans-serif, system-ui, -apple-system, sans-serif;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { ComponentType } from 'react'
|
||||
import type { Component } from 'svelte'
|
||||
import Facts from '~/views/Facts.svelte'
|
||||
import { Home } from '~/views/Home'
|
||||
import { Session } from '~/views/Session'
|
||||
|
||||
/*
|
||||
* What the sidebar can select.
|
||||
*
|
||||
* The shell hands every view the SAME props whichever language it is written in.
|
||||
* That symmetry is the point: porting a Huly view to React means changing which
|
||||
* key its entry carries — `svelte` becomes `react` — and touching nothing else.
|
||||
* So the ~40 remaining `*-resources` plugins are a queue, not a cliff.
|
||||
*/
|
||||
|
||||
/** The context every view receives. */
|
||||
export interface Props extends Record<string, unknown> {
|
||||
workspace: string
|
||||
token: string | null
|
||||
}
|
||||
|
||||
/** A view is React or Svelte. Never both, and there is no third option. */
|
||||
export type Content = { react: ComponentType<Props> } | { svelte: Component<Props> }
|
||||
|
||||
export interface View {
|
||||
/** Stable id — the sidebar selection and the route segment. */
|
||||
id: string
|
||||
label: string
|
||||
content: Content
|
||||
}
|
||||
|
||||
export const VIEWS: readonly View[] = [
|
||||
{ id: 'home', label: 'Home', content: { react: Home } },
|
||||
{ id: 'session', label: 'Session', content: { react: Session } },
|
||||
// The same content in Svelte, through the seam. It reads its tokens from the
|
||||
// same stylesheet the React views do, so the two must render identically —
|
||||
// that equivalence is what tells us a half-migrated shell looks whole.
|
||||
{ id: 'facts', label: 'Session · Svelte', content: { svelte: Facts as Component<Props> } },
|
||||
]
|
||||
|
||||
export function viewFor(id: string | undefined): View {
|
||||
return VIEWS.find((v) => v.id === id) ?? VIEWS[0]
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { expect, test } from '@playwright/test'
|
||||
import { brandFor } from '../src/brand'
|
||||
|
||||
/*
|
||||
* Brand is a function of hostname. These run against REAL hostnames — Chromium's
|
||||
* host-resolver rules point the app's hosts at the dev server — so what is under
|
||||
* test is the wiring and not a stand-in for it.
|
||||
*
|
||||
* The negatives carry the weight. One brand's mark appearing on another's host is
|
||||
* the failure this exists to prevent, and asserting only the positives would pass
|
||||
* for an implementation that renders "Hanzo Team" unconditionally.
|
||||
*/
|
||||
|
||||
test('each host renders its own brand', async ({ page }) => {
|
||||
for (const [host, name] of [
|
||||
['hanzo.team', 'Hanzo Team'],
|
||||
['team.hanzo.ai', 'Hanzo Team'],
|
||||
['tracker.hanzo.ai', 'Tracker'],
|
||||
['team.lux.network', 'Lux Team'],
|
||||
]) {
|
||||
await page.goto(`http://${host}:3000/`)
|
||||
await expect(page.locator('nav[aria-label="Views"]')).toContainText(name)
|
||||
}
|
||||
})
|
||||
|
||||
test('tracker.hanzo.ai does not render Hanzo Team', async ({ page }) => {
|
||||
await page.goto('http://tracker.hanzo.ai:3000/')
|
||||
const nav = page.locator('nav[aria-label="Views"]')
|
||||
await expect(nav).toContainText('Tracker')
|
||||
await expect(nav).not.toContainText('Hanzo Team')
|
||||
})
|
||||
|
||||
test('a non-Hanzo host never renders the Hanzo mark', async ({ page }) => {
|
||||
await page.goto('http://team.lux.network:3000/')
|
||||
await expect(page.locator('nav[aria-label="Views"]')).toContainText('Lux Team')
|
||||
// The mark is chosen by brand.org and the pack ships only Hanzo's, so there is
|
||||
// no path that can put it here.
|
||||
await expect(page.locator('[data-mark="hanzo"]')).toHaveCount(0)
|
||||
await expect(page.locator('[data-mark="lux"]')).toBeVisible()
|
||||
})
|
||||
|
||||
test('a Hanzo host does render the Hanzo mark', async ({ page }) => {
|
||||
await page.goto('http://hanzo.team:3000/')
|
||||
await expect(page.locator('[data-mark="hanzo"]')).toBeVisible()
|
||||
})
|
||||
|
||||
test('an unclaimed host resolves to no brand', () => {
|
||||
// Pure resolution, so it is asserted directly rather than through a page. The
|
||||
// rendering tests above cover the wiring from window.location.hostname.
|
||||
expect(brandFor('evil.example.com')).toBeUndefined()
|
||||
// Another Hanzo surface's host: this app does not serve it, so it gets no brand
|
||||
// here even though it is a legitimate Hanzo domain.
|
||||
expect(brandFor('hanzo.chat')).toBeUndefined()
|
||||
expect(brandFor('')).toBeUndefined()
|
||||
expect(brandFor(undefined)).toBeUndefined()
|
||||
|
||||
// A subdomain of a claimed host still belongs to it.
|
||||
expect(brandFor('eu.hanzo.team')?.id).toBe('team')
|
||||
|
||||
// The specific host wins over the broader one it sits under. This is exactly
|
||||
// where @hanzogui/shell's findSurfaceByHost resolves "Hanzo" instead.
|
||||
expect(brandFor('tracker.hanzo.ai')?.id).toBe('tracker')
|
||||
expect(brandFor('team.hanzo.ai')?.id).toBe('team')
|
||||
})
|
||||
@@ -0,0 +1,46 @@
|
||||
<script lang="ts">
|
||||
/*
|
||||
* The seam's conformance view. Not part of the app — the app never registers it.
|
||||
*
|
||||
* It counts its own live instances and its own listeners so a leak is
|
||||
* observable from the browser. Counting DOM nodes instead would be a test that
|
||||
* cannot fail: React removes the seam's host element on unmount, so the page
|
||||
* looks clean whether or not the Svelte instance was destroyed. The instance and
|
||||
* listener counters are what distinguish "torn down" from "orphaned but hidden".
|
||||
*/
|
||||
import { onDestroy, onMount } from 'svelte'
|
||||
|
||||
export let workspace: string
|
||||
export let token: string | null
|
||||
|
||||
interface Counters {
|
||||
live: number
|
||||
mounts: number
|
||||
destroys: number
|
||||
listeners: number
|
||||
}
|
||||
|
||||
const scope = globalThis as unknown as { probe?: Counters }
|
||||
const probe: Counters = (scope.probe ??= { live: 0, mounts: 0, destroys: 0, listeners: 0 })
|
||||
|
||||
function onResize(): void {
|
||||
// Exists to be registered and removed; a leaked instance keeps it attached.
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
probe.live += 1
|
||||
probe.mounts += 1
|
||||
window.addEventListener('resize', onResize)
|
||||
probe.listeners += 1
|
||||
})
|
||||
|
||||
onDestroy(() => {
|
||||
probe.live -= 1
|
||||
probe.destroys += 1
|
||||
window.removeEventListener('resize', onResize)
|
||||
probe.listeners -= 1
|
||||
})
|
||||
</script>
|
||||
|
||||
<p data-probe="workspace">{workspace}</p>
|
||||
<p data-probe="token">{token === null ? 'absent' : 'present'}</p>
|
||||
@@ -0,0 +1,11 @@
|
||||
<!doctype html>
|
||||
<html lang="en" class="dark">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Svelte seam conformance</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="./main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,34 @@
|
||||
import { useState } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { Svelte } from '~/components/Svelte'
|
||||
import Probe from './Probe.svelte'
|
||||
|
||||
/*
|
||||
* Drives the seam directly so its lifecycle can be asserted without the shell's
|
||||
* data fetching in the way. Exercises the same components/Svelte.tsx the app uses.
|
||||
*
|
||||
* No StrictMode here, on purpose: StrictMode double-invokes effects, so mount and
|
||||
* destroy counts would be doubled and the arithmetic in tests/seam.spec.ts would
|
||||
* stop being exact. The app keeps StrictMode; this harness wants precise counts.
|
||||
*/
|
||||
function Harness() {
|
||||
const [on, setOn] = useState(true)
|
||||
const [n, setN] = useState(0)
|
||||
|
||||
return (
|
||||
<>
|
||||
<button data-probe-action="toggle" onClick={() => setOn((v) => !v)}>
|
||||
toggle
|
||||
</button>
|
||||
<button data-probe-action="bump" onClick={() => setN((v) => v + 1)}>
|
||||
bump
|
||||
</button>
|
||||
<span data-probe-state={on ? 'on' : 'off'} />
|
||||
{on ? <Svelte view={Probe} props={{ workspace: `ws-${n}`, token: null }} /> : null}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const root = document.getElementById('root')
|
||||
if (root === null) throw new Error('#root missing')
|
||||
createRoot(root).render(<Harness />)
|
||||
@@ -0,0 +1,73 @@
|
||||
import { expect, test, type Page } from '@playwright/test'
|
||||
|
||||
/*
|
||||
* The seam's lifecycle contract.
|
||||
*
|
||||
* Leaking one Svelte instance per navigation is the failure mode that matters, and
|
||||
* it is invisible in the DOM: React removes the seam's host element, so the page
|
||||
* looks clean while the orphaned instance keeps its effects and listeners. These
|
||||
* assertions read the instance and listener counters instead, which is the only
|
||||
* way the leak is observable.
|
||||
*/
|
||||
|
||||
const HARNESS = '/tests/probe/index.html'
|
||||
|
||||
interface Counters {
|
||||
live: number
|
||||
mounts: number
|
||||
destroys: number
|
||||
listeners: number
|
||||
}
|
||||
|
||||
const counters = (page: Page): Promise<Counters> =>
|
||||
page.evaluate(() => (globalThis as unknown as { probe: Counters }).probe)
|
||||
|
||||
test('a Svelte view mounts inside React and receives its props', async ({ page }) => {
|
||||
await page.goto(HARNESS)
|
||||
await expect(page.locator('[data-probe="workspace"]')).toHaveText('ws-0')
|
||||
await expect(page.locator('[data-probe="token"]')).toHaveText('absent')
|
||||
})
|
||||
|
||||
test('a prop change reaches a live view without remounting it', async ({ page }) => {
|
||||
await page.goto(HARNESS)
|
||||
await expect(page.locator('[data-probe="workspace"]')).toHaveText('ws-0')
|
||||
|
||||
const before = await counters(page)
|
||||
await page.click('[data-probe-action="bump"]')
|
||||
|
||||
await expect(page.locator('[data-probe="workspace"]')).toHaveText('ws-1')
|
||||
|
||||
const after = await counters(page)
|
||||
// The value changed, so props were delivered. Nothing was rebuilt to deliver
|
||||
// them — a seam that remounted on every prop change would show mounts climbing.
|
||||
expect(after.mounts).toBe(before.mounts)
|
||||
expect(after.destroys).toBe(before.destroys)
|
||||
expect(after.live).toBe(1)
|
||||
})
|
||||
|
||||
test('cycling mount and unmount 25 times leaks no instance and no listener', async ({ page }) => {
|
||||
await page.goto(HARNESS)
|
||||
await expect(page.locator('[data-probe="workspace"]')).toBeVisible()
|
||||
|
||||
for (let i = 0; i < 25; i++) {
|
||||
await page.click('[data-probe-action="toggle"]') // unmount
|
||||
await expect(page.locator('[data-probe-state="off"]')).toBeAttached()
|
||||
await page.click('[data-probe-action="toggle"]') // mount
|
||||
await expect(page.locator('[data-probe-state="on"]')).toBeAttached()
|
||||
}
|
||||
|
||||
const mid = await counters(page)
|
||||
expect(mid.mounts).toBe(26) // the first mount plus 25 more
|
||||
expect(mid.destroys).toBe(25)
|
||||
expect(mid.live).toBe(1) // exactly the one on screen
|
||||
expect(mid.listeners).toBe(1)
|
||||
|
||||
// Take the last one away too: nothing at all should remain alive.
|
||||
await page.click('[data-probe-action="toggle"]')
|
||||
await expect(page.locator('[data-probe="workspace"]')).toHaveCount(0)
|
||||
|
||||
const end = await counters(page)
|
||||
expect(end.destroys).toBe(26)
|
||||
expect(end.live).toBe(0)
|
||||
expect(end.listeners).toBe(0)
|
||||
})
|
||||
@@ -0,0 +1,162 @@
|
||||
import { expect, test, type Page } from '@playwright/test'
|
||||
|
||||
/*
|
||||
* The chrome: it renders, the sidebar navigates, cmd+k opens, the switcher lists
|
||||
* workspaces grouped by org, and a Svelte view mounts in the content area.
|
||||
*
|
||||
* The account RPC is served at the network boundary. Everything above it — the
|
||||
* client, the grouping, the menu — is the real code; only the transport is stood
|
||||
* in for, because a real token needs an interactive hanzo.id sign-in.
|
||||
*/
|
||||
|
||||
const RPC = '**/v1/team/account'
|
||||
|
||||
async function signedIn(page: Page): Promise<void> {
|
||||
await page.addInitScript(() => {
|
||||
window.localStorage.setItem('hanzo-team-token', 'test-token')
|
||||
})
|
||||
await page.route(RPC, async (route) => {
|
||||
const method = (route.request().postDataJSON() as { method: string }).method
|
||||
if (method === 'getLoginInfoByToken') {
|
||||
await route.fulfill({
|
||||
json: { result: { account: 'z@zoo.ngo', name: 'Z', socialId: 's1' } },
|
||||
})
|
||||
return
|
||||
}
|
||||
if (method === 'getUserWorkspaces') {
|
||||
await route.fulfill({
|
||||
json: {
|
||||
result: [
|
||||
{ uuid: 'u1', name: 'Hanzo Core', url: 'core', org: 'hanzo', region: '', mode: 'active', isDisabled: false },
|
||||
{ uuid: 'u2', name: 'Hanzo Labs', url: 'labs', org: 'hanzo', region: '', mode: 'active', isDisabled: false },
|
||||
{ uuid: 'u3', name: 'Zoo Research', url: 'zoo', org: 'zoo', region: '', mode: 'active', isDisabled: false },
|
||||
],
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
await route.fulfill({ json: { error: { code: 'account:status:UnknownMethod' } } })
|
||||
})
|
||||
}
|
||||
|
||||
test('the shell renders its frame', async ({ page }) => {
|
||||
await page.goto('/')
|
||||
await expect(page.locator('nav[aria-label="Views"]')).toBeVisible()
|
||||
await expect(page.locator('header[role="banner"]')).toBeVisible()
|
||||
await expect(page.locator('[data-shell="content"]')).toBeVisible()
|
||||
await expect(page.locator('[data-shell="title"]')).toHaveText('Home')
|
||||
})
|
||||
|
||||
test('navigating every view raises nothing on the console', async ({ page }) => {
|
||||
// A thrown ReferenceError blanks the page while element assertions elsewhere go
|
||||
// on passing against a stale tree, so a suite without this guard can be green
|
||||
// over a broken app. Both engines report here: a Svelte mount failure and a
|
||||
// React render failure land on the same console.
|
||||
const noise: string[] = []
|
||||
page.on('console', (m) => {
|
||||
if (m.type() === 'error') noise.push(m.text())
|
||||
})
|
||||
page.on('pageerror', (e) => noise.push(`pageerror: ${e.message}`))
|
||||
|
||||
await page.goto('/')
|
||||
for (const id of ['session', 'facts', 'home']) {
|
||||
await page.click(`[data-view="${id}"]`)
|
||||
await expect(page.locator('[data-shell="content"]')).toBeVisible()
|
||||
}
|
||||
|
||||
expect(noise).toEqual([])
|
||||
})
|
||||
|
||||
test('the sidebar drives which view is active', async ({ page }) => {
|
||||
await page.goto('/')
|
||||
await expect(page.locator('[data-view="home"]')).toHaveAttribute('aria-current', 'page')
|
||||
|
||||
await page.click('[data-view="session"]')
|
||||
await expect(page.locator('[data-shell="title"]')).toHaveText('Session')
|
||||
await expect(page.locator('[data-view="session"]')).toHaveAttribute('aria-current', 'page')
|
||||
// Selection is exclusive — two views cannot both be current.
|
||||
await expect(page.locator('[aria-current="page"]')).toHaveCount(1)
|
||||
})
|
||||
|
||||
test('a Svelte view mounts in the content area and matches its React twin', async ({ page }) => {
|
||||
await page.goto('/')
|
||||
|
||||
await page.click('[data-view="session"]')
|
||||
await expect(page.locator('[data-facts="engine"]')).toHaveText('React')
|
||||
const react = await page.locator('[data-shell="content"] dl').innerText()
|
||||
|
||||
await page.click('[data-view="facts"]')
|
||||
await expect(page.locator('[data-facts="engine"]')).toHaveText('Svelte')
|
||||
const svelte = await page.locator('[data-shell="content"] dl').innerText()
|
||||
|
||||
// Same facts, same layout, only the engine label differs. That equivalence is
|
||||
// what says a half-migrated shell still looks like one product.
|
||||
expect(svelte.replace('Svelte', 'React')).toBe(react)
|
||||
})
|
||||
|
||||
test('cmd+k opens the palette and navigates', async ({ page }) => {
|
||||
await page.goto('/')
|
||||
await expect(page.locator('[data-palette="input"]')).toHaveCount(0)
|
||||
|
||||
await page.keyboard.press('ControlOrMeta+k')
|
||||
await expect(page.locator('[data-palette="input"]')).toBeVisible()
|
||||
|
||||
await page.locator('[data-palette="input"]').fill('Svelte')
|
||||
await page.click('[data-palette-view="facts"]')
|
||||
|
||||
await expect(page.locator('[data-palette="input"]')).toHaveCount(0)
|
||||
await expect(page.locator('[data-shell="title"]')).toHaveText('Session · Svelte')
|
||||
})
|
||||
|
||||
test('signed out, the shell offers hanzo.id and no credential form', async ({ page }) => {
|
||||
await page.goto('/')
|
||||
await expect(page.locator('[data-account="out"]')).toBeVisible()
|
||||
// hanzo.id is the only door: there is deliberately no local password to type.
|
||||
await expect(page.locator('input[type="password"]')).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('sign-in delegates to the backend hop rather than to hanzo.id directly', async ({ page }) => {
|
||||
await page.goto('/')
|
||||
// Stop at the first hop so the assertion is about where we send the browser.
|
||||
await page.route('**/v1/team/account/auth/openid', (route) =>
|
||||
route.fulfill({ status: 200, body: 'intercepted' }),
|
||||
)
|
||||
await page.click('[data-account="out"]')
|
||||
await page.waitForURL(/\/v1\/team\/account\/auth\/openid$/)
|
||||
expect(page.url()).toContain('/v1/team/account/auth/openid')
|
||||
})
|
||||
|
||||
test('the switcher lists workspaces grouped by org and switches', async ({ page }) => {
|
||||
await signedIn(page)
|
||||
await page.goto('/')
|
||||
|
||||
await expect(page.locator('[data-account="in"]')).toContainText('Z')
|
||||
await page.click('[data-account="in"]')
|
||||
|
||||
await expect(page.locator('[data-workspace="core"]')).toBeVisible()
|
||||
await expect(page.locator('[data-workspace="zoo"]')).toBeVisible()
|
||||
await expect(page.locator('[data-account-link="settings"]')).toBeVisible()
|
||||
await expect(page.locator('[data-account-link="out"]')).toBeVisible()
|
||||
|
||||
// Grouped by owning org, because getUserWorkspaces unions across orgs.
|
||||
const menu = page.locator('[role="menu"]')
|
||||
await expect(menu).toContainText('hanzo')
|
||||
await expect(menu).toContainText('zoo')
|
||||
|
||||
// The first workspace is current until another is chosen.
|
||||
await expect(page.locator('[data-workspace="core"]')).toHaveAttribute('aria-current', 'true')
|
||||
await page.click('[data-workspace="zoo"]')
|
||||
|
||||
await page.click('[data-view="session"]')
|
||||
await expect(page.locator('[data-facts="workspace"]')).toHaveText('zoo')
|
||||
})
|
||||
|
||||
test('the workspace reaches a Svelte view as a prop', async ({ page }) => {
|
||||
await signedIn(page)
|
||||
await page.goto('/')
|
||||
await expect(page.locator('[data-account="in"]')).toBeVisible()
|
||||
|
||||
await page.click('[data-view="facts"]')
|
||||
await expect(page.locator('[data-facts="workspace"]')).toHaveText('core')
|
||||
await expect(page.locator('[data-facts="token"]')).toHaveText('present')
|
||||
})
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"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", "svelte"],
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["src", "components", "views", "tests"]
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
<script lang="ts">
|
||||
/*
|
||||
* A Svelte view, mounted by the shell through components/Svelte.tsx.
|
||||
*
|
||||
* It is deliberately the same content as views/Session.tsx, rendered with
|
||||
* @hanzo/svelte instead of @hanzo/ui. Both read their tokens from src/theme.css,
|
||||
* so the two must look identical — and while ~2,400 Huly views are still Svelte,
|
||||
* that equivalence is what says a half-migrated shell looks whole.
|
||||
*
|
||||
* Note what this view CANNOT do: it has no handle on the sidebar, the router or
|
||||
* the workspace. It receives props and renders. That is the seam's whole point.
|
||||
*/
|
||||
import Badge from '@hanzo/svelte/Badge.svelte'
|
||||
import Card from '@hanzo/svelte/Card.svelte'
|
||||
import CardContent from '@hanzo/svelte/CardContent.svelte'
|
||||
import CardHeader from '@hanzo/svelte/CardHeader.svelte'
|
||||
import CardTitle from '@hanzo/svelte/CardTitle.svelte'
|
||||
|
||||
export let workspace: string
|
||||
export let token: string | null
|
||||
</script>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Session</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<dl class="grid grid-cols-[8rem_1fr] gap-y-2 text-sm">
|
||||
<dt class="text-muted-foreground">Rendered by</dt>
|
||||
<dd data-facts="engine"><Badge>Svelte</Badge></dd>
|
||||
|
||||
<dt class="text-muted-foreground">Workspace</dt>
|
||||
<dd data-facts="workspace">{workspace === '' ? '—' : workspace}</dd>
|
||||
|
||||
<dt class="text-muted-foreground">Token</dt>
|
||||
<dd data-facts="token">{token === null ? 'absent' : 'present'}</dd>
|
||||
</dl>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -0,0 +1,31 @@
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@hanzo/ui'
|
||||
import { VIEWS, type Props } from '~/src/views'
|
||||
|
||||
/** Landing view: what this workspace holds, and how much of it is ported. */
|
||||
export function Home({ workspace }: Props) {
|
||||
const svelte = VIEWS.filter((v) => 'svelte' in v.content).length
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{workspace === '' ? 'No workspace open' : workspace}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="text-muted-foreground text-sm">
|
||||
Pick a view on the left, or press <kbd className="text-foreground">⌘K</kbd>.
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Views</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="text-sm">
|
||||
<p data-home="counts">
|
||||
{VIEWS.length} registered, {VIEWS.length - svelte} React, {svelte} Svelte.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { Badge, Card, CardContent, CardHeader, CardTitle } from '@hanzo/ui'
|
||||
import type { Props } from '~/src/views'
|
||||
|
||||
/** The React half of the parity pair. views/Facts.svelte renders the same facts. */
|
||||
export function Session({ workspace, token }: Props) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Session</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<dl className="grid grid-cols-[8rem_1fr] gap-y-2 text-sm">
|
||||
<dt className="text-muted-foreground">Rendered by</dt>
|
||||
<dd data-facts="engine">
|
||||
<Badge>React</Badge>
|
||||
</dd>
|
||||
|
||||
<dt className="text-muted-foreground">Workspace</dt>
|
||||
<dd data-facts="workspace">{workspace === '' ? '—' : workspace}</dd>
|
||||
|
||||
<dt className="text-muted-foreground">Token</dt>
|
||||
<dd data-facts="token">{token === null ? 'absent' : 'present'}</dd>
|
||||
</dl>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { svelte, vitePreprocess } from '@sveltejs/vite-plugin-svelte'
|
||||
import tailwind from '@tailwindcss/vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import type { UserConfig } from 'vite'
|
||||
|
||||
// React owns the shell; Svelte views mount inside it through components/Svelte.tsx.
|
||||
// One compiler each, one plugin each, no second path.
|
||||
//
|
||||
// `vitePreprocess` is what lets the Huly view corpus through: Svelte 5 strips
|
||||
// plain TS natively, but not `<style lang="scss">` (10+ files) and not TS `enum`
|
||||
// (2 files). Both go through the preprocessor.
|
||||
export default {
|
||||
clearScreen: false,
|
||||
|
||||
plugins: [
|
||||
react(),
|
||||
svelte({ preprocess: vitePreprocess() }),
|
||||
tailwind(),
|
||||
],
|
||||
|
||||
// `~` is the app root, matching tsconfig `paths`. Vite does not read tsconfig
|
||||
// paths, so the two have to be stated once each and agree.
|
||||
resolve: {
|
||||
alias: { '~': fileURLToPath(new URL('.', import.meta.url)).replace(/\/$/, '') },
|
||||
// React and Svelte must each be a single instance; two copies of either
|
||||
// produce hooks-order and lifecycle faults that look like seam bugs.
|
||||
dedupe: ['react', 'react-dom', 'svelte'],
|
||||
},
|
||||
|
||||
server: {
|
||||
port: 3000,
|
||||
// The hosts this app is served on. Brand is a function of hostname, so the
|
||||
// dev server has to answer to each of them for that to be testable at all.
|
||||
allowedHosts: ['hanzo.team', 'team.hanzo.ai', 'tracker.hanzo.ai', 'team.lux.network'],
|
||||
// The Go backend is unchanged and owns every /v1 route.
|
||||
proxy: {
|
||||
'/v1': { target: process.env.TEAM_API ?? 'https://api.hanzo.ai', changeOrigin: true },
|
||||
},
|
||||
},
|
||||
|
||||
build: { outDir: 'dist', emptyOutDir: true },
|
||||
} satisfies UserConfig
|
||||
+7
-2
@@ -16,6 +16,7 @@
|
||||
"./apps/kitchen-sink-go",
|
||||
"./apps/kitchen-sink-shared",
|
||||
"./apps/sandbox",
|
||||
"./apps/team",
|
||||
"./apps/tests/**/*",
|
||||
"./templates/*",
|
||||
"./pkgs/ai",
|
||||
@@ -120,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",
|
||||
@@ -134,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"
|
||||
@@ -141,7 +145,7 @@
|
||||
"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",
|
||||
@@ -166,7 +170,8 @@
|
||||
"@vxrn/vite-flow": "1.12.5",
|
||||
"@vxrn/vite-plugin-metro": "1.12.5",
|
||||
"@vxrn/tslib-lite": "1.12.5",
|
||||
"create-vxrn": "1.12.5"
|
||||
"create-vxrn": "1.12.5",
|
||||
"@react-navigation/bottom-tabs": "7.10.1"
|
||||
},
|
||||
"packageManager": "bun@1.3.9",
|
||||
"manypkg": {
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+47
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+31
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user