feat(console): native dynamic pitch + getting-started guides; drop upstream fork attribution (8.4.151)

Owner direction: "better pitch, not upstream attribution."

1) Remove the in-product OSS fork attribution. Delete ProductUpstreamNote
   (rendered "Built on open source — forked from X (MIT)" under every product)
   and every other surface that showed a "forked from … (license)" line:
   - ProductUpstreamNote.tsx + its DashboardShell mount (replaced by the guide panel)
   - ProductInterstitial's "Forked from X (license)." clause
   - the "Upstream" key-fact injected by overview/resolve.ts
   - the now-dead `upstream` catalog field + its 11 declarations in registry.tsx
   OSS license compliance stays in each repo's NOTICE/LICENSE — this is UI only.

2) Native, dynamic, personalized pitch + getting-started guides + tours. One
   subsystem, extending the existing OnboardingGate/OnboardingWizard, the
   first-run GuidedTour, and LivingOverview (no 3rd-party like Appcues):
   - src/lib/guide/: signals (real per-user facts: role, has-API-key, in-console
     usage), spec (pitch + dynamic getting-started steps whose done-state reads a
     REAL signal — never fabricated; unknown ⟹ not done), guard (per-product
     dismissal), registry (curated pitches for the flagship products).
   - src/components/guide/PitchHero + ProductGuidePanel: headline + value props
     above a getting-started checklist that checks itself off, personalizes which
     steps show (when-predicates), auto-hides once done/dismissed, and launches a
     spotlight tour generated from the INCOMPLETE steps (reuses GuidedTour; zero
     external scripts; works in the go:embed console under any CSP).
   - Mounted once at the top of the console content column (DashboardShell) for
     every product landing + the console home (replacing the standalone
     GetApiKeyCta). FirstRunTour now personalizes CONSOLE_TOUR via resolveTour.
   - Honest, accessible (@hanzo/gui v5 shorthands, reduced-motion-safe via FadeIn),
     org/identity-scoped, skipped on the admin/operator host.

