mirror of
https://github.com/hanzo-apps/hanzo.ai.git
synced 2026-10-28 07:32:52 +00:00
lib/layers.ts is the product: six layers and the Record, each with the operations api.hanzo.ai serves for it (checked against hanzoai/cloud openapi.yaml), a request to paste, docs and the site's own pages. /products, /products/<layer>, the home band, the header's Product menu and the footer row all read it. - Header: Product, Developers, Research, Pricing, Enterprise. - Memory leads with /v1/graph: assert, read, resolve, diff, answer. - /sql, /kv, /docdb, /vector and the old /products/<category> addresses forward through one MOVED table in lib/publish. - /affiliate and /oss state apps/affiliate's and apps/author's own rates; the second pages for the same programmes forward to them. - No real-person faces on agents, no Lux or Zoo partner logos. - Orphans linked, dead links and private-repo links removed; sitemap gate and nav spec hold both. Co-authored-by: Hanzo Dev <[email protected]>
463 lines
21 KiB
TypeScript
463 lines
21 KiB
TypeScript
/**
|
|
* Hanzo Cloud — the product taxonomy.
|
|
*
|
|
* WHICH products exist, what they are called, which category they sit in and in
|
|
* what order is the commerce catalog's answer, read at build time by
|
|
* `scripts/sync-catalog.mjs` into `lib/data/catalog.json`. This file turns that
|
|
* snapshot into the shape every surface renders. It holds no list of products
|
|
* and no list of categories: the previous version did, and the two answers had
|
|
* drifted far enough apart that the site sold a "Payments" category commerce
|
|
* does not carry while missing the "Dev" one it does.
|
|
*
|
|
* The split is by KIND OF FACT, not by convenience:
|
|
*
|
|
* the catalog owns membership · name · slug · order · icon · repo · kind ·
|
|
* the apiPath that proves the product exists
|
|
* this file owns the prose — a category's tagline, a leaf's blurb, the
|
|
* long-form copy a generated overview page renders, and
|
|
* the name where the catalog's belongs to another surface
|
|
*
|
|
* The catalog also states a docs page per product and this file does not read
|
|
* it: all 79 of those are `/docs/services/<slug>` and docs answers 404 for
|
|
* them. `lib/constants/products-metadata` is the one place that answers for
|
|
* docs, one verified URL at a time.
|
|
*
|
|
* A product with no prose here still renders; it reads as its name plus the
|
|
* facts the catalog states about it. Prose for a product the catalog has
|
|
* dropped renders nowhere. Neither can resurrect a product or bury one, which
|
|
* is what makes this a copy deck rather than a second taxonomy.
|
|
*
|
|
* NOTHING UNREACHABLE REACHES THIS FILE. The snapshot only carries products
|
|
* whose apiPath the served OpenAPI document answers (or, for a client, that
|
|
* this repo has a page for) — 53 of the 84 advertised, measured on every build.
|
|
* So the menu is hydrated from the catalog AND cannot advertise a 404. See
|
|
* `scripts/sync-catalog.mjs` for the gate and `scripts/audit-catalog.mjs` for
|
|
* the ratchet that stops the other 31 growing.
|
|
*
|
|
* Every leaf's `href` was resolved against the filesystem by the same sync: a
|
|
* bespoke page where this repo publishes one, `/cloud/<slug>` otherwise, and
|
|
* `cloudPrimitiveSlugs` below builds a page for every one of the latter — so a
|
|
* leaf cannot be a dead link in either branch.
|
|
*/
|
|
|
|
import type { ComponentType } from 'react'
|
|
import { policy } from '@/lib/publish'
|
|
import { opens } from '@/lib/repos'
|
|
import {
|
|
Activity, ArrowLeftRight, AudioLines, BarChart3, Bell, Blocks, Bot, Box, Boxes,
|
|
Brain, Cable, ClipboardList, Cloud, Code, Code2, Coins, Container, Cpu,
|
|
CreditCard, Database, FileText, Fingerprint, FolderGit2, FunctionSquare, Gauge,
|
|
GitBranch, Globe, Hammer, HardDrive, Image, Key, KeyRound, KeySquare, Landmark,
|
|
Layers,
|
|
LayoutDashboard, LineChart, ListChecks, Lock, MessageSquare, Monitor, Network,
|
|
NotebookPen, Package, Play, Radio, Rocket, Ruler, ScrollText, Search, Server,
|
|
Shield, ShieldCheck, Sparkles, Spline, Store, Tag, Terminal, Video, Wallet,
|
|
Waypoints, Workflow, Zap,
|
|
} from 'lucide-react'
|
|
// The attribute is not decoration: Next's bundler infers the type from the
|
|
// extension, but Node's own ESM loader refuses a JSON import without it — and
|
|
// Node is what runs the Playwright specs that import this taxonomy. Without it
|
|
// e2e/catalog-agreement.spec.ts cannot even load.
|
|
import snapshot from './catalog.json' with { type: 'json' }
|
|
|
|
export type PrimitiveStatus = 'ga' | 'beta'
|
|
export type CloudIcon = ComponentType<{ className?: string; size?: number | string }>
|
|
|
|
export interface Primitive {
|
|
/** Display name shown in the mega-menu and as the overview <h1>. */
|
|
title: string
|
|
/** Resolved link — a bespoke product page, or a generated overview route. */
|
|
href: string
|
|
/** The catalog's product slug. Also the `/cloud/<slug>` route when generated. */
|
|
slug: string
|
|
icon: CloudIcon
|
|
/** The operation that proves this product exists (`/v1/vector`). */
|
|
api?: string
|
|
/** The Google Cloud service a reader may know this by. */
|
|
gcp?: string
|
|
/** What the catalog sells this as: a service, or a client of one. */
|
|
kind?: 'service' | 'client'
|
|
/** Source — the product's repo when the catalog names one, else the org. */
|
|
github?: string
|
|
tagline?: string
|
|
description?: string
|
|
features?: string[]
|
|
status?: PrimitiveStatus
|
|
/** Short menu descriptor shown under the leaf title. */
|
|
blurb?: string
|
|
/** Resolved menu descriptor: blurb ?? tagline. */
|
|
desc?: string
|
|
}
|
|
|
|
export interface CloudCategory {
|
|
/** The catalog's category id. */
|
|
id: string
|
|
/** Category label — the mega-menu column header. */
|
|
title: string
|
|
/** Category icon — shown on the homepage overview grid. */
|
|
icon: CloudIcon
|
|
/** One-line category positioning — also the mega-menu column subtitle. */
|
|
tagline: string
|
|
/**
|
|
* The category's primitives, in the catalog's order. Depths are UNEVEN
|
|
* because they are measured, not padded to fit a grid: a category holds
|
|
* exactly the products that answer.
|
|
*/
|
|
items: Primitive[]
|
|
}
|
|
|
|
const ORG = 'https://github.com/hanzoai'
|
|
|
|
/**
|
|
* The catalog names an icon; this resolves the name to a component.
|
|
*
|
|
* Every key the catalog uses today is here, including the ones on products that
|
|
* do not currently answer — a renamed apiPath landing in commerce should light
|
|
* the product up on the next build, not draw it with a placeholder. An unknown
|
|
* name falls back rather than throwing: a marketing page missing one glyph is a
|
|
* blemish, a build that will not render is an outage.
|
|
*/
|
|
const ICON: Record<string, CloudIcon> = {
|
|
Activity, ArrowLeftRight, AudioLines, BarChart3, Bell, Blocks, Bot, Box, Boxes,
|
|
Brain, Cable, ClipboardList, Code, Code2, Coins, Container, Cpu, CreditCard,
|
|
Database, FileText, Fingerprint, FolderGit2, FunctionSquare, Gauge, GitBranch,
|
|
Globe, Hammer, HardDrive, Image, Key, KeyRound, KeySquare, Layers, LineChart,
|
|
ListChecks,
|
|
Lock, MessageSquare, Monitor, Network, NotebookPen, Package, Play, Radio,
|
|
Rocket, Ruler, ScrollText, Search, Server, Shield, ShieldCheck, Sparkles,
|
|
Spline, Store, Tag, Terminal, Video, Wallet, Waypoints, Workflow, Zap,
|
|
}
|
|
|
|
/**
|
|
* The prose for a category. The catalog carries an id, a label and an order —
|
|
* no icon and no positioning line, because those are how this site talks about
|
|
* the category rather than facts about the product line.
|
|
*/
|
|
const CATEGORY: Record<string, { icon: CloudIcon; tagline: string }> = {
|
|
ai: { icon: Brain, tagline: 'Models, agents, and inference — open weights, one API.' },
|
|
compute: { icon: Cpu, tagline: 'GPUs, machines, and functions that scale to zero.' },
|
|
data: { icon: Database, tagline: 'Every persistence primitive — at rest and in motion.' },
|
|
network: { icon: Network, tagline: 'Connect, route, and protect every service.' },
|
|
security: { icon: Shield, tagline: 'Identity, keys, and audit for the whole cloud.' },
|
|
dev: { icon: Terminal, tagline: 'Clients, SDKs, and keys for the whole API.' },
|
|
// `infrastructure`, not `platform`. The catalog renamed this category — the
|
|
// section it sits in is called Platform and a category cannot nest inside a
|
|
// section of its own name — and the key here was left on the old id, so the
|
|
// lookup missed and the category rendered with the fallback Cloud icon and NO
|
|
// tagline at all. It was the one tile with nothing written under it, on every
|
|
// surface that reads this map.
|
|
infrastructure: { icon: Layers, tagline: 'Source to production as declared state.' },
|
|
observe: { icon: Activity, tagline: 'Logs, metrics, traces, and cost in one pane.' },
|
|
web3: { icon: Landmark, tagline: 'On-chain infrastructure for the cloud.' },
|
|
apps: { icon: LayoutDashboard, tagline: 'Production apps built on the cloud.' },
|
|
}
|
|
|
|
interface Copy {
|
|
/**
|
|
* What this site calls the product, where the catalog's name is wrong for the
|
|
* page this repo publishes. It cannot add a product or bury one — the row
|
|
* still has to be in the catalog to be rendered at all — so this stays a copy
|
|
* deck. Set it only when the catalog's name belongs to a different surface.
|
|
*/
|
|
title?: string
|
|
/** Short menu descriptor. */
|
|
blurb?: string
|
|
tagline?: string
|
|
description?: string
|
|
features?: string[]
|
|
status?: PrimitiveStatus
|
|
}
|
|
|
|
/**
|
|
* What this site says about a product, keyed by the catalog's product id.
|
|
*
|
|
* Two depths, and the difference is which page the words are for. A `blurb` is
|
|
* the one line under a leaf in the menu. A `tagline` + `description` +
|
|
* `features` set is the body of a generated `/cloud/<slug>` overview, and only
|
|
* a product without a bespoke page needs one — a product with its own page
|
|
* already has all the copy it will ever have, in that page.
|
|
*/
|
|
const COPY: Record<string, Copy> = {
|
|
// --- AI --------------------------------------------------------------------
|
|
models: { blurb: 'Open-weight model garden' },
|
|
agents: { blurb: 'Build & run agents' },
|
|
embeddings: {
|
|
status: 'ga',
|
|
blurb: 'Turn anything into vectors',
|
|
tagline: 'Turn anything into vectors.',
|
|
description:
|
|
'Open embedding models for text, code, and images behind one API, wired straight into Vector for retrieval. Batch or realtime, metered per token.',
|
|
features: ['Text, code & image models', 'One API, many models', 'Pairs with Vector search', 'Token-metered billing'],
|
|
},
|
|
|
|
images: {
|
|
blurb: 'Text to image',
|
|
tagline: 'Text to image, on the path your client already calls.',
|
|
description:
|
|
'Image generation at /v1/images/generations, the OpenAI-compatible address — an existing client reaches it by changing its base URL and nothing else. Same key and same account as every other capability here.',
|
|
features: ['OpenAI-compatible address', 'One key across modalities', 'No client rewrite', 'Billed with the rest of the account'],
|
|
},
|
|
videos: {
|
|
blurb: 'Text to video',
|
|
// Video is the one modality whose shape a caller has to know about, so the
|
|
// copy leads with it rather than with the model: a render takes minutes, and
|
|
// an API that answered synchronously would be an API that timed out.
|
|
tagline: 'Text to video, as a job you can poll.',
|
|
description:
|
|
'A render takes minutes, so the call does not wait for it: POST /v1/videos/generations returns a job, GET /v1/videos/{id} reports where it has got to, and /v1/videos/{id}/content hands back the finished MP4. Nothing holds a connection open while a model works.',
|
|
features: ['Create, poll, download', 'Progress you can show a reader', 'MP4 out', 'No long-held connection'],
|
|
},
|
|
audio: {
|
|
blurb: 'Speech, music & sound',
|
|
// Named for the family and not for one verb. /v1/audio is five endpoints,
|
|
// and a product called Voice would misdescribe the four that are not.
|
|
tagline: 'Speech in, speech out, and the sound in between.',
|
|
description:
|
|
'The OpenAI-compatible pair — /v1/audio/speech for text to speech, /v1/audio/transcriptions for speech to text — beside the generative verbs the Zen family serves natively: voice, music and foley.',
|
|
features: ['OpenAI-compatible TTS & STT', 'Voice, music and foley', 'Served by the Zen family', 'One key across modalities'],
|
|
},
|
|
|
|
// --- Compute ---------------------------------------------------------------
|
|
gpus: {
|
|
status: 'ga',
|
|
blurb: 'On-demand accelerators',
|
|
tagline: 'On-demand accelerators, by the second.',
|
|
description:
|
|
'H100, A100, and consumer-class GPUs on demand — attach to a machine, a container, or a training job. Per-second billing, no reservations, no idle spend.',
|
|
features: ['H100 / A100 / L40S', 'Per-second metering', 'Attach to any workload', 'No reservation required'],
|
|
},
|
|
machines: { blurb: 'VMs by the second' },
|
|
functions: { blurb: 'Serverless handlers' },
|
|
tasks: { blurb: 'Durable scheduled work' },
|
|
|
|
// --- Data ------------------------------------------------------------------
|
|
vector: { blurb: 'Embeddings & retrieval' },
|
|
sql: { blurb: 'Relational, per tenant' },
|
|
kv: { blurb: 'Cache & queues' },
|
|
s3: { blurb: 'Object storage' },
|
|
datastore: { blurb: 'Analytical columnar store' },
|
|
base: { blurb: 'Instant app backend' },
|
|
docdb: { blurb: 'MongoDB wire protocol' },
|
|
|
|
// --- Network ---------------------------------------------------------------
|
|
gateway: { blurb: 'One API entry point' },
|
|
dns: { blurb: 'Authoritative DNS + DNSSEC' },
|
|
|
|
// --- Security --------------------------------------------------------------
|
|
iam: { blurb: 'OIDC identity & orgs' },
|
|
authz: { blurb: 'Fine-grained access' },
|
|
kms: { blurb: 'Managed keys' },
|
|
secrets: {
|
|
status: 'ga',
|
|
blurb: 'Store once, sync everywhere',
|
|
tagline: 'Store once, sync everywhere.',
|
|
description:
|
|
'Versioned secrets with per-environment scoping, synced into Kubernetes as native resources. Nothing is ever written in plaintext, and every read is audited.',
|
|
features: ['Versioned & environment-scoped', 'Kubernetes sync', 'Never stored in plaintext', 'Every read audited'],
|
|
},
|
|
audit: {
|
|
status: 'ga',
|
|
blurb: 'A tamper-evident trail',
|
|
tagline: 'A tamper-evident trail.',
|
|
description:
|
|
'Every privileged action across the cloud, recorded append-only and anchored on-chain. Query by actor, resource, or time window; export for your auditors.',
|
|
features: ['Append-only by construction', 'On-chain anchoring', 'Query by actor or resource', 'Auditor-ready export'],
|
|
},
|
|
|
|
// --- Platform --------------------------------------------------------------
|
|
projects: { blurb: 'The deploy unit' },
|
|
registry: { blurb: 'Your container registry' },
|
|
environments: {
|
|
status: 'ga',
|
|
blurb: 'Dev, staging, prod — isolated',
|
|
tagline: 'Dev, staging, prod — isolated.',
|
|
description:
|
|
'Every project gets isolated environments with their own secrets, data, and domains. Promote a build between them rather than rebuilding it.',
|
|
features: ['Isolated secrets & data', 'Per-environment domains', 'Promote, do not rebuild', 'Preview environments per branch'],
|
|
},
|
|
builds: {
|
|
status: 'ga',
|
|
blurb: 'Source to signed image',
|
|
tagline: 'Source to signed image.',
|
|
description:
|
|
'Reproducible container builds from a git push, cached and signed, pushed to your own registry. No build logic per repo — one declared pipeline.',
|
|
features: ['Build on push', 'Layer caching', 'Signed images', 'Multi-architecture'],
|
|
},
|
|
releases: {
|
|
status: 'ga',
|
|
blurb: 'Ship with confidence',
|
|
tagline: 'Ship with confidence.',
|
|
description:
|
|
'A release is an immutable pin of image plus configuration. Roll forward or back to any previous one in a single operation, with a full history of who shipped what.',
|
|
features: ['Immutable pins', 'One-step rollback', 'Full ship history', 'Progressive rollout'],
|
|
},
|
|
pipelines: {
|
|
status: 'ga',
|
|
blurb: 'CI/CD as declared state',
|
|
tagline: 'CI/CD as declared state.',
|
|
description:
|
|
'One declarative file describes build, test, and deploy for every repo in the org. Runs on your own runners; no per-repo workflow logic to keep in sync.',
|
|
features: ['One declarative file', 'Self-hosted runners', 'Shared across every repo', 'Deploy gated on tests'],
|
|
},
|
|
|
|
// --- Observe ---------------------------------------------------------------
|
|
// The catalog sells slug `o11y` as "Traces", which is the name of a different
|
|
// surface: the served document carries /v1/traces beside /v1/o11y, so an
|
|
// agent handed "use Hanzo Traces" binds to four operations instead of this
|
|
// product's 365. Every other surface for it — the h1, the footer, the route,
|
|
// the API namespace and the command — says O11y.
|
|
o11y: { title: 'O11y' },
|
|
logs: {
|
|
status: 'ga',
|
|
blurb: 'Search every line',
|
|
tagline: 'Search every line.',
|
|
description:
|
|
'Structured logs from every service in one searchable store, retained per plan and scoped per organization. No agent to install and no second log vendor.',
|
|
features: ['Structured & searchable', 'Per-organization scoping', 'No agent to install', 'Retention per plan'],
|
|
},
|
|
evals: {
|
|
status: 'beta',
|
|
blurb: 'Measure model & agent quality',
|
|
tagline: 'Measure model and agent quality.',
|
|
description:
|
|
'Benchmark, grade, and regression-test models and agents. LLM-as-judge, golden sets, and drift detection with reproducible scorecards anchored on-chain.',
|
|
features: ['LLM-as-judge & golden sets', 'Regression gates in CI', 'Drift detection', 'Anchored scorecards'],
|
|
},
|
|
metrics: { blurb: 'Time series & SLOs' },
|
|
billing: { blurb: 'Usage-based billing' },
|
|
|
|
// --- Apps ------------------------------------------------------------------
|
|
chat: { blurb: 'Conversational AI' },
|
|
bot: { blurb: 'Multi-agent platform' },
|
|
search: { blurb: 'Retrieval & answers' },
|
|
crawl: { blurb: 'Web crawler' },
|
|
}
|
|
|
|
interface Row {
|
|
id: string
|
|
name: string
|
|
category: string
|
|
slug: string
|
|
href: string
|
|
icon: string
|
|
api: string | null
|
|
docs: string | null
|
|
repo: string | null
|
|
gcp: string | null
|
|
order: number
|
|
kind?: string
|
|
}
|
|
|
|
interface Snapshot {
|
|
source: string
|
|
document: string
|
|
fetched: string
|
|
categories: { id: string; label: string; order: number }[]
|
|
products: Row[]
|
|
excluded: { id: string; category: string; api: string | null; why: string }[]
|
|
}
|
|
|
|
const catalog = snapshot as Snapshot
|
|
|
|
const toPrimitive = (row: Row): Primitive => {
|
|
const copy = COPY[row.id] ?? {}
|
|
return {
|
|
...copy,
|
|
title: copy.title ?? row.name,
|
|
href: row.href,
|
|
slug: row.slug,
|
|
icon: ICON[row.icon] ?? Boxes,
|
|
api: row.api ?? undefined,
|
|
gcp: row.gcp ?? undefined,
|
|
kind: row.kind === 'client' ? 'client' : 'service',
|
|
// The product's own repo when the catalog names one and a reader can open
|
|
// it; the org otherwise, which is never a 404.
|
|
github: row.repo && opens(row.repo) ? `https://github.com/${row.repo}` : ORG,
|
|
desc: copy.blurb ?? copy.tagline,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Every category with every leaf resolved — including withdrawn ones. The
|
|
* generated `/cloud/<slug>` pages derive from THIS set, because a withdrawn
|
|
* page still builds and answers on its URL
|
|
* (lib/publish's own contract); only its links disappear.
|
|
*/
|
|
const allCategories: CloudCategory[] = catalog.categories.map((c) => ({
|
|
id: c.id,
|
|
title: c.label,
|
|
icon: CATEGORY[c.id]?.icon ?? Cloud,
|
|
tagline: CATEGORY[c.id]?.tagline ?? '',
|
|
items: catalog.products.filter((p) => p.category === c.id).map(toPrimitive),
|
|
}))
|
|
|
|
/**
|
|
* The taxonomy SHOWS a primitive iff its page is PUBLISHED — the same
|
|
* `policy()` that writes the sitemap and the noindex tags, applied at the one
|
|
* source every display surface reads, so withdrawing a surface in lib/publish
|
|
* empties it out of all of them in the same commit. A category whose every leaf is withdrawn
|
|
* vanishes whole — an empty tile is not a tile.
|
|
*/
|
|
export const cloudCategories: CloudCategory[] = allCategories
|
|
.map((c) => ({ ...c, items: c.items.filter((item) => policy(item.href) === 'public') }))
|
|
.filter((c) => c.items.length > 0)
|
|
|
|
/**
|
|
* A small count as prose spells it — `10` → `Ten`.
|
|
*
|
|
* Display type is the one place a numeral reads worse than a word, and it is
|
|
* exactly where a hand-typed word would go stale. Beyond twelve, prose uses the
|
|
* numeral too, so the fallback is the numeral rather than a longer table.
|
|
*/
|
|
export function spell(n: number): string {
|
|
const words = [
|
|
'Zero', 'One', 'Two', 'Three', 'Four', 'Five',
|
|
'Six', 'Seven', 'Eight', 'Nine', 'Ten', 'Eleven', 'Twelve',
|
|
]
|
|
return words[n] ?? String(n)
|
|
}
|
|
|
|
/** Slugs hosted by the `/cloud/[slug]` dynamic route — every leaf without a page of its own. */
|
|
export const cloudPrimitiveSlugs: string[] = allCategories
|
|
.flatMap((c) => c.items)
|
|
.filter((i) => i.href.startsWith('/cloud/'))
|
|
.map((i) => i.slug)
|
|
|
|
/** Look up a primitive by slug. */
|
|
export function getPrimitive(slug: string): Primitive | undefined {
|
|
return allCategories.flatMap((c) => c.items).find((i) => i.slug === slug)
|
|
}
|
|
|
|
/** Category a primitive belongs to (for the overview breadcrumb). */
|
|
export function getPrimitiveCategory(slug: string): CloudCategory | undefined {
|
|
return allCategories.find((c) => c.items.some((i) => i.slug === slug))
|
|
}
|
|
|
|
/** Every category id the catalog carries. Each one's old `/products/<id>` address forwards to a layer (MOVED in lib/publish). */
|
|
export const categorySlugs: string[] = allCategories.map((c) => c.id)
|
|
|
|
// ── The hero tour ────────────────────────────────────────────────────────────
|
|
|
|
/** One beat of the hero's story: our line, and the API's own words for it. */
|
|
export interface Beat {
|
|
/** The line the hero types. Prose, and the only part of a beat we write. */
|
|
line: string
|
|
method: string
|
|
path: string
|
|
/** The served document's own one-line description of that operation. */
|
|
summary: string
|
|
}
|
|
|
|
/**
|
|
* The story the hero tells, resolved at build time against the SAME served
|
|
* document that decides which products exist (`scripts/sync-catalog.mjs`).
|
|
*
|
|
* A beat survives iff `/v1/openapi.json` carries its path AND its verb, so the
|
|
* hero cannot narrate an operation the platform does not serve — an invented
|
|
* path and a real path with the wrong verb are both dropped, with the reason
|
|
* printed. That is what keeps a scenario from becoming marketing fiction in a
|
|
* terminal font: the prose is ours, the verb, the path and the description are
|
|
* the API's.
|
|
*/
|
|
export const tour: { story: string; beats: Beat[] } = snapshot.tour ?? { story: '', beats: [] }
|