Compare commits

...
1 Commits
Author SHA1 Message Date
hanzo-dev 9c332f6823 team: React shell with one Svelte seam
Replace the One/react-native-web skeleton in apps/team with the React DOM shell
the chrome libraries and the Svelte seam both require, on Vite (the shape lux/mpc
ships). @hanzo/ui is Radix+Tailwind and Svelte mounts into DOM, so neither works
under react-native-web; the previous tree had already recorded the friction by
mirroring surfaces.data.ts with "this native app cannot resolve @hanzo/ui".

The shell: sidebar-driven views, header, cmd+k palette, workspace/user switcher
grouped by owning org (getUserWorkspaces unions across orgs). Chrome comes from
@hanzo/ui's barrel, which is Radix-only and so pulls no Tamagui.

The seam is components/Svelte.tsx and it is the only one. Mount is keyed on the
view, props flow through a $state proxy so a live view updates in place, and
cleanup unmounts. A registry entry carries either `react` or `svelte` and both
receive identical props, so porting a view is a one-key change.

Brand resolves from hostname and an unclaimed host resolves to nothing rather
than to a default. @hanzogui/shell's findSurfaceByHost cannot be reused: it keys
one host per surface, so team.hanzo.ai and tracker.hanzo.ai both suffix-match
hanzo.ai and report brandName "Hanzo".

Sign-in delegates to hanzo.id via /v1/team/account/auth/openid; the backend owns
state and the code exchange, so ~200 lines of in-app PKCE are gone along with the
credential form the backend refuses to serve.

Tailwind needs @source for both libraries — v4 skips node_modules and both ship
utility classes rather than a stylesheet, so dialogs and menus rendered
unpositioned until that landed. Invisible to text and attribute assertions.

