feat(products): canonical Meet Hanzo ecosystem-shell spec + collapse SURFACES/HANZO_APPS into one source

This commit is contained in:
2026-07-23 12:59:02 -07:00
parent 25ab403d81
commit 4c2599d2a9
20 changed files with 1151 additions and 32 deletions
+2 -1
View File
@@ -124,6 +124,7 @@
"license": "BSD-3-Clause",
"author": "Hanzo AI <dev@hanzo.ai>",
"dependencies": {
"@hanzo/logo": "^1.0.13"
"@hanzo/logo": "^1.0.13",
"@hanzo/products": "workspace:*"
}
}
+17 -20
View File
@@ -1,16 +1,15 @@
// The ONE canonical list of Hanzo product surfaces the cross-surface app switcher
// offers — consumed by EVERY surface's launcher so the set never diverges.
// Re-export shim. The launcher surface DATA now lives ONCE in `@hanzo/products`
// (`SURFACES` — the collapse of this list and `@hanzogui/shell`'s `HANZO_APPS`).
// This file preserves the legacy `Surface` / `SurfaceId` / `SURFACES` /
// `otherSurfaces` shape byte-for-byte, so existing importers (the `AppHeader`
// app-switcher) need no change; it only sources the values from the canonical
// registry. Add or reorder a surface in `@hanzo/products`, not here.
//
// Framework-agnostic (plain TS, zero imports): React consumers (the @hanzo/ui
// `AppHeader`, the console `AppLauncher`) import it directly; a surface that
// cannot resolve THIS package — the Svelte Huly-fork `team` (whose own `@hanzo/ui`
// is the workbench UI framework, a name clash) and the shadcn-aliased `app` — keeps
// a byte-identical MIRROR that points back here. Add or reorder a surface HERE and
// every launcher moves together (mirrors are updated in the same change).
//
// `id` is the stable key AND the icon key: each framework maps it to its own glyph
// (React → lucide, Svelte → SurfaceIcon), so the DATA itself stays glyph-free.
// `id` is the stable key AND the icon key: `AppHeader` maps each to a lucide glyph,
// so the DATA stays glyph-free.
import { surfaceById } from '@hanzo/products'
/** The seven surfaces this shell's app switcher offers. */
export type SurfaceId = 'ai' | 'console' | 'app' | 'chat' | 'bot' | 'team' | 'billing'
/** One Hanzo surface the app switcher offers. */
@@ -25,16 +24,14 @@ export type Surface = {
hint: string
}
/** Display order for this shell (the canonical registry may hold more surfaces). */
const ORDER: SurfaceId[] = ['ai', 'console', 'app', 'chat', 'bot', 'team', 'billing']
/** 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' },
]
export const SURFACES: Surface[] = ORDER.map((id) => {
const s = surfaceById(id)!
return { id, label: s.label, href: s.href, hint: s.domain }
})
/** Every surface except `current` — a launcher never links to itself. */
export function otherSurfaces(current?: SurfaceId): Surface[] {
+68 -4
View File
@@ -1,9 +1,15 @@
# @hanzo/products
The canonical Hanzo product catalog — ONE typed contract + the two code maps that
can't live in a database + a commerce-backed client with a checked-in snapshot
fallback. The single source of truth the console, docs, marketing site, and pricing
all read, so the product taxonomy / icons / colors can never drift across surfaces.
The canonical Hanzo product source of truth. Two orthogonal, React-free concerns in
one tree-shakeable barrel:
1. **Catalog** — the console operational taxonomy (below): ONE typed contract + the
two code maps that can't live in a database + a commerce-backed client with a
checked-in snapshot fallback. The single source of truth the console, docs,
marketing site, and pricing all read, so the product taxonomy / icons / colors
can never drift across surfaces.
2. **Ecosystem shell** — the "Meet Hanzo" header/footer/menu (see the section at the
end): the six-product family + the shared chrome every property renders.
## Decomplected design
@@ -91,3 +97,61 @@ validates every `iconKey` against the set's shipped type declarations — no ESM
The snapshot was bootstrapped from the console2 catalog (the richest hand list).
Thereafter commerce is the editable source; the snapshot is the offline fallback +
the commerce seed. Keep it in sync when the seed changes.
## Ecosystem shell ("Meet Hanzo") — the second concern
The shared header / footer / mega-menu every property renders, driven by ONE spec so
the family / domains / actions / destinations never diverge. All React-free data;
each renderer (Next/React, Svelte, Tamagui, Fumadocs) maps a `Link.id` / `Product.id`
/ `Surface.id` to its own glyph — the data stays glyph-free.
Modules:
- `family.ts` — the six flagship products (`FAMILY`) + the `hanzo.ai` `ROOT`. Each
`Product` = `{ id, name, short, domain, url, job, verb?, action }`. The six verbs
(one each): Chat **Use** · App **Build** · Team **Work** · Studio **Create AI** ·
Bot **Deploy** · Cloud **Operate**. `findProduct(id)`.
- `destinations.ts``ORIGIN` (the only place a host string lives) + `DESTINATIONS`
(the 14 canonical shared links: products, apps, models, cloudProducts, downloads,
browserExtension, desktop, vscode, cli, sdks, docs, apiReference, console, status).
- `menu.ts``MEET_HANZO_MENU` = eyebrow + all-products link + the six-product grid
(`= FAMILY`) + a utility row (Models · Enso · Managed Agents · Hanzo Dev · MCP
Tools · Documentation) + an install row (Desktop · Browser · VS Code · CLI · SDKs ·
All downloads).
- `footer.ts``FOOTER` = 6 columns (Products · AI Platform · Install · Developers ·
Resources · Company) + the legal bottom bar.
- `header.ts``HEADERS: Record<SiteId, SiteHeader>` — per-property local nav +
primary action, `productId` tying each site to a `Product`. `findHeader(site)`.
- `surfaces.ts``SURFACES`, the collapsed launcher registry (below) +
`surfaceById` / `otherSurfaces`.
- `link.ts` — the `Link` / `Action` atoms.
### De-dupe: the collapsed launcher registry
`surfaces.ts` is the ONE launcher list, collapsing the two that used to drift:
- **`SURFACES`** (this repo, `pkg/ui/src/product/surfaces.data.ts`) — now a
re-export shim: it sources the DATA from `@hanzo/products` (`surfaceById`) and
preserves the legacy `Surface { id, label, href, hint }` / 7-literal `SurfaceId` /
order byte-for-byte, so `AppHeader` needs no change. WIRED + verified in-repo.
- **`HANZO_APPS`** (`@hanzogui/shell`, a separate repo) — the shim is ready but
**gated on publishing** `@hanzo/products` (cross-repo; a workspace link is not
possible). Until then that list stays local. When published, map each canonical
surface `id` → the shell's inline SVG icon; keep `zach` (personal portal) and
`world` local unless confirmed canonical.
The six product surfaces derive from `FAMILY` (single source); the root surface keeps
the legacy launcher id `"ai"` (not the product id `"hanzo"`); the platform surfaces
(Console · Billing · Account · Admin · Gateway · Platform) are launcher-only real
properties, not flagship products.
### Href provenance (honest)
Addresses the spec pins go through `DESTINATIONS`. Marketing sub-pages the spec names
but does not pin are built from `ORIGIN` on the honest conventions
`hanzo.ai/<slug>` · `docs.hanzo.ai/<slug>` · `/legal/<slug>` · `github.com/hanzoai`.
No host is fabricated (a test asserts every absolute href resolves to a known Hanzo
host and none carries an `/api/` path). Convention slugs to confirm before rollout:
Managed Agents / Hanzo Dev / MCP Tools (`hanzo.ai/{agents,dev,mcp}`), API Platform
(`api.hanzo.ai`), CLI Reference (`docs.hanzo.ai/cli`), the Resources/Company slugs,
and the in-site action paths (`/new`, `/`).
+1
View File
@@ -53,6 +53,7 @@
},
"devDependencies": {
"@hanzogui/lucide-icons-2": "7.3.0",
"@types/node": "^25.9.5",
"@types/react": "^19.0.0",
"react": "^19.0.0",
"tsup": "^8.5.1",
+40
View File
@@ -0,0 +1,40 @@
import { describe, expect, it } from "vitest"
import { DESTINATIONS, ORIGIN, type Destination } from "./destinations"
describe("DESTINATIONS — the canonical shared addresses (pinned by the spec)", () => {
it("matches the spec exactly", () => {
expect(DESTINATIONS).toEqual({
products: "https://hanzo.ai/products",
apps: "https://hanzo.ai/apps",
models: "https://hanzo.ai/models",
cloudProducts: "https://cloud.hanzo.ai/products",
downloads: "https://hanzo.app/download",
browserExtension: "https://hanzo.app/download/browser",
desktop: "https://hanzo.app/download/desktop",
vscode: "https://hanzo.app/download/vscode",
cli: "https://hanzo.app/download/cli",
sdks: "https://docs.hanzo.ai/developers/sdks",
docs: "https://docs.hanzo.ai",
apiReference: "https://docs.hanzo.ai/reference",
console: "https://cloud.hanzo.ai",
status: "https://status.hanzo.ai",
})
})
it("every destination is a non-empty absolute https URL", () => {
for (const key of Object.keys(DESTINATIONS) as Destination[]) {
const href = DESTINATIONS[key]
expect(href).toBeTruthy()
expect(href).toMatch(/^https:\/\//)
}
})
it("never uses an /api/ path prefix", () => {
for (const href of Object.values(DESTINATIONS)) expect(href).not.toMatch(/\/api(\/|$)/)
})
it("composes every destination from an ORIGIN host (no fabricated hosts)", () => {
const hosts = new Set(Object.values(ORIGIN).map((o) => new URL(o).host))
for (const href of Object.values(DESTINATIONS)) expect(hosts).toContain(new URL(href).host)
})
})
+67
View File
@@ -0,0 +1,67 @@
/**
* The canonical destinations map — the shared, cross-property links the shell uses
* everywhere (all-products, all-apps, all-models, the installs, docs, console,
* status). Every menu / footer / header link that points at one of these resolves
* it FROM here, so a shared destination has exactly ONE address.
*
* `ORIGIN` is the one place the real hosts live; `DESTINATIONS` composes the named
* links from them. A convention link elsewhere (a marketing sub-page not pinned by
* the spec, e.g. `hanzo.ai/about`) is built from `ORIGIN` too, so no host string is
* ever repeated. Pure data, React-free.
*/
/** The real Hanzo hosts every shell link is built on. The ONLY place a host lives. */
export const ORIGIN = {
/** The marketing root + umbrella brand. */
root: "https://hanzo.ai",
/** The one documentation site. */
docs: "https://docs.hanzo.ai",
/** The AI cloud console. */
cloud: "https://cloud.hanzo.ai",
/** The LLM gateway / API platform host (`api.*`, never an `/api` path). */
api: "https://api.hanzo.ai",
/** The status page. */
status: "https://status.hanzo.ai",
/** The downloads host (OS-detecting) — the App property also serves `/download`. */
downloads: "https://hanzo.app",
/** The org's GitHub. */
github: "https://github.com/hanzoai",
} as const
/**
* The canonical shared destinations. These addresses are pinned by the ecosystem
* spec — change one here and every surface that links to it moves together.
*/
export const DESTINATIONS = {
/** Meet Hanzo / All products. */
products: `${ORIGIN.root}/products`,
/** All apps. */
apps: `${ORIGIN.root}/apps`,
/** All models. */
models: `${ORIGIN.root}/models`,
/** All cloud products. */
cloudProducts: `${ORIGIN.cloud}/products`,
/** All downloads (OS-detecting). */
downloads: `${ORIGIN.downloads}/download`,
/** The browser extension download. */
browserExtension: `${ORIGIN.downloads}/download/browser`,
/** The desktop app download. */
desktop: `${ORIGIN.downloads}/download/desktop`,
/** The VS Code extension download. */
vscode: `${ORIGIN.downloads}/download/vscode`,
/** The CLI download. */
cli: `${ORIGIN.downloads}/download/cli`,
/** The SDKs index. */
sdks: `${ORIGIN.docs}/developers/sdks`,
/** The documentation home. */
docs: ORIGIN.docs,
/** The API reference. */
apiReference: `${ORIGIN.docs}/reference`,
/** The developer console. */
console: ORIGIN.cloud,
/** The status page. */
status: ORIGIN.status,
} as const
/** A key of the canonical destinations map. */
export type Destination = keyof typeof DESTINATIONS
+84
View File
@@ -0,0 +1,84 @@
import { describe, expect, it } from "vitest"
import { FAMILY, ROOT, PRODUCTS, findProduct, type MenuVerb } from "./family"
import { DESTINATIONS } from "./destinations"
describe("FAMILY — exactly the six flagship products", () => {
it("has exactly six products", () => {
expect(FAMILY).toHaveLength(6)
})
it("is the canonical journey order chat → app → team → studio → bot → cloud", () => {
expect(FAMILY.map((p) => p.id)).toEqual(["chat", "app", "team", "studio", "bot", "cloud"])
})
it("assigns each product its one menu verb, one each", () => {
const byId = Object.fromEntries(FAMILY.map((p) => [p.id, p.verb]))
expect(byId).toEqual({
chat: "Use",
app: "Build",
team: "Work",
studio: "Create AI",
bot: "Deploy",
cloud: "Operate",
})
const verbs = FAMILY.map((p) => p.verb)
expect(new Set(verbs).size).toBe(6) // all distinct
const expected: MenuVerb[] = ["Use", "Build", "Work", "Create AI", "Deploy", "Operate"]
expect(new Set(verbs)).toEqual(new Set(expected))
})
})
describe("ROOT — the hanzo.ai umbrella", () => {
it("is id 'hanzo' on hanzo.ai and carries no verb", () => {
expect(ROOT.id).toBe("hanzo")
expect(ROOT.domain).toBe("hanzo.ai")
expect(ROOT.url).toBe("https://hanzo.ai")
expect(ROOT.verb).toBeUndefined()
})
it("its action is Open Chat (per the header table)", () => {
expect(ROOT.action).toEqual({ label: "Open Chat", href: "https://hanzo.chat" })
})
})
describe("every product row is complete and honest", () => {
it("has non-empty name/short/domain/url/job and an action with label+href", () => {
for (const p of PRODUCTS) {
expect(p.id).toBeTruthy()
expect(p.name).toBeTruthy()
expect(p.short).toBeTruthy()
expect(p.domain).toBeTruthy()
expect(p.job).toBeTruthy()
expect(p.action.label).toBeTruthy()
expect(p.action.href).toBeTruthy()
}
})
it("url is always https://<domain>", () => {
for (const p of PRODUCTS) expect(p.url).toBe(`https://${p.domain}`)
})
it("every action href is an absolute https URL", () => {
for (const p of PRODUCTS) expect(p.action.href).toMatch(/^https:\/\//)
})
it("has no duplicate ids across root + family", () => {
const ids = PRODUCTS.map((p) => p.id)
expect(new Set(ids).size).toBe(ids.length)
})
})
describe("family ↔ destinations consistency", () => {
it("Cloud's front door is the developer console destination", () => {
expect(findProduct("cloud")!.url).toBe(DESTINATIONS.console)
})
})
describe("findProduct", () => {
it("finds root and family members", () => {
expect(findProduct("hanzo")).toBe(ROOT)
expect(findProduct("studio")!.name).toBe("Hanzo Studio")
})
it("returns undefined for an unknown id", () => {
expect(findProduct("nope")).toBeUndefined()
})
})
+123
View File
@@ -0,0 +1,123 @@
/**
* The Hanzo product family — the SIX flagship products the "Meet Hanzo" menu leads
* with, plus the `hanzo.ai` root. This is the single source of truth for the family:
* the mega-menu grid (`./menu`), the footer PRODUCTS column (`./footer`), the
* per-site header identity (`./header`), and the launcher's product tiles
* (`./surfaces`) all read it, so a product's name / domain / job / action can never
* drift across surfaces.
*
* Each product owns exactly ONE job, named by its menu verb:
* Chat → Use · App → Build · Team → Work · Studio → Create AI · Bot → Deploy · Cloud → Operate.
* The journey the family tells: Chat is the front door, App builds things, Team runs
* work, Studio creates intelligence, Bot distributes it, Cloud operates everything.
*
* Pure data, React-free.
*/
import type { Action } from "./link"
/** The one verb naming each flagship product's job. Exactly six, one per product. */
export type MenuVerb = "Use" | "Build" | "Work" | "Create AI" | "Deploy" | "Operate"
/** One product in the family — the unit the menu grid, footer, and headers render. */
export interface Product {
/** Stable id and glyph key (e.g. `"chat"`); `"hanzo"` is the root. Unique. */
id: string
/** Full display name, e.g. `"Hanzo Chat"`. */
name: string
/** Short label for dense surfaces (grid tiles, launcher), e.g. `"Chat"`. */
short: string
/** The property's domain, shown as a subtitle, e.g. `"hanzo.chat"`. */
domain: string
/** Absolute front-door URL — always `https://<domain>`. */
url: string
/** The one job this product owns, in a line. */
job: string
/** The menu verb for the six family products; omitted for the root umbrella. */
verb?: MenuVerb
/** The mega-menu call-to-action, e.g. `{ label: "Open Chat", href: "https://hanzo.chat" }`. */
action: Action
}
/**
* The six flagship products, in the canonical journey order
* (Chat → App → Team, then Studio → Bot → Cloud). Order IS the menu-grid order.
*/
export const FAMILY: Product[] = [
{
id: "chat",
name: "Hanzo Chat",
short: "Chat",
domain: "hanzo.chat",
url: "https://hanzo.chat",
job: "Talk to Hanzo — use models and agents.",
verb: "Use",
action: { label: "Open Chat", href: "https://hanzo.chat" },
},
{
id: "app",
name: "Hanzo App",
short: "App",
domain: "hanzo.app",
url: "https://hanzo.app",
job: "Build AI websites and apps.",
verb: "Build",
action: { label: "Start building", href: "https://hanzo.app" },
},
{
id: "team",
name: "Hanzo Team",
short: "Team",
domain: "hanzo.team",
url: "https://hanzo.team",
job: "Shared work and AI coworkers — channels, projects, tasks, and knowledge.",
verb: "Work",
action: { label: "Open Team", href: "https://hanzo.team" },
},
{
id: "studio",
name: "Hanzo Studio",
short: "Studio",
domain: "studio.hanzo.ai",
url: "https://studio.hanzo.ai",
job: "Design, test, evaluate, and version models, prompts, tools, and agents.",
verb: "Create AI",
action: { label: "Open Studio", href: "https://studio.hanzo.ai" },
},
{
id: "bot",
name: "Hanzo Bot",
short: "Bot",
domain: "hanzo.bot",
url: "https://hanzo.bot",
job: "Publish agents to your site, support, Slack, Discord, and channels.",
verb: "Deploy",
action: { label: "Create a bot", href: "https://hanzo.bot" },
},
{
id: "cloud",
name: "Hanzo Cloud",
short: "Cloud",
domain: "cloud.hanzo.ai",
url: "https://cloud.hanzo.ai",
job: "Run the infrastructure — deploy, observe, secure, and bill.",
verb: "Operate",
action: { label: "Open Cloud", href: "https://cloud.hanzo.ai" },
},
]
/** The `hanzo.ai` root — the umbrella brand, not one of the six verb-products. */
export const ROOT: Product = {
id: "hanzo",
name: "Hanzo",
short: "Hanzo",
domain: "hanzo.ai",
url: "https://hanzo.ai",
job: "AI for every way you build and work.",
action: { label: "Open Chat", href: "https://hanzo.chat" },
}
/** The root plus the six family products — the full product set. */
export const PRODUCTS: Product[] = [ROOT, ...FAMILY]
/** Find a product by id across the root + family; `undefined` when unknown. */
export const findProduct = (id: string): Product | undefined => PRODUCTS.find((p) => p.id === id)
+60
View File
@@ -0,0 +1,60 @@
import { describe, expect, it } from "vitest"
import { FOOTER } from "./footer"
import { FAMILY } from "./family"
import { DESTINATIONS } from "./destinations"
describe("FOOTER — the unified footer", () => {
it("has exactly the six columns in order", () => {
expect(FOOTER.columns.map((c) => c.title)).toEqual([
"PRODUCTS",
"AI PLATFORM",
"INSTALL",
"DEVELOPERS",
"RESOURCES",
"COMPANY",
])
})
it("PRODUCTS column is the six family front doors (single source) plus Dev", () => {
const products = FOOTER.columns.find((c) => c.id === "products")!
expect(products.links.map((l) => l.id)).toEqual(["chat", "app", "team", "studio", "bot", "cloud", "dev"])
// the six product hrefs come straight from FAMILY — they cannot drift
for (const p of FAMILY) {
expect(products.links.find((l) => l.id === p.id)!.href).toBe(p.url)
}
})
it("INSTALL column resolves to the pinned install destinations", () => {
const install = FOOTER.columns.find((c) => c.id === "install")!
expect(install.links.map((l) => l.href)).toEqual([
DESTINATIONS.desktop,
DESTINATIONS.browserExtension,
DESTINATIONS.vscode,
DESTINATIONS.cli,
DESTINATIONS.sdks,
DESTINATIONS.downloads,
])
})
it("the legal bottom bar carries the copyright and the five legal links", () => {
expect(FOOTER.legal.copyright).toBe("© 2026 Hanzo AI, Inc.")
expect(FOOTER.legal.links.map((l) => l.id)).toEqual(["status", "security", "privacy", "terms", "cookies"])
// privacy / terms / cookies live under the one /legal namespace
for (const id of ["privacy", "terms", "cookies"]) {
expect(FOOTER.legal.links.find((l) => l.id === id)!.href).toMatch(/\/legal\//)
}
expect(FOOTER.legal.links.find((l) => l.id === "status")!.href).toBe(DESTINATIONS.status)
})
it("every footer link has a non-empty label + href, no /api/, and no column repeats an id", () => {
for (const col of FOOTER.columns) {
for (const l of col.links) {
expect(l.label).toBeTruthy()
expect(l.href).toBeTruthy()
expect(l.href).not.toMatch(/\/api(\/|$)/)
}
const ids = col.links.map((l) => l.id)
expect(new Set(ids).size).toBe(ids.length)
}
})
})
+119
View File
@@ -0,0 +1,119 @@
/**
* The unified footer — the ONE footer every property renders. Six columns
* (Products · AI Platform · Install · Developers · Resources · Company) plus a
* bottom bar (copyright + legal links). The PRODUCTS column is built from `FAMILY`
* (single source) plus Dev; the INSTALL column and the shared links resolve from
* `DESTINATIONS`, so a footer link and its menu twin can never disagree.
*
* Links whose address the spec pins go through `DESTINATIONS`; marketing sub-pages
* the spec names but does not pin (About, Blog, Learn, …) are built from `ORIGIN`
* on the honest `hanzo.ai/<slug>` · `docs.hanzo.ai/<slug>` · `/legal/<slug>`
* conventions — no host is fabricated. Pure data, React-free.
*/
import type { Link } from "./link"
import { FAMILY } from "./family"
import { DESTINATIONS, ORIGIN } from "./destinations"
/** One footer column — a titled list of links. */
export interface FooterColumn {
/** Stable id. */
id: string
/** The column heading, e.g. `"PRODUCTS"`. */
title: string
/** The column's links, in order. */
links: Link[]
}
/** The full footer model — columns plus the legal bottom bar. */
export interface Footer {
columns: FooterColumn[]
legal: {
/** The copyright line. */
copyright: string
/** The bottom-bar legal links. */
links: Link[]
}
}
/** The PRODUCTS column: the six family products (short label → front door) + Dev. */
const productLinks: Link[] = [
...FAMILY.map((p) => ({ id: p.id, label: p.short, href: p.url })),
{ id: "dev", label: "Dev", href: `${ORIGIN.root}/dev` },
]
export const FOOTER: Footer = {
columns: [
{ id: "products", title: "PRODUCTS", links: productLinks },
{
id: "platform",
title: "AI PLATFORM",
links: [
{ id: "models", label: "Models", href: DESTINATIONS.models },
{ id: "enso", label: "Enso", href: `${ORIGIN.root}/enso` },
{ id: "agents", label: "Managed Agents", href: `${ORIGIN.root}/agents` },
{ id: "mcp", label: "MCP Tools", href: `${ORIGIN.root}/mcp` },
{ id: "api", label: "API Platform", href: ORIGIN.api },
{ id: "console", label: "Developer Console", href: DESTINATIONS.console },
{ id: "cloud-products", label: "All cloud products", href: DESTINATIONS.cloudProducts },
],
},
{
id: "install",
title: "INSTALL",
links: [
{ id: "desktop", label: "Desktop", href: DESTINATIONS.desktop },
{ id: "browser", label: "Browser extension", href: DESTINATIONS.browserExtension },
{ id: "vscode", label: "VS Code", href: DESTINATIONS.vscode },
{ id: "cli", label: "Hanzo CLI", href: DESTINATIONS.cli },
{ id: "sdks", label: "SDKs", href: DESTINATIONS.sdks },
{ id: "downloads", label: "All downloads", href: DESTINATIONS.downloads },
],
},
{
id: "developers",
title: "DEVELOPERS",
links: [
{ id: "docs", label: "Documentation", href: DESTINATIONS.docs },
{ id: "api-reference", label: "API Reference", href: DESTINATIONS.apiReference },
{ id: "sdks", label: "SDKs", href: DESTINATIONS.sdks },
{ id: "mcp", label: "MCP Tools", href: `${ORIGIN.root}/mcp` },
{ id: "cli-reference", label: "CLI Reference", href: `${ORIGIN.docs}/cli` },
{ id: "github", label: "GitHub", href: ORIGIN.github },
],
},
{
id: "resources",
title: "RESOURCES",
links: [
{ id: "learn", label: "Learn", href: `${ORIGIN.docs}/learn` },
{ id: "quickstarts", label: "Quickstarts", href: `${ORIGIN.docs}/quickstarts` },
{ id: "community", label: "Community", href: `${ORIGIN.root}/community` },
{ id: "showcase", label: "Showcase", href: `${ORIGIN.root}/showcase` },
{ id: "changelog", label: "Changelog", href: `${ORIGIN.root}/changelog` },
{ id: "status", label: "Status", href: DESTINATIONS.status },
],
},
{
id: "company",
title: "COMPANY",
links: [
{ id: "about", label: "About", href: `${ORIGIN.root}/about` },
{ id: "customers", label: "Customers", href: `${ORIGIN.root}/customers` },
{ id: "blog", label: "Blog", href: `${ORIGIN.root}/blog` },
{ id: "careers", label: "Careers", href: `${ORIGIN.root}/careers` },
{ id: "enterprise", label: "Enterprise", href: `${ORIGIN.root}/enterprise` },
{ id: "security", label: "Security", href: `${ORIGIN.root}/security` },
],
},
],
legal: {
copyright: "© 2026 Hanzo AI, Inc.",
links: [
{ id: "status", label: "Status", href: DESTINATIONS.status },
{ id: "security", label: "Security", href: `${ORIGIN.root}/security` },
{ id: "privacy", label: "Privacy", href: `${ORIGIN.root}/legal/privacy` },
{ id: "terms", label: "Terms", href: `${ORIGIN.root}/legal/terms` },
{ id: "cookies", label: "Cookies", href: `${ORIGIN.root}/legal/cookies` },
],
},
}
+68
View File
@@ -0,0 +1,68 @@
import { describe, expect, it } from "vitest"
import { HEADERS, findHeader, type SiteId } from "./header"
import { findProduct } from "./family"
const SITES: SiteId[] = ["hanzo.ai", "hanzo.chat", "hanzo.app", "hanzo.team", "studio.hanzo.ai", "hanzo.bot", "cloud.hanzo.ai"]
describe("HEADERS — per-property header config", () => {
it("covers exactly the seven properties", () => {
expect(Object.keys(HEADERS).sort()).toEqual([...SITES].sort())
})
it("each header's productId resolves to a real product", () => {
const map: Record<SiteId, string> = {
"hanzo.ai": "hanzo",
"hanzo.chat": "chat",
"hanzo.app": "app",
"hanzo.team": "team",
"studio.hanzo.ai": "studio",
"hanzo.bot": "bot",
"cloud.hanzo.ai": "cloud",
}
for (const site of SITES) {
expect(HEADERS[site].productId).toBe(map[site])
expect(findProduct(HEADERS[site].productId)).toBeDefined()
}
})
it("carries the spec's local nav labels per site", () => {
const labels = (site: SiteId) => HEADERS[site].localNav.map((n) => n.label)
expect(labels("hanzo.ai")).toEqual(["Models", "Solutions", "Developers", "Pricing", "Enterprise"])
expect(labels("hanzo.chat")).toEqual(["Features", "Models", "Agents", "Download", "Pricing"])
expect(labels("hanzo.app")).toEqual(["Product", "Templates", "Showcase", "Pricing", "Enterprise"])
expect(labels("hanzo.team")).toEqual(["Product", "Solutions", "Integrations", "Pricing"])
expect(labels("studio.hanzo.ai")).toEqual(["Models", "Agents", "Evaluations", "Docs"])
expect(labels("hanzo.bot")).toEqual(["Product", "Channels", "Templates", "Pricing"])
expect(labels("cloud.hanzo.ai")).toEqual(["Products", "Solutions", "Developers", "Pricing", "Docs"])
})
it("carries the spec's primary action label per site", () => {
const action = (site: SiteId) => HEADERS[site].action.label
expect(action("hanzo.ai")).toBe("Open Chat")
expect(action("hanzo.chat")).toBe("New chat")
expect(action("hanzo.app")).toBe("New project")
expect(action("hanzo.team")).toBe("Open workspace")
expect(action("studio.hanzo.ai")).toBe("Create")
expect(action("hanzo.bot")).toBe("Create bot")
expect(action("cloud.hanzo.ai")).toBe("Open console")
})
it("every nav + action href is non-empty, and no local nav repeats an id", () => {
for (const site of SITES) {
const h = HEADERS[site]
expect(h.action.label).toBeTruthy()
expect(h.action.href).toBeTruthy()
for (const n of h.localNav) {
expect(n.label).toBeTruthy()
expect(n.href).toBeTruthy()
}
const ids = h.localNav.map((n) => n.id)
expect(new Set(ids).size).toBe(ids.length)
}
})
it("findHeader resolves a site and is undefined for an unknown one", () => {
expect(findHeader("hanzo.chat")).toBe(HEADERS["hanzo.chat"])
expect(findHeader("example.com")).toBeUndefined()
})
})
+96
View File
@@ -0,0 +1,96 @@
/**
* Per-property header config — the ONE shared header renders the same everywhere;
* only the current product, its site-local nav, and its primary action change.
* `HEADERS` maps each of the seven properties to that delta.
*
* `productId` ties the header to `FAMILY`/`ROOT` (the brand mark + Meet Hanzo menu
* are the same everywhere and come from there). Local-nav items are site-relative
* `/slug` paths (each site serves its own), except the ones that are inherently a
* shared destination (Docs, Download) which resolve from `DESTINATIONS`. Primary
* actions are absolute when they cross to another property (hanzo.ai → Open Chat)
* and site-relative when they act in place (New chat, Create bot, …).
*
* Pure data, React-free.
*/
import type { Action, Link } from "./link"
import { DESTINATIONS } from "./destinations"
/** The seven canonical Hanzo properties. */
export type SiteId =
| "hanzo.ai"
| "hanzo.chat"
| "hanzo.app"
| "hanzo.team"
| "studio.hanzo.ai"
| "hanzo.bot"
| "cloud.hanzo.ai"
/** The header delta for one property. */
export interface SiteHeader {
/** The property. */
site: SiteId
/** Which product this property is — a `FAMILY`/`ROOT` id (`findProduct`). */
productId: string
/** The site-local navigation, in order. */
localNav: Link[]
/** The property's primary call-to-action. */
action: Action
}
const slug = (label: string): string =>
label
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-|-$/g, "")
/** A site-local nav link; defaults to a `/slug` path, overridable for shared destinations. */
const nav = (label: string, href?: string): Link => ({ id: slug(label), label, href: href ?? `/${slug(label)}` })
export const HEADERS: Record<SiteId, SiteHeader> = {
"hanzo.ai": {
site: "hanzo.ai",
productId: "hanzo",
localNav: [nav("Models"), nav("Solutions"), nav("Developers"), nav("Pricing"), nav("Enterprise")],
action: { label: "Open Chat", href: "https://hanzo.chat" },
},
"hanzo.chat": {
site: "hanzo.chat",
productId: "chat",
localNav: [nav("Features"), nav("Models"), nav("Agents"), nav("Download", DESTINATIONS.downloads), nav("Pricing")],
action: { label: "New chat", href: "/new" },
},
"hanzo.app": {
site: "hanzo.app",
productId: "app",
localNav: [nav("Product"), nav("Templates"), nav("Showcase"), nav("Pricing"), nav("Enterprise")],
action: { label: "New project", href: "/new" },
},
"hanzo.team": {
site: "hanzo.team",
productId: "team",
localNav: [nav("Product"), nav("Solutions"), nav("Integrations"), nav("Pricing")],
action: { label: "Open workspace", href: "/" },
},
"studio.hanzo.ai": {
site: "studio.hanzo.ai",
productId: "studio",
localNav: [nav("Models"), nav("Agents"), nav("Evaluations"), nav("Docs", DESTINATIONS.docs)],
action: { label: "Create", href: "/new" },
},
"hanzo.bot": {
site: "hanzo.bot",
productId: "bot",
localNav: [nav("Product"), nav("Channels"), nav("Templates"), nav("Pricing")],
action: { label: "Create bot", href: "/new" },
},
"cloud.hanzo.ai": {
site: "cloud.hanzo.ai",
productId: "cloud",
localNav: [nav("Products"), nav("Solutions"), nav("Developers"), nav("Pricing"), nav("Docs", DESTINATIONS.docs)],
action: { label: "Open console", href: "/" },
},
}
/** Look up a property's header by site; `undefined` when unknown. */
export const findHeader = (site: string): SiteHeader | undefined =>
site in HEADERS ? HEADERS[site as SiteId] : undefined
+25 -7
View File
@@ -1,14 +1,23 @@
/**
* @hanzo/products — the canonical Hanzo product catalog.
* @hanzo/products — the canonical Hanzo product source of truth. Two orthogonal
* concerns, one React-FREE barrel:
*
* ONE typed contract (`CatalogEntry`) + the two code maps that can't live in a DB
* (`iconKey` → component via `@hanzo/products/icons`; `brandColor` → css via
* `swatchHex`) + a `fetchCatalog` client over the editable commerce source with a
* checked-in `SNAPSHOT` fallback + the reconciled taxonomy + docs nav generation.
* 1. The CATALOG — the console operational taxonomy: ONE typed contract
* (`CatalogEntry`) + the two code maps that can't live in a DB (`iconKey` →
* component via `@hanzo/products/icons`; `brandColor` → css via `swatchHex`) +
* a `fetchCatalog` client over the editable commerce source with a checked-in
* `SNAPSHOT` fallback + docs nav generation.
*
* This barrel is React-FREE — the icon map lives at the `@hanzo/products/icons`
* subpath so static consumers (docs, site, pricing) pull no React.
* 2. The ECOSYSTEM SHELL — the "Meet Hanzo" chrome shared by every property: the
* six-product `FAMILY` + `ROOT`, the `MEET_HANZO_MENU`, the unified `FOOTER`,
* the per-site `HEADERS`, the canonical `DESTINATIONS`, and the collapsed
* launcher `SURFACES` (the de-dupe of the two rival app-switcher lists).
*
* The icon map lives at the `@hanzo/products/icons` subpath so static consumers
* (docs, site, pricing) pull no React.
*/
// ── Catalog (console operational taxonomy) ──────────────────────────────────
export * from "./types"
export * from "./categories"
export * from "./brands"
@@ -18,3 +27,12 @@ export * from "./search"
export * from "./snapshot"
export * from "./client"
export * from "./docs"
// ── Ecosystem shell ("Meet Hanzo" header/footer/menu) ───────────────────────
export * from "./link"
export * from "./family"
export * from "./destinations"
export * from "./menu"
export * from "./footer"
export * from "./header"
export * from "./surfaces"
+23
View File
@@ -0,0 +1,23 @@
/**
* The two atoms every ecosystem-shell list is built from — a navigable link and a
* call-to-action. Pure data, no React: a Next/Svelte/Tamagui/Fumadocs renderer
* turns each into its own element. Kept minimal on purpose (an `id` is the React
* list key AND the glyph key each renderer maps to an icon — the data stays
* glyph-free, the same convention the catalog uses for `iconKey`).
*/
/** A labeled destination — one entry in a menu, footer column, or nav row. */
export interface Link {
/** Stable key; also the per-renderer glyph key. Unique within its own list. */
id: string
/** Visible text. */
label: string
/** Where it goes — an absolute `https://…` URL or a site-relative `/path`. */
href: string
}
/** A primary call-to-action — a label and where it goes. Not a list item, so no id. */
export interface Action {
label: string
href: string
}
+53
View File
@@ -0,0 +1,53 @@
import { describe, expect, it } from "vitest"
import { MEET_HANZO_MENU } from "./menu"
import { FAMILY } from "./family"
import { DESTINATIONS } from "./destinations"
describe("MEET_HANZO_MENU — the launcher model", () => {
it("leads with the eyebrow and an all-products link to the products index", () => {
expect(MEET_HANZO_MENU.eyebrow).toBe("AI for every way you build and work.")
expect(MEET_HANZO_MENU.allProducts.label).toBe("Explore all products")
expect(MEET_HANZO_MENU.allProducts.href).toBe(DESTINATIONS.products)
})
it("the grid IS the six-product family (single source, not a copy)", () => {
expect(MEET_HANZO_MENU.products).toBe(FAMILY)
expect(MEET_HANZO_MENU.products).toHaveLength(6)
})
it("the utility row is Models · Enso · Managed Agents · Hanzo Dev · MCP Tools · Documentation", () => {
expect(MEET_HANZO_MENU.utilities.map((l) => l.id)).toEqual(["models", "enso", "agents", "dev", "mcp", "docs"])
expect(MEET_HANZO_MENU.utilities.map((l) => l.label)).toEqual([
"Models",
"Enso",
"Managed Agents",
"Hanzo Dev",
"MCP Tools",
"Documentation",
])
})
it("the install row is Desktop · Browser · VS Code · CLI · SDKs · All downloads", () => {
expect(MEET_HANZO_MENU.installs.map((l) => l.id)).toEqual(["desktop", "browser", "vscode", "cli", "sdks", "downloads"])
// each install link resolves to its pinned destination
expect(MEET_HANZO_MENU.installs.map((l) => l.href)).toEqual([
DESTINATIONS.desktop,
DESTINATIONS.browserExtension,
DESTINATIONS.vscode,
DESTINATIONS.cli,
DESTINATIONS.sdks,
DESTINATIONS.downloads,
])
})
it("every menu link has a non-empty label and href, and no row repeats an id", () => {
for (const row of [MEET_HANZO_MENU.utilities, MEET_HANZO_MENU.installs]) {
for (const l of row) {
expect(l.label).toBeTruthy()
expect(l.href).toBeTruthy()
}
const ids = row.map((l) => l.id)
expect(new Set(ids).size).toBe(ids.length)
}
})
})
+48
View File
@@ -0,0 +1,48 @@
/**
* The "Meet Hanzo" mega-menu — the launcher every property's header opens. It leads
* with the six-product grid (NOT a 9-dot of utilities), then a slim utility row,
* then an install row. The grid IS `FAMILY` (single source); the two rows are links
* into the platform and the installs.
*
* Pure data, React-free — a renderer maps each `Link.id` to its own glyph.
*/
import type { Link } from "./link"
import type { Product } from "./family"
import { FAMILY } from "./family"
import { DESTINATIONS, ORIGIN } from "./destinations"
/** The full Meet Hanzo menu model. */
export interface MeetHanzoMenu {
/** The one-line positioning above the grid. */
eyebrow: string
/** The "Explore all products →" link to the products index. */
allProducts: Link
/** The six-product grid — each cell renders its product's name, job, and action. */
products: Product[]
/** The slim utility row under the grid. */
utilities: Link[]
/** The install row at the foot of the menu. */
installs: Link[]
}
export const MEET_HANZO_MENU: MeetHanzoMenu = {
eyebrow: "AI for every way you build and work.",
allProducts: { id: "all-products", label: "Explore all products", href: DESTINATIONS.products },
products: FAMILY,
utilities: [
{ id: "models", label: "Models", href: DESTINATIONS.models },
{ id: "enso", label: "Enso", href: `${ORIGIN.root}/enso` },
{ id: "agents", label: "Managed Agents", href: `${ORIGIN.root}/agents` },
{ id: "dev", label: "Hanzo Dev", href: `${ORIGIN.root}/dev` },
{ id: "mcp", label: "MCP Tools", href: `${ORIGIN.root}/mcp` },
{ id: "docs", label: "Documentation", href: DESTINATIONS.docs },
],
installs: [
{ id: "desktop", label: "Desktop app", href: DESTINATIONS.desktop },
{ id: "browser", label: "Browser extension", href: DESTINATIONS.browserExtension },
{ id: "vscode", label: "VS Code", href: DESTINATIONS.vscode },
{ id: "cli", label: "CLI", href: DESTINATIONS.cli },
{ id: "sdks", label: "SDKs", href: DESTINATIONS.sdks },
{ id: "downloads", label: "All downloads", href: DESTINATIONS.downloads },
],
}
+90
View File
@@ -0,0 +1,90 @@
import { describe, expect, it } from "vitest"
import { ORIGIN, DESTINATIONS } from "./destinations"
import { PRODUCTS } from "./family"
import { MEET_HANZO_MENU } from "./menu"
import { FOOTER } from "./footer"
import { HEADERS } from "./header"
import { SURFACES } from "./surfaces"
/**
* Cross-cutting honesty guards over the WHOLE ecosystem-shell spec: no href points
* at a fabricated host, none carries an `/api/` path, and none is empty. These bind
* the spec to "real products / domains only".
*/
/** Every real Hanzo host the shell is allowed to link to. */
const ALLOWED_HOSTS = new Set([
"hanzo.ai",
"hanzo.chat",
"hanzo.app",
"hanzo.team",
"studio.hanzo.ai",
"hanzo.bot",
"cloud.hanzo.ai",
"api.hanzo.ai",
"console.hanzo.ai",
"billing.hanzo.ai",
"admin.hanzo.ai",
"platform.hanzo.ai",
"hanzo.id",
"docs.hanzo.ai",
"status.hanzo.ai",
"github.com",
])
/** Collect every href the spec exposes. */
function allHrefs(): string[] {
const out: string[] = []
out.push(...Object.values(ORIGIN))
out.push(...Object.values(DESTINATIONS))
for (const p of PRODUCTS) {
out.push(p.url, p.action.href)
}
out.push(MEET_HANZO_MENU.allProducts.href)
out.push(...MEET_HANZO_MENU.utilities.map((l) => l.href))
out.push(...MEET_HANZO_MENU.installs.map((l) => l.href))
for (const col of FOOTER.columns) out.push(...col.links.map((l) => l.href))
out.push(...FOOTER.legal.links.map((l) => l.href))
for (const h of Object.values(HEADERS)) {
out.push(h.action.href, ...h.localNav.map((n) => n.href))
}
out.push(...SURFACES.map((s) => s.href))
return out
}
describe("ecosystem-shell href integrity", () => {
const hrefs = allHrefs()
it("exposes hrefs to check", () => {
expect(hrefs.length).toBeGreaterThan(60)
})
it("every href is a non-empty string", () => {
for (const h of hrefs) expect(typeof h === "string" && h.length > 0).toBe(true)
})
it("no href uses an /api/ path prefix", () => {
for (const h of hrefs) expect(h).not.toMatch(/\/api(\/|$)/)
})
it("every absolute href points at a real Hanzo host (no fabrication)", () => {
for (const h of hrefs) {
if (!h.startsWith("http")) continue // site-relative nav/action paths
expect(ALLOWED_HOSTS).toContain(new URL(h).host)
}
})
it("every absolute href is https (never plaintext http)", () => {
for (const h of hrefs) {
if (!h.startsWith("http")) continue
expect(h).toMatch(/^https:\/\//)
}
})
it("site-relative hrefs are rooted and never protocol-relative", () => {
for (const h of hrefs) {
if (h.startsWith("http")) continue
expect(h.startsWith("/") && !h.startsWith("//")).toBe(true)
}
})
})
+83
View File
@@ -0,0 +1,83 @@
import { describe, expect, it } from "vitest"
import { SURFACES, surfaceById, otherSurfaces, type Surface } from "./surfaces"
import { findProduct } from "./family"
/**
* The two rival lists this registry collapses, as they shipped:
* - pkg/ui `SURFACES` (Surface = { id, label, href, hint }) — 7 ids.
* - @hanzogui/shell `HANZO_APPS` — the 10 ids; `zach`/`world` stay local to that
* shim (a personal portal / an uncorroborated surface), so the 8 SHARED ids are
* what must be sourced from here.
* These are frozen so a byte-identity drift in the canonical data fails the build.
*/
const LEGACY_PKGUI: { id: string; label: string; href: string; domain: string }[] = [
{ id: "ai", label: "Hanzo AI", href: "https://hanzo.ai", domain: "hanzo.ai" },
{ id: "console", label: "Console", href: "https://console.hanzo.ai", domain: "console.hanzo.ai" },
{ id: "app", label: "App", href: "https://hanzo.app", domain: "hanzo.app" },
{ id: "chat", label: "Chat", href: "https://hanzo.chat", domain: "hanzo.chat" },
{ id: "bot", label: "Bot", href: "https://hanzo.bot", domain: "hanzo.bot" },
{ id: "team", label: "Team", href: "https://hanzo.team", domain: "hanzo.team" },
{ id: "billing", label: "Billing", href: "https://billing.hanzo.ai", domain: "billing.hanzo.ai" },
]
const LEGACY_GUI_SHARED = ["console", "cloud", "chat", "app", "admin", "gateway", "platform", "account"]
describe("SURFACES — the collapsed launcher registry", () => {
it("has no duplicate ids", () => {
const ids = SURFACES.map((s) => s.id)
expect(new Set(ids).size).toBe(ids.length)
})
it("subsumes every id the pkg/ui SURFACES list needed", () => {
for (const l of LEGACY_PKGUI) expect(surfaceById(l.id)).toBeDefined()
})
it("subsumes every shared id the gui HANZO_APPS list needed", () => {
for (const id of LEGACY_GUI_SHARED) expect(surfaceById(id)).toBeDefined()
})
it("reproduces the pkg/ui surfaces byte-for-byte (the shim cannot drift)", () => {
for (const legacy of LEGACY_PKGUI) {
const s = surfaceById(legacy.id)!
expect({ id: s.id, label: s.label, href: s.href, domain: s.domain }).toEqual(legacy)
}
})
it("projects the six product surfaces straight from FAMILY (single source)", () => {
for (const id of ["chat", "app", "team", "studio", "bot", "cloud"]) {
const s = surfaceById(id)!
const p = findProduct(id)!
expect(s.href).toBe(p.url)
expect(s.label).toBe(p.short)
expect(s.domain).toBe(p.domain)
expect(s.blurb).toBe(p.job)
}
})
it("keeps the root under the legacy launcher id 'ai' (not the product id 'hanzo')", () => {
expect(surfaceById("ai")!.href).toBe("https://hanzo.ai")
expect(surfaceById("hanzo")).toBeUndefined()
})
it("every surface is complete with an absolute https href", () => {
for (const s of SURFACES as Surface[]) {
expect(s.id).toBeTruthy()
expect(s.label).toBeTruthy()
expect(s.domain).toBeTruthy()
expect(s.blurb).toBeTruthy()
expect(s.href).toMatch(/^https:\/\//)
}
})
})
describe("registry accessors", () => {
it("otherSurfaces excludes the current surface and returns all when unscoped", () => {
expect(otherSurfaces()).toHaveLength(SURFACES.length)
const others = otherSurfaces("chat")
expect(others).toHaveLength(SURFACES.length - 1)
expect(others.find((s) => s.id === "chat")).toBeUndefined()
})
it("surfaceById returns undefined for an unknown id", () => {
expect(surfaceById("nope")).toBeUndefined()
})
})
+82
View File
@@ -0,0 +1,82 @@
/**
* The launcher surface registry — the ONE cross-surface app-switcher list, the
* collapse of the two lists that used to drift: `SURFACES` (hanzoai/ui
* `pkg/ui/.../surfaces.data.ts`) and `HANZO_APPS` (hanzoai/gui
* `@hanzogui/shell`). Both now re-export from here, so add or reorder a surface
* ONCE and every launcher moves together.
*
* A surface is a place the switcher can open. The six product surfaces DERIVE from
* `FAMILY` (single source — their label/href/domain/job can't drift from the
* product), the root surface is `hanzo.ai` under the legacy launcher id `"ai"`, and
* the platform surfaces (Console, Billing, Account, Admin, Gateway, Platform) are
* launcher-only — real properties that are not flagship products.
*
* `id` is the stable key AND the glyph key: each renderer (React → lucide, the gui
* shell → its inline SVG set) maps it to its own icon, so the DATA stays glyph-free
* and this module imports no React. Pure data.
*/
import { FAMILY, ROOT } from "./family"
/** Every launcher surface id — the family ids (root as legacy `"ai"`) + platform. */
export type SurfaceId =
| "ai"
| "chat"
| "app"
| "team"
| "studio"
| "bot"
| "cloud"
| "console"
| "billing"
| "account"
| "admin"
| "gateway"
| "platform"
/** One launcher surface. */
export interface Surface {
/** Stable id; also the per-renderer glyph key. */
id: SurfaceId
/** Menu label. */
label: string
/** Absolute front-door URL. */
href: string
/** The domain, shown as the tile subtitle. */
domain: string
/** One-line description. */
blurb: string
}
/** The root — `hanzo.ai` under the launcher's legacy id `"ai"`. */
const rootSurface: Surface = { id: "ai", label: "Hanzo AI", href: ROOT.url, domain: ROOT.domain, blurb: ROOT.job }
/** The six product surfaces, projected from `FAMILY` (the single source). */
const productSurfaces: Surface[] = FAMILY.map((p) => ({
id: p.id as SurfaceId,
label: p.short,
href: p.url,
domain: p.domain,
blurb: p.job,
}))
/** Launcher-only surfaces — real properties that are not flagship products. */
const platformSurfaces: Surface[] = [
{ id: "console", label: "Console", href: "https://console.hanzo.ai", domain: "console.hanzo.ai", blurb: "API keys, projects, and products." },
{ id: "billing", label: "Billing", href: "https://billing.hanzo.ai", domain: "billing.hanzo.ai", blurb: "Subscriptions, usage, and invoices." },
{ id: "account", label: "Account", href: "https://hanzo.id/account", domain: "hanzo.id", blurb: "Profile, organizations, and security." },
{ id: "admin", label: "Admin", href: "https://admin.hanzo.ai", domain: "admin.hanzo.ai", blurb: "Platform administration." },
{ id: "gateway", label: "Gateway", href: "https://console.hanzo.ai/gateway", domain: "console.hanzo.ai", blurb: "The unified AI API gateway." },
{ id: "platform", label: "Platform", href: "https://platform.hanzo.ai", domain: "platform.hanzo.ai", blurb: "Deploy and scale services." },
]
/** The full launcher registry, in order: root, the six products, then platform. */
export const SURFACES: Surface[] = [rootSurface, ...productSurfaces, ...platformSurfaces]
const BY_ID = new Map<string, Surface>(SURFACES.map((s) => [s.id, s]))
/** Look up a surface by id; `undefined` when unknown. */
export const surfaceById = (id: string): Surface | undefined => BY_ID.get(id)
/** Every surface except `current` — a launcher never links to itself. */
export const otherSurfaces = (current?: SurfaceId): Surface[] =>
current ? SURFACES.filter((s) => s.id !== current) : SURFACES
+2
View File
@@ -7,6 +7,8 @@
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"types": ["node", "react"],
"ignoreDeprecations": "6.0",
"verbatimModuleSyntax": true,
"isolatedModules": true,
"declaration": true,