Compare commits
1
Commits
main
...
blue/team-shell
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9c332f6823 |
+53
-10
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
body {
|
||||
margin: 0;
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
import type { GuiBuildOptions } from '@hanzogui/core'
|
||||
|
||||
export default {
|
||||
components: ['hanzogui'],
|
||||
config: './src/gui.config.ts',
|
||||
disableExtraction: true,
|
||||
} satisfies GuiBuildOptions
|
||||
@@ -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
@@ -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>"
|
||||
|
||||
@@ -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,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
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
@@ -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>,
|
||||
)
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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' }
|
||||
}
|
||||
@@ -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'
|
||||
@@ -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
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
@@ -13,7 +13,8 @@
|
||||
"paths": {
|
||||
"~/*": ["./*"]
|
||||
},
|
||||
"types": ["vite/client"],
|
||||
"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>
|
||||
)
|
||||
}
|
||||
+35
-13
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user