Tests: +25 vitest (signals/spec/guard/registry). tsc clean, vitest 2876 pass,
next build green.
This commit is contained in:
hanzo-dev
2026-07-22 19:22:10 -07:00
parent a9209b2566
commit b490d5a200
20 changed files with 1311 additions and 124 deletions
+1 -34
View File
@@ -11,7 +11,7 @@
import { useEffect, useState } from 'react'
import { useRouter, usePathname } from 'next/navigation'
import { Button, Card, Spinner, Text, XStack, YStack } from '@hanzo/gui'
import { Star, Lock, ArrowRight, BookOpen, KeyRound } from '@hanzogui/lucide-icons-2'
import { Star, Lock, ArrowRight, BookOpen } from '@hanzogui/lucide-icons-2'
import { config } from '~/config'
import { shellFor } from '~/lib/products/shell'
@@ -22,7 +22,6 @@ import { openProduct } from '~/lib/products/open'
import { useFavorites } from '~/lib/products/favorites'
import { useIsSuperAdmin } from '~/lib/auth/admin'
import { PageHeader } from '~/components/ui/PageHeader'
import { PrimaryButton } from '~/components/ui/PrimaryButton'
import { FadeIn } from '~/components/ui/FadeIn'
import { livingOverviewModule } from '~/components/products/overview/living/LivingOverviewModule'
import { ResourceOverview } from '~/components/products/overview/ResourceOverview'
@@ -104,37 +103,6 @@ function ProductCard({
)
}
/**
* Prominent, always-visible "Get API key" call-to-action at the top of the home.
* A cold customer must reach "New key" in one obvious click from landing — the
* api-keys page is otherwise buried in the collapsed Dev nav group. Routes to the
* real ApiKeysModule (`/api-keys`), where the `hk-` key is created/copied/rotated.
*/
function GetApiKeyCta({ onOpen }: { onOpen: () => void }) {
return (
<Card borderWidth={1} borderColor="$borderColor" bg="$color2" p="$4" data-tour="api-key">
<XStack items="center" justify="space-between" gap="$4" flexWrap="wrap">
<XStack items="center" gap="$3" flex={1} minW={240}>
<YStack bg="$color5" rounded="$4" p="$2.5" items="center" justify="center">
<KeyRound size={20} />
</YStack>
<YStack flex={1} minW={180}>
<Text fontSize="$5" fontWeight="800">
Get your API key
</Text>
<Text fontSize="$3" color="$color11">
Call {config.brandName} models from your apps, SDKs, and CLI with a personal key.
</Text>
</YStack>
</XStack>
<PrimaryButton size="$4" iconAfter={<ArrowRight size={16} />} onPress={onOpen}>
Get API key
</PrimaryButton>
</XStack>
</Card>
)
}
export default function DashboardHome() {
const router = useRouter()
const pathname = usePathname()
@@ -182,7 +150,6 @@ export default function DashboardHome() {
return (
<YStack gap="$7">
<GetApiKeyCta onOpen={() => push('/api-keys')} />
<OverviewDashboard params={{}} />
{/* Observability, front-and-center — the platform's live LLM signals (RED
+2 -2
View File
@@ -1,10 +1,10 @@
{
"name": "@hanzo/console",
"version": "8.4.150",
"version": "8.4.151",
"private": true,
"license": "BSD-3-Clause",
"author": "Hanzo AI <dev@hanzo.ai>",
"description": "Hanzo Cloud Console unified admin console for Hanzo Cloud and all cloud products.",
"description": "Hanzo Cloud Console \u2014 unified admin console for Hanzo Cloud and all cloud products.",
"scripts": {
"dev": "next dev -p 4000",
"build": "next build",
+2 -2
View File
@@ -79,7 +79,7 @@ import {
type ProductSubpage,
} from '~/lib/products/registry'
import { productSubpages, subpageWired } from '~/lib/products/match'
import { ProductUpstreamNote } from '~/components/products/ProductUpstreamNote'
import { ProductGuidePanel } from '~/components/guide/ProductGuidePanel'
import { ConsoleFooter } from '~/components/ConsoleFooter'
import { openProduct } from '~/lib/products/open'
import { entryMatches } from '~/lib/products/search'
@@ -1186,8 +1186,8 @@ export function DashboardShell({ children }: { children: ReactNode }) {
<ScrollView flex={1}>
<XStack justify="center" px="$3" $md={{ px: '$4' }} $xl={{ px: '$6' }}>
<YStack testID="product-content" width="100%" maxW={CONTENT_MAX} pt="$3" pb={80} $md={{ pt: '$4' }} $xl={{ pt: '$5', gap: '$5' }} gap="$4">
<ProductGuidePanel pathname={pathname} />
{children}
<ProductUpstreamNote pathname={pathname} />
<ConsoleFooter />
</YStack>
</XStack>
+242
View File
@@ -0,0 +1,242 @@
'use client'
/**
* PitchHero — the NATIVE, dynamic, personalized product pitch + getting-started
* panel. It replaces the old "forked from X" upstream note with something that SELLS
* the product: a headline + value props (the why), above a getting-started checklist
* (the how) that checks itself off against the user's REAL state and can launch a
* spotlight tour of what's left.
*
* DYNAMIC + honest by construction:
* - every step's done-state comes from a real signal (has an API key, has opened the
* product) — never fabricated; unknown ⟹ not done;
* - the panel auto-hides once every visible step is done (or the user dismisses it),
* so it leads the page for a new user and gets out of a power user's way;
* - `when` predicates personalize which steps appear (e.g. "invite your team" only
* for an org admin);
* - the tour is generated from the INCOMPLETE steps and spotlights their on-screen
* rows — zero external scripts (works in the go:embed console, under any CSP).
*
* Accessible + reduced-motion-safe: entrance via the shared `FadeIn` (CSS keyframe,
* honors prefers-reduced-motion); no count-up or auto-motion; every control labeled.
* @hanzo/gui v5 shorthands only.
*/
import { useEffect, useState } from 'react'
import { useRouter } from 'next/navigation'
import { Button, Card, Text, XStack, YStack } from '@hanzo/gui'
import {
ArrowRight,
Check,
Code,
Coins,
Compass,
Database,
Gauge,
Globe,
Lock,
Plug,
Rocket,
Search,
Shield,
Sparkles,
X,
Zap,
} from '@hanzogui/lucide-icons-2'
import { FadeIn } from '~/components/ui/FadeIn'
import { GuidedTour } from '~/components/tour/GuidedTour'
import { useGuideSignals } from '~/lib/guide/use-signals'
import { stepAnchorId } from '~/lib/guide/signals'
import { isGuideDismissed, dismissGuide } from '~/lib/guide/guard'
import {
buildTourFromSteps,
completion,
incompleteCount,
resolveSteps,
resolveTour,
type PitchIcon,
type ProductGuide,
} from '~/lib/guide/spec'
import type { ProductIcon } from '~/lib/products/registry'
/** Pitch-point icon name → Lucide glyph (kept out of the pure data layer). */
const PITCH_ICON: Record<PitchIcon, ProductIcon> = {
zap: Zap,
gauge: Gauge,
shield: Shield,
sparkles: Sparkles,
plug: Plug,
code: Code,
coins: Coins,
globe: Globe,
lock: Lock,
rocket: Rocket,
search: Search,
database: Database,
}
const onAdminHost = (): boolean =>
typeof window !== 'undefined' && window.location.hostname.startsWith('admin.')
/** The pitch + dynamic getting-started panel for one product guide. */
export function PitchHero({ guide }: { guide: ProductGuide }) {
const router = useRouter()
const { signals, ready } = useGuideSignals()
const owner = signals.owner
const [mounted, setMounted] = useState(false)
const [dismissed, setDismissed] = useState(false)
const [tourOpen, setTourOpen] = useState(false)
useEffect(() => setMounted(true), [])
useEffect(() => {
if (owner) setDismissed(isGuideDismissed(owner, guide.id))
}, [owner, guide.id])
// Never block, never flash: wait for hydration + preferences, and stay off the
// operator host (customer onboarding, not an operator surface).
if (!mounted || !ready || onAdminHost()) return null
const resolved = resolveSteps(guide, signals)
// Auto-hide once there is nothing left to do (or the user dismissed it) — the panel
// leads the page for a new user and disappears for one who is already set up.
if (dismissed || incompleteCount(resolved) === 0) return null
const { done, total, pct } = completion(resolved)
const tourSteps = resolveTour(buildTourFromSteps(guide, resolved), signals)
const { pitch } = guide
const dismiss = () => {
if (owner) dismissGuide(owner, guide.id)
setDismissed(true)
}
return (
<FadeIn style={{ width: '100%' }}>
<Card
testID="product-guide"
p="$4"
$md={{ p: '$5' }}
gap="$4"
borderWidth={1}
borderColor="$borderColor"
bg="$color1"
overflow="hidden"
>
{/* Headline + subhead, with tour + dismiss controls */}
<XStack justify="space-between" items="flex-start" gap="$3" flexWrap="wrap">
<YStack flex={1} minW={260} gap="$2">
<Text fontSize="$1" color="$color10" fontWeight="700" letterSpacing={1}>
GET STARTED
</Text>
<Text fontSize="$8" $md={{ fontSize: '$9' }} fontWeight="900" color="$color12" style={{ lineHeight: 1.12 }}>
{pitch.headline}
</Text>
<Text fontSize="$4" color="$color11" maxW={720}>
{pitch.subhead}
</Text>
</YStack>
<XStack gap="$2" items="center">
{tourSteps.length ? (
<Button size="$2" theme="light" icon={<Compass size={14} />} onPress={() => setTourOpen(true)}>
Take the tour
</Button>
) : null}
<Button size="$2" chromeless circular icon={<X size={16} />} onPress={dismiss} aria-label="Dismiss getting-started" />
</XStack>
</XStack>
{/* Value props — the WHY */}
{pitch.points.length ? (
<XStack flexWrap="wrap" gap="$3">
{pitch.points.map((p) => {
const PIcon = p.icon ? PITCH_ICON[p.icon] : Sparkles
return (
<YStack key={p.title} flex={1} minW={200} gap="$1.5" p="$3" rounded="$4" bg="$color2" borderWidth={1} borderColor="$borderColor">
<XStack items="center" gap="$2">
<PIcon size={16} />
<Text fontSize="$4" fontWeight="800" color="$color12">
{p.title}
</Text>
</XStack>
<Text fontSize="$2" color="$color11">
{p.body}
</Text>
</YStack>
)
})}
</XStack>
) : null}
{/* Getting-started checklist — the dynamic HOW */}
<XStack items="center" justify="space-between" gap="$3" pt="$1">
<Text fontSize="$5" fontWeight="800" color="$color12">
Getting started
</Text>
<Text fontSize="$2" color="$color10" fontWeight="600">
{done} of {total} done
</Text>
</XStack>
{/* Progress meter (static — reduced-motion-safe) */}
<YStack height={6} rounded="$10" bg="$color4" overflow="hidden" aria-hidden>
<YStack height={6} rounded="$10" style={{ width: `${pct}%`, backgroundColor: '#2ea043' }} />
</YStack>
<YStack gap="$2.5">
{resolved.map((r, i) => (
<XStack
key={r.step.id}
data-tour={stepAnchorId(guide.id, r.step.id)}
items="center"
gap="$3"
p="$3"
rounded="$4"
borderWidth={1}
borderColor={r.active ? '$color8' : '$borderColor'}
bg={r.active ? '$color2' : '$color1'}
>
<YStack
width={26}
height={26}
rounded="$10"
items="center"
justify="center"
{...(r.done ? { style: { backgroundColor: '#2ea043' } } : { bg: r.active ? '$color9' : '$color4' })}
>
{r.done ? (
<Check size={15} color="#fff" />
) : (
<Text fontSize="$2" fontWeight="800" color={r.active ? '#fff' : '$color11'}>
{i + 1}
</Text>
)}
</YStack>
<YStack flex={1} gap="$0.5" minW={0}>
<Text fontSize="$4" fontWeight="700" color="$color12" style={r.done ? { textDecorationLine: 'line-through' } : undefined}>
{r.step.title}
</Text>
<Text fontSize="$2" color="$color10">
{r.step.body}
</Text>
</YStack>
{r.step.action ? (
<Button
size="$2"
theme={r.active ? 'light' : undefined}
chromeless={!r.active}
iconAfter={<ArrowRight size={14} />}
onPress={() => router.push(r.step.action!.to)}
>
{r.done ? 'Revisit' : r.step.action.label}
</Button>
) : null}
</XStack>
))}
</YStack>
<GuidedTour steps={tourSteps} open={tourOpen} onClose={() => setTourOpen(false)} onFinish={() => setTourOpen(false)} />
</Card>
</FadeIn>
)
}
@@ -0,0 +1,46 @@
'use client'
/**
* ProductGuidePanel — the ONE place a product's pitch + getting-started panel is
* mounted. Rendered once at the top of the console content column (DashboardShell),
* it resolves the current product from the path and, when that product has a curated
* guide, renders {@link PitchHero}. Everywhere else it renders nothing — the long
* tail keeps its own native overview / admin surface (no generic nagging).
*
* It also records honest landing telemetry for EVERY product (not only guided ones):
* being on a product's landing is a real "you've opened it" fact, which is what lets
* other guides' steps check themselves off (e.g. the console guide's "try it in the
* Playground" ticks once you have actually opened the Playground).
*
* Two mount shapes: `pathname` (product landing, resolved to a canonical id) or an
* explicit `guideId` (the console home passes `overview`). The empty path IS the home.
*/
import { useEffect } from 'react'
import { matchRoute } from '~/lib/products/match'
import { resolveGuide } from '~/lib/guide/registry'
import { useMarkUsed } from '~/lib/guide/use-signals'
import { PitchHero } from './PitchHero'
/** Canonical product id for a path — but ONLY on the product's landing route. */
export function landingProductId(pathname?: string): string | undefined {
if (!pathname) return undefined
const segs = pathname.replace(/^\/+|\/+$/g, '').split('/').filter(Boolean)
if (segs.length === 0) return 'overview' // the console home
if (segs.length !== 1) return undefined // the pitch leads the landing only, not sub-pages
return matchRoute(segs)?.module.id
}
export function ProductGuidePanel({ pathname, guideId }: { pathname?: string; guideId?: string }) {
const markUsed = useMarkUsed()
const id = guideId ?? landingProductId(pathname)
useEffect(() => {
if (id) markUsed(id)
}, [id, markUsed])
if (!id) return null
const guide = resolveGuide(id)
if (!guide) return null
return <PitchHero guide={guide} />
}
@@ -137,7 +137,7 @@ export function ProductInterstitial({ id }: { id: string }) {
<LinkCard
icon={<Github size={20} />}
title="Open source"
body={`${entry.label} is open source at ${entry.repo}.${entry.upstream ? ` Forked from ${entry.upstream.name} (${entry.upstream.license}).` : ''} Read the code, file issues, or contribute.`}
body={`${entry.label} is open source at ${entry.repo}. Read the code, file issues, or contribute.`}
cta="View on GitHub"
onPress={() => open(githubUrl(entry.repo!))}
/>
@@ -1,49 +0,0 @@
'use client'
import { XStack, Text } from '@hanzo/gui'
import { GitFork, ArrowUpRight } from '@hanzogui/lucide-icons-2'
import { matchRoute } from '~/lib/products/match'
import { findEntry } from '~/lib/products/registry'
/**
* Universal per-product OSS upstream credit.
*
* Rendered ONCE at the bottom of the product content column (DashboardShell), so
* EVERY product module — native overview, resource card, or bespoke admin surface —
* surfaces the open-source project it is forked from, from the ONE source of truth
* (the catalog `upstream` field on the matched entry). Renders nothing for original
* Hanzo products or non-product routes. This is the single, DRY attribution surface;
* honest OSS citizenship + license compliance, distinct from the Zen model brand policy.
*/
export function ProductUpstreamNote({ pathname }: { pathname: string }) {
const segs = pathname.split('/').filter(Boolean)
const matched = matchRoute(segs)
// matchRoute resolves the canonical module id (incl. aliases); the full catalog
// entry carries the `upstream` metadata (the slim ProductModule does not).
const up = matched ? findEntry(matched.module.id)?.upstream : undefined
if (!up) return null
return (
<XStack
testID="product-upstream"
mt="$4"
pt="$3"
borderTopWidth={1}
borderColor="$borderColor"
items="center"
gap="$2"
flexWrap="wrap"
cursor="pointer"
hoverStyle={{ opacity: 0.85 }}
onPress={() => {
if (typeof window !== 'undefined') window.open(up.url, '_blank', 'noopener')
}}
>
<GitFork size={13} opacity={0.6} />
<Text fontSize="$2" color="$color10">
{`Built on open source — forked from ${up.name} (${up.license}).`}
</Text>
<ArrowUpRight size={12} opacity={0.5} />
</XStack>
)
}
+1 -17
View File
@@ -32,21 +32,5 @@ export function defaultSpec(entry: CatalogEntry): OverviewSpec {
/** The native-overview spec for an entry: registered content, or the derived default. */
export function resolveSpec(entry: CatalogEntry): OverviewSpec {
const spec = OVERVIEW_SPECS[entry.id] ?? defaultSpec(entry)
// Honest upstream attribution: fork products surface the OSS project they are
// built on (name + SPDX license) as a key fact, on bespoke and derived specs alike.
if (entry.upstream && !spec.facts.some((f) => f.label === 'Upstream')) {
return {
...spec,
facts: [
...spec.facts,
{
label: 'Upstream',
value: entry.upstream.name,
hint: `Forked from ${entry.upstream.name} · ${entry.upstream.license}`,
},
],
}
}
return spec
return OVERVIEW_SPECS[entry.id] ?? defaultSpec(entry)
}
+6 -1
View File
@@ -18,11 +18,14 @@ import { usePathname } from 'next/navigation'
import { useSession } from '~/lib/auth/session'
import { isLocallyComplete, isDismissedForSession } from '~/lib/onboarding/guard'
import { CONSOLE_TOUR, hasSeenTour, markTourSeen } from '~/lib/tour/steps'
import { useGuideSignals } from '~/lib/guide/use-signals'
import { resolveTour } from '~/lib/guide/spec'
import { GuidedTour } from './GuidedTour'
export function FirstRunTour() {
const { account } = useSession()
const pathname = usePathname()
const { signals } = useGuideSignals()
const owner = account?.owner ?? ''
const [mounted, setMounted] = useState(false)
@@ -55,6 +58,8 @@ export function FirstRunTour() {
}
if (!open) return null
return <GuidedTour steps={CONSOLE_TOUR} open={open} onClose={close} onFinish={close} />
// Personalized: drop steps that don't apply to this user (e.g. the API-key step
// once they hold a key) before spotlighting.
return <GuidedTour steps={resolveTour(CONSOLE_TOUR, signals)} open={open} onClose={close} onFinish={close} />
}
+52
View File
@@ -0,0 +1,52 @@
import { describe, it, expect, afterEach } from 'vitest'
import { isGuideDismissed, dismissGuide, resetGuide } from './guard'
/** A Map-backed localStorage so set/get round-trips (real browser semantics). */
function stubWindow(): void {
const store = new Map<string, string>()
;(globalThis as { window?: unknown }).window = {
localStorage: {
getItem: (k: string) => (store.has(k) ? store.get(k)! : null),
setItem: (k: string, val: string) => void store.set(k, val),
removeItem: (k: string) => void store.delete(k),
},
}
}
afterEach(() => {
delete (globalThis as { window?: unknown }).window
})
describe('guide dismissal guard (versioned, owner+product-keyed, SSR-safe)', () => {
it('undismissed → dismiss → dismissed → reset → undismissed', () => {
stubWindow()
expect(isGuideDismissed('o', 'models')).toBe(false)
dismissGuide('o', 'models')
expect(isGuideDismissed('o', 'models')).toBe(true)
resetGuide('o', 'models')
expect(isGuideDismissed('o', 'models')).toBe(false)
})
it('is scoped per owner AND per product (no cross-leak)', () => {
stubWindow()
dismissGuide('o1', 'models')
expect(isGuideDismissed('o1', 'models')).toBe(true)
expect(isGuideDismissed('o2', 'models')).toBe(false)
expect(isGuideDismissed('o1', 'chat')).toBe(false)
})
it('is safe on the server (no window) — false, no throw', () => {
expect(isGuideDismissed('o', 'models')).toBe(false)
expect(() => dismissGuide('o', 'models')).not.toThrow()
expect(() => resetGuide('o', 'models')).not.toThrow()
})
it('ignores empty owner or id', () => {
stubWindow()
dismissGuide('', 'models')
dismissGuide('o', '')
expect(isGuideDismissed('', 'models')).toBe(false)
expect(isGuideDismissed('o', '')).toBe(false)
})
})
+46
View File
@@ -0,0 +1,46 @@
/**
* Per-product guide DISMISSAL guard — versioned, owner-keyed, SSR-safe localStorage,
* mirroring `lib/tour/steps.ts` (hasSeenTour) and `lib/onboarding/guard.ts`.
*
* A guide's getting-started panel hides on its own once the user has finished every
* step (the DYNAMIC completion state). This guard is the MANUAL override: a user can
* dismiss a product's panel and it stays hidden for that product on this browser,
* even with steps outstanding. Resettable, so a "Show guide" affordance can bring it
* back. Owner-keyed so it never leaks across accounts on a shared browser.
*/
/** Bump to re-surface every product guide after a material content change. */
export const GUIDE_VERSION = 1
const dismissKey = (owner: string, id: string): string =>
`hz_guide_dismissed:v${GUIDE_VERSION}:${owner}:${id}`
/** True once this account has dismissed this product's guide panel on this browser. */
export function isGuideDismissed(owner: string, id: string): boolean {
if (!owner || !id || typeof window === 'undefined') return false
try {
return window.localStorage.getItem(dismissKey(owner, id)) === '1'
} catch {
return false
}
}
/** Dismiss a product's guide panel for this account (survives reloads). */
export function dismissGuide(owner: string, id: string): void {
if (!owner || !id || typeof window === 'undefined') return
try {
window.localStorage.setItem(dismissKey(owner, id), '1')
} catch {
/* private mode — worst case the panel shows again next session */
}
}
/** Clear the dismissal so the guide can resurface (a "Show guide" affordance). */
export function resetGuide(owner: string, id: string): void {
if (!owner || !id || typeof window === 'undefined') return
try {
window.localStorage.removeItem(dismissKey(owner, id))
} catch {
/* no-op */
}
}
+64
View File
@@ -0,0 +1,64 @@
import { describe, it, expect } from 'vitest'
import { GUIDES, resolveGuide, hasGuide } from './registry'
import { resolveSteps } from './spec'
import type { GuideSignals } from './signals'
const sig = (over: Partial<GuideSignals> = {}): GuideSignals => ({
owner: 'o',
firstRun: true,
role: 'member',
used: {},
...over,
})
describe('guide registry', () => {
it('resolves curated flagships and is silent for the long tail', () => {
expect(hasGuide('overview')).toBe(true)
expect(hasGuide('models')).toBe(true)
expect(resolveGuide('models')).toBe(GUIDES.models)
expect(resolveGuide('totally-unknown')).toBeUndefined()
expect(hasGuide('totally-unknown')).toBe(false)
})
it('every curated guide is well-formed: pitch + >=1 step, native routes only', () => {
for (const [id, g] of Object.entries(GUIDES)) {
expect(g.id).toBe(id)
expect(g.pitch.headline.length).toBeGreaterThan(0)
expect(g.pitch.subhead.length).toBeGreaterThan(0)
expect(g.steps.length).toBeGreaterThanOrEqual(1)
for (const s of g.steps) {
expect(s.title.length).toBeGreaterThan(0)
expect(s.body.length).toBeGreaterThan(0)
// A CTA is always a NATIVE in-console route (never an external link-out).
if (s.action) expect(s.action.to.startsWith('/')).toBe(true)
}
for (const p of g.pitch.points) {
expect(p.title.length).toBeGreaterThan(0)
expect(p.body.length).toBeGreaterThan(0)
}
if (g.tour) for (const t of g.tour) if (t.target) expect(t.target).toMatch(/^\[data-tour="[a-z-]+"\]$/)
}
})
it('is honest: nothing is checked for a brand-new user (no fabricated progress)', () => {
for (const g of Object.values(GUIDES)) {
const resolved = resolveSteps(g, sig())
expect(resolved.every((r) => r.done === false)).toBe(true)
}
})
it('a step checks off ONLY when its real signal is present', () => {
const models = resolveGuide('models')!
expect(resolveSteps(models, sig()).find((r) => r.step.id === 'api-key')!.done).toBe(false)
expect(resolveSteps(models, sig({ hasApiKey: true })).find((r) => r.step.id === 'api-key')!.done).toBe(true)
})
it('the console guide gates "invite your team" behind an admin role', () => {
const ov = resolveGuide('overview')!
const asMember = resolveSteps(ov, sig()).map((r) => r.step.id)
const asAdmin = resolveSteps(ov, sig({ role: 'admin' })).map((r) => r.step.id)
expect(asMember).not.toContain('invite')
expect(asAdmin).toContain('invite')
})
})
+322
View File
@@ -0,0 +1,322 @@
/**
* The product-guide CATALOG — hand-authored pitches + dynamic getting-started steps
* for the flagship products a user actually onboards into. The DRY twin of
* `overview/spec.ts` (OVERVIEW_SPECS): keyed by catalog id, resolved by `resolveGuide`.
*
* Scope is deliberately CURATED, not universal: a guide exists only where there is a
* real, compelling pitch and honestly-checkable steps to show — so the panel appears
* where it sells the product and is silent everywhere else (no generic nagging on the
* long tail, which keeps its own native overview / admin surface). Every claim here
* is real (drawn from the product's actual capabilities); every step's `done`
* predicate reads a REAL signal, so the checklist can never fabricate progress.
*
* PURE DATA (no React) — the icon is a string name resolved in the component, the
* `done`/`when` predicates are plain functions over `GuideSignals`. Node-testable.
*/
import type { ProductGuide } from './spec'
import { isUsed, canAdminister, type GuideSignals } from './signals'
/** Done when the account holds a Cloud API key (a real, strong signal). */
const hasKey = (s: GuideSignals): boolean => s.hasApiKey === true
/** Done when the user has opened any of the given products in-console (real telemetry). */
const usedAny =
(...ids: string[]) =>
(s: GuideSignals): boolean =>
ids.some((id) => isUsed(s, id))
/** The shared "Create your API key" step — reused across every product that needs a key. */
const getKeyStep = (label: string) => ({
id: 'api-key',
title: 'Create your API key',
body: `Mint a personal \`hk-\` key to call ${label} from your apps, SDKs, and CLI. It is the fastest way to start building.`,
action: { label: 'Get API key', to: '/api-keys' },
done: hasKey,
})
/**
* Curated guides, keyed by catalog id. Route targets are real in-console routes
* (leading slash) — enforced by `registry.test.ts`.
*/
export const GUIDES: Record<string, ProductGuide> = {
// ── Console home — the top-level onboarding checklist ────────────────────────
overview: {
id: 'overview',
label: 'your console',
pitch: {
headline: 'Your entire AI cloud, in one console.',
subhead:
'Models, vector search, functions, deployments, identity, and spend — every Hanzo Cloud product behind one login, one API key, and one bill. Here is the fastest path from signed-in to shipping.',
points: [
{ title: 'One key, every product', body: 'A single credential calls models, embeddings, search, and your own services.', icon: 'zap' },
{ title: 'Pay for what you use', body: 'Per-token pricing, live usage, and spend on the overview — no minimums, no surprises.', icon: 'coins' },
{ title: 'Open by default', body: 'Every product is open-source and runs on your terms — self-host or run it here.', icon: 'globe' },
],
},
steps: [
getKeyStep('every Hanzo model'),
{
id: 'first-call',
title: 'Try it in the Playground',
body: 'Send your first prompt to a live model right in the browser — no code needed — then copy the request as curl or SDK.',
action: { label: 'Open Playground', to: '/playground' },
done: usedAny('playground', 'chat', 'models'),
},
{
id: 'ship',
title: 'Ship something',
body: 'Deploy a serverless function or an app from Git and get a live URL in minutes.',
action: { label: 'Open Functions', to: '/functions' },
done: usedAny('functions', 'projects', 'applications'),
},
{
id: 'invite',
title: 'Invite your team',
body: 'Add teammates and assign roles so everyone works in the same organization.',
action: { label: 'Open IAM', to: '/iam' },
when: (s) => canAdminister(s.role),
done: usedAny('iam'),
},
],
// The home carries stable anchors (sidebar, api-key CTA, metrics) the first-run
// tour already spotlights — appended after the generated step-walk.
tour: [
{ id: 'nav', target: '[data-tour="nav"]', title: 'Every product, one place', body: 'Browse and open every product from the sidebar — pin the ones you use most.', placement: 'right' },
{ id: 'metrics', target: '[data-tour="metrics"]', title: 'Live observability', body: 'Your inference metrics, logs, and traces stream here in real time.', placement: 'top' },
],
},
// ── AI ───────────────────────────────────────────────────────────────────────
models: {
id: 'models',
label: 'Models',
pitch: {
headline: '100+ models. One OpenAI-compatible API.',
subhead:
'Frontier and open models — Zen, Claude, GPT, Llama, and more — behind a single /v1 endpoint. Switch models by changing one string; routing and fallback are built in.',
points: [
{ title: 'Drop-in compatible', body: 'The OpenAI /v1 surface — point your existing SDK at Hanzo and go.', icon: 'plug' },
{ title: 'Per-token pricing', body: 'Transparent per-Mtok rates on every model, metered into one bill.', icon: 'coins' },
{ title: 'Route & fall back', body: 'Set a primary and let requests fail over automatically.', icon: 'gauge' },
],
},
steps: [
getKeyStep('any model'),
{
id: 'playground',
title: 'Compare models in the Playground',
body: 'Send the same prompt to different models side by side and pick the best fit for your task and budget.',
action: { label: 'Open Playground', to: '/playground' },
done: usedAny('playground'),
},
],
},
chat: {
id: 'chat',
label: 'Chat',
pitch: {
headline: 'A production chat surface, grounded in your data.',
subhead:
'Multi-turn conversations over any Hanzo model, with retrieval-augmented answers grounded in your own collections. Streamed token-by-token, with an honest add-credits state.',
points: [
{ title: 'Grounded RAG', body: 'Answers cite your indexed documents, not just the models memory.', icon: 'sparkles' },
{ title: 'Any model', body: 'Default to Zen or pick any model in the catalog per conversation.', icon: 'zap' },
{ title: 'Streaming', body: 'Replies stream as they generate — no waiting for the full turn.', icon: 'gauge' },
],
},
steps: [
getKeyStep('Chat'),
{
id: 'start',
title: 'Start a conversation',
body: 'Open Chat and send your first message to a live model.',
action: { label: 'Open Chat', to: '/chat' },
done: usedAny('chat'),
},
],
},
embeddings: {
id: 'embeddings',
label: 'Embeddings',
pitch: {
headline: 'Vector search over your data, in three steps.',
subhead:
'Create a collection, ingest your documents, and query them semantically — embeddings, indexing, and retrieval on one managed surface backed by a real vector store.',
points: [
{ title: 'Managed collections', body: 'Cosine-indexed collections with ingest and search built in.', icon: 'database' },
{ title: 'Any embedding model', body: 'Generate vectors with the model that fits your recall/cost target.', icon: 'sparkles' },
{ title: 'Semantic search', body: 'Query by meaning and get ranked hits with source locators.', icon: 'search' },
],
},
steps: [
{
id: 'collection',
title: 'Create a collection',
body: 'A collection is your searchable index. Name one and choose its embedding model.',
action: { label: 'Open Embeddings', to: '/embeddings' },
done: usedAny('embeddings'),
},
getKeyStep('the embeddings API'),
],
},
agents: {
id: 'agents',
label: 'Agents',
pitch: {
headline: 'Autonomous agents with tools and memory.',
subhead:
'Compose agents that plan, call tools, and remember across turns — then watch their runs, logs, and metrics in the console.',
points: [
{ title: 'Tool use', body: 'Give an agent tools and let it decide when to call them.', icon: 'plug' },
{ title: 'Observable runs', body: 'Every run streams its steps, logs, and metrics here.', icon: 'gauge' },
{ title: 'Multi-agent', body: 'Coordinate several agents on one task.', icon: 'sparkles' },
],
},
steps: [
getKeyStep('Agents'),
{
id: 'create',
title: 'Create your first agent',
body: 'Define an agent, give it tools, and start a run.',
action: { label: 'Open Agents', to: '/agents' },
done: usedAny('agents'),
},
],
},
playground: {
id: 'playground',
label: 'Playground',
pitch: {
headline: 'Prompt any model, then ship the code.',
subhead:
'Test prompts against live models in the browser, tune parameters, and copy a working request as curl or an SDK snippet.',
points: [
{ title: 'No setup', body: 'Prompt a live model instantly — nothing to install.', icon: 'zap' },
{ title: 'Copy as code', body: 'Export any run as a ready-to-paste curl or SDK call.', icon: 'code' },
{ title: 'Tune it', body: 'Adjust temperature, tokens, and system prompt and see the effect.', icon: 'gauge' },
],
},
steps: [
{
id: 'run',
title: 'Run a prompt',
body: 'Send your first prompt to a live model right here in the Playground.',
action: { label: 'Open Playground', to: '/playground' },
done: usedAny('playground'),
},
getKeyStep('models from your own code'),
],
},
vector: {
id: 'vector',
label: 'Vector',
pitch: {
headline: 'A managed vector database, provisioned on demand.',
subhead:
'Spin up a collection and store, index, and query embeddings with cosine similarity — the vector layer behind retrieval and semantic search.',
points: [
{ title: 'On-demand', body: 'Provision a collection in the console — no cluster to run.', icon: 'database' },
{ title: 'Semantic queries', body: 'Nearest-neighbor search over your vectors.', icon: 'search' },
{ title: 'Open source', body: 'Backed by open vector infrastructure you can self-host.', icon: 'globe' },
],
},
steps: [
{
id: 'collection',
title: 'Create a collection',
body: 'Name a collection to hold your vectors and start indexing.',
action: { label: 'Open Vector', to: '/vector' },
done: usedAny('vector'),
},
],
},
functions: {
id: 'functions',
label: 'Functions',
pitch: {
headline: 'Ship serverless functions in minutes.',
subhead:
'Deploy code that runs on demand — no servers to manage. Triggers, secrets, and per-invocation metrics are built in.',
points: [
{ title: 'No servers', body: 'Push a function and it scales to zero when idle.', icon: 'zap' },
{ title: 'Triggers & secrets', body: 'Wire HTTP or event triggers and inject secrets safely.', icon: 'plug' },
{ title: 'Per-call metrics', body: 'Invocations, duration, and errors tracked per function.', icon: 'gauge' },
],
},
steps: [
{
id: 'deploy',
title: 'Deploy your first function',
body: 'Open Functions and create one — it gets an invocation URL immediately.',
action: { label: 'Open Functions', to: '/functions' },
done: usedAny('functions'),
},
getKeyStep('your functions'),
],
},
projects: {
id: 'projects',
label: 'Projects',
pitch: {
headline: 'From Git to a live URL.',
subhead:
'Deploy apps straight from a repository or an upload and get a production URL, with builds, environments, and releases tracked in the console.',
points: [
{ title: 'Git or upload', body: 'Deploy from a repo or drop in a build — your choice.', icon: 'code' },
{ title: 'Live URL', body: 'Every deploy gets a real, health-gated URL.', icon: 'globe' },
{ title: 'Environments', body: 'Promote across environments with tracked releases.', icon: 'gauge' },
],
},
steps: [
{
id: 'create',
title: 'Create a project',
body: 'Point Projects at a repo or an upload to run your first deploy.',
action: { label: 'Open Projects', to: '/projects' },
done: usedAny('projects', 'applications'),
},
],
},
// ── Security ───────────────────────────────────────────────────────────────
iam: {
id: 'iam',
label: 'IAM',
pitch: {
headline: 'Identity for your whole organization.',
subhead:
'Manage users, roles, and organizations with a real OIDC provider — the same identity every Hanzo product authenticates against.',
points: [
{ title: 'OIDC native', body: 'A standards-compliant issuer your apps can trust.', icon: 'lock' },
{ title: 'Roles & orgs', body: 'Assign roles and scope access per organization.', icon: 'shield' },
{ title: 'One identity', body: 'The same login secures every product in the console.', icon: 'globe' },
],
},
steps: [
{
id: 'invite',
title: 'Invite a teammate',
body: 'Add a user to your organization and assign their role.',
action: { label: 'Open IAM', to: '/iam' },
when: (s) => canAdminister(s.role),
done: usedAny('iam'),
},
],
},
}
/** The curated guide for a catalog id, or `undefined` (the panel then stays silent). */
export function resolveGuide(id: string): ProductGuide | undefined {
return GUIDES[id]
}
/** True when a product has a curated guide. */
export function hasGuide(id: string): boolean {
return id in GUIDES
}
+79
View File
@@ -0,0 +1,79 @@
import { describe, it, expect } from 'vitest'
import {
usedMap,
withUsed,
isUsed,
firstRunFromOnboarding,
roleFrom,
canAdminister,
stepAnchorId,
stepAnchorSelector,
type GuideSignals,
} from './signals'
const sig = (over: Partial<GuideSignals> = {}): GuideSignals => ({
owner: 'hanzo/z',
firstRun: true,
role: 'member',
used: {},
...over,
})
describe('usedMap', () => {
it('keeps only the true entries and tolerates junk', () => {
expect(usedMap({ a: true, b: false, c: 1, d: 'x' })).toEqual({ a: true })
expect(usedMap(null)).toEqual({})
expect(usedMap(undefined)).toEqual({})
expect(usedMap([1, 2])).toEqual({})
expect(usedMap('nope')).toEqual({})
})
})
describe('withUsed / isUsed', () => {
it('adds immutably and idempotently', () => {
const m0: Record<string, boolean> = {}
const m1 = withUsed(m0, 'chat')
expect(m1).toEqual({ chat: true })
expect(m0).toEqual({}) // original untouched
expect(withUsed(m1, 'chat')).toBe(m1) // idempotent → same ref
expect(withUsed(m1, '')).toBe(m1) // ignores empty id
})
it('isUsed reads the signal map', () => {
expect(isUsed(sig({ used: { chat: true } }), 'chat')).toBe(true)
expect(isUsed(sig(), 'chat')).toBe(false)
})
})
describe('firstRunFromOnboarding', () => {
it('is first-run until onboarding is completed', () => {
expect(firstRunFromOnboarding(undefined)).toBe(true)
expect(firstRunFromOnboarding({})).toBe(true)
expect(firstRunFromOnboarding({ completed: true })).toBe(false)
})
})
describe('roleFrom / canAdminister', () => {
it('super-admin wins, then org-admin, then member/unknown', () => {
expect(roleFrom({ isSuperAdmin: true, isAdmin: true, known: true })).toBe('super-admin')
expect(roleFrom({ isAdmin: true, known: true })).toBe('admin')
expect(roleFrom({ known: true })).toBe('member')
expect(roleFrom({ known: false })).toBe('unknown')
expect(roleFrom({})).toBe('unknown')
})
it('canAdminister only for admin + super-admin', () => {
expect(canAdminister('super-admin')).toBe(true)
expect(canAdminister('admin')).toBe(true)
expect(canAdminister('member')).toBe(false)
expect(canAdminister('unknown')).toBe(false)
})
})
describe('anchors', () => {
it('builds a stable data-tour id + selector', () => {
expect(stepAnchorId('overview', 'api-key')).toBe('guide-overview-api-key')
expect(stepAnchorSelector('overview', 'api-key')).toBe('[data-tour="guide-overview-api-key"]')
})
})
+84
View File
@@ -0,0 +1,84 @@
/**
* Guide personalization SIGNALS — the pure value that drives which pitch, which
* getting-started steps, and which tour steps a signed-in user sees. DYNAMIC by
* construction: every field is a REAL fact about the user (their org role, whether
* they hold an API key, what they've actually opened in the console), never a
* fabricated one. An unknown fact stays `undefined` and any step that depends on it
* is treated as NOT done — we never claim a user did something we can't verify.
*
* PURE + SSR-safe (no React, no @hanzo/gui), so it is node-vitest-testable and
* shared by the hook (`use-signals.ts`), the step logic (`spec.ts`), and the tour.
* Mirrors the localStorage/preference conventions of `lib/onboarding/steps.ts` and
* `lib/tour/steps.ts`.
*/
import type { OnboardingState } from '~/lib/onboarding/steps'
/** The org role the guide personalizes on (best-effort; `unknown` when the account is silent). */
export type GuideRole = 'super-admin' | 'admin' | 'member' | 'unknown'
/** The real, per-user facts a guide adapts to. */
export interface GuideSignals {
/** Account owner key (org) — the identity/brand scope of every guide decision. */
owner: string
/** First-run ⟺ onboarding is not yet complete for this account. */
firstRun: boolean
/** The user's role in their org (drives team/admin-only steps). */
role: GuideRole
/** Whether the account holds a Cloud API key. `undefined` until known (honest). */
hasApiKey?: boolean
/** Product ids the user has actually opened/acted on in-console (our own honest telemetry). */
used: Record<string, boolean>
}
/** The preference key holding the honest in-console usage map (`{ [id]: true }`). */
export const USED_PREF_KEY = 'guide.used'
/** Read the usage map out of a raw preference value — defensively, always an object of `true`s. */
export function usedMap(raw: unknown): Record<string, boolean> {
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return {}
const out: Record<string, boolean> = {}
for (const [k, v] of Object.entries(raw as Record<string, unknown>)) if (v === true) out[k] = true
return out
}
/** Add a product id to the usage map (immutable, idempotent). */
export function withUsed(map: Record<string, boolean>, id: string): Record<string, boolean> {
if (!id || map[id]) return map
return { ...map, [id]: true }
}
/** True when the user has opened/acted on a product in-console. */
export function isUsed(signals: GuideSignals, id: string): boolean {
return Boolean(signals.used[id])
}
/** First-run ⟺ onboarding has not been completed. */
export function firstRunFromOnboarding(state: OnboardingState | undefined | null): boolean {
return !state?.completed
}
/** Map an account's admin flags to a role (super-admin wins; then org-admin; then member). */
export function roleFrom(input: {
isSuperAdmin?: boolean
isAdmin?: boolean
known?: boolean
}): GuideRole {
if (input.isSuperAdmin) return 'super-admin'
if (input.isAdmin) return 'admin'
return input.known ? 'member' : 'unknown'
}
/** True for a role that can administer the org (invite users, manage IAM). */
export function canAdminister(role: GuideRole): boolean {
return role === 'admin' || role === 'super-admin'
}
/** Stable `data-tour` anchor id for a guide step row (the tour's spotlight target). */
export function stepAnchorId(guideId: string, stepId: string): string {
return `guide-${guideId}-${stepId}`
}
/** The CSS selector the tour spotlights for a guide step row. */
export function stepAnchorSelector(guideId: string, stepId: string): string {
return `[data-tour="${stepAnchorId(guideId, stepId)}"]`
}
+104
View File
@@ -0,0 +1,104 @@
import { describe, it, expect } from 'vitest'
import {
resolveSteps,
completion,
incompleteCount,
buildTourFromSteps,
resolveTour,
stepDone,
type ProductGuide,
} from './spec'
import type { GuideSignals } from './signals'
import type { TourStep } from '~/lib/tour/steps'
const sig = (over: Partial<GuideSignals> = {}): GuideSignals => ({
owner: 'o',
firstRun: true,
role: 'member',
used: {},
...over,
})
const guide: ProductGuide = {
id: 'demo',
label: 'Demo',
pitch: { headline: 'H', subhead: 'S', points: [] },
steps: [
{ id: 'key', title: 'Get key', body: 'b', action: { label: 'Get', to: '/api-keys' }, done: (s) => s.hasApiKey === true },
{ id: 'use', title: 'Use it', body: 'b', action: { label: 'Open', to: '/demo' }, done: (s) => Boolean(s.used.demo) },
{ id: 'admin', title: 'Admin', body: 'b', when: (s) => s.role === 'admin' || s.role === 'super-admin' },
],
}
describe('resolveSteps', () => {
it('filters by `when`, computes `done`, marks the first not-done active', () => {
const r = resolveSteps(guide, sig()) // member → admin step filtered out
expect(r.map((x) => x.step.id)).toEqual(['key', 'use'])
expect(r[0].done).toBe(false)
expect(r[0].active).toBe(true)
expect(r[1].active).toBe(false)
})
it('a done step is never active — the next not-done becomes active', () => {
const r = resolveSteps(guide, sig({ hasApiKey: true }))
expect(r[0].done).toBe(true)
expect(r[0].active).toBe(false)
expect(r[1].active).toBe(true)
})
it('shows a `when`-gated step to a user who qualifies', () => {
const r = resolveSteps(guide, sig({ role: 'admin' }))
expect(r.map((x) => x.step.id)).toContain('admin')
})
it('never fabricates done for an unknown signal', () => {
// hasApiKey undefined → the predicate returns undefined → NOT done (honest).
expect(stepDone(guide.steps[0], sig())).toBe(false)
})
})
describe('completion / incompleteCount', () => {
it('tallies done/total/pct/complete', () => {
expect(completion(resolveSteps(guide, sig()))).toEqual({ done: 0, total: 2, pct: 0, complete: false })
expect(completion(resolveSteps(guide, sig({ hasApiKey: true, used: { demo: true } })))).toEqual({
done: 2,
total: 2,
pct: 100,
complete: true,
})
expect(incompleteCount(resolveSteps(guide, sig({ hasApiKey: true })))).toBe(1)
})
it('empty steps → zeros, not complete', () => {
expect(completion([])).toEqual({ done: 0, total: 0, pct: 0, complete: false })
expect(incompleteCount([])).toBe(0)
})
})
describe('buildTourFromSteps', () => {
it('spotlights only the incomplete step rows, in order', () => {
const resolved = resolveSteps(guide, sig({ hasApiKey: true })) // key done, use not
const tour = buildTourFromSteps(guide, resolved)
expect(tour.map((t) => t.id)).toEqual(['demo-use'])
expect(tour[0].target).toBe('[data-tour="guide-demo-use"]')
})
it('appends authored extra tour steps after the generated walk', () => {
const extra: TourStep = { id: 'x', title: 't', body: 'b' }
const g2: ProductGuide = { ...guide, tour: [extra] }
const tour = buildTourFromSteps(g2, resolveSteps(g2, sig()))
expect(tour[tour.length - 1].id).toBe('x')
})
})
describe('resolveTour', () => {
it('drops steps whose `when` predicate is false (personalized)', () => {
const steps: TourStep[] = [
{ id: 'a', title: 'a', body: 'b' },
{ id: 'key', title: 'k', body: 'b', when: (s) => s.hasApiKey !== true },
]
expect(resolveTour(steps, sig()).map((s) => s.id)).toEqual(['a', 'key']) // no key → shown
expect(resolveTour(steps, sig({ hasApiKey: true })).map((s) => s.id)).toEqual(['a']) // has key → dropped
})
})
+147
View File
@@ -0,0 +1,147 @@
/**
* The guide MODEL — pure types + logic for a product's pitch, its dynamic
* getting-started steps, and the spotlight tour generated from them. No React, no
* @hanzo/gui, so it is node-vitest-testable and the components stay presentational.
*
* A `ProductGuide` is the SELL for a product: a pitch (what + why) above a
* getting-started checklist (how). The checklist is DYNAMIC — each step carries a
* `done(signals)` predicate evaluated against the user's REAL state, so completed
* steps render checked and drop out of the tour, and the whole panel auto-hides once
* a user has finished (or dismissed) it. `when(signals)` personalizes which steps a
* user even sees (e.g. an "invite your team" step only for an org admin).
*/
import type { GuideSignals } from './signals'
import { stepAnchorSelector } from './signals'
import type { TourStep } from '~/lib/tour/steps'
/** A pitch icon name — resolved to a real Lucide glyph in the component (data stays pure). */
export type PitchIcon =
| 'zap'
| 'gauge'
| 'shield'
| 'sparkles'
| 'plug'
| 'code'
| 'coins'
| 'globe'
| 'lock'
| 'rocket'
| 'search'
| 'database'
/** One value proposition — the WHY of the pitch (short, real, no fabrication). */
export interface PitchPoint {
title: string
body: string
icon?: PitchIcon
}
/** The pitch — what the product is + why it's worth using, above the getting-started steps. */
export interface Pitch {
/** A punchy one-liner that SELLS the product. */
headline: string
/** One or two sentences of "what this is", richer than the catalog one-liner. */
subhead: string
/** 24 value props (the why). */
points: PitchPoint[]
}
/** One getting-started step — the HOW, personalized + checkable against real signals. */
export interface GuideStep {
id: string
title: string
body: string
/** In-console CTA (a native route only — never an external link-out). */
action?: { label: string; to: string }
/**
* DYNAMIC completion predicate against real signals. `true` ⟹ the user has already
* done this (rendered checked). Absent/`undefined`/`false` ⟹ treated as NOT done —
* we never render a fabricated check.
*/
done?: (s: GuideSignals) => boolean | undefined
/** Personalization: include the step only when this holds (default: always). */
when?: (s: GuideSignals) => boolean
}
/** A product's guide: the pitch + its getting-started steps (+ optional extra tour anchors). */
export interface ProductGuide {
/** Catalog id this guide is for. */
id: string
/** Product label (for tour copy / headings). */
label: string
pitch: Pitch
steps: GuideStep[]
/**
* Extra spotlight tour steps beyond the generated step-walk (e.g. the console
* home's nav/api-key/metrics anchors). Usually empty — the tour is generated from
* `steps`. Appended after the step-walk.
*/
tour?: TourStep[]
}
/** A step with its resolved dynamic state, ready to render. */
export interface StepProgress {
step: GuideStep
/** Verified done against the signals. */
done: boolean
/** The single "do this next" step — the first not-done among the visible steps. */
active: boolean
}
/** True when a step is verified done (a `true` predicate; unknown/absent ⟹ false — honest). */
export function stepDone(step: GuideStep, s: GuideSignals): boolean {
return step.done?.(s) === true
}
/** Visible steps for a user: filter by `when`, compute `done`, mark the first not-done active. */
export function resolveSteps(guide: ProductGuide, s: GuideSignals): StepProgress[] {
const visible = guide.steps.filter((st) => (st.when ? st.when(s) : true))
let activeTaken = false
return visible.map((step) => {
const done = stepDone(step, s)
const active = !done && !activeTaken
if (active) activeTaken = true
return { step, done, active }
})
}
/** Completion tally over resolved steps. */
export function completion(steps: StepProgress[]): {
done: number
total: number
pct: number
complete: boolean
} {
const total = steps.length
const done = steps.filter((s) => s.done).length
const pct = total === 0 ? 0 : Math.round((done / total) * 100)
return { done, total, pct, complete: total > 0 && done === total }
}
/** Count of not-yet-done visible steps — drives whether the panel shows at all. */
export function incompleteCount(steps: StepProgress[]): number {
return steps.filter((s) => !s.done).length
}
/**
* The tour for a guide = a spotlight walk over the user's INCOMPLETE steps (each
* highlighting that step's on-screen row), then any extra authored `tour` steps.
* Personalized: done steps are dropped, so the walk covers only what's left.
*/
export function buildTourFromSteps(guide: ProductGuide, resolved: StepProgress[]): TourStep[] {
const stepTour: TourStep[] = resolved
.filter((r) => !r.done)
.map((r) => ({
id: `${guide.id}-${r.step.id}`,
target: stepAnchorSelector(guide.id, r.step.id),
title: r.step.title,
body: r.step.body,
placement: 'right' as const,
}))
return [...stepTour, ...(guide.tour ?? [])]
}
/** Filter tour steps by their optional `when` predicate (dynamic / personalized). */
export function resolveTour(steps: TourStep[], s: GuideSignals): TourStep[] {
return steps.filter((st) => (st.when ? st.when(s) : true))
}
+100
View File
@@ -0,0 +1,100 @@
'use client'
/**
* useGuideSignals — assembles the live {@link GuideSignals} for the signed-in user
* from REAL sources only: the IAM account (owner + role), account preferences
* (onboarding completion + our own in-console usage map), and a best-effort probe of
* whether the account holds a Cloud API key. Nothing here is fabricated: an unknown
* fact (e.g. the key route not wired on a deployment) stays `undefined`, so a step
* that depends on it renders as not-done rather than a false check.
*
* Also exposes `markUsed(id)` — the honest telemetry write: when the user actually
* opens a product in-console we record it (account preference `guide.used`), which is
* what lets a getting-started step check itself off across devices.
*/
import { useCallback, useEffect, useState } from 'react'
import { useSession } from '~/lib/auth/session'
import { usePreferences } from '~/lib/products/preferences'
import { useIsSuperAdmin } from '~/lib/auth/admin'
import { KeysApi } from '~/lib/api/keys'
import type { OnboardingState } from '~/lib/onboarding/steps'
import {
USED_PREF_KEY,
firstRunFromOnboarding,
roleFrom,
usedMap,
withUsed,
type GuideSignals,
} from './signals'
export interface UseGuideSignals {
signals: GuideSignals
/** True once the account + preferences have resolved (avoids a flash of wrong state). */
ready: boolean
/** Record that the user opened/acted on a product — honest in-console telemetry. */
markUsed: (id: string) => void
}
/**
* The honest usage-telemetry writer, WITHOUT the API-key probe — so mounting it on
* every product landing (to record "you opened this") costs only a preference read.
* `useGuideSignals` reuses it; `ProductGuidePanel` uses it alone for pages with no
* guide, so the key probe fires only where a pitch actually renders.
*/
export function useMarkUsed(): (id: string) => void {
const { get, set } = usePreferences()
return useCallback(
(id: string) => {
if (!id) return
const current = usedMap(get<Record<string, boolean>>(USED_PREF_KEY, {}))
if (current[id]) return
set(USED_PREF_KEY, withUsed(current, id))
},
[get, set],
)
}
export function useGuideSignals(): UseGuideSignals {
const { account } = useSession()
const { get, ready: prefsReady } = usePreferences()
const isSuperAdmin = useIsSuperAdmin()
const markUsed = useMarkUsed()
const owner = account?.owner ?? ''
const [hasApiKey, setHasApiKey] = useState<boolean | undefined>(undefined)
// Honest, best-effort: does the account hold a Cloud API key? A failure (route not
// wired on this deployment, offline) leaves it `undefined` — the get-key step then
// stays not-done rather than showing a fabricated check. Re-probes per account.
useEffect(() => {
if (!owner) {
setHasApiKey(undefined)
return
}
let live = true
KeysApi.status()
.then((s) => {
if (live) setHasApiKey(Boolean(s?.hasKey))
})
.catch(() => {
if (live) setHasApiKey(undefined)
})
return () => {
live = false
}
}, [owner])
const used = usedMap(get<Record<string, boolean>>(USED_PREF_KEY, {}))
const onboarding = get<OnboardingState>('onboarding', {})
const signals: GuideSignals = {
owner,
firstRun: firstRunFromOnboarding(onboarding),
role: roleFrom({ isSuperAdmin, isAdmin: account?.isAdmin, known: Boolean(account) }),
hasApiKey,
used,
}
return { signals, ready: prefsReady, markUsed }
}
-17
View File
@@ -404,12 +404,6 @@ type CatalogBase = {
status: ProductStatus
/** Source repo for the product, e.g. 'hanzoai/vector'. Only set where it exists. */
repo?: string
/**
* Upstream open-source project this product is forked from — for honest
* attribution + license compliance. OMIT for original Hanzo products; set it
* only where the repo is a verified fork of the named upstream.
*/
upstream?: { name: string; url: string; license: string }
/** Canonical docs deep link (docs.hanzo.ai/<slug>); falls back to the docs root. */
docs?: string
/** Admin-gated surface (shown with a lock hint; access enforced server-side). */
@@ -1461,7 +1455,6 @@ export const catalog: CatalogEntry[] = [
category: 'Data',
status: 'enabled',
repo: 'hanzoai/vector',
upstream: { name: 'Qdrant', url: 'https://qdrant.tech', license: 'Apache-2.0' },
docs: `${DOCS}/vector`,
kind: 'module',
routes: resourceRoutes({ kind: 'vector', productLabel: 'Hanzo Vector', connectionHint: 'Point a Vector client at host:port using the connection string.' }),
@@ -1487,7 +1480,6 @@ export const catalog: CatalogEntry[] = [
category: 'Data',
status: 'enabled',
repo: 'hanzoai/kv',
upstream: { name: 'Valkey', url: 'https://valkey.io', license: 'BSD-3-Clause' },
docs: `${DOCS}/kv`,
kind: 'module',
routes: resourceRoutes({ kind: 'kv', productLabel: 'Hanzo KV', connectionHint: 'Connect with any KV client using the connection string.' }),
@@ -1506,7 +1498,6 @@ export const catalog: CatalogEntry[] = [
category: 'Data',
status: 'enabled',
repo: 'hanzoai/s3',
upstream: { name: 'SeaweedFS', url: 'https://github.com/seaweedfs/seaweedfs', license: 'Apache-2.0' },
docs: `${DOCS}/storage`,
kind: 'module',
routes: [{ path: '', component: StorageModule }],
@@ -1533,7 +1524,6 @@ export const catalog: CatalogEntry[] = [
label: 'Base',
icon: Boxes,
description: 'Realtime backends for your org — spin up a Base with content types, records, and auth.',
upstream: { name: 'PocketBase', url: 'https://pocketbase.io', license: 'MIT' },
category: 'Data',
status: 'enabled',
repo: 'hanzoai/base',
@@ -1558,7 +1548,6 @@ export const catalog: CatalogEntry[] = [
label: 'Records',
icon: Boxes,
description: 'Browse and edit any Base collection as a CRM/CMS — from its own schema.',
upstream: { name: 'PocketBase', url: 'https://pocketbase.io', license: 'MIT' },
category: 'Data',
status: 'enabled',
repo: 'hanzoai/base',
@@ -1579,7 +1568,6 @@ export const catalog: CatalogEntry[] = [
category: 'Data',
status: 'enabled',
repo: 'hanzoai/docdb',
upstream: { name: 'FerretDB', url: 'https://www.ferretdb.com', license: 'Apache-2.0' },
docs: `${DOCS}/docdb`,
kind: 'module',
routes: resourceRoutes({ kind: 'docdb', productLabel: 'Hanzo DocDB', connectionHint: 'Connect with any DocDB driver using the connection string.' }),
@@ -1595,7 +1583,6 @@ export const catalog: CatalogEntry[] = [
category: 'Network',
status: 'enabled',
repo: 'hanzoai/gateway',
upstream: { name: 'KrakenD', url: 'https://www.krakend.io', license: 'Apache-2.0' },
docs: `${DOCS}/gateway`,
kind: 'module',
routes: overviewRoutes('gateway'),
@@ -1712,7 +1699,6 @@ export const catalog: CatalogEntry[] = [
label: 'IAM',
icon: Shield,
description: 'Organizations, users, and roles (RBAC) — Hanzo IAM.',
upstream: { name: 'Casdoor', url: 'https://casdoor.org', license: 'Apache-2.0' },
category: 'Security',
status: 'enabled',
admin: true,
@@ -2797,7 +2783,6 @@ export const catalog: CatalogEntry[] = [
category: 'Apps',
status: 'enabled',
repo: 'hanzoai/chat',
upstream: { name: 'LibreChat', url: 'https://librechat.ai', license: 'MIT' },
docs: `${DOCS}/chat`,
kind: 'module',
routes: [
@@ -3032,7 +3017,6 @@ export const catalog: CatalogEntry[] = [
category: 'Apps',
status: 'enabled',
repo: 'hanzoai/search',
upstream: { name: 'Meilisearch', url: 'https://www.meilisearch.com', license: 'MIT' },
docs: `${DOCS}/search`,
kind: 'module',
routes: resourceRoutes({ kind: 'search', productLabel: 'Hanzo Search', connectionHint: 'Use the Search host + key from the connection string.' }),
@@ -3104,7 +3088,6 @@ export const catalog: CatalogEntry[] = [
category: 'Apps',
status: 'enabled',
repo: 'hanzoai/studio',
upstream: { name: 'ComfyUI', url: 'https://www.comfy.org', license: 'GPL-3.0' },
docs: `${DOCS}/ai-studio`,
kind: 'module',
// The FULL Studio app, embedded (same-site iframe) — every capability
+12 -1
View File
@@ -10,6 +10,8 @@
* target (or whose target isn't on the current page) renders centered.
*/
import type { GuideSignals } from '~/lib/guide/signals'
/** Where a step's tooltip sits relative to its target (centered when no target). */
export type TourPlacement = 'top' | 'bottom' | 'left' | 'right' | 'center'
@@ -20,6 +22,12 @@ export type TourStep = {
title: string
body: string
placement?: TourPlacement
/**
* Personalization: include the step only when this holds (default: always). Lets a
* tour adapt to the user's real state — e.g. skip the "get your API key" step for a
* user who already has one. Filtered by `resolveTour` in `lib/guide/spec.ts`.
*/
when?: (s: GuideSignals) => boolean
}
/**
@@ -43,10 +51,13 @@ export const CONSOLE_TOUR: TourStep[] = [
},
{
id: 'api-key',
target: '[data-tour="api-key"]',
// The get-your-key affordance now lives in the home getting-started panel.
target: '[data-tour="guide-overview-api-key"]',
title: 'Get your API key',
body: 'Create a personal key to call the models from your apps, SDKs, and CLI. It is the fastest way to start building.',
placement: 'bottom',
// Personalized: a user who already holds a key skips straight past this step.
when: (s) => s.hasApiKey !== true,
},
{
id: 'metrics',