feat(products): canonical Meet Hanzo ecosystem-shell spec + collapse SURFACES/HANZO_APPS into one source
This commit is contained in:
+2
-1
@@ -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:*"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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`, `/`).
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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` },
|
||||
],
|
||||
},
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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 },
|
||||
],
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
@@ -7,6 +7,8 @@
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"types": ["node", "react"],
|
||||
"ignoreDeprecations": "6.0",
|
||||
"verbatimModuleSyntax": true,
|
||||
"isolatedModules": true,
|
||||
"declaration": true,
|
||||
|
||||
Reference in New Issue
Block a user