17 Playwright tests, real browser. The leak assertion and the brand negative were
each shown red under mutation before being restored.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-28 13:40:42 -07:00
48 changed files with 1462 additions and 1077 deletions
+53 -10
View File
@@ -1,17 +1,60 @@
# Hanzo Team
Native Hanzo Team skeleton on hanzogui — one codebase for web, mobile, and desktop.
Three screens share components with web: Shell (AppHeader: mark, org row,
five-surface switcher), Login (hanzo.id OIDC via system browser + deep link),
Wallet (balance and usage, monochrome tokens).
The hanzo.team shell. React owns the chrome; Svelte views mount inside it while
they wait to be ported.
```bash
bun run dev # web dev server
bun run build:web # web production build → dist/client
bun run dev # dev server on :3000
bun run build # production build → dist
bun run typecheck # tsc
bun run ios # native (one / expo)
bun run test # playwright, real browser
```
Desktop wraps `dist/client` with Tauri: build web, then `cargo tauri dev` (or
`cargo check`) inside `src-tauri/`.
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.
-16
View File
@@ -1,16 +0,0 @@
{
"expo": {
"name": "Hanzo Team",
"slug": "team",
"scheme": "hanzo-team",
"newArchEnabled": true,
"platforms": ["ios", "android"],
"plugins": ["vxrn/expo-plugin"],
"ios": {
"bundleIdentifier": "ai.hanzo.team"
},
"android": {
"package": "ai.hanzo.team"
}
}
}
-3
View File
@@ -1,3 +0,0 @@
body {
margin: 0;
}
-42
View File
@@ -1,42 +0,0 @@
import './_layout.css'
import { SchemeProvider, useUserScheme } from '@vxrn/color-scheme'
import { Slot } from 'one'
import { GuiProvider } from 'hanzogui'
import { Shell } from '~/components/Shell'
import { SessionProvider } from '~/src/session'
import config from '~/src/gui.config'
export default function Layout() {
return (
<html lang="en">
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.svg" />
<title>Hanzo Team</title>
</head>
<body>
<SchemeProvider>
<Providers>
<SessionProvider>
<Shell>
<Slot />
</Shell>
</SessionProvider>
</Providers>
</SchemeProvider>
</body>
</html>
)
}
const Providers = ({ children }: { children: React.ReactNode }) => {
const userScheme = useUserScheme()
return (
<GuiProvider config={config} defaultTheme={userScheme.value}>
{children}
</GuiProvider>
)
}
-58
View File
@@ -1,58 +0,0 @@
import { useEffect, useState } from 'react'
import { ActivityIndicator } from 'react-native'
import { useLocalSearchParams } from 'one'
import { SizableText, YStack } from 'hanzogui'
import { Mark } from '~/components/Mark'
import { completeWebCallback } from '~/src/auth'
import { persistSession } from '~/src/session'
// Web-only: hanzo.id redirects here with ?code&state. Complete the PKCE exchange,
// persist the session, then hard-navigate home so the session provider re-reads it.
// (Native completes the flow in-process via expo-web-browser; this route is unused there.)
export default function Callback() {
const params = useLocalSearchParams<{ code?: string; state?: string; error?: string }>()
const [error, setError] = useState<string | null>(null)
useEffect(() => {
let live = true
void (async () => {
try {
const session = await completeWebCallback({
code: params.code,
state: params.state,
error: params.error,
})
await persistSession(session)
globalThis.location?.assign('/')
} catch (e) {
if (live) setError(e instanceof Error ? e.message : String(e))
}
})()
return () => {
live = false
}
}, [params.code, params.state, params.error])
return (
<YStack flex={1} items="center" justify="center" p="$4" gap="$4">
<Mark size={44} />
{error == null ? (
<>
<ActivityIndicator />
<SizableText size="$3" color="$color10">
Completing sign-in
</SizableText>
</>
) : (
<YStack items="center" gap="$2">
<SizableText size="$4" fontWeight="600">
Sign-in failed
</SizableText>
<SizableText size="$2" color="$color10">
{error}
</SizableText>
</YStack>
)}
</YStack>
)
}
-59
View File
@@ -1,59 +0,0 @@
import { Link } from 'one'
import { SizableText, XStack, YStack } from 'hanzogui'
import { useSession } from '~/src/session'
export default function Home() {
const { session, loading } = useSession()
const signedIn = !loading && session != null
const who = session?.user?.email ?? session?.user?.name
const views = signedIn
? [{ href: '/wallet', title: 'Wallet', caption: 'Balance and AI usage' } as const]
: [
{ href: '/login', title: 'Sign in', caption: 'hanzo.id single sign-on' } as const,
{ href: '/wallet', title: 'Wallet', caption: 'Balance and AI usage' } as const,
]
return (
<YStack p="$4" gap="$4" maxW={560} width="100%" self="center">
<YStack gap="$1">
<SizableText size="$7" fontWeight="600">
Team
</SizableText>
<SizableText size="$3" color="$color10">
{signedIn && who != null
? `Signed in as ${who}.`
: 'Chat, projects, and planning for your org — one native app for mobile and desktop.'}
</SizableText>
</YStack>
{views.map((view) => (
<Link key={view.href} href={view.href} asChild>
<XStack
bg="$color1"
borderWidth={1}
borderColor="$borderColor"
rounded="$6"
p="$4"
items="center"
justify="space-between"
cursor="pointer"
pressStyle={{ opacity: 0.7 }}
>
<YStack gap="$1">
<SizableText size="$4" fontWeight="600">
{view.title}
</SizableText>
<SizableText size="$2" color="$color10">
{view.caption}
</SizableText>
</YStack>
<SizableText size="$4" color="$color10">
</SizableText>
</XStack>
</Link>
))}
</YStack>
)
}
-46
View File
@@ -1,46 +0,0 @@
import { Redirect } from 'one'
import { Button, SizableText, YStack } from 'hanzogui'
import { Mark } from '~/components/Mark'
import { useSession } from '~/src/session'
// Real hanzo.id OIDC (Authorization Code + PKCE) via the system browser (native) or a
// full-page redirect (web); the session returns through the app deep link / callback
// route. No in-app credential entry, no fake auth. See ~/src/auth.ts.
export default function Login() {
const { session, loading, signingIn, signIn } = useSession()
if (!loading && session != null) return <Redirect href="/" />
return (
<YStack flex={1} items="center" justify="center" p="$4" gap="$4">
<Mark size={44} />
<YStack items="center" gap="$1">
<SizableText size="$6" fontWeight="600">
Sign in to Hanzo
</SizableText>
<SizableText size="$2" color="$color10">
One account for every surface
</SizableText>
</YStack>
<Button
size="$4"
bg="$color"
borderWidth={0}
disabled={signingIn}
opacity={signingIn ? 0.6 : 1}
pressStyle={{ opacity: 0.8, bg: '$color' }}
onPress={() => void signIn()}
>
<Button.Text color="$background" fontWeight="600">
{signingIn ? 'Opening…' : 'Continue with hanzo.id'}
</Button.Text>
</Button>
<SizableText size="$1" color="$color10">
Opens hanzo.id · returns via hanzo-team://callback
</SizableText>
</YStack>
)
}
-100
View File
@@ -1,100 +0,0 @@
import { useEffect, useState } from 'react'
import { Link } from 'one'
import { Separator, SizableText, XStack, YStack } from 'hanzogui'
import { useSession } from '~/src/session'
import { fetchWallet, type Wallet } from '~/src/billing'
// Real balance + usage from billing.hanzo.ai, read with the session's bearer token.
// No hardcoded money: signed-out shows a sign-in prompt; a failed fetch shows an
// honest unavailable state rather than a fake $0.00.
export default function WalletScreen() {
const { session, loading } = useSession()
const [wallet, setWallet] = useState<Wallet | null>(null)
const [state, setState] = useState<'idle' | 'loading' | 'error'>('idle')
useEffect(() => {
const token = session?.accessToken
if (token == null) return
let live = true
setState('loading')
void fetchWallet(token)
.then((w) => {
if (live) {
setWallet(w)
setState('idle')
}
})
.catch(() => {
if (live) setState('error')
})
return () => {
live = false
}
}, [session?.accessToken])
if (!loading && session == null) {
return (
<YStack p="$4" gap="$3" maxW={560} width="100%" self="center">
<SizableText size="$6" fontWeight="600">
Wallet
</SizableText>
<SizableText size="$3" color="$color10">
Sign in to see your balance and AI usage.
</SizableText>
<Link href="/login" asChild>
<XStack
bg="$color"
rounded="$10"
px="$4"
py="$2.5"
self="flex-start"
cursor="pointer"
pressStyle={{ opacity: 0.8 }}
>
<SizableText size="$3" fontWeight="600" color="$background">
Sign in
</SizableText>
</XStack>
</Link>
</YStack>
)
}
const balance = wallet != null ? `$${wallet.balanceUsd.toFixed(2)}` : state === 'error' ? '—' : '…'
return (
<YStack p="$4" gap="$4" maxW={560} width="100%" self="center">
<YStack bg="$color1" borderWidth={1} borderColor="$borderColor" rounded="$6" p="$4" gap="$1">
<SizableText size="$2" color="$color10">
Balance
</SizableText>
<SizableText size="$9" fontWeight="600">
{balance}
</SizableText>
<SizableText size="$1" color="$color10">
{state === 'error'
? 'Balance unavailable right now'
: 'Usage-metered · billed via billing.hanzo.ai'}
</SizableText>
</YStack>
{wallet != null && wallet.usage.length > 0 ? (
<YStack bg="$color1" borderWidth={1} borderColor="$borderColor" rounded="$6">
{wallet.usage.map((row, index) => (
<YStack key={row.label}>
{index > 0 ? <Separator borderColor="$borderColor" /> : null}
<XStack p="$3.5" items="center" justify="space-between">
<SizableText size="$3" color="$color10">
{row.label}
</SizableText>
<SizableText size="$3" fontWeight="600">
{row.value}
</SizableText>
</XStack>
</YStack>
))}
</YStack>
) : null}
</YStack>
)
}
+96
View File
@@ -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>
)
}
+44 -9
View File
@@ -1,14 +1,49 @@
import { useTheme } from 'hanzogui'
import Svg, { Path } from 'react-native-svg'
import { MARK_PATHS, MARK_VIEWBOX } from '@hanzo/logo'
import type { Brand } from '~/src/brand'
const d =
'M45.9269 46.3536C45.65 45.3274 44.9038 44.8299 43.8628 44.9314C41.8474 45.1268 41.1782 44.4808 40.2218 43.0613C42.7423 43.5952 44.3679 42.9389 44.8756 42.6471C45.5295 42.2694 46.191 41.785 45.9295 40.9306C45.6731 40.0945 44.9038 39.9278 44.1141 40.0789C42.9936 40.2925 41.9192 40.4175 41.0474 39.3261C41.232 39.1594 41.4166 38.9875 41.6064 38.8234C42.0705 38.4249 42.4295 37.9561 42.1936 37.3231C41.9679 36.7188 41.3961 36.3724 40.8141 36.4792C40.3397 36.5678 39.3884 36.8647 39.0987 36.3802L39.1115 36.375C39.0013 36.349 38.832 35.8957 38.7961 35.5649C38.6269 33.9474 39.3269 32.395 40.2705 31.1057C41.4474 29.5038 42.2551 27.7378 42.6141 25.7791C43.1756 22.7055 42.5807 19.8456 40.9192 17.2174C38.9474 14.097 36.1679 12.0419 32.509 11.2865C31.65 11.1094 30.8115 11.0156 29.9987 11C29.1833 11.0156 28.3474 11.1094 27.4884 11.2865C23.832 12.0419 21.05 14.097 19.0782 17.2174C17.4192 19.8456 16.8243 22.7055 17.3833 25.7791C17.7397 27.7404 18.55 29.5038 19.7269 31.1057C20.6731 32.395 21.3731 33.9448 21.2013 35.5649C21.1654 35.8957 20.9961 36.349 20.8859 36.375L20.8987 36.3802C20.609 36.8621 19.6602 36.5678 19.1833 36.4792C18.6038 36.3724 18.0295 36.7188 17.8038 37.3231C17.5679 37.9561 17.9243 38.4249 18.391 38.8234C18.5807 38.9875 18.7654 39.1594 18.95 39.3261C18.0782 40.4175 17.0013 40.2925 15.8833 40.0789C15.0936 39.9278 14.3243 40.0945 14.0679 40.9306C13.8064 41.785 14.4679 42.2694 15.1218 42.6471C15.6269 42.9389 17.2551 43.5926 19.7756 43.0613C18.8192 44.4808 18.15 45.1268 16.1346 44.9314C15.0936 44.8299 14.3448 45.3274 14.0705 46.3536C13.8013 47.3564 14.332 48.0753 15.1731 48.5546C15.7243 48.8672 16.3397 48.9896 16.991 49C20.5577 49.0495 23.5013 47.5778 26.1525 45.3638C27.6115 44.1448 28.8115 43.5275 30.0013 43.5119C31.191 43.5275 32.3884 44.1448 33.85 45.3638C36.5013 47.5778 39.4448 49.0495 43.0115 49C43.6628 48.9922 44.2782 48.8672 44.8295 48.5546C45.6731 48.0753 46.2013 47.3564 45.932 46.3536H45.9269ZM25.0756 34.2027C23.9731 34.2001 23.0628 33.278 23.0602 32.1658C23.0602 31.0328 23.9807 30.1055 25.1166 30.0951C26.1961 30.0847 27.1577 31.1083 27.1474 32.257C27.1372 33.3952 26.2705 34.2079 25.0756 34.2027ZM34.9192 34.2027C33.7218 34.2079 32.8577 33.3926 32.8474 32.257C32.8372 31.1083 33.8013 30.0847 34.8782 30.0951C36.0141 30.1081 36.9346 31.0354 36.9346 32.1658C36.9346 33.278 36.0218 34.2001 34.9192 34.2027Z'
/*
* 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>
)
}
export const Mark = ({ size = 24 }: { size?: number }) => {
const theme = useTheme()
return (
<Svg width={size} height={size} viewBox="10 8 40 44" fill="none">
<Path d={d} fill={theme.color.get()} />
</Svg>
<>
<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>
</>
)
}
+86
View File
@@ -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>
)
}
-30
View File
@@ -1,30 +0,0 @@
import { Moon, Sun, SunMoon } from '@hanzogui/lucide-icons-2'
import { useSystemScheme, useUserScheme } from '@vxrn/color-scheme'
import { Appearance } from 'react-native'
import { isWeb, View } from 'hanzogui'
const order = ['system', 'light', 'dark'] as const
export const SchemeToggle = () => {
const userScheme = useUserScheme()
const systemScheme = useSystemScheme()
const Icon =
userScheme.setting === 'system' ? SunMoon : userScheme.setting === 'dark' ? Moon : Sun
return (
<View
p="$2"
cursor="pointer"
pressStyle={{ opacity: 0.6 }}
onPress={() => {
const next = order[(order.indexOf(userScheme.setting) + 1) % 3]
if (!isWeb) {
Appearance.setColorScheme(next === 'system' ? systemScheme : next)
}
userScheme.set(next)
}}
>
<Icon size={18} color="$color10" />
</View>
)
}
+98 -139
View File
@@ -1,152 +1,111 @@
import { useState } from 'react'
import { Linking } from 'react-native'
import { useRouter } from 'one'
import { ChevronDown } from '@hanzogui/lucide-icons-2'
import { ScrollView, SizableText, XStack, YStack } from 'hanzogui'
import { Mark } from './Mark'
import { SchemeToggle } from './Scheme'
import { SURFACES } from '~/src/surfaces'
import { useSession } from '~/src/session'
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'
// app frame: AppHeader (mark · org switcher · seven-surface switcher · auth) over content
export const Shell = ({ children }: { children: React.ReactNode }) => {
const router = useRouter()
const { session, signOut } = useSession()
const [orgOpen, setOrgOpen] = useState(false)
const [activeOrg, setActiveOrg] = useState<string | undefined>(undefined)
/*
* 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)
const orgs = session?.user?.orgs ?? []
const currentOrg = activeOrg ?? orgs[0] ?? 'Hanzo'
const signedIn = session != 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 (
<YStack flex={1} minH="100%" bg="$background">
<YStack borderBottomWidth={1} borderColor="$borderColor" px="$4" pt="$3" pb="$2" gap="$2">
<XStack items="center" gap="$3">
<Mark size={22} />
<div className="flex h-full w-full">
<Sidebar brand={brand} views={VIEWS} active={view.id} onSelect={select} />
<YStack position="relative">
<XStack
items="center"
gap="$2"
rounded="$4"
px="$2"
py="$1"
cursor="pointer"
pressStyle={{ bg: '$color1' }}
onPress={() => setOrgOpen((v) => (orgs.length > 1 ? !v : v))}
>
<YStack width={20} height={20} rounded={999} bg="$color" items="center" justify="center">
<SizableText size="$1" fontWeight="700" color="$background">
{currentOrg.charAt(0).toUpperCase()}
</SizableText>
</YStack>
<SizableText size="$3" fontWeight="600">
{currentOrg}
</SizableText>
{orgs.length > 1 ? <ChevronDown size={14} color="$color10" /> : null}
</XStack>
<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>
{orgOpen && orgs.length > 1 ? (
<YStack
position="absolute"
t={38}
l={0}
minW={180}
bg="$background"
borderWidth={1}
borderColor="$borderColor"
rounded="$4"
py="$1"
z={100}
>
{orgs.map((org) => (
<XStack
key={org}
px="$3"
py="$2"
cursor="pointer"
pressStyle={{ bg: '$color1' }}
onPress={() => {
setActiveOrg(org)
setOrgOpen(false)
}}
>
<SizableText size="$3" color={org === currentOrg ? '$color' : '$color10'}>
{org}
</SizableText>
</XStack>
))}
</YStack>
) : null}
</YStack>
<Separator />
<XStack flex={1} />
{error !== null ? (
<p role="alert" data-shell="error" className="text-destructive px-4 py-2 text-xs">
{error}
</p>
) : null}
{signedIn ? (
<XStack
rounded="$10"
px="$3"
py="$1.5"
borderWidth={1}
borderColor="$borderColor"
cursor="pointer"
pressStyle={{ opacity: 0.6 }}
onPress={() => void signOut()}
>
<SizableText size="$2" color="$color10">
Sign out
</SizableText>
</XStack>
<main className="min-h-0 flex-1 overflow-auto p-4" data-shell="content">
{'react' in view.content ? (
<view.content.react {...props} />
) : (
<XStack
rounded="$10"
px="$3"
py="$1.5"
bg="$color"
cursor="pointer"
pressStyle={{ opacity: 0.8 }}
onPress={() => router.push('/login')}
>
<SizableText size="$2" color="$background" fontWeight="600">
Sign in
</SizableText>
</XStack>
<Svelte view={view.content.svelte} props={props} className="h-full" />
)}
</main>
</div>
<SchemeToggle />
</XStack>
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
<XStack gap="$2">
{SURFACES.map((surface) => {
const active = surface.id === 'team'
return (
<XStack
key={surface.id}
rounded="$10"
px="$3"
py="$1.5"
borderWidth={1}
borderColor={active ? '$borderColor' : 'transparent'}
bg={active ? '$color1' : 'transparent'}
cursor="pointer"
pressStyle={{ opacity: 0.6 }}
onPress={() => {
if (!active) void Linking.openURL(surface.href)
}}
>
<SizableText size="$2" color={active ? '$color' : '$color10'}>
{surface.label}
</SizableText>
</XStack>
)
})}
</XStack>
</ScrollView>
</YStack>
{children}
</YStack>
<Palette
views={VIEWS}
workspaces={workspaces}
onView={select}
onWorkspace={(w) => setWorkspace(w.url)}
/>
</div>
)
}
+56
View File
@@ -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>
)
}
+60
View File
@@ -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} />
}
+26
View File
@@ -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]
}
-7
View File
@@ -1,7 +0,0 @@
import type { GuiBuildOptions } from '@hanzogui/core'
export default {
components: ['hanzogui'],
config: './src/gui.config.ts',
disableExtraction: true,
} satisfies GuiBuildOptions
+13
View File
@@ -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>
+18 -21
View File
@@ -4,32 +4,29 @@
"private": true,
"type": "module",
"scripts": {
"dev": "one dev",
"build:web": "one build",
"serve": "one serve",
"ios": "one run:ios",
"android": "one run:android",
"typecheck": "tsc --noEmit"
"dev": "vite",
"build": "vite build",
"serve": "vite preview",
"typecheck": "tsc --noEmit",
"test": "playwright test"
},
"dependencies": {
"@hanzogui/config": "workspace:*",
"@hanzogui/core": "workspace:*",
"@hanzogui/lucide-icons-2": "workspace:*",
"@vxrn/color-scheme": "^1.12.5",
"expo": "~55.0.6",
"one": "1.12.5",
"react": ">=19",
"react-native": "0.83.2",
"react-native-safe-area-context": "~5.7.0",
"react-native-screens": "~4.23.0",
"react-native-svg": "15.15.3",
"react-native-web": "^0.21.0",
"hanzogui": "workspace:*"
"@hanzo/svelte": "^1.0.0",
"@hanzo/ui": "^8.0.26",
"react": "19.1.0",
"react-dom": "19.1.0",
"svelte": "^5.56.8"
},
"devDependencies": {
"@hanzogui/vite-plugin": "workspace:*",
"@playwright/test": "^1.49.1",
"@sveltejs/vite-plugin-svelte": "^6.2.1",
"@tailwindcss/vite": "^4",
"@types/react": "~19.1.10",
"vite": "^8.0.3"
"@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>"
+30
View File
@@ -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,
},
})
+65
View File
@@ -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
}
-250
View File
@@ -1,250 +0,0 @@
// Real hanzo.id OIDC — Authorization Code + PKCE. This module is the protocol (no
// platform imports): it builds the authorize URL, exchanges the code, and reads the
// profile. The browser step is delegated to the platform-split ./oidc-browser (native
// opens the system browser; web redirects the page), so expo stays out of the web bundle.
import { CLIENT_ID, ISSUER, SCOPES } from './oidc-config'
import { authorize, redirectUri } from './oidc-browser'
import type { AuthOutcome } from './oidc-types'
export { CLIENT_ID, redirectUri }
export interface Tokens {
accessToken: string
refreshToken?: string
/** epoch millis the access token expires, when the server reports expires_in */
expiresAt?: number
}
export interface UserInfo {
sub: string
name?: string
email?: string
/** org slugs the account belongs to, when the IAM userinfo carries groups */
orgs?: string[]
}
export interface Session extends Tokens {
user?: UserInfo
}
interface Endpoints {
authorization: string
token: string
userinfo: string
}
// Casdoor/IAM defaults, used when discovery is unreachable.
const FALLBACK: Endpoints = {
authorization: `${ISSUER}/login/oauth/authorize`,
token: `${ISSUER}/api/login/oauth/access_token`,
userinfo: `${ISSUER}/api/userinfo`,
}
let endpointsCache: Endpoints | undefined
/** Resolve OIDC endpoints from the discovery document, falling back to Casdoor paths. */
export async function discover(): Promise<Endpoints> {
if (endpointsCache !== undefined) return endpointsCache
try {
const res = await fetch(`${ISSUER}/.well-known/openid-configuration`)
if (res.ok) {
const d = (await res.json()) as Record<string, string>
endpointsCache = {
authorization: d.authorization_endpoint ?? FALLBACK.authorization,
token: d.token_endpoint ?? FALLBACK.token,
userinfo: d.userinfo_endpoint ?? FALLBACK.userinfo,
}
return endpointsCache
}
} catch {
// discovery blocked — fall through to the known Casdoor paths
}
endpointsCache = FALLBACK
return endpointsCache
}
const UNRESERVED = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~'
function randomString(length: number): string {
const bytes = new Uint8Array(length)
const c = globalThis.crypto
if (c?.getRandomValues != null) c.getRandomValues(bytes)
else for (let i = 0; i < length; i++) bytes[i] = Math.floor(Math.random() * 256)
let out = ''
for (let i = 0; i < length; i++) out += UNRESERVED[bytes[i] % UNRESERVED.length]
return out
}
function base64Url(bytes: Uint8Array): string {
let bin = ''
for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i])
const b64 = typeof btoa === 'function' ? btoa(bin) : Buffer.from(bytes).toString('base64')
return b64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
}
interface Challenge {
value: string
method: 'S256' | 'plain'
}
/** PKCE challenge: SHA-256 where a subtle-crypto digest exists (web), else plain. */
async function codeChallenge(verifier: string): Promise<Challenge> {
const subtle = globalThis.crypto?.subtle
if (subtle?.digest != null) {
const data = new TextEncoder().encode(verifier)
const digest = await subtle.digest('SHA-256', data)
return { value: base64Url(new Uint8Array(digest)), method: 'S256' }
}
return { value: verifier, method: 'plain' }
}
function buildAuthUrl(opts: {
endpoint: string
state: string
challenge: Challenge
redirect: string
}): string {
const q = new URLSearchParams({
client_id: CLIENT_ID,
response_type: 'code',
scope: SCOPES,
redirect_uri: opts.redirect,
state: opts.state,
code_challenge: opts.challenge.value,
code_challenge_method: opts.challenge.method,
})
return `${opts.endpoint}?${q.toString()}`
}
function parseCallback(url: string): { code?: string; state?: string; error?: string } {
const q = url.includes('?') ? url.slice(url.indexOf('?') + 1) : ''
const p = new URLSearchParams(q)
return {
code: p.get('code') ?? undefined,
state: p.get('state') ?? undefined,
error: p.get('error') ?? undefined,
}
}
async function exchangeCode(code: string, verifier: string, redirect: string): Promise<Tokens> {
const { token } = await discover()
const body = new URLSearchParams({
grant_type: 'authorization_code',
code,
redirect_uri: redirect,
client_id: CLIENT_ID,
code_verifier: verifier,
})
const res = await fetch(token, {
method: 'POST',
headers: { 'content-type': 'application/x-www-form-urlencoded', accept: 'application/json' },
body: body.toString(),
})
if (!res.ok) throw new Error(`token exchange failed (${res.status})`)
const d = (await res.json()) as Record<string, unknown>
const accessToken = d.access_token
if (typeof accessToken !== 'string' || accessToken.length === 0) {
throw new Error(typeof d.error === 'string' ? d.error : 'no access_token in token response')
}
const expiresIn = typeof d.expires_in === 'number' ? d.expires_in : undefined
return {
accessToken,
refreshToken: typeof d.refresh_token === 'string' ? d.refresh_token : undefined,
expiresAt: expiresIn !== undefined ? Date.now() + expiresIn * 1000 : undefined,
}
}
/** Read the signed-in profile from the OIDC userinfo endpoint. */
export async function fetchUserInfo(accessToken: string): Promise<UserInfo | undefined> {
try {
const { userinfo } = await discover()
const res = await fetch(userinfo, { headers: { authorization: `Bearer ${accessToken}` } })
if (!res.ok) return undefined
const d = (await res.json()) as Record<string, unknown>
const groups = Array.isArray(d.groups)
? (d.groups as unknown[]).filter((g): g is string => typeof g === 'string')
: undefined
return {
sub: String(d.sub ?? d.id ?? ''),
name:
typeof d.name === 'string'
? d.name
: typeof d.preferred_username === 'string'
? d.preferred_username
: undefined,
email: typeof d.email === 'string' ? d.email : undefined,
orgs: groups,
}
} catch {
return undefined
}
}
/** Web PKCE spans a page navigation — stash the verifier/state for the /callback route. */
const PENDING_KEY = 'hanzo-team-oidc-pending'
interface Pending {
verifier: string
state: string
redirect: string
}
function savePending(p: Pending): void {
globalThis.sessionStorage?.setItem(PENDING_KEY, JSON.stringify(p))
}
function loadPending(): Pending | undefined {
const raw = globalThis.sessionStorage?.getItem(PENDING_KEY)
return raw != null ? (JSON.parse(raw) as Pending) : undefined
}
function clearPending(): void {
globalThis.sessionStorage?.removeItem(PENDING_KEY)
}
export type LoginResult =
| { status: 'session'; session: Session }
| { status: 'redirecting' }
| { status: 'cancelled' }
/**
* Begin sign-in. Native completes in-process (returns the session); web navigates
* away and completes on the /callback route (returns `redirecting`).
*/
export async function login(): Promise<LoginResult> {
const { authorization } = await discover()
const redirect = redirectUri()
const verifier = randomString(64)
const state = randomString(24)
const challenge = await codeChallenge(verifier)
const url = buildAuthUrl({ endpoint: authorization, state, challenge, redirect })
// Persist for the web /callback route; a no-op on native (no sessionStorage).
savePending({ verifier, state, redirect })
const outcome: AuthOutcome = await authorize(url)
if (outcome.kind === 'redirecting') return { status: 'redirecting' }
if (outcome.kind === 'cancelled') return { status: 'cancelled' }
const cb = parseCallback(outcome.url)
if (cb.error != null) throw new Error(cb.error)
if (cb.code == null) throw new Error('no authorization code in callback')
if (cb.state !== state) throw new Error('state mismatch')
const tokens = await exchangeCode(cb.code, verifier, redirect)
const user = await fetchUserInfo(tokens.accessToken)
return { status: 'session', session: { ...tokens, user } }
}
/** Complete the web redirect flow from the /callback route's query string. */
export async function completeWebCallback(query: {
code?: string
state?: string
error?: string
}): Promise<Session> {
if (query.error != null) throw new Error(query.error)
const pending = loadPending()
clearPending()
if (pending == null) throw new Error('no pending sign-in')
if (query.code == null) throw new Error('no authorization code')
if (query.state !== pending.state) throw new Error('state mismatch')
const tokens = await exchangeCode(query.code, pending.verifier, pending.redirect)
const user = await fetchUserInfo(tokens.accessToken)
return { ...tokens, user }
}
-56
View File
@@ -1,56 +0,0 @@
// Real wallet data — balance + usage read from the billing surface with the session's
// bearer token. No hardcoded money: on error the wallet shows an honest unavailable
// state, never a fake $0.00.
const BILLING_BASE = 'https://billing.hanzo.ai'
export interface UsageRow {
label: string
value: string
}
export interface Wallet {
/** balance in USD */
balanceUsd: number
usage: UsageRow[]
}
function num(v: unknown): number | undefined {
if (typeof v === 'number' && Number.isFinite(v)) return v
if (typeof v === 'string' && v.trim() !== '' && Number.isFinite(Number(v))) return Number(v)
return undefined
}
function usd(record: Record<string, unknown>): number | undefined {
// atto-USD (18-dec) is the canonical money unit; also accept plain USD / cents fields.
const atto = record.balance_atto_usd ?? record.balanceAtto ?? record.atto_usd
if (typeof atto === 'string' && /^-?\d+$/.test(atto)) return Number(BigInt(atto)) / 1e18
const dollars = num(record.balance_usd ?? record.balanceUsd ?? record.balance ?? record.credits)
if (dollars !== undefined) return dollars
const cents = num(record.balance_cents ?? record.balanceCents)
if (cents !== undefined) return cents / 100
return undefined
}
/** GET the org wallet for the signed-in session. Throws on any non-2xx / bad shape. */
export async function fetchWallet(accessToken: string): Promise<Wallet> {
const res = await fetch(`${BILLING_BASE}/v1/billing/balance`, {
headers: { authorization: `Bearer ${accessToken}`, accept: 'application/json' },
})
if (!res.ok) throw new Error(`billing ${res.status}`)
const data = (await res.json()) as Record<string, unknown>
const balanceUsd = usd(data)
if (balanceUsd === undefined) throw new Error('no balance in billing response')
const usage: UsageRow[] = []
const period = data.period ?? data.month
if (typeof period === 'string') usage.push({ label: 'Period', value: period })
const spent = num(data.spent_usd ?? data.spentUsd ?? data.usage_usd)
if (spent !== undefined) usage.push({ label: 'Spent this period', value: `$${spent.toFixed(2)}` })
const requests = num(data.requests ?? data.request_count)
if (requests !== undefined) usage.push({ label: 'Requests', value: String(requests) })
const seats = num(data.seats ?? data.seat_count)
if (seats !== undefined) usage.push({ label: 'Seats', value: String(seats) })
return { balanceUsd, usage }
}
+64
View File
@@ -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
}
-30
View File
@@ -1,30 +0,0 @@
import { defaultConfig } from '@hanzogui/config/v5'
import { createGui } from 'hanzogui'
// hanzo monochrome: dark #000 / #0a0a0a / #1f1f1f · light #fff / #f7f7f7 / #ebebeb
export const config = createGui({
...defaultConfig,
themes: {
...defaultConfig.themes,
dark: {
...defaultConfig.themes.dark,
background: '#000000',
color1: '#0a0a0a',
borderColor: '#1f1f1f',
},
light: {
...defaultConfig.themes.light,
background: '#ffffff',
color1: '#f7f7f7',
borderColor: '#ebebeb',
},
},
})
export type Conf = typeof config
declare module 'hanzogui' {
interface GuiCustomConfig extends Conf {}
}
export default config
+16
View File
@@ -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>,
)
-18
View File
@@ -1,18 +0,0 @@
// NATIVE authorize step: open hanzo.id in the system browser and read the
// `hanzo-team://callback` deep link back. Keeps the expo modules (which pull
// expo-modules-core) out of the web bundle — the web build resolves oidc-browser.web.ts.
import * as WebBrowser from 'expo-web-browser'
import * as Linking from 'expo-linking'
import { REDIRECT_SCHEME } from './oidc-config'
import type { AuthOutcome } from './oidc-types'
export function redirectUri(): string {
return Linking.createURL('callback', { scheme: REDIRECT_SCHEME })
}
export async function authorize(url: string): Promise<AuthOutcome> {
const result = await WebBrowser.openAuthSessionAsync(url, redirectUri())
if (result.type !== 'success' || result.url == null) return { kind: 'cancelled' }
return { kind: 'callback', url: result.url }
}
-13
View File
@@ -1,13 +0,0 @@
// WEB authorize step: full-page redirect to hanzo.id. The /callback route completes
// the exchange. No expo imports here, so the web bundle never pulls expo-modules-core.
import type { AuthOutcome } from './oidc-types'
export function redirectUri(): string {
return `${globalThis.location?.origin ?? 'https://hanzo.team'}/callback`
}
export async function authorize(url: string): Promise<AuthOutcome> {
globalThis.location?.assign(url)
return { kind: 'redirecting' }
}
-9
View File
@@ -1,9 +0,0 @@
// Shared hanzo.id OIDC constants (no platform imports) so both the protocol module
// (auth.ts) and the platform-split browser modules read one source of truth.
/** The public native client registered in IAM (hanzo.id). */
export const CLIENT_ID = 'hanzo-team-native'
export const ISSUER = 'https://hanzo.id'
export const SCOPES = 'openid profile email'
/** Deep-link scheme (app.json `scheme`) the IAM redirect must whitelist. */
export const REDIRECT_SCHEME = 'hanzo-team'
-6
View File
@@ -1,6 +0,0 @@
// The result of driving the authorize step, produced by the platform-split
// ./oidc-browser module (native uses the system browser; web redirects the page).
export type AuthOutcome =
| { kind: 'callback'; url: string } // native: the app-scheme redirect came back
| { kind: 'redirecting' } // web: the page navigated to hanzo.id
| { kind: 'cancelled' } // the user dismissed the browser
+55
View File
@@ -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 }
}
-91
View File
@@ -1,91 +0,0 @@
// One session store for every screen. Persists the OIDC session (AsyncStorage works
// on web via localStorage and on native), exposes it through React context, and owns
// sign-in / sign-out so the screens never touch the protocol directly.
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'
import { getItem, removeItem, setItem } from './store'
import { login as oidcLogin, type Session } from './auth'
const STORAGE_KEY = 'hanzo-team-session'
async function load(): Promise<Session | null> {
const raw = await getItem(STORAGE_KEY)
if (raw == null) return null
try {
return JSON.parse(raw) as Session
} catch {
return null
}
}
async function save(session: Session | null): Promise<void> {
if (session == null) await removeItem(STORAGE_KEY)
else await setItem(STORAGE_KEY, JSON.stringify(session))
}
export interface SessionState {
session: Session | null
loading: boolean
/** true while a sign-in is in flight */
signingIn: boolean
signIn: () => Promise<void>
signOut: () => Promise<void>
}
const SessionContext = createContext<SessionState | undefined>(undefined)
export function SessionProvider({ children }: { children: React.ReactNode }): React.ReactElement {
const [session, setSession] = useState<Session | null>(null)
const [loading, setLoading] = useState(true)
const [signingIn, setSigningIn] = useState(false)
useEffect(() => {
let live = true
void load().then((s) => {
if (live) {
setSession(s)
setLoading(false)
}
})
return () => {
live = false
}
}, [])
const signIn = useCallback(async () => {
setSigningIn(true)
try {
const result = await oidcLogin()
if (result.status === 'session') {
await save(result.session)
setSession(result.session)
}
// 'redirecting' (web) completes on the /callback route; 'cancelled' is a no-op
} finally {
setSigningIn(false)
}
}, [])
const signOut = useCallback(async () => {
await save(null)
setSession(null)
}, [])
const value = useMemo<SessionState>(
() => ({ session, loading, signingIn, signIn, signOut }),
[session, loading, signingIn, signIn, signOut],
)
return <SessionContext.Provider value={value}>{children}</SessionContext.Provider>
}
export function useSession(): SessionState {
const ctx = useContext(SessionContext)
if (ctx === undefined) throw new Error('useSession must be used within a SessionProvider')
return ctx
}
/** Persist a session obtained outside the provider (the web /callback route). */
export async function persistSession(session: Session): Promise<void> {
await save(session)
}
-8
View File
@@ -1,8 +0,0 @@
// NATIVE key/value store — AsyncStorage. Split so the web bundle (store.web.ts) uses
// localStorage directly and never pulls the native module into the web build.
import AsyncStorage from '@react-native-async-storage/async-storage'
export const getItem = (key: string): Promise<string | null> => AsyncStorage.getItem(key)
export const setItem = (key: string, value: string): Promise<void> => AsyncStorage.setItem(key, value)
export const removeItem = (key: string): Promise<void> => AsyncStorage.removeItem(key)
-11
View File
@@ -1,11 +0,0 @@
// WEB key/value store — localStorage, matching the async store.ts signature.
export async function getItem(key: string): Promise<string | null> {
return globalThis.localStorage?.getItem(key) ?? null
}
export async function setItem(key: string, value: string): Promise<void> {
globalThis.localStorage?.setItem(key, value)
}
export async function removeItem(key: string): Promise<void> {
globalThis.localStorage?.removeItem(key)
}
-30
View File
@@ -1,30 +0,0 @@
// MIRROR of the canonical hanzoai/ui `pkg/ui/src/product/surfaces.data.ts` — keep
// the data byte-identical. This native app cannot resolve the @hanzo/ui package, so
// the ONE cross-surface app-switcher list is mirrored here. Update all mirrors in the
// same change (hanzoai/ui, hanzoai/team Svelte fork, and here).
export type SurfaceId = 'ai' | 'console' | 'app' | 'chat' | 'bot' | 'team' | 'billing'
/** One Hanzo surface the app switcher offers. */
export interface Surface {
id: SurfaceId
label: string
href: string
hint: string
}
/** The seven Hanzo surfaces (`console` opens the cloud AI console). */
export const SURFACES: Surface[] = [
{ id: 'ai', label: 'Hanzo AI', href: 'https://hanzo.ai', hint: 'hanzo.ai' },
{ id: 'console', label: 'Console', href: 'https://console.hanzo.ai', hint: 'console.hanzo.ai' },
{ id: 'app', label: 'App', href: 'https://hanzo.app', hint: 'hanzo.app' },
{ id: 'chat', label: 'Chat', href: 'https://hanzo.chat', hint: 'hanzo.chat' },
{ id: 'bot', label: 'Bot', href: 'https://hanzo.bot', hint: 'hanzo.bot' },
{ id: 'team', label: 'Team', href: 'https://hanzo.team', hint: 'hanzo.team' },
{ id: 'billing', label: 'Billing', href: 'https://billing.hanzo.ai', hint: 'billing.hanzo.ai' },
]
/** Every surface except `current` — a launcher never links to itself. */
export function otherSurfaces(current?: SurfaceId): Surface[] {
return current !== undefined ? SURFACES.filter((s) => s.id !== current) : SURFACES
}
+114
View File
@@ -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);
}
+43
View File
@@ -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]
}
+64
View File
@@ -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')
})
+46
View File
@@ -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>
+11
View File
@@ -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>
+34
View File
@@ -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 />)
+73
View File
@@ -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)
})
+162
View File
@@ -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')
})
+3 -2
View File
@@ -13,7 +13,8 @@
"paths": {
"~/*": ["./*"]
},
"types": ["vite/client"],
"types": ["vite/client", "svelte"],
"noEmit": true
}
},
"include": ["src", "components", "views", "tests"]
}
+39
View File
@@ -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>
+31
View File
@@ -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>
)
}
+27
View File
@@ -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>
)
}
+35 -13
View File
@@ -1,21 +1,43 @@
import { guiPlugin } from '@hanzogui/vite-plugin'
import { one } from 'one/vite'
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: [
one({
ssr: {
dedupeSymlinkedModules: true,
},
web: {
defaultRenderMode: 'spa',
},
}),
guiPlugin(),
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